T-HUB/apps/api/src/chatgov/policy.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

301 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import { BadRequestException } from "@nestjs/common";
/**
* Chat governance policy primitives (chatgov-design.md §1a/§1d).
*
* These functions are PURE on purpose — no DB, no clock, no randomness. The
* compiled snapshot and its `policy_version` hash must be deterministic so
* that the same rules + quorum state always produce byte-identical output
* (P0 acceptance: "policy compile is pure + snapshot hash deterministic").
* Time-dependent values (opens_at/closes_at) never enter the hash input.
*/
export type GateMode = "immediate" | "quorum" | "admin_only";
/** Whose ballots count toward an arm. Eligibility is resolved at CLOSE time
* (TOCTOU guard, §1a) by the evaluator; the compile step only records the
* shape. */
export type ArmKind = "admins" | "participants";
/** Participants denominator: observed roster | explicit eligible roster | pure count. */
export type ParticipantsOf = "observed" | "eligible" | "none";
export interface QuorumArm {
kind: ArmKind;
/** Absolute count needed from this arm. */
x: number;
/** Participants arms only: how the denominator is derived. */
of?: ParticipantsOf;
}
export interface TimeboxSpec {
/** ISO-8601 duration (e.g. "PT2H"). Validated shape-only here. */
duration: string;
onExpire: "approve" | "reject" | "escalateOwner";
}
/** §1a pass-criteria rule object as stored in posting_rules.policy and
* polls.rule (compiled). */
export interface PolicyRule {
version: number;
mode: GateMode;
arms?: QuorumArm[];
/** all (AND, default) | any (OR). */
join?: "all" | "any";
allowVeto?: boolean;
/** Seconds; early close only when quorum ∧ dwell. */
minDwellSeconds?: number;
timebox?: TimeboxSpec;
/** named | anonymous — never mixed silently. */
anonymity?: "named" | "anonymous";
allowSelfApproval?: boolean;
/** Draft edit ⇒ approvals reset, poll re-arms. */
resetOnChange?: boolean;
/** Evaluate + log, never block/fire. */
dryRun?: boolean;
}
export const POLICY_SCHEMA_VERSION = 1;
/** Machine-readable reason codes (design §4). Kept here (not in the module
* file) so P1-P3 import them without dragging in Nest decorators. */
export const REASON_CODES = [
"INSUFFICIENT_QUORUM",
"INELIGIBLE_ACTOR",
"SELF_APPROVAL_DENIED",
"TIMEBOX_EXPIRED_AUTO_REJECT",
"TIMEBOX_EXPIRED_AUTO_APPROVE",
"RESET_ON_CHANGE",
"PENDING_POLL",
"VETOED",
"OUTSIDE_POLICY",
"POLICY_DRY_RUN",
"OVERRIDDEN_BY_OWNER",
"LOCKED_CHANNEL",
"NO_CHANNEL_BOUND",
"CONFIRM_EXPIRED",
] as const;
export type ReasonCode = (typeof REASON_CODES)[number];
export const GATES = ["E1", "E2", "E3"] as const;
export type Gate = (typeof GATES)[number];
/** Public (denormalised) chatgov permissions. Seeded into role_permissions by
* migration 024; mirrored here because packages/core's PERMISSIONS list is
* frozen for the WP2 slice and apps/web reads permissions from the API. */
export const CHATGOV_PERMISSIONS = {
/** Rules editor, topic registry, identity approval, policy compile. */
manage: "chatgov.manage",
/** Open/cast governance votes. */
vote: "chatgov.vote",
} as const;
const DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
export function isUuidList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string" && UUID_RE.test(v));
}
/** Parse + validate a §1a-shaped policy object (unknown in, typed out). */
export function parsePolicyRule(input: unknown, field = "policy"): PolicyRule {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new BadRequestException(`${field} must be an object`);
}
const raw = input as Record<string, unknown>;
const version = raw.version === undefined ? POLICY_SCHEMA_VERSION : raw.version;
if (version !== POLICY_SCHEMA_VERSION) {
throw new BadRequestException(`${field}.version must be ${POLICY_SCHEMA_VERSION}`);
}
const mode = raw.mode;
if (mode !== "immediate" && mode !== "quorum" && mode !== "admin_only") {
throw new BadRequestException(`${field}.mode must be immediate, quorum or admin_only`);
}
const join = raw.join === undefined ? "all" : raw.join;
if (join !== "all" && join !== "any") throw new BadRequestException(`${field}.join must be all or any`);
const arms: QuorumArm[] = [];
if (raw.arms !== undefined) {
if (!Array.isArray(raw.arms)) throw new BadRequestException(`${field}.arms must be an array`);
if (raw.arms.length === 0) throw new BadRequestException(`${field}.arms must not be empty`);
if (raw.arms.length > 4) throw new BadRequestException(`${field}.arms supports at most 4 arms`);
for (const [index, entry] of raw.arms.entries()) {
if (typeof entry !== "object" || entry === null) throw new BadRequestException(`${field}.arms[${index}] must be an object`);
const arm = entry as Record<string, unknown>;
if (arm.kind !== "admins" && arm.kind !== "participants") {
throw new BadRequestException(`${field}.arms[${index}].kind must be admins or participants`);
}
const x = arm.x;
if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > 1000) {
throw new BadRequestException(`${field}.arms[${index}].x must be an integer between 1 and 1000`);
}
if (arm.of !== undefined) {
if (arm.kind !== "participants") {
throw new BadRequestException(`${field}.arms[${index}].of applies to participants arms only`);
}
if (arm.of !== "observed" && arm.of !== "eligible" && arm.of !== "none") {
throw new BadRequestException(`${field}.arms[${index}].of must be observed, eligible or none`);
}
}
arms.push({ kind: arm.kind, x, ...(arm.of === undefined ? {} : { of: arm.of }) });
}
}
if (mode === "admin_only" && arms.some((arm) => arm.kind === "participants")) {
throw new BadRequestException(`${field}: admin_only mode has no participants arm`);
}
if (mode === "quorum" && arms.length === 0) {
throw new BadRequestException(`${field}: quorum mode requires arms`);
}
if (mode !== "quorum" && arms.length > 0) {
throw new BadRequestException(`${field}: ${mode} mode takes no arms`);
}
const timebox = raw.timebox;
let parsedTimebox: TimeboxSpec | undefined;
if (timebox !== undefined) {
if (typeof timebox !== "object" || timebox === null) throw new BadRequestException(`${field}.timebox must be an object`);
const spec = timebox as Record<string, unknown>;
if (typeof spec.duration !== "string" || !DURATION.test(spec.duration)) {
throw new BadRequestException(`${field}.timebox.duration must be an ISO-8601 duration`);
}
if (spec.onExpire !== "approve" && spec.onExpire !== "reject" && spec.onExpire !== "escalateOwner") {
throw new BadRequestException(`${field}.timebox.onExpire must be approve, reject or escalateOwner`);
}
parsedTimebox = { duration: spec.duration, onExpire: spec.onExpire };
}
if (parsedTimebox === undefined && mode !== "immediate") {
throw new BadRequestException(`${field}: ${mode} mode requires timebox`);
}
const anonymity = raw.anonymity === undefined ? "named" : raw.anonymity;
if (anonymity !== "named" && anonymity !== "anonymous") {
throw new BadRequestException(`${field}.anonymity must be named or anonymous`);
}
const allowVeto = raw.allowVeto === undefined ? false : requireBoolean(raw.allowVeto, `${field}.allowVeto`);
const allowSelfApproval = raw.allowSelfApproval === undefined ? false : requireBoolean(raw.allowSelfApproval, `${field}.allowSelfApproval`);
const resetOnChange = raw.resetOnChange === undefined ? true : requireBoolean(raw.resetOnChange, `${field}.resetOnChange`);
const dryRun = raw.dryRun === undefined ? false : requireBoolean(raw.dryRun, `${field}.dryRun`);
const minDwellSeconds = raw.minDwellSeconds === undefined ? 0 : raw.minDwellSeconds;
if (typeof minDwellSeconds !== "number" || !Number.isInteger(minDwellSeconds) || minDwellSeconds < 0 || minDwellSeconds > 86400) {
throw new BadRequestException(`${field}.minDwellSeconds must be an integer between 0 and 86400`);
}
return {
version: POLICY_SCHEMA_VERSION,
mode,
...(arms.length === 0 ? {} : { arms }),
join,
allowVeto,
minDwellSeconds,
...(parsedTimebox === undefined ? {} : { timebox: parsedTimebox }),
anonymity,
allowSelfApproval,
resetOnChange,
dryRun,
};
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new BadRequestException(`${field} must be a boolean`);
return value;
}
/**
* Canonical JSON: keys sorted lexicographically, no insignificant whitespace,
* arrays keep order. This — not JSON.stringify of the source object — is the
* hash input, so an equivalent policy written with different key order or
* explicit defaults still compiles to the same version.
*/
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
}
/** Deterministic snapshot hash: sha256 over the canonical JSON (hex, 16 chars
* — short enough for UI display, long enough to be collision-safe here). */
export function policyHash(value: unknown): string {
return createHash("sha256").update(canonicalJson(value)).digest("hex").slice(0, 16);
}
// ---------------------------------------------------------------------------
// Compile: rules + quorum state -> immutable snapshot.
// ---------------------------------------------------------------------------
export type RuleScope = "workspace" | "account" | "content_type" | "topic";
export interface CompiledPolicySnapshot {
rule: PolicyRule;
policy_version: string;
}
/** A live rule candidate offered to the compiler. */
export interface RuleCandidate {
scope: RuleScope;
rule: PolicyRule;
}
/** Quorum denominators AT COMPILE TIME. Recorded in the snapshot for
* observability only — never hashed — so a re-compile after roster drift
* yields the same policy_version unless the policy itself changed. */
export interface QuorumStateInput {
adminCount: number;
observedParticipants: number;
eligibleParticipants: number;
}
/** Precedence (D4): topic > content_type > account > workspace. */
const SCOPE_ORDER: readonly RuleScope[] = ["workspace", "account", "content_type", "topic"];
/**
* Resolve the winning rule among `candidates` (any order, duplicates allowed —
* most specific scope wins; ties resolve to the first candidate of that scope,
* which the caller keeps stable by ordering rules by created_at).
*/
export function resolvePrecedence(candidates: RuleCandidate[]): RuleCandidate {
let winner: RuleCandidate | null = null;
for (const candidate of candidates) {
if (winner === null || SCOPE_ORDER.indexOf(candidate.scope) > SCOPE_ORDER.indexOf(winner.scope)) {
winner = candidate;
}
}
if (winner !== null) return winner;
// Fail-closed baseline: an explicit `immediate` policy so every destination
// has a persisted snapshot (§1d fallback:block is an EVALUATION posture —
// OUTSIDE_POLICY at the gate — not a compile input).
return { scope: "workspace", rule: parsePolicyRule({ mode: "immediate", allowVeto: false }) };
}
export interface CompiledSnapshot {
rule: PolicyRule;
policy_version: string;
resolvedFrom: Array<{ scope: RuleScope }>;
quorum: QuorumStateInput;
}
/**
* Compile the effective policy for one destination. PURE + DETERMINISTIC:
* - candidates in any order produce the same winner and the same version;
* - the version hashes only the winning rule (quorum denominators are
* observability, not identity);
* - no clock, no DB, no randomness.
*/
export function compilePolicy(candidates: RuleCandidate[], quorum: QuorumStateInput): CompiledSnapshot {
const winner = resolvePrecedence(candidates);
const resolvedFrom = [...candidates]
.sort((a, b) => a.scope.localeCompare(b.scope))
.map((c) => ({ scope: c.scope }));
return {
rule: winner.rule,
policy_version: policyHash(winner.rule),
resolvedFrom,
quorum,
};
}