From b89dea5c937498d2d1c2cf56fff8df4b37cb6da2 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Wed, 14 Oct 2026 08:04:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20per-connection=20publish=20governance?= =?UTF-8?q?=20=E2=80=94=20auto,=201-admin,=20majority,=20consensus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/migrations/033_publish_governance.sql | 40 ++ apps/api/src/chatgov/poll.service.ts | 22 +- apps/api/src/connections/connection-store.ts | 23 +- .../src/connections/connections.controller.ts | 24 +- .../connections.integration.test.ts | 24 ++ .../src/connections/connections.service.ts | 6 + .../approval-gate.integration.test.ts | 2 +- .../src/publishing/approval-gate.service.ts | 2 +- apps/api/src/publishing/poll-approval.ts | 9 + .../publish-governance.integration.test.ts | 282 ++++++++++++++ .../src/publishing/publish-governance.test.ts | 93 +++++ apps/api/src/publishing/publish-governance.ts | 345 ++++++++++++++++++ .../src/publishing/publishing.controller.ts | 18 +- apps/api/src/publishing/publishing.service.ts | 68 +++- .../src/activities/dispatch-gate.test.ts | 30 ++ .../scheduler/src/activities/dispatch-gate.ts | 37 +- .../activities/publish.activity.gate.test.ts | 8 + apps/web/src/app/(main)/calendar/page.tsx | 69 +++- apps/web/src/lib/api-types.ts | 23 ++ infra/occupymedia/edge/README.md | 6 +- .../occupymedia/edge/docker-compose.edge.yml | 1 + infra/occupymedia/edge/hub-chat-auth.css | 228 ++++++++++++ infra/occupymedia/edge/nginx.conf | 33 ++ infra/occupymedia/element/AUTH-RESTYLE.md | 215 +++++++++++ .../element/apply-auth-branding.py | 46 +++ .../element/auth-branding.patch.json | 14 + 26 files changed, 1637 insertions(+), 31 deletions(-) create mode 100644 apps/api/migrations/033_publish_governance.sql create mode 100644 apps/api/src/publishing/publish-governance.integration.test.ts create mode 100644 apps/api/src/publishing/publish-governance.test.ts create mode 100644 apps/api/src/publishing/publish-governance.ts create mode 100644 infra/occupymedia/edge/hub-chat-auth.css create mode 100644 infra/occupymedia/element/AUTH-RESTYLE.md create mode 100644 infra/occupymedia/element/apply-auth-branding.py create mode 100644 infra/occupymedia/element/auth-branding.patch.json diff --git a/apps/api/migrations/033_publish_governance.sql b/apps/api/migrations/033_publish_governance.sql new file mode 100644 index 0000000..f2dd27e --- /dev/null +++ b/apps/api/migrations/033_publish_governance.sql @@ -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'; diff --git a/apps/api/src/chatgov/poll.service.ts b/apps/api/src/chatgov/poll.service.ts index b30de16..d287ef1 100644 --- a/apps/api/src/chatgov/poll.service.ts +++ b/apps/api/src/chatgov/poll.service.ts @@ -6,6 +6,7 @@ import { scrub } from '../audit/audit.service.js'; import { draftDestinationAccounts } from '../publishing/approval-gate.service.js'; import type { DraftPost } from '../publishing/publishing.service.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 { 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; 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; + /** '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 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; @@ -301,7 +305,12 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise Object.assign(poll, claimed.rows[0]); 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'] : []; - 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; await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id, passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }), @@ -316,14 +325,15 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise { if (!poll.draft_id) return; 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'; 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,$5,$6,$7,$8,$9)`, [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', - { ...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 { @@ -344,7 +354,9 @@ async function stalePoll(client: PoolClient, poll: PollRow): Promise { Object.assign(poll, changed.rows[0]); await audit(client, poll, 'poll.stale', null, { reason: '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) { await openPollInTransaction(client, poll.workspace_id, { 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; const rule = await draftPollRule(client, { post }); 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); } diff --git a/apps/api/src/connections/connection-store.ts b/apps/api/src/connections/connection-store.ts index a8246ad..4d70eea 100644 --- a/apps/api/src/connections/connection-store.ts +++ b/apps/api/src/connections/connection-store.ts @@ -6,6 +6,7 @@ import { withTenant } from "../db/client.js"; import type { SealedCredential } from "../vault/vault.js"; import type { ConnectionSummary } from "./connections.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 ConnectionStore { @@ -15,6 +16,8 @@ export interface ConnectionStore { save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise; rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise; remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise): Promise; + /** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */ + setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise; } 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(); const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId }; 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"); } 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]); @@ -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])); } } } + async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise { + 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 { await this.transaction(workspaceId, async (client) => { await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); diff --git a/apps/api/src/connections/connections.controller.ts b/apps/api/src/connections/connections.controller.ts index f42da3f..577a0b6 100644 --- a/apps/api/src/connections/connections.controller.ts +++ b/apps/api/src/connections/connections.controller.ts @@ -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 { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js"; import { ConnectionsService, type ConnectionSummary } from "./connections.service.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") export class ConnectionsController { @@ -74,6 +76,19 @@ export class ConnectionsController { 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") @UseGuards(RbacGuard) @RequirePermission("connection.manage") @@ -82,3 +97,10 @@ export class ConnectionsController { 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; +} diff --git a/apps/api/src/connections/connections.integration.test.ts b/apps/api/src/connections/connections.integration.test.ts index 681bd3e..f3b999d 100644 --- a/apps/api/src/connections/connections.integration.test.ts +++ b/apps/api/src/connections/connections.integration.test.ts @@ -15,6 +15,7 @@ import { RbacGuard } from "../rbac/rbac.guard.js"; import { logger } from "../telemetry/logger.js"; import { ConnectionsController } from "./connections.controller.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 { RedisPendingStore } from "./pending.js"; import { MemoryPendingRedis } from "./testing.js"; @@ -50,6 +51,14 @@ class MemoryConnections implements ConnectionStore { this.rows.delete(id); this.audit.push({ action: "connection.remove" }); } + async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise { + 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 { const row = await this.get(workspaceId, id); if (row.credential?.ciphertext.equals(previous.ciphertext)) { @@ -333,6 +342,21 @@ describe("HTTP OAuth connect with real connectors and mock authorization server" 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 () => { await finish(await begin()); const row = (await listed())[0]; diff --git a/apps/api/src/connections/connections.service.ts b/apps/api/src/connections/connections.service.ts index 25ac270..ebcaffa 100644 --- a/apps/api/src/connections/connections.service.ts +++ b/apps/api/src/connections/connections.service.ts @@ -11,6 +11,7 @@ import { connectionRegistry } from "./connector-registry.js"; import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js"; import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js"; import { oauthFetch } from "./oauth-fetch.js"; +import type { PublishGovernance } from "../publishing/publish-governance.js"; export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable"; export interface ConnectionSummary { @@ -214,6 +215,11 @@ export class ConnectionsService { } 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 { await this.deps.store.assertAuthorized(session); await this.deps.store.remove(session, id, async (stored) => { diff --git a/apps/api/src/publishing/approval-gate.integration.test.ts b/apps/api/src/publishing/approval-gate.integration.test.ts index cf8811e..75aa820 100644 --- a/apps/api/src/publishing/approval-gate.integration.test.ts +++ b/apps/api/src/publishing/approval-gate.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; 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): diff --git a/apps/api/src/publishing/approval-gate.service.ts b/apps/api/src/publishing/approval-gate.service.ts index b18390b..c3edeb4 100644 --- a/apps/api/src/publishing/approval-gate.service.ts +++ b/apps/api/src/publishing/approval-gate.service.ts @@ -240,7 +240,7 @@ export class ApprovalGateService { ); adminApproval = membership.rows.some((row) => APPROVAL_GATE_ADMIN_ROLES.includes(String(row.role))); 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], ); pollState = String(poll.rows[0]?.state ?? "none"); diff --git a/apps/api/src/publishing/poll-approval.ts b/apps/api/src/publishing/poll-approval.ts index 1e219d0..4815e68 100644 --- a/apps/api/src/publishing/poll-approval.ts +++ b/apps/api/src/publishing/poll-approval.ts @@ -2,6 +2,7 @@ import { ConflictException } from '@nestjs/common'; import { canonicalPostHash } from '@tactical-media-broadcast-hub/publishing-execution'; import type { PoolClient } from 'pg'; import { ApprovalGateService } from './approval-gate.service.js'; +import { onDraftApproved } from './publish-governance.js'; import type { DraftPost } from './publishing.service.js'; /** 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, { actor: `system:chat-poll-gate:${input.pollId}`, pollId: input.pollId, 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' } }); + } } diff --git a/apps/api/src/publishing/publish-governance.integration.test.ts b/apps/api/src/publishing/publish-governance.integration.test.ts new file mode 100644 index 0000000..47f3d64 --- /dev/null +++ b/apps/api/src/publishing/publish-governance.integration.test.ts @@ -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(fn: (client: PoolClient) => Promise): Promise { + 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 => (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) { + 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 { + 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; poll_id: string }>); + } + + async function draftState(draftId: string): Promise { + 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" }); + }); +}); diff --git a/apps/api/src/publishing/publish-governance.test.ts b/apps/api/src/publishing/publish-governance.test.ts new file mode 100644 index 0000000..e26eecf --- /dev/null +++ b/apps/api/src/publishing/publish-governance.test.ts @@ -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"); + }); +}); diff --git a/apps/api/src/publishing/publish-governance.ts b/apps/api/src/publishing/publish-governance.ts new file mode 100644 index 0000000..8a5220b --- /dev/null +++ b/apps/api/src/publishing/publish-governance.ts @@ -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; + +/** Publish polls close after a day; an unanswered gate rejects (re-publishable). */ +export const PUBLISH_GATE_TIMEBOX = { duration: "P1D", onExpire: "reject" } as const; + +const ADMIN_ROLES = ["Owner", "Admin"]; + +/** Lenient read of stored fields: anything unrecognised is manual (null). */ +export function readPublishGovernance(fields: unknown): PublishGovernance | null { + if (typeof fields !== "object" || fields === null) return null; + const value = (fields as Record).publishGovernance; + return PUBLISH_GOVERNANCE_MODES.find((mode) => mode === value) ?? null; +} + +export function isPublishGateMode(value: PublishGovernance | null): value is PublishGateMode { + return value === "admin1" || value === "admin_majority" || value === "admin_consensus"; +} + +/** + * Compile a gate mode into a §1a rule against the admin roster AT OPEN. + * The count is snapshotted into the rule (and its policy_version): admins + * added later do not raise the bar; admins removed later can make the arm + * unreachable, which closes the poll `quorum_unreachable` (blocked). + * + * admin1 admin_only (the evaluator's implied {admins, x:1} arm) + * admin_majority quorum, [{admins, x: floor(n/2)+1}], join any + * admin_consensus quorum, [{admins, x: n}], join all, veto allowed + * + * Self-approval is allowed: the content was already approved under the + * proposal policy; this gate is the admins' sign-off on the dispatch, and + * excluding an author-admin would make consensus unreachable by design. + */ +export function compilePublishRule(mode: PublishGateMode, adminCount: number): PolicyRule { + if (!Number.isInteger(adminCount) || adminCount < 1) { + throw new ConflictException({ code: "PUBLISH_GATE_NO_ADMINS", message: "publish gate needs at least one active admin" }); + } + const common = { version: 1, anonymity: "named", allowSelfApproval: true, resetOnChange: false, timebox: PUBLISH_GATE_TIMEBOX }; + switch (mode) { + case "admin1": + return parsePolicyRule({ ...common, mode: "admin_only" }); + case "admin_majority": + return parsePolicyRule({ ...common, mode: "quorum", join: "any", arms: [{ kind: "admins", x: Math.floor(adminCount / 2) + 1 }] }); + case "admin_consensus": + return parsePolicyRule({ ...common, mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: adminCount }] }); + } +} + +/** + * Admins whose ballot counts toward an `admins` arm — the same definition the + * poll tally uses (poll.service resolveTally): active Owner/Admin members + * holding chatgov.vote, plus active members with an approved Admin/Owner + * chat-identity role override. + */ +export async function governanceAdminIds(client: PoolClient, workspaceId: string): Promise { + const rows = await client.query<{ user_id: string }>( + `SELECT m.user_id FROM memberships m + WHERE m.workspace_id = $1 AND m.status = 'active' + AND ((m.role = ANY($2::text[]) AND EXISTS (SELECT 1 FROM role_permissions rp WHERE rp.role = m.role AND rp.permission = 'chatgov.vote')) + OR EXISTS (SELECT 1 FROM chat_identities ci WHERE ci.user_id = m.user_id AND ci.status = 'approved' AND ci.role_override = ANY($2::text[]))) + ORDER BY m.user_id`, + [workspaceId, ADMIN_ROLES], + ); + return rows.rows.map((row) => String(row.user_id)); +} + +/** What the publish route answers when the publish is held for admins. */ +export interface PublishHold { + state: "awaiting_publish_approval"; + draftId: string; + connectionId: string; + publishGovernance: PublishGateMode; + pollId: string; + closesAt: Date; + rule: PolicyRule; +} + +interface PublishRequest { + publishAt: string; + requiredPermission: "publication.schedule" | "publication.publish"; +} + +interface PublishPollRow { + id: string; + state: string; + closes_at: Date; + rule: PolicyRule; +} + +function holdFrom(draftId: string, connectionId: string, mode: PublishGateMode, poll: PublishPollRow): PublishHold { + return { state: "awaiting_publish_approval", draftId, connectionId, publishGovernance: mode, pollId: poll.id, closesAt: new Date(poll.closes_at), rule: poll.rule }; +} + +/** + * Publish-route gate (runs inside PublishingService.schedule's transaction, + * after its tenancy/provider/group checks). `proceed` = the publish poll for + * the CURRENT approved revision passed, or the draft is not currently + * approved (scheduleAttempt then refuses with its usual error). Otherwise + * the open poll is reused, or a fresh one is opened (first request, or a + * re-publish after a failed/expired/vetoed gate). + */ +export async function checkPublishGate(client: PoolClient, input: { + workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; + requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"]; +}): Promise<{ kind: "proceed" } | { kind: "held"; hold: PublishHold; opened: boolean }> { + const draft = (await client.query( + "SELECT state, revision, approved_revision, approved_hash FROM drafts WHERE id = $1 FOR UPDATE", [input.draftId])).rows[0]; + if (draft === undefined || draft.state !== "approved" || draft.approved_hash === null + || draft.approved_revision === null || String(draft.approved_revision) !== String(draft.revision)) { + return { kind: "proceed" }; + } + const latest = await latestPublishPoll(client, input.draftId, input.connectionId, String(draft.approved_revision)); + if (latest?.state === "passed") return { kind: "proceed" }; + if (latest?.state === "open") return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, latest), opened: false }; + const poll = await openPublishPoll(client, { ...input, revision: String(draft.approved_revision) }); + return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, poll), opened: true }; +} + +async function latestPublishPoll(client: PoolClient, draftId: string, connectionId: string, revision: string): Promise { + return (await client.query( + `SELECT id, state, closes_at, rule FROM polls + WHERE draft_id = $1 AND subject_kind = 'draft_publish' AND publish_connection_id = $2 AND draft_revision = $3 + ORDER BY created_at DESC LIMIT 1`, + [draftId, connectionId, revision], + )).rows[0]; +} + +/** + * Open the publish-phase poll + its pending E2 gate row + audit, in the + * caller's transaction (the caller holds the draft row lock; same-transaction + * FK KEY SHARE on gate_decisions/polls is safe). The requester is recorded as + * created_by and becomes the scheduling principal on release. + */ +export async function openPublishPoll(client: PoolClient, input: { + workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; revision: string; + requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"]; +}): Promise { + const admins = await governanceAdminIds(client, input.workspaceId); + const rule = compilePublishRule(input.mode, admins.length); + const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1", [input.connectionId])).rows[0]; + const fields = (connection?.fields ?? {}) as Record; + const label = typeof fields.handle === "string" && fields.handle !== "" ? fields.handle : String(connection?.provider ?? "connection"); + const question = `Publish the approved draft to ${label}?`.slice(0, 300); + const now = new Date(); + const request: PublishRequest = { publishAt: input.publishAt.toISOString(), requiredPermission: input.requiredPermission }; + const policyVersion = policyHash(rule); + const poll = (await client.query( + `INSERT INTO polls (workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at, + created_by, draft_revision, publish_connection_id, publish_request) + VALUES ($1, $2, 'draft_publish', $3, 'yes_no', $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, state, closes_at, rule`, + [input.workspaceId, input.draftId, question, rule, policyVersion, now, new Date(now.getTime() + durationMs(rule.timebox?.duration ?? "P1D")), + input.requestedBy, input.revision, input.connectionId, request], + )).rows[0]!; + const needed = rule.mode === "admin_only" ? 1 : rule.arms?.[0]?.x ?? 1; + await client.query( + `INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id) + VALUES ($1, $2, $3, 'E2', $4, 'pending', $5, $6, $7, $8)`, + [input.workspaceId, input.draftId, { platform: String(connection?.provider ?? "unknown"), accountId: input.connectionId }, policyVersion, + { phase: "publish", publishGovernance: input.mode, adminCount: admins.length, arms: [{ kind: "admins", got: 0, needed }], pollState: "open" }, + ["INSUFFICIENT_QUORUM", "PENDING_POLL"], `user:${input.requestedBy}`, poll.id], + ); + await client.query("SELECT audit_append($1, $2, 'Member', 'poll.opened', 'poll', $3, $4)", [input.workspaceId, input.requestedBy, poll.id, + scrub({ draftId: input.draftId, kind: "yes_no", phase: "publish", connectionId: input.connectionId, publishGovernance: input.mode, + adminCount: admins.length, policyVersion, actor: `user:${input.requestedBy}` })]); + return poll; +} + +type ScheduleRepository = Pick; +let defaultRepository: ScheduleRepository | undefined; +function repositoryOrDefault(repository?: ScheduleRepository): ScheduleRepository { + if (repository !== undefined) return repository; + defaultRepository ??= new PublishingExecutionRepository(pool); + return defaultRepository; +} + +/** + * Schedule inside a SAVEPOINT so a refused publish (credential gone, approval + * voided, schedule already active, principal revoked) never rolls back the + * vote / approval that triggered it. The refusal is audited instead. + */ +async function scheduleGuarded(client: PoolClient, repository: ScheduleRepository, input: Parameters[1]): +Promise<{ ok: true; attempt: ScheduledAttempt } | { ok: false; code: string }> { + await client.query("SAVEPOINT publish_governance"); + try { + const attempt = await repository.scheduleAttempt(client, input); + await client.query("RELEASE SAVEPOINT publish_governance"); + return { ok: true, attempt }; + } catch (error) { + await client.query("ROLLBACK TO SAVEPOINT publish_governance"); + if (error instanceof PublishingStateError) return { ok: false, code: error.code }; + throw error; + } +} + +async function auditDraft(client: PoolClient, workspaceId: string, actor: { userId: string | null; role: string }, action: string, draftId: string, diff: Record): Promise { + await client.query("SELECT audit_append($1, $2, $3, $4, 'draft', $5, $6)", [workspaceId, actor.userId, actor.role, action, draftId, scrub(diff)]); +} + +/** + * E2 close seam for publish polls (called by poll.service evaluateLocked + * under the workspace → draft → poll locks, after the exactly-once claim). + * A passed poll schedules the publish the requester asked for; the scheduler + * sweep starts the workflow. A refused schedule is audited, the poll stays + * passed, and the holder can re-publish once the cause is fixed. + */ +export async function releasePublishPoll(client: PoolClient, poll: { + id: string; workspace_id: string; draft_id: string | null; created_by: string; + publish_connection_id: string | null; publish_request: unknown; +}, repository?: ScheduleRepository): Promise<{ released: true; scheduleId: string } | { released: false; code: string }> { + if (poll.draft_id === null || poll.publish_connection_id === null) return { released: false, code: "NOT_A_PUBLISH_POLL" }; + const request = (poll.publish_request ?? {}) as Partial; + const requested = typeof request.publishAt === "string" ? new Date(request.publishAt) : new Date(); + const publishAt = Number.isFinite(requested.getTime()) && requested.getTime() > Date.now() ? requested : new Date(); + const requiredPermission = request.requiredPermission === "publication.schedule" ? "publication.schedule" : "publication.publish"; + const result = await scheduleGuarded(client, repositoryOrDefault(repository), { + workspaceId: poll.workspace_id, draftId: poll.draft_id, connectionId: poll.publish_connection_id, + scheduledByUserId: poll.created_by, requiredPermission, publishAt, + }); + const system = { userId: null, role: "System" }; + if (!result.ok) { + await auditDraft(client, poll.workspace_id, system, "publication.publish_release_failed", poll.draft_id, + { pollId: poll.id, connectionId: poll.publish_connection_id, code: result.code, actor: `system:chat-poll-gate:${poll.id}` }); + return { released: false, code: result.code }; + } + await auditDraft(client, poll.workspace_id, system, "publication.scheduled", poll.draft_id, { + connectionId: poll.publish_connection_id, publishAt: publishAt.toISOString(), trigger: "publish_poll_passed", + pollId: poll.id, scheduledByUserId: poll.created_by, actor: `system:chat-poll-gate:${poll.id}`, + }); + return { released: true, scheduleId: result.attempt.scheduleId }; +} + +/** + * The principal a system-initiated publish runs as: the approving user when + * they could publish to this connection themselves, else a live Owner/Admin + * (the poll-approval delegation pattern). Mirrors the repository's authorize + * predicate, which re-checks it at schedule and at every claim. + */ +async function publishPrincipal(client: PoolClient, workspaceId: string, connectionId: string, preferredUserId: string | null): Promise { + const row = (await client.query<{ user_id: string }>( + `SELECT m.user_id FROM memberships m + JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'publication.publish' + WHERE m.workspace_id = $1 AND m.status = 'active' + AND (m.role = ANY($4::text[]) OR EXISTS (SELECT 1 FROM group_connections gc JOIN group_members gm ON gm.group_id = gc.group_id + WHERE gc.connection_id = $2 AND gm.user_id = m.user_id)) + ORDER BY COALESCE(m.user_id = $3::uuid, false) DESC, CASE m.role WHEN 'Owner' THEN 0 WHEN 'Admin' THEN 1 ELSE 2 END, m.user_id + LIMIT 1`, + [workspaceId, connectionId, preferredUserId, ADMIN_ROLES], + )).rows[0]; + return row === undefined ? null : String(row.user_id); +} + +export type ApprovalFollowUp = + | { kind: "dispatched"; connectionId: string; attempt: ScheduledAttempt; publishAt: Date } + | { kind: "held"; connectionId: string; pollId: string } + | { kind: "skipped"; connectionId: string; code: string }; + +/** + * Approval seam: runs in the SAME transaction that moved the draft to + * `approved` (console approve, or the E2 proposal-poll pass), after the + * UPDATE. Manual connections (no setting) return null — today's behavior. + * Never throws for a refused follow-up: the approval must stand on its own. + */ +export async function onDraftApproved(client: PoolClient, input: { + workspaceId: string; draftId: string; providerMetadata: Record; + preferredUserId: string | null; actor: { userId: string | null; role: string }; +}, repository?: ScheduleRepository): Promise { + const connectionId = input.providerMetadata.connectionId; + if (typeof connectionId !== "string" || connectionId === "") return null; + const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId])).rows[0]; + if (connection === undefined) return null; + const mode = readPublishGovernance(connection.fields); + if (mode === null) return null; + const skip = async (code: string): Promise => { + await auditDraft(client, input.workspaceId, input.actor, "publication.auto_publish_skipped", input.draftId, { connectionId, publishGovernance: mode, code }); + return { kind: "skipped", connectionId, code }; + }; + if (!isPublishingProvider(String(connection.provider))) return skip("PROVIDER_NOT_PUBLISHING"); + const principal = await publishPrincipal(client, input.workspaceId, connectionId, input.preferredUserId); + if (principal === null) return skip("NO_AUTHORIZED_PRINCIPAL"); + + if (mode === "auto") { + const publishAt = new Date(); + const result = await scheduleGuarded(client, repositoryOrDefault(repository), { + workspaceId: input.workspaceId, draftId: input.draftId, connectionId, + scheduledByUserId: principal, requiredPermission: "publication.publish", publishAt, + }); + if (!result.ok) return skip(result.code); + await auditDraft(client, input.workspaceId, input.actor, "publication.scheduled", input.draftId, { + connectionId, publishAt: publishAt.toISOString(), trigger: "auto_on_approval", scheduledByUserId: principal, + }); + return { kind: "dispatched", connectionId, attempt: result.attempt, publishAt }; + } + + // Gate modes: the second gate starts running as soon as the draft is + // approved; the publish route reuses this poll instead of opening another. + await client.query("SAVEPOINT publish_governance_open"); + try { + const gate = await checkPublishGate(client, { + workspaceId: input.workspaceId, draftId: input.draftId, connectionId, mode, + requestedBy: principal, publishAt: new Date(), requiredPermission: "publication.publish", + }); + await client.query("RELEASE SAVEPOINT publish_governance_open"); + if (gate.kind === "held") return { kind: "held", connectionId, pollId: gate.hold.pollId }; + return null; + } catch (error) { + await client.query("ROLLBACK TO SAVEPOINT publish_governance_open"); + if (error instanceof ConflictException) return skip("PUBLISH_GATE_UNAVAILABLE"); + throw error; + } +} diff --git a/apps/api/src/publishing/publishing.controller.ts b/apps/api/src/publishing/publishing.controller.ts index daaddb0..e6aa5d9 100644 --- a/apps/api/src/publishing/publishing.controller.ts +++ b/apps/api/src/publishing/publishing.controller.ts @@ -23,7 +23,7 @@ import { type DraftPost, type DraftRecord, type DraftState, - type ScheduledPublicationRecord, + type PublishOutcome, } from "./publishing.service.js"; export interface CreateDraftDto { @@ -163,10 +163,10 @@ export class PublishingController { @Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown, - ): Promise { + ): Promise { const session = requireSession(request); const dto = parseScheduleDraft(body); - return this.publishing.schedule( + return this.publishing.requestPublish( session.workspaceId, actorFrom(session), parseId(draftId), @@ -179,17 +179,17 @@ export class PublishingController { @HttpCode(202) @RequirePermission("publication.publish") @RequireGroupAccess("body") - async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise { + async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise { const session = requireSession(request); 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") @HttpCode(202) @RequirePermission("publication.schedule") @RequireGroupAccess("body") - async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise { + async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise { return this.resume(request, draftId, body, "failed"); } @@ -197,16 +197,16 @@ export class PublishingController { @HttpCode(202) @RequirePermission("publication.schedule") @RequireGroupAccess("body") - async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise { + async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise { return this.resume(request, draftId, body, "needs_reauthorization"); } - private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise { + private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise { const session = requireSession(request); const dto = parseScheduleDraft(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"); - 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") diff --git a/apps/api/src/publishing/publishing.service.ts b/apps/api/src/publishing/publishing.service.ts index c3f6e41..c0eae1e 100644 --- a/apps/api/src/publishing/publishing.service.ts +++ b/apps/api/src/publishing/publishing.service.ts @@ -1,7 +1,9 @@ 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 { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime"; 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 type { ConnectorConnection } from "@tactical-media-broadcast-hub/core"; 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 { scrub } from "../audit/audit.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 { userCanUseConnection } from "../groups/groups.service.js"; import { vault } from "../vault/vault.js"; @@ -47,6 +50,9 @@ export interface ScheduledPublicationRecord { 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 { userId: string; role: string; @@ -380,15 +386,30 @@ export class PublishingService { */ async approve(workspaceId: string, actor: Actor, draftId: string): Promise { const gate = this.dependencies.approvalGate ?? null; + let followUp: ApprovalFollowUp | null = null; 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", to: "approved", }, actor.userId, gate === null ? undefined : async (client: PoolClient, draft: DraftRecord) => { const evaluation = await gate.evaluate(client, workspaceId, draftId, draft.post, actor.userId); if (evaluation.blocked.length > 0) throw new ApprovalGateBlockedError(evaluation.blocked); 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) { if (error instanceof ApprovalGateBlockedError) { // The approval rolled back; record the blocked verdicts with no draft @@ -408,6 +429,12 @@ export class PublishingService { }, 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( workspaceId: string, actor: Actor, @@ -417,6 +444,23 @@ export class PublishingService { requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule", expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string }, ): Promise { + 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 { if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp"); await this.dependencies.assertReady(); @@ -424,13 +468,29 @@ export class PublishingService { 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 // 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 (!isPublishingProvider(String(connection.rows[0]?.provider))) throw new BadRequestException("provider is not enabled for publishing"); if (!(await userCanUseConnection(client, workspaceId, actor.userId, connectionId))) { 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; try { attempt = await this.dependencies.repository.scheduleAttempt(client, { @@ -448,6 +508,7 @@ export class PublishingService { }); return scheduled; }); + if (scheduled.state === "awaiting_publish_approval") return scheduled; await this.dependencies.workflowStarter .startScheduledPublish({ workspaceId, scheduleId: scheduled.id, generation: scheduled.generation, idempotencyKey: scheduled.idempotencyKey }, publishAt) .catch(() => undefined); @@ -500,6 +561,8 @@ export class PublishingService { /** E1 seam: runs inside the transition's transaction, under the draft row * lock, before the UPDATE — may abort the whole transition by throwing. */ afterLock?: (client: PoolClient, draft: DraftRecord) => Promise, + /** Runs after the UPDATE + audit, same transaction (publish governance). */ + afterTransition?: (client: PoolClient, draft: DraftRecord) => Promise, ): Promise { await this.dependencies.assertReady(); return this.dependencies.tenantTransaction(workspaceId, async (client) => { @@ -521,6 +584,7 @@ export class PublishingService { const draft = draftFromRow(requiredRow(result.rows[0], "transitioned draft")); if (to === "in_review") await autoOpenDraftPoll(client, workspaceId, draftId, actor, draft.post); await this.audit(client, workspaceId, actor, action, draftId, diff); + if (afterTransition !== undefined) await afterTransition(client, draft); return draft; }); } diff --git a/apps/scheduler/src/activities/dispatch-gate.test.ts b/apps/scheduler/src/activities/dispatch-gate.test.ts index 6c2e08d..9c43c4e 100644 --- a/apps/scheduler/src/activities/dispatch-gate.test.ts +++ b/apps/scheduler/src/activities/dispatch-gate.test.ts @@ -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", () => { it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => { expect(E3_REASON_CODES).toEqual([ diff --git a/apps/scheduler/src/activities/dispatch-gate.ts b/apps/scheduler/src/activities/dispatch-gate.ts index f00eb38..3c7f3e9 100644 --- a/apps/scheduler/src/activities/dispatch-gate.ts +++ b/apps/scheduler/src/activities/dispatch-gate.ts @@ -49,6 +49,13 @@ export type ReasonCode = (typeof E3_REASON_CODES)[number]; 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. */ interface E3Row { state: string | null; @@ -58,6 +65,10 @@ interface E3Row { effective: { mode: "immediate" | "quorum" | "admin_only" } | null; policy_version: 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 { @@ -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 }; } + // 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 // (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send). 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, s.effective, s.policy_version, (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 LEFT JOIN posting_policy_snapshots s 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 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], ); const row = result.rows[0] as E3Row | undefined; diff --git a/apps/scheduler/src/activities/publish.activity.gate.test.ts b/apps/scheduler/src/activities/publish.activity.gate.test.ts index 1d4f9e0..4e82a58 100644 --- a/apps/scheduler/src/activities/publish.activity.gate.test.ts +++ b/apps/scheduler/src/activities/publish.activity.gate.test.ts @@ -85,6 +85,14 @@ describe("E3 gate in the dispatch path", () => { 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 () => { const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null }); const h = harness({ kind: "claimed", job }, gate); diff --git a/apps/web/src/app/(main)/calendar/page.tsx b/apps/web/src/app/(main)/calendar/page.tsx index f27508b..9ddf33f 100644 --- a/apps/web/src/app/(main)/calendar/page.tsx +++ b/apps/web/src/app/(main)/calendar/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Badge, Button } from "../../../components/ui/primitives"; import { OfflineBadge } from "../../../components/ui/offline-badge"; import { EmptyState } from "../../../components/brand/empty-state"; @@ -9,7 +9,7 @@ import { api, errorMessage } from "../../../lib/api-client"; import { PLATFORMS } from "../../../lib/platforms"; import { useAuth } from "../../../lib/auth-store"; 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 @@ -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 }; } +/** + * 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 { const { can } = useAuth(); const drafts = useApiResource("/publishing/drafts", identity, SAMPLE, { @@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement { const [notice, setNotice] = useState(null); const [now, setNow] = useState(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>({}); + const fetchPublications = useCallback((ids: string[]) => { + if (!live || ids.length === 0) return; + for (const id of ids) { + void api + .get(`/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([]); + 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. useEffect(() => { const timer = setInterval(() => { drafts.reload(); connections.reload(); + fetchPublications(draftIdsRef.current); setNow(Date.now()); }, 30_000); return () => clearInterval(timer); - }, [drafts.reload, connections.reload]); - - const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]); - const groups = useMemo(() => groupByDay(rows), [rows]); + }, [drafts.reload, connections.reload, fetchPublications]); async function cancel(row: ScheduledRow): Promise { if (!live) { @@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement { {rows.length} scheduled · refreshes every 30s -

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.

