Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
212
apps/api/migrations/001_tenants.sql
Normal file
212
apps/api/migrations/001_tenants.sql
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
-- 001: tenant schema, RLS, audit chain, lifecycle triggers
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE workspaces (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
slug text NOT NULL UNIQUE,
|
||||
authz_epoch bigint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
external_id text NOT NULL UNIQUE,
|
||||
email text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE memberships (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
role text NOT NULL,
|
||||
permission text NOT NULL,
|
||||
PRIMARY KEY (role, permission)
|
||||
);
|
||||
|
||||
CREATE TABLE connections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
channel_id text NOT NULL,
|
||||
fields jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE credentials (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
connection_id uuid REFERENCES connections(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL,
|
||||
ciphertext bytea NOT NULL,
|
||||
key_version int NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
rotated_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
actor_user_id uuid,
|
||||
actor_role text,
|
||||
action text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
target_id text NOT NULL,
|
||||
diff jsonb,
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
seq bigint GENERATED ALWAYS AS IDENTITY,
|
||||
prev_hash text,
|
||||
hash text NOT NULL
|
||||
);
|
||||
CREATE INDEX audit_log_ws_chain ON audit_log (workspace_id, seq DESC) WHERE workspace_id IS NOT NULL;
|
||||
CREATE INDEX audit_log_system_chain ON audit_log (seq DESC) WHERE workspace_id IS NULL;
|
||||
|
||||
-- Tenancy guard for SECURITY DEFINER functions (audit finding #3):
|
||||
-- every cross-tenant function takes an explicit workspace parameter and
|
||||
-- asserts it against the session's tenant context.
|
||||
CREATE FUNCTION fn_assert_tenant(p_workspace_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
DECLARE v_ctx text;
|
||||
BEGIN
|
||||
v_ctx := current_setting('app.workspace_id', true);
|
||||
IF v_ctx IS NULL OR v_ctx = '' OR v_ctx::uuid IS DISTINCT FROM p_workspace_id THEN
|
||||
RAISE EXCEPTION 'tenant context mismatch' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM workspaces w WHERE w.id = p_workspace_id) THEN
|
||||
RAISE EXCEPTION 'unknown workspace' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Hash chain: tenant rows chain per workspace; system rows chain on the
|
||||
-- workspace_id IS NULL branch. Branch selection is by TENANT CONTEXT
|
||||
-- PRESENCE, never by role (audit findings #1/#6: sp_scheduler/vault callers
|
||||
-- with an active tenant context must append to that tenant's chain).
|
||||
CREATE FUNCTION audit_hash(p_prev text, p_actor uuid, p_action text, p_target_id text, p_at timestamptz)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT encode(digest(
|
||||
coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' ||
|
||||
p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text,
|
||||
'sha256'), 'hex')
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION audit_append(
|
||||
p_workspace_id uuid,
|
||||
p_actor_user_id uuid,
|
||||
p_actor_role text,
|
||||
p_action text,
|
||||
p_target_type text,
|
||||
p_target_id text,
|
||||
p_diff jsonb DEFAULT NULL
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
|
||||
BEGIN
|
||||
PERFORM fn_assert_tenant(p_workspace_id);
|
||||
SELECT hash INTO v_prev FROM audit_log
|
||||
WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1;
|
||||
v_hash := audit_hash(v_prev, p_actor_user_id, p_action, p_target_id, v_now);
|
||||
INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id, diff, at, prev_hash, hash)
|
||||
VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION audit_append_system(
|
||||
p_actor_user_id uuid,
|
||||
p_action text,
|
||||
p_target_type text,
|
||||
p_target_id text,
|
||||
p_diff jsonb DEFAULT NULL
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
|
||||
BEGIN
|
||||
IF coalesce(current_setting('app.workspace_id', true), '') <> '' THEN
|
||||
RAISE EXCEPTION 'system append must not run inside a tenant context (use audit_append)';
|
||||
END IF;
|
||||
SELECT hash INTO v_prev FROM audit_log
|
||||
WHERE workspace_id IS NULL ORDER BY seq DESC LIMIT 1;
|
||||
v_hash := audit_hash(v_prev, p_actor_user_id, p_action, p_target_id, v_now);
|
||||
INSERT INTO audit_log (workspace_id, actor_user_id, action, target_type, target_id, diff, at, prev_hash, hash)
|
||||
VALUES (NULL, p_actor_user_id, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END $$;
|
||||
|
||||
-- Tamper detection: recompute the chain.
|
||||
CREATE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE r record; v_prev text := NULL;
|
||||
BEGIN
|
||||
FOR r IN SELECT * FROM audit_log
|
||||
WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id
|
||||
ORDER BY seq ASC LOOP
|
||||
IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF;
|
||||
IF audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at) <> r.hash THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
v_prev := r.hash;
|
||||
END LOOP;
|
||||
RETURN true;
|
||||
END $$;
|
||||
|
||||
-- Lifecycle immutability (audit finding #8): tenant rows never change tenant.
|
||||
CREATE FUNCTION reject_workspace_change() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.workspace_id IS DISTINCT FROM OLD.workspace_id THEN
|
||||
RAISE EXCEPTION 'workspace_id is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_conn_ws_immutable BEFORE UPDATE ON connections
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_cred_ws_immutable BEFORE UPDATE ON credentials
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_mem_ws_immutable BEFORE UPDATE ON memberships
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
-- Continuous authz (audit finding #2): membership/role changes bump the
|
||||
-- workspace authz epoch so live sessions fail closed on next check.
|
||||
-- COALESCE matters: OLD is NULL-valued on INSERT, NEW on DELETE.
|
||||
CREATE FUNCTION bump_authz_epoch() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
UPDATE workspaces SET authz_epoch = authz_epoch + 1
|
||||
WHERE id = COALESCE(NEW.workspace_id, OLD.workspace_id);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_mem_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON memberships
|
||||
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
|
||||
|
||||
-- RLS: fail closed. Missing app.workspace_id => NULL => no tenant rows visible.
|
||||
-- FORCE: applies even to the table owner, so the single-role dev pool cannot
|
||||
-- silently bypass tenancy; tenant writes must go through withTenant().
|
||||
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE connections ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credentials ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE memberships FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE connections FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE credentials FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON memberships
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON connections
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON credentials
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_audit ON audit_log
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
90
apps/api/migrations/002_rbac_seed.sql
Normal file
90
apps/api/migrations/002_rbac_seed.sql
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
-- 002: RBAC seed — 37 permissions across 10 roles
|
||||
-- publication.delete is a legacy seed (including Publisher). Deprecated for moderation;
|
||||
-- future hide/delete routes must use dedicated moderation permissions from 011.
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Owner', p FROM unnest(ARRAY[
|
||||
'workspace.view','workspace.settings','workspace.members.manage',
|
||||
'connection.view','connection.manage','credential.manage',
|
||||
'draft.view','draft.create','draft.edit','approval.request','approval.grant',
|
||||
'publication.publish','publication.schedule','publication.delete',
|
||||
'media.view','media.manage','inbox.view','inbox.reply','stream.view',
|
||||
'analytics.view','audit.view',
|
||||
'virtualchannel.view','virtualchannel.create','virtualchannel.edit',
|
||||
'director.view','director.take','director.override_auto','director.change_layout','director.lock_program',
|
||||
'streamsource.manage','streamoutput.manage',
|
||||
'livestream.view','livestream.manage',
|
||||
'livechat.view','livechat.send','livechat.moderate'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Admin', p FROM unnest(ARRAY[
|
||||
'workspace.view','workspace.settings','workspace.members.manage',
|
||||
'connection.view','connection.manage','credential.manage',
|
||||
'draft.view','draft.create','draft.edit','approval.request','approval.grant',
|
||||
'publication.publish','publication.schedule','publication.delete',
|
||||
'media.view','media.manage','inbox.view','inbox.reply','stream.view',
|
||||
'analytics.view','audit.view',
|
||||
'virtualchannel.view','director.view','livestream.view','livechat.view'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Publisher', p FROM unnest(ARRAY[
|
||||
'workspace.view','connection.view','draft.view','draft.create','draft.edit',
|
||||
'approval.request','publication.publish','publication.schedule','publication.delete',
|
||||
'media.view','media.manage','inbox.view','inbox.reply','stream.view','analytics.view'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Editor', p FROM unnest(ARRAY[
|
||||
'workspace.view','connection.view','draft.view','draft.create','draft.edit',
|
||||
'media.view','media.manage','inbox.view','inbox.reply','stream.view'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Approver', p FROM unnest(ARRAY[
|
||||
'workspace.view','draft.view','approval.grant','analytics.view','audit.view'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Analyst', p FROM unnest(ARRAY[
|
||||
'workspace.view','analytics.view','stream.view','audit.view'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Moderator', p FROM unnest(ARRAY[
|
||||
'workspace.view','inbox.view','inbox.reply',
|
||||
'livechat.view','livechat.send','livechat.moderate'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'VideoPublisher', p FROM unnest(ARRAY[
|
||||
'workspace.view','draft.view','draft.create','draft.edit',
|
||||
'publication.publish','publication.schedule','media.view','media.manage'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'StreamOperator', p FROM unnest(ARRAY[
|
||||
'workspace.view','livestream.view','livestream.manage','stream.view'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'BroadcastOperator', p FROM unnest(ARRAY[
|
||||
'workspace.view','virtualchannel.view',
|
||||
'director.view','director.take','director.override_auto','director.change_layout','director.lock_program',
|
||||
'streamsource.manage','livestream.view',
|
||||
'livechat.view','livechat.send','livechat.moderate'
|
||||
]) AS p
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
19
apps/api/migrations/003_app_role.sql
Normal file
19
apps/api/migrations/003_app_role.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- 003: dedicated non-superuser app role for the runtime pool.
|
||||
-- Migrations run as the table owner (superuser in dev); the application and
|
||||
-- tests connect as sp_app — neither superuser nor owner — so FORCE ROW LEVEL
|
||||
-- SECURITY binds for real.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sp_app') THEN
|
||||
CREATE ROLE sp_app LOGIN PASSWORD 'sp_app_dev' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO sp_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sp_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sp_app;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO sp_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO sp_app;
|
||||
16
apps/api/migrations/004_policy_nullif.sql
Normal file
16
apps/api/migrations/004_policy_nullif.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 004: pooled sessions can carry an empty-string GUC placeholder after
|
||||
-- SET LOCAL commits; NULLIF makes the RLS expressions fail closed cleanly
|
||||
-- instead of raising a cast error.
|
||||
DROP POLICY IF EXISTS tenant_isolation ON memberships;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON connections;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON credentials;
|
||||
DROP POLICY IF EXISTS tenant_audit ON audit_log;
|
||||
|
||||
CREATE POLICY tenant_isolation ON memberships
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON connections
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON credentials
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_audit ON audit_log
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
10
apps/api/migrations/005_epoch_fix.sql
Normal file
10
apps/api/migrations/005_epoch_fix.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- 005: fix epoch bump for INSERT (OLD is NULL on INSERT; the original
|
||||
-- UPDATE matched zero rows silently). CREATE OR REPLACE also strips any
|
||||
-- debug variant left in dev databases.
|
||||
CREATE OR REPLACE FUNCTION bump_authz_epoch() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
UPDATE workspaces SET authz_epoch = authz_epoch + 1
|
||||
WHERE id = COALESCE(NEW.workspace_id, OLD.workspace_id);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END $$;
|
||||
89
apps/api/migrations/006_publishing.sql
Normal file
89
apps/api/migrations/006_publishing.sql
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
-- 006: publishing pipeline (drafts -> approval -> scheduling -> publication)
|
||||
CREATE TABLE drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
author_user_id uuid NOT NULL REFERENCES users(id),
|
||||
post jsonb NOT NULL,
|
||||
state text NOT NULL DEFAULT 'editing'
|
||||
CHECK (state IN ('editing', 'in_review', 'approved', 'rejected', 'scheduled', 'published')),
|
||||
reviewer_user_id uuid REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE scheduled_publications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
draft_id uuid NOT NULL REFERENCES drafts(id) ON DELETE CASCADE,
|
||||
connection_id uuid NOT NULL REFERENCES connections(id),
|
||||
publish_at timestamptz NOT NULL,
|
||||
idempotency_key text NOT NULL UNIQUE,
|
||||
state text NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending', 'in_flight', 'done', 'failed')),
|
||||
error text,
|
||||
UNIQUE (draft_id)
|
||||
);
|
||||
|
||||
CREATE TABLE publications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
draft_id uuid NOT NULL REFERENCES drafts(id) ON DELETE CASCADE,
|
||||
connection_id uuid NOT NULL REFERENCES connections(id),
|
||||
idempotency_key text NOT NULL UNIQUE,
|
||||
remote_id text NOT NULL,
|
||||
remote_url text,
|
||||
published_at timestamptz NOT NULL,
|
||||
provider_metadata jsonb NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX drafts_workspace_state ON drafts (workspace_id, state);
|
||||
CREATE INDEX scheduled_publications_due ON scheduled_publications (publish_at)
|
||||
WHERE state = 'pending';
|
||||
CREATE INDEX publications_draft ON publications (draft_id);
|
||||
|
||||
CREATE TRIGGER trg_draft_ws_immutable BEFORE UPDATE ON drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_scheduled_pub_ws_immutable BEFORE UPDATE ON scheduled_publications
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_publication_ws_immutable BEFORE UPDATE ON publications
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE drafts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scheduled_publications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publications ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE drafts FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scheduled_publications FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publications FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON drafts
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON scheduled_publications
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON publications
|
||||
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 drafts, scheduled_publications, publications TO sp_app;
|
||||
|
||||
-- Narrow SECURITY DEFINER lookups let the scheduler discover tenant context
|
||||
-- without weakening FORCE RLS for ordinary table access.
|
||||
CREATE FUNCTION publishing_schedule_workspace(p_idempotency_key text) RETURNS uuid
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT workspace_id
|
||||
FROM scheduled_publications
|
||||
WHERE idempotency_key = p_idempotency_key
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION publishing_due_schedules() RETURNS TABLE(idempotency_key text, publish_at timestamptz)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT sp.idempotency_key, sp.publish_at
|
||||
FROM scheduled_publications sp
|
||||
WHERE sp.state = 'pending' AND sp.publish_at <= now()
|
||||
ORDER BY sp.publish_at
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION publishing_schedule_workspace(text) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION publishing_due_schedules() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION publishing_schedule_workspace(text), publishing_due_schedules() TO sp_app;
|
||||
30
apps/api/migrations/007_auth.sql
Normal file
30
apps/api/migrations/007_auth.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- 007: first-party auth (email + password) and membership invitations.
|
||||
-- Passwords live in their own table (never in users, never in audit diffs);
|
||||
-- hashes are scrypt with a per-user salt, produced by apps/api/src/auth/password.ts.
|
||||
CREATE TABLE user_passwords (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
password_hash text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Invited members exist as memberships before they first sign in.
|
||||
ALTER TABLE memberships
|
||||
ADD COLUMN status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'invited'));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON user_passwords TO sp_app;
|
||||
|
||||
-- Login happens before any tenant context exists, and memberships is FORCE
|
||||
-- RLS. This narrow SECURITY DEFINER lookup returns only the caller-supplied
|
||||
-- user's own memberships so login can pick a workspace without weakening RLS.
|
||||
CREATE FUNCTION auth_user_memberships(p_user_id uuid)
|
||||
RETURNS TABLE(workspace_id uuid, role text, status text, created_at timestamptz)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT m.workspace_id, m.role, m.status, m.created_at
|
||||
FROM memberships m
|
||||
WHERE m.user_id = p_user_id
|
||||
ORDER BY m.created_at ASC
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION auth_user_memberships(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION auth_user_memberships(uuid) TO sp_app;
|
||||
104
apps/api/migrations/008_groups.sql
Normal file
104
apps/api/migrations/008_groups.sql
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
-- 008: groups — sub-workspace sharing scopes for connections and assets.
|
||||
-- Group membership gates which connections a non-Owner/Admin can publish through.
|
||||
--
|
||||
-- Child tables carry a denormalised workspace_id so that:
|
||||
-- * RLS is the same direct column predicate as every other tenant table
|
||||
-- (no subquery policies);
|
||||
-- * composite FKs pin every row to ONE workspace — a group cannot hold a
|
||||
-- connection or a member from another tenant, even via the owner pool;
|
||||
-- * the epoch bump can read workspace_id from the row itself. Looking it up
|
||||
-- in groups would miss cascaded deletes (DELETE FROM groups -> its
|
||||
-- group_members rows), because the parent row is already gone when the
|
||||
-- child's AFTER trigger runs — deleting a group would silently skip the bump.
|
||||
|
||||
-- Composite FK targets.
|
||||
ALTER TABLE connections ADD CONSTRAINT connections_id_workspace_key UNIQUE (id, workspace_id);
|
||||
|
||||
CREATE TABLE groups (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name text NOT NULL CHECK (btrim(name) <> ''),
|
||||
description text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, name),
|
||||
UNIQUE (id, workspace_id)
|
||||
);
|
||||
|
||||
CREATE TABLE group_members (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
group_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, user_id),
|
||||
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE,
|
||||
-- Only workspace members can join a group; leaving the workspace leaves its groups.
|
||||
FOREIGN KEY (workspace_id, user_id) REFERENCES memberships(workspace_id, user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE group_connections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
group_id uuid NOT NULL,
|
||||
connection_id uuid NOT NULL,
|
||||
assigned_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, connection_id),
|
||||
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections(id, workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE group_assets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
group_id uuid NOT NULL,
|
||||
asset_id text NOT NULL CHECK (btrim(asset_id) <> ''), -- media asset storage key or id
|
||||
assigned_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, asset_id),
|
||||
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX group_members_user ON group_members (workspace_id, user_id);
|
||||
CREATE INDEX group_connections_connection ON group_connections (connection_id);
|
||||
|
||||
CREATE TRIGGER trg_group_ws_immutable BEFORE UPDATE ON groups
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_group_member_ws_immutable BEFORE UPDATE ON group_members
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_group_connection_ws_immutable BEFORE UPDATE ON group_connections
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_group_asset_ws_immutable BEFORE UPDATE ON group_assets
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
-- Continuous authz: group membership is an authorization input (it gates
|
||||
-- publishing), so changes bump the workspace epoch exactly like memberships.
|
||||
-- Reuses bump_authz_epoch() (001/005): it reads the row's own workspace_id,
|
||||
-- which also covers cascades from DELETE FROM groups / memberships.
|
||||
CREATE TRIGGER trg_group_member_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON group_members
|
||||
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
|
||||
|
||||
ALTER TABLE groups ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE group_members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE group_connections ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE group_assets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE groups FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE group_members FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE group_connections FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE group_assets FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON groups
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON group_members
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON group_connections
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON group_assets
|
||||
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 groups, group_members, group_connections, group_assets TO sp_app;
|
||||
52
apps/api/migrations/009_video.sql
Normal file
52
apps/api/migrations/009_video.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- 009: first-class video assets and their processing lifecycle
|
||||
CREATE TABLE video_assets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
storage_key text NOT NULL,
|
||||
mime_type text NOT NULL,
|
||||
size_bytes bigint NOT NULL,
|
||||
width int,
|
||||
height int,
|
||||
duration_ms int,
|
||||
frame_rate numeric,
|
||||
codec text,
|
||||
audio_codec text,
|
||||
thumbnail_key text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE video_processing_jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
video_asset_id uuid NOT NULL REFERENCES video_assets(id) ON DELETE CASCADE,
|
||||
state text NOT NULL CHECK (state IN ('queued', 'probing', 'transcoding', 'complete', 'failed')),
|
||||
progress_percent int NOT NULL DEFAULT 0,
|
||||
output_key text,
|
||||
error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX video_assets_workspace_created
|
||||
ON video_assets (workspace_id, created_at DESC, id DESC);
|
||||
CREATE INDEX video_processing_jobs_asset_created
|
||||
ON video_processing_jobs (video_asset_id, created_at DESC);
|
||||
|
||||
CREATE TRIGGER trg_video_asset_ws_immutable BEFORE UPDATE ON video_assets
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_video_job_ws_immutable BEFORE UPDATE ON video_processing_jobs
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE video_assets ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE video_processing_jobs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE video_assets FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE video_processing_jobs FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON video_assets
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON video_processing_jobs
|
||||
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 video_assets, video_processing_jobs TO sp_app;
|
||||
80
apps/api/migrations/010_audit_hash_v2.sql
Normal file
80
apps/api/migrations/010_audit_hash_v2.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- 010: hash the audit diff and serialize appends to each chain.
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE audit_log ADD COLUMN hash_version smallint NOT NULL DEFAULT 1
|
||||
CHECK (hash_version IN (1, 2));
|
||||
|
||||
CREATE FUNCTION audit_hash_v2(p_prev text, p_actor uuid, p_action text, p_target_id text,
|
||||
p_at timestamptz, p_diff jsonb)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT encode(digest(
|
||||
coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' ||
|
||||
p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text || '|' ||
|
||||
coalesce(p_diff::text, ''),
|
||||
'sha256'), 'hex')
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit_append(
|
||||
p_workspace_id uuid, p_actor_user_id uuid, p_actor_role text, p_action text,
|
||||
p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
|
||||
BEGIN
|
||||
PERFORM fn_assert_tenant(p_workspace_id);
|
||||
PERFORM pg_advisory_xact_lock(hashtext('audit:' || p_workspace_id::text));
|
||||
SELECT hash INTO v_prev FROM audit_log
|
||||
WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1;
|
||||
v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff);
|
||||
INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id,
|
||||
diff, at, prev_hash, hash, hash_version)
|
||||
VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id,
|
||||
p_diff, v_now, v_prev, v_hash, 2)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit_append_system(
|
||||
p_actor_user_id uuid, p_action text, p_target_type text, p_target_id text,
|
||||
p_diff jsonb DEFAULT NULL
|
||||
) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
|
||||
BEGIN
|
||||
IF coalesce(current_setting('app.workspace_id', true), '') <> '' THEN
|
||||
RAISE EXCEPTION 'system append must not run inside a tenant context (use audit_append)';
|
||||
END IF;
|
||||
PERFORM pg_advisory_xact_lock(hashtext('audit:system'));
|
||||
SELECT hash INTO v_prev FROM audit_log
|
||||
WHERE workspace_id IS NULL ORDER BY seq DESC LIMIT 1;
|
||||
v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff);
|
||||
INSERT INTO audit_log (workspace_id, actor_user_id, action, target_type, target_id,
|
||||
diff, at, prev_hash, hash, hash_version)
|
||||
VALUES (NULL, p_actor_user_id, p_action, p_target_type, p_target_id,
|
||||
p_diff, v_now, v_prev, v_hash, 2)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE r record; v_prev text := NULL; v_expected text;
|
||||
BEGIN
|
||||
FOR r IN SELECT * FROM audit_log
|
||||
WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id
|
||||
ORDER BY seq ASC LOOP
|
||||
IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF;
|
||||
IF r.hash_version = 1 THEN
|
||||
v_expected := audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at);
|
||||
ELSE
|
||||
v_expected := audit_hash_v2(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at, r.diff);
|
||||
END IF;
|
||||
IF v_expected IS DISTINCT FROM r.hash THEN RETURN false; END IF;
|
||||
v_prev := r.hash;
|
||||
END LOOP;
|
||||
RETURN true;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
114
apps/api/migrations/012_live_sources.sql
Normal file
114
apps/api/migrations/012_live_sources.sql
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
-- 012: WP8 Live Source Monitor — per-source reducer state + observation dedupe.
|
||||
-- (010/011 are reserved for the WP7 egress package; the monitor consumes those
|
||||
-- budgets later and deliberately carries no rate machinery of its own.)
|
||||
--
|
||||
-- One row per monitored source identity (connection_id, remote_ref): the keyed
|
||||
-- partition the MonitorObservation reducer folds into (proposal §4.4). Each
|
||||
-- child row carries a denormalised workspace_id so RLS stays a direct column
|
||||
-- predicate and composite FKs pin the row to ONE workspace's connection.
|
||||
--
|
||||
-- `version` is the per-source compare-and-swap token: every applied mutation
|
||||
-- bumps it, updates are guarded by `WHERE ... AND version = $n`, conflicting
|
||||
-- writers reload and re-evaluate, and downstream consumers (WS fan-out,
|
||||
-- outbox) drop non-increasing versions.
|
||||
|
||||
CREATE TABLE source_monitor_states (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
connection_id uuid NOT NULL,
|
||||
remote_ref text NOT NULL CHECK (btrim(remote_ref) <> ''),
|
||||
state text NOT NULL DEFAULT 'OFFLINE'
|
||||
CHECK (state IN ('OFFLINE', 'PROBING', 'LIVE', 'UNSTABLE', 'ERROR')),
|
||||
version bigint NOT NULL DEFAULT 0 CHECK (version >= 0),
|
||||
-- Highest *available* signal tier; NULL until the first fresh signal lands.
|
||||
authoritative_tier text
|
||||
CHECK (authoritative_tier IN ('media-health', 'hls-probe', 'webhook', 'api-poll')),
|
||||
-- Last applied observedAt per signal tier. Staleness is judged WITHIN one
|
||||
-- tier only (provider clocks are not comparable across signal sources).
|
||||
last_media_health_at timestamptz,
|
||||
last_hls_probe_at timestamptz,
|
||||
last_webhook_at timestamptz,
|
||||
last_api_poll_at timestamptz,
|
||||
-- Remaining per-tier reducer bookkeeping: lastOutcome, lastProviderEventId,
|
||||
-- deniedUntil, failStreak. jsonb keeps the reducer state shape in one place.
|
||||
tier_status jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ok_streak int NOT NULL DEFAULT 0 CHECK (ok_streak >= 0),
|
||||
fail_streak int NOT NULL DEFAULT 0 CHECK (fail_streak >= 0),
|
||||
state_entered_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Hysteresis: min-hold keeps a freshly entered state from being cut down,
|
||||
-- cooldown blocks the reverse transition (no rapid A -> B -> A).
|
||||
hold_until timestamptz,
|
||||
cooldown_until timestamptz,
|
||||
previous_state text CHECK (previous_state IN ('OFFLINE', 'PROBING', 'LIVE', 'UNSTABLE', 'ERROR')),
|
||||
last_transition_at timestamptz,
|
||||
last_transition_reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, connection_id, remote_ref),
|
||||
UNIQUE (id, workspace_id),
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX source_monitor_states_ws_created
|
||||
ON source_monitor_states (workspace_id, created_at DESC, id DESC);
|
||||
CREATE INDEX source_monitor_states_ws_state
|
||||
ON source_monitor_states (workspace_id, state);
|
||||
|
||||
-- Observation dedupe is enforced by the DB, not the app (proposal §4.4): a
|
||||
-- replayed providerEventId can never apply twice, regardless of which process
|
||||
-- or replica delivers it. Rows are journal entries, never mutated.
|
||||
CREATE TABLE monitor_seen_events (
|
||||
workspace_id uuid NOT NULL,
|
||||
provider_event_id text NOT NULL CHECK (btrim(provider_event_id) <> ''),
|
||||
source_monitor_state_id uuid NOT NULL,
|
||||
seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, provider_event_id),
|
||||
FOREIGN KEY (source_monitor_state_id, workspace_id)
|
||||
REFERENCES source_monitor_states (id, workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX monitor_seen_events_seen_at ON monitor_seen_events (seen_at);
|
||||
|
||||
CREATE TRIGGER trg_monitor_state_ws_immutable BEFORE UPDATE ON source_monitor_states
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_monitor_seen_event_ws_immutable BEFORE UPDATE ON monitor_seen_events
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE source_monitor_states ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE monitor_seen_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE source_monitor_states FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE monitor_seen_events FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON source_monitor_states
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON monitor_seen_events
|
||||
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 source_monitor_states, monitor_seen_events TO sp_app;
|
||||
|
||||
-- Narrow SECURITY DEFINER lookup: the monitor/scheduler processes discover the
|
||||
-- tenant that owns a source without weakening FORCE RLS for ordinary access
|
||||
-- (same standing as publishing_schedule_workspace in 006).
|
||||
CREATE FUNCTION live_monitor_source_workspace(p_connection_id uuid, p_remote_ref text) RETURNS uuid
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT workspace_id
|
||||
FROM source_monitor_states
|
||||
WHERE connection_id = p_connection_id AND remote_ref = p_remote_ref
|
||||
LIMIT 1
|
||||
$$;
|
||||
|
||||
-- Retention for the dedupe journal. Runs in system context on purpose: pruned
|
||||
-- rows span tenants, and a tenant that stops recording must not leak journal
|
||||
-- rows forever.
|
||||
CREATE FUNCTION monitor_prune_seen_events(p_before timestamptz) RETURNS integer
|
||||
LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$
|
||||
WITH removed AS (
|
||||
DELETE FROM monitor_seen_events WHERE seen_at < p_before RETURNING 1
|
||||
)
|
||||
SELECT count(*) FROM removed
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION live_monitor_source_workspace(uuid, text) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION monitor_prune_seen_events(timestamptz) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION live_monitor_source_workspace(uuid, text), monitor_prune_seen_events(timestamptz) TO sp_app;
|
||||
327
apps/api/migrations/013_production.sql
Normal file
327
apps/api/migrations/013_production.sql
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
-- 013: Streaming slice S1 — production schema (docs/design/streaming-architecture.md
|
||||
-- §2, §4, §6 S1; proposal §4.4 same-channel invariant).
|
||||
--
|
||||
-- No virtual channel or channel-source table existed before this migration, so
|
||||
-- 013 creates the minimal `virtual_channels` / `virtual_channel_sources` the
|
||||
-- production tables hang off. `virtual_channel_sources.kind` is born with
|
||||
-- `playlist` alongside `hls` (§5.1: a playlist is an ordinary ingest kind).
|
||||
--
|
||||
-- Same-channel invariant (structural, not a handler check): every production
|
||||
-- row carries `virtual_channel_id`, and every reference to a scene, source or
|
||||
-- overlay goes through a composite FK that includes (workspace_id,
|
||||
-- virtual_channel_id). A slot can only reference a source of its own channel,
|
||||
-- a bus can only hold a scene of its own channel, an overlay revision can only
|
||||
-- belong to an overlay slot of its own channel — a cross-channel write fails
|
||||
-- at the constraint, whichever code path issued it.
|
||||
--
|
||||
-- Guard FKs (a reference that must block a direct delete but not a purge)
|
||||
-- are NO ACTION *and* DEFERRABLE INITIALLY DEFERRED. Plain NO ACTION is
|
||||
-- checked when each cascade step's RI trigger fires, so a workspace purge that
|
||||
-- reaches video_assets before it reaches playlist_items (deeper in the
|
||||
-- workspace -> channel -> source -> item chain) aborts mid-cascade. Deferred,
|
||||
-- the check runs at commit: a purge succeeds, a direct delete still fails.
|
||||
--
|
||||
-- Geometry is integer basis points of the output frame (0..10000), matching
|
||||
-- the desired-state v2 type layer (apps/api/src/media-control/production.ts).
|
||||
|
||||
-- Video assets get the composite key that playlist items reference, so a
|
||||
-- playlist can never name another tenant's asset by raw id.
|
||||
ALTER TABLE video_assets ADD CONSTRAINT video_assets_id_workspace_key UNIQUE (id, workspace_id);
|
||||
|
||||
-- Ownership columns (the row's parent aggregate) are immutable: moving a row
|
||||
-- to another parent is delete + create. A column listed with a leading '?' is
|
||||
-- an `ON DELETE SET NULL` reference: only its non-null -> NULL transition (the
|
||||
-- FK action itself) is permitted.
|
||||
CREATE FUNCTION reject_owner_change() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
col text;
|
||||
nullable boolean;
|
||||
old_value text;
|
||||
new_value text;
|
||||
BEGIN
|
||||
FOREACH col IN ARRAY TG_ARGV LOOP
|
||||
nullable := left(col, 1) = '?';
|
||||
IF nullable THEN col := substr(col, 2); END IF;
|
||||
old_value := to_jsonb(OLD) ->> col;
|
||||
new_value := to_jsonb(NEW) ->> col;
|
||||
IF new_value IS DISTINCT FROM old_value AND NOT (nullable AND new_value IS NULL) THEN
|
||||
RAISE EXCEPTION '%.% is immutable', TG_TABLE_NAME, col USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE virtual_channels (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||
name text NOT NULL CHECK (btrim(name) <> '' AND length(name) <= 120),
|
||||
desired_status text NOT NULL DEFAULT 'STOPPED' CHECK (desired_status IN ('STOPPED', 'RUNNING')),
|
||||
observed_status text NOT NULL DEFAULT 'STOPPED'
|
||||
CHECK (observed_status IN ('PENDING_CAPACITY', 'STOPPED', 'STARTING', 'RUNNING', 'DEGRADED', 'RESTARTING', 'FAILED', 'UNKNOWN')),
|
||||
-- The single per-channel desiredVersion fence: every desired-state tx
|
||||
-- (TAKE, scene edit, overlay swap, gain step) locks this row FOR UPDATE and
|
||||
-- bumps it once.
|
||||
desired_version bigint NOT NULL DEFAULT 0 CHECK (desired_version >= 0),
|
||||
observed_version bigint NOT NULL DEFAULT 0 CHECK (observed_version >= 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, slug),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE virtual_channel_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('hls', 'playlist')),
|
||||
label text NOT NULL CHECK (label ~ '^[[:alnum:]][[:alnum:] _.-]{0,63}$'),
|
||||
priority int NOT NULL DEFAULT 0 CHECK (priority >= 0),
|
||||
-- Connection the source was imported through (a candidate poller); NULL for
|
||||
-- playlists and hand-entered URLs.
|
||||
connection_id uuid,
|
||||
-- Remote identity (import dedupe); a playlist is local and has none.
|
||||
remote_ref text CHECK (remote_ref IS NULL OR btrim(remote_ref) <> ''),
|
||||
hls_url text CHECK (hls_url IS NULL OR (hls_url ~ '^https://' AND length(hls_url) <= 2048)),
|
||||
playlist_loop boolean,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (
|
||||
(kind = 'hls' AND hls_url IS NOT NULL AND playlist_loop IS NULL) OR
|
||||
(kind = 'playlist' AND hls_url IS NULL AND remote_ref IS NULL AND connection_id IS NULL AND playlist_loop IS NOT NULL)
|
||||
),
|
||||
UNIQUE (workspace_id, virtual_channel_id, remote_ref),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, virtual_channel_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE INDEX virtual_channel_sources_priority ON virtual_channel_sources (workspace_id, virtual_channel_id, priority);
|
||||
CREATE INDEX virtual_channel_sources_connection ON virtual_channel_sources (connection_id, workspace_id) WHERE connection_id IS NOT NULL;
|
||||
|
||||
-- Ordered, pre-normalized assets of a playlist source (the worker's asset feeder walks these).
|
||||
CREATE TABLE playlist_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
source_id uuid NOT NULL,
|
||||
position int NOT NULL CHECK (position >= 0 AND position < 500),
|
||||
video_asset_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, source_id, position),
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, source_id) REFERENCES virtual_channel_sources (workspace_id, virtual_channel_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (video_asset_id, workspace_id) REFERENCES video_assets (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE INDEX playlist_items_asset ON playlist_items (video_asset_id, workspace_id);
|
||||
|
||||
CREATE TABLE production_scenes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
label text NOT NULL CHECK (label ~ '^[[:alnum:]][[:alnum:] _.-]{0,63}$'),
|
||||
layout text NOT NULL CHECK (layout IN ('single', 'pip', '2x1', '2x2', '3x3', '4x4', 'custom')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, virtual_channel_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
CREATE INDEX production_scenes_channel ON production_scenes (workspace_id, virtual_channel_id);
|
||||
|
||||
CREATE TABLE scene_slots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
scene_id uuid NOT NULL,
|
||||
slot_index int NOT NULL CHECK (slot_index >= 0 AND slot_index < 16),
|
||||
-- NULL = empty slot (renders the fallback); also the ON DELETE SET NULL target.
|
||||
source_id uuid,
|
||||
-- Explicit rectangle for custom layouts and PIP insets; all NULL = the layout's own cell.
|
||||
x int, y int, w int, h int,
|
||||
fit text NOT NULL DEFAULT 'contain' CHECK (fit IN ('contain', 'cover', 'crop')),
|
||||
audio_mode text NOT NULL DEFAULT 'off' CHECK (audio_mode IN ('off', 'on', 'primary')),
|
||||
gain_db smallint NOT NULL DEFAULT 0 CHECK (gain_db BETWEEN -24 AND 0 AND gain_db % 3 = 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK ((x IS NULL AND y IS NULL AND w IS NULL AND h IS NULL) OR
|
||||
(x IS NOT NULL AND y IS NOT NULL AND w IS NOT NULL AND h IS NOT NULL)),
|
||||
CHECK (x >= 0 AND y >= 0 AND w > 0 AND h > 0),
|
||||
CHECK (x + w <= 10000 AND y + h <= 10000),
|
||||
UNIQUE (workspace_id, scene_id, slot_index),
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Column-list SET NULL (PostgreSQL >= 15): empties the slot, keeps tenant + channel set.
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, source_id) REFERENCES virtual_channel_sources (workspace_id, virtual_channel_id, id)
|
||||
ON DELETE SET NULL (source_id) ON UPDATE RESTRICT
|
||||
);
|
||||
-- At most one primary audio slot per scene (program mix default: primary-only).
|
||||
CREATE UNIQUE INDEX scene_slots_one_primary ON scene_slots (workspace_id, scene_id) WHERE audio_mode = 'primary';
|
||||
CREATE INDEX scene_slots_source ON scene_slots (workspace_id, virtual_channel_id, source_id) WHERE source_id IS NOT NULL;
|
||||
CREATE INDEX scene_slots_scene ON scene_slots (workspace_id, virtual_channel_id, scene_id);
|
||||
|
||||
CREATE TABLE production_buses (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
bus text NOT NULL CHECK (bus IN ('preview', 'program')),
|
||||
-- Declared mutable reference: TAKE/CUT re-point it in place. NULL = empty bus.
|
||||
-- NO ACTION: a scene on a bus cannot be deleted directly, but a channel
|
||||
-- cascade that removes both succeeds (checked at end of statement).
|
||||
scene_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, virtual_channel_id, bus),
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE INDEX production_buses_scene ON production_buses (workspace_id, virtual_channel_id, scene_id) WHERE scene_id IS NOT NULL;
|
||||
|
||||
-- An overlay slot sits on one scene; toggling `enabled` arms it on PST and it
|
||||
-- goes live with TAKE. Its pixels are always a pre-rendered asset (§4.2)
|
||||
-- except the server-generated clock, whose format is a closed enum.
|
||||
CREATE TABLE overlay_slots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
scene_id uuid NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('lower_third', 'chyron', 'logo', 'clock')),
|
||||
enabled boolean NOT NULL DEFAULT false,
|
||||
layer smallint NOT NULL DEFAULT 0 CHECK (layer BETWEEN 0 AND 7),
|
||||
pos_x int NOT NULL DEFAULT 0 CHECK (pos_x BETWEEN 0 AND 10000),
|
||||
pos_y int NOT NULL DEFAULT 0 CHECK (pos_y BETWEEN 0 AND 10000),
|
||||
chyron_speed_px_s smallint CHECK (chyron_speed_px_s BETWEEN 40 AND 400),
|
||||
chyron_strip_width_px int CHECK (chyron_strip_width_px BETWEEN 1 AND 16384),
|
||||
clock_format text CHECK (clock_format IN ('HH:MM', 'HH:MM:SS')),
|
||||
-- Revision currently composited; NULL until the first render lands.
|
||||
current_revision_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK ((kind = 'chyron') = (chyron_speed_px_s IS NOT NULL AND chyron_strip_width_px IS NOT NULL)),
|
||||
CHECK (kind = 'chyron' OR (chyron_speed_px_s IS NULL AND chyron_strip_width_px IS NULL)),
|
||||
CHECK ((kind = 'clock') = (clock_format IS NOT NULL)),
|
||||
CHECK (kind <> 'clock' OR current_revision_id IS NULL),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, virtual_channel_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
CREATE INDEX overlay_slots_scene ON overlay_slots (workspace_id, virtual_channel_id, scene_id);
|
||||
|
||||
-- Append-only history of an overlay slot's validated content and its rendered
|
||||
-- asset. FFmpeg only ever sees `asset_id` (sha256 content id from StorageService).
|
||||
CREATE TABLE overlay_revisions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
overlay_slot_id uuid NOT NULL,
|
||||
template_id text NOT NULL CHECK (template_id ~ '^[a-z0-9][a-z0-9_-]{0,63}$'),
|
||||
-- Typed field values (title <= 80, subtitle <= 120, style enum), validated in the app layer.
|
||||
fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(fields) = 'object'),
|
||||
render_state text NOT NULL DEFAULT 'pending' CHECK (render_state IN ('pending', 'rendered', 'failed')),
|
||||
asset_id text CHECK (asset_id ~ '^[0-9a-f]{64}$'),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK ((render_state = 'rendered') = (asset_id IS NOT NULL)),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, virtual_channel_id, overlay_slot_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, overlay_slot_id) REFERENCES overlay_slots (workspace_id, virtual_channel_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
CREATE INDEX overlay_revisions_slot ON overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, created_at DESC);
|
||||
|
||||
-- The current revision must be one of THIS slot's revisions (same channel, same slot).
|
||||
ALTER TABLE overlay_slots ADD CONSTRAINT overlay_slots_current_revision_fkey
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id, id, current_revision_id)
|
||||
REFERENCES overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, id)
|
||||
ON DELETE SET NULL (current_revision_id) ON UPDATE RESTRICT;
|
||||
CREATE INDEX overlay_slots_current_revision ON overlay_slots (workspace_id, virtual_channel_id, id, current_revision_id)
|
||||
WHERE current_revision_id IS NOT NULL;
|
||||
|
||||
-- Immutability: tenant column everywhere, ownership columns per table.
|
||||
CREATE TRIGGER trg_virtual_channel_ws_immutable BEFORE UPDATE ON virtual_channels
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_vc_source_ws_immutable BEFORE UPDATE ON virtual_channel_sources
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_vc_source_owner_immutable BEFORE UPDATE ON virtual_channel_sources
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'kind');
|
||||
CREATE TRIGGER trg_playlist_item_ws_immutable BEFORE UPDATE ON playlist_items
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_playlist_item_owner_immutable BEFORE UPDATE ON playlist_items
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'source_id');
|
||||
CREATE TRIGGER trg_scene_ws_immutable BEFORE UPDATE ON production_scenes
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_scene_owner_immutable BEFORE UPDATE ON production_scenes
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id');
|
||||
CREATE TRIGGER trg_scene_slot_ws_immutable BEFORE UPDATE ON scene_slots
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_scene_slot_owner_immutable BEFORE UPDATE ON scene_slots
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'scene_id', '?source_id');
|
||||
CREATE TRIGGER trg_bus_ws_immutable BEFORE UPDATE ON production_buses
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_bus_owner_immutable BEFORE UPDATE ON production_buses
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'bus');
|
||||
CREATE TRIGGER trg_overlay_slot_ws_immutable BEFORE UPDATE ON overlay_slots
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_overlay_slot_owner_immutable BEFORE UPDATE ON overlay_slots
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'scene_id', 'kind');
|
||||
CREATE TRIGGER trg_overlay_revision_ws_immutable BEFORE UPDATE ON overlay_revisions
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_overlay_revision_owner_immutable BEFORE UPDATE ON overlay_revisions
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'overlay_slot_id', 'template_id', 'fields');
|
||||
|
||||
ALTER TABLE virtual_channels ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE virtual_channel_sources ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE playlist_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE production_scenes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scene_slots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE production_buses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE overlay_slots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE overlay_revisions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE virtual_channels FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE virtual_channel_sources FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE playlist_items FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE production_scenes FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scene_slots FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE production_buses FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE overlay_slots FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE overlay_revisions FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON virtual_channels
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON virtual_channel_sources
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON playlist_items
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON production_scenes
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON scene_slots
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON production_buses
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON overlay_slots
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON overlay_revisions
|
||||
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 virtual_channels, virtual_channel_sources, playlist_items,
|
||||
production_scenes, scene_slots, production_buses, overlay_slots, overlay_revisions TO sp_app;
|
||||
236
apps/api/migrations/014_live_chat.sql
Normal file
236
apps/api/migrations/014_live_chat.sql
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
-- 014: Streaming slice S5 — chat hub + device pairing
|
||||
-- (docs/design/streaming-architecture.md §3, §6 S5).
|
||||
--
|
||||
-- Design notes:
|
||||
-- * `live_chat_messages` is keyed by (workspace, channel) — the chat hub's
|
||||
-- aggregate is a virtual channel's live chat, and the seq cursor a client
|
||||
-- resumes with is per channel. Global provider dedupe uses a nonpartitioned
|
||||
-- claim journal; the paging index is (workspace_id, channel_id, seq).
|
||||
-- * Ordering is a hub-assigned `seq` under a row lock on the channel's
|
||||
-- chat_seq row; provider timestamps stay display-only (clocks skew, §3.2).
|
||||
-- * Monthly received_at partitions retain ingress ordering. PostgreSQL needs
|
||||
-- the partition key in unique constraints, so global provider dedupe lives
|
||||
-- in the nonpartitioned live_chat_seen_events journal, claimed in the same
|
||||
-- transaction as the message insert.
|
||||
-- * Devices and pairings carry their own workspace column and direct
|
||||
-- workspace FK so revocation is workspace-scoped; user_id references
|
||||
-- users(id) ON DELETE CASCADE so deleting a user removes their devices.
|
||||
--
|
||||
-- RLS / NULLIF / sp_app grants follow 012/013 conventions.
|
||||
|
||||
-- One row per (workspace, channel) holding the chat sequence. FOR UPDATE on
|
||||
-- this row inside the ingest transaction is what makes seq assignment monotonic
|
||||
-- per channel (§3.2 "monotonic seq assigned at hub ingress").
|
||||
CREATE TABLE live_chat_seq (
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
channel_id uuid NOT NULL,
|
||||
seq bigint NOT NULL DEFAULT 0 CHECK (seq >= 0),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, channel_id),
|
||||
FOREIGN KEY (channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE live_chat_messages (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
-- Provider connection that produced the message (capability target).
|
||||
connection_id uuid NOT NULL,
|
||||
provider text NOT NULL CHECK (btrim(provider) <> ''),
|
||||
-- Provider's own message id; the dedupe key with workspace + provider.
|
||||
provider_msg_id text NOT NULL CHECK (btrim(provider_msg_id) <> ''),
|
||||
-- Author
|
||||
author_handle text NOT NULL CHECK (btrim(author_handle) <> ''),
|
||||
author_display_name text,
|
||||
author_avatar_url text,
|
||||
author_badges jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(author_badges) = 'array'),
|
||||
author_color text NOT NULL CHECK (author_color <> ''),
|
||||
body text NOT NULL CHECK (length(body) <= 5000),
|
||||
-- Reply threading (provider message id of the parent, nullable).
|
||||
reply_to text,
|
||||
-- Hub ingress
|
||||
seq bigint NOT NULL CHECK (seq > 0),
|
||||
sent_at timestamptz NOT NULL,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
is_moderated boolean NOT NULL DEFAULT false,
|
||||
source text NOT NULL DEFAULT 'live-chat' CHECK (source IN ('live-chat', 'social-reply', 'system')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, received_at),
|
||||
UNIQUE (workspace_id, provider, provider_msg_id, received_at),
|
||||
FOREIGN KEY (channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
-- Precreate monthly partitions; administrators run this before a new month.
|
||||
CREATE FUNCTION live_chat_ensure_month(p_month date) RETURNS void
|
||||
LANGUAGE plpgsql SET search_path = public AS $$
|
||||
DECLARE v_start timestamptz := date_trunc('month', p_month::timestamptz);
|
||||
v_table text := 'live_chat_messages_' || to_char(v_start, 'YYYYMM');
|
||||
BEGIN
|
||||
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF live_chat_messages FOR VALUES FROM (%L) TO (%L)',
|
||||
v_table, v_start, v_start + interval '1 month');
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', v_table);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', v_table);
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=v_table AND policyname='tenant_isolation') THEN
|
||||
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid)', v_table);
|
||||
END IF;
|
||||
END $$;
|
||||
REVOKE ALL ON FUNCTION live_chat_ensure_month(date) FROM PUBLIC;
|
||||
SELECT live_chat_ensure_month((current_date - interval '1 month')::date);
|
||||
SELECT live_chat_ensure_month(current_date);
|
||||
SELECT live_chat_ensure_month((current_date + interval '1 month')::date);
|
||||
CREATE TABLE live_chat_messages_default PARTITION OF live_chat_messages DEFAULT;
|
||||
ALTER TABLE live_chat_messages_default ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_messages_default FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON live_chat_messages_default
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
CREATE INDEX live_chat_messages_channel_seq ON live_chat_messages (workspace_id, channel_id, seq);
|
||||
CREATE INDEX live_chat_messages_author ON live_chat_messages (workspace_id, channel_id, author_handle);
|
||||
|
||||
-- Global provider-message/event claim journal, mirroring
|
||||
-- monitor_seen_events (ON CONFLICT DO NOTHING semantics come from the PK).
|
||||
CREATE TABLE live_chat_seen_events (
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
provider text NOT NULL CHECK (btrim(provider) <> ''),
|
||||
provider_event_id text NOT NULL CHECK (btrim(provider_event_id) <> ''),
|
||||
seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, provider, provider_event_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_live_chat_seen_event_ws_immutable BEFORE UPDATE ON live_chat_seen_events
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
-- Devices: Opticast authenticates with a device token, not a session cookie
|
||||
-- (§3.4). Epoch-gated: revoking bumps the epoch, tokens fail closed.
|
||||
CREATE TABLE devices (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
name text NOT NULL CHECK (btrim(name) <> '' AND length(name) <= 120),
|
||||
scopes text[] NOT NULL DEFAULT '{livechat.view,stream.view}'::text[],
|
||||
epoch bigint NOT NULL DEFAULT 1 CHECK (epoch >= 1),
|
||||
authz_epoch bigint NOT NULL,
|
||||
last_seen_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id)
|
||||
);
|
||||
CREATE INDEX devices_ws_user ON devices (workspace_id, user_id);
|
||||
CREATE INDEX devices_ws_revoked ON devices (workspace_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- Single-use, TTL'd pairing codes. `redeemed_at` enforces single use; the
|
||||
-- expiry is checked by the app (a code may expire unredeemed, and a purge of
|
||||
-- expired rows is a plain DELETE).
|
||||
CREATE TABLE device_pairings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
code text NOT NULL CHECK (code ~ '^[A-Z0-9]{4}-[A-Z0-9]{4}$'),
|
||||
qr_payload text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
redeemed_at timestamptz,
|
||||
device_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (code),
|
||||
UNIQUE (workspace_id, code),
|
||||
FOREIGN KEY (device_id, workspace_id) REFERENCES devices (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE INDEX device_pairings_ws_open ON device_pairings (workspace_id, code) WHERE redeemed_at IS NULL;
|
||||
|
||||
CREATE TRIGGER trg_device_ws_immutable BEFORE UPDATE ON devices FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_device_pairing_ws_immutable BEFORE UPDATE ON device_pairings FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_live_chat_message_ws_immutable BEFORE UPDATE ON live_chat_messages FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_live_chat_seq_ws_immutable BEFORE UPDATE ON live_chat_seq FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE live_chat_seq ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_seen_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE devices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE device_pairings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_seq FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_messages FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_seen_events FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE devices FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE device_pairings FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON live_chat_seq
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON live_chat_messages
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON live_chat_seen_events
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON devices
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON device_pairings
|
||||
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 live_chat_seq, live_chat_messages, live_chat_seen_events,
|
||||
devices, device_pairings TO sp_app;
|
||||
|
||||
-- Retention (follow-on, noted here so the partition/retention row from the
|
||||
-- work-breakdown finds its anchor): pruning live_chat_seen_events older than
|
||||
-- the journal window, mirroring monitor_prune_seen_events from 012.
|
||||
CREATE FUNCTION live_chat_prune_seen_events(p_before timestamptz) RETURNS integer
|
||||
LANGUAGE sql SECURITY INVOKER SET search_path = public AS $$
|
||||
WITH removed AS (
|
||||
DELETE FROM live_chat_seen_events WHERE seen_at < p_before RETURNING 1
|
||||
)
|
||||
SELECT count(*) FROM removed
|
||||
$$;
|
||||
REVOKE ALL ON FUNCTION live_chat_prune_seen_events(timestamptz) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION live_chat_prune_seen_events(timestamptz) TO sp_app;
|
||||
|
||||
-- Resolves only a valid short-lived bearer pairing code; no tenant rows exposed.
|
||||
CREATE FUNCTION device_pairing_workspace(p_code text) RETURNS uuid
|
||||
LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT workspace_id FROM device_pairings WHERE code=p_code AND expires_at > now()
|
||||
$$;
|
||||
REVOKE ALL ON FUNCTION device_pairing_workspace(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION device_pairing_workspace(text) TO sp_app;
|
||||
|
||||
-- Tenant-scoped retention for messages and their dedupe claims.
|
||||
CREATE FUNCTION live_chat_prune(p_workspace uuid, p_before timestamptz) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE v_count integer;
|
||||
BEGIN
|
||||
PERFORM fn_assert_tenant(p_workspace);
|
||||
DELETE FROM live_chat_messages WHERE workspace_id=p_workspace AND received_at < p_before;
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
DELETE FROM live_chat_seen_events WHERE workspace_id=p_workspace AND seen_at < p_before;
|
||||
DELETE FROM device_pairings WHERE workspace_id=p_workspace AND expires_at < p_before;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
REVOKE ALL ON FUNCTION live_chat_prune(uuid,timestamptz) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION live_chat_prune(uuid,timestamptz) TO sp_app;
|
||||
|
||||
CREATE TABLE live_chat_requests (
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
route text NOT NULL,
|
||||
request_key text NOT NULL,
|
||||
request_hash text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','complete','failed')),
|
||||
response jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id,user_id,route,request_key)
|
||||
);
|
||||
ALTER TABLE live_chat_requests ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_chat_requests FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON live_chat_requests
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE TRIGGER trg_live_chat_request_ws_immutable BEFORE UPDATE ON live_chat_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
GRANT SELECT,INSERT,UPDATE,DELETE ON live_chat_requests TO sp_app;
|
||||
88
apps/api/migrations/015_archive.sql
Normal file
88
apps/api/migrations/015_archive.sql
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
-- 015: WP15 Telegram live channel archive. All identities are tenant pinned.
|
||||
CREATE TABLE feed_subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
type text NOT NULL CHECK (type = 'telegram_channel'),
|
||||
connection_id uuid NOT NULL,
|
||||
channel_id text NOT NULL CHECK (btrim(channel_id) <> ''),
|
||||
health text NOT NULL DEFAULT 'active' CHECK (health IN ('active', 'bot_removed', 'admin_required')),
|
||||
health_checked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, connection_id, channel_id),
|
||||
UNIQUE (id, workspace_id),
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
CREATE INDEX feed_subscriptions_channel ON feed_subscriptions (workspace_id, channel_id);
|
||||
|
||||
CREATE TABLE archived_feed_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
remote_ref text NOT NULL CHECK (remote_ref ~ '^(-?[0-9]+|@[A-Za-z0-9_]+):[0-9]+$'),
|
||||
channel_id text NOT NULL,
|
||||
message_id bigint NOT NULL CHECK (message_id > 0),
|
||||
media_group_id text,
|
||||
text_content text NOT NULL DEFAULT '',
|
||||
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
|
||||
revision integer NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
media_refs jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media_refs) = 'array'),
|
||||
attribution jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(attribution) = 'object'),
|
||||
protected_content boolean NOT NULL DEFAULT false,
|
||||
posted_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, remote_ref),
|
||||
UNIQUE (id, workspace_id),
|
||||
CHECK (remote_ref = channel_id || ':' || message_id::text)
|
||||
);
|
||||
CREATE INDEX archived_feed_items_cursor ON archived_feed_items (workspace_id, posted_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE archived_feed_revisions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
item_id uuid NOT NULL,
|
||||
revision integer NOT NULL CHECK (revision > 0),
|
||||
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
|
||||
text_content text NOT NULL,
|
||||
media_refs jsonb NOT NULL CHECK (jsonb_typeof(media_refs) = 'array'),
|
||||
attribution jsonb NOT NULL CHECK (jsonb_typeof(attribution) = 'object'),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, item_id, revision),
|
||||
FOREIGN KEY (item_id, workspace_id) REFERENCES archived_feed_items (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
-- drafts predates tenant-pinned references.
|
||||
ALTER TABLE drafts ADD CONSTRAINT drafts_id_workspace_key UNIQUE (id, workspace_id);
|
||||
|
||||
CREATE TABLE archived_feed_drafts (
|
||||
workspace_id uuid NOT NULL,
|
||||
item_id uuid NOT NULL,
|
||||
draft_id uuid NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, item_id, draft_id),
|
||||
FOREIGN KEY (item_id, workspace_id) REFERENCES archived_feed_items (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_feed_subscriptions_ws_immutable BEFORE UPDATE ON feed_subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_archived_feed_items_ws_immutable BEFORE UPDATE ON archived_feed_items
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_archived_feed_revisions_ws_immutable BEFORE UPDATE ON archived_feed_revisions
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_archived_feed_drafts_ws_immutable BEFORE UPDATE ON archived_feed_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE feed_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE archived_feed_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE archived_feed_revisions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE archived_feed_drafts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE feed_subscriptions FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE archived_feed_items FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE archived_feed_revisions FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE archived_feed_drafts FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON feed_subscriptions USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON archived_feed_items USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON archived_feed_revisions USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON archived_feed_drafts 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 feed_subscriptions, archived_feed_items, archived_feed_revisions, archived_feed_drafts TO sp_app;
|
||||
20
apps/api/migrations/016_username_auth.sql
Normal file
20
apps/api/migrations/016_username_auth.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- 016: username-based first-party auth.
|
||||
-- Usernames are the login identity (external_id = local:<username>); email is
|
||||
-- optional metadata for future password recovery. Existing email-keyed users
|
||||
-- get a username derived from their email local-part (or display name).
|
||||
ALTER TABLE users ADD COLUMN username text;
|
||||
|
||||
UPDATE users SET username = COALESCE(
|
||||
NULLIF(regexp_replace(lower(split_part(coalesce(email, ''), '@', 1)), '[^a-z0-9_.-]', '', 'g'), ''),
|
||||
NULLIF(regexp_replace(lower(display_name), '[^a-z0-9_.-]', '', 'g'), ''),
|
||||
'member'
|
||||
);
|
||||
-- Deterministic dedupe: colliding candidates get a short id suffix.
|
||||
UPDATE users u SET username = u.username || '_' || substr(u.id::text, 1, 4)
|
||||
WHERE EXISTS (SELECT 1 FROM users v WHERE v.username = u.username AND v.id < u.id);
|
||||
|
||||
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
|
||||
CREATE UNIQUE INDEX users_username_key ON users (username);
|
||||
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO sp_app;
|
||||
64
apps/api/migrations/017_invite_links.sql
Normal file
64
apps/api/migrations/017_invite_links.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
-- 017: multi-use, expiring workspace invite links (Telegram-style).
|
||||
-- One link, many claimants: anyone holding the token may join with the link's
|
||||
-- role until the link expires, is revoked, or exhausts max_uses (null =
|
||||
-- unlimited until expiry). Claimers arrive unauthenticated, so the claim path
|
||||
-- is the SECURITY DEFINER analogue of login (see invite_link_for_claim below):
|
||||
-- workspace_invite_links is FORCE RLS and no tenant context exists pre-join.
|
||||
CREATE TABLE workspace_invite_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
role text NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
created_by uuid NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
max_uses int CHECK (max_uses IS NULL OR max_uses > 0),
|
||||
use_count int NOT NULL DEFAULT 0 CHECK (use_count >= 0),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_invite_link_ws_immutable BEFORE UPDATE ON workspace_invite_links
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
-- Same fail-closed tenant isolation as every tenant table (001/006 style):
|
||||
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
|
||||
ALTER TABLE workspace_invite_links ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE workspace_invite_links FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON workspace_invite_links
|
||||
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 workspace_invite_links TO sp_app;
|
||||
|
||||
-- Claim runs with NO tenant context (the claimer has no membership yet), and
|
||||
-- the table is FORCE RLS: ordinary SELECTs see zero rows. Like
|
||||
-- auth_user_memberships (007) and publishing_schedule_workspace (006), this
|
||||
-- narrow SECURITY DEFINER lookup exposes exactly one row keyed by the secret
|
||||
-- token — workspace_id discovery only; the caller enters the tenant context
|
||||
-- afterwards and every subsequent statement is RLS-bound as sp_app.
|
||||
CREATE FUNCTION invite_link_for_claim(p_token text)
|
||||
RETURNS TABLE(id uuid, workspace_id uuid, role text, expires_at timestamptz,
|
||||
max_uses int, use_count int, revoked_at timestamptz)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT l.id, l.workspace_id, l.role, l.expires_at, l.max_uses, l.use_count, l.revoked_at
|
||||
FROM workspace_invite_links l
|
||||
WHERE l.token = p_token
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION invite_link_for_claim(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION invite_link_for_claim(text) TO sp_app;
|
||||
|
||||
-- Token lookup at claim time: point lookup served by the backing index of the
|
||||
-- inline UNIQUE constraint on token (auto-named workspace_invite_links_token_key).
|
||||
-- Statement for documentation/parity with 016's users_username_key — do NOT run twice:
|
||||
-- CREATE UNIQUE INDEX workspace_invite_links_token_key ON workspace_invite_links (token);
|
||||
|
||||
-- Workspace listing (admin GET /team/invite-links), newest first:
|
||||
CREATE INDEX workspace_invite_links_ws_created
|
||||
ON workspace_invite_links (workspace_id, created_at DESC);
|
||||
|
||||
-- Expiry sweep / active-link diagnostics, mirroring the partial-index style of
|
||||
-- audit_log (001) and scheduled_publications_due (006):
|
||||
CREATE INDEX workspace_invite_links_expiry
|
||||
ON workspace_invite_links (expires_at) WHERE revoked_at IS NULL;
|
||||
485
apps/api/migrations/018_publishing_execution.sql
Normal file
485
apps/api/migrations/018_publishing_execution.sql
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
-- 018: WP3 publishing execution spine (fences, attempt journal, receipts).
|
||||
-- Preserves 006: same drafts / scheduled_publications / publications tables,
|
||||
-- same PKs, UNIQUE(draft_id) on schedules and global idempotency_key
|
||||
-- uniqueness. Adds the scheduling principal, revision/approval proof,
|
||||
-- credential generation/revocation, execution generation and the durable
|
||||
-- attempt journal the scheduler claims, fences and finalizes against.
|
||||
--
|
||||
-- The current runner (db/migrate.ts) wraps each file in ONE transaction, so
|
||||
-- this file is written to be transactional end to end: no CREATE INDEX
|
||||
-- CONCURRENTLY, bounded by lock_timeout, preflight fails closed on corrupt
|
||||
-- tenant references instead of silently reparenting them. Quiesce the
|
||||
-- publishing worker for the upgrade (plan §2 "Existing data rollout").
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Preflight: refuse to upgrade data that the tenant-composite FKs below would
|
||||
-- reject anyway, with an operator-readable reason. Nothing is reparented.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE v_bad bigint;
|
||||
BEGIN
|
||||
SELECT count(*) INTO v_bad FROM scheduled_publications sp
|
||||
WHERE NOT EXISTS (SELECT 1 FROM drafts d WHERE d.id = sp.draft_id AND d.workspace_id = sp.workspace_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = sp.connection_id AND c.workspace_id = sp.workspace_id);
|
||||
IF v_bad > 0 THEN
|
||||
RAISE EXCEPTION '018 preflight: % scheduled_publications rows reference a draft/connection in another workspace', v_bad;
|
||||
END IF;
|
||||
SELECT count(*) INTO v_bad FROM publications p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM drafts d WHERE d.id = p.draft_id AND d.workspace_id = p.workspace_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = p.connection_id AND c.workspace_id = p.workspace_id);
|
||||
IF v_bad > 0 THEN
|
||||
RAISE EXCEPTION '018 preflight: % publications rows reference a draft/connection in another workspace', v_bad;
|
||||
END IF;
|
||||
SELECT count(*) INTO v_bad FROM credentials cr
|
||||
WHERE cr.connection_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = cr.connection_id AND c.workspace_id = cr.workspace_id);
|
||||
IF v_bad > 0 THEN
|
||||
RAISE EXCEPTION '018 preflight: % credentials rows reference a connection in another workspace', v_bad;
|
||||
END IF;
|
||||
SELECT count(*) INTO v_bad FROM (
|
||||
SELECT 1 FROM credentials WHERE kind = 'connector-oauth' AND connection_id IS NOT NULL
|
||||
GROUP BY workspace_id, connection_id HAVING count(*) > 1
|
||||
) dup;
|
||||
IF v_bad > 0 THEN
|
||||
RAISE EXCEPTION '018 preflight: % connections have more than one connector-oauth credential; reconcile before upgrading', v_bad;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Credentials: generation + revocation. key_version is NOT a token
|
||||
-- generation (APV1 sealing writes version 1 on every rotation).
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE credentials
|
||||
ADD COLUMN generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0),
|
||||
ADD COLUMN revoked_at timestamptz;
|
||||
ALTER TABLE credentials ADD CONSTRAINT credentials_id_workspace_key UNIQUE (id, workspace_id);
|
||||
-- One live connector credential per connection; readers stop using LIMIT 1.
|
||||
CREATE UNIQUE INDEX credentials_connector_oauth_unique
|
||||
ON credentials (workspace_id, connection_id) WHERE kind = 'connector-oauth';
|
||||
|
||||
-- generation is DB-owned: any secret-material or lifecycle change rotates it,
|
||||
-- and callers cannot rewind it.
|
||||
CREATE FUNCTION credentials_bump_generation() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.ciphertext IS DISTINCT FROM OLD.ciphertext
|
||||
OR NEW.key_version IS DISTINCT FROM OLD.key_version
|
||||
OR NEW.rotated_at IS DISTINCT FROM OLD.rotated_at
|
||||
OR NEW.revoked_at IS DISTINCT FROM OLD.revoked_at THEN
|
||||
NEW.generation := OLD.generation + 1;
|
||||
ELSE
|
||||
NEW.generation := OLD.generation;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_cred_generation BEFORE UPDATE ON credentials
|
||||
FOR EACH ROW EXECUTE FUNCTION credentials_bump_generation();
|
||||
-- Credentials are an execution-authorization input: changes bump the epoch
|
||||
-- exactly like memberships (reuses bump_authz_epoch, 001/005).
|
||||
CREATE TRIGGER trg_cred_bump_epoch
|
||||
AFTER INSERT OR DELETE OR UPDATE OF ciphertext, key_version, rotated_at, revoked_at ON credentials
|
||||
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Connections: tombstones keep journal evidence intact after removal.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE connections ADD COLUMN disconnected_at timestamptz;
|
||||
CREATE TRIGGER trg_conn_disconnect_bump_epoch AFTER UPDATE OF disconnected_at ON connections
|
||||
FOR EACH ROW WHEN (NEW.disconnected_at IS DISTINCT FROM OLD.disconnected_at)
|
||||
EXECUTE FUNCTION bump_authz_epoch();
|
||||
|
||||
-- Group sharing gates userCanUseConnection, so it bumps the epoch too (008
|
||||
-- only covered group_members).
|
||||
CREATE TRIGGER trg_group_connection_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON group_connections
|
||||
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
|
||||
|
||||
-- role_permissions is global. Every workspace's Owner/Admin/... roles come
|
||||
-- from it, so a change bumps all workspaces once per statement, locked in
|
||||
-- UUID order to avoid lock-order inversions between concurrent bumps.
|
||||
CREATE FUNCTION bump_all_authz_epochs() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM 1 FROM workspaces ORDER BY id FOR UPDATE;
|
||||
UPDATE workspaces SET authz_epoch = authz_epoch + 1;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_role_permissions_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON role_permissions
|
||||
FOR EACH STATEMENT EXECUTE FUNCTION bump_all_authz_epochs();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Drafts: revision + approval proof + business attempt counter.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE drafts
|
||||
ADD COLUMN revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
ADD COLUMN approved_revision bigint,
|
||||
ADD COLUMN approved_hash text,
|
||||
ADD COLUMN approved_at timestamptz,
|
||||
ADD COLUMN review_reason text,
|
||||
ADD COLUMN publication_attempt bigint NOT NULL DEFAULT 0 CHECK (publication_attempt >= 0);
|
||||
-- drafts_id_workspace_key (id, workspace_id) already exists from 015.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Scheduled publications: principal/fence snapshot + execution generation.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE scheduled_publications
|
||||
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN scheduled_by_user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
ADD COLUMN required_permission text
|
||||
CHECK (required_permission IN ('publication.schedule', 'publication.publish')),
|
||||
ADD COLUMN authorized_epoch bigint,
|
||||
ADD COLUMN approved_revision bigint,
|
||||
ADD COLUMN post_hash text,
|
||||
ADD COLUMN credential_id uuid,
|
||||
ADD COLUMN credential_generation bigint,
|
||||
ADD COLUMN generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0),
|
||||
ADD COLUMN attempt_no bigint NOT NULL DEFAULT 1 CHECK (attempt_no > 0),
|
||||
ADD COLUMN execution_version smallint NOT NULL DEFAULT 0 CHECK (execution_version IN (0, 1)),
|
||||
ADD COLUMN timezone text NOT NULL DEFAULT 'UTC',
|
||||
ADD COLUMN local_publish_at timestamp,
|
||||
ADD COLUMN tzdata_version text,
|
||||
ADD COLUMN next_attempt_at timestamptz,
|
||||
ADD COLUMN retry_count int NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
ADD COLUMN error_code text;
|
||||
|
||||
ALTER TABLE scheduled_publications DROP CONSTRAINT scheduled_publications_state_check;
|
||||
ALTER TABLE scheduled_publications ADD CONSTRAINT scheduled_publications_state_check
|
||||
CHECK (state IN ('pending', 'in_flight', 'done', 'failed', 'needs_reauthorization', 'cancelled'));
|
||||
|
||||
-- Legacy rows: timestamps from the draft, due time from publish_at, UTC zone.
|
||||
UPDATE scheduled_publications sp
|
||||
SET created_at = d.created_at, updated_at = d.updated_at, next_attempt_at = sp.publish_at
|
||||
FROM drafts d
|
||||
WHERE d.id = sp.draft_id AND d.workspace_id = sp.workspace_id;
|
||||
|
||||
-- Existing publications are authoritative: their schedule is done, draft published.
|
||||
UPDATE scheduled_publications sp SET state = 'done', error = NULL, error_code = NULL
|
||||
WHERE EXISTS (SELECT 1 FROM publications p
|
||||
WHERE p.workspace_id = sp.workspace_id
|
||||
AND (p.idempotency_key = sp.idempotency_key OR p.draft_id = sp.draft_id));
|
||||
UPDATE drafts d SET state = 'published'
|
||||
WHERE d.state <> 'published'
|
||||
AND EXISTS (SELECT 1 FROM publications p WHERE p.workspace_id = d.workspace_id AND p.draft_id = d.id);
|
||||
-- No recorded principal: never infer one from author/audit history.
|
||||
UPDATE scheduled_publications SET state = 'needs_reauthorization', error_code = 'LEGACY_AUTHORIZATION_MISSING'
|
||||
WHERE state = 'pending';
|
||||
-- In flight without a publication: the send may have happened.
|
||||
UPDATE scheduled_publications SET state = 'failed', error_code = 'PUBLISH_OUTCOME_UNKNOWN'
|
||||
WHERE state = 'in_flight';
|
||||
|
||||
-- Upgraded (execution_version = 1) rows must carry every execution fence
|
||||
-- while executable or done. Legacy rows (0) are readable, never executable:
|
||||
-- every claim requires execution_version = 1.
|
||||
ALTER TABLE scheduled_publications ADD CONSTRAINT scheduled_publications_fences_check CHECK (
|
||||
execution_version = 0
|
||||
OR state IN ('failed', 'needs_reauthorization', 'cancelled')
|
||||
OR (scheduled_by_user_id IS NOT NULL AND required_permission IS NOT NULL
|
||||
AND authorized_epoch IS NOT NULL AND approved_revision IS NOT NULL
|
||||
AND post_hash IS NOT NULL AND credential_id IS NOT NULL
|
||||
AND credential_generation IS NOT NULL AND next_attempt_at IS NOT NULL)
|
||||
);
|
||||
|
||||
ALTER TABLE scheduled_publications
|
||||
ADD CONSTRAINT scheduled_publications_id_workspace_key UNIQUE (id, workspace_id);
|
||||
|
||||
-- Tenant-composite replacements for 006's single-column FKs (same ON DELETE
|
||||
-- semantics; journal tables below add NO ACTION guards).
|
||||
ALTER TABLE scheduled_publications
|
||||
DROP CONSTRAINT scheduled_publications_draft_id_fkey,
|
||||
DROP CONSTRAINT scheduled_publications_connection_id_fkey,
|
||||
ADD CONSTRAINT scheduled_publications_draft_ws_fkey FOREIGN KEY (draft_id, workspace_id)
|
||||
REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
ADD CONSTRAINT scheduled_publications_connection_ws_fkey FOREIGN KEY (connection_id, workspace_id)
|
||||
REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
ADD CONSTRAINT scheduled_publications_credential_ws_fkey FOREIGN KEY (credential_id, workspace_id)
|
||||
REFERENCES credentials (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE publications
|
||||
DROP CONSTRAINT publications_draft_id_fkey,
|
||||
DROP CONSTRAINT publications_connection_id_fkey,
|
||||
ADD CONSTRAINT publications_draft_ws_fkey FOREIGN KEY (draft_id, workspace_id)
|
||||
REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
ADD CONSTRAINT publications_connection_ws_fkey FOREIGN KEY (connection_id, workspace_id)
|
||||
REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
|
||||
-- Schedules: DB-owned updated_at; generation and execution_version only move forward.
|
||||
CREATE FUNCTION scheduled_publications_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.generation < OLD.generation THEN
|
||||
RAISE EXCEPTION 'schedule generation cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.execution_version < OLD.execution_version THEN
|
||||
RAISE EXCEPTION 'schedule execution_version cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_scheduled_pub_guard BEFORE UPDATE ON scheduled_publications
|
||||
FOR EACH ROW EXECUTE FUNCTION scheduled_publications_guard();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Execution journal: one row per business attempt. Snapshot columns are
|
||||
-- immutable; mutable columns carry ownership (owner_token/claim_epoch/lease)
|
||||
-- and the dispatch marker.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE publishing_attempts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
schedule_id uuid NOT NULL,
|
||||
draft_id uuid NOT NULL,
|
||||
connection_id uuid NOT NULL,
|
||||
attempt_no bigint NOT NULL CHECK (attempt_no > 0),
|
||||
idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''),
|
||||
schedule_generation bigint NOT NULL CHECK (schedule_generation > 0),
|
||||
post_snapshot jsonb NOT NULL CHECK (jsonb_typeof(post_snapshot) = 'object'),
|
||||
post_hash text NOT NULL CHECK (btrim(post_hash) <> ''),
|
||||
scheduled_by_user_id uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
required_permission text NOT NULL
|
||||
CHECK (required_permission IN ('publication.schedule', 'publication.publish')),
|
||||
approved_revision bigint NOT NULL CHECK (approved_revision > 0),
|
||||
reviewer_user_id uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
credential_id uuid NOT NULL,
|
||||
credential_generation bigint NOT NULL CHECK (credential_generation > 0),
|
||||
authorized_epoch bigint NOT NULL,
|
||||
dispatch_count int NOT NULL DEFAULT 0 CHECK (dispatch_count >= 0 AND dispatch_count <= 5),
|
||||
status text NOT NULL DEFAULT 'ready'
|
||||
CHECK (status IN ('ready', 'in_progress', 'completed', 'failed', 'unknown', 'abandoned')),
|
||||
claim_epoch bigint NOT NULL DEFAULT 0 CHECK (claim_epoch >= 0),
|
||||
owner_token uuid,
|
||||
lease_until timestamptz,
|
||||
dispatch_started_at timestamptz,
|
||||
provider_retry_deadline timestamptz,
|
||||
error_code text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Ownership exists exactly while in progress.
|
||||
CHECK ((status = 'in_progress') = (owner_token IS NOT NULL AND lease_until IS NOT NULL)),
|
||||
CHECK (status = 'in_progress' OR (owner_token IS NULL AND lease_until IS NULL)),
|
||||
-- A dispatch marker implies at least one counted dispatch.
|
||||
CHECK (dispatch_started_at IS NULL OR dispatch_count > 0),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, draft_id, attempt_no),
|
||||
UNIQUE (workspace_id, idempotency_key),
|
||||
FOREIGN KEY (schedule_id, workspace_id) REFERENCES scheduled_publications (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (credential_id, workspace_id) REFERENCES credentials (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
-- Snapshot immutability, terminal statuses, and DB-owned claim epoch: every
|
||||
-- new owner token advances claim_epoch by exactly one, so a deposed owner's
|
||||
-- (owner_token, claim_epoch) pair can never match again.
|
||||
CREATE FUNCTION publishing_attempts_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (NEW.schedule_id, NEW.draft_id, NEW.connection_id, NEW.attempt_no, NEW.idempotency_key,
|
||||
NEW.schedule_generation, NEW.post_snapshot, NEW.post_hash, NEW.scheduled_by_user_id,
|
||||
NEW.required_permission, NEW.approved_revision, NEW.reviewer_user_id,
|
||||
NEW.credential_id, NEW.credential_generation, NEW.created_at)
|
||||
IS DISTINCT FROM
|
||||
(OLD.schedule_id, OLD.draft_id, OLD.connection_id, OLD.attempt_no, OLD.idempotency_key,
|
||||
OLD.schedule_generation, OLD.post_snapshot, OLD.post_hash, OLD.scheduled_by_user_id,
|
||||
OLD.required_permission, OLD.approved_revision, OLD.reviewer_user_id,
|
||||
OLD.credential_id, OLD.credential_generation, OLD.created_at) THEN
|
||||
RAISE EXCEPTION 'publishing attempt snapshot is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.status IN ('completed', 'abandoned') AND NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
RAISE EXCEPTION 'publishing attempt % is terminal', OLD.status USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.dispatch_count < OLD.dispatch_count THEN
|
||||
RAISE EXCEPTION 'dispatch_count cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.provider_retry_deadline IS DISTINCT FROM OLD.provider_retry_deadline
|
||||
AND OLD.provider_retry_deadline IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'provider_retry_deadline is fixed at first dispatch' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.owner_token IS NOT NULL AND NEW.owner_token IS DISTINCT FROM OLD.owner_token THEN
|
||||
NEW.claim_epoch := OLD.claim_epoch + 1;
|
||||
ELSE
|
||||
NEW.claim_epoch := OLD.claim_epoch;
|
||||
END IF;
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_publishing_attempt_ws_immutable BEFORE UPDATE ON publishing_attempts
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_publishing_attempt_guard BEFORE UPDATE ON publishing_attempts
|
||||
FOR EACH ROW EXECUTE FUNCTION publishing_attempts_guard();
|
||||
|
||||
-- Durable provider evidence, separate from the attempt so it survives activity
|
||||
-- timeouts and stale-owner completion refusal. Append-only.
|
||||
CREATE TABLE publishing_receipts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
attempt_id uuid NOT NULL,
|
||||
remote_id text NOT NULL CHECK (btrim(remote_id) <> '' AND length(remote_id) <= 512),
|
||||
remote_url text CHECK (remote_url IS NULL OR length(remote_url) <= 2048),
|
||||
published_at timestamptz,
|
||||
provider_metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_metadata) = 'object'),
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, attempt_id),
|
||||
FOREIGN KEY (attempt_id, workspace_id) REFERENCES publishing_attempts (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
-- Receipts are never rewritten. Deletion is only the workspace purge cascade
|
||||
-- (the parent workspace row is already gone when the cascade reaches here).
|
||||
CREATE FUNCTION publishing_receipts_append_only() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF EXISTS (SELECT 1 FROM workspaces w WHERE w.id = OLD.workspace_id) THEN
|
||||
RAISE EXCEPTION 'publishing receipts are append-only' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'publishing receipts are append-only' USING ERRCODE = '23514';
|
||||
END $$;
|
||||
CREATE TRIGGER trg_publishing_receipt_append_only BEFORE UPDATE OR DELETE ON publishing_receipts
|
||||
FOR EACH ROW EXECUTE FUNCTION publishing_receipts_append_only();
|
||||
|
||||
-- Draft-creation request idempotency, committed atomically with the draft.
|
||||
CREATE TABLE publishing_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
route text NOT NULL CHECK (btrim(route) <> ''),
|
||||
request_key uuid NOT NULL,
|
||||
request_hash text NOT NULL CHECK (btrim(request_hash) <> ''),
|
||||
response jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, user_id, route, request_key)
|
||||
);
|
||||
CREATE TRIGGER trg_publishing_request_ws_immutable BEFORE UPDATE ON publishing_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Draft fence: content changes rotate the revision and void approval; they
|
||||
-- are refused while a dispatch is active or its outcome is unknown.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE FUNCTION drafts_revision_fence() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.publication_attempt < OLD.publication_attempt THEN
|
||||
RAISE EXCEPTION 'publication_attempt cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.post IS DISTINCT FROM OLD.post THEN
|
||||
IF EXISTS (SELECT 1 FROM publishing_attempts a
|
||||
WHERE a.workspace_id = OLD.workspace_id AND a.draft_id = OLD.id
|
||||
AND (a.status = 'unknown'
|
||||
OR (a.status = 'in_progress' AND a.dispatch_started_at IS NOT NULL))) THEN
|
||||
RAISE EXCEPTION 'draft content is locked while a dispatch is unsettled' USING ERRCODE = '55000';
|
||||
END IF;
|
||||
NEW.revision := OLD.revision + 1;
|
||||
NEW.approved_revision := NULL;
|
||||
NEW.approved_hash := NULL;
|
||||
NEW.approved_at := NULL;
|
||||
NEW.reviewer_user_id := NULL;
|
||||
ELSE
|
||||
NEW.revision := OLD.revision;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_draft_revision_fence BEFORE UPDATE ON drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION drafts_revision_fence();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RLS + grants for the new tenant tables (001/006/017 style).
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE publishing_attempts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publishing_receipts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publishing_requests ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publishing_attempts FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publishing_receipts FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE publishing_requests FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON publishing_attempts
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON publishing_receipts
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
CREATE POLICY tenant_isolation ON publishing_requests
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
-- 003's default privileges grant all four verbs; narrow them explicitly:
|
||||
-- the journal is never deleted by the app, receipts are append-only.
|
||||
GRANT SELECT, INSERT, UPDATE ON publishing_attempts TO sp_app;
|
||||
REVOKE DELETE ON publishing_attempts FROM sp_app;
|
||||
GRANT SELECT, INSERT ON publishing_receipts TO sp_app;
|
||||
REVOKE UPDATE, DELETE ON publishing_receipts FROM sp_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON publishing_requests TO sp_app;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Indexes: keyset lists (no OFFSET), due/recovery discovery, FK coverage.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE INDEX drafts_ws_updated ON drafts (workspace_id, updated_at DESC, id DESC);
|
||||
CREATE INDEX drafts_ws_state_updated ON drafts (workspace_id, state, updated_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX scheduled_publications_ws_publish ON scheduled_publications (workspace_id, publish_at DESC, id DESC);
|
||||
CREATE INDEX scheduled_publications_ws_conn_publish
|
||||
ON scheduled_publications (workspace_id, connection_id, publish_at DESC, id DESC);
|
||||
CREATE INDEX scheduled_publications_next_due ON scheduled_publications (next_attempt_at, id)
|
||||
WHERE state = 'pending';
|
||||
CREATE INDEX scheduled_publications_ws_credential ON scheduled_publications (workspace_id, credential_id)
|
||||
WHERE credential_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX publications_ws_published ON publications (workspace_id, published_at DESC, id DESC);
|
||||
CREATE INDEX publications_ws_conn_published
|
||||
ON publications (workspace_id, connection_id, published_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX publishing_attempts_ws_schedule ON publishing_attempts (workspace_id, schedule_id, attempt_no DESC);
|
||||
CREATE INDEX publishing_attempts_ws_connection ON publishing_attempts (workspace_id, connection_id);
|
||||
CREATE INDEX publishing_attempts_ws_credential ON publishing_attempts (workspace_id, credential_id);
|
||||
CREATE INDEX publishing_attempts_lease_recovery ON publishing_attempts (lease_until, id)
|
||||
WHERE status = 'in_progress';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Discovery (replaces 006). Identifiers/times only, bounded, fixed
|
||||
-- search_path, PUBLIC revoked. Callers enter withTenant(workspace_id) and
|
||||
-- re-verify key/generation ownership under RLS.
|
||||
-- ---------------------------------------------------------------------------
|
||||
DROP FUNCTION publishing_due_schedules();
|
||||
|
||||
CREATE FUNCTION publishing_due_schedules(p_limit integer DEFAULT 100)
|
||||
RETURNS TABLE(workspace_id uuid, schedule_id uuid, idempotency_key text, generation bigint, publish_at timestamptz)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT due.workspace_id, due.schedule_id, due.idempotency_key, due.generation, due.publish_at
|
||||
FROM (
|
||||
SELECT sp.workspace_id, sp.id AS schedule_id, sp.idempotency_key, sp.generation,
|
||||
sp.publish_at, sp.next_attempt_at AS due_at
|
||||
FROM scheduled_publications sp
|
||||
WHERE sp.state = 'pending' AND sp.execution_version = 1 AND sp.next_attempt_at <= now()
|
||||
UNION ALL
|
||||
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
|
||||
sp.publish_at, a.lease_until AS due_at
|
||||
FROM publishing_attempts a
|
||||
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
|
||||
WHERE a.status = 'in_progress' AND a.lease_until < now()
|
||||
) due
|
||||
ORDER BY due.due_at, due.schedule_id
|
||||
LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 100)
|
||||
$$;
|
||||
|
||||
-- Attempt journal first (every executable key), then legacy schedules/publications.
|
||||
CREATE OR REPLACE FUNCTION publishing_schedule_workspace(p_idempotency_key text) RETURNS uuid
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT COALESCE(
|
||||
(SELECT a.workspace_id FROM publishing_attempts a WHERE a.idempotency_key = p_idempotency_key LIMIT 1),
|
||||
(SELECT sp.workspace_id FROM scheduled_publications sp WHERE sp.idempotency_key = p_idempotency_key),
|
||||
(SELECT p.workspace_id FROM publications p WHERE p.idempotency_key = p_idempotency_key)
|
||||
)
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION publishing_due_schedules(integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION publishing_schedule_workspace(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION publishing_due_schedules(integer), publishing_schedule_workspace(text) TO sp_app;
|
||||
103
apps/api/migrations/019_publishing_integration.sql
Normal file
103
apps/api/migrations/019_publishing_integration.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
-- 018 is retained unchanged: local anp_publishing_it records its application.
|
||||
-- These corrections use independently tracked resumable stages.
|
||||
-- wp3:phase expand transaction
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
ALTER TABLE publishing_attempts ADD COLUMN first_dispatch_started_at timestamptz;
|
||||
CREATE OR REPLACE FUNCTION publishing_attempts_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (NEW.schedule_id, NEW.draft_id, NEW.connection_id, NEW.attempt_no, NEW.idempotency_key,
|
||||
NEW.schedule_generation, NEW.post_snapshot, NEW.post_hash, NEW.scheduled_by_user_id,
|
||||
NEW.required_permission, NEW.approved_revision, NEW.reviewer_user_id,
|
||||
NEW.credential_id, NEW.credential_generation, NEW.created_at)
|
||||
IS DISTINCT FROM
|
||||
(OLD.schedule_id, OLD.draft_id, OLD.connection_id, OLD.attempt_no, OLD.idempotency_key,
|
||||
OLD.schedule_generation, OLD.post_snapshot, OLD.post_hash, OLD.scheduled_by_user_id,
|
||||
OLD.required_permission, OLD.approved_revision, OLD.reviewer_user_id,
|
||||
OLD.credential_id, OLD.credential_generation, OLD.created_at) THEN
|
||||
RAISE EXCEPTION 'publishing attempt snapshot is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.status IN ('completed', 'abandoned') AND NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
RAISE EXCEPTION 'publishing attempt % is terminal', OLD.status USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.first_dispatch_started_at IS NOT NULL AND NEW.first_dispatch_started_at IS DISTINCT FROM OLD.first_dispatch_started_at THEN
|
||||
RAISE EXCEPTION 'first dispatch time is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.dispatch_count < OLD.dispatch_count THEN
|
||||
RAISE EXCEPTION 'dispatch_count cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.provider_retry_deadline IS DISTINCT FROM OLD.provider_retry_deadline
|
||||
AND OLD.provider_retry_deadline IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'provider_retry_deadline is fixed at first dispatch' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.owner_token IS NOT NULL AND NEW.owner_token IS DISTINCT FROM OLD.owner_token THEN
|
||||
NEW.claim_epoch := OLD.claim_epoch + 1;
|
||||
ELSE
|
||||
NEW.claim_epoch := OLD.claim_epoch;
|
||||
END IF;
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION drafts_revision_fence() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.publication_attempt < OLD.publication_attempt THEN
|
||||
RAISE EXCEPTION 'publication_attempt cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.post IS DISTINCT FROM OLD.post THEN
|
||||
IF EXISTS (SELECT 1 FROM publishing_attempts a
|
||||
WHERE a.workspace_id = OLD.workspace_id AND a.draft_id = OLD.id
|
||||
AND (a.status = 'unknown'
|
||||
OR (a.status IN ('ready', 'in_progress') AND a.dispatch_started_at IS NOT NULL))) THEN
|
||||
RAISE EXCEPTION 'draft content is locked while a dispatch is unsettled' USING ERRCODE = '55000';
|
||||
END IF;
|
||||
NEW.revision := OLD.revision + 1;
|
||||
NEW.approved_revision := NULL;
|
||||
NEW.approved_hash := NULL;
|
||||
NEW.approved_at := NULL;
|
||||
NEW.reviewer_user_id := NULL;
|
||||
ELSE
|
||||
NEW.revision := OLD.revision;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
-- wp3:phase dispatch_backfill batch
|
||||
WITH batch AS (
|
||||
SELECT id FROM publishing_attempts WHERE first_dispatch_started_at IS NULL AND dispatch_count > 0 LIMIT 1000 FOR UPDATE
|
||||
)
|
||||
UPDATE publishing_attempts a SET first_dispatch_started_at = CASE WHEN a.dispatch_count > 1 OR a.dispatch_started_at IS NULL THEN a.created_at ELSE a.dispatch_started_at END FROM batch WHERE a.id = batch.id;
|
||||
-- wp3:phase receipt_recovery_index concurrent
|
||||
CREATE INDEX CONCURRENTLY publishing_receipts_recovery ON publishing_receipts (workspace_id, attempt_id, received_at);
|
||||
-- wp3:phase cutover transaction
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
DROP FUNCTION publishing_due_schedules(integer);
|
||||
CREATE FUNCTION publishing_due_schedules(p_limit integer DEFAULT 100)
|
||||
RETURNS TABLE(workspace_id uuid, schedule_id uuid, idempotency_key text, generation bigint, publish_at timestamptz, evidence_only boolean)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT due.workspace_id, due.schedule_id, due.idempotency_key, due.generation, due.publish_at, due.evidence_only
|
||||
FROM (
|
||||
SELECT sp.workspace_id, sp.id AS schedule_id, sp.idempotency_key, sp.generation,
|
||||
sp.publish_at, sp.next_attempt_at AS due_at, false AS evidence_only
|
||||
FROM scheduled_publications sp
|
||||
WHERE sp.state = 'pending' AND sp.execution_version = 1 AND sp.next_attempt_at <= now() AND NOT EXISTS (SELECT 1 FROM publishing_attempts a JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id WHERE a.idempotency_key = sp.idempotency_key AND a.workspace_id = sp.workspace_id)
|
||||
UNION ALL
|
||||
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
|
||||
sp.publish_at, a.lease_until AS due_at, false AS evidence_only
|
||||
FROM publishing_attempts a
|
||||
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
|
||||
WHERE a.status = 'in_progress' AND a.lease_until < now() AND NOT EXISTS (SELECT 1 FROM publishing_receipts r WHERE r.attempt_id = a.id AND r.workspace_id = a.workspace_id)
|
||||
UNION ALL
|
||||
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
|
||||
sp.publish_at, r.received_at AS due_at, true AS evidence_only
|
||||
FROM publishing_attempts a
|
||||
JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id
|
||||
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
|
||||
WHERE (a.status IN ('ready', 'failed', 'unknown') OR (a.status = 'in_progress' AND a.lease_until < now()))
|
||||
) due
|
||||
ORDER BY due.due_at, due.schedule_id
|
||||
LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 100)
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION publishing_due_schedules(integer) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION publishing_due_schedules(integer) TO sp_app;
|
||||
93
apps/api/migrations/020_analytics.sql
Normal file
93
apps/api/migrations/020_analytics.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
-- 020: WP3 analytics snapshots — monthly per-workspace rollups.
|
||||
--
|
||||
-- One row per (workspace, month), refreshed in place by the snapshot writer
|
||||
-- (apps/api/src/analytics/snapshot.service.ts) with set-based SQL and an
|
||||
-- idempotent upsert on (workspace_id, month). Months are materialized as
|
||||
-- range partitions of a `month date` key, mirroring 014_live_chat.sql.
|
||||
--
|
||||
-- Creating future partitions: RLS on the partitioned parent does NOT apply to
|
||||
-- direct leaf access, so every leaf carries its own ENABLE/FORCE RLS and
|
||||
-- tenant_isolation policy. `analytics_ensure_month(date)` creates a month's
|
||||
-- partition idempotently with those controls; this migration precreates the
|
||||
-- previous, current and next month. Administrators run
|
||||
-- SELECT analytics_ensure_month(date '2025-09-01');
|
||||
-- ahead of each new month (cron it monthly, one statement, no downtime). The
|
||||
-- DEFAULT partition below catches any month whose partition was not created,
|
||||
-- so a missed call never drops data — it only loses pruning until the
|
||||
-- partition is created and the rows moved into it.
|
||||
--
|
||||
-- Partitioned-leaf register entry (proposal §4.4, exception (a)): PK carries
|
||||
-- the partition key, the table is never the target of an FK, and rows are
|
||||
-- referenced only by (workspace_id, month) / id soft links.
|
||||
--
|
||||
-- Query catalog (owning WP: WP3 — packages/db does not exist in this repo, so
|
||||
-- the catalog entries live here and in snapshot.service.ts):
|
||||
-- analytics.snapshot.refresh INSERT .. ON CONFLICT (workspace_id, month) —
|
||||
-- served by the PK (workspace_id, month, id); write path, p95 <= 250 ms.
|
||||
-- analytics.snapshot.list keyset page ORDER BY month DESC — same PK,
|
||||
-- backward index scan, p95 <= 20 ms.
|
||||
-- analytics.snapshot.summary two PK point lookups (current + previous
|
||||
-- month), p95 <= 20 ms.
|
||||
CREATE TABLE analytics_snapshots (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Always a month start; the CHECK keeps the value aligned with its partition.
|
||||
month date NOT NULL CHECK (month = date_trunc('month', month)::date),
|
||||
publications_total bigint NOT NULL DEFAULT 0 CHECK (publications_total >= 0),
|
||||
-- {provider: count} for publications published in the month.
|
||||
publications_by_provider jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(publications_by_provider) = 'object'),
|
||||
posts_archived_total bigint NOT NULL DEFAULT 0 CHECK (posts_archived_total >= 0),
|
||||
-- Point-in-time current state at snapshot time (not a monthly delta):
|
||||
-- connections not disconnected when the snapshot was taken.
|
||||
active_connections int NOT NULL DEFAULT 0 CHECK (active_connections >= 0),
|
||||
drafts_created_total bigint NOT NULL DEFAULT 0 CHECK (drafts_created_total >= 0),
|
||||
publishing_attempts_total bigint NOT NULL DEFAULT 0 CHECK (publishing_attempts_total >= 0),
|
||||
-- Attempts whose terminal status is 'failed', counted by their creation month.
|
||||
publish_failures_total bigint NOT NULL DEFAULT 0 CHECK (publish_failures_total >= 0),
|
||||
snapshot_started_at timestamptz NOT NULL DEFAULT now(),
|
||||
snapshot_completed_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, month, id),
|
||||
UNIQUE (workspace_id, month)
|
||||
) PARTITION BY RANGE (month);
|
||||
|
||||
-- Idempotent: safe to re-run for the same month.
|
||||
CREATE FUNCTION analytics_ensure_month(p_month date) RETURNS void
|
||||
LANGUAGE plpgsql SET search_path = public AS $$
|
||||
DECLARE v_start date := date_trunc('month', p_month)::date;
|
||||
v_table text := 'analytics_snapshots_' || to_char(v_start, 'YYYYMM');
|
||||
BEGIN
|
||||
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF analytics_snapshots FOR VALUES FROM (%L) TO (%L)',
|
||||
v_table, v_start, (v_start + interval '1 month')::date);
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', v_table);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', v_table);
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = v_table AND policyname = 'tenant_isolation') THEN
|
||||
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid)', v_table);
|
||||
END IF;
|
||||
END $$;
|
||||
REVOKE ALL ON FUNCTION analytics_ensure_month(date) FROM PUBLIC;
|
||||
|
||||
SELECT analytics_ensure_month((current_date - interval '1 month')::date);
|
||||
SELECT analytics_ensure_month(current_date);
|
||||
SELECT analytics_ensure_month((current_date + interval '1 month')::date);
|
||||
|
||||
-- Safety net for months nobody pre-created (see header).
|
||||
CREATE TABLE analytics_snapshots_default PARTITION OF analytics_snapshots DEFAULT;
|
||||
ALTER TABLE analytics_snapshots_default ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE analytics_snapshots_default FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON analytics_snapshots_default
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
CREATE TRIGGER trg_analytics_snapshot_ws_immutable BEFORE UPDATE ON analytics_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE analytics_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE analytics_snapshots FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON analytics_snapshots
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
-- Grants on the parent cover all current and future partitions for queries
|
||||
-- routed through it (014 style); direct leaf access also inherits from the
|
||||
-- owner's default privileges (003).
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON analytics_snapshots TO sp_app;
|
||||
52
apps/api/migrations/021_stream_columns.sql
Normal file
52
apps/api/migrations/021_stream_columns.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-- 021: editable multi-platform streams columns — column definitions (WP3 §1.2, P0a).
|
||||
-- stream_columns only: the resolved-source / ingest / fan-out / budget tables
|
||||
-- (column_sources, feed_items, feed_item_columns, provider_budget_ledger) land
|
||||
-- in their own forward-only migrations so each slice stays independently
|
||||
-- reviewable. Additive: no existing table is touched.
|
||||
--
|
||||
-- `spec` and `refresh_policy` are JSONB blobs validated app-side by
|
||||
-- @tactical-media-broadcast-hub/core (stream-columns.ts); invalid specs are rejected before
|
||||
-- insert and never stored. The CHECKs here are only coarse backstops — the
|
||||
-- authoritative typed validation lives in the app layer (017-style split).
|
||||
CREATE TABLE stream_columns (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
type text NOT NULL CHECK (type IN ('platform_feed','keyword','account_list','saved_search')),
|
||||
title text NOT NULL CHECK (char_length(title) <= 80), -- user-editable; app layer enforces too
|
||||
position integer NOT NULL DEFAULT 0 CHECK (position >= 0), -- dense rank; reorder rewrites the tail
|
||||
spec jsonb NOT NULL CHECK (jsonb_typeof(spec) = 'object'),
|
||||
refresh_policy jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(refresh_policy) = 'object'),
|
||||
created_by uuid NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz -- soft delete keeps feed_items FK-stable (§1.2)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_stream_columns_ws_immutable BEFORE UPDATE ON stream_columns
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
-- Same fail-closed tenant isolation as every tenant table (001/006/017 style):
|
||||
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
|
||||
ALTER TABLE stream_columns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE stream_columns FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON stream_columns
|
||||
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 stream_columns TO sp_app;
|
||||
|
||||
-- Dense column order per workspace (UI chips, seeding, reorder). Partial on
|
||||
-- deleted_at IS NULL: soft-deleted columns keep their row (FK-stable items)
|
||||
-- without reserving their slot, so reorder/seeding only fights live rows.
|
||||
-- Named per design §1.2; lazy seeding relies on it for idempotence (§1.3).
|
||||
CREATE UNIQUE INDEX stream_columns_ws_order_uq
|
||||
ON stream_columns (workspace_id, position)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Workspace listing (GET /feeds/columns), newest-position reads served by the
|
||||
-- unique index above; this covers soft-deleted lookups and count queries in
|
||||
-- the partial-index style of workspace_invite_links_expiry (017).
|
||||
CREATE INDEX stream_columns_ws_idx
|
||||
ON stream_columns (workspace_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
136
apps/api/migrations/022_live_destinations.sql
Normal file
136
apps/api/migrations/022_live_destinations.sql
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
-- 022: WP6 live-stream destinations + stream outputs (go-live slice).
|
||||
--
|
||||
-- Aggregate boundary (proposal §4.4, WP6/WP9): a `live_stream_destinations`
|
||||
-- row owns the platform-side live target — provider, ingest endpoint URL and
|
||||
-- the vaulted ingest credential — and never references media outputs. A
|
||||
-- `stream_outputs` row is a media-output fan-out from a virtual channel and
|
||||
-- references EITHER a destination (credential resolved through the
|
||||
-- destination at spawn) OR its own generic credential handle — never both
|
||||
-- (CHECK below, mirrored in packages/core/src/vc.ts and API validation).
|
||||
-- Enablement lives only on StreamOutput; the destination carries no
|
||||
-- enabled flag of its own, so "disable one output" never mutates the shared
|
||||
-- platform target.
|
||||
--
|
||||
-- FK actions per the WP6 lifecycle rule: both cross-aggregate FKs are
|
||||
-- ON DELETE NO ACTION ON UPDATE RESTRICT; deletion is app-driven — 409 while
|
||||
-- a referencing output is enabled, otherwise (disabled output) the app
|
||||
-- disables dependents, revokes the ingest credential, then deletes the row.
|
||||
-- `credentials` gains the tenant-composite key this migration needs
|
||||
-- (tenant-composite FKs everywhere, per proposal §4.4/§4.5).
|
||||
--
|
||||
-- Query catalog (owning WP: WP6 — packages/db does not exist in this repo, so
|
||||
-- the catalog lives here and in apps/api/src/live/live-destination.repository.ts):
|
||||
-- live.destinations.list keyset page ORDER BY label — served by
|
||||
-- live_destinations_ws_label; read path, p95 <= 20 ms.
|
||||
-- live.destinations.usage point lookup of referencing outputs by
|
||||
-- destination (tenant) — served by stream_outputs_destination partial
|
||||
-- index; read path, p95 <= 20 ms.
|
||||
-- live.outputs.channel channel fan-out list — served by
|
||||
-- stream_outputs_channel; read path, p95 <= 20 ms.
|
||||
BEGIN;
|
||||
|
||||
-- 001 created credentials with only a surrogate PK; 018 added the
|
||||
-- tenant-composite key the cross-aggregate FK below requires.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'credentials_id_workspace_key') THEN
|
||||
ALTER TABLE credentials ADD CONSTRAINT credentials_id_workspace_key UNIQUE (id, workspace_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE live_stream_destinations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
label text NOT NULL CHECK (btrim(label) <> '' AND length(label) <= 120),
|
||||
-- Provider of the platform-side target (manifest id of a connector); the
|
||||
-- app resolves behaviour through the connector registry, never by branching
|
||||
-- on this value.
|
||||
provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64),
|
||||
-- Ingest endpoint (rtmp/rtmps/srt). Validated app-side by the shared
|
||||
-- media-control endpoint shape validator before insert; the CHECKs here are
|
||||
-- coarse backstops only.
|
||||
endpoint_url text NOT NULL
|
||||
CHECK (endpoint_url ~ '^(rtmp|rtmps|srt)://' AND length(endpoint_url) <= 2048),
|
||||
-- Ingest credential (stream key etc.) vaulted in `credentials`; NULL only
|
||||
-- for providers with no key. Tenant-composite FK; deleting the credential
|
||||
-- row before the destination is a FK error by design (lifecycle revokes in
|
||||
-- order: destination first, then its credential).
|
||||
credential_id uuid,
|
||||
-- Derived, explicitly-stale platform state (runtime truth is Redis obs:*).
|
||||
last_platform_state text NOT NULL DEFAULT 'unknown'
|
||||
CHECK (last_platform_state IN ('unknown', 'created', 'waiting', 'live', 'ended')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (credential_id, workspace_id) REFERENCES credentials (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_live_dest_ws_immutable BEFORE UPDATE ON live_stream_destinations
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
-- Same fail-closed tenant isolation as every tenant table (001/021 style):
|
||||
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
|
||||
ALTER TABLE live_stream_destinations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE live_stream_destinations FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON live_stream_destinations
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
-- Per-workspace listing ordered by label (live.destinations.list).
|
||||
CREATE INDEX live_destinations_ws_label ON live_stream_destinations (workspace_id, label);
|
||||
|
||||
-- Media-output fan-out of a virtual channel onto one live target.
|
||||
CREATE TABLE stream_outputs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
label text NOT NULL CHECK (btrim(label) <> '' AND length(label) <= 120),
|
||||
-- Output profile reference; resolution is app-side.
|
||||
profile text NOT NULL DEFAULT '720p30'
|
||||
CHECK (profile IN ('720p30', '1080p30', '1080p60')),
|
||||
mode text NOT NULL DEFAULT 'passthrough' CHECK (mode IN ('passthrough', 'normalize')),
|
||||
-- Enablement lives ONLY here (WP9): a disabled output stops pushing but the
|
||||
-- destination row and its credential survive untouched.
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Exactly one of (destination_id, credential_handle_id): destination-
|
||||
-- referenced outputs resolve the ingest credential through the destination
|
||||
-- at spawn; generic outputs carry their own opaque vault handle. The
|
||||
-- NOT-VALID backstop below is validated app-side (021-style split).
|
||||
destination_id uuid,
|
||||
credential_handle_id text,
|
||||
CONSTRAINT stream_outputs_target_exclusive CHECK (
|
||||
(destination_id IS NULL) <> (credential_handle_id IS NULL)
|
||||
),
|
||||
CONSTRAINT stream_outputs_handle_shape CHECK (
|
||||
credential_handle_id IS NULL OR credential_handle_id ~ '^vh_[A-Za-z0-9_-]{16,128}$'
|
||||
),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, virtual_channel_id, id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (destination_id, workspace_id) REFERENCES live_stream_destinations (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_stream_outputs_ws_immutable BEFORE UPDATE ON stream_outputs
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
ALTER TABLE stream_outputs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE stream_outputs FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON stream_outputs
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
-- References by destination (live.destinations.usage, 409 on delete).
|
||||
CREATE INDEX stream_outputs_destination ON stream_outputs (workspace_id, destination_id)
|
||||
WHERE destination_id IS NOT NULL;
|
||||
-- Channel fan-out listing (live.outputs.channel).
|
||||
CREATE INDEX stream_outputs_channel ON stream_outputs (workspace_id, virtual_channel_id);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON live_stream_destinations TO sp_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON stream_outputs TO sp_app;
|
||||
|
||||
COMMIT;
|
||||
160
apps/api/migrations/023_virtual_channel_import.sql
Normal file
160
apps/api/migrations/023_virtual_channel_import.sql
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
-- 023: WP7 bulk source import + live-settings guard.
|
||||
--
|
||||
-- Three additive pieces, one migration:
|
||||
--
|
||||
-- (1) `virtual_channel_sources` (013) is widened to admit *imported* sources.
|
||||
-- 013's CHECK only modelled hand-entered HLS URLs and local playlists; a
|
||||
-- PeerTube channel/live imported in bulk carries `remote_ref` +
|
||||
-- `connection_id` and has an HLS URL only once a live is resolvable. The
|
||||
-- dedupe key UNIQUE (workspace_id, virtual_channel_id, remote_ref) from
|
||||
-- 013 is unchanged and stays the only dedupe authority: imports write
|
||||
-- INSERT ... ON CONFLICT DO NOTHING RETURNING id.
|
||||
--
|
||||
-- (2) `virtual_channel_imports`: one row per bulk import request. The HTTP
|
||||
-- request only parses + validates + creates the job row; resolution and
|
||||
-- inserts run in the background, and the row is the progress/report
|
||||
-- document (processed/total + imported/duplicate/invalid/failed counts
|
||||
-- and a bounded per-entry outcome list). A `running` row whose heartbeat
|
||||
-- (`updated_at`) went stale is reaped to `abandoned` on read, so a
|
||||
-- process crash never leaves a job spinning forever.
|
||||
--
|
||||
-- (3) `virtual_channel_live_settings`: the channel's live output settings
|
||||
-- (profile/resolution, latency, replay policy) behind an optimistic
|
||||
-- concurrency `version`. Writers take the channel row FOR UPDATE (one
|
||||
-- writer per channel), then CAS on `version`; a stale version or a
|
||||
-- started live is a 409 with nothing written. The CHECKs mirror the
|
||||
-- app-side validator (WP7 acceptance: permanentLive + saveReplay and an
|
||||
-- out-of-range latencyMode are rejected by the DB on a direct insert
|
||||
-- too), and a trigger keeps `version` monotone.
|
||||
--
|
||||
-- House style throughout: tenant-composite keys (id, workspace_id), composite
|
||||
-- FKs that pin every child to one workspace's channel, reject_workspace_change
|
||||
-- / reject_owner_change immutability, FORCE RLS with the NULLIF tenant policy,
|
||||
-- grants to sp_app (003 adds the default-privilege side for future tables).
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
-- (1) Imported sources: kind 'hls' now requires an HLS URL *or* a remote_ref
|
||||
-- (a channel imported now, playback attached when its live starts). The
|
||||
-- playlist arm is untouched: local, connectionless, remote_ref-less.
|
||||
ALTER TABLE virtual_channel_sources DROP CONSTRAINT virtual_channel_sources_check;
|
||||
ALTER TABLE virtual_channel_sources ADD CONSTRAINT virtual_channel_sources_check CHECK (
|
||||
(
|
||||
kind = 'hls' AND playlist_loop IS NULL
|
||||
AND (hls_url IS NOT NULL OR remote_ref IS NOT NULL)
|
||||
) OR (
|
||||
kind = 'playlist' AND hls_url IS NULL AND remote_ref IS NULL
|
||||
AND connection_id IS NULL AND playlist_loop IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- (2) Import jobs.
|
||||
CREATE TABLE virtual_channel_imports (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
-- Connection the import resolves through (default host for bare handles).
|
||||
connection_id uuid,
|
||||
status text NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running', 'completed', 'failed', 'abandoned')),
|
||||
-- total counts every input row (valid or not); processed advances per row.
|
||||
total int NOT NULL DEFAULT 0 CHECK (total >= 0 AND total <= 5000),
|
||||
processed int NOT NULL DEFAULT 0 CHECK (processed >= 0 AND processed <= total),
|
||||
imported int NOT NULL DEFAULT 0 CHECK (imported >= 0),
|
||||
duplicate int NOT NULL DEFAULT 0 CHECK (duplicate >= 0),
|
||||
invalid int NOT NULL DEFAULT 0 CHECK (invalid >= 0),
|
||||
failed int NOT NULL DEFAULT 0 CHECK (failed >= 0),
|
||||
-- Bounded per-entry report (first IMPORT_REPORT_ENTRY_LIMIT rows): what the
|
||||
-- user pasted, what happened, why. Counts are always authoritative.
|
||||
entries jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(entries) = 'array'),
|
||||
error text,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
UNIQUE (id, workspace_id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_vci_ws_immutable BEFORE UPDATE ON virtual_channel_imports
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_vci_owner_immutable BEFORE UPDATE ON virtual_channel_imports
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id');
|
||||
|
||||
ALTER TABLE virtual_channel_imports ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE virtual_channel_imports FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON virtual_channel_imports
|
||||
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
|
||||
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
|
||||
|
||||
CREATE INDEX virtual_channel_imports_channel
|
||||
ON virtual_channel_imports (workspace_id, virtual_channel_id, created_at DESC);
|
||||
|
||||
-- (3) Channel live settings, version-fenced.
|
||||
CREATE TABLE virtual_channel_live_settings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL,
|
||||
virtual_channel_id uuid NOT NULL,
|
||||
-- Output shape: a named preset, or an explicit triple (custom).
|
||||
output_profile text NOT NULL DEFAULT '720p30'
|
||||
CHECK (output_profile IN ('720p30', '1080p30', '1080p60', 'custom')),
|
||||
width int CHECK (width IS NULL OR (width >= 16 AND width <= 7680 AND width % 2 = 0)),
|
||||
height int CHECK (height IS NULL OR (height >= 16 AND height <= 4320 AND height % 2 = 0)),
|
||||
fps int CHECK (fps IS NULL OR (fps >= 1 AND fps <= 120)),
|
||||
-- LiveVideoLatencyMode: 1=Default, 2=High latency, 3=Small latency.
|
||||
latency_mode int NOT NULL DEFAULT 1 CHECK (latency_mode IN (1, 2, 3)),
|
||||
permanent_live boolean NOT NULL DEFAULT false,
|
||||
save_replay boolean NOT NULL DEFAULT false,
|
||||
-- Replay privacy etc.; only meaningful while save_replay is on.
|
||||
replay_settings jsonb CHECK (replay_settings IS NULL OR jsonb_typeof(replay_settings) = 'object'),
|
||||
-- Latch: once the channel's live has started, settings are frozen (409).
|
||||
live_started_at timestamptz,
|
||||
-- Optimistic concurrency: every accepted write bumps version exactly once.
|
||||
version bigint NOT NULL DEFAULT 0 CHECK (version >= 0),
|
||||
updated_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, virtual_channel_id),
|
||||
UNIQUE (id, workspace_id),
|
||||
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
CHECK (
|
||||
(output_profile <> 'custom' AND width IS NULL AND height IS NULL AND fps IS NULL)
|
||||
OR (output_profile = 'custom' AND width IS NOT NULL AND height IS NOT NULL AND fps IS NOT NULL)
|
||||
),
|
||||
-- PeerTube rejects saveReplay + permanentLive (400); we refuse it earlier
|
||||
-- and the DB refuses it for direct inserts.
|
||||
CHECK (NOT (permanent_live AND save_replay)),
|
||||
CHECK (save_replay OR replay_settings IS NULL)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_vcls_ws_immutable BEFORE UPDATE ON virtual_channel_live_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
CREATE TRIGGER trg_vcls_owner_immutable BEFORE UPDATE ON virtual_channel_live_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id');
|
||||
|
||||
-- version never regresses; a stale CAS writer cannot rewind the fence.
|
||||
CREATE FUNCTION reject_live_settings_version_regression() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.version < OLD.version THEN
|
||||
RAISE EXCEPTION 'live settings version cannot decrease' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_vcls_version_monotone BEFORE UPDATE ON virtual_channel_live_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_live_settings_version_regression();
|
||||
|
||||
ALTER TABLE virtual_channel_live_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE virtual_channel_live_settings FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON virtual_channel_live_settings
|
||||
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 virtual_channel_imports, virtual_channel_live_settings TO sp_app;
|
||||
477
apps/api/migrations/024_chatgov.sql
Normal file
477
apps/api/migrations/024_chatgov.sql
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
-- 024: chat governance spine — chatgov design §2, P0 slice (glm).
|
||||
--
|
||||
-- Tables: polls, poll_votes, posting_rules, posting_policy_snapshots,
|
||||
-- topic_bindings, chat_command_audit, chat_identities, gate_decisions.
|
||||
-- poll_mirrors (card edit bookkeeping) is deliberately NOT here: it belongs to
|
||||
-- the P5 mirror slice and lands in its own forward migration.
|
||||
--
|
||||
-- Design notes (chatgov-design.md §0/§1/§2):
|
||||
-- * Polls are OURS, not Telegram's (D1): ballots live in poll_votes, the
|
||||
-- Telegram card is only a mirror. `polls.rule` is the compiled §1a object
|
||||
-- snapshotted at open and never re-resolved mid-flight; `policy_version`
|
||||
-- is its deterministic hash pinned at open and at every decision.
|
||||
-- * One open gate per draft (partial unique index) stops poll spam gating
|
||||
-- the same draft twice; a revote is a NEW poll with parent_poll_id.
|
||||
-- * posting_rules are data with precedence topic > content_type > account >
|
||||
-- workspace (D4). Only ONE live rule per (scope, account, topic,
|
||||
-- content_type) slot: the partial NULLS NOT DISTINCT index ignores
|
||||
-- superseded rows so a revision frees its slot without losing lineage.
|
||||
-- Effective policies are compiled at SAVE time into
|
||||
-- posting_policy_snapshots (whole-block replacement, no field merges).
|
||||
-- * topic_bindings are OUR registry of (connection, chat, thread) — the Bot
|
||||
-- API cannot list topics, so what we created here is authoritative (D3).
|
||||
-- `message_thread_id IS NULL` means the channel root / whole group, so the
|
||||
-- slot key needs NULLS NOT DISTINCT.
|
||||
-- * chat_identities key on the immutable numeric provider user id — never a
|
||||
-- username (§6). /register creates `pending`; console approval flips to
|
||||
-- `approved`; nothing self-elevates.
|
||||
-- * chat_command_audit records EVERY command attempt including rejects
|
||||
-- (rate_limited, unauthorized, unknown_command). UNIQUE (workspace,
|
||||
-- provider, update_id) is the replay killer for Telegram updates.
|
||||
-- * gate_decisions: one row per destination per gate evaluation (E1/E2/E3,
|
||||
-- D8 partial fan-out is a first-class outcome). `inputs` carries the
|
||||
-- quorum state at close; reason codes are machine-readable (§4).
|
||||
--
|
||||
-- Retention classes (WP2 convention; no registry table exists yet, so this
|
||||
-- comment is the registration): poll_votes, chat_command_audit and
|
||||
-- gate_decisions are append-hot and are monthly-partition candidates once a
|
||||
-- partition manager lands (014/020 style leaves). polls, posting_rules,
|
||||
-- posting_policy_snapshots, topic_bindings and chat_identities stay
|
||||
-- unpartitioned — they are FK parents / small current-state tables.
|
||||
--
|
||||
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow 001/014/021
|
||||
-- conventions: no app.workspace_id => NULL => no rows; FORCE binds sp_app too.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- topic_bindings
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE topic_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Telegram connection (one bot token per connection).
|
||||
connection_id uuid NOT NULL,
|
||||
-- Numeric -100… chat id preferred (usernames drift). Text, not bigint:
|
||||
-- provider-neutral and stable across platforms.
|
||||
chat_id text NOT NULL CHECK (chat_id ~ '^-?[0-9]{1,32}$'),
|
||||
-- Telegram message_thread_id; NULL = channel root / whole group.
|
||||
message_thread_id text CHECK (message_thread_id IS NULL OR message_thread_id ~ '^[0-9]{1,32}$'),
|
||||
binding_type text NOT NULL CHECK (binding_type IN ('announce', 'control', 'audit')),
|
||||
-- Telegram topic name limit is 128.
|
||||
name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 128),
|
||||
-- One of the 7 fixed Telegram icon colors (7322096, 16766592, 13338331,
|
||||
-- 9367192, 16749490, 16478047, 8914859).
|
||||
icon_color integer CHECK (icon_color IS NULL OR icon_color IN
|
||||
(7322096, 16766592, 13338331, 9367192, 16749490, 16478047, 8914859)),
|
||||
state text NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'closed', 'archived')),
|
||||
-- Accounts (connections) that post into this topic. Array members cannot be
|
||||
-- FK-enforced in PostgreSQL; the app layer validates they belong to the same
|
||||
-- workspace (chatgov policy-service).
|
||||
account_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
-- Control bindings: the virtual channel the commands act on.
|
||||
linked_channel_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE NULLS NOT DISTINCT (workspace_id, connection_id, chat_id, message_thread_id),
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (linked_channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_topic_bindings_ws_immutable BEFORE UPDATE ON topic_bindings
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE topic_bindings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE topic_bindings FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON topic_bindings
|
||||
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 topic_bindings TO sp_app;
|
||||
|
||||
-- One control binding per virtual channel (design §2): two control topics
|
||||
-- commanding the same channel would race the director.
|
||||
CREATE UNIQUE INDEX topic_bindings_one_control
|
||||
ON topic_bindings (workspace_id, linked_channel_id)
|
||||
WHERE binding_type = 'control' AND linked_channel_id IS NOT NULL;
|
||||
-- Topic screen grid + gateway binding resolution (chat_id + thread -> row).
|
||||
CREATE INDEX topic_bindings_ws_conn ON topic_bindings (workspace_id, connection_id, state);
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- polls
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE polls (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Live-chat channel the poll is scoped to (nullable: console-only poll).
|
||||
channel_id uuid,
|
||||
-- Vote scoped to a subchat when set (topic binding), else the whole chat.
|
||||
topic_binding_id uuid,
|
||||
subject_kind text NOT NULL CHECK (subject_kind IN ('draft', 'chat_post', 'none')),
|
||||
-- Set when subject_kind = 'draft'. Drafts are the only gating subject (D5).
|
||||
draft_id uuid,
|
||||
question text NOT NULL CHECK (char_length(question) BETWEEN 1 AND 300),
|
||||
-- Revote lineage: a re-vote is a NEW poll pointing at the closed one.
|
||||
parent_poll_id uuid,
|
||||
-- Compiled §1a pass-criteria object, snapshotted at open (never re-resolved).
|
||||
rule jsonb NOT NULL CHECK (jsonb_typeof(rule) = 'object'),
|
||||
-- Deterministic hash of `rule` at open (pinned on every decision too).
|
||||
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
|
||||
-- named | anonymous, extracted from `rule` for queryability. Anonymous polls
|
||||
-- store NO voter linkage: poll_votes rows are written for named mode only
|
||||
-- (§6 privacy; anonymous sentiment rides native Telegram polls in P7).
|
||||
anonymity text NOT NULL DEFAULT 'named' CHECK (anonymity IN ('named', 'anonymous')),
|
||||
state text NOT NULL DEFAULT 'open'
|
||||
CHECK (state IN ('open', 'passed', 'failed', 'vetoed', 'quorum_unreachable', 'cancelled')),
|
||||
opens_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- opens_at + rule.timebox.duration.
|
||||
closes_at timestamptz NOT NULL,
|
||||
closed_at timestamptz,
|
||||
close_reason text CHECK (close_reason IN ('quorum_early', 'timebox', 'veto', 'cancelled')),
|
||||
-- Exactly-once close claim: the state flip and its downstream trigger share
|
||||
-- one transaction guarded by this column (P2 poll-service claims it).
|
||||
outbox_state text NOT NULL DEFAULT 'none' CHECK (outbox_state IN ('none', 'claimed', 'done')),
|
||||
created_by uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
CHECK ((state = 'open') = (closed_at IS NULL)),
|
||||
CHECK (close_reason IS NULL OR state <> 'open'),
|
||||
FOREIGN KEY (channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (topic_binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_polls_ws_immutable BEFORE UPDATE ON polls
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE polls ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE polls FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON polls
|
||||
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 polls TO sp_app;
|
||||
|
||||
-- One open gate per draft (design §2): a second open poll on the same draft is
|
||||
-- refused by the index, not by application discipline.
|
||||
CREATE UNIQUE INDEX polls_open_one_per_draft ON polls (workspace_id, draft_id)
|
||||
WHERE state = 'open' AND draft_id IS NOT NULL;
|
||||
-- Close sweep + overlay listing ("closing soon" chips).
|
||||
CREATE INDEX polls_ws_state ON polls (workspace_id, state, closes_at);
|
||||
-- Revote lineage walks (blocked queue "open re-vote" action).
|
||||
CREATE INDEX polls_ws_parent ON polls (workspace_id, parent_poll_id) WHERE parent_poll_id IS NOT NULL;
|
||||
|
||||
-- Self-reference after the table exists (avoids a forward declaration).
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_parent_fk
|
||||
FOREIGN KEY (parent_poll_id, workspace_id) REFERENCES polls (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- poll_votes
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE poll_votes (
|
||||
poll_id uuid NOT NULL,
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
voter_kind text NOT NULL CHECK (voter_kind IN ('user', 'chat_identity', 'system')),
|
||||
-- Resolved workspace user, when known (named mode).
|
||||
voter_user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
-- provider:from.id (numeric for Telegram). Idempotency key half.
|
||||
voter_remote_id text NOT NULL CHECK (btrim(voter_remote_id) <> ''),
|
||||
voter_handle text NOT NULL CHECK (btrim(voter_handle) <> ''),
|
||||
ballot text NOT NULL CHECK (ballot IN ('yes', 'no', 'veto', 'abstain')),
|
||||
-- Eligibility AT CAST (informational; eligibility is re-read at close).
|
||||
eligible boolean NOT NULL DEFAULT true,
|
||||
-- Ineligible votes are stored, never counted (D9 / GitLab pattern).
|
||||
counted boolean NOT NULL DEFAULT true,
|
||||
-- INELIGIBLE_ACTOR | SELF_APPROVAL_DENIED | ...
|
||||
ineligible_reason text,
|
||||
cast_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (poll_id, voter_remote_id),
|
||||
FOREIGN KEY (poll_id, workspace_id) REFERENCES polls (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_poll_votes_ws_immutable BEFORE UPDATE ON poll_votes
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE poll_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE poll_votes FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON poll_votes
|
||||
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 poll_votes TO sp_app;
|
||||
|
||||
-- Tally reads (quorum evaluation on every ballot) hit counted votes only.
|
||||
CREATE INDEX poll_votes_poll ON poll_votes (poll_id, counted);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- posting_rules
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE posting_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Precedence (D4): topic > content_type > account > workspace.
|
||||
scope text NOT NULL CHECK (scope IN ('workspace', 'account', 'content_type', 'topic')),
|
||||
-- Connection id (the policy anchor is the ACCOUNT, platform is an attribute).
|
||||
-- NULL for workspace-scope rules and workspace-wide content_type rules.
|
||||
account_id uuid,
|
||||
-- Set when scope = 'topic'.
|
||||
topic_binding_id uuid,
|
||||
-- 'alert' | 'video' | ... when scope = 'content_type'.
|
||||
content_type text,
|
||||
-- §1a-shaped rule object minus the subject; validated app-side by
|
||||
-- apps/api/src/chatgov/policy.ts before insert (021-style split: the CHECKs
|
||||
-- here are only coarse backstops).
|
||||
policy jsonb NOT NULL CHECK (jsonb_typeof(policy) = 'object'),
|
||||
-- Compiled snapshot hash at save (deterministic; identical content shares
|
||||
-- a version, so a re-save of the same policy is a no-op version-wise).
|
||||
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
|
||||
-- Shadow mode: evaluate + log, never block/fire (POLICY_DRY_RUN).
|
||||
dry_run boolean NOT NULL DEFAULT false,
|
||||
created_by uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Revision chain hook (P6 rules editor): a superseded row no longer holds
|
||||
-- its scope slot (see the partial unique index below) but keeps lineage.
|
||||
superseded_by uuid,
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (account_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (topic_binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_posting_rules_ws_immutable BEFORE UPDATE ON posting_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE posting_rules ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE posting_rules FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON posting_rules
|
||||
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 posting_rules TO sp_app;
|
||||
|
||||
-- ONE live rule per scope slot. NULLS NOT DISTINCT because "no account" /
|
||||
-- "no topic" / "no content type" is itself a slot value (workspace default,
|
||||
-- channel root); a plain UNIQUE would allow unbounded duplicates there.
|
||||
-- Partial on superseded_by IS NULL so a revision frees the slot.
|
||||
CREATE UNIQUE INDEX posting_rules_live_uq
|
||||
ON posting_rules (workspace_id, scope, account_id, topic_binding_id, content_type)
|
||||
NULLS NOT DISTINCT
|
||||
WHERE superseded_by IS NULL;
|
||||
-- Rules-editor listing and per-destination resolution scans.
|
||||
CREATE INDEX posting_rules_ws_scope ON posting_rules (workspace_id, scope, account_id, topic_binding_id)
|
||||
WHERE superseded_by IS NULL;
|
||||
|
||||
ALTER TABLE posting_rules ADD CONSTRAINT posting_rules_superseded_fk
|
||||
FOREIGN KEY (superseded_by, workspace_id) REFERENCES posting_rules (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- chat_identities
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE chat_identities (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
provider text NOT NULL CHECK (btrim(provider) <> ''),
|
||||
-- Immutable numeric provider user id (Telegram from.id) — NEVER a username
|
||||
-- (§6: usernames are never trusted for authorization).
|
||||
remote_user_id text NOT NULL CHECK (remote_user_id ~ '^[0-9]{1,20}$'),
|
||||
-- Linked workspace user once /register is approved in the console.
|
||||
user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
display_name text,
|
||||
-- §1a admins-arm eligibility: membership role ∈ {Owner, Admin} OR this
|
||||
-- override ≥ that role (evaluated at CLOSE time — TOCTOU guard). NULL = the
|
||||
-- linked membership role governs. Written only by chatgov.manage holders.
|
||||
role_override text CHECK (role_override IS NULL OR role_override IN ('Moderator', 'Editor', 'Publisher', 'Approver', 'Admin', 'Owner')),
|
||||
-- /register creates pending; approval is a console action (chatgov.manage),
|
||||
-- never self-service.
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'banned')),
|
||||
approved_by uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, provider, remote_user_id),
|
||||
-- Approval must name its approver.
|
||||
CHECK (status <> 'approved' OR approved_by IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_chat_identities_ws_immutable BEFORE UPDATE ON chat_identities
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE chat_identities ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE chat_identities FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON chat_identities
|
||||
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_identities TO sp_app;
|
||||
|
||||
-- Gateway authorization path: numeric from.id -> identity -> membership role.
|
||||
CREATE UNIQUE INDEX chat_identities_ws_user
|
||||
ON chat_identities (workspace_id, provider, user_id) WHERE user_id IS NOT NULL AND status = 'approved';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- chat_command_audit
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE chat_command_audit (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Telegram update id (or provider event id): the replay killer.
|
||||
update_id bigint NOT NULL CHECK (update_id > 0),
|
||||
-- Topic binding the command arrived on (NULL: unbound chat — itself a
|
||||
-- reject reason, NO_CHANNEL_BOUND).
|
||||
binding_id uuid,
|
||||
provider text NOT NULL CHECK (btrim(provider) <> ''),
|
||||
remote_user_id text NOT NULL CHECK (btrim(remote_user_id) <> ''),
|
||||
-- Resolved identity's workspace user, if any.
|
||||
user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
command text NOT NULL CHECK (btrim(command) <> ''),
|
||||
args jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(args) = 'object'),
|
||||
-- RBAC permission the command required (NULL for /help, /register).
|
||||
permission text,
|
||||
-- Every attempt is audited, including rejects (§6).
|
||||
decision text NOT NULL CHECK (decision IN ('accepted', 'rejected', 'unauthorized', 'unknown_command', 'rate_limited')),
|
||||
-- Machine code: INELIGIBLE_ACTOR | LOCKED_CHANNEL | CONFIRM_EXPIRED | NO_CHANNEL_BOUND | ...
|
||||
reason text,
|
||||
-- Confirmation nonce when confirm-gated (P4).
|
||||
nonce text,
|
||||
-- Chat nonce reused as the director Idempotency-Key (P3/P4).
|
||||
idempotency_key text,
|
||||
-- Receipt fingerprint / poll id / draft id.
|
||||
result_ref jsonb CHECK (result_ref IS NULL OR jsonb_typeof(result_ref) = 'object'),
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, provider, update_id),
|
||||
FOREIGN KEY (binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_chat_command_audit_ws_immutable BEFORE UPDATE ON chat_command_audit
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE chat_command_audit ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE chat_command_audit FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON chat_command_audit
|
||||
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_command_audit TO sp_app;
|
||||
|
||||
CREATE INDEX chat_command_audit_ws_time ON chat_command_audit (workspace_id, received_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- posting_policy_snapshots — effective policy resolved at SAVE time (D4).
|
||||
-- Evaluation reads ONLY snapshots; rules are re-compiled on every rule change.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE posting_policy_snapshots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
destination_kind text NOT NULL CHECK (destination_kind IN ('account', 'topic')),
|
||||
-- The account (connection) the destination posts through; topic snapshots
|
||||
-- carry their owning connection here too.
|
||||
account_id uuid NOT NULL,
|
||||
-- NULL = the account root (whole account, no topic override).
|
||||
topic_binding_id uuid,
|
||||
-- '' = the destination's default (no content-type override); content_type
|
||||
-- rules compile one snapshot per type so E3 can re-check by (account,
|
||||
-- [topic], contentType) without re-resolving precedence at dispatch time.
|
||||
content_type text NOT NULL DEFAULT '' CHECK (content_type ~ '^[a-z0-9_.-]{0,40}$'),
|
||||
-- Compiled §1a object. Immutable per policy_version: a rule change writes a
|
||||
-- NEW version; decisions pin the version they evaluated.
|
||||
effective jsonb NOT NULL CHECK (jsonb_typeof(effective) = 'object'),
|
||||
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
|
||||
-- Audit: which scopes/rules contributed to this resolution.
|
||||
resolved_from jsonb NOT NULL CHECK (jsonb_typeof(resolved_from) = 'object'),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- NULLS NOT DISTINCT: topic_binding_id NULL is its own slot (account root).
|
||||
-- The design sketch's PK omitted content_type; the column is kept in the
|
||||
-- slot key so a content_type rule cannot silently overwrite the account's
|
||||
-- default snapshot (whole-block replacement, one row per resolved slot).
|
||||
UNIQUE NULLS NOT DISTINCT (workspace_id, destination_kind, account_id, topic_binding_id, content_type),
|
||||
FOREIGN KEY (account_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (topic_binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_posting_policy_snapshots_ws_immutable BEFORE UPDATE ON posting_policy_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE posting_policy_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE posting_policy_snapshots FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON posting_policy_snapshots
|
||||
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 posting_policy_snapshots TO sp_app;
|
||||
|
||||
-- E3 pre-dispatch re-check reads by (workspace, account[, topic][, contentType]).
|
||||
CREATE INDEX posting_policy_snapshots_ws_acct
|
||||
ON posting_policy_snapshots (workspace_id, account_id, updated_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- gate_decisions — one row per destination per gate evaluation (D8).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE gate_decisions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
draft_id uuid NOT NULL,
|
||||
-- {platform, accountId, topicBindingId?} — the destination this row is about.
|
||||
destination jsonb NOT NULL CHECK (jsonb_typeof(destination) = 'object'),
|
||||
gate text NOT NULL CHECK (gate IN ('E1', 'E2', 'E3')),
|
||||
-- Which posting rule fired (NULL when the fail-closed default applied).
|
||||
rule_id uuid,
|
||||
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
|
||||
outcome text NOT NULL CHECK (outcome IN ('fired', 'blocked', 'pending')),
|
||||
-- Approvals, poll tally, quorum state at close (arm-by-arm got/needed).
|
||||
inputs jsonb NOT NULL CHECK (jsonb_typeof(inputs) = 'object'),
|
||||
-- INSUFFICIENT_QUORUM | INELIGIBLE_ACTOR | SELF_APPROVAL_DENIED | VETOED |
|
||||
-- TIMEBOX_EXPIRED_AUTO_REJECT | RESET_ON_CHANGE | PENDING_POLL |
|
||||
-- OUTSIDE_POLICY | POLICY_DRY_RUN | OVERRIDDEN_BY_OWNER | ...
|
||||
reason_codes text[] NOT NULL DEFAULT '{}'::text[],
|
||||
-- 'system:gate-e3' | 'user:<id>' | 'chat_poll:<pollId>'
|
||||
actor text NOT NULL CHECK (btrim(actor) <> ''),
|
||||
-- E2 rows carry the poll whose outcome this decision persists.
|
||||
poll_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (rule_id, workspace_id) REFERENCES posting_rules (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (poll_id, workspace_id) REFERENCES polls (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_gate_decisions_ws_immutable BEFORE UPDATE ON gate_decisions
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE gate_decisions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE gate_decisions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON gate_decisions
|
||||
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 gate_decisions TO sp_app;
|
||||
|
||||
-- Blocked queue + draft timeline (P1) read by (workspace, draft, gate).
|
||||
CREATE INDEX gate_decisions_draft ON gate_decisions (workspace_id, draft_id, gate);
|
||||
-- Poll outcome lookup from the blocked queue's "open re-vote" action.
|
||||
CREATE INDEX gate_decisions_poll ON gate_decisions (workspace_id, poll_id) WHERE poll_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RBAC: chatgov permissions, seeded in the 002 pattern (ON CONFLICT DO NOTHING).
|
||||
-- chatgov.manage = rules editor, topic registry, identity approval, policy
|
||||
-- compile — Owner/Admin. chatgov.vote = open/cast governance votes — the
|
||||
-- content + broadcast roles. One statement => one authz-epoch bump (018).
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT v.role, v.permission
|
||||
FROM (VALUES
|
||||
('Owner', 'chatgov.manage'),
|
||||
('Owner', 'chatgov.vote'),
|
||||
('Admin', 'chatgov.manage'),
|
||||
('Admin', 'chatgov.vote'),
|
||||
('Publisher', 'chatgov.vote'),
|
||||
('Editor', 'chatgov.vote'),
|
||||
('Approver', 'chatgov.vote'),
|
||||
('Moderator', 'chatgov.vote'),
|
||||
('BroadcastOperator', 'chatgov.vote')
|
||||
) AS v(role, permission)
|
||||
ON CONFLICT DO NOTHING;
|
||||
55
apps/api/migrations/025_archive_search.sql
Normal file
55
apps/api/migrations/025_archive_search.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
-- 025: WP15 archive full-text search (firehose research recommendation:
|
||||
-- Postgres FTS phase-1) over archived_feed_items.
|
||||
--
|
||||
-- Search surface:
|
||||
-- * tsvector over text_content + channel/attribution metadata, kept in sync
|
||||
-- by trigger (the normalizer writes text_content on insert AND on edits,
|
||||
-- so a plain trigger beats a generated column: 015's reject_workspace_change
|
||||
-- trigger guards workspace_id, and edits must reindex text).
|
||||
-- * GIN indexes: tsvector lookup + pg_trgm similarity for typo tolerance.
|
||||
-- * The API ORs websearch_to_tsquery matching with pg_trgm similarity so a
|
||||
-- misspelled term still matches via trigram distance (similarity >= 0.3).
|
||||
-- * RLS is untouched: archived_feed_items keeps its tenant_isolation policy
|
||||
-- from 015; search runs inside withTenant() so FORCE RLS binds honestly.
|
||||
--
|
||||
-- CREATE EXTENSION requires privileges sp_app lacks; this migration runs as
|
||||
-- the table owner only (015 grants are preserved below for completeness).
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
ALTER TABLE archived_feed_items ADD COLUMN search_vector tsvector NOT NULL DEFAULT '';
|
||||
|
||||
-- String-valued attribution keys worth indexing as metadata (normalizer maps
|
||||
-- Telegram author/origin fields here). Kept narrow on purpose.
|
||||
CREATE FUNCTION archived_attribution_text(attribution jsonb)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT coalesce(string_agg(value, ' '), '')
|
||||
FROM jsonb_each_text(attribution)
|
||||
WHERE key IN ('channelUsername', 'channelTitle', 'author', 'authorSignature',
|
||||
'forwardFromChat', 'forwardFromUser', 'signature')
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION archived_feed_items_search_vector() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.channel_id, '')), 'A')
|
||||
|| setweight(to_tsvector('simple', archived_attribution_text(NEW.attribution)), 'B')
|
||||
|| to_tsvector('simple', coalesce(NEW.text_content, ''));
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_archived_feed_items_search
|
||||
BEFORE INSERT OR UPDATE OF text_content, channel_id, attribution
|
||||
ON archived_feed_items FOR EACH ROW
|
||||
EXECUTE FUNCTION archived_feed_items_search_vector();
|
||||
|
||||
-- Exact/lexical lookups. 'simple' config on purpose: the corpus is a
|
||||
-- multilingual firehose, no stemming dictionary is "right", so phase-1 is
|
||||
-- exact term matching plus trigram fuzziness.
|
||||
CREATE INDEX archived_feed_items_search ON archived_feed_items USING gin (search_vector);
|
||||
|
||||
-- Fuzzy path (typo tolerance) + unindexed-corpus ILIKE support on text.
|
||||
CREATE INDEX archived_feed_items_text_trgm ON archived_feed_items USING gin (text_content gin_trgm_ops);
|
||||
CREATE INDEX archived_feed_items_channel_trgm ON archived_feed_items USING gin (channel_id gin_trgm_ops);
|
||||
|
||||
GRANT SELECT ON archived_feed_items TO sp_app;
|
||||
72
apps/api/migrations/025_chatgov_p2.sql
Normal file
72
apps/api/migrations/025_chatgov_p2.sql
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
-- P2 competitive short-post variants. 022/023/024 exist. The runner keys
|
||||
-- migrations by filename; 025_archive_search.sql belongs to another slice.
|
||||
ALTER TABLE polls
|
||||
ADD COLUMN kind text NOT NULL DEFAULT 'yes_no' CHECK (kind IN ('yes_no', 'variant_selection')),
|
||||
ADD COLUMN winner_option_id uuid,
|
||||
ADD COLUMN draft_revision bigint,
|
||||
ADD COLUMN content_kind text NOT NULL DEFAULT 'short_post' CHECK (content_kind IN ('short_post', 'document')),
|
||||
ADD COLUMN eligible_user_ids uuid[];
|
||||
-- document is a schema seam only; P2 accepts short_post exclusively.
|
||||
ALTER TABLE polls DROP CONSTRAINT polls_state_check;
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_state_check CHECK
|
||||
(state IN ('open', 'passed', 'failed', 'vetoed', 'quorum_unreachable', 'cancelled', 'stale'));
|
||||
ALTER TABLE polls DROP CONSTRAINT polls_close_reason_check;
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_close_reason_check CHECK
|
||||
(close_reason IN ('quorum_early', 'timebox', 'veto', 'cancelled', 'quorum_unreachable', 'reset_on_change'));
|
||||
|
||||
CREATE TABLE poll_options (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
poll_id uuid NOT NULL,
|
||||
seq integer NOT NULL CHECK (seq > 0),
|
||||
-- Future document revision chains can anchor here. Short posts use the
|
||||
-- poll's numeric draft_revision plus this immutable text snapshot.
|
||||
draft_revision_id uuid,
|
||||
text text NOT NULL CHECK (char_length(text) BETWEEN 1 AND 5000),
|
||||
submitted_by uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
submitted_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
UNIQUE (poll_id, seq),
|
||||
UNIQUE (id, poll_id, workspace_id),
|
||||
FOREIGN KEY (poll_id, workspace_id) REFERENCES polls(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
CREATE TRIGGER trg_poll_options_ws_immutable BEFORE UPDATE ON poll_options
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
-- Artifacts cannot be edited or individually removed, even by SQL callers.
|
||||
-- Cascading workspace/poll retention remains possible.
|
||||
CREATE FUNCTION chatgov_option_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' OR pg_trigger_depth() = 1 THEN
|
||||
RAISE EXCEPTION 'poll options are immutable' USING ERRCODE = '55000';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_poll_options_immutable BEFORE UPDATE OR DELETE ON poll_options
|
||||
FOR EACH ROW EXECUTE FUNCTION chatgov_option_immutable();
|
||||
ALTER TABLE poll_options ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE poll_options FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON poll_options
|
||||
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 poll_options TO sp_app;
|
||||
|
||||
ALTER TABLE poll_votes ADD COLUMN option_id uuid;
|
||||
ALTER TABLE poll_votes ADD CONSTRAINT poll_votes_option_fk
|
||||
FOREIGN KEY (option_id, poll_id, workspace_id) REFERENCES poll_options(id, poll_id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
ALTER TABLE poll_votes ADD CONSTRAINT poll_votes_option_ballot_check CHECK (option_id IS NULL OR ballot = 'yes');
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_winner_option_fk
|
||||
FOREIGN KEY (winner_option_id, id, workspace_id) REFERENCES poll_options(id, poll_id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_winner_state_check CHECK
|
||||
(winner_option_id IS NULL OR (kind = 'variant_selection' AND state = 'passed'));
|
||||
ALTER TABLE poll_votes ADD COLUMN voter_identity_id uuid REFERENCES chat_identities(id) ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
ALTER TABLE polls ADD COLUMN final_evaluation jsonb;
|
||||
-- Narrow expiry discovery follows the publishing due-schedule house pattern:
|
||||
-- returns references only, never ballots/content. Mutations still use RLS.
|
||||
CREATE FUNCTION chatgov_due_polls() RETURNS TABLE(workspace_id uuid, id uuid)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||
SELECT p.workspace_id, p.id FROM polls p WHERE p.state = 'open' AND p.closes_at <= now()
|
||||
ORDER BY p.closes_at LIMIT 500
|
||||
$$;
|
||||
REVOKE ALL ON FUNCTION chatgov_due_polls() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION chatgov_due_polls() TO sp_app;
|
||||
230
apps/api/migrations/026_columns_p1.sql
Normal file
230
apps/api/migrations/026_columns_p1.sql
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
-- 026: columns P1 — persistence + RBAC (WP3 §1.2/§3, §6 P1 row).
|
||||
--
|
||||
-- (025 was taken by WP15 archive search; this file is the columns slice.)
|
||||
--
|
||||
-- Three things land here, in 024's many-tables-one-file style:
|
||||
--
|
||||
-- 1. The resolved-source / ingest / fan-out tables 021 deliberately left
|
||||
-- for their own slice: column_sources, feed_items, feed_item_columns.
|
||||
-- (provider_budget_ledger is P2b/P5 territory — no ledger caller exists
|
||||
-- yet, so the table is not created here.)
|
||||
-- 2. host_budgets — the shared per-host egress budget table owned by
|
||||
-- @tactical-media-broadcast-hub/egress (its migrations/001_host_budgets.sql, applied
|
||||
-- verbatim with credit; the egress package never migrates at runtime and
|
||||
-- expects the application to apply and ledger it). The columns scheduler
|
||||
-- is the first app-side caller of HostBudgets that does not go through
|
||||
-- the whole egressFetch client, so the table must exist before P1's
|
||||
-- poll loop draws from it. IF NOT EXISTS keeps this idempotent if a
|
||||
-- later egress-integration migration also carries it.
|
||||
-- 3. The `streams.columns.manage` RBAC seed (design §1.4/§0.5): write
|
||||
-- permission on columns, granted to Owner/Admin/Editor by default.
|
||||
-- Reads stay on `draft.view`.
|
||||
--
|
||||
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the
|
||||
-- 001/014/021/024 conventions: no app.workspace_id => NULL => no rows; FORCE
|
||||
-- binds sp_app too. `spec`/`refresh_policy` stay validated app-side by
|
||||
-- @tactical-media-broadcast-hub/core (021 note); the CHECKs here are coarse backstops.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- column_sources — what the scheduler actually polls for a column (§1.2)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE column_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
column_id uuid NOT NULL REFERENCES stream_columns(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Identity of the thing polled (manifest id of a connector).
|
||||
provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64),
|
||||
-- NULL for keyless sources (YouTube RSS, public Bluesky/Mastodon) — those
|
||||
-- draw from the per-host budget only (§1.2 "Keyless sources").
|
||||
connection_id uuid,
|
||||
source_kind text NOT NULL CHECK (source_kind IN ('home','mentions','search','author')),
|
||||
-- Platform-adapted query (post-adaptation, what gets sent); NULL for
|
||||
-- home/mentions/author sources.
|
||||
query text,
|
||||
-- Canonical account ref for source_kind='author': '{provider}:{handle@host}'.
|
||||
actor_ref text,
|
||||
state text NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending','ready','stale','error','unsupported','rate_limited','budget_paused','disabled')),
|
||||
last_error text,
|
||||
last_polled_at timestamptz,
|
||||
next_poll_at timestamptz,
|
||||
consecutive_failures integer NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT,
|
||||
-- One source row per (column, polled identity); NULLs equal NULLs so a
|
||||
-- keyless search source dedupes against itself (024 NULLS NOT DISTINCT
|
||||
-- style). The SAME identity in two different columns is intentional (§3.3:
|
||||
-- following the same account in two columns costs one poll) — the due
|
||||
-- source scan collapses those rows into one poll and fans items out to
|
||||
-- every column sharing the identity.
|
||||
UNIQUE NULLS NOT DISTINCT (workspace_id, column_id, provider, source_kind, connection_id, query, actor_ref)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_column_sources_ws_immutable BEFORE UPDATE ON column_sources
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE column_sources ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE column_sources FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON column_sources
|
||||
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 column_sources TO sp_app;
|
||||
|
||||
-- Due-source scan (§3.4): workspace + next_poll_at with the disabled rows
|
||||
-- partial-indexed away. Design §1.2 names this column_sources_due_idx on
|
||||
-- (next_poll_at); workspace leads here because every scan is tenant-scoped
|
||||
-- under RLS and the composite keeps each workspace's due window contiguous.
|
||||
CREATE INDEX column_sources_due_idx
|
||||
ON column_sources (workspace_id, next_poll_at)
|
||||
WHERE state <> 'disabled';
|
||||
|
||||
-- Column fan-out listing (ColumnView.sources) and per-column refresh.
|
||||
CREATE INDEX column_sources_column_idx ON column_sources (column_id);
|
||||
|
||||
-- Connection → sources (the degenerate platform_feed lookup in the scheduler
|
||||
-- and the /feeds per-connection store fallback).
|
||||
CREATE INDEX column_sources_connection_idx
|
||||
ON column_sources (connection_id)
|
||||
WHERE connection_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- feed_items — ingested items (§1.2): persisted depth for keyset pagination
|
||||
-- and cross-column dedup; Redis stays the hot read-through cache in front.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE feed_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- sha256(workspace_id | provider | canonical_remote_id) — see §3.3; the
|
||||
-- canonicalization rule is app-side, the key is opaque to the DB.
|
||||
dedup_key text NOT NULL CHECK (dedup_key <> ''),
|
||||
provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64),
|
||||
remote_id text NOT NULL CHECK (remote_id <> ''),
|
||||
-- Connector-reported FeedItem.id; first writer wins, kept stable so a
|
||||
-- cursor minted from the hot cache still tie-breaks identically when the
|
||||
-- same page is later served from the store.
|
||||
item_id text NOT NULL CHECK (item_id <> ''),
|
||||
author_handle text NOT NULL CHECK (author_handle <> ''),
|
||||
-- Canonical '{provider}:{handle@host}' when resolvable (P0c feedAuthorRef).
|
||||
author_ref text,
|
||||
-- Provider permalink; absolute credential-free http(s) only (P0c parseSafeHttpUrl).
|
||||
url text CHECK (url IS NULL OR url ~ '^https?://'),
|
||||
-- Provider timestamp — the deterministic keyset sort key.
|
||||
created_at_remote timestamptz NOT NULL,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- safe-content typed rich-text AST. NEVER raw HTML (§5.1) — the app layer
|
||||
-- produces this through normalizeFeedItem; the CHECK only backstops shape.
|
||||
content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'),
|
||||
-- Derived flat text (astToPlainText) for saved-search filtering/previews.
|
||||
plain_text text NOT NULL,
|
||||
-- [{kind:'image'|'video', proxyUrl, ...}] — proxy-minted only (§5.3).
|
||||
media jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media) = 'array'),
|
||||
-- Finite numbers only (validated at ingest).
|
||||
metrics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metrics) = 'object'),
|
||||
source_id uuid NOT NULL,
|
||||
-- ToS expiry (YouTube Data-API items: fetched_at + 30d); sweeper is P2c.
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, dedup_key),
|
||||
UNIQUE (id, workspace_id),
|
||||
FOREIGN KEY (source_id, workspace_id) REFERENCES column_sources (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_feed_items_ws_immutable BEFORE UPDATE ON feed_items
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE feed_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE feed_items FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON feed_items
|
||||
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 feed_items TO sp_app;
|
||||
|
||||
-- Keyset serving: GET /feeds/columns/:id/items pages (created_at_remote DESC,
|
||||
-- dedup_key DESC) over this index (§1.4 "Cursor rule").
|
||||
CREATE INDEX feed_items_ws_time_idx
|
||||
ON feed_items (workspace_id, created_at_remote DESC, dedup_key DESC);
|
||||
|
||||
-- Store backfill for /feeds per connection and per-source item listing.
|
||||
CREATE INDEX feed_items_source_idx ON feed_items (source_id, created_at_remote DESC);
|
||||
|
||||
-- Expiry sweeper (P2c) pre-indexed here so the later slice adds no DDL.
|
||||
CREATE INDEX feed_items_expiry_idx ON feed_items (expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- feed_item_columns — which columns show an item (§1.2 table 4). An item
|
||||
-- ingested for a source identity is visible to every column sharing that
|
||||
-- identity; rows are written by poll-time fan-out with ON CONFLICT DO NOTHING.
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- stream_columns (021) has only its surrogate PK; migrations < 025 are frozen,
|
||||
-- so the tenant-composite keys the FKs below need are added here — additive
|
||||
-- unique indexes on supersets of existing keys, so no uniqueness changes.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS stream_columns_id_workspace_key
|
||||
ON stream_columns (id, workspace_id);
|
||||
|
||||
CREATE TABLE feed_item_columns (
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
item_id uuid NOT NULL,
|
||||
column_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, column_id),
|
||||
FOREIGN KEY (item_id, workspace_id) REFERENCES feed_items (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
FOREIGN KEY (column_id, workspace_id) REFERENCES stream_columns (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_feed_item_columns_ws_immutable BEFORE UPDATE ON feed_item_columns
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE feed_item_columns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE feed_item_columns FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON feed_item_columns
|
||||
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, DELETE ON feed_item_columns TO sp_app;
|
||||
|
||||
-- Serving index: the items endpoint joins column_id → item_id before the
|
||||
-- keyset filter; the PK alone would force an item_id-ordered probe.
|
||||
CREATE INDEX feed_item_columns_column_idx ON feed_item_columns (column_id, item_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- host_budgets — applied verbatim from @tactical-media-broadcast-hub/egress
|
||||
-- migrations/001_host_budgets.sql (WP7/WP8). Infrastructure state shared by
|
||||
-- all egress callers; deliberately NOT tenant/RLS scoped (one IDNA lowercase
|
||||
-- host:port key across callers and host classes — the whole point is that
|
||||
-- monitor, feeds and any other caller of the same host share one bucket).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS host_budgets (
|
||||
host text PRIMARY KEY CHECK (host <> '' AND host = lower(host)),
|
||||
capacity double precision NOT NULL CHECK (capacity >= 1 AND capacity < 'Infinity'::double precision),
|
||||
-- Tokens per fixed 10-second interval (fractional tokens support continuous refill).
|
||||
refill_per_interval double precision NOT NULL CHECK (refill_per_interval > 0 AND refill_per_interval < 'Infinity'::double precision),
|
||||
tokens double precision NOT NULL CHECK (tokens >= 0 AND tokens <= capacity),
|
||||
-- A future timestamp parks the bucket: no refill/debit until this deadline.
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp() CHECK (isfinite(updated_at))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE host_budgets IS 'Infrastructure egress budgets; one IDNA lowercase host:effective-port key across callers and host classes. Retain while host is in use.';
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON host_budgets TO sp_app;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RBAC seed: streams.columns.manage (design §1.4 — granted to Owner/Admin/
|
||||
-- Editor by default; reads stay on draft.view). 002's unnest + ON CONFLICT
|
||||
-- pattern so the seed is re-runnable and never fights a later role edit.
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO role_permissions (role, permission)
|
||||
SELECT 'Owner', 'streams.columns.manage'
|
||||
UNION ALL SELECT 'Admin', 'streams.columns.manage'
|
||||
UNION ALL SELECT 'Editor', 'streams.columns.manage'
|
||||
ON CONFLICT DO NOTHING;
|
||||
186
apps/api/migrations/027_intake.sql
Normal file
186
apps/api/migrations/027_intake.sql
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
-- 027: content intake firehose phase 1 (WP15+ firehose research §1/§2/§4/§5.3,
|
||||
-- decision record docs/decisions/2026-10-09-intake-posture.md — Tier-1
|
||||
-- sanctioned sources only, metadata-first).
|
||||
--
|
||||
-- NOTE on the number: this slice was specced as "026_intake.sql (next free)"
|
||||
-- but 025_archive_search and 026_columns_p1 already exist, so intake lands as
|
||||
-- 027 — the next free slot at authoring time.
|
||||
--
|
||||
-- Two tables:
|
||||
--
|
||||
-- intake_sources the per-workspace subscription registry (§4.4 sketch,
|
||||
-- tenant-scoped per the phase-1 dedup note in §2.4: dedup
|
||||
-- happens WITHIN a workspace; the global item registry +
|
||||
-- claims split is the phase-1.5 upgrade).
|
||||
-- intake_items the harvested rows. Dedup is CONTENT-ADDRESSED:
|
||||
-- UNIQUE (workspace_id, content_hash) where content_hash is
|
||||
-- sha256(normalized plain text + media origin identity), so
|
||||
-- the same statement arriving via RSS and a Mastodon boost
|
||||
-- (or the same URL watched twice) stores once. Storage-level
|
||||
-- dedup stays in the S3 content-addressed layer.
|
||||
--
|
||||
-- `content` is the P0c safe-content rich-text AST — the ONLY render source;
|
||||
-- adapters never hand raw provider HTML through (apps/api/src/feeds/
|
||||
-- feed-normalize.ts funnels every item). `text` is the derived flat text
|
||||
-- (astToPlainText) that FTS + pg_trgm index.
|
||||
--
|
||||
-- FTS mirrors 025's design: 'simple' config (multilingual firehose, no
|
||||
-- stemming dictionary is "right"), websearch_to_tsquery in the API ORed with
|
||||
-- pg_trgm similarity for typo tolerance; tsvector maintained by trigger
|
||||
-- because edits/telegram revisions must reindex.
|
||||
--
|
||||
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the
|
||||
-- 001/015/026 conventions: no app.workspace_id => NULL => no rows; FORCE
|
||||
-- binds sp_app too. CREATE EXTENSION requires privileges sp_app lacks; this
|
||||
-- migration runs as the table owner only (025 note).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- intake_sources
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE intake_sources (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
-- Tier-1 sanctioned intake kinds (decision record). bluesky_keyword is a
|
||||
-- stub seam in this slice — the Jetstream consumer is a later slice, so the
|
||||
-- scheduler parks those rows in health='unsupported' rather than polling.
|
||||
kind text NOT NULL CHECK (kind IN ('rss','mastodon_tag','mastodon_account','bluesky_keyword',
|
||||
'telegram_channel','peertube_channel','youtube_channel','web_watch')),
|
||||
-- Per-kind identity: url (rss/web_watch), tag (mastodon_tag), instance +
|
||||
-- accountId (mastodon_account), keywords[] (bluesky_keyword), channelId
|
||||
-- (telegram_channel / youtube_channel), handle + instance (peertube_channel).
|
||||
-- Validated app-side per kind; the DB only backstops "is an object".
|
||||
params jsonb NOT NULL CHECK (jsonb_typeof(params) = 'object'),
|
||||
-- sha256 of the canonically serialized params — the stable identity half of
|
||||
-- UNIQUE (workspace_id, kind, params_hash). Canonicalization is app-side.
|
||||
params_hash text NOT NULL CHECK (params_hash ~ '^[0-9a-f]{64}$'),
|
||||
-- 0 = push-only (telegram webhook bridge, bluesky Jetstream): never due.
|
||||
poll_interval_sec integer NOT NULL DEFAULT 300 CHECK (poll_interval_sec >= 0),
|
||||
-- Adapter carry: etag / lastModified / lastContentHash / lastRemoteRef.
|
||||
state jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(state) = 'object'),
|
||||
health text NOT NULL DEFAULT 'active'
|
||||
CHECK (health IN ('active','backoff','error','paused','unsupported')),
|
||||
last_error text,
|
||||
last_polled_at timestamptz,
|
||||
next_poll_at timestamptz,
|
||||
consecutive_failures integer NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, id),
|
||||
UNIQUE (workspace_id, kind, params_hash)
|
||||
);
|
||||
|
||||
CREATE TRIGGER trg_intake_sources_ws_immutable BEFORE UPDATE ON intake_sources
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE intake_sources ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE intake_sources FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON intake_sources
|
||||
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 intake_sources TO sp_app;
|
||||
|
||||
-- Due-source scan (the scheduler's loop): workspace + next_poll_at with the
|
||||
-- non-pollable rows partial-indexed away.
|
||||
CREATE INDEX intake_sources_due_idx
|
||||
ON intake_sources (workspace_id, next_poll_at)
|
||||
WHERE health NOT IN ('paused','unsupported');
|
||||
|
||||
-- Listing / "which sources feed this item".
|
||||
CREATE INDEX intake_sources_kind_idx ON intake_sources (workspace_id, kind);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- intake_items
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE intake_items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
source_id uuid NOT NULL,
|
||||
-- Platform-native id for the source (guid, status id, yt:video:…, tg chat:id,
|
||||
-- url#hash for web_watch diffs).
|
||||
remote_ref text NOT NULL CHECK (btrim(remote_ref) <> '' AND length(remote_ref) <= 2048),
|
||||
-- sha256(NFKC + whitespace-collapsed AST plain text + sorted media origin
|
||||
-- identity). THE dedup key: UNIQUE (workspace_id, content_hash).
|
||||
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
|
||||
published_at timestamptz NOT NULL,
|
||||
-- Flat text derived from `content` at ingest (astToPlainText) — the FTS and
|
||||
-- pg_trgm corpus. `text` is non-reserved; kept as the specced column name.
|
||||
text text NOT NULL DEFAULT '',
|
||||
-- P0c safe-content rich-text AST (doc/paragraph/inline). NEVER raw HTML.
|
||||
content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'),
|
||||
-- [{kind, proxyUrl}…] for pull adapters (feed-normalize proxied media) or
|
||||
-- [{kind, storageKey, …}] for the Telegram archive bridge (content-addressed
|
||||
-- S3 keys already harvested by apps/api/src/archive).
|
||||
media_refs jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media_refs) = 'array'),
|
||||
-- {authorHandle, authorRef, url, title, provider, …kind extras}
|
||||
attribution jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(attribution) = 'object'),
|
||||
fetched_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (id, workspace_id),
|
||||
-- Content-addressed dedup (spec §2.4 layer 2): first writer wins, every
|
||||
-- later delivery of the same content no-ops.
|
||||
UNIQUE (workspace_id, content_hash),
|
||||
FOREIGN KEY (source_id, workspace_id) REFERENCES intake_sources (id, workspace_id)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
-- Maintained by trigger below; declared here so the column exists for the
|
||||
-- index + trigger DDL in one place.
|
||||
ALTER TABLE intake_items ADD COLUMN search_vector tsvector NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TRIGGER trg_intake_items_ws_immutable BEFORE UPDATE ON intake_items
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
|
||||
|
||||
ALTER TABLE intake_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE intake_items FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON intake_items
|
||||
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 intake_items TO sp_app;
|
||||
|
||||
-- Per-source listing (GET /intake/items?sourceId=) keyset order.
|
||||
CREATE INDEX intake_items_source_idx
|
||||
ON intake_items (workspace_id, source_id, published_at DESC, id DESC);
|
||||
|
||||
-- Whole-workspace newest-first (GET /intake/items without a source filter).
|
||||
CREATE INDEX intake_items_time_idx
|
||||
ON intake_items (workspace_id, published_at DESC, id DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- FTS (025 pattern)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- String-valued attribution keys worth indexing as metadata.
|
||||
CREATE FUNCTION intake_attribution_text(attribution jsonb)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||
SELECT coalesce(string_agg(value, ' '), '')
|
||||
FROM jsonb_each_text(attribution)
|
||||
WHERE key IN ('authorHandle','authorRef','title','channelTitle','channelUsername',
|
||||
'author','authorSignature','forwardFromChat','forwardFromUser','handle')
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION intake_items_search_vector() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.attribution->>'title', '')), 'A')
|
||||
|| setweight(to_tsvector('simple', intake_attribution_text(NEW.attribution)), 'B')
|
||||
|| to_tsvector('simple', coalesce(NEW.text, ''));
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER trg_intake_items_search
|
||||
BEFORE INSERT OR UPDATE OF text, attribution
|
||||
ON intake_items FOR EACH ROW
|
||||
EXECUTE FUNCTION intake_items_search_vector();
|
||||
|
||||
-- Exact/lexical path. fastupdate stays on explicitly: GIN insert cost is the
|
||||
-- watch item at firehose write rates (research §2.7).
|
||||
CREATE INDEX intake_items_fts ON intake_items USING gin (search_vector) WITH (fastupdate = on);
|
||||
|
||||
-- Fuzzy path (typo tolerance) + ILIKE support on the text corpus.
|
||||
CREATE INDEX intake_items_text_trgm ON intake_items USING gin (text gin_trgm_ops);
|
||||
79
apps/api/migrations/028_instance_settings.sql
Normal file
79
apps/api/migrations/028_instance_settings.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- 028: instance-level admin settings — content-policy posture for intake.
|
||||
--
|
||||
-- The intake-posture decision record
|
||||
-- (docs/decisions/2026-10-09-intake-posture.md) grades the intake tiers by
|
||||
-- legal/ToS risk:
|
||||
--
|
||||
-- YouTube channel RSS + metadata = safe (keyless, no quota, no ToS risk)
|
||||
-- YouTube full harvest (yt-dlp) = ToS risk — per-source opt-in ONLY
|
||||
-- (027's DEFAULT_MEDIA_POLICY already
|
||||
-- parks youtube_channel at 'metadata');
|
||||
-- this switch is the instance-wide gate
|
||||
-- the per-source opt-in is subordinate to
|
||||
-- YouTube broadcast playlists = CC-only (Creative Commons licensed
|
||||
-- content only) — safe to rebroadcast
|
||||
-- Locked platforms (X/IG/TikTok) = metadata-only via sanctioned
|
||||
-- connectors; a bounded read-only pool is
|
||||
-- the decision record's Tier-2 "small
|
||||
-- bounded pool, later" option — opt-in
|
||||
--
|
||||
-- Those posture choices are INSTANCE-level (one deployment = one collective,
|
||||
-- one egress IP, one legal exposure), not per-workspace: no workspace admin
|
||||
-- may widen them for everyone. Hence this table is a system table:
|
||||
--
|
||||
-- * no workspace_id — deliberately. It is not a tenant row.
|
||||
-- * no RLS — there is nothing to isolate BY. Access control is at the API
|
||||
-- seam (SettingsService runs as sp_app; only Owner/Admin roles reach the
|
||||
-- setters through the controller's role check). NOT a grant-surface for
|
||||
-- tenant sessions: the app pool is the only grantee.
|
||||
--
|
||||
-- key-value shape (key TEXT PK, value TEXT NOT NULL) per task spec: settings
|
||||
-- are few, admin-facing and read as a whole — typed decoding lives in
|
||||
-- apps/api/src/settings/settings.service.ts (the ONE place that interprets
|
||||
-- values; nothing else parses these strings), values are CHECK-constrained
|
||||
-- to the exact literal the service layer understands so a stray UPDATE can
|
||||
-- never park an undecodable row in the table.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE instance_settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- Legal-exposure switches must never hold anything but the literals the
|
||||
-- service layer decodes; unknown keys are equally rejected so a typo can't
|
||||
-- fork the settings surface.
|
||||
CONSTRAINT instance_settings_key_allowed CHECK (key IN (
|
||||
'youtube_full_harvest', 'youtube_cc_only_broadcast',
|
||||
'scrape_metadata_only', 'scrape_bounded_pilot', 'intake_posture'
|
||||
)),
|
||||
CONSTRAINT instance_settings_bool_value CHECK (
|
||||
key NOT IN ('youtube_full_harvest', 'youtube_cc_only_broadcast',
|
||||
'scrape_metadata_only', 'scrape_bounded_pilot')
|
||||
OR value IN ('true', 'false')
|
||||
),
|
||||
CONSTRAINT instance_settings_posture_value CHECK (
|
||||
key <> 'intake_posture'
|
||||
OR value IN ('metadata_only', 'community_submissions', 'bounded_pilot')
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Seed defaults (the safe posture — matches the 2026-10-09 decision record:
|
||||
-- "metadata-only + community submissions + Tier-1 sanctioned sources").
|
||||
-- ON CONFLICT DO NOTHING keeps re-runs and later re-seeds idempotent.
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO instance_settings (key, value) VALUES
|
||||
('youtube_full_harvest', 'false'), -- yt-dlp OFF at the gate
|
||||
('youtube_cc_only_broadcast', 'true'), -- CC-only broadcast filter
|
||||
('scrape_metadata_only', 'true'), -- locked platforms: metadata
|
||||
('scrape_bounded_pilot', 'false'), -- no bounded X pool
|
||||
('intake_posture', 'metadata_only') -- overall posture
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- System table: sp_app is the ONLY grantee (003's default privileges already
|
||||
-- cover it, but the explicit grant documents intent the way 006/008/009 do).
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON instance_settings TO sp_app;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Add a link
Reference in a new issue