feat: per-connection publish governance — auto, 1-admin, majority, consensus

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.
This commit is contained in:
glass beadian 2026-10-14 08:04:56 +00:00
parent f2eb14956e
commit b89dea5c93
26 changed files with 1637 additions and 31 deletions

View file

@ -0,0 +1,40 @@
-- Per-connection publish governance (publish-phase gate).
--
-- connections.fields.publishGovernance selects what happens once a draft is
-- approved: unset = manual publish (today), 'auto' = dispatch on approval,
-- 'admin1' | 'admin_majority' | 'admin_consensus' = a SECOND chatgov poll
-- (subject_kind 'draft_publish', kind yes_no) must pass before the publish
-- is scheduled. The setting itself lives in the established fields jsonb;
-- this migration only teaches `polls` about the publish-phase subject.
--
-- publish_connection_id the destination the publish poll releases to
-- publish_request {publishAt, requiredPermission} captured at hold
-- time; the requester is polls.created_by and stays
-- the scheduling principal (re-checked at claim).
ALTER TABLE polls DROP CONSTRAINT polls_subject_kind_check;
ALTER TABLE polls ADD CONSTRAINT polls_subject_kind_check CHECK
(subject_kind IN ('draft', 'chat_post', 'none', 'draft_publish'));
ALTER TABLE polls
ADD COLUMN publish_connection_id uuid,
ADD COLUMN publish_request jsonb CHECK (publish_request IS NULL OR jsonb_typeof(publish_request) = 'object');
ALTER TABLE polls ADD CONSTRAINT polls_publish_connection_fk
FOREIGN KEY (publish_connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT;
ALTER TABLE polls ADD CONSTRAINT polls_publish_subject_check CHECK (
(subject_kind = 'draft_publish') = (publish_connection_id IS NOT NULL)
AND (subject_kind <> 'draft_publish' OR (draft_id IS NOT NULL AND publish_request IS NOT NULL))
);
-- One open REVIEW gate per draft stays index-enforced; publish gates get
-- their own slot per (draft, destination) so they never collide with it.
DROP INDEX polls_open_one_per_draft;
CREATE UNIQUE INDEX polls_open_one_per_draft ON polls (workspace_id, draft_id)
WHERE state = 'open' AND draft_id IS NOT NULL AND subject_kind <> 'draft_publish';
CREATE UNIQUE INDEX polls_open_one_per_publish ON polls (workspace_id, draft_id, publish_connection_id)
WHERE state = 'open' AND subject_kind = 'draft_publish';
CREATE INDEX polls_ws_publish ON polls (workspace_id, draft_id, publish_connection_id, created_at DESC)
WHERE subject_kind = 'draft_publish';

View file

@ -6,6 +6,7 @@ import { scrub } from '../audit/audit.service.js';
import { draftDestinationAccounts } from '../publishing/approval-gate.service.js'; import { draftDestinationAccounts } from '../publishing/approval-gate.service.js';
import type { DraftPost } from '../publishing/publishing.service.js'; import type { DraftPost } from '../publishing/publishing.service.js';
import { transitionPollDraft } from '../publishing/poll-approval.js'; import { transitionPollDraft } from '../publishing/poll-approval.js';
import { releasePublishPoll } from '../publishing/publish-governance.js';
import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js'; import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js'; import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
@ -18,7 +19,10 @@ interface PollRow {
anonymity: string; opens_at: Date; closes_at: Date; created_by: string; anonymity: string; opens_at: Date; closes_at: Date; created_by: string;
winner_option_id: string | null; eligible_user_ids: string[] | null; winner_option_id: string | null; eligible_user_ids: string[] | null;
parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string; parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string;
/** 'draft_publish' = publish-phase gate (publish-governance.ts); else a review/chat poll. */
subject_kind: string; publish_connection_id: string | null; publish_request: unknown;
} }
const isPublishPoll = (poll: PollRow): boolean => poll.subject_kind === 'draft_publish';
interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string } interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string }
interface Member { user_id: string; role: string; can_vote: boolean } interface Member { user_id: string; role: string; can_vote: boolean }
interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string; interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string;
@ -301,7 +305,12 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
Object.assign(poll, claimed.rows[0]); Object.assign(poll, claimed.rows[0]);
const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM'] const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM']
: evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : []; : evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : [];
if (poll.draft_id && !poll.rule.dryRun) { if (poll.draft_id && !poll.rule.dryRun && isPublishPoll(poll)) {
// Publish phase: the draft stays approved either way; a pass schedules
// the held publish (execute-on-vote), a fail/expiry/veto leaves it
// re-publishable and is recorded blocked below.
if (evaluation.state === 'passed') await releasePublishPoll(client, poll);
} else if (poll.draft_id && !poll.rule.dryRun) {
const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined; const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined;
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id, await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }), passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }),
@ -316,14 +325,15 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> { async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
if (!poll.draft_id) return; if (!poll.draft_id) return;
const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0]; const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
for (const accountId of draftDestinationAccounts(draft.post as DraftPost)) { const accounts = isPublishPoll(poll) && poll.publish_connection_id ? [poll.publish_connection_id] : draftDestinationAccounts(draft.post as DraftPost);
for (const accountId of accounts) {
const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown'; const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown';
await client.query(`INSERT INTO gate_decisions await client.query(`INSERT INTO gate_decisions
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id) (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
VALUES ($1,$2,$3,'E2',$4,$5,$6,$7,$8,$9)`, VALUES ($1,$2,$3,'E2',$4,$5,$6,$7,$8,$9)`,
[poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) }, [poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) },
poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked', poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked',
{ ...evaluation, pollState: poll.state }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]); { ...evaluation, pollState: poll.state, ...(isPublishPoll(poll) ? { phase: 'publish' } : {}) }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
} }
} }
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> { async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
@ -344,7 +354,9 @@ async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
Object.assign(poll, changed.rows[0]); Object.assign(poll, changed.rows[0]);
await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' }); await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' });
await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']); await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']);
if (poll.rule.dryRun || !poll.draft_id) return; // A stale publish gate never re-arms or touches the draft: the approval it
// answered is gone, and a new approval opens a new publish gate.
if (poll.rule.dryRun || !poll.draft_id || isPublishPoll(poll)) return;
if (poll.rule.resetOnChange) { if (poll.rule.resetOnChange) {
await openPollInTransaction(client, poll.workspace_id, { await openPollInTransaction(client, poll.workspace_id, {
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question, draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
@ -375,6 +387,6 @@ export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string,
if (!draftDestinationAccounts(post).length) return; if (!draftDestinationAccounts(post).length) return;
const rule = await draftPollRule(client, { post }); const rule = await draftPollRule(client, { post });
if (!rule) return; if (!rule) return;
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open'", [draftId])).rowCount) return; if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open' AND subject_kind <> 'draft_publish'", [draftId])).rowCount) return;
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor); await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
} }

View file

@ -6,6 +6,7 @@ import { withTenant } from "../db/client.js";
import type { SealedCredential } from "../vault/vault.js"; import type { SealedCredential } from "../vault/vault.js";
import type { ConnectionSummary } from "./connections.service.js"; import type { ConnectionSummary } from "./connections.service.js";
import { scrub } from "../audit/audit.service.js"; import { scrub } from "../audit/audit.service.js";
import { readPublishGovernance, type PublishGovernance } from "../publishing/publish-governance.js";
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null } export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
export interface ConnectionStore { export interface ConnectionStore {
@ -15,6 +16,8 @@ export interface ConnectionStore {
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>; save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>; rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>; remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
/** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */
setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void>;
} }
export class PgConnectionStore implements ConnectionStore { export class PgConnectionStore implements ConnectionStore {
@ -65,7 +68,9 @@ export class PgConnectionStore implements ConnectionStore {
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException(); if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId }; const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
if (reconnect) { if (reconnect) {
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]); // Governance settings are not provider identity: a reconnect keeps them.
const updated = await client.query(`UPDATE connections SET fields = $2::jsonb || jsonb_strip_nulls(jsonb_build_object('publishGovernance', fields->'publishGovernance')),
disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id`, [connection.id, fields, connection.provider]);
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization"); if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
} else { } else {
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]); await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
@ -106,6 +111,22 @@ export class PgConnectionStore implements ConnectionStore {
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); } catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
} }
} }
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
await this.transaction(session.workspaceId, async (client) => {
// Serialized with membership revocation like every governance write.
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
if (!current.rowCount) throw new NotFoundException("Connection not found");
const previous = readPublishGovernance(current.rows[0]?.fields);
await client.query(
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
[id, mode],
);
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
});
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> { async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
await this.transaction(workspaceId, async (client) => { await this.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);

View file

@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common"; import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js"; import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js"; import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js"; import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
import { CHATGOV_PERMISSIONS } from "../chatgov/policy.js";
import { PUBLISH_GOVERNANCE_MODES, type PublishGovernance } from "../publishing/publish-governance.js";
@Controller("connections") @Controller("connections")
export class ConnectionsController { export class ConnectionsController {
@ -74,6 +76,19 @@ export class ConnectionsController {
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id))); return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
} }
/**
* Publish governance for one connection: auto | admin1 | admin_majority |
* admin_consensus, or null for manual publishing. Deciding who must sign
* off a publish is a governance rule, so it needs chatgov.manage.
*/
@Patch(":connectionId/publish-governance")
@UseGuards(RbacGuard)
@RequirePermission(CHATGOV_PERMISSIONS.manage)
async publishGovernance(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Body() body: unknown) {
const input = requireObject(body, "publish governance");
return this.connections.setPublishGovernance(requireSession(request), parseId(id), parsePublishGovernance(input.publishGovernance));
}
@Delete(":connectionId") @Delete(":connectionId")
@UseGuards(RbacGuard) @UseGuards(RbacGuard)
@RequirePermission("connection.manage") @RequirePermission("connection.manage")
@ -82,3 +97,10 @@ export class ConnectionsController {
return { removed: true, token }; return { removed: true, token };
} }
} }
function parsePublishGovernance(value: unknown): PublishGovernance | null {
if (value === null) return null;
const mode = PUBLISH_GOVERNANCE_MODES.find((candidate) => candidate === value);
if (mode === undefined) throw new BadRequestException(`publishGovernance must be null or one of: ${PUBLISH_GOVERNANCE_MODES.join(", ")}`);
return mode;
}

View file

@ -15,6 +15,7 @@ import { RbacGuard } from "../rbac/rbac.guard.js";
import { logger } from "../telemetry/logger.js"; import { logger } from "../telemetry/logger.js";
import { ConnectionsController } from "./connections.controller.js"; import { ConnectionsController } from "./connections.controller.js";
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js"; import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
import type { PublishGovernance } from "../publishing/publish-governance.js";
import type { ConnectionStore, StoredConnection } from "./connection-store.js"; import type { ConnectionStore, StoredConnection } from "./connection-store.js";
import { RedisPendingStore } from "./pending.js"; import { RedisPendingStore } from "./pending.js";
import { MemoryPendingRedis } from "./testing.js"; import { MemoryPendingRedis } from "./testing.js";
@ -50,6 +51,14 @@ class MemoryConnections implements ConnectionStore {
this.rows.delete(id); this.rows.delete(id);
this.audit.push({ action: "connection.remove" }); this.audit.push({ action: "connection.remove" });
} }
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
await this.assertAuthorized(session);
const row = await this.get(session.workspaceId, id);
const fields = { ...row.connection.fields };
delete fields.publishGovernance;
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
this.audit.push({ action: "connection.publish_governance" });
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> { async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
const row = await this.get(workspaceId, id); const row = await this.get(workspaceId, id);
if (row.credential?.ciphertext.equals(previous.ciphertext)) { if (row.credential?.ciphertext.equals(previous.ciphertext)) {
@ -333,6 +342,21 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
bridgeConfigured = false; bridgeConfigured = false;
}); });
it("sets, validates and clears per-connection publish governance", async () => {
await finish(await begin());
const id = (await listed())[0]?.id ?? "";
const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" });
expect(set.status).toBe(200);
expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" });
expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority");
expect(store.audit.at(-1)?.action).toBe("connection.publish_governance");
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400);
expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400);
expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400);
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200);
expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance");
});
it("does not resurrect a connection removed while reconnect consent is pending", async () => { it("does not resurrect a connection removed while reconnect consent is pending", async () => {
await finish(await begin()); await finish(await begin());
const row = (await listed())[0]; const row = (await listed())[0];

View file

@ -11,6 +11,7 @@ import { connectionRegistry } from "./connector-registry.js";
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js"; import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js"; import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
import { oauthFetch } from "./oauth-fetch.js"; import { oauthFetch } from "./oauth-fetch.js";
import type { PublishGovernance } from "../publishing/publish-governance.js";
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable"; export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
export interface ConnectionSummary { export interface ConnectionSummary {
@ -214,6 +215,11 @@ export class ConnectionsService {
} }
return rows; return rows;
} }
/** Per-connection publish governance (publish-governance.ts); null = manual. */
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<{ connectionId: string; publishGovernance: PublishGovernance | null }> {
await this.deps.store.setPublishGovernance(session, id, mode);
return { connectionId: id, publishGovernance: mode };
}
async remove(session: SessionPayload, id: string): Promise<string> { async remove(session: SessionPayload, id: string): Promise<string> {
await this.deps.store.assertAuthorized(session); await this.deps.store.assertAuthorized(session);
await this.deps.store.remove(session, id, async (stored) => { await this.deps.store.remove(session, id, async (stored) => {

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { Pool, type PoolClient } from "pg"; import { Pool, type PoolClient } from "pg";
import { PublishingExecutionRepository, assertPublishingSchema } from "@tactical-media-broadcast-hub/publishing-execution"; import { PublishingExecutionRepository } from "@tactical-media-broadcast-hub/publishing-execution";
/** /**
* chatgov P1 gates against a live PostgreSQL (chatgov-design.md §7 P1 row): * chatgov P1 gates against a live PostgreSQL (chatgov-design.md §7 P1 row):

View file

@ -240,7 +240,7 @@ export class ApprovalGateService {
); );
adminApproval = membership.rows.some((row) => APPROVAL_GATE_ADMIN_ROLES.includes(String(row.role))); adminApproval = membership.rows.some((row) => APPROVAL_GATE_ADMIN_ROLES.includes(String(row.role)));
const poll = await client.query<{ state: string }>( const poll = await client.query<{ state: string }>(
"SELECT state FROM polls WHERE draft_id = $1 ORDER BY created_at DESC LIMIT 1", "SELECT state FROM polls WHERE draft_id = $1 AND subject_kind <> 'draft_publish' ORDER BY created_at DESC LIMIT 1",
[draftId], [draftId],
); );
pollState = String(poll.rows[0]?.state ?? "none"); pollState = String(poll.rows[0]?.state ?? "none");

View file

@ -2,6 +2,7 @@ import { ConflictException } from '@nestjs/common';
import { canonicalPostHash } from '@tactical-media-broadcast-hub/publishing-execution'; import { canonicalPostHash } from '@tactical-media-broadcast-hub/publishing-execution';
import type { PoolClient } from 'pg'; import type { PoolClient } from 'pg';
import { ApprovalGateService } from './approval-gate.service.js'; import { ApprovalGateService } from './approval-gate.service.js';
import { onDraftApproved } from './publish-governance.js';
import type { DraftPost } from './publishing.service.js'; import type { DraftPost } from './publishing.service.js';
/** E2's transactional publishing seam. A System audit actor delegates to a /** E2's transactional publishing seam. A System audit actor delegates to a
@ -42,4 +43,12 @@ export async function transitionPollDraft(client: PoolClient, input: {
input.passed ? 'draft.approved' : 'draft.rejected', input.draftId, input.passed ? 'draft.approved' : 'draft.rejected', input.draftId,
{ actor: `system:chat-poll-gate:${input.pollId}`, pollId: input.pollId, { actor: `system:chat-poll-gate:${input.pollId}`, pollId: input.pollId,
authorizingUserId: reviewer, from: current.state, to: input.passed ? 'approved' : 'rejected', reason: input.reason }]); authorizingUserId: reviewer, from: current.state, to: input.passed ? 'approved' : 'rejected', reason: input.reason }]);
// Execute-on-vote for the target connection's publish governance (auto →
// schedule now; gate modes → open the publish poll). The scheduler sweep
// starts the workflow for the committed pending row.
if (input.passed) {
await onDraftApproved(client, { workspaceId: input.workspaceId, draftId: input.draftId,
providerMetadata: (draft.post as DraftPost).providerMetadata ?? {}, preferredUserId: reviewer,
actor: { userId: null, role: 'System' } });
}
} }

View file

@ -0,0 +1,282 @@
import { ConflictException, ForbiddenException } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { Pool, type PoolClient } from "pg";
import { PublishingExecutionRepository, assertPublishingSchema, type AttemptRef } from "@tactical-media-broadcast-hub/publishing-execution";
/**
* Per-connection publish governance against a live PostgreSQL:
* - auto: a console approval AND a passing proposal poll (E2) each schedule
* the publish on the unchanged durable path
* - admin1 / admin_majority / admin_consensus: approval opens the publish
* poll, the publish route holds (202 body), the pass schedules it
* - fail / expire / veto: the draft stays approved, the gate row is blocked
* with reason codes, and a re-publish opens a fresh poll
* - E3 (real DispatchGateService): a gated destination dispatches only once
* its publish poll passed
*
* Owns its workspace in the chatgov IT database (CHATGOV_IT_DATABASE_URL +
* CHATGOV_IT_SUPER_DATABASE_URL, migrated through 033). Skips clean.
*/
const appUrl = process.env.CHATGOV_IT_DATABASE_URL;
const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL;
type Modules = {
publishing: typeof import("./publishing.service.js");
polls: typeof import("../chatgov/poll.service.js");
store: typeof import("../connections/connection-store.js");
dispatchGate: typeof import("../../../scheduler/src/activities/dispatch-gate.js");
db: typeof import("../db/client.js");
};
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("publish governance (live DB)", () => {
let m: Modules;
let ownerPool: Pool;
let appPool: Pool;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
let ws: string;
let ownerId: string;
let admin2: string;
let admin3: string;
let editorId: string;
const conn: Record<"auto" | "admin1" | "admin_majority" | "admin_consensus" | "manual", string> = { auto: "", admin1: "", admin_majority: "", admin_consensus: "", manual: "" };
const starts: AttemptRef[] = [];
async function inTenant<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await ownerPool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [ws]);
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
beforeAll(async () => {
process.env.DATABASE_URL = appUrl;
process.env.SUPER_DATABASE_URL = ownerUrl;
m = {
publishing: await import("./publishing.service.js"),
polls: await import("../chatgov/poll.service.js"),
store: await import("../connections/connection-store.js"),
dispatchGate: await import("../../../scheduler/src/activities/dispatch-gate.js"),
db: await import("../db/client.js"),
};
ownerPool = new Pool({ connectionString: ownerUrl });
appPool = new Pool({ connectionString: appUrl });
const c = await ownerPool.connect();
try {
ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["pubgov", `pubgov-${suffix}`])).rows[0].id as string;
const user = async (name: string): Promise<string> => (await c.query(
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
[`pubgov:${name}:${suffix}`, `pubgov-${name}-${suffix}`, `pubgov-${name}-${suffix}@example.com`, name],
)).rows[0].id as string;
ownerId = await user("owner");
admin2 = await user("admin2");
admin3 = await user("admin3");
editorId = await user("editor");
for (const [id, role] of [[ownerId, "Owner"], [admin2, "Admin"], [admin3, "Admin"], [editorId, "Editor"]] as const) {
await c.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'active')", [ws, id, role]);
}
} finally {
c.release();
}
await inTenant(async (client) => {
for (const mode of Object.keys(conn) as Array<keyof typeof conn>) {
const fields = mode === "manual" ? { handle: "manual" } : { handle: mode, publishGovernance: mode };
conn[mode] = (await client.query("INSERT INTO connections (workspace_id, provider, channel_id, fields) VALUES ($1, 'mastodon', $2, $3) RETURNING id",
[ws, `pubgov-${mode}`, fields])).rows[0].id as string;
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext) VALUES ($1, $2, 'connector-oauth', '\\x00')", [ws, conn[mode]]);
// A compiled immediate snapshot (no live rules: E1 stays ungated) so
// E3 evaluates the publish gate instead of failing OUTSIDE_POLICY.
await client.query(`INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, effective, policy_version, resolved_from)
VALUES ($1, 'account', $2, '{"version":1,"mode":"immediate"}', 'pv-immediate', '{}')`, [ws, conn[mode]]);
}
});
}, 60_000);
afterAll(async () => {
if (ownerPool !== undefined) {
await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]);
await ownerPool.query("DELETE FROM users WHERE external_id LIKE $1", [`pubgov:%:${suffix}`]);
await ownerPool.end();
}
await appPool?.end();
await m?.db.pool.end();
});
const owner = () => ({ userId: ownerId, role: "Owner" });
function service() {
return new m.publishing.PublishingService({
tenantTransaction: m.db.withTenant,
workflowStarter: { startScheduledPublish: vi.fn(async (ref: AttemptRef) => { starts.push(ref); }) },
repository: new PublishingExecutionRepository(m.db.pool),
assertReady: () => assertPublishingSchema(m.db.pool),
});
}
async function approvedDraft(connectionId: string): Promise<string> {
const publishing = service();
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
text: `governed ${connectionId} ${Math.random()}`, mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId },
});
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
await publishing.approve(ws, owner(), draft.id);
return draft.id;
}
const vote = (pollId: string, userId: string, ballot: "yes" | "no" | "veto") =>
new m.polls.PollService().vote(ws, pollId, { ballot }, { kind: "user", userId });
async function publishPolls(draftId: string) {
return inTenant(async (c) => (await c.query(
"SELECT id, state, kind, rule, created_by, publish_connection_id FROM polls WHERE draft_id = $1 AND subject_kind = 'draft_publish' ORDER BY created_at",
[draftId])).rows as Array<{ id: string; state: string; kind: string; rule: { mode: string; arms?: Array<{ x: number }>; join?: string }; created_by: string; publish_connection_id: string }>);
}
async function schedules(draftId: string) {
return inTenant(async (c) => (await c.query(
"SELECT id, state, connection_id, scheduled_by_user_id, required_permission, idempotency_key FROM scheduled_publications WHERE draft_id = $1",
[draftId])).rows as Array<{ id: string; state: string; connection_id: string; scheduled_by_user_id: string; required_permission: string; idempotency_key: string }>);
}
async function publishGates(draftId: string) {
return inTenant(async (c) => (await c.query(
"SELECT gate, outcome, reason_codes, inputs, poll_id FROM gate_decisions WHERE draft_id = $1 AND (inputs->>'phase') = 'publish' ORDER BY created_at, id",
[draftId])).rows as Array<{ gate: string; outcome: string; reason_codes: string[]; inputs: Record<string, unknown>; poll_id: string }>);
}
async function draftState(draftId: string): Promise<string> {
return inTenant(async (c) => String((await c.query("SELECT state FROM drafts WHERE id = $1", [draftId])).rows[0]?.state));
}
async function e3(draftId: string, connectionId: string, idempotencyKey: string) {
return new m.dispatchGate.DispatchGateService(appPool).check({ workspaceId: ws, draftId, idempotencyKey, connectionId, platform: "mastodon" });
}
it("auto: a console approval schedules the publish immediately and hints the workflow", async () => {
starts.length = 0;
const draftId = await approvedDraft(conn.auto);
const [scheduled] = await schedules(draftId);
expect(scheduled).toMatchObject({ state: "pending", connection_id: conn.auto, scheduled_by_user_id: ownerId, required_permission: "publication.publish" });
expect(starts).toEqual([expect.objectContaining({ workspaceId: ws, scheduleId: scheduled?.id })]);
expect(await publishPolls(draftId)).toEqual([]);
const audit = await inTenant(async (c) => (await c.query("SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'publication.scheduled'", [draftId])).rows);
expect(audit[0]?.diff).toMatchObject({ trigger: "auto_on_approval", connectionId: conn.auto });
expect((await e3(draftId, conn.auto, scheduled!.idempotency_key)).outcome).toBe("fired");
});
it("auto: a passing proposal poll (E2 execute-on-vote) schedules the publish too", async () => {
const publishing = service();
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
text: "auto via poll", mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId: conn.auto },
});
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
const card = await new m.polls.PollService().open(ws, {
draftId: draft.id, kind: "yes_no", question: "Ship it?", rule: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } },
}, { userId: ownerId });
await vote(card.poll.id, admin2, "yes");
expect(await draftState(draft.id)).toBe("approved");
expect(await schedules(draft.id)).toEqual([expect.objectContaining({ state: "pending", connection_id: conn.auto, required_permission: "publication.publish" })]);
});
it("admin1: approval opens the publish poll, the publish route holds, one admin yes releases", async () => {
starts.length = 0;
const draftId = await approvedDraft(conn.admin1);
const [poll] = await publishPolls(draftId);
expect(poll).toMatchObject({ state: "open", kind: "yes_no", created_by: ownerId, publish_connection_id: conn.admin1, rule: { mode: "admin_only" } });
expect(await publishGates(draftId)).toEqual([expect.objectContaining({ gate: "E2", outcome: "pending", reason_codes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], poll_id: poll?.id })]);
const publishing = service();
const held = await publishing.requestPublish(ws, { userId: admin2, role: "Admin" }, draftId, conn.admin1, new Date(), "publication.publish");
expect(held).toMatchObject({ state: "awaiting_publish_approval", pollId: poll?.id, publishGovernance: "admin1", connectionId: conn.admin1 });
await expect(publishing.schedule(ws, owner(), draftId, conn.admin1, new Date())).rejects.toBeInstanceOf(ConflictException);
expect(await schedules(draftId)).toEqual([]);
expect(starts).toEqual([]);
await vote(poll!.id, admin2, "yes");
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
const [scheduled] = await schedules(draftId);
expect(scheduled).toMatchObject({ state: "pending", connection_id: conn.admin1, scheduled_by_user_id: ownerId });
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ gate: "E2", outcome: "fired", poll_id: poll?.id }));
expect(await draftState(draftId)).toBe("approved");
expect((await e3(draftId, conn.admin1, scheduled!.idempotency_key)).outcome).toBe("fired");
});
it("admin_majority: needs 2 of 3 admins", async () => {
const draftId = await approvedDraft(conn.admin_majority);
const [poll] = await publishPolls(draftId);
expect(poll?.rule).toMatchObject({ mode: "quorum", join: "any", arms: [{ kind: "admins", x: 2 }] });
await vote(poll!.id, admin2, "yes");
expect((await publishPolls(draftId))[0]?.state).toBe("open");
expect(await schedules(draftId)).toEqual([]);
await vote(poll!.id, admin3, "yes");
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
expect(await schedules(draftId)).toEqual([expect.objectContaining({ state: "pending", connection_id: conn.admin_majority })]);
});
it("admin_consensus: one no blocks; expiry rejects, the draft stays approved and is re-publishable; a veto closes at once", async () => {
const draftId = await approvedDraft(conn.admin_consensus);
const [poll] = await publishPolls(draftId);
expect(poll?.rule).toMatchObject({ mode: "quorum", join: "all", arms: [{ kind: "admins", x: 3 }] });
await vote(poll!.id, ownerId, "yes");
await vote(poll!.id, admin2, "yes");
await vote(poll!.id, admin3, "no");
expect((await publishPolls(draftId))[0]?.state).toBe("open");
expect(await schedules(draftId)).toEqual([]);
await inTenant((c) => c.query("UPDATE polls SET opens_at = now() - interval '2 days', closes_at = now() - interval '1 second' WHERE id = $1", [poll!.id]));
await new m.polls.PollService().close(ws, poll!.id);
expect((await publishPolls(draftId))[0]?.state).toBe("failed");
expect(await draftState(draftId)).toBe("approved");
expect(await schedules(draftId)).toEqual([]);
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ outcome: "blocked", reason_codes: ["TIMEBOX_EXPIRED_AUTO_REJECT"], poll_id: poll?.id }));
const again = await service().requestPublish(ws, owner(), draftId, conn.admin_consensus, new Date(), "publication.publish");
expect(again.state).toBe("awaiting_publish_approval");
const reopened = again.state === "awaiting_publish_approval" ? again.pollId : "";
expect(reopened).not.toBe(poll?.id);
await vote(reopened, admin3, "veto");
expect((await publishPolls(draftId)).map((p) => p.state)).toEqual(["failed", "vetoed"]);
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ outcome: "blocked", reason_codes: ["VETOED"], poll_id: reopened }));
expect(await schedules(draftId)).toEqual([]);
expect(await draftState(draftId)).toBe("approved");
});
it("E3 releases a gated destination only on poll pass (schedule made before the gate was switched on)", async () => {
const draftId = await approvedDraft(conn.manual);
expect(await publishPolls(draftId)).toEqual([]); // manual: approval changes nothing
const scheduled = await service().requestPublish(ws, owner(), draftId, conn.manual, new Date(Date.now() + 3_600_000), "publication.publish");
if (scheduled.state === "awaiting_publish_approval") throw new Error("manual connection must not hold");
const epoch = String((await ownerPool.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [ws])).rows[0].authz_epoch);
const session = (userId: string, role: string) => ({ userId, role, workspaceId: ws, authzEpoch: Number(epoch), issuedAt: 0, expiresAt: Math.floor(Date.now() / 1000) + 300 });
const store = new m.store.PgConnectionStore(m.db.withTenant);
await expect(store.setPublishGovernance(session(editorId, "Editor"), conn.manual, "auto")).rejects.toBeInstanceOf(ForbiddenException);
await store.setPublishGovernance(session(ownerId, "Owner"), conn.manual, "admin1");
const blocked = await e3(draftId, conn.manual, scheduled.idempotencyKey);
expect(blocked).toMatchObject({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], pollState: "none" });
const held = await service().requestPublish(ws, owner(), draftId, conn.manual, new Date(), "publication.publish");
if (held.state !== "awaiting_publish_approval") throw new Error("gated connection must hold");
expect((await e3(draftId, conn.manual, scheduled.idempotencyKey)).pollState).toBe("open");
await vote(held.pollId, admin2, "yes");
expect((await e3(draftId, conn.manual, scheduled.idempotencyKey)).outcome).toBe("fired");
// The pass found the earlier pending schedule still active: the release is
// refused (audited) rather than doubling the publish.
const audit = await inTenant(async (c) => (await c.query("SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'publication.publish_release_failed'", [draftId])).rows);
expect(audit[0]?.diff).toMatchObject({ code: "SCHEDULE_ACTIVE", pollId: held.pollId });
expect(await schedules(draftId)).toHaveLength(1);
await store.setPublishGovernance(session(ownerId, "Owner"), conn.manual, null);
const fields = await inTenant(async (c) => (await c.query("SELECT fields FROM connections WHERE id = $1", [conn.manual])).rows[0].fields);
expect(fields).toEqual({ handle: "manual" });
});
});

