Concept project · runs in your browserTypeScript · React.js · Next.js
Multi-tenant SaaS Backend with NestJS and PostgreSQL Demo
A simulator of a multi-tenant NestJS and PostgreSQL backend. Sign in as a user of one of three fictional companies, switch the active tenant and call the API. Ask for another tenant's project and get a 404, spoof a tenant header and get a 403, hit the free plan limit and get a 402. Turn off the safety layers one at a time to see which one catches the leak.
Interactive demo
Multi-tenant API console
Ctrl/⌘ + Enter to sendPick an endpoint on the left and press Send.
database (viewed as superuser)
| id | tenant_id | name | created_by |
|---|---|---|---|
| 101 | t_acme | Online booking | sara@acme.test |
| 102 | t_acme | Recall reminders | sara@acme.test |
| 103 | t_acme | X-ray archive | dan@acme.test |
| 104 | t_acme | Price list update | sara@acme.test |
| 105 | t_bluebird | Route planner | li@bluebird.test |
| 106 | t_bluebird | Driver app | li@bluebird.test |
| 107 | t_bluebird | Depot scanners | li@bluebird.test |
| 108 | t_bluebird | Customs paperwork | sara@acme.test |
| 109 | t_bluebird | Fuel reports | li@bluebird.test |
| 110 | t_bluebird | Order #1042 claim | li@bluebird.test |
| 111 | t_cedar | Shop catalogue | mo@cedar.test |
| 112 | t_cedar | Reading club signup | mo@cedar.test |
request log
- $ waiting for requests…
row-level security policy
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id', true)::text)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::text);
-- same policy on memberships, invitations, audit_log
-- the API connects as app_user (NOBYPASSRLS); only platform_admin has BYPASSRLSEach request opens a transaction and runs set_config('app.tenant_id', …, true). The true makes it transaction-local, so a pooled connection never carries one tenant's setting into the next request.
## How it works
The active tenant travels inside the access token. TenantGuard checks that you are still a member and that any X-Tenant-Id header matches the token. An interceptor then opens a transaction and runs set_config('app.tenant_id', …, true), which every RLS policy reads.
Services also add WHERE tenant_id = $1. That is two independent layers. Tick the “forgets WHERE tenant_id” bug and the SQL log shows RLS filtering the other tenants' rows anyway. Only when you also disable RLS does data leak, and the demo flags it in red.
Every write adds an audit_log row in the same transaction. A thrown error rolls back both, so the log never records something that did not happen.
## View source
The code running in the window above, exactly as deployed.
app/lab/multi-tenant-saas-backend/app.ts501 lines
/**
* The simulated NestJS app for the multi-tenant backend. Request order mirrors the production design:
*
* JwtAuthGuard → TenantGuard → TenantContextInterceptor (BEGIN; set_config('app.tenant_id', …, true))
* → RolesGuard → PlanLimitGuard → controller → service → COMMIT / ROLLBACK
*
* Tenant isolation is enforced twice: services add WHERE tenant_id = $1, and PostgreSQL RLS filters every
* query by the session setting. The demo lets you switch either layer off to see what the other one catches.
*/
import {
BadRequestException,
ConflictException,
ForbiddenException,
GoneException,
HttpException,
NotFoundException,
PaymentRequiredException,
UnauthorizedException,
fnv1a,
header,
isRecord,
matchRoute,
parsePath,
type ApiRequest,
type ApiResponse,
type Endpoint,
type HttpMethod,
type TraceStep,
} from "@/components/lab/api/http";
import { PLANS, insertCheck, select, type Db, type Membership, type PgSession, type Plan, type TenantRole, type User } from "./db";
/* ---------------------------------------------------------------- tokens */
type Claims = { sub: number; tid: string | null; role: TenantRole | null; exp: number };
const SECRET = "demo-tenant-secret";
const b64 = (s: string) => btoa(s).replace(/=+$/, "");
export function signToken(c: Claims) {
const body = b64(JSON.stringify(c));
return `eyJhbGciOiJIUzI1NiJ9.${body}.${fnv1a(SECRET + body)}${fnv1a(body + SECRET)}`;
}
export function readToken(t: string): Claims | null {
const [, body, sig] = t.split(".");
if (!body || sig !== fnv1a(SECRET + body) + fnv1a(body + SECRET)) return null;
try {
return JSON.parse(atob(body)) as Claims;
} catch {
return null;
}
}
/* ---------------------------------------------------------------- routes */
export type Flags = { rlsEnabled: boolean; serviceForgetsTenantFilter: boolean };
type Ctx = {
db: Db;
now: number;
user: User | null;
tenantId: string | null;
member: Membership | null;
pg: PgSession;
params: Record<string, string>;
body: Record<string, unknown>;
flags: Flags;
/** The WHERE tenant_id value the service adds (null when the "forgotten filter" bug is on). */
appTenant: string | null;
audit: (action: string, target: string) => void;
};
type Result = { status: number; body?: unknown };
type Route = {
method: HttpMethod;
path: string;
summary: string;
tag: string;
handler: string;
isPublic?: boolean;
/** Needs an active tenant in the token. */
tenant?: boolean;
roles?: TenantRole[];
limit?: "projects" | "members";
platformAdmin?: boolean;
example?: string;
sampleBody?: unknown;
run: (ctx: Ctx) => Result;
};
const ACCESS_TTL = 30 * 60_000;
const INVITE_TTL = 48 * 3_600_000;
function issue(db: Db, user: User, tenantId: string | null, now: number) {
const m = tenantId ? db.memberships.find((x) => x.userId === user.id && x.tenantId === tenantId) ?? null : null;
return signToken({ sub: user.id, tid: m?.tenantId ?? null, role: m?.role ?? null, exp: now + ACCESS_TTL });
}
const str = (v: unknown, field: string, min = 1, max = 80) => {
if (typeof v !== "string" || v.trim().length < min || v.trim().length > max) {
throw new BadRequestException("Validation failed", { fields: { [field]: [`${field} must be a string of ${min}–${max} characters`] } });
}
return v.trim();
};
function usage(ctx: Ctx) {
const members = select(ctx.pg, "memberships", ctx.db.memberships, ctx.appTenant).rows.length;
const pending = select(ctx.pg, "invitations", ctx.db.invitations, ctx.appTenant, (i) => !i.acceptedAt && Date.parse(i.expiresAt) > ctx.now, "accepted_at IS NULL AND expires_at > now()").rows.length;
const projects = select(ctx.pg, "projects", ctx.db.projects, ctx.appTenant).rows.length;
return { members, pending, projects };
}
export const ROUTES: Route[] = [
{
method: "POST",
path: "/auth/login",
handler: "AuthController.login",
summary: "Demo login by email (no password); returns a token for your first organisation",
tag: "auth",
isPublic: true,
sampleBody: { email: "sara@acme.test" },
run: (ctx) => {
const email = str(ctx.body.email, "email", 3).toLowerCase();
ctx.pg.sql.push(`SELECT * FROM users WHERE email = '${email}'`);
const user = ctx.db.users.find((u) => u.email === email);
if (!user) throw new UnauthorizedException("Unknown email");
const first = ctx.db.memberships.find((m) => m.userId === user.id)?.tenantId ?? null;
return { status: 200, body: { accessToken: issue(ctx.db, user, first, ctx.now), activeTenant: first } };
},
},
{
method: "POST",
path: "/auth/switch-tenant",
handler: "AuthController.switchTenant",
summary: "Issue a new token for another organisation you belong to",
tag: "auth",
sampleBody: { tenantId: "t_bluebird" },
run: (ctx) => {
const tid = str(ctx.body.tenantId, "tenantId", 3, 40);
ctx.pg.sql.push(`SELECT role FROM memberships WHERE user_id = ${ctx.user!.id} AND tenant_id = '${tid}' -- runs as auth_service (no tenant set yet)`);
const m = ctx.db.memberships.find((x) => x.userId === ctx.user!.id && x.tenantId === tid);
if (!m) throw new ForbiddenException(`You are not a member of ${tid}`);
return { status: 200, body: { accessToken: issue(ctx.db, ctx.user!, tid, ctx.now), activeTenant: tid, role: m.role } };
},
},
{
method: "GET",
path: "/me",
handler: "MeController.get",
summary: "Current user, active tenant and all memberships",
tag: "auth",
run: (ctx) => ({
status: 200,
body: {
user: ctx.user,
activeTenant: ctx.tenantId,
memberships: ctx.db.memberships.filter((m) => m.userId === ctx.user!.id).map((m) => ({ tenantId: m.tenantId, role: m.role })),
},
}),
},
{
method: "GET",
path: "/projects",
handler: "ProjectsController.findAll",
summary: "Projects of the active tenant",
tag: "projects",
tenant: true,
run: (ctx) => {
const { rows } = select(ctx.pg, "projects", ctx.db.projects, ctx.appTenant, undefined, "");
return { status: 200, body: { data: rows, count: rows.length } };
},
},
{
method: "GET",
path: "/projects/:id",
handler: "ProjectsController.findOne",
summary: "One project; other tenants' ids answer 404",
tag: "projects",
tenant: true,
example: "/projects/105",
run: (ctx) => {
const id = Number(ctx.params.id);
const { rows } = select(ctx.pg, "projects", ctx.db.projects, ctx.appTenant, (p) => p.id === id, `id = ${id}`);
if (!rows[0]) throw new NotFoundException(`Project ${ctx.params.id} not found`);
return { status: 200, body: rows[0] };
},
},
{
method: "POST",
path: "/projects",
handler: "ProjectsController.create",
summary: "Create a project (plan limit applies)",
tag: "projects",
tenant: true,
limit: "projects",
sampleBody: { name: "Patient survey" },
run: (ctx) => {
const name = str(ctx.body.name, "name", 3, 60);
// tenant_id always comes from the request context, never from the body.
const row = { id: ++ctx.db.seq.project, tenantId: ctx.tenantId!, name, createdBy: ctx.user!.id, createdAt: new Date(ctx.now).toISOString() };
ctx.db.projects.push(row);
ctx.pg.sql.push(`INSERT INTO projects (tenant_id, name, created_by) VALUES (current_setting('app.tenant_id'), '${name}', ${ctx.user!.id})`);
ctx.audit("project.create", `project:${row.id}`);
return { status: 201, body: row };
},
},
{
method: "DELETE",
path: "/projects/:id",
handler: "ProjectsController.remove",
summary: "Delete a project (owner or admin)",
tag: "projects",
tenant: true,
roles: ["owner", "admin"],
example: "/projects/104",
run: (ctx) => {
const id = Number(ctx.params.id);
const { rows } = select(ctx.pg, "projects", ctx.db.projects, ctx.appTenant, (p) => p.id === id, `id = ${id}`);
if (!rows[0]) throw new NotFoundException(`Project ${ctx.params.id} not found`);
ctx.db.projects = ctx.db.projects.filter((p) => p !== rows[0]);
ctx.pg.sql.push(`DELETE FROM projects WHERE id = ${id} -- RLS USING clause applies to DELETE too`);
ctx.audit("project.delete", `project:${id}`);
return { status: 204 };
},
},
{
method: "GET",
path: "/members",
handler: "MembersController.findAll",
summary: "Members of the active tenant",
tag: "members",
tenant: true,
run: (ctx) => {
const { rows } = select(ctx.pg, "memberships", ctx.db.memberships, ctx.appTenant);
return { status: 200, body: { data: rows.map((m) => ({ ...m, email: ctx.db.users.find((u) => u.id === m.userId)?.email })) } };
},
},
{
method: "POST",
path: "/invitations",
handler: "InvitationsController.create",
summary: "Invite someone by email (owner or admin, plan limit applies)",
tag: "members",
tenant: true,
roles: ["owner", "admin"],
limit: "members",
sampleBody: { email: "new.hire@acme.test", role: "member" },
run: (ctx) => {
const email = str(ctx.body.email, "email", 5).toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new BadRequestException("Validation failed", { fields: { email: ["email must be an email"] } });
const role = ctx.body.role ?? "member";
if (role !== "member" && role !== "admin") throw new BadRequestException("Validation failed", { fields: { role: ["role must be one of: member, admin"] } });
const existing = ctx.db.users.find((u) => u.email === email);
if (existing && ctx.db.memberships.some((m) => m.userId === existing.id && m.tenantId === ctx.tenantId)) {
throw new ConflictException(`${email} is already a member`);
}
const id = ++ctx.db.seq.invitation;
const token = `inv_${id}_${fnv1a(`${email}:${ctx.now}`)}`;
const inv = { id, tenantId: ctx.tenantId!, email, role: role as TenantRole, token, expiresAt: new Date(ctx.now + INVITE_TTL).toISOString(), acceptedAt: null };
const denied = insertCheck(ctx.pg, "invitations", inv);
if (denied) throw new HttpException(500, denied);
ctx.db.invitations.push(inv);
ctx.pg.sql.push(`INSERT INTO invitations (tenant_id, email, role, token_hash, expires_at) VALUES (current_setting('app.tenant_id'), '${email}', '${String(role)}', $1, now() + interval '48 hours')`);
ctx.audit("invitation.create", email);
return { status: 201, body: { id, email, role, expiresAt: inv.expiresAt, acceptUrl: `/invitations/accept?token=${token}`, token } };
},
},
{
method: "POST",
path: "/invitations/accept",
handler: "InvitationsController.accept",
summary: "Accept an invitation token (creates the user if needed)",
tag: "members",
isPublic: true,
sampleBody: { token: "<paste the token from the invite response>" },
run: (ctx) => {
const token = str(ctx.body.token, "token", 5, 80);
// Token lookup runs as the invitations service role, before any tenant is known.
ctx.pg.sql.push(`SELECT * FROM invitations WHERE token_hash = $1 FOR UPDATE -- invite_service role`);
const inv = ctx.db.invitations.find((i) => i.token === token);
if (!inv) throw new NotFoundException("Invitation not found");
if (inv.acceptedAt) throw new ConflictException("Invitation was already used");
if (Date.parse(inv.expiresAt) <= ctx.now) throw new GoneException("Invitation expired. Ask for a new one.");
ctx.pg.tenantId = inv.tenantId;
ctx.tenantId = inv.tenantId;
ctx.pg.sql.push(`SELECT set_config('app.tenant_id', '${inv.tenantId}', true) -- tenant now known from the invitation`);
let user = ctx.db.users.find((u) => u.email === inv.email);
if (!user) {
user = { id: ++ctx.db.seq.user, email: inv.email, name: inv.email.split("@")[0]!, platformAdmin: false };
ctx.db.users.push(user);
ctx.pg.sql.push(`INSERT INTO users (email, name) VALUES ('${user.email}', '${user.name}')`);
}
ctx.db.memberships.push({ tenantId: inv.tenantId, userId: user.id, role: inv.role });
inv.acceptedAt = new Date(ctx.now).toISOString();
ctx.pg.sql.push(`INSERT INTO memberships (tenant_id, user_id, role) VALUES ('${inv.tenantId}', ${user.id}, '${inv.role}')`);
ctx.pg.sql.push(`UPDATE invitations SET accepted_at = now() WHERE id = ${inv.id}`);
ctx.audit("member.join", user.email);
return { status: 200, body: { tenantId: inv.tenantId, user, accessToken: issue(ctx.db, user, inv.tenantId, ctx.now) } };
},
},
{
method: "GET",
path: "/audit-log",
handler: "AuditController.findAll",
summary: "Audit trail of the active tenant (owner or admin)",
tag: "tenant",
tenant: true,
roles: ["owner", "admin"],
run: (ctx) => {
const { rows } = select(ctx.pg, "audit_log", ctx.db.auditLog, ctx.appTenant);
return { status: 200, body: { data: [...rows].reverse().slice(0, 20) } };
},
},
{
method: "GET",
path: "/tenant/usage",
handler: "TenantController.usage",
summary: "Plan and usage of the active tenant",
tag: "tenant",
tenant: true,
run: (ctx) => {
const t = ctx.db.tenants.find((x) => x.id === ctx.tenantId)!;
const u = usage(ctx);
return { status: 200, body: { tenant: t.id, plan: t.plan, limits: PLANS[t.plan], usage: u } };
},
},
{
method: "PATCH",
path: "/tenant/plan",
handler: "TenantController.changePlan",
summary: "Change plan (owner only)",
tag: "tenant",
tenant: true,
roles: ["owner"],
sampleBody: { plan: "pro" },
run: (ctx) => {
const plan = ctx.body.plan;
if (plan !== "free" && plan !== "pro") throw new BadRequestException("Validation failed", { fields: { plan: ["plan must be one of: free, pro"] } });
const t = ctx.db.tenants.find((x) => x.id === ctx.tenantId)!;
if (plan === "free") {
const u = usage(ctx);
if (u.projects > PLANS.free.projects || u.members > PLANS.free.members) {
throw new ConflictException(`Usage (${u.projects} projects, ${u.members} members) is above the free plan limits`);
}
}
ctx.pg.sql.push(`UPDATE tenants SET plan = '${plan}' WHERE id = current_setting('app.tenant_id')`);
ctx.audit("plan.change", `${t.plan} → ${plan}`);
t.plan = plan as Plan;
return { status: 200, body: t };
},
},
{
method: "GET",
path: "/admin/tenants",
handler: "AdminController.tenants",
summary: "All tenants with usage (platform admin, BYPASSRLS)",
tag: "platform",
platformAdmin: true,
run: (ctx) => {
ctx.pg.sql.push(`SELECT t.*, count(DISTINCT p.id) AS projects, count(DISTINCT m.user_id) AS members FROM tenants t LEFT JOIN projects p ON … LEFT JOIN memberships m ON … GROUP BY t.id`);
return {
status: 200,
body: {
data: ctx.db.tenants.map((t) => ({
...t,
projects: ctx.db.projects.filter((p) => p.tenantId === t.id).length,
members: ctx.db.memberships.filter((m) => m.tenantId === t.id).length,
})),
},
};
},
},
];
export const ENDPOINTS: Endpoint[] = ROUTES.map((r) => ({
method: r.method,
path: r.path,
summary: r.summary,
tag: r.tag,
auth: r.isPublic ? "public" : r.platformAdmin ? "platform admin" : r.roles ? r.roles.join(" | ") : "bearer",
example: r.example,
sampleBody: r.sampleBody,
}));
/* -------------------------------------------------------------- pipeline */
export function handle(db: Db, req: ApiRequest, now: number, flags: Flags): { db: Db; res: ApiResponse; route?: Route } {
const trace: TraceStep[] = [];
const sql: string[] = [];
const { pathname } = parsePath(req.path);
const draft = structuredClone(db);
const candidates = ROUTES.filter((r) => matchRoute(r.path, pathname));
const route = candidates.find((r) => r.method === req.method);
const fail = (e: HttpException) => {
if (sql.length) sql.push("ROLLBACK");
trace.push({ stage: "ExceptionFilter", ok: false, note: String(e.status) });
return { db, res: { status: e.status, body: e.toBody(), trace, sql }, route };
};
if (!route) return fail(new HttpException(candidates.length ? 405 : 404, `Cannot ${req.method} ${pathname}`));
const pg: PgSession = { tenantId: null, bypassRls: false, rlsEnabled: flags.rlsEnabled, sql };
try {
let user: User | null = null;
let claims: Claims | null = null;
if (!route.isPublic) {
const m = /^Bearer\s+(.+)$/i.exec(header(req, "authorization") ?? "");
claims = m ? readToken(m[1]!.trim()) : null;
if (!claims || claims.exp <= now) {
trace.push({ stage: "JwtAuthGuard", ok: false, note: m ? "invalid or expired" : "no token" });
throw new UnauthorizedException(m ? "Invalid or expired token" : "Missing bearer token");
}
user = draft.users.find((u) => u.id === claims!.sub) ?? null;
if (!user) throw new UnauthorizedException();
trace.push({ stage: "JwtAuthGuard", ok: true, note: user.email });
}
if (route.platformAdmin) {
const ok = !!user?.platformAdmin;
trace.push({ stage: "PlatformAdminGuard", ok });
if (!ok) throw new ForbiddenException("Platform admins only");
pg.bypassRls = true;
sql.push("BEGIN", "SET LOCAL ROLE platform_admin -- BYPASSRLS, read-only");
}
let member: Membership | null = null;
if (route.tenant) {
const tid = claims?.tid ?? null;
if (!tid) {
trace.push({ stage: "TenantGuard", ok: false, note: "no active tenant" });
throw new ForbiddenException("No active tenant. Call POST /auth/switch-tenant first.");
}
const hdr = header(req, "x-tenant-id");
if (hdr && hdr !== tid) {
trace.push({ stage: "TenantGuard", ok: false, note: `header ${hdr} ≠ token ${tid}` });
throw new ForbiddenException(`X-Tenant-Id "${hdr}" does not match the active tenant in your token`);
}
// Re-check membership on every request: removing someone takes effect before their token expires.
member = draft.memberships.find((x) => x.userId === user!.id && x.tenantId === tid) ?? null;
if (!member) {
trace.push({ stage: "TenantGuard", ok: false, note: "membership revoked" });
throw new ForbiddenException("You are no longer a member of this tenant");
}
trace.push({ stage: "TenantGuard", ok: true, note: tid });
pg.tenantId = tid;
sql.push("BEGIN", `SELECT set_config('app.tenant_id', '${tid}', true) -- transaction-local`);
trace.push({ stage: "TenantContextInterceptor", ok: true, note: "set_config" });
} else if (!route.platformAdmin && req.method !== "GET") {
sql.push("BEGIN");
}
if (route.roles) {
const ok = !!member && route.roles.includes(member.role);
trace.push({ stage: "RolesGuard", ok, note: route.roles.join("|") });
if (!ok) throw new ForbiddenException(`Requires role ${route.roles.join(" or ")} in this tenant (you are ${member?.role ?? "none"})`);
}
const body = isRecord(req.body) ? req.body : {};
const ctx: Ctx = {
db: draft,
now,
user,
tenantId: pg.tenantId,
member,
pg,
params: matchRoute(route.path, pathname)!,
body,
flags,
appTenant: flags.serviceForgetsTenantFilter ? null : pg.tenantId,
audit: (action, target) => {
const row = { id: ++draft.seq.audit, tenantId: ctx.tenantId!, at: new Date(now).toISOString(), actor: user?.email ?? "(invite)", action, target };
draft.auditLog.push(row);
sql.push(`INSERT INTO audit_log (tenant_id, actor, action, target) VALUES (current_setting('app.tenant_id'), '${row.actor}', '${action}', '${target}')`);
},
};
if (route.limit) {
const t = draft.tenants.find((x) => x.id === pg.tenantId)!;
const u = usage(ctx);
const used = route.limit === "projects" ? u.projects : u.members + u.pending;
const max = PLANS[t.plan][route.limit];
const ok = used < max;
trace.push({ stage: "PlanLimitGuard", ok, note: `${route.limit} ${used}/${max}` });
if (!ok) {
throw new PaymentRequiredException(`The ${PLANS[t.plan].label} plan allows ${max} ${route.limit}${route.limit === "members" ? " (pending invitations count)" : ""}. Upgrade to add more.`, {
limit: route.limit,
used,
max,
plan: t.plan,
});
}
}
const out = route.run(ctx);
trace.push({ stage: route.handler, ok: true, note: String(out.status) });
if (sql[0] === "BEGIN") sql.push("COMMIT");
return { db: draft, res: { status: out.status, body: out.body ?? null, trace, sql }, route };
} catch (e) {
if (e instanceof HttpException) {
if (!trace.some((t) => !t.ok)) trace.push({ stage: route.handler, ok: false, note: String(e.status) });
return fail(e);
}
throw e;
}
}app/lab/multi-tenant-saas-backend/db.ts134 lines
/**
* Shared-database multi-tenancy: every tenant-owned row carries tenant_id, and a PostgreSQL row-level
* security policy compares it with the per-transaction setting app.tenant_id. This module holds the rows
* and a tiny query layer that applies that policy exactly where PostgreSQL would.
*/
export const PLANS = {
free: { label: "Free", members: 3, projects: 5 },
pro: { label: "Pro", members: 25, projects: 100 },
} as const;
export type Plan = keyof typeof PLANS;
export type TenantRole = "owner" | "admin" | "member";
export type Tenant = { id: string; name: string; plan: Plan; createdAt: string };
export type User = { id: number; email: string; name: string; platformAdmin: boolean };
export type Membership = { tenantId: string; userId: number; role: TenantRole };
export type Project = { id: number; tenantId: string; name: string; createdBy: number; createdAt: string };
export type Invitation = {
id: number;
tenantId: string;
email: string;
role: TenantRole;
token: string;
expiresAt: string;
acceptedAt: string | null;
};
export type AuditEntry = { id: number; tenantId: string; at: string; actor: string; action: string; target: string };
export type Db = {
tenants: Tenant[];
users: User[];
memberships: Membership[];
projects: Project[];
invitations: Invitation[];
auditLog: AuditEntry[];
seq: { user: number; project: number; invitation: number; audit: number };
};
/** Tables with a tenant_id column and an RLS policy. */
export const TENANT_TABLES = ["projects", "memberships", "invitations", "audit_log"] as const;
export const RLS_SQL = `ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id', true)::text)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::text);
-- same policy on memberships, invitations, audit_log
-- the API connects as app_user (NOBYPASSRLS); only platform_admin has BYPASSRLS`;
const iso = (d: number, h = 9) => new Date(Date.UTC(2026, 9, d, h)).toISOString();
export function seedDb(): Db {
const tenants: Tenant[] = [
{ id: "t_acme", name: "Acme Dental", plan: "free", createdAt: iso(1) },
{ id: "t_bluebird", name: "Bluebird Logistics", plan: "pro", createdAt: iso(2) },
{ id: "t_cedar", name: "Cedar Books", plan: "free", createdAt: iso(3) },
];
const users: User[] = [
{ id: 1, email: "sara@acme.test", name: "Sara (Acme owner)", platformAdmin: false },
{ id: 2, email: "dan@acme.test", name: "Dan (Acme member)", platformAdmin: false },
{ id: 3, email: "li@bluebird.test", name: "Li (Bluebird owner)", platformAdmin: false },
{ id: 4, email: "mo@cedar.test", name: "Mo (Cedar owner)", platformAdmin: false },
{ id: 5, email: "ops@platform.test", name: "Ops (platform admin)", platformAdmin: true },
];
const memberships: Membership[] = [
{ tenantId: "t_acme", userId: 1, role: "owner" },
{ tenantId: "t_acme", userId: 2, role: "member" },
{ tenantId: "t_bluebird", userId: 3, role: "owner" },
{ tenantId: "t_bluebird", userId: 1, role: "member" },
{ tenantId: "t_cedar", userId: 4, role: "owner" },
];
const p: [string, string, number][] = [
["t_acme", "Online booking", 1],
["t_acme", "Recall reminders", 1],
["t_acme", "X-ray archive", 2],
["t_acme", "Price list update", 1],
["t_bluebird", "Route planner", 3],
["t_bluebird", "Driver app", 3],
["t_bluebird", "Depot scanners", 3],
["t_bluebird", "Customs paperwork", 1],
["t_bluebird", "Fuel reports", 3],
["t_bluebird", "Order #1042 claim", 3],
["t_cedar", "Shop catalogue", 4],
["t_cedar", "Reading club signup", 4],
];
const projects: Project[] = p.map(([tenantId, name, createdBy], i) => ({ id: 101 + i, tenantId, name, createdBy, createdAt: iso(4 + i) }));
const auditLog: AuditEntry[] = [
{ id: 1, tenantId: "t_acme", at: iso(4), actor: "sara@acme.test", action: "project.create", target: "project:101" },
{ id: 2, tenantId: "t_bluebird", at: iso(8), actor: "li@bluebird.test", action: "member.add", target: "user:1" },
{ id: 3, tenantId: "t_cedar", at: iso(14), actor: "mo@cedar.test", action: "project.create", target: "project:111" },
];
return {
tenants,
users,
memberships,
projects,
invitations: [],
auditLog,
seq: { user: users.length, project: 100 + projects.length, invitation: 0, audit: auditLog.length },
};
}
/** Session settings for one transaction: what SET LOCAL / set_config would hold. */
export type PgSession = { tenantId: string | null; bypassRls: boolean; rlsEnabled: boolean; sql: string[] };
type Row = { tenantId: string };
/**
* SELECT with the RLS policy applied. `appWhere` is the tenant filter the service adds itself;
* passing null simulates a forgotten WHERE clause. Returns the rows plus how many the policy removed.
*/
export function select<T extends Row>(s: PgSession, table: string, rows: T[], appWhere: string | null, extra?: (r: T) => boolean, extraSql = "") {
const matchesApp = (r: T) => (appWhere === null || r.tenantId === appWhere) && (!extra || extra(r));
const candidates = rows.filter(matchesApp);
const policyOn = s.rlsEnabled && !s.bypassRls;
const visible = policyOn ? candidates.filter((r) => r.tenantId === s.tenantId) : candidates;
const hidden = candidates.length - visible.length;
const where = [appWhere !== null ? `tenant_id = '${appWhere}'` : null, extraSql || null].filter(Boolean).join(" AND ");
s.sql.push(
`SELECT * FROM ${table}${where ? ` WHERE ${where}` : ""} -- ${policyOn ? `RLS: ${visible.length} visible, ${hidden} filtered by policy` : s.bypassRls ? "BYPASSRLS role" : "RLS DISABLED"}`,
);
return { rows: visible, hidden };
}
/** INSERT with the policy's WITH CHECK: a row for another tenant is refused by the database. */
export function insertCheck(s: PgSession, table: string, row: Row): string | null {
if (s.rlsEnabled && !s.bypassRls && row.tenantId !== s.tenantId) {
return `new row violates row-level security policy for table "${table}"`;
}
return null;
}app/lab/multi-tenant-saas-backend/Demo.tsx334 lines
"use client";
import { useRef, useState } from "react";
import { ApiConsole, type ConsolePreset, type ConsoleResult } from "@/components/lab/api/ApiConsole";
import { DbTable } from "@/components/lab/api/DbTable";
import { RequestLog, appendLog, statusTone, type LogLine } from "@/components/lab/api/RequestLog";
import { STATUS_TEXT, isRecord, type ApiRequest, type HttpMethod } from "@/components/lab/api/http";
import { usePersisted } from "@/components/lab/api/usePersisted";
import { ENDPOINTS, handle, readToken, type Flags } from "./app";
import { PLANS, RLS_SQL, seedDb, type Db } from "./db";
const KEY = "lab:multi-tenant-saas-backend:v1";
type State = { db: Db; token: string | null; flags: Flags; clockOffsetH: number };
const seed = (): State => ({ db: seedDb(), token: null, flags: { rlsEnabled: true, serviceForgetsTenantFilter: false }, clockOffsetH: 0 });
const TENANT_CLS: Record<string, string> = { t_acme: "text-accent", t_bluebird: "text-accent-2", t_cedar: "text-accent-3" };
const tcls = (id: string) => TENANT_CLS[id] ?? "text-warn-fg";
const headersFor = (token: string | null, extra: Record<string, string> = {}) =>
JSON.stringify({ ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extra }, null, 2);
export default function Demo() {
const [state, setState, resetState] = usePersisted<State>(KEY, seed);
const stateRef = useRef(state);
stateRef.current = state;
const [headers, setHeaders] = useState(() => headersFor(null));
const [synced, setSynced] = useState(false);
const [preset, setPreset] = useState<ConsolePreset | null>(null);
const [log, setLog] = useState<LogLine[]>([]);
const [leak, setLeak] = useState<string[] | null>(null);
const [tab, setTab] = useState<"projects" | "invitations" | "audit_log" | "memberships">("projects");
if (!synced && state.token) {
setSynced(true);
setHeaders(headersFor(state.token));
}
const claims = state.token ? readToken(state.token) : null;
const me = claims ? state.db.users.find((u) => u.id === claims.sub) ?? null : null;
const active = claims?.tid ?? null;
const myTenants = me ? state.db.memberships.filter((m) => m.userId === me.id) : [];
function send(req: ApiRequest) {
const cur = stateRef.current;
const { db, res } = handle(cur.db, req, Date.now() + cur.clockOffsetH * 3_600_000, cur.flags);
let token = cur.token;
if (res.status === 200 && isRecord(res.body) && typeof res.body.accessToken === "string") {
token = res.body.accessToken;
setHeaders(headersFor(token));
}
const next = { ...cur, db, token };
stateRef.current = next;
setState(next);
// Leak detector: did any returned row belong to a tenant other than the caller's active one?
const tid = token ? readToken(token)?.tid : null;
const rows = isRecord(res.body) && Array.isArray(res.body.data) ? res.body.data : isRecord(res.body) ? [res.body] : [];
const foreign = new Set<string>();
if (!req.path.startsWith("/admin")) for (const r of rows) if (isRecord(r) && typeof r.tenantId === "string" && tid && r.tenantId !== tid) foreign.add(r.tenantId);
setLeak(foreign.size ? [...foreign] : null);
return res;
}
function onResult({ req, res, ms }: ConsoleResult) {
const msg = isRecord(res.body) && typeof res.body.message === "string" ? ` · ${res.body.message}` : "";
setLog((l) =>
appendLog(l, [
{ text: `→ ${req.method} ${req.path}`, tone: "info" },
...(res.sql ?? []).map((q) => ({ text: ` ${q}`, tone: "sql" as const })),
{ text: `← ${res.status} ${STATUS_TEXT[res.status] ?? ""} (${ms} ms)${msg}`, tone: statusTone(res.status) },
]),
);
}
function quick(method: HttpMethod, path: string, body?: unknown, hdrs?: string) {
let parsed: Record<string, string> = {};
try {
parsed = JSON.parse(hdrs ?? headers) as Record<string, string>;
} catch {
/* headers box holds invalid JSON: send without headers */
}
const req: ApiRequest = { method, path, headers: parsed, body };
onResult({ req, res: send(req), ms: 0 });
}
function login(email: string) {
if (!email) {
setState((s) => ({ ...s, token: null }));
setHeaders(headersFor(null));
return;
}
quick("POST", "/auth/login", { email }, "{}");
}
const scenarios: { label: string; run: () => void }[] = [
{ label: "Read another tenant's project", run: () => setPreset({ id: Date.now(), method: "GET", path: "/projects/105" }) },
{
label: "Spoof X-Tenant-Id header",
run: () => {
setHeaders(headersFor(stateRef.current.token, { "X-Tenant-Id": active === "t_bluebird" ? "t_acme" : "t_bluebird" }));
setPreset({ id: Date.now(), method: "GET", path: "/projects" });
},
},
{ label: "Create a project (plan limit)", run: () => setPreset({ id: Date.now(), method: "POST", path: "/projects", body: { name: "Patient survey", tenantId: "t_bluebird" } }) },
{ label: "Invite a member", run: () => setPreset({ id: Date.now(), method: "POST", path: "/invitations", body: { email: "new.hire@acme.test", role: "member" } }) },
{ label: "Platform: list tenants", run: () => setPreset({ id: Date.now(), method: "GET", path: "/admin/tenants" }) },
];
function reset() {
resetState();
setHeaders(headersFor(null));
setLog([]);
setLeak(null);
}
const now = Date.now() + state.clockOffsetH * 3_600_000;
const btn = "rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent hover:text-accent";
const userName = (id: number) => state.db.users.find((u) => u.id === id)?.email ?? `#${id}`;
const toolbar = (
<div className="space-y-3 rounded-md border border-border bg-bg-elev p-3">
<div className="flex flex-wrap items-end gap-3">
<label>
<span className="block font-mono text-xs text-fg-muted">signed in as</span>
<select value={me?.email ?? ""} onChange={(e) => login(e.target.value)} className="mt-1 rounded-md border border-border bg-bg px-2 py-1.5 text-sm">
<option value="">nobody</option>
{state.db.users.map((u) => (
<option key={u.id} value={u.email}>
{u.email}
</option>
))}
</select>
</label>
<label>
<span className="block font-mono text-xs text-fg-muted">active tenant (in token)</span>
<select
value={active ?? ""}
disabled={!me || myTenants.length === 0}
onChange={(e) => quick("POST", "/auth/switch-tenant", { tenantId: e.target.value })}
className="mt-1 rounded-md border border-border bg-bg px-2 py-1.5 text-sm disabled:opacity-50"
>
{!active && <option value="">none</option>}
{myTenants.map((m) => (
<option key={m.tenantId} value={m.tenantId}>
{state.db.tenants.find((t) => t.id === m.tenantId)?.name} · {m.role}
</option>
))}
</select>
</label>
{me?.platformAdmin && <span className="pb-2 text-xs text-warn-fg">platform admin: no tenant, uses /admin routes</span>}
</div>
<fieldset className="flex flex-wrap gap-x-4 gap-y-2 font-mono text-xs">
<legend className="sr-only">Safety layers</legend>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={state.flags.rlsEnabled}
onChange={(e) => setState((s) => ({ ...s, flags: { ...s.flags, rlsEnabled: e.target.checked } }))}
className="size-4 accent-[var(--accent)]"
/>
PostgreSQL RLS enabled
</label>
<label className="flex items-center gap-1.5">
<input
type="checkbox"
checked={state.flags.serviceForgetsTenantFilter}
onChange={(e) => setState((s) => ({ ...s, flags: { ...s.flags, serviceForgetsTenantFilter: e.target.checked } }))}
className="size-4 accent-[var(--danger)]"
/>
<span className="text-danger">bug: service forgets WHERE tenant_id</span>
</label>
<button type="button" onClick={() => setState((s) => ({ ...s, clockOffsetH: s.clockOffsetH + 24 }))} className={btn}>
clock +24 h ({state.clockOffsetH} h)
</button>
</fieldset>
<div className="flex flex-wrap gap-1.5">
{scenarios.map((s) => (
<button key={s.label} type="button" onClick={s.run} className="rounded-full border border-border px-2.5 py-0.5 text-xs hover:border-accent-2 hover:text-accent-2">
{s.label}
</button>
))}
</div>
{leak && (
<p role="alert" className="rounded border border-danger/50 px-2 py-1 text-xs text-danger">
Data leak: the last response contained rows from {leak.join(", ")}. Both safety layers are off. Turn RLS back on and send it again.
</p>
)}
</div>
);
return (
<div>
<ApiConsole title="Multi-tenant API console" endpoints={ENDPOINTS} send={send} headers={headers} onHeadersChange={setHeaders} onResult={onResult} preset={preset} toolbar={toolbar} />
<div className="grid gap-3 border-t border-border p-4 sm:grid-cols-3">
{state.db.tenants.map((t) => {
const members = state.db.memberships.filter((m) => m.tenantId === t.id).length;
const pending = state.db.invitations.filter((i) => i.tenantId === t.id && !i.acceptedAt && Date.parse(i.expiresAt) > now).length;
const projects = state.db.projects.filter((p) => p.tenantId === t.id).length;
const lim = PLANS[t.plan];
const Bar = ({ used, max, label }: { used: number; max: number; label: string }) => (
<div>
<div className="flex justify-between font-mono text-[11px] text-fg-muted">
<span>{label}</span>
<span className={used >= max ? "text-danger" : ""}>
{used}/{max}
</span>
</div>
<div className="mt-0.5 h-1.5 rounded bg-bg-muted" role="presentation">
<div className={`h-1.5 rounded ${used >= max ? "bg-danger" : "bg-accent"}`} style={{ width: `${Math.min(100, (used / max) * 100)}%` }} />
</div>
</div>
);
return (
<div key={t.id} className={`space-y-2 rounded-md border p-3 ${active === t.id ? "border-accent" : "border-border"}`}>
<div className="flex items-baseline justify-between gap-2">
<span className={`font-medium ${tcls(t.id)}`}>{t.name}</span>
<span className="rounded bg-bg-muted px-1.5 font-mono text-[11px]">{lim.label}</span>
</div>
<Bar used={members + pending} max={lim.members} label={`members${pending ? ` (+${pending} invited)` : ""}`} />
<Bar used={projects} max={lim.projects} label="projects" />
{active === t.id && <p className="font-mono text-[11px] text-accent">● active session</p>}
</div>
);
})}
</div>
<div className="grid gap-6 border-t border-border p-4 lg:grid-cols-[1.3fr_1fr]">
<section aria-labelledby="mt-db" className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 id="mt-db" className="font-mono text-sm font-semibold">
database <span className="text-fg-muted">(viewed as superuser)</span>
</h3>
<div role="tablist" aria-label="Tables" className="flex flex-wrap gap-1">
{(["projects", "memberships", "invitations", "audit_log"] as const).map((t) => (
<button key={t} role="tab" type="button" aria-selected={tab === t} onClick={() => setTab(t)} className={`rounded px-2 py-0.5 font-mono text-xs ${tab === t ? "bg-accent text-accent-fg" : "text-fg-muted hover:text-fg"}`}>
{t}
</button>
))}
</div>
</div>
{tab === "projects" && (
<DbTable
name="projects"
rows={state.db.projects}
rowKey={(r) => r.id}
dim={(r) => !!active && r.tenantId !== active}
caption={active ? `Greyed rows are invisible to app_user while app.tenant_id = '${active}'.` : "Sign in to see which rows the RLS policy exposes."}
columns={[
{ key: "id", label: "id" },
{ key: "tenantId", label: "tenant_id", render: (r) => <span className={tcls(r.tenantId)}>{r.tenantId}</span> },
{ key: "name", label: "name" },
{ key: "createdBy", label: "created_by", render: (r) => userName(r.createdBy) },
]}
/>
)}
{tab === "memberships" && (
<DbTable
name="memberships"
rows={state.db.memberships}
rowKey={(r) => `${r.tenantId}:${r.userId}`}
dim={(r) => !!active && r.tenantId !== active}
columns={[
{ key: "tenantId", label: "tenant_id", render: (r) => <span className={tcls(r.tenantId)}>{r.tenantId}</span> },
{ key: "userId", label: "user", render: (r) => userName(r.userId) },
{ key: "role", label: "role" },
]}
/>
)}
{tab === "invitations" && (
<DbTable
name="invitations"
rows={state.db.invitations}
rowKey={(r) => r.id}
dim={(r) => !!r.acceptedAt || Date.parse(r.expiresAt) <= now}
empty="No invitations yet. Send POST /invitations as an owner."
caption="Production stores only a hash of the token; the demo shows it so you can accept."
columns={[
{ key: "id", label: "id" },
{ key: "tenantId", label: "tenant_id", render: (r) => <span className={tcls(r.tenantId)}>{r.tenantId}</span> },
{ key: "email", label: "email" },
{
key: "state",
label: "state",
render: (r) => (r.acceptedAt ? "accepted" : Date.parse(r.expiresAt) <= now ? <span className="text-danger">expired</span> : <span className="text-accent">pending</span>),
},
{
key: "act",
label: "",
render: (r) => (
<button type="button" onClick={() => quick("POST", "/invitations/accept", { token: r.token }, "{}")} className="rounded border border-border px-1.5 font-mono text-[11px] hover:border-accent hover:text-accent" aria-label={`Accept invitation for ${r.email}`}>
accept
</button>
),
},
]}
/>
)}
{tab === "audit_log" && (
<DbTable
name="audit_log"
rows={[...state.db.auditLog].reverse()}
rowKey={(r) => r.id}
dim={(r) => !!active && r.tenantId !== active}
columns={[
{ key: "at", label: "at", render: (r) => r.at.slice(5, 16).replace("T", " ") },
{ key: "tenantId", label: "tenant_id", render: (r) => <span className={tcls(r.tenantId)}>{r.tenantId}</span> },
{ key: "actor", label: "actor" },
{ key: "action", label: "action" },
{ key: "target", label: "target" },
]}
/>
)}
<RequestLog lines={log} onClear={() => setLog([])} className="h-64" />
</section>
<section aria-labelledby="rls-h" className="min-w-0">
<h3 id="rls-h" className="font-mono text-sm font-semibold">
row-level security policy
</h3>
<pre className="mt-2 overflow-x-auto rounded-md border border-border bg-bg-muted p-3 font-mono text-[12px] leading-relaxed">{RLS_SQL}</pre>
<p className="mt-3 text-xs text-fg-muted">
Each request opens a transaction and runs <code>set_config('app.tenant_id', …, true)</code>. The <code>true</code> makes it transaction-local, so a
pooled connection never carries one tenant's setting into the next request.
</p>
<button type="button" onClick={reset} className={`${btn} mt-4`}>
reset demo
</button>
</section>
</div>
</div>
);
}components/lab/api/ApiConsole.tsx341 lines
"use client";
import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
import { STATUS_TEXT, type ApiRequest, type ApiResponse, type Endpoint, type HttpMethod } from "./http";
const METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
// Method badges sit on theme surfaces, so they use theme tokens.
const METHOD_CLS: Record<HttpMethod, string> = {
GET: "text-accent-2",
POST: "text-accent",
PUT: "text-warn-fg",
PATCH: "text-warn-fg",
DELETE: "text-danger",
};
/** Minimal JSON syntax colouring for the always-dark response panel. */
export function JsonCode({ value, className = "" }: { value: unknown; className?: string }) {
const text = value === undefined ? "" : JSON.stringify(value, null, 2);
const parts = useMemo(() => {
const out: { t: string; c: string }[] = [];
const re = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
let last = 0;
for (const m of text.matchAll(re)) {
const i = m.index ?? 0;
if (i > last) out.push({ t: text.slice(last, i), c: "text-[#a1a1aa]" });
if (m[1]) {
out.push({ t: m[1], c: m[2] ? "text-[#93c5fd]" : "text-[#86efac]" });
if (m[2]) out.push({ t: m[2], c: "text-[#a1a1aa]" });
} else if (m[3]) out.push({ t: m[3], c: "text-[#f9a8d4]" });
else out.push({ t: m[0], c: "text-[#fcd34d]" });
last = i + m[0].length;
}
if (last < text.length) out.push({ t: text.slice(last), c: "text-[#a1a1aa]" });
return out;
}, [text]);
return (
<pre className={`overflow-auto font-mono text-[12px] leading-relaxed whitespace-pre-wrap break-words ${className}`}>
{parts.map((p, i) => (
<span key={i} className={p.c}>
{p.t}
</span>
))}
</pre>
);
}
export function StatusBadge({ status }: { status: number }) {
const cls =
status < 300
? "border-[#4ade80]/40 text-[#4ade80]"
: status < 500 && (status === 401 || status === 403 || status === 404 || status === 409 || status === 410)
? "border-[#fbbf24]/40 text-[#fbbf24]"
: "border-[#f87171]/40 text-[#f87171]";
return (
<span className={`rounded border px-2 py-0.5 font-mono text-xs font-semibold ${cls}`}>
{status} {STATUS_TEXT[status] ?? ""}
</span>
);
}
export type ConsolePreset = { id: number; method: HttpMethod; path: string; body?: unknown };
export type ConsoleResult = { req: ApiRequest; res: ApiResponse; ms: number };
type Props = {
endpoints: Endpoint[];
/** The simulated server. It may throw; the console turns that into a 500. */
send: (req: ApiRequest) => ApiResponse;
/** Request headers as editable JSON text (controlled by the parent so it can inject tokens). */
headers: string;
onHeadersChange: (v: string) => void;
onResult?: (r: ConsoleResult) => void;
/** Load a request into the editor from outside (bump `id` to re-apply). */
preset?: ConsolePreset | null;
/** Extra controls above the editor, such as a role or tenant switcher. */
toolbar?: ReactNode;
title?: string;
};
export function ApiConsole({ endpoints, send, headers, onHeadersChange, onResult, preset, toolbar, title = "API console" }: Props) {
const uid = useId();
const first = endpoints[0];
const [method, setMethod] = useState<HttpMethod>(first?.method ?? "GET");
const [path, setPath] = useState(first?.example ?? first?.path ?? "/");
const [bodyText, setBodyText] = useState(first?.sampleBody ? JSON.stringify(first.sampleBody, null, 2) : "");
const [busy, setBusy] = useState(false);
const [clientError, setClientError] = useState<string | null>(null);
const [result, setResult] = useState<ConsoleResult | null>(null);
const [filter, setFilter] = useState("");
useEffect(() => {
if (!preset) return;
setMethod(preset.method);
setPath(preset.path);
setBodyText(preset.body === undefined ? "" : JSON.stringify(preset.body, null, 2));
setClientError(null);
}, [preset]);
function pick(e: Endpoint) {
setMethod(e.method);
setPath(e.example ?? e.path);
setBodyText(e.sampleBody === undefined ? "" : JSON.stringify(e.sampleBody, null, 2));
setClientError(null);
}
function run() {
if (busy) return;
let hdrs: Record<string, string> = {};
let body: unknown = undefined;
try {
const parsed: unknown = headers.trim() ? JSON.parse(headers) : {};
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("not an object");
hdrs = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
} catch {
setClientError("Headers must be a JSON object, e.g. { \"Authorization\": \"Bearer …\" }.");
return;
}
if (bodyText.trim() && method !== "GET" && method !== "DELETE") {
try {
body = JSON.parse(bodyText);
} catch (err) {
setClientError(`Body is not valid JSON: ${(err as Error).message}`);
return;
}
}
setClientError(null);
setBusy(true);
const req: ApiRequest = { method, path: path.trim() || "/", headers: hdrs, body };
const ms = 40 + Math.round(Math.random() * 120);
window.setTimeout(() => {
let res: ApiResponse;
try {
res = send(req);
} catch (err) {
res = { status: 500, body: { statusCode: 500, message: (err as Error).message, error: "Internal Server Error" } };
}
const r = { req, res, ms };
setResult(r);
setBusy(false);
onResult?.(r);
}, ms);
}
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
run();
}
};
const groups = useMemo(() => {
const q = filter.trim().toLowerCase();
const m = new Map<string, Endpoint[]>();
for (const e of endpoints) {
if (q && !`${e.method} ${e.path} ${e.summary}`.toLowerCase().includes(q)) continue;
m.set(e.tag, [...(m.get(e.tag) ?? []), e]);
}
return [...m.entries()];
}, [endpoints, filter]);
const hasBody = method !== "GET" && method !== "DELETE";
const input = "w-full rounded-md border border-border bg-bg-elev px-3 py-2 font-mono text-[13px] outline-none focus:border-accent";
return (
<div className="grid md:grid-cols-[15rem_1fr]">
<nav aria-label="Endpoints" className="border-b border-border bg-bg-elev p-3 md:border-r md:border-b-0">
<label className="block">
<span className="sr-only">Filter endpoints</span>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="filter endpoints…"
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs outline-none focus:border-accent"
/>
</label>
<div className="mt-2 max-h-56 space-y-3 overflow-y-auto md:max-h-[34rem]">
{groups.map(([tag, list]) => (
<div key={tag}>
<p className="font-mono text-[11px] tracking-wide text-fg-muted uppercase">{tag}</p>
<ul className="mt-1 space-y-0.5">
{list.map((e) => {
const active = e.method === method && (e.example ?? e.path) === path;
return (
<li key={e.method + e.path}>
<button
type="button"
onClick={() => pick(e)}
title={e.summary}
className={`flex w-full items-baseline gap-2 rounded px-1.5 py-1 text-left font-mono text-xs hover:bg-bg-muted ${active ? "bg-accent-soft" : ""}`}
>
<span className={`w-12 shrink-0 font-semibold ${METHOD_CLS[e.method]}`}>{e.method}</span>
<span className="min-w-0 flex-1 break-all">{e.path}</span>
{e.auth && e.auth !== "public" && (
<span className="shrink-0 text-fg-muted" aria-label={`requires ${e.auth}`} title={`requires ${e.auth}`}>
⚿
</span>
)}
</button>
</li>
);
})}
</ul>
</div>
))}
{groups.length === 0 && <p className="text-xs text-fg-muted">No endpoint matches.</p>}
</div>
</nav>
<div className="min-w-0 space-y-4 p-4">
<div className="flex items-center justify-between gap-2">
<h3 className="font-mono text-sm font-semibold">{title}</h3>
<span className="hidden font-mono text-[11px] text-fg-muted sm:inline">Ctrl/⌘ + Enter to send</span>
</div>
{toolbar}
<form
onSubmit={(e) => {
e.preventDefault();
run();
}}
className="space-y-3"
>
<div className="flex flex-wrap gap-2">
<label className="shrink-0">
<span className="sr-only">HTTP method</span>
<select
value={method}
onChange={(e) => setMethod(e.target.value as HttpMethod)}
className={`rounded-md border border-border bg-bg-elev px-2 py-2 font-mono text-[13px] font-semibold ${METHOD_CLS[method]}`}
>
{METHODS.map((m) => (
<option key={m}>{m}</option>
))}
</select>
</label>
<label className="min-w-0 flex-1 basis-48">
<span className="sr-only">Request path</span>
<input value={path} onChange={(e) => setPath(e.target.value)} onKeyDown={onKey} className={input} spellCheck={false} />
</label>
<button
type="submit"
disabled={busy}
className="rounded-md bg-accent px-4 py-2 font-mono text-sm font-semibold text-accent-fg hover:opacity-90 disabled:opacity-60"
>
{busy ? "sending…" : "Send"}
</button>
</div>
<div className={`grid gap-3 ${hasBody ? "lg:grid-cols-2" : ""}`}>
<label className="block">
<span className="font-mono text-xs text-fg-muted">headers (JSON)</span>
<textarea
id={`${uid}-h`}
value={headers}
onChange={(e) => onHeadersChange(e.target.value)}
onKeyDown={onKey}
rows={4}
spellCheck={false}
className={`${input} mt-1 resize-y`}
/>
</label>
{hasBody && (
<label className="block">
<span className="font-mono text-xs text-fg-muted">body (JSON)</span>
<textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
onKeyDown={onKey}
rows={6}
spellCheck={false}
className={`${input} mt-1 resize-y`}
/>
</label>
)}
</div>
{clientError && (
<p role="alert" className="text-sm text-danger">
{clientError}
</p>
)}
</form>
<section aria-label="Response" aria-live="polite" aria-busy={busy} className="rounded-md border border-border bg-[#0b0b0d] p-3">
{!result && !busy && <p className="font-mono text-xs text-[#71717a]">Pick an endpoint on the left and press Send.</p>}
{busy && <p className="animate-pulse font-mono text-xs text-[#a1a1aa]">waiting for response…</p>}
{result && !busy && (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 font-mono text-xs text-[#a1a1aa]">
<StatusBadge status={result.res.status} />
<span>
{result.req.method} {result.req.path}
</span>
<span className="ml-auto">{result.ms} ms</span>
</div>
{result.res.trace && result.res.trace.length > 0 && (
<ol className="flex flex-wrap items-center gap-1 font-mono text-[11px]" aria-label="Request lifecycle">
{result.res.trace.map((s, i) => (
<li key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-[#52525b]" aria-hidden="true">→</span>}
<span
title={s.note}
className={`rounded border px-1.5 py-0.5 ${s.ok ? "border-[#4ade80]/30 text-[#4ade80]" : "border-[#f87171]/40 text-[#f87171]"}`}
>
{s.ok ? "✓" : "✗"} {s.stage}
{s.note && <span className="text-[#a1a1aa]"> · {s.note}</span>}
</span>
</li>
))}
</ol>
)}
{result.res.headers && (
<div className="font-mono text-[11px] text-[#a1a1aa]">
{Object.entries(result.res.headers).map(([k, v]) => (
<div key={k}>
<span className="text-[#93c5fd]">{k}</span>: {v}
</div>
))}
</div>
)}
{result.res.status === 204 ? (
<p className="font-mono text-xs text-[#71717a]">(no body)</p>
) : (
<JsonCode value={result.res.body} className="max-h-80" />
)}
{result.res.sql && result.res.sql.length > 0 && (
<div className="border-t border-[#27272a] pt-2">
<p className="font-mono text-[11px] text-[#71717a]">-- SQL executed</p>
{result.res.sql.map((q, i) => (
<p key={i} className="font-mono text-[11px] break-words text-[#93c5fd]">
{q}
</p>
))}
</div>
)}
</div>
)}
</section>
</div>
</div>
);
}