Public: anyone can read this; the crew can change it.
The forge panel's proof chain survives a failed send: a rejected fetch or an error answer keeps the token #5889
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5355-the-forge-panel-s-proof-chain-survives-a → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +45
−18
src/apps/note_words.ts −2
| @@ -15,5 +15,3 @@ export const NOTE_FAILED = "That did not go through. Try again."; | ||
| 15 | 15 | /** A note longer than one issue body may be. */ |
| 16 | 16 | export const NOTE_TOO_LONG = "That is too long to send in one go."; |
| 17 | 17 | |
| 18 | /** The panel's proof chain broke: the page must be loaded again to be known as its signed-in person. */ | |
| 19 | export const PROOF_LOST = "Reload this page to send as yourself; until then notes go as a visitor."; |
src/apps/toolbar.ts +4 −12
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { staticResponse } from "../web/asset.ts"; |
| 15 | 15 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | import { NOTE_EMPTY, NOTE_FAILED, PROOF_LOST } from "./note_words.ts"; | |
| 16 | import { NOTE_EMPTY, NOTE_FAILED } from "./note_words.ts"; | |
| 17 | 17 | |
| 18 | 18 | /** The client, as plain source: no `eval`, no inline handlers. */ |
| 19 | 19 | export const toolbarJs = `(function () { |
| @@ -35,25 +35,17 @@ export const toolbarJs = `(function () { | ||
| 35 | 35 | var proof = d.proof || ""; |
| 36 | 36 | if (me.removeAttribute) me.removeAttribute("data-proof"); |
| 37 | 37 | var queue = Promise.resolve(); |
| 38 | var lost = false; | |
| 39 | // The chain broke (a failed call, or an answer with no next proof): say so once, in the panel. | |
| 40 | function broke() { | |
| 41 | proof = ""; | |
| 42 | lost = true; | |
| 43 | if (typeof tellSay !== "undefined") tellSay.textContent = ${JSON.stringify(PROOF_LOST)}; | |
| 44 | } | |
| 45 | 38 | // One call at a time, each carrying the proof the last answer left. |
| 46 | 39 | function call(url, init) { |
| 47 | 40 | var run = function () { |
| 48 | 41 | init.headers = Object.assign({}, init.headers); |
| 49 | var sent = proof; | |
| 50 | if (sent) init.headers["x-forge-proof"] = sent; | |
| 42 | if (proof) init.headers["x-forge-proof"] = proof; | |
| 43 | // Only an answer's next proof replaces the one held: a rejected fetch, or an answer that did not spend it, keeps the chain. | |
| 51 | 44 | return fetch(url, init).then(function (r) { |
| 52 | 45 | var next = r.headers.get("x-forge-proof"); |
| 53 | 46 | if (next) proof = next; |
| 54 | else if (sent) broke(); | |
| 55 | 47 | return r; |
| 56 | }, function (e) { if (sent) broke(); throw e; }); | |
| 48 | }); | |
| 57 | 49 | }; |
| 58 | 50 | var p = queue.then(run, run); |
| 59 | 51 | queue = p.catch(function () {}); |
test/apps-feedback.test.ts +41 −4
| @@ -11,7 +11,6 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { newApp } from "../src/actions/apps.ts"; |
| 13 | 13 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; |
| 14 | import { PROOF_LOST } from "../src/apps/note_words.ts"; | |
| 15 | 14 | import { toolbarJs } from "../src/apps/toolbar.ts"; |
| 16 | 15 | import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts"; |
| 17 | 16 | import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts"; |
| @@ -504,9 +503,47 @@ describe("the forge button takes notes from anyone", () => { | ||
| 504 | 503 | expect(((await again.json()) as { name: string }).name).toBe("@alice"); |
| 505 | 504 | }); |
| 506 | 505 | |
| 507 | test("the panel says so when its proof chain breaks", () => { | |
| 508 | expect(toolbarJs).toContain("broke()"); | |
| 509 | expect(toolbarJs).toContain(PROOF_LOST); | |
| 506 | test("a refused note still hands on the next proof, and the next note goes as the person", async () => { | |
| 507 | const owner = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "notes")!); | |
| 508 | const loaded = await page("notes", owner); | |
| 509 | let proof = loaded.proof; | |
| 510 | let limited: Response | undefined; | |
| 511 | for (let i = 0; i <= PER_SENDER + 1 && limited === undefined; i++) { | |
| 512 | const res = await postAs("notes", owner, proof, `chain ${i}`); | |
| 513 | proof = res.headers.get("x-forge-proof") ?? undefined; | |
| 514 | expect(proof).toBeTruthy(); | |
| 515 | if (res.status === 429) limited = res; | |
| 516 | } | |
| 517 | expect(limited).toBeDefined(); | |
| 518 | const bad = await at("notes", "/__forge/note", { method: "POST", headers: withProof(proof), body: "{" }, owner); | |
| 519 | expect(bad.status).toBe(400); | |
| 520 | proof = bad.headers.get("x-forge-proof") ?? undefined; | |
| 521 | expect(proof).toBeTruthy(); | |
| 522 | const rude = await at("notes", "/__forge/note", { method: "POST", headers: withProof(proof), body: JSON.stringify({ text: "this is sh1t" }) }, owner); | |
| 523 | expect(rude.status).toBe(422); | |
| 524 | proof = rude.headers.get("x-forge-proof") ?? undefined; | |
| 525 | expect(proof).toBeTruthy(); | |
| 526 | const mine = (await (await at("notes", "/__forge/notes", { headers: withProof(proof) }, owner)).json()) as { name: string }; | |
| 527 | expect(mine.name).toMatch(/^@/); | |
| 528 | }); | |
| 529 | ||
| 530 | test("a send the network rejected is retried with the same proof and goes as the person", async () => { | |
| 531 | const owner = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "notes")!); | |
| 532 | const loaded = await page("notes", owner); | |
| 533 | // The first send never arrives, so the door never sees it; the panel keeps its token. | |
| 534 | const retry = await postAs("notes", owner, loaded.proof, "second try"); | |
| 535 | expect(retry.status === 201 || retry.status === 429).toBe(true); | |
| 536 | expect(retry.headers.get("x-forge-proof")).toBeTruthy(); | |
| 537 | if (retry.status === 201) expect(((await retry.json()) as { name: string }).name).toMatch(/^@/); | |
| 538 | }); | |
| 539 | ||
| 540 | test("the panel keeps its proof unless an answer brings the next", () => { | |
| 541 | const call = /function call\([\s\S]*?\n \}\n/.exec(toolbarJs)![0]; | |
| 542 | expect(call).toContain("if (next) proof = next;"); | |
| 543 | expect(call).not.toMatch(/proof = ""/); | |
| 544 | expect(call).not.toContain("broke"); | |
| 545 | expect(call).not.toContain("function (e)"); | |
| 546 | expect(toolbarJs).not.toContain("broke()"); | |
| 510 | 547 | }); |
| 511 | 548 | |
| 512 | 549 | test("the panel takes the proof from its tag, strips it, and sends it with each call", () => { |
new above
offline
Removing PROOF_LOST left a trailing blank line at the end of the file. Trim it.