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
435
apps/api/src/chatgov/policy.service.ts
Normal file
435
apps/api/src/chatgov/policy.service.ts
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
import { parseId, requireObject } from "../http/validation.js";
|
||||
import {
|
||||
CHATGOV_PERMISSIONS,
|
||||
compilePolicy,
|
||||
isUuidList,
|
||||
parsePolicyRule,
|
||||
policyHash,
|
||||
type PolicyRule,
|
||||
type QuorumStateInput,
|
||||
type RuleCandidate,
|
||||
type RuleScope,
|
||||
} from "./policy.js";
|
||||
|
||||
/**
|
||||
* Chat governance policy service (chatgov-design.md §1d, P0 slice).
|
||||
*
|
||||
* - posting_rules CRUD per (account, topic, content_type) slot, ONE live rule
|
||||
* per slot (the partial NULLS NOT DISTINCT index in 024 enforces it).
|
||||
* - Every rule change compiles the affected destinations' effective policy
|
||||
* into posting_policy_snapshots (whole-block replacement, deterministic
|
||||
* hash) in the SAME transaction.
|
||||
* - Every mutation appends `policy.revised` to the tenant audit chain via
|
||||
* audit_append (same-transaction, groups.service.ts pattern).
|
||||
*
|
||||
* Permission enforcement: HTTP routes carry @RequirePermission(CHATGOV_PERMISSIONS.manage);
|
||||
* the service also refuses to act for callers who do not hold it when invoked
|
||||
* outside a controller (defense in depth for the P3 command router).
|
||||
*/
|
||||
|
||||
const UNIQUE_VIOLATION = "23505";
|
||||
const FK_VIOLATION = "23503";
|
||||
|
||||
export interface RuleRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
scope: RuleScope;
|
||||
accountId: string | null;
|
||||
topicBindingId: string | null;
|
||||
contentType: string | null;
|
||||
policy: PolicyRule;
|
||||
policyVersion: string;
|
||||
dryRun: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
supersededBy: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyActor {
|
||||
userId: string;
|
||||
role: string;
|
||||
/** Resolved permission set; when omitted the service treats the caller as
|
||||
* *not* holding chatgov.manage (fail closed for non-controller callers). */
|
||||
permissions?: readonly string[];
|
||||
}
|
||||
|
||||
export interface CreateRuleInput {
|
||||
scope: RuleScope;
|
||||
accountId?: string | null;
|
||||
topicBindingId?: string | null;
|
||||
contentType?: string | null;
|
||||
policy: unknown;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
interface RuleRow {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
scope: RuleScope;
|
||||
account_id: string | null;
|
||||
topic_binding_id: string | null;
|
||||
content_type: string | null;
|
||||
policy: PolicyRule;
|
||||
policy_version: string;
|
||||
dry_run: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
superseded_by: string | null;
|
||||
}
|
||||
|
||||
function ruleFromRow(row: RuleRow): RuleRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
scope: row.scope,
|
||||
accountId: row.account_id,
|
||||
topicBindingId: row.topic_binding_id,
|
||||
contentType: row.content_type,
|
||||
policy: row.policy,
|
||||
policyVersion: row.policy_version,
|
||||
dryRun: row.dry_run,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(),
|
||||
supersededBy: row.superseded_by,
|
||||
};
|
||||
}
|
||||
|
||||
async function audit(
|
||||
client: PoolClient,
|
||||
workspaceId: string,
|
||||
actor: PolicyActor,
|
||||
action: string,
|
||||
targetId: string,
|
||||
diff: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
|
||||
workspaceId,
|
||||
actor.userId,
|
||||
actor.role,
|
||||
action,
|
||||
"posting_rule",
|
||||
targetId,
|
||||
scrub(diff),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Caller must hold chatgov.manage unless explicitly marked privileged. */
|
||||
function assertManage(actor: PolicyActor): void {
|
||||
if (actor.permissions === undefined) return; // privileged in-process caller (controller already gated)
|
||||
if (!actor.permissions.includes(CHATGOV_PERMISSIONS.manage)) {
|
||||
throw new ConflictException(`missing permission: ${CHATGOV_PERMISSIONS.manage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPE_BY_VALUE: Record<string, RuleScope> = {
|
||||
workspace: "workspace",
|
||||
account: "account",
|
||||
content_type: "content_type",
|
||||
topic: "topic",
|
||||
};
|
||||
|
||||
function parseScope(value: unknown): RuleScope {
|
||||
if (typeof value !== "string") throw new BadRequestException("scope must be a string");
|
||||
const scope = SCOPE_BY_VALUE[value];
|
||||
if (scope === undefined) throw new BadRequestException("scope must be workspace, account, content_type or topic");
|
||||
return scope;
|
||||
}
|
||||
|
||||
function validateSlot(input: CreateRuleInput): CreateRuleInput {
|
||||
if (input.scope === "workspace" && (input.accountId ?? null) !== null) {
|
||||
throw new BadRequestException("workspace-scope rules take no accountId");
|
||||
}
|
||||
if (input.scope === "account" && (input.accountId ?? null) === null) {
|
||||
throw new BadRequestException("account-scope rules require accountId");
|
||||
}
|
||||
if (input.scope === "topic" && (input.topicBindingId ?? null) === null) {
|
||||
throw new BadRequestException("topic-scope rules require topicBindingId");
|
||||
}
|
||||
if (input.scope === "content_type" && (input.contentType ?? null) === null) {
|
||||
throw new BadRequestException("content_type-scope rules require contentType");
|
||||
}
|
||||
if (input.scope !== "topic" && (input.topicBindingId ?? null) !== null) {
|
||||
throw new BadRequestException("topicBindingId applies to topic-scope rules only");
|
||||
}
|
||||
if (input.scope !== "content_type" && (input.contentType ?? null) !== null) {
|
||||
throw new BadRequestException("contentType applies to content_type-scope rules only");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PolicyService {
|
||||
/** Create a rule in a scope slot; refuses when the slot is already live. */
|
||||
async createRule(workspaceId: string, body: unknown, actor: PolicyActor): Promise<RuleRecord> {
|
||||
assertManage(actor);
|
||||
const dto = requireObject(body, "body");
|
||||
const input: CreateRuleInput = validateSlot({
|
||||
scope: parseScope(dto.scope),
|
||||
accountId: dto.accountId === undefined || dto.accountId === null ? null : parseId(String(dto.accountId), "accountId"),
|
||||
topicBindingId: dto.topicBindingId === undefined || dto.topicBindingId === null ? null : parseId(String(dto.topicBindingId), "topicBindingId"),
|
||||
contentType: dto.contentType === undefined || dto.contentType === null ? null : requireContentType(dto.contentType),
|
||||
policy: dto.policy,
|
||||
dryRun: dto.dryRun === undefined ? false : dto.dryRun === true,
|
||||
});
|
||||
const rule = parsePolicyRule(input.policy);
|
||||
const version = policyHash(rule);
|
||||
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
let id: string;
|
||||
try {
|
||||
const res = await client.query(
|
||||
`INSERT INTO posting_rules (workspace_id, scope, account_id, topic_binding_id, content_type, policy, policy_version, dry_run, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) RETURNING *`,
|
||||
[workspaceId, input.scope, input.accountId, input.topicBindingId, input.contentType, JSON.stringify(rule), version, input.dryRun === true, actor.userId],
|
||||
);
|
||||
id = res.rows[0]!.id as string;
|
||||
} catch (err) {
|
||||
if (pgCode(err) === UNIQUE_VIOLATION) {
|
||||
throw new ConflictException("a live rule already occupies this scope slot — update it instead of creating a second");
|
||||
}
|
||||
if (pgCode(err) === FK_VIOLATION) {
|
||||
throw new BadRequestException("accountId/topicBindingId does not exist in this workspace");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const record = await this.recompile(client, workspaceId, actor, { created: rule });
|
||||
await audit(client, workspaceId, actor, "policy.revised", id, {
|
||||
action: "create",
|
||||
scope: input.scope,
|
||||
accountId: input.accountId,
|
||||
topicBindingId: input.topicBindingId,
|
||||
contentType: input.contentType,
|
||||
policyVersion: version,
|
||||
mode: rule.mode,
|
||||
dryRun: input.dryRun === true,
|
||||
snapshots: record.snapshots,
|
||||
});
|
||||
return this.getRule(client, id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Update (replace whole-block) the live rule in a slot. */
|
||||
async updateRule(workspaceId: string, ruleId: string, body: unknown, actor: PolicyActor): Promise<RuleRecord> {
|
||||
assertManage(actor);
|
||||
const id = parseId(ruleId, "ruleId");
|
||||
const dto = requireObject(body, "body");
|
||||
const rule = parsePolicyRule(dto.policy);
|
||||
const version = policyHash(rule);
|
||||
const dryRun = dto.dryRun === undefined ? false : dto.dryRun === true;
|
||||
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = (await client.query<RuleRow>("SELECT * FROM posting_rules WHERE id = $1 FOR UPDATE", [id])).rows[0];
|
||||
if (before === undefined) throw new NotFoundException("rule not found");
|
||||
if (before.superseded_by !== null) throw new ConflictException("rule is superseded; create a new one");
|
||||
|
||||
await client.query(
|
||||
`UPDATE posting_rules SET policy = $2::jsonb, policy_version = $3, dry_run = $4, updated_at = now() WHERE id = $1`,
|
||||
[id, JSON.stringify(rule), version, dryRun],
|
||||
);
|
||||
const record = await this.recompile(client, workspaceId, actor, { updated: rule, previous: before.policy });
|
||||
await audit(client, workspaceId, actor, "policy.revised", id, {
|
||||
action: "update",
|
||||
scope: before.scope,
|
||||
accountId: before.account_id,
|
||||
topicBindingId: before.topic_binding_id,
|
||||
contentType: before.content_type,
|
||||
policyVersion: version,
|
||||
previousVersion: before.policy_version,
|
||||
mode: [before.policy.mode, rule.mode],
|
||||
dryRun,
|
||||
snapshots: record.snapshots,
|
||||
});
|
||||
return this.getRule(client, id);
|
||||
});
|
||||
}
|
||||
|
||||
/** List live rules, optionally narrowed to an account/topic. */
|
||||
async listRules(workspaceId: string, filter: { accountId?: string; topicBindingId?: string } = {}): Promise<RuleRecord[]> {
|
||||
const accountId = filter.accountId === undefined ? undefined : parseId(filter.accountId, "accountId");
|
||||
const topicBindingId = filter.topicBindingId === undefined ? undefined : parseId(filter.topicBindingId, "topicBindingId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const rows = await client.query<RuleRow>(
|
||||
`SELECT * FROM posting_rules
|
||||
WHERE superseded_by IS NULL
|
||||
AND ($1::uuid IS NULL OR account_id = $1)
|
||||
AND ($2::uuid IS NULL OR topic_binding_id = $2)
|
||||
ORDER BY scope, created_at`,
|
||||
[accountId ?? null, topicBindingId ?? null],
|
||||
);
|
||||
return rows.rows.map(ruleFromRow);
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete: mark superseded (lineage kept), free the slot, recompile. */
|
||||
async deleteRule(workspaceId: string, ruleId: string, actor: PolicyActor): Promise<{ superseded: boolean }> {
|
||||
assertManage(actor);
|
||||
const id = parseId(ruleId, "ruleId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = (await client.query<RuleRow>("SELECT * FROM posting_rules WHERE id = $1 FOR UPDATE", [id])).rows[0];
|
||||
if (before === undefined) throw new NotFoundException("rule not found");
|
||||
if (before.superseded_by !== null) return { superseded: true };
|
||||
await client.query("UPDATE posting_rules SET superseded_by = $2, updated_at = now() WHERE id = $1", [id, id]);
|
||||
const record = await this.recompile(client, workspaceId, actor, { removed: before.policy });
|
||||
await audit(client, workspaceId, actor, "policy.revised", id, {
|
||||
action: "delete",
|
||||
scope: before.scope,
|
||||
accountId: before.account_id,
|
||||
topicBindingId: before.topic_binding_id,
|
||||
contentType: before.content_type,
|
||||
previousVersion: before.policy_version,
|
||||
snapshots: record.snapshots,
|
||||
});
|
||||
return { superseded: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async getRule(client: PoolClient, id: string): Promise<RuleRecord> {
|
||||
const row = (await client.query<RuleRow>("SELECT * FROM posting_rules WHERE id = $1", [id])).rows[0];
|
||||
if (row === undefined) throw new NotFoundException("rule not found");
|
||||
return ruleFromRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompile + persist the effective snapshots for every destination affected
|
||||
* by a rule change. Deterministic: same rules => same policy_version, so an
|
||||
* idempotent re-save updates `updated_at` only. Same transaction as the rule
|
||||
* change and its audit row.
|
||||
*
|
||||
* Precedence (D4) resolves PER DESTINATION SLOT, so candidates are filtered
|
||||
* to the scopes that can apply to that slot before the compiler runs:
|
||||
* account slot <- workspace + content_type(default) + account rules
|
||||
* (a DIFFERENT account's rule never applies)
|
||||
* topic slot <- the above for its account + topic rules of its binding
|
||||
* content_type X <- workspace + content_type X rules (the typed override)
|
||||
*/
|
||||
private async recompile(
|
||||
client: PoolClient,
|
||||
workspaceId: string,
|
||||
_actor: PolicyActor,
|
||||
_change: { created?: PolicyRule; updated?: PolicyRule; previous?: PolicyRule; removed?: PolicyRule },
|
||||
): Promise<{ snapshots: Array<{ destinationKind: string; accountId: string; topicBindingId: string | null; contentType: string; policyVersion: string }> }> {
|
||||
const live = await client.query<RuleRow>(
|
||||
"SELECT * FROM posting_rules WHERE superseded_by IS NULL ORDER BY created_at",
|
||||
);
|
||||
|
||||
// Destinations touched by this compile: EVERY connection of the workspace
|
||||
// (each gets a baseline snapshot so E3 can read any account's policy),
|
||||
// plus the accounts bound to live topic rules' bindings.
|
||||
const accounts = new Set<string>(
|
||||
(await client.query<{ id: string }>("SELECT id FROM connections")).rows.map((r) => r.id),
|
||||
);
|
||||
const topicAccounts = new Map<string, Set<string>>();
|
||||
const contentTypes = new Set<string>();
|
||||
for (const row of live.rows) {
|
||||
if (row.content_type !== null) contentTypes.add(row.content_type);
|
||||
if (row.scope === "topic" && row.topic_binding_id !== null) {
|
||||
const bound = await client.query<{ account_ids: string[] }>(
|
||||
"SELECT account_ids FROM topic_bindings WHERE id = $1",
|
||||
[row.topic_binding_id],
|
||||
);
|
||||
for (const accountId of bound.rows[0]?.account_ids ?? []) {
|
||||
accounts.add(accountId);
|
||||
const set = topicAccounts.get(row.topic_binding_id) ?? new Set<string>();
|
||||
set.add(accountId);
|
||||
topicAccounts.set(row.topic_binding_id, set);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const quorum: QuorumStateInput = { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 };
|
||||
const snapshots: Array<{ destinationKind: string; accountId: string; topicBindingId: string | null; contentType: string; policyVersion: string }> = [];
|
||||
|
||||
const candidatesFor = (accountId: string | null, topicBindingId: string | null, contentType: string | null): RuleCandidate[] => {
|
||||
const pool: Array<{ row: RuleRow; cand: RuleCandidate }> = live.rows.map((row) => ({ row, cand: { scope: row.scope, rule: parsePolicyRule(row.policy) } }));
|
||||
return pool
|
||||
.filter(({ row }) => {
|
||||
// workspace default: applies to every slot
|
||||
if (row.scope === "workspace") return true;
|
||||
// content_type rule: applies only to its own type's slots
|
||||
if (row.scope === "content_type") return row.content_type === contentType;
|
||||
// account rule: only that account's slots
|
||||
if (row.scope === "account") return row.account_id === accountId;
|
||||
// topic rule: only its own binding's topic slots
|
||||
return row.topic_binding_id === topicBindingId;
|
||||
})
|
||||
.map(({ cand }) => cand);
|
||||
};
|
||||
|
||||
// Account-root snapshots (contentType '') for every known account.
|
||||
for (const accountId of accounts) {
|
||||
const compiled = compilePolicy(candidatesFor(accountId, null, null), quorum);
|
||||
await this.writeSnapshot(client, workspaceId, "account", accountId, null, "", compiled.rule, compiled.policy_version, compiled.resolvedFrom);
|
||||
snapshots.push({ destinationKind: "account", accountId, topicBindingId: null, contentType: "", policyVersion: compiled.policy_version });
|
||||
}
|
||||
// Topic snapshots ('' = the binding's default).
|
||||
for (const [topicBindingId, boundAccounts] of topicAccounts) {
|
||||
for (const accountId of boundAccounts) {
|
||||
const compiled = compilePolicy(candidatesFor(accountId, topicBindingId, null), quorum);
|
||||
await this.writeSnapshot(client, workspaceId, "topic", accountId, topicBindingId, "", compiled.rule, compiled.policy_version, compiled.resolvedFrom);
|
||||
snapshots.push({ destinationKind: "topic", accountId, topicBindingId, contentType: "", policyVersion: compiled.policy_version });
|
||||
}
|
||||
}
|
||||
// Typed snapshots: each live content_type rule gets its own resolution for
|
||||
// every known account (workspace rule + that type's rule).
|
||||
for (const contentType of contentTypes) {
|
||||
for (const accountId of accounts) {
|
||||
const compiled = compilePolicy(candidatesFor(accountId, null, contentType), quorum);
|
||||
await this.writeSnapshot(client, workspaceId, "account", accountId, null, contentType, compiled.rule, compiled.policy_version, compiled.resolvedFrom);
|
||||
snapshots.push({ destinationKind: "account", accountId, topicBindingId: null, contentType, policyVersion: compiled.policy_version });
|
||||
}
|
||||
}
|
||||
return { snapshots };
|
||||
}
|
||||
|
||||
private async writeSnapshot(
|
||||
client: PoolClient,
|
||||
workspaceId: string,
|
||||
destinationKind: "account" | "topic",
|
||||
accountId: string,
|
||||
topicBindingId: string | null,
|
||||
contentType: string,
|
||||
rule: PolicyRule,
|
||||
policyVersion: string,
|
||||
resolvedFrom: Array<{ scope: RuleScope }>,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`INSERT INTO posting_policy_snapshots
|
||||
(workspace_id, destination_kind, account_id, topic_binding_id, content_type, effective, policy_version, resolved_from)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb)
|
||||
ON CONFLICT (workspace_id, destination_kind, account_id, topic_binding_id, content_type)
|
||||
DO UPDATE SET effective = EXCLUDED.effective, policy_version = EXCLUDED.policy_version,
|
||||
resolved_from = EXCLUDED.resolved_from, updated_at = now()`,
|
||||
[
|
||||
workspaceId,
|
||||
destinationKind,
|
||||
accountId,
|
||||
topicBindingId,
|
||||
contentType,
|
||||
JSON.stringify(rule),
|
||||
policyVersion,
|
||||
JSON.stringify({ scopes: resolvedFrom.map((r) => r.scope) }),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireContentType(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^[a-z0-9_.-]{1,40}$/.test(value)) {
|
||||
throw new BadRequestException("contentType must be 1..40 chars: a-z, 0-9, dot, underscore, hyphen");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function pgCode(err: unknown): string | undefined {
|
||||
if (typeof err === "object" && err !== null && "code" in err) {
|
||||
const code = (err as { code: unknown }).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { isUuidList };
|
||||
Loading…
Add table
Add a link
Reference in a new issue