import { ConflictException } from "@nestjs/common"; import type { PoolClient } from "pg"; import { isPublishingProvider } from "@tactical-media-broadcast-hub/connector-runtime"; import { PublishingExecutionRepository, PublishingStateError, type ScheduledAttempt } from "@tactical-media-broadcast-hub/publishing-execution"; import { scrub } from "../audit/audit.service.js"; import { parsePolicyRule, policyHash, type PolicyRule } from "../chatgov/policy.js"; import { durationMs } from "../chatgov/poll-evaluator.js"; import { pool } from "../db/client.js"; /** * Per-connection publish governance (the publish-phase gate). * * `connections.fields.publishGovernance` decides what happens once a draft * targeting that connection (providerMetadata.connectionId) is `approved`: * * unset manual — today's behavior: a publication.publish holder * calls POST /publishing/drafts/:id/publish. * auto the approval itself schedules the publish (now). No * second gate: the proposal vote was the decision. * admin1 a SECOND chatgov poll (subject_kind 'draft_publish', * admin_majority kind yes_no) opens on approval / on the publish route; * admin_consensus the publish is scheduled only when it passes * (execute-on-vote). Same rule shapes as proposal polls. * * The scheduling itself is always PublishingExecutionRepository.scheduleAttempt * — the durable pending row the scheduler sweep turns into the unchanged * `scheduledPublish` Temporal workflow — so every authorization fence the * claim re-reads (principal permission, approver, credential generation) * still applies to governed and automatic publishes. E3 additionally refuses * to dispatch to a gated connection unless the publish poll for the approved * revision passed (apps/scheduler dispatch-gate.ts). */ export const PUBLISH_GOVERNANCE_MODES = ["auto", "admin1", "admin_majority", "admin_consensus"] as const; export type PublishGovernance = (typeof PUBLISH_GOVERNANCE_MODES)[number]; export type PublishGateMode = Exclude; /** Publish polls close after a day; an unanswered gate rejects (re-publishable). */ export const PUBLISH_GATE_TIMEBOX = { duration: "P1D", onExpire: "reject" } as const; const ADMIN_ROLES = ["Owner", "Admin"]; /** Lenient read of stored fields: anything unrecognised is manual (null). */ export function readPublishGovernance(fields: unknown): PublishGovernance | null { if (typeof fields !== "object" || fields === null) return null; const value = (fields as Record).publishGovernance; return PUBLISH_GOVERNANCE_MODES.find((mode) => mode === value) ?? null; } export function isPublishGateMode(value: PublishGovernance | null): value is PublishGateMode { return value === "admin1" || value === "admin_majority" || value === "admin_consensus"; } /** * Compile a gate mode into a §1a rule against the admin roster AT OPEN. * The count is snapshotted into the rule (and its policy_version): admins * added later do not raise the bar; admins removed later can make the arm * unreachable, which closes the poll `quorum_unreachable` (blocked). * * admin1 admin_only (the evaluator's implied {admins, x:1} arm) * admin_majority quorum, [{admins, x: floor(n/2)+1}], join any * admin_consensus quorum, [{admins, x: n}], join all, veto allowed * * Self-approval is allowed: the content was already approved under the * proposal policy; this gate is the admins' sign-off on the dispatch, and * excluding an author-admin would make consensus unreachable by design. */ export function compilePublishRule(mode: PublishGateMode, adminCount: number): PolicyRule { if (!Number.isInteger(adminCount) || adminCount < 1) { throw new ConflictException({ code: "PUBLISH_GATE_NO_ADMINS", message: "publish gate needs at least one active admin" }); } const common = { version: 1, anonymity: "named", allowSelfApproval: true, resetOnChange: false, timebox: PUBLISH_GATE_TIMEBOX }; switch (mode) { case "admin1": return parsePolicyRule({ ...common, mode: "admin_only" }); case "admin_majority": return parsePolicyRule({ ...common, mode: "quorum", join: "any", arms: [{ kind: "admins", x: Math.floor(adminCount / 2) + 1 }] }); case "admin_consensus": return parsePolicyRule({ ...common, mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: adminCount }] }); } } /** * Admins whose ballot counts toward an `admins` arm — the same definition the * poll tally uses (poll.service resolveTally): active Owner/Admin members * holding chatgov.vote, plus active members with an approved Admin/Owner * chat-identity role override. */ export async function governanceAdminIds(client: PoolClient, workspaceId: string): Promise { const rows = await client.query<{ user_id: string }>( `SELECT m.user_id FROM memberships m WHERE m.workspace_id = $1 AND m.status = 'active' AND ((m.role = ANY($2::text[]) AND EXISTS (SELECT 1 FROM role_permissions rp WHERE rp.role = m.role AND rp.permission = 'chatgov.vote')) OR EXISTS (SELECT 1 FROM chat_identities ci WHERE ci.user_id = m.user_id AND ci.status = 'approved' AND ci.role_override = ANY($2::text[]))) ORDER BY m.user_id`, [workspaceId, ADMIN_ROLES], ); return rows.rows.map((row) => String(row.user_id)); } /** What the publish route answers when the publish is held for admins. */ export interface PublishHold { state: "awaiting_publish_approval"; draftId: string; connectionId: string; publishGovernance: PublishGateMode; pollId: string; closesAt: Date; rule: PolicyRule; } interface PublishRequest { publishAt: string; requiredPermission: "publication.schedule" | "publication.publish"; } interface PublishPollRow { id: string; state: string; closes_at: Date; rule: PolicyRule; } function holdFrom(draftId: string, connectionId: string, mode: PublishGateMode, poll: PublishPollRow): PublishHold { return { state: "awaiting_publish_approval", draftId, connectionId, publishGovernance: mode, pollId: poll.id, closesAt: new Date(poll.closes_at), rule: poll.rule }; } /** * Publish-route gate (runs inside PublishingService.schedule's transaction, * after its tenancy/provider/group checks). `proceed` = the publish poll for * the CURRENT approved revision passed, or the draft is not currently * approved (scheduleAttempt then refuses with its usual error). Otherwise * the open poll is reused, or a fresh one is opened (first request, or a * re-publish after a failed/expired/vetoed gate). */ export async function checkPublishGate(client: PoolClient, input: { workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"]; }): Promise<{ kind: "proceed" } | { kind: "held"; hold: PublishHold; opened: boolean }> { const draft = (await client.query( "SELECT state, revision, approved_revision, approved_hash FROM drafts WHERE id = $1 FOR UPDATE", [input.draftId])).rows[0]; if (draft === undefined || draft.state !== "approved" || draft.approved_hash === null || draft.approved_revision === null || String(draft.approved_revision) !== String(draft.revision)) { return { kind: "proceed" }; } const latest = await latestPublishPoll(client, input.draftId, input.connectionId, String(draft.approved_revision)); if (latest?.state === "passed") return { kind: "proceed" }; if (latest?.state === "open") return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, latest), opened: false }; const poll = await openPublishPoll(client, { ...input, revision: String(draft.approved_revision) }); return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, poll), opened: true }; } async function latestPublishPoll(client: PoolClient, draftId: string, connectionId: string, revision: string): Promise { return (await client.query( `SELECT id, state, closes_at, rule FROM polls WHERE draft_id = $1 AND subject_kind = 'draft_publish' AND publish_connection_id = $2 AND draft_revision = $3 ORDER BY created_at DESC LIMIT 1`, [draftId, connectionId, revision], )).rows[0]; } /** * Open the publish-phase poll + its pending E2 gate row + audit, in the * caller's transaction (the caller holds the draft row lock; same-transaction * FK KEY SHARE on gate_decisions/polls is safe). The requester is recorded as * created_by and becomes the scheduling principal on release. */ export async function openPublishPoll(client: PoolClient, input: { workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; revision: string; requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"]; }): Promise { const admins = await governanceAdminIds(client, input.workspaceId); const rule = compilePublishRule(input.mode, admins.length); const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1", [input.connectionId])).rows[0]; const fields = (connection?.fields ?? {}) as Record; const label = typeof fields.handle === "string" && fields.handle !== "" ? fields.handle : String(connection?.provider ?? "connection"); const question = `Publish the approved draft to ${label}?`.slice(0, 300); const now = new Date(); const request: PublishRequest = { publishAt: input.publishAt.toISOString(), requiredPermission: input.requiredPermission }; const policyVersion = policyHash(rule); const poll = (await client.query( `INSERT INTO polls (workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at, created_by, draft_revision, publish_connection_id, publish_request) VALUES ($1, $2, 'draft_publish', $3, 'yes_no', $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, state, closes_at, rule`, [input.workspaceId, input.draftId, question, rule, policyVersion, now, new Date(now.getTime() + durationMs(rule.timebox?.duration ?? "P1D")), input.requestedBy, input.revision, input.connectionId, request], )).rows[0]!; const needed = rule.mode === "admin_only" ? 1 : rule.arms?.[0]?.x ?? 1; await client.query( `INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id) VALUES ($1, $2, $3, 'E2', $4, 'pending', $5, $6, $7, $8)`, [input.workspaceId, input.draftId, { platform: String(connection?.provider ?? "unknown"), accountId: input.connectionId }, policyVersion, { phase: "publish", publishGovernance: input.mode, adminCount: admins.length, arms: [{ kind: "admins", got: 0, needed }], pollState: "open" }, ["INSUFFICIENT_QUORUM", "PENDING_POLL"], `user:${input.requestedBy}`, poll.id], ); await client.query("SELECT audit_append($1, $2, 'Member', 'poll.opened', 'poll', $3, $4)", [input.workspaceId, input.requestedBy, poll.id, scrub({ draftId: input.draftId, kind: "yes_no", phase: "publish", connectionId: input.connectionId, publishGovernance: input.mode, adminCount: admins.length, policyVersion, actor: `user:${input.requestedBy}` })]); return poll; } type ScheduleRepository = Pick; let defaultRepository: ScheduleRepository | undefined; function repositoryOrDefault(repository?: ScheduleRepository): ScheduleRepository { if (repository !== undefined) return repository; defaultRepository ??= new PublishingExecutionRepository(pool); return defaultRepository; } /** * Schedule inside a SAVEPOINT so a refused publish (credential gone, approval * voided, schedule already active, principal revoked) never rolls back the * vote / approval that triggered it. The refusal is audited instead. */ async function scheduleGuarded(client: PoolClient, repository: ScheduleRepository, input: Parameters[1]): Promise<{ ok: true; attempt: ScheduledAttempt } | { ok: false; code: string }> { await client.query("SAVEPOINT publish_governance"); try { const attempt = await repository.scheduleAttempt(client, input); await client.query("RELEASE SAVEPOINT publish_governance"); return { ok: true, attempt }; } catch (error) { await client.query("ROLLBACK TO SAVEPOINT publish_governance"); if (error instanceof PublishingStateError) return { ok: false, code: error.code }; throw error; } } async function auditDraft(client: PoolClient, workspaceId: string, actor: { userId: string | null; role: string }, action: string, draftId: string, diff: Record): Promise { await client.query("SELECT audit_append($1, $2, $3, $4, 'draft', $5, $6)", [workspaceId, actor.userId, actor.role, action, draftId, scrub(diff)]); } /** * E2 close seam for publish polls (called by poll.service evaluateLocked * under the workspace → draft → poll locks, after the exactly-once claim). * A passed poll schedules the publish the requester asked for; the scheduler * sweep starts the workflow. A refused schedule is audited, the poll stays * passed, and the holder can re-publish once the cause is fixed. */ export async function releasePublishPoll(client: PoolClient, poll: { id: string; workspace_id: string; draft_id: string | null; created_by: string; publish_connection_id: string | null; publish_request: unknown; }, repository?: ScheduleRepository): Promise<{ released: true; scheduleId: string } | { released: false; code: string }> { if (poll.draft_id === null || poll.publish_connection_id === null) return { released: false, code: "NOT_A_PUBLISH_POLL" }; const request = (poll.publish_request ?? {}) as Partial; const requested = typeof request.publishAt === "string" ? new Date(request.publishAt) : new Date(); const publishAt = Number.isFinite(requested.getTime()) && requested.getTime() > Date.now() ? requested : new Date(); const requiredPermission = request.requiredPermission === "publication.schedule" ? "publication.schedule" : "publication.publish"; const result = await scheduleGuarded(client, repositoryOrDefault(repository), { workspaceId: poll.workspace_id, draftId: poll.draft_id, connectionId: poll.publish_connection_id, scheduledByUserId: poll.created_by, requiredPermission, publishAt, }); const system = { userId: null, role: "System" }; if (!result.ok) { await auditDraft(client, poll.workspace_id, system, "publication.publish_release_failed", poll.draft_id, { pollId: poll.id, connectionId: poll.publish_connection_id, code: result.code, actor: `system:chat-poll-gate:${poll.id}` }); return { released: false, code: result.code }; } await auditDraft(client, poll.workspace_id, system, "publication.scheduled", poll.draft_id, { connectionId: poll.publish_connection_id, publishAt: publishAt.toISOString(), trigger: "publish_poll_passed", pollId: poll.id, scheduledByUserId: poll.created_by, actor: `system:chat-poll-gate:${poll.id}`, }); return { released: true, scheduleId: result.attempt.scheduleId }; } /** * The principal a system-initiated publish runs as: the approving user when * they could publish to this connection themselves, else a live Owner/Admin * (the poll-approval delegation pattern). Mirrors the repository's authorize * predicate, which re-checks it at schedule and at every claim. */ async function publishPrincipal(client: PoolClient, workspaceId: string, connectionId: string, preferredUserId: string | null): Promise { const row = (await client.query<{ user_id: string }>( `SELECT m.user_id FROM memberships m JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'publication.publish' WHERE m.workspace_id = $1 AND m.status = 'active' AND (m.role = ANY($4::text[]) OR EXISTS (SELECT 1 FROM group_connections gc JOIN group_members gm ON gm.group_id = gc.group_id WHERE gc.connection_id = $2 AND gm.user_id = m.user_id)) ORDER BY COALESCE(m.user_id = $3::uuid, false) DESC, CASE m.role WHEN 'Owner' THEN 0 WHEN 'Admin' THEN 1 ELSE 2 END, m.user_id LIMIT 1`, [workspaceId, connectionId, preferredUserId, ADMIN_ROLES], )).rows[0]; return row === undefined ? null : String(row.user_id); } export type ApprovalFollowUp = | { kind: "dispatched"; connectionId: string; attempt: ScheduledAttempt; publishAt: Date } | { kind: "held"; connectionId: string; pollId: string } | { kind: "skipped"; connectionId: string; code: string }; /** * Approval seam: runs in the SAME transaction that moved the draft to * `approved` (console approve, or the E2 proposal-poll pass), after the * UPDATE. Manual connections (no setting) return null — today's behavior. * Never throws for a refused follow-up: the approval must stand on its own. */ export async function onDraftApproved(client: PoolClient, input: { workspaceId: string; draftId: string; providerMetadata: Record; preferredUserId: string | null; actor: { userId: string | null; role: string }; }, repository?: ScheduleRepository): Promise { const connectionId = input.providerMetadata.connectionId; if (typeof connectionId !== "string" || connectionId === "") return null; const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId])).rows[0]; if (connection === undefined) return null; const mode = readPublishGovernance(connection.fields); if (mode === null) return null; const skip = async (code: string): Promise => { await auditDraft(client, input.workspaceId, input.actor, "publication.auto_publish_skipped", input.draftId, { connectionId, publishGovernance: mode, code }); return { kind: "skipped", connectionId, code }; }; if (!isPublishingProvider(String(connection.provider))) return skip("PROVIDER_NOT_PUBLISHING"); const principal = await publishPrincipal(client, input.workspaceId, connectionId, input.preferredUserId); if (principal === null) return skip("NO_AUTHORIZED_PRINCIPAL"); if (mode === "auto") { const publishAt = new Date(); const result = await scheduleGuarded(client, repositoryOrDefault(repository), { workspaceId: input.workspaceId, draftId: input.draftId, connectionId, scheduledByUserId: principal, requiredPermission: "publication.publish", publishAt, }); if (!result.ok) return skip(result.code); await auditDraft(client, input.workspaceId, input.actor, "publication.scheduled", input.draftId, { connectionId, publishAt: publishAt.toISOString(), trigger: "auto_on_approval", scheduledByUserId: principal, }); return { kind: "dispatched", connectionId, attempt: result.attempt, publishAt }; } // Gate modes: the second gate starts running as soon as the draft is // approved; the publish route reuses this poll instead of opening another. await client.query("SAVEPOINT publish_governance_open"); try { const gate = await checkPublishGate(client, { workspaceId: input.workspaceId, draftId: input.draftId, connectionId, mode, requestedBy: principal, publishAt: new Date(), requiredPermission: "publication.publish", }); await client.query("RELEASE SAVEPOINT publish_governance_open"); if (gate.kind === "held") return { kind: "held", connectionId, pollId: gate.hold.pollId }; return null; } catch (error) { await client.query("ROLLBACK TO SAVEPOINT publish_governance_open"); if (error instanceof ConflictException) return skip("PUBLISH_GATE_UNAVAILABLE"); throw error; } }