View file

@ -0,0 +1,93 @@
import { ConflictException } from "@nestjs/common";
import { describe, expect, it } from "vitest";
import { evaluatePoll, type EvaluatedBallot } from "../chatgov/poll-evaluator.js";
import { compilePublishRule, isPublishGateMode, readPublishGovernance, type PublishGateMode } from "./publish-governance.js";
/**
* Publish-phase gate: the per-connection setting compiles onto the SAME §1a
* rule shapes as proposal polls, and the existing pure evaluator decides it.
*/
const opensAt = new Date("2026-10-14T10:00:00Z");
const closesAt = new Date("2026-10-15T10:00:00Z");
function admin(ballot: EvaluatedBallot["ballot"]): EvaluatedBallot {
return { ballot, optionId: null, counted: true, admin: true, observed: false, participant: true };
}
function decide(mode: PublishGateMode, admins: number, ballots: EvaluatedBallot[], now = new Date("2026-10-14T10:05:00Z")) {
return evaluatePoll({
kind: "yes_no", rule: compilePublishRule(mode, admins), opensAt, closesAt, now, ballots, options: [],
possible: { admins, eligible: admins, observed: 0 },
});
}
describe("publish governance setting", () => {
it("reads only the known modes; anything else is manual (null)", () => {
expect(readPublishGovernance({ publishGovernance: "auto" })).toBe("auto");
expect(readPublishGovernance({ publishGovernance: "admin_consensus" })).toBe("admin_consensus");
expect(readPublishGovernance({ publishGovernance: "everyone" })).toBeNull();
expect(readPublishGovernance({})).toBeNull();
expect(readPublishGovernance(null)).toBeNull();
expect(isPublishGateMode("auto")).toBe(false);
expect(isPublishGateMode(null)).toBe(false);
expect(isPublishGateMode("admin1")).toBe(true);
});
});
describe("compilePublishRule", () => {
it("admin1 compiles to admin_only (the evaluator's implied single admin arm)", () => {
const rule = compilePublishRule("admin1", 3);
expect(rule).toMatchObject({ mode: "admin_only", anonymity: "named", allowSelfApproval: true, timebox: { duration: "P1D", onExpire: "reject" } });
expect(rule.arms).toBeUndefined();
});
it("admin_majority needs floor(n/2)+1 admins, snapshotted from the roster at open", () => {
expect(compilePublishRule("admin_majority", 3)).toMatchObject({ mode: "quorum", join: "any", arms: [{ kind: "admins", x: 2 }] });
expect(compilePublishRule("admin_majority", 4).arms).toEqual([{ kind: "admins", x: 3 }]);
expect(compilePublishRule("admin_majority", 1).arms).toEqual([{ kind: "admins", x: 1 }]);
});
it("admin_consensus needs every admin, join all, and allows a veto", () => {
expect(compilePublishRule("admin_consensus", 3)).toMatchObject({ mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: 3 }] });
});
it("refuses to open a gate nobody can pass", () => {
expect(() => compilePublishRule("admin1", 0)).toThrow(ConflictException);
});
});
describe("publish gate decisions (pure evaluator)", () => {
it("admin1 passes with one admin yes", () => {
expect(decide("admin1", 3, []).state).toBeNull();
expect(decide("admin1", 3, [admin("yes")])).toMatchObject({ state: "passed", reason: "quorum_early" });
});
it("admin1 ignores a non-admin yes", () => {
expect(decide("admin1", 3, [{ ...admin("yes"), admin: false }]).state).toBeNull();
});
it("admin_majority needs 2 of 3", () => {
expect(decide("admin_majority", 3, [admin("yes")]).state).toBeNull();
expect(decide("admin_majority", 3, [admin("yes"), admin("yes")])).toMatchObject({ state: "passed", quorum: true });
});
it("admin_consensus is held by one standing no and rejects at the timebox", () => {
const ballots = [admin("yes"), admin("yes"), admin("no")];
expect(decide("admin_consensus", 3, ballots)).toMatchObject({ state: null, quorum: false });
expect(decide("admin_consensus", 3, ballots, closesAt)).toMatchObject({ state: "failed", reason: "timebox" });
expect(decide("admin_consensus", 3, [admin("yes"), admin("yes"), admin("yes")]).state).toBe("passed");
});
it("admin_consensus closes immediately on an admin veto", () => {
expect(decide("admin_consensus", 3, [admin("yes"), admin("veto")])).toMatchObject({ state: "vetoed", reason: "veto" });
});
it("a removed admin can make consensus unreachable (roster changes after open never lower the bar)", () => {
const evaluation = evaluatePoll({
kind: "yes_no", rule: compilePublishRule("admin_consensus", 3), opensAt, closesAt, now: new Date("2026-10-14T10:05:00Z"),
ballots: [admin("yes")], options: [], possible: { admins: 2, eligible: 2, observed: 0 },
});
expect(evaluation.state).toBe("quorum_unreachable");
});
});

