Concept project · runs in your browserTypeScript · React.js · Next.js
NestJS REST API with Auth, RBAC and Swagger Demo
An in-browser simulator of a NestJS REST API. Sign in as one of four demo accounts, call any endpoint from the Swagger-style list and watch each request pass or fail the JWT guard, the permission guard and the validation pipe. Untick a permission in the matrix and the same call turns into a 403. Every query lands in the request log.
Interactive demo
NestJS API · Swagger-style console
Ctrl/⌘ + Enter to sendPick an endpoint on the left and press Send.
database (PostgreSQL via Prisma)
| id | name | status | budget | owner |
|---|---|---|---|---|
| 1 | Acme Dental booking flow | active | 12000 | 3 · Uma |
| 2 | Clinic reminders by SMS | active | 4000 | 3 · Uma |
| 3 | Patient portal redesign | paused | 18000 | 4 · Omar |
| 4 | Invoice export to CSV | active | NULL | 4 · Omar |
| 5 | Staff rota planner | active | 9000 | 2 · Eli |
| 6 | Marketing site refresh | archived | 3500 | 2 · Eli |
| 7 | Reporting dashboard v2 | active | 22000 | 1 · Ada |
| 8 | Data retention clean-up | active | NULL | 1 · Ada |
| 9 | Order #1042 dispute tooling | archived | 800 | 3 · Uma |
| 10 | Mobile check-in kiosk | active | 15000 | 4 · Omar |
| 11 | Audit log export | paused | 2500 | 2 · Eli |
| 12 | Onboarding checklist | active | 1200 | 3 · Uma |
request log
- $ waiting for requests…
permission matrix
Read by PermissionsGuard on every request. Untick a box, then send the same request again.
| permission | admin | editor | user |
|---|---|---|---|
| projects:read:own | |||
| projects:read:any | |||
| projects:create | |||
| projects:update:own | |||
| projects:update:any | |||
| projects:delete:own | |||
| projects:delete:any | |||
| tasks:read | |||
| users:read | |||
| users:manage |
## How it works
Every request goes through handle() in app.ts, which runs the same chain Nest does: logger middleware, JwtAuthGuard, PermissionsGuard, the ValidationPipe, the controller method and, when something throws, the exception filter. The response panel shows that chain with a tick or a cross on each stage.
Permissions are data, not code. Each route declares what it needs (for example projects:update:own | projects:update:any) and the guard checks the role's row in the matrix. Ownership is checked in the service: :any lets admins touch every project, :own only your own. Reading a project you cannot see returns 404, so ids cannot be probed.
Each request works on a copy of the database. A thrown exception throws the copy away, like a rolled-back transaction. Refresh tokens rotate on every use; replaying an old one revokes the whole token family.
## View source
The code running in the window above, exactly as deployed.
app/lab/nestjs-rest-api-rbac/app.ts528 lines
/**
* The simulated NestJS application: route table (controllers), guards, the validation pipe and an
* exception filter, run in the same order Nest runs them:
*
* middleware → JwtAuthGuard → PermissionsGuard → ValidationPipe → controller → service → exception filter
*
* Each request works on a draft copy of the database. A thrown HttpException discards the draft (like a
* rolled-back Prisma $transaction); a normal return commits it.
*/
import {
BadRequestException,
ForbiddenException,
HttpException,
NotFoundException,
TooManyRequestsException,
UnauthorizedException,
header,
matchRoute,
parsePath,
type ApiRequest,
type ApiResponse,
type Endpoint,
type HttpMethod,
type TraceStep,
} from "@/components/lab/api/http";
import {
ACCESS_TTL_SEC,
issueRefreshToken,
rotateRefreshToken,
signAccessToken,
verifyAccessToken,
verifyPassword,
} from "./auth";
import { ROLES, type Db, type Permission, type Project, type User } from "./db";
import {
IsEmail,
IsIn,
IsInt,
IsString,
Max,
MaxLength,
Min,
MinLength,
validate,
validationErrorBody,
type Dto,
} from "./validation";
/* ----------------------------------------------------------------- DTOs */
const LoginDto: Dto = { name: "LoginDto", fields: { email: { checks: [IsEmail()] }, password: { checks: [IsString(), MinLength(8)] } } };
const RefreshDto: Dto = { name: "RefreshDto", fields: { refreshToken: { checks: [IsString(), MinLength(10)] } } };
const STATUSES = ["active", "paused", "archived"] as const;
const CreateProjectDto: Dto = {
name: "CreateProjectDto",
fields: {
name: { checks: [IsString(), MinLength(3), MaxLength(60)] },
description: { optional: true, nullable: true, checks: [IsString(), MaxLength(280)] },
status: { optional: true, checks: [IsIn(STATUSES)] },
budget: { optional: true, nullable: true, checks: [IsInt(), Min(0), Max(10_000_000)] },
},
};
const UpdateProjectDto: Dto = {
name: "UpdateProjectDto",
fields: Object.fromEntries(Object.entries(CreateProjectDto.fields).map(([k, v]) => [k, { ...v, optional: true }])),
};
const UpdateRoleDto: Dto = { name: "UpdateRoleDto", fields: { role: { checks: [IsIn(ROLES)] } } };
const ListProjectsQuery: Dto = {
name: "ListProjectsQuery",
fields: {
limit: { optional: true, number: true, checks: [IsInt(), Min(1), Max(50)] },
cursor: { optional: true, checks: [IsString()] },
status: { optional: true, checks: [IsIn(STATUSES)] },
q: { optional: true, checks: [IsString(), MaxLength(60)] },
sort: { optional: true, checks: [IsIn(["createdAt", "-createdAt", "name", "-name"])] },
},
};
/* --------------------------------------------------------------- routes */
type Ctx = {
db: Db;
now: number;
user: User | null;
params: Record<string, string>;
query: Record<string, unknown>;
body: Record<string, unknown>;
sql: string[];
can: (p: Permission) => boolean;
};
type Result = { status: number; body?: unknown; headers?: Record<string, string> };
type Route = {
method: HttpMethod;
path: string;
handler: string;
summary: string;
tag: string;
/** @Public() routes skip JwtAuthGuard. */
isPublic?: boolean;
/** @RequirePermissions(...): the user needs at least one of these. */
anyOf?: Permission[];
body?: Dto;
query?: Dto;
example?: string;
sampleBody?: unknown;
run: (ctx: Ctx) => Result;
};
const publicUser = (u: User) => ({ id: u.id, email: u.email, name: u.name, role: u.role, createdAt: u.createdAt });
function findProject(ctx: Ctx, readAny: Permission, readOwn: Permission): Project {
const id = Number(ctx.params.id);
if (!Number.isInteger(id)) throw new BadRequestException("Validation failed (numeric string is expected)");
ctx.sql.push(`SELECT * FROM "Project" WHERE id = ${id} LIMIT 1`);
const p = ctx.db.projects.find((x) => x.id === id);
// Hide other people's projects behind 404 rather than 403 so ids cannot be probed.
if (!p || (!ctx.can(readAny) && !(ctx.can(readOwn) && p.ownerId === ctx.user!.id))) {
throw new NotFoundException(`Project ${id} not found`);
}
return p;
}
/** Ownership check for writes: ":any" wins, ":own" only for your own rows. */
function assertCan(ctx: Ctx, p: Project, any: Permission, own: Permission, verb: string) {
if (ctx.can(any)) return;
if (ctx.can(own) && p.ownerId === ctx.user!.id) return;
throw new ForbiddenException(
ctx.can(own) ? `You can only ${verb} projects you own` : `Missing permission ${any} or ${own}`,
);
}
const encodeCursor = (id: number) => btoa(`id:${id}`).replace(/=+$/, "");
const decodeCursor = (c: string) => {
try {
const m = /^id:(\d+)$/.exec(atob(c));
return m ? Number(m[1]) : null;
} catch {
return null;
}
};
export const ROUTES: Route[] = [
{
method: "GET",
path: "/health",
handler: "HealthController.check",
summary: "Liveness and database check",
tag: "health",
isPublic: true,
run: (ctx) => {
ctx.sql.push("SELECT 1");
return { status: 200, body: { status: "ok", info: { database: { status: "up" } } } };
},
},
{
method: "POST",
path: "/auth/login",
handler: "AuthController.login",
summary: "Exchange email and password for an access and refresh token",
tag: "auth",
isPublic: true,
body: LoginDto,
sampleBody: { email: "uma@acme.test", password: "demo-pass-2026" },
run: (ctx) => {
const email = String(ctx.body.email).toLowerCase();
const windowMs = 15 * 60_000;
const fails = (ctx.db.loginFailures[email] ?? []).filter((t) => t > ctx.now - windowMs);
if (fails.length >= 5) {
const retry = Math.ceil((fails[0]! + windowMs - ctx.now) / 1000);
throw new TooManyRequestsException("Too many failed logins for this account. Try again later.", retry);
}
ctx.sql.push(`SELECT * FROM "User" WHERE email = '${email}' LIMIT 1`);
const user = ctx.db.users.find((u) => u.email === email);
// Same message for "no such user" and "wrong password", so emails cannot be enumerated.
if (!user || !verifyPassword(user.passwordHash, String(ctx.body.password), user.email)) {
ctx.db.loginFailures[email] = [...fails, ctx.now];
return { status: 401, body: new UnauthorizedException("Invalid email or password").toBody() };
}
delete ctx.db.loginFailures[email];
const family = `fam_${user.id}_${ctx.now.toString(36)}`;
const { token } = issueRefreshToken(ctx.db, user.id, family, ctx.now);
ctx.sql.push(`INSERT INTO "RefreshToken" (user_id, family, token_hash, expires_at) VALUES (${user.id}, '${family}', '…', now() + interval '7 days')`);
return {
status: 200,
body: { accessToken: signAccessToken(user, ctx.now), refreshToken: token, tokenType: "Bearer", expiresIn: ACCESS_TTL_SEC, user: publicUser(user) },
};
},
},
{
method: "POST",
path: "/auth/refresh",
handler: "AuthController.refresh",
summary: "Rotate the refresh token and get a new access token",
tag: "auth",
isPublic: true,
body: RefreshDto,
sampleBody: { refreshToken: "<paste a refresh token>" },
run: (ctx) => {
ctx.sql.push(`SELECT * FROM "RefreshToken" WHERE token_hash = $1 FOR UPDATE`);
const r = rotateRefreshToken(ctx.db, String(ctx.body.refreshToken), ctx.now);
if (!r.ok) {
if (r.revokedFamily) ctx.sql.push(`UPDATE "RefreshToken" SET revoked_at = now() WHERE family = '${r.revokedFamily}' AND revoked_at IS NULL`);
// Returned (not thrown) so the family revocation is committed.
return { status: 401, body: new UnauthorizedException(r.reason).toBody() };
}
ctx.sql.push(`UPDATE "RefreshToken" SET revoked_at = now(), replaced_by = $2 WHERE id = $1`);
const user = ctx.db.users.find((u) => u.id === r.userId)!;
return { status: 200, body: { accessToken: signAccessToken(user, ctx.now), refreshToken: r.token, tokenType: "Bearer", expiresIn: ACCESS_TTL_SEC } };
},
},
{
method: "POST",
path: "/auth/logout",
handler: "AuthController.logout",
summary: "Revoke every refresh token of the current user",
tag: "auth",
run: (ctx) => {
const nowIso = new Date(ctx.now).toISOString();
let n = 0;
for (const r of ctx.db.refreshTokens) {
if (r.userId === ctx.user!.id && !r.revokedAt) {
r.revokedAt = nowIso;
n++;
}
}
ctx.sql.push(`UPDATE "RefreshToken" SET revoked_at = now() WHERE user_id = ${ctx.user!.id} AND revoked_at IS NULL -- ${n} rows`);
return { status: 204 };
},
},
{
method: "GET",
path: "/auth/me",
handler: "AuthController.me",
summary: "The current user and their effective permissions",
tag: "auth",
run: (ctx) => ({ status: 200, body: { ...publicUser(ctx.user!), permissions: ctx.db.matrix[ctx.user!.role] } }),
},
{
method: "GET",
path: "/projects",
handler: "ProjectsController.findAll",
summary: "List projects with cursor pagination, filter and sort",
tag: "projects",
anyOf: ["projects:read:own", "projects:read:any"],
query: ListProjectsQuery,
example: "/projects?limit=5&sort=-createdAt",
run: (ctx) => {
const limit = (ctx.query.limit as number | undefined) ?? 10;
const sort = (ctx.query.sort as string | undefined) ?? "-createdAt";
const scopeAll = ctx.can("projects:read:any");
const where: string[] = [];
let rows = ctx.db.projects;
if (!scopeAll) {
rows = rows.filter((p) => p.ownerId === ctx.user!.id);
where.push(`owner_id = ${ctx.user!.id}`);
}
if (ctx.query.status) {
rows = rows.filter((p) => p.status === ctx.query.status);
where.push(`status = '${String(ctx.query.status)}'`);
}
if (ctx.query.q) {
const q = String(ctx.query.q).toLowerCase();
rows = rows.filter((p) => p.name.toLowerCase().includes(q));
where.push(`name ILIKE '%${q}%'`);
}
const desc = sort.startsWith("-");
const key = desc ? sort.slice(1) : sort;
rows = [...rows].sort((a, b) => {
const av = key === "name" ? a.name.toLowerCase() : a.createdAt;
const bv = key === "name" ? b.name.toLowerCase() : b.createdAt;
const c = av < bv ? -1 : av > bv ? 1 : a.id - b.id;
return desc ? -c : c;
});
const total = rows.length;
let start = 0;
if (ctx.query.cursor) {
const id = decodeCursor(String(ctx.query.cursor));
const idx = id === null ? -1 : rows.findIndex((p) => p.id === id);
if (idx < 0) throw new BadRequestException("cursor is invalid or points at a row you cannot see");
start = idx + 1;
}
const page = rows.slice(start, start + limit);
const last = page.at(-1);
const hasMore = start + limit < total;
ctx.sql.push(
`SELECT * FROM "Project"${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY ${key === "name" ? "name" : "created_at"} ${desc ? "DESC" : "ASC"}, id LIMIT ${limit + 1}${ctx.query.cursor ? " -- after cursor" : ""}`,
);
return {
status: 200,
body: { data: page, meta: { limit, count: page.length, total, hasMore, nextCursor: hasMore && last ? encodeCursor(last.id) : null } },
};
},
},
{
method: "GET",
path: "/projects/:id",
handler: "ProjectsController.findOne",
summary: "One project (owners only, unless projects:read:any)",
tag: "projects",
anyOf: ["projects:read:own", "projects:read:any"],
example: "/projects/3",
run: (ctx) => ({ status: 200, body: findProject(ctx, "projects:read:any", "projects:read:own") }),
},
{
method: "GET",
path: "/projects/:id/tasks",
handler: "TasksController.findForProject",
summary: "Tasks of a project you can see",
tag: "projects",
anyOf: ["tasks:read"],
example: "/projects/1/tasks",
run: (ctx) => {
const p = findProject(ctx, "projects:read:any", "projects:read:own");
ctx.sql.push(`SELECT * FROM "Task" WHERE project_id = ${p.id} ORDER BY id`);
return { status: 200, body: { data: ctx.db.tasks.filter((t) => t.projectId === p.id) } };
},
},
{
method: "POST",
path: "/projects",
handler: "ProjectsController.create",
summary: "Create a project owned by the caller",
tag: "projects",
anyOf: ["projects:create"],
body: CreateProjectDto,
sampleBody: { name: "Waiting room display", status: "active", budget: 5000, isAdmin: true },
run: (ctx) => {
const nowIso = new Date(ctx.now).toISOString();
const name = String(ctx.body.name);
if (ctx.db.projects.some((p) => p.ownerId === ctx.user!.id && p.name.toLowerCase() === name.toLowerCase())) {
// Prisma P2002 on @@unique([ownerId, name]) mapped to 409 by the exception filter.
throw new HttpException(409, `You already have a project named "${name}"`);
}
const p: Project = {
id: ++ctx.db.seq.project,
name,
description: (ctx.body.description as string | null | undefined) ?? null,
status: (ctx.body.status as Project["status"] | undefined) ?? "active",
budget: (ctx.body.budget as number | null | undefined) ?? null,
ownerId: ctx.user!.id,
createdAt: nowIso,
updatedAt: nowIso,
};
ctx.db.projects.push(p);
ctx.sql.push(`INSERT INTO "Project" (name, status, budget, owner_id) VALUES ('${p.name}', '${p.status}', ${p.budget}, ${p.ownerId}) RETURNING *`);
return { status: 201, body: p, headers: { Location: `/projects/${p.id}` } };
},
},
{
method: "PATCH",
path: "/projects/:id",
handler: "ProjectsController.update",
summary: "Update a project (own, or any with projects:update:any)",
tag: "projects",
anyOf: ["projects:update:own", "projects:update:any"],
body: UpdateProjectDto,
example: "/projects/1",
sampleBody: { status: "paused" },
run: (ctx) => {
const p = findProject(ctx, "projects:read:any", "projects:read:own");
assertCan(ctx, p, "projects:update:any", "projects:update:own", "update");
Object.assign(p, ctx.body, { updatedAt: new Date(ctx.now).toISOString() });
ctx.sql.push(`UPDATE "Project" SET ${Object.keys(ctx.body).map((k) => `${k} = $`).join(", ")}, updated_at = now() WHERE id = ${p.id} RETURNING *`);
return { status: 200, body: p };
},
},
{
method: "DELETE",
path: "/projects/:id",
handler: "ProjectsController.remove",
summary: "Delete a project and its tasks",
tag: "projects",
anyOf: ["projects:delete:own", "projects:delete:any"],
example: "/projects/2",
run: (ctx) => {
const p = findProject(ctx, "projects:read:any", "projects:read:own");
assertCan(ctx, p, "projects:delete:any", "projects:delete:own", "delete");
ctx.db.projects = ctx.db.projects.filter((x) => x.id !== p.id);
ctx.db.tasks = ctx.db.tasks.filter((t) => t.projectId !== p.id);
ctx.sql.push(`DELETE FROM "Project" WHERE id = ${p.id} -- tasks removed by ON DELETE CASCADE`);
return { status: 204 };
},
},
{
method: "GET",
path: "/users",
handler: "UsersController.findAll",
summary: "All users (admin)",
tag: "users",
anyOf: ["users:read"],
run: (ctx) => {
ctx.sql.push(`SELECT id, email, name, role, created_at FROM "User" ORDER BY id`);
return { status: 200, body: { data: ctx.db.users.map(publicUser) } };
},
},
{
method: "PATCH",
path: "/users/:id/role",
handler: "UsersController.changeRole",
summary: "Change a user's role (admin)",
tag: "users",
anyOf: ["users:manage"],
body: UpdateRoleDto,
example: "/users/3/role",
sampleBody: { role: "editor" },
run: (ctx) => {
const id = Number(ctx.params.id);
const u = ctx.db.users.find((x) => x.id === id);
if (!u) throw new NotFoundException(`User ${ctx.params.id} not found`);
if (u.id === ctx.user!.id) throw new ForbiddenException("You cannot change your own role");
u.role = ctx.body.role as User["role"];
ctx.sql.push(`UPDATE "User" SET role = '${u.role}' WHERE id = ${u.id}`);
return { status: 200, body: publicUser(u) };
},
},
];
export const ENDPOINTS: Endpoint[] = ROUTES.map((r) => ({
method: r.method,
path: r.path,
summary: r.summary,
tag: r.tag,
auth: r.isPublic ? "public" : r.anyOf ? r.anyOf.join(" | ") : "bearer",
example: r.example,
sampleBody: r.sampleBody,
}));
/* -------------------------------------------------------------- pipeline */
export type PipeOptions = { forbidNonWhitelisted: boolean };
export function handle(
db: Db,
req: ApiRequest,
now: number,
opts: PipeOptions = { forbidNonWhitelisted: false },
): { db: Db; res: ApiResponse; route?: Route } {
const trace: TraceStep[] = [{ stage: "LoggerMiddleware", ok: true }];
const sql: string[] = [];
const { pathname, query } = 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): { db: Db; res: ApiResponse; route?: Route } => {
trace.push({ stage: "HttpExceptionFilter", ok: false, note: String(e.status) });
return { db, res: { status: e.status, body: e.toBody(), trace, sql }, route };
};
if (!route) {
return fail(
candidates.length
? new HttpException(405, `Cannot ${req.method} ${pathname}`)
: new NotFoundException(`Cannot ${req.method} ${pathname}`),
);
}
try {
// JwtAuthGuard (+ JwtStrategy.validate, which re-loads the user so role changes apply at once)
let user: User | null = null;
if (!route.isPublic) {
const auth = header(req, "authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
if (!m) {
trace.push({ stage: "JwtAuthGuard", ok: false, note: "no bearer token" });
throw new UnauthorizedException("Missing bearer token");
}
const v = verifyAccessToken(m[1]!, now);
if (!v.ok) {
trace.push({ stage: "JwtAuthGuard", ok: false, note: v.reason });
throw new UnauthorizedException(v.reason);
}
user = draft.users.find((u) => u.id === v.payload.sub) ?? null;
if (!user) {
trace.push({ stage: "JwtAuthGuard", ok: false, note: "user deleted" });
throw new UnauthorizedException();
}
trace.push({ stage: "JwtAuthGuard", ok: true, note: `${user.email} (${user.role})` });
}
const perms = new Set(user ? draft.matrix[user.role] : []);
const can = (p: Permission) => perms.has(p);
// PermissionsGuard
if (route.anyOf) {
const ok = route.anyOf.some(can);
trace.push({ stage: "PermissionsGuard", ok, note: route.anyOf.join(" | ") });
if (!ok) throw new ForbiddenException(`Role "${user!.role}" lacks ${route.anyOf.join(" or ")}`);
}
// ValidationPipe on @Body() and @Query()
let body: Record<string, unknown> = {};
let q: Record<string, unknown> = {};
const notes: string[] = [];
for (const [dto, input, set] of [
[route.body, req.body, (v: Record<string, unknown>) => (body = v)],
[route.query, query, (v: Record<string, unknown>) => (q = v)],
] as const) {
if (!dto) continue;
// Unknown query params are dropped; unknown body fields are stripped, or rejected with forbidNonWhitelisted.
const r = validate(input, dto, { whitelist: true, forbidNonWhitelisted: dto === route.body && opts.forbidNonWhitelisted });
if (!r.ok) {
trace.push({ stage: `ValidationPipe(${dto.name})`, ok: false, note: `${r.errors.length} error(s)` });
trace.push({ stage: "HttpExceptionFilter", ok: false, note: "400" });
return { db, res: { status: 400, body: validationErrorBody(r.errors), trace, sql }, route };
}
if (r.stripped.length) notes.push(`stripped ${r.stripped.join(", ")}`);
set(r.value);
}
if (route.body || route.query) trace.push({ stage: "ValidationPipe", ok: true, note: notes.join("; ") || undefined });
const out = route.run({ db: draft, now, user, params: matchRoute(route.path, pathname)!, query: q, body, sql, can });
trace.push({ stage: route.handler, ok: out.status < 400, note: String(out.status) });
if (out.status >= 400) trace.push({ stage: "HttpExceptionFilter", ok: false, note: String(out.status) });
if (sql.length && out.status < 400 && req.method !== "GET") {
sql.unshift("BEGIN");
sql.push("COMMIT");
}
return { db: draft, res: { status: out.status, body: out.body ?? null, headers: out.headers, trace, sql }, route };
} catch (e) {
if (e instanceof HttpException) {
if (sql.length && req.method !== "GET") sql.push("ROLLBACK");
return fail(e);
}
throw e;
}
}app/lab/nestjs-rest-api-rbac/auth.ts110 lines
/**
* Auth primitives: access tokens (JWT shape), password hashing and rotating refresh tokens.
*
* Production design: @nestjs/jwt signs HS256 tokens with a secret from ConfigService, passwords use argon2id,
* refresh tokens are random 256-bit strings stored as SHA-256 hashes. The browser demo keeps the same shapes
* and rules but uses a small FNV hash so it runs synchronously without crypto.subtle.
*/
import { fnv1a } from "@/components/lab/api/http";
import type { Db, RefreshToken, Role, User } from "./db";
export const ACCESS_TTL_SEC = 15 * 60;
export const REFRESH_TTL_SEC = 7 * 24 * 3600;
const SECRET = "demo-secret-not-for-production";
export type JwtPayload = { sub: number; email: string; role: Role; iat: number; exp: number };
const b64url = (s: string) => btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64url = (s: string) => atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4));
/** A deterministic stand-in for HMAC-SHA256: four chained FNV rounds, 32 hex chars. */
function sign(data: string): string {
let out = "";
let h = SECRET;
for (let i = 0; i < 4; i++) {
h = fnv1a(h + "|" + data + "|" + i);
out += h;
}
return b64url(out);
}
export function signAccessToken(user: User, nowMs: number): string {
const iat = Math.floor(nowMs / 1000);
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role, iat, exp: iat + ACCESS_TTL_SEC };
const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64url(JSON.stringify(payload));
return `${head}.${body}.${sign(`${head}.${body}`)}`;
}
export type VerifyResult = { ok: true; payload: JwtPayload } | { ok: false; reason: string };
/** What JwtStrategy does before validate(): structure, signature, expiry. */
export function verifyAccessToken(token: string, nowMs: number): VerifyResult {
const parts = token.split(".");
if (parts.length !== 3) return { ok: false, reason: "jwt malformed" };
const [head, body, sig] = parts as [string, string, string];
if (sign(`${head}.${body}`) !== sig) return { ok: false, reason: "invalid signature" };
let payload: JwtPayload;
try {
payload = JSON.parse(unb64url(body)) as JwtPayload;
} catch {
return { ok: false, reason: "jwt malformed" };
}
if (payload.exp * 1000 <= nowMs) return { ok: false, reason: "jwt expired" };
return { ok: true, payload };
}
/** Decode without verifying, for display only. */
export function decodeToken(token: string): JwtPayload | null {
try {
return JSON.parse(unb64url(token.split(".")[1] ?? "")) as JwtPayload;
} catch {
return null;
}
}
/** Stand-in for argon2.hash(password). Salted so equal passwords give different hashes. */
export const hashPassword = (pw: string, salt: string) => `$demo-argon2id$${fnv1a(salt)}$${fnv1a(salt + ":" + pw)}${fnv1a(pw + ":" + salt)}`;
export const verifyPassword = (hash: string, pw: string, salt: string) => hashPassword(pw, salt) === hash;
/** Issue a refresh token. Only its hash is stored, so a database leak does not leak sessions. */
export function issueRefreshToken(db: Db, userId: number, family: string, nowMs: number): { token: string; row: RefreshToken } {
const id = ++db.seq.refreshToken;
const token = `rt_${id}_${fnv1a(`${userId}:${family}:${nowMs}:${id}`)}${fnv1a(String(nowMs * id))}`;
const row: RefreshToken = {
id,
userId,
family,
tokenHash: fnv1a(token),
expiresAt: new Date(nowMs + REFRESH_TTL_SEC * 1000).toISOString(),
revokedAt: null,
replacedBy: null,
};
db.refreshTokens.push(row);
return { token, row };
}
export type RotateResult =
| { ok: true; userId: number; token: string }
| { ok: false; reason: string; revokedFamily?: string };
/**
* Refresh-token rotation with reuse detection (OAuth 2.0 Security BCP):
* a valid token is revoked and replaced; presenting an already-rotated token means it was stolen or replayed,
* so every token in that family is revoked and the user has to log in again.
*/
export function rotateRefreshToken(db: Db, token: string, nowMs: number): RotateResult {
const row = db.refreshTokens.find((r) => r.tokenHash === fnv1a(token));
if (!row) return { ok: false, reason: "Unknown refresh token" };
const nowIso = new Date(nowMs).toISOString();
if (row.revokedAt) {
for (const r of db.refreshTokens) if (r.family === row.family && !r.revokedAt) r.revokedAt = nowIso;
return { ok: false, reason: "Refresh token reuse detected. All sessions in this family were revoked.", revokedFamily: row.family };
}
if (Date.parse(row.expiresAt) <= nowMs) return { ok: false, reason: "Refresh token expired" };
const next = issueRefreshToken(db, row.userId, row.family, nowMs);
row.revokedAt = nowIso;
row.replacedBy = next.row.id;
return { ok: true, userId: row.userId, token: next.token };
}app/lab/nestjs-rest-api-rbac/validation.ts97 lines
/**
* A small re-implementation of class-validator + Nest's ValidationPipe so the demo produces the same
* error messages and the same whitelist behaviour as:
*
* app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
*
* DTO classes in the production design look like:
*
* export class CreateProjectDto {
* @IsString() @MinLength(3) @MaxLength(60) name: string;
* @IsOptional() @IsIn(["active", "paused", "archived"]) status?: ProjectStatus;
* }
*/
import { isRecord } from "@/components/lab/api/http";
type Check = { name: string; test: (v: unknown) => boolean; message: (prop: string) => string };
export const IsString = (): Check => ({ name: "isString", test: (v) => typeof v === "string", message: (p) => `${p} must be a string` });
export const IsEmail = (): Check => ({
name: "isEmail",
test: (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
message: (p) => `${p} must be an email`,
});
export const MinLength = (n: number): Check => ({
name: "minLength",
test: (v) => typeof v === "string" && v.trim().length >= n,
message: (p) => `${p} must be longer than or equal to ${n} characters`,
});
export const MaxLength = (n: number): Check => ({
name: "maxLength",
test: (v) => typeof v === "string" && v.length <= n,
message: (p) => `${p} must be shorter than or equal to ${n} characters`,
});
export const IsInt = (): Check => ({ name: "isInt", test: (v) => Number.isInteger(v), message: (p) => `${p} must be an integer number` });
export const Min = (n: number): Check => ({ name: "min", test: (v) => typeof v === "number" && v >= n, message: (p) => `${p} must not be less than ${n}` });
export const Max = (n: number): Check => ({ name: "max", test: (v) => typeof v === "number" && v <= n, message: (p) => `${p} must not be greater than ${n}` });
export const IsIn = (values: readonly string[]): Check => ({
name: "isIn",
test: (v) => typeof v === "string" && values.includes(v),
message: (p) => `${p} must be one of the following values: ${values.join(", ")}`,
});
export type FieldSpec = { optional?: boolean; nullable?: boolean; /** transform: true turns query-string "5" into 5 */ number?: boolean; checks: Check[] };
export type Dto = { name: string; fields: Record<string, FieldSpec> };
export type FieldError = { property: string; constraints: Record<string, string> };
export type PipeResult = { ok: true; value: Record<string, unknown>; stripped: string[] } | { ok: false; errors: FieldError[] };
export function validate(input: unknown, dto: Dto, opts = { whitelist: true, forbidNonWhitelisted: true }): PipeResult {
if (input === undefined || input === null) input = {};
if (!isRecord(input)) return { ok: false, errors: [{ property: "body", constraints: { isObject: "body must be a JSON object" } }] };
const errors: FieldError[] = [];
const value: Record<string, unknown> = {};
const stripped: string[] = [];
for (const key of Object.keys(input)) {
if (dto.fields[key]) continue;
if (opts.forbidNonWhitelisted) errors.push({ property: key, constraints: { whitelistValidation: `property ${key} should not exist` } });
else if (opts.whitelist) stripped.push(key);
else value[key] = input[key];
}
for (const [prop, spec] of Object.entries(dto.fields)) {
let v = input[prop];
if (v === undefined || (v === "" && spec.number)) {
if (!spec.optional) {
const c: Record<string, string> = {};
for (const ch of spec.checks) c[ch.name] = ch.message(prop);
errors.push({ property: prop, constraints: c });
}
continue;
}
if (v === null && spec.nullable) {
value[prop] = null;
continue;
}
if (spec.number && typeof v === "string" && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
const c: Record<string, string> = {};
for (const ch of spec.checks) if (!ch.test(v)) c[ch.name] = ch.message(prop);
if (Object.keys(c).length) errors.push({ property: prop, constraints: c });
else value[prop] = typeof v === "string" ? v.trim() : v;
}
return errors.length ? { ok: false, errors } : { ok: true, value, stripped };
}
/** The exceptionFactory: Nest's flat message array plus a per-field map that front ends can bind to inputs. */
export function validationErrorBody(errors: FieldError[]) {
return {
statusCode: 400,
message: errors.flatMap((e) => Object.values(e.constraints)),
error: "Bad Request",
fields: Object.fromEntries(errors.map((e) => [e.property, Object.values(e.constraints)])),
};
}app/lab/nestjs-rest-api-rbac/db.ts120 lines
/**
* The "database" for the RBAC demo: plain rows that mirror the Prisma schema in the case study.
* In production these are PostgreSQL tables behind PrismaService; here they live in one serialisable object.
*/
export const ROLES = ["admin", "editor", "user"] as const;
export type Role = (typeof ROLES)[number];
export const PERMISSIONS = [
"projects:read:own",
"projects:read:any",
"projects:create",
"projects:update:own",
"projects:update:any",
"projects:delete:own",
"projects:delete:any",
"tasks:read",
"users:read",
"users:manage",
] as const;
export type Permission = (typeof PERMISSIONS)[number];
export type User = { id: number; email: string; name: string; role: Role; passwordHash: string; createdAt: string };
export type ProjectStatus = "active" | "paused" | "archived";
export type Project = {
id: number;
name: string;
description: string | null;
status: ProjectStatus;
budget: number | null;
ownerId: number;
createdAt: string;
updatedAt: string;
};
export type Task = { id: number; projectId: number; title: string; done: boolean };
export type RefreshToken = {
id: number;
userId: number;
/** All tokens created from one login share a family; reuse of an old one revokes the whole family. */
family: string;
tokenHash: string;
expiresAt: string;
revokedAt: string | null;
replacedBy: number | null;
};
export type Db = {
users: User[];
projects: Project[];
tasks: Task[];
refreshTokens: RefreshToken[];
/** Role → permissions. Editable in the demo; in production this is a role_permissions table cached per request. */
matrix: Record<Role, Permission[]>;
/** Failed login timestamps per email, for the login throttle. */
loginFailures: Record<string, number[]>;
seq: { project: number; task: number; refreshToken: number };
};
export const DEFAULT_MATRIX: Record<Role, Permission[]> = {
admin: [...PERMISSIONS],
editor: ["projects:read:any", "projects:create", "projects:update:own", "projects:delete:own", "tasks:read"],
user: ["projects:read:own", "projects:create", "projects:update:own", "projects:delete:own", "tasks:read"],
};
/** Every demo account uses this password. Shown in the UI on purpose. */
export const DEMO_PASSWORD = "demo-pass-2026";
const iso = (day: number, hour = 9) => new Date(Date.UTC(2026, 9, day, hour, 0, 0)).toISOString();
export function seedDb(hashPassword: (pw: string, salt: string) => string): Db {
const users: User[] = [
{ id: 1, email: "ada@acme.test", name: "Ada Admin", role: "admin" },
{ id: 2, email: "eli@acme.test", name: "Eli Editor", role: "editor" },
{ id: 3, email: "uma@acme.test", name: "Uma User", role: "user" },
{ id: 4, email: "omar@acme.test", name: "Omar User", role: "user" },
].map((u) => ({ ...u, role: u.role as Role, passwordHash: hashPassword(DEMO_PASSWORD, u.email), createdAt: iso(1) }));
const names: [string, number, ProjectStatus, number | null][] = [
["Acme Dental booking flow", 3, "active", 12000],
["Clinic reminders by SMS", 3, "active", 4000],
["Patient portal redesign", 4, "paused", 18000],
["Invoice export to CSV", 4, "active", null],
["Staff rota planner", 2, "active", 9000],
["Marketing site refresh", 2, "archived", 3500],
["Reporting dashboard v2", 1, "active", 22000],
["Data retention clean-up", 1, "active", null],
["Order #1042 dispute tooling", 3, "archived", 800],
["Mobile check-in kiosk", 4, "active", 15000],
["Audit log export", 2, "paused", 2500],
["Onboarding checklist", 3, "active", 1200],
];
const projects: Project[] = names.map(([name, ownerId, status, budget], i) => ({
id: i + 1,
name,
description: null,
status,
budget,
ownerId,
createdAt: iso(2 + i, 10),
updatedAt: iso(2 + i, 10),
}));
const TASKS = ["Write spec", "Design review", "Build API", "QA pass", "Release"];
const tasks: Task[] = [];
for (const p of projects) {
const n = 2 + (p.id % 3);
for (let i = 0; i < n; i++) tasks.push({ id: tasks.length + 1, projectId: p.id, title: TASKS[i]!, done: i < n - 2 });
}
return {
users,
projects,
tasks,
refreshTokens: [],
matrix: structuredClone(DEFAULT_MATRIX),
loginFailures: {},
seq: { project: projects.length, task: tasks.length, refreshToken: 0 },
};
}app/lab/nestjs-rest-api-rbac/Demo.tsx368 lines
"use client";
import { useEffect, useMemo, 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 } from "@/components/lab/api/http";
import { usePersisted } from "@/components/lab/api/usePersisted";
import { ENDPOINTS, handle } from "./app";
import { decodeToken, hashPassword } from "./auth";
import { DEFAULT_MATRIX, DEMO_PASSWORD, PERMISSIONS, ROLES, seedDb, type Db, type Permission, type Project, type Role } from "./db";
const KEY = "lab:nestjs-rest-api-rbac:v1";
type Session = { accessToken: string; refreshToken: string; previousRefresh: string | null } | null;
type State = { db: Db; session: Session; clockOffsetMin: number; forbidUnknown: boolean };
const seed = (): State => ({ db: seedDb(hashPassword), session: null, clockOffsetMin: 0, forbidUnknown: false });
const headersFor = (token: string | null) =>
JSON.stringify(token ? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } : { "Content-Type": "application/json" }, null, 2);
const SCENARIOS: { label: string; preset: Omit<ConsolePreset, "id">; hint: string }[] = [
{ label: "Paginate my projects", preset: { method: "GET", path: "/projects?limit=3&sort=name" }, hint: "Copy meta.nextCursor into ?cursor= for the next page." },
{ label: "Admin-only list (403 for users)", preset: { method: "GET", path: "/users" }, hint: "Needs users:read." },
{ label: "Invalid body (400)", preset: { method: "POST", path: "/projects", body: { name: "AB", status: "done", budget: -5 } }, hint: "Field-level errors from the ValidationPipe." },
{ label: "Edit someone else's project", preset: { method: "PATCH", path: "/projects/1", body: { status: "archived" } }, hint: "Project 1 belongs to Uma. Eli gets 403, Omar gets 404." },
{ label: "Role escalation attempt", preset: { method: "POST", path: "/projects", body: { name: "Sneaky", ownerId: 1, role: "admin" } }, hint: "Unknown fields are stripped or rejected." },
];
const ROLE_CLS: Record<Role, string> = { admin: "text-accent-3", editor: "text-accent-2", user: "text-accent" };
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 [headersSynced, setHeadersSynced] = useState(false);
const [log, setLog] = useState<LogLine[]>([]);
const [preset, setPreset] = useState<ConsolePreset | null>(null);
const [tab, setTab] = useState<"projects" | "users" | "tasks" | "refresh_tokens">("projects");
const [returnedIds, setReturnedIds] = useState<number[]>([]);
const [notice, setNotice] = useState<string | null>(null);
// After the saved state loads, put the saved token back into the headers once.
if (!headersSynced && state.session) {
setHeadersSynced(true);
setHeaders(headersFor(state.session.accessToken));
}
const now = () => Date.now() + stateRef.current.clockOffsetMin * 60_000;
/** The single entry point into the simulated Nest app. Also keeps the session in sync with auth responses. */
function send(req: ApiRequest) {
const cur = stateRef.current;
const { db, res, route } = handle(cur.db, req, now(), { forbidNonWhitelisted: cur.forbidUnknown });
let session = cur.session;
if (res.status === 200 && isRecord(res.body) && typeof res.body.accessToken === "string" && route) {
session = {
accessToken: res.body.accessToken,
refreshToken: String(res.body.refreshToken),
previousRefresh: route.path === "/auth/refresh" ? (cur.session?.refreshToken ?? null) : null,
};
setHeaders(headersFor(session.accessToken));
}
if (route?.path === "/auth/logout" && res.status === 204) {
session = null;
setHeaders(headersFor(null));
}
const next = { ...cur, db, session };
stateRef.current = next;
setState(next);
return res;
}
function onResult({ req, res, ms }: ConsoleResult) {
const body = res.body;
const ids: number[] = [];
if (isRecord(body)) {
if (Array.isArray(body.data)) for (const r of body.data) if (isRecord(r) && typeof r.ownerId === "number") ids.push(r.id as number);
if (typeof body.ownerId === "number") ids.push(body.id as number);
}
setReturnedIds(ids);
const lines: { text: string; tone: LogLine["tone"] }[] = [
{ 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)${isRecord(body) && typeof body.message === "string" ? ` · ${body.message}` : ""}`, tone: statusTone(res.status) },
];
setLog((l) => appendLog(l, lines));
}
/** Quick login through the real /auth/login route, so it shows up in the log like any request. */
function loginAs(email: string | null) {
setNotice(null);
if (!email) {
setState((s) => ({ ...s, session: null }));
setHeaders(headersFor(null));
setLog((l) => appendLog(l, [{ text: "# signed out locally (token dropped from headers)", tone: "dim" }]));
return;
}
const req: ApiRequest = { method: "POST", path: "/auth/login", headers: {}, body: { email, password: DEMO_PASSWORD } };
const res = send(req);
onResult({ req: { ...req, body: { email, password: "••••" } }, res, ms: 0 });
}
function refresh(useOld: boolean) {
const s = stateRef.current.session;
const token = useOld ? s?.previousRefresh : s?.refreshToken;
if (!token) {
setNotice(useOld ? "Rotate the token once first, then replay the old one." : "Sign in first.");
return;
}
setNotice(null);
const req: ApiRequest = { method: "POST", path: "/auth/refresh", headers: {}, body: { refreshToken: token } };
const res = send(req);
onResult({ req: { ...req, body: { refreshToken: token.slice(0, 10) + "…" } }, res, ms: 0 });
if (useOld && res.status === 401) setNotice("Reuse detected: the whole token family is revoked. The current refresh token no longer works either.");
}
function togglePerm(role: Role, p: Permission) {
setState((s) => {
const has = s.db.matrix[role].includes(p);
const list = has ? s.db.matrix[role].filter((x) => x !== p) : [...s.db.matrix[role], p];
return { ...s, db: { ...s.db, matrix: { ...s.db.matrix, [role]: list } } };
});
}
function reset() {
resetState();
setHeaders(headersFor(null));
setLog([]);
setReturnedIds([]);
setNotice(null);
}
const payload = state.session ? decodeToken(state.session.accessToken) : null;
const me = payload ? state.db.users.find((u) => u.id === payload.sub) ?? null : null;
const perms = useMemo(() => new Set(me ? state.db.matrix[me.role] : []), [me, state.db.matrix]);
const canSee = (p: Project) => perms.has("projects:read:any") || (perms.has("projects:read:own") && p.ownerId === me?.id);
const userName = (id: number) => state.db.users.find((u) => u.id === id)?.name.split(" ")[0] ?? `#${id}`;
const btn = "rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent hover:text-accent";
const toolbar = (
<div className="space-y-3 rounded-md border border-border bg-bg-elev p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-fg-muted">sign in as</span>
{state.db.users.map((u) => (
<button
key={u.id}
type="button"
onClick={() => loginAs(u.email)}
aria-pressed={me?.id === u.id}
className={`${btn} ${me?.id === u.id ? "border-accent bg-accent-soft" : ""}`}
>
{u.name.split(" ")[0]} <span className={ROLE_CLS[u.role]}>{u.role}</span>
</button>
))}
<button type="button" onClick={() => loginAs(null)} aria-pressed={!state.session} className={`${btn} ${!state.session ? "border-accent bg-accent-soft" : ""}`}>
anonymous
</button>
</div>
<div className="flex flex-wrap items-center gap-2 font-mono text-xs">
{payload ? (
<span className="text-fg-muted">
JWT <span className="text-fg">sub={payload.sub}</span> role=<span className={ROLE_CLS[payload.role]}>{payload.role}</span> exp in{" "}
<ExpiresIn exp={payload.exp} offsetMin={state.clockOffsetMin} />
</span>
) : (
<span className="text-fg-muted">no token: protected routes return 401</span>
)}
<button type="button" onClick={() => refresh(false)} className={btn}>
refresh token
</button>
<button type="button" onClick={() => refresh(true)} className={btn}>
replay old refresh token
</button>
<button type="button" onClick={() => setState((s) => ({ ...s, clockOffsetMin: s.clockOffsetMin + 16 }))} className={btn}>
clock +16 min
</button>
<label className="flex items-center gap-1.5 text-fg-muted">
<input type="checkbox" checked={state.forbidUnknown} onChange={(e) => setState((s) => ({ ...s, forbidUnknown: e.target.checked }))} className="accent-[var(--accent)]" />
forbidNonWhitelisted
</label>
</div>
{notice && (
<p role="status" className="text-xs text-warn-fg">
{notice}
</p>
)}
<div className="flex flex-wrap gap-1.5">
{SCENARIOS.map((s) => (
<button key={s.label} type="button" title={s.hint} onClick={() => setPreset({ ...s.preset, id: Date.now() })} 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>
</div>
);
return (
<div>
<ApiConsole
title="NestJS API · Swagger-style console"
endpoints={ENDPOINTS}
send={send}
headers={headers}
onHeadersChange={setHeaders}
onResult={onResult}
preset={preset}
toolbar={toolbar}
/>
<div className="grid gap-6 border-t border-border p-4 lg:grid-cols-[1.3fr_1fr]">
<section aria-labelledby="db-h" className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 id="db-h" className="font-mono text-sm font-semibold">
database <span className="text-fg-muted">(PostgreSQL via Prisma)</span>
</h3>
<div role="tablist" aria-label="Tables" className="flex flex-wrap gap-1">
{(["projects", "users", "tasks", "refresh_tokens"] 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="Project"
rows={state.db.projects}
rowKey={(r) => r.id}
highlight={(r) => returnedIds.includes(r.id)}
dim={(r) => !!me && !canSee(r)}
caption={me ? `Greyed rows are outside ${me.name.split(" ")[0]}'s read scope. Tinted rows were in the last response.` : "Sign in to see which rows your role can read."}
columns={[
{ key: "id", label: "id" },
{ key: "name", label: "name" },
{ key: "status", label: "status" },
{ key: "budget", label: "budget" },
{ key: "ownerId", label: "owner", render: (r) => `${r.ownerId} · ${userName(r.ownerId)}` },
]}
/>
)}
{tab === "users" && (
<DbTable
name="User"
rows={state.db.users}
rowKey={(r) => r.id}
highlight={(r) => r.id === me?.id}
caption={`Every demo account uses the password ${DEMO_PASSWORD}`}
columns={[
{ key: "id", label: "id" },
{ key: "email", label: "email" },
{ key: "role", label: "role", render: (r) => <span className={ROLE_CLS[r.role]}>{r.role}</span> },
{ key: "passwordHash", label: "password_hash", render: (r) => r.passwordHash.slice(0, 26) + "…" },
]}
/>
)}
{tab === "tasks" && (
<DbTable
name="Task"
rows={state.db.tasks}
rowKey={(r) => r.id}
columns={[
{ key: "id", label: "id" },
{ key: "projectId", label: "project_id" },
{ key: "title", label: "title" },
{ key: "done", label: "done" },
]}
/>
)}
{tab === "refresh_tokens" && (
<DbTable
name="RefreshToken"
rows={state.db.refreshTokens}
rowKey={(r) => r.id}
dim={(r) => !!r.revokedAt}
empty="No sessions yet. Sign in to create one."
caption="Only hashes are stored. Revoked rows are greyed."
columns={[
{ key: "id", label: "id" },
{ key: "userId", label: "user_id" },
{ key: "family", label: "family" },
{ key: "tokenHash", label: "token_hash" },
{ key: "revokedAt", label: "revoked_at", render: (r) => (r.revokedAt ? r.revokedAt.slice(11, 19) : <span className="text-accent">active</span>) },
{ key: "replacedBy", label: "replaced_by" },
]}
/>
)}
<RequestLog lines={log} onClear={() => setLog([])} className="h-64" />
</section>
<section aria-labelledby="matrix-h" className="min-w-0">
<div className="flex items-center justify-between">
<h3 id="matrix-h" className="font-mono text-sm font-semibold">
permission matrix
</h3>
<button type="button" onClick={() => setState((s) => ({ ...s, db: { ...s.db, matrix: structuredClone(DEFAULT_MATRIX) } }))} className="font-mono text-xs text-fg-muted hover:text-accent">
defaults
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">Read by PermissionsGuard on every request. Untick a box, then send the same request again.</p>
<div className="mt-2 overflow-x-auto rounded-md border border-border">
<table className="w-full font-mono text-xs">
<caption className="sr-only">Permissions granted to each role</caption>
<thead className="bg-bg-muted text-fg-muted">
<tr>
<th scope="col" className="px-2 py-1.5 text-left font-normal">
permission
</th>
{ROLES.map((r) => (
<th key={r} scope="col" className={`px-2 py-1.5 font-normal ${ROLE_CLS[r]}`}>
{r}
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSIONS.map((p) => (
<tr key={p} className="border-t border-border">
<th scope="row" className="px-2 py-1 text-left font-normal">
{p}
</th>
{ROLES.map((r) => (
<td key={r} className="px-2 py-1 text-center">
<input
type="checkbox"
checked={state.db.matrix[r].includes(p)}
onChange={() => togglePerm(r, p)}
aria-label={`${r} has ${p}`}
className="size-4 accent-[var(--accent)]"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 font-mono text-xs text-fg-muted">
<span>clock: +{state.clockOffsetMin} min</span>
<button type="button" onClick={reset} className={btn}>
reset demo
</button>
</div>
</section>
</div>
</div>
);
}
function ExpiresIn({ exp, offsetMin }: { exp: number; offsetMin: number }) {
// Rendered only once a token exists (after mount), so reading the clock cannot cause a hydration mismatch.
const [, tick] = useState(0);
useEffect(() => {
const t = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(t);
}, []);
const left = Math.round((exp * 1000 - (Date.now() + offsetMin * 60_000)) / 1000);
if (left <= 0) return <span className="text-danger">expired (expect 401)</span>;
return (
<span className="text-fg">
{Math.floor(left / 60)}m {String(left % 60).padStart(2, "0")}s
</span>
);
}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>
);
}components/lab/api/http.ts164 lines
/**
* Tiny HTTP vocabulary shared by the backend simulators in /lab.
* The names mirror NestJS (HttpException, ForbiddenException, ...) so the demo services read like Nest code.
*/
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export type ApiRequest = {
method: HttpMethod;
/** Path including the query string, e.g. "/projects?limit=5". */
path: string;
headers: Record<string, string>;
body?: unknown;
};
/** One stage of the request lifecycle (middleware, guard, pipe, handler, filter). */
export type TraceStep = { stage: string; ok: boolean; note?: string };
export type ApiResponse = {
status: number;
body: unknown;
headers?: Record<string, string>;
trace?: TraceStep[];
/** SQL the simulated service ran, shown under the response. */
sql?: string[];
};
export type Endpoint = {
method: HttpMethod;
/** Route template, e.g. "/projects/:id". */
path: string;
summary: string;
tag: string;
/** Short label for the auth requirement, e.g. "public", "projects:read". */
auth?: string;
/** Path that is loaded into the console when the endpoint is picked. */
example?: string;
sampleBody?: unknown;
};
export const STATUS_TEXT: Record<number, string> = {
200: "OK",
201: "Created",
204: "No Content",
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
410: "Gone",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
};
export class HttpException extends Error {
constructor(
readonly status: number,
message: string,
readonly extra: Record<string, unknown> = {},
) {
super(message);
}
/** Same shape as Nest's built-in exception filter: { statusCode, message, error }. */
toBody() {
return { statusCode: this.status, message: this.message, error: STATUS_TEXT[this.status] ?? "Error", ...this.extra };
}
}
export class BadRequestException extends HttpException {
constructor(message: string, extra?: Record<string, unknown>) {
super(400, message, extra);
}
}
export class UnauthorizedException extends HttpException {
constructor(message = "Unauthorized") {
super(401, message);
}
}
export class PaymentRequiredException extends HttpException {
constructor(message: string, extra?: Record<string, unknown>) {
super(402, message, extra);
}
}
export class ForbiddenException extends HttpException {
constructor(message = "Forbidden resource") {
super(403, message);
}
}
export class NotFoundException extends HttpException {
constructor(message = "Not Found") {
super(404, message);
}
}
export class ConflictException extends HttpException {
constructor(message: string) {
super(409, message);
}
}
export class GoneException extends HttpException {
constructor(message: string) {
super(410, message);
}
}
export class UnprocessableEntityException extends HttpException {
constructor(message: string) {
super(422, message);
}
}
export class TooManyRequestsException extends HttpException {
constructor(message: string, retryAfterSec: number) {
super(429, message, { retryAfter: retryAfterSec });
}
}
/** Match "/projects/:id" against "/projects/42" and return { id: "42" }, or null. */
export function matchRoute(template: string, pathname: string): Record<string, string> | null {
const t = template.split("/").filter(Boolean);
const p = pathname.split("/").filter(Boolean);
if (t.length !== p.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < t.length; i++) {
const seg = t[i]!;
const val = p[i]!;
if (seg.startsWith(":")) params[seg.slice(1)] = decodeURIComponent(val);
else if (seg !== val) return null;
}
return params;
}
/** Split "/a/b?x=1&y=2" into pathname and a plain query object. */
export function parsePath(path: string): { pathname: string; query: Record<string, string> } {
const [pathname = "/", qs = ""] = path.split("?", 2);
const query: Record<string, string> = {};
for (const part of qs.split("&")) {
if (!part) continue;
const [k = "", v = ""] = part.split("=", 2);
query[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, " "));
}
return { pathname: pathname.replace(/\/+$/, "") || "/", query };
}
/** Header lookup that ignores case, like Express's req.get(). */
export function header(req: ApiRequest, name: string): string | undefined {
const n = name.toLowerCase();
for (const [k, v] of Object.entries(req.headers)) if (k.toLowerCase() === n) return v;
return undefined;
}
export const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/** Deterministic 32-bit FNV-1a hash, used for fake signatures and request fingerprints. */
export function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}