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/.
136 lines
7.2 KiB
PL/PgSQL
136 lines
7.2 KiB
PL/PgSQL
-- 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;
|