From 4a0d398a760f925ea3e06753c47a5255854550f1 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Tue, 13 Oct 2026 15:29:16 +0000 Subject: [PATCH] fix: Matrix SSO via dedicated hub appservice (AS register + get_token impersonation) The live tuwunel does not offer m.login.jwt (the jwt.* flat config keys were inert), so the JWT broker flow could never work. Pivot: appservice.hub owns the exclusive hub_* namespace on the homeserver; POST /matrix/session now registers @hub_ with the as_token and mints a single-use m.login.token via login/get_token (?user_id= impersonation). Optional MATRIX_SSO_HS_TOKEN authenticates inbound appservice transactions, acked by the new MatrixAppserviceController sink. Response shape unchanged. --- apps/api/src/matrix-sso/matrix-sso.config.ts | 63 +-- .../src/matrix-sso/matrix-sso.controller.ts | 19 +- apps/api/src/matrix-sso/matrix-sso.module.ts | 6 +- apps/api/src/matrix-sso/matrix-sso.service.ts | 97 ++-- apps/api/src/matrix-sso/matrix-sso.test.ts | 81 ++- scripts/e2e/governance-sim.mjs | 474 ++++++++++++++++++ 6 files changed, 591 insertions(+), 149 deletions(-) create mode 100644 scripts/e2e/governance-sim.mjs diff --git a/apps/api/src/matrix-sso/matrix-sso.config.ts b/apps/api/src/matrix-sso/matrix-sso.config.ts index 14ab8d2..2808001 100644 --- a/apps/api/src/matrix-sso/matrix-sso.config.ts +++ b/apps/api/src/matrix-sso/matrix-sso.config.ts @@ -1,30 +1,29 @@ -import { createHmac } from "node:crypto"; - /** * Hub → Matrix single sign-on configuration. * - * The Hub never holds a Matrix password for its users. tuwunel (1.9.4) accepts - * `org.matrix.login.jwt` logins signed with a shared HMAC secret - * (`[global.jwt]` in tuwunel.toml) and, with `register_user = true`, creates - * the account on first login. The Hub signs a 60-second JWT for the CURRENT - * session's user, trades it for a broker access token, asks - * POST /_matrix/client/v1/login/get_token for a short-lived m.login.token - * (UIA satisfied by the same JWT), and logs the broker device out again. The - * m.login.token is what Element consumes. + * The Hub never holds a Matrix password for its users. A dedicated Matrix + * appservice ("appservice.hub" in tuwunel.toml) owns the hub_* user namespace + * exclusively: with its as_token the Hub registers `@hub_` on first + * use and mints short-lived m.login.tokens for Element via + * POST /_matrix/client/v1/login/get_token (application-service impersonation + * via the ?user_id= query parameter). The login token is what Element + * consumes; the as_token never leaves the API process. * * ENV CONTRACT (read per call, like ocisConfigFromEnv — tests reconfigure * without a rebuild; nothing is read at import time): * - * MATRIX_SSO_JWT_SECRET REQUIRED to enable. Same value as - * `[global.jwt] key` in tuwunel.toml (format HMAC). + * MATRIX_SSO_AS_TOKEN REQUIRED to enable. The appservice.hub + * as_token from tuwunel.toml. Scoped by the + * homeserver to the exclusive hub_* namespace. + * MATRIX_SSO_HS_TOKEN Optional. The appservice.hub hs_token; when + * set, inbound appservice transactions are + * authenticated instead of acked blindly. * MATRIX_SSO_HOMESERVER_URL client-server base, default * ARCHIVE_PROVISIONER_HOMESERVER_URL. * MATRIX_SSO_SERVER_NAME Matrix server_name for MXIDs, default the * homeserver URL's hostname. * MATRIX_SSO_ELEMENT_URL Element Web origin, default the homeserver * URL's origin (chat.occupymedia.cc serves both). - * MATRIX_SSO_JWT_ISSUER `iss` claim, default "tmbh-hub". - * MATRIX_SSO_JWT_AUDIENCE `aud` claim, default the server name. * MATRIX_SSO_LOCALPART_PREFIX prefix for Hub-derived localparts, default * "hub_". The literal "none" disables it — only * do that when every pre-existing Matrix @@ -36,16 +35,13 @@ export interface MatrixSsoConfig { homeserverUrl: URL; serverName: string; elementUrl: URL; - jwtSecret: string; - issuer: string; - audience: string; + asToken: string; localpartPrefix: string; } export class MatrixSsoConfigError extends Error {} const DEFAULT_PREFIX = "hub_"; -const DEFAULT_ISSUER = "tmbh-hub"; function httpUrl(name: string, raw: string): URL { let url: URL; @@ -64,8 +60,8 @@ function trimmed(env: Readonly>, name: string /** null = SSO not configured (routes answer 503); throws on malformed config. */ export function loadMatrixSsoConfig(env: Readonly> = process.env): MatrixSsoConfig | null { - const jwtSecret = trimmed(env, "MATRIX_SSO_JWT_SECRET"); - if (jwtSecret === "") return null; + const asToken = trimmed(env, "MATRIX_SSO_AS_TOKEN"); + if (asToken === "") return null; const rawHomeserver = trimmed(env, "MATRIX_SSO_HOMESERVER_URL") || trimmed(env, "ARCHIVE_PROVISIONER_HOMESERVER_URL"); if (rawHomeserver === "") { throw new MatrixSsoConfigError("MATRIX_SSO_HOMESERVER_URL (or ARCHIVE_PROVISIONER_HOMESERVER_URL) is required"); @@ -83,9 +79,7 @@ export function loadMatrixSsoConfig(env: Readonly): string => Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); - const header = encode({ alg: "HS256", typ: "JWT" }); - const payload = encode({ - sub: localpart, - iss: config.issuer, - aud: config.audience, - iat: nowSeconds, - nbf: nowSeconds - 5, - exp: nowSeconds + SSO_JWT_TTL_SECONDS, - }); - const signature = createHmac("sha256", config.jwtSecret).update(`${header}.${payload}`).digest("base64url"); - return `${header}.${payload}.${signature}`; -} diff --git a/apps/api/src/matrix-sso/matrix-sso.controller.ts b/apps/api/src/matrix-sso/matrix-sso.controller.ts index 52e3445..e003e42 100644 --- a/apps/api/src/matrix-sso/matrix-sso.controller.ts +++ b/apps/api/src/matrix-sso/matrix-sso.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode, Inject, Post, Req, UseGuards } from "@nestjs/common"; +import { Controller, Get, Headers, HttpCode, Inject, Post, Req, UnauthorizedException, UseGuards } from "@nestjs/common"; import type { Request } from "express"; import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js"; import { requireSession } from "../http/validation.js"; @@ -33,3 +33,20 @@ export class MatrixSsoController { return this.sso.mintSession(session.workspaceId, session.userId); } } + +/** + * tuwunel pushes events for hub_* users here (application-service + * transactions). The Hub only mints sign-in tokens and reads nothing back, + * so every transaction is acked; when MATRIX_SSO_HS_TOKEN is configured the + * push must carry it as a bearer token. + */ +@Controller() +export class MatrixAppserviceController { + @Post("_matrix/app/v1/transactions/:txnId") + @HttpCode(200) + ack(@Headers("authorization") authorization: string | undefined): Record { + const expected = process.env.MATRIX_SSO_HS_TOKEN?.trim() ?? ""; + if (expected !== "" && authorization !== `Bearer ${expected}`) throw new UnauthorizedException(); + return {}; + } +} diff --git a/apps/api/src/matrix-sso/matrix-sso.module.ts b/apps/api/src/matrix-sso/matrix-sso.module.ts index 6ee58e5..502b700 100644 --- a/apps/api/src/matrix-sso/matrix-sso.module.ts +++ b/apps/api/src/matrix-sso/matrix-sso.module.ts @@ -1,14 +1,14 @@ import { Module } from "@nestjs/common"; -import { MatrixSsoController } from "./matrix-sso.controller.js"; +import { MatrixAppserviceController, MatrixSsoController } from "./matrix-sso.controller.js"; import { MatrixSsoService } from "./matrix-sso.service.js"; /** * Matrix SSO module — signs Hub users into Element without a Matrix password. * Env is read per request (see matrix-sso.config.ts), so a deployment without - * MATRIX_SSO_JWT_SECRET boots fine and the mint route answers 503. + * MATRIX_SSO_AS_TOKEN boots fine and the mint route answers 503. */ @Module({ - controllers: [MatrixSsoController], + controllers: [MatrixSsoController, MatrixAppserviceController], providers: [MatrixSsoService], }) export class MatrixSsoModule {} diff --git a/apps/api/src/matrix-sso/matrix-sso.service.ts b/apps/api/src/matrix-sso/matrix-sso.service.ts index 497b00e..121a75b 100644 --- a/apps/api/src/matrix-sso/matrix-sso.service.ts +++ b/apps/api/src/matrix-sso/matrix-sso.service.ts @@ -1,6 +1,6 @@ import { ConflictException, Inject, Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common"; import { withTenant } from "../db/client.js"; -import { loadMatrixSsoConfig, matrixUserIdFor, signSsoJwt, type MatrixSsoConfig } from "./matrix-sso.config.js"; +import { loadMatrixSsoConfig, matrixUserIdFor, type MatrixSsoConfig } from "./matrix-sso.config.js"; /** What the Hub tells the web client about the caller's chat identity (no secrets). */ export interface MatrixIdentity { @@ -25,9 +25,6 @@ export type MatrixFetch = (url: string, init: RequestInit) => Promise; export const MATRIX_SSO_FETCH: unique symbol = Symbol("MATRIX_SSO_FETCH"); -/** Stable broker device: a failed logout leaves at most ONE stray device per user. */ -const BROKER_DEVICE_ID = "HUB_SSO_BROKER"; - function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; } @@ -46,8 +43,9 @@ async function describe(res: Response): Promise { /** * Hub → Matrix SSO. Every mint is for the session's own user: the user id is * derived server-side from `users.username`, never taken from the request. - * Nothing minted here is logged or persisted; the broker access token lives - * for the duration of one request and is logged out before returning. + * Nothing minted here is logged or persisted; the credentials the Hub touches + * are its own appservice token (homeserver-scoped to the hub_* namespace) and + * the single-use login token handed to the caller. */ @Injectable() export class MatrixSsoService { @@ -73,45 +71,60 @@ export class MatrixSsoService { async mintSession(workspaceId: string, userId: string): Promise { const config = loadMatrixSsoConfig(); if (config === null) { - throw new ServiceUnavailableException("chat sign-in is not configured: set MATRIX_SSO_JWT_SECRET and MATRIX_SSO_HOMESERVER_URL"); + throw new ServiceUnavailableException("chat sign-in is not configured: set MATRIX_SSO_AS_TOKEN and MATRIX_SSO_HOMESERVER_URL"); } const mapped = matrixUserIdFor(config, await this.usernameFor(workspaceId, userId)); if (mapped === null) throw new ConflictException("this username cannot be used as a Matrix user id"); return this.mintFor(config, mapped.localpart); } - /** JWT login → get_token (UIA via the same JWT) → logout. Exposed for tests. */ + /** AS register (first use) → get_token via ?user_id= impersonation. Exposed for tests. */ async mintFor(config: MatrixSsoConfig, localpart: string): Promise { - const jwt = signSsoJwt(config, localpart); - const login = await this.call(config, "/_matrix/client/v3/login", null, { - type: "org.matrix.login.jwt", - token: jwt, - device_id: BROKER_DEVICE_ID, - initial_device_display_name: "Hub sign-in broker", - }, "JWT login"); - const accessToken = login.access_token; - const matrixUserId = login.user_id; - if (typeof accessToken !== "string" || typeof matrixUserId !== "string") { - throw new ServiceUnavailableException("homeserver login response missing access_token/user_id"); + const userId = `@${localpart}:${config.serverName}`; + await this.ensureUser(config, localpart); + const minted = await this.call( + config, + `/_matrix/client/v1/login/get_token?user_id=${encodeURIComponent(userId)}`, + config.asToken, + {}, + "get_token", + ); + const loginToken = minted.login_token; + if (typeof loginToken !== "string" || loginToken === "") { + throw new ServiceUnavailableException("homeserver get_token response missing login_token"); } + return { + homeserverUrl: config.homeserverUrl.origin, + elementUrl: config.elementUrl.origin, + userId, + loginToken, + expiresInMs: typeof minted.expires_in_ms === "number" ? minted.expires_in_ms : 120_000, + }; + } + + /** Register @hub_ on first use; an existing account is fine. */ + private async ensureUser(config: MatrixSsoConfig, localpart: string): Promise { + let res: Response; try { - const minted = await this.call(config, "/_matrix/client/v1/login/get_token", accessToken, { - auth: { type: "org.matrix.login.jwt", token: jwt }, - }, "get_token"); - const loginToken = minted.login_token; - if (typeof loginToken !== "string" || loginToken === "") { - throw new ServiceUnavailableException("homeserver get_token response missing login_token"); - } - return { - homeserverUrl: config.homeserverUrl.origin, - elementUrl: config.elementUrl.origin, - userId: matrixUserId, - loginToken, - expiresInMs: typeof minted.expires_in_ms === "number" ? minted.expires_in_ms : 120_000, - }; - } finally { - await this.logout(config, accessToken); + res = await this.fetcher(new URL("/_matrix/client/v3/register", config.homeserverUrl).toString(), { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + authorization: `Bearer ${config.asToken}`, + }, + body: JSON.stringify({ type: "m.login.application_service", username: localpart, inhibit_login: true }), + redirect: "error", + }); + } catch (err) { + this.logger.warn(`register unreachable: ${err instanceof Error ? err.name : "error"}`); + throw new ServiceUnavailableException("chat homeserver unreachable (register)"); } + if (res.ok) return; + const detail = await describe(res); + if (detail.includes("M_USER_IN_USE")) return; // already provisioned — fine + this.logger.warn(`register refused: ${detail}`); + throw new ServiceUnavailableException(`chat homeserver refused register (${detail})`); } private async call( @@ -143,20 +156,6 @@ export class MatrixSsoService { return record(await res.json().catch(() => null)); } - /** Best-effort: a stray broker device is harmless (fixed id, reused next mint). */ - private async logout(config: MatrixSsoConfig, accessToken: string): Promise { - try { - await this.fetcher(new URL("/_matrix/client/v3/logout", config.homeserverUrl).toString(), { - method: "POST", - headers: { accept: "application/json", "content-type": "application/json", authorization: `Bearer ${accessToken}` }, - body: "{}", - redirect: "error", - }); - } catch { - this.logger.warn("broker logout failed; device HUB_SSO_BROKER will be reused on the next mint"); - } - } - private async usernameFor(workspaceId: string, userId: string): Promise { const row = await withTenant(workspaceId, (client) => client.query<{ username: string }>("SELECT username FROM users WHERE id = $1", [userId]), diff --git a/apps/api/src/matrix-sso/matrix-sso.test.ts b/apps/api/src/matrix-sso/matrix-sso.test.ts index 53f5c92..32cadbd 100644 --- a/apps/api/src/matrix-sso/matrix-sso.test.ts +++ b/apps/api/src/matrix-sso/matrix-sso.test.ts @@ -1,12 +1,11 @@ -import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; import { ServiceUnavailableException } from "@nestjs/common"; -import { loadMatrixSsoConfig, matrixUserIdFor, signSsoJwt, MatrixSsoConfigError, SSO_JWT_TTL_SECONDS, type MatrixSsoConfig } from "./matrix-sso.config.js"; +import { loadMatrixSsoConfig, matrixUserIdFor, MatrixSsoConfigError, type MatrixSsoConfig } from "./matrix-sso.config.js"; import { MatrixSsoService, type MatrixFetch } from "./matrix-sso.service.js"; import { driveSessionFromEnv } from "../group-files/drive.controller.js"; const BASE_ENV = { - MATRIX_SSO_JWT_SECRET: "test-shared-secret", + MATRIX_SSO_AS_TOKEN: "test-as-token", MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org", }; @@ -16,18 +15,14 @@ function config(env: Record = BASE_ENV): MatrixSsoConfig { return loaded; } -function decodePart(part: string): Record { - return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record; -} - interface Recorded { url: string; authorization: string | null; body: Record; } -/** Fake tuwunel: answers login / get_token / logout, records every call. */ -function fakeHomeserver(overrides: { getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } { +/** Fake tuwunel: answers register / get_token, records every call. */ +function fakeHomeserver(overrides: { registerStatus?: number; getTokenStatus?: number } = {}): { fetcher: MatrixFetch; calls: Recorded[] } { const calls: Recorded[] = []; const fetcher: MatrixFetch = async (url, init) => { const headers = new Headers(init.headers); @@ -37,8 +32,11 @@ function fakeHomeserver(overrides: { getTokenStatus?: number } = {}): { fetcher: body: typeof init.body === "string" ? (JSON.parse(init.body) as Record) : {}, }); const path = new URL(url).pathname; - if (path === "/_matrix/client/v3/login") { - return Response.json({ access_token: "broker-access", user_id: "@hub_ada:chat.example.org", device_id: "HUB_SSO_BROKER" }); + if (path === "/_matrix/client/v3/register") { + if (overrides.registerStatus !== undefined) { + return Response.json({ errcode: "M_USER_IN_USE", error: "User ID already taken." }, { status: overrides.registerStatus }); + } + return Response.json({ user_id: "@hub_ada:chat.example.org", home_server: "chat.example.org" }); } if (path === "/_matrix/client/v1/login/get_token") { if (overrides.getTokenStatus !== undefined) { @@ -46,28 +44,26 @@ function fakeHomeserver(overrides: { getTokenStatus?: number } = {}): { fetcher: } return Response.json({ login_token: "single-use-login-token", expires_in_ms: 120000 }); } - if (path === "/_matrix/client/v3/logout") return Response.json({}); return Response.json({ errcode: "M_UNRECOGNIZED" }, { status: 404 }); }; return { fetcher, calls }; } describe("loadMatrixSsoConfig", () => { - it("is disabled without a JWT secret", () => { + it("is disabled without an appservice token", () => { expect(loadMatrixSsoConfig({ MATRIX_SSO_HOMESERVER_URL: "https://chat.example.org" })).toBeNull(); }); - it("derives server name, Element origin, audience and the safe prefix from the homeserver URL", () => { + it("derives server name, Element origin and the safe prefix from the homeserver URL", () => { const loaded = config(); expect(loaded.serverName).toBe("chat.example.org"); expect(loaded.elementUrl.origin).toBe("https://chat.example.org"); - expect(loaded.audience).toBe("chat.example.org"); - expect(loaded.issuer).toBe("tmbh-hub"); + expect(loaded.asToken).toBe("test-as-token"); expect(loaded.localpartPrefix).toBe("hub_"); }); it("falls back to the provisioner homeserver URL", () => { - const loaded = config({ MATRIX_SSO_JWT_SECRET: "s", ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://hs.example.org" }); + const loaded = config({ MATRIX_SSO_AS_TOKEN: "s", ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://hs.example.org" }); expect(loaded.homeserverUrl.origin).toBe("https://hs.example.org"); }); @@ -77,7 +73,7 @@ describe("loadMatrixSsoConfig", () => { }); it("rejects malformed values", () => { - expect(() => loadMatrixSsoConfig({ MATRIX_SSO_JWT_SECRET: "s" })).toThrow(MatrixSsoConfigError); + expect(() => loadMatrixSsoConfig({ MATRIX_SSO_AS_TOKEN: "s" })).toThrow(MatrixSsoConfigError); expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_HOMESERVER_URL: "ftp://x" })).toThrow(MatrixSsoConfigError); expect(() => loadMatrixSsoConfig({ ...BASE_ENV, MATRIX_SSO_LOCALPART_PREFIX: "Hub:" })).toThrow(MatrixSsoConfigError); }); @@ -95,27 +91,8 @@ describe("matrixUserIdFor", () => { }); }); -describe("signSsoJwt", () => { - it("produces an HS256 token tuwunel's [global.jwt] validator accepts", () => { - const loaded = config(); - const token = signSsoJwt(loaded, "hub_ada", 1_000_000); - const [header, payload, signature] = token.split("."); - expect(decodePart(header!)).toEqual({ alg: "HS256", typ: "JWT" }); - expect(decodePart(payload!)).toEqual({ - sub: "hub_ada", - iss: "tmbh-hub", - aud: "chat.example.org", - iat: 1_000_000, - nbf: 1_000_000 - 5, - exp: 1_000_000 + SSO_JWT_TTL_SECONDS, - }); - const expected = createHmac("sha256", "test-shared-secret").update(`${header}.${payload}`).digest("base64url"); - expect(signature).toBe(expected); - }); -}); - describe("MatrixSsoService.mintFor", () => { - it("JWT login → get_token with JWT UIA → logout, returning the login token", async () => { + it("registers the namespace user, then mints a login token via AS impersonation", async () => { const { fetcher, calls } = fakeHomeserver(); const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada"); @@ -127,28 +104,32 @@ describe("MatrixSsoService.mintFor", () => { expiresInMs: 120000, }); expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ - "/_matrix/client/v3/login", + "/_matrix/client/v3/register", "/_matrix/client/v1/login/get_token", - "/_matrix/client/v3/logout", ]); - const [login, getToken, logout] = calls; - expect(login!.authorization).toBeNull(); - expect(login!.body.type).toBe("org.matrix.login.jwt"); - expect(login!.body.device_id).toBe("HUB_SSO_BROKER"); - expect(getToken!.authorization).toBe("Bearer broker-access"); - expect(getToken!.body.auth).toEqual({ type: "org.matrix.login.jwt", token: login!.body.token }); - expect(logout!.authorization).toBe("Bearer broker-access"); + const [register, getToken] = calls; + expect(register!.authorization).toBe("Bearer test-as-token"); + expect(register!.body).toEqual({ type: "m.login.application_service", username: "hub_ada", inhibit_login: true }); + expect(getToken!.authorization).toBe("Bearer test-as-token"); + expect(new URL(getToken!.url).searchParams.get("user_id")).toBe("@hub_ada:chat.example.org"); }); - it("maps a refused get_token to 503, still logs the broker out, and never echoes the JWT", async () => { + it("treats an already-provisioned user as success", async () => { + const { fetcher, calls } = fakeHomeserver({ registerStatus: 400 }); + const session = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada"); + expect(session.loginToken).toBe("single-use-login-token"); + expect(calls).toHaveLength(2); + }); + + it("maps a refused get_token to 503 with the homeserver errcode, never the token", async () => { const { fetcher, calls } = fakeHomeserver({ getTokenStatus: 403 }); const error = await new MatrixSsoService(fetcher).mintFor(config(), "hub_ada").catch((err: unknown) => err); expect(error).toBeInstanceOf(ServiceUnavailableException); const message = error instanceof Error ? error.message : ""; expect(message).toContain("M_FORBIDDEN"); - expect(message).not.toContain(String(calls[0]!.body.token)); - expect(new URL(calls.at(-1)!.url).pathname).toBe("/_matrix/client/v3/logout"); + expect(message).not.toContain("test-as-token"); + expect(calls).toHaveLength(2); }); it("maps an unreachable homeserver to 503", async () => { diff --git a/scripts/e2e/governance-sim.mjs b/scripts/e2e/governance-sim.mjs new file mode 100644 index 0000000..8095c1f --- /dev/null +++ b/scripts/e2e/governance-sim.mjs @@ -0,0 +1,474 @@ +#!/usr/bin/env node +// End-to-end governance simulation against the LIVE deployment. +// +// RADAR_PASSWORD=... node scripts/e2e/governance-sim.mjs +// +// Env: API_URL (default https://api.occupymedia.cc), MASTODON_URL +// (default https://social.occupymedia.cc), RADAR_EMAIL, RADAR_PASSWORD +// (Mastodon web sign-in, used only to click "Authorize" in the real OAuth +// consent flow), RADAR_TOKEN (else read over ssh), REPORT +// (default /tmp/opencode/workers/GOV-SIM-REPORT.md). +// +// All created data is prefixed "e2e-gov-". Re-runnable: existing users, +// memberships and the Mastodon connection are reused. +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +const API = (process.env.API_URL ?? "https://api.occupymedia.cc").replace(/\/$/, ""); +const MASTO = (process.env.MASTODON_URL ?? "https://social.occupymedia.cc").replace(/\/$/, ""); +const REPORT = process.env.REPORT ?? "/tmp/opencode/workers/GOV-SIM-REPORT.md"; +const PASSWORD = "GovSim!2026x"; +const RUN = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15); +const TAG = "acampada26s"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const log = (...a) => console.log(`[${new Date().toISOString().slice(11, 19)}]`, ...a); + +// --------------------------------------------------------------------------- +// Report bookkeeping +// --------------------------------------------------------------------------- +const calls = []; // per-section API call log +const sections = []; +const gaps = []; +const deviations = []; +let current = null; + +function section(name, meta = {}) { + current = { name, ...meta, calls: [], checks: [], notes: [] }; + sections.push(current); + return current; +} +function check(label, expected, actual, body) { + const pass = JSON.stringify(expected) === JSON.stringify(actual); + current.checks.push({ label, expected, actual, pass, ...(pass ? {} : { body }) }); + log(`${pass ? "PASS" : "FAIL"} ${current.name} — ${label}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`); + return pass; +} +const note = (text) => { current?.notes.push(text); log(` note: ${text}`); }; +const gap = (text) => { if (!gaps.includes(text)) gaps.push(text); }; + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- +function summarize(body) { + if (body === undefined || body === null) return ""; + const keys = ["username", "workspaceName", "role", "provider", "instanceUrl", "draftId", "kind", "ballot", "connectionId", "scope", "accountId"]; + const out = {}; + for (const k of keys) if (body[k] !== undefined) out[k] = body[k]; + if (body.rule) out.rule = body.rule; + if (body.policy) out.policy = body.policy; + if (body.post) out.post = { text: String(body.post.text).slice(0, 40) + "…", providerMetadata: body.post.providerMetadata }; + return JSON.stringify(out); +} + +async function api(method, path, opts = {}) { + const r = await apiOnce(method, path, opts); + // Membership/role writes bump the workspace authorization epoch: re-mint + // the caller's session once and retry. + if (r.status === 401 && /epoch stale/.test(JSON.stringify(r.data)) && opts.as && ctx.workspaceId) { + const key = opts.as === "owner" ? "owner" : opts.as.replace(/^e2e-gov-/, ""); + const name = key === "owner" ? "e2e-gov-owner" : `e2e-gov-${key}`; + ctx.tokens[key] = (await apiOnce("POST", "/auth/login", { body: { username: name, password: PASSWORD, workspaceId: ctx.workspaceId }, as: name })).data.token; + return apiOnce(method, path, { ...opts, token: ctx.tokens[key] }); + } + if ((opts.ok ?? true) && r.status >= 400) { + const err = new Error(`${method} ${path} -> ${r.status}: ${JSON.stringify(r.data).slice(0, 500)}`); + err.status = r.status; err.data = r.data; + throw err; + } + return r; +} + +async function apiOnce(method, path, { token, body, as } = {}) { + const res = await fetch(`${API}${path}`, { + method, + headers: { ...(body === undefined ? {} : { "content-type": "application/json" }), ...(token ? { authorization: `Bearer ${token}` } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: "manual", + }); + const text = await res.text(); + let data; + try { data = text ? JSON.parse(text) : null; } catch { data = text; } + const entry = { method, path, as, status: res.status, req: summarize(body) }; + (current ? current.calls : calls).push(entry); + return { status: res.status, data, headers: res.headers }; +} + +function radarToken() { + if (process.env.RADAR_TOKEN) return process.env.RADAR_TOKEN.trim(); + return execFileSync("ssh", ["-i", `${process.env.HOME}/.ssh/devserver`, "root@192.168.13.22", + "pct exec 150 -- cat /root/.occ-mastodon/radar_token"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); +} + +async function masto(token, path) { + const res = await fetch(`${MASTO}${path}`, { headers: { authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`mastodon ${path} -> ${res.status}`); + return res.json(); +} + +// Minimal cookie jar for the Mastodon web consent flow. +class Jar { + constructor() { this.c = new Map(); } + take(res) { + for (const line of res.headers.getSetCookie?.() ?? []) { + const [pair] = line.split(";"); const i = pair.indexOf("="); + this.c.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()); + } + } + header() { return [...this.c].map(([k, v]) => `${k}=${v}`).join("; "); } +} +const hidden = (html) => Object.fromEntries([...html.matchAll(/]*>/g)].map((m) => m[0]) + .filter((t) => /type=["']hidden["']/.test(t)) + .map((t) => [t.match(/name=["']([^"']+)["']/)?.[1], (t.match(/value=["']([^"']*)["']/)?.[1] ?? "").replace(/&/g, "&")]) + .filter(([k]) => k)); + +// --------------------------------------------------------------------------- +// Identity helpers +// --------------------------------------------------------------------------- +async function registerOrLogin(username, displayName, workspaceName) { + const body = { username, password: PASSWORD, displayName, ...(workspaceName ? { workspaceName } : {}) }; + const reg = await api("POST", "/auth/register", { body, ok: false, as: username }); + if (reg.status < 300) return { ...reg.data, reused: false }; + if (reg.status !== 409) throw new Error(`register ${username}: ${reg.status} ${JSON.stringify(reg.data)}`); + const login = await api("POST", "/auth/login", { body: { username, password: PASSWORD }, as: username }); + return { ...login.data, reused: true }; +} +async function loginTo(username, workspaceId) { + return (await api("POST", "/auth/login", { body: { username, password: PASSWORD, workspaceId }, as: username })).data; +} +const wsOf = (me) => me?.workspace?.id ?? me?.workspaceId ?? me?.activeWorkspace?.id; +const uidOf = (me) => me?.user?.id ?? me?.userId ?? me?.id; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +const ctx = { users: {}, tokens: {} }; + +async function setup() { + section("Setup: identities, membership, roles"); + const owner = await registerOrLogin("e2e-gov-owner", "GovSim Owner", "GovSim Collective"); + ctx.workspaceId = wsOf(owner.me); + ctx.ownerId = uidOf(owner.me); + ctx.tokens.owner = owner.token; + note(`owner ${owner.reused ? "reused (login)" : "registered"}; workspace ${ctx.workspaceId}; me keys: ${Object.keys(owner.me ?? {}).join(",")}`); + if (!ctx.workspaceId) throw new Error(`cannot find workspaceId in me: ${JSON.stringify(owner.me)}`); + + const members = async () => (await api("GET", "/team/members", { token: ctx.tokens.owner, as: "owner" })).data; + let roster = await members(); + const list = Array.isArray(roster) ? roster : roster.members ?? []; + note(`member row keys: ${Object.keys(list[0] ?? {}).join(",")}`); + + let link = null; + for (let i = 1; i <= 9; i++) { + const name = `e2e-gov-u${i}`; + const solo = await registerOrLogin(name, `GovSim U${i}`); + const uid = uidOf(solo.me); + ctx.users[`u${i}`] = { name, id: uid }; + const already = list.find((m) => (m.userId ?? m.user_id ?? m.user?.id) === uid || m.username === name); + if (!already) { + link ??= (await api("POST", "/team/invite-links", { token: ctx.tokens.owner, as: "owner", body: { role: "Editor", expiresInHours: 1, maxUses: 9 } })).data; + const claim = await api("POST", `/invites/${encodeURIComponent(link.token)}/claim`, { token: solo.token, as: name, body: {}, ok: false }); + if (claim.status >= 300 && claim.status !== 409) throw new Error(`claim ${name}: ${claim.status} ${JSON.stringify(claim.data)}`); + } + ctx.tokens[`u${i}`] = (await loginTo(name, ctx.workspaceId)).token; + } + roster = await members(); + const all = Array.isArray(roster) ? roster : roster.members ?? []; + const find = (u) => all.find((m) => (m.userId ?? m.user_id ?? m.user?.id) === u.id || m.username === u.name); + for (const k of ["u1", "u2", "u3"]) { + const m = find(ctx.users[k]); + if (!m) throw new Error(`${k} not a member after claim`); + if (m.role !== "Admin") await api("PATCH", `/team/members/${m.id ?? m.membershipId}/role`, { token: ctx.tokens.owner, as: "owner", body: { role: "Admin" } }); + } + // Role changes bump the membership epoch: re-mint sessions. + for (let i = 1; i <= 9; i++) ctx.tokens[`u${i}`] = (await loginTo(`e2e-gov-u${i}`, ctx.workspaceId)).token; + ctx.tokens.owner = (await loginTo("e2e-gov-owner", ctx.workspaceId)).token; + const finalRoster = await members(); + const fr = Array.isArray(finalRoster) ? finalRoster : finalRoster.members ?? []; + const roles = Object.fromEntries(Object.entries(ctx.users).map(([k, u]) => [k, find.call(null, u) && fr.find((m) => (m.userId ?? m.user_id ?? m.user?.id) === u.id || m.username === u.name)?.role])); + check("u1..u3 Admin, u4..u9 Editor (chatgov.vote via migration 024)", + { u1: "Admin", u2: "Admin", u3: "Admin", u4: "Editor", u5: "Editor", u6: "Editor", u7: "Editor", u8: "Editor", u9: "Editor" }, roles, fr); + check("active member count (owner + 9)", 10, fr.filter((m) => (m.status ?? "active") === "active").length, fr); +} + +async function connectMastodon() { + section("Setup: Mastodon connection (OAuth authorization-code flow)"); + const existing = (await api("GET", "/connections", { token: ctx.tokens.owner, as: "owner" })).data; + const conns = Array.isArray(existing) ? existing : existing.connections ?? []; + note(`connection row keys: ${Object.keys(conns[0] ?? {}).join(",")}`); + const live = conns.find((c) => c.provider === "mastodon" && !c.disconnectedAt && !c.disconnected_at); + if (live) { ctx.connectionId = live.id; note(`reusing mastodon connection ${live.id}`); return; } + + gap("No route accepts a pre-issued Mastodon access_token (POST /connections only has /authorize → OAuth consent → /connections/callback, plus telegram/bot-token). The sim drives the real consent flow as radar instead."); + const auth = await api("POST", "/connections/authorize", { token: ctx.tokens.owner, as: "owner", body: { provider: "mastodon", instanceUrl: MASTO } }); + const apiJar = new Jar(); apiJar.take({ headers: auth.headers }); + const redirectUrl = auth.data.redirectUrl; + + if (!process.env.RADAR_PASSWORD) throw new Error("RADAR_PASSWORD is required to approve the OAuth consent as radar"); + const jar = new Jar(); + const get = async (url) => { const r = await fetch(url, { headers: { cookie: jar.header() }, redirect: "manual" }); jar.take(r); return r; }; + const post = async (url, form) => { + const r = await fetch(url, { method: "POST", headers: { cookie: jar.header(), "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form), redirect: "manual" }); + jar.take(r); return r; + }; + const signIn = await (await get(`${MASTO}/auth/sign_in`)).text(); + const csrf = signIn.match(/name="csrf-token" content="([^"]+)"/)?.[1] ?? hidden(signIn).authenticity_token; + const s = await post(`${MASTO}/auth/sign_in`, { authenticity_token: csrf, "user[email]": process.env.RADAR_EMAIL ?? "radar@occupymedia.cc", "user[password]": process.env.RADAR_PASSWORD }); + current.calls.push({ method: "POST", path: `${MASTO}/auth/sign_in`, status: s.status, req: "radar web sign-in" }); + if (s.status !== 302 && s.status !== 303) throw new Error(`mastodon sign-in failed: ${s.status}`); + + let r = await get(redirectUrl); + current.calls.push({ method: "GET", path: `${MASTO}/oauth/authorize`, status: r.status, req: "consent page" }); + let callback = r.headers.get("location"); + if (r.status === 200) { + const html = await r.text(); + const form = html.split(/
/action="\/oauth\/authorize"/.test(f) && !/name="_method" value="delete"/.test(f)); + if (!form) throw new Error("consent form not found"); + r = await post(`${MASTO}/oauth/authorize`, hidden(form)); + current.calls.push({ method: "POST", path: `${MASTO}/oauth/authorize`, status: r.status, req: "approve consent" }); + callback = r.headers.get("location"); + } + if (!callback?.startsWith(`${API}/connections/callback`)) throw new Error(`unexpected consent redirect: ${r.status} ${callback}`); + const cb = await fetch(callback, { headers: { cookie: apiJar.header() }, redirect: "manual" }); + const landing = cb.headers.get("location") ?? ""; + current.calls.push({ method: "GET", path: "/connections/callback?code&state", status: cb.status, req: `→ ${landing.replace(/\?.*/, "?…")}` }); + note(`callback landing: ${landing}`); + + const after = (await api("GET", "/connections", { token: ctx.tokens.owner, as: "owner" })).data; + const list = Array.isArray(after) ? after : after.connections ?? []; + const conn = list.find((c) => c.provider === "mastodon" && !c.disconnectedAt && !c.disconnected_at); + check("mastodon connection exists after OAuth", true, conn !== undefined, after); + if (!conn) throw new Error("mastodon connection was not created"); + ctx.connectionId = conn.id; +} + +async function sources() { + section("Setup: source list (#acampada26s as radar)"); + ctx.radar = radarToken(); + ctx.radarAccount = await masto(ctx.radar, "/api/v1/accounts/verify_credentials"); + const tl = await masto(ctx.radar, `/api/v1/timelines/tag/${TAG}?limit=20`); + current.calls.push({ method: "GET", path: `${MASTO}/api/v1/timelines/tag/${TAG}`, status: 200, req: "radar token" }); + ctx.sources = tl.map((s) => ({ id: s.id, url: s.url, acct: s.account.acct, text: s.content.replace(/<[^>]+>/g, "").slice(0, 80) })); + note(`${ctx.sources.length} source posts: ${ctx.sources.slice(0, 8).map((s) => s.id).join(", ")}`); + check("hashtag timeline has source posts", true, ctx.sources.length > 0, tl); +} + +// One governed action: draft (reply to a source post) → submit → poll → votes. +async function governedDraft(id, source) { + const marker = `e2e-gov-${RUN}-${id}`; + const post = { + text: `[${marker}] Governance sim ${id}: re ${source.url} #${TAG}`, + mediaAssetIds: [], + visibility: "unlisted", + providerMetadata: { connectionId: ctx.connectionId, inReplyToId: source.id }, + }; + const draft = (await api("POST", "/publishing/drafts", { token: ctx.tokens.owner, as: "owner", body: { post } })).data; + await api("POST", `/publishing/drafts/${draft.id}/submit`, { token: ctx.tokens.owner, as: "owner" }); + return { draft, marker }; +} + +async function openPoll(draftId, rule, question) { + return (await api("POST", "/chatgov/polls", { token: ctx.tokens.owner, as: "owner", body: { draftId, kind: "yes_no", rule, question } })).data; +} +async function vote(user, pollId, ballot) { + return (await api("POST", `/chatgov/polls/${pollId}/vote`, { token: ctx.tokens[user], as: user, body: { ballot } })).data; +} +async function pollCard(pollId) { + return (await api("GET", `/chatgov/polls/${pollId}`, { token: ctx.tokens.owner, as: "owner" })).data; +} +async function waitClosed(pollId, closesAt) { + const wait = Math.max(0, Date.parse(closesAt) - Date.now()) + 1500; + log(` waiting ${Math.round(wait / 1000)}s for timebox`); + await sleep(wait); + return pollCard(pollId); +} +async function draftState(draftId) { + const list = (await api("GET", "/publishing/drafts?limit=100", { token: ctx.tokens.owner, as: "owner" })).data; + return (Array.isArray(list) ? list : list.drafts ?? []).find((d) => d.id === draftId)?.state ?? "unknown"; +} + +async function findOnMastodon(marker, timeoutMs = 90_000) { + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + const statuses = await masto(ctx.radar, `/api/v1/accounts/${ctx.radarAccount.id}/statuses?limit=40`); + const hit = statuses.find((s) => s.content.includes(marker)); + if (hit) return hit; + await sleep(3000); + } + return null; +} + +// After an approval: dispatch, then confirm the status landed as a reply. +async function executeAndVerify(draftId, marker, source) { + const pub = await api("POST", `/publishing/drafts/${draftId}/publish`, { token: ctx.tokens.owner, as: "owner", body: { connectionId: ctx.connectionId }, ok: false }); + check("publish accepted (202)", 202, pub.status, pub.data); + if (pub.status !== 202) return; + const hit = await findOnMastodon(marker); + check("status appeared on Mastodon", true, hit !== null, null); + if (hit) { + check("status is a reply to the source post", source.id, hit.in_reply_to_id, hit); + note(`Mastodon status ${hit.url}`); + } +} +// After a rejection: dispatch must be refused and nothing may appear. +async function refuseAndVerify(draftId, marker) { + const pub = await api("POST", `/publishing/drafts/${draftId}/publish`, { token: ctx.tokens.owner, as: "owner", body: { connectionId: ctx.connectionId }, ok: false }); + check("publish refused for non-approved draft (4xx)", true, pub.status >= 400 && pub.status < 500, pub.data); + const hit = await findOnMastodon(marker, 8000); + check("nothing posted on Mastodon", null, hit?.url ?? null, hit); +} + +function evalState(cardData) { + return { state: cardData.poll.state, reason: cardData.poll.close_reason ?? cardData.evaluation?.reason ?? null }; +} + +const TB = (duration, onExpire) => ({ duration, onExpire }); + +const SCENARIOS = [ + { id: "S1", title: "admin_only, PT10S reject — u1 yes", rule: { mode: "admin_only", timebox: TB("PT10S", "reject"), minDwellSeconds: 0 }, + votes: [["u1", "yes"]], expect: { state: "passed", reason: "quorum_early" }, draft: "approved" }, + { id: "S2", title: "admin_only, PT10S reject — nobody votes", rule: { mode: "admin_only", timebox: TB("PT10S", "reject"), minDwellSeconds: 0 }, + votes: [], wait: true, expect: { state: "failed", reason: "timebox" }, draft: "rejected" }, + { id: "S3", title: "quorum [{admins,x:2}] any, PT15S reject — u1+u2 yes", rule: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], join: "any", timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, + votes: [["u1", "yes"], ["u2", "yes"]], expect: { state: "passed", reason: "quorum_early" }, draft: "approved" }, + { id: "S4", title: "quorum [{admins,x:3}] all, PT15S reject — u1,u2 yes, u3 no", rule: { mode: "quorum", arms: [{ kind: "admins", x: 3 }], join: "all", timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, + votes: [["u1", "yes"], ["u2", "yes"], ["u3", "no"]], wait: true, expect: { state: "failed", reason: "timebox" }, draft: "rejected", + why: "A 'no' never closes a poll early (ballots can be recast). With possible admins = 3 (Owner is the draft author and excluded by allowSelfApproval=false), quorum stays reachable, so the poll fails only at the timebox." }, + { id: "S5", title: "quorum [{admins,x:3}] all, PT15S reject — u1,u2,u3 yes", rule: { mode: "quorum", arms: [{ kind: "admins", x: 3 }], join: "all", timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, + votes: [["u1", "yes"], ["u2", "yes"], ["u3", "yes"]], expect: { state: "passed", reason: "quorum_early" }, draft: "approved" }, + { id: "S6", title: "quorum [{admins,x:1}] + allowVeto, PT15S reject — u1 yes, then u4 veto (spec order)", rule: { mode: "quorum", arms: [{ kind: "admins", x: 1 }], allowVeto: true, timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, + votes: [["u1", "yes"], ["u4", "veto"]], specExpect: { state: "vetoed", reason: "veto" }, expect: { state: "passed", reason: "quorum_early" }, draft: "approved", + why: "poll-evaluator.ts: with minDwellSeconds=0, u1's yes satisfies the arm and closes the poll 'passed' (quorum_early) inside the same vote transaction; u4's later veto hits a closed poll and is a no-op (PollService.vote returns the frozen card). The spec's 'vetoed' expectation is unreachable in this order." }, + { id: "S6b", title: "quorum [{admins,x:1}] + allowVeto, PT15S reject — u4 veto first, then u1 yes", rule: { mode: "quorum", arms: [{ kind: "admins", x: 1 }], allowVeto: true, timebox: TB("PT15S", "reject"), minDwellSeconds: 0 }, + votes: [["u4", "veto"], ["u1", "yes"]], expect: { state: "vetoed", reason: "veto" }, draft: "rejected", + why: "Veto is checked before quorum, so a counted veto (u4 = Editor, holds chatgov.vote) closes the poll 'vetoed' immediately." }, + { id: "S8", title: "quorum [{participants,x:4,of:eligible}], PT10S approve — only u4,u5 vote yes", rule: { mode: "quorum", arms: [{ kind: "participants", x: 4, of: "eligible" }], timebox: TB("PT10S", "approve"), minDwellSeconds: 0 }, + votes: [["u4", "yes"], ["u5", "yes"]], wait: true, expect: { state: "failed", reason: "timebox" }, draft: "rejected", + why: "poll-evaluator.ts line `state = quorum && choice && onExpire === 'approve' ? 'passed' : 'failed'`: onExpire=approve only approves at expiry when quorum IS met; it never substitutes for quorum. Possible eligible = 9 ≥ 4, so it is not quorum_unreachable early. Expected: failed / timebox (gate reason TIMEBOX_EXPIRED_AUTO_REJECT)." }, +]; + +async function runScenario(sc, sourceIdx) { + section(`${sc.id} — ${sc.title}`, { rule: sc.rule, why: sc.why }); + const source = ctx.sources[sourceIdx % ctx.sources.length]; + const { draft, marker } = await governedDraft(sc.id, source); + note(`draft ${draft.id} → reply to ${source.url}`); + let card = await openPoll(draft.id, sc.rule, `${marker}: approve reply to ${source.id}?`); + note(`poll ${card.poll.id} closes_at ${new Date(card.poll.closes_at).toISOString()}`); + for (const [user, ballot] of sc.votes) { + const res = await api("POST", `/chatgov/polls/${card.poll.id}/vote`, { token: ctx.tokens[user], as: user, body: { ballot }, ok: false }); + if (res.status >= 400) note(`vote ${user}=${ballot} → ${res.status} ${JSON.stringify(res.data)}`); + else { card = res.data; note(`after ${user}=${ballot}: state=${card.poll.state} arms=${JSON.stringify(card.evaluation.arms.map((a) => `${a.kind}:${a.got}/${a.needed}`))}`); } + } + if (sc.wait && card.poll.state === "open") card = await waitClosed(card.poll.id, card.poll.closes_at); + else card = await pollCard(card.poll.id); + const actual = evalState(card); + if (sc.specExpect) check("poll state vs ORIGINAL SPEC", sc.specExpect, actual, card); + check("poll state/reason (per poll-evaluator.ts)", sc.expect, actual, card); + const counted = card.votes.map((v) => `${v.voter_handle}:${v.ballot}:${v.counted ? "counted" : v.ineligible_reason}`); + note(`ballots: ${counted.join(", ")}`); + check("draft state after E2", sc.draft, await draftState(draft.id), null); + const gates = (await api("GET", `/publishing/drafts/${draft.id}/gates`, { token: ctx.tokens.owner, as: "owner" })).data; + const e2 = gates.find((g) => g.gate === "E2"); + check("E2 gate decision recorded", sc.draft === "approved" ? "fired" : "blocked", e2?.outcome ?? null, gates); + if (e2) note(`E2 reasonCodes ${JSON.stringify(e2.reasonCodes)} policyVersion ${e2.policyVersion}`); + if (sc.draft === "approved") await executeAndVerify(draft.id, marker, source); + else await refuseAndVerify(draft.id, marker); +} + +async function runImmediate(sourceIdx) { + section("S7 — immediate mode: action executes without any poll", { + why: "Immediate is a compiled destination policy (POST /chatgov/rules). draftPollRule() drops immediate rules, so submit opens no poll; E1 fires on approve. The draft state machine still requires editing→in_review→approved before dispatch.", + }); + const rules = (await api("GET", `/chatgov/rules?accountId=${ctx.connectionId}`, { token: ctx.tokens.owner, as: "owner" })).data; + let rule = (Array.isArray(rules) ? rules : []).find((r) => r.scope === "account" && !r.supersededBy); + if (!rule) rule = (await api("POST", "/chatgov/rules", { token: ctx.tokens.owner, as: "owner", body: { scope: "account", accountId: ctx.connectionId, policy: { mode: "immediate" } } })).data; + ctx.ruleId = rule.id; + try { + const source = ctx.sources[sourceIdx % ctx.sources.length]; + const { draft, marker } = await governedDraft("S7", source); + const gatesBefore = (await api("GET", `/publishing/drafts/${draft.id}/gates`, { token: ctx.tokens.owner, as: "owner" })).data; + check("no E2 (poll) decision exists after submit", 0, gatesBefore.filter((g) => g.gate === "E2").length, gatesBefore); + const approve = await api("POST", `/publishing/drafts/${draft.id}/approve`, { token: ctx.tokens.owner, as: "owner", ok: false }); + check("approve succeeds without a poll", true, approve.status < 300, approve.data); + const gates = (await api("GET", `/publishing/drafts/${draft.id}/gates`, { token: ctx.tokens.owner, as: "owner" })).data; + const e1 = gates.find((g) => g.gate === "E1"); + check("E1 fired (immediate policy)", "fired", e1?.outcome ?? null, gates); + check("still no poll/E2 for the draft", 0, gates.filter((g) => g.gate === "E2").length, gates); + check("draft state", "approved", await draftState(draft.id), null); + await executeAndVerify(draft.id, marker, source); + } finally { + // Leave the workspace without live posting_rules (opt-out of chatgov E1). + await api("DELETE", `/chatgov/rules/${ctx.ruleId}`, { token: ctx.tokens.owner, as: "owner", ok: false }); + } +} + +function writeReport(fatal) { + const lines = []; + const scen = sections.filter((s) => /^S\d/.test(s.name)); + const passCount = scen.filter((s) => s.checks.length && s.checks.every((c) => c.pass)).length; + lines.push(`# Governance simulation report — ${new Date().toISOString()}`, ""); + lines.push(`- API: ${API}`, `- Mastodon: ${MASTO} (acting account @${ctx.radarAccount?.acct ?? "radar"})`, `- Run id: ${RUN} (status markers \`e2e-gov-${RUN}-Sx\`)`, + `- Workspace: ${ctx.workspaceId ?? "?"} ("GovSim Collective"), connection ${ctx.connectionId ?? "?"}`, + `- Scenarios fully passing: ${passCount}/${scen.length}`, ""); + if (fatal) lines.push(`**FATAL:** ${fatal}`, ""); + lines.push("## Summary", "", "| Section | Checks | Result |", "|---|---|---|"); + for (const s of sections) { + const failed = s.checks.filter((c) => !c.pass).length; + lines.push(`| ${s.name} | ${s.checks.length - failed}/${s.checks.length} | ${s.checks.length === 0 ? "—" : failed ? "**FAIL**" : "PASS"} |`); + } + lines.push(""); + for (const s of sections) { + lines.push(`## ${s.name}`, ""); + if (s.rule) lines.push("Policy rule:", "```json", JSON.stringify(s.rule), "```"); + if (s.why) lines.push(`Expected-state rationale: ${s.why}`, ""); + lines.push("API calls:", ""); + for (const c of s.calls) lines.push(`- \`${c.method} ${c.path}\` → ${c.status}${c.as ? ` (as ${c.as})` : ""}${c.req ? ` ${c.req}` : ""}`); + lines.push("", "| Check | Expected | Actual | Result |", "|---|---|---|---|"); + for (const c of s.checks) lines.push(`| ${c.label} | \`${JSON.stringify(c.expected)}\` | \`${JSON.stringify(c.actual)}\` | ${c.pass ? "PASS" : "**FAIL**"} |`); + for (const c of s.checks.filter((x) => !x.pass && x.body !== undefined && x.body !== null)) { + lines.push("", `Response body for failed check "${c.label}":`, "```json", JSON.stringify(c.body, null, 2).slice(0, 6000), "```"); + } + if (s.notes.length) lines.push("", "Notes:", ...s.notes.map((n) => `- ${n}`)); + lines.push(""); + } + lines.push("## GAPS (missing routes / features)", "", ...gaps.map((g) => `- ${g}`), ""); + lines.push("## Deviations from the task spec", "", ...deviations.map((d) => `- ${d}`), ""); + mkdirSync(dirname(REPORT), { recursive: true }); + writeFileSync(REPORT, lines.join("\n")); + log(`report written to ${REPORT}`); +} + +gap("Governed Mastodon boost/reblog: drafts only dispatch via connector.publish (status, optional providerMetadata.inReplyToId). connector-mastodon has amplify(repost) but no API route reaches it for Mastodon (POST /archive/items/:id/amplify is Telegram-only). Scenarios therefore execute a reply to the source post."); +gap("No auto-dispatch on poll pass: E2 moves the draft to 'approved' only; execution needs a separate POST /publishing/drafts/:id/publish by a publication.publish holder."); +gap("LIVE DEPLOY: publish workflows are started on Temporal task queue 'tmbh-publishing' (current API default, TEMPORAL_TASK_QUEUE unset) but the deployed scheduler worker (image built 2026-10-10) polls 'superposter-publishing'. Workflows sit Running with an unclaimed first task and scheduled_publications stay 'pending' — approved actions never reach Mastodon. Fix: set TEMPORAL_TASK_QUEUE identically on api+scheduler, or redeploy the scheduler from current source."); +gap("No read route for scheduled_publications / publication receipts (remote status id); execution is verified on the Mastodon side via the radar token."); +deviations.push("Connection created through the real OAuth authorization-code flow (radar web sign-in + consent), not with {instanceUrl, access_token} — no such route exists."); +deviations.push("Invited members join as 'Editor' (an existing role holding chatgov.vote per migration 024); u1–u3 are then PATCHed to 'Admin' via /team/members/:membershipId/role."); +deviations.push("Polls are opened explicitly (POST /chatgov/polls with draftId + supplied rule, kind yes_no) because the workspace has no posting_rules; with no live rules draftPollRule() accepts the supplied rule."); +deviations.push("S6 is run twice: in the spec's vote order (S6, expected 'vetoed' per spec but 'passed' per code) and with the veto first (S6b) to exercise the veto path."); +deviations.push("Statuses are posted with visibility 'unlisted' to keep the public timeline clean."); + +let fatal = null; +try { + await setup(); + await connectMastodon(); + await sources(); + // ONLY=S7,S8 re-runs a subset (e.g. after a transient deploy outage). + const only = process.env.ONLY ? new Set(process.env.ONLY.split(",")) : null; + let i = 0; + for (const sc of SCENARIOS) { + i++; + if (only && !only.has(sc.id)) continue; + try { await runScenario(sc, i - 1); } + catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } + } + if (!only || only.has("S7")) try { await runImmediate(i++); } + catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } +} catch (e) { + fatal = e.message; + console.error(e); + if (current) check("step completed without error", "ok", String(e.message), e.data); +} finally { + writeReport(fatal); +}