From 5fe7b6777205a4d46e4f34dd50fa4ba2ae801dd1 Mon Sep 17 00:00:00 2001 From: John Thomson Date: Tue, 14 Jul 2026 19:14:25 -0500 Subject: [PATCH] Cloud TC server (Devin review slice): schema, RLS, RPCs, pgTAP + edge functions Standalone review slice of the Cloud Team Collections server layer (Supabase schema/RLS/RPCs with pgTAP tests, plus the checkin/download edge functions), extracted from cloud-collections for focused automated review. Not for merge. Co-Authored-By: Claude Fable 5 --- supabase/.gitignore | 5 + supabase/config.toml | 187 ++++ supabase/functions/.gitkeep | 0 supabase/functions/_shared/env.ts | 83 ++ supabase/functions/_shared/errors.ts | 39 + supabase/functions/_shared/handler.ts | 58 ++ supabase/functions/_shared/invariants.test.ts | 98 ++ supabase/functions/_shared/rpc.ts | 101 ++ supabase/functions/_shared/s3.test.ts | 227 +++++ supabase/functions/_shared/s3.ts | 230 +++++ supabase/functions/_shared/test_support.ts | 113 ++ .../functions/checkin-abort/index.test.ts | 97 ++ supabase/functions/checkin-abort/index.ts | 24 + .../functions/checkin-finish/index.test.ts | 226 ++++ supabase/functions/checkin-finish/index.ts | 114 +++ .../functions/checkin-start/index.test.ts | 246 +++++ supabase/functions/checkin-start/index.ts | 95 ++ .../collection-files-finish/index.test.ts | 136 +++ .../collection-files-finish/index.ts | 81 ++ .../collection-files-start/index.test.ts | 114 +++ .../functions/collection-files-start/index.ts | 66 ++ .../functions/download-start/index.test.ts | 95 ++ supabase/functions/download-start/index.ts | 31 + .../migrations/20260706000001_tc_schema.sql | 557 ++++++++++ supabase/migrations/20260706000002_tc_rls.sql | 229 +++++ .../migrations/20260706000003_tc_rpcs.sql | 963 ++++++++++++++++++ ...0260706000004_tc_checkin_txn_functions.sql | 842 +++++++++++++++ .../20260707000005_tc_get_book_manifest.sql | 77 ++ .../20260707000006_tc_locked_by_display.sql | 344 +++++++ .../20260709000007_tc_checkout_takeover.sql | 127 +++ .../20260711000001_tc_reap_counter_fix.sql | 49 + ...20260711000002_tc_takeover_error_codes.sql | 95 ++ .../20260711000003_tc_locked_seat.sql | 454 +++++++++ .../20260713000001_tc_member_display_name.sql | 261 +++++ ...260713000002_tc_members_pending_uq_fix.sql | 27 + supabase/snippets/.gitkeep | 0 supabase/tests/01_tc_schema_test.sql | 464 +++++++++ .../tests/02_tc_checkout_takeover_test.sql | 230 +++++ .../tests/03_tc_member_display_name_test.sql | 294 ++++++ 39 files changed, 7479 insertions(+) create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/functions/.gitkeep create mode 100644 supabase/functions/_shared/env.ts create mode 100644 supabase/functions/_shared/errors.ts create mode 100644 supabase/functions/_shared/handler.ts create mode 100644 supabase/functions/_shared/invariants.test.ts create mode 100644 supabase/functions/_shared/rpc.ts create mode 100644 supabase/functions/_shared/s3.test.ts create mode 100644 supabase/functions/_shared/s3.ts create mode 100644 supabase/functions/_shared/test_support.ts create mode 100644 supabase/functions/checkin-abort/index.test.ts create mode 100644 supabase/functions/checkin-abort/index.ts create mode 100644 supabase/functions/checkin-finish/index.test.ts create mode 100644 supabase/functions/checkin-finish/index.ts create mode 100644 supabase/functions/checkin-start/index.test.ts create mode 100644 supabase/functions/checkin-start/index.ts create mode 100644 supabase/functions/collection-files-finish/index.test.ts create mode 100644 supabase/functions/collection-files-finish/index.ts create mode 100644 supabase/functions/collection-files-start/index.test.ts create mode 100644 supabase/functions/collection-files-start/index.ts create mode 100644 supabase/functions/download-start/index.test.ts create mode 100644 supabase/functions/download-start/index.ts create mode 100644 supabase/migrations/20260706000001_tc_schema.sql create mode 100644 supabase/migrations/20260706000002_tc_rls.sql create mode 100644 supabase/migrations/20260706000003_tc_rpcs.sql create mode 100644 supabase/migrations/20260706000004_tc_checkin_txn_functions.sql create mode 100644 supabase/migrations/20260707000005_tc_get_book_manifest.sql create mode 100644 supabase/migrations/20260707000006_tc_locked_by_display.sql create mode 100644 supabase/migrations/20260709000007_tc_checkout_takeover.sql create mode 100644 supabase/migrations/20260711000001_tc_reap_counter_fix.sql create mode 100644 supabase/migrations/20260711000002_tc_takeover_error_codes.sql create mode 100644 supabase/migrations/20260711000003_tc_locked_seat.sql create mode 100644 supabase/migrations/20260713000001_tc_member_display_name.sql create mode 100644 supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql create mode 100644 supabase/snippets/.gitkeep create mode 100644 supabase/tests/01_tc_schema_test.sql create mode 100644 supabase/tests/02_tc_checkout_takeover_test.sql create mode 100644 supabase/tests/03_tc_member_display_name_test.sql diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 000000000000..7690614d5873 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,5 @@ +# Supabase CLI local state — never commit +.branches/ +.temp/ +# snippets/ and functions/ hold committed content (.gitkeep at minimum): Podman does not +# auto-create missing bind-mount directories, so fresh clones must already have them. diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 000000000000..55608c9adcbd --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,187 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "bloom-team-collections" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get +# API endpoints. `public` and `graphql_public` schemas are always included. +schemas = ["public", "graphql_public", "tc"] +# Extra schemas to add to the search_path of every request. `public` is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload +# size for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run +# `SHOW server_version;` on the remote database to check. +major_version = 15 + +# Dev seed: creates the standard dev users (admin/alice/bob@dev.local) — see server/dev/README.md +[db.seed] +enabled = true +sql_paths = ["../server/dev/seed.sql"] + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv6) +# ip_version = "IPv6" +# The minimum JWT expiry to allow for realtime subscriptions (in seconds). +# min_message_expiry_ms = 1000 +# Maximum number of channels per client. +# max_channels_per_client = 100 +# Timeout for auth checks (in milliseconds). +# experimental_broadcast_ack_ms = 300 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, +# they are monitored, and you can view the emails that would have been sent from the web +# interface on inbucket. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +smtp_port = 54325 +pop3_port = 54326 + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[storage.image_transformation] +enabled = true + +# Uncomment to use a custom domain for storage uploads +# [storage.s3_protocol] +# enabled = true +# endpoint = "env(SUPABASE_STORAGE_S3_ENDPOINT)" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs +# used in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 +# week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows session invalidation from server-side. (default: true) +enable_signup = true +# IMPORTANT: for Cloud TC dev, auto-confirm is ON so any login is "verified" +# (mimics a backend that accepts any sign-in). Real Firebase/BloomLibrary auth plugs +# in behind CloudAuth later; tc.jwt_email_verified() on the server handles both shapes. +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false # dev: auto-confirm = no email loop needed + +[auth.sms] +enable_signup = false + +[auth.external.apple] +enabled = false + +[auth.external.azure] +enabled = false + +[auth.external.bitbucket] +enabled = false + +[auth.external.discord] +enabled = false + +[auth.external.facebook] +enabled = false + +[auth.external.figma] +enabled = false + +[auth.external.github] +enabled = false + +[auth.external.gitlab] +enabled = false + +[auth.external.google] +enabled = false + +[auth.external.keycloak] +enabled = false + +[auth.external.linkedin_oidc] +enabled = false + +[auth.external.notion] +enabled = false + +[auth.external.twitch] +enabled = false + +[auth.external.twitter] +enabled = false + +[auth.external.slack_oidc] +enabled = false + +[auth.external.spotify] +enabled = false + +[auth.external.workos] +enabled = false + +[auth.external.zoom] +enabled = false + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload (recommended for development), `per_worker` for production. +# NOTE (task 02, 6 Jul 2026): `oneshot` re-transpiles/type-checks the whole module graph +# — including the heavy npm:@aws-sdk/client-s3 + client-sts imports in +# supabase/functions/_shared/s3.ts — on EVERY request. That cold-start regularly exceeds +# the edge-runtime's fixed ~10s worker-boot timeout (InvalidWorkerCreation: "worker did +# not respond in time"), which made checkin-start/etc. fail every call locally. `per_worker` +# compiles once and reuses the worker, which is also a closer match to how these functions +# actually run in production. See server/dev/README.md. +policy = "per_worker" +inspector_port = 8083 + +[analytics] +enabled = false +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" diff --git a/supabase/functions/.gitkeep b/supabase/functions/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/supabase/functions/_shared/env.ts b/supabase/functions/_shared/env.ts new file mode 100644 index 000000000000..c8f80d04ceff --- /dev/null +++ b/supabase/functions/_shared/env.ts @@ -0,0 +1,83 @@ +// Environment / configuration helpers shared by every Cloud Team Collections edge +// function. Centralised here so the dev/prod credential-provider seam (see +// server/dev/DEV-CREDENTIALS.md) lives in exactly one place. + +/** Reads a required env var; throws (fails fast) if missing — see AGENTS.md testing + * philosophy: don't silently work around a missing dependency. */ +export const requireEnv = (name: string): string => { + const value = Deno.env.get(name); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +}; + +/** Optional env var with a default. */ +export const optionalEnv = (name: string, fallback: string): string => + Deno.env.get(name) ?? fallback; + +/** True when running against the LOCAL stack (MinIO via AssumeRole, on the developer's own + * machine) rather than real AWS. Mirrors server/dev/DEV-CREDENTIALS.md's + * `BLOOM_CLOUD_LOCAL_MODE` secret. Named "local", not "dev": in the Bloom ecosystem "dev" + * conventionally means a REAL, hosted, reserved-for-testing deployment (dev.bloomlibrary.org), + * and a future cloud-collections project will likely carry "dev" in its name — a hosted "dev" + * deployment runs with this flag FALSE. */ +export const isLocalMode = (): boolean => + optionalEnv("BLOOM_CLOUD_LOCAL_MODE", "false") === "true"; + +/** Supabase project URL + anon key, auto-injected by the Supabase CLI/platform into + * every edge function's environment — used to call PostgREST RPCs with the caller's + * OWN forwarded JWT (never the service-role key; see the migration's header comment + * for why that is both sufficient and correct here). */ +export const supabaseUrl = (): string => requireEnv("SUPABASE_URL"); +export const supabaseAnonKey = (): string => requireEnv("SUPABASE_ANON_KEY"); + +/** S3 / MinIO connection details. */ +export interface S3Env { + endpoint: string; + bucket: string; + region: string; + forcePathStyle: boolean; +} + +export const s3Env = (): S3Env => ({ + endpoint: requireEnv("BLOOM_S3_ENDPOINT"), + bucket: requireEnv("BLOOM_S3_BUCKET"), + region: optionalEnv("BLOOM_S3_REGION", "us-east-1"), + // MinIO requires path-style; real AWS uses virtual-hosted style. Local mode always + // forces path-style; production can opt out via BLOOM_S3_FORCE_PATH_STYLE=false. + forcePathStyle: + isLocalMode() || + optionalEnv("BLOOM_S3_FORCE_PATH_STYLE", "true") === "true", +}); + +/** Root/admin credentials used ONLY server-side (never sent to a client) to call + * MinIO's AssumeRole STS endpoint in local mode, and for admin S3 operations + * (HeadObject / GetObjectAttributes verification, .manifest.json writes). */ +export const minioRootCredentials = () => ({ + accessKeyId: optionalEnv("BLOOM_S3_ROOT_ACCESS_KEY", "minioadmin"), + secretAccessKey: optionalEnv("BLOOM_S3_ROOT_SECRET_KEY", "minioadmin"), +}); + +/** Production broker configuration: the "assume-only" IAM user's credentials and the + * bloom-teams-broker role ARN it is allowed to assume (see server/provision-aws.ts). */ +export const prodBrokerConfig = () => ({ + roleArn: requireEnv("BLOOM_TEAMS_BROKER_ROLE_ARN"), + accessKeyId: requireEnv("AWS_ACCESS_KEY_ID"), + secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY"), + region: optionalEnv("AWS_REGION", "us-east-1"), +}); + +/** Admin S3 credentials for server-side verification/writes (HeadObject, + * GetObjectAttributes, .manifest.json PUT). In local mode this is the MinIO root user; in + * production a dedicated admin/broker identity with full bucket access (distinct + * from the narrowly-scoped session credentials handed to clients). */ +export const adminS3Credentials = () => { + if (isLocalMode()) { + return minioRootCredentials(); + } + return { + accessKeyId: requireEnv("BLOOM_S3_ADMIN_ACCESS_KEY"), + secretAccessKey: requireEnv("BLOOM_S3_ADMIN_SECRET_KEY"), + }; +}; diff --git a/supabase/functions/_shared/errors.ts b/supabase/functions/_shared/errors.ts new file mode 100644 index 000000000000..c9176d9d9c49 --- /dev/null +++ b/supabase/functions/_shared/errors.ts @@ -0,0 +1,39 @@ +// Small JSON-response helpers shared by every edge function. Kept deliberately +// un-clever: the contract error shapes in CONTRACTS.md are just +// `{ error: "", ...extra }`, so we build exactly that. + +export const CORS_HEADERS: Record = { + // Bloom Desktop calls these from the C# process (HttpClient), not a browser page, + // so CORS is not actually load-bearing — but it's harmless and cheap to allow, in + // case any tooling (smoke tests, future browser-based admin UI) calls in directly. + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +export const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + +/** Standard error envelope: `{ error: "", ...extra }`. */ +export const errorResponse = ( + status: number, + error: string, + extra?: Record, +): Response => jsonResponse(status, { error, ...extra }); + +export class HttpError extends Error { + readonly status: number; + readonly body: Record; + constructor(status: number, body: Record) { + super(typeof body.error === "string" ? body.error : `HTTP ${status}`); + this.status = status; + this.body = body; + } + toResponse(): Response { + return jsonResponse(this.status, this.body); + } +} diff --git a/supabase/functions/_shared/handler.ts b/supabase/functions/_shared/handler.ts new file mode 100644 index 000000000000..39e489a0f630 --- /dev/null +++ b/supabase/functions/_shared/handler.ts @@ -0,0 +1,58 @@ +// Common Deno.serve wrapper for every Cloud TC edge function: CORS preflight, +// method + JSON-body handling, and turning a thrown HttpError into the right JSON +// response. Keeps each function's index.ts focused on its own request shape. +import { CORS_HEADERS, errorResponse, HttpError } from "./errors.ts"; + +export type JsonHandler = ( + req: Request, + body: Record, +) => Promise; + +/** Fails fast (throws HttpError 400) if `value` is missing/empty — used for the + * required fields in each function's request body. */ +export const requireField = ( + body: Record, + name: string, +): T => { + const value = body[name]; + if (value === undefined || value === null || value === "") { + throw new HttpError(400, { error: "invalid_request", field: name }); + } + return value as T; +}; + +export const optionalField = ( + body: Record, + name: string, +): T | null => (body[name] as T | undefined) ?? null; + +export const serveJsonPost = (handler: JsonHandler): void => { + Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: CORS_HEADERS }); + } + if (req.method !== "POST") { + return errorResponse(405, "method_not_allowed"); + } + + let body: Record; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return errorResponse(400, "invalid_json"); + } + + try { + return await handler(req, body); + } catch (err) { + if (err instanceof HttpError) { + return err.toResponse(); + } + console.error("Unhandled error:", err); + return errorResponse(500, "internal_error", { + message: String(err), + }); + } + }); +}; diff --git a/supabase/functions/_shared/invariants.test.ts b/supabase/functions/_shared/invariants.test.ts new file mode 100644 index 000000000000..45c6604b6c66 --- /dev/null +++ b/supabase/functions/_shared/invariants.test.ts @@ -0,0 +1,98 @@ +// Cross-file invariant (task 02 acceptance criterion): a check-in/collection-files +// transaction's lifetime must be strictly less than S3's noncurrent-version-expiry +// lifecycle floor. If it weren't, a slow/stalled transaction could still be trying to +// reference an object version that MinIO/S3 has already permanently deleted as a +// noncurrent version — silently corrupting an in-progress check-in. +// +// There is no single source-of-truth constant shared by the SQL (Postgres) and the S3 +// lifecycle config (docker-compose.yml locally; server/provision-aws in production), so +// this test re-parses both source files at test time rather than hardcoding both sides +// — that way it actually fails loudly if someone changes one without the other, per +// AGENTS.md's testing philosophy ("test failures indicate what went wrong"). +import { assert, assertEquals } from "jsr:@std/assert@1"; + +const repoRoot = new URL("../../../", import.meta.url); // supabase/functions/_shared/ -> repo root + +const readText = async (relativePath: string): Promise => + await Deno.readTextFile(new URL(relativePath, repoRoot)); + +/** Extracts every `INTERVAL ' hours'` used as a checkin/collection-files transaction + * expiry (in the schema's initial DEFAULT and both transaction functions' resume-path + * updates) and asserts they all agree — a mismatch would mean a resumed transaction + * silently gets a different lifetime than a fresh one. */ +Deno.test( + "invariant: transaction expiry intervals are internally consistent (48h everywhere)", + async () => { + const schema = await readText( + "supabase/migrations/20260706000001_tc_schema.sql", + ); + const txFunctions = await readText( + "supabase/migrations/20260706000004_tc_checkin_txn_functions.sql", + ); + + const schemaHours = [ + ...schema.matchAll( + /expires_at\s+timestamptz[^,]*INTERVAL '(\d+) hours'/g, + ), + ].map((m) => Number(m[1])); + const resumeHours = [ + ...txFunctions.matchAll( + /expires_at\s*=\s*now\(\)\s*\+\s*INTERVAL '(\d+) hours'/g, + ), + ].map((m) => Number(m[1])); + + assert( + schemaHours.length >= 1, + "expected to find at least one expires_at DEFAULT INTERVAL in the schema", + ); + assert( + resumeHours.length >= 2, + "expected checkin_start_tx AND collection_files_start_tx resume updates", + ); + + const allHours = [...schemaHours, ...resumeHours]; + for (const h of allHours) { + assertEquals( + h, + allHours[0], + `all transaction-expiry intervals must match; found ${allHours.join(", ")}`, + ); + } + }, +); + +Deno.test( + "invariant: transaction lifetime (48h) is strictly less than the S3 noncurrent-version-expiry floor (7d, dev MinIO)", + async () => { + const schema = await readText( + "supabase/migrations/20260706000001_tc_schema.sql", + ); + const compose = await readText("server/dev/docker-compose.yml"); + + const txHoursMatch = schema.match( + /expires_at\s+timestamptz[^,]*INTERVAL '(\d+) hours'/, + ); + assert( + txHoursMatch, + "could not find the checkin_transactions expires_at default in the schema migration", + ); + const txHours = Number(txHoursMatch[1]); + + const noncurrentDaysMatch = compose.match( + /--noncurrent-expire-days (\d+)/, + ); + assert( + noncurrentDaysMatch, + "could not find --noncurrent-expire-days in server/dev/docker-compose.yml", + ); + const noncurrentDays = Number(noncurrentDaysMatch[1]); + + assert( + txHours < noncurrentDays * 24, + `CONTRACTS.md invariant violated: transaction lifetime (${txHours}h) must be strictly ` + + `less than the noncurrent-version-expiry floor (${noncurrentDays}d = ${noncurrentDays * 24}h) — ` + + `otherwise a version an in-flight transaction still references could be permanently deleted ` + + `out from under it.`, + ); + }, +); diff --git a/supabase/functions/_shared/rpc.ts b/supabase/functions/_shared/rpc.ts new file mode 100644 index 000000000000..9832afc0cfd3 --- /dev/null +++ b/supabase/functions/_shared/rpc.ts @@ -0,0 +1,101 @@ +// Thin PostgREST client for the `tc` schema. Edge functions are thin orchestrators: +// the heavy per-request DB logic (locking, manifest diffing, atomic multi-table +// writes) lives in SECURITY DEFINER Postgres functions +// (supabase/migrations/20260706000004_tc_checkin_txn_functions.sql) that we call +// here via RPC, ALWAYS forwarding the caller's own Authorization header rather than +// a service-role key — see that migration's header comment for why this is correct +// (PostgREST resolves auth.jwt() from the Authorization bearer token regardless of +// which apikey is presented, and every function re-validates internally). +import { HttpError } from "./errors.ts"; +import { supabaseAnonKey, supabaseUrl } from "./env.ts"; + +/** Extracts the incoming request's bearer token unmodified. Edge functions run with + * verify_jwt = true by default (config.toml), so by the time our code runs the + * platform has already rejected missing/invalid tokens with 401 — this is just for + * forwarding, not for verification. */ +export const authHeader = (req: Request): string => { + const value = req.headers.get("Authorization"); + if (!value) { + // Should not happen given verify_jwt = true, but fail loudly rather than + // silently calling PostgREST unauthenticated if it ever does. + throw new HttpError(401, { error: "unauthenticated" }); + } + return value; +}; + +/** Calls a `tc` schema RPC (POST /rest/v1/rpc/) with the caller's own JWT. + * On a PostgREST error response, unwraps the PT### HTTP-status convention (see the + * migration header comment) and the JSON-encoded `message` field into a proper + * HttpError with the structured contract error body. */ +export const callTcRpc = async ( + req: Request, + fnName: string, + args: Record, +): Promise => { + const res = await fetch(`${supabaseUrl()}/rest/v1/rpc/${fnName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: supabaseAnonKey(), + Authorization: authHeader(req), + "Content-Profile": "tc", + "Accept-Profile": "tc", + }, + body: JSON.stringify(args), + }); + + const text = await res.text(); + const parsed = text ? JSON.parse(text) : null; + + if (!res.ok) { + throw new HttpError(res.status, parsePostgrestErrorBody(parsed)); + } + + return parsed as T; +}; + +/** Plain PostgREST read (GET /rest/v1/?...) under RLS with the caller's own + * JWT — used where a full RPC round-trip isn't needed (e.g. checkin-finish reading + * back its own open transaction row to learn which paths to verify against S3). */ +export const selectTcRow = async >( + req: Request, + table: string, + query: string, +): Promise => { + const res = await fetch(`${supabaseUrl()}/rest/v1/${table}?${query}`, { + method: "GET", + headers: { + apikey: supabaseAnonKey(), + Authorization: authHeader(req), + "Accept-Profile": "tc", + }, + }); + const text = await res.text(); + const parsed = text ? JSON.parse(text) : []; + if (!res.ok) { + throw new HttpError(res.status, parsePostgrestErrorBody(parsed)); + } + const rows = parsed as T[]; + return rows[0] ?? null; +}; + +/** PostgREST wraps our `RAISE EXCEPTION '%', ` message in + * `{ message, code, details, hint }`. Our SQL always raises a JSON-object message + * (e.g. `{"error":"LockHeldByOther","holder":{...}}`), so unwrap it back into a + * flat contract-shaped body. Falls back gracefully for anything unexpected + * (a Postgres-native error, a constraint violation, etc.) rather than throwing. */ +const parsePostgrestErrorBody = (body: unknown): Record => { + const message = (body as { message?: unknown } | null)?.message; + if (typeof message === "string") { + try { + const inner = JSON.parse(message); + if (inner && typeof inner === "object") { + return inner as Record; + } + } catch { + // Not JSON — fall through to the generic shape below. + } + return { error: message }; + } + return { error: "internal_error", details: body }; +}; diff --git a/supabase/functions/_shared/s3.test.ts b/supabase/functions/_shared/s3.test.ts new file mode 100644 index 000000000000..d461976baabe --- /dev/null +++ b/supabase/functions/_shared/s3.test.ts @@ -0,0 +1,227 @@ +// Unit tests for _shared/s3.ts: the dev-mode MinIO AssumeRole credential seam, checksum +// verification, and the manifest backup write. S3Client/STSClient calls are mocked via +// aws-sdk-client-mock (patches the SDK client prototypes) rather than a live MinIO — +// the live-integration spike (see the task's Progress log) already exercised the real +// MinIO AssumeRole round-trip; these tests cover the wiring/logic cheaply and +// hermetically instead. +import { assertEquals, assertExists } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "npm:@aws-sdk/client-s3@3"; +import { setTestEnv } from "./test_support.ts"; + +setTestEnv(); + +// deno-lint-ignore no-import-assign +const { + getScopedCredentials, + hexToBase64, + verifyUploadedObject, + writeManifestBackup, +} = await import("./s3.ts"); + +Deno.test( + "hexToBase64 round-trips a known SHA-256 hex digest to its base64 form", + () => { + // sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + const hex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const b64 = hexToBase64(hex); + // Sanity: decoding the base64 back to bytes and re-hex-encoding must match the input. + const decoded = atob(b64); + const rehexed = Array.from(decoded) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + assertEquals( + rehexed, + hex, + "hexToBase64 must be a faithful hex->base64 re-encoding, not a no-op", + ); + }, +); + +Deno.test( + "getScopedCredentials (dev mode) calls MinIO AssumeRole and returns the STS response shape", + async () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({ + Credentials: { + AccessKeyId: "MOCK_ACCESS_KEY", + SecretAccessKey: "MOCK_SECRET", + SessionToken: "MOCK_SESSION_TOKEN", + Expiration: new Date("2026-01-01T01:00:00Z"), + }, + }); + + const result = await getScopedCredentials("tc/col1/books/book1/", [ + "s3:PutObject", + ]); + + assertEquals(result.bucket, "bloom-teams-test"); + assertEquals(result.prefix, "tc/col1/books/book1/"); + assertEquals(result.credentials.accessKeyId, "MOCK_ACCESS_KEY"); + assertEquals(result.credentials.sessionToken, "MOCK_SESSION_TOKEN"); + assertEquals(result.credentials.expiration, "2026-01-01T01:00:00.000Z"); + + // Dev mode must NOT pass a session Policy (see s3.ts's comment: MinIO dev creds get + // the parent identity's full access; scoping is a production-only measure). + const call = stsMock.commandCalls(AssumeRoleCommand)[0]; + assertEquals(call.args[0].input.Policy, undefined); + + stsMock.restore(); + }, +); + +Deno.test( + "getScopedCredentials (dev mode) throws if MinIO AssumeRole returns no credentials", + async () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({}); // no Credentials field + + let threw = false; + try { + await getScopedCredentials("tc/col1/books/book1/", [ + "s3:PutObject", + ]); + } catch (err) { + threw = true; + assertEquals( + (err as Error).message, + "MinIO AssumeRole did not return credentials", + ); + } + assertEquals( + threw, + true, + "must fail fast rather than return a half-formed credential object", + ); + + stsMock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns the version-id + checksum when they match", + async () => { + const s3Mock = mockClient(S3Client); + const expectedHex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(expectedHex), + VersionId: "v1", + }); + + const client = new S3Client({ region: "us-east-1" }); + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/book.htm", + expectedHex, + ); + + assertExists(result); + assertEquals(result!.s3VersionId, "v1"); + + s3Mock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns null when the stored checksum does not match", + async () => { + const s3Mock = mockClient(S3Client); + const wrongHex = + "0000000000000000000000000000000000000000000000000000000000000000"; + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(wrongHex), + VersionId: "v1", + }); + + const client = new S3Client({ region: "us-east-1" }); + const expectedHex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/book.htm", + expectedHex, + ); + + assertEquals( + result, + null, + "a checksum mismatch must be reported as unverified, not thrown", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns null (not throw) when the object is missing", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).rejects(new Error("NotFound")); + + const client = new S3Client({ region: "us-east-1" }); + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/missing.htm", + "abc", + ); + + assertEquals( + result, + null, + "a missing object must surface as 'not verified', not an unhandled rejection", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "verifyUploadedObject returns null when VersionId is absent (unversioned bucket misconfig)", + async () => { + const s3Mock = mockClient(S3Client); + const hex = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + s3Mock + .on(HeadObjectCommand) + .resolves({ ChecksumSHA256: hexToBase64(hex) }); // no VersionId + + const client = new S3Client({ region: "us-east-1" }); + const result = await verifyUploadedObject( + client, + "bucket", + "tc/col1/books/book1/book.htm", + hex, + ); + + assertEquals(result, null); + + s3Mock.restore(); + }, +); + +Deno.test( + "writeManifestBackup never throws even when the PUT fails (best-effort backup)", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(PutObjectCommand).rejects(new Error("simulated S3 outage")); + + const client = new S3Client({ region: "us-east-1" }); + // Must resolve, not reject — checkin-finish's response to the client must not + // depend on this backup write succeeding (see s3.ts's doc comment). + await writeManifestBackup(client, "bucket", "tc/col1/books/book1/", { + some: "manifest", + }); + + s3Mock.restore(); + }, +); diff --git a/supabase/functions/_shared/s3.ts b/supabase/functions/_shared/s3.ts new file mode 100644 index 000000000000..902e9c1d4591 --- /dev/null +++ b/supabase/functions/_shared/s3.ts @@ -0,0 +1,230 @@ +// S3 credential-issuance seam (dev MinIO AssumeRole vs. production AWS STS) and the +// small set of admin S3 operations edge functions need (checksum verification, +// version-id capture, .manifest.json writes). Only the functions under +// supabase/functions/** ever construct clients with these credentials — see +// server/dev/DEV-CREDENTIALS.md for the full spec this implements. +import { STSClient, AssumeRoleCommand } from "npm:@aws-sdk/client-sts@3"; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "npm:@aws-sdk/client-s3@3"; +import { + adminS3Credentials, + isLocalMode, + minioRootCredentials, + prodBrokerConfig, + s3Env, +} from "./env.ts"; + +export interface ScopedCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; // ISO 8601 +} + +export interface S3Descriptor { + bucket: string; + region: string; + prefix: string; + credentials: ScopedCredentials; +} + +const DEFAULT_DURATION_SECONDS = 3600; + +/** Builds an IAM-style session policy scoped to one prefix. MinIO's AssumeRole + * accepts a Policy parameter but — per DEV-CREDENTIALS.md — local mode deliberately + * does NOT pass one (prefix scoping is a production security measure; MinIO local + * creds get the parent/root identity's full access). Only used in prod mode. */ +const buildSessionPolicy = ( + bucket: string, + prefix: string, + actions: string[], +): string => + JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: actions, + Resource: [`arn:aws:s3:::${bucket}/${prefix}*`], + }, + ], + }); + +/** Issues short-lived, per-request S3 credentials scoped to `prefix`, in the + * IDENTICAL shape whether backed by MinIO (local) or real AWS STS (production) — see + * DEV-CREDENTIALS.md. `actions` is only enforced in production (a real IAM session + * policy); local mode gets full-bucket root-derived temp credentials. */ +export const getScopedCredentials = async ( + prefix: string, + actions: string[], + durationSeconds: number = DEFAULT_DURATION_SECONDS, +): Promise => { + const env = s3Env(); + + if (isLocalMode()) { + const root = minioRootCredentials(); + const sts = new STSClient({ + endpoint: env.endpoint, + region: env.region, + credentials: { + accessKeyId: root.accessKeyId, + secretAccessKey: root.secretAccessKey, + }, + }); + // MinIO's AssumeRole ignores RoleArn/RoleSessionName content but the AWS SDK's + // TS types require them — see DEV-CREDENTIALS.md's "empirical correction": + // local mode MUST mint real MinIO temp creds (fabricated tokens are rejected). + const result = await sts.send( + new AssumeRoleCommand({ + RoleArn: + "arn:aws:iam::000000000000:role/bloom-teams-dev-placeholder", + RoleSessionName: `bloom-dev-${crypto.randomUUID()}`, + DurationSeconds: durationSeconds, + }), + ); + const creds = result.Credentials; + if ( + !creds?.AccessKeyId || + !creds.SecretAccessKey || + !creds.SessionToken + ) { + throw new Error("MinIO AssumeRole did not return credentials"); + } + return { + bucket: env.bucket, + region: env.region, + prefix, + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: ( + creds.Expiration ?? + new Date(Date.now() + durationSeconds * 1000) + ).toISOString(), + }, + }; + } + + const broker = prodBrokerConfig(); + const sts = new STSClient({ + region: broker.region, + credentials: { + accessKeyId: broker.accessKeyId, + secretAccessKey: broker.secretAccessKey, + }, + }); + const result = await sts.send( + new AssumeRoleCommand({ + RoleArn: broker.roleArn, + RoleSessionName: `bloom-teams-${crypto.randomUUID()}`, + DurationSeconds: durationSeconds, + Policy: buildSessionPolicy(env.bucket, prefix, actions), + }), + ); + const creds = result.Credentials; + if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) { + throw new Error("AWS STS AssumeRole did not return credentials"); + } + return { + bucket: env.bucket, + region: env.region, + prefix, + credentials: { + accessKeyId: creds.AccessKeyId, + secretAccessKey: creds.SecretAccessKey, + sessionToken: creds.SessionToken, + expiration: ( + creds.Expiration ?? + new Date(Date.now() + durationSeconds * 1000) + ).toISOString(), + }, + }; +}; + +/** Admin S3 client for server-side-only operations: HeadObject checksum/version-id + * verification and .manifest.json backup writes. Never handed to a client. */ +export const adminS3Client = (): S3Client => { + const env = s3Env(); + const creds = adminS3Credentials(); + return new S3Client({ + endpoint: env.endpoint, + region: env.region, + forcePathStyle: env.forcePathStyle, + credentials: { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + }, + }); +}; + +export interface VerifiedUpload { + s3VersionId: string; + sha256Base64: string; +} + +/** Base64 <-> hex helpers. S3's x-amz-checksum-sha256 attribute is base64; the + * manifest's `sha256` field (matching the C# client, which uses + * Convert.ToHexString/SHA256) is lowercase hex. */ +export const hexToBase64 = (hex: string): string => { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return btoa(String.fromCharCode(...bytes)); +}; + +/** HeadObject with ChecksumMode ENABLED to read back the stored SHA-256 attribute + * and capture the S3 version-id created by the just-completed PUT. Returns null if + * the object is missing or its checksum doesn't match `expectedSha256Hex`. */ +export const verifyUploadedObject = async ( + client: S3Client, + bucket: string, + key: string, + expectedSha256Hex: string, +): Promise => { + try { + const head = await client.send( + new HeadObjectCommand({ + Bucket: bucket, + Key: key, + ChecksumMode: "ENABLED", + }), + ); + const actual = head.ChecksumSHA256; + const expected = hexToBase64(expectedSha256Hex); + if (!actual || actual !== expected || !head.VersionId) { + return null; + } + return { s3VersionId: head.VersionId, sha256Base64: actual }; + } catch { + // NotFound (or any other S3 error) — treat as "not verified", let the caller + // report it as a missing/bad upload rather than propagating a 5xx. + return null; + } +}; + +/** Best-effort `.manifest.json` backup write (CONTRACTS.md S3 layout). Never + * throws — this is a convenience backup, not the source of truth (that's the DB). */ +export const writeManifestBackup = async ( + client: S3Client, + bucket: string, + prefix: string, + manifest: unknown, +): Promise => { + try { + await client.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${prefix}.manifest.json`, + Body: JSON.stringify(manifest, null, 2), + ContentType: "application/json", + }), + ); + } catch (err) { + console.error("writeManifestBackup failed (non-fatal):", err); + } +}; diff --git a/supabase/functions/_shared/test_support.ts b/supabase/functions/_shared/test_support.ts new file mode 100644 index 000000000000..e2aec5a540d8 --- /dev/null +++ b/supabase/functions/_shared/test_support.ts @@ -0,0 +1,113 @@ +// Shared helpers for the Deno unit tests under supabase/functions/**. Every edge +// function handler talks to two external systems: PostgREST (via plain `fetch` in +// rpc.ts) and S3/STS (via the AWS SDK clients in s3.ts). This module gives tests a +// cheap way to fake both without a live stack: +// - `withMockFetch` temporarily replaces `globalThis.fetch` for the duration of one +// test, then restores the original — used to fake PostgREST RPC/table responses. +// - `setTestEnv` populates the env vars `_shared/env.ts` requires, so importing a +// handler module never throws "missing required environment variable" at test time. +// - `mockRequest` builds a same-shaped `Request` the handler expects (JSON body + +// bearer token), matching what `serveJsonPost` hands the handler after parsing. +// S3/STS mocking uses `aws-sdk-client-mock` directly in each test file (it patches the +// SDK client prototypes, so no indirection is needed here). + +/** Sets every env var `_shared/env.ts` reads, with dev-mode-friendly defaults. Call + * this at the top of every test file (module scope) — handlers call `s3Env()` / + * `supabaseUrl()` etc. eagerly inside the request path, not at import time, but it's + * simplest to just always have them present. */ +export const setTestEnv = (): void => { + Deno.env.set("SUPABASE_URL", "http://127.0.0.1:54321"); + Deno.env.set("SUPABASE_ANON_KEY", "test-anon-key"); + Deno.env.set("BLOOM_CLOUD_LOCAL_MODE", "true"); + Deno.env.set("BLOOM_S3_ENDPOINT", "http://minio.invalid:9000"); + Deno.env.set("BLOOM_S3_BUCKET", "bloom-teams-test"); + Deno.env.set("BLOOM_S3_REGION", "us-east-1"); + Deno.env.set("BLOOM_S3_ROOT_ACCESS_KEY", "test-root-key"); + Deno.env.set("BLOOM_S3_ROOT_SECRET_KEY", "test-root-secret"); +}; + +/** Builds a `Request` shaped like what `serveJsonPost` passes to a handler: JSON body, + * bearer auth header. Handlers only read `req.headers`, never re-read the body (that + * was already consumed by `serveJsonPost`), so this is safe to pass directly. */ +export const mockRequest = (body: unknown, token = "test-jwt"): Request => + new Request("http://localhost/test", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + +/** Calls an exported handler the same way `serveJsonPost` (handler.ts) would: a thrown + * `HttpError` becomes its JSON error `Response` instead of an unhandled rejection. + * Handlers are written to throw (see AGENTS.md's fail-fast testing philosophy) and rely + * on `serveJsonPost`'s try/catch to translate that — tests calling the handler directly + * (bypassing serveJsonPost) need the same translation, or every error-path test would + * have to catch HttpError itself. */ +export const callHandler = async ( + handler: (req: Request, body: Record) => Promise, + req: Request, + body: Record, +): Promise => { + // Imported lazily to avoid every test file needing its own import of HttpError. + const { HttpError } = await import("./errors.ts"); + try { + return await handler(req, body); + } catch (err) { + if (err instanceof HttpError) { + return err.toResponse(); + } + throw err; + } +}; + +export type FetchStub = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** Replaces `globalThis.fetch` with `stub` for the duration of `fn`, always restoring + * the original afterward (even if `fn` throws) — used to fake PostgREST responses from + * `_shared/rpc.ts`'s `callTcRpc`/`selectTcRow`, which call the real `fetch`. */ +export const withMockFetch = async ( + stub: FetchStub, + fn: () => Promise, +): Promise => { + const original = globalThis.fetch; + // deno-lint-ignore no-explicit-any + globalThis.fetch = stub as any; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +}; + +/** A `fetch` stub that dispatches by matching a substring against the request URL, in + * order — the first match wins. Each route returns `{ status, body }`; `body` is + * JSON-stringified (or `""` for `null`, matching how PostgREST responds to e.g. a + * successful RPC with no return value). */ +export const routedFetchStub = ( + routes: { when: string; status: number; body: unknown }[], +): FetchStub => { + return (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const route = routes.find((r) => url.includes(r.when)); + if (!route) { + throw new Error(`routedFetchStub: no route matched for ${url}`); + } + const text = route.body === null ? "" : JSON.stringify(route.body); + return Promise.resolve( + new Response(text, { + status: route.status, + headers: { "Content-Type": "application/json" }, + }), + ); + }; +}; diff --git a/supabase/functions/checkin-abort/index.test.ts b/supabase/functions/checkin-abort/index.test.ts new file mode 100644 index 000000000000..6a02b8c1adc6 --- /dev/null +++ b/supabase/functions/checkin-abort/index.test.ts @@ -0,0 +1,97 @@ +// Unit tests for checkin-abort's handler — the thinnest of the six, so mostly pinning +// down request validation and RPC error/argument passthrough. +import { assertEquals } from "jsr:@std/assert@1"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +Deno.test( + "checkin-abort: happy path calls checkin_abort_tx with the transactionId and returns 200 {}", + async () => { + let sentArgs: unknown; + const fetchStub: typeof fetch = (input, init) => { + sentArgs = JSON.parse(String(init?.body)); + return Promise.resolve(new Response("", { status: 200 })); + }; + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 200); + assertEquals(await res.json(), {}); + assertEquals(sentArgs, { p_transaction_id: "tx-1" }); + }, +); + +Deno.test( + "checkin-abort: missing transactionId -> 400 before any RPC call", + async () => { + const fetchStub = routedFetchStub([]); // must not be called + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({}), {}), + ); + + assertEquals(res.status, 400); + const json = await res.json(); + assertEquals(json.error, "invalid_request"); + assertEquals(json.field, "transactionId"); + }, +); + +Deno.test( + "checkin-abort: RPC 409 already_finished passes through", + async () => { + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_abort_tx", + status: 409, + body: { + message: JSON.stringify({ error: "already_finished" }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 409); + assertEquals((await res.json()).error, "already_finished"); + }, +); + +Deno.test( + "checkin-abort: RPC 404 transaction_not_found passes through", + async () => { + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_abort_tx", + status: 404, + body: { + message: JSON.stringify({ error: "transaction_not_found" }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "nope" }), { + transactionId: "nope", + }), + ); + + assertEquals(res.status, 404); + assertEquals((await res.json()).error, "transaction_not_found"); + }, +); diff --git a/supabase/functions/checkin-abort/index.ts b/supabase/functions/checkin-abort/index.ts new file mode 100644 index 000000000000..af42a02a2163 --- /dev/null +++ b/supabase/functions/checkin-abort/index.ts @@ -0,0 +1,24 @@ +// POST /functions/v1/checkin-abort — CONTRACTS.md §checkin-abort +// Req: { transactionId } -> 200. Idempotent; rolls back a never-finished new book. +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const transactionId = requireField(body, "transactionId"); + + await callTcRpc(req, "checkin_abort_tx", { + p_transaction_id: transactionId, + }); + + return jsonResponse(200, {}); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/checkin-finish/index.test.ts b/supabase/functions/checkin-finish/index.test.ts new file mode 100644 index 000000000000..915e13c5fb75 --- /dev/null +++ b/supabase/functions/checkin-finish/index.test.ts @@ -0,0 +1,226 @@ +// Unit tests for checkin-finish's handler. PostgREST calls (both the selectTcRow reads +// of checkin_transactions/books and the checkin_finish_tx RPC) are faked via a fetch +// stub; the S3 HeadObject checksum verification is faked via aws-sdk-client-mock. The +// live-integration spike already exercises the real MinIO checksum round-trip; these +// tests pin down the handler's own wiring: which paths get verified, what gets sent to +// the RPC, and error passthrough. +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "npm:@aws-sdk/client-s3@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); +const { hexToBase64 } = await import("../_shared/s3.ts"); + +const TX_ROW = { + id: "tx-1", + collection_id: "col-1", + book_id: "book-1", + changed_paths: ["book.htm"], + proposed_files: [ + { + path: "book.htm", + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + size: 0, + }, + ], + status: "open", +}; +const BOOK_ROW = { instance_id: "instance-1" }; + +const routesFor = ( + txRow: unknown, + bookRow: unknown, + finishStatus: number, + finishBody: unknown, +) => + routedFetchStub([ + { + when: "checkin_transactions", + status: txRow ? 200 : 200, + body: txRow ? [txRow] : [], + }, + { + when: "/books?", + status: bookRow ? 200 : 200, + body: bookRow ? [bookRow] : [], + }, + { + when: "rpc/checkin_finish_tx", + status: finishStatus, + body: finishBody, + }, + ]); + +Deno.test( + "checkin-finish: happy path verifies checksum, captures version-id, returns versionId+seq", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-42", + }); + + const fetchStub = routesFor(TX_ROW, BOOK_ROW, 200, { + versionId: "ver-1", + seq: 3, + }); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.versionId, "ver-1"); + assertEquals(json.seq, 3); + + // The HeadObject must have been issued against the right key (prefix + path). + const headCalls = s3Mock.commandCalls(HeadObjectCommand); + assertEquals(headCalls.length, 1); + assertEquals( + headCalls[0].args[0].input.Key, + "tc/col-1/books/instance-1/book.htm", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "checkin-finish: unverifiable upload is omitted from `captured` (DB RPC reports MissingOrBadUploads)", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).rejects(new Error("NotFound")); // never uploaded + + let capturedSentToRpc: unknown; + const fetchStub: typeof fetch = (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url.includes("checkin_transactions")) { + return Promise.resolve( + new Response(JSON.stringify([TX_ROW]), { status: 200 }), + ); + } + if (url.includes("/books?")) { + return Promise.resolve( + new Response(JSON.stringify([BOOK_ROW]), { status: 200 }), + ); + } + if (url.includes("rpc/checkin_finish_tx")) { + capturedSentToRpc = JSON.parse(String(init?.body)).p_captured; + return Promise.resolve( + new Response( + JSON.stringify({ + message: JSON.stringify({ + error: "MissingOrBadUploads", + paths: ["book.htm"], + }), + }), + { status: 409 }, + ), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }; + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "MissingOrBadUploads"); + assertEquals(json.paths, ["book.htm"]); + // The edge function must not have fabricated a captured entry for the failed path — + // it lets the DB-side check (which independently re-verifies) report the gap. + assertEquals( + capturedSentToRpc, + [], + "unverified path must not appear in p_captured", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "checkin-finish: unknown transactionId -> 404 before any S3 call", + async () => { + const s3Mock = mockClient(S3Client); + const fetchStub = routedFetchStub([ + { when: "checkin_transactions", status: 200, body: [] }, // selectTcRow finds nothing + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "nope" }), { + transactionId: "nope", + }), + ); + + assertEquals(res.status, 404); + const json = await res.json(); + assertEquals(json.error, "transaction_not_found"); + assertEquals(s3Mock.commandCalls(HeadObjectCommand).length, 0); + + s3Mock.restore(); + }, +); + +Deno.test( + "checkin-finish: writes a .manifest.json backup when the RPC returns one, but it never affects the response", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-1", + }); + s3Mock + .on(PutObjectCommand) + .rejects(new Error("simulated backup-write outage")); + + const fetchStub = routesFor(TX_ROW, BOOK_ROW, 200, { + versionId: "ver-9", + seq: 9, + manifest: [{ path: "book.htm" }], + }); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + // Even though the manifest backup PUT fails, the client-facing response must be + // unaffected (writeManifestBackup is documented best-effort/never-throws). + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.versionId, "ver-9"); + assertEquals( + "manifest" in json, + false, + "the internal `manifest` field must never leak to the client", + ); + + s3Mock.restore(); + }, +); diff --git a/supabase/functions/checkin-finish/index.ts b/supabase/functions/checkin-finish/index.ts new file mode 100644 index 000000000000..2d292405f570 --- /dev/null +++ b/supabase/functions/checkin-finish/index.ts @@ -0,0 +1,114 @@ +// POST /functions/v1/checkin-finish — CONTRACTS.md §checkin-finish +// +// Req: { transactionId, comment?, keepCheckedOut? } +// Verifies each changed object's sha256 attribute server-side, captures S3 +// version-ids, then commits the single atomic DB transaction (tc.checkin_finish_tx). +// 200: { versionId, seq } · 409 MissingOrBadUploads { paths[] } · 410 expired. +import { + optionalField, + requireField, + serveJsonPost, +} from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc, selectTcRow } from "../_shared/rpc.ts"; +import { + adminS3Client, + verifyUploadedObject, + writeManifestBackup, +} from "../_shared/s3.ts"; +import { s3Env } from "../_shared/env.ts"; + +interface CheckinTransactionRow { + id: string; + collection_id: string; + book_id: string; + changed_paths: string[]; + proposed_files: { path: string; sha256: string; size: number }[]; + status: string; +} + +interface BookRow { + instance_id: string; +} + +interface CheckinFinishResult { + versionId: string; + seq: number; + manifest?: unknown; +} + +// Exported so Deno tests can import and call it directly with a mocked Request, +// without triggering Deno.serve — see the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const transactionId = requireField(body, "transactionId"); + const comment = optionalField(body, "comment"); + const keepCheckedOut = Boolean(body["keepCheckedOut"]); + + // Read back our own open transaction (RLS restricts this to rows we started) so + // we know which S3 objects to verify — checkin-finish's request body carries no + // file list per CONTRACTS.md. + const tx = await selectTcRow( + req, + "checkin_transactions", + `id=eq.${transactionId}&select=id,collection_id,book_id,changed_paths,proposed_files,status`, + ); + if (!tx) { + throw new HttpError(404, { error: "transaction_not_found" }); + } + + const book = await selectTcRow( + req, + "books", + `id=eq.${tx.book_id}&select=instance_id`, + ); + if (!book) { + throw new HttpError(404, { error: "book_not_found" }); + } + + const prefix = `tc/${tx.collection_id}/books/${book.instance_id}/`; + const { bucket } = s3Env(); + const client = adminS3Client(); + + // Verify every changed path against S3; anything that fails is simply omitted + // from `captured` — tc.checkin_finish_tx independently detects and reports the + // gap as 409 MissingOrBadUploads, so there is no duplicated logic here. + const captured: { path: string; s3VersionId: string }[] = []; + for (const path of tx.changed_paths) { + const proposed = tx.proposed_files.find((f) => f.path === path); + if (!proposed) continue; // defensive; DB-side check still catches this as missing + const verified = await verifyUploadedObject( + client, + bucket, + `${prefix}${path}`, + proposed.sha256, + ); + if (verified) { + captured.push({ path, s3VersionId: verified.s3VersionId }); + } + } + + const result = await callTcRpc( + req, + "checkin_finish_tx", + { + p_transaction_id: transactionId, + p_comment: comment, + p_keep_checked_out: keepCheckedOut, + p_captured: captured, + }, + ); + + if (result.manifest) { + // Best-effort backup; never blocks the response (see writeManifestBackup). + await writeManifestBackup(client, bucket, prefix, result.manifest); + } + + return jsonResponse(200, { versionId: result.versionId, seq: result.seq }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/checkin-start/index.test.ts b/supabase/functions/checkin-start/index.test.ts new file mode 100644 index 000000000000..e11605f19078 --- /dev/null +++ b/supabase/functions/checkin-start/index.test.ts @@ -0,0 +1,246 @@ +// Unit tests for checkin-start's handler: PostgREST RPC calls are faked via a fetch +// stub (see _shared/test_support.ts); the MinIO/STS AssumeRole call is faked via +// aws-sdk-client-mock. The live-integration spike (task's Progress log) already +// exercises the real stack end-to-end; these tests pin down the handler's own request +// validation, RPC-argument wiring, and error passthrough cheaply and hermetically. +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +const VALID_BODY = { + collectionId: "11111111-1111-1111-1111-111111111111", + bookId: null, + bookInstanceId: "22222222-2222-2222-2222-222222222222", + proposedName: "My Book", + checksum: "abc123", + clientVersion: "1.0.0", + files: [{ path: "book.htm", sha256: "deadbeef", size: 42 }], +}; + +const stubAssumeRole = () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({ + Credentials: { + AccessKeyId: "K", + SecretAccessKey: "S", + SessionToken: "T", + Expiration: new Date("2026-01-01T01:00:00Z"), + }, + }); + return stsMock; +}; + +Deno.test( + "checkin-start: happy path returns transactionId, changedPaths and scoped s3 creds", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 200, + body: { + transactionId: "tx-1", + bookId: "book-1", + changedPaths: ["book.htm"], + }, + }, + { + when: "rest/v1/books", + status: 200, + body: [{ instance_id: "22222222-2222-2222-2222-222222222222" }], + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.transactionId, "tx-1"); + assertEquals(json.changedPaths, ["book.htm"]); + assertEquals(json.s3.bucket, "bloom-teams-test"); + // CONTRACTS.md: creds scoped to tc/{cid}/books/{instance_id}/* — the instance id read + // back from the books row, not the request body (see the mismatch test below). + assertEquals( + json.s3.prefix, + "tc/11111111-1111-1111-1111-111111111111/books/22222222-2222-2222-2222-222222222222/", + ); + assertEquals(json.s3.credentials.sessionToken, "T"); + // bookId is internal-only — CONTRACTS.md's 200 response never exposes it (that's + // what makes an uncommitted new book invisible until the client re-learns its id + // via get_collection_state/checkout_book). + assertEquals("bookId" in json, false); + + stsMock.restore(); + }, +); + +Deno.test( + "checkin-start: s3 prefix comes from the DB-canonical instance_id, not the caller-supplied bookInstanceId", + async () => { + const stsMock = stubAssumeRole(); + // The caller claims an instance id belonging to some OTHER book; the books row for the + // book actually being checked in has a different (canonical) instance id. The issued + // credentials must be scoped to the canonical one. + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 200, + body: { + transactionId: "tx-1", + bookId: "book-1", + changedPaths: ["book.htm"], + }, + }, + { + when: "rest/v1/books", + status: 200, + body: [{ instance_id: "99999999-9999-9999-9999-999999999999" }], + }, + ]); + const bodyWithForeignInstanceId = { + ...VALID_BODY, + bookId: "book-1", + bookInstanceId: "22222222-2222-2222-2222-222222222222", + }; + + const res = await withMockFetch(fetchStub, () => + callHandler( + handler, + mockRequest(bodyWithForeignInstanceId), + bodyWithForeignInstanceId, + ), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals( + json.s3.prefix, + "tc/11111111-1111-1111-1111-111111111111/books/99999999-9999-9999-9999-999999999999/", + ); + + stsMock.restore(); + }, +); + +Deno.test( + "checkin-start: missing required field -> 400 before any RPC/S3 call", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); // must not be called + + const { checksum: _omit, ...bodyMissingChecksum } = VALID_BODY; + const res = await withMockFetch(fetchStub, () => + callHandler( + handler, + mockRequest(bodyMissingChecksum), + bodyMissingChecksum, + ), + ); + + assertEquals(res.status, 400); + const json = await res.json(); + assertEquals(json.error, "invalid_request"); + assertEquals(json.field, "checksum"); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must fail validation before touching S3", + ); + + stsMock.restore(); + }, +); + +Deno.test( + "checkin-start: RPC 409 LockHeldByOther passes through with the holder payload intact", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 409, + // PostgREST wraps our RAISE EXCEPTION message like this — see rpc.ts's + // parsePostgrestErrorBody, which unwraps it back to the flat contract shape. + body: { + message: JSON.stringify({ + error: "LockHeldByOther", + holder: { userId: "u2" }, + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "LockHeldByOther"); + assertEquals(json.holder.userId, "u2"); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must not issue S3 creds when the RPC itself failed", + ); + + stsMock.restore(); + }, +); + +Deno.test("checkin-start: RPC 426 ClientOutOfDate passes through", async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/checkin_start_tx", + status: 426, + body: { + message: JSON.stringify({ + error: "ClientOutOfDate", + minVersion: "2.0.0", + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 426); + const json = await res.json(); + assertEquals(json.error, "ClientOutOfDate"); + assertEquals(json.minVersion, "2.0.0"); + + stsMock.restore(); +}); + +Deno.test( + "checkin-start: missing Authorization header -> 401 (defensive; platform normally rejects first)", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); + + const reqNoAuth = new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(VALID_BODY), + }); + const res = await callHandler(handler, reqNoAuth, VALID_BODY); + + assertEquals(res.status, 401); + stsMock.restore(); + }, +); diff --git a/supabase/functions/checkin-start/index.ts b/supabase/functions/checkin-start/index.ts new file mode 100644 index 000000000000..51e5786e547f --- /dev/null +++ b/supabase/functions/checkin-start/index.ts @@ -0,0 +1,95 @@ +// POST /functions/v1/checkin-start — CONTRACTS.md §checkin-start +// +// Req: { collectionId, bookId?, bookInstanceId, proposedName, baseVersionId?, +// checksum, clientVersion, files: [{path, sha256, size}] } +// 200: { transactionId, changedPaths[], s3: { bucket, region, prefix, credentials } } +// Errors: 401/403 · 409 LockHeldByOther/BaseVersionSuperseded/NameConflict · 426 ClientOutOfDate. +import { + optionalField, + requireField, + serveJsonPost, +} from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc, selectTcRow } from "../_shared/rpc.ts"; +import { getScopedCredentials } from "../_shared/s3.ts"; + +interface CheckinStartResult { + transactionId: string; + bookId: string; + changedPaths: string[]; +} + +interface BookRow { + instance_id: string; +} + +// The credentials handed to the client for the duration of a check-in: they need to +// PUT new/changed content and read back what's already there (e.g. to resume after +// an interrupted upload). +const CHECKIN_ACTIONS = [ + "s3:PutObject", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", +]; + +// Exported (rather than only passed inline to serveJsonPost) so Deno tests can import +// and call it directly with a mocked Request, without triggering Deno.serve — see the +// `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const collectionId = requireField(body, "collectionId"); + const bookInstanceId = requireField(body, "bookInstanceId"); + const proposedName = requireField(body, "proposedName"); + const checksum = requireField(body, "checksum"); + const clientVersion = requireField(body, "clientVersion"); + const files = requireField(body, "files"); + const bookId = optionalField(body, "bookId"); + const baseVersionId = optionalField(body, "baseVersionId"); + + const result = await callTcRpc( + req, + "checkin_start_tx", + { + p_collection_id: collectionId, + p_book_id: bookId, + p_book_instance_id: bookInstanceId, + p_proposed_name: proposedName, + p_base_version_id: baseVersionId, + p_checksum: checksum, + p_client_version: clientVersion, + p_files: files, + }, + ); + + // Scope the S3 credentials to the DB-canonical instance_id, never the caller-supplied + // bookInstanceId: for an existing book checkin_start_tx validates/locks by bookId and + // ignores the client's instance id, so using the client value here would let a member + // request write credentials for an arbitrary book's prefix (Greptile P1, PR #8048). + // Same read-back pattern as checkin-finish; for the new-book path the row was just + // created from bookInstanceId, so the canonical value is identical. + const book = await selectTcRow( + req, + "books", + `id=eq.${result.bookId}&select=instance_id`, + ); + if (!book) { + throw new HttpError(404, { error: "book_not_found" }); + } + + const prefix = `tc/${collectionId}/books/${book.instance_id}/`; + const s3 = await getScopedCredentials(prefix, CHECKIN_ACTIONS); + + return jsonResponse(200, { + transactionId: result.transactionId, + changedPaths: result.changedPaths, + s3, + }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/collection-files-finish/index.test.ts b/supabase/functions/collection-files-finish/index.test.ts new file mode 100644 index 000000000000..7d8c1f0b9d38 --- /dev/null +++ b/supabase/functions/collection-files-finish/index.test.ts @@ -0,0 +1,136 @@ +// Unit tests for collection-files-finish's handler — same shape as checkin-finish but +// scoped to a collection_file_transactions row instead of a book. +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { HeadObjectCommand, S3Client } from "npm:@aws-sdk/client-s3@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); +const { hexToBase64 } = await import("../_shared/s3.ts"); + +const TX_ROW = { + id: "tx-1", + collection_id: "col-1", + group_key: "allowed-words", + changed_paths: ["allowed.txt"], + proposed_files: [ + { + path: "allowed.txt", + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + size: 0, + }, + ], +}; + +Deno.test( + "collection-files-finish: happy path verifies checksum under collectionFiles/{groupKey}/ and returns version", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-1", + }); + + const fetchStub = routedFetchStub([ + { + when: "collection_file_transactions", + status: 200, + body: [TX_ROW], + }, + { + when: "rpc/collection_files_finish_tx", + status: 200, + body: { version: 4 }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 200); + assertEquals((await res.json()).version, 4); + + const headCalls = s3Mock.commandCalls(HeadObjectCommand); + assertEquals(headCalls.length, 1); + assertEquals( + headCalls[0].args[0].input.Key, + "tc/col-1/collectionFiles/allowed-words/allowed.txt", + ); + + s3Mock.restore(); + }, +); + +Deno.test( + "collection-files-finish: RPC 409 VersionConflict at finish time (repo-wins) passes through", + async () => { + const s3Mock = mockClient(S3Client); + s3Mock.on(HeadObjectCommand).resolves({ + ChecksumSHA256: hexToBase64(TX_ROW.proposed_files[0].sha256), + VersionId: "v-1", + }); + + const fetchStub = routedFetchStub([ + { + when: "collection_file_transactions", + status: 200, + body: [TX_ROW], + }, + { + when: "rpc/collection_files_finish_tx", + status: 409, + body: { + message: JSON.stringify({ + error: "VersionConflict", + currentVersion: 7, + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "tx-1" }), { + transactionId: "tx-1", + }), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "VersionConflict"); + assertEquals(json.currentVersion, 7); + + s3Mock.restore(); + }, +); + +Deno.test( + "collection-files-finish: unknown transactionId -> 404 before any S3 call", + async () => { + const s3Mock = mockClient(S3Client); + const fetchStub = routedFetchStub([ + { when: "collection_file_transactions", status: 200, body: [] }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ transactionId: "nope" }), { + transactionId: "nope", + }), + ); + + assertEquals(res.status, 404); + assertEquals((await res.json()).error, "transaction_not_found"); + assertEquals(s3Mock.commandCalls(HeadObjectCommand).length, 0); + + s3Mock.restore(); + }, +); diff --git a/supabase/functions/collection-files-finish/index.ts b/supabase/functions/collection-files-finish/index.ts new file mode 100644 index 000000000000..47cc7ef936fa --- /dev/null +++ b/supabase/functions/collection-files-finish/index.ts @@ -0,0 +1,81 @@ +// POST /functions/v1/collection-files-finish — CONTRACTS.md §collection-files-start/finish +// Req: { transactionId } -> bumps the group version atomically. +// 409 VersionConflict ⇒ client pulls first (repo-wins rule); 409 MissingOrBadUploads. +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc, selectTcRow } from "../_shared/rpc.ts"; +import { + adminS3Client, + verifyUploadedObject, + writeManifestBackup, +} from "../_shared/s3.ts"; +import { s3Env } from "../_shared/env.ts"; + +interface CollectionFileTransactionRow { + id: string; + collection_id: string; + group_key: string; + changed_paths: string[]; + proposed_files: { path: string; sha256: string; size: number }[]; +} + +interface CollectionFilesFinishResult { + version: number; + manifest?: unknown; +} + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const transactionId = requireField(body, "transactionId"); + + const tx = await selectTcRow( + req, + "collection_file_transactions", + `id=eq.${transactionId}&select=id,collection_id,group_key,changed_paths,proposed_files`, + ); + if (!tx) { + throw new HttpError(404, { error: "transaction_not_found" }); + } + + const prefix = `tc/${tx.collection_id}/collectionFiles/${tx.group_key}/`; + const { bucket } = s3Env(); + const client = adminS3Client(); + + const captured: { path: string; s3VersionId: string }[] = []; + for (const path of tx.changed_paths) { + const proposed = tx.proposed_files.find((f) => f.path === path); + if (!proposed) continue; + const verified = await verifyUploadedObject( + client, + bucket, + `${prefix}${path}`, + proposed.sha256, + ); + if (verified) { + captured.push({ path, s3VersionId: verified.s3VersionId }); + } + } + + const result = await callTcRpc( + req, + "collection_files_finish_tx", + { + p_transaction_id: transactionId, + p_captured: captured, + }, + ); + + if (result.manifest) { + await writeManifestBackup(client, bucket, prefix, result.manifest); + } + + return jsonResponse(200, { version: result.version }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/collection-files-start/index.test.ts b/supabase/functions/collection-files-start/index.test.ts new file mode 100644 index 000000000000..47e7d4a43e84 --- /dev/null +++ b/supabase/functions/collection-files-start/index.test.ts @@ -0,0 +1,114 @@ +// Unit tests for collection-files-start's handler: groupKey validation, the +// optimistic-version RPC call, and scoped S3 credential issuance. +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +const VALID_BODY = { + collectionId: "col-1", + groupKey: "allowed-words", + expectedVersion: 0, + files: [{ path: "allowed.txt", sha256: "abc", size: 3 }], +}; + +const stubAssumeRole = () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({ + Credentials: { + AccessKeyId: "K", + SecretAccessKey: "S", + SessionToken: "T", + Expiration: new Date("2026-01-01T01:00:00Z"), + }, + }); + return stsMock; +}; + +Deno.test( + "collection-files-start: happy path scopes creds under collectionFiles/{groupKey}/", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/collection_files_start_tx", + status: 200, + body: { transactionId: "tx-1", changedPaths: ["allowed.txt"] }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.transactionId, "tx-1"); + assertEquals(json.s3.prefix, "tc/col-1/collectionFiles/allowed-words/"); + + stsMock.restore(); + }, +); + +Deno.test( + "collection-files-start: invalid groupKey -> 400 before any RPC/S3 call", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); + const badBody = { ...VALID_BODY, groupKey: "not-a-real-group" }; + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(badBody), badBody), + ); + + assertEquals(res.status, 400); + assertEquals((await res.json()).field, "groupKey"); + assertEquals(stsMock.commandCalls(AssumeRoleCommand).length, 0); + + stsMock.restore(); + }, +); + +Deno.test( + "collection-files-start: RPC 409 VersionConflict passes through with currentVersion", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/collection_files_start_tx", + status: 409, + body: { + message: JSON.stringify({ + error: "VersionConflict", + currentVersion: 5, + }), + }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest(VALID_BODY), VALID_BODY), + ); + + assertEquals(res.status, 409); + const json = await res.json(); + assertEquals(json.error, "VersionConflict"); + assertEquals(json.currentVersion, 5); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must not issue creds on a version conflict", + ); + + stsMock.restore(); + }, +); diff --git a/supabase/functions/collection-files-start/index.ts b/supabase/functions/collection-files-start/index.ts new file mode 100644 index 000000000000..87c79e4de445 --- /dev/null +++ b/supabase/functions/collection-files-start/index.ts @@ -0,0 +1,66 @@ +// POST /functions/v1/collection-files-start — CONTRACTS.md §collection-files-start/finish +// Req: { collectionId, groupKey: 'other'|'allowed-words'|'sample-texts', expectedVersion, +// files[] } -> two-phase like check-in. +// 409 VersionConflict ⇒ client pulls first (repo-wins rule). +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { HttpError, jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; +import { getScopedCredentials } from "../_shared/s3.ts"; + +const VALID_GROUP_KEYS = new Set(["other", "allowed-words", "sample-texts"]); + +const COLLECTION_FILES_ACTIONS = [ + "s3:PutObject", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", +]; + +interface CollectionFilesStartResult { + transactionId: string; + changedPaths: string[]; +} + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const collectionId = requireField(body, "collectionId"); + const groupKey = requireField(body, "groupKey"); + const expectedVersion = requireField(body, "expectedVersion"); + const files = requireField(body, "files"); + + if (!VALID_GROUP_KEYS.has(groupKey)) { + throw new HttpError(400, { + error: "invalid_request", + field: "groupKey", + }); + } + + const result = await callTcRpc( + req, + "collection_files_start_tx", + { + p_collection_id: collectionId, + p_group_key: groupKey, + p_expected_version: expectedVersion, + p_files: files, + }, + ); + + const prefix = `tc/${collectionId}/collectionFiles/${groupKey}/`; + const s3 = await getScopedCredentials(prefix, COLLECTION_FILES_ACTIONS); + + return jsonResponse(200, { + transactionId: result.transactionId, + changedPaths: result.changedPaths, + s3, + }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/functions/download-start/index.test.ts b/supabase/functions/download-start/index.test.ts new file mode 100644 index 000000000000..5cb2885fdcc8 --- /dev/null +++ b/supabase/functions/download-start/index.test.ts @@ -0,0 +1,95 @@ +// Unit tests for download-start's handler: membership check via RPC, then read-only +// scoped S3 credentials (GetObject + GetObjectVersion only — see CONTRACTS.md). +import { assertEquals } from "jsr:@std/assert@1"; +import { mockClient } from "npm:aws-sdk-client-mock@4"; +import { AssumeRoleCommand, STSClient } from "npm:@aws-sdk/client-sts@3"; +import { + callHandler, + mockRequest, + routedFetchStub, + setTestEnv, + withMockFetch, +} from "../_shared/test_support.ts"; + +setTestEnv(); +const { handler } = await import("./index.ts"); + +const stubAssumeRole = () => { + const stsMock = mockClient(STSClient); + stsMock.on(AssumeRoleCommand).resolves({ + Credentials: { + AccessKeyId: "K", + SecretAccessKey: "S", + SessionToken: "T", + Expiration: new Date("2026-01-01T01:00:00Z"), + }, + }); + return stsMock; +}; + +Deno.test( + "download-start: happy path returns collection-scoped read-only creds", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { when: "rpc/download_start_check", status: 200, body: null }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ collectionId: "col-1" }), { + collectionId: "col-1", + }), + ); + + assertEquals(res.status, 200); + const json = await res.json(); + assertEquals(json.s3.prefix, "tc/col-1/"); + assertEquals(json.s3.credentials.sessionToken, "T"); + + stsMock.restore(); + }, +); + +Deno.test( + "download-start: not a member -> 403, no S3 credentials issued", + async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([ + { + when: "rpc/download_start_check", + status: 403, + body: { message: JSON.stringify({ error: "not_a_member" }) }, + }, + ]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({ collectionId: "col-1" }), { + collectionId: "col-1", + }), + ); + + assertEquals(res.status, 403); + assertEquals((await res.json()).error, "not_a_member"); + assertEquals( + stsMock.commandCalls(AssumeRoleCommand).length, + 0, + "must not issue creds when membership check fails", + ); + + stsMock.restore(); + }, +); + +Deno.test("download-start: missing collectionId -> 400", async () => { + const stsMock = stubAssumeRole(); + const fetchStub = routedFetchStub([]); + + const res = await withMockFetch(fetchStub, () => + callHandler(handler, mockRequest({}), {}), + ); + + assertEquals(res.status, 400); + assertEquals((await res.json()).field, "collectionId"); + + stsMock.restore(); +}); diff --git a/supabase/functions/download-start/index.ts b/supabase/functions/download-start/index.ts new file mode 100644 index 000000000000..6a50e7c80430 --- /dev/null +++ b/supabase/functions/download-start/index.ts @@ -0,0 +1,31 @@ +// POST /functions/v1/download-start — CONTRACTS.md §download-start +// Req: { collectionId } -> 200 { s3: {...} } read-only creds +// (GetObject + GetObjectVersion) scoped tc/{cid}/*, 1h. +import { requireField, serveJsonPost } from "../_shared/handler.ts"; +import { jsonResponse } from "../_shared/errors.ts"; +import { callTcRpc } from "../_shared/rpc.ts"; +import { getScopedCredentials } from "../_shared/s3.ts"; + +const DOWNLOAD_ACTIONS = ["s3:GetObject", "s3:GetObjectVersion"]; + +// Exported so Deno tests can import and call it directly — see checkin-start/index.ts's +// comment on the `import.meta.main` guard below. +export const handler = async ( + req: Request, + body: Record, +): Promise => { + const collectionId = requireField(body, "collectionId"); + + await callTcRpc(req, "download_start_check", { + p_collection_id: collectionId, + }); + + const prefix = `tc/${collectionId}/`; + const s3 = await getScopedCredentials(prefix, DOWNLOAD_ACTIONS); + + return jsonResponse(200, { s3 }); +}; + +if (import.meta.main) { + serveJsonPost(handler); +} diff --git a/supabase/migrations/20260706000001_tc_schema.sql b/supabase/migrations/20260706000001_tc_schema.sql new file mode 100644 index 000000000000..bc0935f07d31 --- /dev/null +++ b/supabase/migrations/20260706000001_tc_schema.sql @@ -0,0 +1,557 @@ +-- ============================================================================= +-- Migration: tc schema + core tables +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Creates the `tc` schema and all persistent tables: +-- collections, members, books, versions, version_files, +-- collection_file_groups, collection_group_files, +-- color_palette_entries, events, checkin_transactions +-- +-- Design references: +-- Design/CloudTeamCollections.md +-- Design/CloudTeamCollections/CONTRACTS.md +-- Design/CloudTeamCollections/tasks/01-server-schema.md +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Schema +-- --------------------------------------------------------------------------- +CREATE SCHEMA IF NOT EXISTS tc; + +-- Expose tc in the search path for PostgREST (config.toml also sets extra_search_path). +-- Functions are defined in the tc schema explicitly; no implicit search-path dependency. + +-- --------------------------------------------------------------------------- +-- Helper: JWT email-verified check +-- --------------------------------------------------------------------------- +-- THIS IS THE ONE PLACE that reads email-verification off the JWT. +-- Two auth shapes are supported: +-- 1. Firebase ID token (real production): carries `email_verified` boolean in the JWT. +-- 2. Local GoTrue (dev stack, task 11): auto-confirm is ON so every login is confirmed; +-- GoTrue does NOT put `email_verified` in the JWT, but sets `aud = 'authenticated'` +-- and the user record is confirmed. We treat any local-GoTrue user as verified. +-- +-- Callers (claim_memberships, etc.) MUST call this function instead of reading the claim +-- directly so that a future auth change only needs to touch this one function. +CREATE OR REPLACE FUNCTION tc.jwt_email_verified() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + CASE + -- Firebase-style: explicit boolean claim (may arrive as 'true'::text or true::bool) + WHEN (auth.jwt() ->> 'email_verified') IS NOT NULL THEN + (auth.jwt() ->> 'email_verified')::boolean + -- Local GoTrue (dev): no email_verified claim; role = 'authenticated' implies confirmed + WHEN (auth.jwt() ->> 'role') = 'authenticated' THEN + TRUE + ELSE + FALSE + END +$$; + +COMMENT ON FUNCTION tc.jwt_email_verified() IS + 'The ONLY place that decides whether the caller''s email is verified. ' + 'Handles both a Firebase-style email_verified JWT claim and local-GoTrue auto-confirmed ' + 'users (dev stack). All callers must use this function, never the claim directly.'; + +-- --------------------------------------------------------------------------- +-- Helper: current user id from JWT sub claim (TEXT, not uuid) +-- --------------------------------------------------------------------------- +-- User ids are TEXT: Firebase UIDs are ~28 base64 chars; local-GoTrue issues uuid strings. +-- Both fit in TEXT without casting. +CREATE OR REPLACE FUNCTION tc.current_user_id() +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT auth.jwt() ->> 'sub' +$$; + +COMMENT ON FUNCTION tc.current_user_id() IS + 'Returns the caller''s user id from the JWT sub claim as TEXT. ' + 'Firebase UIDs (~28 chars) and local-GoTrue UUIDs both fit in TEXT.'; + +-- --------------------------------------------------------------------------- +-- Helper: current user email from JWT +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.current_user_email() +RETURNS text +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT lower(auth.jwt() ->> 'email') +$$; + +COMMENT ON FUNCTION tc.current_user_email() IS + 'Returns the caller''s email from the JWT, lowercased for case-insensitive comparison.'; + +-- --------------------------------------------------------------------------- +-- collections +-- --------------------------------------------------------------------------- +-- One row per cloud Team Collection. The id is the Bloom CollectionId GUID +-- (same value in TeamCollectionLink.txt: cloud://sil.bloom/collection/). +CREATE TABLE tc.collections ( + id uuid PRIMARY KEY, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + created_by text NOT NULL -- user_id (TEXT; see note above) +); + +COMMENT ON TABLE tc.collections IS + 'One row per cloud Team Collection. id = Bloom CollectionId GUID.'; +COMMENT ON COLUMN tc.collections.id IS + 'The collection UUID — same value as in TeamCollectionLink.txt ' + '(cloud://sil.bloom/collection/).'; +COMMENT ON COLUMN tc.collections.created_by IS + 'TEXT user id (Firebase UID or GoTrue UUID) of the creator.'; + +-- --------------------------------------------------------------------------- +-- members (approved-accounts table) +-- --------------------------------------------------------------------------- +-- Each row is one approved email ↔ collection binding. +-- user_id is NULL until the account holder claims the seat (see claim_memberships RPC). +-- Constraints: +-- • A claimed user may appear at most once per collection (UNIQUE WHERE user_id IS NOT NULL). +-- • At least one admin must remain (enforced by last-admin guard trigger). +CREATE TYPE tc.member_role AS ENUM ('admin', 'member'); + +CREATE TABLE tc.members ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + email text NOT NULL, -- the approved email (lowercase, NFC-normalized on insert) + role tc.member_role NOT NULL DEFAULT 'member', + user_id text, -- NULL until claimed; TEXT (Firebase UID / GoTrue UUID) + added_by text NOT NULL, -- user_id of the admin who added this row + added_at timestamptz NOT NULL DEFAULT now(), + claimed_at timestamptz, + + -- Unique approved email per collection (case-insensitive; enforced via lower() on insert) + CONSTRAINT members_collection_email_uq UNIQUE (collection_id, email), + + -- A claimed user appears at most once per collection + CONSTRAINT members_claimed_user_uq UNIQUE NULLS NOT DISTINCT (collection_id, user_id) +); + +COMMENT ON TABLE tc.members IS + 'Approved-accounts table. Unclaimed rows (user_id IS NULL) are pending until the ' + 'account holder signs in and calls claim_memberships(). ' + 'email is stored lowercase + NFC-normalised.'; +COMMENT ON COLUMN tc.members.user_id IS + 'NULL until the account holder claims the seat. TEXT covers both Firebase UIDs and ' + 'local-GoTrue UUIDs.'; + +CREATE INDEX members_collection_id_idx ON tc.members(collection_id); +CREATE INDEX members_user_id_idx ON tc.members(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX members_email_idx ON tc.members(lower(email)); + +-- --------------------------------------------------------------------------- +-- Trigger: last-admin guard on members +-- --------------------------------------------------------------------------- +-- Prevents the last admin of a collection from being removed or demoted to member. +CREATE OR REPLACE FUNCTION tc.members_last_admin_guard() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + admin_count integer; +BEGIN + -- Fires on DELETE or UPDATE (role change to 'member' / user_id removal) + -- Determine the collection_id being affected + IF TG_OP = 'DELETE' THEN + -- Count remaining admins after this hypothetical delete + SELECT count(*) INTO admin_count + FROM tc.members + WHERE collection_id = OLD.collection_id + AND role = 'admin' + AND id != OLD.id; + + IF admin_count = 0 AND OLD.role = 'admin' THEN + RAISE EXCEPTION 'last_admin_guard: cannot remove the last admin of collection %', + OLD.collection_id + USING ERRCODE = 'P0001'; + END IF; + ELSIF TG_OP = 'UPDATE' THEN + -- Fires when role changes from admin → member + IF OLD.role = 'admin' AND NEW.role = 'member' THEN + SELECT count(*) INTO admin_count + FROM tc.members + WHERE collection_id = OLD.collection_id + AND role = 'admin' + AND id != OLD.id; + + IF admin_count = 0 THEN + RAISE EXCEPTION 'last_admin_guard: cannot demote the last admin of collection %', + OLD.collection_id + USING ERRCODE = 'P0001'; + END IF; + END IF; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +COMMENT ON FUNCTION tc.members_last_admin_guard() IS + 'Trigger function: prevents deleting or demoting the last admin of a collection.'; + +CREATE TRIGGER members_last_admin_guard_tg + BEFORE DELETE OR UPDATE ON tc.members + FOR EACH ROW EXECUTE FUNCTION tc.members_last_admin_guard(); + +-- --------------------------------------------------------------------------- +-- books +-- --------------------------------------------------------------------------- +-- Authoritative server-side state for each book in a collection. +-- Lock columns: locked_by (TEXT user_id), locked_by_machine, locked_at. +-- deleted_at: soft tombstone (set by delete_book, cleared by undelete_book). +-- instance_id: client-generated UUID (stable across renames; keyed in S3). +-- +-- Uniqueness constraints: +-- 1. (collection_id, instance_id) — one S3 prefix per book. +-- 2. Live lower(NFC-normalized name) per collection (tombstoned names reusable). +CREATE TABLE tc.books ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + instance_id uuid NOT NULL, + name text NOT NULL, -- NFC-normalized on insert/update via trigger + current_version_id uuid, -- FK to tc.versions, set after first checkin-finish + current_version_seq bigint, -- denormalised seq for quick status reads + current_checksum text, -- SHA-256 of the current version manifest + locked_by text, -- TEXT user_id; NULL = not checked out + locked_by_machine text, -- machine label provided by the client + locked_at timestamptz, + deleted_at timestamptz, -- tombstone; NULL = live + created_at timestamptz NOT NULL DEFAULT now(), + created_by text NOT NULL, + + -- Physical uniqueness: one S3 prefix per book in a collection + CONSTRAINT books_collection_instance_uq UNIQUE (collection_id, instance_id) +); + +COMMENT ON TABLE tc.books IS + 'Authoritative book state per collection. Lock columns, soft tombstone, ' + 'current-version denormalization. All state transitions go through RPCs/edge functions; ' + 'no direct writes via PostgREST.'; +COMMENT ON COLUMN tc.books.instance_id IS + 'Client-generated UUID stable across renames. Used as the S3 prefix key ' + '(tc/{cid}/books/{instance_id}/).'; +COMMENT ON COLUMN tc.books.name IS + 'NFC-normalized on write by the nfc_normalize_book_name trigger. ' + 'Live-name uniqueness (lower(name)) is enforced by a partial unique index.'; +COMMENT ON COLUMN tc.books.deleted_at IS + 'Soft tombstone: non-NULL = deleted. Tombstoned names are reusable (excluded from the ' + 'live-name uniqueness index).'; + +CREATE INDEX books_collection_id_idx ON tc.books(collection_id); +CREATE INDEX books_locked_by_idx ON tc.books(locked_by) WHERE locked_by IS NOT NULL; +CREATE INDEX books_instance_id_idx ON tc.books(instance_id); + +-- Partial unique index: enforce lower(NFC-normalized name) uniqueness among live books. +-- Tombstoned (deleted_at IS NOT NULL) names are excluded → reusable after deletion. +CREATE UNIQUE INDEX books_live_name_uq + ON tc.books (collection_id, lower(normalize(name, NFC))) + WHERE deleted_at IS NULL; + +-- --------------------------------------------------------------------------- +-- Trigger: NFC-normalize book names on insert/update +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.nfc_normalize_book_name() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.name := normalize(NEW.name, NFC); + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc.nfc_normalize_book_name() IS + 'Trigger: NFC-normalize the book name before every insert or update.'; + +CREATE TRIGGER books_nfc_normalize_name_tg + BEFORE INSERT OR UPDATE OF name ON tc.books + FOR EACH ROW EXECUTE FUNCTION tc.nfc_normalize_book_name(); + +-- --------------------------------------------------------------------------- +-- versions (metadata per check-in) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + book_id uuid NOT NULL REFERENCES tc.books(id) ON DELETE CASCADE, + collection_id uuid NOT NULL, -- denormalised for fast queries + seq bigint NOT NULL, -- monotonically increasing per book + checksum text NOT NULL, -- SHA-256 of the manifest + comment text, + created_by text NOT NULL, -- user_id + created_at timestamptz NOT NULL DEFAULT now(), + client_version text, -- Bloom version string + + CONSTRAINT versions_book_seq_uq UNIQUE (book_id, seq) +); + +COMMENT ON TABLE tc.versions IS + 'One metadata row per successful check-in (checkin-finish edge function). ' + 'seq is monotonically increasing per book.'; + +CREATE INDEX versions_book_id_idx ON tc.versions(book_id); +CREATE INDEX versions_collection_id_idx ON tc.versions(collection_id); + +-- --------------------------------------------------------------------------- +-- version_files (CURRENT manifest: path → sha256 + s3_version_id) +-- --------------------------------------------------------------------------- +-- Rows here are the live manifest for the current version of each book. +-- Superseded rows are pruned by checkin-finish when a new version is committed. +-- s3_version_id is the S3 object version captured at upload time; reads always use +-- (path, s3_version_id) — never "latest" — for transactional safety. +CREATE TABLE tc.version_files ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + book_id uuid NOT NULL REFERENCES tc.books(id) ON DELETE CASCADE, + version_id uuid NOT NULL REFERENCES tc.versions(id) ON DELETE CASCADE, + path text NOT NULL, -- relative path within the book; NFC-normalized + sha256 text NOT NULL, + size_bytes bigint NOT NULL, + s3_version_id text NOT NULL, -- S3 object version id captured at PUT time + + CONSTRAINT version_files_book_path_uq UNIQUE (book_id, path) +); + +COMMENT ON TABLE tc.version_files IS + 'Current manifest for each book: path → sha256, size, s3_version_id. ' + 'Superseded rows are pruned at checkin-finish. Reads always use (path, s3_version_id).'; + +CREATE INDEX version_files_book_id_idx ON tc.version_files(book_id); +CREATE INDEX version_files_version_id_idx ON tc.version_files(version_id); + +-- Trigger: NFC-normalize version_files.path on insert +CREATE OR REPLACE FUNCTION tc.nfc_normalize_path() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.path := normalize(NEW.path, NFC); + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc.nfc_normalize_path() IS + 'Trigger: NFC-normalize the file path before every insert or update.'; + +CREATE TRIGGER version_files_nfc_normalize_path_tg + BEFORE INSERT OR UPDATE OF path ON tc.version_files + FOR EACH ROW EXECUTE FUNCTION tc.nfc_normalize_path(); + +-- --------------------------------------------------------------------------- +-- collection_file_groups (versioned blobs: allowed-words, sample-texts, other) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.collection_file_groups ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + group_key text NOT NULL + CHECK (group_key IN ('other', 'allowed-words', 'sample-texts')), + version bigint NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by text NOT NULL, + + CONSTRAINT collection_file_groups_uq UNIQUE (collection_id, group_key) +); + +COMMENT ON TABLE tc.collection_file_groups IS + 'Versioned collection-level file groups. version is bumped atomically by ' + 'collection-files-finish edge function; 409 VersionConflict if expectedVersion != version.'; + +CREATE INDEX collection_file_groups_collection_id_idx ON tc.collection_file_groups(collection_id); + +-- --------------------------------------------------------------------------- +-- collection_group_files (manifest per group) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.collection_group_files ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + group_id bigint NOT NULL REFERENCES tc.collection_file_groups(id) ON DELETE CASCADE, + path text NOT NULL, -- NFC-normalized + sha256 text NOT NULL, + size_bytes bigint NOT NULL, + s3_version_id text NOT NULL, + + CONSTRAINT collection_group_files_group_path_uq UNIQUE (group_id, path) +); + +COMMENT ON TABLE tc.collection_group_files IS + 'Current file manifest for each collection_file_group. ' + 'Rows for the old version are replaced atomically by collection-files-finish.'; + +CREATE INDEX collection_group_files_group_id_idx ON tc.collection_group_files(group_id); + +CREATE TRIGGER collection_group_files_nfc_normalize_path_tg + BEFORE INSERT OR UPDATE OF path ON tc.collection_group_files + FOR EACH ROW EXECUTE FUNCTION tc.nfc_normalize_path(); + +-- --------------------------------------------------------------------------- +-- color_palette_entries (union-merge; no deletes) +-- --------------------------------------------------------------------------- +CREATE TABLE tc.color_palette_entries ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + palette text NOT NULL, -- named palette (e.g. 'cover-colors') + color text NOT NULL, -- hex or CSS color string + added_at timestamptz NOT NULL DEFAULT now(), + added_by text NOT NULL, + + CONSTRAINT color_palette_entries_uq UNIQUE (collection_id, palette, color) +); + +COMMENT ON TABLE tc.color_palette_entries IS + 'Color palette entries per collection. Merge is union-only: ' + 'insert ... on conflict do nothing. No rows are ever deleted.'; + +CREATE INDEX color_palette_entries_collection_id_idx ON tc.color_palette_entries(collection_id); + +-- --------------------------------------------------------------------------- +-- events (history log + realtime source + polling cursor) +-- --------------------------------------------------------------------------- +-- Numeric type values MUST match the C# BookHistoryEventType enum +-- (src/BloomExe/History/HistoryEvent.cs) which stores the integer in SQLite. +-- The enum is: +-- CheckOut = 0 +-- CheckIn = 1 +-- Created = 2 +-- Renamed = 3 +-- Uploaded = 4 (legacy; not used in cloud TC) +-- ForcedUnlock = 5 +-- ImportSpreadsheet = 6 (client-side only; may appear in log_event) +-- SyncProblem = 7 (legacy; not used in cloud TC) +-- Deleted = 8 +-- Moved = 9 +-- +-- Cloud-TC-specific incident types start at 100 to avoid collision with any future +-- additions to BookHistoryEventType (which must be added at the end of that enum): +-- WorkPreservedLocally = 100 (Lost & Found: local work saved as .bloomSource) +-- (future incidents: 101, 102, ...) +-- +-- The check constraint below lists all valid values; update it when extending. +CREATE TABLE tc.events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + book_id uuid REFERENCES tc.books(id) ON DELETE SET NULL, + type integer NOT NULL + CHECK (type IN ( + -- C# BookHistoryEventType (must stay in sync with HistoryEvent.cs) + 0, -- CheckOut + 1, -- CheckIn + 2, -- Created + 3, -- Renamed + 4, -- Uploaded (legacy) + 5, -- ForcedUnlock + 6, -- ImportSpreadsheet + 7, -- SyncProblem (legacy) + 8, -- Deleted + 9, -- Moved + -- Cloud-TC incident extensions (start at 100, never collide with C# enum) + 100 -- WorkPreservedLocally + )), + by_user_id text NOT NULL, + by_user_name text, + by_email text, + book_version_seq bigint, + lock_info jsonb, -- JSON {locked_by, machine} snapshot for ForcedUnlock events + book_name text, -- denormalized for display (name at event time) + group_key text, -- for collection-files events + message text, -- check-in comment / incident detail + bloom_version text, + occurred_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE tc.events IS + 'History log, realtime broadcast source, and polling cursor. ' + 'type values mirror C# BookHistoryEventType (HistoryEvent.cs): 0=CheckOut, 1=CheckIn, ' + '2=Created, 3=Renamed, 4=Uploaded(legacy), 5=ForcedUnlock, 6=ImportSpreadsheet, ' + '7=SyncProblem(legacy), 8=Deleted, 9=Moved. ' + 'Cloud-TC incident extensions start at 100 to avoid colliding with future C# additions: ' + '100=WorkPreservedLocally.'; + +CREATE INDEX events_collection_id_idx ON tc.events(collection_id); +CREATE INDEX events_book_id_idx ON tc.events(book_id) WHERE book_id IS NOT NULL; +-- Cursor index: get_changes(since_event_id) uses id > since_event_id +CREATE INDEX events_collection_cursor_idx ON tc.events(collection_id, id); + +-- --------------------------------------------------------------------------- +-- Realtime broadcast trigger on events +-- --------------------------------------------------------------------------- +-- Broadcasts a lightweight message on the private channel collection:{uuid} whenever a +-- new event row is inserted. Clients receive the metadata; large payloads (book content) +-- are never pushed — clients call get_changes(since) to fetch details. +-- +-- TODO(realtime, wave 4): pg_notify does NOT reach Supabase Realtime websockets. When the +-- realtime optimization lands (polling ships first — CloudCollectionMonitor), replace this +-- with realtime.send(payload, event, topic, private), topic 'collection:' || collection_id +-- per CONTRACTS.md §Realtime, wrapped in an EXCEPTION guard so an events INSERT never fails +-- in environments without the realtime schema (e.g. bare-Postgres pgTAP CI). +CREATE OR REPLACE FUNCTION tc.events_realtime_broadcast() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM pg_notify( + 'realtime:' || NEW.collection_id::text, + json_build_object( + 'eventId', NEW.id, + 'type', NEW.type, + 'bookId', NEW.book_id, + 'versionSeq', NEW.book_version_seq, + 'byUserName', NEW.by_user_name, + 'byEmail', NEW.by_email, + 'lock', NEW.lock_info, + 'name', NEW.book_name, + 'groupKey', NEW.group_key + )::text + ); + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc.events_realtime_broadcast() IS + 'Broadcasts a realtime notification on channel realtime:{collection_id} for every new ' + 'event row. The message shape matches CONTRACTS.md §Realtime.'; + +CREATE TRIGGER events_realtime_broadcast_tg + AFTER INSERT ON tc.events + FOR EACH ROW EXECUTE FUNCTION tc.events_realtime_broadcast(); + +-- --------------------------------------------------------------------------- +-- checkin_transactions (open send transactions; reaped on expiry) +-- --------------------------------------------------------------------------- +-- Tracks in-flight checkin-start → checkin-finish two-phase commits. +-- An open transaction means a new book row may exist with no current_version_id (invisible). +-- Expiry is 48 hours (per design); expired rows should be reaped by a scheduled function +-- (edge function task 02 / pg_cron). +CREATE TABLE tc.checkin_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + book_id uuid NOT NULL REFERENCES tc.books(id) ON DELETE CASCADE, + started_by text NOT NULL, -- user_id + proposed_name text NOT NULL, + base_version_id uuid REFERENCES tc.versions(id), + changed_paths text[] NOT NULL DEFAULT '{}', + client_version text, + started_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '48 hours'), + finished_at timestamptz, -- set on checkin-finish (success or abort) + aborted_at timestamptz, + status text NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'finished', 'aborted', 'expired')) +); + +COMMENT ON TABLE tc.checkin_transactions IS + 'Open check-in transactions (checkin-start → checkin-finish). ' + 'expires_at = 48h; expired rows are reaped by a scheduled edge function. ' + 'An open transaction for a new book means the book row has no current_version_id ' + 'and is invisible to teammates until checkin-finish commits it.'; + +CREATE INDEX checkin_transactions_book_id_idx ON tc.checkin_transactions(book_id); +CREATE INDEX checkin_transactions_started_by_idx ON tc.checkin_transactions(started_by); +CREATE INDEX checkin_transactions_expires_at_idx ON tc.checkin_transactions(expires_at) + WHERE status = 'open'; diff --git a/supabase/migrations/20260706000002_tc_rls.sql b/supabase/migrations/20260706000002_tc_rls.sql new file mode 100644 index 000000000000..b2014fa74770 --- /dev/null +++ b/supabase/migrations/20260706000002_tc_rls.sql @@ -0,0 +1,229 @@ +-- ============================================================================= +-- Migration: Row-Level Security policies for tc schema +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- RLS principles: +-- • Members can read their own collections and related data. +-- • Admins can manage membership, force-unlock, and approve accounts. +-- • NO direct INSERT/UPDATE/DELETE on books or versions via PostgREST — +-- all state transitions go through the RPCs defined in 20260706000003_tc_rpcs.sql. +-- • The realtime channel is private: only members of a collection subscribe. +-- • Service-role (used by edge functions) bypasses RLS; all RPCs run as SECURITY DEFINER +-- to get elevated access where needed. +-- ============================================================================= + +-- Enable RLS on every table in the tc schema. +ALTER TABLE tc.collections ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.members ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.books ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.versions ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.version_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.collection_file_groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.collection_group_files ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.color_palette_entries ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.events ENABLE ROW LEVEL SECURITY; +ALTER TABLE tc.checkin_transactions ENABLE ROW LEVEL SECURITY; + +-- --------------------------------------------------------------------------- +-- Helper: is the caller a member of a given collection? +-- --------------------------------------------------------------------------- +-- Used by multiple policies; defined as a stable SQL function so Postgres can +-- inline it into RLS policy checks without repeated subquery overhead. +CREATE OR REPLACE FUNCTION tc.is_member(p_collection_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = tc.current_user_id() + ) +$$; + +COMMENT ON FUNCTION tc.is_member(uuid) IS + 'Returns TRUE when the caller (JWT sub) is a claimed member of the given collection.'; + +-- --------------------------------------------------------------------------- +-- Helper: is the caller an admin of a given collection? +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.is_admin(p_collection_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = tc.current_user_id() + AND m.role = 'admin' + ) +$$; + +COMMENT ON FUNCTION tc.is_admin(uuid) IS + 'Returns TRUE when the caller (JWT sub) is a claimed admin of the given collection.'; + +-- ============================================================================= +-- COLLECTIONS +-- ============================================================================= + +-- Members can read their own collections. +CREATE POLICY collections_select ON tc.collections + FOR SELECT + USING (tc.is_member(id)); + +-- No direct INSERT/UPDATE/DELETE via PostgREST: create_collection RPC handles creation. +-- (No INSERT/UPDATE/DELETE policies → those operations are blocked for non-service-role.) + +-- ============================================================================= +-- MEMBERS +-- ============================================================================= + +-- Members can read the member list for their collections (so they know who else is in it). +CREATE POLICY members_select ON tc.members + FOR SELECT + USING (tc.is_member(collection_id)); + +-- Admins can insert new member rows (add_member RPC runs SECURITY DEFINER, but this +-- policy is defence-in-depth in case someone calls the table directly). +CREATE POLICY members_insert ON tc.members + FOR INSERT + WITH CHECK (tc.is_admin(collection_id)); + +-- Admins can update roles; the claim_memberships RPC writes user_id (SECURITY DEFINER). +CREATE POLICY members_update ON tc.members + FOR UPDATE + USING (tc.is_admin(collection_id)) + WITH CHECK (tc.is_admin(collection_id)); + +-- Admins can remove member rows. +CREATE POLICY members_delete ON tc.members + FOR DELETE + USING (tc.is_admin(collection_id)); + +-- ============================================================================= +-- BOOKS +-- ============================================================================= +-- NO direct writes (INSERT/UPDATE/DELETE) allowed via PostgREST. +-- All transitions go through SECURITY DEFINER RPCs. + +-- Members can read books in their collections (including tombstoned ones — needed +-- for get_collection_state delta and history display). +CREATE POLICY books_select ON tc.books + FOR SELECT + USING (tc.is_member(collection_id)); + +-- ============================================================================= +-- VERSIONS +-- ============================================================================= +-- Read-only for members; written only by checkin-finish edge function (service-role). + +CREATE POLICY versions_select ON tc.versions + FOR SELECT + USING (tc.is_member(collection_id)); + +-- ============================================================================= +-- VERSION_FILES +-- ============================================================================= +-- Read-only for members (for manifest inspection); written only by checkin-finish. + +CREATE POLICY version_files_select ON tc.version_files + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tc.books b + WHERE b.id = version_files.book_id + AND tc.is_member(b.collection_id) + ) + ); + +-- ============================================================================= +-- COLLECTION_FILE_GROUPS +-- ============================================================================= + +CREATE POLICY collection_file_groups_select ON tc.collection_file_groups + FOR SELECT + USING (tc.is_member(collection_id)); + +-- ============================================================================= +-- COLLECTION_GROUP_FILES +-- ============================================================================= + +CREATE POLICY collection_group_files_select ON tc.collection_group_files + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM tc.collection_file_groups fg + WHERE fg.id = collection_group_files.group_id + AND tc.is_member(fg.collection_id) + ) + ); + +-- ============================================================================= +-- COLOR_PALETTE_ENTRIES +-- ============================================================================= + +-- Members can read palette entries. +CREATE POLICY color_palette_entries_select ON tc.color_palette_entries + FOR SELECT + USING (tc.is_member(collection_id)); + +-- add_palette_colors RPC inserts via SECURITY DEFINER; direct insert requires membership. +-- (The RPC is the preferred path; this is defence-in-depth.) +CREATE POLICY color_palette_entries_insert ON tc.color_palette_entries + FOR INSERT + WITH CHECK (tc.is_member(collection_id)); + +-- ============================================================================= +-- EVENTS +-- ============================================================================= + +-- Members can read events for their collections. +CREATE POLICY events_select ON tc.events + FOR SELECT + USING (tc.is_member(collection_id)); + +-- Members can insert their own events via log_event RPC (SECURITY DEFINER). +-- Defence-in-depth: direct INSERT requires membership. +CREATE POLICY events_insert ON tc.events + FOR INSERT + WITH CHECK ( + tc.is_member(collection_id) + AND by_user_id = tc.current_user_id() + ); + +-- No UPDATE or DELETE on events (immutable audit log). + +-- ============================================================================= +-- CHECKIN_TRANSACTIONS +-- ============================================================================= + +-- Users can read their own open transactions. +CREATE POLICY checkin_transactions_select ON tc.checkin_transactions + FOR SELECT + USING (started_by = tc.current_user_id()); + +-- Insertions are done by the checkin-start edge function (service-role / SECURITY DEFINER). +-- No direct write access via PostgREST. + +-- ============================================================================= +-- Grant usage on schema and SELECT on all tables to authenticated role +-- ============================================================================= +-- PostgREST uses the `authenticated` role for JWT-carrying requests. +GRANT USAGE ON SCHEMA tc TO authenticated; +GRANT SELECT ON ALL TABLES IN SCHEMA tc TO authenticated; +GRANT INSERT ON tc.members TO authenticated; +GRANT UPDATE ON tc.members TO authenticated; +GRANT DELETE ON tc.members TO authenticated; +GRANT INSERT ON tc.color_palette_entries TO authenticated; +GRANT INSERT ON tc.events TO authenticated; + +-- Sequences needed for GENERATED ALWAYS AS IDENTITY columns. +GRANT USAGE ON ALL SEQUENCES IN SCHEMA tc TO authenticated; + +-- anon role gets no access (all endpoints require a JWT). +REVOKE ALL ON ALL TABLES IN SCHEMA tc FROM anon; diff --git a/supabase/migrations/20260706000003_tc_rpcs.sql b/supabase/migrations/20260706000003_tc_rpcs.sql new file mode 100644 index 000000000000..095a5f48531d --- /dev/null +++ b/supabase/migrations/20260706000003_tc_rpcs.sql @@ -0,0 +1,963 @@ +-- ============================================================================= +-- Migration: Postgres RPCs (PostgREST /rest/v1/rpc/...) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- All RPCs defined here are SECURITY DEFINER so they can bypass RLS where needed +-- (e.g. writing books/versions, reading members to check admin status). +-- They re-check membership / admin status internally before any mutation. +-- +-- RPC signatures must match CONTRACTS.md exactly. +-- All timestamps server-side via now(). +-- User ids are TEXT (Firebase UIDs ~28 chars; local-GoTrue UUIDs; both fit in TEXT). +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- create_collection(id uuid, name text) +-- --------------------------------------------------------------------------- +-- Creates a collection + the caller as the sole claimed admin. +-- Requires the caller to be authenticated (email_verified is NOT required for +-- collection creation — just a valid JWT). +CREATE OR REPLACE FUNCTION tc.create_collection( + p_id uuid, + p_name text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_email text; +BEGIN + v_user_id := tc.current_user_id(); + v_email := tc.current_user_email(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + + -- Insert collection + INSERT INTO tc.collections (id, name, created_by) + VALUES (p_id, normalize(p_name, NFC), v_user_id); + + -- Insert caller as sole claimed admin + INSERT INTO tc.members (collection_id, email, role, user_id, added_by, claimed_at) + VALUES (p_id, lower(v_email), 'admin', v_user_id, v_user_id, now()); +END; +$$; + +COMMENT ON FUNCTION tc.create_collection(uuid, text) IS + 'CONTRACTS.md: create_collection — creates collection + caller as sole claimed admin.'; + +-- --------------------------------------------------------------------------- +-- my_collections() +-- --------------------------------------------------------------------------- +-- Returns collections where the caller's email is in the approved list +-- (claimed or unclaimed — email match; used in the "Get my Team Collections" flow). +CREATE OR REPLACE FUNCTION tc.my_collections() +RETURNS TABLE ( + id uuid, + name text, + created_at timestamptz, + created_by text, + my_role tc.member_role, + is_claimed boolean +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + c.id, + c.name, + c.created_at, + c.created_by, + m.role AS my_role, + (m.user_id IS NOT NULL) AS is_claimed + FROM tc.collections c + JOIN tc.members m + ON m.collection_id = c.id + AND lower(m.email) = tc.current_user_email() + ORDER BY c.name +$$; + +COMMENT ON FUNCTION tc.my_collections() IS + 'CONTRACTS.md: my_collections — returns collections where the caller''s email is approved ' + '(claimed or not).'; + +-- --------------------------------------------------------------------------- +-- claim_memberships() +-- --------------------------------------------------------------------------- +-- Fills user_id on rows matching the caller's verified email. +-- REQUIRES email_verified = true (tc.jwt_email_verified()). +CREATE OR REPLACE FUNCTION tc.claim_memberships() +RETURNS TABLE ( + collection_id uuid, + role tc.member_role +) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_email text; +BEGIN + IF NOT tc.jwt_email_verified() THEN + RAISE EXCEPTION 'email_not_verified: claiming memberships requires a verified email' + USING ERRCODE = '28000'; + END IF; + + v_user_id := tc.current_user_id(); + v_email := tc.current_user_email(); + + -- Fill user_id on unclaimed matching rows + UPDATE tc.members m + SET user_id = v_user_id, + claimed_at = now() + WHERE lower(m.email) = v_email + AND m.user_id IS NULL; + + -- Return the now-claimed memberships + RETURN QUERY + SELECT m.collection_id, m.role + FROM tc.members m + WHERE m.user_id = v_user_id; +END; +$$; + +COMMENT ON FUNCTION tc.claim_memberships() IS + 'CONTRACTS.md: claim_memberships — fills user_id on rows matching the caller''s verified ' + 'email. Requires tc.jwt_email_verified().'; + +-- --------------------------------------------------------------------------- +-- get_collection_state(collection_id uuid, since_event_id bigint) +-- --------------------------------------------------------------------------- +-- Full or delta snapshot: book rows (locks, current version seq + checksum), +-- collection-file group versions, max_event_id. +-- If since_event_id IS NULL → full snapshot. +-- If since_event_id is provided → delta (only books touched since that event). +CREATE OR REPLACE FUNCTION tc.get_collection_state( + p_collection_id uuid, + p_since_event_id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_max_event_id bigint; + v_books jsonb; + v_groups jsonb; +BEGIN + -- Verify membership + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Max event id for the cursor + SELECT max(id) INTO v_max_event_id + FROM tc.events + WHERE collection_id = p_collection_id; + + -- Books: full or delta + IF p_since_event_id IS NULL THEN + -- Full snapshot: all live books. + -- (task 02 fix, live-integration spike 6 Jul 2026): a book with + -- current_version_id IS NULL has never had a first checkin-finish — per + -- CONTRACTS.md's checkin-start spec it is "invisible to teammates until + -- first commit". The delta branch below gets this for free (a never- + -- committed book has no events yet to join against), but this full-snapshot + -- branch queried tc.books directly and leaked it to every member. Exclude + -- it unless the caller is the one who has it locked (i.e. is themselves + -- mid-Send) — they should still see their own in-flight new book. + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by + FROM tc.books b + WHERE b.collection_id = p_collection_id + AND (b.current_version_id IS NOT NULL OR b.locked_by = tc.current_user_id()) + ORDER BY lower(b.name) + ) b; + ELSE + -- Delta: only books that have an event since since_event_id + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + WHERE b.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + END IF; + + -- Collection file group versions + SELECT jsonb_agg(row_to_json(g)::jsonb) + INTO v_groups + FROM ( + SELECT group_key, version, updated_at + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id + ORDER BY group_key + ) g; + + RETURN jsonb_build_object( + 'books', COALESCE(v_books, '[]'::jsonb), + 'groups', COALESCE(v_groups, '[]'::jsonb), + 'max_event_id', v_max_event_id + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_state(uuid, bigint) IS + 'CONTRACTS.md: get_collection_state — full/delta snapshot of book rows + group versions + ' + 'max_event_id. since_event_id = NULL → full; otherwise delta.'; + +-- --------------------------------------------------------------------------- +-- get_changes(collection_id uuid, since_event_id bigint) +-- --------------------------------------------------------------------------- +-- Returns events + touched book rows since the cursor (for polling / reconnect catch-up). +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up.'; + +-- --------------------------------------------------------------------------- +-- checkout_book(book_id uuid, machine text) +-- --------------------------------------------------------------------------- +-- Conditional lock: race-free UPDATE … WHERE locked_by IS NULL OR locked_by = me. +-- Returns the resulting lock status: {success, locked_by, locked_by_machine, locked_at}. +CREATE OR REPLACE FUNCTION tc.checkout_book( + p_book_id uuid, + p_machine text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + -- Get book + membership check + SELECT b.collection_id INTO v_collection + FROM tc.books b + WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Race-free conditional UPDATE + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND (locked_by IS NULL OR locked_by = v_user_id); + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_at', now() + ); + ELSE + -- Lock held by someone else + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book(uuid, text) IS + 'CONTRACTS.md: checkout_book — conditional lock (race-free UPDATE WHERE locked_by IS NULL ' + 'OR locked_by = me). Returns {success, locked_by, locked_by_machine, locked_at}. ' + 'Emits CheckOut event (type=0) on success.'; + +-- --------------------------------------------------------------------------- +-- unlock_book(book_id uuid) +-- --------------------------------------------------------------------------- +-- Release the caller's own lock (undo checkout, no content change, no event needed — +-- caller never committed content so no history entry required). +-- Only the lock holder can unlock; no admin override here (use force_unlock). +CREATE OR REPLACE FUNCTION tc.unlock_book( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_locked_by text; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT b.collection_id, b.locked_by INTO v_collection, v_locked_by + FROM tc.books b + WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + IF v_locked_by IS DISTINCT FROM v_user_id THEN + RAISE EXCEPTION 'lock_not_held: book is not locked by you' USING ERRCODE = 'P0001'; + END IF; + + UPDATE tc.books + SET locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = p_book_id; +END; +$$; + +COMMENT ON FUNCTION tc.unlock_book(uuid) IS + 'CONTRACTS.md: unlock_book — release own lock (undo checkout, no content change). ' + 'Only the lock holder may call this; use force_unlock for admin override.'; + +-- --------------------------------------------------------------------------- +-- force_unlock(book_id uuid) +-- --------------------------------------------------------------------------- +-- Admin-only; releases any lock; emits ForcedUnlock event (type=5). +CREATE OR REPLACE FUNCTION tc.force_unlock( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_admin(v_row.collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + -- Snapshot the lock state for the audit event before clearing it + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, + lock_info, book_name + ) + VALUES ( + v_row.collection_id, p_book_id, 5, -- ForcedUnlock + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + jsonb_build_object( + 'locked_by', v_row.locked_by, + 'machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ), + v_row.name + ); + + UPDATE tc.books + SET locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = p_book_id; +END; +$$; + +COMMENT ON FUNCTION tc.force_unlock(uuid) IS + 'CONTRACTS.md: force_unlock — admin-only; releases any lock; emits ForcedUnlock (type=5).'; + +-- --------------------------------------------------------------------------- +-- delete_book(book_id uuid) +-- --------------------------------------------------------------------------- +-- Requires the caller holds the lock (editorial workflow: check out, then delete). +-- Sets deleted_at tombstone; emits Deleted event (type=8). +CREATE OR REPLACE FUNCTION tc.delete_book( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_row.collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + IF v_row.locked_by IS DISTINCT FROM v_user_id THEN + RAISE EXCEPTION 'lock_required: caller must hold the lock to delete a book' + USING ERRCODE = 'P0001'; + END IF; + + IF v_row.deleted_at IS NOT NULL THEN + RAISE EXCEPTION 'already_deleted' USING ERRCODE = 'P0001'; + END IF; + + UPDATE tc.books + SET deleted_at = now(), + locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = p_book_id; + + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + VALUES ( + v_row.collection_id, p_book_id, 8, -- Deleted + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name + ); +END; +$$; + +COMMENT ON FUNCTION tc.delete_book(uuid) IS + 'CONTRACTS.md: delete_book — requires caller holds the lock; sets deleted_at tombstone; ' + 'emits Deleted (type=8). Lock is released on deletion.'; + +-- --------------------------------------------------------------------------- +-- undelete_book(book_id uuid) +-- --------------------------------------------------------------------------- +-- Admin-only; clears tombstone. Name-uniqueness is re-enforced: if another live book +-- already uses the same lower(NFC(name)), the call fails with a name_conflict error. +CREATE OR REPLACE FUNCTION tc.undelete_book( + p_book_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_row tc.books%ROWTYPE; + v_conflict_count integer; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_admin(v_row.collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + IF v_row.deleted_at IS NULL THEN + RAISE EXCEPTION 'not_deleted: book is not tombstoned' USING ERRCODE = 'P0001'; + END IF; + + -- Check live-name uniqueness before restoring + SELECT count(*) INTO v_conflict_count + FROM tc.books + WHERE collection_id = v_row.collection_id + AND id != p_book_id + AND deleted_at IS NULL + AND lower(normalize(name, NFC)) = lower(normalize(v_row.name, NFC)); + + IF v_conflict_count > 0 THEN + RAISE EXCEPTION 'name_conflict: a live book already uses this name' + USING ERRCODE = 'P0001'; + END IF; + + UPDATE tc.books + SET deleted_at = NULL + WHERE id = p_book_id; + + -- Log the undelete as a Created event to make it visible in history + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name, message + ) + VALUES ( + v_row.collection_id, p_book_id, 2, -- Created (reuse; undelete restores the book) + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name, 'undeleted' + ); +END; +$$; + +COMMENT ON FUNCTION tc.undelete_book(uuid) IS + 'CONTRACTS.md: undelete_book — admin-only; clears tombstone; enforces live-name ' + 'uniqueness (raises name_conflict if another live book uses the same name).'; + +-- --------------------------------------------------------------------------- +-- rename_check(book_id uuid, new_name text) +-- --------------------------------------------------------------------------- +-- Advisory uniqueness pre-check (returns whether the name is available). +-- Does NOT perform the rename — that happens via checkin-finish. +CREATE OR REPLACE FUNCTION tc.rename_check( + p_book_id uuid, + p_new_name text +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_collection uuid; + v_conflict boolean; +BEGIN + SELECT b.collection_id INTO v_collection + FROM tc.books b WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM tc.books + WHERE collection_id = v_collection + AND id != p_book_id + AND deleted_at IS NULL + AND lower(normalize(name, NFC)) = lower(normalize(p_new_name, NFC)) + ) INTO v_conflict; + + RETURN jsonb_build_object( + 'available', NOT v_conflict, + 'conflict', v_conflict + ); +END; +$$; + +COMMENT ON FUNCTION tc.rename_check(uuid, text) IS + 'CONTRACTS.md: rename_check — advisory live-name uniqueness pre-check. ' + 'Returns {available, conflict}. Does NOT perform the rename.'; + +-- --------------------------------------------------------------------------- +-- members_list(collection_id uuid) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.members_list( + p_collection_id uuid +) +RETURNS TABLE ( + id bigint, + email text, + role tc.member_role, + user_id text, + added_by text, + added_at timestamptz, + claimed_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT m.id, m.email, m.role, m.user_id, m.added_by, m.added_at, m.claimed_at + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND tc.is_member(p_collection_id) -- membership gate + ORDER BY m.email +$$; + +COMMENT ON FUNCTION tc.members_list(uuid) IS + 'CONTRACTS.md: members list — returns approved-accounts for the collection. ' + 'Any member may call this.'; + +-- --------------------------------------------------------------------------- +-- members_add(collection_id uuid, email text, role tc.member_role) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.members_add( + p_collection_id uuid, + p_email text, + p_role tc.member_role DEFAULT 'member' +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_new_id bigint; +BEGIN + v_user_id := tc.current_user_id(); + + IF NOT tc.is_admin(p_collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + INSERT INTO tc.members (collection_id, email, role, added_by) + VALUES (p_collection_id, lower(normalize(p_email, NFC)), p_role, v_user_id) + ON CONFLICT (collection_id, email) DO NOTHING + RETURNING id INTO v_new_id; + + RETURN v_new_id; +END; +$$; + +COMMENT ON FUNCTION tc.members_add(uuid, text, tc.member_role) IS + 'CONTRACTS.md: members add — admin-only; adds an approved-account email. ' + 'Idempotent (on conflict do nothing).'; + +-- --------------------------------------------------------------------------- +-- members_remove(collection_id uuid, member_id bigint) +-- --------------------------------------------------------------------------- +-- Admin-only; removing a member force-unlocks any books they hold and emits events. +CREATE OR REPLACE FUNCTION tc.members_remove( + p_collection_id uuid, + p_member_id bigint +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_caller_id text; + v_target_user_id text; + v_book record; +BEGIN + v_caller_id := tc.current_user_id(); + + IF NOT tc.is_admin(p_collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + SELECT user_id INTO v_target_user_id + FROM tc.members + WHERE id = p_member_id AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'member_not_found' USING ERRCODE = 'P0002'; + END IF; + + -- Force-unlock all books held by this user and emit ForcedUnlock events + FOR v_book IN + SELECT b.id, b.name, b.locked_by_machine, b.locked_at + FROM tc.books b + WHERE b.collection_id = p_collection_id + AND b.locked_by = v_target_user_id + LOOP + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, + lock_info, book_name, message + ) + VALUES ( + p_collection_id, v_book.id, 5, -- ForcedUnlock + v_caller_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + jsonb_build_object( + 'locked_by', v_target_user_id, + 'machine', v_book.locked_by_machine, + 'locked_at', v_book.locked_at + ), + v_book.name, + 'lock released due to member removal' + ); + + UPDATE tc.books + SET locked_by = NULL, + locked_by_machine = NULL, + locked_at = NULL + WHERE id = v_book.id; + END LOOP; + + -- Delete the member row (last-admin guard trigger will fire here if applicable) + DELETE FROM tc.members WHERE id = p_member_id; +END; +$$; + +COMMENT ON FUNCTION tc.members_remove(uuid, bigint) IS + 'CONTRACTS.md: members remove — admin-only; force-unlocks any books held by the removed ' + 'member (emits ForcedUnlock events). Last-admin guard trigger fires on DELETE.'; + +-- --------------------------------------------------------------------------- +-- members_set_role(collection_id uuid, member_id bigint, new_role tc.member_role) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.members_set_role( + p_collection_id uuid, + p_member_id bigint, + p_new_role tc.member_role +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + IF NOT tc.is_admin(p_collection_id) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + -- The last-admin guard trigger will raise if this demotes the last admin. + UPDATE tc.members + SET role = p_new_role + WHERE id = p_member_id + AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'member_not_found' USING ERRCODE = 'P0002'; + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.members_set_role(uuid, bigint, tc.member_role) IS + 'CONTRACTS.md: members set_role — admin-only; last-admin guard trigger fires on demotion.'; + +-- --------------------------------------------------------------------------- +-- add_palette_colors(collection_id uuid, palette text, colors text[]) +-- --------------------------------------------------------------------------- +-- Union merge: insert-on-conflict-do-nothing. +CREATE OR REPLACE FUNCTION tc.add_palette_colors( + p_collection_id uuid, + p_palette text, + p_colors text[] +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_color text; +BEGIN + v_user_id := tc.current_user_id(); + + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + FOREACH v_color IN ARRAY p_colors LOOP + INSERT INTO tc.color_palette_entries (collection_id, palette, color, added_by) + VALUES (p_collection_id, p_palette, v_color, v_user_id) + ON CONFLICT (collection_id, palette, color) DO NOTHING; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION tc.add_palette_colors(uuid, text, text[]) IS + 'CONTRACTS.md: add_palette_colors — union merge; insert-on-conflict-do-nothing. ' + 'Any member may call.'; + +-- --------------------------------------------------------------------------- +-- log_event(collection_id uuid, book_id uuid, type integer, message text, +-- book_name text, bloom_version text) +-- --------------------------------------------------------------------------- +-- Client-originated history entries (e.g. WorkPreservedLocally incidents). +CREATE OR REPLACE FUNCTION tc.log_event( + p_collection_id uuid, + p_book_id uuid DEFAULT NULL, + p_type integer DEFAULT NULL, + p_message text DEFAULT NULL, + p_book_name text DEFAULT NULL, + p_bloom_version text DEFAULT NULL +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_event_id bigint; +BEGIN + v_user_id := tc.current_user_id(); + + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- type must be a valid event type value (the check constraint on tc.events will catch + -- invalid values, but we give a friendlier error here). + IF p_type IS NULL THEN + RAISE EXCEPTION 'event_type_required' USING ERRCODE = '22023'; + END IF; + + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, + book_name, message, bloom_version + ) + VALUES ( + p_collection_id, p_book_id, p_type, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + p_book_name, p_message, p_bloom_version + ) + RETURNING id INTO v_event_id; + + RETURN v_event_id; +END; +$$; + +COMMENT ON FUNCTION tc.log_event(uuid, uuid, integer, text, text, text) IS + 'CONTRACTS.md: log_event — client-originated history entries (e.g. WorkPreservedLocally ' + 'incident events). Returns the new event id.'; + +-- --------------------------------------------------------------------------- +-- Grant EXECUTE on all RPCs to authenticated role +-- --------------------------------------------------------------------------- +GRANT EXECUTE ON FUNCTION tc.create_collection(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.my_collections() TO authenticated; +GRANT EXECUTE ON FUNCTION tc.claim_memberships() TO authenticated; +GRANT EXECUTE ON FUNCTION tc.get_collection_state(uuid, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.get_changes(uuid, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkout_book(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.unlock_book(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.force_unlock(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.delete_book(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.undelete_book(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.rename_check(uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_list(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_add(uuid, text, tc.member_role) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_remove(uuid, bigint) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.members_set_role(uuid, bigint, tc.member_role) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.add_palette_colors(uuid, text, text[]) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.log_event(uuid, uuid, integer, text, text, text) TO authenticated; diff --git a/supabase/migrations/20260706000004_tc_checkin_txn_functions.sql b/supabase/migrations/20260706000004_tc_checkin_txn_functions.sql new file mode 100644 index 000000000000..9dcb62100774 --- /dev/null +++ b/supabase/migrations/20260706000004_tc_checkin_txn_functions.sql @@ -0,0 +1,842 @@ +-- ============================================================================= +-- Migration: check-in / collection-files transaction functions (task 02) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- These functions implement the atomic-DB-transaction half of the two-phase +-- check-in / collection-files protocols described in CONTRACTS.md. They are +-- called ONLY by the edge functions in supabase/functions/** (checkin-start, +-- checkin-finish, checkin-abort, collection-files-start, collection-files-finish), +-- forwarding the CALLING USER'S OWN JWT (not a service-role key) so that +-- tc.current_user_id()/tc.current_user_email() resolve correctly. They are +-- SECURITY DEFINER (like every other RPC in this schema) so they can write to +-- tables that have no direct-write RLS policy for `authenticated`. +-- +-- They are NOT part of the public wire contract in CONTRACTS.md — that document +-- governs the HTTP shape of the edge functions, not these internal helpers. A +-- client should never call these directly; nothing stops it (same trust model as +-- every other RPC here: re-validate everything internally), but there is no +-- reason to. +-- +-- HTTP-status passthrough convention: PostgREST maps an ERRCODE of the form +-- 'PT###' directly to HTTP status ###. We RAISE EXCEPTION '%', +-- USING ERRCODE = 'PT409' (etc.) so the edge function can forward the same +-- status code and JSON.parse() the exception message for structured fields +-- (e.g. the lock holder's identity). See _shared/rpc.ts on the edge-function side. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Extra checkin_transactions columns needed to make checkin-finish stateless +-- (it receives only { transactionId, comment?, keepCheckedOut? } per CONTRACTS.md — +-- no files list — so the full proposed manifest and its checksum must be +-- persisted at checkin-start time). +-- --------------------------------------------------------------------------- +ALTER TABLE tc.checkin_transactions + ADD COLUMN IF NOT EXISTS proposed_files jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS checksum text, + ADD COLUMN IF NOT EXISTS result_version_id uuid REFERENCES tc.versions(id), + ADD COLUMN IF NOT EXISTS result_seq bigint; + +COMMENT ON COLUMN tc.checkin_transactions.proposed_files IS + 'Full proposed manifest [{path,sha256,size}] captured at checkin-start; ' + 'checkin-finish reconstructs the committed manifest from this + changed_paths + ' + 'the S3 version-ids captured after upload verification.'; +COMMENT ON COLUMN tc.checkin_transactions.checksum IS + 'SHA-256 checksum of the full proposed manifest, supplied at checkin-start and ' + 'persisted as tc.versions.checksum / tc.books.current_checksum on finish.'; +COMMENT ON COLUMN tc.checkin_transactions.result_version_id IS + 'Set on successful checkin-finish; makes a repeated checkin-finish call for an ' + 'already-finished transaction idempotent (returns the same result).'; + +-- --------------------------------------------------------------------------- +-- collection_file_transactions (two-phase commit for collection-files-start/finish) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS tc.collection_file_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id uuid NOT NULL REFERENCES tc.collections(id) ON DELETE CASCADE, + group_key text NOT NULL + CHECK (group_key IN ('other', 'allowed-words', 'sample-texts')), + started_by text NOT NULL, + expected_version bigint NOT NULL, + proposed_files jsonb NOT NULL DEFAULT '[]'::jsonb, + changed_paths text[] NOT NULL DEFAULT '{}', + started_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL DEFAULT (now() + INTERVAL '48 hours'), + finished_at timestamptz, + aborted_at timestamptz, + status text NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'finished', 'aborted', 'expired')), + result_version bigint +); + +COMMENT ON TABLE tc.collection_file_transactions IS + 'Open collection-files-start -> collection-files-finish two-phase commits. ' + 'Mirrors tc.checkin_transactions but scoped to (collection_id, group_key) instead ' + 'of a book.'; + +CREATE INDEX IF NOT EXISTS collection_file_transactions_scope_idx + ON tc.collection_file_transactions(collection_id, group_key, started_by) + WHERE status = 'open'; +CREATE INDEX IF NOT EXISTS collection_file_transactions_expires_at_idx + ON tc.collection_file_transactions(expires_at) WHERE status = 'open'; + +ALTER TABLE tc.collection_file_transactions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY collection_file_transactions_select ON tc.collection_file_transactions + FOR SELECT + USING (started_by = tc.current_user_id()); + +-- --------------------------------------------------------------------------- +-- Minimum supported client version (ClientOutOfDate / 426 handling) +-- --------------------------------------------------------------------------- +-- A tiny "constant function" so operators can bump the floor by CREATE OR REPLACE +-- without a migration. Version strings compare as dotted integer tuples +-- ('1.2.3' < '1.10.0'); a NULL/unparsable client_version is treated as out of date +-- only if the floor is above '0.0.0' (keeps existing behaviour permissive by default). +CREATE OR REPLACE FUNCTION tc.min_supported_client_version() +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT '0.0.0'::text +$$; + +COMMENT ON FUNCTION tc.min_supported_client_version() IS + 'Floor Bloom client version for cloud check-in operations. Bump via ' + 'CREATE OR REPLACE FUNCTION when a breaking client-side protocol change ships. ' + 'ClientOutOfDate (426) is raised when the caller''s clientVersion sorts below this.'; + +CREATE OR REPLACE FUNCTION tc.is_client_version_supported(p_client_version text) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_min int[]; + v_cur int[]; + v_floor text := tc.min_supported_client_version(); +BEGIN + IF v_floor IS NULL OR v_floor = '0.0.0' THEN + RETURN true; -- floor disabled + END IF; + IF p_client_version IS NULL OR p_client_version !~ '^[0-9]+(\.[0-9]+)*$' THEN + RETURN false; -- unparsable version, floor is enabled ⇒ reject + END IF; + + SELECT array_agg(x::int) INTO v_min FROM unnest(string_to_array(v_floor, '.')) x; + SELECT array_agg(x::int) INTO v_cur FROM unnest(string_to_array(p_client_version, '.')) x; + + FOR i IN 1 .. greatest(array_length(v_min, 1), array_length(v_cur, 1)) LOOP + IF COALESCE(v_cur[i], 0) > COALESCE(v_min[i], 0) THEN + RETURN true; + ELSIF COALESCE(v_cur[i], 0) < COALESCE(v_min[i], 0) THEN + RETURN false; + END IF; + END LOOP; + RETURN true; -- equal +END; +$$; + +COMMENT ON FUNCTION tc.is_client_version_supported(text) IS + 'Dotted-integer version compare against tc.min_supported_client_version(). ' + 'Used to raise ClientOutOfDate (426) in checkin_start_tx.'; + +-- --------------------------------------------------------------------------- +-- download_start_check(collection_id uuid) +-- --------------------------------------------------------------------------- +-- All the DB-side work download-start needs: confirm membership. Raises PT403 +-- if not a member; returns void on success. +CREATE OR REPLACE FUNCTION tc.download_start_check( + p_collection_id uuid +) +RETURNS void +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +BEGIN + IF tc.current_user_id() IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.download_start_check(uuid) IS + 'Internal to the download-start edge function: membership gate only. ' + 'PT403 not_a_member if the caller is not a member of the collection.'; + +-- --------------------------------------------------------------------------- +-- _checkin_reap_book(book_id uuid) — internal helper +-- --------------------------------------------------------------------------- +-- Reaps expired OPEN transactions tied to a single book. A brand-new book that +-- was never finished (current_version_id IS NULL) is deleted outright (fully +-- invisible, as if the Send had never started); an existing book's stale +-- transaction is just marked 'expired' — its lock is left alone (expiry of a +-- check-in attempt should not silently release a checkout the user still holds). +CREATE OR REPLACE FUNCTION tc._checkin_reap_book(p_book_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_new_book boolean; +BEGIN + SELECT (current_version_id IS NULL) INTO v_new_book + FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RETURN; + END IF; + + IF v_new_book THEN + -- Deleting the book cascades its (expired, still-open) transactions. + DELETE FROM tc.books + WHERE id = p_book_id + AND current_version_id IS NULL + AND EXISTS ( + SELECT 1 FROM tc.checkin_transactions t + WHERE t.book_id = p_book_id AND t.status = 'open' AND t.expires_at < now() + ) + -- never reap while ANOTHER still-live open transaction exists + AND NOT EXISTS ( + SELECT 1 FROM tc.checkin_transactions t + WHERE t.book_id = p_book_id AND t.status = 'open' AND t.expires_at >= now() + ); + ELSE + UPDATE tc.checkin_transactions + SET status = 'expired' + WHERE book_id = p_book_id AND status = 'open' AND expires_at < now(); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc._checkin_reap_book(uuid) IS + 'Internal: reap expired open checkin_transactions for one book. New, ' + 'never-finished books are deleted outright; existing books just have the ' + 'stale transaction marked expired (lock is left untouched).'; + +-- --------------------------------------------------------------------------- +-- reap_expired_checkin_transactions() — global sweep +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.reap_expired_checkin_transactions() +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_book_id uuid; + v_count integer := 0; +BEGIN + FOR v_book_id IN + SELECT DISTINCT book_id FROM tc.checkin_transactions + WHERE status = 'open' AND expires_at < now() + LOOP + PERFORM tc._checkin_reap_book(v_book_id); + v_count := v_count + 1; + END LOOP; + + UPDATE tc.collection_file_transactions + SET status = 'expired' + WHERE status = 'open' AND expires_at < now(); + GET DIAGNOSTICS v_count = ROW_COUNT; + + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION tc.reap_expired_checkin_transactions() IS + 'Global expiry sweep for both checkin_transactions (via _checkin_reap_book) and ' + 'collection_file_transactions. Called opportunistically at the top of ' + 'checkin_start_tx/checkin_abort_tx/collection_files_start_tx; also safe to run ' + 'from a scheduled job if one is ever wired up (no pg_cron dependency here).'; + +-- --------------------------------------------------------------------------- +-- checkin_start_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.checkin_start_tx( + p_collection_id uuid, + p_book_id uuid, -- NULL ⇒ new book + p_book_instance_id uuid, + p_proposed_name text, + p_base_version_id uuid, + p_checksum text, + p_client_version text, + p_files jsonb -- [{path, sha256, size}], full proposed manifest +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_book tc.books%ROWTYPE; + v_changed text[]; + v_tx_id uuid; + v_existing_tx tc.checkin_transactions%ROWTYPE; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + IF NOT tc.is_client_version_supported(p_client_version) THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'ClientOutOfDate', + 'minVersion', tc.min_supported_client_version() + )::text USING ERRCODE = 'PT426'; + END IF; + + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + PERFORM tc.reap_expired_checkin_transactions(); + + IF p_book_id IS NULL THEN + -- ---- New-book path (or resume of our own not-yet-committed new-book Send) -- + -- CONTRACTS.md's checkin-start response never exposes `bookId` (that's the + -- whole point of "invisible until commit") so a client resuming an + -- interrupted new-book Send has no id to pass back — it re-calls with + -- bookId=null and the SAME bookInstanceId, which is the only identity it has. + -- We must therefore recognize "a row already exists with this instance_id, + -- but it's OUR OWN never-committed, still-locked-to-us row" as a resume, not + -- a conflict — otherwise resume is unreachable (every retry would trip the + -- instance-id uniqueness check below against the row created by try #1). + SELECT * INTO v_book FROM tc.books + WHERE collection_id = p_collection_id AND instance_id = p_book_instance_id; + + IF FOUND THEN + IF v_book.current_version_id IS NOT NULL OR v_book.locked_by IS DISTINCT FROM v_user_id THEN + -- Committed already, or in-flight under someone else's Send: genuine conflict. + RAISE EXCEPTION '%', json_build_object('error', 'NameConflict', + 'detail', 'instance_id already in use')::text + USING ERRCODE = 'PT409'; + END IF; + -- else: fall through with v_book already set to our own resumable row. + ELSE + IF EXISTS ( + SELECT 1 FROM tc.books + WHERE collection_id = p_collection_id + AND deleted_at IS NULL + AND lower(normalize(name, NFC)) = lower(normalize(p_proposed_name, NFC)) + ) THEN + RAISE EXCEPTION '%', json_build_object('error', 'NameConflict')::text + USING ERRCODE = 'PT409'; + END IF; + + INSERT INTO tc.books ( + collection_id, instance_id, name, locked_by, locked_at, created_by + ) + VALUES ( + p_collection_id, p_book_instance_id, p_proposed_name, v_user_id, now(), v_user_id + ) + RETURNING * INTO v_book; + END IF; + ELSE + -- ---- Existing-book path --------------------------------------------- + SELECT * INTO v_book FROM tc.books + WHERE id = p_book_id AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"book_not_found"}' USING ERRCODE = 'PT404'; + END IF; + + IF v_book.locked_by IS NOT NULL AND v_book.locked_by <> v_user_id THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'LockHeldByOther', + 'holder', json_build_object( + 'userId', v_book.locked_by, + 'machine', v_book.locked_by_machine, + 'lockedAt', v_book.locked_at + ) + )::text USING ERRCODE = 'PT409'; + END IF; + + IF p_base_version_id IS NOT NULL + AND v_book.current_version_id IS DISTINCT FROM p_base_version_id THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'BaseVersionSuperseded', + 'currentVersionId', v_book.current_version_id, + 'currentVersionSeq', v_book.current_version_seq + )::text USING ERRCODE = 'PT409'; + END IF; + + -- Take the lock if free; no-op if already ours (lock ACQUISITION here, not just + -- verification, is a deliberate reading of "membership + lock checks" — see + -- orchestration report for the alternative interpretation considered). + UPDATE tc.books + SET locked_by = v_user_id, locked_at = now() + WHERE id = p_book_id AND (locked_by IS NULL OR locked_by = v_user_id) + RETURNING * INTO v_book; + END IF; + + -- ---- Diff proposed manifest vs current -------------------------------- + SELECT COALESCE(array_agg(f.path), '{}') INTO v_changed + FROM jsonb_to_recordset(p_files) AS f(path text, sha256 text, size bigint) + WHERE NOT EXISTS ( + SELECT 1 FROM tc.version_files vf + WHERE vf.book_id = v_book.id + AND vf.path = f.path + AND vf.sha256 = f.sha256 + AND vf.size_bytes = f.size + ); + + -- ---- Resume an already-open transaction for this (book, caller) ------- + SELECT * INTO v_existing_tx + FROM tc.checkin_transactions + WHERE book_id = v_book.id AND started_by = v_user_id AND status = 'open'; + + IF FOUND THEN + UPDATE tc.checkin_transactions + SET proposed_name = p_proposed_name, + base_version_id = p_base_version_id, + checksum = p_checksum, + client_version = p_client_version, + proposed_files = p_files, + changed_paths = v_changed, + expires_at = now() + INTERVAL '48 hours' + WHERE id = v_existing_tx.id + RETURNING id INTO v_tx_id; + ELSE + INSERT INTO tc.checkin_transactions ( + collection_id, book_id, started_by, proposed_name, base_version_id, + changed_paths, client_version, proposed_files, checksum + ) + VALUES ( + p_collection_id, v_book.id, v_user_id, p_proposed_name, p_base_version_id, + v_changed, p_client_version, p_files, p_checksum + ) + RETURNING id INTO v_tx_id; + END IF; + + RETURN jsonb_build_object( + 'transactionId', v_tx_id, + 'bookId', v_book.id, + 'changedPaths', to_jsonb(v_changed) + ); +END; +$$; + +COMMENT ON FUNCTION tc.checkin_start_tx(uuid, uuid, uuid, text, uuid, text, text, jsonb) IS + 'Internal to the checkin-start edge function. Handles membership/lock/base-version ' + 'checks, the new-book path, manifest diffing, and open-transaction resume. ' + 'Raises PT401/PT403/PT404/PT409/PT426 per CONTRACTS.md checkin-start error list.'; + +-- --------------------------------------------------------------------------- +-- checkin_finish_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.checkin_finish_tx( + p_transaction_id uuid, + p_comment text, + p_keep_checked_out boolean, + p_captured jsonb -- [{path, s3VersionId}] for every path in changed_paths +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_tx tc.checkin_transactions%ROWTYPE; + v_missing text[]; + v_final jsonb; + v_was_new boolean; + v_new_seq bigint; + v_version_id uuid; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + SELECT * INTO v_tx FROM tc.checkin_transactions WHERE id = p_transaction_id; + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"transaction_not_found"}' USING ERRCODE = 'PT404'; + END IF; + IF v_tx.started_by <> v_user_id THEN + RAISE EXCEPTION '%', '{"error":"forbidden"}' USING ERRCODE = 'PT403'; + END IF; + + IF v_tx.status = 'finished' THEN + -- Idempotent retry: return the previously-committed result unchanged. + RETURN jsonb_build_object('versionId', v_tx.result_version_id, 'seq', v_tx.result_seq); + END IF; + + IF v_tx.status = 'aborted' THEN + RAISE EXCEPTION '%', '{"error":"transaction_aborted"}' USING ERRCODE = 'PT409'; + END IF; + + IF v_tx.status = 'expired' OR v_tx.expires_at < now() THEN + UPDATE tc.checkin_transactions SET status = 'expired' + WHERE id = p_transaction_id AND status = 'open'; + RAISE EXCEPTION '%', '{"error":"TransactionExpired"}' USING ERRCODE = 'PT410'; + END IF; + + -- ---- Verify every changed path was captured (uploaded + checksum-verified + -- by the edge function before calling us) ------------------------- + SELECT COALESCE(array_agg(cp), '{}') INTO v_missing + FROM unnest(v_tx.changed_paths) cp + WHERE NOT EXISTS ( + SELECT 1 FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = cp AND c."s3VersionId" IS NOT NULL + ); + + IF array_length(v_missing, 1) > 0 THEN + -- Transaction stays OPEN so the client can re-upload and retry. + RAISE EXCEPTION '%', json_build_object( + 'error', 'MissingOrBadUploads', 'paths', to_jsonb(v_missing) + )::text USING ERRCODE = 'PT409'; + END IF; + + -- ---- Build the final manifest: proposed_files, with s3_version_id from + -- p_captured for changed paths and from the CURRENT manifest for + -- everything else. ------------------------------------------------- + SELECT jsonb_agg(jsonb_build_object( + 'path', f.path, + 'sha256', f.sha256, + 'size', f.size, + 's3VersionId', COALESCE( + (SELECT c."s3VersionId" FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = f.path), + (SELECT vf.s3_version_id FROM tc.version_files vf + WHERE vf.book_id = v_tx.book_id AND vf.path = f.path) + ) + )) + INTO v_final + FROM jsonb_to_recordset(v_tx.proposed_files) AS f(path text, sha256 text, size bigint); + + IF EXISTS ( + SELECT 1 FROM jsonb_array_elements(COALESCE(v_final, '[]'::jsonb)) e + WHERE e->>'s3VersionId' IS NULL + ) THEN + -- Defensive: a path was neither captured now nor present in the prior manifest. + RAISE EXCEPTION '%', json_build_object('error', 'MissingOrBadUploads', + 'paths', (SELECT jsonb_agg(e->>'path') FROM jsonb_array_elements(v_final) e + WHERE e->>'s3VersionId' IS NULL))::text + USING ERRCODE = 'PT409'; + END IF; + + v_was_new := (SELECT current_version_id FROM tc.books WHERE id = v_tx.book_id) IS NULL; + v_new_seq := COALESCE((SELECT max(seq) FROM tc.versions WHERE book_id = v_tx.book_id), 0) + 1; + + INSERT INTO tc.versions (book_id, collection_id, seq, checksum, comment, created_by, client_version) + VALUES (v_tx.book_id, v_tx.collection_id, v_new_seq, v_tx.checksum, p_comment, v_user_id, v_tx.client_version) + RETURNING id INTO v_version_id; + + DELETE FROM tc.version_files WHERE book_id = v_tx.book_id; + + INSERT INTO tc.version_files (book_id, version_id, path, sha256, size_bytes, s3_version_id) + SELECT v_tx.book_id, v_version_id, e->>'path', e->>'sha256', (e->>'size')::bigint, e->>'s3VersionId' + FROM jsonb_array_elements(v_final) e; + + UPDATE tc.books + SET current_version_id = v_version_id, + current_version_seq = v_new_seq, + current_checksum = v_tx.checksum, + name = v_tx.proposed_name, + locked_by = CASE WHEN p_keep_checked_out THEN locked_by ELSE NULL END, + locked_by_machine = CASE WHEN p_keep_checked_out THEN locked_by_machine ELSE NULL END, + locked_at = CASE WHEN p_keep_checked_out THEN locked_at ELSE NULL END + WHERE id = v_tx.book_id; + + IF v_was_new THEN + INSERT INTO tc.events (collection_id, book_id, type, by_user_id, by_user_name, by_email, book_name, bloom_version) + VALUES (v_tx.collection_id, v_tx.book_id, 2, v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), v_tx.proposed_name, v_tx.client_version); + END IF; + + INSERT INTO tc.events ( + collection_id, book_id, type, by_user_id, by_user_name, by_email, + book_version_seq, book_name, message, bloom_version + ) + VALUES ( + v_tx.collection_id, v_tx.book_id, 1, v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_new_seq, v_tx.proposed_name, p_comment, v_tx.client_version + ); + + UPDATE tc.checkin_transactions + SET status = 'finished', finished_at = now(), result_version_id = v_version_id, result_seq = v_new_seq + WHERE id = p_transaction_id; + + -- 'manifest' is NOT part of the CONTRACTS.md response ({versionId, seq} only) — it + -- is extra data for the edge function's own use (writing .manifest.json to S3); + -- the edge function must not forward it to the client. + RETURN jsonb_build_object('versionId', v_version_id, 'seq', v_new_seq, 'manifest', v_final); +END; +$$; + +COMMENT ON FUNCTION tc.checkin_finish_tx(uuid, text, boolean, jsonb) IS + 'Internal to the checkin-finish edge function. Single atomic DB transaction: ' + 'version row, current-manifest replacement, book update, lock release, events, ' + 'transaction close. Idempotent when re-called on an already-finished transaction. ' + 'Raises PT401/PT403/PT404/PT409(MissingOrBadUploads)/PT410(expired).'; + +-- --------------------------------------------------------------------------- +-- checkin_abort_tx(transaction_id uuid) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.checkin_abort_tx( + p_transaction_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_tx tc.checkin_transactions%ROWTYPE; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + SELECT * INTO v_tx FROM tc.checkin_transactions WHERE id = p_transaction_id; + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"transaction_not_found"}' USING ERRCODE = 'PT404'; + END IF; + IF v_tx.started_by <> v_user_id THEN + RAISE EXCEPTION '%', '{"error":"forbidden"}' USING ERRCODE = 'PT403'; + END IF; + + IF v_tx.status = 'aborted' THEN + RETURN; -- idempotent + END IF; + IF v_tx.status = 'finished' THEN + RAISE EXCEPTION '%', '{"error":"already_finished"}' USING ERRCODE = 'PT409'; + END IF; + + UPDATE tc.checkin_transactions SET status = 'aborted', aborted_at = now() + WHERE id = p_transaction_id; + + -- Roll back a never-finished new book entirely (fully invisible, as designed). + -- Existing books keep whatever lock they had — aborting a Send is not the same + -- as releasing a Checkout. + PERFORM 1 FROM tc.books WHERE id = v_tx.book_id AND current_version_id IS NULL; + IF FOUND AND NOT EXISTS ( + SELECT 1 FROM tc.checkin_transactions + WHERE book_id = v_tx.book_id AND status = 'open' + ) THEN + DELETE FROM tc.books WHERE id = v_tx.book_id AND current_version_id IS NULL; + END IF; + + PERFORM tc.reap_expired_checkin_transactions(); +END; +$$; + +COMMENT ON FUNCTION tc.checkin_abort_tx(uuid) IS + 'Internal to the checkin-abort edge function. Idempotent. Rolls back a never-' + 'finished new book entirely; leaves an existing book''s lock untouched.'; + +-- --------------------------------------------------------------------------- +-- collection_files_start_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.collection_files_start_tx( + p_collection_id uuid, + p_group_key text, + p_expected_version bigint, + p_files jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_group tc.collection_file_groups%ROWTYPE; + v_changed text[]; + v_tx_id uuid; + v_existing tc.collection_file_transactions%ROWTYPE; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + PERFORM tc.reap_expired_checkin_transactions(); + + INSERT INTO tc.collection_file_groups (collection_id, group_key, version, updated_by) + VALUES (p_collection_id, p_group_key, 0, v_user_id) + ON CONFLICT (collection_id, group_key) DO NOTHING; + + SELECT * INTO v_group FROM tc.collection_file_groups + WHERE collection_id = p_collection_id AND group_key = p_group_key; + + IF v_group.version <> p_expected_version THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'VersionConflict', 'currentVersion', v_group.version + )::text USING ERRCODE = 'PT409'; + END IF; + + SELECT COALESCE(array_agg(f.path), '{}') INTO v_changed + FROM jsonb_to_recordset(p_files) AS f(path text, sha256 text, size bigint) + WHERE NOT EXISTS ( + SELECT 1 FROM tc.collection_group_files gf + WHERE gf.group_id = v_group.id + AND gf.path = f.path + AND gf.sha256 = f.sha256 + AND gf.size_bytes = f.size + ); + + SELECT * INTO v_existing FROM tc.collection_file_transactions + WHERE collection_id = p_collection_id AND group_key = p_group_key + AND started_by = v_user_id AND status = 'open'; + + IF FOUND THEN + UPDATE tc.collection_file_transactions + SET expected_version = p_expected_version, + proposed_files = p_files, + changed_paths = v_changed, + expires_at = now() + INTERVAL '48 hours' + WHERE id = v_existing.id + RETURNING id INTO v_tx_id; + ELSE + INSERT INTO tc.collection_file_transactions ( + collection_id, group_key, started_by, expected_version, proposed_files, changed_paths + ) + VALUES ( + p_collection_id, p_group_key, v_user_id, p_expected_version, p_files, v_changed + ) + RETURNING id INTO v_tx_id; + END IF; + + RETURN jsonb_build_object('transactionId', v_tx_id, 'changedPaths', to_jsonb(v_changed)); +END; +$$; + +COMMENT ON FUNCTION tc.collection_files_start_tx(uuid, text, bigint, jsonb) IS + 'Internal to the collection-files-start edge function. Optimistic-version gate ' + '(PT409 VersionConflict) + manifest diff + transaction open/resume.'; + +-- --------------------------------------------------------------------------- +-- collection_files_finish_tx(...) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.collection_files_finish_tx( + p_transaction_id uuid, + p_captured jsonb +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text := tc.current_user_id(); + v_tx tc.collection_file_transactions%ROWTYPE; + v_group tc.collection_file_groups%ROWTYPE; + v_missing text[]; + v_final jsonb; + v_new_ver bigint; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION '%', '{"error":"unauthenticated"}' USING ERRCODE = 'PT401'; + END IF; + + SELECT * INTO v_tx FROM tc.collection_file_transactions WHERE id = p_transaction_id; + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"transaction_not_found"}' USING ERRCODE = 'PT404'; + END IF; + IF v_tx.started_by <> v_user_id THEN + RAISE EXCEPTION '%', '{"error":"forbidden"}' USING ERRCODE = 'PT403'; + END IF; + + IF v_tx.status = 'finished' THEN + RETURN jsonb_build_object('version', v_tx.result_version); + END IF; + IF v_tx.status = 'aborted' THEN + RAISE EXCEPTION '%', '{"error":"transaction_aborted"}' USING ERRCODE = 'PT409'; + END IF; + IF v_tx.status = 'expired' OR v_tx.expires_at < now() THEN + UPDATE tc.collection_file_transactions SET status = 'expired' + WHERE id = p_transaction_id AND status = 'open'; + RAISE EXCEPTION '%', '{"error":"TransactionExpired"}' USING ERRCODE = 'PT410'; + END IF; + + SELECT * INTO v_group FROM tc.collection_file_groups + WHERE collection_id = v_tx.collection_id AND group_key = v_tx.group_key; + + IF v_group.version <> v_tx.expected_version THEN + UPDATE tc.collection_file_transactions SET status = 'aborted', aborted_at = now() + WHERE id = p_transaction_id; + RAISE EXCEPTION '%', json_build_object( + 'error', 'VersionConflict', 'currentVersion', v_group.version + )::text USING ERRCODE = 'PT409'; + END IF; + + SELECT COALESCE(array_agg(cp), '{}') INTO v_missing + FROM unnest(v_tx.changed_paths) cp + WHERE NOT EXISTS ( + SELECT 1 FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = cp AND c."s3VersionId" IS NOT NULL + ); + + IF array_length(v_missing, 1) > 0 THEN + RAISE EXCEPTION '%', json_build_object( + 'error', 'MissingOrBadUploads', 'paths', to_jsonb(v_missing) + )::text USING ERRCODE = 'PT409'; + END IF; + + SELECT jsonb_agg(jsonb_build_object( + 'path', f.path, + 'sha256', f.sha256, + 'size', f.size, + 's3VersionId', COALESCE( + (SELECT c."s3VersionId" FROM jsonb_to_recordset(p_captured) AS c(path text, "s3VersionId" text) + WHERE c.path = f.path), + (SELECT gf.s3_version_id FROM tc.collection_group_files gf + WHERE gf.group_id = v_group.id AND gf.path = f.path) + ) + )) + INTO v_final + FROM jsonb_to_recordset(v_tx.proposed_files) AS f(path text, sha256 text, size bigint); + + v_new_ver := v_tx.expected_version + 1; + + UPDATE tc.collection_file_groups + SET version = v_new_ver, updated_at = now(), updated_by = v_user_id + WHERE id = v_group.id AND version = v_tx.expected_version; + + IF NOT FOUND THEN + UPDATE tc.collection_file_transactions SET status = 'aborted', aborted_at = now() + WHERE id = p_transaction_id; + RAISE EXCEPTION '%', json_build_object( + 'error', 'VersionConflict', 'currentVersion', v_group.version + )::text USING ERRCODE = 'PT409'; + END IF; + + DELETE FROM tc.collection_group_files WHERE group_id = v_group.id; + + INSERT INTO tc.collection_group_files (group_id, path, sha256, size_bytes, s3_version_id) + SELECT v_group.id, e->>'path', e->>'sha256', (e->>'size')::bigint, e->>'s3VersionId' + FROM jsonb_array_elements(v_final) e; + + INSERT INTO tc.events (collection_id, type, by_user_id, by_user_name, by_email, group_key) + VALUES (v_tx.collection_id, 1, v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), v_tx.group_key); + + UPDATE tc.collection_file_transactions + SET status = 'finished', finished_at = now(), result_version = v_new_ver + WHERE id = p_transaction_id; + + -- 'manifest' is extra data for the edge function only (not part of the + -- CONTRACTS.md {version} response) — used to write the .manifest.json backup. + RETURN jsonb_build_object('version', v_new_ver, 'manifest', v_final); +END; +$$; + +COMMENT ON FUNCTION tc.collection_files_finish_tx(uuid, jsonb) IS + 'Internal to the collection-files-finish edge function. Re-checks the optimistic ' + 'version at finish time too (repo-wins rule); PT409 VersionConflict aborts the ' + 'transaction so a stale retry cannot succeed later.'; + +-- --------------------------------------------------------------------------- +-- Grants — same pattern as 20260706000003_tc_rpcs.sql (SECURITY DEFINER + grant +-- to `authenticated`; internal re-validation makes this safe to call directly). +-- --------------------------------------------------------------------------- +GRANT EXECUTE ON FUNCTION tc.download_start_check(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkin_start_tx(uuid, uuid, uuid, text, uuid, text, text, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkin_finish_tx(uuid, text, boolean, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.checkin_abort_tx(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.collection_files_start_tx(uuid, text, bigint, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.collection_files_finish_tx(uuid, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION tc.reap_expired_checkin_transactions() TO authenticated; + +GRANT SELECT ON tc.collection_file_transactions TO authenticated; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA tc TO authenticated; diff --git a/supabase/migrations/20260707000005_tc_get_book_manifest.sql b/supabase/migrations/20260707000005_tc_get_book_manifest.sql new file mode 100644 index 000000000000..303ccf997269 --- /dev/null +++ b/supabase/migrations/20260707000005_tc_get_book_manifest.sql @@ -0,0 +1,77 @@ +-- ============================================================================= +-- Migration: get_book_manifest RPC (CONTRACTS.md v1.2, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Task 04's client review found that no RPC returns a book's per-file manifest +-- (path → sha256, size, s3_version_id), which the Receive path needs in order to +-- download by pinned (path, s3VersionId). get_collection_state/get_changes carry +-- only the aggregate current_version_seq/current_checksum by design (cheap +-- polling); this RPC is the explicit per-book fetch used when actual bytes are +-- about to move. (The S3 .manifest.json object is a backup of the same data, +-- not the authority.) + +-- Returns: { bookId, versionId, seq, checksum, files: [{path, sha256, size, s3VersionId}] } +-- Errors: book_not_found (P0002) — including for a never-committed (versionless) +-- book unless the caller is the one mid-Send holding its lock, mirroring +-- get_collection_state's invisibility rule; not_a_member (42501). +CREATE OR REPLACE FUNCTION tc.get_book_manifest( + p_book_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_row tc.books%ROWTYPE; + v_files jsonb; +BEGIN + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_row.collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Never-committed books are invisible to everyone except their mid-Send owner + -- (same rule as get_collection_state's full snapshot). + IF v_row.current_version_id IS NULL + AND v_row.locked_by IS DISTINCT FROM tc.current_user_id() THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'path', vf.path, + 'sha256', vf.sha256, + 'size', vf.size_bytes, + 's3VersionId', vf.s3_version_id + ) + ORDER BY vf.path + ), + '[]'::jsonb + ) + INTO v_files + FROM tc.version_files vf + WHERE vf.book_id = p_book_id; + + RETURN jsonb_build_object( + 'bookId', v_row.id, + 'versionId', v_row.current_version_id, + 'seq', v_row.current_version_seq, + 'checksum', v_row.current_checksum, + 'files', v_files + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_book_manifest(uuid) IS + 'CONTRACTS.md v1.2: get_book_manifest — per-file current manifest for one book ' + '(path, sha256, size, s3VersionId), used by Receive to download pinned versions. ' + 'Enforces the never-committed-book invisibility rule.'; + +GRANT EXECUTE ON FUNCTION tc.get_book_manifest(uuid) TO authenticated; diff --git a/supabase/migrations/20260707000006_tc_locked_by_display.sql b/supabase/migrations/20260707000006_tc_locked_by_display.sql new file mode 100644 index 000000000000..b82d73fd138b --- /dev/null +++ b/supabase/migrations/20260707000006_tc_locked_by_display.sql @@ -0,0 +1,344 @@ +-- ============================================================================= +-- Migration: lockedByEmail / lockedByName display fields (CONTRACTS.md v1.2, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Task 05's live-testing discovery: tc.books.locked_by stores the raw auth user_id +-- (JWT `sub`), not an email or display name -- useless for a "checked out to Sara" +-- UI label. The client-side workaround (CloudTeamCollection.ResolveLockedByForDisplay) +-- can only resolve the CALLER's OWN id back to their OWN email; a teammate's lock +-- still shows as a bare id. This migration adds a server-side resolution so every +-- member sees every OTHER member's lock with a friendly identity too. +-- +-- Email comes from tc.members (the approved-accounts table already keyed by +-- (collection_id, user_id) once claimed) -- authoritative and always present for +-- anyone who could possibly hold a lock (checkout_book requires membership). +-- A display *name* has no dedicated column anywhere server-side (tc.members has +-- none; the dev auth provider never sets one either) -- the best available source +-- is tc.events.by_user_name, which captures the JWT `name` claim at the moment of +-- each of that user's actions in this collection (see 20260706000003_tc_rpcs.sql's +-- `(auth.jwt() ->> 'name')` calls). We take the most recent non-null one. In dev-auth +-- mode this will normally be NULL (GoTrue sign-up carries no name claim), which is a +-- known, acceptable gap until real auth (Option A/B/C) supplies one -- exactly the +-- same "may be null, treat as no worse than today" contract as the client's own +-- lockedByFirstName/lockedBySurname fields. +-- +-- tc.get_book_manifest also gains lockedBy/lockedByEmail/lockedByName (previously it +-- returned no lock information at all) so the Receive path can show "still checked +-- out to X" without a second round trip. + +-- --------------------------------------------------------------------------- +-- Helper: resolve a user_id's best-known email/display name within one collection. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.resolve_member_display( + p_collection_id uuid, + p_user_id text, + OUT email text, + OUT display_name text +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + m.email, + ( + SELECT e.by_user_name + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.by_user_id = p_user_id + AND e.by_user_name IS NOT NULL + ORDER BY e.id DESC + LIMIT 1 + ) + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = p_user_id + LIMIT 1; +$$; + +COMMENT ON FUNCTION tc.resolve_member_display(uuid, text) IS + 'Best-effort resolution of a locked_by/created_by user_id to a display email ' + '(from tc.members, authoritative) and display name (from the most recent ' + 'tc.events.by_user_name for that user in this collection, often NULL in dev-auth ' + 'mode). Returns an all-NULL row (never an error) when p_user_id is NULL or unknown.'; + +GRANT EXECUTE ON FUNCTION tc.resolve_member_display(uuid, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- get_collection_state: add locked_by_email / locked_by_name to each book row. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_collection_state( + p_collection_id uuid, + p_since_event_id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_max_event_id bigint; + v_books jsonb; + v_groups jsonb; +BEGIN + -- Verify membership + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Max event id for the cursor + SELECT max(id) INTO v_max_event_id + FROM tc.events + WHERE collection_id = p_collection_id; + + -- Books: full or delta + IF p_since_event_id IS NULL THEN + -- Full snapshot: all live books. + -- (task 02 fix, live-integration spike 6 Jul 2026): a book with + -- current_version_id IS NULL has never had a first checkin-finish — per + -- CONTRACTS.md's checkin-start spec it is "invisible to teammates until + -- first commit". The delta branch below gets this for free (a never- + -- committed book has no events yet to join against), but this full-snapshot + -- branch queried tc.books directly and leaked it to every member. Exclude + -- it unless the caller is the one who has it locked (i.e. is themselves + -- mid-Send) — they should still see their own in-flight new book. + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND (b.current_version_id IS NOT NULL OR b.locked_by = tc.current_user_id()) + ORDER BY lower(b.name) + ) b; + ELSE + -- Delta: only books that have an event since since_event_id + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + END IF; + + -- Collection file group versions + SELECT jsonb_agg(row_to_json(g)::jsonb) + INTO v_groups + FROM ( + SELECT group_key, version, updated_at + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id + ORDER BY group_key + ) g; + + RETURN jsonb_build_object( + 'books', COALESCE(v_books, '[]'::jsonb), + 'groups', COALESCE(v_groups, '[]'::jsonb), + 'max_event_id', v_max_event_id + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_state(uuid, bigint) IS + 'CONTRACTS.md: get_collection_state — full/delta snapshot of book rows + group versions + ' + 'max_event_id. since_event_id = NULL → full; otherwise delta. v1.2 (20260707000006): book ' + 'rows also carry locked_by_email/locked_by_name for display.'; + +-- --------------------------------------------------------------------------- +-- get_changes: add locked_by_email / locked_by_name to each touched book row. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_at, + b.deleted_at, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up. v1.2 (20260707000006): ' + 'touched book rows also carry locked_by_email/locked_by_name for display.'; + +-- --------------------------------------------------------------------------- +-- get_book_manifest: add lockedBy / lockedByEmail / lockedByName. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_book_manifest( + p_book_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_row tc.books%ROWTYPE; + v_files jsonb; + v_email text; + v_name text; +BEGIN + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_row.collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Never-committed books are invisible to everyone except their mid-Send owner + -- (same rule as get_collection_state's full snapshot). + IF v_row.current_version_id IS NULL + AND v_row.locked_by IS DISTINCT FROM tc.current_user_id() THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'path', vf.path, + 'sha256', vf.sha256, + 'size', vf.size_bytes, + 's3VersionId', vf.s3_version_id + ) + ORDER BY vf.path + ), + '[]'::jsonb + ) + INTO v_files + FROM tc.version_files vf + WHERE vf.book_id = p_book_id; + + SELECT rd.email, rd.display_name + INTO v_email, v_name + FROM tc.resolve_member_display(v_row.collection_id, v_row.locked_by) rd; + + RETURN jsonb_build_object( + 'bookId', v_row.id, + 'versionId', v_row.current_version_id, + 'seq', v_row.current_version_seq, + 'checksum', v_row.current_checksum, + 'files', v_files, + 'lockedBy', v_row.locked_by, + 'lockedByEmail', v_email, + 'lockedByName', v_name + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_book_manifest(uuid) IS + 'CONTRACTS.md v1.2: get_book_manifest — per-file current manifest for one book ' + '(path, sha256, size, s3VersionId), used by Receive to download pinned versions. ' + 'Enforces the never-committed-book invisibility rule. v1.2 (20260707000006): also ' + 'reports lockedBy/lockedByEmail/lockedByName so Receive can show "still checked out ' + 'to X" without a second round trip.'; + +GRANT EXECUTE ON FUNCTION tc.get_book_manifest(uuid) TO authenticated; diff --git a/supabase/migrations/20260709000007_tc_checkout_takeover.sql b/supabase/migrations/20260709000007_tc_checkout_takeover.sql new file mode 100644 index 000000000000..bd1161b288f8 --- /dev/null +++ b/supabase/migrations/20260709000007_tc_checkout_takeover.sql @@ -0,0 +1,127 @@ +-- ============================================================================= +-- Account-switch checkout takeover (dogfood batch 1, item 9) +-- ============================================================================= +-- Design/CloudTeamCollections/orchestration/DOGFOOD-BATCH-1.md item 9 (John's decision): +-- a local collection joined under account A is reopened signed in as a DIFFERENT member +-- account B. B may edit books A left checked out on THIS machine, and the first time B's +-- Bloom needs to push a change to the server (check-in), the checkout must atomically move +-- to B -- but ONLY because A's lock was for this same machine; a lock A holds on a different +-- machine remains a genuine conflict, unchanged. +-- +-- This is purely additive: tc.checkin_start_tx (20260706000004) is NOT modified. That +-- function already rejects a check-in when `locked_by <> caller` (see its "LockHeldByOther" +-- raise). Rather than loosen that gate (a real behavior/contract change to an existing, +-- already-shipped RPC, which per this task's rules is an orchestrator decision, not something +-- to do unilaterally), the C# client calls this NEW RPC first, so that by the time +-- checkin_start_tx runs, `locked_by` already equals the caller and its existing check passes +-- cleanly with zero change to its behavior. +-- +-- CONTRACTS.md ADDITION FLAGGED (not applied here -- this task's rules say contract changes/ +-- additions should be flagged for the orchestrator, not self-applied to CONTRACTS.md): add a +-- `checkout_book_takeover(book_id uuid, machine text) -> {success, locked_by, +-- locked_by_machine, locked_at}` row to the RPCs table, alongside checkout_book/unlock_book/ +-- force_unlock. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- checkout_book_takeover(book_id uuid, machine text) +-- --------------------------------------------------------------------------- +-- Same shape and conventions as tc.checkout_book (20260706000003): race-free conditional +-- UPDATE, returns {success, locked_by, locked_by_machine, locked_at}, emits a CheckOut event +-- (type=0) on a genuine handover. Differs from checkout_book only in its WHERE clause: instead +-- of requiring the lock be free (or already the caller's), it requires the lock be held by +-- SOMEONE ELSE on the SAME machine the caller is on now. It is safe to call speculatively +-- (e.g. before every check-in) -- if the caller already holds the lock, or the book is +-- unlocked, or it's locked on a different machine, no row matches and `success` is false with +-- no event emitted; callers that only wanted "make sure a same-machine takeover happens if +-- eligible" can treat "false" here as "nothing to take over" and proceed with their own next +-- step's ordinary conflict handling (e.g. checkin_start_tx's unmodified LockHeldByOther gate +-- for a genuinely-different-machine conflict). +CREATE OR REPLACE FUNCTION tc.checkout_book_takeover( + p_book_id uuid, + p_machine text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_before tc.books%ROWTYPE; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_before FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + v_collection := v_before.collection_id; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Race-free conditional UPDATE: only takes the lock from a DIFFERENT account, and only + -- when that account's lock is recorded against the SAME machine the caller is on now. + -- Never takes over a lock held on a different machine -- that stays a genuine conflict. + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND locked_by IS NOT NULL + AND locked_by <> v_user_id + AND locked_by_machine = p_machine; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) -- same event type an ordinary checkout_book success + -- emits, since from the audit trail's point of view this genuinely is B checking the + -- book out; the preceding history already shows A's own checkout, so the handoff reads + -- naturally without needing a new event-type constant shared across client/server. + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_at', v_row.locked_at + ); + ELSE + -- Nothing to take over (already ours, unlocked, or locked on a different machine). + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book_takeover(uuid, text) IS + 'CONTRACTS.md addition (flagged, not yet applied to CONTRACTS.md -- see Design/CloudTeamCollections ' + 'orchestration item 9 report): checkout_book_takeover -- atomically reassigns a book''s lock from a ' + 'DIFFERENT account to the caller, but ONLY when the existing lock is recorded for the SAME machine ' + '(account-switch behavior, batch item 9). Returns {success, locked_by, locked_by_machine, locked_at}, ' + 'same shape as checkout_book. Emits a CheckOut event (type=0) only when the lock actually changed ' + 'hands. Never modifies checkin_start_tx/checkin_finish_tx -- purely additive.'; + +GRANT EXECUTE ON FUNCTION tc.checkout_book_takeover(uuid, text) TO authenticated; diff --git a/supabase/migrations/20260711000001_tc_reap_counter_fix.sql b/supabase/migrations/20260711000001_tc_reap_counter_fix.sql new file mode 100644 index 000000000000..bb60ee0d9be9 --- /dev/null +++ b/supabase/migrations/20260711000001_tc_reap_counter_fix.sql @@ -0,0 +1,49 @@ +-- ============================================================================= +-- Fix: reap_expired_checkin_transactions returned the wrong count +-- ============================================================================= +-- Found by Greptile review on PR #8048: the original function (20260706000004) +-- accumulated a per-book count in v_count across the loop, then immediately +-- clobbered it with `GET DIAGNOSTICS v_count = ROW_COUNT` from the +-- collection_file_transactions sweep, so it always returned only the +-- collection-file count. Diagnostic-only today (every call site PERFORMs and +-- discards the result), but the return value now matches the documented intent: +-- total items reaped across both sweeps. +-- +-- Convention: merged migrations are never edited in place -- fixes ship as new +-- migration files (see Design/CloudTeamCollections/IMPLEMENTATION.md). +-- ============================================================================= + +CREATE OR REPLACE FUNCTION tc.reap_expired_checkin_transactions() +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_book_id uuid; + v_count integer := 0; + v_updated integer; +BEGIN + FOR v_book_id IN + SELECT DISTINCT book_id FROM tc.checkin_transactions + WHERE status = 'open' AND expires_at < now() + LOOP + PERFORM tc._checkin_reap_book(v_book_id); + v_count := v_count + 1; + END LOOP; + + UPDATE tc.collection_file_transactions + SET status = 'expired' + WHERE status = 'open' AND expires_at < now(); + GET DIAGNOSTICS v_updated = ROW_COUNT; + v_count := v_count + v_updated; + + RETURN v_count; +END; +$$; + +COMMENT ON FUNCTION tc.reap_expired_checkin_transactions() IS + 'Global expiry sweep for both checkin_transactions (via _checkin_reap_book) and ' + 'collection_file_transactions. Returns the total number of items reaped across both ' + 'sweeps. Called opportunistically at the top of checkin_start_tx/checkin_abort_tx/' + 'collection_files_start_tx; also safe to run from a scheduled job if one is ever ' + 'wired up (no pg_cron dependency here).'; diff --git a/supabase/migrations/20260711000002_tc_takeover_error_codes.sql b/supabase/migrations/20260711000002_tc_takeover_error_codes.sql new file mode 100644 index 000000000000..92fe944c574d --- /dev/null +++ b/supabase/migrations/20260711000002_tc_takeover_error_codes.sql @@ -0,0 +1,95 @@ +-- ============================================================================= +-- Fix: checkout_book_takeover error codes aligned with the schema-wide convention +-- ============================================================================= +-- Found by Greptile review on PR #8048: the original function (20260709000007) +-- raised `book_not_found` with ERRCODE P0002 and `not_a_member` with ERRCODE +-- 42501 -- bare-string messages with codes PostgREST does not map to HTTP +-- statuses. Every other RPC in this schema raises a JSON-object message with a +-- PT### passthrough code (PT404/PT403), which PostgREST turns into the matching +-- HTTP status and CloudCollectionClientException then maps to a typed +-- CloudErrorCode instead of Unknown. This migration re-creates the function +-- with only those two RAISE statements changed; the takeover logic itself is +-- untouched (see 20260709000007 for the full design commentary). +-- +-- Convention: merged migrations are never edited in place -- fixes ship as new +-- migration files (see Design/CloudTeamCollections/IMPLEMENTATION.md). +-- ============================================================================= + +CREATE OR REPLACE FUNCTION tc.checkout_book_takeover( + p_book_id uuid, + p_machine text +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_before tc.books%ROWTYPE; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_before FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"book_not_found"}' USING ERRCODE = 'PT404'; + END IF; + + v_collection := v_before.collection_id; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + -- Race-free conditional UPDATE: only takes the lock from a DIFFERENT account, and only + -- when that account's lock is recorded against the SAME machine the caller is on now. + -- Never takes over a lock held on a different machine -- that stays a genuine conflict. + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND locked_by IS NOT NULL + AND locked_by <> v_user_id + AND locked_by_machine = p_machine; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) -- same event type an ordinary checkout_book success + -- emits, since from the audit trail's point of view this genuinely is B checking the + -- book out; the preceding history already shows A's own checkout, so the handoff reads + -- naturally without needing a new event-type constant shared across client/server. + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_at', v_row.locked_at + ); + ELSE + -- Nothing to take over (already ours, unlocked, or locked on a different machine). + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; diff --git a/supabase/migrations/20260711000003_tc_locked_seat.sql b/supabase/migrations/20260711000003_tc_locked_seat.sql new file mode 100644 index 000000000000..eb65195e3d2e --- /dev/null +++ b/supabase/migrations/20260711000003_tc_locked_seat.sql @@ -0,0 +1,454 @@ +-- ============================================================================= +-- Per-collection-copy "seat" on checkouts (dogfood batch 1, bug #0 — John's decision) +-- ============================================================================= +-- Item 9's same-machine takeover (20260709000007) gated only on the machine name, so +-- ANY same-machine account could take over ANY same-machine lock — including across two +-- separate local copies of the collection on one computer (two "seats"), which is what +-- e2e-4 simulates and what a shared lab machine would really be. John's ruling +-- (11 Jul 2026, recorded in orchestration/DOGFOOD-BATCH-1.md): editing/takeover of a +-- checkout is only legitimate where the book is checked out — in THAT local copy of the +-- collection. So the lock record now carries a seat id (client-computed stable hash of +-- the local collection folder path — never the raw path, for privacy), and takeover +-- requires machine AND seat to match. A lock with no recorded seat (legacy row, or a +-- lock acquired via checkin_start_tx's take-if-free path, which has no seat parameter) +-- REFUSES takeover — fail-safe. +-- +-- Seat lifecycle: set by checkout_book/checkout_book_takeover; cleared automatically by +-- a BEFORE UPDATE trigger whenever locked_by is cleared (covers unlock_book, +-- force_unlock, checkin_finish_tx's release, and any future unlock path without +-- recreating them here). +-- +-- CONTRACTS.md v1.5: checkout_book/checkout_book_takeover gain an optional `seat` +-- parameter and return `locked_seat`; get_collection_state/get_changes book rows carry +-- `locked_seat`. All additive. +-- ============================================================================= + +ALTER TABLE tc.books ADD COLUMN locked_seat text; + +COMMENT ON COLUMN tc.books.locked_seat IS + 'Which local copy of the collection ("seat") holds the lock: a client-computed ' + 'stable hash of the local collection folder path (never the raw path). NULL = ' + 'unknown (legacy lock, or one acquired by checkin_start_tx''s take-if-free path); ' + 'a NULL seat can never be taken over (fail-safe).'; + +-- --------------------------------------------------------------------------- +-- Trigger: locked_seat can never outlive locked_by. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc._clear_seat_on_unlock() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.locked_by IS NULL THEN + NEW.locked_seat := NULL; + END IF; + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION tc._clear_seat_on_unlock() IS + 'Internal: clears tc.books.locked_seat whenever locked_by is cleared, so every ' + 'unlock path (unlock_book, force_unlock, checkin_finish_tx, future ones) stays ' + 'seat-consistent without each having to remember the column.'; + +CREATE TRIGGER books_clear_seat_on_unlock + BEFORE UPDATE ON tc.books + FOR EACH ROW + EXECUTE FUNCTION tc._clear_seat_on_unlock(); + +-- --------------------------------------------------------------------------- +-- checkout_book(book_id, machine, seat) — records the caller's seat with the lock. +-- --------------------------------------------------------------------------- +-- DROP + CREATE (not OR REPLACE) because the signature gains a parameter. The new +-- parameter has a DEFAULT so pre-seat callers (and pgTAP's 2-arg calls) keep working; +-- they simply record an unknown (NULL) seat. +DROP FUNCTION tc.checkout_book(uuid, text); + +CREATE FUNCTION tc.checkout_book( + p_book_id uuid, + p_machine text, + p_seat text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + -- Get book + membership check + SELECT b.collection_id INTO v_collection + FROM tc.books b + WHERE b.id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'book_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Race-free conditional UPDATE + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_seat = p_seat, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND (locked_by IS NULL OR locked_by = v_user_id); + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_seat', p_seat, + 'locked_at', now() + ); + ELSE + -- Lock held by someone else + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_seat', v_row.locked_seat, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book(uuid, text, text) IS + 'CONTRACTS.md: checkout_book — conditional lock (race-free UPDATE WHERE locked_by IS NULL ' + 'OR locked_by = me). v1.5: also records the caller''s seat (local-copy id) with the lock. ' + 'Returns {success, locked_by, locked_by_machine, locked_seat, locked_at}. ' + 'Emits CheckOut event (type=0) on success.'; + +GRANT EXECUTE ON FUNCTION tc.checkout_book(uuid, text, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- checkout_book_takeover(book_id, machine, seat) — takeover now requires the seat too. +-- --------------------------------------------------------------------------- +DROP FUNCTION tc.checkout_book_takeover(uuid, text); + +CREATE FUNCTION tc.checkout_book_takeover( + p_book_id uuid, + p_machine text, + p_seat text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id text; + v_collection uuid; + v_before tc.books%ROWTYPE; + v_updated integer; -- row count from the conditional UPDATE (0 or 1) + v_row tc.books%ROWTYPE; +BEGIN + v_user_id := tc.current_user_id(); + + SELECT * INTO v_before FROM tc.books WHERE id = p_book_id; + + IF NOT FOUND THEN + RAISE EXCEPTION '%', '{"error":"book_not_found"}' USING ERRCODE = 'PT404'; + END IF; + + v_collection := v_before.collection_id; + + IF NOT tc.is_member(v_collection) THEN + RAISE EXCEPTION '%', '{"error":"not_a_member"}' USING ERRCODE = 'PT403'; + END IF; + + -- Race-free conditional UPDATE: only takes the lock from a DIFFERENT account, and only + -- when that account's lock is recorded against the SAME machine AND the SAME seat + -- (local collection copy) the caller is on now (bug #0, John's ruling: takeover is the + -- shared-computer, same-local-folder scenario — two folders on one machine are two + -- seats and remain a genuine conflict). A NULL stored seat (legacy lock, or one taken + -- by checkin_start_tx's take-if-free path) never matches — fail-safe. A NULL p_seat + -- (pre-seat caller) likewise can never take over. + UPDATE tc.books + SET locked_by = v_user_id, + locked_by_machine = p_machine, + locked_seat = p_seat, + locked_at = now() + WHERE id = p_book_id + AND deleted_at IS NULL + AND locked_by IS NOT NULL + AND locked_by <> v_user_id + AND locked_by_machine = p_machine + AND locked_seat IS NOT NULL + AND locked_seat = p_seat; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Fetch resulting row + SELECT * INTO v_row FROM tc.books WHERE id = p_book_id; + + IF v_updated > 0 THEN + -- Emit CheckOut event (type = 0) -- same event type an ordinary checkout_book success + -- emits, since from the audit trail's point of view this genuinely is B checking the + -- book out; the preceding history already shows A's own checkout, so the handoff reads + -- naturally without needing a new event-type constant shared across client/server. + INSERT INTO tc.events ( + collection_id, book_id, type, + by_user_id, by_user_name, by_email, book_name + ) + SELECT + v_row.collection_id, p_book_id, 0, + v_user_id, (auth.jwt() ->> 'name'), tc.current_user_email(), + v_row.name; + + RETURN jsonb_build_object( + 'success', true, + 'locked_by', v_user_id, + 'locked_by_machine', p_machine, + 'locked_seat', p_seat, + 'locked_at', v_row.locked_at + ); + ELSE + -- Nothing to take over (already ours, unlocked, locked on a different machine, or + -- locked in a different/unknown seat). + RETURN jsonb_build_object( + 'success', false, + 'locked_by', v_row.locked_by, + 'locked_by_machine', v_row.locked_by_machine, + 'locked_seat', v_row.locked_seat, + 'locked_at', v_row.locked_at + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION tc.checkout_book_takeover(uuid, text, text) IS + 'CONTRACTS.md v1.5: checkout_book_takeover — atomically reassigns a book''s lock from a ' + 'DIFFERENT account to the caller, but ONLY when the existing lock is recorded for the SAME ' + 'machine AND the SAME seat (local collection copy) — bug #0: two local copies on one ' + 'computer are two seats; a NULL stored seat never matches (fail-safe). Returns ' + '{success, locked_by, locked_by_machine, locked_seat, locked_at}. Emits a CheckOut event ' + '(type=0) only when the lock actually changed hands.'; + +GRANT EXECUTE ON FUNCTION tc.checkout_book_takeover(uuid, text, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- get_collection_state / get_changes: expose locked_seat on book rows (additive). +-- --------------------------------------------------------------------------- +-- Full recreations of the 20260707000006 versions with locked_seat added to each +-- book-row SELECT; no other change. +CREATE OR REPLACE FUNCTION tc.get_collection_state( + p_collection_id uuid, + p_since_event_id bigint DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_max_event_id bigint; + v_books jsonb; + v_groups jsonb; +BEGIN + -- Verify membership + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Max event id for the cursor + SELECT max(id) INTO v_max_event_id + FROM tc.events + WHERE collection_id = p_collection_id; + + -- Books: full or delta + IF p_since_event_id IS NULL THEN + -- Full snapshot: all live books, minus never-committed books invisible to + -- everyone but their own mid-Send lock holder (see 20260707000006 for history). + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND (b.current_version_id IS NOT NULL OR b.locked_by = tc.current_user_id()) + ORDER BY lower(b.name) + ) b; + ELSE + -- Delta: only books that have an event since since_event_id + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + b.created_at, + b.created_by, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE b.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + END IF; + + -- Collection file group versions + SELECT jsonb_agg(row_to_json(g)::jsonb) + INTO v_groups + FROM ( + SELECT group_key, version, updated_at + FROM tc.collection_file_groups + WHERE collection_id = p_collection_id + ORDER BY group_key + ) g; + + RETURN jsonb_build_object( + 'books', COALESCE(v_books, '[]'::jsonb), + 'groups', COALESCE(v_groups, '[]'::jsonb), + 'max_event_id', v_max_event_id + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_collection_state(uuid, bigint) IS + 'CONTRACTS.md: get_collection_state — full/delta snapshot of book rows + group versions + ' + 'max_event_id. since_event_id = NULL → full; otherwise delta. v1.2 (20260707000006): book ' + 'rows also carry locked_by_email/locked_by_name for display. v1.5 (20260711000003): book ' + 'rows also carry locked_seat.'; + +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up. v1.2 (20260707000006): ' + 'touched book rows also carry locked_by_email/locked_by_name for display. v1.5 ' + '(20260711000003): touched book rows also carry locked_seat.'; diff --git a/supabase/migrations/20260713000001_tc_member_display_name.sql b/supabase/migrations/20260713000001_tc_member_display_name.sql new file mode 100644 index 000000000000..933a55695a13 --- /dev/null +++ b/supabase/migrations/20260713000001_tc_member_display_name.sql @@ -0,0 +1,261 @@ +-- ============================================================================= +-- Migration: durable member display names (CONTRACTS.md v1.6, additive) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Dogfood batch 1, John's 13 Jul 2026 request: "checked out to X" (and similar) should +-- show a human-readable name with the email as a fallback, and admins should be able to +-- set that name for each team member from the Sharing panel. +-- +-- Until now the only display-name source was tc.events.by_user_name — the JWT `name` +-- claim captured per action (see 20260707000006), which is normally NULL in dev-auth +-- mode and never editable. This migration adds the durable, editable source: +-- +-- • tc.members.display_name (nullable text) — the canonical per-collection name. +-- • tc.resolve_member_display now prefers it, keeping the JWT-claim capture as a +-- fallback — so get_collection_state / get_changes / get_book_manifest (all of +-- which already report locked_by_name via this helper, unchanged signatures) +-- pick the new source up with no further changes. +-- • tc.members_list now returns display_name so the Sharing panel can show/edit it. +-- • tc.members_set_display_name — admin may set anyone's name; a claimed member may +-- set their own. Blank/whitespace clears back to NULL (= fall back to email). + +-- --------------------------------------------------------------------------- +-- Column +-- --------------------------------------------------------------------------- +ALTER TABLE tc.members ADD COLUMN display_name text; + +COMMENT ON COLUMN tc.members.display_name IS + 'Human-readable name shown in place of the email wherever the member is displayed ' + '(checkout status, history, sharing panel). NULL = none set; display falls back to ' + 'email. Set via tc.members_set_display_name (admin, or the claimed member themselves).'; + +-- --------------------------------------------------------------------------- +-- resolve_member_display: prefer the durable column, keep the JWT-claim fallback. +-- Same signature as 20260707000006, so the current get_collection_state / get_changes / +-- get_book_manifest bodies (latest: 20260711000003) need no re-creation. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.resolve_member_display( + p_collection_id uuid, + p_user_id text, + OUT email text, + OUT display_name text +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT + m.email, + COALESCE( + m.display_name, + ( + SELECT e.by_user_name + FROM tc.events e + WHERE e.collection_id = p_collection_id + AND e.by_user_id = p_user_id + AND e.by_user_name IS NOT NULL + ORDER BY e.id DESC + LIMIT 1 + ) + ) + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND m.user_id = p_user_id + LIMIT 1; +$$; + +COMMENT ON FUNCTION tc.resolve_member_display(uuid, text) IS + 'Best-effort resolution of a locked_by/created_by user_id to a display email ' + '(from tc.members, authoritative) and display name. v1.6 (20260713000001): prefers ' + 'the durable tc.members.display_name; falls back to the most recent ' + 'tc.events.by_user_name JWT-claim capture (often NULL in dev-auth mode). Returns an ' + 'all-NULL row (never an error) when p_user_id is NULL or unknown.'; + +-- --------------------------------------------------------------------------- +-- members_list: add display_name. Return type changes, so DROP + CREATE (the +-- 20260711000003 checkout_book precedent) and re-GRANT. +-- --------------------------------------------------------------------------- +DROP FUNCTION tc.members_list(uuid); + +CREATE FUNCTION tc.members_list( + p_collection_id uuid +) +RETURNS TABLE ( + id bigint, + email text, + display_name text, + role tc.member_role, + user_id text, + added_by text, + added_at timestamptz, + claimed_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT m.id, m.email, m.display_name, m.role, m.user_id, m.added_by, m.added_at, + m.claimed_at + FROM tc.members m + WHERE m.collection_id = p_collection_id + AND tc.is_member(p_collection_id) -- membership gate + ORDER BY m.email +$$; + +COMMENT ON FUNCTION tc.members_list(uuid) IS + 'CONTRACTS.md: members list — returns approved-accounts for the collection. ' + 'Any member may call this. v1.6 (20260713000001): rows also carry display_name.'; + +GRANT EXECUTE ON FUNCTION tc.members_list(uuid) TO authenticated; + +-- --------------------------------------------------------------------------- +-- members_set_display_name(collection_id uuid, member_id bigint, display_name text) +-- --------------------------------------------------------------------------- +-- Admin may set any member's name; a claimed member may set their own (the Sharing +-- panel is admin-edit-only for now, but the permission model anticipates a +-- set-my-own-name UI). Blank/whitespace input clears the name back to NULL. +CREATE FUNCTION tc.members_set_display_name( + p_collection_id uuid, + p_member_id bigint, + p_display_name text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_target_user_id text; + v_name text; +BEGIN + -- Membership gate first, so non-members learn nothing about member row ids. + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + SELECT user_id INTO v_target_user_id + FROM tc.members + WHERE id = p_member_id AND collection_id = p_collection_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'member_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT tc.is_admin(p_collection_id) + AND (v_target_user_id IS NULL OR v_target_user_id <> tc.current_user_id()) THEN + RAISE EXCEPTION 'admin_required' USING ERRCODE = '42501'; + END IF; + + v_name := NULLIF(btrim(p_display_name), ''); + IF char_length(v_name) > 100 THEN + RAISE EXCEPTION 'display_name_too_long' USING ERRCODE = '22001'; + END IF; + + UPDATE tc.members + SET display_name = v_name + WHERE id = p_member_id + AND collection_id = p_collection_id; +END; +$$; + +COMMENT ON FUNCTION tc.members_set_display_name(uuid, bigint, text) IS + 'CONTRACTS.md v1.6: members set_display_name — admin may set any member''s display ' + 'name; a claimed member may set their own. Trims; blank clears to NULL (display ' + 'falls back to email). Max 100 chars.'; + +GRANT EXECUTE ON FUNCTION tc.members_set_display_name(uuid, bigint, text) TO authenticated; + +-- --------------------------------------------------------------------------- +-- get_changes: event rows gain by_display_name (additive) so history shows the +-- CURRENT durable display name, not just the JWT-claim capture frozen into +-- by_user_name at event time. Full recreation of the 20260711000003 version with +-- only that addition (same signature, so CREATE OR REPLACE). +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION tc.get_changes( + p_collection_id uuid, + p_since_event_id bigint +) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +AS $$ +DECLARE + v_events jsonb; + v_books jsonb; +BEGIN + IF NOT tc.is_member(p_collection_id) THEN + RAISE EXCEPTION 'not_a_member' USING ERRCODE = '42501'; + END IF; + + -- Events since cursor + SELECT jsonb_agg(row_to_json(e)::jsonb ORDER BY e.id) + INTO v_events + FROM ( + SELECT + e.id, + e.book_id, + e.type, + e.by_user_id, + e.by_user_name, + e.by_email, + erd.display_name AS by_display_name, + e.book_version_seq, + e.lock_info, + e.book_name, + e.group_key, + e.message, + e.bloom_version, + e.occurred_at + FROM tc.events e + LEFT JOIN LATERAL tc.resolve_member_display(e.collection_id, e.by_user_id) erd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY e.id + ) e; + + -- Touched book rows (distinct books referenced in those events) + SELECT jsonb_agg(row_to_json(b)::jsonb) + INTO v_books + FROM ( + SELECT DISTINCT ON (b.id) + b.id, + b.instance_id, + b.name, + b.current_version_id, + b.current_version_seq, + b.current_checksum, + b.locked_by, + b.locked_by_machine, + b.locked_seat, + b.locked_at, + b.deleted_at, + rd.email AS locked_by_email, + rd.display_name AS locked_by_name + FROM tc.books b + JOIN tc.events e ON e.book_id = b.id + LEFT JOIN LATERAL tc.resolve_member_display(b.collection_id, b.locked_by) rd + ON true + WHERE e.collection_id = p_collection_id + AND e.id > p_since_event_id + ORDER BY b.id + ) b; + + RETURN jsonb_build_object( + 'events', COALESCE(v_events, '[]'::jsonb), + 'books', COALESCE(v_books, '[]'::jsonb), + 'max_event_id', ( + SELECT max(id) FROM tc.events + WHERE collection_id = p_collection_id + AND id > p_since_event_id + ) + ); +END; +$$; + +COMMENT ON FUNCTION tc.get_changes(uuid, bigint) IS + 'CONTRACTS.md: get_changes — events + touched book rows since the cursor. ' + 'Used for polling (60s fallback) and realtime reconnect catch-up. v1.2 (20260707000006): ' + 'touched book rows also carry locked_by_email/locked_by_name for display. v1.5 ' + '(20260711000003): touched book rows also carry locked_seat. v1.6 (20260713000001): ' + 'event rows also carry by_display_name (the current durable display name of by_user_id).'; diff --git a/supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql b/supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql new file mode 100644 index 000000000000..30a93efbd1b5 --- /dev/null +++ b/supabase/migrations/20260713000002_tc_members_pending_uq_fix.sql @@ -0,0 +1,27 @@ +-- ============================================================================= +-- Migration: allow multiple PENDING invitations per collection (constraint fix) +-- Cloud Team Collections — Bloom Desktop +-- ============================================================================= +-- Found by 03_tc_member_display_name_test.sql's fixture (13 Jul 2026): the original +-- schema declared +-- +-- CONSTRAINT members_claimed_user_uq UNIQUE NULLS NOT DISTINCT (collection_id, user_id) +-- +-- NULLS NOT DISTINCT makes two unclaimed rows (user_id IS NULL) collide, so a second +-- invitation raises 23505 from members_add before the first invitee claims — i.e. a +-- collection could only ever have ONE pending invitation at a time. That contradicts +-- the schema's own documented intent ("A claimed user may appear at most once per +-- collection (UNIQUE WHERE user_id IS NOT NULL)"); dogfooding never tripped it only +-- because invites happened to alternate with claims. members_add's ON CONFLICT clause +-- targets the (collection_id, email) constraint, so this error escaped it unhandled. +-- +-- Fix: same constraint with default NULLS DISTINCT semantics — claimed users stay +-- unique per collection, unclaimed rows don't collide. +ALTER TABLE tc.members DROP CONSTRAINT members_claimed_user_uq; +ALTER TABLE tc.members ADD CONSTRAINT members_claimed_user_uq + UNIQUE (collection_id, user_id); + +COMMENT ON CONSTRAINT members_claimed_user_uq ON tc.members IS + 'A claimed user appears at most once per collection. NULL user_id rows (pending ' + 'invitations) do not collide (default NULLS DISTINCT; fixed 20260713000002 — the ' + 'original NULLS NOT DISTINCT allowed only one pending invitation per collection).'; diff --git a/supabase/snippets/.gitkeep b/supabase/snippets/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/supabase/tests/01_tc_schema_test.sql b/supabase/tests/01_tc_schema_test.sql new file mode 100644 index 000000000000..f3d926736506 --- /dev/null +++ b/supabase/tests/01_tc_schema_test.sql @@ -0,0 +1,464 @@ +-- ============================================================================= +-- pgTAP tests: Cloud Team Collections — tc schema, RLS, RPCs +-- ============================================================================= +-- NOTE: These tests are AUTHORED but UNRUN — no Docker / Supabase CLI on this +-- machine. Run with: +-- supabase start +-- supabase test db +-- or: +-- psql -U postgres -h localhost -p 54322 -f supabase/tests/01_tc_schema_test.sql +-- +-- Requires: pgTAP extension (bundled with local Supabase), pgtap schema accessible. +-- ============================================================================= + +BEGIN; + +-- Load pgTAP +SELECT plan(42); -- update count when tests are added/removed + +-- ============================================================================= +-- 0. Sanity: schema and key tables exist +-- ============================================================================= + +SELECT has_schema('tc', 'tc schema exists'); + +SELECT has_table('tc', 'collections', 'tc.collections table exists'); +SELECT has_table('tc', 'members', 'tc.members table exists'); +SELECT has_table('tc', 'books', 'tc.books table exists'); +SELECT has_table('tc', 'versions', 'tc.versions table exists'); +SELECT has_table('tc', 'version_files', 'tc.version_files table exists'); +SELECT has_table('tc', 'collection_file_groups','tc.collection_file_groups table exists'); +SELECT has_table('tc', 'collection_group_files','tc.collection_group_files table exists'); +SELECT has_table('tc', 'color_palette_entries', 'tc.color_palette_entries table exists'); +SELECT has_table('tc', 'events', 'tc.events table exists'); +SELECT has_table('tc', 'checkin_transactions', 'tc.checkin_transactions table exists'); + +SELECT has_function('tc', 'jwt_email_verified', 'tc.jwt_email_verified() exists'); +SELECT has_function('tc', 'create_collection', 'tc.create_collection() exists'); +SELECT has_function('tc', 'claim_memberships', 'tc.claim_memberships() exists'); +SELECT has_function('tc', 'checkout_book', 'tc.checkout_book() exists'); + +-- ============================================================================= +-- Test fixture helpers +-- ============================================================================= +-- Create two test users and a collection for use in subsequent tests. +-- We impersonate JWT callers via set_config so SECURITY DEFINER functions +-- can read auth.jwt(). In real Supabase these come from the auth layer. + +CREATE SCHEMA IF NOT EXISTS tests; + +-- Helper: set a fake JWT so auth.jwt() returns a known sub/email +CREATE OR REPLACE FUNCTION tests.set_jwt( + p_sub text, + p_email text, + p_email_verified boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_token text; +BEGIN + -- Build a minimal JWT payload (no real signing needed for pgTAP in local DB; + -- Supabase local instance accepts JWTs signed with the project's anon key, + -- but for pgTAP we use set_config to inject the claims directly into the + -- session so auth.jwt() returns them). + PERFORM set_config( + 'request.jwt.claims', + json_build_object( + 'sub', p_sub, + 'email', p_email, + 'email_verified', p_email_verified, + 'role', 'authenticated', + 'aud', 'authenticated' + )::text, + true -- local to transaction + ); +END; +$$; + +-- ============================================================================= +-- 1. jwt_email_verified() +-- ============================================================================= + +-- 1a. Firebase-style: email_verified = true +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"firebase-uid-abc","email":"alice@example.com","email_verified":true,"role":"authenticated"}', + true); +END; +$$; +SELECT ok(tc.jwt_email_verified(), '1a: jwt_email_verified() true for Firebase email_verified=true'); + +-- 1b. Firebase-style: email_verified = false +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"firebase-uid-abc","email":"alice@example.com","email_verified":false,"role":"authenticated"}', + true); +END; +$$; +SELECT ok(NOT tc.jwt_email_verified(), '1b: jwt_email_verified() false for Firebase email_verified=false'); + +-- 1c. Local GoTrue: no email_verified claim, role = 'authenticated' +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","email":"dev@localhost","role":"authenticated"}', + true); +END; +$$; +SELECT ok(tc.jwt_email_verified(), '1c: jwt_email_verified() true for local GoTrue (no claim, role=authenticated)'); + +-- ============================================================================= +-- 2. create_collection + RLS: member can read their collection +-- ============================================================================= + +-- Set up as user Alice +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +-- Alice creates a collection +SELECT lives_ok( + $$SELECT tc.create_collection('a0000000-0000-0000-0000-000000000001'::uuid, 'Alice Test Collection')$$, + '2a: create_collection succeeds for authenticated user' +); + +-- Alice can see her collection via RLS. Must run as the authenticated role — the suite's +-- postgres superuser bypasses RLS, which would make this assertion pass vacuously. +SET LOCAL ROLE authenticated; + +SELECT ok( + (SELECT count(*) = 1 FROM tc.collections WHERE id = 'a0000000-0000-0000-0000-000000000001'), + '2b: Alice can SELECT her collection (RLS: is_member)' +); + +-- Alice is an admin member +SELECT ok( + (SELECT role = 'admin' FROM tc.members + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND user_id = 'user-alice-001'), + '2c: Alice is recorded as admin of her collection' +); + +RESET ROLE; + +-- ============================================================================= +-- 3. RLS matrix: non-member cannot read +-- ============================================================================= + +-- Set up as user Bob (not a member) +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":true,"role":"authenticated","name":"Bob"}', + true); +END; +$$; + +-- RLS only applies to non-superuser roles: the suite runs as postgres, which BYPASSES +-- row security, so these direct-table assertions must run as the authenticated role +-- (the role PostgREST uses for JWT-carrying requests). RESET ROLE afterwards so later +-- fixture writes run as postgres again. +SET LOCAL ROLE authenticated; + +SELECT ok( + (SELECT count(*) = 0 FROM tc.collections WHERE id = 'a0000000-0000-0000-0000-000000000001'), + '3a: Non-member Bob cannot SELECT Alice''s collection (RLS)' +); + +SELECT ok( + (SELECT count(*) = 0 FROM tc.books + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001'), + '3b: Non-member Bob cannot SELECT books in Alice''s collection (RLS)' +); + +SELECT ok( + (SELECT count(*) = 0 FROM tc.events + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001'), + '3c: Non-member Bob cannot SELECT events in Alice''s collection (RLS)' +); + +RESET ROLE; + +-- ============================================================================= +-- 4. claim_memberships requires verified email +-- ============================================================================= + +-- Add Bob as an approved member (Alice adds him) +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.members_add('a0000000-0000-0000-0000-000000000001', 'bob@example.com', 'member')$$, + '4a: Admin Alice can add Bob as approved member' +); + +-- Bob with unverified email cannot claim +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":false,"role":"authenticated"}', + true); +END; +$$; + +SELECT throws_ok( + $$SELECT tc.claim_memberships()$$, + '28000', -- invalid_authorization_specification, raised by claim_memberships + NULL, + '4b: claim_memberships raises when email_verified=false' +); + +-- Bob with verified email can claim +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":true,"role":"authenticated"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.claim_memberships()$$, + '4c: claim_memberships succeeds for verified Bob' +); + +SELECT ok( + (SELECT user_id = 'user-bob-002' FROM tc.members + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND email = 'bob@example.com'), + '4d: Bob''s user_id is filled after claiming' +); + +-- ============================================================================= +-- 5. checkout_book concurrency: exactly one winner +-- ============================================================================= +-- Insert a test book directly (SECURITY DEFINER helper — RLS bypassed for setup). +INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) +VALUES ( + 'b0000000-0000-0000-0000-000000000001'::uuid, + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'b0000000-0000-0000-0000-000000000002'::uuid, + 'Test Book', + 'user-alice-001' +); + +-- Simulate two concurrent checkout attempts using two separate DO blocks. +-- We use advisory locks to test the conditional UPDATE serialization. +-- In practice the conditional UPDATE is atomic at READ COMMITTED; this test +-- verifies that calling checkout_book twice yields exactly one success. + +-- Alice checks out +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT ok( + (SELECT (tc.checkout_book('b0000000-0000-0000-0000-000000000001', 'AliceMachine')) ->> 'success' = 'true'), + '5a: Alice wins the checkout race (first call)' +); + +-- Bob tries to check out the same book — should fail +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-bob-002","email":"bob@example.com","email_verified":true,"role":"authenticated","name":"Bob"}', + true); +END; +$$; + +SELECT ok( + (SELECT (tc.checkout_book('b0000000-0000-0000-0000-000000000001', 'BobMachine')) ->> 'success' = 'false'), + '5b: Bob loses the checkout race (lock already held)' +); + +-- Exactly one CheckOut event (type=0) emitted +SELECT ok( + (SELECT count(*) = 1 FROM tc.events + WHERE book_id = 'b0000000-0000-0000-0000-000000000001' + AND type = 0), + '5c: exactly one CheckOut event (type=0) emitted' +); + +-- ============================================================================= +-- 6. last-admin guard +-- ============================================================================= + +-- Attempt to remove Alice (the only admin) should fail +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT throws_ok( + $$SELECT tc.members_remove( + 'a0000000-0000-0000-0000-000000000001', + (SELECT id FROM tc.members WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND user_id = 'user-alice-001') + )$$, + 'P0001', + NULL, + '6a: Removing the last admin raises last_admin_guard' +); + +-- Demoting Alice to member should also fail +SELECT throws_ok( + $$UPDATE tc.members + SET role = 'member' + WHERE collection_id = 'a0000000-0000-0000-0000-000000000001' + AND user_id = 'user-alice-001'$$, + 'P0001', + NULL, + '6b: Demoting the last admin raises last_admin_guard' +); + +-- ============================================================================= +-- 7. get_changes cursor +-- ============================================================================= + +-- Log an event as Alice +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.log_event( + 'a0000000-0000-0000-0000-000000000001', + 'b0000000-0000-0000-0000-000000000001', + 100, -- WorkPreservedLocally + 'test incident', + 'Test Book', + '6.5.0' + )$$, + '7a: log_event succeeds for a member' +); + +-- get_changes with cursor = 0 returns events +SELECT ok( + (SELECT jsonb_array_length( + (tc.get_changes('a0000000-0000-0000-0000-000000000001', 0)) -> 'events' + ) > 0), + '7b: get_changes(since=0) returns at least one event' +); + +-- get_changes with cursor = max returns empty +SELECT ok( + (SELECT jsonb_array_length( + (tc.get_changes( + 'a0000000-0000-0000-0000-000000000001', + (SELECT max(id) FROM tc.events WHERE collection_id = 'a0000000-0000-0000-0000-000000000001') + )) -> 'events' + ) = 0), + '7c: get_changes(since=max_id) returns empty events' +); + +-- ============================================================================= +-- 8. Tombstone / undelete +-- ============================================================================= + +-- Alice must hold the lock to delete (she already holds it from checkout in test 5a) +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +SELECT lives_ok( + $$SELECT tc.delete_book('b0000000-0000-0000-0000-000000000001')$$, + '8a: delete_book succeeds when caller holds the lock' +); + +SELECT ok( + (SELECT deleted_at IS NOT NULL FROM tc.books + WHERE id = 'b0000000-0000-0000-0000-000000000001'), + '8b: deleted_at is set after delete_book' +); + +-- Admin (Alice) can undelete +SELECT lives_ok( + $$SELECT tc.undelete_book('b0000000-0000-0000-0000-000000000001')$$, + '8c: admin can undelete a tombstoned book' +); + +SELECT ok( + (SELECT deleted_at IS NULL FROM tc.books + WHERE id = 'b0000000-0000-0000-0000-000000000001'), + '8d: deleted_at is NULL after undelete_book' +); + +-- ============================================================================= +-- 9. Live-name uniqueness: tombstoned names are reusable +-- ============================================================================= + +-- Delete the existing book to tombstone the name 'Test Book' +DO $$ +BEGIN + PERFORM set_config('request.jwt.claims', + '{"sub":"user-alice-001","email":"alice@example.com","email_verified":true,"role":"authenticated","name":"Alice"}', + true); +END; +$$; + +-- Re-checkout so we can delete again +SELECT tc.checkout_book('b0000000-0000-0000-0000-000000000001', 'AliceMachine'); +SELECT tc.delete_book('b0000000-0000-0000-0000-000000000001'); + +-- Inserting a new book with the same name should succeed (tombstone excluded from index) +SELECT lives_ok( + $$INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) + VALUES ( + 'b0000000-0000-0000-0000-000000000099'::uuid, + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'b0000000-0000-0000-0000-000000000098'::uuid, + 'Test Book', + 'user-alice-001' + )$$, + '9a: inserting a live book with tombstoned name succeeds (name reuse)' +); + +-- But a second live book with the same name should fail +SELECT throws_ok( + $$INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) + VALUES ( + 'b0000000-0000-0000-0000-000000000097'::uuid, + 'a0000000-0000-0000-0000-000000000001'::uuid, + 'b0000000-0000-0000-0000-000000000096'::uuid, + 'Test Book', + 'user-alice-001' + )$$, + '23505', -- unique_violation + NULL, + '9b: inserting a second live book with same name raises unique_violation' +); + +-- ============================================================================= +-- Finish +-- ============================================================================= + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/02_tc_checkout_takeover_test.sql b/supabase/tests/02_tc_checkout_takeover_test.sql new file mode 100644 index 000000000000..e02af48fb9ca --- /dev/null +++ b/supabase/tests/02_tc_checkout_takeover_test.sql @@ -0,0 +1,230 @@ +-- ============================================================================= +-- pgTAP tests: tc.checkout_book_takeover (dogfood batch 1, item 9 -- account-switch +-- checkout takeover; extended by 20260711000003's per-collection-copy "seat", bug #0) +-- ============================================================================= +-- Run against a local Supabase stack: +-- supabase start +-- supabase test db +-- ============================================================================= + +BEGIN; + +SELECT plan(23); + +SELECT has_function('tc', 'checkout_book_takeover', 'tc.checkout_book_takeover() exists'); + +-- Helper: set a fake JWT so auth.jwt() returns a known sub/email (same helper as +-- 01_tc_schema_test.sql; re-declared here since each test file runs standalone). +CREATE SCHEMA IF NOT EXISTS tests; + +CREATE OR REPLACE FUNCTION tests.set_jwt( + p_sub text, + p_email text, + p_email_verified boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM set_config( + 'request.jwt.claims', + json_build_object( + 'sub', p_sub, + 'email', p_email, + 'email_verified', p_email_verified, + 'role', 'authenticated', + 'aud', 'authenticated' + )::text, + true + ); +END; +$$; + +-- ============================================================================= +-- Fixture: a collection with Alice (admin) and Bob (member, claimed), a book Alice has +-- checked out on "SharedMachine" in her own local copy ("seat-alice-copy"). Uses the +-- public RPCs (create_collection/members_add/claim_memberships), matching +-- 01_tc_schema_test.sql's own fixture convention. +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-tko', 'alice-tko@example.com', true); + +SELECT lives_ok( + $$SELECT tc.create_collection('c0000000-0000-0000-0000-00000000a001'::uuid, 'Takeover Test Collection')$$, + '0a: create_collection succeeds for Alice' +); + +SELECT lives_ok( + $$SELECT tc.members_add('c0000000-0000-0000-0000-00000000a001', 'bob-tko@example.com', 'member')$$, + '0b: Alice adds Bob as an approved member' +); + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT lives_ok( + $$SELECT tc.claim_memberships()$$, + '0c: Bob claims his membership' +); + +SELECT tests.set_jwt('user-alice-tko', 'alice-tko@example.com', true); + +-- Insert a test book directly (SECURITY DEFINER helper — RLS bypassed for setup), matching +-- 01_tc_schema_test.sql section 5's own convention. +INSERT INTO tc.books (id, collection_id, instance_id, name, created_by) +VALUES ( + 'b0000000-0000-0000-0000-00000000a001'::uuid, + 'c0000000-0000-0000-0000-00000000a001'::uuid, + 'b0000000-0000-0000-0000-00000000a002'::uuid, + 'Takeover Test Book', + 'user-alice-tko' +); + +-- Alice checks the book out on SharedMachine, seat "seat-alice-copy" (ordinary +-- checkout_book, already tested elsewhere -- used here purely as fixture setup). +SELECT tc.checkout_book('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy'); + +SELECT ok( + (SELECT locked_seat FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'seat-alice-copy', + '0d: checkout_book records the caller''s seat with the lock' +); + +-- ============================================================================= +-- 1. Bob (different account) CANNOT take over across machines or across seats +-- ============================================================================= + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'BobsOwnMachine', 'seat-alice-copy')) ->> 'success' = 'false'), + '1a: Bob cannot take over Alice''s lock from a DIFFERENT machine' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-alice-tko', + '1b: the lock still belongs to Alice after the cross-machine attempt' +); + +-- bug #0 (e2e-4's scenario): same machine but a DIFFERENT local copy of the collection. +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-bob-copy')) ->> 'success' = 'false'), + '1c: Bob cannot take over from the SAME machine but a DIFFERENT seat (separate local copy)' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-alice-tko', + '1d: the lock still belongs to Alice after the wrong-seat attempt' +); + +-- A pre-seat caller (p_seat defaults to NULL) can never take over. +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine')) ->> 'success' = 'false'), + '1e: a caller supplying no seat cannot take over' +); + +-- ============================================================================= +-- 2. Bob CAN take over a lock held on the SAME machine in the SAME seat (the true +-- shared-computer scenario: account B opens the exact local folder account A used) +-- ============================================================================= + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy')) ->> 'success' = 'true'), + '2a: Bob takes over Alice''s same-machine same-seat lock' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-bob-tko', + '2b: the lock now belongs to Bob' +); + +SELECT ok( + (SELECT locked_by_machine FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'SharedMachine', + '2c: the machine is unchanged (still SharedMachine)' +); + +SELECT ok( + (SELECT locked_seat FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'seat-alice-copy', + '2d: the seat is unchanged (still the shared local copy)' +); + +SELECT ok( + (SELECT count(*) = 1 FROM tc.events + WHERE book_id = 'b0000000-0000-0000-0000-00000000a001' + AND type = 0 + AND by_user_id = 'user-bob-tko'), + '2e: exactly one CheckOut event (type=0) recorded for Bob''s takeover' +); + +-- ============================================================================= +-- 3. Calling it again for the CURRENT holder is a harmless no-op (not a new "takeover") +-- ============================================================================= + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy')) ->> 'success' = 'false'), + '3a: re-calling takeover when the caller already holds the lock reports no change' +); + +SELECT ok( + (SELECT count(*) = 1 FROM tc.events + WHERE book_id = 'b0000000-0000-0000-0000-00000000a001' + AND type = 0 + AND by_user_id = 'user-bob-tko'), + '3b: no duplicate CheckOut event was emitted for the no-op re-call' +); + +-- ============================================================================= +-- 4. A non-member cannot take over any lock +-- ============================================================================= + +SELECT tests.set_jwt('user-carol-tko', 'carol-tko@example.com', true); + +-- PT403 (not 42501): checkout_book_takeover raises the schema-wide PT### passthrough +-- codes as of 20260711000002. +SELECT throws_ok( + $$SELECT tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-alice-copy')$$, + 'PT403', + NULL, + '4a: a non-member cannot take over a lock (not_a_member)' +); + +-- ============================================================================= +-- 5. Unlock clears the seat (books_clear_seat_on_unlock trigger) +-- ============================================================================= + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT lives_ok( + $$SELECT tc.unlock_book('b0000000-0000-0000-0000-00000000a001')$$, + '5a: the current holder can unlock' +); + +SELECT ok( + (SELECT locked_seat IS NULL FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001'), + '5b: locked_seat is cleared with the lock (trigger)' +); + +-- ============================================================================= +-- 6. A lock with NO recorded seat (legacy/pre-seat, or checkin_start_tx's take-if-free +-- path) can never be taken over — fail-safe. +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-tko', 'alice-tko@example.com', true); + +SELECT ok( + (SELECT (tc.checkout_book('b0000000-0000-0000-0000-00000000a001', 'SharedMachine')) ->> 'success' = 'true'), + '6a: a pre-seat checkout (no seat argument) still succeeds' +); + +SELECT tests.set_jwt('user-bob-tko', 'bob-tko@example.com', true); + +SELECT ok( + (SELECT (tc.checkout_book_takeover('b0000000-0000-0000-0000-00000000a001', 'SharedMachine', 'seat-bob-copy')) ->> 'success' = 'false'), + '6b: a NULL stored seat never matches — takeover refused (fail-safe)' +); + +SELECT ok( + (SELECT locked_by FROM tc.books WHERE id = 'b0000000-0000-0000-0000-00000000a001') = 'user-alice-tko', + '6c: the null-seat lock still belongs to Alice' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/03_tc_member_display_name_test.sql b/supabase/tests/03_tc_member_display_name_test.sql new file mode 100644 index 000000000000..6ff279f47d89 --- /dev/null +++ b/supabase/tests/03_tc_member_display_name_test.sql @@ -0,0 +1,294 @@ +-- ============================================================================= +-- pgTAP tests: tc.members.display_name + tc.members_set_display_name +-- (dogfood batch 1, John's 13 Jul 2026 member-display-name request; +-- migration 20260713000001) +-- ============================================================================= +-- Run against a local Supabase stack: +-- supabase start +-- supabase test db +-- ============================================================================= + +BEGIN; + +SELECT plan(24); + +SELECT has_column('tc', 'members', 'display_name', 'tc.members.display_name exists'); +SELECT has_function( + 'tc', 'members_set_display_name', + 'tc.members_set_display_name() exists' +); + +-- Helper: set a fake JWT so auth.jwt() returns a known sub/email (same helper as +-- 01_tc_schema_test.sql; re-declared here since each test file runs standalone). +CREATE SCHEMA IF NOT EXISTS tests; + +CREATE OR REPLACE FUNCTION tests.set_jwt( + p_sub text, + p_email text, + p_email_verified boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM set_config( + 'request.jwt.claims', + json_build_object( + 'sub', p_sub, + 'email', p_email, + 'email_verified', p_email_verified, + 'role', 'authenticated', + 'aud', 'authenticated' + )::text, + true + ); +END; +$$; + +-- ============================================================================= +-- Fixture: Alice (admin), Bob (member, claimed), Carol (approved, never claims). +-- Uses the public RPCs, matching the other test files' fixture convention. +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-dn', 'alice-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.create_collection('c0000000-0000-0000-0000-00000000d001'::uuid, 'Display Name Test Collection')$$, + '0a: create_collection succeeds for Alice' +); + +SELECT lives_ok( + $$SELECT tc.members_add('c0000000-0000-0000-0000-00000000d001', 'bob-dn@example.com', 'member')$$, + '0b: Alice adds Bob as an approved member' +); + +SELECT lives_ok( + $$SELECT tc.members_add('c0000000-0000-0000-0000-00000000d001', 'carol-dn@example.com', 'member')$$, + '0c: Alice adds Carol (who will stay unclaimed/pending)' +); + +SELECT tests.set_jwt('user-bob-dn', 'bob-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.claim_memberships()$$, + '0d: Bob claims his membership' +); + +-- ============================================================================= +-- 1. Admin can set anyone's display name (claimed or pending) +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-dn', 'alice-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + ' Bob the Builder ')$$, + '1a: admin sets a claimed member''s display name' +); + +SELECT is( + (SELECT ml.display_name + FROM tc.members_list('c0000000-0000-0000-0000-00000000d001') ml + WHERE ml.email = 'bob-dn@example.com'), + 'Bob the Builder', + '1b: members_list returns the (trimmed) display name' +); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'carol-dn@example.com'), + 'Carol C')$$, + '1c: admin sets a PENDING (unclaimed) member''s display name' +); + +-- ============================================================================= +-- 2. Non-admin member: own name yes, anyone else's no +-- ============================================================================= + +SELECT tests.set_jwt('user-bob-dn', 'bob-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + 'Just Bob')$$, + '2a: a claimed non-admin member sets their OWN display name' +); + +SELECT is( + (SELECT m.display_name FROM tc.members m WHERE m.email = 'bob-dn@example.com'), + 'Just Bob', + '2b: the self-set name stuck' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'alice-dn@example.com'), + 'Not Your Name')$$, + '42501', + 'admin_required', + '2c: a non-admin cannot set another member''s display name' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'carol-dn@example.com'), + 'Not Your Name Either')$$, + '42501', + 'admin_required', + '2d: a non-admin cannot set an unclaimed member''s display name' +); + +-- Sanity: Alice's name was NOT changed by the refused 2c call. +SELECT is( + (SELECT m.display_name FROM tc.members m WHERE m.email = 'alice-dn@example.com'), + NULL, + '2e: the refused call did not write anything' +); + +-- ============================================================================= +-- 3. Non-member is refused before learning anything +-- ============================================================================= + +SELECT tests.set_jwt('user-mallory-dn', 'mallory-dn@example.com', true); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + 1, + 'Intruder')$$, + '42501', + 'not_a_member', + '3: a non-member is refused (not_a_member, not member_not_found)' +); + +-- ============================================================================= +-- 4. Blank clears to NULL; unknown member id; over-long name +-- ============================================================================= + +SELECT tests.set_jwt('user-alice-dn', 'alice-dn@example.com', true); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + ' ')$$, + '4a: blank/whitespace input is accepted' +); + +SELECT is( + (SELECT m.display_name FROM tc.members m WHERE m.email = 'bob-dn@example.com'), + NULL, + '4b: ...and clears the display name back to NULL' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + 999999999, + 'Nobody')$$, + 'P0002', + 'member_not_found', + '4c: unknown member id raises member_not_found' +); + +SELECT throws_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + repeat('x', 101))$$, + '22001', + 'display_name_too_long', + '4d: a 101-char name raises display_name_too_long' +); + +-- ============================================================================= +-- 5. resolve_member_display precedence: durable column beats the JWT-claim +-- event capture; the event capture remains the fallback. +-- ============================================================================= + +-- A historical event carrying the JWT name claim for Bob (direct insert for setup, +-- matching the other files' fixture convention). +INSERT INTO tc.events (collection_id, type, by_user_id, by_user_name, by_email) +VALUES ( + 'c0000000-0000-0000-0000-00000000d001'::uuid, + 1, -- CheckIn + 'user-bob-dn', + 'JWT Bob', + 'bob-dn@example.com' +); + +-- Bob's display_name is NULL right now (cleared in 4a/4b) → fallback applies. +SELECT is( + (SELECT rd.display_name + FROM tc.resolve_member_display( + 'c0000000-0000-0000-0000-00000000d001'::uuid, 'user-bob-dn') rd), + 'JWT Bob', + '5a: with no durable name, the latest event by_user_name is the fallback' +); + +SELECT lives_ok( + $$SELECT tc.members_set_display_name( + 'c0000000-0000-0000-0000-00000000d001', + (SELECT id FROM tc.members WHERE email = 'bob-dn@example.com'), + 'Durable Bob')$$, + '5b: admin sets the durable name again' +); + +SELECT is( + (SELECT rd.display_name + FROM tc.resolve_member_display( + 'c0000000-0000-0000-0000-00000000d001'::uuid, 'user-bob-dn') rd), + 'Durable Bob', + '5c: the durable column now beats the event fallback' +); + +-- ...and the whole pipeline: a book Bob has checked out reports his durable name as +-- locked_by_name in get_collection_state (queried as Bob himself, since a +-- never-committed book is only visible to its locker). +INSERT INTO tc.books (id, collection_id, instance_id, name, created_by, locked_by, + locked_by_machine, locked_at) +VALUES ( + 'b0000000-0000-0000-0000-00000000d001'::uuid, + 'c0000000-0000-0000-0000-00000000d001'::uuid, + 'b0000000-0000-0000-0000-00000000d002'::uuid, + 'Display Name Test Book', + 'user-bob-dn', + 'user-bob-dn', + 'BobsMachine', + now() +); + +SELECT tests.set_jwt('user-bob-dn', 'bob-dn@example.com', true); + +SELECT is( + (SELECT b ->> 'locked_by_name' + FROM jsonb_array_elements( + tc.get_collection_state('c0000000-0000-0000-0000-00000000d001'::uuid) -> 'books' + ) b + WHERE b ->> 'name' = 'Display Name Test Book'), + 'Durable Bob', + '5d: get_collection_state book rows carry the durable name as locked_by_name' +); + +-- ...and history: get_changes event rows report the CURRENT durable name as +-- by_display_name, alongside the frozen at-event-time by_user_name ('JWT Bob'). +SELECT is( + (SELECT e ->> 'by_display_name' + FROM jsonb_array_elements( + tc.get_changes('c0000000-0000-0000-0000-00000000d001'::uuid, 0) -> 'events' + ) e + WHERE e ->> 'by_user_id' = 'user-bob-dn' + LIMIT 1), + 'Durable Bob', + '5e: get_changes event rows carry the current durable name as by_display_name' +); + +SELECT * FROM finish(); + +ROLLBACK;