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/.
301 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|