Skip to content
~sami-ullah

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.

Read the case study →

Interactive demo

samiullah.tech/lab/nestjs-rest-api-rbac

NestJS API · Swagger-style console

sign in as
no token: protected routes return 401

Pick an endpoint on the left and press Send.

database (PostgreSQL via Prisma)

table Project12 rows
Sign in to see which rows your role can read.
Rows in table Project
idnamestatusbudgetowner
1Acme Dental booking flowactive120003 · Uma
2Clinic reminders by SMSactive40003 · Uma
3Patient portal redesignpaused180004 · Omar
4Invoice export to CSVactiveNULL4 · Omar
5Staff rota planneractive90002 · Eli
6Marketing site refresharchived35002 · Eli
7Reporting dashboard v2active220001 · Ada
8Data retention clean-upactiveNULL1 · Ada
9Order #1042 dispute toolingarchived8003 · Uma
10Mobile check-in kioskactive150004 · Omar
11Audit log exportpaused25002 · Eli
12Onboarding checklistactive12003 · Uma

request log

  1. $ waiting for requests…

permission matrix

Read by PermissionsGuard on every request. Untick a box, then send the same request again.

Permissions granted to each role
permissionadmineditoruser
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
clock: +0 min

## 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");
}