feat: per-connection publish governance — auto, 1-admin, majority, consensus
Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
This commit is contained in:
parent
f2eb14956e
commit
b89dea5c93
26 changed files with 1637 additions and 31 deletions
40
apps/api/migrations/033_publish_governance.sql
Normal file
40
apps/api/migrations/033_publish_governance.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- Per-connection publish governance (publish-phase gate).
|
||||
--
|
||||
-- connections.fields.publishGovernance selects what happens once a draft is
|
||||
-- approved: unset = manual publish (today), 'auto' = dispatch on approval,
|
||||
-- 'admin1' | 'admin_majority' | 'admin_consensus' = a SECOND chatgov poll
|
||||
-- (subject_kind 'draft_publish', kind yes_no) must pass before the publish
|
||||
-- is scheduled. The setting itself lives in the established fields jsonb;
|
||||
-- this migration only teaches `polls` about the publish-phase subject.
|
||||
--
|
||||
-- publish_connection_id the destination the publish poll releases to
|
||||
-- publish_request {publishAt, requiredPermission} captured at hold
|
||||
-- time; the requester is polls.created_by and stays
|
||||
-- the scheduling principal (re-checked at claim).
|
||||
|
||||
ALTER TABLE polls DROP CONSTRAINT polls_subject_kind_check;
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_subject_kind_check CHECK
|
||||
(subject_kind IN ('draft', 'chat_post', 'none', 'draft_publish'));
|
||||
|
||||
ALTER TABLE polls
|
||||
ADD COLUMN publish_connection_id uuid,
|
||||
ADD COLUMN publish_request jsonb CHECK (publish_request IS NULL OR jsonb_typeof(publish_request) = 'object');
|
||||
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_publish_connection_fk
|
||||
FOREIGN KEY (publish_connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_publish_subject_check CHECK (
|
||||
(subject_kind = 'draft_publish') = (publish_connection_id IS NOT NULL)
|
||||
AND (subject_kind <> 'draft_publish' OR (draft_id IS NOT NULL AND publish_request IS NOT NULL))
|
||||
);
|
||||
|
||||
-- One open REVIEW gate per draft stays index-enforced; publish gates get
|
||||
-- their own slot per (draft, destination) so they never collide with it.
|
||||
DROP INDEX polls_open_one_per_draft;
|
||||
CREATE UNIQUE INDEX polls_open_one_per_draft ON polls (workspace_id, draft_id)
|
||||
WHERE state = 'open' AND draft_id IS NOT NULL AND subject_kind <> 'draft_publish';
|
||||
CREATE UNIQUE INDEX polls_open_one_per_publish ON polls (workspace_id, draft_id, publish_connection_id)
|
||||
WHERE state = 'open' AND subject_kind = 'draft_publish';
|
||||
CREATE INDEX polls_ws_publish ON polls (workspace_id, draft_id, publish_connection_id, created_at DESC)
|
||||
WHERE subject_kind = 'draft_publish';
|
||||
|
|
@ -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<void>
|
|||
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<void>
|
|||
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
|
||||
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<PollCard> {
|
||||
|
|
@ -344,7 +354,9 @@ async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
|
||||
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
|
||||
/** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */
|
||||
setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void>;
|
||||
}
|
||||
|
||||
export class PgConnectionStore implements ConnectionStore {
|
||||
|
|
@ -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<void> {
|
||||
await this.transaction(session.workspaceId, async (client) => {
|
||||
// Serialized with membership revocation like every governance write.
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
|
||||
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
|
||||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
|
||||
if (!current.rowCount) throw new NotFoundException("Connection not found");
|
||||
const previous = readPublishGovernance(current.rows[0]?.fields);
|
||||
await client.query(
|
||||
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
|
||||
[id, mode],
|
||||
);
|
||||
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
|
||||
});
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
await this.transaction(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await this.assertAuthorized(session);
|
||||
const row = await this.get(session.workspaceId, id);
|
||||
const fields = { ...row.connection.fields };
|
||||
delete fields.publishGovernance;
|
||||
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
|
||||
this.audit.push({ action: "connection.publish_governance" });
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
await this.deps.store.assertAuthorized(session);
|
||||
await this.deps.store.remove(session, id, async (stored) => {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
282
apps/api/src/publishing/publish-governance.integration.test.ts
Normal file
282
apps/api/src/publishing/publish-governance.integration.test.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { ConflictException, ForbiddenException } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { PublishingExecutionRepository, assertPublishingSchema, type AttemptRef } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
|
||||
/**
|
||||
* Per-connection publish governance against a live PostgreSQL:
|
||||
* - auto: a console approval AND a passing proposal poll (E2) each schedule
|
||||
* the publish on the unchanged durable path
|
||||
* - admin1 / admin_majority / admin_consensus: approval opens the publish
|
||||
* poll, the publish route holds (202 body), the pass schedules it
|
||||
* - fail / expire / veto: the draft stays approved, the gate row is blocked
|
||||
* with reason codes, and a re-publish opens a fresh poll
|
||||
* - E3 (real DispatchGateService): a gated destination dispatches only once
|
||||
* its publish poll passed
|
||||
*
|
||||
* Owns its workspace in the chatgov IT database (CHATGOV_IT_DATABASE_URL +
|
||||
* CHATGOV_IT_SUPER_DATABASE_URL, migrated through 033). Skips clean.
|
||||
*/
|
||||
const appUrl = process.env.CHATGOV_IT_DATABASE_URL;
|
||||
const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL;
|
||||
|
||||
type Modules = {
|
||||
publishing: typeof import("./publishing.service.js");
|
||||
polls: typeof import("../chatgov/poll.service.js");
|
||||
store: typeof import("../connections/connection-store.js");
|
||||
dispatchGate: typeof import("../../../scheduler/src/activities/dispatch-gate.js");
|
||||
db: typeof import("../db/client.js");
|
||||
};
|
||||
|
||||
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("publish governance (live DB)", () => {
|
||||
let m: Modules;
|
||||
let ownerPool: Pool;
|
||||
let appPool: Pool;
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
||||
let ws: string;
|
||||
let ownerId: string;
|
||||
let admin2: string;
|
||||
let admin3: string;
|
||||
let editorId: string;
|
||||
const conn: Record<"auto" | "admin1" | "admin_majority" | "admin_consensus" | "manual", string> = { auto: "", admin1: "", admin_majority: "", admin_consensus: "", manual: "" };
|
||||
const starts: AttemptRef[] = [];
|
||||
|
||||
async function inTenant<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await ownerPool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [ws]);
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = appUrl;
|
||||
process.env.SUPER_DATABASE_URL = ownerUrl;
|
||||
m = {
|
||||
publishing: await import("./publishing.service.js"),
|
||||
polls: await import("../chatgov/poll.service.js"),
|
||||
store: await import("../connections/connection-store.js"),
|
||||
dispatchGate: await import("../../../scheduler/src/activities/dispatch-gate.js"),
|
||||
db: await import("../db/client.js"),
|
||||
};
|
||||
ownerPool = new Pool({ connectionString: ownerUrl });
|
||||
appPool = new Pool({ connectionString: appUrl });
|
||||
const c = await ownerPool.connect();
|
||||
try {
|
||||
ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["pubgov", `pubgov-${suffix}`])).rows[0].id as string;
|
||||
const user = async (name: string): Promise<string> => (await c.query(
|
||||
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
[`pubgov:${name}:${suffix}`, `pubgov-${name}-${suffix}`, `pubgov-${name}-${suffix}@example.com`, name],
|
||||
)).rows[0].id as string;
|
||||
ownerId = await user("owner");
|
||||
admin2 = await user("admin2");
|
||||
admin3 = await user("admin3");
|
||||
editorId = await user("editor");
|
||||
for (const [id, role] of [[ownerId, "Owner"], [admin2, "Admin"], [admin3, "Admin"], [editorId, "Editor"]] as const) {
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'active')", [ws, id, role]);
|
||||
}
|
||||
} finally {
|
||||
c.release();
|
||||
}
|
||||
await inTenant(async (client) => {
|
||||
for (const mode of Object.keys(conn) as Array<keyof typeof conn>) {
|
||||
const fields = mode === "manual" ? { handle: "manual" } : { handle: mode, publishGovernance: mode };
|
||||
conn[mode] = (await client.query("INSERT INTO connections (workspace_id, provider, channel_id, fields) VALUES ($1, 'mastodon', $2, $3) RETURNING id",
|
||||
[ws, `pubgov-${mode}`, fields])).rows[0].id as string;
|
||||
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext) VALUES ($1, $2, 'connector-oauth', '\\x00')", [ws, conn[mode]]);
|
||||
// A compiled immediate snapshot (no live rules: E1 stays ungated) so
|
||||
// E3 evaluates the publish gate instead of failing OUTSIDE_POLICY.
|
||||
await client.query(`INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, effective, policy_version, resolved_from)
|
||||
VALUES ($1, 'account', $2, '{"version":1,"mode":"immediate"}', 'pv-immediate', '{}')`, [ws, conn[mode]]);
|
||||
}
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ownerPool !== undefined) {
|
||||
await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]);
|
||||
await ownerPool.query("DELETE FROM users WHERE external_id LIKE $1", [`pubgov:%:${suffix}`]);
|
||||
await ownerPool.end();
|
||||
}
|
||||
await appPool?.end();
|
||||
await m?.db.pool.end();
|
||||
});
|
||||
|
||||
const owner = () => ({ userId: ownerId, role: "Owner" });
|
||||
|
||||
function service() {
|
||||
return new m.publishing.PublishingService({
|
||||
tenantTransaction: m.db.withTenant,
|
||||
workflowStarter: { startScheduledPublish: vi.fn(async (ref: AttemptRef) => { starts.push(ref); }) },
|
||||
repository: new PublishingExecutionRepository(m.db.pool),
|
||||
assertReady: () => assertPublishingSchema(m.db.pool),
|
||||
});
|
||||
}
|
||||
|
||||
async function approvedDraft(connectionId: string): Promise<string> {
|
||||
const publishing = service();
|
||||
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
|
||||
text: `governed ${connectionId} ${Math.random()}`, mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId },
|
||||
});
|
||||
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
|
||||
await publishing.approve(ws, owner(), draft.id);
|
||||
return draft.id;
|
||||
}
|
||||
|
||||
const vote = (pollId: string, userId: string, ballot: "yes" | "no" | "veto") =>
|
||||
new m.polls.PollService().vote(ws, pollId, { ballot }, { kind: "user", userId });
|
||||
|
||||
async function publishPolls(draftId: string) {
|
||||
return inTenant(async (c) => (await c.query(
|
||||
"SELECT id, state, kind, rule, created_by, publish_connection_id FROM polls WHERE draft_id = $1 AND subject_kind = 'draft_publish' ORDER BY created_at",
|
||||
[draftId])).rows as Array<{ id: string; state: string; kind: string; rule: { mode: string; arms?: Array<{ x: number }>; join?: string }; created_by: string; publish_connection_id: string }>);
|
||||
}
|
||||
|
||||
async function schedules(draftId: string) {
|
||||
return inTenant(async (c) => (await c.query(
|
||||
"SELECT id, state, connection_id, scheduled_by_user_id, required_permission, idempotency_key FROM scheduled_publications WHERE draft_id = $1",
|
||||
[draftId])).rows as Array<{ id: string; state: string; connection_id: string; scheduled_by_user_id: string; required_permission: string; idempotency_key: string }>);
|
||||
}
|
||||
|
||||
async function publishGates(draftId: string) {
|
||||
return inTenant(async (c) => (await c.query(
|
||||
"SELECT gate, outcome, reason_codes, inputs, poll_id FROM gate_decisions WHERE draft_id = $1 AND (inputs->>'phase') = 'publish' ORDER BY created_at, id",
|
||||
[draftId])).rows as Array<{ gate: string; outcome: string; reason_codes: string[]; inputs: Record<string, unknown>; poll_id: string }>);
|
||||
}
|
||||
|
||||
async function draftState(draftId: string): Promise<string> {
|
||||
return inTenant(async (c) => String((await c.query("SELECT state FROM drafts WHERE id = $1", [draftId])).rows[0]?.state));
|
||||
}
|
||||
|
||||
async function e3(draftId: string, connectionId: string, idempotencyKey: string) {
|
||||
return new m.dispatchGate.DispatchGateService(appPool).check({ workspaceId: ws, draftId, idempotencyKey, connectionId, platform: "mastodon" });
|
||||
}
|
||||
|
||||
it("auto: a console approval schedules the publish immediately and hints the workflow", async () => {
|
||||
starts.length = 0;
|
||||
const draftId = await approvedDraft(conn.auto);
|
||||
const [scheduled] = await schedules(draftId);
|
||||
expect(scheduled).toMatchObject({ state: "pending", connection_id: conn.auto, scheduled_by_user_id: ownerId, required_permission: "publication.publish" });
|
||||
expect(starts).toEqual([expect.objectContaining({ workspaceId: ws, scheduleId: scheduled?.id })]);
|
||||
expect(await publishPolls(draftId)).toEqual([]);
|
||||
const audit = await inTenant(async (c) => (await c.query("SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'publication.scheduled'", [draftId])).rows);
|
||||
expect(audit[0]?.diff).toMatchObject({ trigger: "auto_on_approval", connectionId: conn.auto });
|
||||
expect((await e3(draftId, conn.auto, scheduled!.idempotency_key)).outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("auto: a passing proposal poll (E2 execute-on-vote) schedules the publish too", async () => {
|
||||
const publishing = service();
|
||||
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
|
||||
text: "auto via poll", mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId: conn.auto },
|
||||
});
|
||||
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
|
||||
const card = await new m.polls.PollService().open(ws, {
|
||||
draftId: draft.id, kind: "yes_no", question: "Ship it?", rule: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
}, { userId: ownerId });
|
||||
await vote(card.poll.id, admin2, "yes");
|
||||
expect(await draftState(draft.id)).toBe("approved");
|
||||
expect(await schedules(draft.id)).toEqual([expect.objectContaining({ state: "pending", connection_id: conn.auto, required_permission: "publication.publish" })]);
|
||||
});
|
||||
|
||||
it("admin1: approval opens the publish poll, the publish route holds, one admin yes releases", async () => {
|
||||
starts.length = 0;
|
||||
const draftId = await approvedDraft(conn.admin1);
|
||||
const [poll] = await publishPolls(draftId);
|
||||
expect(poll).toMatchObject({ state: "open", kind: "yes_no", created_by: ownerId, publish_connection_id: conn.admin1, rule: { mode: "admin_only" } });
|
||||
expect(await publishGates(draftId)).toEqual([expect.objectContaining({ gate: "E2", outcome: "pending", reason_codes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], poll_id: poll?.id })]);
|
||||
|
||||
const publishing = service();
|
||||
const held = await publishing.requestPublish(ws, { userId: admin2, role: "Admin" }, draftId, conn.admin1, new Date(), "publication.publish");
|
||||
expect(held).toMatchObject({ state: "awaiting_publish_approval", pollId: poll?.id, publishGovernance: "admin1", connectionId: conn.admin1 });
|
||||
await expect(publishing.schedule(ws, owner(), draftId, conn.admin1, new Date())).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
expect(starts).toEqual([]);
|
||||
|
||||
await vote(poll!.id, admin2, "yes");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
|
||||
const [scheduled] = await schedules(draftId);
|
||||
expect(scheduled).toMatchObject({ state: "pending", connection_id: conn.admin1, scheduled_by_user_id: ownerId });
|
||||
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ gate: "E2", outcome: "fired", poll_id: poll?.id }));
|
||||
expect(await draftState(draftId)).toBe("approved");
|
||||
expect((await e3(draftId, conn.admin1, scheduled!.idempotency_key)).outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("admin_majority: needs 2 of 3 admins", async () => {
|
||||
const draftId = await approvedDraft(conn.admin_majority);
|
||||
const [poll] = await publishPolls(draftId);
|
||||
expect(poll?.rule).toMatchObject({ mode: "quorum", join: "any", arms: [{ kind: "admins", x: 2 }] });
|
||||
await vote(poll!.id, admin2, "yes");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("open");
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
await vote(poll!.id, admin3, "yes");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
|
||||
expect(await schedules(draftId)).toEqual([expect.objectContaining({ state: "pending", connection_id: conn.admin_majority })]);
|
||||
});
|
||||
|
||||
it("admin_consensus: one no blocks; expiry rejects, the draft stays approved and is re-publishable; a veto closes at once", async () => {
|
||||
const draftId = await approvedDraft(conn.admin_consensus);
|
||||
const [poll] = await publishPolls(draftId);
|
||||
expect(poll?.rule).toMatchObject({ mode: "quorum", join: "all", arms: [{ kind: "admins", x: 3 }] });
|
||||
await vote(poll!.id, ownerId, "yes");
|
||||
await vote(poll!.id, admin2, "yes");
|
||||
await vote(poll!.id, admin3, "no");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("open");
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
|
||||
await inTenant((c) => c.query("UPDATE polls SET opens_at = now() - interval '2 days', closes_at = now() - interval '1 second' WHERE id = $1", [poll!.id]));
|
||||
await new m.polls.PollService().close(ws, poll!.id);
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("failed");
|
||||
expect(await draftState(draftId)).toBe("approved");
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ outcome: "blocked", reason_codes: ["TIMEBOX_EXPIRED_AUTO_REJECT"], poll_id: poll?.id }));
|
||||
|
||||
const again = await service().requestPublish(ws, owner(), draftId, conn.admin_consensus, new Date(), "publication.publish");
|
||||
expect(again.state).toBe("awaiting_publish_approval");
|
||||
const reopened = again.state === "awaiting_publish_approval" ? again.pollId : "";
|
||||
expect(reopened).not.toBe(poll?.id);
|
||||
await vote(reopened, admin3, "veto");
|
||||
expect((await publishPolls(draftId)).map((p) => p.state)).toEqual(["failed", "vetoed"]);
|
||||
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ outcome: "blocked", reason_codes: ["VETOED"], poll_id: reopened }));
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
expect(await draftState(draftId)).toBe("approved");
|
||||
});
|
||||
|
||||
it("E3 releases a gated destination only on poll pass (schedule made before the gate was switched on)", async () => {
|
||||
const draftId = await approvedDraft(conn.manual);
|
||||
expect(await publishPolls(draftId)).toEqual([]); // manual: approval changes nothing
|
||||
const scheduled = await service().requestPublish(ws, owner(), draftId, conn.manual, new Date(Date.now() + 3_600_000), "publication.publish");
|
||||
if (scheduled.state === "awaiting_publish_approval") throw new Error("manual connection must not hold");
|
||||
|
||||
const epoch = String((await ownerPool.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [ws])).rows[0].authz_epoch);
|
||||
const session = (userId: string, role: string) => ({ userId, role, workspaceId: ws, authzEpoch: Number(epoch), issuedAt: 0, expiresAt: Math.floor(Date.now() / 1000) + 300 });
|
||||
const store = new m.store.PgConnectionStore(m.db.withTenant);
|
||||
await expect(store.setPublishGovernance(session(editorId, "Editor"), conn.manual, "auto")).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await store.setPublishGovernance(session(ownerId, "Owner"), conn.manual, "admin1");
|
||||
|
||||
const blocked = await e3(draftId, conn.manual, scheduled.idempotencyKey);
|
||||
expect(blocked).toMatchObject({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], pollState: "none" });
|
||||
|
||||
const held = await service().requestPublish(ws, owner(), draftId, conn.manual, new Date(), "publication.publish");
|
||||
if (held.state !== "awaiting_publish_approval") throw new Error("gated connection must hold");
|
||||
expect((await e3(draftId, conn.manual, scheduled.idempotencyKey)).pollState).toBe("open");
|
||||
await vote(held.pollId, admin2, "yes");
|
||||
expect((await e3(draftId, conn.manual, scheduled.idempotencyKey)).outcome).toBe("fired");
|
||||
// The pass found the earlier pending schedule still active: the release is
|
||||
// refused (audited) rather than doubling the publish.
|
||||
const audit = await inTenant(async (c) => (await c.query("SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'publication.publish_release_failed'", [draftId])).rows);
|
||||
expect(audit[0]?.diff).toMatchObject({ code: "SCHEDULE_ACTIVE", pollId: held.pollId });
|
||||
expect(await schedules(draftId)).toHaveLength(1);
|
||||
|
||||
await store.setPublishGovernance(session(ownerId, "Owner"), conn.manual, null);
|
||||
const fields = await inTenant(async (c) => (await c.query("SELECT fields FROM connections WHERE id = $1", [conn.manual])).rows[0].fields);
|
||||
expect(fields).toEqual({ handle: "manual" });
|
||||
});
|
||||
});
|
||||
93
apps/api/src/publishing/publish-governance.test.ts
Normal file
93
apps/api/src/publishing/publish-governance.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { ConflictException } from "@nestjs/common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluatePoll, type EvaluatedBallot } from "../chatgov/poll-evaluator.js";
|
||||
import { compilePublishRule, isPublishGateMode, readPublishGovernance, type PublishGateMode } from "./publish-governance.js";
|
||||
|
||||
/**
|
||||
* Publish-phase gate: the per-connection setting compiles onto the SAME §1a
|
||||
* rule shapes as proposal polls, and the existing pure evaluator decides it.
|
||||
*/
|
||||
|
||||
const opensAt = new Date("2026-10-14T10:00:00Z");
|
||||
const closesAt = new Date("2026-10-15T10:00:00Z");
|
||||
|
||||
function admin(ballot: EvaluatedBallot["ballot"]): EvaluatedBallot {
|
||||
return { ballot, optionId: null, counted: true, admin: true, observed: false, participant: true };
|
||||
}
|
||||
|
||||
function decide(mode: PublishGateMode, admins: number, ballots: EvaluatedBallot[], now = new Date("2026-10-14T10:05:00Z")) {
|
||||
return evaluatePoll({
|
||||
kind: "yes_no", rule: compilePublishRule(mode, admins), opensAt, closesAt, now, ballots, options: [],
|
||||
possible: { admins, eligible: admins, observed: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("publish governance setting", () => {
|
||||
it("reads only the known modes; anything else is manual (null)", () => {
|
||||
expect(readPublishGovernance({ publishGovernance: "auto" })).toBe("auto");
|
||||
expect(readPublishGovernance({ publishGovernance: "admin_consensus" })).toBe("admin_consensus");
|
||||
expect(readPublishGovernance({ publishGovernance: "everyone" })).toBeNull();
|
||||
expect(readPublishGovernance({})).toBeNull();
|
||||
expect(readPublishGovernance(null)).toBeNull();
|
||||
expect(isPublishGateMode("auto")).toBe(false);
|
||||
expect(isPublishGateMode(null)).toBe(false);
|
||||
expect(isPublishGateMode("admin1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compilePublishRule", () => {
|
||||
it("admin1 compiles to admin_only (the evaluator's implied single admin arm)", () => {
|
||||
const rule = compilePublishRule("admin1", 3);
|
||||
expect(rule).toMatchObject({ mode: "admin_only", anonymity: "named", allowSelfApproval: true, timebox: { duration: "P1D", onExpire: "reject" } });
|
||||
expect(rule.arms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin_majority needs floor(n/2)+1 admins, snapshotted from the roster at open", () => {
|
||||
expect(compilePublishRule("admin_majority", 3)).toMatchObject({ mode: "quorum", join: "any", arms: [{ kind: "admins", x: 2 }] });
|
||||
expect(compilePublishRule("admin_majority", 4).arms).toEqual([{ kind: "admins", x: 3 }]);
|
||||
expect(compilePublishRule("admin_majority", 1).arms).toEqual([{ kind: "admins", x: 1 }]);
|
||||
});
|
||||
|
||||
it("admin_consensus needs every admin, join all, and allows a veto", () => {
|
||||
expect(compilePublishRule("admin_consensus", 3)).toMatchObject({ mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: 3 }] });
|
||||
});
|
||||
|
||||
it("refuses to open a gate nobody can pass", () => {
|
||||
expect(() => compilePublishRule("admin1", 0)).toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish gate decisions (pure evaluator)", () => {
|
||||
it("admin1 passes with one admin yes", () => {
|
||||
expect(decide("admin1", 3, []).state).toBeNull();
|
||||
expect(decide("admin1", 3, [admin("yes")])).toMatchObject({ state: "passed", reason: "quorum_early" });
|
||||
});
|
||||
|
||||
it("admin1 ignores a non-admin yes", () => {
|
||||
expect(decide("admin1", 3, [{ ...admin("yes"), admin: false }]).state).toBeNull();
|
||||
});
|
||||
|
||||
it("admin_majority needs 2 of 3", () => {
|
||||
expect(decide("admin_majority", 3, [admin("yes")]).state).toBeNull();
|
||||
expect(decide("admin_majority", 3, [admin("yes"), admin("yes")])).toMatchObject({ state: "passed", quorum: true });
|
||||
});
|
||||
|
||||
it("admin_consensus is held by one standing no and rejects at the timebox", () => {
|
||||
const ballots = [admin("yes"), admin("yes"), admin("no")];
|
||||
expect(decide("admin_consensus", 3, ballots)).toMatchObject({ state: null, quorum: false });
|
||||
expect(decide("admin_consensus", 3, ballots, closesAt)).toMatchObject({ state: "failed", reason: "timebox" });
|
||||
expect(decide("admin_consensus", 3, [admin("yes"), admin("yes"), admin("yes")]).state).toBe("passed");
|
||||
});
|
||||
|
||||
it("admin_consensus closes immediately on an admin veto", () => {
|
||||
expect(decide("admin_consensus", 3, [admin("yes"), admin("veto")])).toMatchObject({ state: "vetoed", reason: "veto" });
|
||||
});
|
||||
|
||||
it("a removed admin can make consensus unreachable (roster changes after open never lower the bar)", () => {
|
||||
const evaluation = evaluatePoll({
|
||||
kind: "yes_no", rule: compilePublishRule("admin_consensus", 3), opensAt, closesAt, now: new Date("2026-10-14T10:05:00Z"),
|
||||
ballots: [admin("yes")], options: [], possible: { admins: 2, eligible: 2, observed: 0 },
|
||||
});
|
||||
expect(evaluation.state).toBe("quorum_unreachable");
|
||||
});
|
||||
});
|
||||
345
apps/api/src/publishing/publish-governance.ts
Normal file
345
apps/api/src/publishing/publish-governance.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { ConflictException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import { isPublishingProvider } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||
import { PublishingExecutionRepository, PublishingStateError, type ScheduledAttempt } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
import { parsePolicyRule, policyHash, type PolicyRule } from "../chatgov/policy.js";
|
||||
import { durationMs } from "../chatgov/poll-evaluator.js";
|
||||
import { pool } from "../db/client.js";
|
||||
|
||||
/**
|
||||
* Per-connection publish governance (the publish-phase gate).
|
||||
*
|
||||
* `connections.fields.publishGovernance` decides what happens once a draft
|
||||
* targeting that connection (providerMetadata.connectionId) is `approved`:
|
||||
*
|
||||
* unset manual — today's behavior: a publication.publish holder
|
||||
* calls POST /publishing/drafts/:id/publish.
|
||||
* auto the approval itself schedules the publish (now). No
|
||||
* second gate: the proposal vote was the decision.
|
||||
* admin1 a SECOND chatgov poll (subject_kind 'draft_publish',
|
||||
* admin_majority kind yes_no) opens on approval / on the publish route;
|
||||
* admin_consensus the publish is scheduled only when it passes
|
||||
* (execute-on-vote). Same rule shapes as proposal polls.
|
||||
*
|
||||
* The scheduling itself is always PublishingExecutionRepository.scheduleAttempt
|
||||
* — the durable pending row the scheduler sweep turns into the unchanged
|
||||
* `scheduledPublish` Temporal workflow — so every authorization fence the
|
||||
* claim re-reads (principal permission, approver, credential generation)
|
||||
* still applies to governed and automatic publishes. E3 additionally refuses
|
||||
* to dispatch to a gated connection unless the publish poll for the approved
|
||||
* revision passed (apps/scheduler dispatch-gate.ts).
|
||||
*/
|
||||
|
||||
export const PUBLISH_GOVERNANCE_MODES = ["auto", "admin1", "admin_majority", "admin_consensus"] as const;
|
||||
export type PublishGovernance = (typeof PUBLISH_GOVERNANCE_MODES)[number];
|
||||
export type PublishGateMode = Exclude<PublishGovernance, "auto">;
|
||||
|
||||
/** Publish polls close after a day; an unanswered gate rejects (re-publishable). */
|
||||
export const PUBLISH_GATE_TIMEBOX = { duration: "P1D", onExpire: "reject" } as const;
|
||||
|
||||
const ADMIN_ROLES = ["Owner", "Admin"];
|
||||
|
||||
/** Lenient read of stored fields: anything unrecognised is manual (null). */
|
||||
export function readPublishGovernance(fields: unknown): PublishGovernance | null {
|
||||
if (typeof fields !== "object" || fields === null) return null;
|
||||
const value = (fields as Record<string, unknown>).publishGovernance;
|
||||
return PUBLISH_GOVERNANCE_MODES.find((mode) => mode === value) ?? null;
|
||||
}
|
||||
|
||||
export function isPublishGateMode(value: PublishGovernance | null): value is PublishGateMode {
|
||||
return value === "admin1" || value === "admin_majority" || value === "admin_consensus";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a gate mode into a §1a rule against the admin roster AT OPEN.
|
||||
* The count is snapshotted into the rule (and its policy_version): admins
|
||||
* added later do not raise the bar; admins removed later can make the arm
|
||||
* unreachable, which closes the poll `quorum_unreachable` (blocked).
|
||||
*
|
||||
* admin1 admin_only (the evaluator's implied {admins, x:1} arm)
|
||||
* admin_majority quorum, [{admins, x: floor(n/2)+1}], join any
|
||||
* admin_consensus quorum, [{admins, x: n}], join all, veto allowed
|
||||
*
|
||||
* Self-approval is allowed: the content was already approved under the
|
||||
* proposal policy; this gate is the admins' sign-off on the dispatch, and
|
||||
* excluding an author-admin would make consensus unreachable by design.
|
||||
*/
|
||||
export function compilePublishRule(mode: PublishGateMode, adminCount: number): PolicyRule {
|
||||
if (!Number.isInteger(adminCount) || adminCount < 1) {
|
||||
throw new ConflictException({ code: "PUBLISH_GATE_NO_ADMINS", message: "publish gate needs at least one active admin" });
|
||||
}
|
||||
const common = { version: 1, anonymity: "named", allowSelfApproval: true, resetOnChange: false, timebox: PUBLISH_GATE_TIMEBOX };
|
||||
switch (mode) {
|
||||
case "admin1":
|
||||
return parsePolicyRule({ ...common, mode: "admin_only" });
|
||||
case "admin_majority":
|
||||
return parsePolicyRule({ ...common, mode: "quorum", join: "any", arms: [{ kind: "admins", x: Math.floor(adminCount / 2) + 1 }] });
|
||||
case "admin_consensus":
|
||||
return parsePolicyRule({ ...common, mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: adminCount }] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins whose ballot counts toward an `admins` arm — the same definition the
|
||||
* poll tally uses (poll.service resolveTally): active Owner/Admin members
|
||||
* holding chatgov.vote, plus active members with an approved Admin/Owner
|
||||
* chat-identity role override.
|
||||
*/
|
||||
export async function governanceAdminIds(client: PoolClient, workspaceId: string): Promise<string[]> {
|
||||
const rows = await client.query<{ user_id: string }>(
|
||||
`SELECT m.user_id FROM memberships m
|
||||
WHERE m.workspace_id = $1 AND m.status = 'active'
|
||||
AND ((m.role = ANY($2::text[]) AND EXISTS (SELECT 1 FROM role_permissions rp WHERE rp.role = m.role AND rp.permission = 'chatgov.vote'))
|
||||
OR EXISTS (SELECT 1 FROM chat_identities ci WHERE ci.user_id = m.user_id AND ci.status = 'approved' AND ci.role_override = ANY($2::text[])))
|
||||
ORDER BY m.user_id`,
|
||||
[workspaceId, ADMIN_ROLES],
|
||||
);
|
||||
return rows.rows.map((row) => String(row.user_id));
|
||||
}
|
||||
|
||||
/** What the publish route answers when the publish is held for admins. */
|
||||
export interface PublishHold {
|
||||
state: "awaiting_publish_approval";
|
||||
draftId: string;
|
||||
connectionId: string;
|
||||
publishGovernance: PublishGateMode;
|
||||
pollId: string;
|
||||
closesAt: Date;
|
||||
rule: PolicyRule;
|
||||
}
|
||||
|
||||
interface PublishRequest {
|
||||
publishAt: string;
|
||||
requiredPermission: "publication.schedule" | "publication.publish";
|
||||
}
|
||||
|
||||
interface PublishPollRow {
|
||||
id: string;
|
||||
state: string;
|
||||
closes_at: Date;
|
||||
rule: PolicyRule;
|
||||
}
|
||||
|
||||
function holdFrom(draftId: string, connectionId: string, mode: PublishGateMode, poll: PublishPollRow): PublishHold {
|
||||
return { state: "awaiting_publish_approval", draftId, connectionId, publishGovernance: mode, pollId: poll.id, closesAt: new Date(poll.closes_at), rule: poll.rule };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish-route gate (runs inside PublishingService.schedule's transaction,
|
||||
* after its tenancy/provider/group checks). `proceed` = the publish poll for
|
||||
* the CURRENT approved revision passed, or the draft is not currently
|
||||
* approved (scheduleAttempt then refuses with its usual error). Otherwise
|
||||
* the open poll is reused, or a fresh one is opened (first request, or a
|
||||
* re-publish after a failed/expired/vetoed gate).
|
||||
*/
|
||||
export async function checkPublishGate(client: PoolClient, input: {
|
||||
workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode;
|
||||
requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"];
|
||||
}): Promise<{ kind: "proceed" } | { kind: "held"; hold: PublishHold; opened: boolean }> {
|
||||
const draft = (await client.query(
|
||||
"SELECT state, revision, approved_revision, approved_hash FROM drafts WHERE id = $1 FOR UPDATE", [input.draftId])).rows[0];
|
||||
if (draft === undefined || draft.state !== "approved" || draft.approved_hash === null
|
||||
|| draft.approved_revision === null || String(draft.approved_revision) !== String(draft.revision)) {
|
||||
return { kind: "proceed" };
|
||||
}
|
||||
const latest = await latestPublishPoll(client, input.draftId, input.connectionId, String(draft.approved_revision));
|
||||
if (latest?.state === "passed") return { kind: "proceed" };
|
||||
if (latest?.state === "open") return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, latest), opened: false };
|
||||
const poll = await openPublishPoll(client, { ...input, revision: String(draft.approved_revision) });
|
||||
return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, poll), opened: true };
|
||||
}
|
||||
|
||||
async function latestPublishPoll(client: PoolClient, draftId: string, connectionId: string, revision: string): Promise<PublishPollRow | undefined> {
|
||||
return (await client.query<PublishPollRow>(
|
||||
`SELECT id, state, closes_at, rule FROM polls
|
||||
WHERE draft_id = $1 AND subject_kind = 'draft_publish' AND publish_connection_id = $2 AND draft_revision = $3
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[draftId, connectionId, revision],
|
||||
)).rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the publish-phase poll + its pending E2 gate row + audit, in the
|
||||
* caller's transaction (the caller holds the draft row lock; same-transaction
|
||||
* FK KEY SHARE on gate_decisions/polls is safe). The requester is recorded as
|
||||
* created_by and becomes the scheduling principal on release.
|
||||
*/
|
||||
export async function openPublishPoll(client: PoolClient, input: {
|
||||
workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; revision: string;
|
||||
requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"];
|
||||
}): Promise<PublishPollRow> {
|
||||
const admins = await governanceAdminIds(client, input.workspaceId);
|
||||
const rule = compilePublishRule(input.mode, admins.length);
|
||||
const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1", [input.connectionId])).rows[0];
|
||||
const fields = (connection?.fields ?? {}) as Record<string, unknown>;
|
||||
const label = typeof fields.handle === "string" && fields.handle !== "" ? fields.handle : String(connection?.provider ?? "connection");
|
||||
const question = `Publish the approved draft to ${label}?`.slice(0, 300);
|
||||
const now = new Date();
|
||||
const request: PublishRequest = { publishAt: input.publishAt.toISOString(), requiredPermission: input.requiredPermission };
|
||||
const policyVersion = policyHash(rule);
|
||||
const poll = (await client.query<PublishPollRow>(
|
||||
`INSERT INTO polls (workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at,
|
||||
created_by, draft_revision, publish_connection_id, publish_request)
|
||||
VALUES ($1, $2, 'draft_publish', $3, 'yes_no', $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, state, closes_at, rule`,
|
||||
[input.workspaceId, input.draftId, question, rule, policyVersion, now, new Date(now.getTime() + durationMs(rule.timebox?.duration ?? "P1D")),
|
||||
input.requestedBy, input.revision, input.connectionId, request],
|
||||
)).rows[0]!;
|
||||
const needed = rule.mode === "admin_only" ? 1 : rule.arms?.[0]?.x ?? 1;
|
||||
await client.query(
|
||||
`INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
||||
VALUES ($1, $2, $3, 'E2', $4, 'pending', $5, $6, $7, $8)`,
|
||||
[input.workspaceId, input.draftId, { platform: String(connection?.provider ?? "unknown"), accountId: input.connectionId }, policyVersion,
|
||||
{ phase: "publish", publishGovernance: input.mode, adminCount: admins.length, arms: [{ kind: "admins", got: 0, needed }], pollState: "open" },
|
||||
["INSUFFICIENT_QUORUM", "PENDING_POLL"], `user:${input.requestedBy}`, poll.id],
|
||||
);
|
||||
await client.query("SELECT audit_append($1, $2, 'Member', 'poll.opened', 'poll', $3, $4)", [input.workspaceId, input.requestedBy, poll.id,
|
||||
scrub({ draftId: input.draftId, kind: "yes_no", phase: "publish", connectionId: input.connectionId, publishGovernance: input.mode,
|
||||
adminCount: admins.length, policyVersion, actor: `user:${input.requestedBy}` })]);
|
||||
return poll;
|
||||
}
|
||||
|
||||
type ScheduleRepository = Pick<PublishingExecutionRepository, "scheduleAttempt">;
|
||||
let defaultRepository: ScheduleRepository | undefined;
|
||||
function repositoryOrDefault(repository?: ScheduleRepository): ScheduleRepository {
|
||||
if (repository !== undefined) return repository;
|
||||
defaultRepository ??= new PublishingExecutionRepository(pool);
|
||||
return defaultRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule inside a SAVEPOINT so a refused publish (credential gone, approval
|
||||
* voided, schedule already active, principal revoked) never rolls back the
|
||||
* vote / approval that triggered it. The refusal is audited instead.
|
||||
*/
|
||||
async function scheduleGuarded(client: PoolClient, repository: ScheduleRepository, input: Parameters<ScheduleRepository["scheduleAttempt"]>[1]):
|
||||
Promise<{ ok: true; attempt: ScheduledAttempt } | { ok: false; code: string }> {
|
||||
await client.query("SAVEPOINT publish_governance");
|
||||
try {
|
||||
const attempt = await repository.scheduleAttempt(client, input);
|
||||
await client.query("RELEASE SAVEPOINT publish_governance");
|
||||
return { ok: true, attempt };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK TO SAVEPOINT publish_governance");
|
||||
if (error instanceof PublishingStateError) return { ok: false, code: error.code };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function auditDraft(client: PoolClient, workspaceId: string, actor: { userId: string | null; role: string }, action: string, draftId: string, diff: Record<string, unknown>): Promise<void> {
|
||||
await client.query("SELECT audit_append($1, $2, $3, $4, 'draft', $5, $6)", [workspaceId, actor.userId, actor.role, action, draftId, scrub(diff)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* E2 close seam for publish polls (called by poll.service evaluateLocked
|
||||
* under the workspace → draft → poll locks, after the exactly-once claim).
|
||||
* A passed poll schedules the publish the requester asked for; the scheduler
|
||||
* sweep starts the workflow. A refused schedule is audited, the poll stays
|
||||
* passed, and the holder can re-publish once the cause is fixed.
|
||||
*/
|
||||
export async function releasePublishPoll(client: PoolClient, poll: {
|
||||
id: string; workspace_id: string; draft_id: string | null; created_by: string;
|
||||
publish_connection_id: string | null; publish_request: unknown;
|
||||
}, repository?: ScheduleRepository): Promise<{ released: true; scheduleId: string } | { released: false; code: string }> {
|
||||
if (poll.draft_id === null || poll.publish_connection_id === null) return { released: false, code: "NOT_A_PUBLISH_POLL" };
|
||||
const request = (poll.publish_request ?? {}) as Partial<PublishRequest>;
|
||||
const requested = typeof request.publishAt === "string" ? new Date(request.publishAt) : new Date();
|
||||
const publishAt = Number.isFinite(requested.getTime()) && requested.getTime() > Date.now() ? requested : new Date();
|
||||
const requiredPermission = request.requiredPermission === "publication.schedule" ? "publication.schedule" : "publication.publish";
|
||||
const result = await scheduleGuarded(client, repositoryOrDefault(repository), {
|
||||
workspaceId: poll.workspace_id, draftId: poll.draft_id, connectionId: poll.publish_connection_id,
|
||||
scheduledByUserId: poll.created_by, requiredPermission, publishAt,
|
||||
});
|
||||
const system = { userId: null, role: "System" };
|
||||
if (!result.ok) {
|
||||
await auditDraft(client, poll.workspace_id, system, "publication.publish_release_failed", poll.draft_id,
|
||||
{ pollId: poll.id, connectionId: poll.publish_connection_id, code: result.code, actor: `system:chat-poll-gate:${poll.id}` });
|
||||
return { released: false, code: result.code };
|
||||
}
|
||||
await auditDraft(client, poll.workspace_id, system, "publication.scheduled", poll.draft_id, {
|
||||
connectionId: poll.publish_connection_id, publishAt: publishAt.toISOString(), trigger: "publish_poll_passed",
|
||||
pollId: poll.id, scheduledByUserId: poll.created_by, actor: `system:chat-poll-gate:${poll.id}`,
|
||||
});
|
||||
return { released: true, scheduleId: result.attempt.scheduleId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The principal a system-initiated publish runs as: the approving user when
|
||||
* they could publish to this connection themselves, else a live Owner/Admin
|
||||
* (the poll-approval delegation pattern). Mirrors the repository's authorize
|
||||
* predicate, which re-checks it at schedule and at every claim.
|
||||
*/
|
||||
async function publishPrincipal(client: PoolClient, workspaceId: string, connectionId: string, preferredUserId: string | null): Promise<string | null> {
|
||||
const row = (await client.query<{ user_id: string }>(
|
||||
`SELECT m.user_id FROM memberships m
|
||||
JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'publication.publish'
|
||||
WHERE m.workspace_id = $1 AND m.status = 'active'
|
||||
AND (m.role = ANY($4::text[]) OR EXISTS (SELECT 1 FROM group_connections gc JOIN group_members gm ON gm.group_id = gc.group_id
|
||||
WHERE gc.connection_id = $2 AND gm.user_id = m.user_id))
|
||||
ORDER BY COALESCE(m.user_id = $3::uuid, false) DESC, CASE m.role WHEN 'Owner' THEN 0 WHEN 'Admin' THEN 1 ELSE 2 END, m.user_id
|
||||
LIMIT 1`,
|
||||
[workspaceId, connectionId, preferredUserId, ADMIN_ROLES],
|
||||
)).rows[0];
|
||||
return row === undefined ? null : String(row.user_id);
|
||||
}
|
||||
|
||||
export type ApprovalFollowUp =
|
||||
| { kind: "dispatched"; connectionId: string; attempt: ScheduledAttempt; publishAt: Date }
|
||||
| { kind: "held"; connectionId: string; pollId: string }
|
||||
| { kind: "skipped"; connectionId: string; code: string };
|
||||
|
||||
/**
|
||||
* Approval seam: runs in the SAME transaction that moved the draft to
|
||||
* `approved` (console approve, or the E2 proposal-poll pass), after the
|
||||
* UPDATE. Manual connections (no setting) return null — today's behavior.
|
||||
* Never throws for a refused follow-up: the approval must stand on its own.
|
||||
*/
|
||||
export async function onDraftApproved(client: PoolClient, input: {
|
||||
workspaceId: string; draftId: string; providerMetadata: Record<string, unknown>;
|
||||
preferredUserId: string | null; actor: { userId: string | null; role: string };
|
||||
}, repository?: ScheduleRepository): Promise<ApprovalFollowUp | null> {
|
||||
const connectionId = input.providerMetadata.connectionId;
|
||||
if (typeof connectionId !== "string" || connectionId === "") return null;
|
||||
const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId])).rows[0];
|
||||
if (connection === undefined) return null;
|
||||
const mode = readPublishGovernance(connection.fields);
|
||||
if (mode === null) return null;
|
||||
const skip = async (code: string): Promise<ApprovalFollowUp> => {
|
||||
await auditDraft(client, input.workspaceId, input.actor, "publication.auto_publish_skipped", input.draftId, { connectionId, publishGovernance: mode, code });
|
||||
return { kind: "skipped", connectionId, code };
|
||||
};
|
||||
if (!isPublishingProvider(String(connection.provider))) return skip("PROVIDER_NOT_PUBLISHING");
|
||||
const principal = await publishPrincipal(client, input.workspaceId, connectionId, input.preferredUserId);
|
||||
if (principal === null) return skip("NO_AUTHORIZED_PRINCIPAL");
|
||||
|
||||
if (mode === "auto") {
|
||||
const publishAt = new Date();
|
||||
const result = await scheduleGuarded(client, repositoryOrDefault(repository), {
|
||||
workspaceId: input.workspaceId, draftId: input.draftId, connectionId,
|
||||
scheduledByUserId: principal, requiredPermission: "publication.publish", publishAt,
|
||||
});
|
||||
if (!result.ok) return skip(result.code);
|
||||
await auditDraft(client, input.workspaceId, input.actor, "publication.scheduled", input.draftId, {
|
||||
connectionId, publishAt: publishAt.toISOString(), trigger: "auto_on_approval", scheduledByUserId: principal,
|
||||
});
|
||||
return { kind: "dispatched", connectionId, attempt: result.attempt, publishAt };
|
||||
}
|
||||
|
||||
// Gate modes: the second gate starts running as soon as the draft is
|
||||
// approved; the publish route reuses this poll instead of opening another.
|
||||
await client.query("SAVEPOINT publish_governance_open");
|
||||
try {
|
||||
const gate = await checkPublishGate(client, {
|
||||
workspaceId: input.workspaceId, draftId: input.draftId, connectionId, mode,
|
||||
requestedBy: principal, publishAt: new Date(), requiredPermission: "publication.publish",
|
||||
});
|
||||
await client.query("RELEASE SAVEPOINT publish_governance_open");
|
||||
if (gate.kind === "held") return { kind: "held", connectionId, pollId: gate.hold.pollId };
|
||||
return null;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK TO SAVEPOINT publish_governance_open");
|
||||
if (error instanceof ConflictException) return skip("PUBLISH_GATE_UNAVAILABLE");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ScheduledPublicationRecord> {
|
||||
): Promise<PublishOutcome> {
|
||||
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<ScheduledPublicationRecord> {
|
||||
async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||
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<ScheduledPublicationRecord> {
|
||||
async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||
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<ScheduledPublicationRecord> {
|
||||
async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||
return this.resume(request, draftId, body, "needs_reauthorization");
|
||||
}
|
||||
|
||||
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<ScheduledPublicationRecord> {
|
||||
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<PublishOutcome> {
|
||||
const session = requireSession(request);
|
||||
const 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")
|
||||
|
|
|
|||
|
|
@ -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<DraftRecord> {
|
||||
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<ScheduledPublicationRecord> {
|
||||
const outcome = await this.requestPublish(workspaceId, actor, draftId, connectionId, publishAt, requiredPermission, expectedPrevious);
|
||||
if (outcome.state === "awaiting_publish_approval") {
|
||||
throw new ConflictException({ code: "PUBLISH_AWAITING_APPROVAL", message: "publish is held for the connection's admin publish gate", hold: outcome });
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Schedule, or hold behind the target connection's publish gate. */
|
||||
async requestPublish(
|
||||
workspaceId: string,
|
||||
actor: Actor,
|
||||
draftId: string,
|
||||
connectionId: string,
|
||||
publishAt: Date,
|
||||
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
|
||||
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
|
||||
): Promise<PublishOutcome> {
|
||||
if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp");
|
||||
|
||||
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<void>,
|
||||
/** Runs after the UPDATE + audit, same transaction (publish governance). */
|
||||
afterTransition?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
|
||||
): Promise<DraftRecord> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
|
||||
|
|
@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement {
|
|||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
|
||||
// Dispatch receipts per draft: one GET per scheduled draft (the API has no
|
||||
// bulk endpoint), refreshed with the list. Only drafts that actually have
|
||||
// publications surface a badge.
|
||||
const [publications, setPublications] = useState<Record<string, PublicationsResponse | null>>({});
|
||||
const fetchPublications = useCallback((ids: string[]) => {
|
||||
if (!live || ids.length === 0) return;
|
||||
for (const id of ids) {
|
||||
void api
|
||||
.get<PublicationsResponse>(`/publishing/drafts/${id}/publications`)
|
||||
.then((response) => setPublications((current) => ({ ...current, [id]: response })))
|
||||
.catch(() => setPublications((current) => ({ ...current, [id]: null })));
|
||||
}
|
||||
}, [live]);
|
||||
|
||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||
|
||||
// The scheduled set as a stable string so the fetch effect below only fires
|
||||
// when it actually changes, and the timer below always reads the latest ids.
|
||||
const draftIds = rows.map((row) => row.id);
|
||||
const draftIdsRef = useRef<string[]>([]);
|
||||
draftIdsRef.current = draftIds;
|
||||
const draftIdsKey = draftIds.join(",");
|
||||
|
||||
// Fetch receipts on load and whenever the scheduled set changes (a new
|
||||
// schedule appears, or one is cancelled).
|
||||
useEffect(() => {
|
||||
fetchPublications(draftIdsKey === "" ? [] : draftIdsKey.split(","));
|
||||
}, [fetchPublications, draftIdsKey]);
|
||||
|
||||
// Same cadence as the streams wall: refresh on a 30s timer.
|
||||
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<void> {
|
||||
if (!live) {
|
||||
|
|
@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<span className="ml-auto text-2xs text-text-faint">
|
||||
{rows.length} scheduled · refreshes every 30s
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); fetchPublications(draftIdsRef.current); setNow(Date.now()); }}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
|
||||
UTC time beside it; execution state lives in the audit log, not this list.
|
||||
UTC time beside it; drafts with dispatch receipts show their latest outcome, with the remote link when the
|
||||
provider returned one.
|
||||
</p>
|
||||
|
||||
{notice !== null && (
|
||||
|
|
@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
|
||||
{group.rows.map((row) => {
|
||||
const relative = relativeLabel(row.publishAt, now);
|
||||
const latest = latestPublication(publications[row.id]);
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
|
||||
<div className="flex w-44 shrink-0 flex-col gap-0.5">
|
||||
|
|
@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
|
||||
<Badge tone="accent">scheduled</Badge>
|
||||
{latest !== null && (
|
||||
<Badge tone={latest.state === "completed" ? "live" : latest.state === "failed" ? "danger" : "warn"}>
|
||||
{latest.state.replace(/_/g, " ")}
|
||||
{latest.remoteUrl !== null && (
|
||||
<a href={latest.remoteUrl} target="_blank" rel="noreferrer" className="underline decoration-current/40 underline-offset-2 hover:decoration-current">
|
||||
open ↗
|
||||
</a>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{row.targetLabel}</span>
|
||||
{!row.fromScheduledAt && (
|
||||
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
228
infra/occupymedia/edge/hub-chat-auth.css
Normal file
228
infra/occupymedia/edge/hub-chat-auth.css
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/* Element Web 1.12.30 auth layout overrides, loaded by the chat edge vhost.
|
||||
* Keep every rule inside mx_AuthPage: the signed-in client keeps its existing
|
||||
* custom-OccupyHub Dark theme and Compound tokens. No hashed selectors or JS.
|
||||
* branding.auth_header_logo_url supplies the small Hub mark; see ../element/.
|
||||
*/
|
||||
.mx_AuthPage {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
height: auto !important;
|
||||
padding: 88px 20px 24px;
|
||||
background: #060403 !important; /* Also replaces the inline background image. */
|
||||
color: #f5efed;
|
||||
color-scheme: dark;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif !important;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthPage_modal {
|
||||
/* AuthHeader's absolute positioning is relative to AuthPage, not the card. */
|
||||
box-sizing: border-box;
|
||||
position: static !important;
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
border: 1px solid #2a2320;
|
||||
border-radius: 10px;
|
||||
background: #0f0a09 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthPage_modalBlur {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthPage_modalContent {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #0f0a09 !important;
|
||||
}
|
||||
|
||||
/* Turn the old logo sidebar into quiet branding in the top-left corner.
|
||||
* Re-enable it on phones: Element normally hides AuthHeader below 480px.
|
||||
*/
|
||||
.mx_AuthPage .mx_AuthHeader {
|
||||
position: absolute;
|
||||
inset: 24px auto auto 24px;
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
max-width: calc(100% - 48px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthHeaderLogo {
|
||||
display: flex !important;
|
||||
flex: 0 1 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthHeaderLogo img {
|
||||
display: block;
|
||||
flex: 0 0 32px;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
max-width: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthHeaderLogo::after {
|
||||
content: "Tactical Media Broadcast Hub";
|
||||
color: #f5efed;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthBody {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border-radius: inherit;
|
||||
background: #0f0a09 !important;
|
||||
color: #c0b4af;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthBody h1 {
|
||||
margin: 0 0 20px;
|
||||
color: #f5efed;
|
||||
font-size: 20px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_Register_mainContent {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* With disable_custom_urls=true this is only a redundant homeserver label.
|
||||
* Keep the picker visible if a future config allows editing the server.
|
||||
*/
|
||||
.mx_AuthPage .mx_ServerPicker:not(:has(.mx_ServerPicker_change)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_Field:not(.mx_Field_valid):not(.mx_Field_invalid) {
|
||||
border-color: #3a322d;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_Field:focus-within {
|
||||
border-color: #ff1610;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_Field :is(input, select, textarea) {
|
||||
background: #0f0a09 !important;
|
||||
color: #f5efed !important;
|
||||
font-family: inherit;
|
||||
font-size: 16px; /* Avoid iOS zoom when focusing a field. */
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_Field label {
|
||||
background-color: #0f0a09 !important;
|
||||
color: #c0b4af;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mx_AuthPage :is(a, .mx_AccessibleButton_kind_link, .mx_AccessibleButton_kind_link_inline) {
|
||||
color: #c0b4af !important;
|
||||
}
|
||||
|
||||
.mx_AuthPage :is(a, button, [role="button"]):focus-visible {
|
||||
outline: 2px solid #ff1610;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Compound's data-kind is stable; Element's submit class covers older buttons. */
|
||||
.mx_AuthPage :is(button[type="submit"], .mx_Login_submit, [data-kind="primary"]) {
|
||||
min-height: 36px;
|
||||
height: auto;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #ff1610 !important;
|
||||
color: #fff !important;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mx_AuthPage :is(button[type="submit"], .mx_Login_submit, [data-kind="primary"]):hover:not(:disabled):not([aria-disabled="true"]) {
|
||||
background: #e3130e !important;
|
||||
}
|
||||
|
||||
.mx_AuthPage :is(button[type="submit"], .mx_Login_submit, [data-kind="primary"]):is(:disabled, [aria-disabled="true"]) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthFooter {
|
||||
box-sizing: border-box;
|
||||
margin-top: 24px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthFooter a {
|
||||
display: inline-block;
|
||||
margin: 4px 10px;
|
||||
}
|
||||
|
||||
/* Defensive fallback while config is being rolled out: no welcome hero. */
|
||||
.mx_AuthPage .mx_DefaultWelcome {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_DefaultWelcome > :is(h1, p),
|
||||
.mx_AuthPage .mx_DefaultWelcome_logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_DefaultWelcome_buttons {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_DefaultWelcome_buttons a {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mx_AuthPage {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthHeader {
|
||||
inset-inline-start: 16px;
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthBody {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_Register_footerActions {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthBody_fieldRow {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mx_AuthPage .mx_AuthBody_fieldRow > .mx_Field {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 '</head>' '<link rel="stylesheet" href="/hub-chat-auth.css?v=1"></head>';
|
||||
sub_filter_once on;
|
||||
# text/html is sub_filter's default type; JSON/JS/CSS are not rewritten.
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 '</head>' '<link rel="stylesheet" href="/hub-chat-auth.css?v=1"></head>';
|
||||
sub_filter_once on;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
215
infra/occupymedia/element/AUTH-RESTYLE.md
Normal file
215
infra/occupymedia/element/AUTH-RESTYLE.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# Chat sign-in restyle and deployment report
|
||||
|
||||
Repo implementation only; no production connection, deployment, or git commit
|
||||
was made. The server-owned `/opt/occ-element/config.json` is not in this repo.
|
||||
The supplied patch merges into that file rather than replacing its configuration.
|
||||
|
||||
Signed-out visitors opening `chat.occupymedia.cc` will see a small Hub mark and
|
||||
wordmark in the top-left, a centered card containing **Sign in**, username and
|
||||
password fields, and a red submit button. Password recovery and account creation
|
||||
remain available as quiet links. Registration uses the same compact card. The
|
||||
background is `#060403`, the card is `#0F0A09`, and the font is system-ui. The
|
||||
mark is capped at 32px; there is no logo sidebar, background artwork, or hero.
|
||||
Long forms and errors scroll on small screens rather than overlapping the brand.
|
||||
Element's footer remains small and muted.
|
||||
|
||||
## Verified Element Web 1.12.30 behavior
|
||||
|
||||
No Element source or config template was found in the repository or its installed
|
||||
dependencies. Research used the official **v1.12.30** source archive and the
|
||||
matching `ghcr.io/element-hq/element-web:v1.12.30` image, in temporary local files.
|
||||
|
||||
| Configuration | Effect in 1.12.30 |
|
||||
| --- | --- |
|
||||
| `branding.auth_header_logo_url` | Image source in login/registration headers and the default welcome component. This is nested under `branding`, not a top-level key. |
|
||||
| `branding.logo_link_url` | Link destination where Element actually renders a linked logo, such as default welcome. |
|
||||
| `auth_header_logo_click_url` | Does not exist in this release. The login/registration header renders an unlinked image; a config option cannot make that header clickable. |
|
||||
| `brand` | App name used in translated text; does not change the header's layout or image. |
|
||||
| `branding.welcome_background_url` | Background artwork across auth pages. The injected CSS replaces the inline background with a solid color. |
|
||||
| `embedded_pages.login_for_welcome` | Uses the login form for the signed-out landing page, bypassing default/custom welcome content. |
|
||||
| `default_server_config` | Selects the homeserver and identity-server connection details; does not control branding or layout. Left intact. |
|
||||
| `disable_custom_urls` | Removes server editing. The CSS hides the redundant server label only when the edit control is absent. |
|
||||
| `disable_3pid_login` | Removes the username/email/phone selector, leaving username/password. |
|
||||
| `disable_login_language_selector` | Removes the language dropdown from the auth header. |
|
||||
|
||||
See the pinned [configuration docs](https://github.com/element-hq/element-web/blob/v1.12.30/docs/config.md),
|
||||
[AuthHeaderLogo source](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/components/views/auth/AuthHeaderLogo.tsx),
|
||||
and [DefaultWelcome source](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/components/views/auth/DefaultWelcome.tsx).
|
||||
|
||||
The structure is `.mx_AuthPage` → `.mx_AuthPage_modal` →
|
||||
`main.mx_AuthPage_modalContent`, with `.mx_AuthHeader` and `.mx_AuthBody` as
|
||||
siblings. The large logo comes from `.mx_AuthHeaderLogo > img` inside a 206px
|
||||
sidebar; the original body is 500px wide. The wrapper also renders a blur layer
|
||||
and `.mx_AuthFooter`. See [AuthPage](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/components/views/auth/AuthPage.tsx)
|
||||
and [upstream auth CSS](https://github.com/element-hq/element-web/tree/v1.12.30/apps/web/res/css/views/auth).
|
||||
There is no `.mx_AuthPage_logo` in this release's auth structure.
|
||||
|
||||
`setting_defaults.custom_themes` accepts theme colors, fonts and Compound token
|
||||
overrides, not an arbitrary CSS stylesheet URL. `source_url` records a theme
|
||||
JSON's origin, not a CSS file. The injected link intentionally has no
|
||||
`data-mx-theme`, so Element's theme switching will not disable it. See the
|
||||
[theming docs](https://github.com/element-hq/element-web/blob/v1.12.30/docs/theming.md)
|
||||
and [theme loader/types](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/theme.ts).
|
||||
|
||||
On first visits from iOS/Android, Element redirects a bare `/` to
|
||||
`/mobile_guide/` **before** loading config. Both chat vhosts redirect that
|
||||
app-download page to `/#/login`, which bypasses the mobile redirect. Disabling
|
||||
`mobile_guide_toast` also removes the later mobile-app prompt. See the pinned
|
||||
[startup source](https://github.com/element-hq/element-web/blob/v1.12.30/apps/web/src/vector/index.ts).
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| [../edge/hub-chat-auth.css](../edge/hub-chat-auth.css) | Scoped auth overrides: corner branding, compact responsive card, warm colors, focus styles and red primary actions. Uses stable `mx_*` classes, structural selectors and Compound `data-kind`, with no hashed class selectors. |
|
||||
| [../edge/nginx.conf](../edge/nginx.conf) | Both HTTP tunnel and HTTPS chat vhosts serve `/hub-chat-auth.css` and inject its link once before `</head>` in HTML. Clears upstream Accept-Encoding so sub_filter can read HTML. Routes the mobile guide to login. Matrix API and SSO helper keep their own locations. |
|
||||
| [../edge/docker-compose.edge.yml](../edge/docker-compose.edge.yml) | Read-only bind mount for the stylesheet. |
|
||||
| [auth-branding.patch.json](auth-branding.patch.json) | Hub logo URL, supported logo-link setting, direct login landing and removal of extra login selectors. |
|
||||
| [apply-auth-branding.py](apply-auth-branding.py) | Merge/preview utility. Preserves unrelated settings and writes in place to retain Docker's single-file bind mount. |
|
||||
| [../edge/README.md](../edge/README.md) | Links this report and corrects the documented chat routing. |
|
||||
| [AUTH-RESTYLE.md](AUTH-RESTYLE.md) | This research and deployment report. |
|
||||
|
||||
The stylesheet is downloaded with the SPA shell; nginx cannot see hash routes.
|
||||
It applies only within `.mx_AuthPage`, including recovery/auth verification
|
||||
screens using that wrapper. Signed-in room UI keeps the existing custom theme.
|
||||
The `element.occupymedia.cc` alias vhost is not changed.
|
||||
|
||||
## Exact deployment steps (operator runs these; not executed here)
|
||||
|
||||
Use the existing CT 150 checkout at `/opt/anarchoposter`. Run server commands
|
||||
with permission to edit `/opt/occ-element/config.json` and manage Docker.
|
||||
|
||||
1. **On the server, before copying files**, back up the config and edge files.
|
||||
Keep this shell open so `occ_auth_backup` remains available for later steps.
|
||||
|
||||
```bash
|
||||
occ_auth_backup="/opt/occ-element/backups/hub-auth-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
mkdir -p "$occ_auth_backup"
|
||||
cp -p /opt/occ-element/config.json "$occ_auth_backup/config.json"
|
||||
cp -p /opt/anarchoposter/infra/occupymedia/edge/nginx.conf "$occ_auth_backup/nginx.conf"
|
||||
cp -p /opt/anarchoposter/infra/occupymedia/edge/docker-compose.edge.yml "$occ_auth_backup/docker-compose.edge.yml"
|
||||
if test -f /opt/anarchoposter/infra/occupymedia/edge/hub-chat-auth.css; then
|
||||
cp -p /opt/anarchoposter/infra/occupymedia/edge/hub-chat-auth.css "$occ_auth_backup/hub-chat-auth.css"
|
||||
fi
|
||||
printf 'Backup: %s\n' "$occ_auth_backup"
|
||||
```
|
||||
|
||||
2. **From this workstation's repo root**, copy only the reviewed infra files.
|
||||
Set `OCC_EDGE_SSH` to your existing SSH destination for CT 150; no hostname or
|
||||
credentials are assumed here.
|
||||
|
||||
```bash
|
||||
OCC_EDGE_SSH='YOUR_EXISTING_CT_150_SSH_DESTINATION'
|
||||
rsync -aR -- \
|
||||
infra/occupymedia/edge/nginx.conf \
|
||||
infra/occupymedia/edge/docker-compose.edge.yml \
|
||||
infra/occupymedia/edge/hub-chat-auth.css \
|
||||
infra/occupymedia/edge/README.md \
|
||||
infra/occupymedia/element/ \
|
||||
"$OCC_EDGE_SSH:/opt/anarchoposter/"
|
||||
```
|
||||
|
||||
3. **On the server**, validate the candidate edge config with the new mounts,
|
||||
then merge the auth patch and recreate only the edge service:
|
||||
|
||||
```bash
|
||||
cd /opt/anarchoposter/infra/occupymedia/edge
|
||||
docker compose -f docker-compose.edge.yml config --quiet
|
||||
docker compose -f docker-compose.edge.yml run --rm --no-deps edge nginx -t
|
||||
python3 ../element/apply-auth-branding.py /opt/occ-element/config.json > "$occ_auth_backup/config.preview.json"
|
||||
diff -u "$occ_auth_backup/config.json" "$occ_auth_backup/config.preview.json"
|
||||
python3 ../element/apply-auth-branding.py /opt/occ-element/config.json --write
|
||||
docker compose -f docker-compose.edge.yml up -d --no-deps --force-recreate edge
|
||||
docker exec occ-edge-1 nginx -t
|
||||
```
|
||||
|
||||
Run each command only if the preceding validation succeeded; `diff` exits 1
|
||||
for the expected changes. Recreating the edge is required for the new CSS
|
||||
mount and ensures it sees copied files even if their inodes changed. Element
|
||||
needs no restart: the merge utility retains the config inode. Its existing
|
||||
`setting_defaults.custom_themes`, Compound tokens, `default_theme`, and
|
||||
`default_server_config` remain intact. Do not replace config.json with the
|
||||
patch JSON or try to register this CSS as a custom theme.
|
||||
|
||||
4. **On the server**, verify the public HTML, stylesheet and served config:
|
||||
|
||||
```bash
|
||||
curl -fsS --compressed https://chat.occupymedia.cc/ -o "$occ_auth_backup/index.served.html"
|
||||
curl -fsS 'https://chat.occupymedia.cc/hub-chat-auth.css?v=1' -o "$occ_auth_backup/auth.served.css"
|
||||
curl -fsS https://chat.occupymedia.cc/config.json -o "$occ_auth_backup/config.served.json"
|
||||
cmp hub-chat-auth.css "$occ_auth_backup/auth.served.css"
|
||||
python3 - "$occ_auth_backup" <<'PY'
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
backup = Path(sys.argv[1])
|
||||
html = (backup / "index.served.html").read_text()
|
||||
assert html.count('href="/hub-chat-auth.css?v=1"') == 1
|
||||
old = json.loads((backup / "config.json").read_text())
|
||||
served = json.loads((backup / "config.served.json").read_text())
|
||||
for key in ("setting_defaults", "default_theme", "default_server_config", "default_server_name"):
|
||||
assert served.get(key) == old.get(key), key
|
||||
assert served["branding"]["auth_header_logo_url"] == "https://app.occupymedia.cc/brand/logo.svg"
|
||||
assert served["branding"]["logo_link_url"] == "https://app.occupymedia.cc"
|
||||
assert served["embedded_pages"]["login_for_welcome"] is True
|
||||
assert served["disable_3pid_login"] is True
|
||||
assert served["disable_custom_urls"] is True
|
||||
print("Public HTML injection, stylesheet and preserved theme verified")
|
||||
PY
|
||||
```
|
||||
|
||||
If a CDN serves an older HTML/config response, purge those URLs and retry.
|
||||
In a fresh signed-out browser, open `/`, `/#/login` and `/#/register`, and
|
||||
check the page on a phone as well. Check password errors, keyboard focus,
|
||||
recovery and an existing signed-in room. Browser reloads load the new config.
|
||||
Element also supports domain-specific config files; if this installation
|
||||
starts serving a successful `/config.chat.occupymedia.cc.json`, apply the
|
||||
same merge to its mounted source because it takes precedence over config.json.
|
||||
|
||||
Rollback using the printed backup directory: restore the old nginx and compose
|
||||
files, restore the old config **in place**, then recreate the edge service with
|
||||
the old compose file:
|
||||
|
||||
```bash
|
||||
cp -p "$occ_auth_backup/nginx.conf" /opt/anarchoposter/infra/occupymedia/edge/nginx.conf
|
||||
cp -p "$occ_auth_backup/docker-compose.edge.yml" /opt/anarchoposter/infra/occupymedia/edge/docker-compose.edge.yml
|
||||
if test -f "$occ_auth_backup/hub-chat-auth.css"; then
|
||||
cp -p "$occ_auth_backup/hub-chat-auth.css" /opt/anarchoposter/infra/occupymedia/edge/hub-chat-auth.css
|
||||
fi
|
||||
python3 - "$occ_auth_backup/config.json" /opt/occ-element/config.json <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
data = Path(sys.argv[1]).read_bytes()
|
||||
with Path(sys.argv[2]).open("r+b") as target:
|
||||
target.write(data)
|
||||
target.truncate()
|
||||
PY
|
||||
cd /opt/anarchoposter/infra/occupymedia/edge
|
||||
docker compose -f docker-compose.edge.yml run --rm --no-deps edge nginx -t
|
||||
docker compose -f docker-compose.edge.yml up -d --no-deps --force-recreate edge
|
||||
```
|
||||
|
||||
## Local validation
|
||||
|
||||
- Real Element v1.12.30 and nginx:1.27-alpine ran on an isolated local Docker
|
||||
network with temporary certificates and loopback ports; Matrix responses and
|
||||
the Hub asset request were mocked. No live accounts or production services
|
||||
were accessed.
|
||||
- Full repo nginx config passed `nginx -t`; compose configuration parsed.
|
||||
Nginx emits an existing duplicate `text/html` MIME warning in the unrelated
|
||||
files sign-in location; it does not prevent validation.
|
||||
- Both chat HTTP/HTTPS vhosts injected exactly one stylesheet link despite the
|
||||
client advertising gzip. JSON and the SSO helper were unchanged; CSS content
|
||||
and content type matched the repo file.
|
||||
- Chromium checked bare `/` → login, 420px desktop card, 32px corner logo,
|
||||
system-ui, warm background, red submit, two login inputs, mock failed login,
|
||||
visible errors, and registration. Checked widths 320/390/844px including
|
||||
landscape: no horizontal overflow or brand/form overlap. A fresh iPhone user
|
||||
agent reached the form through the mobile-guide redirect.
|
||||
- The injected link remained enabled after custom-theme loading. A room DOM
|
||||
without the auth wrapper retained its original button color.
|
||||
- Config merge checks confirmed preservation of theme, Compound tokens,
|
||||
homeserver and sibling keys, idempotence, and the original file inode.
|
||||
|
||||
Real successful authentication and production cache/routing behavior still need
|
||||
the operator's post-deployment checks. Existing unrelated workspace changes were
|
||||
left alone; no files in `apps/api` were edited by this task.
|
||||
46
infra/occupymedia/element/apply-auth-branding.py
Normal file
46
infra/occupymedia/element/apply-auth-branding.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Merge the auth-only patch into an existing, file-bind-mounted Element config."""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def merge(current, patch):
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, dict):
|
||||
if not isinstance(current.get(key), dict):
|
||||
current[key] = {}
|
||||
merge(current[key], value)
|
||||
else:
|
||||
current[key] = value
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("config", type=Path)
|
||||
parser.add_argument("--write", action="store_true", help="write in place; otherwise print a preview")
|
||||
args = parser.parse_args()
|
||||
patch = json.loads(Path(__file__).with_name("auth-branding.patch.json").read_text())
|
||||
# r+ intentionally retains the inode: replacing this file would leave an
|
||||
# existing Docker single-file bind mount serving the old config.
|
||||
with args.config.open("r+" if args.write else "r", encoding="utf-8") as config_file:
|
||||
current = json.load(config_file)
|
||||
if not isinstance(current, dict):
|
||||
raise ValueError("Element config must be a JSON object")
|
||||
original = copy.deepcopy(current)
|
||||
merge(current, patch)
|
||||
for key in ("setting_defaults", "default_theme", "default_server_config", "default_server_name"):
|
||||
assert current.get(key) == original.get(key), f"Auth patch must preserve {key}"
|
||||
output = json.dumps(current, indent=2, ensure_ascii=False) + "\n"
|
||||
if args.write:
|
||||
config_file.seek(0)
|
||||
config_file.write(output)
|
||||
config_file.truncate()
|
||||
else:
|
||||
print(output, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
14
infra/occupymedia/element/auth-branding.patch.json
Normal file
14
infra/occupymedia/element/auth-branding.patch.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"brand": "Tactical Media Broadcast Hub",
|
||||
"branding": {
|
||||
"auth_header_logo_url": "https://app.occupymedia.cc/brand/logo.svg",
|
||||
"logo_link_url": "https://app.occupymedia.cc"
|
||||
},
|
||||
"embedded_pages": {
|
||||
"login_for_welcome": true
|
||||
},
|
||||
"disable_custom_urls": true,
|
||||
"disable_3pid_login": true,
|
||||
"disable_login_language_selector": true,
|
||||
"mobile_guide_toast": false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue