diff --git a/src/probe-worker.mjs b/src/probe-worker.mjs index 344e76a..5a836c9 100644 --- a/src/probe-worker.mjs +++ b/src/probe-worker.mjs @@ -167,17 +167,31 @@ export async function decideWithModel(probe, env) { } } -async function publishReply(room, text, env) { +export async function publishReply(room, text, env) { const nonce = Date.now(); const sig = await signText(room, String(nonce), text, env.TECHNOCORE_AGENT_PRIVATE_KEY); - const response = await fetch(`${env.TECHNOCORE_URL || DEFAULT_BASE_URL}/r/${encodeURIComponent(room)}`, { + const baseUrl = env.TECHNOCORE_URL || DEFAULT_BASE_URL; + const response = await fetch(`${baseUrl}/r/${encodeURIComponent(room)}`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ did: env.TECHNOCORE_AGENT_DID, sig, nonce: String(nonce), text }) }); if (!response.ok) throw new Error(`Technocore publish failed: ${response.status}`); - const accepted = await response.json(); - if (!accepted?.seq) throw new Error("Technocore did not confirm the reply"); + await response.text(); + + // Successful Technocore writes return the room's plain-text view, even when + // the request advertises JSON. Confirm the exact signed record with a fresh + // machine-readable read instead of trying to parse the write response. + const confirmation = await readJson( + `${baseUrl}/r/${encodeURIComponent(room)}?limit=50&format=json&n=${nonce}` + ); + const accepted = (confirmation?.messages || []).find((record) => + record.from === env.TECHNOCORE_AGENT_DID + && String(record.nonce) === String(nonce) + && record.text === text + && record.sig === sig + ); + if (!accepted?.seq) throw new Error("Technocore did not confirm the signed reply"); return accepted.seq; } diff --git a/tests/probe-worker.test.mjs b/tests/probe-worker.test.mjs index 81c03e3..5f24b7e 100644 --- a/tests/probe-worker.test.mjs +++ b/tests/probe-worker.test.mjs @@ -8,6 +8,7 @@ import { parseProbe, parseProbeReply, probeAgeMs, + publishReply, validateProbeDecision, verifySignedRecord } from "../src/probe-worker.mjs"; @@ -162,6 +163,32 @@ test("hot-polls configured probe rooms with a sequence cursor", async () => { } }); +test("confirms a signed write after Technocore returns its plain-text room view", async () => { + const originalFetch = globalThis.fetch; + const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]); + const privateKey = Buffer.from(await crypto.subtle.exportKey("pkcs8", pair.privateKey)).toString("base64"); + let posted; + globalThis.fetch = async (_url, options) => { + if (options?.method === "POST") { + posted = JSON.parse(options.body); + return { ok: true, text: async () => "# room technocore\n" }; + } + return { + ok: true, + json: async () => ({ messages: [{ seq: 42, from: posted.did, nonce: posted.nonce, text: posted.text, sig: posted.sig }] }) + }; + }; + try { + assert.equal(await publishReply("technocore", "probe v1 reply | run.1 | ack | bounded answer citing run.1", { + TECHNOCORE_AGENT_DID: "did:key:z6MkTest", + TECHNOCORE_AGENT_PRIVATE_KEY: privateKey, + TECHNOCORE_URL: "https://technocore.chat" + }), 42); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("accepts the official DID signature and rejects tampering", async () => { const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]); const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));