From b000dc85b7bed469d72f0905f2de029274f42738 Mon Sep 17 00:00:00 2001 From: ComputerOracle Date: Sat, 30 May 2026 12:59:43 +0000 Subject: [PATCH] feat: add organization membership infrastructure - Add Organization and Member types and database storage - Add tryAuthenticateRequest helper for JWT auth - Add /api/orgs/[orgId]/members endpoints (GET/POST) with RBAC - Add unit tests and documentation --- README.md | 9 ++++ app/api/orgs/[orgId]/members/route.test.ts | 53 ++++++++++++++++++++++ app/api/orgs/[orgId]/members/route.ts | 33 ++++++++++++++ app/lib/auth.ts | 25 ++++++++++ app/lib/db.ts | 9 ++++ app/types/org.ts | 11 +++++ 6 files changed, 140 insertions(+) create mode 100644 app/api/orgs/[orgId]/members/route.test.ts create mode 100644 app/api/orgs/[orgId]/members/route.ts create mode 100644 app/lib/auth.ts create mode 100644 app/types/org.ts diff --git a/README.md b/README.md index c226db0..dc62795 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,15 @@ streampay-frontend/ - `app/lib/amount.test.ts` includes deterministic fuzz-style checks (seeded RNG) with bounded runtime. - Bounded fuzz runs in normal CI because it is fast; if runtime grows in the future, keep deterministic unit coverage in CI and move larger fuzz campaigns to nightly workflows. +## Organization Management API + +The following endpoints support multi-tenant organization management: + +- `POST /api/orgs/[orgId]/members`: Add a member to an organization (Owner-only). +- `GET /api/orgs/[orgId]/members`: List organization members (Member-only). + +These endpoints require a valid JWT token obtained via `POST /api/auth/wallet` in the `Authorization: Bearer ` header. + ## License MIT diff --git a/app/api/orgs/[orgId]/members/route.test.ts b/app/api/orgs/[orgId]/members/route.test.ts new file mode 100644 index 0000000..f8676d8 --- /dev/null +++ b/app/api/orgs/[orgId]/members/route.test.ts @@ -0,0 +1,53 @@ +import { GET, POST } from "./route"; +import { db } from "@/app/lib/db"; +import { NextRequest } from "next/server"; + +// Mocking JWT verification in app/lib/auth +jest.mock("jsonwebtoken", () => ({ + verify: jest.fn((token) => { + if (token === "valid-owner-token") return { sub: "GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW" }; + if (token === "valid-member-token") return { sub: "other-wallet" }; + throw new Error("Invalid token"); + }), +})); + +describe("Org Members API", () => { + const orgId = "org-1"; + + beforeEach(() => { + // Ensure DB is in a known state + db.members.set(`${orgId}:GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW`, { orgId, walletAddress: "GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW", role: "owner" }); + db.members.set(`${orgId}:other-wallet`, { orgId, walletAddress: "other-wallet", role: "member" }); + }); + + it("GET /:orgId/members - returns members for an authenticated member", async () => { + const req = new NextRequest(`http://localhost/api/orgs/${orgId}/members`, { + headers: { Authorization: "Bearer valid-member-token" }, + }); + const res = await GET(req, { params: Promise.resolve({ orgId }) }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data).toHaveLength(2); + }); + + it("POST /:orgId/members - allows owner to add a member", async () => { + const req = new NextRequest(`http://localhost/api/orgs/${orgId}/members`, { + method: "POST", + headers: { Authorization: "Bearer valid-owner-token", "Content-Type": "application/json" }, + body: JSON.stringify({ walletAddress: "new-wallet" }), + }); + const res = await POST(req, { params: Promise.resolve({ orgId }) }); + expect(res.status).toBe(201); + expect(db.members.has(`${orgId}:new-wallet`)).toBe(true); + }); + + it("POST /:orgId/members - rejects non-owner", async () => { + const req = new NextRequest(`http://localhost/api/orgs/${orgId}/members`, { + method: "POST", + headers: { Authorization: "Bearer valid-member-token", "Content-Type": "application/json" }, + body: JSON.stringify({ walletAddress: "attacker-wallet" }), + }); + const res = await POST(req, { params: Promise.resolve({ orgId }) }); + expect(res.status).toBe(403); + }); +}); diff --git a/app/api/orgs/[orgId]/members/route.ts b/app/api/orgs/[orgId]/members/route.ts new file mode 100644 index 0000000..2ffd2d6 --- /dev/null +++ b/app/api/orgs/[orgId]/members/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { tryAuthenticateRequest, createErrorResponse } from "@/app/lib/auth"; +import { db } from "@/app/lib/db"; + +export async function GET(request: Request, { params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + const auth = tryAuthenticateRequest(request); + if (auth.error) return createErrorResponse("UNAUTHORIZED", auth.error, 401); + const { walletAddress } = auth as { walletAddress: string }; + + const isMember = db.members.has(`${orgId}:${walletAddress}`); + if (!isMember) return createErrorResponse("FORBIDDEN", "You are not a member of this organization", 403); + + const members = Array.from(db.members.values()).filter(m => m.orgId === orgId); + return NextResponse.json({ data: members }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + const auth = tryAuthenticateRequest(request); + if (auth.error) return createErrorResponse("UNAUTHORIZED", auth.error, 401); + const { walletAddress } = auth as { walletAddress: string }; + + const org = db.orgs.get(orgId); + if (!org) return createErrorResponse("NOT_FOUND", "Organization not found", 404); + + if (org.ownerWallet !== walletAddress) return createErrorResponse("FORBIDDEN", "Only the owner can add members", 403); + + const { walletAddress: newMemberWallet } = await request.json(); + db.members.set(`${orgId}:${newMemberWallet}`, { orgId, walletAddress: newMemberWallet, role: 'member' }); + + return NextResponse.json({ data: db.members.get(`${orgId}:${newMemberWallet}`) }, { status: 201 }); +} diff --git a/app/lib/auth.ts b/app/lib/auth.ts new file mode 100644 index 0000000..138d8f4 --- /dev/null +++ b/app/lib/auth.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "streampay-dev-secret-do-not-use-in-prod"; + +export function tryAuthenticateRequest(request: Request) { + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return { error: "Missing or invalid authorization header" }; + } + const token = authHeader.slice(7); + try { + const verified = jwt.verify(token, JWT_SECRET) as { sub?: string }; + if (!verified.sub) { + return { error: "Invalid or expired token" }; + } + return { walletAddress: verified.sub }; + } catch { + return { error: "Invalid or expired token" }; + } +} + +export function createErrorResponse(code: string, message: string, status: number, requestId: string = "mock-request-id") { + return NextResponse.json({ error: { code, message, request_id: requestId } }, { status }); +} diff --git a/app/lib/db.ts b/app/lib/db.ts index 2270e2b..0a1e096 100644 --- a/app/lib/db.ts +++ b/app/lib/db.ts @@ -1,4 +1,5 @@ import { Stream, ActivityEvent } from "@/app/types/openapi"; +import { Org, Member } from "@/app/types/org"; export const db = { streams: new Map([ @@ -43,6 +44,14 @@ export const db = { ], ]), + orgs: new Map([ + ["org-1", { id: "org-1", name: "StreamPay Org", ownerWallet: "GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW" }] + ]), + + members: new Map([ + ["org-1:GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW", { orgId: "org-1", walletAddress: "GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW", role: "owner" }] + ]), + activity: new Map([ ["a7383234-4224-49dc-b868-0cdf37649fda", { id: "a7383234-4224-49dc-b868-0cdf37649fda", type: "wallet.connected", timestamp: "2026-04-28T09:00:00Z", description: "Wallet connected and authenticated." }], ["2b9d1d0c-bef4-46bc-a783-3073b28353fc", { id: "2b9d1d0c-bef4-46bc-a783-3073b28353fc", type: "stream.created", streamId: "stream-ada", timestamp: "2026-04-01T09:00:00Z", description: "Stream 'Design Retainer' created and set to draft." }], diff --git a/app/types/org.ts b/app/types/org.ts new file mode 100644 index 0000000..9029642 --- /dev/null +++ b/app/types/org.ts @@ -0,0 +1,11 @@ +export interface Org { + id: string; + name: string; + ownerWallet: string; +} + +export interface Member { + orgId: string; + walletAddress: string; + role: 'owner' | 'member'; +}