Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to this project are documented here. Format follows

### Fixed

- Signed Technocore POSTs in the MCP client and live-deal example now use manual redirect handling, so an HTTP 301/302/303 cannot be followed as a body-dropping GET and reported as a successful frame write (#202).
- `tclk_post_frame` now accepts exact decimal-string nonces in addition to safe integer
numbers, so signed Technocore nonces above JavaScript's safe-integer range are preserved
without precision loss. Unsafe numeric nonces (> 2^53 - 1) are rejected at the MCP schema
Expand Down
1 change: 1 addition & 0 deletions examples/live-deal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async function post(signer, room, frame) {
`${BASE}/r/${room}`,
{
method: "POST",
redirect: "manual",
headers: { "content-type": "application/json" },
body: JSON.stringify({ did: signer.did, sig, nonce: String(nonce), text }),
},
Expand Down
1 change: 1 addition & 0 deletions mcp/src/technocore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function createClient(opts: { baseUrl?: string; fetch?: FetchLike } = {})
assertRoomName(room);
const response = await doFetch(`${baseUrl}/r/${room}`, {
method: "POST",
redirect: "manual",
headers: { "content-type": "application/json" },
body: JSON.stringify({
did: post.did,
Expand Down
99 changes: 99 additions & 0 deletions mcp/tests/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: Apache-2.0
// Native fetch against disposable loopback endpoints: no live venue is contacted.

import { createServer, type Server } from "node:http";
import { afterEach, describe, expect, it } from "vitest";

import { createClient } from "../src/technocore.js";
import { createHandlers } from "../src/tools.js";
import { HASH_OFFER } from "./fixtures.js";

const servers: Server[] = [];
const post = { did: "transport-test", sig: "transport-test", nonce: "7", text: "test frame" };

afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
server.closeAllConnections();
})));
});

async function venue(status: number, targetBody = "ok 7") {
const calls: { method: string; url: string; body: string }[] = [];
const stored: string[] = [];
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk) => { body += chunk; });
request.on("end", () => {
calls.push({ method: request.method!, url: request.url!, body });
if (request.method === "POST") stored.push(body);
if (request.url!.startsWith("/r/") && status >= 300 && status < 400) {
response.writeHead(status, { location: "/target" });
response.end("redirected\n");
} else {
response.writeHead(request.url === "/target" ? 200 : status);
response.end(targetBody);
}
});
});
servers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing loopback address");
const baseUrl = `http://127.0.0.1:${address.port}`;
return { baseUrl, client: createClient({ baseUrl }), calls, stored };
}

describe("signed-write redirects", () => {
it.each([301, 302, 303, 307, 308])("rejects HTTP %i without following or retrying", async (status) => {
const { client, calls } = await venue(status);
await expect(client.postSigned("test", post)).rejects.toThrow(`failed with ${status}`);
expect(calls).toEqual([{ method: "POST", url: "/r/test", body: JSON.stringify(post) }]);
});

it("keeps a direct successful POST response and body", async () => {
const { client, calls } = await venue(201);
await expect(client.postSigned("test", post)).resolves.toBe("ok 7");
expect(calls).toEqual([{ method: "POST", url: "/r/test", body: JSON.stringify(post) }]);
});

it("does not report a redirected tool write as confirmed even if the origin stored it", async () => {
const { baseUrl, calls, stored } = await venue(302);
const handlers = createHandlers({ env: { TECHNOCORE_URL: baseUrl } });
const line = handlers.tclk_make_offer(HASH_OFFER).line;
await expect(handlers.tclk_post_frame({ room: "test", line, did: post.did, sig: post.sig, nonce: post.nonce }))
.rejects.toThrow("failed with 302");
// An error cannot prove that the first endpoint did not accept the write.
expect(stored).toHaveLength(1);
expect(JSON.parse(stored[0]).text).toBe(line);
expect(calls).toHaveLength(1);
});
});

