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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
95
apps/api/src/team/invite-link.controller.ts
Normal file
95
apps/api/src/team/invite-link.controller.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
275
apps/api/src/team/invite-link.integration.test.ts
Normal file
275
apps/api/src/team/invite-link.integration.test.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
407
apps/api/src/team/invite-link.service.ts
Normal file
407
apps/api/src/team/invite-link.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
61
apps/api/src/team/team.controller.ts
Normal file
61
apps/api/src/team/team.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
214
apps/api/src/team/team.service.ts
Normal file
214
apps/api/src/team/team.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue