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 { draftDestinationAccounts } from '../publishing/approval-gate.service.js';
|
||||||
import type { DraftPost } from '../publishing/publishing.service.js';
|
import type { DraftPost } from '../publishing/publishing.service.js';
|
||||||
import { transitionPollDraft } from '../publishing/poll-approval.js';
|
import { transitionPollDraft } from '../publishing/poll-approval.js';
|
||||||
|
import { releasePublishPoll } from '../publishing/publish-governance.js';
|
||||||
import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
|
import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
|
||||||
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
|
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
|
||||||
|
|
||||||
|
|
@ -18,7 +19,10 @@ interface PollRow {
|
||||||
anonymity: string; opens_at: Date; closes_at: Date; created_by: string;
|
anonymity: string; opens_at: Date; closes_at: Date; created_by: string;
|
||||||
winner_option_id: string | null; eligible_user_ids: string[] | null;
|
winner_option_id: string | null; eligible_user_ids: string[] | null;
|
||||||
parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string;
|
parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string;
|
||||||
|
/** 'draft_publish' = publish-phase gate (publish-governance.ts); else a review/chat poll. */
|
||||||
|
subject_kind: string; publish_connection_id: string | null; publish_request: unknown;
|
||||||
}
|
}
|
||||||
|
const isPublishPoll = (poll: PollRow): boolean => poll.subject_kind === 'draft_publish';
|
||||||
interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string }
|
interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string }
|
||||||
interface Member { user_id: string; role: string; can_vote: boolean }
|
interface Member { user_id: string; role: string; can_vote: boolean }
|
||||||
interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string;
|
interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string;
|
||||||
|
|
@ -301,7 +305,12 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
|
||||||
Object.assign(poll, claimed.rows[0]);
|
Object.assign(poll, claimed.rows[0]);
|
||||||
const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM']
|
const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM']
|
||||||
: evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : [];
|
: evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : [];
|
||||||
if (poll.draft_id && !poll.rule.dryRun) {
|
if (poll.draft_id && !poll.rule.dryRun && isPublishPoll(poll)) {
|
||||||
|
// Publish phase: the draft stays approved either way; a pass schedules
|
||||||
|
// the held publish (execute-on-vote), a fail/expiry/veto leaves it
|
||||||
|
// re-publishable and is recorded blocked below.
|
||||||
|
if (evaluation.state === 'passed') await releasePublishPoll(client, poll);
|
||||||
|
} else if (poll.draft_id && !poll.rule.dryRun) {
|
||||||
const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined;
|
const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined;
|
||||||
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
|
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
|
||||||
passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }),
|
passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }),
|
||||||
|
|
@ -316,14 +325,15 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
|
||||||
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
|
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
|
||||||
if (!poll.draft_id) return;
|
if (!poll.draft_id) return;
|
||||||
const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
|
const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
|
||||||
for (const accountId of draftDestinationAccounts(draft.post as DraftPost)) {
|
const accounts = isPublishPoll(poll) && poll.publish_connection_id ? [poll.publish_connection_id] : draftDestinationAccounts(draft.post as DraftPost);
|
||||||
|
for (const accountId of accounts) {
|
||||||
const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown';
|
const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown';
|
||||||
await client.query(`INSERT INTO gate_decisions
|
await client.query(`INSERT INTO gate_decisions
|
||||||
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
||||||
VALUES ($1,$2,$3,'E2',$4,$5,$6,$7,$8,$9)`,
|
VALUES ($1,$2,$3,'E2',$4,$5,$6,$7,$8,$9)`,
|
||||||
[poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) },
|
[poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) },
|
||||||
poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked',
|
poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked',
|
||||||
{ ...evaluation, pollState: poll.state }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
|
{ ...evaluation, pollState: poll.state, ...(isPublishPoll(poll) ? { phase: 'publish' } : {}) }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
|
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
|
||||||
|
|
@ -344,7 +354,9 @@ async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
|
||||||
Object.assign(poll, changed.rows[0]);
|
Object.assign(poll, changed.rows[0]);
|
||||||
await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' });
|
await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' });
|
||||||
await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']);
|
await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']);
|
||||||
if (poll.rule.dryRun || !poll.draft_id) return;
|
// A stale publish gate never re-arms or touches the draft: the approval it
|
||||||
|
// answered is gone, and a new approval opens a new publish gate.
|
||||||
|
if (poll.rule.dryRun || !poll.draft_id || isPublishPoll(poll)) return;
|
||||||
if (poll.rule.resetOnChange) {
|
if (poll.rule.resetOnChange) {
|
||||||
await openPollInTransaction(client, poll.workspace_id, {
|
await openPollInTransaction(client, poll.workspace_id, {
|
||||||
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
|
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
|
||||||
|
|
@ -375,6 +387,6 @@ export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string,
|
||||||
if (!draftDestinationAccounts(post).length) return;
|
if (!draftDestinationAccounts(post).length) return;
|
||||||
const rule = await draftPollRule(client, { post });
|
const rule = await draftPollRule(client, { post });
|
||||||
if (!rule) return;
|
if (!rule) return;
|
||||||
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open'", [draftId])).rowCount) return;
|
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open' AND subject_kind <> 'draft_publish'", [draftId])).rowCount) return;
|
||||||
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
|
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { withTenant } from "../db/client.js";
|
||||||
import type { SealedCredential } from "../vault/vault.js";
|
import type { SealedCredential } from "../vault/vault.js";
|
||||||
import type { ConnectionSummary } from "./connections.service.js";
|
import type { ConnectionSummary } from "./connections.service.js";
|
||||||
import { scrub } from "../audit/audit.service.js";
|
import { scrub } from "../audit/audit.service.js";
|
||||||
|
import { readPublishGovernance, type PublishGovernance } from "../publishing/publish-governance.js";
|
||||||
|
|
||||||
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
|
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
|
||||||
export interface ConnectionStore {
|
export interface ConnectionStore {
|
||||||
|
|
@ -15,6 +16,8 @@ export interface ConnectionStore {
|
||||||
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
|
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
|
||||||
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
|
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
|
||||||
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
|
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
|
||||||
|
/** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */
|
||||||
|
setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PgConnectionStore implements ConnectionStore {
|
export class PgConnectionStore implements ConnectionStore {
|
||||||
|
|
@ -65,7 +68,9 @@ export class PgConnectionStore implements ConnectionStore {
|
||||||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||||
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
|
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
|
||||||
if (reconnect) {
|
if (reconnect) {
|
||||||
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]);
|
// Governance settings are not provider identity: a reconnect keeps them.
|
||||||
|
const updated = await client.query(`UPDATE connections SET fields = $2::jsonb || jsonb_strip_nulls(jsonb_build_object('publishGovernance', fields->'publishGovernance')),
|
||||||
|
disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id`, [connection.id, fields, connection.provider]);
|
||||||
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
|
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
|
||||||
} else {
|
} else {
|
||||||
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
|
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
|
||||||
|
|
@ -106,6 +111,22 @@ export class PgConnectionStore implements ConnectionStore {
|
||||||
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
|
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
|
||||||
|
await this.transaction(session.workspaceId, async (client) => {
|
||||||
|
// Serialized with membership revocation like every governance write.
|
||||||
|
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
|
||||||
|
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
|
||||||
|
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||||
|
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
|
||||||
|
if (!current.rowCount) throw new NotFoundException("Connection not found");
|
||||||
|
const previous = readPublishGovernance(current.rows[0]?.fields);
|
||||||
|
await client.query(
|
||||||
|
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
|
||||||
|
[id, mode],
|
||||||
|
);
|
||||||
|
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||||
await this.transaction(workspaceId, async (client) => {
|
await this.transaction(workspaceId, async (client) => {
|
||||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||||
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||||
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
|
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
|
||||||
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
|
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
|
||||||
|
import { CHATGOV_PERMISSIONS } from "../chatgov/policy.js";
|
||||||
|
import { PUBLISH_GOVERNANCE_MODES, type PublishGovernance } from "../publishing/publish-governance.js";
|
||||||
|
|
||||||
@Controller("connections")
|
@Controller("connections")
|
||||||
export class ConnectionsController {
|
export class ConnectionsController {
|
||||||
|
|
@ -74,6 +76,19 @@ export class ConnectionsController {
|
||||||
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
|
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish governance for one connection: auto | admin1 | admin_majority |
|
||||||
|
* admin_consensus, or null for manual publishing. Deciding who must sign
|
||||||
|
* off a publish is a governance rule, so it needs chatgov.manage.
|
||||||
|
*/
|
||||||
|
@Patch(":connectionId/publish-governance")
|
||||||
|
@UseGuards(RbacGuard)
|
||||||
|
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||||
|
async publishGovernance(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Body() body: unknown) {
|
||||||
|
const input = requireObject(body, "publish governance");
|
||||||
|
return this.connections.setPublishGovernance(requireSession(request), parseId(id), parsePublishGovernance(input.publishGovernance));
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(":connectionId")
|
@Delete(":connectionId")
|
||||||
@UseGuards(RbacGuard)
|
@UseGuards(RbacGuard)
|
||||||
@RequirePermission("connection.manage")
|
@RequirePermission("connection.manage")
|
||||||
|
|
@ -82,3 +97,10 @@ export class ConnectionsController {
|
||||||
return { removed: true, token };
|
return { removed: true, token };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePublishGovernance(value: unknown): PublishGovernance | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
const mode = PUBLISH_GOVERNANCE_MODES.find((candidate) => candidate === value);
|
||||||
|
if (mode === undefined) throw new BadRequestException(`publishGovernance must be null or one of: ${PUBLISH_GOVERNANCE_MODES.join(", ")}`);
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { RbacGuard } from "../rbac/rbac.guard.js";
|
||||||
import { logger } from "../telemetry/logger.js";
|
import { logger } from "../telemetry/logger.js";
|
||||||
import { ConnectionsController } from "./connections.controller.js";
|
import { ConnectionsController } from "./connections.controller.js";
|
||||||
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
|
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
|
||||||
|
import type { PublishGovernance } from "../publishing/publish-governance.js";
|
||||||
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
|
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
|
||||||
import { RedisPendingStore } from "./pending.js";
|
import { RedisPendingStore } from "./pending.js";
|
||||||
import { MemoryPendingRedis } from "./testing.js";
|
import { MemoryPendingRedis } from "./testing.js";
|
||||||
|
|
@ -50,6 +51,14 @@ class MemoryConnections implements ConnectionStore {
|
||||||
this.rows.delete(id);
|
this.rows.delete(id);
|
||||||
this.audit.push({ action: "connection.remove" });
|
this.audit.push({ action: "connection.remove" });
|
||||||
}
|
}
|
||||||
|
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
|
||||||
|
await this.assertAuthorized(session);
|
||||||
|
const row = await this.get(session.workspaceId, id);
|
||||||
|
const fields = { ...row.connection.fields };
|
||||||
|
delete fields.publishGovernance;
|
||||||
|
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
|
||||||
|
this.audit.push({ action: "connection.publish_governance" });
|
||||||
|
}
|
||||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||||
const row = await this.get(workspaceId, id);
|
const row = await this.get(workspaceId, id);
|
||||||
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
|
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
|
||||||
|
|
@ -333,6 +342,21 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
|
||||||
bridgeConfigured = false;
|
bridgeConfigured = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets, validates and clears per-connection publish governance", async () => {
|
||||||
|
await finish(await begin());
|
||||||
|
const id = (await listed())[0]?.id ?? "";
|
||||||
|
const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" });
|
||||||
|
expect(set.status).toBe(200);
|
||||||
|
expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" });
|
||||||
|
expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority");
|
||||||
|
expect(store.audit.at(-1)?.action).toBe("connection.publish_governance");
|
||||||
|
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400);
|
||||||
|
expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400);
|
||||||
|
expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400);
|
||||||
|
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200);
|
||||||
|
expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
|
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
|
||||||
await finish(await begin());
|
await finish(await begin());
|
||||||
const row = (await listed())[0];
|
const row = (await listed())[0];
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { connectionRegistry } from "./connector-registry.js";
|
||||||
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
|
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
|
||||||
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
|
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
|
||||||
import { oauthFetch } from "./oauth-fetch.js";
|
import { oauthFetch } from "./oauth-fetch.js";
|
||||||
|
import type { PublishGovernance } from "../publishing/publish-governance.js";
|
||||||
|
|
||||||
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
|
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
|
||||||
export interface ConnectionSummary {
|
export interface ConnectionSummary {
|
||||||
|
|
@ -214,6 +215,11 @@ export class ConnectionsService {
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
/** Per-connection publish governance (publish-governance.ts); null = manual. */
|
||||||
|
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<{ connectionId: string; publishGovernance: PublishGovernance | null }> {
|
||||||
|
await this.deps.store.setPublishGovernance(session, id, mode);
|
||||||
|
return { connectionId: id, publishGovernance: mode };
|
||||||
|
}
|
||||||
async remove(session: SessionPayload, id: string): Promise<string> {
|
async remove(session: SessionPayload, id: string): Promise<string> {
|
||||||
await this.deps.store.assertAuthorized(session);
|
await this.deps.store.assertAuthorized(session);
|
||||||
await this.deps.store.remove(session, id, async (stored) => {
|
await this.deps.store.remove(session, id, async (stored) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { Pool, type PoolClient } from "pg";
|
import { Pool, type PoolClient } from "pg";
|
||||||
import { PublishingExecutionRepository, assertPublishingSchema } from "@tactical-media-broadcast-hub/publishing-execution";
|
import { PublishingExecutionRepository } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* chatgov P1 gates against a live PostgreSQL (chatgov-design.md §7 P1 row):
|
* chatgov P1 gates against a live PostgreSQL (chatgov-design.md §7 P1 row):
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ export class ApprovalGateService {
|
||||||
);
|
);
|
||||||
adminApproval = membership.rows.some((row) => APPROVAL_GATE_ADMIN_ROLES.includes(String(row.role)));
|
adminApproval = membership.rows.some((row) => APPROVAL_GATE_ADMIN_ROLES.includes(String(row.role)));
|
||||||
const poll = await client.query<{ state: string }>(
|
const poll = await client.query<{ state: string }>(
|
||||||
"SELECT state FROM polls WHERE draft_id = $1 ORDER BY created_at DESC LIMIT 1",
|
"SELECT state FROM polls WHERE draft_id = $1 AND subject_kind <> 'draft_publish' ORDER BY created_at DESC LIMIT 1",
|
||||||
[draftId],
|
[draftId],
|
||||||
);
|
);
|
||||||
pollState = String(poll.rows[0]?.state ?? "none");
|
pollState = String(poll.rows[0]?.state ?? "none");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { ConflictException } from '@nestjs/common';
|
||||||
import { canonicalPostHash } from '@tactical-media-broadcast-hub/publishing-execution';
|
import { canonicalPostHash } from '@tactical-media-broadcast-hub/publishing-execution';
|
||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient } from 'pg';
|
||||||
import { ApprovalGateService } from './approval-gate.service.js';
|
import { ApprovalGateService } from './approval-gate.service.js';
|
||||||
|
import { onDraftApproved } from './publish-governance.js';
|
||||||
import type { DraftPost } from './publishing.service.js';
|
import type { DraftPost } from './publishing.service.js';
|
||||||
|
|
||||||
/** E2's transactional publishing seam. A System audit actor delegates to a
|
/** E2's transactional publishing seam. A System audit actor delegates to a
|
||||||
|
|
@ -42,4 +43,12 @@ export async function transitionPollDraft(client: PoolClient, input: {
|
||||||
input.passed ? 'draft.approved' : 'draft.rejected', input.draftId,
|
input.passed ? 'draft.approved' : 'draft.rejected', input.draftId,
|
||||||
{ actor: `system:chat-poll-gate:${input.pollId}`, pollId: input.pollId,
|
{ actor: `system:chat-poll-gate:${input.pollId}`, pollId: input.pollId,
|
||||||
authorizingUserId: reviewer, from: current.state, to: input.passed ? 'approved' : 'rejected', reason: input.reason }]);
|
authorizingUserId: reviewer, from: current.state, to: input.passed ? 'approved' : 'rejected', reason: input.reason }]);
|
||||||
|
// Execute-on-vote for the target connection's publish governance (auto →
|
||||||
|
// schedule now; gate modes → open the publish poll). The scheduler sweep
|
||||||
|
// starts the workflow for the committed pending row.
|
||||||
|
if (input.passed) {
|
||||||
|
await onDraftApproved(client, { workspaceId: input.workspaceId, draftId: input.draftId,
|
||||||
|
providerMetadata: (draft.post as DraftPost).providerMetadata ?? {}, preferredUserId: reviewer,
|
||||||
|
actor: { userId: null, role: 'System' } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 DraftPost,
|
||||||
type DraftRecord,
|
type DraftRecord,
|
||||||
type DraftState,
|
type DraftState,
|
||||||
type ScheduledPublicationRecord,
|
type PublishOutcome,
|
||||||
} from "./publishing.service.js";
|
} from "./publishing.service.js";
|
||||||
|
|
||||||
export interface CreateDraftDto {
|
export interface CreateDraftDto {
|
||||||
|
|
@ -163,10 +163,10 @@ export class PublishingController {
|
||||||
@Req() request: Request & AuthenticatedRequest,
|
@Req() request: Request & AuthenticatedRequest,
|
||||||
@Param("draftId") draftId: string,
|
@Param("draftId") draftId: string,
|
||||||
@Body() body: unknown,
|
@Body() body: unknown,
|
||||||
): Promise<ScheduledPublicationRecord> {
|
): Promise<PublishOutcome> {
|
||||||
const session = requireSession(request);
|
const session = requireSession(request);
|
||||||
const dto = parseScheduleDraft(body);
|
const dto = parseScheduleDraft(body);
|
||||||
return this.publishing.schedule(
|
return this.publishing.requestPublish(
|
||||||
session.workspaceId,
|
session.workspaceId,
|
||||||
actorFrom(session),
|
actorFrom(session),
|
||||||
parseId(draftId),
|
parseId(draftId),
|
||||||
|
|
@ -179,17 +179,17 @@ export class PublishingController {
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@RequirePermission("publication.publish")
|
@RequirePermission("publication.publish")
|
||||||
@RequireGroupAccess("body")
|
@RequireGroupAccess("body")
|
||||||
async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> {
|
async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||||
const session = requireSession(request);
|
const session = requireSession(request);
|
||||||
const input = requireObject(body, "body");
|
const input = requireObject(body, "body");
|
||||||
return this.publishing.schedule(session.workspaceId, actorFrom(session), parseId(draftId), parseId(requireString(input.connectionId, "connectionId")), new Date(), "publication.publish");
|
return this.publishing.requestPublish(session.workspaceId, actorFrom(session), parseId(draftId), parseId(requireString(input.connectionId, "connectionId")), new Date(), "publication.publish");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":draftId/retry")
|
@Post(":draftId/retry")
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@RequirePermission("publication.schedule")
|
@RequirePermission("publication.schedule")
|
||||||
@RequireGroupAccess("body")
|
@RequireGroupAccess("body")
|
||||||
async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> {
|
async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||||
return this.resume(request, draftId, body, "failed");
|
return this.resume(request, draftId, body, "failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,16 +197,16 @@ export class PublishingController {
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@RequirePermission("publication.schedule")
|
@RequirePermission("publication.schedule")
|
||||||
@RequireGroupAccess("body")
|
@RequireGroupAccess("body")
|
||||||
async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> {
|
async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||||
return this.resume(request, draftId, body, "needs_reauthorization");
|
return this.resume(request, draftId, body, "needs_reauthorization");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<ScheduledPublicationRecord> {
|
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<PublishOutcome> {
|
||||||
const session = requireSession(request);
|
const session = requireSession(request);
|
||||||
const dto = parseScheduleDraft(body);
|
const dto = parseScheduleDraft(body);
|
||||||
const input = requireObject(body, "body");
|
const input = requireObject(body, "body");
|
||||||
if (typeof input.expectedGeneration !== "string" || !/^[1-9][0-9]*$/.test(input.expectedGeneration)) throw new BadRequestException("expectedGeneration must be a positive decimal string");
|
if (typeof input.expectedGeneration !== "string" || !/^[1-9][0-9]*$/.test(input.expectedGeneration)) throw new BadRequestException("expectedGeneration must be a positive decimal string");
|
||||||
return this.publishing.schedule(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, new Date(dto.publishAt), "publication.schedule", { state, generation: input.expectedGeneration });
|
return this.publishing.requestPublish(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, new Date(dto.publishAt), "publication.schedule", { state, generation: input.expectedGeneration });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":draftId/schedule")
|
@Delete(":draftId/schedule")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||||
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
|
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||||
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||||
import { Client, Connection, WorkflowExecutionAlreadyStartedError } from "@temporalio/client";
|
import { Client, Connection, WorkflowExecutionAlreadyStartedError } from "@temporalio/client";
|
||||||
|
|
@ -11,6 +13,7 @@ import { markDraftPollsStale, autoOpenDraftPoll } from "../chatgov/poll.service.
|
||||||
import { PgConnectionStore } from "../connections/connection-store.js";
|
import { PgConnectionStore } from "../connections/connection-store.js";
|
||||||
import { scrub } from "../audit/audit.service.js";
|
import { scrub } from "../audit/audit.service.js";
|
||||||
import { ApprovalGateService, ApprovalGateBlockedError } from "./approval-gate.service.js";
|
import { ApprovalGateService, ApprovalGateBlockedError } from "./approval-gate.service.js";
|
||||||
|
import { checkPublishGate, isPublishGateMode, onDraftApproved, readPublishGovernance, type ApprovalFollowUp, type PublishHold } from "./publish-governance.js";
|
||||||
import { pool, withTenant } from "../db/client.js";
|
import { pool, withTenant } from "../db/client.js";
|
||||||
import { userCanUseConnection } from "../groups/groups.service.js";
|
import { userCanUseConnection } from "../groups/groups.service.js";
|
||||||
import { vault } from "../vault/vault.js";
|
import { vault } from "../vault/vault.js";
|
||||||
|
|
@ -47,6 +50,9 @@ export interface ScheduledPublicationRecord {
|
||||||
state: "pending" | "in_flight" | "done" | "failed" | "needs_reauthorization" | "cancelled";
|
state: "pending" | "in_flight" | "done" | "failed" | "needs_reauthorization" | "cancelled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The publish/schedule routes either schedule, or hold for the publish gate. */
|
||||||
|
export type PublishOutcome = ScheduledPublicationRecord | PublishHold;
|
||||||
|
|
||||||
export interface Actor {
|
export interface Actor {
|
||||||
userId: string;
|
userId: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
|
@ -380,15 +386,30 @@ export class PublishingService {
|
||||||
*/
|
*/
|
||||||
async approve(workspaceId: string, actor: Actor, draftId: string): Promise<DraftRecord> {
|
async approve(workspaceId: string, actor: Actor, draftId: string): Promise<DraftRecord> {
|
||||||
const gate = this.dependencies.approvalGate ?? null;
|
const gate = this.dependencies.approvalGate ?? null;
|
||||||
|
let followUp: ApprovalFollowUp | null = null;
|
||||||
try {
|
try {
|
||||||
return await this.transition(workspaceId, actor, draftId, "in_review", "approved", "draft.approved", {
|
const approved = await this.transition(workspaceId, actor, draftId, "in_review", "approved", "draft.approved", {
|
||||||
from: "in_review",
|
from: "in_review",
|
||||||
to: "approved",
|
to: "approved",
|
||||||
}, actor.userId, gate === null ? undefined : async (client: PoolClient, draft: DraftRecord) => {
|
}, actor.userId, gate === null ? undefined : async (client: PoolClient, draft: DraftRecord) => {
|
||||||
const evaluation = await gate.evaluate(client, workspaceId, draftId, draft.post, actor.userId);
|
const evaluation = await gate.evaluate(client, workspaceId, draftId, draft.post, actor.userId);
|
||||||
if (evaluation.blocked.length > 0) throw new ApprovalGateBlockedError(evaluation.blocked);
|
if (evaluation.blocked.length > 0) throw new ApprovalGateBlockedError(evaluation.blocked);
|
||||||
await gate.recordFiredInTxn(client, workspaceId, draftId, actor.userId, evaluation.verdicts);
|
await gate.recordFiredInTxn(client, workspaceId, draftId, actor.userId, evaluation.verdicts);
|
||||||
|
}, async (client: PoolClient, draft: DraftRecord) => {
|
||||||
|
// Publish governance of the target connection: auto schedules now,
|
||||||
|
// gate modes open the publish poll; manual connections are untouched.
|
||||||
|
followUp = await onDraftApproved(client, {
|
||||||
|
workspaceId, draftId, providerMetadata: draft.post.providerMetadata ?? {}, preferredUserId: actor.userId, actor,
|
||||||
|
}, this.dependencies.repository);
|
||||||
});
|
});
|
||||||
|
// Assigned inside the transaction callback (TS cannot see that write).
|
||||||
|
const dispatched = followUp as ApprovalFollowUp | null;
|
||||||
|
if (dispatched !== null && dispatched.kind === "dispatched") {
|
||||||
|
await this.dependencies.workflowStarter
|
||||||
|
.startScheduledPublish({ workspaceId, scheduleId: dispatched.attempt.scheduleId, generation: dispatched.attempt.generation, idempotencyKey: dispatched.attempt.idempotencyKey }, dispatched.publishAt)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
return approved;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApprovalGateBlockedError) {
|
if (error instanceof ApprovalGateBlockedError) {
|
||||||
// The approval rolled back; record the blocked verdicts with no draft
|
// The approval rolled back; record the blocked verdicts with no draft
|
||||||
|
|
@ -408,6 +429,12 @@ export class PublishingService {
|
||||||
}, actor.userId);
|
}, actor.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatic scheduling. A connection under an admin publish gate cannot
|
||||||
|
* be scheduled around it: while its publish poll is pending this refuses
|
||||||
|
* with 409 PUBLISH_AWAITING_APPROVAL (the poll is opened/kept). The HTTP
|
||||||
|
* routes use requestPublish, which answers 202 with the hold instead.
|
||||||
|
*/
|
||||||
async schedule(
|
async schedule(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
|
|
@ -417,6 +444,23 @@ export class PublishingService {
|
||||||
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
|
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
|
||||||
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
|
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
|
||||||
): Promise<ScheduledPublicationRecord> {
|
): Promise<ScheduledPublicationRecord> {
|
||||||
|
const outcome = await this.requestPublish(workspaceId, actor, draftId, connectionId, publishAt, requiredPermission, expectedPrevious);
|
||||||
|
if (outcome.state === "awaiting_publish_approval") {
|
||||||
|
throw new ConflictException({ code: "PUBLISH_AWAITING_APPROVAL", message: "publish is held for the connection's admin publish gate", hold: outcome });
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule, or hold behind the target connection's publish gate. */
|
||||||
|
async requestPublish(
|
||||||
|
workspaceId: string,
|
||||||
|
actor: Actor,
|
||||||
|
draftId: string,
|
||||||
|
connectionId: string,
|
||||||
|
publishAt: Date,
|
||||||
|
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
|
||||||
|
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
|
||||||
|
): Promise<PublishOutcome> {
|
||||||
if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp");
|
if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp");
|
||||||
|
|
||||||
await this.dependencies.assertReady();
|
await this.dependencies.assertReady();
|
||||||
|
|
@ -424,13 +468,29 @@ export class PublishingService {
|
||||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||||
// FK checks bypass RLS, so a foreign tenant's connection id would satisfy
|
// FK checks bypass RLS, so a foreign tenant's connection id would satisfy
|
||||||
// the constraint; this RLS-scoped read is the actual tenancy check.
|
// the constraint; this RLS-scoped read is the actual tenancy check.
|
||||||
const connection = await client.query("SELECT provider FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId]);
|
const connection = await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId]);
|
||||||
if (connection.rowCount !== 1) throw new NotFoundException(`connection ${connectionId} not found`);
|
if (connection.rowCount !== 1) throw new NotFoundException(`connection ${connectionId} not found`);
|
||||||
if (!isPublishingProvider(String(connection.rows[0]?.provider))) throw new BadRequestException("provider is not enabled for publishing");
|
if (!isPublishingProvider(String(connection.rows[0]?.provider))) throw new BadRequestException("provider is not enabled for publishing");
|
||||||
if (!(await userCanUseConnection(client, workspaceId, actor.userId, connectionId))) {
|
if (!(await userCanUseConnection(client, workspaceId, actor.userId, connectionId))) {
|
||||||
throw new ForbiddenException("connection is not shared with any of your groups");
|
throw new ForbiddenException("connection is not shared with any of your groups");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish-phase gate: a gated connection schedules only once the admins'
|
||||||
|
// publish poll for the current approved revision has passed.
|
||||||
|
const governance = readPublishGovernance(connection.rows[0]?.fields);
|
||||||
|
if (isPublishGateMode(governance)) {
|
||||||
|
const gate = await checkPublishGate(client, {
|
||||||
|
workspaceId, draftId, connectionId, mode: governance, requestedBy: actor.userId, publishAt, requiredPermission,
|
||||||
|
});
|
||||||
|
if (gate.kind === "held") {
|
||||||
|
await this.audit(client, workspaceId, actor, "publication.publish_held", draftId, {
|
||||||
|
connectionId, publishGovernance: governance, pollId: gate.hold.pollId, opened: gate.opened,
|
||||||
|
publishAt: publishAt.toISOString(),
|
||||||
|
});
|
||||||
|
return gate.hold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let attempt;
|
let attempt;
|
||||||
try {
|
try {
|
||||||
attempt = await this.dependencies.repository.scheduleAttempt(client, {
|
attempt = await this.dependencies.repository.scheduleAttempt(client, {
|
||||||
|
|
@ -448,6 +508,7 @@ export class PublishingService {
|
||||||
});
|
});
|
||||||
return scheduled;
|
return scheduled;
|
||||||
});
|
});
|
||||||
|
if (scheduled.state === "awaiting_publish_approval") return scheduled;
|
||||||
await this.dependencies.workflowStarter
|
await this.dependencies.workflowStarter
|
||||||
.startScheduledPublish({ workspaceId, scheduleId: scheduled.id, generation: scheduled.generation, idempotencyKey: scheduled.idempotencyKey }, publishAt)
|
.startScheduledPublish({ workspaceId, scheduleId: scheduled.id, generation: scheduled.generation, idempotencyKey: scheduled.idempotencyKey }, publishAt)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
@ -500,6 +561,8 @@ export class PublishingService {
|
||||||
/** E1 seam: runs inside the transition's transaction, under the draft row
|
/** E1 seam: runs inside the transition's transaction, under the draft row
|
||||||
* lock, before the UPDATE — may abort the whole transition by throwing. */
|
* lock, before the UPDATE — may abort the whole transition by throwing. */
|
||||||
afterLock?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
|
afterLock?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
|
||||||
|
/** Runs after the UPDATE + audit, same transaction (publish governance). */
|
||||||
|
afterTransition?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
|
||||||
): Promise<DraftRecord> {
|
): Promise<DraftRecord> {
|
||||||
await this.dependencies.assertReady();
|
await this.dependencies.assertReady();
|
||||||
return this.dependencies.tenantTransaction(workspaceId, async (client) => {
|
return this.dependencies.tenantTransaction(workspaceId, async (client) => {
|
||||||
|
|
@ -521,6 +584,7 @@ export class PublishingService {
|
||||||
const draft = draftFromRow(requiredRow(result.rows[0], "transitioned draft"));
|
const draft = draftFromRow(requiredRow(result.rows[0], "transitioned draft"));
|
||||||
if (to === "in_review") await autoOpenDraftPoll(client, workspaceId, draftId, actor, draft.post);
|
if (to === "in_review") await autoOpenDraftPoll(client, workspaceId, draftId, actor, draft.post);
|
||||||
await this.audit(client, workspaceId, actor, action, draftId, diff);
|
await this.audit(client, workspaceId, actor, action, draftId, diff);
|
||||||
|
if (afterTransition !== undefined) await afterTransition(client, draft);
|
||||||
return draft;
|
return draft;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,36 @@ describe("E3 pre-dispatch evaluation", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("E3 publish-phase gate (connections.fields.publishGovernance)", () => {
|
||||||
|
for (const mode of ["admin1", "admin_majority", "admin_consensus"]) {
|
||||||
|
it(`${mode}: holds until the publish poll for the approved revision passed`, () => {
|
||||||
|
for (const pending of [null, "open", "failed", "vetoed", "quorum_unreachable", "stale"]) {
|
||||||
|
const outcome = evaluateE3(row({ publish_governance: mode, publish_poll_state: pending }));
|
||||||
|
expect(outcome.outcome).toBe("blocked");
|
||||||
|
expect(outcome.reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
|
||||||
|
expect(outcome.pollState).toBe(pending ?? "none");
|
||||||
|
}
|
||||||
|
expect(evaluateE3(row({ publish_governance: mode, publish_poll_state: "passed" })).outcome).toBe("fired");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("auto and unset connections need no publish poll", () => {
|
||||||
|
expect(evaluateE3(row({ publish_governance: "auto", publish_poll_state: null })).outcome).toBe("fired");
|
||||||
|
expect(evaluateE3(row({ publish_governance: null, publish_poll_state: null })).outcome).toBe("fired");
|
||||||
|
expect(evaluateE3(row({ publish_governance: "unknown-mode", publish_poll_state: null })).outcome).toBe("fired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a revoked approval still wins over a passed publish poll", () => {
|
||||||
|
const outcome = evaluateE3(row({ state: "editing", publish_governance: "admin1", publish_poll_state: "passed" }));
|
||||||
|
expect(outcome.reasonCodes).toEqual(["RESET_ON_CHANGE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a passed publish poll does not bypass the snapshot fail-closed posture", () => {
|
||||||
|
const outcome = evaluateE3(row({ effective: null, policy_version: null, publish_governance: "admin1", publish_poll_state: "passed" }));
|
||||||
|
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("reason-code vocabulary mirror", () => {
|
describe("reason-code vocabulary mirror", () => {
|
||||||
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
|
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
|
||||||
expect(E3_REASON_CODES).toEqual([
|
expect(E3_REASON_CODES).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ export type ReasonCode = (typeof E3_REASON_CODES)[number];
|
||||||
|
|
||||||
export const E3_ACTOR = "system:gate-e3";
|
export const E3_ACTOR = "system:gate-e3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish-phase gate modes (connections.fields.publishGovernance), mirrored
|
||||||
|
* from apps/api/src/publishing/publish-governance.ts across the app package
|
||||||
|
* boundary. "auto" and unset need no publish poll.
|
||||||
|
*/
|
||||||
|
export const PUBLISH_GATE_MODES = ["admin1", "admin_majority", "admin_consensus"] as const;
|
||||||
|
|
||||||
/** DB column order for the re-check query. */
|
/** DB column order for the re-check query. */
|
||||||
interface E3Row {
|
interface E3Row {
|
||||||
state: string | null;
|
state: string | null;
|
||||||
|
|
@ -58,6 +65,10 @@ interface E3Row {
|
||||||
effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
|
effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
|
||||||
policy_version: string | null;
|
policy_version: string | null;
|
||||||
poll_state: string | null;
|
poll_state: string | null;
|
||||||
|
/** connections.fields->>'publishGovernance' of the attempt's destination. */
|
||||||
|
publish_governance?: string | null;
|
||||||
|
/** Latest publish poll for (draft, destination, approved revision). */
|
||||||
|
publish_poll_state?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E3Outcome {
|
export interface E3Outcome {
|
||||||
|
|
@ -87,6 +98,22 @@ export function evaluateE3(row: E3Row | undefined): E3Outcome {
|
||||||
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
|
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b) Publish-phase gate: a gated destination dispatches only after the
|
||||||
|
// admins' publish poll for THIS approved revision passed. Attempts are
|
||||||
|
// normally created by that pass; this re-check also catches schedules
|
||||||
|
// made before the connection was switched to a gate mode.
|
||||||
|
if ((PUBLISH_GATE_MODES as readonly string[]).includes(row.publish_governance ?? "")
|
||||||
|
&& row.publish_poll_state !== "passed") {
|
||||||
|
return {
|
||||||
|
outcome: "blocked",
|
||||||
|
reasonCodes: [reason("INSUFFICIENT_QUORUM"), reason("PENDING_POLL")],
|
||||||
|
policyVersion: row.policy_version ?? "none",
|
||||||
|
mode: row.effective?.mode ?? "none",
|
||||||
|
approvedHash,
|
||||||
|
pollState: row.publish_poll_state ?? "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 2) A compiled snapshot exists for this destination. None => fail closed
|
// 2) A compiled snapshot exists for this destination. None => fail closed
|
||||||
// (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send).
|
// (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send).
|
||||||
if (row.effective === null || row.policy_version === null) {
|
if (row.effective === null || row.policy_version === null) {
|
||||||
|
|
@ -159,7 +186,12 @@ export class DispatchGateService {
|
||||||
SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id,
|
SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id,
|
||||||
s.effective, s.policy_version,
|
s.effective, s.policy_version,
|
||||||
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled'
|
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled'
|
||||||
ORDER BY p.created_at DESC LIMIT 1) AS poll_state
|
AND p.subject_kind <> 'draft_publish'
|
||||||
|
ORDER BY p.created_at DESC LIMIT 1) AS poll_state,
|
||||||
|
(SELECT c.fields->>'publishGovernance' FROM connections c WHERE c.id = $2::uuid) AS publish_governance,
|
||||||
|
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.subject_kind = 'draft_publish'
|
||||||
|
AND p.publish_connection_id = $2::uuid AND p.draft_revision = d.approved_revision
|
||||||
|
ORDER BY p.created_at DESC LIMIT 1) AS publish_poll_state
|
||||||
FROM drafts d
|
FROM drafts d
|
||||||
LEFT JOIN posting_policy_snapshots s
|
LEFT JOIN posting_policy_snapshots s
|
||||||
ON s.workspace_id = d.workspace_id
|
ON s.workspace_id = d.workspace_id
|
||||||
|
|
@ -168,7 +200,8 @@ export class DispatchGateService {
|
||||||
AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid
|
AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid
|
||||||
WHERE d.id = (SELECT draft_id FROM attempt)
|
WHERE d.id = (SELECT draft_id FROM attempt)
|
||||||
)
|
)
|
||||||
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state FROM target`,
|
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state,
|
||||||
|
publish_governance, publish_poll_state FROM target`,
|
||||||
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
|
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
|
||||||
);
|
);
|
||||||
const row = result.rows[0] as E3Row | undefined;
|
const row = result.rows[0] as E3Row | undefined;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,14 @@ describe("E3 gate in the dispatch path", () => {
|
||||||
expect(h.publish).not.toHaveBeenCalled();
|
expect(h.publish).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("publish gate pending (admin poll not passed): known no-send with zero HTTP requests", async () => {
|
||||||
|
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: "open" });
|
||||||
|
const h = harness({ kind: "claimed", job }, gate);
|
||||||
|
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_INSUFFICIENT_QUORUM" });
|
||||||
|
expect(h.fetchImpl).not.toHaveBeenCalled();
|
||||||
|
expect(h.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("gate evaluated per destination with the lease's idempotency key", async () => {
|
it("gate evaluated per destination with the lease's idempotency key", async () => {
|
||||||
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
|
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
|
||||||
const h = harness({ kind: "claimed", job }, gate);
|
const h = harness({ kind: "claimed", job }, gate);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Badge, Button } from "../../../components/ui/primitives";
|
import { Badge, Button } from "../../../components/ui/primitives";
|
||||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||||
import { EmptyState } from "../../../components/brand/empty-state";
|
import { EmptyState } from "../../../components/brand/empty-state";
|
||||||
|
|
@ -9,7 +9,7 @@ import { api, errorMessage } from "../../../lib/api-client";
|
||||||
import { PLATFORMS } from "../../../lib/platforms";
|
import { PLATFORMS } from "../../../lib/platforms";
|
||||||
import { useAuth } from "../../../lib/auth-store";
|
import { useAuth } from "../../../lib/auth-store";
|
||||||
import { useApiResource } from "../../../lib/use-api";
|
import { useApiResource } from "../../../lib/use-api";
|
||||||
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types";
|
import type { ConnectionSummary, DraftRecord, PublicationsResponse } from "../../../lib/api-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar = the scheduled slice of the publishing pipeline. There is no
|
* Calendar = the scheduled slice of the publishing pipeline. There is no
|
||||||
|
|
@ -119,6 +119,19 @@ function relativeLabel(iso: string, now: number): { text: string; overdue: boole
|
||||||
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
|
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest dispatch receipt wins: the API orders publications by attempt
|
||||||
|
* number descending, and a retry's outcome supersedes the earlier attempt.
|
||||||
|
*/
|
||||||
|
function latestPublication(response: PublicationsResponse | null | undefined): {
|
||||||
|
state: string;
|
||||||
|
remoteUrl: string | null;
|
||||||
|
} | null {
|
||||||
|
const latest = response?.publications[0];
|
||||||
|
if (latest === undefined) return null;
|
||||||
|
return { state: latest.state, remoteUrl: latest.remoteUrl ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
export default function CalendarPage(): React.ReactElement {
|
export default function CalendarPage(): React.ReactElement {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
|
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
|
||||||
|
|
@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement {
|
||||||
const [notice, setNotice] = useState<Notice | null>(null);
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
const [now, setNow] = useState<number>(Date.now());
|
const [now, setNow] = useState<number>(Date.now());
|
||||||
|
|
||||||
|
// Dispatch receipts per draft: one GET per scheduled draft (the API has no
|
||||||
|
// bulk endpoint), refreshed with the list. Only drafts that actually have
|
||||||
|
// publications surface a badge.
|
||||||
|
const [publications, setPublications] = useState<Record<string, PublicationsResponse | null>>({});
|
||||||
|
const fetchPublications = useCallback((ids: string[]) => {
|
||||||
|
if (!live || ids.length === 0) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
void api
|
||||||
|
.get<PublicationsResponse>(`/publishing/drafts/${id}/publications`)
|
||||||
|
.then((response) => setPublications((current) => ({ ...current, [id]: response })))
|
||||||
|
.catch(() => setPublications((current) => ({ ...current, [id]: null })));
|
||||||
|
}
|
||||||
|
}, [live]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||||
|
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||||
|
|
||||||
|
// The scheduled set as a stable string so the fetch effect below only fires
|
||||||
|
// when it actually changes, and the timer below always reads the latest ids.
|
||||||
|
const draftIds = rows.map((row) => row.id);
|
||||||
|
const draftIdsRef = useRef<string[]>([]);
|
||||||
|
draftIdsRef.current = draftIds;
|
||||||
|
const draftIdsKey = draftIds.join(",");
|
||||||
|
|
||||||
|
// Fetch receipts on load and whenever the scheduled set changes (a new
|
||||||
|
// schedule appears, or one is cancelled).
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPublications(draftIdsKey === "" ? [] : draftIdsKey.split(","));
|
||||||
|
}, [fetchPublications, draftIdsKey]);
|
||||||
|
|
||||||
// Same cadence as the streams wall: refresh on a 30s timer.
|
// Same cadence as the streams wall: refresh on a 30s timer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
drafts.reload();
|
drafts.reload();
|
||||||
connections.reload();
|
connections.reload();
|
||||||
|
fetchPublications(draftIdsRef.current);
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [drafts.reload, connections.reload]);
|
}, [drafts.reload, connections.reload, fetchPublications]);
|
||||||
|
|
||||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
|
||||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
|
||||||
|
|
||||||
async function cancel(row: ScheduledRow): Promise<void> {
|
async function cancel(row: ScheduledRow): Promise<void> {
|
||||||
if (!live) {
|
if (!live) {
|
||||||
|
|
@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement {
|
||||||
<span className="ml-auto text-2xs text-text-faint">
|
<span className="ml-auto text-2xs text-text-faint">
|
||||||
{rows.length} scheduled · refreshes every 30s
|
{rows.length} scheduled · refreshes every 30s
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
|
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); fetchPublications(draftIdsRef.current); setNow(Date.now()); }}>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-4 text-xs text-text-faint">
|
<p className="mb-4 text-xs text-text-faint">
|
||||||
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
|
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
|
||||||
UTC time beside it; execution state lives in the audit log, not this list.
|
UTC time beside it; drafts with dispatch receipts show their latest outcome, with the remote link when the
|
||||||
|
provider returned one.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{notice !== null && (
|
{notice !== null && (
|
||||||
|
|
@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
|
||||||
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
|
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
|
||||||
{group.rows.map((row) => {
|
{group.rows.map((row) => {
|
||||||
const relative = relativeLabel(row.publishAt, now);
|
const relative = relativeLabel(row.publishAt, now);
|
||||||
|
const latest = latestPublication(publications[row.id]);
|
||||||
return (
|
return (
|
||||||
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
|
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
|
||||||
<div className="flex w-44 shrink-0 flex-col gap-0.5">
|
<div className="flex w-44 shrink-0 flex-col gap-0.5">
|
||||||
|
|
@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {
|
||||||
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
|
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
|
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
|
||||||
<Badge tone="accent">scheduled</Badge>
|
<Badge tone="accent">scheduled</Badge>
|
||||||
|
{latest !== null && (
|
||||||
|
<Badge tone={latest.state === "completed" ? "live" : latest.state === "failed" ? "danger" : "warn"}>
|
||||||
|
{latest.state.replace(/_/g, " ")}
|
||||||
|
{latest.remoteUrl !== null && (
|
||||||
|
<a href={latest.remoteUrl} target="_blank" rel="noreferrer" className="underline decoration-current/40 underline-offset-2 hover:decoration-current">
|
||||||
|
open ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<span>{row.targetLabel}</span>
|
<span>{row.targetLabel}</span>
|
||||||
{!row.fromScheduledAt && (
|
{!row.fromScheduledAt && (
|
||||||
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
|
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
|
||||||
|
|
|
||||||
|
|
@ -164,3 +164,26 @@ export interface ScheduledPublicationRecord {
|
||||||
publishAt: string;
|
publishAt: string;
|
||||||
state: "pending" | "in_flight" | "done" | "failed";
|
state: "pending" | "in_flight" | "done" | "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One dispatch attempt for a draft, newest first — GET /publishing/drafts/:draftId/publications. */
|
||||||
|
export interface DraftPublication {
|
||||||
|
id: string;
|
||||||
|
connectionId: string;
|
||||||
|
attemptNo: string;
|
||||||
|
dispatchCount: number;
|
||||||
|
state: "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned";
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
firstDispatchStartedAt?: string;
|
||||||
|
dispatchStartedAt?: string;
|
||||||
|
providerRetryDeadline?: string;
|
||||||
|
receivedAt?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
remoteId?: string;
|
||||||
|
remoteUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicationsResponse {
|
||||||
|
publications: DraftPublication[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,17 @@ nginx edge mirrors the tunnel ingress by host:
|
||||||
- `app.occupymedia.cc` → `web:3001` (occ-app compose network)
|
- `app.occupymedia.cc` → `web:3001` (occ-app compose network)
|
||||||
- `api.occupymedia.cc` → `api:3000`
|
- `api.occupymedia.cc` → `api:3000`
|
||||||
- `tube.occupymedia.cc` → `100.92.231.98:9000` (occ-tube via Tailscale)
|
- `tube.occupymedia.cc` → `100.92.231.98:9000` (occ-tube via Tailscale)
|
||||||
- `chat.occupymedia.cc` → `100.117.210.52:6167` (occ-chat conduwuit)
|
- `chat.occupymedia.cc` → `occ-element:80` (client); `/_matrix` → `100.117.210.52:6167` (occ-chat conduwuit)
|
||||||
- `ping.occupymedia.cc` → 200 "edge-ok" (activation probe target)
|
- `ping.occupymedia.cc` → 200 "edge-ok" (activation probe target)
|
||||||
- unknown SNI → `ssl_reject_handshake`
|
- unknown SNI → `ssl_reject_handshake`
|
||||||
|
|
||||||
TLS: Let's Encrypt wildcard `*.occupymedia.cc` via dns-01 (no port 80 needed).
|
TLS: Let's Encrypt wildcard `*.occupymedia.cc` via dns-01 (no port 80 needed).
|
||||||
Renewal: `occ-edge-certbot.timer`, twice daily, reloads nginx.
|
Renewal: `occ-edge-certbot.timer`, twice daily, reloads nginx.
|
||||||
|
|
||||||
|
Chat's signed-out client uses the Hub auth stylesheet on both ingress paths.
|
||||||
|
See the [Element auth restyle report](../element/AUTH-RESTYLE.md) for verified
|
||||||
|
1.12.30 configuration keys, the config merge patch, and exact deployment steps.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ services:
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- /opt/occ-brand:/opt/occ-brand:ro
|
- /opt/occ-brand:/opt/occ-brand:ro
|
||||||
- ./hub-sso:/opt/hub-sso:ro
|
- ./hub-sso:/opt/hub-sso:ro
|
||||||
|
- ./hub-chat-auth.css:/opt/occ-chat-brand/hub-chat-auth.css:ro
|
||||||
- ./certs:/etc/letsencrypt:ro
|
- ./certs:/etc/letsencrypt:ro
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
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;
|
listen [::]:80;
|
||||||
server_name chat.occupymedia.cc;
|
server_name chat.occupymedia.cc;
|
||||||
# Tunnel origin path: the branded client on this host, Matrix API to the homeserver.
|
# Tunnel origin path: the branded client on this host, Matrix API to the homeserver.
|
||||||
|
# Element redirects first-time phone visitors here before loading config.
|
||||||
|
# Send them to the form rather than the separate Element app-download hero.
|
||||||
|
location = /mobile_guide/ {
|
||||||
|
# Preserve the browser's HTTPS origin even on the tunnel's HTTP hop.
|
||||||
|
absolute_redirect off;
|
||||||
|
return 302 /#/login;
|
||||||
|
}
|
||||||
|
location = /hub-chat-auth.css {
|
||||||
|
alias /opt/occ-chat-brand/hub-chat-auth.css;
|
||||||
|
default_type text/css;
|
||||||
|
add_header X-Served-By occ-edge always;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
location ^~ /_matrix {
|
location ^~ /_matrix {
|
||||||
proxy_pass http://100.117.210.52:6167;
|
proxy_pass http://100.117.210.52:6167;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -131,6 +144,12 @@ http {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Hash routes never reach nginx. Inject into the SPA HTML shell; the
|
||||||
|
# stylesheet only targets mx_AuthPage and leaves signed-in rooms alone.
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
sub_filter '</head>' '<link rel="stylesheet" href="/hub-chat-auth.css?v=1"></head>';
|
||||||
|
sub_filter_once on;
|
||||||
|
# text/html is sub_filter's default type; JSON/JS/CSS are not rewritten.
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +162,17 @@ http {
|
||||||
ssl_certificate /etc/letsencrypt/live/occupymedia.cc/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/occupymedia.cc/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/occupymedia.cc/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/occupymedia.cc/privkey.pem;
|
||||||
|
|
||||||
|
location = /mobile_guide/ {
|
||||||
|
absolute_redirect off;
|
||||||
|
return 302 /#/login;
|
||||||
|
}
|
||||||
|
location = /hub-chat-auth.css {
|
||||||
|
alias /opt/occ-chat-brand/hub-chat-auth.css;
|
||||||
|
default_type text/css;
|
||||||
|
add_header X-Served-By occ-edge always;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
location ^~ /_matrix {
|
location ^~ /_matrix {
|
||||||
proxy_pass http://100.117.210.52:6167;
|
proxy_pass http://100.117.210.52:6167;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
@ -176,6 +206,9 @@ http {
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
sub_filter '</head>' '<link rel="stylesheet" href="/hub-chat-auth.css?v=1"></head>';
|
||||||
|
sub_filter_once on;
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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