diff --git a/README.md b/README.md index 6fbb203..1269299 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,15 @@ The helper lives in `app/lib/errors/index.ts`. Use `errorResponse(code, message, See `app/lib/api-version.ts` for the `toV2Stream()` conversion and `openapi.json` for the full OpenAPI 3.1 spec. +## 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 index 9970c7f..2713fb4 100644 --- a/app/api/orgs/[orgId]/members/route.ts +++ b/app/api/orgs/[orgId]/members/route.ts @@ -1,3 +1,35 @@ +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 }); /** * GET /api/orgs/:orgId/members — List members * POST /api/orgs/:orgId/members — Add a member diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 30edf65..be63d26 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -1,3 +1,27 @@ +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 }); /** * app/lib/auth.ts * diff --git a/app/lib/db.ts b/app/lib/db.ts index 0130f58..16fc16d 100644 --- a/app/lib/db.ts +++ b/app/lib/db.ts @@ -1,3 +1,68 @@ +import { Stream, ActivityEvent } from "@/app/types/openapi"; +import { Org, Member } from "@/app/types/org"; + +export const db = { + streams: new Map([ + [ + "stream-ada", + { + id: "stream-ada", + recipient: "Ada Creative Studio", + rate: "120 XLM / month", + schedule: "Pays every 30 days", + status: "active", + nextAction: "pause", + createdAt: "2026-04-01T09:00:00Z", + updatedAt: "2026-04-28T10:30:00Z", + }, + ], + [ + "stream-kemi", + { + id: "stream-kemi", + recipient: "Kemi Onboarding Support", + rate: "32 XLM / week", + schedule: "Draft stream ready to launch", + status: "draft", + nextAction: "start", + createdAt: "2026-04-10T14:00:00Z", + updatedAt: "2026-04-28T11:00:00Z", + }, + ], + [ + "stream-yusuf", + { + id: "stream-yusuf", + recipient: "Yusuf QA Partnership", + rate: "18 XLM / day", + schedule: "Ended yesterday with funds available", + status: "ended", + nextAction: "withdraw", + createdAt: "2026-04-15T08:00:00Z", + updatedAt: "2026-04-27T20:00:00Z", + }, + ], + ]), + + 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." }], + ["d1578871-4be9-4c6a-bef5-12b2b5836478", { id: "d1578871-4be9-4c6a-bef5-12b2b5836478", type: "stream.started", streamId: "stream-ada", timestamp: "2026-04-01T09:05:00Z", description: "Stream 'Design Retainer' activated." }], + ["288f315d-5520-46e9-8acf-96994c87b786", { id: "288f315d-5520-46e9-8acf-96994c87b786", type: "stream.created", streamId: "stream-kemi", timestamp: "2026-04-10T14:00:00Z", description: "Stream 'Kemi Onboarding Support' created as draft." }], + ["3bea183d-c3b5-4e96-9fbe-804f3aee49e9", { id: "3bea183d-c3b5-4e96-9fbe-804f3aee49e9", type: "stream.created", streamId: "stream-yusuf", timestamp: "2026-04-15T08:00:00Z", description: "Stream 'Yusuf QA Partnership' created." }], + ["5ffa85da-27a4-4f7c-bde0-e5c067a28015", { id: "5ffa85da-27a4-4f7c-bde0-e5c067a28015", type: "stream.stopped", streamId: "stream-yusuf", timestamp: "2026-04-27T20:00:00Z", description: "Stream 'Yusuf QA Partnership' stopped and settled automatically." }], + ]), + + idempotency: new Map(), +}; import type { ActivityEvent, ExportJob, Stream, User } from "@/app/types/openapi"; import { createInMemoryPersistenceStore } from "@/app/lib/repositories/in-memory"; import { 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'; +}