Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,95 @@
import { Body, Controller, Delete, Get, HttpCode, Inject, Param, Post, Req, Res, UnauthorizedException, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import {
optionalEmail,
optionalString,
parseId,
requireObject,
requirePositiveInt,
requireSession,
requireString,
} from "../http/validation.js";
import { verifySession } from "../auth/session.js";
import {
InviteLinkService,
type InviteLink,
type InviteLinkCreateInput,
type InviteLinkInfo,
} from "./invite-link.service.js";
import type { AuthResult } from "../auth/auth.service.js";
/**
* Admin surface for invite links, extending the `team` namespace: mint a
* multi-use link (token returned ONCE), list them (never the token), revoke.
*/
@Controller("team")
@UseGuards(RbacGuard)
export class InviteLinkController {
constructor(@Inject(InviteLinkService) private readonly links: InviteLinkService) {}
@Post("invite-links")
@RequirePermission("workspace.members.manage")
async create(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<InviteLink> {
const session = requireSession(request);
const dto = requireObject(body, "body");
const input: InviteLinkCreateInput = { role: requireString(dto.role, "role"), expiresInHours: requirePositiveInt(dto.expiresInHours, "expiresInHours", 720) };
const maxUses = dto.maxUses === undefined || dto.maxUses === null ? undefined : requirePositiveInt(dto.maxUses, "maxUses", 10_000);
return this.links.create(session.workspaceId, { userId: session.userId, role: session.role }, { ...input, ...(maxUses === undefined ? {} : { maxUses }) });
}
@Get("invite-links")
@RequirePermission("workspace.members.manage")
async list(@Req() request: Request & AuthenticatedRequest): Promise<InviteLink[]> {
return this.links.list(requireSession(request).workspaceId);
}
@Delete("invite-links/:id")
@RequirePermission("workspace.members.manage")
async revoke(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string): Promise<InviteLink> {
const session = requireSession(request);
return this.links.revoke(session.workspaceId, { userId: session.userId, role: session.role }, parseId(id));
}
}
/**
* Public claim surface. PATH-based routes keep the token out of the request
* body/log pairing issues while matching the SPA's route shape; there is NO
* RbacGuard here (the claimer may not be authenticated yet), so any presented
* Bearer session is verified manually, exactly like AuthController.reissue.
*/
@Controller("invites")
export class InviteClaimController {
constructor(@Inject(InviteLinkService) private readonly links: InviteLinkService) {}
@Get(":token/info")
async info(@Param("token") token: string, @Res({ passthrough: true }) response: Response): Promise<InviteLinkInfo> {
response.setHeader("Cache-Control", "no-store");
return this.links.info(requireString(token, "token"));
}
@Post(":token/claim")
@HttpCode(201)
async claim(@Param("token") token: string, @Req() request: Request, @Body() body: unknown): Promise<AuthResult> {
const dto = requireObject(body, "body");
const authorization = request.headers.authorization;
if (authorization !== undefined && !authorization.startsWith("Bearer ")) throw new UnauthorizedException();
const session = authorization === undefined ? undefined : verifySession(authorization.slice("Bearer ".length));
if (authorization !== undefined && session === null) throw new UnauthorizedException();
// Shape-only extraction: username/password FORMAT validation lives in the
// service identity block, which runs after link validation so a bogus
// token only ever sees 404 here.
const username = optionalString(dto.username, "username");
const password = optionalString(dto.password, "password");
const displayName = optionalString(dto.displayName, "displayName");
const email = optionalEmail(dto.email);
return this.links.claim({
token: requireString(token, "token"),
...(session === undefined ? {} : { session }),
...(username === undefined ? {} : { username }),
...(password === undefined ? {} : { password }),
...(displayName === undefined ? {} : { displayName }),
...(email === undefined ? {} : { email }),
});
}
}

View file

@ -0,0 +1,275 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
/**
* Invite-link lifecycle against a live PostgreSQL, as sp_app (FORCE RLS binds).
* Uses its OWN database (INVITE_IT_DATABASE_URL, plus INVITE_IT_SUPER_DATABASE_URL
* for migrations/seeding) for the same reason as auth.integration.test.ts:
* tenancy.integration.test.ts wipes every workspace in the shared DB and vitest
* runs files in parallel. Skipped when unset.
*/
const appUrl = process.env.INVITE_IT_DATABASE_URL;
const ownerUrl = process.env.INVITE_IT_SUPER_DATABASE_URL;
type Modules = {
links: typeof import("./invite-link.service.js");
auth: typeof import("../auth/auth.service.js");
session: typeof import("../auth/session.js");
db: typeof import("../db/client.js");
};
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("invite links (live DB)", () => {
let m: Modules;
let ownerPool: Pool;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
const ownerUsername = `invowner${suffix}`;
let workspaceId: string;
let actor: { userId: string; role: string };
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await ownerPool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
beforeAll(async () => {
// The pool reads DATABASE_URL at import time: point it at the isolated DB first.
process.env.DATABASE_URL = appUrl;
process.env.SUPER_DATABASE_URL = ownerUrl;
const { runMigrations } = await import("../db/migrate.js");
await runMigrations(join(process.cwd(), "migrations"));
m = {
links: await import("./invite-link.service.js"),
auth: await import("../auth/auth.service.js"),
session: await import("../auth/session.js"),
db: await import("../db/client.js"),
};
ownerPool = new Pool({ connectionString: ownerUrl });
const auth = new m.auth.AuthService();
const reg = await auth.register({
workspaceName: "Invite IT",
username: ownerUsername,
password: "correct-horse-1",
displayName: "Owner",
email: `invowner-${suffix}@example.com`,
});
workspaceId = reg.me.workspace.id;
const session = m.session.verifySession(reg.token);
if (session === null) throw new Error("session did not verify");
actor = { userId: session.userId, role: session.role };
});
afterAll(async () => {
if (ownerPool !== undefined) {
await asOwner(async (c) => {
await c.query("DELETE FROM workspaces WHERE id = $1", [workspaceId]);
await c.query("DELETE FROM users WHERE username = ANY($1::text[])", [
[ownerUsername, ...claimedUsernames].filter((u): u is string => u !== null),
]);
});
await ownerPool.end();
}
await m?.db.pool.end();
});
/** Claimed usernames, for teardown. */
const claimedUsernames: Array<string | null> = [];
/** Directly rewrite a link's state through the owner pool (inside the tenant context — RLS binds the owner too). */
async function forceLinkState(linkId: string, sql: string, params: unknown[] = []): Promise<void> {
await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
await c.query(sql, [linkId, ...params]);
await c.query("COMMIT");
});
}
async function membershipRole(userId: string): Promise<{ role: string; status: string } | undefined> {
return asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const rows = (
await c.query("SELECT role, status FROM memberships WHERE workspace_id = $1 AND user_id = $2", [workspaceId, userId])
).rows as Array<{ role: string; status: string }>;
await c.query("COMMIT");
return rows[0];
});
}
async function useCount(linkId: string): Promise<number> {
return asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const rows = (await c.query("SELECT use_count FROM workspace_invite_links WHERE id = $1", [linkId])).rows as Array<{ use_count: number }>;
await c.query("COMMIT");
return rows[0]?.use_count ?? -1;
});
}
async function auditActions(targetId: string): Promise<string[]> {
return asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const rows = (await c.query("SELECT action, diff FROM audit_log WHERE target_id = $1 ORDER BY seq ASC", [targetId])).rows as Array<{
action: string;
diff: Record<string, unknown> | null;
}>;
await c.query("COMMIT");
return rows.map((r) => r.action);
});
}
it("create -> info -> claim with a fresh username; the response is an AuthResult and the membership is active", async () => {
const links = new m.links.InviteLinkService();
const created = await links.create(workspaceId, actor, { role: "Publisher", expiresInHours: 24, maxUses: 5 });
expect(created.token).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(created.url).toContain(`/join/${created.token}`);
expect(created.status).toBe("active");
expect(created.useCount).toBe(0);
const info = await links.info(created.token as string);
expect(info.workspaceName).toBe("Invite IT");
expect(info.role).toBe("Publisher");
expect(info.maxUses).toBe(5);
expect(info.usedCount).toBe(0);
await expect(links.info("no-such-token")).rejects.toMatchObject({ status: 404 });
const username = `joiner${suffix}`;
claimedUsernames.push(username);
const result = await links.claim({ token: created.token as string, username, password: "correct-horse-1" });
expect(result.me.role).toBe("Publisher");
expect(result.me.user.username).toBe(username);
expect(result.me.workspace.id).toBe(workspaceId);
expect(result.me.permissions).toContain("draft.create");
const session = m.session.verifySession(result.token);
if (session === null) throw new Error("claim token did not verify");
expect(session.workspaceId).toBe(workspaceId);
const membership = await membershipRole(session.userId);
expect(membership).toEqual({ role: "Publisher", status: "active" });
expect(await useCount(created.id)).toBe(1);
expect(await auditActions(created.id)).toEqual(["invite_link.created"]);
expect(await auditActions(session.userId)).toEqual([]); // audit targets the membership id, not the user
});
it("double-claim by the same user is 409 and does not consume a use", async () => {
const links = new m.links.InviteLinkService();
const created = await links.create(workspaceId, actor, { role: "Approver", expiresInHours: 24 });
const username = `twice${suffix}`;
claimedUsernames.push(username);
const first = await links.claim({ token: created.token as string, username, password: "correct-horse-1", displayName: "Twice" });
const userId = m.session.verifySession(first.token)?.userId;
if (userId === undefined) throw new Error("no session");
await expect(links.claim({ token: created.token as string, username, password: "correct-horse-1" })).rejects.toMatchObject({
status: 409,
});
// Still 409 (not a 401) when the credentials are presented via Bearer instead.
const auth = new m.auth.AuthService();
await expect(auth.login({ username, password: "correct-horse-1" })).resolves.toMatchObject({ me: { role: "Approver" } });
expect(await useCount(created.id)).toBe(1); // unchanged: no partial consumption
});
it("an expired link is 410 on info and claim", async () => {
const links = new m.links.InviteLinkService();
const created = await links.create(workspaceId, actor, { role: "Publisher", expiresInHours: 1 });
await forceLinkState(created.id, "UPDATE workspace_invite_links SET expires_at = now() - interval '1 hour' WHERE id = $1");
await expect(links.info(created.token as string)).rejects.toMatchObject({ status: 410 });
const username = `late${suffix}`;
await expect(links.claim({ token: created.token as string, username, password: "correct-horse-1" })).rejects.toMatchObject({
status: 410,
});
// Rolled back: the whole claim transaction (user insert included) undid.
await expect(userByExternalId(username)).rejects.toThrow(/missing/);
expect((await links.list(workspaceId)).find((l) => l.id === created.id)?.status).toBe("expired");
});
it("a link that exhausts max_uses is 410, and exhaustion is visible in list", async () => {
const links = new m.links.InviteLinkService();
const created = await links.create(workspaceId, actor, { role: "Publisher", expiresInHours: 24, maxUses: 1 });
const first = `single${suffix}`;
claimedUsernames.push(first);
await links.claim({ token: created.token as string, username: first, password: "correct-horse-1" });
expect(await useCount(created.id)).toBe(1);
await expect(links.info(created.token as string)).rejects.toMatchObject({ status: 410 });
const second = `second${suffix}`;
await expect(links.claim({ token: created.token as string, username: second, password: "correct-horse-1" })).rejects.toMatchObject({
status: 410,
});
const listed = (await links.list(workspaceId)).find((l) => l.id === created.id);
expect(listed?.status).toBe("exhausted");
});
it("a revoked link is 410, revoking twice is 409, and an unknown id is 404", async () => {
const links = new m.links.InviteLinkService();
const created = await links.create(workspaceId, actor, { role: "Publisher", expiresInHours: 24 });
const revoked = await links.revoke(workspaceId, actor, created.id);
expect(revoked.status).toBe("revoked");
expect(revoked.revokedAt).not.toBeNull();
await expect(links.info(created.token as string)).rejects.toMatchObject({ status: 410 });
const username = `postrev${suffix}`;
await expect(links.claim({ token: created.token as string, username, password: "correct-horse-1" })).rejects.toMatchObject({
status: 410,
});
await expect(links.revoke(workspaceId, actor, created.id)).rejects.toMatchObject({ status: 409 });
await expect(links.revoke(workspaceId, actor, "00000000-0000-4000-8000-000000000000")).rejects.toMatchObject({ status: 404 });
});
it("list returns derived statuses and NEVER the token; the audit diff never contains it either", async () => {
const links = new m.links.InviteLinkService();
const active = await links.create(workspaceId, actor, { role: "Publisher", expiresInHours: 24 });
const listed = await links.list(workspaceId);
expect(listed.length).toBeGreaterThan(0);
for (const link of listed) {
expect(link.token).toBeUndefined();
expect(link.url).toBeUndefined();
expect(["active", "expired", "revoked", "exhausted"]).toContain(link.status);
}
expect(listed.find((l) => l.id === active.id)?.status).toBe("active");
const auditRow = await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const rows = (
await c.query("SELECT diff FROM audit_log WHERE action = 'invite_link.created' AND target_id = $1", [active.id])
).rows as Array<{ diff: Record<string, unknown> | null }>;
await c.query("COMMIT");
return rows[0]?.diff ?? null;
});
expect(auditRow).toEqual({ role: "Publisher", expiresAt: active.expiresAt, maxUses: null });
expect(JSON.stringify(auditRow)).not.toContain(active.token as string);
});
it("an authenticated (Bearer) claim joins with the existing identity; unknown roles are refused at create", async () => {
const links = new m.links.InviteLinkService();
const username = `bearer${suffix}`;
claimedUsernames.push(username);
// Register a separate workspace to prove the claim crosses tenants.
const auth = new m.auth.AuthService();
const reg = await auth.register({ workspaceName: "Bearer Home", username, password: "correct-horse-1", displayName: "B" });
const session = m.session.verifySession(reg.token);
if (session === null) throw new Error("no session");
const created = await links.create(workspaceId, actor, { role: "Approver", expiresInHours: 24 });
const result = await links.claim({ token: created.token as string, session });
expect(result.me.workspace.id).toBe(workspaceId);
expect(result.me.user.username).toBe(username);
expect(await membershipRole(session.userId)).toEqual({ role: created.role, status: "active" });
await expect(links.create(workspaceId, actor, { role: "NotARole", expiresInHours: 24 })).rejects.toMatchObject({ status: 400 });
await expect(auth.login({ username, password: "correct-horse-1" })).resolves.toMatchObject({ me: { workspace: { id: reg.me.workspace.id } } });
});
/** Resolve a claimed username to its user id via the owner pool (users has no RLS). */
async function userByExternalId(username: string): Promise<string> {
return asOwner(async (c) => {
const rows = (await c.query("SELECT id FROM users WHERE username = $1", [username])).rows as Array<{ id: string }>;
if (rows[0] === undefined) throw new Error(`user ${username} missing`);
return rows[0].id;
});
}
});

View file

@ -0,0 +1,407 @@
import { randomBytes } from "node:crypto";
import {
BadRequestException,
ConflictException,
GoneException,
Injectable,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import type { PoolClient } from "pg";
import { appendTenantAudit } from "../audit/audit.service.js";
import { issueSessionInTenant, localExternalId, type AuthResult, type MeResponse } from "../auth/auth.service.js";
import { MIN_PASSWORD_LENGTH, burnPasswordCheck, hashPassword, verifyPassword } from "../auth/password.js";
import { verifySession, type SessionPayload } from "../auth/session.js";
import { withSystem, withTenant } from "../db/client.js";
import { requireString, requireUsername } from "../http/validation.js";
import type { TeamActor } from "./team.service.js";
export type InviteLinkStatus = "active" | "expired" | "revoked" | "exhausted";
/**
* Admin-facing view of a link. `token`/`url` are returned by create ONLY —
* list/get never re-expose the secret, since anyone holding it can join.
*/
export interface InviteLink {
id: string;
role: string;
createdAt: string; // ISO
expiresAt: string; // ISO
maxUses: number | null;
useCount: number;
revokedAt: string | null;
status: InviteLinkStatus; // computed server-side, like MemberStatus
createdByUserId: string;
token?: string; // create response ONLY
url?: string; // create response ONLY: `${webOrigin}/join/<token>` — shown once, never in list/audit
}
export interface InviteLinkCreateInput {
role: string;
expiresInHours: number; // 1..720; server computes expires_at
maxUses?: number; // absent/null = unlimited until expiry
}
/** Public, pre-auth landing-page metadata for a token (GET /invites/:token/info). */
export interface InviteLinkInfo {
workspaceName: string;
role: string;
expiresAt: string;
maxUses: number | null;
usedCount: number;
}
export interface InviteClaimInput {
token: string;
/** Present when the caller brought a valid Bearer session (verified manually — this route has no guard). */
session?: SessionPayload | null;
/** Required when `session` is absent. */
username?: string;
password?: string;
displayName?: string;
email?: string;
}
const OWNER = "Owner";
interface InviteLinkRow {
id: string;
workspace_id: string;
role: string;
created_by: string;
created_at: Date;
expires_at: Date;
max_uses: number | null;
use_count: number;
revoked_at: Date | null;
}
interface LinkClaimRow {
id: string;
workspace_id: string;
role: string;
expires_at: Date;
max_uses: number | null;
use_count: number;
revoked_at: Date | null;
}
const LINK_COLUMNS = "id, workspace_id, role, created_by, created_at, expires_at, max_uses, use_count, revoked_at";
/** Same precedence as the claim guards: revoked beats expired beats exhausted. */
function statusOf(row: Pick<LinkClaimRow, "revoked_at" | "expires_at" | "max_uses" | "use_count">): InviteLinkStatus {
if (row.revoked_at !== null) return "revoked";
if (new Date(row.expires_at).getTime() <= Date.now()) return "expired";
if (row.max_uses !== null && row.use_count >= row.max_uses) return "exhausted";
return "active";
}
function toIso(value: Date): string {
return new Date(value).toISOString();
}
/** Web origin the invite URL points at; the SPA owns the /join/<token> landing page. */
function publicWebUrl(): string {
return (process.env.APP_URL ?? process.env.WEB_ORIGIN ?? "http://localhost:3001").replace(/\/+$/, "");
}
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && "code" in err && (err as { code?: string }).code === "23505";
}
@Injectable()
export class InviteLinkService {
async create(workspaceId: string, actor: TeamActor, input: InviteLinkCreateInput): Promise<InviteLink> {
if (input.expiresInHours < 1 || input.expiresInHours > 720) throw new BadRequestException("expiresInHours must be between 1 and 720");
if (input.maxUses !== undefined && (!Number.isInteger(input.maxUses) || input.maxUses < 1)) {
throw new BadRequestException("maxUses must be a positive integer");
}
if (input.role === OWNER) throw new BadRequestException("invite links cannot grant the Owner role — invite members and promote them in Team");
// 256 bits of entropy: tokens are unguessable and never stored hashed, so
// they are only ever shown once (create response) and never in list/audit.
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + input.expiresInHours * 3_600_000);
const row = await withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
await this.assertRoleExists(client, input.role);
const res = await client.query(
`INSERT INTO workspace_invite_links (workspace_id, role, token, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING ${LINK_COLUMNS}`,
[workspaceId, input.role, token, actor.userId, expiresAt, input.maxUses ?? null],
);
return res.rows[0] as InviteLinkRow;
});
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "invite_link.created",
targetType: "invite_link",
targetId: row.id,
diff: { role: row.role, expiresAt: toIso(row.expires_at), maxUses: row.max_uses }, // never the token
});
return { ...this.toLink(row), token, url: `${publicWebUrl()}/join/${token}` };
}
async list(workspaceId: string): Promise<InviteLink[]> {
const res = await withTenant(workspaceId, (client) =>
client.query(`SELECT ${LINK_COLUMNS} FROM workspace_invite_links WHERE workspace_id = $1 ORDER BY created_at DESC`, [workspaceId]),
);
// The token column is deliberately not selected: listing is admin UI and
// must never re-expose a live secret.
return (res.rows as InviteLinkRow[]).map((row) => this.toLink(row));
}
async revoke(workspaceId: string, actor: TeamActor, linkId: string): Promise<InviteLink> {
const outcome = await withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const updated = await client.query(
`UPDATE workspace_invite_links SET revoked_at = now()
WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL RETURNING ${LINK_COLUMNS}`,
[linkId, workspaceId],
);
const row = updated.rows[0] as InviteLinkRow | undefined;
if (row !== undefined) return { row, changed: true };
const existing = await client.query(
`SELECT ${LINK_COLUMNS} FROM workspace_invite_links WHERE id = $1 AND workspace_id = $2`,
[linkId, workspaceId],
);
const found = existing.rows[0] as InviteLinkRow | undefined;
if (found === undefined) throw new NotFoundException(`invite link ${linkId} not found`);
return { row: found, changed: false };
});
if (!outcome.changed) throw new ConflictException("invite link already revoked");
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "invite_link.revoked",
targetType: "invite_link",
targetId: linkId,
diff: { role: outcome.row.role, useCount: outcome.row.use_count }, // never the token
});
return this.toLink(outcome.row);
}
/**
* Pre-auth landing page data. `invite_link_for_claim` is the only way to see
* a link outside a tenant context; `workspaces` has no RLS, so the name join
* is safe under `withSystem`. Projection is name/role/counters only.
*/
async info(token: string): Promise<InviteLinkInfo> {
const res = await withSystem((client) =>
client.query(
`SELECT l.role, l.expires_at, l.max_uses, l.use_count, l.revoked_at, w.name AS workspace_name
FROM invite_link_for_claim($1) l JOIN workspaces w ON w.id = l.workspace_id`,
[token],
),
);
const row = res.rows[0] as
| { role: string; expires_at: Date; max_uses: number | null; use_count: number; revoked_at: Date | null; workspace_name: string }
| undefined;
if (row === undefined) throw new NotFoundException("invite link is invalid or has expired");
const status = statusOf(row);
if (status === "revoked") throw new GoneException("invite link has been revoked");
if (status === "expired") throw new GoneException("invite link has expired");
if (status === "exhausted") throw new GoneException("invite link has no uses left");
return {
workspaceName: row.workspace_name,
role: row.role,
expiresAt: toIso(row.expires_at),
maxUses: row.max_uses,
usedCount: row.use_count,
};
}
/**
* Register-or-sign-in + join in one system transaction, returning the same
* `AuthResult` shape as POST /auth/login so the SPA reuses its auth handler.
*
* Everything runs in ONE `withSystem` transaction (register's pattern): the
* claimer has no tenant context yet, so the link is resolved through the
* narrow SECURITY DEFINER lookup, then `set_config('app.workspace_id', …, true)`
* enters the target tenant for the RLS-bound membership insert, the atomic
* use consumption, and the session mint (which reads the post-bump epoch).
*/
async claim(input: InviteClaimInput): Promise<AuthResult> {
const token = requireString(input.token, "token");
const outcome = await withSystem(async (client) => {
// 1. Link resolution through the SECURITY DEFINER lookup (no tenant context exists yet).
const linkRes = await client.query(
"SELECT id, workspace_id, role, expires_at, max_uses, use_count, revoked_at FROM invite_link_for_claim($1)",
[token],
);
const link = linkRes.rows[0] as LinkClaimRow | undefined;
if (link === undefined) throw new NotFoundException("invite link is invalid or has expired");
// 2. Validation (410 Gone for dead-but-real links) — BEFORE any credential
// work, so a made-up token only ever sees 404 and usernames stay unenumerable.
if (link.revoked_at !== null) throw new GoneException("invite link has been revoked");
if (new Date(link.expires_at).getTime() <= Date.now()) throw new GoneException("invite link has expired");
if (link.max_uses !== null && link.use_count >= link.max_uses) throw new GoneException("invite link has no uses left");
await this.assertRoleAlive(client, link.role);
// 3. Identity resolution (only when no Bearer session was presented).
let userId: string;
let username: string | null = null;
const session = input.session ?? undefined;
if (session !== undefined) {
userId = session.userId;
} else {
const name = requireUsername(input.username, "username");
const password = requireString(input.password, "password");
username = name;
const found = await client.query(
`SELECT u.id, p.password_hash FROM users u
LEFT JOIN user_passwords p ON p.user_id = u.id
WHERE u.external_id = $1`,
[localExternalId(name)],
);
const row = found.rows[0] as { id: string; password_hash: string | null } | undefined;
if (row === undefined) {
// Register. Same anti-hijack rule as AuthService.register: an existing
// identity (including a password-less invited placeholder) can never
// be claimed by registering — see the branches below.
if (password.length < MIN_PASSWORD_LENGTH) {
throw new BadRequestException(`password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const passwordHash = await hashPassword(password);
const displayName = (input.displayName ?? name).trim().slice(0, 100);
let created: { rows: Array<{ id: string }> };
try {
created = await client.query(
`INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4)
ON CONFLICT (external_id) DO NOTHING
RETURNING id`,
[localExternalId(name), name, input.email ?? null, displayName],
);
} catch (err) {
// Username collides with a differently-keyed account (e.g. an email
// placeholder that already took this username): surface 409, not a 500.
if (isUniqueViolation(err)) throw new ConflictException("an account with this username already exists — sign in instead");
throw err;
}
const id = created.rows[0]?.id as string | undefined;
if (id === undefined) throw new ConflictException("an account with this username already exists — sign in instead");
userId = id;
await client.query("INSERT INTO user_passwords (user_id, password_hash) VALUES ($1, $2)", [userId, passwordHash]);
} else if (row.password_hash === null) {
// Password-less invited placeholder: burning keeps claim timing flat.
await burnPasswordCheck(password);
throw new ConflictException("an account with this username already exists — sign in instead");
} else if (!(await verifyPassword(password, row.password_hash))) {
throw new UnauthorizedException("invalid email or password");
} else {
userId = row.id;
}
}
// 4. Tenant entry: transaction-local, cannot leak past COMMIT/ROLLBACK.
await client.query("SELECT set_config('app.workspace_id', $1, true)", [link.workspace_id]);
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [link.workspace_id]);
// 5. Membership insert. Conflict (any existing row, including an 'invited'
// placeholder) => 409 and NO use consumed: a link must never be a
// privilege-upgrade vector for an existing membership.
const inserted = await client.query(
`INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'active')
ON CONFLICT (workspace_id, user_id) DO NOTHING
RETURNING id`,
[link.workspace_id, userId, link.role],
);
const membershipId = inserted.rows[0]?.id as string | undefined;
if (membershipId === undefined) {
throw new ConflictException("you are already a member of this workspace — sign in instead");
}
// 6. Atomic consumption: one conditional statement, no SELECT-then-check
// TOCTOU. EvalPlanQual re-evaluates the guard on the locked row, so
// concurrent claimers cannot both spend the last use.
const consumed = await client.query(
`UPDATE workspace_invite_links SET use_count = use_count + 1
WHERE id = $1 AND revoked_at IS NULL AND expires_at > now()
AND (max_uses IS NULL OR use_count < max_uses)`,
[link.id],
);
if ((consumed.rowCount ?? 0) === 0) throw new GoneException("invite link is no longer active");
// 7. Session mint inside the same tx, reading the post-bump epoch and the
// fresh membership row (the membership insert bumped the workspace epoch).
const sessionToken = await issueSessionInTenant(client, link.workspace_id, userId);
return {
workspaceId: link.workspace_id,
userId,
membershipId,
linkId: link.id,
role: link.role,
token: sessionToken,
username,
};
});
// 8. After commit (appendTenantAudit opens its own withTenant).
const me = await this.meFor(outcome.workspaceId, outcome.userId, outcome.token);
await appendTenantAudit(outcome.workspaceId, {
actorUserId: outcome.userId,
actorRole: outcome.role,
action: "membership.joined",
targetType: "membership",
targetId: outcome.membershipId,
diff: { linkId: outcome.linkId, role: outcome.role, username: outcome.username ?? me.user.username }, // never the token
});
return { token: outcome.token, me };
}
private toLink(row: InviteLinkRow): InviteLink {
return {
id: row.id,
role: row.role,
createdAt: toIso(row.created_at),
expiresAt: toIso(row.expires_at),
maxUses: row.max_uses,
useCount: row.use_count,
revokedAt: row.revoked_at === null ? null : toIso(row.revoked_at),
status: statusOf(row),
createdByUserId: row.created_by,
};
}
/** AuthService.meFor's projection (it is private there), reused for the claim response. */
private async meFor(workspaceId: string, userId: string, token: string): Promise<MeResponse> {
return withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const res = await client.query(
`SELECT m.role, u.username, u.email, u.display_name, w.name, w.slug
FROM memberships m
JOIN users u ON u.id = m.user_id
JOIN workspaces w ON w.id = m.workspace_id
WHERE m.workspace_id = $1 AND m.user_id = $2 AND m.status = 'active'`,
[workspaceId, userId],
);
const row = res.rows[0] as
| { role: string; username: string; email: string | null; display_name: string; name: string; slug: string }
| undefined;
if (row === undefined) throw new UnauthorizedException("membership no longer active");
const perms = await client.query("SELECT permission FROM role_permissions WHERE role = $1 ORDER BY permission", [row.role]);
return {
user: { id: userId, username: row.username, email: row.email, displayName: row.display_name },
workspace: { id: workspaceId, name: row.name, slug: row.slug },
role: row.role,
permissions: (perms.rows as Array<{ permission: string }>).map((p) => p.permission),
expiresAt: verifySession(token)?.expiresAt ?? 0,
};
});
}
private async assertRoleExists(client: PoolClient, role: string): Promise<void> {
const res = await client.query("SELECT 1 FROM role_permissions WHERE role = $1 LIMIT 1", [role]);
if (res.rowCount === 0) throw new BadRequestException(`unknown role: ${role}`);
}
/** Claim-time role check: a removed role means the link is dead, not a bad request. */
private async assertRoleAlive(client: PoolClient, role: string): Promise<void> {
const res = await client.query("SELECT 1 FROM role_permissions WHERE role = $1 LIMIT 1", [role]);
if (res.rowCount === 0) throw new GoneException("invite link references a removed role");
}
}

View file

@ -0,0 +1,61 @@
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { parseId, requireEmail, requireObject, requireSession, requireString } from "../http/validation.js";
import { TeamService, type Member, type MemberMutationResult } from "./team.service.js";
@Controller("team")
@UseGuards(RbacGuard)
export class TeamController {
constructor(@Inject(TeamService) private readonly team: TeamService) {}
/** Every role holds workspace.view, so any member can see who else is in the workspace. */
@Get("members")
@RequirePermission("workspace.view")
async list(@Req() request: Request & AuthenticatedRequest): Promise<Member[]> {
return this.team.list(requireSession(request).workspaceId);
}
@Get("roles")
@RequirePermission("workspace.view")
async roles(): Promise<string[]> {
return this.team.roles();
}
@Post("members/invite")
@RequirePermission("workspace.members.manage")
async invite(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<MemberMutationResult> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.team.invite(
session.workspaceId,
{ userId: session.userId, role: session.role },
requireEmail(dto.email),
requireString(dto.role, "role"),
);
}
@Patch("members/:id/role")
@RequirePermission("workspace.members.manage")
async changeRole(
@Req() request: Request & AuthenticatedRequest,
@Param("id") id: string,
@Body() body: unknown,
): Promise<MemberMutationResult> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.team.changeRole(
session.workspaceId,
{ userId: session.userId, role: session.role },
parseId(id),
requireString(dto.role, "role"),
);
}
@Delete("members/:id")
@RequirePermission("workspace.members.manage")
async remove(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string): Promise<MemberMutationResult> {
const session = requireSession(request);
return this.team.remove(session.workspaceId, { userId: session.userId, role: session.role }, parseId(id));
}
}

View file

@ -0,0 +1,214 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { randomBytes } from "node:crypto";
import { appendTenantAudit } from "../audit/audit.service.js";
import { issueSessionInTenant, localExternalId } from "../auth/auth.service.js";
import { pool, withTenant } from "../db/client.js";
export type MemberStatus = "active" | "invited";
export interface Member {
id: string;
userId: string;
username: string;
email: string | null;
displayName: string;
role: string;
status: MemberStatus;
createdAt: string;
}
/**
* Every membership mutation bumps the workspace authz epoch (DB trigger), which
* invalidates ALL live sessions — including the caller's. Mutations therefore
* return a fresh token for the caller, minted inside the same transaction from
* the post-change membership row, so the acting admin is not signed out.
*/
export interface MemberMutationResult {
member: Member | null;
token: string | null;
}
export interface TeamActor {
userId: string;
role: string;
}
const OWNER = "Owner";
interface MemberRow {
id: string;
user_id: string;
username: string;
email: string | null;
display_name: string;
role: string;
status: MemberStatus;
created_at: Date;
}
function memberFromRow(row: MemberRow): Member {
return {
id: row.id,
userId: row.user_id,
username: row.username,
email: row.email,
displayName: row.display_name,
role: row.role,
status: row.status,
createdAt: new Date(row.created_at).toISOString(),
};
}
const MEMBER_SELECT = `SELECT m.id, m.user_id, u.username, u.email, u.display_name, m.role, m.status, m.created_at
FROM memberships m JOIN users u ON u.id = m.user_id`;
/** Placeholder username derived from the invite email's local-part, with a uniqueness fallback. */
function usernameFromEmail(email: string): string {
return (email.split("@")[0] ?? "member").toLowerCase().replace(/[^a-z0-9_.-]/g, "").slice(0, 32) || "member";
}
@Injectable()
export class TeamService {
async list(workspaceId: string): Promise<Member[]> {
const res = await withTenant(workspaceId, (client) => client.query(`${MEMBER_SELECT} ORDER BY m.created_at ASC`));
return (res.rows as MemberRow[]).map(memberFromRow);
}
async roles(): Promise<string[]> {
// role_permissions is global reference data (no RLS).
const res = await pool.query("SELECT DISTINCT role FROM role_permissions ORDER BY role");
return (res.rows as Array<{ role: string }>).map((r) => r.role);
}
async invite(workspaceId: string, actor: TeamActor, email: string, role: string): Promise<MemberMutationResult> {
const result = await withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
await this.assertRoleExists(client, role);
const actorRole = await this.actorRole(client, workspaceId, actor.userId);
if (role === OWNER && actorRole !== OWNER) throw new ForbiddenException("only an Owner can invite another Owner");
const existing = await client.query("SELECT id FROM users WHERE lower(email) = $1 ORDER BY created_at ASC LIMIT 1", [email]);
let userId = existing.rows[0]?.id as string | undefined;
if (userId === undefined) {
const base = usernameFromEmail(email);
const displayName = email.split("@")[0] ?? email;
for (let attempt = 0; userId === undefined && attempt < 3; attempt += 1) {
const username = attempt === 0 ? base : `${base}_${randomBytes(2).toString("hex")}`;
const created = await client.query(
`INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO NOTHING
RETURNING id`,
[localExternalId(email), username, email, displayName],
);
userId = created.rows[0]?.id as string | undefined;
}
if (userId === undefined) throw new ConflictException("could not derive a username for this invite");
}
const inserted = await client.query(
`INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'invited')
ON CONFLICT (workspace_id, user_id) DO NOTHING RETURNING id`,
[workspaceId, userId, role],
);
const membershipId = inserted.rows[0]?.id as string | undefined;
if (membershipId === undefined) throw new ConflictException(`${email} is already a member of this workspace`);
return {
member: await this.memberById(client, membershipId),
token: await issueSessionInTenant(client, workspaceId, actor.userId),
};
});
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "membership.invited",
targetType: "membership",
targetId: result.member.id,
diff: { email, role },
});
return result;
}
async changeRole(workspaceId: string, actor: TeamActor, membershipId: string, role: string): Promise<MemberMutationResult> {
const result = await withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
await this.assertRoleExists(client, role);
const target = await this.memberById(client, membershipId, true);
if (target.role === role) return { member: target, token: null, previous: role };
const actorRole = await this.actorRole(client, workspaceId, actor.userId);
if ((role === OWNER || target.role === OWNER) && actorRole !== OWNER) {
throw new ForbiddenException("only an Owner can grant or revoke the Owner role");
}
if (target.role === OWNER && target.status === "active") await this.assertNotLastOwner(client);
await client.query("UPDATE memberships SET role = $2 WHERE id = $1", [membershipId, role]);
return {
member: await this.memberById(client, membershipId),
token: await issueSessionInTenant(client, workspaceId, actor.userId),
previous: target.role,
};
});
if (result.previous !== role) {
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "membership.role_changed",
targetType: "membership",
targetId: membershipId,
diff: { email: result.member.email, role: [result.previous, role] },
});
}
return { member: result.member, token: result.token };
}
async remove(workspaceId: string, actor: TeamActor, membershipId: string): Promise<MemberMutationResult> {
const removed = await withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const target = await this.memberById(client, membershipId, true);
const actorRole = await this.actorRole(client, workspaceId, actor.userId);
if (target.role === OWNER) {
if (actorRole !== OWNER) throw new ForbiddenException("only an Owner can remove an Owner");
if (target.status === "active") await this.assertNotLastOwner(client);
}
await client.query("DELETE FROM memberships WHERE id = $1", [membershipId]);
// Removing yourself ends your access: no replacement token.
const token = target.userId === actor.userId ? null : await issueSessionInTenant(client, workspaceId, actor.userId);
return { target, token };
});
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "membership.removed",
targetType: "membership",
targetId: membershipId,
diff: { email: removed.target.email, role: removed.target.role },
});
return { member: null, token: removed.token };
}
private async memberById(client: PoolClient, membershipId: string, lock = false): Promise<Member> {
const res = await client.query(`${MEMBER_SELECT} WHERE m.id = $1${lock ? " FOR UPDATE OF m" : ""}`, [membershipId]);
const row = res.rows[0] as MemberRow | undefined;
if (row === undefined) throw new NotFoundException(`member ${membershipId} not found`);
return memberFromRow(row);
}
private async actorRole(client: PoolClient, workspaceId: string, userId: string): Promise<string> {
const res = await client.query(
"SELECT role FROM memberships WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'",
[workspaceId, userId],
);
const role = res.rows[0]?.role as string | undefined;
if (role === undefined) throw new ForbiddenException("no active membership in this workspace");
return role;
}
private async assertRoleExists(client: PoolClient, role: string): Promise<void> {
const res = await client.query("SELECT 1 FROM role_permissions WHERE role = $1 LIMIT 1", [role]);
if (res.rowCount === 0) throw new BadRequestException(`unknown role: ${role}`);
}
private async assertNotLastOwner(client: PoolClient): Promise<void> {
// Lock every Owner row so two concurrent demotions cannot both pass the check.
const res = await client.query(`SELECT id FROM memberships WHERE role = $1 AND status = 'active' FOR UPDATE`, [OWNER]);
if ((res.rowCount ?? 0) <= 1) throw new BadRequestException("a workspace must keep at least one active Owner");
}
}