Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
345 lines
20 KiB
TypeScript
345 lines
20 KiB
TypeScript
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<PublishGovernance, "auto">;
|
|
|
|
/** 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<string, unknown>).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<string[]> {
|
|
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<PublishPollRow | undefined> {
|
|
return (await client.query<PublishPollRow>(
|
|
`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<PublishPollRow> {
|
|
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<string, unknown>;
|
|
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<PublishPollRow>(
|
|
`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<PublishingExecutionRepository, "scheduleAttempt">;
|
|
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<ScheduleRepository["scheduleAttempt"]>[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<string, unknown>): Promise<void> {
|
|
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<PublishRequest>;
|
|
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<string | null> {
|
|
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<string, unknown>;
|
|
preferredUserId: string | null; actor: { userId: string | null; role: string };
|
|
}, repository?: ScheduleRepository): Promise<ApprovalFollowUp | null> {
|
|
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<ApprovalFollowUp> => {
|
|
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;
|
|
}
|
|
}
|