{notice !== null && ( @@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
    {group.rows.map((row) => { const relative = relativeLabel(row.publishAt, now); + const latest = latestPublication(publications[row.id]); return (
  • @@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {

    {row.text}

    scheduled + {latest !== null && ( + + {latest.state.replace(/_/g, " ")} + {latest.remoteUrl !== null && ( + + open ↗ + + )} + + )} {row.targetLabel} {!row.fromScheduledAt && ( diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index a32980e..5a46e0e 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -164,3 +164,26 @@ export interface ScheduledPublicationRecord { publishAt: string; 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[]; +} diff --git a/infra/occupymedia/edge/README.md b/infra/occupymedia/edge/README.md index 69ed7d2..a43d113 100644 --- a/infra/occupymedia/edge/README.md +++ b/infra/occupymedia/edge/README.md @@ -20,13 +20,17 @@ nginx edge mirrors the tunnel ingress by host: - `app.occupymedia.cc` → `web:3001` (occ-app compose network) - `api.occupymedia.cc` → `api:3000` - `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) - unknown SNI → `ssl_reject_handshake` TLS: Let's Encrypt wildcard `*.occupymedia.cc` via dns-01 (no port 80 needed). 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 | File | Role | diff --git a/infra/occupymedia/edge/docker-compose.edge.yml b/infra/occupymedia/edge/docker-compose.edge.yml index 3b5f9d2..c5dee3b 100644 --- a/infra/occupymedia/edge/docker-compose.edge.yml +++ b/infra/occupymedia/edge/docker-compose.edge.yml @@ -11,6 +11,7 @@ services: - ./nginx.conf:/etc/nginx/nginx.conf:ro - /opt/occ-brand:/opt/occ-brand:ro - ./hub-sso:/opt/hub-sso:ro + - ./hub-chat-auth.css:/opt/occ-chat-brand/hub-chat-auth.css:ro - ./certs:/etc/letsencrypt:ro networks: diff --git a/infra/occupymedia/edge/hub-chat-auth.css b/infra/occupymedia/edge/hub-chat-auth.css new file mode 100644 index 0000000..702b612 --- /dev/null +++ b/infra/occupymedia/edge/hub-chat-auth.css @@ -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; + } +} diff --git a/infra/occupymedia/edge/nginx.conf b/infra/occupymedia/edge/nginx.conf index 699e407..31bf703 100644 --- a/infra/occupymedia/edge/nginx.conf +++ b/infra/occupymedia/edge/nginx.conf @@ -103,6 +103,19 @@ http { listen [::]:80; server_name chat.occupymedia.cc; # 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 { proxy_pass http://100.117.210.52:6167; proxy_http_version 1.1; @@ -131,6 +144,12 @@ http { proxy_http_version 1.1; proxy_set_header Host $host; 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 '' ''; + sub_filter_once on; + # text/html is sub_filter's default type; JSON/JS/CSS are not rewritten. proxy_read_timeout 60s; } } @@ -143,6 +162,17 @@ http { ssl_certificate /etc/letsencrypt/live/occupymedia.cc/fullchain.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 { proxy_pass http://100.117.210.52:6167; proxy_http_version 1.1; @@ -176,6 +206,9 @@ http { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; + proxy_set_header Accept-Encoding ""; + sub_filter '' ''; + sub_filter_once on; proxy_read_timeout 60s; } } diff --git a/infra/occupymedia/element/AUTH-RESTYLE.md b/infra/occupymedia/element/AUTH-RESTYLE.md new file mode 100644 index 0000000..9f87b34 --- /dev/null +++ b/infra/occupymedia/element/AUTH-RESTYLE.md @@ -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 `` 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. diff --git a/infra/occupymedia/element/apply-auth-branding.py b/infra/occupymedia/element/apply-auth-branding.py new file mode 100644 index 0000000..2d7496d --- /dev/null +++ b/infra/occupymedia/element/apply-auth-branding.py @@ -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() diff --git a/infra/occupymedia/element/auth-branding.patch.json b/infra/occupymedia/element/auth-branding.patch.json new file mode 100644 index 0000000..234f92e --- /dev/null +++ b/infra/occupymedia/element/auth-branding.patch.json @@ -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 +}