diff --git a/package.json b/package.json index 0153e6276..f2967e178 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "test": "vitest run", "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY --allow-net=127.0.0.1 supabase/functions/", - "test:db": "supabase test db && bash scripts/test-daily-quota.sh", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_BASE_URL --allow-net=127.0.0.1 supabase/functions/", + "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", "test:e2e:staging": "playwright test --project=staging", diff --git a/scripts/test-workspace-invite-concurrency.sh b/scripts/test-workspace-invite-concurrency.sh new file mode 100755 index 000000000..ff1195604 --- /dev/null +++ b/scripts/test-workspace-invite-concurrency.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +database_url="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" +database_password="${SUPABASE_DB_PASSWORD:-postgres}" +workspace_id="74444444-4444-4444-8444-444444444444" +owner_id="71111111-1111-4111-8111-111111111111" +first_user_id="72222222-2222-4222-8222-222222222222" +second_user_id="73333333-3333-4333-8333-333333333333" +result_dir="$(mktemp -d)" +first_pid="" +second_pid="" + +cleanup() { + touch "$result_dir/release-first" + if [ -n "$first_pid" ]; then wait "$first_pid" 2>/dev/null || true; fi + if [ -n "$second_pid" ]; then wait "$second_pid" 2>/dev/null || true; fi + PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 </dev/null +DELETE FROM public.workspaces WHERE id = '$workspace_id'; +DELETE FROM auth.users WHERE id IN ('$owner_id', '$first_user_id', '$second_user_id'); +SQL + rm -rf "$result_dir" +} +trap cleanup EXIT + +PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 </dev/null +DELETE FROM public.workspaces WHERE id = '$workspace_id'; +DELETE FROM auth.users WHERE id IN ('$owner_id', '$first_user_id', '$second_user_id'); +INSERT INTO auth.users (id) VALUES ('$owner_id'), ('$first_user_id'), ('$second_user_id'); +INSERT INTO public.workspaces (id, owner_id, motion, initial_language, create_request_id) +VALUES ( + '$workspace_id', '$owner_id', 'Concurrent invite race', 'en', + '76666666-6666-4666-8666-666666666666' +); +INSERT INTO public.workspace_members (workspace_id, user_id) +VALUES ('$workspace_id', '$owner_id'); +INSERT INTO private.workspace_invites ( + id, workspace_id, kind, secret_digest, issued_by, expires_at, max_uses +) VALUES ( + '75555555-5555-4555-8555-555555555555', '$workspace_id', 'code', + decode(repeat('cd', 32), 'hex'), '$owner_id', now() + interval '48 hours', 1 +); +SQL + +redeem_first() { + PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 <"$result_dir/first" & +first_pid=$! + +for _ in {1..100}; do + if [ -e "$result_dir/first-holds-lock" ]; then break; fi + if ! kill -0 "$first_pid" 2>/dev/null; then wait "$first_pid"; fi + sleep 0.05 +done +if [ ! -e "$result_dir/first-holds-lock" ]; then + echo "first invite redemption did not acquire its locks" + exit 1 +fi + +redeem_second >"$result_dir/second" & +second_pid=$! + +second_is_waiting=false +for _ in {1..100}; do + if [ "$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \ + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid <> pg_backend_pid() AND wait_event_type = 'Lock' AND query LIKE '%workspace_invite_concurrency_second%')")" = "t" ]; then + second_is_waiting=true + break + fi + if ! kill -0 "$second_pid" 2>/dev/null; then break; fi + sleep 0.05 +done +if [ "$second_is_waiting" != true ]; then + echo "second invite redemption did not wait on the first transaction" + exit 1 +fi + +touch "$result_dir/release-first" +first_result=0 +second_result=0 +wait "$first_pid" || first_result=$? +wait "$second_pid" || second_result=$? +if [ "$first_result" -ne 0 ] || [ "$second_result" -ne 0 ]; then exit 1; fi + +actual_results="$(grep -hE '^(accepted|unavailable)\|' "$result_dir/first" "$result_dir/second" | sort)" +expected_results=$'accepted|74444444-4444-4444-8444-444444444444|f\nunavailable||f' +if [ "$actual_results" != "$expected_results" ]; then + echo "unexpected concurrent invite results:" + echo "$actual_results" + exit 1 +fi + +stored_state="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \ + "SELECT i.use_count, count(m.user_id) FROM private.workspace_invites i LEFT JOIN public.workspace_members m ON m.workspace_id = i.workspace_id AND m.user_id IN ('$first_user_id', '$second_user_id') WHERE i.id = '75555555-5555-4555-8555-555555555555' GROUP BY i.use_count")" +if [ "$stored_state" != "1|1" ]; then + echo "expected one invite use and one admitted member, got: $stored_state" + exit 1 +fi + +echo "Concurrent last-use redemption: 1 accepted, 1 rejected, stored use count 1" diff --git a/supabase/README.md b/supabase/README.md index 59e34b51a..ff9206687 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -39,6 +39,7 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh - `import-source`: authenticated public-URL import with SSRF protection on redirects, timeout, content-type and size limits, readable-text extraction, quota checks, and logging. - `share`: authenticated creation of expiring share codes for supported saved content. +- `workspace-invites`: authenticated workspace code and email invitation creation, rotation, revocation, and redemption. The handler keeps raw invite values and email addresses out of database calls and uses service-role-only transactional RPCs for limits and membership changes. - `export-gdoc`: authenticated Google Docs export using a short-lived Google access token. It rejects refresh tokens. ### Authentication and administration @@ -135,12 +136,16 @@ Document names, never values. Depending on deployed features, server-side config - research provider keys; - Stripe secret, webhook secret, and price configuration; - demo-code hashing material; +- `WORKSPACE_INVITE_SECRET` for invite derivation, keyed hashes, and target-email encryption; +- `WORKSPACE_INVITE_MAILER_URL`, `WORKSPACE_INVITE_MAILER_TOKEN`, and `WORKSPACE_INVITE_BASE_URL` for the operator-supplied email relay; - sparring integrity material; - Google export configuration; - allowed origins and environment-specific application URLs. Use `supabase secrets` or protected deployment configuration for server values. Never place server secrets in `VITE_*`, source files, docs, screenshots, traces, logs, or comments. +The invitation handler does not select an email provider. Its mail boundary sends `{ to, inviteUrl, idempotencyKey }` to the configured relay. The relay must deduplicate repeated acceptance of the same key, which stays stable across pending-delivery retries. The handler marks delivery only after relay acceptance and suppresses later sends only after that mark commits. Email creation fails before storing an invite when the three mailer values are absent. Local tests inject an in-memory sender and make no external mail request. + Public browser configuration in [`../src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) is not a replacement for server authorization. ## Local, staging, and production diff --git a/supabase/config.toml b/supabase/config.toml index 562c86101..d21b5bbaf 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -33,6 +33,9 @@ verify_jwt = false [functions.import-source] verify_jwt = false +[functions.workspace-invites] +verify_jwt = false + # Stripe webhook calls come from Stripe (no Supabase JWT), so gateway JWT # verification must be disabled for this endpoint. [functions.stripe-webhook] diff --git a/supabase/functions/workspace-invites/index.ts b/supabase/functions/workspace-invites/index.ts new file mode 100644 index 000000000..146f42881 --- /dev/null +++ b/supabase/functions/workspace-invites/index.ts @@ -0,0 +1,424 @@ +import { HttpError, requireUser } from "../_shared/auth.ts"; + +const ALLOWED_ORIGINS = [ + "https://debate.pi-stack.org", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://localhost:18080", + "http://127.0.0.1:18080", + "http://localhost:5173", + "http://127.0.0.1:54321", + "capacitor://localhost", +]; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export type InviteSender = ( + message: { to: string; inviteUrl: string; idempotencyKey: string }, +) => Promise; + +type Dependencies = { + sender: InviteSender; + baseUrl?: string; +}; + +function corsHeaders(origin: string | null) { + return { + "Access-Control-Allow-Origin": origin && ALLOWED_ORIGINS.includes(origin) + ? origin + : ALLOWED_ORIGINS[0], + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }; +} + +function json(body: unknown, status: number, headers: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { ...headers, "Content-Type": "application/json" }, + }); +} + +function requiredString(value: unknown, name: string) { + if (typeof value !== "string" || !value.trim()) { + throw new HttpError(400, `Invalid ${name}`); + } + return value.trim(); +} + +function requiredUuid(value: unknown, name: string) { + const text = requiredString(value, name); + if (!UUID.test(text)) throw new HttpError(400, `Invalid ${name}`); + return text; +} + +function normalizeEmail(value: unknown) { + const email = requiredString(value, "email").toLowerCase(); + if (email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { + throw new HttpError(400, "Invalid email"); + } + return email; +} + +function normalizeCode(value: string) { + const compact = value.toUpperCase().replace(/[^A-Z2-7]/g, ""); + if (!compact.startsWith("WS") || compact.length !== 22) { + throw new HttpError(400, "Invalid invitation"); + } + return `WS-${compact.slice(2).match(/.{4}/g)!.join("-")}`; +} + +function secretKey() { + const value = Deno.env.get("WORKSPACE_INVITE_SECRET"); + if (!value || value.length < 32) { + throw new Error("Workspace invitations are not configured"); + } + return value; +} + +async function hmac(label: string, value: string) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secretKey()), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array( + await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(`${label}:${value}`), + ), + ); +} + +function hex(bytes: Uint8Array) { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function base64Url(bytes: Uint8Array) { + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll( + "/", + "_", + ).replace(/=+$/, ""); +} + +function base32(bytes: Uint8Array) { + let bits = 0; + let value = 0; + let output = ""; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits) output += BASE32[(value << (5 - bits)) & 31]; + return output; +} + +async function inviteSecret( + kind: "code" | "email", + actorId: string, + requestId: string, +) { + const material = await hmac("invite", `${kind}:${actorId}:${requestId}`); + if (kind === "email") return `wi_${base64Url(material.slice(0, 24))}`; + const code = base32(material).slice(0, 20).match(/.{4}/g)!.join("-"); + return `WS-${code}`; +} + +async function digest(value: string) { + return `\\x${hex(await hmac("digest", value))}`; +} + +async function keyedHash(label: "email" | "ip", value: string) { + return `\\x${hex(await hmac(label, value))}`; +} + +async function encryptEmail(email: string) { + const keyBytes = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`workspace-invite-email:${secretKey()}`), + ); + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [ + "encrypt", + ]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(email), + ), + ); + return `v1.${base64Url(iv)}.${base64Url(ciphertext)}`; +} + +function trustedProxyIp(req: Request) { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) { + const values = forwarded.split(",").map((value) => value.trim()).filter( + Boolean, + ); + if (values.length) return values[values.length - 1]; + } + return req.headers.get("x-real-ip")?.trim() || null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function firstRecord(data: unknown) { + const value = Array.isArray(data) ? data[0] : data; + return isRecord(value) ? value : null; +} + +function mapRpcError(error: { message?: string } | null) { + const message = error?.message || ""; + if (message.includes("operation ID conflict")) { + throw new HttpError(409, "Request ID conflict"); + } + if (message.includes("workspace unavailable")) { + throw new HttpError(404, "Workspace unavailable"); + } + if (message.includes("invite rate limited")) { + throw new HttpError(429, "Too many invitations. Try again later."); + } + if (message.includes("invite capacity exceeded")) { + throw new HttpError(409, "Workspace member limit reached"); + } + if (message.includes("invalid invite request")) { + throw new HttpError(400, "Invalid invitation request"); + } + throw new Error("Invitation database operation failed"); +} + +function configuredSender(): { sender: InviteSender; baseUrl: string } { + const endpoint = Deno.env.get("WORKSPACE_INVITE_MAILER_URL"); + const token = Deno.env.get("WORKSPACE_INVITE_MAILER_TOKEN"); + const baseUrl = Deno.env.get("WORKSPACE_INVITE_BASE_URL"); + if (!endpoint || !token || !baseUrl) { + throw new HttpError(503, "Invitation email is unavailable"); + } + + return { + baseUrl, + sender: async (message) => { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + if (!response.ok) throw new Error("Invitation email delivery failed"); + }, + }; +} + +export async function handleRequest(req: Request, dependencies?: Dependencies) { + const cors = corsHeaders(req.headers.get("origin")); + if (req.method === "OPTIONS") return new Response("ok", { headers: cors }); + if (req.method !== "POST") { + return json({ error: "Method not allowed" }, 405, cors); + } + + try { + const auth = await requireUser(req); + const parsedBody: unknown = await req.json().catch(() => null); + if (!isRecord(parsedBody) || typeof parsedBody.action !== "string") { + throw new HttpError(400, "Invalid invitation request"); + } + const body = parsedBody; + + const action = body.action; + const requestId = requiredUuid(body.requestId, "request ID"); + + if (action === "create_code" || action === "rotate_code") { + const workspaceId = requiredUuid(body.workspaceId, "workspace ID"); + const rotateInviteId = action === "rotate_code" + ? requiredUuid(body.inviteId, "invite ID") + : null; + const maxUses = body.maxUses === undefined ? null : Number(body.maxUses); + if ( + maxUses !== null && + (!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 30) + ) { + throw new HttpError(400, "Invalid max uses"); + } + const code = await inviteSecret("code", auth.user.id, requestId); + const { data, error } = await auth.adminClient.rpc( + "create_workspace_invite", + { + p_actor_id: auth.user.id, + p_workspace_id: workspaceId, + p_kind: "code", + p_secret_digest: await digest(code), + p_target_email_ciphertext: null, + p_target_email_hash: null, + p_max_uses: maxUses, + p_request_id: requestId, + p_rotate_invite_id: rotateInviteId, + }, + ); + if (error) mapRpcError(error); + const result = firstRecord(data); + if (!result || result.revoked === true) { + throw new HttpError(409, "Invitation is no longer available"); + } + return json( + { + inviteId: result.invite_id, + code, + expiresAt: result.expires_at, + maxUses: result.max_uses, + }, + 201, + cors, + ); + } + + if (action === "revoke") { + const { data, error } = await auth.adminClient.rpc( + "revoke_workspace_invite", + { + p_actor_id: auth.user.id, + p_workspace_id: requiredUuid(body.workspaceId, "workspace ID"), + p_invite_id: requiredUuid(body.inviteId, "invite ID"), + p_request_id: requestId, + }, + ); + if (error) mapRpcError(error); + const result = firstRecord(data); + if (!result?.revoked) { + throw new Error("Invitation revoke returned no result"); + } + return json({ revoked: true }, 200, cors); + } + + if (action === "create_email") { + const delivery = dependencies ?? configuredSender(); + const workspaceId = requiredUuid(body.workspaceId, "workspace ID"); + const email = normalizeEmail(body.email); + const token = await inviteSecret("email", auth.user.id, requestId); + const { data, error } = await auth.adminClient.rpc( + "create_workspace_invite", + { + p_actor_id: auth.user.id, + p_workspace_id: workspaceId, + p_kind: "email", + p_secret_digest: await digest(token), + p_target_email_ciphertext: await encryptEmail(email), + p_target_email_hash: await keyedHash("email", email), + p_max_uses: 1, + p_request_id: requestId, + p_rotate_invite_id: null, + }, + ); + if (error) mapRpcError(error); + const result = firstRecord(data); + if (!result || result.revoked === true) { + throw new HttpError(503, "Invitation email is unavailable"); + } + + if (result.email_delivered !== true) { + const expiresAt = typeof result.expires_at === "string" + ? Date.parse(result.expires_at) + : NaN; + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + throw new HttpError(503, "Invitation email is unavailable"); + } + const baseUrl = (delivery.baseUrl || "https://debate.pi-stack.org") + .replace(/\/$/, ""); + try { + await delivery.sender({ + to: email, + inviteUrl: `${baseUrl}/app/workspaces/invite?token=${ + encodeURIComponent(token) + }`, + idempotencyKey: `workspace-invite:${result.invite_id}`, + }); + } catch { + throw new HttpError(503, "Invitation email is unavailable"); + } + + const { data: delivered, error: deliveryError } = await auth.adminClient + .rpc("mark_workspace_invite_email_delivered", { + p_actor_id: auth.user.id, + p_invite_id: result.invite_id, + }); + if (deliveryError || delivered !== true) { + throw new Error("Invitation delivery state update failed"); + } + } + return json({ accepted: true }, 202, cors); + } + + if (action === "redeem") { + const supplied = requiredString(body.code ?? body.token, "invitation"); + const secret = body.code !== undefined + ? normalizeCode(supplied) + : supplied; + if (body.code === undefined && !/^wi_[A-Za-z0-9_-]{32}$/.test(secret)) { + throw new HttpError(400, "Invalid invitation"); + } + const emailHash = auth.user.email && auth.user.email_confirmed_at + ? await keyedHash("email", auth.user.email.trim().toLowerCase()) + : null; + const ip = trustedProxyIp(req); + const { data, error } = await auth.adminClient.rpc( + "redeem_workspace_invite", + { + p_actor_id: auth.user.id, + p_secret_digest: await digest(secret), + p_actor_email_hash: emailHash, + p_ip_hash: ip ? await keyedHash("ip", ip) : null, + p_request_id: requestId, + }, + ); + if (error) mapRpcError(error); + const result = firstRecord(data); + if (!result) throw new Error("Invitation redemption returned no result"); + if (result.status === "rate_limited") { + return json( + { error: "Too many attempts. Try again later." }, + 429, + cors, + ); + } + if (result.status !== "accepted") { + return json({ error: "Invitation unavailable" }, 403, cors); + } + return json( + { + workspaceId: result.workspace_id, + member: true, + alreadyMember: result.already_member, + }, + 200, + cors, + ); + } + + throw new HttpError(400, "Invalid invitation action"); + } catch (error) { + if (error instanceof HttpError) { + return json({ error: error.message }, error.status, cors); + } + return json( + { error: "Invitations are temporarily unavailable" }, + 500, + cors, + ); + } +} + +if (import.meta.main) Deno.serve((req) => handleRequest(req)); diff --git a/supabase/functions/workspace-invites/index_test.ts b/supabase/functions/workspace-invites/index_test.ts new file mode 100644 index 000000000..f21254689 --- /dev/null +++ b/supabase/functions/workspace-invites/index_test.ts @@ -0,0 +1,634 @@ +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +for ( + const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_ANON_KEY: "test-anon-key", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + WORKSPACE_INVITE_SECRET: "test-workspace-invite-secret-with-enough-entropy", + }) +) Deno.env.set(key, value); +for ( + const key of [ + "WORKSPACE_INVITE_MAILER_URL", + "WORKSPACE_INVITE_MAILER_TOKEN", + "WORKSPACE_INVITE_BASE_URL", + ] +) Deno.env.delete(key); + +type TestUser = { + id: string; + email: string; + email_confirmed_at?: string; + app_metadata: Record; + user_metadata: Record; + aud: string; + created_at: string; +}; + +const actor: TestUser = { + id: "11111111-1111-4111-8111-111111111111", + email: "student@example.com", + email_confirmed_at: "2026-09-20T12:00:00.000Z", + app_metadata: {}, + user_metadata: { email: "forged@example.com" }, + aud: "authenticated", + created_at: "2026-09-20T12:00:00.000Z", +}; + +const workspaceId = "22222222-2222-4222-8222-222222222222"; +const inviteId = "33333333-3333-4333-8333-333333333333"; +const requestId = "44444444-4444-4444-8444-444444444444"; + +type RpcResult = Record | Record[] | boolean; + +type SentMessage = { + to: string; + inviteUrl: string; + idempotencyKey?: string; +}; + +type Scenario = { + rpc?: Record; + user?: TestUser; +}; + +async function runScenario( + body: Record, + scenario: Scenario = {}, + sender?: (message: SentMessage) => Promise, + baseUrl?: string, +) { + const calls: Array< + { body: Record | null; pathname: string } + > = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = + (async (input: Request | URL | string, init?: RequestInit) => { + const request = input instanceof Request + ? input + : new Request(input, init); + const url = new URL(request.url); + const text = request.method === "GET" ? "" : await request.clone().text(); + const requestBody = text + ? JSON.parse(text) as Record + : null; + calls.push({ body: requestBody, pathname: url.pathname }); + + if (url.pathname === "/auth/v1/user") { + return jsonResponse(scenario.user ?? actor); + } + + const rpcName = url.pathname.split("/rest/v1/rpc/")[1]; + if (rpcName) { + const result = scenario.rpc?.[rpcName]; + if ( + typeof result === "object" && result !== null && + !Array.isArray(result) && "error" in result + ) { + return jsonResponse({ message: result.error }, 500); + } + if (result !== undefined) return jsonResponse(result); + throw new Error(`Unhandled RPC: ${rpcName}`); + } + + throw new Error( + `Unhandled Supabase request: ${request.method} ${url.pathname}`, + ); + }) as typeof fetch; + + try { + const { handleRequest } = await import("./index.ts"); + const response = await handleRequest( + new Request( + "http://127.0.0.1/functions/v1/workspace-invites", + { + method: "POST", + headers: { + Authorization: "Bearer test-user-token", + "Content-Type": "application/json", + "X-Forwarded-For": "198.51.100.20, 203.0.113.9", + }, + body: JSON.stringify(body), + }, + ), + sender ? { sender, baseUrl } : undefined, + ); + return { calls, response }; + } finally { + globalThis.fetch = originalFetch; + } +} + +function rpcCall( + calls: Awaited>["calls"], + name: string, +) { + return calls.find((call) => call.pathname === `/rest/v1/rpc/${name}`); +} + +Deno.test("code creation returns one high-entropy code and sends only its digest to Postgres", async () => { + const { calls, response } = await runScenario({ + action: "create_code", + workspaceId, + requestId, + maxUses: 4, + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2026-09-22T12:00:00.000Z", + max_uses: 4, + replayed: false, + }], + }, + }); + + const payload = await response.json(); + if (response.status !== 201) { + throw new Error(`code creation returned ${response.status}`); + } + if (!/^WS-(?:[A-Z2-7]{4}-){4}[A-Z2-7]{4}$/.test(payload.code)) { + throw new Error("code does not carry 100 bits of base32 invite material"); + } + const call = rpcCall(calls, "create_workspace_invite"); + if (!call?.body || typeof call.body.p_secret_digest !== "string") { + throw new Error("code digest was not sent to the invite RPC"); + } + const serialized = JSON.stringify(call.body); + if (serialized.includes(payload.code)) { + throw new Error("raw code reached Postgres"); + } + if ( + serialized.includes("198.51.100.20") || serialized.includes("203.0.113.9") + ) { + throw new Error("raw client IP reached Postgres"); + } +}); + +Deno.test("code rotation revokes the selected code through the create RPC", async () => { + const { calls, response } = await runScenario({ + action: "rotate_code", + workspaceId, + inviteId, + requestId, + maxUses: 2, + }, { + rpc: { + create_workspace_invite: [{ + invite_id: "55555555-5555-4555-8555-555555555555", + expires_at: "2026-09-22T12:00:00.000Z", + max_uses: 2, + replayed: false, + }], + }, + }); + + if (response.status !== 201) { + throw new Error(`code rotation returned ${response.status}`); + } + const call = rpcCall(calls, "create_workspace_invite"); + if ( + call?.body?.p_rotate_invite_id !== inviteId || call.body.p_kind !== "code" + ) { + throw new Error("rotate_code did not bind the old code to its replacement"); + } +}); + +Deno.test("code revocation routes to the dedicated service RPC", async () => { + const { calls, response } = await runScenario({ + action: "revoke", + workspaceId, + inviteId, + requestId, + }, { + rpc: { revoke_workspace_invite: [{ revoked: true, replayed: false }] }, + }); + + if ( + response.status !== 200 || + JSON.stringify(await response.json()) !== '{"revoked":true}' + ) { + throw new Error(`code revocation returned ${response.status}`); + } + const call = rpcCall(calls, "revoke_workspace_invite"); + if (call?.body?.p_invite_id !== inviteId) { + throw new Error("revoke omitted the selected invite"); + } +}); + +Deno.test("email creation has one uniform response and sends through the injected boundary", async () => { + const sent: SentMessage[] = []; + const { calls, response } = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: " Student@Example.COM ", + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2026-09-27T12:00:00.000Z", + max_uses: 1, + replayed: false, + email_delivered: false, + }], + mark_workspace_invite_email_delivered: true, + }, + }, (message) => { + sent.push(message); + return Promise.resolve(); + }, "https://invites.test/"); + + if ( + response.status !== 202 || + JSON.stringify(await response.json()) !== '{"accepted":true}' + ) { + throw new Error( + "email creation did not return the generic accepted response", + ); + } + if (sent.length !== 1 || sent[0].to !== "student@example.com") { + throw new Error( + "the controlled sender did not receive the normalized target once", + ); + } + if ( + !sent[0].inviteUrl.startsWith( + "https://invites.test/app/workspaces/invite?token=wi_", + ) + ) { + throw new Error("email sender did not receive the resolved invite URL"); + } + if (sent[0].idempotencyKey !== `workspace-invite:${inviteId}`) { + throw new Error("email sender did not receive the stable invite key"); + } + if (calls.some((call) => call.pathname.includes("/auth/v1/admin/users"))) { + throw new Error("email creation performed an Auth-account lookup"); + } + const call = rpcCall(calls, "create_workspace_invite"); + const serialized = JSON.stringify(call?.body ?? {}); + if ( + serialized.toLowerCase().includes("student@example.com") || + serialized.includes("wi_") + ) { + throw new Error("plaintext email or token reached Postgres"); + } + const deliveredCall = rpcCall( + calls, + "mark_workspace_invite_email_delivered", + ); + if ( + deliveredCall?.body?.p_invite_id !== inviteId || + deliveredCall.body.p_actor_id !== actor.id + ) { + throw new Error("accepted delivery was not marked for the exact invite"); + } + const deliveredBody = JSON.stringify(deliveredCall.body); + if ( + deliveredBody.includes("student@example.com") || + deliveredBody.includes("wi_") || + deliveredBody.includes("198.51.100.20") + ) { + throw new Error("delivery marking received private invite material"); + } +}); + +Deno.test("commit-before-send replay retries pending email delivery", async () => { + const sent: SentMessage[] = []; + const { response } = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2026-09-27T12:00:00.000Z", + max_uses: 1, + replayed: true, + email_delivered: false, + }], + mark_workspace_invite_email_delivered: true, + }, + }, (message) => { + sent.push(message); + return Promise.resolve(); + }); + + if (response.status !== 202 || sent.length !== 1) { + throw new Error("pending replay did not retry delivery"); + } +}); + +Deno.test("expired pending replay does not send or mark delivery", async () => { + let sendCount = 0; + const { calls, response } = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2020-01-01T00:00:00.000Z", + max_uses: 1, + replayed: true, + email_delivered: false, + }], + }, + }, () => { + sendCount += 1; + return Promise.resolve(); + }); + + if (response.status !== 503 || sendCount !== 0) { + throw new Error("expired pending invite attempted delivery"); + } + if (rpcCall(calls, "mark_workspace_invite_email_delivered")) { + throw new Error("expired pending invite was marked delivered"); + } +}); + +Deno.test("sender acceptance with mark failure retries the same relay key", async () => { + const sent: SentMessage[] = []; + const pendingInvite = { + invite_id: inviteId, + expires_at: "2026-09-27T12:00:00.000Z", + max_uses: 1, + replayed: true, + email_delivered: false, + }; + const first = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [pendingInvite], + mark_workspace_invite_email_delivered: { error: "mark failed" }, + }, + }, (message) => { + sent.push(message); + return Promise.resolve(); + }); + const second = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [pendingInvite], + mark_workspace_invite_email_delivered: true, + }, + }, (message) => { + sent.push(message); + return Promise.resolve(); + }); + + if (first.response.status !== 500 || second.response.status !== 202) { + throw new Error("mark failure did not fail generically then recover"); + } + if ( + sent.length !== 2 || sent[0].idempotencyKey !== sent[1].idempotencyKey || + sent[0].idempotencyKey !== `workspace-invite:${inviteId}` + ) { + throw new Error("mark retry changed the relay idempotency key"); + } +}); + +Deno.test("already-delivered replay suppresses email delivery", async () => { + let sendCount = 0; + const { calls, response } = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2026-09-27T12:00:00.000Z", + max_uses: 1, + replayed: true, + email_delivered: true, + }], + }, + }, () => { + sendCount += 1; + return Promise.resolve(); + }); + + if (response.status !== 202 || sendCount !== 0) { + throw new Error("delivered replay sent another email"); + } + if (rpcCall(calls, "mark_workspace_invite_email_delivered")) { + throw new Error("delivered replay marked delivery again"); + } +}); + +Deno.test("sender failure leaves delivery pending and retryable", async () => { + const sent: SentMessage[] = []; + const first = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2026-09-27T12:00:00.000Z", + max_uses: 1, + replayed: false, + email_delivered: false, + }], + }, + }, (message) => { + sent.push(message); + return Promise.reject(new Error("relay unavailable")); + }); + const second = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }, { + rpc: { + create_workspace_invite: [{ + invite_id: inviteId, + expires_at: "2026-09-27T12:00:00.000Z", + max_uses: 1, + replayed: true, + email_delivered: false, + }], + mark_workspace_invite_email_delivered: true, + }, + }, (message) => { + sent.push(message); + return Promise.resolve(); + }); + + if (first.response.status !== 503 || second.response.status !== 202) { + throw new Error("sender failure was not retried from pending state"); + } + if ( + rpcCall(first.calls, "revoke_workspace_invite") || + rpcCall(first.calls, "mark_workspace_invite_email_delivered") + ) { + throw new Error("sender failure revoked or delivered the pending invite"); + } + if ( + sent.length !== 2 || sent[0].idempotencyKey !== sent[1].idempotencyKey + ) { + throw new Error("sender retry changed the relay idempotency key"); + } +}); + +Deno.test("missing email relay configuration fails before invite storage", async () => { + const { calls, response } = await runScenario({ + action: "create_email", + workspaceId, + requestId, + email: "student@example.com", + }); + + if (response.status !== 503) { + throw new Error(`unconfigured email returned ${response.status}`); + } + if (rpcCall(calls, "create_workspace_invite")) { + throw new Error("unconfigured email stored an invitation"); + } +}); + +Deno.test("redeem uses the fresh verified Auth email and stores only keyed hashes", async () => { + const secret = "wi_abcdefghijklmnopqrstuvwxyzABCDEF"; + const { calls, response } = await runScenario({ + action: "redeem", + requestId, + token: secret, + }, { + rpc: { + redeem_workspace_invite: [{ + status: "accepted", + workspace_id: workspaceId, + already_member: false, + }], + }, + }); + + const payload = await response.json(); + if ( + response.status !== 200 || payload.workspaceId !== workspaceId || + payload.alreadyMember !== false + ) { + throw new Error("redeem did not return the membership outcome"); + } + const call = rpcCall(calls, "redeem_workspace_invite"); + const serialized = JSON.stringify(call?.body ?? {}); + if ( + serialized.includes(secret) || serialized.includes(actor.email) || + serialized.includes("forged@example.com") + ) { + throw new Error("redeem sent a token or email instead of keyed hashes"); + } + if ( + serialized.includes("198.51.100.20") || serialized.includes("203.0.113.9") + ) { + throw new Error("redeem sent a raw proxy IP"); + } + if (call?.body?.p_actor_email_hash === null) { + throw new Error("verified email hash was omitted"); + } +}); + +Deno.test("an unverified Auth email cannot be supplied to email redemption", async () => { + const { calls, response } = await runScenario({ + action: "redeem", + requestId, + token: "wi_0123456789abcdefghijklmnopqrstuv", + }, { + user: { ...actor, email_confirmed_at: undefined }, + rpc: { + redeem_workspace_invite: [{ + status: "unavailable", + workspace_id: null, + already_member: false, + }], + }, + }); + + if (response.status !== 403) { + throw new Error(`unverified email redemption returned ${response.status}`); + } + const call = rpcCall(calls, "redeem_workspace_invite"); + if (call?.body?.p_actor_email_hash !== null) { + throw new Error("unverified email produced an email hash"); + } +}); + +Deno.test("rate-store RPC failure stops redemption with a generic secret-free error", async () => { + const secret = "WS-AAAA-BBBB-CCCC-DDDD-EEEE"; + const { calls, response } = await runScenario({ + action: "redeem", + requestId, + code: secret, + }, { + rpc: { redeem_workspace_invite: { error: `store failed near ${secret}` } }, + }); + + const text = await response.text(); + if (response.status !== 500 || !text.includes("temporarily unavailable")) { + throw new Error(`rate-store failure returned ${response.status}: ${text}`); + } + if ( + text.includes(secret) || JSON.stringify(calls).includes("198.51.100.20") + ) { + throw new Error("a secret or raw IP leaked through the failure path"); + } +}); + +Deno.test("all actions authenticate through the shared bearer boundary", async () => { + const { handleRequest } = await import("./index.ts"); + const response = await handleRequest( + new Request( + "http://127.0.0.1/functions/v1/workspace-invites", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "create_code", workspaceId, requestId }), + }, + ), + ); + + if (response.status !== 401) { + throw new Error(`missing bearer token returned ${response.status}`); + } +}); + +Deno.test("Capacitor preflight receives the native app origin", async () => { + const { handleRequest } = await import("./index.ts"); + const response = await handleRequest( + new Request( + "http://127.0.0.1/functions/v1/workspace-invites", + { method: "OPTIONS", headers: { Origin: "capacitor://localhost" } }, + ), + ); + + if ( + response.headers.get("Access-Control-Allow-Origin") !== + "capacitor://localhost" + ) { + throw new Error("workspace invites rejected the Capacitor origin"); + } +}); diff --git a/supabase/migrations/20260920182442_workspace_invites.sql b/supabase/migrations/20260920182442_workspace_invites.sql new file mode 100644 index 000000000..a9e6c3572 --- /dev/null +++ b/supabase/migrations/20260920182442_workspace_invites.sql @@ -0,0 +1,585 @@ +CREATE TABLE private.workspace_invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE, + kind text NOT NULL CHECK (kind IN ('code', 'email')), + secret_digest bytea NOT NULL UNIQUE CHECK (octet_length(secret_digest) = 32), + target_email_ciphertext text, + target_email_hash bytea, + issued_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + rotation_of uuid REFERENCES private.workspace_invites(id) ON DELETE SET NULL, + expires_at timestamptz NOT NULL, + max_uses integer NOT NULL CHECK (max_uses BETWEEN 1 AND 30), + use_count integer NOT NULL DEFAULT 0 CHECK (use_count BETWEEN 0 AND max_uses), + revoked_at timestamptz, + email_delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT workspace_invites_target_check CHECK ( + ( + kind = 'code' + AND target_email_ciphertext IS NULL + AND target_email_hash IS NULL + AND email_delivered_at IS NULL + ) + OR ( + kind = 'email' + AND length(target_email_ciphertext) > 0 + AND octet_length(target_email_hash) = 32 + AND max_uses = 1 + ) + ), + CONSTRAINT workspace_invites_delivery_time_check CHECK ( + email_delivered_at IS NULL OR email_delivered_at >= created_at + ) +); + +CREATE TABLE private.workspace_invite_attempts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + actor_id uuid NOT NULL, + ip_hash bytea CHECK (ip_hash IS NULL OR octet_length(ip_hash) = 32), + success boolean NOT NULL, + failure_reason text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX workspace_invites_workspace_created_idx + ON private.workspace_invites(workspace_id, created_at DESC); +CREATE INDEX workspace_invites_issuer_created_idx + ON private.workspace_invites(issued_by, created_at DESC) + WHERE issued_by IS NOT NULL; +CREATE INDEX workspace_invites_rotation_of_idx + ON private.workspace_invites(rotation_of) + WHERE rotation_of IS NOT NULL; +CREATE INDEX workspace_invites_live_code_idx + ON private.workspace_invites(workspace_id, id) + WHERE kind = 'code' AND revoked_at IS NULL; +CREATE INDEX workspace_invite_attempts_actor_failure_idx + ON private.workspace_invite_attempts(actor_id, created_at DESC) + WHERE success = false; +CREATE INDEX workspace_invite_attempts_ip_failure_idx + ON private.workspace_invite_attempts(ip_hash, created_at DESC) + WHERE success = false AND ip_hash IS NOT NULL; +CREATE INDEX workspace_invite_attempts_created_idx + ON private.workspace_invite_attempts(created_at); + +ALTER TABLE private.workspace_invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE private.workspace_invite_attempts ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE private.workspace_invites, private.workspace_invite_attempts + FROM PUBLIC, anon, authenticated, service_role; +REVOKE ALL ON SEQUENCE private.workspace_invite_attempts_id_seq + FROM PUBLIC, anon, authenticated, service_role; + +ALTER TABLE public.workspace_audit_events + ADD COLUMN invite_id uuid REFERENCES private.workspace_invites(id) ON DELETE SET NULL, + ADD COLUMN invite_result text; + +CREATE INDEX workspace_audit_events_invite_id_idx + ON public.workspace_audit_events(invite_id) + WHERE invite_id IS NOT NULL; + +CREATE FUNCTION private.require_workspace_invite_service_role() +RETURNS void +LANGUAGE plpgsql +STABLE +SET search_path = '' +AS $$ +BEGIN + IF coalesce(current_setting('request.jwt.claim.role', true), '') <> 'service_role' THEN + RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE = 'service role required'; + END IF; +END; +$$; + +REVOKE ALL ON FUNCTION private.require_workspace_invite_service_role() + FROM PUBLIC, anon, authenticated, service_role; + +CREATE FUNCTION public.create_workspace_invite( + p_actor_id uuid, + p_workspace_id uuid, + p_kind text, + p_secret_digest bytea, + p_target_email_ciphertext text, + p_target_email_hash bytea, + p_max_uses integer, + p_request_id uuid, + p_rotate_invite_id uuid +) +RETURNS TABLE ( + invite_id uuid, + expires_at timestamptz, + max_uses integer, + replayed boolean, + revoked boolean, + email_delivered boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_workspace public.workspaces%ROWTYPE; + v_existing public.workspace_audit_events%ROWTYPE; + v_previous private.workspace_invites%ROWTYPE; + v_invite private.workspace_invites%ROWTYPE; + v_fingerprint bytea; + v_remaining integer; + v_max_uses integer; +BEGIN + PERFORM private.require_workspace_invite_service_role(); + IF p_actor_id IS NULL OR p_workspace_id IS NULL OR p_request_id IS NULL + OR p_kind NOT IN ('code', 'email') + OR p_secret_digest IS NULL OR octet_length(p_secret_digest) <> 32 THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended('workspace-invite-request:' || p_request_id::text, 0)); + PERFORM pg_advisory_xact_lock(hashtextextended('workspace-invite-actor:' || p_actor_id::text, 0)); + + SELECT w.* INTO v_workspace + FROM public.workspaces AS w + WHERE w.id = p_workspace_id + AND w.status = 'active' + AND EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = w.id AND m.user_id = p_actor_id + ) + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'create_workspace_invite', + 'actor', p_actor_id, + 'workspace', p_workspace_id, + 'kind', p_kind, + 'secret_digest', encode(p_secret_digest, 'hex'), + 'target_email_hash', CASE + WHEN p_target_email_hash IS NULL THEN NULL + ELSE encode(p_target_email_hash, 'hex') + END, + 'max_uses', p_max_uses, + 'rotate_invite', p_rotate_invite_id + )); + + SELECT a.* INTO v_existing + FROM public.workspace_audit_events AS a + WHERE a.operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint + OR v_existing.invite_id IS NULL THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + + SELECT i.* INTO v_invite + FROM private.workspace_invites AS i + WHERE i.id = v_existing.invite_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + RETURN QUERY SELECT + v_invite.id, v_invite.expires_at, v_invite.max_uses, true, + v_invite.revoked_at IS NOT NULL, v_invite.email_delivered_at IS NOT NULL; + RETURN; + END IF; + + IF (SELECT count(*) FROM private.workspace_invites + WHERE issued_by = p_actor_id AND created_at >= now() - interval '1 hour') >= 20 + OR (SELECT count(*) FROM private.workspace_invites + WHERE workspace_id = p_workspace_id AND created_at >= now() - interval '1 day') >= 50 THEN + RAISE EXCEPTION 'invite rate limited'; + END IF; + + SELECT 30 - count(*)::integer INTO v_remaining + FROM public.workspace_members + WHERE workspace_id = p_workspace_id; + + IF v_remaining <= 0 THEN + RAISE EXCEPTION 'invite capacity exceeded'; + END IF; + + IF p_kind = 'email' THEN + IF p_target_email_ciphertext IS NULL OR p_target_email_ciphertext = '' + OR p_target_email_hash IS NULL OR octet_length(p_target_email_hash) <> 32 + OR p_max_uses IS DISTINCT FROM 1 OR p_rotate_invite_id IS NOT NULL THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + v_max_uses := 1; + ELSE + IF p_target_email_ciphertext IS NOT NULL OR p_target_email_hash IS NOT NULL THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + v_max_uses := coalesce(p_max_uses, v_remaining); + END IF; + + IF v_max_uses NOT BETWEEN 1 AND v_remaining THEN + RAISE EXCEPTION 'invite capacity exceeded'; + END IF; + + IF p_rotate_invite_id IS NOT NULL THEN + IF p_kind <> 'code' THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + + SELECT i.* INTO v_previous + FROM private.workspace_invites AS i + WHERE i.id = p_rotate_invite_id + AND i.workspace_id = p_workspace_id + AND i.kind = 'code' + AND i.revoked_at IS NULL + AND i.expires_at > now() + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + UPDATE private.workspace_invites + SET revoked_at = now() + WHERE id = v_previous.id; + END IF; + + INSERT INTO private.workspace_invites ( + workspace_id, kind, secret_digest, target_email_ciphertext, + target_email_hash, issued_by, rotation_of, expires_at, max_uses + ) + VALUES ( + p_workspace_id, p_kind, p_secret_digest, p_target_email_ciphertext, + p_target_email_hash, p_actor_id, p_rotate_invite_id, + now() + CASE p_kind WHEN 'code' THEN interval '48 hours' ELSE interval '7 days' END, + v_max_uses + ) + RETURNING * INTO v_invite; + + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, + invite_id, invite_result + ) + VALUES ( + p_request_id, v_fingerprint, p_workspace_id, p_actor_id, + CASE WHEN p_rotate_invite_id IS NULL + THEN 'invite_' || p_kind || '_create' + ELSE 'invite_code_rotate' + END, + v_invite.id, 'created' + ); + + RETURN QUERY SELECT + v_invite.id, v_invite.expires_at, v_invite.max_uses, false, false, + v_invite.email_delivered_at IS NOT NULL; +END; +$$; + +CREATE FUNCTION public.mark_workspace_invite_email_delivered( + p_actor_id uuid, + p_invite_id uuid +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + PERFORM private.require_workspace_invite_service_role(); + IF p_actor_id IS NULL OR p_invite_id IS NULL THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + + UPDATE private.workspace_invites + SET email_delivered_at = coalesce(email_delivered_at, now()) + WHERE id = p_invite_id + AND kind = 'email' + AND issued_by = p_actor_id + AND revoked_at IS NULL + AND expires_at > now(); + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + RETURN true; +END; +$$; + +CREATE FUNCTION public.revoke_workspace_invite( + p_actor_id uuid, + p_workspace_id uuid, + p_invite_id uuid, + p_request_id uuid +) +RETURNS TABLE (revoked boolean, replayed boolean) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_existing public.workspace_audit_events%ROWTYPE; + v_fingerprint bytea; +BEGIN + PERFORM private.require_workspace_invite_service_role(); + IF p_actor_id IS NULL OR p_workspace_id IS NULL OR p_invite_id IS NULL OR p_request_id IS NULL THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended('workspace-invite-request:' || p_request_id::text, 0)); + + PERFORM 1 + FROM public.workspaces AS w + WHERE w.id = p_workspace_id + AND w.status = 'active' + AND EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = w.id AND m.user_id = p_actor_id + ) + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'revoke_workspace_invite', + 'actor', p_actor_id, + 'workspace', p_workspace_id, + 'invite', p_invite_id + )); + + SELECT a.* INTO v_existing + FROM public.workspace_audit_events AS a + WHERE a.operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint + OR v_existing.invite_id IS DISTINCT FROM p_invite_id THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + RETURN QUERY SELECT true, true; + RETURN; + END IF; + + UPDATE private.workspace_invites + SET revoked_at = coalesce(revoked_at, now()) + WHERE id = p_invite_id + AND workspace_id = p_workspace_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, + invite_id, invite_result + ) + VALUES ( + p_request_id, v_fingerprint, p_workspace_id, p_actor_id, + 'invite_revoke', p_invite_id, 'revoked' + ); + + RETURN QUERY SELECT true, false; +END; +$$; + +CREATE FUNCTION public.redeem_workspace_invite( + p_actor_id uuid, + p_secret_digest bytea, + p_actor_email_hash bytea, + p_ip_hash bytea, + p_request_id uuid +) +RETURNS TABLE (status text, workspace_id uuid, already_member boolean) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_existing public.workspace_audit_events%ROWTYPE; + v_invite private.workspace_invites%ROWTYPE; + v_fingerprint bytea; + v_is_member boolean; +BEGIN + PERFORM private.require_workspace_invite_service_role(); + IF p_actor_id IS NULL OR p_request_id IS NULL + OR p_secret_digest IS NULL OR octet_length(p_secret_digest) <> 32 + OR (p_actor_email_hash IS NOT NULL AND octet_length(p_actor_email_hash) <> 32) + OR (p_ip_hash IS NOT NULL AND octet_length(p_ip_hash) <> 32) THEN + RAISE EXCEPTION 'invalid invite request'; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended('workspace-invite-request:' || p_request_id::text, 0)); + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'redeem_workspace_invite', + 'actor', p_actor_id, + 'secret_digest', encode(p_secret_digest, 'hex'), + 'actor_email_hash', CASE + WHEN p_actor_email_hash IS NULL THEN NULL + ELSE encode(p_actor_email_hash, 'hex') + END + )); + + SELECT a.* INTO v_existing + FROM public.workspace_audit_events AS a + WHERE a.operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + IF EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = v_existing.workspace_id AND m.user_id = p_actor_id + ) THEN + RETURN QUERY SELECT + 'accepted'::text, v_existing.workspace_id, + v_existing.invite_result = 'member_existing'; + ELSE + RETURN QUERY SELECT 'unavailable'::text, NULL::uuid, false; + END IF; + RETURN; + END IF; + + PERFORM pg_advisory_xact_lock(hashtextextended('workspace-invite-actor:' || p_actor_id::text, 0)); + IF p_ip_hash IS NOT NULL THEN + PERFORM pg_advisory_xact_lock(hashtextextended('workspace-invite-ip:' || encode(p_ip_hash, 'hex'), 0)); + END IF; + + DELETE FROM private.workspace_invite_attempts + WHERE created_at < now() - interval '1 day'; + + IF (SELECT count(*) FROM private.workspace_invite_attempts + WHERE actor_id = p_actor_id AND success = false + AND created_at >= now() - interval '15 minutes') >= 10 + OR (p_ip_hash IS NOT NULL AND ( + SELECT count(*) FROM private.workspace_invite_attempts + WHERE ip_hash = p_ip_hash AND success = false + AND created_at >= now() - interval '15 minutes' + ) >= 10) THEN + RETURN QUERY SELECT 'rate_limited'::text, NULL::uuid, false; + RETURN; + END IF; + + SELECT i.* INTO v_invite + FROM private.workspace_invites AS i + WHERE i.secret_digest = p_secret_digest; + + IF NOT FOUND THEN + INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success, failure_reason) + VALUES (p_actor_id, p_ip_hash, false, 'unavailable'); + RETURN QUERY SELECT 'unavailable'::text, NULL::uuid, false; + RETURN; + END IF; + + PERFORM 1 FROM public.workspaces + WHERE id = v_invite.workspace_id + FOR UPDATE; + + SELECT i.* INTO v_invite + FROM private.workspace_invites AS i + WHERE i.id = v_invite.id + FOR UPDATE; + + SELECT EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = v_invite.workspace_id AND m.user_id = p_actor_id + ) INTO v_is_member; + + IF v_is_member THEN + INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success) + VALUES (p_actor_id, p_ip_hash, true); + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, + invite_id, invite_result + ) + VALUES ( + p_request_id, v_fingerprint, v_invite.workspace_id, p_actor_id, + 'invite_redeem', v_invite.id, 'member_existing' + ); + RETURN QUERY SELECT 'accepted'::text, v_invite.workspace_id, true; + RETURN; + END IF; + + IF v_invite.revoked_at IS NOT NULL OR v_invite.expires_at <= now() + OR v_invite.use_count >= v_invite.max_uses + OR NOT EXISTS ( + SELECT 1 FROM public.workspaces AS w + WHERE w.id = v_invite.workspace_id AND w.status = 'active' + ) + OR (v_invite.kind = 'email' + AND v_invite.target_email_hash IS DISTINCT FROM p_actor_email_hash) THEN + INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success, failure_reason) + VALUES (p_actor_id, p_ip_hash, false, 'unavailable'); + RETURN QUERY SELECT 'unavailable'::text, NULL::uuid, false; + RETURN; + END IF; + + IF (SELECT count(*) FROM public.workspace_members AS m + WHERE m.workspace_id = v_invite.workspace_id) >= 30 THEN + INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success, failure_reason) + VALUES (p_actor_id, p_ip_hash, false, 'capacity'); + RETURN QUERY SELECT 'unavailable'::text, NULL::uuid, false; + RETURN; + END IF; + + INSERT INTO public.workspace_members (workspace_id, user_id) + VALUES (v_invite.workspace_id, p_actor_id); + + UPDATE private.workspace_invites + SET use_count = use_count + 1 + WHERE id = v_invite.id; + + INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success) + VALUES (p_actor_id, p_ip_hash, true); + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, + invite_id, invite_result + ) + VALUES ( + p_request_id, v_fingerprint, v_invite.workspace_id, p_actor_id, + 'invite_redeem', v_invite.id, 'member_added' + ); + + RETURN QUERY SELECT 'accepted'::text, v_invite.workspace_id, false; +END; +$$; + +CREATE FUNCTION private.revoke_workspace_invites_after_member_removal() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + UPDATE private.workspace_invites + SET revoked_at = coalesce(revoked_at, now()) + WHERE workspace_id = OLD.workspace_id + AND revoked_at IS NULL; + RETURN OLD; +END; +$$; + +CREATE TRIGGER workspace_members_revoke_invites_after_delete +AFTER DELETE ON public.workspace_members +FOR EACH ROW EXECUTE FUNCTION private.revoke_workspace_invites_after_member_removal(); + +REVOKE ALL ON FUNCTION private.revoke_workspace_invites_after_member_removal() + FROM PUBLIC, anon, authenticated, service_role; +REVOKE ALL ON FUNCTION public.create_workspace_invite(uuid, uuid, text, bytea, text, bytea, integer, uuid, uuid) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.revoke_workspace_invite(uuid, uuid, uuid, uuid) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.redeem_workspace_invite(uuid, bytea, bytea, bytea, uuid) + FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.mark_workspace_invite_email_delivered(uuid, uuid) + FROM PUBLIC, anon, authenticated; + +GRANT EXECUTE ON FUNCTION public.create_workspace_invite(uuid, uuid, text, bytea, text, bytea, integer, uuid, uuid) + TO service_role; +GRANT EXECUTE ON FUNCTION public.revoke_workspace_invite(uuid, uuid, uuid, uuid) + TO service_role; +GRANT EXECUTE ON FUNCTION public.redeem_workspace_invite(uuid, bytea, bytea, bytea, uuid) + TO service_role; +GRANT EXECUTE ON FUNCTION public.mark_workspace_invite_email_delivered(uuid, uuid) + TO service_role; diff --git a/supabase/tests/workspace_invites_test.sql b/supabase/tests/workspace_invites_test.sql new file mode 100644 index 000000000..90327e009 --- /dev/null +++ b/supabase/tests/workspace_invites_test.sql @@ -0,0 +1,595 @@ +BEGIN; +SELECT plan(48); + +INSERT INTO auth.users (id, email, email_confirmed_at) +VALUES + ('61111111-1111-4111-8111-111111111111', 'owner@example.com', now()), + ('62222222-2222-4222-8222-222222222222', 'member@example.com', now()), + ('63333333-3333-4333-8333-333333333333', 'joiner@example.com', now()), + ('64444444-4444-4444-8444-444444444444', 'other@example.com', now()); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '61111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT public.create_workspace( + 'Invite test motion', 'Invite test workspace', 'en', + '60000000-0000-4000-8000-000000000001' +); + +RESET ROLE; +SELECT set_config( + 'test.workspace_id', + (SELECT id::text FROM public.workspaces WHERE owner_id = '61111111-1111-4111-8111-111111111111'), + true +); +INSERT INTO public.workspace_members (workspace_id, user_id) +VALUES (current_setting('test.workspace_id')::uuid, '62222222-2222-4222-8222-222222222222'); + +SELECT has_table('private', 'workspace_invites', 'private workspace invites exist'); +SELECT has_table('private', 'workspace_invite_attempts', 'private redemption attempts exist'); +SELECT ok( + NOT has_table_privilege('anon', 'private.workspace_invites', 'SELECT,INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'private.workspace_invites', 'SELECT,INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('anon', 'private.workspace_invite_attempts', 'SELECT,INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'private.workspace_invite_attempts', 'SELECT,INSERT,UPDATE,DELETE'), + 'browser roles have no direct invite or attempt access' +); +SELECT ok( + NOT has_function_privilege( + 'authenticated', + 'public.create_workspace_invite(uuid,uuid,text,bytea,text,bytea,integer,uuid,uuid)', + 'EXECUTE' + ) + AND NOT has_function_privilege( + 'authenticated', 'public.redeem_workspace_invite(uuid,bytea,bytea,bytea,uuid)', 'EXECUTE' + ) + AND has_function_privilege( + 'service_role', + 'public.create_workspace_invite(uuid,uuid,text,bytea,text,bytea,integer,uuid,uuid)', + 'EXECUTE' + ) + AND has_function_privilege( + 'service_role', 'public.redeem_workspace_invite(uuid,bytea,bytea,bytea,uuid)', 'EXECUTE' + ) + AND NOT has_function_privilege( + 'authenticated', 'public.revoke_workspace_invite(uuid,uuid,uuid,uuid)', 'EXECUTE' + ) + AND has_function_privilege( + 'service_role', 'public.revoke_workspace_invite(uuid,uuid,uuid,uuid)', 'EXECUTE' + ) + AND NOT has_function_privilege( + 'anon', 'public.mark_workspace_invite_email_delivered(uuid,uuid)', 'EXECUTE' + ) + AND NOT has_function_privilege( + 'authenticated', 'public.mark_workspace_invite_email_delivered(uuid,uuid)', 'EXECUTE' + ) + AND has_function_privilege( + 'service_role', 'public.mark_workspace_invite_email_delivered(uuid,uuid)', 'EXECUTE' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_proc p, + LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) acl + WHERE p.oid = 'public.mark_workspace_invite_email_delivered(uuid,uuid)'::regprocedure + AND acl.grantee = 0 + AND acl.privilege_type = 'EXECUTE' + ), + 'only service_role can execute all four invite RPCs' +); + +SELECT throws_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('01', 32), 'hex'), NULL, NULL, 2, + '60000000-0000-4000-8000-000000000002', NULL + )$$, + '42501', 'service role required', + 'the privileged create RPC independently rejects a missing service-role claim' +); + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT lives_ok( + $$SELECT * FROM public.create_workspace_invite( + '62222222-2222-4222-8222-222222222222', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('02', 32), 'hex'), NULL, NULL, 2, + '60000000-0000-4000-8000-000000000003', NULL + )$$, + 'any current member can create a shareable code' +); +RESET ROLE; +SELECT set_config( + 'test.code_invite_id', + (SELECT id::text FROM private.workspace_invites WHERE secret_digest = decode(repeat('02', 32), 'hex')), + true +); +SELECT results_eq( + $$SELECT expires_at > now() + interval '47 hours 59 minutes' + AND expires_at <= now() + interval '48 hours 1 minute' + AND max_uses = 2 + FROM private.workspace_invites + WHERE id = current_setting('test.code_invite_id')::uuid$$, + ARRAY[true], + 'shareable codes expire in 48 hours and keep their bounded use count' +); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT invite_id FROM public.create_workspace_invite( + '62222222-2222-4222-8222-222222222222', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('02', 32), 'hex'), NULL, NULL, 2, + '60000000-0000-4000-8000-000000000003', NULL + )$$, + ARRAY[current_setting('test.code_invite_id')::uuid], + 'an identical create replay returns the original invite' +); +SELECT throws_ok( + $$SELECT * FROM public.create_workspace_invite( + '62222222-2222-4222-8222-222222222222', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('03', 32), 'hex'), NULL, NULL, 2, + '60000000-0000-4000-8000-000000000003', NULL + )$$, + 'P0001', 'operation ID conflict', + 'conflicting request-ID reuse is rejected' +); + +SELECT lives_ok( + $$SELECT * FROM public.create_workspace_invite( + '62222222-2222-4222-8222-222222222222', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('09', 32), 'hex'), NULL, NULL, 1, + '60000000-0000-4000-8000-000000000019', NULL + )$$, + 'a member can create the code used by the revoke contract' +); +RESET ROLE; +SELECT set_config( + 'test.revoke_invite_id', + (SELECT id::text FROM private.workspace_invites WHERE secret_digest = decode(repeat('09', 32), 'hex')), + true +); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT revoked FROM public.revoke_workspace_invite( + '62222222-2222-4222-8222-222222222222', current_setting('test.workspace_id')::uuid, + current_setting('test.revoke_invite_id')::uuid, + '60000000-0000-4000-8000-000000000020' + )$$, + ARRAY[true], + 'any current member can revoke a shareable code through the service RPC' +); +SELECT throws_ok( + $$SELECT * FROM public.revoke_workspace_invite( + '64444444-4444-4444-8444-444444444444', current_setting('test.workspace_id')::uuid, + current_setting('test.revoke_invite_id')::uuid, + '60000000-0000-4000-8000-000000000021' + )$$, + 'P0001', 'workspace unavailable', + 'the service RPC cannot revoke on behalf of a non-member' +); + +SELECT results_eq( + $$SELECT status, already_member FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('02', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000004' + )$$, + $$VALUES ('accepted'::text, false)$$, + 'a valid code creates membership' +); +SELECT results_eq( + $$SELECT status, already_member FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('02', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000004' + )$$, + $$VALUES ('accepted'::text, false)$$, + 'an identical redemption replay returns its original membership outcome' +); +SELECT throws_ok( + $$SELECT * FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('03', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000004' + )$$, + 'P0001', 'operation ID conflict', + 'a redemption request ID cannot be reused with another secret' +); +RESET ROLE; +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members + WHERE workspace_id = current_setting('test.workspace_id')::uuid + AND user_id = '63333333-3333-4333-8333-333333333333'$$, + ARRAY[1::bigint], + 'code redemption creates exactly one membership row' +); +SELECT results_eq( + $$SELECT status, already_member FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('02', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000005' + )$$, + $$VALUES ('accepted'::text, true)$$, + 'accepting while already a member returns the same membership outcome' +); +SELECT results_eq( + $$SELECT use_count FROM private.workspace_invites + WHERE id = current_setting('test.code_invite_id')::uuid$$, + ARRAY[1], + 'already-member acceptance does not consume another use' +); + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT lives_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('04', 32), 'hex'), NULL, NULL, 1, + '60000000-0000-4000-8000-000000000006', current_setting('test.code_invite_id')::uuid + )$$, + 'a member can rotate a shareable code' +); +RESET ROLE; +SELECT results_eq( + $$SELECT revoked_at IS NOT NULL FROM private.workspace_invites + WHERE id = current_setting('test.code_invite_id')::uuid$$, + ARRAY[true], + 'rotation revokes the prior code' +); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '64444444-4444-4444-8444-444444444444', decode(repeat('02', 32), 'hex'), + decode(repeat('14', 32), 'hex'), decode(repeat('24', 32), 'hex'), + '60000000-0000-4000-8000-000000000007' + )$$, + ARRAY['unavailable'::text], + 'a rotated code cannot be redeemed' +); + +SELECT lives_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'email', decode(repeat('05', 32), 'hex'), 'ciphertext-only', decode(repeat('15', 32), 'hex'), 1, + '60000000-0000-4000-8000-000000000008', NULL + )$$, + 'a member can create a single-use email invitation' +); +RESET ROLE; +SELECT set_config( + 'test.email_invite_id', + (SELECT id::text FROM private.workspace_invites WHERE secret_digest = decode(repeat('05', 32), 'hex')), + true +); +SELECT results_eq( + $$SELECT expires_at > now() + interval '6 days 23 hours 59 minutes' + AND expires_at <= now() + interval '7 days 1 minute' + AND max_uses = 1 + AND email_delivered_at IS NULL + FROM private.workspace_invites WHERE secret_digest = decode(repeat('05', 32), 'hex')$$, + ARRAY[true], + 'email invitations begin pending, are single-use, and expire in seven days' +); +UPDATE private.workspace_invites +SET expires_at = now() - interval '1 second' +WHERE id = current_setting('test.email_invite_id')::uuid; +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT public.mark_workspace_invite_email_delivered( + '61111111-1111-4111-8111-111111111111', current_setting('test.email_invite_id')::uuid + )$$, + 'P0001', 'workspace unavailable', + 'the delivery marker rejects an expired pending invitation' +); +RESET ROLE; +SELECT results_eq( + $$SELECT email_delivered_at IS NULL FROM private.workspace_invites + WHERE id = current_setting('test.email_invite_id')::uuid$$, + ARRAY[true], + 'expired delivery rejection keeps the invitation pending' +); +UPDATE private.workspace_invites +SET expires_at = now() + interval '7 days' +WHERE id = current_setting('test.email_invite_id')::uuid; +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.mark_workspace_invite_email_delivered( + '61111111-1111-4111-8111-111111111111', current_setting('test.email_invite_id')::uuid + )$$, + '42501', 'service role required', + 'the delivery marker independently rejects a missing service-role claim' +); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT public.mark_workspace_invite_email_delivered( + '62222222-2222-4222-8222-222222222222', current_setting('test.email_invite_id')::uuid + )$$, + 'P0001', 'workspace unavailable', + 'the delivery marker rejects an actor who did not issue the invitation' +); +SELECT throws_ok( + $$SELECT public.mark_workspace_invite_email_delivered( + '61111111-1111-4111-8111-111111111111', 'ffffffff-ffff-4fff-8fff-ffffffffffff' + )$$, + 'P0001', 'workspace unavailable', + 'the delivery marker rejects an unknown invitation' +); +SELECT results_eq( + $$SELECT public.mark_workspace_invite_email_delivered( + '61111111-1111-4111-8111-111111111111', current_setting('test.email_invite_id')::uuid + )$$, + ARRAY[true], + 'the service role marks the issuing actor email invitation delivered' +); +RESET ROLE; +SELECT set_config( + 'test.email_delivered_at', + (SELECT email_delivered_at::text FROM private.workspace_invites + WHERE id = current_setting('test.email_invite_id')::uuid), + true +); +SELECT ok( + current_setting('test.email_delivered_at') <> '', + 'accepted relay delivery persists a delivered timestamp' +); +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT public.mark_workspace_invite_email_delivered( + '61111111-1111-4111-8111-111111111111', current_setting('test.email_invite_id')::uuid + ), + (SELECT email_delivered_at::text = current_setting('test.email_delivered_at') + FROM private.workspace_invites + WHERE id = current_setting('test.email_invite_id')::uuid)$$, + $$VALUES (true, true)$$, + 'delivered marking is idempotent and keeps the original timestamp' +); +RESET ROLE; +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '64444444-4444-4444-8444-444444444444', decode(repeat('05', 32), 'hex'), + decode(repeat('99', 32), 'hex'), decode(repeat('24', 32), 'hex'), + '60000000-0000-4000-8000-000000000009' + )$$, + ARRAY['unavailable'::text], + 'email redemption rejects the wrong verified Auth email hash' +); +RESET ROLE; +SELECT results_eq( + $$SELECT use_count FROM private.workspace_invites + WHERE secret_digest = decode(repeat('05', 32), 'hex')$$, + ARRAY[0], + 'wrong-email redemption consumes no invite use' +); + +UPDATE private.workspace_invites +SET expires_at = now() - interval '1 second' +WHERE secret_digest = decode(repeat('04', 32), 'hex'); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '64444444-4444-4444-8444-444444444444', decode(repeat('04', 32), 'hex'), + decode(repeat('14', 32), 'hex'), decode(repeat('24', 32), 'hex'), + '60000000-0000-4000-8000-000000000010' + )$$, + ARRAY['unavailable'::text], + 'an expired code cannot be redeemed' +); + +RESET ROLE; +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT lives_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'email', decode(repeat('0c', 32), 'hex'), 'removed-member-ciphertext', + decode(repeat('13', 32), 'hex'), 1, + '60000000-0000-4000-8000-000000000026', NULL + )$$, + 'a live email invitation can target the member before removal' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '61111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT public.change_workspace_access( + current_setting('test.workspace_id')::uuid, 'remove', + '63333333-3333-4333-8333-333333333333', 0, + '60000000-0000-4000-8000-000000000011' +); + +RESET ROLE; +SELECT results_eq( + $$SELECT count(*)::bigint FROM private.workspace_invites + WHERE workspace_id = current_setting('test.workspace_id')::uuid + AND revoked_at IS NULL$$, + ARRAY[0::bigint], + 'member removal revokes every live invitation' +); + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('04', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000012' + )$$, + ARRAY['unavailable'::text], + 'a removed user cannot rejoin with an old code' +); + +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('0c', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000027' + )$$, + ARRAY['unavailable'::text], + 'a removed user cannot rejoin with an old email invitation' +); + +SELECT lives_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('06', 32), 'hex'), NULL, NULL, 1, + '60000000-0000-4000-8000-000000000013', NULL + )$$, + 'a newly created invite can restore removed membership' +); +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '63333333-3333-4333-8333-333333333333', decode(repeat('06', 32), 'hex'), + decode(repeat('13', 32), 'hex'), decode(repeat('23', 32), 'hex'), + '60000000-0000-4000-8000-000000000014' + )$$, + ARRAY['accepted'::text], + 'the explicit new invite restores membership' +); + +RESET ROLE; +INSERT INTO private.workspace_invite_attempts (actor_id, ip_hash, success, created_at) +SELECT '64444444-4444-4444-8444-444444444444', decode(repeat('24', 32), 'hex'), false, now() +FROM generate_series(1, 10); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT status FROM public.redeem_workspace_invite( + '64444444-4444-4444-8444-444444444444', decode(repeat('ff', 32), 'hex'), + decode(repeat('14', 32), 'hex'), decode(repeat('24', 32), 'hex'), + '60000000-0000-4000-8000-000000000015' + )$$, + ARRAY['rate_limited'::text], + 'ten actor or trusted-proxy failures block redemption for fifteen minutes' +); + +RESET ROLE; +ALTER TABLE private.workspace_invite_attempts RENAME TO workspace_invite_attempts_unavailable; +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT * FROM public.redeem_workspace_invite( + '62222222-2222-4222-8222-222222222222', decode(repeat('ff', 32), 'hex'), + decode(repeat('12', 32), 'hex'), decode(repeat('22', 32), 'hex'), + '60000000-0000-4000-8000-000000000016' + )$$, + '42P01', NULL, + 'redemption fails closed when its attempt store is unavailable' +); +RESET ROLE; +ALTER TABLE private.workspace_invite_attempts_unavailable RENAME TO workspace_invite_attempts; + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('07', 32), 'hex'), NULL, NULL, 30, + '60000000-0000-4000-8000-000000000017', NULL + )$$, + 'P0001', 'invite capacity exceeded', + 'a code cannot exceed remaining workspace capacity' +); + +RESET ROLE; +INSERT INTO private.workspace_invites ( + workspace_id, kind, secret_digest, issued_by, expires_at, max_uses +) +SELECT current_setting('test.workspace_id')::uuid, 'code', digest(i::text, 'sha256'), + '61111111-1111-4111-8111-111111111111', now() + interval '48 hours', 1 +FROM generate_series(1, 19) AS i; +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT * FROM public.create_workspace_invite( + '61111111-1111-4111-8111-111111111111', current_setting('test.workspace_id')::uuid, + 'code', decode(repeat('08', 32), 'hex'), NULL, NULL, 1, + '60000000-0000-4000-8000-000000000018', NULL + )$$, + 'P0001', 'invite rate limited', + 'a member cannot create more than twenty invitations per hour' +); + +RESET ROLE; +INSERT INTO public.workspaces ( + id, owner_id, motion, initial_language, create_request_id +) VALUES ( + '65555555-5555-4555-8555-555555555555', + '61111111-1111-4111-8111-111111111111', + 'Daily invite limit', 'en', '60000000-0000-4000-8000-000000000022' +); +INSERT INTO public.workspace_members (workspace_id, user_id) +VALUES ('65555555-5555-4555-8555-555555555555', '62222222-2222-4222-8222-222222222222'); +INSERT INTO private.workspace_invites ( + workspace_id, kind, secret_digest, expires_at, max_uses +) +SELECT '65555555-5555-4555-8555-555555555555', 'code', + digest('daily-' || i::text, 'sha256'), now() + interval '48 hours', 1 +FROM generate_series(1, 50) AS i; + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT * FROM public.create_workspace_invite( + '62222222-2222-4222-8222-222222222222', '65555555-5555-4555-8555-555555555555', + 'code', decode(repeat('0a', 32), 'hex'), NULL, NULL, 1, + '60000000-0000-4000-8000-000000000023', NULL + )$$, + 'P0001', 'invite rate limited', + 'a workspace cannot create more than fifty invitations per day' +); + +RESET ROLE; +INSERT INTO public.workspaces ( + id, owner_id, motion, initial_language, create_request_id +) VALUES ( + '66666666-6666-4666-8666-666666666666', + '61111111-1111-4111-8111-111111111111', + 'Full workspace', 'en', '60000000-0000-4000-8000-000000000024' +); +INSERT INTO auth.users (id) +SELECT md5('workspace-cap-' || i::text)::uuid FROM generate_series(1, 28) AS i; +INSERT INTO public.workspace_members (workspace_id, user_id) +VALUES + ('66666666-6666-4666-8666-666666666666', '61111111-1111-4111-8111-111111111111'), + ('66666666-6666-4666-8666-666666666666', '62222222-2222-4222-8222-222222222222'); +INSERT INTO public.workspace_members (workspace_id, user_id) +SELECT '66666666-6666-4666-8666-666666666666', md5('workspace-cap-' || i::text)::uuid +FROM generate_series(1, 28) AS i; + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT throws_ok( + $$SELECT * FROM public.create_workspace_invite( + '62222222-2222-4222-8222-222222222222', '66666666-6666-4666-8666-666666666666', + 'code', decode(repeat('0b', 32), 'hex'), NULL, NULL, 1, + '60000000-0000-4000-8000-000000000025', NULL + )$$, + 'P0001', 'invite capacity exceeded', + 'a workspace already at thirty members rejects new invitations' +); + +RESET ROLE; +SELECT ok( + NOT EXISTS ( + SELECT 1 FROM public.workspace_audit_events + WHERE action LIKE 'invite_%' + AND row_to_json(workspace_audit_events)::text ~* '(owner@example|member@example|ciphertext-only|02020202|198\\.51)' + ), + 'member-visible invite audit events contain no token, email, IP, or ciphertext' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM information_schema.tables + WHERE table_schema = 'public' AND table_name IN ('teams', 'workspace_teams')$$, + ARRAY[0::bigint], + 'invitations add workspace membership without a Team entity' +); + +SELECT * FROM finish(); +ROLLBACK;