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
100
apps/api/src/chatgov/chatgov.controller.ts
Normal file
100
apps/api/src/chatgov/chatgov.controller.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { parseId, requireObject, requireSession } from "../http/validation.js";
|
||||
import { CHATGOV_PERMISSIONS } from "./policy.js";
|
||||
import { PolicyService, type PolicyActor, type RuleRecord } from "./policy.service.js";
|
||||
import { GateService, type GateDecisionRecord, type RecordGateInput } from "./gate.service.js";
|
||||
|
||||
/**
|
||||
* Chat governance console API (§5): rules editor + gate decision reads.
|
||||
* The command router (/poll, /tally, …) is P3 and does NOT live here.
|
||||
*
|
||||
* Permission model (§6): chatgov.manage gates every rule mutation; reads are
|
||||
* workspace.view-shaped (any member sees the policy that governs them).
|
||||
*/
|
||||
@Controller("chatgov")
|
||||
@UseGuards(RbacGuard)
|
||||
export class ChatgovController {
|
||||
constructor(
|
||||
@Inject(PolicyService) private readonly policy: PolicyService,
|
||||
@Inject(GateService) private readonly gates: GateService,
|
||||
) {}
|
||||
|
||||
@Get("rules")
|
||||
@RequirePermission("workspace.view")
|
||||
async listRules(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Query("accountId") accountId: string | undefined,
|
||||
@Query("topicBindingId") topicBindingId: string | undefined,
|
||||
): Promise<RuleRecord[]> {
|
||||
return this.policy.listRules(requireSession(request).workspaceId, {
|
||||
...(accountId === undefined || accountId === "" ? {} : { accountId }),
|
||||
...(topicBindingId === undefined || topicBindingId === "" ? {} : { topicBindingId }),
|
||||
});
|
||||
}
|
||||
|
||||
@Post("rules")
|
||||
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||
async createRule(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<RuleRecord> {
|
||||
return this.policy.createRule(requireSession(request).workspaceId, body, actorOf(request));
|
||||
}
|
||||
|
||||
@Patch("rules/:id")
|
||||
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||
async updateRule(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<RuleRecord> {
|
||||
return this.policy.updateRule(requireSession(request).workspaceId, parseId(id, "id"), body, actorOf(request));
|
||||
}
|
||||
|
||||
@Delete("rules/:id")
|
||||
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||
async deleteRule(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string): Promise<{ superseded: boolean }> {
|
||||
return this.policy.deleteRule(requireSession(request).workspaceId, parseId(id, "id"), actorOf(request));
|
||||
}
|
||||
|
||||
/** Blocked-queue / draft timeline feed (P6 renders it). */
|
||||
@Get("decisions/draft/:draftId")
|
||||
@RequirePermission("draft.view")
|
||||
async decisionsForDraft(
|
||||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("draftId") draftId: string,
|
||||
): Promise<GateDecisionRecord[]> {
|
||||
return this.gates.listForDraft(requireSession(request).workspaceId, parseId(draftId, "draftId"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal write path for the Temporal E3 worker / E1 review hook (P1).
|
||||
* Service-to-service in v1: the route exists so the seam is testable, and is
|
||||
* gated on chatgov.manage (an Owner/Admin console action in practice).
|
||||
*/
|
||||
@Post("decisions")
|
||||
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||
async recordDecision(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<GateDecisionRecord> {
|
||||
const dto = requireObject(body, "body");
|
||||
const session = requireSession(request);
|
||||
return this.gates.record(session.workspaceId, {
|
||||
draftId: String(dto.draftId),
|
||||
destination: dto.destination as RecordGateInput["destination"],
|
||||
gate: dto.gate as RecordGateInput["gate"],
|
||||
...(dto.ruleId === undefined || dto.ruleId === null ? {} : { ruleId: String(dto.ruleId) }),
|
||||
policyVersion: String(dto.policyVersion),
|
||||
outcome: dto.outcome as RecordGateInput["outcome"],
|
||||
inputs: (dto.inputs ?? {}) as RecordGateInput["inputs"],
|
||||
reasonCodes: Array.isArray(dto.reasonCodes) ? (dto.reasonCodes as unknown[]).filter((c): c is RecordGateInput["reasonCodes"][number] => typeof c === "string") : [],
|
||||
actor: typeof dto.actor === "string" ? dto.actor : `user:${session.userId}`,
|
||||
...(dto.pollId === undefined || dto.pollId === null ? {} : { pollId: String(dto.pollId) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function actorOf(request: Request & AuthenticatedRequest): PolicyActor {
|
||||
const session = requireSession(request);
|
||||
// The route decorator already enforced chatgov.manage; `permissions` is left
|
||||
// unset so the service's defense-in-depth check treats the controller as the
|
||||
// privileged caller (it re-checks only for non-controller callers).
|
||||
return { userId: session.userId, role: session.role };
|
||||
}
|
||||
710
apps/api/src/chatgov/chatgov.integration.test.ts
Normal file
710
apps/api/src/chatgov/chatgov.integration.test.ts
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
|
||||
/**
|
||||
* chatgov P0 spine against a live PostgreSQL (chatgov-design.md §7 DoD):
|
||||
* - migration idempotency (re-run applies nothing)
|
||||
* - RLS denial for EVERY new table (workspace B sees/updates/deletes zero A
|
||||
* rows, and B cannot write into A's workspace — WITH CHECK)
|
||||
* - rule CRUD + one-live-rule-per-slot
|
||||
* - policy compile -> posting_policy_snapshots with a deterministic version
|
||||
* - RBAC permission gating (chatgov.manage; Editor is denied, Admin allowed)
|
||||
* - audit event landing (policy.revised / policy.gate in audit_log)
|
||||
*
|
||||
* Owns its own database (CHATGOV_IT_DATABASE_URL + CHATGOV_IT_SUPER_DATABASE_URL,
|
||||
* local-db-sol.env pattern) because the shared tenancy DB is wiped by other
|
||||
* suites and vitest runs files in parallel. Skips cleanly when unset.
|
||||
*/
|
||||
const appUrl = process.env.CHATGOV_IT_DATABASE_URL;
|
||||
const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL;
|
||||
const dbAvailable = appUrl !== undefined && ownerUrl !== undefined;
|
||||
|
||||
const RLS_VIOLATION = "42501";
|
||||
const UNIQUE_VIOLATION = "23505";
|
||||
const FK_VIOLATION = "23503";
|
||||
const TABLES = [
|
||||
"polls",
|
||||
"poll_votes",
|
||||
"posting_rules",
|
||||
"posting_policy_snapshots",
|
||||
"topic_bindings",
|
||||
"chat_command_audit",
|
||||
"chat_identities",
|
||||
"gate_decisions",
|
||||
] as const;
|
||||
|
||||
type Modules = {
|
||||
policy: typeof import("./policy.js");
|
||||
policyService: typeof import("./policy.service.js");
|
||||
gate: typeof import("./gate.service.js");
|
||||
db: typeof import("../db/client.js");
|
||||
guard: typeof import("../rbac/rbac.guard.js");
|
||||
session: typeof import("../auth/session.js");
|
||||
};
|
||||
|
||||
describe.skipIf(!dbAvailable)("chatgov P0 spine (live DB)", () => {
|
||||
let m: Modules;
|
||||
let ownerPool: Pool;
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
||||
let wsA: string;
|
||||
let wsB: string;
|
||||
let userA: string;
|
||||
let connA1: string;
|
||||
let connA2: string;
|
||||
let connB: string;
|
||||
let topicA: string;
|
||||
let draftA: string;
|
||||
|
||||
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await ownerPool.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed tenant rows honestly: FORCE RLS applies to the owner too. */
|
||||
async function inTenant(client: PoolClient, wsId: string, fn: () => Promise<void>): Promise<void> {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [wsId]);
|
||||
await fn();
|
||||
await client.query("COMMIT");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// The shared pool reads DATABASE_URL at import time: point it at this
|
||||
// isolated DB first, then import the modules under test.
|
||||
process.env.DATABASE_URL = appUrl;
|
||||
process.env.SUPER_DATABASE_URL = ownerUrl;
|
||||
const { runMigrations } = await import("../db/migrate.js");
|
||||
// Idempotency: the CLI already applied every migration; re-running must
|
||||
// apply nothing (schema_migrations guards).
|
||||
const applied = await runMigrations(join(process.cwd(), "migrations"));
|
||||
expect(applied).toEqual([]);
|
||||
|
||||
m = {
|
||||
policy: await import("./policy.js"),
|
||||
policyService: await import("./policy.service.js"),
|
||||
gate: await import("./gate.service.js"),
|
||||
db: await import("../db/client.js"),
|
||||
guard: await import("../rbac/rbac.guard.js"),
|
||||
session: await import("../auth/session.js"),
|
||||
};
|
||||
ownerPool = new Pool({ connectionString: ownerUrl });
|
||||
|
||||
await asOwner(async (c) => {
|
||||
const ws = async (name: string): Promise<string> =>
|
||||
(await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [name, `${name}-${suffix}`])).rows[0].id as string;
|
||||
wsA = await ws("cg-a");
|
||||
wsB = await ws("cg-b");
|
||||
const user = async (name: string): Promise<string> =>
|
||||
(await c.query(
|
||||
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
[`cg:${name}:${suffix}`, `cg-${name}-${suffix}`, `${name}-${suffix}@example.com`, name],
|
||||
)).rows[0].id as string;
|
||||
userA = await user("owner");
|
||||
const editorA = await user("editor");
|
||||
|
||||
const conn = async (wsId: string, name: string): Promise<string> =>
|
||||
(await c.query(
|
||||
"INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id",
|
||||
[wsId, `ch-${name}`],
|
||||
)).rows[0].id as string;
|
||||
connA1 = await conn(wsA, "a1");
|
||||
connA2 = await conn(wsA, "a2");
|
||||
connB = await conn(wsB, "b1");
|
||||
|
||||
await inTenant(c, wsA, async () => {
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsA, userA]);
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Editor')", [wsA, editorA]);
|
||||
draftA = (await c.query("INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, $3::jsonb) RETURNING id", [wsA, userA, JSON.stringify({ body: "x" })])).rows[0].id as string;
|
||||
topicA = (await c.query(
|
||||
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name, account_ids, icon_color)
|
||||
VALUES ($1, $2, '-100200', '7', 'announce', 'Ops', $3, 7322096) RETURNING id`,
|
||||
[wsA, connA1, [connA1]],
|
||||
)).rows[0].id as string;
|
||||
// Seed rows for the RLS denial suite (A-side data B must not see).
|
||||
const poll = (await c.query(
|
||||
`INSERT INTO polls (workspace_id, channel_id, topic_binding_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by)
|
||||
VALUES ($1, NULL, $2, 'draft', $3, 'ship it?', $4::jsonb, 'pv-seed', now() + interval '1 hour', $5) RETURNING id`,
|
||||
[wsA, topicA, draftA, JSON.stringify({ version: 1, mode: "quorum", arms: [{ kind: "admins", x: 1 }], timebox: { duration: "PT1H", onExpire: "reject" } }), userA],
|
||||
)).rows[0].id as string;
|
||||
await c.query(
|
||||
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot)
|
||||
VALUES ($1, $2, 'chat_identity', $3, '424242', 'opal', 'yes')`,
|
||||
[poll, wsA, userA],
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO chat_identities (workspace_id, provider, remote_user_id, user_id, display_name, status, approved_by)
|
||||
VALUES ($1, 'telegram', '111222333', $2, 'Opal', 'approved', $2)`,
|
||||
[wsA, userA],
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO chat_command_audit (workspace_id, update_id, binding_id, provider, remote_user_id, user_id, command, args, permission, decision)
|
||||
VALUES ($1, 9001, $2, 'telegram', '111222333', $3, '/tally', '{}'::jsonb, 'director.view', 'accepted')`,
|
||||
[wsA, topicA, userA],
|
||||
);
|
||||
// one row of every remaining table so the RLS sweep counts real data
|
||||
await c.query(
|
||||
`INSERT INTO posting_rules (workspace_id, scope, policy, policy_version, created_by)
|
||||
VALUES ($1, 'workspace', $2::jsonb, 'pv-seed', $3)`,
|
||||
[wsA, JSON.stringify({ version: 1, mode: "immediate" }), userA],
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, topic_binding_id, content_type, effective, policy_version, resolved_from)
|
||||
VALUES ($1, 'account', $2, NULL, '', $3::jsonb, 'pv-seed', '{"scopes":["workspace"]}'::jsonb)`,
|
||||
[wsA, connA1, JSON.stringify({ version: 1, mode: "immediate" })],
|
||||
);
|
||||
await c.query(
|
||||
`INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor)
|
||||
VALUES ($1, $2, $3::jsonb, 'E1', 'pv-seed', 'fired', '{}'::jsonb, '{}', 'system:gate-e1')`,
|
||||
[wsA, draftA, JSON.stringify({ platform: "telegram", accountId: connA1 })],
|
||||
);
|
||||
});
|
||||
await inTenant(c, wsB, async () => {
|
||||
const ownerB = await user("owner-b");
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsB, ownerB]);
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ownerPool !== undefined) {
|
||||
await asOwner(async (c) => {
|
||||
await c.query("DELETE FROM workspaces WHERE slug LIKE $1", [`%-${suffix}`]);
|
||||
await c.query("DELETE FROM users WHERE external_id LIKE $1", [`cg:%:${suffix}`]);
|
||||
});
|
||||
await ownerPool.end();
|
||||
}
|
||||
await m?.db.pool.end();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("migration + RBAC seed", () => {
|
||||
it("idempotent: a second run applies nothing", async () => {
|
||||
const { runMigrations } = await import("../db/migrate.js");
|
||||
const previous = process.env.SUPER_DATABASE_URL;
|
||||
process.env.SUPER_DATABASE_URL = ownerUrl;
|
||||
try {
|
||||
expect(await runMigrations(join(process.cwd(), "migrations"))).toEqual([]);
|
||||
} finally {
|
||||
process.env.SUPER_DATABASE_URL = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("chatgov permissions are seeded per the 002 pattern", async () => {
|
||||
const rows = await asOwner((c) => c.query<{ role: string; permission: string }>(
|
||||
"SELECT role, permission FROM role_permissions WHERE permission LIKE 'chatgov%' ORDER BY role, permission"),
|
||||
);
|
||||
const map = new Map<string, string[]>();
|
||||
for (const row of rows.rows) {
|
||||
map.set(row.role, [...(map.get(row.role) ?? []), row.permission]);
|
||||
}
|
||||
expect(map.get("Owner")).toEqual(["chatgov.manage", "chatgov.vote"]);
|
||||
expect(map.get("Admin")).toEqual(["chatgov.manage", "chatgov.vote"]);
|
||||
for (const role of ["Publisher", "Editor", "Approver", "Moderator", "BroadcastOperator"]) {
|
||||
expect(map.get(role)).toEqual(["chatgov.vote"]);
|
||||
}
|
||||
for (const role of ["Analyst", "VideoPublisher", "StreamOperator"]) {
|
||||
expect(map.has(role)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("every new table is RLS-enabled AND forced with a tenant_isolation policy", async () => {
|
||||
for (const table of TABLES) {
|
||||
const flags = await asOwner((c) => c.query<{ relrowsecurity: boolean; relforcerowsecurity: boolean }>(
|
||||
"SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = $1::regclass", [table]));
|
||||
expect(flags.rows[0], table).toEqual({ relrowsecurity: true, relforcerowsecurity: true });
|
||||
const policies = await asOwner((c) => c.query<{ policyname: string }>(
|
||||
"SELECT policyname FROM pg_policies WHERE tablename = $1", [table]));
|
||||
expect(policies.rows.map((r) => r.policyname), table).toContain("tenant_isolation");
|
||||
}
|
||||
});
|
||||
|
||||
it("sp_app holds table grants (and is neither superuser nor BYPASSRLS)", async () => {
|
||||
for (const table of TABLES) {
|
||||
const grants = await asOwner((c) => c.query<{ privilege_type: string }>(
|
||||
"SELECT privilege_type FROM information_schema.role_table_grants WHERE grantee = 'sp_app' AND table_name = $1", [table]));
|
||||
expect(new Set(grants.rows.map((r) => r.privilege_type)), table)
|
||||
.toEqual(new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]));
|
||||
}
|
||||
const role = await asOwner((c) => c.query<{ rolsuper: boolean; rolbypassrls: boolean }>(
|
||||
"SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'sp_app'"));
|
||||
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
|
||||
});
|
||||
});
|
||||
|
||||
/** Fresh workspace with its own connections/topic (isolated rule slots). */
|
||||
async function seedWorkspace(name: string): Promise<{ ws: string; conn1: string; conn2: string; topic: string }> {
|
||||
return asOwner(async (c) => {
|
||||
const ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [`cg-${name}`, `cg-${name}-${suffix}`])).rows[0].id as string;
|
||||
let conn1 = "";
|
||||
let conn2 = "";
|
||||
let topic = "";
|
||||
await inTenant(c, ws, async () => {
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [ws, userA]);
|
||||
conn1 = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [ws, `ch-${name}-1`])).rows[0].id as string;
|
||||
conn2 = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [ws, `ch-${name}-2`])).rows[0].id as string;
|
||||
topic = (await c.query(
|
||||
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name, account_ids)
|
||||
VALUES ($1, $2, '-100200', '7', 'announce', 'Ops', $3) RETURNING id`,
|
||||
[ws, conn1, [conn1]])).rows[0].id as string;
|
||||
});
|
||||
return { ws, conn1, conn2, topic };
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("RLS denial suite (FORCE + NULLIF; B must see zero A rows)", () => {
|
||||
it.each(TABLES)("%s: B sees, updates and deletes none of A's rows", async (table) => {
|
||||
const seen = await m.db.withTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table} WHERE workspace_id = $1`, [wsA]));
|
||||
expect(seen.rows[0]?.n).toBe(0);
|
||||
const noFilter = await m.db.withTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
|
||||
expect(noFilter.rows[0]?.n).toBe(0);
|
||||
// A harmless mutable column per table (several have no updated_at).
|
||||
const touch: Record<(typeof TABLES)[number], string> = {
|
||||
polls: "outbox_state = outbox_state",
|
||||
poll_votes: "ballot = ballot",
|
||||
posting_rules: "dry_run = dry_run",
|
||||
posting_policy_snapshots: "policy_version = policy_version",
|
||||
topic_bindings: "name = name",
|
||||
chat_command_audit: "reason = reason",
|
||||
chat_identities: "display_name = display_name",
|
||||
gate_decisions: "actor = actor",
|
||||
};
|
||||
const updated = await m.db.withTenant(wsB, (c) => c.query(`UPDATE ${table} SET ${touch[table]} WHERE workspace_id = $1`, [wsA]));
|
||||
expect(updated.rowCount).toBe(0);
|
||||
const deleted = await m.db.withTenant(wsB, (c) => c.query(`DELETE FROM ${table} WHERE workspace_id = $1`, [wsA]));
|
||||
expect(deleted.rowCount).toBe(0);
|
||||
const ownStillThere = await m.db.withTenant(wsA, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
|
||||
expect(ownStillThere.rows[0]?.n).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("no tenant context (and an empty one) sees nothing on any table", async () => {
|
||||
for (const table of TABLES) {
|
||||
const none = await m.db.withSystem((c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
|
||||
expect(none.rows[0]?.n, table).toBe(0);
|
||||
}
|
||||
for (const table of TABLES) {
|
||||
const leaked = await m.db.withTenant(wsB, async (c) => {
|
||||
// empty-string GUC (004 pooled-session hazard) must fail closed too
|
||||
await c.query("SELECT set_config('app.workspace_id', '', true)");
|
||||
return c.query(`SELECT count(*)::int AS n FROM ${table}`);
|
||||
});
|
||||
expect(leaked.rows[0]?.n, table).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("B cannot write rows into A's workspace (WITH CHECK) on representative tables", async () => {
|
||||
const attempts: Array<[string, unknown[]]> = [
|
||||
["INSERT INTO posting_rules (workspace_id, scope, policy, policy_version, created_by) VALUES ($1, 'workspace', '{}'::jsonb, 'pv', $2)", [wsA, userA]],
|
||||
["INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor) VALUES ($1, $2, '{}'::jsonb, 'E1', 'pv', 'pending', '{}'::jsonb, '{}', 'system:gate')", [wsA, draftA]],
|
||||
["INSERT INTO chat_identities (workspace_id, provider, remote_user_id) VALUES ($1, 'telegram', '999')", [wsA]],
|
||||
["INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name) VALUES ($1, $2, '-100999', 'announce', 'Sneaky')", [wsA, connA1]],
|
||||
["INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision) VALUES ($1, 42, 'telegram', '999', '/x', 'rejected')", [wsA]],
|
||||
["INSERT INTO polls (workspace_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by) VALUES ($1, 'draft', $2, 'q?', '{}'::jsonb, 'pv', now() + interval '1 hour', $3)", [wsA, draftA, userA]],
|
||||
];
|
||||
for (const [sql, params] of attempts) {
|
||||
await expect(m.db.withTenant(wsB, (c) => c.query(sql, params)), sql).rejects.toMatchObject({ code: RLS_VIOLATION });
|
||||
}
|
||||
});
|
||||
|
||||
it("cross-tenant composite FKs: B's rows cannot point at A's parents", async () => {
|
||||
await expect(m.db.withTenant(wsB, async (c) => c.query(
|
||||
"INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name) VALUES ($1, $2, '-100888', 'announce', 'X')",
|
||||
[wsB, connA1],
|
||||
))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
await expect(m.db.withTenant(wsB, async (c) => c.query(
|
||||
"INSERT INTO posting_rules (workspace_id, scope, account_id, policy, policy_version, created_by) VALUES ($1, 'account', $2, '{}'::jsonb, 'pv', $3)",
|
||||
[wsB, connA1, userA],
|
||||
))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("rule CRUD + compile-to-snapshot", () => {
|
||||
it("create rejects a second live rule in the same slot (unique index)", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
const actor = { userId: userA, role: "Owner" };
|
||||
const body = { scope: "account", accountId: connA1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } };
|
||||
await expect(service.createRule(wsA, body, actor)).resolves.toBeTruthy();
|
||||
await expect(service.createRule(wsA, body, actor)).rejects.toMatchObject({ status: 409 });
|
||||
// a different slot is fine
|
||||
await expect(service.createRule(wsA, { ...body, accountId: connA2 }, actor)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("slot validation refuses nonsense shapes with 400s", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
const actor = { userId: userA, role: "Owner" };
|
||||
const bad: unknown[] = [
|
||||
{ scope: "workspace", accountId: connA1, policy: { mode: "immediate" } },
|
||||
{ scope: "account", policy: { mode: "immediate" } },
|
||||
{ scope: "topic", policy: { mode: "immediate" } },
|
||||
{ scope: "content_type", policy: { mode: "immediate" } },
|
||||
{ scope: "topic", topicBindingId: topicA, contentType: "alert", policy: { mode: "immediate" } },
|
||||
{ scope: "mystery", policy: { mode: "immediate" } },
|
||||
{ scope: "workspace", policy: { mode: "nope" } },
|
||||
];
|
||||
for (const body of bad) {
|
||||
await expect(service.createRule(wsA, body, actor), JSON.stringify(body)).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it("compile-to-snapshot is deterministic and precedence-correct", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
const actor = { userId: userA, role: "Owner" };
|
||||
const { ws, conn1, conn2, topic } = await seedWorkspace("compile");
|
||||
// workspace default
|
||||
await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor);
|
||||
// account override for conn1
|
||||
const accountRule = await service.createRule(ws, {
|
||||
scope: "account", accountId: conn1,
|
||||
policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
}, actor);
|
||||
// topic override (highest precedence) for the topic bound to conn1
|
||||
await service.createRule(ws, {
|
||||
scope: "topic", topicBindingId: topic,
|
||||
policy: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } },
|
||||
}, actor);
|
||||
// content_type override
|
||||
await service.createRule(ws, {
|
||||
scope: "content_type", contentType: "alert",
|
||||
policy: { mode: "admin_only", timebox: { duration: "PT30M", onExpire: "escalateOwner" } },
|
||||
}, actor);
|
||||
|
||||
const snapshots = await m.db.withTenant(ws, (c) => c.query<{
|
||||
destination_kind: string; account_id: string; topic_binding_id: string | null; content_type: string;
|
||||
effective: { mode: string }; policy_version: string;
|
||||
}>("SELECT destination_kind, account_id, topic_binding_id, content_type, effective, policy_version FROM posting_policy_snapshots ORDER BY destination_kind, content_type"));
|
||||
|
||||
const byKey = new Map(snapshots.rows.map((r) => [`${r.destination_kind}|${r.account_id}|${r.topic_binding_id ?? ""}|${r.content_type}`, r]));
|
||||
const vImmediate = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" }));
|
||||
const vAdmin1h = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } }));
|
||||
const vQuorum = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } }));
|
||||
const vAlert = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT30M", onExpire: "escalateOwner" } }));
|
||||
|
||||
// account root of connA1: account rule beats the workspace default
|
||||
expect(byKey.get(`account|${conn1}||`)?.policy_version).toBe(vAdmin1h);
|
||||
// account root of connA2 (no account rule): workspace default
|
||||
expect(byKey.get(`account|${conn2}||`)?.policy_version).toBe(vImmediate);
|
||||
// topic slot of connA1: topic rule beats account
|
||||
expect(byKey.get(`topic|${conn1}|${topic}|`)?.policy_version).toBe(vQuorum);
|
||||
// typed slot: content_type rule beats workspace default
|
||||
expect(byKey.get(`account|${conn1}||alert`)?.policy_version).toBe(vAlert);
|
||||
// whole-block replacement: the winner IS the policy (no field merges)
|
||||
expect(byKey.get(`topic|${conn1}|${topic}|`)?.effective.mode).toBe("quorum");
|
||||
expect(byKey.get(`account|${conn1}||`)?.effective).not.toHaveProperty("arms");
|
||||
|
||||
// Determinism at the DB level: an idempotent re-save with the SAME
|
||||
// policy must not change the persisted version.
|
||||
const before = byKey.get(`account|${conn1}||`)?.policy_version;
|
||||
await service.updateRule(ws, accountRule.id, { policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
|
||||
const after = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>(
|
||||
"SELECT policy_version FROM posting_policy_snapshots WHERE destination_kind = 'account' AND account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn1]));
|
||||
expect(after.rows[0]!.policy_version).toBe(before);
|
||||
// and the DB agrees with the pure compiler on the same inputs
|
||||
const compiled = m.policy.compilePolicy(
|
||||
[
|
||||
{ scope: "workspace", rule: m.policy.parsePolicyRule({ mode: "immediate" }) },
|
||||
{ scope: "account", rule: m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } }) },
|
||||
],
|
||||
{ adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 },
|
||||
);
|
||||
expect(after.rows[0]!.policy_version).toBe(compiled.policy_version);
|
||||
// the snapshot rows live in ws only: invisible from wsA (RLS)
|
||||
const inA = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
|
||||
"SELECT count(*)::int AS n FROM posting_policy_snapshots WHERE account_id = $1", [conn1]));
|
||||
expect(inA.rows[0]!.n).toBe(0);
|
||||
});
|
||||
|
||||
it("delete supersedes: the slot frees and the snapshot falls back", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
const actor = { userId: userA, role: "Owner" };
|
||||
const { ws, conn2 } = await seedWorkspace("fallback");
|
||||
await service.createRule(ws, { scope: "account", accountId: conn2, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
|
||||
const during = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>(
|
||||
"SELECT policy_version FROM posting_policy_snapshots WHERE account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn2]));
|
||||
expect(during.rows[0]!.policy_version).not.toBe(m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" })));
|
||||
|
||||
const live = await service.listRules(ws, { accountId: conn2 });
|
||||
await expect(service.deleteRule(ws, live[0]!.id, actor)).resolves.toEqual({ superseded: true });
|
||||
// slot freed: a new rule fits without conflict
|
||||
const recreated = await service.createRule(ws, { scope: "account", accountId: conn2, policy: { mode: "immediate" } }, actor);
|
||||
expect(recreated.supersededBy).toBeNull();
|
||||
const after = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>(
|
||||
"SELECT policy_version FROM posting_policy_snapshots WHERE account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn2]));
|
||||
expect(after.rows[0]!.policy_version).toBe(m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" })));
|
||||
// superseded rows survive (lineage) but never win
|
||||
const visible = await service.listRules(ws, { accountId: conn2 });
|
||||
expect(visible).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("listRules filters by account/topic and hides superseded rows", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
const actor = { userId: userA, role: "Owner" };
|
||||
const { ws, conn1, topic } = await seedWorkspace("listing");
|
||||
await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor);
|
||||
await service.createRule(ws, { scope: "account", accountId: conn1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
|
||||
await service.createRule(ws, { scope: "topic", topicBindingId: topic, policy: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } } }, actor);
|
||||
const all = await service.listRules(ws);
|
||||
expect(all.every((r) => r.supersededBy === null)).toBe(true);
|
||||
expect(all).toHaveLength(3);
|
||||
const forTopic = await service.listRules(ws, { topicBindingId: topic });
|
||||
expect(forTopic.map((r) => r.scope)).toEqual(["topic"]);
|
||||
const forAccount = await service.listRules(ws, { accountId: conn1 });
|
||||
expect(forAccount.map((r) => r.scope)).toEqual(["account"]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("gate_decisions writer", () => {
|
||||
it("persists outcomes with codes + quorum state and audits them", async () => {
|
||||
const service = new m.gate.GateService();
|
||||
const decision = await service.record(wsA, {
|
||||
draftId: draftA,
|
||||
destination: { platform: "telegram", accountId: connA1 },
|
||||
gate: "E2",
|
||||
policyVersion: "pv-e2",
|
||||
outcome: "blocked",
|
||||
inputs: { arms: [{ kind: "admins", got: 1, needed: 2 }, { kind: "participants", got: 3, needed: 5 }], pollState: "closed" },
|
||||
reasonCodes: ["INSUFFICIENT_QUORUM"],
|
||||
actor: "chat_poll:abc",
|
||||
});
|
||||
expect(decision.outcome).toBe("blocked");
|
||||
expect(decision.reasonCodes).toEqual(["INSUFFICIENT_QUORUM"]);
|
||||
expect(decision.inputs.arms).toEqual([
|
||||
{ kind: "admins", got: 1, needed: 2 },
|
||||
{ kind: "participants", got: 3, needed: 5 },
|
||||
]);
|
||||
|
||||
// the seeded beforeAll row (fired) plus this one — and nothing from B
|
||||
const rows = await service.listForDraft(wsA, draftA);
|
||||
expect(rows.map((r) => r.id)).toContain(decision.id);
|
||||
expect(rows.every((r) => r.workspaceId === wsA)).toBe(true);
|
||||
|
||||
// audit landed: policy.gate on the tenant chain
|
||||
const audited = await m.db.withTenant(wsA, (c) => c.query<{ action: string; target_type: string; target_id: string; diff: { outcome?: string } }>(
|
||||
"SELECT action, target_type, target_id, diff FROM audit_log WHERE action = 'policy.gate' AND target_id = $1", [draftA]));
|
||||
expect(audited.rows).toHaveLength(1);
|
||||
expect(audited.rows[0]!.target_type).toBe("draft");
|
||||
expect(audited.rows[0]!.diff.outcome).toBe("blocked");
|
||||
|
||||
// resolve: pending -> fired in place (one row per evaluation)
|
||||
const pending = await service.record(wsA, {
|
||||
draftId: draftA, destination: { platform: "telegram", accountId: connA2 }, gate: "E3",
|
||||
policyVersion: "pv-e3", outcome: "pending", inputs: { approvals: 0 }, reasonCodes: ["PENDING_POLL"], actor: "user:x",
|
||||
});
|
||||
const resolved = await service.resolve(wsA, pending.id, "fired", [], "system:gate-e3");
|
||||
expect(resolved.outcome).toBe("fired");
|
||||
expect(resolved.id).toBe(pending.id);
|
||||
const audited2 = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
|
||||
"SELECT count(*)::int AS n FROM audit_log WHERE action = 'policy.gate.resolved'"));
|
||||
expect(audited2.rows[0]!.n).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses free-text reason codes (vocabulary is closed)", async () => {
|
||||
const service = new m.gate.GateService();
|
||||
await expect(service.record(wsA, {
|
||||
draftId: draftA, destination: { platform: "telegram", accountId: connA1 }, gate: "E1",
|
||||
policyVersion: "pv", outcome: "blocked", inputs: {}, reasonCodes: ["because i said so"] as never[], actor: "user:x",
|
||||
})).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it("RLS: B's draft has none of A's decisions and vice versa", async () => {
|
||||
const service = new m.gate.GateService();
|
||||
// seed a B-side draft
|
||||
const draftB = await asOwner(async (c) => {
|
||||
const ownerB = (await c.query<{ id: string }>("SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.workspace_id = $1 AND m.role = 'Owner' LIMIT 1", [wsB])).rows[0]!.id;
|
||||
let id = "";
|
||||
await inTenant(c, wsB, async () => {
|
||||
id = (await c.query("INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, '{}'::jsonb) RETURNING id", [wsB, ownerB])).rows[0].id as string;
|
||||
});
|
||||
return id;
|
||||
});
|
||||
await service.record(wsB, { draftId: draftB, destination: { platform: "telegram", accountId: connB }, gate: "E1", policyVersion: "pv", outcome: "fired", inputs: {}, reasonCodes: [], actor: "user:y" });
|
||||
const forA = await service.listForDraft(wsA, draftA);
|
||||
expect(forA.every((d) => d.workspaceId === wsA)).toBe(true);
|
||||
const forB = await service.listForDraft(wsB, draftB);
|
||||
expect(forB.every((d) => d.workspaceId === wsB)).toBe(true);
|
||||
expect(forB.map((d) => d.destination.accountId)).toEqual([connB]);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("audit chain", () => {
|
||||
it("policy.revised lands for create/update/delete (same transaction)", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
const actor = { userId: userA, role: "Owner" };
|
||||
const { ws } = await seedWorkspace("audit");
|
||||
const created = await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor);
|
||||
await service.updateRule(ws, created.id, { policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
|
||||
await service.deleteRule(ws, created.id, actor);
|
||||
|
||||
const events = await m.db.withTenant(ws, (c) => c.query<{ action: string; diff: { action?: string } }>(
|
||||
"SELECT action, diff FROM audit_log WHERE action = 'policy.revised' ORDER BY seq"));
|
||||
expect(events.rows.map((r) => r.diff.action)).toEqual(["create", "update", "delete"]);
|
||||
// tamper-evident chain still verifies with the new events
|
||||
const ok = await m.db.withTenant(ws, (c) => c.query<{ ok: boolean }>("SELECT audit_verify_chain($1) AS ok", [ws]));
|
||||
expect(ok.rows[0]?.ok).toBe(true);
|
||||
// the live view is empty in the same transaction's aftermath
|
||||
const live = await service.listRules(ws);
|
||||
expect(live).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("RBAC permission gating", () => {
|
||||
it("service refuses non-privileged callers without chatgov.manage", async () => {
|
||||
const service = new m.policyService.PolicyService();
|
||||
// Editor holds chatgov.vote but NOT chatgov.manage
|
||||
const editor = { userId: userA, role: "Editor", permissions: ["chatgov.vote", "workspace.view"] };
|
||||
await expect(service.createRule(wsA, { scope: "workspace", policy: { mode: "immediate" } }, editor))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
// the seeded permission matrix agrees
|
||||
const matrix = await asOwner((c) => c.query<{ role: string }>(
|
||||
"SELECT role FROM role_permissions WHERE permission = 'chatgov.manage'"));
|
||||
expect(matrix.rows.map((r) => r.role).sort()).toEqual(["Admin", "Owner"]);
|
||||
});
|
||||
|
||||
it("RbacGuard enforces chatgov.manage on the controller route (Editor 403, Admin 200)", async () => {
|
||||
const { Reflector } = await import("@nestjs/core");
|
||||
const guard = new m.guard.RbacGuard(new Reflector());
|
||||
const editor = (await asOwner((c) => c.query<{ id: string }>(
|
||||
"SELECT u.id FROM users u WHERE u.external_id = $1", [`cg:editor:${suffix}`]))).rows[0]!.id;
|
||||
const epoch = await asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0]!.authz_epoch));
|
||||
const token = (userId: string, role: string): string => m.session.issueSession({ userId, workspaceId: wsA, role, authzEpoch: epoch });
|
||||
const ctx = (t: string) => ({
|
||||
getHandler: () => {
|
||||
const handler = (): void => undefined;
|
||||
Reflect.defineMetadata(m.guard.PERMISSION_KEY, "chatgov.manage", handler);
|
||||
return handler;
|
||||
},
|
||||
switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: `Bearer ${t}` }, params: {} }) }),
|
||||
}) as unknown as import("@nestjs/common").ExecutionContext;
|
||||
await expect(guard.canActivate(ctx(token(editor, "Editor")))).rejects.toMatchObject({ status: 403 });
|
||||
await expect(guard.canActivate(ctx(token(userA, "Admin")))).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("invariants: one open poll per draft, vote idempotency, replay dedupe", () => {
|
||||
it("a second OPEN poll for the same draft is refused; a closed one frees the gate", async () => {
|
||||
const openPoll = async (): Promise<string> => (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
|
||||
`INSERT INTO polls (workspace_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by)
|
||||
VALUES ($1, 'draft', $2, 'again?', '{}'::jsonb, 'pv2', now() + interval '30 minutes', $3) RETURNING id`,
|
||||
[wsA, draftA, userA]))).rows[0]!.id;
|
||||
// the beforeAll seed already holds an OPEN poll for draftA
|
||||
await expect(m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
|
||||
`SELECT id FROM polls WHERE subject_kind = 'draft' AND draft_id = $1 AND state = 'open'`, [draftA]))).resolves.toBeTruthy();
|
||||
await expect(openPoll()).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
// close it (the poll service P2 owns the real transition; here: direct)
|
||||
const existing = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
|
||||
"SELECT id FROM polls WHERE draft_id = $1 AND state = 'open'", [draftA]))).rows[0]!.id;
|
||||
await m.db.withTenant(wsA, (c) => c.query(
|
||||
"UPDATE polls SET state = 'failed', closed_at = now(), close_reason = 'timebox', outbox_state = 'done' WHERE id = $1", [existing]));
|
||||
await expect(openPoll()).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("revote lineage: parent_poll_id must stay inside the workspace (composite FK)", async () => {
|
||||
const parent = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
|
||||
"SELECT id FROM polls WHERE workspace_id = $1 ORDER BY created_at LIMIT 1", [wsA]))).rows[0]!.id;
|
||||
const ownerB = (await asOwner((c) => c.query<{ id: string }>(
|
||||
"SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.workspace_id = $1 AND m.role = 'Owner' LIMIT 1", [wsB]))).rows[0]!.id;
|
||||
await expect(m.db.withTenant(wsB, (c) => c.query(
|
||||
`INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by, parent_poll_id)
|
||||
VALUES ($1, 'none', 'q', '{}'::jsonb, 'pv', now() + interval '1 hour', $2, $3)`,
|
||||
[wsB, ownerB, parent]))).rejects.toMatchObject({ code: FK_VIOLATION });
|
||||
});
|
||||
|
||||
it("poll_votes: one ballot per (poll, voter) — recast replaces, never duplicates", async () => {
|
||||
const poll = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
|
||||
`INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by)
|
||||
VALUES ($1, 'none', 'ballot test?', '{}'::jsonb, 'pv-b', now() + interval '1 hour', $2) RETURNING id`,
|
||||
[wsA, userA]))).rows[0]!.id;
|
||||
const cast = (ballot: string): Promise<unknown> => m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot)
|
||||
VALUES ($1, $2, 'chat_identity', '777', 'someone', $3)`,
|
||||
[poll, wsA, ballot]));
|
||||
await cast("no");
|
||||
await expect(cast("yes")).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
await m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot)
|
||||
VALUES ($1, $2, 'chat_identity', '777', 'someone', 'yes')
|
||||
ON CONFLICT (poll_id, voter_remote_id) DO UPDATE SET ballot = EXCLUDED.ballot, cast_at = now()`,
|
||||
[poll, wsA]));
|
||||
const rows = await m.db.withTenant(wsA, (c) => c.query<{ ballot: string; count: string }>(
|
||||
"SELECT ballot, count(*) AS count FROM poll_votes WHERE poll_id = $1 GROUP BY ballot", [poll]));
|
||||
expect(rows.rows).toEqual([{ ballot: "yes", count: "1" }]);
|
||||
});
|
||||
|
||||
it("ineligible ballots are stored but not counted", async () => {
|
||||
const poll = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
|
||||
`INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by)
|
||||
VALUES ($1, 'none', 'eligibility?', '{}'::jsonb, 'pv-c', now() + interval '1 hour', $2) RETURNING id`,
|
||||
[wsA, userA]))).rows[0]!.id;
|
||||
await m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot, eligible, counted, ineligible_reason)
|
||||
VALUES ($1, $2, 'chat_identity', '888', 'rando', 'yes', false, false, 'INELIGIBLE_ACTOR')`,
|
||||
[poll, wsA]));
|
||||
const counted = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
|
||||
"SELECT count(*)::int AS n FROM poll_votes WHERE poll_id = $1 AND counted", [poll]));
|
||||
expect(counted.rows[0]!.n).toBe(0);
|
||||
const stored = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
|
||||
"SELECT count(*)::int AS n FROM poll_votes WHERE poll_id = $1", [poll]));
|
||||
expect(stored.rows[0]!.n).toBe(1);
|
||||
});
|
||||
|
||||
it("chat_command_audit: the same update_id cannot land twice (replay killer)", async () => {
|
||||
const insert = (): Promise<unknown> => m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision)
|
||||
VALUES ($1, 777001, 'telegram', '111222333', '/tally', 'accepted')`,
|
||||
[wsA]));
|
||||
await insert();
|
||||
await expect(insert()).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
// a different workspace may reuse the provider's update id
|
||||
await expect(m.db.withTenant(wsB, (c) => c.query(
|
||||
`INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision)
|
||||
VALUES ($1, 777001, 'telegram', '1', '/tally', 'accepted')`,
|
||||
[wsB]))).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("chat_identities: approval requires an approver; one approved link per user", async () => {
|
||||
await expect(m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO chat_identities (workspace_id, provider, remote_user_id, user_id, status)
|
||||
VALUES ($1, 'telegram', '555000111', $2, 'approved')`,
|
||||
[wsA, userA]))).rejects.toThrow(/chat_identities_check/);
|
||||
const second = await m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO chat_identities (workspace_id, provider, remote_user_id, display_name) VALUES ($1, 'telegram', '555000112', 'pending one') RETURNING id`,
|
||||
[wsA]));
|
||||
expect(second.rows).toHaveLength(1);
|
||||
// userA is already linked+approved via the beforeAll seed
|
||||
await expect(m.db.withTenant(wsA, (c) => c.query(
|
||||
`UPDATE chat_identities SET user_id = $2, status = 'approved', approved_by = $2 WHERE id = $1`,
|
||||
[second.rows[0]!.id, userA]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
});
|
||||
|
||||
it("topic_bindings: one control binding per virtual channel; slot uniqueness holds", async () => {
|
||||
await expect(m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name)
|
||||
VALUES ($1, $2, '-100200', '7', 'control', 'Dup slot')`,
|
||||
[wsA, connA1]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
const vc = await asOwner(async (c) => {
|
||||
let id = "";
|
||||
await inTenant(c, wsA, async () => {
|
||||
id = (await c.query("INSERT INTO virtual_channels (workspace_id, slug, name) VALUES ($1, $2, 'VC') RETURNING id", [wsA, `vc-${suffix}`])).rows[0].id as string;
|
||||
});
|
||||
return id;
|
||||
});
|
||||
await m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name, linked_channel_id)
|
||||
VALUES ($1, $2, '-100300', 'control', 'Cmd', $3)`,
|
||||
[wsA, connA1, vc]));
|
||||
await expect(m.db.withTenant(wsA, (c) => c.query(
|
||||
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name, linked_channel_id)
|
||||
VALUES ($1, $2, '-100301', 'control', 'Cmd2', $3)`,
|
||||
[wsA, connA1, vc]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
|
||||
});
|
||||
});
|
||||
});
|
||||
27
apps/api/src/chatgov/chatgov.module.ts
Normal file
27
apps/api/src/chatgov/chatgov.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { PollTimeboxService } from "./poll-timebox.service.js";
|
||||
import { PollService } from "./poll.service.js";
|
||||
import { PollController } from "./poll.controller.js";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PolicyService } from "./policy.service.js";
|
||||
import { GateService } from "./gate.service.js";
|
||||
import { ChatgovController } from "./chatgov.controller.js";
|
||||
|
||||
/**
|
||||
* Chat governance P0 spine (chatgov-design.md §1d).
|
||||
*
|
||||
* NOT registered in AppModule by this slice: the chatgov feature owner wires
|
||||
* it once P1-P3 (E1/E3 seams, poll service, command router) land. Register
|
||||
* with one line in apps/api/src/app.module.ts:
|
||||
*
|
||||
* imports: [..., ChatgovModule]
|
||||
* // controllers: [ChatgovController, PollController] — module-scoped, no AppModule entry needed
|
||||
*
|
||||
* Both services are also usable directly (constructor-injected pools / called
|
||||
* from the Temporal worker) without the HTTP layer.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ChatgovController, PollController],
|
||||
providers: [PolicyService, GateService, PollService, PollTimeboxService],
|
||||
exports: [PolicyService, GateService, PollService],
|
||||
})
|
||||
export class ChatgovModule {}
|
||||
283
apps/api/src/chatgov/gate.service.ts
Normal file
283
apps/api/src/chatgov/gate.service.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { BadRequestException, 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 } from "../http/validation.js";
|
||||
import { GATES, REASON_CODES, type Gate, type ReasonCode } from "./policy.js";
|
||||
|
||||
/**
|
||||
* gate_decisions writer (chatgov-design.md §1d step 5, §2, P0).
|
||||
*
|
||||
* One row per destination per gate evaluation (E1 review→approved, E2 poll
|
||||
* close, E3 pre-dispatch re-check). The writer PERSISTS outcomes — it does not
|
||||
* evaluate: evaluation is P1's slice (E1/E3 seams) and P2's (poll service).
|
||||
* Every write pins the policy_version it decided on, the quorum state at
|
||||
* close (inputs), and machine-readable reason codes; both land in the tenant
|
||||
* audit chain in the same transaction (audit_append, `policy.gate` action).
|
||||
*
|
||||
* Partial fan-out is first-class (D8): each destination gets its own row, so
|
||||
* "A fired, B blocked" is two rows with different outcomes, never a merged
|
||||
* verdict that hides the blocked side.
|
||||
*/
|
||||
|
||||
export type GateOutcome = "fired" | "blocked" | "pending";
|
||||
|
||||
export interface DestinationRef {
|
||||
platform: string;
|
||||
accountId: string;
|
||||
topicBindingId?: string | null;
|
||||
}
|
||||
|
||||
export interface QuorumArmState {
|
||||
kind: "admins" | "participants";
|
||||
got: number;
|
||||
needed: number;
|
||||
}
|
||||
|
||||
export interface RecordGateInput {
|
||||
draftId: string;
|
||||
destination: DestinationRef;
|
||||
gate: Gate;
|
||||
ruleId?: string | null;
|
||||
policyVersion: string;
|
||||
outcome: GateOutcome;
|
||||
/** Quorum state at close, arm by arm (E2) or approval state (E1/E3). */
|
||||
inputs: {
|
||||
arms?: QuorumArmState[];
|
||||
approvals?: number;
|
||||
approvedHash?: string;
|
||||
pollState?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
reasonCodes: readonly ReasonCode[];
|
||||
actor: string;
|
||||
pollId?: string | null;
|
||||
}
|
||||
|
||||
export interface GateDecisionRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
destination: DestinationRef;
|
||||
gate: Gate;
|
||||
ruleId: string | null;
|
||||
policyVersion: string;
|
||||
outcome: GateOutcome;
|
||||
inputs: RecordGateInput["inputs"];
|
||||
reasonCodes: string[];
|
||||
actor: string;
|
||||
pollId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DecisionRow {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
draft_id: string;
|
||||
destination: DestinationRef;
|
||||
gate: Gate;
|
||||
rule_id: string | null;
|
||||
policy_version: string;
|
||||
outcome: GateOutcome;
|
||||
inputs: RecordGateInput["inputs"];
|
||||
reason_codes: string[];
|
||||
actor: string;
|
||||
poll_id: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
function fromRow(row: DecisionRow): GateDecisionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
draftId: row.draft_id,
|
||||
destination: row.destination,
|
||||
gate: row.gate,
|
||||
ruleId: row.rule_id,
|
||||
policyVersion: row.policy_version,
|
||||
outcome: row.outcome,
|
||||
inputs: row.inputs,
|
||||
reasonCodes: row.reason_codes ?? [],
|
||||
actor: row.actor,
|
||||
pollId: row.poll_id,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Free-text reason codes are rejected: only the §4 vocabulary is stored, so
|
||||
* the blocked queue's reason chips stay exhaustive and translatable. */
|
||||
function parseReasonCodes(values: readonly unknown[]): ReasonCode[] {
|
||||
const allowed: readonly string[] = REASON_CODES;
|
||||
const out: ReasonCode[] = [];
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new BadRequestException(`reason code must be one of: ${allowed.join(", ")}`);
|
||||
}
|
||||
if (!out.includes(value as ReasonCode)) out.push(value as ReasonCode);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GateService {
|
||||
/** Persist one gate outcome (+ audit row, same transaction). */
|
||||
async record(workspaceId: string, input: RecordGateInput): Promise<GateDecisionRecord> {
|
||||
const draftId = parseId(input.draftId, "draftId");
|
||||
const policyVersion = requireVersion(input.policyVersion);
|
||||
const actor = requireActor(input.actor);
|
||||
if (!GATES.includes(input.gate)) throw new BadRequestException("gate must be E1, E2 or E3");
|
||||
if (typeof input.destination !== "object" || input.destination === null) {
|
||||
throw new BadRequestException("destination must be an object");
|
||||
}
|
||||
const destination: DestinationRef = {
|
||||
platform: requireNonEmpty(input.destination.platform, "destination.platform"),
|
||||
accountId: parseId(input.destination.accountId, "destination.accountId"),
|
||||
...(input.destination.topicBindingId === undefined || input.destination.topicBindingId === null
|
||||
? {}
|
||||
: { topicBindingId: parseId(input.destination.topicBindingId, "destination.topicBindingId") }),
|
||||
};
|
||||
if (typeof input.inputs !== "object" || input.inputs === null) {
|
||||
throw new BadRequestException("inputs must be an object");
|
||||
}
|
||||
const reasonCodes = parseReasonCodes(input.reasonCodes);
|
||||
if (input.outcome !== "fired" && input.outcome !== "blocked" && input.outcome !== "pending") {
|
||||
throw new BadRequestException("outcome must be fired, blocked or pending");
|
||||
}
|
||||
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const id = await this.insert(client, workspaceId, {
|
||||
draftId,
|
||||
destination,
|
||||
gate: input.gate,
|
||||
ruleId: input.ruleId === undefined || input.ruleId === null ? null : parseId(input.ruleId, "ruleId"),
|
||||
policyVersion,
|
||||
outcome: input.outcome,
|
||||
inputs: input.inputs,
|
||||
reasonCodes,
|
||||
actor,
|
||||
pollId: input.pollId === undefined || input.pollId === null ? null : parseId(input.pollId, "pollId"),
|
||||
});
|
||||
const row = (await client.query<DecisionRow>("SELECT * FROM gate_decisions WHERE id = $1", [id])).rows[0]!;
|
||||
// Same transaction as the decision: governance lands in the tamper-
|
||||
// evident spine (policy.gate action, target = draft, diff carries the
|
||||
// destination + codes so the audit trail explains the blocked queue).
|
||||
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
|
||||
workspaceId,
|
||||
null,
|
||||
"sp_api",
|
||||
"policy.gate",
|
||||
"draft",
|
||||
draftId,
|
||||
scrub({
|
||||
gate: input.gate,
|
||||
destination,
|
||||
outcome: input.outcome,
|
||||
reasonCodes,
|
||||
policyVersion,
|
||||
pollId: row.poll_id,
|
||||
inputs: input.inputs,
|
||||
}),
|
||||
]);
|
||||
return fromRow(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Pending → fired/blocked transition for an existing decision row. */
|
||||
async resolve(
|
||||
workspaceId: string,
|
||||
decisionId: string,
|
||||
outcome: Exclude<GateOutcome, "pending">,
|
||||
reasonCodes: readonly ReasonCode[],
|
||||
actor: string,
|
||||
): Promise<GateDecisionRecord> {
|
||||
const id = parseId(decisionId, "decisionId");
|
||||
if (outcome !== "fired" && outcome !== "blocked") throw new BadRequestException("outcome must be fired or blocked");
|
||||
const codes = parseReasonCodes(reasonCodes);
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const before = (await client.query<DecisionRow>("SELECT * FROM gate_decisions WHERE id = $1 FOR UPDATE", [id])).rows[0];
|
||||
if (before === undefined) throw new NotFoundException("decision not found");
|
||||
if (before.outcome !== "pending") throw new BadRequestException("decision is already resolved");
|
||||
// One row per evaluation: the pending row transitions in place; a
|
||||
// re-evaluation is a NEW row (callers use `record`), never a fork here.
|
||||
await client.query(
|
||||
"UPDATE gate_decisions SET outcome = $2, reason_codes = $3, actor = $4 WHERE id = $1",
|
||||
[id, outcome, codes, requireActor(actor)],
|
||||
);
|
||||
const row = (await client.query<DecisionRow>("SELECT * FROM gate_decisions WHERE id = $1", [id])).rows[0]!;
|
||||
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
|
||||
workspaceId,
|
||||
null,
|
||||
"sp_api",
|
||||
"policy.gate.resolved",
|
||||
"draft",
|
||||
before.draft_id,
|
||||
scrub({ gate: before.gate, decisionId: id, outcome, reasonCodes: codes }),
|
||||
]);
|
||||
return fromRow(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Blocked-queue / draft-timeline read (P1/P6 consume this). */
|
||||
async listForDraft(workspaceId: string, draftId: string): Promise<GateDecisionRecord[]> {
|
||||
const id = parseId(draftId, "draftId");
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const rows = await client.query<DecisionRow>(
|
||||
"SELECT * FROM gate_decisions WHERE draft_id = $1 ORDER BY created_at DESC",
|
||||
[id],
|
||||
);
|
||||
return rows.rows.map(fromRow);
|
||||
});
|
||||
}
|
||||
|
||||
private async insert(
|
||||
client: PoolClient,
|
||||
workspaceId: string,
|
||||
args: {
|
||||
draftId: string;
|
||||
destination: DestinationRef;
|
||||
gate: Gate;
|
||||
ruleId: string | null;
|
||||
policyVersion: string;
|
||||
outcome: GateOutcome;
|
||||
inputs: RecordGateInput["inputs"];
|
||||
reasonCodes: string[];
|
||||
actor: string;
|
||||
pollId: string | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
const res = await client.query(
|
||||
`INSERT INTO gate_decisions
|
||||
(workspace_id, draft_id, destination, gate, rule_id, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8::jsonb, $9, $10, $11) RETURNING id`,
|
||||
[
|
||||
workspaceId,
|
||||
args.draftId,
|
||||
JSON.stringify(args.destination),
|
||||
args.gate,
|
||||
args.ruleId,
|
||||
args.policyVersion,
|
||||
args.outcome,
|
||||
JSON.stringify(args.inputs),
|
||||
args.reasonCodes,
|
||||
args.actor,
|
||||
args.pollId,
|
||||
],
|
||||
);
|
||||
return res.rows[0]!.id as string;
|
||||
}
|
||||
}
|
||||
|
||||
function requireVersion(value: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new BadRequestException("policyVersion must be a non-empty string");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireActor(value: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new BadRequestException("actor must be a non-empty string");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new BadRequestException(`${field} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
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 };
|
||||
173
apps/api/src/chatgov/policy.test.ts
Normal file
173
apps/api/src/chatgov/policy.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalJson,
|
||||
compilePolicy,
|
||||
parsePolicyRule,
|
||||
policyHash,
|
||||
resolvePrecedence,
|
||||
type QuorumStateInput,
|
||||
type RuleCandidate,
|
||||
} from "./policy.js";
|
||||
|
||||
/**
|
||||
* P0 acceptance (design §7): "policy compile is pure + snapshot hash
|
||||
* deterministic". Pure-function tests — no DB, no clock.
|
||||
*/
|
||||
|
||||
const quorum: QuorumStateInput = { adminCount: 3, observedParticipants: 12, eligibleParticipants: 8 };
|
||||
|
||||
const immediate = { version: 1, mode: "immediate" as const };
|
||||
const adminOnly = { version: 1, mode: "admin_only" as const, timebox: { duration: "PT1H", onExpire: "reject" as const } };
|
||||
const quorumRule = {
|
||||
version: 1,
|
||||
mode: "quorum" as const,
|
||||
arms: [
|
||||
{ kind: "admins" as const, x: 2 },
|
||||
{ kind: "participants" as const, x: 3, of: "observed" as const },
|
||||
],
|
||||
join: "all" as const,
|
||||
allowVeto: true,
|
||||
timebox: { duration: "PT2H", onExpire: "reject" as const },
|
||||
};
|
||||
|
||||
describe("canonicalJson", () => {
|
||||
it("sorts keys and drops undefined, so key order never changes the hash input", () => {
|
||||
expect(canonicalJson({ b: 1, a: 2 })).toBe(canonicalJson({ a: 2, b: 1 }));
|
||||
expect(canonicalJson({ a: undefined, b: 1 })).toBe(canonicalJson({ b: 1 }));
|
||||
expect(canonicalJson({ a: [1, { y: 2, x: 1 }] })).toBe('{"a":[1,{"x":1,"y":2}]}');
|
||||
});
|
||||
it("keeps array order (order is data)", () => {
|
||||
expect(canonicalJson({ a: [2, 1] })).not.toBe(canonicalJson({ a: [1, 2] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyHash determinism", () => {
|
||||
it("same rule (any key order / explicit defaults) => same hash", () => {
|
||||
const a = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } });
|
||||
const b = parsePolicyRule({
|
||||
timebox: { onExpire: "reject", duration: "PT2H" },
|
||||
arms: [{ x: 2, kind: "admins" }],
|
||||
mode: "quorum",
|
||||
join: "all",
|
||||
allowVeto: false,
|
||||
anonymity: "named",
|
||||
allowSelfApproval: false,
|
||||
resetOnChange: true,
|
||||
dryRun: false,
|
||||
minDwellSeconds: 0,
|
||||
});
|
||||
expect(policyHash(a)).toBe(policyHash(b));
|
||||
});
|
||||
it("different policy => different hash", () => {
|
||||
expect(policyHash(parsePolicyRule({ mode: "immediate" })))
|
||||
.not.toBe(policyHash(parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } })));
|
||||
const withVeto = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], allowVeto: true, timebox: { duration: "PT1H", onExpire: "reject" } });
|
||||
const noVeto = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], allowVeto: false, timebox: { duration: "PT1H", onExpire: "reject" } });
|
||||
expect(policyHash(withVeto)).not.toBe(policyHash(noVeto));
|
||||
});
|
||||
it("quorum denominators never enter the hash", () => {
|
||||
const rule = parsePolicyRule(quorumRule);
|
||||
const withSmall: QuorumStateInput = { adminCount: 1, observedParticipants: 1, eligibleParticipants: 1 };
|
||||
const withBig: QuorumStateInput = { adminCount: 99, observedParticipants: 9999, eligibleParticipants: 500 };
|
||||
expect(policyHash(rule)).toBe(compilePolicy([{ scope: "workspace", rule }], withSmall).policy_version);
|
||||
expect(compilePolicy([{ scope: "workspace", rule }], withSmall).policy_version)
|
||||
.toBe(compilePolicy([{ scope: "workspace", rule }], withBig).policy_version);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compilePolicy", () => {
|
||||
it("candidate order does not change the compiled snapshot", () => {
|
||||
const candidates: RuleCandidate[] = [
|
||||
{ scope: "workspace", rule: parsePolicyRule(immediate) },
|
||||
{ scope: "account", rule: parsePolicyRule(adminOnly) },
|
||||
{ scope: "topic", rule: parsePolicyRule(quorumRule) },
|
||||
];
|
||||
const forward = compilePolicy(candidates, quorum);
|
||||
const shuffled = compilePolicy([...candidates].reverse(), quorum);
|
||||
expect(forward.policy_version).toBe(shuffled.policy_version);
|
||||
expect(canonicalJson(forward.rule)).toBe(canonicalJson(shuffled.rule));
|
||||
});
|
||||
it("repeated compiles are byte-identical (no hidden state)", () => {
|
||||
const candidates: RuleCandidate[] = [{ scope: "content_type", rule: parsePolicyRule(adminOnly) }];
|
||||
const first = compilePolicy(candidates, quorum);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
expect(compilePolicy(candidates, quorum)).toEqual(first);
|
||||
}
|
||||
});
|
||||
it("no candidates => fail-closed immediate baseline with a stable version", () => {
|
||||
const empty = compilePolicy([], quorum);
|
||||
expect(empty.rule.mode).toBe("immediate");
|
||||
expect(empty.policy_version).toBe(compilePolicy([], { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 }).policy_version);
|
||||
expect(empty.policy_version).toBe(policyHash(parsePolicyRule({ mode: "immediate", allowVeto: false })));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePrecedence (D4: topic > content_type > account > workspace)", () => {
|
||||
it("picks the most specific scope", () => {
|
||||
const all: RuleCandidate[] = [
|
||||
{ scope: "workspace", rule: parsePolicyRule(immediate) },
|
||||
{ scope: "account", rule: parsePolicyRule(adminOnly) },
|
||||
{ scope: "content_type", rule: parsePolicyRule(adminOnly) },
|
||||
{ scope: "topic", rule: parsePolicyRule(quorumRule) },
|
||||
];
|
||||
expect(resolvePrecedence(all).scope).toBe("topic");
|
||||
expect(resolvePrecedence(all.filter((c) => c.scope !== "topic")).scope).toBe("content_type");
|
||||
expect(resolvePrecedence(all.filter((c) => c.scope === "account" || c.scope === "workspace")).scope).toBe("account");
|
||||
expect(resolvePrecedence(all.filter((c) => c.scope === "workspace")).scope).toBe("workspace");
|
||||
});
|
||||
it("whole-block replacement: the winner's rule IS the effective policy", () => {
|
||||
const winner = resolvePrecedence([
|
||||
{ scope: "workspace", rule: parsePolicyRule(quorumRule) },
|
||||
{ scope: "topic", rule: parsePolicyRule(adminOnly) },
|
||||
]);
|
||||
expect(winner.rule.mode).toBe("admin_only");
|
||||
expect(winner.rule).not.toHaveProperty("arms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePolicyRule validation (400s, never 500s)", () => {
|
||||
it("rejects malformed shapes", () => {
|
||||
const bad: unknown[] = [
|
||||
null,
|
||||
"immediate",
|
||||
{ mode: "nope" },
|
||||
{ mode: "quorum" }, // no arms
|
||||
{ mode: "quorum", arms: [] },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 0 }] },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "2 hours", onExpire: "reject" } },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }] }, // no timebox
|
||||
{ mode: "admin_only", arms: [{ kind: "participants", x: 2 }], timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
{ mode: "immediate", arms: [{ kind: "admins", x: 1 }] },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 2, of: "observed" }], timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
{ mode: "quorum", arms: [{ kind: "participants", x: 2 }], timebox: { duration: "PT1H", onExpire: "maybe" } },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], join: "xor", timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], anonymity: "secret", timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], minDwellSeconds: -1, timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
{ version: 2, mode: "immediate" },
|
||||
];
|
||||
for (const input of bad) {
|
||||
expect(() => parsePolicyRule(input), JSON.stringify(input)).toThrow();
|
||||
}
|
||||
});
|
||||
it("accepts the full §1a surface and normalises defaults", () => {
|
||||
const rule = parsePolicyRule(quorumRule);
|
||||
expect(rule).toMatchObject({
|
||||
version: 1,
|
||||
mode: "quorum",
|
||||
join: "all",
|
||||
allowVeto: true,
|
||||
anonymity: "named",
|
||||
allowSelfApproval: false,
|
||||
resetOnChange: true,
|
||||
dryRun: false,
|
||||
minDwellSeconds: 0,
|
||||
});
|
||||
expect(rule.arms).toHaveLength(2);
|
||||
expect(rule.timebox).toEqual({ duration: "PT2H", onExpire: "reject" });
|
||||
});
|
||||
it("ISO-8601 duration shapes accepted include days/minutes/seconds", () => {
|
||||
for (const duration of ["PT30M", "PT45S", "P1DT12H", "PT2H30M"]) {
|
||||
expect(() => parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 1 }], timebox: { duration, onExpire: "escalateOwner" } })).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
301
apps/api/src/chatgov/policy.ts
Normal file
301
apps/api/src/chatgov/policy.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
71
apps/api/src/chatgov/poll-evaluator.ts
Normal file
71
apps/api/src/chatgov/poll-evaluator.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { PolicyRule, QuorumArm } from './policy.js';
|
||||
|
||||
export interface EvaluatedBallot {
|
||||
ballot: 'yes' | 'no' | 'veto' | 'abstain';
|
||||
optionId: string | null;
|
||||
counted: boolean;
|
||||
admin: boolean;
|
||||
observed: boolean;
|
||||
participant: boolean;
|
||||
}
|
||||
export interface OptionTally { id: string; seq: number; submittedAt: string; votes: number }
|
||||
export interface ArmTally { kind: QuorumArm['kind']; of?: QuorumArm['of']; got: number; needed: number; possible: number | null }
|
||||
export interface PollEvaluation {
|
||||
arms: ArmTally[];
|
||||
quorum: boolean;
|
||||
winnerOptionId: string | null;
|
||||
tallies: OptionTally[];
|
||||
turnout: number;
|
||||
state: 'passed' | 'failed' | 'vetoed' | 'quorum_unreachable' | null;
|
||||
reason: 'quorum_early' | 'timebox' | 'veto' | 'quorum_unreachable' | null;
|
||||
}
|
||||
|
||||
/** Pure decision; membership, self-approval and observed roster are resolved
|
||||
* afresh by the service. A plurality never substitutes for legitimacy. */
|
||||
export function evaluatePoll(input: {
|
||||
kind: 'yes_no' | 'variant_selection'; rule: PolicyRule;
|
||||
opensAt: Date; closesAt: Date; now: Date;
|
||||
ballots: EvaluatedBallot[]; options: Omit<OptionTally, 'votes'>[];
|
||||
possible: { admins: number; eligible: number; observed: number };
|
||||
}): PollEvaluation {
|
||||
const { rule, ballots } = input;
|
||||
const selected = ballots.filter(b => b.counted && b.ballot === 'yes' && (input.kind === 'yes_no' || b.optionId !== null));
|
||||
const tallies = input.options.map(o => ({ ...o, votes: selected.filter(b => b.optionId === o.id).length }));
|
||||
const winner = [...tallies].sort((a, b) => b.votes - a.votes || Date.parse(a.submittedAt) - Date.parse(b.submittedAt) || a.seq - b.seq)[0];
|
||||
const specs: QuorumArm[] = rule.mode === 'admin_only' ? [{ kind: 'admins', x: 1 }] : rule.arms ?? [];
|
||||
const arms = specs.map(arm => {
|
||||
const matches = (b: EvaluatedBallot): boolean => arm.kind === 'admins' ? b.admin : arm.of === 'observed' ? b.observed : b.participant;
|
||||
return { kind: arm.kind, ...(arm.of === undefined ? {} : { of: arm.of }),
|
||||
got: selected.filter(matches).length, needed: arm.x,
|
||||
// Observed/count-only rosters can grow; never eagerly declare them impossible.
|
||||
possible: arm.kind === 'admins' ? input.possible.admins : arm.of === 'eligible' ? input.possible.eligible : null };
|
||||
});
|
||||
const quorum = rule.mode === 'immediate' || (rule.join === 'any' ? arms.some(a => a.got >= a.needed) : arms.every(a => a.got >= a.needed));
|
||||
const result: PollEvaluation = { arms, quorum, tallies, turnout: selected.length,
|
||||
winnerOptionId: winner?.id ?? null, state: null, reason: null };
|
||||
const choice = input.kind === 'yes_no' || winner !== undefined;
|
||||
if (rule.allowVeto && ballots.some(b => b.counted && b.ballot === 'veto')) {
|
||||
result.state = 'vetoed'; result.reason = 'veto';
|
||||
} else if (input.now >= input.closesAt) {
|
||||
result.state = quorum && choice && rule.timebox?.onExpire === 'approve' ? 'passed' : 'failed';
|
||||
result.reason = 'timebox';
|
||||
} else if (quorum && choice && input.now.getTime() - input.opensAt.getTime() >= (rule.minDwellSeconds ?? 0) * 1000) {
|
||||
result.state = 'passed'; result.reason = 'quorum_early';
|
||||
} else {
|
||||
const impossible = (a: ArmTally): boolean => a.possible !== null && a.possible < a.needed;
|
||||
if (arms.length && (rule.join === 'any' ? arms.every(impossible) : arms.some(impossible))) {
|
||||
result.state = 'quorum_unreachable'; result.reason = 'quorum_unreachable';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Fixed-length ISO durations only; months/years are calendar-dependent. */
|
||||
export function durationMs(duration: string): number {
|
||||
const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(duration);
|
||||
if (!match) throw new Error('poll timebox requires a fixed duration (weeks/days/hours/minutes/seconds)');
|
||||
const [, w, d, h, m, s] = match;
|
||||
const ms = ((Number(w ?? 0) * 7 + Number(d ?? 0)) * 86400 + Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0)) * 1000;
|
||||
if (!Number.isFinite(ms) || ms <= 0 || ms > 365 * 86400_000) throw new Error('poll duration must be positive and at most 365 days');
|
||||
return ms;
|
||||
}
|
||||
30
apps/api/src/chatgov/poll-timebox.service.ts
Normal file
30
apps/api/src/chatgov/poll-timebox.service.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import { pool } from '../db/client.js';
|
||||
import { PollService } from './poll.service.js';
|
||||
|
||||
/** Durable database timeboxes plus lazy read evaluation. A Temporal adapter
|
||||
* can call the same exactly-once close without changing the state machine. */
|
||||
@Injectable()
|
||||
export class PollTimeboxService implements OnModuleInit, OnModuleDestroy {
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private running = false;
|
||||
private readonly logger = new Logger(PollTimeboxService.name);
|
||||
constructor(@Inject(PollService) private readonly polls: PollService) {}
|
||||
onModuleInit(): void {
|
||||
this.timer = setInterval(() => { void this.tick(); }, 1000);
|
||||
this.timer.unref();
|
||||
}
|
||||
onModuleDestroy(): void { if (this.timer) clearInterval(this.timer); }
|
||||
async tick(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const due = await pool.query<{ workspace_id: string; id: string }>('SELECT * FROM chatgov_due_polls()');
|
||||
for (const row of due.rows) {
|
||||
try { await this.polls.close(row.workspace_id, row.id); }
|
||||
catch (error) { this.logger.warn(`poll ${row.id} expiry will retry: ${String(error)}`); }
|
||||
}
|
||||
} catch (error) { this.logger.warn(`poll timebox discovery will retry: ${String(error)}`); }
|
||||
finally { this.running = false; }
|
||||
}
|
||||
}
|
||||
37
apps/api/src/chatgov/poll.controller.ts
Normal file
37
apps/api/src/chatgov/poll.controller.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Body, Controller, Get, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from '../rbac/rbac.guard.js';
|
||||
import { requireSession } from '../http/validation.js';
|
||||
import { PollService, type PollCard } from './poll.service.js';
|
||||
|
||||
@Controller('chatgov/polls')
|
||||
@UseGuards(RbacGuard)
|
||||
export class PollController {
|
||||
constructor(@Inject(PollService) private readonly polls: PollService) {}
|
||||
@Get(':id')
|
||||
@RequirePermission('workspace.view')
|
||||
async get(@Req() req: Request & AuthenticatedRequest, @Param('id') id: string): Promise<PollCard> {
|
||||
const session = requireSession(req);
|
||||
return this.polls.get(session.workspaceId, id, session.userId);
|
||||
}
|
||||
@Post()
|
||||
@RequirePermission('chatgov.vote')
|
||||
async open(@Req() req: Request & AuthenticatedRequest, @Body() body: unknown): Promise<PollCard> {
|
||||
const session = requireSession(req);
|
||||
return this.polls.open(session.workspaceId, body, { userId: session.userId });
|
||||
}
|
||||
@Post(':id/options')
|
||||
@RequirePermission('chatgov.vote')
|
||||
async fork(@Req() req: Request & AuthenticatedRequest, @Param('id') id: string, @Body() body: unknown): Promise<PollCard> {
|
||||
const session = requireSession(req);
|
||||
return this.polls.forkEdit(session.workspaceId, id, body, { userId: session.userId });
|
||||
}
|
||||
// Any authenticated member can store an ineligible ballot. Eligibility is
|
||||
// enforced by the service; identity fields in the body are never trusted.
|
||||
@Post(':id/vote')
|
||||
@RequirePermission('workspace.view')
|
||||
async vote(@Req() req: Request & AuthenticatedRequest, @Param('id') id: string, @Body() body: unknown): Promise<PollCard> {
|
||||
const session = requireSession(req);
|
||||
return this.polls.vote(session.workspaceId, id, body, { kind: 'user', userId: session.userId });
|
||||
}
|
||||
}
|
||||
380
apps/api/src/chatgov/poll.service.ts
Normal file
380
apps/api/src/chatgov/poll.service.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { withTenant } from '../db/client.js';
|
||||
import { parseId, requireObject, requireString } from '../http/validation.js';
|
||||
import { scrub } from '../audit/audit.service.js';
|
||||
import { draftDestinationAccounts } from '../publishing/approval-gate.service.js';
|
||||
import type { DraftPost } from '../publishing/publishing.service.js';
|
||||
import { transitionPollDraft } from '../publishing/poll-approval.js';
|
||||
import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
|
||||
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
|
||||
|
||||
export interface PollActor { userId: string; role?: string }
|
||||
export interface PollVoter { kind: 'user' | 'chat_identity'; userId?: string; provider?: string; remoteId?: string; handle?: string }
|
||||
interface PollRow {
|
||||
id: string; workspace_id: string; draft_id: string | null; draft_revision: string | null;
|
||||
channel_id: string | null; topic_binding_id: string | null; question: string;
|
||||
kind: 'yes_no' | 'variant_selection'; state: string; rule: PolicyRule; policy_version: string;
|
||||
anonymity: string; opens_at: Date; closes_at: Date; created_by: string;
|
||||
winner_option_id: string | null; eligible_user_ids: string[] | null;
|
||||
parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string;
|
||||
}
|
||||
interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string }
|
||||
interface Member { user_id: string; role: string; can_vote: boolean }
|
||||
interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string;
|
||||
ballot: EvaluatedBallot['ballot']; option_id: string | null; counted: boolean; eligible: boolean; ineligible_reason: string | null }
|
||||
export interface PollCard {
|
||||
poll: PollRow; options: OptionRow[]; evaluation: PollEvaluation;
|
||||
votes: VoteRow[]; voterEligibility: { eligible: boolean; admin: boolean; reason: string | null } | null;
|
||||
}
|
||||
|
||||
/** All writes use workspace → draft → poll lock order, matching publishing
|
||||
* and membership epoch updates. The outbox claim and E2 transition share one
|
||||
* transaction; rollback also releases the claim. Late ballots are no-ops. */
|
||||
@Injectable()
|
||||
export class PollService {
|
||||
async open(workspaceId: string, body: unknown, actor: PollActor): Promise<PollCard> {
|
||||
return withTenant(workspaceId, async client => {
|
||||
await lockWorkspace(client, workspaceId);
|
||||
const poll = await openPollInTransaction(client, workspaceId, body, actor);
|
||||
return card(client, poll, actor.userId);
|
||||
});
|
||||
}
|
||||
async forkEdit(workspaceId: string, pollId: string, body: unknown, actor: PollActor): Promise<PollCard> {
|
||||
const dto = requireObject(body, 'body'); const text = variantText(dto.text);
|
||||
return this.locked(workspaceId, pollId, async (client, poll) => {
|
||||
await requireVoter(client, workspaceId, actor.userId);
|
||||
await evaluateLocked(client, poll);
|
||||
if (poll.state !== 'open' || poll.kind !== 'variant_selection') throw new ConflictException('only open variant polls accept forks');
|
||||
const seq = Number((await client.query('SELECT COALESCE(max(seq), 0) + 1 AS seq FROM poll_options WHERE poll_id = $1', [poll.id])).rows[0]?.seq);
|
||||
await insertOption(client, poll, seq, text, actor.userId);
|
||||
await audit(client, poll, 'poll.option_submitted', actor.userId, { seq });
|
||||
return card(client, poll, actor.userId);
|
||||
});
|
||||
}
|
||||
async vote(workspaceId: string, pollId: string, body: unknown, voter: PollVoter): Promise<PollCard> {
|
||||
const dto = requireObject(body, 'body');
|
||||
const optionId = dto.optionId === undefined || dto.optionId === null ? null : parseId(requireString(dto.optionId, 'optionId'));
|
||||
const ballot = dto.ballot ?? (optionId ? 'yes' : undefined);
|
||||
if (!['yes', 'no', 'veto', 'abstain'].includes(String(ballot))) throw new BadRequestException('invalid ballot');
|
||||
return this.locked(workspaceId, pollId, async (client, poll) => {
|
||||
// Expiry is resolved before accepting a late ballot; retries after close
|
||||
// render the frozen artifact without changing the decision.
|
||||
if (new Date() >= poll.closes_at) await evaluateLocked(client, poll);
|
||||
if (poll.state !== 'open') return card(client, poll, voter.userId);
|
||||
if (poll.kind === 'variant_selection' && ballot === 'yes' && optionId === null) throw new BadRequestException('variant vote requires optionId');
|
||||
if (optionId && (poll.kind !== 'variant_selection' || ballot !== 'yes')) throw new BadRequestException('optionId only applies to a variant choice');
|
||||
if (optionId && !(await client.query('SELECT id FROM poll_options WHERE id = $1 AND poll_id = $2', [optionId, poll.id])).rowCount) throw new BadRequestException('option does not belong to this poll');
|
||||
if (ballot === 'veto' && !poll.rule.allowVeto) throw new BadRequestException('veto is disabled');
|
||||
let userId = voter.userId ?? null;
|
||||
let key: string;
|
||||
let identityId: string | null = null;
|
||||
if (voter.kind === 'user') {
|
||||
if (!userId) throw new BadRequestException('user voter needs userId');
|
||||
parseId(userId, 'voter.userId'); key = `user:${userId}`;
|
||||
} else {
|
||||
const provider = requireString(voter.provider, 'provider');
|
||||
const remote = requireString(voter.remoteId, 'remoteId');
|
||||
if (!/^[0-9]{1,20}$/.test(remote) || !/^[a-z0-9_-]+$/.test(provider)) throw new BadRequestException('invalid immutable provider identity');
|
||||
key = `${provider}:${remote}`;
|
||||
// Never trust a supplied linked user id from the gateway.
|
||||
const identity = (await client.query("SELECT id, user_id, status FROM chat_identities WHERE provider = $1 AND remote_user_id = $2", [provider, remote])).rows[0];
|
||||
identityId = identity?.id as string | undefined ?? null;
|
||||
userId = identity?.status === 'approved' ? identity.user_id as string | null : null;
|
||||
}
|
||||
// Linked identities and web users share ONE ballot. Alternate provider
|
||||
// accounts cannot inflate turnout. Unlinked ballots are still retained.
|
||||
if (userId) key = `user:${userId}`;
|
||||
await client.query(`INSERT INTO poll_votes (workspace_id, poll_id, voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot, option_id, eligible, counted, voter_identity_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, false, $9)
|
||||
ON CONFLICT (poll_id, voter_remote_id) DO UPDATE SET voter_kind = EXCLUDED.voter_kind,
|
||||
voter_user_id = EXCLUDED.voter_user_id, voter_identity_id = EXCLUDED.voter_identity_id, voter_handle = EXCLUDED.voter_handle,
|
||||
ballot = EXCLUDED.ballot, option_id = EXCLUDED.option_id, cast_at = clock_timestamp()`,
|
||||
[workspaceId, poll.id, voter.kind, userId, key, voter.handle ?? key, ballot, optionId, identityId]);
|
||||
await audit(client, poll, 'poll.voted', userId, { voter: key, ballot, optionId });
|
||||
await evaluateLocked(client, poll);
|
||||
return card(client, poll, userId ?? undefined);
|
||||
});
|
||||
}
|
||||
async get(workspaceId: string, pollId: string, userId?: string): Promise<PollCard> {
|
||||
return this.locked(workspaceId, pollId, async (client, poll) => {
|
||||
await evaluateLocked(client, poll); return card(client, poll, userId);
|
||||
});
|
||||
}
|
||||
async evaluate(workspaceId: string, pollId: string): Promise<PollCard> { return this.get(workspaceId, pollId); }
|
||||
/** Deliberately re-evaluates the authoritative conditions; callers cannot
|
||||
* force a pass or supply a stale tally. Suitable for competing timeboxes. */
|
||||
async close(workspaceId: string, pollId: string): Promise<PollCard> { return this.get(workspaceId, pollId); }
|
||||
async sweep(workspaceId: string): Promise<void> {
|
||||
const ids = await withTenant(workspaceId, c => c.query<{ id: string }>("SELECT id FROM polls WHERE state = 'open' AND (closes_at <= now() OR opens_at + COALESCE((rule->>'minDwellSeconds')::integer,0) * interval '1 second' <= now())", []));
|
||||
for (const { id } of ids.rows) await this.evaluate(workspaceId, id);
|
||||
}
|
||||
private async locked<T>(workspaceId: string, pollId: string, fn: (client: PoolClient, poll: PollRow) => Promise<T>): Promise<T> {
|
||||
parseId(pollId, 'pollId');
|
||||
return withTenant(workspaceId, async client => {
|
||||
await lockWorkspace(client, workspaceId);
|
||||
const found = (await client.query<PollRow>('SELECT * FROM polls WHERE id = $1', [pollId])).rows[0];
|
||||
if (!found) throw new NotFoundException('poll not found');
|
||||
if (found.draft_id) await client.query('SELECT id FROM drafts WHERE id = $1 FOR UPDATE', [found.draft_id]);
|
||||
const poll = (await client.query<PollRow>('SELECT * FROM polls WHERE id = $1 FOR UPDATE', [pollId])).rows[0]!;
|
||||
return fn(client, poll);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function lockWorkspace(client: PoolClient, workspaceId: string): Promise<void> {
|
||||
await client.query('SELECT id FROM workspaces WHERE id = $1 FOR UPDATE', [workspaceId]);
|
||||
}
|
||||
async function members(client: PoolClient, workspaceId: string): Promise<Member[]> {
|
||||
return (await client.query<Member>(`SELECT m.user_id, m.role, EXISTS(SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.role = m.role AND rp.permission = 'chatgov.vote') AS can_vote
|
||||
FROM memberships m WHERE workspace_id = $1 AND status = 'active'`, [workspaceId])).rows;
|
||||
}
|
||||
async function requireVoter(client: PoolClient, workspaceId: string, userId: string): Promise<void> {
|
||||
parseId(userId, 'actor.userId');
|
||||
if (!(await members(client, workspaceId)).some(m => m.user_id === userId && m.can_vote)) throw new ForbiddenException('chatgov.vote required');
|
||||
}
|
||||
function variantText(value: unknown): string {
|
||||
const text = requireString(value, 'text');
|
||||
if (text.length > 5000) throw new BadRequestException('variant text must be at most 5000 characters');
|
||||
return text;
|
||||
}
|
||||
async function insertOption(client: PoolClient, poll: PollRow, seq: number, text: string, userId: string): Promise<void> {
|
||||
await client.query('INSERT INTO poll_options (workspace_id, poll_id, seq, text, submitted_by) VALUES ($1,$2,$3,$4,$5)', [poll.workspace_id, poll.id, seq, text, userId]);
|
||||
}
|
||||
async function options(client: PoolClient, pollId: string): Promise<OptionRow[]> {
|
||||
return (await client.query<OptionRow>(`SELECT o.*, u.display_name AS author FROM poll_options o
|
||||
JOIN users u ON u.id = o.submitted_by WHERE poll_id = $1 ORDER BY seq`, [pollId])).rows;
|
||||
}
|
||||
async function audit(client: PoolClient, poll: PollRow, action: string, userId: string | null, diff: Record<string, unknown>): Promise<void> {
|
||||
await client.query("SELECT audit_append($1,$2,$3,$4,'poll',$5,$6)", [poll.workspace_id, userId, userId ? 'Member' : 'System', action, poll.id,
|
||||
scrub({ ...diff, policyVersion: poll.policy_version, actor: userId ? `user:${userId}` : `system:chat-poll-gate:${poll.id}` })]);
|
||||
}
|
||||
|
||||
/** Snapshot all applicable arms. Different OR-policy groups cannot be
|
||||
* flattened into a single §1a object without changing their meaning. */
|
||||
export async function draftPollRule(client: PoolClient, draft: { post: DraftPost }, supplied?: unknown): Promise<PolicyRule | null> {
|
||||
const accountIds = draftDestinationAccounts(draft.post);
|
||||
const live = await client.query("SELECT 1 FROM posting_rules WHERE superseded_by IS NULL LIMIT 1");
|
||||
const rules: PolicyRule[] = [];
|
||||
if (accountIds.length && live.rowCount) {
|
||||
for (const account of accountIds) {
|
||||
const topic = typeof draft.post.providerMetadata.topicBindingId === 'string' ? draft.post.providerMetadata.topicBindingId : null;
|
||||
const snapshot = (await client.query(`SELECT effective FROM posting_policy_snapshots WHERE account_id = $1
|
||||
AND content_type = '' AND (topic_binding_id IS NULL OR topic_binding_id = $2)
|
||||
ORDER BY topic_binding_id NULLS LAST LIMIT 1`, [account, topic])).rows[0];
|
||||
if (!snapshot) throw new ConflictException('OUTSIDE_POLICY: destination snapshot missing');
|
||||
const rule = parsePolicyRule(snapshot.effective);
|
||||
if (rule.mode !== 'immediate' && !rule.dryRun) rules.push(rule);
|
||||
}
|
||||
}
|
||||
let rule: PolicyRule | null = rules[0] ?? null;
|
||||
if (rules.some(r => policyHash(r) !== policyHash(rule))) {
|
||||
if (rules.some(r => r.join === 'any')) throw new ConflictException('incompatible OR policy groups require independent polls');
|
||||
rule = parsePolicyRule({ ...rules[0], mode: 'quorum', join: 'all',
|
||||
arms: rules.flatMap<QuorumArm>(r => r.mode === 'admin_only' ? [{ kind: 'admins', x: 1 }] : r.arms ?? []).filter((a, i, all) => all.findIndex(b => b.kind === a.kind && b.of === a.of && b.x === a.x) === i),
|
||||
allowVeto: rules.some(r => r.allowVeto), allowSelfApproval: rules.every(r => r.allowSelfApproval),
|
||||
resetOnChange: rules.some(r => r.resetOnChange), minDwellSeconds: Math.max(...rules.map(r => r.minDwellSeconds ?? 0)),
|
||||
timebox: { duration: rules.reduce((a,b) => durationMs(a.timebox!.duration) >= durationMs(b.timebox!.duration) ? a : b).timebox!.duration, onExpire: 'reject' } });
|
||||
}
|
||||
if (supplied !== undefined) {
|
||||
const requested = parsePolicyRule(supplied);
|
||||
if (rule && policyHash(rule) !== policyHash(requested)) throw new BadRequestException('rule must match the compiled destination policy');
|
||||
rule ??= requested;
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
export async function openPollInTransaction(client: PoolClient, workspaceId: string, body: unknown, actor: PollActor): Promise<PollRow> {
|
||||
const dto = requireObject(body, 'body');
|
||||
await requireVoter(client, workspaceId, actor.userId);
|
||||
if (dto.contentKind !== undefined && dto.contentKind !== 'short_post') throw new BadRequestException('document polls are a future track');
|
||||
const draftId = dto.draftId == null ? null : parseId(requireString(dto.draftId, 'draftId'));
|
||||
const draft = draftId ? (await client.query('SELECT * FROM drafts WHERE id = $1 FOR UPDATE', [draftId])).rows[0] : null;
|
||||
if (draftId && !draft) throw new NotFoundException('draft not found');
|
||||
if (draft && !['editing', 'in_review'].includes(String(draft.state))) throw new ConflictException('draft is not reviewable');
|
||||
const rule = draft ? await draftPollRule(client, draft as { post: DraftPost }, dto.rule) : parsePolicyRule(dto.rule);
|
||||
if (!rule) throw new BadRequestException('a rule or compiled chat-signal policy is required');
|
||||
if (rule.anonymity !== 'named') throw new BadRequestException('anonymous sentiment belongs to the separate Telegram track; governance needs attributable ballots');
|
||||
const kind = dto.kind ?? (draft ? 'variant_selection' : 'yes_no');
|
||||
if (kind !== 'yes_no' && kind !== 'variant_selection') throw new BadRequestException('invalid poll kind');
|
||||
if (kind === 'variant_selection' && !draft) throw new BadRequestException('variant selection requires a draft');
|
||||
if (dto.variants !== undefined && !Array.isArray(dto.variants)) throw new BadRequestException('variants must be an array');
|
||||
const variants = (dto.variants as unknown[] | undefined ?? []).map(v => variantText(typeof v === 'string' ? v : requireObject(v, 'variant').text));
|
||||
if (kind === 'yes_no' && variants.length) throw new BadRequestException('yes/no polls do not accept variants');
|
||||
if (variants.length > 99) throw new BadRequestException('at most 99 initial forks');
|
||||
const eligibleIds = dto.eligibleUserIds === undefined ? null : (Array.isArray(dto.eligibleUserIds) ? dto.eligibleUserIds.map(v => parseId(requireString(v, 'eligibleUserId'))) : (() => { throw new BadRequestException('eligibleUserIds must be an array'); })());
|
||||
const question = requireString(dto.question ?? 'Choose the post to approve', 'question');
|
||||
if (question.length > 300) throw new BadRequestException('question too long');
|
||||
let duration: number;
|
||||
try { duration = durationMs(rule.timebox?.duration ?? 'PT1H'); } catch (error) { throw new BadRequestException(String(error)); }
|
||||
const now = new Date();
|
||||
const poll = (await client.query<PollRow>(`INSERT INTO polls
|
||||
(workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at, created_by, draft_revision, parent_poll_id, channel_id, topic_binding_id, eligible_user_ids)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||
[workspaceId, draftId, draftId ? 'draft' : 'none', question, kind, rule, policyHash(rule), now, new Date(now.getTime() + duration), actor.userId,
|
||||
draft?.revision ?? null, dto.parentPollId == null ? null : parseId(requireString(dto.parentPollId, 'parentPollId')),
|
||||
dto.channelId == null ? null : parseId(requireString(dto.channelId, 'channelId')),
|
||||
dto.topicBindingId == null ? null : parseId(requireString(dto.topicBindingId, 'topicBindingId')), eligibleIds])).rows[0]!;
|
||||
if (kind === 'variant_selection') {
|
||||
await insertOption(client, poll, 1, variantText((draft!.post as DraftPost).text), String(draft!.author_user_id));
|
||||
for (const [i, text] of variants.entries()) await insertOption(client, poll, i + 2, text, actor.userId);
|
||||
}
|
||||
await audit(client, poll, 'poll.opened', actor.userId, { draftId, kind, parentPollId: poll.parent_poll_id });
|
||||
return poll;
|
||||
}
|
||||
|
||||
async function resolveTally(client: PoolClient, poll: PollRow, persist: boolean): Promise<{ evaluation: PollEvaluation; votes: VoteRow[] }> {
|
||||
const roster = await members(client, poll.workspace_id);
|
||||
const draft = poll.draft_id ? (await client.query('SELECT author_user_id FROM drafts WHERE id = $1', [poll.draft_id])).rows[0] : null;
|
||||
const opts = await options(client, poll.id);
|
||||
const votes = (await client.query<VoteRow & { identity_status: string | null; identity_user_id: string | null; role_override: string | null; cast_at: Date; voter_identity_id: string | null }>(`SELECT v.*, ci.status AS identity_status, ci.user_id AS identity_user_id, ci.role_override
|
||||
FROM poll_votes v LEFT JOIN chat_identities ci ON ci.id = v.voter_identity_id
|
||||
WHERE poll_id = $1 ORDER BY cast_at DESC, voter_remote_id`, [poll.id])).rows;
|
||||
// Ingress currently persists handles, not immutable remote ids. Only
|
||||
// numeric author_handle evidence can bind to approved identities safely.
|
||||
// A username must never become an authorization key.
|
||||
const observed = new Set<string>();
|
||||
if (poll.channel_id) {
|
||||
const rows = await client.query<{ user_id: string }>(`SELECT DISTINCT ci.user_id FROM live_chat_messages lm
|
||||
JOIN chat_identities ci ON ci.provider = lm.provider AND ci.remote_user_id = lm.author_handle
|
||||
WHERE lm.channel_id = $1 AND lm.received_at >= $2 AND ci.status = 'approved' AND ci.user_id IS NOT NULL`, [poll.channel_id, poll.opens_at]);
|
||||
for (const row of rows.rows) observed.add(row.user_id);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const ballots: EvaluatedBallot[] = [];
|
||||
const eligibleMembers = roster.filter(m => m.can_vote);
|
||||
const identities = (await client.query<{ user_id: string; role_override: string }>("SELECT user_id, role_override FROM chat_identities WHERE status = 'approved' AND role_override IN ('Admin','Owner')")).rows;
|
||||
const adminIds = new Set(roster.filter(m => m.can_vote && ['Admin', 'Owner'].includes(m.role)).map(m => m.user_id));
|
||||
for (const identity of identities) if (roster.some(m => m.user_id === identity.user_id)) adminIds.add(identity.user_id);
|
||||
for (const vote of votes) {
|
||||
const identityOkay = !vote.voter_identity_id || (vote.identity_status === 'approved' && vote.identity_user_id === vote.voter_user_id);
|
||||
const member = roster.find(m => m.user_id === vote.voter_user_id);
|
||||
const eligible = identityOkay && member !== undefined && (member.can_vote || ['Admin','Owner'].includes(vote.role_override ?? ''));
|
||||
const selfDenied = !poll.rule.allowSelfApproval && vote.ballot === 'yes' && (poll.kind === 'variant_selection'
|
||||
? opts.some(o => o.id === vote.option_id && o.submitted_by === vote.voter_user_id)
|
||||
: draft?.author_user_id === vote.voter_user_id);
|
||||
const duplicate = vote.voter_user_id !== null && seen.has(vote.voter_user_id);
|
||||
if (eligible && vote.voter_user_id) seen.add(vote.voter_user_id);
|
||||
vote.eligible = eligible;
|
||||
vote.counted = eligible && !selfDenied && !duplicate;
|
||||
vote.ineligible_reason = !eligible || duplicate ? 'INELIGIBLE_ACTOR' : selfDenied ? 'SELF_APPROVAL_DENIED' : null;
|
||||
if (persist) await client.query(`UPDATE poll_votes SET eligible = $3, counted = $4, ineligible_reason = $5
|
||||
WHERE poll_id = $1 AND voter_remote_id = $2`, [poll.id, vote.voter_remote_id, vote.eligible, vote.counted, vote.ineligible_reason]);
|
||||
ballots.push({ ballot: vote.ballot, optionId: vote.option_id, counted: vote.counted,
|
||||
admin: adminIds.has(vote.voter_user_id ?? ''), observed: observed.has(vote.voter_user_id ?? ''),
|
||||
participant: poll.eligible_user_ids === null || poll.eligible_user_ids.includes(vote.voter_user_id ?? '') });
|
||||
}
|
||||
const eligible = eligibleMembers.filter(m => poll.eligible_user_ids === null || poll.eligible_user_ids.includes(m.user_id));
|
||||
// Potential self-voters must have at least one option authored by someone
|
||||
// else. A no/self ballot can be recast, so it is never subtracted twice.
|
||||
const canChoose = (id: string): boolean => poll.rule.allowSelfApproval === true || (poll.kind === 'variant_selection'
|
||||
? opts.some(o => o.submitted_by !== id) : draft?.author_user_id !== id);
|
||||
const evaluation = evaluatePoll({ kind: poll.kind, rule: poll.rule, opensAt: poll.opens_at, closesAt: poll.closes_at, now: new Date(), ballots,
|
||||
options: opts.map(o => ({ id: o.id, seq: o.seq, submittedAt: o.submitted_at.toISOString() })),
|
||||
possible: { admins: [...adminIds].filter(canChoose).length, eligible: eligible.filter(m => canChoose(m.user_id)).length, observed: observed.size } });
|
||||
// Keep the public DTO separate from identity resolution internals.
|
||||
return { evaluation, votes: votes.map(v => ({ voter_kind: v.voter_kind, voter_user_id: v.voter_user_id,
|
||||
voter_remote_id: v.voter_remote_id, voter_handle: v.voter_handle, ballot: v.ballot, option_id: v.option_id,
|
||||
counted: v.counted, eligible: v.eligible, ineligible_reason: v.ineligible_reason })) };
|
||||
}
|
||||
|
||||
async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void> {
|
||||
if (poll.state !== 'open') return;
|
||||
if (poll.draft_id && poll.draft_revision !== null) {
|
||||
const draft = (await client.query('SELECT revision FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
|
||||
if (String(draft?.revision) !== String(poll.draft_revision)) {
|
||||
await stalePoll(client, poll); return;
|
||||
}
|
||||
}
|
||||
const { evaluation } = await resolveTally(client, poll, true);
|
||||
await audit(client, poll, 'policy.evaluated', null, { evaluation });
|
||||
// Shadow polls retain their artifact and terminal outcome but cannot
|
||||
// approve/reject drafts. Their E2 decision is pending, never a release.
|
||||
if (!evaluation.state) return;
|
||||
const claimed = await client.query(`UPDATE polls SET outbox_state = 'claimed', state = $2, closed_at = clock_timestamp(),
|
||||
close_reason = $3, winner_option_id = $4, final_evaluation = $5
|
||||
WHERE id = $1 AND state = 'open' AND outbox_state = 'none' RETURNING *`,
|
||||
[poll.id, evaluation.state, evaluation.reason,
|
||||
evaluation.state === 'passed' && poll.kind === 'variant_selection' ? evaluation.winnerOptionId : null, evaluation]);
|
||||
if (!claimed.rowCount) return;
|
||||
Object.assign(poll, claimed.rows[0]);
|
||||
const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM']
|
||||
: evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : [];
|
||||
if (poll.draft_id && !poll.rule.dryRun) {
|
||||
const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined;
|
||||
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
|
||||
passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }),
|
||||
reason: reasons[0] ?? 'quorum_early', escalate: evaluation.reason === 'timebox' && poll.rule.timebox?.onExpire === 'escalateOwner' });
|
||||
}
|
||||
await recordE2(client, poll, evaluation, poll.rule.dryRun ? ['POLICY_DRY_RUN'] : reasons);
|
||||
await audit(client, poll, 'poll.closed', null, { state: poll.state, reason: poll.close_reason, winnerOptionId: poll.winner_option_id, evaluation });
|
||||
await audit(client, poll, evaluation.state === 'passed' && !poll.rule.dryRun ? 'release.fired' : 'release.blocked', null, { reasons });
|
||||
await client.query("UPDATE polls SET outbox_state = 'done' WHERE id = $1 AND outbox_state = 'claimed'", [poll.id]);
|
||||
poll.outbox_state = 'done';
|
||||
}
|
||||
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
|
||||
if (!poll.draft_id) return;
|
||||
const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
|
||||
for (const accountId of draftDestinationAccounts(draft.post as DraftPost)) {
|
||||
const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown';
|
||||
await client.query(`INSERT INTO gate_decisions
|
||||
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
||||
VALUES ($1,$2,$3,'E2',$4,$5,$6,$7,$8,$9)`,
|
||||
[poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) },
|
||||
poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked',
|
||||
{ ...evaluation, pollState: poll.state }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
|
||||
}
|
||||
}
|
||||
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
|
||||
const final = (poll as PollRow & { final_evaluation?: PollEvaluation }).final_evaluation;
|
||||
const tally = final && poll.state !== 'open'
|
||||
? { evaluation: final, votes: (await client.query<VoteRow>('SELECT voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot, option_id, counted, eligible, ineligible_reason FROM poll_votes WHERE poll_id = $1 ORDER BY cast_at', [poll.id])).rows }
|
||||
: await resolveTally(client, poll, false);
|
||||
const member = userId ? (await members(client, poll.workspace_id)).find(m => m.user_id === userId) : undefined;
|
||||
return { poll, options: await options(client, poll.id), evaluation: tally.evaluation, votes: tally.votes,
|
||||
voterEligibility: userId ? { eligible: member?.can_vote ?? false, admin: member !== undefined && ['Admin','Owner'].includes(member.role), reason: member?.can_vote ? null : 'INELIGIBLE_ACTOR' } : null };
|
||||
}
|
||||
|
||||
async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
|
||||
const { evaluation } = await resolveTally(client, poll, true);
|
||||
const changed = await client.query(`UPDATE polls SET state = 'stale', closed_at = clock_timestamp(), close_reason = 'reset_on_change',
|
||||
outbox_state = 'done', final_evaluation = $2 WHERE id = $1 AND state = 'open' AND outbox_state = 'none' RETURNING *`, [poll.id, evaluation]);
|
||||
if (!changed.rowCount) return;
|
||||
Object.assign(poll, changed.rows[0]);
|
||||
await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' });
|
||||
await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']);
|
||||
if (poll.rule.dryRun || !poll.draft_id) return;
|
||||
if (poll.rule.resetOnChange) {
|
||||
await openPollInTransaction(client, poll.workspace_id, {
|
||||
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
|
||||
parentPollId: poll.id, channelId: poll.channel_id, topicBindingId: poll.topic_binding_id,
|
||||
...(poll.eligible_user_ids ? { eligibleUserIds: poll.eligible_user_ids } : {}),
|
||||
}, { userId: await rearmActor(client, poll) });
|
||||
} else {
|
||||
// Stale content can never be auto-approved, even with an approve expiry
|
||||
// policy. escalateOwner keeps it in review; all other stale gates reject.
|
||||
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
|
||||
passed: false, reason: 'RESET_ON_CHANGE', escalate: poll.rule.timebox?.onExpire === 'escalateOwner' });
|
||||
}
|
||||
}
|
||||
async function rearmActor(client: PoolClient, poll: PollRow): Promise<string> {
|
||||
const roster = await members(client, poll.workspace_id);
|
||||
const actor = roster.find(m => m.user_id === poll.created_by && m.can_vote) ?? roster.find(m => m.can_vote && ['Owner','Admin'].includes(m.role));
|
||||
if (!actor) throw new ConflictException('cannot re-arm without an eligible opener');
|
||||
return actor.user_id;
|
||||
}
|
||||
/** Called under publishing's workspace/draft lock, after its revision trigger.
|
||||
* The old options and ballots stay attached to the stale artifact forever. */
|
||||
export async function markDraftPollsStale(client: PoolClient, workspaceId: string, draftId: string): Promise<void> {
|
||||
const polls = (await client.query<PollRow>("SELECT * FROM polls WHERE workspace_id = $1 AND draft_id = $2 AND state = 'open' FOR UPDATE", [workspaceId, draftId])).rows;
|
||||
for (const poll of polls) await evaluateLocked(client, poll);
|
||||
}
|
||||
/** Auto-arm on submit, using ONLY compiled destination snapshots. */
|
||||
export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string, draftId: string, actor: PollActor, post: DraftPost): Promise<void> {
|
||||
if (!draftDestinationAccounts(post).length) return;
|
||||
const rule = await draftPollRule(client, { post });
|
||||
if (!rule) return;
|
||||
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open'", [draftId])).rowCount) return;
|
||||
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue