diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs index 463429e..610dd3b 100644 --- a/tests/persona-matrix-mouth.test.mjs +++ b/tests/persona-matrix-mouth.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { resolve, join } from "node:path"; -import { createMouth, createBrakeReader, guarded, redact } from "../tools/persona-matrix-mouth.mjs"; +import { createMouth, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs"; const TOKEN = "syt_SECRET_TOKEN_9f3"; const HS_NAME = "gamecult.test"; @@ -577,3 +577,24 @@ test("a same-server invitation to a room that is not a DM is neither joined nor assert.deepEqual([joinsOf(hs), leavesOf(hs)], [[], []]); } finally { await hs.close(); } }); + +test("project rooms: slug mapping, per-Persona lists, absent variable, malformed alias", () => { + assert.equal(projectRoomsVariable("eureka"), "MATRIX_ROOMS_EUREKA"); + assert.equal(projectRoomsVariable("nova-2.x"), "MATRIX_ROOMS_NOVA_2_X"); + const env = { MATRIX_ROOMS: "#swarm:t, #ops:t", MATRIX_ROOMS_EUREKA: "#eureka-proj:t,#ops:t", MATRIX_ROOMS_NOVA_2: "#nova-proj:t" }; + assert.deepEqual(roomsFor(env, "eureka"), ["#swarm:t", "#ops:t", "#eureka-proj:t"], "shared plus own, deduplicated"); + assert.deepEqual(roomsFor(env, "nova-2"), ["#swarm:t", "#ops:t", "#nova-proj:t"], "dash maps to underscore"); + assert.ok(!roomsFor(env, "nova-2").includes("#eureka-proj:t") && !roomsFor(env, "eureka").includes("#nova-proj:t")); + assert.deepEqual(roomsFor(env, "other"), ["#swarm:t", "#ops:t"], "absent variable: shared rooms only"); + assert.throws(() => roomsFor({ ...env, MATRIX_ROOMS_EUREKA: "#ok:t,nope" }, "eureka"), /MATRIX_ROOMS_EUREKA.*malformed/); +}); + +test("send to another Persona's project room is refused as UnknownRoom", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const env = { MATRIX_ROOMS: `#swarm:${HS_NAME}`, MATRIX_ROOMS_EUREKA: `#eureka-proj:${HS_NAME}`, MATRIX_ROOMS_NOVA: `#nova-proj:${HS_NAME}` }; + const mouth = mouthFor(hs, { rooms: roomsFor(env, "eureka") }); + assert.equal((await mouth.send({ requestId: "p", persona: "eureka", roomAlias: `#nova-proj:${HS_NAME}`, text: "x" })).refusal, "UnknownRoom"); + assert.equal(hs.puts().length, 0); + } finally { await hs.close(); } +}); diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index c991288..6ea8246 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -396,12 +396,32 @@ function credential(env, name) { return readFileSync(join(required(env, "CREDENTIALS_DIRECTORY"), name), "utf8").trim(); } +const ROOM_ALIAS = /^#[^\s:,]+:[^\s,]+$/; + +// The env variable holding one Persona's project rooms: MATRIX_ROOMS_, the slug +// upper-cased with "." and "-" turned into "_". +export function projectRoomsVariable(persona) { + return `MATRIX_ROOMS_${persona.toUpperCase().replace(/[.-]/g, "_")}`; +} + +// One Persona's rooms: the shared MATRIX_ROOMS plus her own project rooms, deduplicated. +// Another Persona's project variable is never read. A malformed project alias is refused. +export function roomsFor(env, persona) { + const list = text => text.split(",").map(s => s.trim()).filter(Boolean); + const name = projectRoomsVariable(persona); + const project = list(env[name] ?? ""); + const bad = project.find(alias => !ROOM_ALIAS.test(alias)); + if (bad) throw new Error(`${name} has a malformed room alias: ${JSON.stringify(bad)} (expected #name:server)`); + return [...new Set([...list(required(env, "MATRIX_ROOMS")), ...project])]; +} + // PERSONAS is the comma-separated list of Persona slugs this process speaks for. // Each slug names its credential (matrix-token-), its cursor directory under // STATE_DIRECTORY and its brake at CONTROL_ROOT//control.cc. export async function main(env = process.env) { const personas = required(env, "PERSONAS").split(",").map(s => s.trim()).filter(Boolean); if (personas.length === 0 || new Set(personas).size !== personas.length || !personas.every(p => SLUG.test(p))) throw new Error("PERSONAS must be distinct slugs"); + if (new Set(personas.map(projectRoomsVariable)).size !== personas.length) throw new Error("PERSONAS must map to distinct MATRIX_ROOMS_ variables"); const tokens = new Map(personas.map(p => [p, credential(env, `matrix-token-${p}`)])); const chatKey = credential(env, "chat-key"); const secrets = [...tokens.values(), chatKey]; @@ -410,13 +430,13 @@ export async function main(env = process.env) { const log = line => console.error(redact(line, secrets)); const homeserver = required(env, "MATRIX_HOMESERVER").replace(/\/$/, ""); const serverName = required(env, "MATRIX_SERVER_NAME"); - const rooms = required(env, "MATRIX_ROOMS").split(",").map(s => s.trim()).filter(Boolean); + const roomsByPersona = new Map(personas.map(persona => [persona, roomsFor(env, persona)])); const stateRoot = required(env, "STATE_DIRECTORY"); const controlRoot = env.CONTROL_ROOT ?? "/etc/gamecult/minds"; const mouths = new Map(personas.map(persona => { mkdirSync(join(stateRoot, persona), { recursive: true }); return [persona, createMouth({ - persona, homeserver, serverName, rooms, token: tokens.get(persona), log: line => log(`${persona}: ${line}`), + persona, homeserver, serverName, rooms: roomsByPersona.get(persona), token: tokens.get(persona), log: line => log(`${persona}: ${line}`), state: cultcacheState({ runtime, path: join(stateRoot, persona, "cursor.cc") }), brake: createBrakeReader({ controlPath: join(controlRoot, persona, "control.cc"), readStore: runtime.readSingleFileStore, decode: runtime.msgpack.decode }), })];