describe("ordinary read redirects", () => {
it("still follows a room read redirect", async () => {
const view = { room: "test", count: 0, last_seq: 0, messages: [] };
const { client, calls } = await venue(302, JSON.stringify(view));
await expect(client.readRoom("test", 7)).resolves.toEqual(view);
expect(calls.map(({ method, url }) => ({ method, url }))).toEqual([
{ method: "GET", url: "/r/test?format=json&since=7" },
{ method: "GET", url: "/target" },
]);
});

it("still follows an export redirect", async () => {
const row = { seq: 1, ts: "2026-10-10T00:00:00Z", from: "~test", text: "hello" };
const { client, calls } = await venue(302, JSON.stringify(row) + "\n");
const records = await client.exportRoom("test");
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({ room: "test", seq: 1, line: "hello" });
expect(calls.map(({ method, url }) => ({ method, url }))).toEqual([
{ method: "GET", url: "/r/test/export" },
{ method: "GET", url: "/target" },
]);
});
});
31 changes: 31 additions & 0 deletions mcp/tests/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,37 @@ describe("tclk_post_frame — tier 2, server-signed", () => {
/failed with 403: 403 bad sig/,
);
});

it("fails closed on redirects before fetch can rewrite a signed POST as GET", async () => {
const calls: { url: string; init?: RequestInit }[] = [];
const fetchLike = async (url: string, init?: RequestInit): Promise<Response> => {
calls.push({ url, init });
if (init?.redirect !== "manual") {
// Model default fetch redirect handling: a 301/302/303 POST can become a GET whose
// successful response says nothing about whether the signed write landed.
return new Response("room read", { status: 200 });
}
return new Response("", {
status: 302,
headers: { location: `https://technocore.chat/r/${ROOM}` },
});
};
const h = createHandlers({
env: {
TECHNOCORE_SIGNING_KEY: PAYER_SEED,
TECHNOCORE_URL: "http://technocore.chat",
},
fetch: fetchLike,
});

await expect(h.tclk_post_frame({ room: ROOM, line: offerLine() })).rejects.toThrow(
/POST .* failed with 302/,
);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(`http://technocore.chat/r/${ROOM}`);
expect(calls[0].init?.method).toBe("POST");
expect(calls[0].init?.redirect).toBe("manual");
});
});

describe("tclk_read_room", () => {
Expand Down
16 changes: 16 additions & 0 deletions tests/fixtures/live-deal-redirect-fetch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
//
// All HTTP is intercepted: a default-follow signed POST models the misleading final GET 200.
globalThis.fetch = async (_url, init) => {
if (init?.method === "POST") {
if (init.redirect === "manual") {
return new Response("", {
status: 302,
headers: { location: "https://example.invalid/r/tclk-offers" },
});
}
return new Response("followed GET, no signed write", { status: 200 });
}
// Allow the example's initial note write to reach its first signed POST.
return new Response("ok", { status: 200 });
};
24 changes: 24 additions & 0 deletions tests/live-deal-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: Apache-2.0
//
// Execute the real example with a mocked venue; no network requests or room writes.
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const example = fileURLToPath(new URL("../examples/live-deal.mjs", import.meta.url));
const preload = fileURLToPath(new URL("./fixtures/live-deal-redirect-fetch.mjs", import.meta.url));

describe("live-deal signed POST transport", () => {
it("refuses a redirect instead of reporting an unpersisted offer as posted", () => {
const run = spawnSync(process.execPath, ["--import", preload, example], {
encoding: "utf8",
timeout: 15_000,
env: { ...process.env, TECHNOCORE_URL: "http://localhost:8080" },
});

expect(run.error).toBeUndefined();
expect(run.status).toBe(1);
expect(run.stderr).toContain("The venue refused. post to tclk-offers: 302");
expect(run.stdout).not.toMatch(/offer\s+posted to/);
});
});