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; get(workspaceId: string, id: string): Promise; assertAuthorized(session: SessionPayload): Promise; save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise; rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise; remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise): Promise; /** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */ setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise; } export class PgConnectionStore implements ConnectionStore { constructor(private readonly transaction: typeof withTenant = withTenant) {} async assertAuthorized(session: SessionPayload): Promise { 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 { 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; 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 { return this.transaction(workspaceId, (client) => this.read(client, workspaceId, id)); } private async read(client: PoolClient, workspaceId: string, id: string): Promise { 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; 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 { 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): Promise { 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 { await this.transaction(session.workspaceId, async (client) => { // Serialized with membership revocation like every governance write. await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]); const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]); if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException(); const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]); if (!current.rowCount) throw new NotFoundException("Connection not found"); const previous = readPublishGovernance(current.rows[0]?.fields); await client.query( "UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1", [id, mode], ); await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]); }); } async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise { await this.transaction(workspaceId, async (client) => { await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]); // 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]); }); } }