Public: anyone can read this; the crew can change it.

A note from the app's own origin is a visitor's unless it carries the forge panel's one-use proof #5743

Merged opened this

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

Merged

scout/forge:scout/5612-a-note-from-the-app-s-own-origin-is-a-vi → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/5612-a-note-from-the-app-s-own-origin-is-a-vi was merged into main as b0f0f12.

Review

Sign in to review this change.
8 files changed +227 −20
README.md +11
@@ -989,6 +989,17 @@ Under the box, four starter sentences are plain links (no script):
989989 server-rendered page paints up to 100 ms later (a page sent with a
990990 `Content-Length`, or one that starts with something other than markup, does
991991 not wait). Event streams and WebSockets are untouched.
992 - **A signed-in note needs the panel's one-use proof (#5612).** The grant
993 cookie alone no longer makes a note `@name`: the forge puts a one-use token in
994 the panel's script tag on a signed-in page load, the panel sends it as
995 `x-forge-proof` and each answer carries the next. Without it, a reused one or
996 one for another grant or host, the sender is a visitor and `admin` is false.
997 This stops an app's code that merely holds the cookie (a `fetch` from its
998 page). It does **not** stop app code that runs in the same page and watches
999 for the tag or patches `fetch` before the panel runs: the token reaches the
1000 panel through the page, so that code can read it. Closing that needs the
1001 panel in a cross-origin frame, which is not built. If a call fails the panel
1002 asks for a reload, since the chain is then spent.
9921003 - **The forge button is a feedback door (#4758).** One tap opens *Tell the
9931004 builders*: a text box and Send, no account. The note is posted to the app's
9941005 own host (`POST /__forge/note`, same-origin only) and filed as an issue on
src/apps/feedback.ts +46 −3
@@ -35,6 +35,8 @@ export interface FeedbackAsk {
3535 readonly sealed: boolean;
3636 /** The person the app's grant names, if any. */
3737 readonly viewerId: string | undefined;
38 /** The grant cookie's value, which the panel's proof is bound to. */
39 readonly grantCookie?: string | undefined;
3840 readonly secure: boolean;
3941 /** The client's limit key, from the socket (`clientBucket`). */
4042 readonly source: string;
@@ -85,6 +87,39 @@ function doorFor(ask: FeedbackAsk): Door | undefined {
8587 return undefined;
8688 }
8789
90 // ---- the panel's one-use proof ----
91 //
92 // The app's own code runs on this origin and can send the person's grant
93 // cookie with a fetch, so a cookie alone does not say the forge panel asked.
94 // The panel's script tag carries a token the forge minted for that page load;
95 // each answer to a request that spent one carries the next in `x-forge-proof`.
96 // Held in memory, as the process is one: a restart makes everyone a visitor
97 // until the next page load.
98
99 export const PROOF_HEADER = "x-forge-proof";
100 const PROOF_TTL_MS = 24 * 3_600_000;
101 const PROOF_MAX = 20_000;
102 const proofs = new Map<string, { bind: string; expires: number }>();
103
104 const bindOf = (grantCookie: string, host: string): string => sha256(`${host}\n${grantCookie}`);
105
106 /** A fresh token for this grant cookie on this host. Never put it in a URL. */
107 export function mintProof(grantCookie: string, host: string, now = Date.now()): string {
108 while (proofs.size >= PROOF_MAX) proofs.delete(proofs.keys().next().value!);
109 const token = randomBytes(24).toString("base64url");
110 proofs.set(token, { bind: bindOf(grantCookie, host), expires: now + PROOF_TTL_MS });
111 return token;
112 }
113
114 /** Spend a token: true once, for the grant and host it was minted for. */
115 function spendProof(token: string | null, grantCookie: string | undefined, host: string | null, now = Date.now()): boolean {
116 if (token === null || grantCookie === undefined || host === null) return false;
117 const held = proofs.get(token);
118 if (held === undefined) return false;
119 proofs.delete(token);
120 return held.expires > now && held.bind === bindOf(grantCookie, host);
121 }
122
88123 /** The sender: a person's key and name, or the anonymous cookie's. */
89124 function senderOf(ask: FeedbackAsk, door: Door): { sender: Sender; fresh?: string } {
90125 if (door.viewer !== undefined)
@@ -155,12 +190,12 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> {
155190 return json(201, { note: sent.value, name: sender.name }, cookies);
156191 }
157192
158 function list(ask: FeedbackAsk, door: Door): Response {
193 function list(ask: FeedbackAsk, door: Door, proven: boolean): Response {
159194 const { sender, fresh } = senderOf(ask, door);
160195 const notes = notesFor(ask.app, ask.record, door.repo, ask.forgeUrl, sender);
161196 return json(
162197 200,
163 { name: sender.name, notes, admin: door.viewer !== undefined && canAdmin(ask.app.db, door.repo, door.viewer) },
198 { name: sender.name, notes, admin: proven && door.viewer !== undefined && canAdmin(ask.app.db, door.repo, door.viewer) },
164199 fresh === undefined ? {} : { "set-cookie": visitorCookie(fresh, ask.secure) },
165200 );
166201 }
@@ -171,11 +206,19 @@ export async function feedback(ask: FeedbackAsk): Promise<Response | undefined>
171206 const method = ask.request.method;
172207 if (path === "/__forge/note/moderate") return json(404, { error: "not_found" });
173208 if (path !== "/__forge/note" && path !== "/__forge/notes") return undefined;
209 const host = ask.request.headers.get("host");
210 const proven = spendProof(ask.request.headers.get(PROOF_HEADER), ask.grantCookie, host);
211 const answer = await answerNote(proven ? ask : { ...ask, viewerId: undefined }, path, method, proven);
212 if (proven) answer.headers.set(PROOF_HEADER, mintProof(ask.grantCookie!, host!));
213 return answer;
214 }
215
216 async function answerNote(ask: FeedbackAsk, path: string, method: string, proven: boolean): Promise<Response> {
174217 const door = doorFor(ask);
175218 if (door === undefined) return json(404, { error: "not_found" });
176219 if (path === "/__forge/notes") {
177220 if (method !== "GET") return json(405, { error: "method" }, { allow: "GET" });
178 return list(ask, door);
221 return list(ask, door, proven);
179222 }
180223 if (method !== "POST") return json(405, { error: "method" }, { allow: "POST" });
181224 if (!sameOrigin(ask.request)) return json(403, { error: "cross_origin" });
src/apps/note_words.ts +3
@@ -14,3 +14,6 @@ export const NOTE_FAILED = "That did not go through. Try again.";
1414
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.";
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/proxy.ts +19 −1
@@ -36,7 +36,7 @@ import { loadKey } from "../kernel/secret.ts";
3636 import { previewEnv, type EnvName } from "./data.ts";
3737 import type { AppsRuntime, EnvStatus } from "./runtime.ts";
3838 import { crewNow, tellLink } from "./crew.ts";
39 import { feedback } from "./feedback.ts";
39 import { feedback, mintProof } from "./feedback.ts";
4040 import { clientBucket } from "../http/peer.ts";
4141 import { doorPage } from "./door.ts";
4242 import { notFound } from "../http/respond.ts";
@@ -673,6 +673,7 @@ export async function proxyApp(
673673 env: target.env,
674674 sealed,
675675 viewerId,
676 grantCookie: cookie,
676677 secure:
677678 url.protocol === "https:" ||
678679 request.headers.get("x-forwarded-proto") === "https",
@@ -811,10 +812,20 @@ export async function proxyApp(
811812 } catch {
812813 return unavailable(runtime, target, status, "the app stopped answering");
813814 }
815 // A signed-in person's page load gets the panel's one-use proof, and so
816 // is theirs alone: never stored or shared. A page the app's own script
817 // fetched (dest "empty", or a same-origin frame) gets none.
818 const grantCookie = cookieValue(request, grantCookieName(target));
819 const proof =
820 vouched?.who !== undefined && grantCookie !== undefined && proofWanted(request)
821 ? mintProof(grantCookie, request.headers.get("host") ?? "")
822 : undefined;
823 if (proof !== undefined) out.set("cache-control", "private, no-store");
814824 const page = withToolbar(
815825 bytes,
816826 runtime.options.forgeUrl,
817827 pillWhere(runtime, target, vouched?.who),
828 proof,
818829 );
819830 let sent = page;
820831 // Whether it is gzipped depends on the client, so every copy says so.
@@ -847,6 +858,13 @@ export async function proxyApp(
847858 );
848859 }
849860
861 /** Whether a request is a page load the panel may be proved on (see `mintProof`). */
862 export function proofWanted(request: Request): boolean {
863 const dest = request.headers.get("sec-fetch-dest");
864 if (dest === null || dest === "document") return true;
865 return dest === "iframe" && request.headers.get("sec-fetch-site") === "cross-site";
866 }
867
850868 /**
851869 * An event stream, passed through untouched while it keeps the environment
852870 * busy: held from its first byte until it ends, fails, or the browser
src/apps/toolbar.ts +36 −6
@@ -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 } from "./note_words.ts";
16 import { NOTE_EMPTY, NOTE_FAILED, PROOF_LOST } from "./note_words.ts";
1717
1818 /** The client, as plain source: no `eval`, no inline handlers. */
1919 export const toolbarJs = `(function () {
@@ -31,6 +31,34 @@ export const toolbarJs = `(function () {
3131 var pill = document.createElement("button");
3232 pill.type = "button";
3333 var d = me.dataset || {};
34 // The one-use proof the forge put on this page load: taken, then gone from the page.
35 var proof = d.proof || "";

The token is readable here by any earlier app script (a MutationObserver or a patched window.fetch) before this line strips it. Deliver it so app JS cannot see it, and capture the native fetch first.

36 if (me.removeAttribute) me.removeAttribute("data-proof");
37 var queue = Promise.resolve();
38 var lost = false;
outdated

If the fetch rejects, or the response has no next token, proof stays spent and every later call silently becomes a visitor's. Tell the person to reload.

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 // One call at a time, each carrying the proof the last answer left.
46 function call(url, init) {
47 var run = function () {
48 init.headers = Object.assign({}, init.headers);
49 var sent = proof;
50 if (sent) init.headers["x-forge-proof"] = sent;
51 return fetch(url, init).then(function (r) {
52 var next = r.headers.get("x-forge-proof");
53 if (next) proof = next;
54 else if (sent) broke();
55 return r;
56 }, function (e) { if (sent) broke(); throw e; });
57 };
58 var p = queue.then(run, run);
59 queue = p.catch(function () {});
60 return p;
61 }
3462 var where = d.env === "production" ? "production" : d.pull ? "preview of pull #" + d.pull : "";
3563 pill.setAttribute("aria-label", [d.app, where].filter(Boolean).join(", ") || "Forge");
3664 pill.setAttribute("aria-expanded", "false");
@@ -141,7 +169,7 @@ export const toolbarJs = `(function () {
141169 });
142170 }
143171 function refresh(sent) {
144 fetch("/__forge/notes", { credentials: "same-origin", cache: "no-store" })
172 call("/__forge/notes", { credentials: "same-origin", cache: "no-store" })
145173 .then(function (r) { return r.ok ? r.json() : null; })
146174 .then(function (j) {
147175 if (!j || !Array.isArray(j.notes)) return;
@@ -160,7 +188,7 @@ export const toolbarJs = `(function () {
160188 tellGo.setAttribute("aria-busy", "true");
161189 tellGo.style.opacity = ".6";
162190 function idle() { tellGo.disabled = false; tellGo.removeAttribute("aria-busy"); tellGo.style.opacity = "1"; }
163 fetch("/__forge/note", {
191 call("/__forge/note", {
164192 method: "POST",
165193 credentials: "same-origin",
166194 headers: { "content-type": "application/json" },
@@ -421,7 +449,7 @@ export function escapeAttr(text: string): string {
421449 }
422450
423451 /** The script tag, with where-you-are as escaped data attributes. */
424 export function toolbarTag(forgeUrl: string, where?: PillWhere): string {
452 export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string): string {
425453 const data =
426454 where === undefined
427455 ? ""
@@ -435,7 +463,8 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere): string {
435463 .filter((pair): pair is [string, string] => pair[1] !== undefined)
436464 .map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`)
437465 .join("");
438 return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data} defer></script>`;
466 const token = proof === undefined ? "" : ` data-proof="${escapeAttr(proof)}"`;
467 return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data}${token} defer></script>`;
439468 }
440469
441470 /** `bytes` with the pill's tag before the last `</body>`, or at the end. */
@@ -443,8 +472,9 @@ export function withToolbar(
443472 bytes: Uint8Array,
444473 forgeUrl: string,
445474 where?: PillWhere,
475 proof?: string,
446476 ): Uint8Array<ArrayBuffer> {
447 const tag = Buffer.from(toolbarTag(forgeUrl, where));
477 const tag = Buffer.from(toolbarTag(forgeUrl, where, proof));
448478 const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
449479 // latin1 is one character per byte, so an index in it is a byte offset.
450480 const at = buffer.toString("latin1").toLowerCase().lastIndexOf("</body");
test/apps-crew-pill.test.ts +4 −3
@@ -197,9 +197,10 @@ describe("the crew line", () => {
197197
198198 test("the script asks once, same-origin, and never reads the page or its cookies", () => {
199199 expect(toolbarJs).toContain("/__forge/crew");
200 // The crew line is asked once; the feedback door (#4758) adds the
201 // sender's notes, polled only while the panel is open, and the send.
202 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(3);
200 // One fetch is the crew line; the other is `call`, the feedback door's
outdated

Wrap this comment; it is one very long line.

201 // single path (#4758): the sender's notes, polled only while the panel
202 // is open, and the send, each carrying the proof header in and out.
203 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2);
203204 expect(toolbarJs.match(/setInterval/g)?.length).toBe(1);
204205 expect(toolbarJs).not.toMatch(
205206 /document\.cookie|querySelector|getElementById|setTimeout/,
test/apps-feedback.test.ts +106 −5
@@ -4,13 +4,14 @@
44 * a status that follows the issue, and the owner can hide or ban.
55 */
66
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { newApp } from "../src/actions/apps.ts";
13 import { AppsRuntime } from "../src/apps/runtime.ts";
13 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
14 import { PROOF_LOST } from "../src/apps/note_words.ts";
1415 import { toolbarJs } from "../src/apps/toolbar.ts";
1516 import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts";
1617 import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts";
@@ -85,6 +86,24 @@ describe("the forge button takes notes from anyone", () => {
8586 });
8687 const post = (text: string, cookie = "", source = "", slug = "notes") =>
8788 at(slug, "/__forge/note", { method: "POST", body: JSON.stringify({ text, page: `http://${host(slug)}/lists?x=1`, viewport: "390x844" }) }, cookie, source);
89 /** A page load through the proxy; the app behind it is a stub that serves a small page. */
90 const page = async (slug: string, cookie: string, headers: Record<string, string> = {}): Promise<{ res: Response; proof: string | undefined }> => {
91 const backend = Bun.serve({ port: 0, fetch: () => new Response("<html><body>hi</body></html>", { headers: { "content-type": "text/html" } }) });
92 const spy = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
93 () => ({ env: "production", state: "running", port: backend.port, commitOid: "a".repeat(40) }) as EnvStatus,
94 );
95 try {
96 const res = await at(slug, "/", { headers }, cookie);
97 const text = await res.text();
98 return { res, proof: /data-proof="([^"]+)"/.exec(text)?.[1] };
99 } finally {
100 spy.mockRestore();
101 backend.stop(true);
102 }
103 };
104 const withProof = (proof: string | undefined, headers: Record<string, string> = {}) => ({ ...headers, ...(proof ? { "x-forge-proof": proof } : {}) });
105 const postAs = (slug: string, cookie: string, proof: string | undefined, text = "A note") =>
106 at(slug, "/__forge/note", { method: "POST", headers: withProof(proof), body: JSON.stringify({ text, page: `http://${host(slug)}/`, viewport: "390x844" }) }, cookie, `10.9.${Math.floor(Math.random() * 250)}.${Math.floor(Math.random() * 250)}`);
88107 const cookieOf = (res: Response) => (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
89108
90109 test("a visitor's note files a from-app issue with page, viewport and time", async () => {
@@ -186,7 +205,8 @@ describe("the forge button takes notes from anyone", () => {
186205 expect(issues()).toBe(before);
187206 expect((await at("notes--98", "/__forge/notes")).status).toBe(404);
188207 const owner = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "notes")!, "pull-98");
189 expect((await post("from a person", owner, "", "notes--98")).status).toBe(201);
208 const { proof } = await page("notes--98", owner);
209 expect((await postAs("notes--98", owner, proof, "from a person")).status).toBe(201);
190210 expect(issues()).toBe(before + 1);
191211 // The same app's production stays open to visitors.
192212 expect((await post("still open", "", "", "notes")).status).toBe(201);
@@ -299,14 +319,15 @@ describe("the forge button takes notes from anyone", () => {
299319
300320 test("a private app takes notes from a signed-in reader, as themselves, and from no one else", async () => {
301321 const reader = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "diary")!);
302 const res = await post("A note on the diary", reader, "10.5.0.1", "diary");
322 const first = await page("diary", reader);
323 const res = await postAs("diary", reader, first.proof, "A note on the diary");
303324 expect(res.status).toBe(201);
304325 expect(((await res.json()) as { name: string }).name).toBe("@alice");
305326 const repo = getRepo(app.db, "alice", "diary")!;
306327 const issue = app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1 ORDER BY number DESC").get(repo.id)!;
307328 expect(issue.body).toContain("by @alice.");
308329 expect(issue.body).toContain("Untrusted data");
309 const mine = (await (await at("diary", "/__forge/notes", {}, reader)).json()) as { notes: unknown[]; admin: boolean };
330 const mine = (await (await at("diary", "/__forge/notes", { headers: withProof(res.headers.get("x-forge-proof")!) }, reader)).json()) as { notes: unknown[]; admin: boolean };
310331 expect(mine.notes.length).toBe(1);
311332 expect(mine.admin).toBe(true);
312333 expect((await at("diary", "/__forge/notes")).status).toBe(404);
@@ -413,6 +434,86 @@ describe("the forge button takes notes from anyone", () => {
413434 expect((await at("notes", path, { method: "POST", body: JSON.stringify({ id: secondId, action: "ban" }) }, "")).status).toBe(404);
414435 });
415436
437 test("a note from the app's own origin is a visitor's unless it carries the panel's proof", async () => {
438 const record = getAppBySlug(app.db, "notes")!;
439 const owner = await grantedCookie(app, servers.url, alice, record);
440 const list = async (cookie: string, proof?: string) =>
441 (await (await at("notes", "/__forge/notes", { headers: withProof(proof) }, cookie)).json()) as { name: string; admin: boolean };
442
443 // Unproven: the grant cookie alone is a visitor.
444 const bare = await postAs("notes", owner, undefined);
445 expect(bare.status).toBe(201);
446 expect(((await bare.json()) as { name: string }).name).toMatch(/^visitor-/);
447 expect(bare.headers.get("x-forge-proof")).toBeNull();
448 const seen = await list(owner);
449 expect(seen.name).toMatch(/^visitor-/);
450 expect(seen.admin).toBe(false);
451
452 // Proven: @alice, the list is theirs, and the answer carries the next token.
453 const loaded = await page("notes", owner);
454 expect(loaded.proof).toBeTruthy();
455 expect(loaded.res.headers.get("cache-control")).toBe("private, no-store");
456 const proven = await postAs("notes", owner, loaded.proof);
457 expect(((await proven.json()) as { name: string }).name).toBe("@alice");
458 const next = proven.headers.get("x-forge-proof")!;
459 expect(next).toBeTruthy();
460 expect(next).not.toBe(loaded.proof);
461 const mine = await list(owner, next);
462 expect(mine.name).toBe("@alice");
463 expect(mine.admin).toBe(true);
464
465 // Reused: the spent token is a visitor.
466 const reused = await postAs("notes", owner, loaded.proof);
467 expect(((await reused.json()) as { name: string }).name).toMatch(/^visitor-/);
468 expect(reused.headers.get("x-forge-proof")).toBeNull();
469
470 // Another grant's token, and another host's.
471 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
472 if (!bob.ok) throw new Error(bob.error);
473 const bobs = await grantedCookie(app, servers.url, bob.value, record);
474 const foreignGrant = await page("notes", owner);
475 expect((await list(bobs, foreignGrant.proof)).name).toMatch(/^visitor-/);
476 const foreignHost = await page("notes", owner);
477 const onOther = await at("diary", "/__forge/notes", { headers: withProof(foreignHost.proof) }, await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "diary")!));
478 expect(onOther.status).toBe(404);
479
480 // A same-origin fetch of the page gets no token; a document load and a cross-site frame do.
481 expect((await page("notes", owner, { "sec-fetch-dest": "empty", "sec-fetch-site": "same-origin" })).proof).toBeUndefined();
482 expect((await page("notes", owner, { "sec-fetch-dest": "iframe", "sec-fetch-site": "same-origin" })).proof).toBeUndefined();
483 expect((await page("notes", owner, { "sec-fetch-dest": "document" })).proof).toBeTruthy();
484 expect((await page("notes", owner, { "sec-fetch-dest": "iframe", "sec-fetch-site": "cross-site" })).proof).toBeTruthy();
485 // A visitor with no grant is given none.
486 expect((await page("notes", "")).proof).toBeUndefined();
487 });
488
489 test("app code that holds the cookie and a copy of a spent or stale token is refused", async () => {
490 const owner = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "notes")!);
491 const loaded = await page("notes", owner);
492 // The panel spends the page's token and gets the next; a hostile script that
493 // copied the page's token, or guesses one, acts as a visitor.
494 const panel = await postAs("notes", owner, loaded.proof);
495 expect(((await panel.json()) as { name: string }).name).toBe("@alice");
496 for (const stolen of [loaded.proof, "A".repeat(32), ""]) {
497 const res = await postAs("notes", owner, stolen);
498 expect(((await res.json()) as { name: string }).name).toMatch(/^visitor-/);
499 expect(res.headers.get("x-forge-proof")).toBeNull();
500 }
501 // The chain's next token is still good for the panel.
502 const next = panel.headers.get("x-forge-proof")!;
503 const again = await at("notes", "/__forge/notes", { headers: withProof(next) }, owner);
504 expect(((await again.json()) as { name: string }).name).toBe("@alice");
505 });
506
507 test("the panel says so when its proof chain breaks", () => {
508 expect(toolbarJs).toContain("broke()");
509 expect(toolbarJs).toContain(PROOF_LOST);
510 });
511
512 test("the panel takes the proof from its tag, strips it, and sends it with each call", () => {
513 expect(toolbarJs).toContain('removeAttribute("data-proof")');
514 expect(toolbarJs).toContain("x-forge-proof");
515 });
516
416517 test("the clock is the real one again", () => {
417518 noteClock.now = () => Date.now();
418519 });
test/toolbar-actions.test.ts +2 −2
@@ -90,9 +90,9 @@ describe("toolbar actions", () => {
9090 expect(toolbarJs).toContain('"/toolbar"');
9191 // Its requests are all to the app's own origin: the crew line (#2233) and,
9292 // for the feedback door (#4758), the sender's notes and their status.
93 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(3);
93 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2);
9494 expect(toolbarJs).toContain('fetch("/__forge/crew"');
95 expect(toolbarJs).toContain('fetch("/__forge/note"');
95 expect(toolbarJs).toContain('call("/__forge/note"');
9696 expect(toolbarJs).not.toMatch(/FormData|\.send\(/);
9797 expect(toolbarJs).not.toContain("XMLHttpRequest");
9898 expect(toolbarJs).not.toContain("document.cookie");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs