-- 029: group files index — the Telegram-like group file contract -- (docs/occupymedia-storage.md §"Telegram-like group file contract" and -- §"Metadata index and hub-archive seam"). -- -- Metadata ONLY. chat_media_archive is the index over remote oCIS payloads: -- it NEVER holds file bodies, encrypted-file keys, WOPI tokens or presigned -- URLs. Verified byte size + SHA-256 only — the payload always lives behind -- oCIS WebDAV, streamed through the API with the hash re-verified. -- -- Attribution is by sender MXID (never a display name/alias) and origin is -- one of the four SharedFileOrigin kinds (packages/core/src/shared-files.ts). -- event_id is NULL for direct uploads/exports until a room announcement -- supplies one, so replay identity uniqueness is PARTIAL on event_id IS NOT -- NULL: "unique (workspace_id, room_id, event_id) for attachment events". -- -- Retention is kept SEPARATE from link expiry: mode is 'keep' (phase-one -- default, no automated purge) or 'until' (requires retention_until), with an -- orthogonal legal_hold flag that blocks deletion. A share expiring never -- deletes or hides a file. -- -- group_file_shares persists link IDs + sha256(token) ONLY: the plaintext -- token is returned once at creation and can never be recovered from the DB. -- No presigned object URLs are ever stored. public links require an explicit -- expiry (access='public' ⇒ expires_at is bounded below); the claim path uses -- the narrow SECURITY DEFINER lookup (017 invite-link pattern) because the -- claimer has no tenant context and the table is FORCE RLS. -- -- archive_sync_state / archive_sync_outbox reserve the hub-archive seam -- (HubArchivePort): the sync bot (not implemented in this slice) commits the -- library reference and its outbox entry BEFORE advancing sync_cursor. -- -- RLS / NULLIF / reject_workspace_change / sp_app grants follow the -- 001/008/021/026 conventions: no app.workspace_id => NULL => no rows; FORCE -- binds sp_app too. `origin`/`mime_class`/`retention_mode`/`legal_hold` CHECKs are coarse -- DB backstops — the authoritative typed validation is app-side -- (apps/api/src/group-files/group-files.validation.ts). BEGIN; -- --------------------------------------------------------------------------- -- chat_media_archive — the metadata index -- --------------------------------------------------------------------------- CREATE TABLE chat_media_archive ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE ON UPDATE RESTRICT, -- Matrix room ID when the file arrived through a room; NULL for direct -- uploads/exports that have not been announced into one yet. Tenant-pinned. room_id text CHECK (btrim(room_id) = '' OR room_id ~ '^!'), -- NULL for direct uploads/pad exports until a room announcement supplies one. event_id text CHECK (event_id IS NULL OR btrim(event_id) <> ''), origin text NOT NULL CHECK (origin IN ('matrix','bridge','upload','pad-export')), -- Sender attribution: MXID only, never a display name (mxc/handle spoofing). sender_mxid text NOT NULL CHECK (sender_mxid ~ '^@[^@\s]+:[^@\s]+$'), -- Redacted/superseded Matrix event context for the hub-archive seam; never -- file bodies or encrypted-file keys (jsonb_typeof backstop only). matrix_event_json jsonb CHECK (matrix_event_json IS NULL OR jsonb_typeof(matrix_event_json) = 'object'), original_name text NOT NULL CHECK (btrim(original_name) <> '' AND char_length(original_name) <= 1024), -- Normalized safe display filename (app-side sanitizeName). safe_name text NOT NULL CHECK (btrim(safe_name) <> '' AND char_length(safe_name) <= 255), mime text NOT NULL CHECK (mime <> ''), -- Coarse class derived from mime (image/video/audio/document/archive/other). mime_class text NOT NULL CHECK (mime_class IN ('image','video','audio','document','archive','other')), byte_size bigint NOT NULL CHECK (byte_size >= 0), -- SHA-256 of verified payload bytes, lowercase hex. Never an S3 ETag. sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), -- Stable oCIS library resource identity (payload stays remote). ocis_resource_id text NOT NULL CHECK (btrim(ocis_resource_id) <> ''), ocis_resource_version text, created_at timestamptz NOT NULL, indexed_at timestamptz NOT NULL DEFAULT now(), available boolean NOT NULL DEFAULT true, -- FileRetention (packages/core): mode is keep|until; legalHold is a FLAG -- alongside the mode, not a third mode. retention_mode text NOT NULL DEFAULT 'keep' CHECK (retention_mode IN ('keep','until')), legal_hold boolean NOT NULL DEFAULT false, retention_until timestamptz, CONSTRAINT chat_media_archive_ws_group FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT, -- 'until' retention requires a deadline; 'keep' must not carry one. -- legalHold is orthogonal: it blocks deletion regardless of mode. CONSTRAINT chat_media_archive_retention_shape CHECK ( (retention_mode = 'until' AND retention_until IS NOT NULL) OR (retention_mode = 'keep' AND retention_until IS NULL) ) ); -- Tenant-pinned reference target for group_file_shares (015 style): -- the composite UNIQUE lets child FKs pin (id, workspace_id) so a share can -- never point across tenants even if ids collided. CREATE UNIQUE INDEX chat_media_archive_id_ws_key ON chat_media_archive (id, workspace_id); CREATE TRIGGER trg_chat_media_archive_ws_immutable BEFORE UPDATE ON chat_media_archive FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE chat_media_archive ENABLE ROW LEVEL SECURITY; ALTER TABLE chat_media_archive FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON chat_media_archive USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON chat_media_archive TO sp_app; -- Replay identity for ATTACHMENT events (029 note: uploads have NULL event_id, -- so the unique constraint must be partial). AttachmentArchiveKey's -- collision-free replay rule, enforced durably. CREATE UNIQUE INDEX chat_media_archive_replay_uq ON chat_media_archive (workspace_id, room_id, event_id) WHERE event_id IS NOT NULL; -- Cursor pagination index: (createdAt,id) keyset scoped to workspace/group. -- Spec: "indexes on (workspace_id, group_id, created_at desc, id desc)". CREATE INDEX chat_media_archive_ws_group_time_idx ON chat_media_archive (workspace_id, group_id, created_at DESC, id DESC); -- Sender/MIME-class/checksum facets (spec: "indexes on ... sender, MIME class, -- name and checksum"). MXID is indexed for per-sender listing/attribution. CREATE INDEX chat_media_archive_sender_idx ON chat_media_archive (workspace_id, sender_mxid, created_at DESC); CREATE INDEX chat_media_archive_mime_class_idx ON chat_media_archive (mime_class); CREATE INDEX chat_media_archive_sha256_idx ON chat_media_archive (sha256); CREATE INDEX chat_media_archive_name_idx ON chat_media_archive (safe_name text_pattern_ops); -- --------------------------------------------------------------------------- -- group_file_shares — link IDs + sha256(token), never presigned URLs -- --------------------------------------------------------------------------- CREATE TABLE group_file_shares ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, item_id uuid NOT NULL, -- 'members' (default library link, ACL-checked) or 'public' (requires an -- explicit grant + bounded expiry; revoked on removal per the contract). access text NOT NULL DEFAULT 'members' CHECK (access IN ('members','public')), -- sha256(plaintext token). The plaintext is returned ONCE at creation; the -- DB can never re-derive it, so a leaked DB dump mints no downloads. token_hash text NOT NULL UNIQUE CHECK (token_hash ~ '^[0-9a-f]{64}$'), created_by uuid REFERENCES users(id) ON DELETE SET NULL, expires_at timestamptz NOT NULL, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT group_file_shares_ws_item FOREIGN KEY (item_id, workspace_id) REFERENCES chat_media_archive(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE UNIQUE INDEX group_file_shares_ws_item_key ON group_file_shares (item_id, workspace_id); CREATE TRIGGER trg_group_file_shares_ws_immutable BEFORE UPDATE ON group_file_shares FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE group_file_shares ENABLE ROW LEVEL SECURITY; ALTER TABLE group_file_shares FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON group_file_shares USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON group_file_shares TO sp_app; -- --------------------------------------------------------------------------- -- share_for_public_claim — narrow SECURITY DEFINER lookup (017 pattern) -- --------------------------------------------------------------------------- -- Public downloads arrive with NO tenant context (the claimer holds only the -- token) and group_file_shares is FORCE RLS, so an ordinary SELECT sees zero -- rows. Exactly like invite_link_for_claim (017), this exposes ONE row keyed -- by the secret — workspace/file discovery only. It returns the row only when -- it is live: not revoked and not expired (the caller still rechecks the item -- row's availability/retention inside the tenant context it enters next). CREATE FUNCTION share_for_public_claim(p_token_hash text) RETURNS TABLE(id uuid, workspace_id uuid, item_id uuid, access text, expires_at timestamptz, revoked_at timestamptz) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT s.id, s.workspace_id, s.item_id, s.access, s.expires_at, s.revoked_at FROM group_file_shares s WHERE s.token_hash = p_token_hash AND s.revoked_at IS NULL AND s.expires_at > now() $$; REVOKE ALL ON FUNCTION share_for_public_claim(text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION share_for_public_claim(text) TO sp_app; -- --------------------------------------------------------------------------- -- archive_sync_state — per-room sync cursor (hub-archive seam) -- --------------------------------------------------------------------------- CREATE TABLE archive_sync_state ( room_id text PRIMARY KEY CHECK (btrim(room_id) <> ''), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, -- Last committed Matrix sync token; the bot never advances past a failed -- event (durable retry lives in archive_sync_outbox). sync_cursor text, updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TRIGGER trg_archive_sync_state_ws_immutable BEFORE UPDATE ON archive_sync_state FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE archive_sync_state ENABLE ROW LEVEL SECURITY; ALTER TABLE archive_sync_state FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON archive_sync_state USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON archive_sync_state TO sp_app; -- --------------------------------------------------------------------------- -- archive_sync_outbox — durable reply/retry records before cursor advance -- --------------------------------------------------------------------------- CREATE TABLE archive_sync_outbox ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, room_id text NOT NULL CHECK (btrim(room_id) <> ''), event_id text CHECK (event_id IS NULL OR btrim(event_id) <> ''), -- Idempotent claim key: attachmentArchiveKey(workspace,room,event) shape. claim_key text NOT NULL CHECK (btrim(claim_key) <> ''), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','done','failed')), attempts int NOT NULL DEFAULT 0 CHECK (attempts >= 0), last_error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -- DB-level idempotency for the durable claim: one outbox row per -- attachmentArchiveKey(workspace,room,event). The bot inserts with a -- NOT EXISTS guard + bare ON CONFLICT DO NOTHING; this index makes the -- race window impossible rather than unlikely. CREATE UNIQUE INDEX archive_sync_outbox_claim_key_uq ON archive_sync_outbox (claim_key); CREATE TRIGGER trg_archive_sync_outbox_ws_immutable BEFORE UPDATE ON archive_sync_outbox FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE archive_sync_outbox ENABLE ROW LEVEL SECURITY; ALTER TABLE archive_sync_outbox FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON archive_sync_outbox USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON archive_sync_outbox TO sp_app; -- Worker scan: pending-first, oldest-first. CREATE INDEX archive_sync_outbox_status_idx ON archive_sync_outbox (status, created_at); -- Replay/idempotency lookup by claim key. CREATE INDEX archive_sync_outbox_claim_idx ON archive_sync_outbox (claim_key); COMMIT;