Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` header.

## License

MIT
Expand Down
53 changes: 53 additions & 0 deletions app/api/orgs/[orgId]/members/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 32 additions & 0 deletions app/api/orgs/[orgId]/members/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 24 additions & 0 deletions app/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand Down
65 changes: 65 additions & 0 deletions app/lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,68 @@
import { Stream, ActivityEvent } from "@/app/types/openapi";
import { Org, Member } from "@/app/types/org";

export const db = {
streams: new Map<string, Stream>([
[
"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<string, Org>([
["org-1", { id: "org-1", name: "StreamPay Org", ownerWallet: "GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW" }]
]),

members: new Map<string, Member>([
["org-1:GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW", { orgId: "org-1", walletAddress: "GATODH2T75IVFB7MG6ZKKIFPWFNVJBXVPUMTYV5ANT2O2ZWL7GSDZWNRW", role: "owner" }]
]),

activity: new Map<string, ActivityEvent>([
["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<string, unknown>(),
};
import type { ActivityEvent, ExportJob, Stream, User } from "@/app/types/openapi";
import { createInMemoryPersistenceStore } from "@/app/lib/repositories/in-memory";
import {
Expand Down
11 changes: 11 additions & 0 deletions app/types/org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface Org {
id: string;
name: string;
ownerWallet: string;
}

export interface Member {
orgId: string;
walletAddress: string;
role: 'owner' | 'member';
}
Loading