T-HUB/apps/api/src/connections/connection-store.ts
glass beadian b89dea5c93 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.
2026-10-14 08:04:56 +00:00

138 lines
12 KiB
TypeScript

import { NotFoundException, ForbiddenException } from "@nestjs/common";
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
import type { SessionPayload } from "../auth/session.js";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import type { SealedCredential } from "../vault/vault.js";
import type { ConnectionSummary } from "./connections.service.js";
import { scrub } from "../audit/audit.service.js";
import { readPublishGovernance, type PublishGovernance } from "../publishing/publish-governance.js";
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
export interface ConnectionStore {
list(workspaceId: string): Promise<ConnectionSummary[]>;
get(workspaceId: string, id: string): Promise<StoredConnection>;
assertAuthorized(session: SessionPayload): 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>;
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 {
constructor(private readonly transaction: typeof withTenant = withTenant) {}
async assertAuthorized(session: SessionPayload): Promise<void> {
if (session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException("Session expired");
const result = await this.transaction(session.workspaceId, (client) => client.query(
`SELECT 1 FROM workspaces w JOIN memberships m ON m.workspace_id = w.id
JOIN role_permissions rp ON rp.role = m.role
WHERE w.id = $1 AND w.authz_epoch = $2 AND m.user_id = $3 AND m.status = 'active' AND rp.permission = 'connection.manage'`,
[session.workspaceId, session.authzEpoch, session.userId],
));
if (result.rowCount !== 1) throw new ForbiddenException("Connection authorization no longer valid");
}
async list(workspaceId: string): Promise<ConnectionSummary[]> {
const res = await this.transaction(workspaceId, (client) => client.query(
`SELECT c.*, cr.ciphertext, coalesce(cr.rotated_at, cr.created_at) AS rotated_at,
(SELECT array_agg(p.published_at ORDER BY p.published_at DESC) FROM publications p
WHERE p.connection_id = c.id AND p.published_at > now() - interval '24 hours') AS recent
FROM connections c LEFT JOIN LATERAL (SELECT * FROM credentials WHERE connection_id = c.id AND kind = 'connector-oauth' AND revoked_at IS NULL LIMIT 1) cr ON true WHERE c.disconnected_at IS NULL ORDER BY c.created_at`,
));
return res.rows.map((row: { id: string; provider: string; channel_id: string; fields: Record<string, unknown>; ciphertext: Buffer | null; created_at: Date; rotated_at: Date | null; recent: Date[] | null }) => ({
id: row.id, provider: row.provider, channelId: row.channel_id, fields: scrub(row.fields) ?? {},
health: row.ciphertext ? "unavailable" : "needs_credentials", credentialRotatedAt: row.rotated_at?.toISOString() ?? null,
recentPublishes: (row.recent ?? []).map((date) => date.toISOString()), createdAt: row.created_at.toISOString(),
}));
}
async get(workspaceId: string, id: string): Promise<StoredConnection> {
return this.transaction(workspaceId, (client) => this.read(client, workspaceId, id));
}
private async read(client: PoolClient, workspaceId: string, id: string): Promise<StoredConnection> {
const result = await client.query(
`SELECT c.*, cr.ciphertext, cr.key_version FROM connections c LEFT JOIN LATERAL
(SELECT * FROM credentials WHERE connection_id = c.id AND kind = 'connector-oauth' AND revoked_at IS NULL LIMIT 1) cr ON true WHERE c.id = $1`, [id],
);
const row = result.rows[0] as { id: string; provider: string; channel_id: string; fields: Record<string, string>; created_at: Date; ciphertext: Buffer | null; key_version: number } | undefined;
if (!row) throw new NotFoundException("Connection not found");
return {
connection: { id: row.id, workspaceId, provider: row.provider, channelId: row.channel_id, fields: row.fields, createdAt: row.created_at, credentialId: { kind: "credential-ref", id: row.id } },
credential: row.ciphertext ? { ciphertext: row.ciphertext, keyVersion: row.key_version } : null,
};
}
async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void> {
await this.transaction(session.workspaceId, async (client) => {
// Serialize with membership revocation; re-check at commit, not only before provider I/O.
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 = 'connection.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
if (reconnect) {
// Governance settings are not provider identity: a reconnect keeps them.
const updated = await client.query(`UPDATE connections SET fields = $2::jsonb || jsonb_strip_nulls(jsonb_build_object('publishGovernance', fields->'publishGovernance')),
disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id`, [connection.id, fields, connection.provider]);
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
} else {
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
}
const existing = await client.query("SELECT id FROM credentials WHERE connection_id = $1 AND kind = 'connector-oauth' FOR UPDATE", [connection.id]);
if (existing.rows[0]) {
await client.query("UPDATE credentials SET ciphertext = $2, key_version = $3, rotated_at = now(), revoked_at = NULL WHERE id = $1", [existing.rows[0].id, credential.ciphertext, credential.keyVersion]);
} else {
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext, key_version) VALUES ($1,$2,'connector-oauth',$3,$4)", [session.workspaceId, connection.id, credential.ciphertext, credential.keyVersion]);
}
await client.query("SELECT audit_append($1,$2,$3,$4,'connection',$5,$6)", [session.workspaceId, session.userId, session.role, reconnect ? "connection.reconnect" : "connection.connect", connection.id, { provider: connection.provider }]);
});
}
async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void> {
let stored: StoredConnection | undefined;
await this.transaction(session.workspaceId, async (client) => {
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 = 'connection.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const locked = await client.query("SELECT id FROM connections WHERE id = $1 FOR UPDATE", [id]);
if (!locked.rowCount) throw new NotFoundException("Connection not found");
stored = await this.read(client, session.workspaceId, id);
await client.query("UPDATE credentials SET revoked_at = now() WHERE connection_id = $1 AND revoked_at IS NULL", [id]);
await client.query("UPDATE connections SET disconnected_at = COALESCE(disconnected_at, now()) WHERE id = $1", [id]);
// Undispatched intent pauses. Dispatched or unknown work retains all evidence.
const paused = await client.query<{ id: string; draft_id: string }>(`UPDATE scheduled_publications s SET state = 'needs_reauthorization', error_code = 'CREDENTIAL_CHANGED'
WHERE connection_id = $1 AND state IN ('pending', 'in_flight') AND NOT EXISTS
(SELECT 1 FROM publishing_attempts a WHERE a.schedule_id = s.id AND (a.status = 'unknown' OR (a.dispatch_started_at IS NOT NULL AND a.status NOT IN ('completed', 'failed'))))
RETURNING s.id, s.draft_id`, [id]);
const scheduleIds = paused.rows.map((row) => row.id);
const draftIds = paused.rows.map((row) => row.draft_id);
await client.query("UPDATE publishing_attempts SET status = 'abandoned', owner_token = NULL, lease_until = NULL, error_code = 'CREDENTIAL_CHANGED' WHERE schedule_id = ANY($1::uuid[]) AND status IN ('ready', 'in_progress')", [scheduleIds]);
await client.query("UPDATE drafts SET state = CASE WHEN approved_revision = revision AND approved_hash IS NOT NULL THEN 'approved' ELSE 'editing' END, updated_at = now() WHERE id = ANY($1::uuid[]) AND state = 'scheduled'", [draftIds]);
await client.query("SELECT audit_append($1,$2,$3,'connection.remove','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id]);
});
if (stored) {
try { await revoke(stored); }
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
}
}
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
await this.transaction(session.workspaceId, async (client) => {
// Serialized with membership revocation like every governance write.
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
if (!current.rowCount) throw new NotFoundException("Connection not found");
const previous = readPublishGovernance(current.rows[0]?.fields);
await client.query(
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
[id, mode],
);
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
});
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
await this.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
// A health refresh must never overwrite a concurrent explicit reconnect.
const updated = await client.query("UPDATE credentials SET ciphertext = $3, key_version = $4, rotated_at = now() WHERE connection_id = $1 AND ciphertext = $2 AND kind = 'connector-oauth' AND revoked_at IS NULL RETURNING id", [id, previous.ciphertext, credential.ciphertext, credential.keyVersion]);
if (updated.rowCount) await client.query("SELECT audit_append($1,NULL,'Service','credential.refresh','connection',$2,NULL)", [workspaceId, id]);
});
}
}