View file

@ -0,0 +1,345 @@
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;
}
}

View file

@ -23,7 +23,7 @@ import {
type DraftPost, type DraftPost,
type DraftRecord, type DraftRecord,
type DraftState, type DraftState,
type ScheduledPublicationRecord, type PublishOutcome,
} from "./publishing.service.js"; } from "./publishing.service.js";
export interface CreateDraftDto { export interface CreateDraftDto {
@ -163,10 +163,10 @@ export class PublishingController {
@Req() request: Request & AuthenticatedRequest, @Req() request: Request & AuthenticatedRequest,
@Param("draftId") draftId: string, @Param("draftId") draftId: string,
@Body() body: unknown, @Body() body: unknown,
): Promise<ScheduledPublicationRecord> { ): Promise<PublishOutcome> {
const session = requireSession(request); const session = requireSession(request);
const dto = parseScheduleDraft(body); const dto = parseScheduleDraft(body);
return this.publishing.schedule( return this.publishing.requestPublish(
session.workspaceId, session.workspaceId,
actorFrom(session), actorFrom(session),
parseId(draftId), parseId(draftId),
@ -179,17 +179,17 @@ export class PublishingController {
@HttpCode(202) @HttpCode(202)
@RequirePermission("publication.publish") @RequirePermission("publication.publish")
@RequireGroupAccess("body") @RequireGroupAccess("body")
async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> { async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
const session = requireSession(request); const session = requireSession(request);
const input = requireObject(body, "body"); const input = requireObject(body, "body");
return this.publishing.schedule(session.workspaceId, actorFrom(session), parseId(draftId), parseId(requireString(input.connectionId, "connectionId")), new Date(), "publication.publish"); return this.publishing.requestPublish(session.workspaceId, actorFrom(session), parseId(draftId), parseId(requireString(input.connectionId, "connectionId")), new Date(), "publication.publish");
} }
@Post(":draftId/retry") @Post(":draftId/retry")
@HttpCode(202) @HttpCode(202)
@RequirePermission("publication.schedule") @RequirePermission("publication.schedule")
@RequireGroupAccess("body") @RequireGroupAccess("body")
async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> { async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
return this.resume(request, draftId, body, "failed"); return this.resume(request, draftId, body, "failed");
} }
@ -197,16 +197,16 @@ export class PublishingController {
@HttpCode(202) @HttpCode(202)
@RequirePermission("publication.schedule") @RequirePermission("publication.schedule")
@RequireGroupAccess("body") @RequireGroupAccess("body")
async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> { async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
return this.resume(request, draftId, body, "needs_reauthorization"); return this.resume(request, draftId, body, "needs_reauthorization");
} }
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<ScheduledPublicationRecord> { private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<PublishOutcome> {
const session = requireSession(request); const session = requireSession(request);
const dto = parseScheduleDraft(body); const dto = parseScheduleDraft(body);
const input = requireObject(body, "body"); const input = requireObject(body, "body");
if (typeof input.expectedGeneration !== "string" || !/^[1-9][0-9]*$/.test(input.expectedGeneration)) throw new BadRequestException("expectedGeneration must be a positive decimal string"); if (typeof input.expectedGeneration !== "string" || !/^[1-9][0-9]*$/.test(input.expectedGeneration)) throw new BadRequestException("expectedGeneration must be a positive decimal string");
return this.publishing.schedule(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, new Date(dto.publishAt), "publication.schedule", { state, generation: input.expectedGeneration }); return this.publishing.requestPublish(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, new Date(dto.publishAt), "publication.schedule", { state, generation: input.expectedGeneration });
} }
@Delete(":draftId/schedule") @Delete(":draftId/schedule")

View file

@ -1,7 +1,9 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon"; import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime"; import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk"; import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram"; import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core"; import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import { Client, Connection, WorkflowExecutionAlreadyStartedError } from "@temporalio/client"; import { Client, Connection, WorkflowExecutionAlreadyStartedError } from "@temporalio/client";
@ -11,6 +13,7 @@ import { markDraftPollsStale, autoOpenDraftPoll } from "../chatgov/poll.service.
import { PgConnectionStore } from "../connections/connection-store.js"; import { PgConnectionStore } from "../connections/connection-store.js";
import { scrub } from "../audit/audit.service.js"; import { scrub } from "../audit/audit.service.js";
import { ApprovalGateService, ApprovalGateBlockedError } from "./approval-gate.service.js"; import { ApprovalGateService, ApprovalGateBlockedError } from "./approval-gate.service.js";
import { checkPublishGate, isPublishGateMode, onDraftApproved, readPublishGovernance, type ApprovalFollowUp, type PublishHold } from "./publish-governance.js";
import { pool, withTenant } from "../db/client.js"; import { pool, withTenant } from "../db/client.js";
import { userCanUseConnection } from "../groups/groups.service.js"; import { userCanUseConnection } from "../groups/groups.service.js";
import { vault } from "../vault/vault.js"; import { vault } from "../vault/vault.js";
@ -47,6 +50,9 @@ export interface ScheduledPublicationRecord {
state: "pending" | "in_flight" | "done" | "failed" | "needs_reauthorization" | "cancelled"; state: "pending" | "in_flight" | "done" | "failed" | "needs_reauthorization" | "cancelled";
} }
/** The publish/schedule routes either schedule, or hold for the publish gate. */
export type PublishOutcome = ScheduledPublicationRecord | PublishHold;
export interface Actor { export interface Actor {
userId: string; userId: string;
role: string; role: string;
@ -380,15 +386,30 @@ export class PublishingService {
*/ */
async approve(workspaceId: string, actor: Actor, draftId: string): Promise<DraftRecord> { async approve(workspaceId: string, actor: Actor, draftId: string): Promise<DraftRecord> {
const gate = this.dependencies.approvalGate ?? null; const gate = this.dependencies.approvalGate ?? null;
let followUp: ApprovalFollowUp | null = null;
try { try {
return await this.transition(workspaceId, actor, draftId, "in_review", "approved", "draft.approved", { const approved = await this.transition(workspaceId, actor, draftId, "in_review", "approved", "draft.approved", {
from: "in_review", from: "in_review",
to: "approved", to: "approved",
}, actor.userId, gate === null ? undefined : async (client: PoolClient, draft: DraftRecord) => { }, actor.userId, gate === null ? undefined : async (client: PoolClient, draft: DraftRecord) => {
const evaluation = await gate.evaluate(client, workspaceId, draftId, draft.post, actor.userId); const evaluation = await gate.evaluate(client, workspaceId, draftId, draft.post, actor.userId);
if (evaluation.blocked.length > 0) throw new ApprovalGateBlockedError(evaluation.blocked); if (evaluation.blocked.length > 0) throw new ApprovalGateBlockedError(evaluation.blocked);
await gate.recordFiredInTxn(client, workspaceId, draftId, actor.userId, evaluation.verdicts); await gate.recordFiredInTxn(client, workspaceId, draftId, actor.userId, evaluation.verdicts);
}, async (client: PoolClient, draft: DraftRecord) => {
// Publish governance of the target connection: auto schedules now,
// gate modes open the publish poll; manual connections are untouched.
followUp = await onDraftApproved(client, {
workspaceId, draftId, providerMetadata: draft.post.providerMetadata ?? {}, preferredUserId: actor.userId, actor,
}, this.dependencies.repository);
}); });
// Assigned inside the transaction callback (TS cannot see that write).
const dispatched = followUp as ApprovalFollowUp | null;
if (dispatched !== null && dispatched.kind === "dispatched") {
await this.dependencies.workflowStarter
.startScheduledPublish({ workspaceId, scheduleId: dispatched.attempt.scheduleId, generation: dispatched.attempt.generation, idempotencyKey: dispatched.attempt.idempotencyKey }, dispatched.publishAt)
.catch(() => undefined);
}
return approved;
} catch (error) { } catch (error) {
if (error instanceof ApprovalGateBlockedError) { if (error instanceof ApprovalGateBlockedError) {
// The approval rolled back; record the blocked verdicts with no draft // The approval rolled back; record the blocked verdicts with no draft
@ -408,6 +429,12 @@ export class PublishingService {
}, actor.userId); }, actor.userId);
} }
/**
* Programmatic scheduling. A connection under an admin publish gate cannot
* be scheduled around it: while its publish poll is pending this refuses
* with 409 PUBLISH_AWAITING_APPROVAL (the poll is opened/kept). The HTTP
* routes use requestPublish, which answers 202 with the hold instead.
*/
async schedule( async schedule(
workspaceId: string, workspaceId: string,
actor: Actor, actor: Actor,
@ -417,6 +444,23 @@ export class PublishingService {
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule", requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string }, expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
): Promise<ScheduledPublicationRecord> { ): Promise<ScheduledPublicationRecord> {
const outcome = await this.requestPublish(workspaceId, actor, draftId, connectionId, publishAt, requiredPermission, expectedPrevious);
if (outcome.state === "awaiting_publish_approval") {
throw new ConflictException({ code: "PUBLISH_AWAITING_APPROVAL", message: "publish is held for the connection's admin publish gate", hold: outcome });
}
return outcome;
}
/** Schedule, or hold behind the target connection's publish gate. */
async requestPublish(
workspaceId: string,
actor: Actor,
draftId: string,
connectionId: string,
publishAt: Date,
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
): Promise<PublishOutcome> {
if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp"); if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp");
await this.dependencies.assertReady(); await this.dependencies.assertReady();
@ -424,13 +468,29 @@ export class PublishingService {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
// FK checks bypass RLS, so a foreign tenant's connection id would satisfy // FK checks bypass RLS, so a foreign tenant's connection id would satisfy
// the constraint; this RLS-scoped read is the actual tenancy check. // the constraint; this RLS-scoped read is the actual tenancy check.
const connection = await client.query("SELECT provider FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId]); const connection = await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId]);
if (connection.rowCount !== 1) throw new NotFoundException(`connection ${connectionId} not found`); if (connection.rowCount !== 1) throw new NotFoundException(`connection ${connectionId} not found`);
if (!isPublishingProvider(String(connection.rows[0]?.provider))) throw new BadRequestException("provider is not enabled for publishing"); if (!isPublishingProvider(String(connection.rows[0]?.provider))) throw new BadRequestException("provider is not enabled for publishing");
if (!(await userCanUseConnection(client, workspaceId, actor.userId, connectionId))) { if (!(await userCanUseConnection(client, workspaceId, actor.userId, connectionId))) {
throw new ForbiddenException("connection is not shared with any of your groups"); throw new ForbiddenException("connection is not shared with any of your groups");
} }
// Publish-phase gate: a gated connection schedules only once the admins'
// publish poll for the current approved revision has passed.
const governance = readPublishGovernance(connection.rows[0]?.fields);
if (isPublishGateMode(governance)) {
const gate = await checkPublishGate(client, {
workspaceId, draftId, connectionId, mode: governance, requestedBy: actor.userId, publishAt, requiredPermission,
});
if (gate.kind === "held") {
await this.audit(client, workspaceId, actor, "publication.publish_held", draftId, {
connectionId, publishGovernance: governance, pollId: gate.hold.pollId, opened: gate.opened,
publishAt: publishAt.toISOString(),
});
return gate.hold;
}
}
let attempt; let attempt;
try { try {
attempt = await this.dependencies.repository.scheduleAttempt(client, { attempt = await this.dependencies.repository.scheduleAttempt(client, {
@ -448,6 +508,7 @@ export class PublishingService {
}); });
return scheduled; return scheduled;
}); });
if (scheduled.state === "awaiting_publish_approval") return scheduled;
await this.dependencies.workflowStarter await this.dependencies.workflowStarter
.startScheduledPublish({ workspaceId, scheduleId: scheduled.id, generation: scheduled.generation, idempotencyKey: scheduled.idempotencyKey }, publishAt) .startScheduledPublish({ workspaceId, scheduleId: scheduled.id, generation: scheduled.generation, idempotencyKey: scheduled.idempotencyKey }, publishAt)
.catch(() => undefined); .catch(() => undefined);
@ -500,6 +561,8 @@ export class PublishingService {
/** E1 seam: runs inside the transition's transaction, under the draft row /** E1 seam: runs inside the transition's transaction, under the draft row
* lock, before the UPDATE — may abort the whole transition by throwing. */ * lock, before the UPDATE — may abort the whole transition by throwing. */
afterLock?: (client: PoolClient, draft: DraftRecord) => Promise<void>, afterLock?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
/** Runs after the UPDATE + audit, same transaction (publish governance). */
afterTransition?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
): Promise<DraftRecord> { ): Promise<DraftRecord> {
await this.dependencies.assertReady(); await this.dependencies.assertReady();
return this.dependencies.tenantTransaction(workspaceId, async (client) => { return this.dependencies.tenantTransaction(workspaceId, async (client) => {
@ -521,6 +584,7 @@ export class PublishingService {
const draft = draftFromRow(requiredRow(result.rows[0], "transitioned draft")); const draft = draftFromRow(requiredRow(result.rows[0], "transitioned draft"));
if (to === "in_review") await autoOpenDraftPoll(client, workspaceId, draftId, actor, draft.post); if (to === "in_review") await autoOpenDraftPoll(client, workspaceId, draftId, actor, draft.post);
await this.audit(client, workspaceId, actor, action, draftId, diff); await this.audit(client, workspaceId, actor, action, draftId, diff);
if (afterTransition !== undefined) await afterTransition(client, draft);
return draft; return draft;
}); });
} }

View file

@ -79,6 +79,36 @@ describe("E3 pre-dispatch evaluation", () => {
}); });
}); });
describe("E3 publish-phase gate (connections.fields.publishGovernance)", () => {
for (const mode of ["admin1", "admin_majority", "admin_consensus"]) {
it(`${mode}: holds until the publish poll for the approved revision passed`, () => {
for (const pending of [null, "open", "failed", "vetoed", "quorum_unreachable", "stale"]) {
const outcome = evaluateE3(row({ publish_governance: mode, publish_poll_state: pending }));
expect(outcome.outcome).toBe("blocked");
expect(outcome.reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
expect(outcome.pollState).toBe(pending ?? "none");
}
expect(evaluateE3(row({ publish_governance: mode, publish_poll_state: "passed" })).outcome).toBe("fired");
});
}
it("auto and unset connections need no publish poll", () => {
expect(evaluateE3(row({ publish_governance: "auto", publish_poll_state: null })).outcome).toBe("fired");
expect(evaluateE3(row({ publish_governance: null, publish_poll_state: null })).outcome).toBe("fired");
expect(evaluateE3(row({ publish_governance: "unknown-mode", publish_poll_state: null })).outcome).toBe("fired");
});
it("a revoked approval still wins over a passed publish poll", () => {
const outcome = evaluateE3(row({ state: "editing", publish_governance: "admin1", publish_poll_state: "passed" }));
expect(outcome.reasonCodes).toEqual(["RESET_ON_CHANGE"]);
});
it("a passed publish poll does not bypass the snapshot fail-closed posture", () => {
const outcome = evaluateE3(row({ effective: null, policy_version: null, publish_governance: "admin1", publish_poll_state: "passed" }));
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
});
});
describe("reason-code vocabulary mirror", () => { describe("reason-code vocabulary mirror", () => {
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => { it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
expect(E3_REASON_CODES).toEqual([ expect(E3_REASON_CODES).toEqual([

View file

@ -49,6 +49,13 @@ export type ReasonCode = (typeof E3_REASON_CODES)[number];
export const E3_ACTOR = "system:gate-e3"; export const E3_ACTOR = "system:gate-e3";
/**
* Publish-phase gate modes (connections.fields.publishGovernance), mirrored
* from apps/api/src/publishing/publish-governance.ts across the app package
* boundary. "auto" and unset need no publish poll.
*/
export const PUBLISH_GATE_MODES = ["admin1", "admin_majority", "admin_consensus"] as const;
/** DB column order for the re-check query. */ /** DB column order for the re-check query. */
interface E3Row { interface E3Row {
state: string | null; state: string | null;
@ -58,6 +65,10 @@ interface E3Row {
effective: { mode: "immediate" | "quorum" | "admin_only" } | null; effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
policy_version: string | null; policy_version: string | null;
poll_state: string | null; poll_state: string | null;
/** connections.fields->>'publishGovernance' of the attempt's destination. */
publish_governance?: string | null;
/** Latest publish poll for (draft, destination, approved revision). */
publish_poll_state?: string | null;
} }
export interface E3Outcome { export interface E3Outcome {
@ -87,6 +98,22 @@ export function evaluateE3(row: E3Row | undefined): E3Outcome {
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null }; return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
} }
// 1b) Publish-phase gate: a gated destination dispatches only after the
// admins' publish poll for THIS approved revision passed. Attempts are
// normally created by that pass; this re-check also catches schedules
// made before the connection was switched to a gate mode.
if ((PUBLISH_GATE_MODES as readonly string[]).includes(row.publish_governance ?? "")
&& row.publish_poll_state !== "passed") {
return {
outcome: "blocked",
reasonCodes: [reason("INSUFFICIENT_QUORUM"), reason("PENDING_POLL")],
policyVersion: row.policy_version ?? "none",
mode: row.effective?.mode ?? "none",
approvedHash,
pollState: row.publish_poll_state ?? "none",
};
}
// 2) A compiled snapshot exists for this destination. None => fail closed // 2) A compiled snapshot exists for this destination. None => fail closed
// (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send). // (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send).
if (row.effective === null || row.policy_version === null) { if (row.effective === null || row.policy_version === null) {
@ -159,7 +186,12 @@ export class DispatchGateService {
SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id, SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id,
s.effective, s.policy_version, s.effective, s.policy_version,
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled' (SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled'
ORDER BY p.created_at DESC LIMIT 1) AS poll_state AND p.subject_kind <> 'draft_publish'
ORDER BY p.created_at DESC LIMIT 1) AS poll_state,
(SELECT c.fields->>'publishGovernance' FROM connections c WHERE c.id = $2::uuid) AS publish_governance,
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.subject_kind = 'draft_publish'
AND p.publish_connection_id = $2::uuid AND p.draft_revision = d.approved_revision
ORDER BY p.created_at DESC LIMIT 1) AS publish_poll_state
FROM drafts d FROM drafts d
LEFT JOIN posting_policy_snapshots s LEFT JOIN posting_policy_snapshots s
ON s.workspace_id = d.workspace_id ON s.workspace_id = d.workspace_id
@ -168,7 +200,8 @@ export class DispatchGateService {
AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid
WHERE d.id = (SELECT draft_id FROM attempt) WHERE d.id = (SELECT draft_id FROM attempt)
) )
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state FROM target`, SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state,
publish_governance, publish_poll_state FROM target`,
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null], [args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
); );
const row = result.rows[0] as E3Row | undefined; const row = result.rows[0] as E3Row | undefined;

View file

@ -85,6 +85,14 @@ describe("E3 gate in the dispatch path", () => {
expect(h.publish).not.toHaveBeenCalled(); expect(h.publish).not.toHaveBeenCalled();
}); });
it("publish gate pending (admin poll not passed): known no-send with zero HTTP requests", async () => {
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: "open" });
const h = harness({ kind: "claimed", job }, gate);
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_INSUFFICIENT_QUORUM" });
expect(h.fetchImpl).not.toHaveBeenCalled();
expect(h.publish).not.toHaveBeenCalled();
});
it("gate evaluated per destination with the lease's idempotency key", async () => { it("gate evaluated per destination with the lease's idempotency key", async () => {
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null }); const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
const h = harness({ kind: "claimed", job }, gate); const h = harness({ kind: "claimed", job }, gate);

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge, Button } from "../../../components/ui/primitives"; import { Badge, Button } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge"; import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state"; import { EmptyState } from "../../../components/brand/empty-state";
@ -9,7 +9,7 @@ import { api, errorMessage } from "../../../lib/api-client";
import { PLATFORMS } from "../../../lib/platforms"; import { PLATFORMS } from "../../../lib/platforms";
import { useAuth } from "../../../lib/auth-store"; import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api"; import { useApiResource } from "../../../lib/use-api";
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types"; import type { ConnectionSummary, DraftRecord, PublicationsResponse } from "../../../lib/api-types";
/** /**
* Calendar = the scheduled slice of the publishing pipeline. There is no * Calendar = the scheduled slice of the publishing pipeline. There is no
@ -119,6 +119,19 @@ function relativeLabel(iso: string, now: number): { text: string; overdue: boole
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 }; return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
} }
/**
* The newest dispatch receipt wins: the API orders publications by attempt
* number descending, and a retry's outcome supersedes the earlier attempt.
*/
function latestPublication(response: PublicationsResponse | null | undefined): {
state: string;
remoteUrl: string | null;
} | null {
const latest = response?.publications[0];
if (latest === undefined) return null;
return { state: latest.state, remoteUrl: latest.remoteUrl ?? null };
}
export default function CalendarPage(): React.ReactElement { export default function CalendarPage(): React.ReactElement {
const { can } = useAuth(); const { can } = useAuth();
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, { const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement {
const [notice, setNotice] = useState<Notice | null>(null); const [notice, setNotice] = useState<Notice | null>(null);
const [now, setNow] = useState<number>(Date.now()); const [now, setNow] = useState<number>(Date.now());
// Dispatch receipts per draft: one GET per scheduled draft (the API has no
// bulk endpoint), refreshed with the list. Only drafts that actually have
// publications surface a badge.
const [publications, setPublications] = useState<Record<string, PublicationsResponse | null>>({});
const fetchPublications = useCallback((ids: string[]) => {
if (!live || ids.length === 0) return;
for (const id of ids) {
void api
.get<PublicationsResponse>(`/publishing/drafts/${id}/publications`)
.then((response) => setPublications((current) => ({ ...current, [id]: response })))
.catch(() => setPublications((current) => ({ ...current, [id]: null })));
}
}, [live]);
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
const groups = useMemo(() => groupByDay(rows), [rows]);
// The scheduled set as a stable string so the fetch effect below only fires
// when it actually changes, and the timer below always reads the latest ids.
const draftIds = rows.map((row) => row.id);
const draftIdsRef = useRef<string[]>([]);
draftIdsRef.current = draftIds;
const draftIdsKey = draftIds.join(",");
// Fetch receipts on load and whenever the scheduled set changes (a new
// schedule appears, or one is cancelled).
useEffect(() => {
fetchPublications(draftIdsKey === "" ? [] : draftIdsKey.split(","));
}, [fetchPublications, draftIdsKey]);
// Same cadence as the streams wall: refresh on a 30s timer. // Same cadence as the streams wall: refresh on a 30s timer.
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
drafts.reload(); drafts.reload();
connections.reload(); connections.reload();
fetchPublications(draftIdsRef.current);
setNow(Date.now()); setNow(Date.now());
}, 30_000); }, 30_000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [drafts.reload, connections.reload]); }, [drafts.reload, connections.reload, fetchPublications]);
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
const groups = useMemo(() => groupByDay(rows), [rows]);
async function cancel(row: ScheduledRow): Promise<void> { async function cancel(row: ScheduledRow): Promise<void> {
if (!live) { if (!live) {
@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement {
<span className="ml-auto text-2xs text-text-faint"> <span className="ml-auto text-2xs text-text-faint">
{rows.length} scheduled · refreshes every 30s {rows.length} scheduled · refreshes every 30s
</span> </span>
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}> <Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); fetchPublications(draftIdsRef.current); setNow(Date.now()); }}>
Refresh Refresh
</Button> </Button>
</div> </div>
<p className="mb-4 text-xs text-text-faint"> <p className="mb-4 text-xs text-text-faint">
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
UTC time beside it; execution state lives in the audit log, not this list. UTC time beside it; drafts with dispatch receipts show their latest outcome, with the remote link when the
provider returned one.
</p> </p>
{notice !== null && ( {notice !== null && (
@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface"> <ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
{group.rows.map((row) => { {group.rows.map((row) => {
const relative = relativeLabel(row.publishAt, now); const relative = relativeLabel(row.publishAt, now);
const latest = latestPublication(publications[row.id]);
return ( return (
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4"> <li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
<div className="flex w-44 shrink-0 flex-col gap-0.5"> <div className="flex w-44 shrink-0 flex-col gap-0.5">
@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p> <p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint"> <div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
<Badge tone="accent">scheduled</Badge> <Badge tone="accent">scheduled</Badge>
{latest !== null && (
<Badge tone={latest.state === "completed" ? "live" : latest.state === "failed" ? "danger" : "warn"}>
{latest.state.replace(/_/g, " ")}
{latest.remoteUrl !== null && (
<a href={latest.remoteUrl} target="_blank" rel="noreferrer" className="underline decoration-current/40 underline-offset-2 hover:decoration-current">
open ↗
</a>
)}
</Badge>
)}
<span>{row.targetLabel}</span> <span>{row.targetLabel}</span>
{!row.fromScheduledAt && ( {!row.fromScheduledAt && (
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update."> <span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">

View file

@ -164,3 +164,26 @@ export interface ScheduledPublicationRecord {
publishAt: string; publishAt: string;
state: "pending" | "in_flight" | "done" | "failed"; state: "pending" | "in_flight" | "done" | "failed";
} }
/** One dispatch attempt for a draft, newest first — GET /publishing/drafts/:draftId/publications. */
export interface DraftPublication {
id: string;
connectionId: string;
attemptNo: string;
dispatchCount: number;
state: "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned";
createdAt: string;
updatedAt: string;
firstDispatchStartedAt?: string;
dispatchStartedAt?: string;
providerRetryDeadline?: string;
receivedAt?: string;
publishedAt?: string;
remoteId?: string;
remoteUrl?: string;
error?: string;
}
export interface PublicationsResponse {
publications: DraftPublication[];
}

View file

@ -20,13 +20,17 @@ nginx edge mirrors the tunnel ingress by host:
- `app.occupymedia.cc` → `web:3001` (occ-app compose network) - `app.occupymedia.cc` → `web:3001` (occ-app compose network)
- `api.occupymedia.cc` → `api:3000` - `api.occupymedia.cc` → `api:3000`
- `tube.occupymedia.cc` → `100.92.231.98:9000` (occ-tube via Tailscale) - `tube.occupymedia.cc` → `100.92.231.98:9000` (occ-tube via Tailscale)
- `chat.occupymedia.cc` → `100.117.210.52:6167` (occ-chat conduwuit) - `chat.occupymedia.cc` → `occ-element:80` (client); `/_matrix` → `100.117.210.52:6167` (occ-chat conduwuit)
- `ping.occupymedia.cc` → 200 "edge-ok" (activation probe target) - `ping.occupymedia.cc` → 200 "edge-ok" (activation probe target)
- unknown SNI → `ssl_reject_handshake` - unknown SNI → `ssl_reject_handshake`
TLS: Let's Encrypt wildcard `*.occupymedia.cc` via dns-01 (no port 80 needed). TLS: Let's Encrypt wildcard `*.occupymedia.cc` via dns-01 (no port 80 needed).
Renewal: `occ-edge-certbot.timer`, twice daily, reloads nginx. Renewal: `occ-edge-certbot.timer`, twice daily, reloads nginx.
Chat's signed-out client uses the Hub auth stylesheet on both ingress paths.
See the [Element auth restyle report](../element/AUTH-RESTYLE.md) for verified
1.12.30 configuration keys, the config merge patch, and exact deployment steps.
## Files ## Files
| File | Role | | File | Role |

View file

@ -11,6 +11,7 @@ services:
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro
- /opt/occ-brand:/opt/occ-brand:ro - /opt/occ-brand:/opt/occ-brand:ro
- ./hub-sso:/opt/hub-sso:ro - ./hub-sso:/opt/hub-sso:ro
- ./hub-chat-auth.css:/opt/occ-chat-brand/hub-chat-auth.css:ro
- ./certs:/etc/letsencrypt:ro - ./certs:/etc/letsencrypt:ro
networks: networks:

View file

@ -0,0 +1,228 @@
/* Element Web 1.12.30 auth layout overrides, loaded by the chat edge vhost.
* Keep every rule inside mx_AuthPage: the signed-in client keeps its existing
* custom-OccupyHub Dark theme and Compound tokens. No hashed selectors or JS.
* branding.auth_header_logo_url supplies the small Hub mark; see ../element/.
*/
.mx_AuthPage {
position: relative;
box-sizing: border-box;
min-height: 100vh;
min-height: 100dvh;
height: auto !important;
padding: 88px 20px 24px;
background: #060403 !important; /* Also replaces the inline background image. */
color: #f5efed;
color-scheme: dark;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif !important;
}
.mx_AuthPage .mx_AuthPage_modal {
/* AuthHeader's absolute positioning is relative to AuthPage, not the card. */
box-sizing: border-box;
position: static !important;
flex: 0 0 auto;
width: 420px;
max-width: 100%;
margin: auto;
border: 1px solid #2a2320;
border-radius: 10px;
background: #0f0a09 !important;
box-shadow: none !important;
}
.mx_AuthPage .mx_AuthPage_modalBlur {
display: none !important;
}
.mx_AuthPage .mx_AuthPage_modalContent {
flex-direction: column;
width: 100%;
min-width: 0;
background: #0f0a09 !important;
}
/* Turn the old logo sidebar into quiet branding in the top-left corner.
* Re-enable it on phones: Element normally hides AuthHeader below 480px.
*/
.mx_AuthPage .mx_AuthHeader {
position: absolute;
inset: 24px auto auto 24px;
display: flex !important;
flex-direction: row;
align-items: center;
width: auto;
max-width: calc(100% - 48px);
padding: 0;
}
.mx_AuthPage .mx_AuthHeaderLogo {
display: flex !important;
flex: 0 1 auto;
align-items: center;
gap: 10px;
min-width: 0;
margin: 0;
padding: 0;
}
.mx_AuthPage .mx_AuthHeaderLogo img {
display: block;
flex: 0 0 32px;
width: 32px !important;
height: 32px !important;
max-width: 32px;
object-fit: contain;
}
.mx_AuthPage .mx_AuthHeaderLogo::after {
content: "Tactical Media Broadcast Hub";
color: #f5efed;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
}
.mx_AuthPage .mx_AuthBody {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 0;
padding: 24px;
border-radius: inherit;
background: #0f0a09 !important;
color: #c0b4af;
font-size: 14px;
line-height: 1.5;
}
.mx_AuthPage .mx_AuthBody h1 {
margin: 0 0 20px;
color: #f5efed;
font-size: 20px;
line-height: 1.3;
}
.mx_AuthPage .mx_Register_mainContent {
min-height: 0;
}
/* With disable_custom_urls=true this is only a redundant homeserver label.
* Keep the picker visible if a future config allows editing the server.
*/
.mx_AuthPage .mx_ServerPicker:not(:has(.mx_ServerPicker_change)) {
display: none;
}
.mx_AuthPage .mx_Field:not(.mx_Field_valid):not(.mx_Field_invalid) {
border-color: #3a322d;
}
.mx_AuthPage .mx_Field:focus-within {
border-color: #ff1610;
}
.mx_AuthPage .mx_Field :is(input, select, textarea) {
background: #0f0a09 !important;
color: #f5efed !important;
font-family: inherit;
font-size: 16px; /* Avoid iOS zoom when focusing a field. */
}
.mx_AuthPage .mx_Field label {
background-color: #0f0a09 !important;
color: #c0b4af;
font-family: inherit;
}
.mx_AuthPage :is(a, .mx_AccessibleButton_kind_link, .mx_AccessibleButton_kind_link_inline) {
color: #c0b4af !important;
}
.mx_AuthPage :is(a, button, [role="button"]):focus-visible {
outline: 2px solid #ff1610;
outline-offset: 3px;
}
/* Compound's data-kind is stable; Element's submit class covers older buttons. */
.mx_AuthPage :is(button[type="submit"], .mx_Login_submit, [data-kind="primary"]) {
min-height: 36px;
height: auto;
border: 1px solid transparent;
border-radius: 6px;
background: #ff1610 !important;
color: #fff !important;
font-family: inherit;
font-weight: 600;
}
.mx_AuthPage :is(button[type="submit"], .mx_Login_submit, [data-kind="primary"]):hover:not(:disabled):not([aria-disabled="true"]) {
background: #e3130e !important;
}
.mx_AuthPage :is(button[type="submit"], .mx_Login_submit, [data-kind="primary"]):is(:disabled, [aria-disabled="true"]) {
opacity: 0.45;
}
.mx_AuthPage .mx_AuthFooter {
box-sizing: border-box;
margin-top: 24px;
padding: 0;
background: none;
font-family: inherit;
font-size: 12px;
}
.mx_AuthPage .mx_AuthFooter a {
display: inline-block;
margin: 4px 10px;
}
/* Defensive fallback while config is being rolled out: no welcome hero. */
.mx_AuthPage .mx_DefaultWelcome {
width: 100%;
}
.mx_AuthPage .mx_DefaultWelcome > :is(h1, p),
.mx_AuthPage .mx_DefaultWelcome_logo {
display: none;
}
.mx_AuthPage .mx_DefaultWelcome_buttons {
margin: 0;
padding: 0;
border: 0;
}
.mx_AuthPage .mx_DefaultWelcome_buttons a {
box-sizing: border-box;
width: 100%;
}
@media (max-width: 480px) {
.mx_AuthPage {
padding-inline: 16px;
}
.mx_AuthPage .mx_AuthHeader {
inset-inline-start: 16px;
max-width: calc(100% - 32px);
}
.mx_AuthPage .mx_AuthBody {
padding: 20px;
}
.mx_AuthPage .mx_Register_footerActions {
flex-wrap: wrap;
gap: 12px;
}
.mx_AuthPage .mx_AuthBody_fieldRow {
flex-direction: column;
gap: 12px;
}
.mx_AuthPage .mx_AuthBody_fieldRow > .mx_Field {
margin: 0;
}
}

View file

@ -103,6 +103,19 @@ http {
listen [::]:80; listen [::]:80;
server_name chat.occupymedia.cc; server_name chat.occupymedia.cc;
# Tunnel origin path: the branded client on this host, Matrix API to the homeserver. # Tunnel origin path: the branded client on this host, Matrix API to the homeserver.
# Element redirects first-time phone visitors here before loading config.
# Send them to the form rather than the separate Element app-download hero.
location = /mobile_guide/ {
# Preserve the browser's HTTPS origin even on the tunnel's HTTP hop.
absolute_redirect off;
return 302 /#/login;
}
location = /hub-chat-auth.css {
alias /opt/occ-chat-brand/hub-chat-auth.css;
default_type text/css;
add_header X-Served-By occ-edge always;
add_header Cache-Control "no-cache" always;
}
location ^~ /_matrix { location ^~ /_matrix {
proxy_pass http://100.117.210.52:6167; proxy_pass http://100.117.210.52:6167;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -131,6 +144,12 @@ http {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Hash routes never reach nginx. Inject into the SPA HTML shell; the
# stylesheet only targets mx_AuthPage and leaves signed-in rooms alone.
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<link rel="stylesheet" href="/hub-chat-auth.css?v=1"></head>';
sub_filter_once on;
# text/html is sub_filter's default type; JSON/JS/CSS are not rewritten.
proxy_read_timeout 60s; proxy_read_timeout 60s;
} }
} }
@ -143,6 +162,17 @@ http {
ssl_certificate /etc/letsencrypt/live/occupymedia.cc/fullchain.pem; ssl_certificate /etc/letsencrypt/live/occupymedia.cc/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/occupymedia.cc/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/occupymedia.cc/privkey.pem;
location = /mobile_guide/ {
absolute_redirect off;
return 302 /#/login;
}
location = /hub-chat-auth.css {
alias /opt/occ-chat-brand/hub-chat-auth.css;
default_type text/css;
add_header X-Served-By occ-edge always;
add_header Cache-Control "no-cache" always;
}
location ^~ /_matrix { location ^~ /_matrix {
proxy_pass http://100.117.210.52:6167; proxy_pass http://100.117.210.52:6167;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -176,6 +206,9 @@ http {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<link rel="stylesheet" href="/hub-chat-auth.css?v=1"></head>';
sub_filter_once on;
proxy_read_timeout 60s; proxy_read_timeout 60s;
} }
} }

View file

@ -0,0 +1,215 @@
# Chat sign-in restyle and deployment report
Repo implementation only; no production connection, deployment, or git commit
was made. The server-owned `/opt/occ-element/config.json` is not in this repo.
The supplied patch merges into that file rather than replacing its configuration.
Signed-out visitors opening `chat.occupymedia.cc` will see a small Hub mark and
wordmark in the top-left, a centered card containing **Sign in**, username and
password fields, and a red submit button. Password recovery and account creation
remain available as quiet links. Registration uses the same compact card. The
background is `#060403`, the card is `#0F0A09`, and the font is system-ui. The
mark is capped at 32px; there is no logo sidebar, background artwork, or hero.
Long forms and errors scroll on small screens rather than overlapping the brand.
Element's footer remains small and muted.
## Verified Element Web 1.12.30 behavior
No Element source or config template was found in the repository or its installed
dependencies. Research used the official **v1.12.30** source archive and the
matching `ghcr.io/element-hq/element-web:v1.12.30` image, in temporary local files.
| Configuration | Effect in 1.12.30 |
| --- | --- |
| `branding.auth_header_logo_url` | Image source in login/registration headers and the default welcome component. This is nested under `branding`, not a top-level key. |
| `branding.logo_link_url` | Link destination where Element actually renders a linked logo, such as default welcome. |
| `auth_header_logo_click_url` | Does not exist in this release. The login/registration header renders an unlinked image; a config option cannot make that header clickable. |
| `brand` | App name used in translated text; does not change the header's layout or image. |
| `branding.welcome_background_url` | Background artwork across auth pages. The injected CSS replaces the inline background with a solid color. |
| `embedded_pages.login_for_welcome` | Uses the login form for the signed-out landing page, bypassing default/custom welcome content. |
| `default_server_config` | Selects the homeserver and identity-server connection details; does not control branding or layout. Left intact. |
| `disable_custom_urls` | Removes server editing. The CSS hides the redundant server label only when the edit control is absent. |
| `disable_3pid_login` | Removes the username/email/phone selector, leaving username/password. |
| `disable_login_language_selector` | Removes the language dropdown from the auth header. |
See the pinned [configuration docs](https://github.com/element-hq/element-web/blob/v1.12.30/docs/config.md),
[AuthHeaderLogo source](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/components/views/auth/AuthHeaderLogo.tsx),
and [DefaultWelcome source](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/components/views/auth/DefaultWelcome.tsx).
The structure is `.mx_AuthPage` → `.mx_AuthPage_modal` →
`main.mx_AuthPage_modalContent`, with `.mx_AuthHeader` and `.mx_AuthBody` as
siblings. The large logo comes from `.mx_AuthHeaderLogo > img` inside a 206px
sidebar; the original body is 500px wide. The wrapper also renders a blur layer
and `.mx_AuthFooter`. See [AuthPage](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/components/views/auth/AuthPage.tsx)
and [upstream auth CSS](https://github.com/element-hq/element-web/tree/v1.12.30/apps/web/res/css/views/auth).
There is no `.mx_AuthPage_logo` in this release's auth structure.
`setting_defaults.custom_themes` accepts theme colors, fonts and Compound token
overrides, not an arbitrary CSS stylesheet URL. `source_url` records a theme
JSON's origin, not a CSS file. The injected link intentionally has no
`data-mx-theme`, so Element's theme switching will not disable it. See the
[theming docs](https://github.com/element-hq/element-web/blob/v1.12.30/docs/theming.md)
and [theme loader/types](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/theme.ts).
On first visits from iOS/Android, Element redirects a bare `/` to
`/mobile_guide/` **before** loading config. Both chat vhosts redirect that
app-download page to `/#/login`, which bypasses the mobile redirect. Disabling
`mobile_guide_toast` also removes the later mobile-app prompt. See the pinned
[startup source](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/vector/index.ts).
## Files changed
| File | Purpose |
| --- | --- |
| [../edge/hub-chat-auth.css](../edge/hub-chat-auth.css) | Scoped auth overrides: corner branding, compact responsive card, warm colors, focus styles and red primary actions. Uses stable `mx_*` classes, structural selectors and Compound `data-kind`, with no hashed class selectors. |
| [../edge/nginx.conf](../edge/nginx.conf) | Both HTTP tunnel and HTTPS chat vhosts serve `/hub-chat-auth.css` and inject its link once before `</head>` in HTML. Clears upstream Accept-Encoding so sub_filter can read HTML. Routes the mobile guide to login. Matrix API and SSO helper keep their own locations. |
| [../edge/docker-compose.edge.yml](../edge/docker-compose.edge.yml) | Read-only bind mount for the stylesheet. |
| [auth-branding.patch.json](auth-branding.patch.json) | Hub logo URL, supported logo-link setting, direct login landing and removal of extra login selectors. |
| [apply-auth-branding.py](apply-auth-branding.py) | Merge/preview utility. Preserves unrelated settings and writes in place to retain Docker's single-file bind mount. |
| [../edge/README.md](../edge/README.md) | Links this report and corrects the documented chat routing. |
| [AUTH-RESTYLE.md](AUTH-RESTYLE.md) | This research and deployment report. |
The stylesheet is downloaded with the SPA shell; nginx cannot see hash routes.
It applies only within `.mx_AuthPage`, including recovery/auth verification
screens using that wrapper. Signed-in room UI keeps the existing custom theme.
The `element.occupymedia.cc` alias vhost is not changed.
## Exact deployment steps (operator runs these; not executed here)
Use the existing CT 150 checkout at `/opt/anarchoposter`. Run server commands
with permission to edit `/opt/occ-element/config.json` and manage Docker.
1. **On the server, before copying files**, back up the config and edge files.
Keep this shell open so `occ_auth_backup` remains available for later steps.
```bash
occ_auth_backup="/opt/occ-element/backups/hub-auth-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$occ_auth_backup"
cp -p /opt/occ-element/config.json "$occ_auth_backup/config.json"
cp -p /opt/anarchoposter/infra/occupymedia/edge/nginx.conf "$occ_auth_backup/nginx.conf"
cp -p /opt/anarchoposter/infra/occupymedia/edge/docker-compose.edge.yml "$occ_auth_backup/docker-compose.edge.yml"
if test -f /opt/anarchoposter/infra/occupymedia/edge/hub-chat-auth.css; then
cp -p /opt/anarchoposter/infra/occupymedia/edge/hub-chat-auth.css "$occ_auth_backup/hub-chat-auth.css"
fi
printf 'Backup: %s\n' "$occ_auth_backup"
```
2. **From this workstation's repo root**, copy only the reviewed infra files.
Set `OCC_EDGE_SSH` to your existing SSH destination for CT 150; no hostname or
credentials are assumed here.
```bash
OCC_EDGE_SSH='YOUR_EXISTING_CT_150_SSH_DESTINATION'
rsync -aR -- \
infra/occupymedia/edge/nginx.conf \
infra/occupymedia/edge/docker-compose.edge.yml \
infra/occupymedia/edge/hub-chat-auth.css \
infra/occupymedia/edge/README.md \
infra/occupymedia/element/ \
"$OCC_EDGE_SSH:/opt/anarchoposter/"
```
3. **On the server**, validate the candidate edge config with the new mounts,
then merge the auth patch and recreate only the edge service:
```bash
cd /opt/anarchoposter/infra/occupymedia/edge
docker compose -f docker-compose.edge.yml config --quiet
docker compose -f docker-compose.edge.yml run --rm --no-deps edge nginx -t
python3 ../element/apply-auth-branding.py /opt/occ-element/config.json > "$occ_auth_backup/config.preview.json"
diff -u "$occ_auth_backup/config.json" "$occ_auth_backup/config.preview.json"
python3 ../element/apply-auth-branding.py /opt/occ-element/config.json --write
docker compose -f docker-compose.edge.yml up -d --no-deps --force-recreate edge
docker exec occ-edge-1 nginx -t
```
Run each command only if the preceding validation succeeded; `diff` exits 1
for the expected changes. Recreating the edge is required for the new CSS
mount and ensures it sees copied files even if their inodes changed. Element
needs no restart: the merge utility retains the config inode. Its existing
`setting_defaults.custom_themes`, Compound tokens, `default_theme`, and
`default_server_config` remain intact. Do not replace config.json with the
patch JSON or try to register this CSS as a custom theme.
4. **On the server**, verify the public HTML, stylesheet and served config:
```bash
curl -fsS --compressed https://chat.occupymedia.cc/ -o "$occ_auth_backup/index.served.html"
curl -fsS 'https://chat.occupymedia.cc/hub-chat-auth.css?v=1' -o "$occ_auth_backup/auth.served.css"
curl -fsS https://chat.occupymedia.cc/config.json -o "$occ_auth_backup/config.served.json"
cmp hub-chat-auth.css "$occ_auth_backup/auth.served.css"
python3 - "$occ_auth_backup" <<'PY'
import json, sys
from pathlib import Path
backup = Path(sys.argv[1])
html = (backup / "index.served.html").read_text()
assert html.count('href="/hub-chat-auth.css?v=1"') == 1
old = json.loads((backup / "config.json").read_text())
served = json.loads((backup / "config.served.json").read_text())
for key in ("setting_defaults", "default_theme", "default_server_config", "default_server_name"):
assert served.get(key) == old.get(key), key
assert served["branding"]["auth_header_logo_url"] == "https://app.occupymedia.cc/brand/logo.svg"
assert served["branding"]["logo_link_url"] == "https://app.occupymedia.cc"
assert served["embedded_pages"]["login_for_welcome"] is True
assert served["disable_3pid_login"] is True
assert served["disable_custom_urls"] is True
print("Public HTML injection, stylesheet and preserved theme verified")
PY
```
If a CDN serves an older HTML/config response, purge those URLs and retry.
In a fresh signed-out browser, open `/`, `/#/login` and `/#/register`, and
check the page on a phone as well. Check password errors, keyboard focus,
recovery and an existing signed-in room. Browser reloads load the new config.
Element also supports domain-specific config files; if this installation
starts serving a successful `/config.chat.occupymedia.cc.json`, apply the
same merge to its mounted source because it takes precedence over config.json.
Rollback using the printed backup directory: restore the old nginx and compose
files, restore the old config **in place**, then recreate the edge service with
the old compose file:
```bash
cp -p "$occ_auth_backup/nginx.conf" /opt/anarchoposter/infra/occupymedia/edge/nginx.conf
cp -p "$occ_auth_backup/docker-compose.edge.yml" /opt/anarchoposter/infra/occupymedia/edge/docker-compose.edge.yml
if test -f "$occ_auth_backup/hub-chat-auth.css"; then
cp -p "$occ_auth_backup/hub-chat-auth.css" /opt/anarchoposter/infra/occupymedia/edge/hub-chat-auth.css
fi
python3 - "$occ_auth_backup/config.json" /opt/occ-element/config.json <<'PY'
from pathlib import Path
import sys
data = Path(sys.argv[1]).read_bytes()
with Path(sys.argv[2]).open("r+b") as target:
target.write(data)
target.truncate()
PY
cd /opt/anarchoposter/infra/occupymedia/edge
docker compose -f docker-compose.edge.yml run --rm --no-deps edge nginx -t
docker compose -f docker-compose.edge.yml up -d --no-deps --force-recreate edge
```
## Local validation
- Real Element v1.12.30 and nginx:1.27-alpine ran on an isolated local Docker
network with temporary certificates and loopback ports; Matrix responses and
the Hub asset request were mocked. No live accounts or production services
were accessed.
- Full repo nginx config passed `nginx -t`; compose configuration parsed.
Nginx emits an existing duplicate `text/html` MIME warning in the unrelated
files sign-in location; it does not prevent validation.
- Both chat HTTP/HTTPS vhosts injected exactly one stylesheet link despite the
client advertising gzip. JSON and the SSO helper were unchanged; CSS content
and content type matched the repo file.
- Chromium checked bare `/` → login, 420px desktop card, 32px corner logo,
system-ui, warm background, red submit, two login inputs, mock failed login,
visible errors, and registration. Checked widths 320/390/844px including
landscape: no horizontal overflow or brand/form overlap. A fresh iPhone user
agent reached the form through the mobile-guide redirect.
- The injected link remained enabled after custom-theme loading. A room DOM
without the auth wrapper retained its original button color.
- Config merge checks confirmed preservation of theme, Compound tokens,
homeserver and sibling keys, idempotence, and the original file inode.
Real successful authentication and production cache/routing behavior still need
the operator's post-deployment checks. Existing unrelated workspace changes were
left alone; no files in `apps/api` were edited by this task.

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Merge the auth-only patch into an existing, file-bind-mounted Element config."""
import argparse
import copy
import json
from pathlib import Path
def merge(current, patch):
for key, value in patch.items():
if isinstance(value, dict):
if not isinstance(current.get(key), dict):
current[key] = {}
merge(current[key], value)
else:
current[key] = value
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("config", type=Path)
parser.add_argument("--write", action="store_true", help="write in place; otherwise print a preview")
args = parser.parse_args()
patch = json.loads(Path(__file__).with_name("auth-branding.patch.json").read_text())
# r+ intentionally retains the inode: replacing this file would leave an
# existing Docker single-file bind mount serving the old config.
with args.config.open("r+" if args.write else "r", encoding="utf-8") as config_file:
current = json.load(config_file)
if not isinstance(current, dict):
raise ValueError("Element config must be a JSON object")
original = copy.deepcopy(current)
merge(current, patch)
for key in ("setting_defaults", "default_theme", "default_server_config", "default_server_name"):
assert current.get(key) == original.get(key), f"Auth patch must preserve {key}"
output = json.dumps(current, indent=2, ensure_ascii=False) + "\n"
if args.write:
config_file.seek(0)
config_file.write(output)
config_file.truncate()
else:
print(output, end="")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,14 @@
{
"brand": "Tactical Media Broadcast Hub",
"branding": {
"auth_header_logo_url": "https://app.occupymedia.cc/brand/logo.svg",
"logo_link_url": "https://app.occupymedia.cc"
},
"embedded_pages": {
"login_for_welcome": true
},
"disable_custom_urls": true,
"disable_3pid_login": true,
"disable_login_language_selector": true,
"mobile_guide_toast": false
}