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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5355-the-forge-panel-s-proof-chain-survives-a → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/5355-the-forge-panel-s-proof-chain-survives-a was merged into main as b62389b.

Review

Sign in to review this change.
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.";
1515 /** A note longer than one issue body may be. */
1616 export const NOTE_TOO_LONG = "That is too long to send in one go.";
1717

Removing PROOF_LOST left a trailing blank line at the end of the file. Trim it.

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 @@
1313
1414 import { staticResponse } from "../web/asset.ts";
1515 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";
1717
1818 /** The client, as plain source: no `eval`, no inline handlers. */
1919 export const toolbarJs = `(function () {
@@ -35,25 +35,17 @@ export const toolbarJs = `(function () {
3535 var proof = d.proof || "";
3636 if (me.removeAttribute) me.removeAttribute("data-proof");
3737 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 }
4538 // One call at a time, each carrying the proof the last answer left.
4639 function call(url, init) {
4740 var run = function () {
4841 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.
5144 return fetch(url, init).then(function (r) {
5245 var next = r.headers.get("x-forge-proof");
5346 if (next) proof = next;
54 else if (sent) broke();
5547 return r;
56 }, function (e) { if (sent) broke(); throw e; });
48 });
5749 };
5850 var p = queue.then(run, run);
5951 queue = p.catch(function () {});
test/apps-feedback.test.ts +41 −4
@@ -11,7 +11,6 @@ import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { newApp } from "../src/actions/apps.ts";
1313 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
14 import { PROOF_LOST } from "../src/apps/note_words.ts";
1514 import { toolbarJs } from "../src/apps/toolbar.ts";
1615 import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts";
1716 import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts";
@@ -504,9 +503,47 @@ describe("the forge button takes notes from anyone", () => {
504503 expect(((await again.json()) as { name: string }).name).toBe("@alice");
505504 });
506505
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()");
510547 });
511548
512549 test("the panel takes the proof from its tag, strips it, and sends it with each call", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs