Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
117
apps/api/src/connections/connection-store.ts
Normal file
117
apps/api/src/connections/connection-store.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
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) {
|
||||
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]);
|
||||
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 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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue