@fangorn-network/westmarch 0.0.1-dev → 0.0.3-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/consume/apps.js +4 -2
  3. package/consume/corpora.js +1 -1
  4. package/consume/directory.js +1 -1
  5. package/consume/embed.js +1 -1
  6. package/consume/lint.js +1 -1
  7. package/consume/mcp-bin.js +6 -0
  8. package/consume/mcp.js +193 -34
  9. package/consume/ort-preload.js +25 -0
  10. package/consume/rank.js +1 -1
  11. package/consume/roles.js +6 -4
  12. package/consume/shard.js +1 -1
  13. package/consume/steam.js +1 -1
  14. package/consume/taste-doc.js +1 -1
  15. package/consume/taste.js +1 -1
  16. package/consume/tools.js +28 -5
  17. package/consume/ui.js +1 -1
  18. package/consume/view-tools.js +202 -0
  19. package/consume/x402.js +94 -0
  20. package/dist/ts/eval.js +244 -0
  21. package/docs/app-to-agent.md +595 -185
  22. package/package.json +24 -10
  23. package/publish/agent-card.js +12 -2
  24. package/publish/cohort.js +1 -1
  25. package/publish/demand.js +1 -1
  26. package/publish/enrich.js +1 -1
  27. package/publish/envelope.js +1 -1
  28. package/publish/eval.ts +250 -0
  29. package/publish/graph.js +1 -1
  30. package/publish/manifest.js +1 -1
  31. package/publish/reactions.js +4 -2
  32. package/publish/settle.js +1 -1
  33. package/publish/ship.js +462 -0
  34. package/publish/terms.js +1 -1
  35. package/publish/vault.js +1 -1
  36. package/publish/view-cli.js +39 -0
  37. package/publish/view.js +434 -0
  38. package/publish/westmarch.d.ts +35 -0
  39. package/site/agent.js +44 -0
  40. package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
  41. package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
  42. package/site/dist/assets/index-CaHGhiKK.js +8 -0
  43. package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  44. package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
  45. package/site/dist/assets/roles-C9V_uDr_.js +1 -0
  46. package/site/dist/assets/roles-YDRjv_PY.js +1 -0
  47. package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  48. package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
  49. package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
  50. package/site/dist/index.html +20 -0
  51. package/site/feed.js +62 -0
  52. package/site/store.js +87 -0
  53. package/site/worker.js +103 -0
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <script type="module" crossorigin src="./assets/index-CaHGhiKK.js"></script>
4
+ <link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-Dd_uD5pT.js">
5
+ <link rel="modulepreload" crossorigin href="./assets/preload-helper-uBIymjUX.js">
6
+ <link rel="modulepreload" crossorigin href="./assets/roles-C9V_uDr_.js">
7
+
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title>Fangorn app</title>
11
+ <style>:root{--bg:#fbfaf7;--card:#fff;--fg:#1d1d1b;--muted:#6b6a66;--accent:#2b5d8a;--line:#e4e1da;--chip:#efece5;--mark:#fdeaa8;--hot:#c0392b;--edge:#b9b5ab}@media (prefers-color-scheme:dark){:root{--bg:#151514;--card:#1c1c1a;--fg:#ecebe6;--muted:#9a988f;--accent:#7fb0dc;--line:#2c2b28;--chip:#262522;--mark:#5a4a12;--hot:#e0584c;--edge:#55534e}}*{box-sizing:border-box}body{background:var(--bg);color:var(--fg);margin:0;font:16px/1.5 system-ui,sans-serif}.bar{z-index:2;background:var(--bg);border-bottom:1px solid var(--line);position:sticky;top:0}.bar>div{flex-wrap:wrap;align-items:center;gap:12px;max-width:820px;margin:0 auto;padding:10px 16px;display:flex}#home{color:var(--fg);white-space:nowrap;font-size:1.15rem;font-weight:700;text-decoration:none}#q{flex:260px}#q input{width:100%;font:inherit;border:1px solid var(--line);background:var(--card);color:inherit;border-radius:10px;padding:9px 12px}nav{gap:14px;font-size:.92rem;display:flex}nav a{color:var(--muted);text-decoration:none}nav a.on,nav a:hover{color:var(--accent)}main{max-width:820px;margin:0 auto;padding:16px 16px 48px}.about{color:var(--muted);margin:4px 0 14px}#status{color:var(--muted)}button{font:inherit;cursor:pointer}.chips{flex-wrap:wrap;gap:6px;margin:4px 0 16px;display:flex}.chip{border:1px solid var(--line);background:var(--chip);color:var(--fg);border-radius:999px;padding:3px 11px;font-size:.86rem}.chip.on{background:var(--accent);border-color:var(--accent);color:var(--bg)}h2{text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:26px 0 10px;font-size:.78rem}h2.page{text-transform:none;letter-spacing:0;color:var(--fg);font-size:1.2rem}article{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:12px;padding:14px 16px}article header{flex-direction:column;margin-bottom:4px;display:flex}article time,small{color:var(--muted);font-size:.8rem}h3{margin:0;font-size:1.02rem}ul{margin:0;padding:0;list-style:none}.row{border-top:1px solid var(--line);align-items:flex-start;gap:10px;padding:9px 0;display:flex}article .row:first-child,.list .row:first-child{border-top:0}.list{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:4px 16px}.main{overflow-wrap:anywhere;flex-direction:column;flex:1;gap:2px;min-width:0;display:flex}.title{color:var(--accent);text-decoration:none}.title:hover{text-decoration:underline}.detail{color:var(--muted);margin:0;font-size:.9rem}mark{background:var(--mark);color:inherit;border-radius:2px}.star{color:var(--muted);background:0 0;border:0;padding:2px 4px;font-size:1.15rem;line-height:1}.star.on{color:#d4a017}.more,.linkish{color:var(--accent);background:0 0;border:0;padding:6px 0 0;font-size:.9rem}.resultsbar{color:var(--muted);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px;font-size:.9rem;display:flex}.sort{align-items:center;gap:6px;display:flex}.hint,.empty{color:var(--muted);font-size:.9rem}.record h2{text-transform:none;letter-spacing:0;color:var(--fg);margin:4px 0 10px;font-size:1.3rem}.record dl{grid-template-columns:max-content 1fr;gap:2px 14px;margin:12px 0;font-size:.88rem;display:grid}.record dt{color:var(--muted);text-transform:capitalize}.record dd{margin:0}.acts{flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0 4px;display:flex}.act{border:1px solid var(--line);background:var(--chip);color:var(--fg);border-radius:8px;padding:5px 12px;font-size:.88rem;text-decoration:none}.doc iframe{border:1px solid var(--line);background:#fff;border-radius:8px;width:100%;height:80vh;margin-top:12px}body.wide main,body.wide .bar>div{max-width:1200px}.mapbar input{width:100%;font:inherit;border:1px solid var(--line);background:var(--card);color:inherit;border-radius:10px;margin-bottom:8px;padding:9px 12px}.mapwrap{grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:start;gap:16px;display:grid}@media (width<=760px){.mapwrap{grid-template-columns:1fr}}svg.map{width:100%;height:auto;display:block;position:sticky;top:70px}svg.map path{stroke:var(--edge);stroke-width:.8px;cursor:pointer}svg.map path:hover,svg.map path:focus{stroke:var(--accent);stroke-width:2px;outline:none}svg.map path.near{stroke:var(--accent);stroke-width:2px;stroke-dasharray:4 3}svg.map path.on{stroke:var(--fg);stroke-width:2.6px}.h0{fill:var(--card);background:var(--card)}.h1{fill:color-mix(in srgb, var(--hot) 18%, var(--card));background:color-mix(in srgb, var(--hot) 18%, var(--card))}.h2{fill:color-mix(in srgb, var(--hot) 36%, var(--card));background:color-mix(in srgb, var(--hot) 36%, var(--card))}.h3{fill:color-mix(in srgb, var(--hot) 56%, var(--card));background:color-mix(in srgb, var(--hot) 56%, var(--card))}.h4{fill:color-mix(in srgb, var(--hot) 78%, var(--card));background:color-mix(in srgb, var(--hot) 78%, var(--card))}.h5{fill:var(--hot);background:var(--hot)}.sw{border:1px solid var(--edge);vertical-align:-1px;width:14px;height:12px;display:inline-block}.panel h3{margin:4px 0}.panel .chips{align-items:center;margin:6px 0}.panel .list{margin-top:8px}footer{max-width:820px;color:var(--muted);margin:0 auto;padding:0 16px 32px;font-size:.8rem}footer a{color:var(--accent)}</style>
12
+ <header class="bar"><div>
13
+ <a id="home" href="#/"><span id="name">Fangorn app</span></a>
14
+ <form id="q" role="search"><input type="search" disabled placeholder="Loading…" aria-label="Search"></form>
15
+ <nav><a href="#/saved">Saved<span id="nsaved"></span></a><a href="#/history">History</a></nav>
16
+ </div></header>
17
+ <main id="view"><p id="status">Loading the data…</p></main>
18
+ <footer>Search runs in your browser; your searches and saved items stay on this device unless you export them.
19
+ Documents are the publishers' own, shown as they published them. Agents: <a href="./.well-known/agent-card.json">agent card</a>.</footer>
20
+ </html>
package/site/feed.js ADDED
@@ -0,0 +1,62 @@
1
+ // The front page without a query: what is coming up and what just happened, one card per
2
+ // occasion (a meeting, an issue, an episode), from the roles the app declared. Nothing here
3
+ // knows what a row is: an occasion is the rows sharing a subtitle and a facet value, and
4
+ // its date is their temporal field.
5
+ import { pick, subtitleOf, titleOf, values } from "../consume/roles.js";
6
+
7
+ /** The field a reader narrows by first: the app's declared facet, else the first tag with
8
+ * a handful of values (a town, not every body in every town). */
9
+ export function facetField(rows, roles) {
10
+ const count = (f) => new Set(rows.slice(0, 5000).flatMap((r) => values(r[f] ?? ""))).size;
11
+ return roles.facets?.[0] ?? roles.tags.find((f) => { const n = count(f); return n >= 2 && n <= 12; }) ?? null;
12
+ }
13
+
14
+ /** Occasions: `{ key, date, place, label, items }`, split into upcoming (soonest first) and
15
+ * past (latest first). Items repeat across an occasion's documents (its agenda, its
16
+ * minutes): one per title, the one that says what happened. */
17
+ export function occasions(rows, roles, { facet = null, only = null, today = new Date().toISOString().slice(0, 10) } = {}) {
18
+ const groups = new Map();
19
+ for (const r of rows) {
20
+ const date = String(pick(r, roles.temporal) ?? "").slice(0, 10);
21
+ if (!date) continue;
22
+ const place = facet ? String(r[facet] ?? "") : "";
23
+ if (only && place !== only) continue;
24
+ const label = subtitleOf(r, roles) ?? "";
25
+ const key = `${place}|${label}|${date}`;
26
+ let g = groups.get(key);
27
+ if (!g) groups.set(key, (g = { key, date, place, label, byTitle: new Map() }));
28
+ const t = titleOf(r, roles), k = t.toLowerCase();
29
+ const had = g.byTitle.get(k);
30
+ if (!had || detail(had, roles).length < detail(r, roles).length) g.byTitle.set(k, r);
31
+ }
32
+ const all = [...groups.values()].map(({ byTitle, ...g }) => ({ ...g, items: [...byTitle.values()] }));
33
+ return {
34
+ upcoming: all.filter((g) => g.date > today).sort((a, b) => a.date.localeCompare(b.date) || a.place.localeCompare(b.place)),
35
+ past: all.filter((g) => g.date <= today).sort((a, b) => b.date.localeCompare(a.date) || a.place.localeCompare(b.place)),
36
+ };
37
+ }
38
+
39
+ /** What a row says beyond its title: its text, less the title it opens with. */
40
+ export function detail(r, roles) {
41
+ const t = titleOf(r, roles), x = String(r.text ?? "");
42
+ const rest = x.startsWith(t.replace(/\.$/, "")) ? x.slice(t.replace(/\.$/, "").length).replace(/^[.\s]+/, "") : x;
43
+ return rest === t ? "" : rest.trim();
44
+ }
45
+
46
+ // ── self-check: `node site/feed.js` ──
47
+ if (typeof process !== "undefined" && process.argv[1]?.endsWith("/feed.js")) {
48
+ const { rolesFrom } = await import("../consume/roles.js");
49
+ const roles = rolesFrom([{ role_map: { title: "heading", subtitle: "meeting", temporal: "date", tags: ["city", "body"], text: ["text"] },
50
+ presentation: { facets: ["city"] } }]);
51
+ const row = (city, date, heading, text = heading) => ({ city, date, heading, meeting: `Board · ${date}`, body: "Board", text });
52
+ const rows = [row("A", "2026-01-01", "Budget"), row("A", "2026-01-01", "Budget", "Budget. Approved 5-0."), row("A", "2026-01-01", "Roads"),
53
+ row("B", "2026-02-01", "Parks"), row("A", "2026-03-01", "Zoning"), { heading: "undated" }];
54
+ const eq = (a, b, m) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)}`); };
55
+ eq(facetField(rows, roles), "city", "the declared facet wins");
56
+ const { upcoming, past } = occasions(rows, roles, { facet: "city", today: "2026-02-15" });
57
+ eq(upcoming.map((g) => g.key), ["A|Board · 2026-03-01|2026-03-01"], "a future date is coming up");
58
+ eq(past.map((g) => `${g.place}${g.date}`), ["B2026-02-01", "A2026-01-01"], "past, latest first");
59
+ eq(past[1].items.map((r) => detail(r, roles)), ["Approved 5-0.", ""], "one item per title, the one that says what happened");
60
+ eq(occasions(rows, roles, { facet: "city", only: "B", today: "2026-02-15" }).past.length, 1, "narrowed to one place");
61
+ console.log("feed.js self-check ok — declared facet, upcoming vs past, one item per title keeping the decision, narrowed by place");
62
+ }
package/site/store.js ADDED
@@ -0,0 +1,87 @@
1
+ // What a person does on the page, kept on their own device: what they saved, what they
2
+ // passed on, and what they searched this session. Nothing leaves the browser unless they
3
+ // export it. The saved items are also the likes a taste is built from (consume/taste.js),
4
+ // so "For you" is theirs, and so is the file they can hand an agent.
5
+ //
6
+ // Saved items are snapshots keyed by the publisher's own id (the identity role), not the
7
+ // row's CID: a record re-published with a new CID must still be the item they saved.
8
+ import { exportTaste, taste } from "../consume/taste.js";
9
+ import { packVec, unpackVec } from "../consume/embed.js";
10
+
11
+ const read = (s, k, dflt) => { try { return JSON.parse(s?.getItem(k) ?? "null") ?? dflt; } catch { return dflt; } };
12
+ const write = (s, k, v) => { try { s?.setItem(k, JSON.stringify(v)); } catch { /* private mode or full: the page still works */ } };
13
+
14
+ /** `local` keeps saved items across visits, `session` keeps this visit's searches. */
15
+ export function createStore({ local, session, app = "app" } = {}) {
16
+ const K = { saved: `${app}:saved`, passed: `${app}:passed`, history: `${app}:history` };
17
+ return {
18
+ saved: () => read(local, K.saved, []),
19
+ passed: () => read(local, K.passed, []),
20
+ history: () => read(session, K.history, []),
21
+ isSaved(key) { return this.saved().some((s) => s.key === key); },
22
+ toggleSave(item) {
23
+ const all = this.saved(), at = all.findIndex((s) => s.key === item.key);
24
+ if (at >= 0) all.splice(at, 1); else all.push({ ...item, savedAt: new Date().toISOString() });
25
+ write(local, K.saved, all);
26
+ write(local, K.passed, this.passed().filter((p) => p.key !== item.key));
27
+ return at < 0;
28
+ },
29
+ pass(item) {
30
+ write(local, K.passed, [...this.passed().filter((p) => p.key !== item.key), item].slice(-50));
31
+ write(local, K.saved, this.saved().filter((s) => s.key !== item.key));
32
+ },
33
+ remember(query) {
34
+ const q = query.trim();
35
+ if (!q) return;
36
+ write(session, K.history, [{ q, at: new Date().toISOString() }, ...this.history().filter((h) => h.q !== q)].slice(0, 50));
37
+ },
38
+ clear(which) { write(which === "history" ? session : local, K[which], []); },
39
+ /** The taste these choices make, or null before the first save. */
40
+ taste() {
41
+ const vec = (x) => ({ id: x.key, title: x.title, vector: x.v ? unpackVec(x.v) : null });
42
+ return taste(this.saved().map(vec), this.passed().map(vec));
43
+ },
44
+ /** Everything, for the person to keep or hand to an agent. */
45
+ bundle({ name = app, url = "" } = {}) {
46
+ const strip = ({ v, ...x }) => x;
47
+ return { app: name, url, exported: new Date().toISOString(), saved: this.saved().map(strip),
48
+ searches: this.history(), taste: exportTaste(this.taste()) };
49
+ },
50
+ };
51
+ }
52
+
53
+ /** A saved-item snapshot of a row: enough to list it, link it and rank by it later. */
54
+ export const snapshot = (r, { key, title, subtitle, place, date, link, detail }) => ({
55
+ key, title, subtitle, place, date, url: link ?? null, detail: detail ?? "", ...(r.vector ? { v: packVec(Array.from(r.vector)) } : {}),
56
+ });
57
+
58
+ const csvCell = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
59
+ export const toCSV = (items) => ["title,place,subtitle,date,url,detail", ...items.map((i) =>
60
+ [i.title, i.place, i.subtitle, i.date, i.url, i.detail].map(csvCell).join(","))].join("\n");
61
+ export const toMarkdown = (items, name = "Saved") => [`# ${name}`, "", ...items.map((i) =>
62
+ `- **${i.url ? `[${i.title}](${i.url})` : i.title}** — ${[i.place, i.subtitle].filter(Boolean).join(" · ")}${i.detail ? `\n ${i.detail}` : ""}`)].join("\n");
63
+
64
+ // ── self-check: `node site/store.js` ──
65
+ if (typeof process !== "undefined" && process.argv[1]?.endsWith("/store.js")) {
66
+ const mem = () => { const m = new Map(); return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, v) }; };
67
+ const s = createStore({ local: mem(), session: mem(), app: "t" });
68
+ const row = (i) => ({ vector: Array.from({ length: 8 }, (_, j) => (j === i ? 1 : 0.1)) });
69
+ const a = snapshot(row(0), { key: "a", title: "Budget", place: "X", date: "2026-01-01" });
70
+ const b = snapshot(row(1), { key: "b", title: "Roads, \"east\"", place: "Y" });
71
+ const eq = (x, y, m) => { if (JSON.stringify(x) !== JSON.stringify(y)) throw new Error(`${m}: ${JSON.stringify(x)}`); };
72
+ eq(s.taste(), null, "no taste before a save");
73
+ s.toggleSave(a); s.toggleSave(b);
74
+ eq(s.saved().map((x) => x.key), ["a", "b"], "saved in order");
75
+ if (!s.taste()?.q) throw new Error("saves make a taste");
76
+ s.pass(b);
77
+ eq([s.saved().map((x) => x.key), s.passed().map((x) => x.key)], [["a"], ["b"]], "passing on an item unsaves it");
78
+ eq(s.taste().rejected, ["Roads, \"east\""], "and it steers the taste away");
79
+ s.remember("snow"); s.remember("budget"); s.remember("snow");
80
+ eq(s.history().map((h) => h.q), ["snow", "budget"], "history: newest first, no repeats");
81
+ const out = s.bundle({ name: "T" });
82
+ if (out.saved[0].v || !out.taste?.q || out.searches.length !== 2) throw new Error("the bundle carries choices and taste, not raw vectors");
83
+ if (!toCSV([b]).includes('"Roads, ""east"""')) throw new Error("CSV quoting");
84
+ if (createStore({ local: { getItem() { throw new Error("blocked"); }, setItem() { throw new Error("blocked"); } } }).saved().length !== 0)
85
+ throw new Error("blocked storage reads as empty");
86
+ console.log("store.js self-check ok — saves and passes steer a taste, history dedupes, the bundle carries no raw vectors, CSV quotes, blocked storage is empty not fatal");
87
+ }
package/site/worker.js ADDED
@@ -0,0 +1,103 @@
1
+ // The app's front door (Cloudflare Pages advanced mode): the static site, plus two things a
2
+ // static site cannot do. `westmarch-ship` writes this to site/_worker.js with CONFIG filled in.
3
+ //
4
+ // GET /doc?u=<url> a record's source document, served from this origin so the page can
5
+ // show it inline. Source sites forbid framing and send no CORS, so the
6
+ // page cannot fetch or frame them itself. Only hosts the app's own
7
+ // records link to (CONFIG.docHosts), only PDFs, cached for a day.
8
+ // GET /paid/<item id> with CONFIG.paid: 402 + the price; with a valid X-PAYMENT header, the
9
+ // facilitator verifies and settles it, then the record is returned
10
+ // GET /_paid/* 404 to everyone: the records sit here as static files, and the
11
+ // only way to one is through a settled payment
12
+ // anything else the static site, with the headers _headers would have set
13
+ //
14
+ // A record that does not exist is a 404 before any price is quoted: nobody pays for nothing.
15
+ // Each record's sha256 is in the app's public (committed) data, so a buyer can check it.
16
+
17
+ const CONFIG = /*CONFIG*/ { docHosts: [], paid: null } /*END*/;
18
+
19
+ const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "X-PAYMENT, content-type", "access-control-expose-headers": "X-PAYMENT-RESPONSE" };
20
+ const json = (o, status = 200, headers = {}) =>
21
+ new Response(JSON.stringify(o), { status, headers: { "content-type": "application/json", ...CORS, ...headers } });
22
+ const note = (msg, status) => new Response(`<!doctype html><meta charset="utf-8"><body style="font:15px system-ui;padding:24px;color:#555">${msg}</body>`,
23
+ { status, headers: { "content-type": "text/html; charset=utf-8" } });
24
+
25
+ async function doc(request, url) {
26
+ let src;
27
+ try { src = new URL(url.searchParams.get("u") ?? ""); } catch { return note("No document named.", 400); }
28
+ if (src.protocol !== "https:" || !CONFIG.docHosts.includes(src.hostname)) return note("Not one of this app's sources.", 403);
29
+ const cache = caches.default, key = new Request(url.href);
30
+ const hit = await cache.match(key);
31
+ if (hit) return hit;
32
+ const up = await fetch(src.href, { headers: { "user-agent": "Mozilla/5.0 (compatible; fangorn-app/0.1; +https://fangorn.network)" }, redirect: "follow" });
33
+ // A redirect may not leave the allowed hosts, and only a PDF is shown: an HTML page from
34
+ // someone else's site, served from this origin, would run as this app.
35
+ if (!CONFIG.docHosts.includes(new URL(up.url).hostname)) return note("The source redirected elsewhere.", 403);
36
+ if (!up.ok || !(up.headers.get("content-type") ?? "").includes("pdf"))
37
+ return note(`This document is not a PDF. <a href="${src.href.replace(/"/g, "&quot;")}" target="_blank" rel="noopener">Open it on the source site</a>.`, 415);
38
+ const res = new Response(up.body, { headers: { "content-type": "application/pdf", "content-disposition": "inline",
39
+ "cache-control": "public, max-age=86400", "x-content-type-options": "nosniff" } });
40
+ await cache.put(key, res.clone());
41
+ return res;
42
+ }
43
+
44
+ async function fileFor(id) {
45
+ const d = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
46
+ return [...d].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
47
+ }
48
+
49
+ async function facilitator(path, payment, requirements) {
50
+ const r = await fetch(`${CONFIG.paid.facilitator}/${path}`, {
51
+ method: "POST", headers: { "content-type": "application/json" },
52
+ body: JSON.stringify({ x402Version: 1, paymentPayload: payment, paymentRequirements: requirements }),
53
+ });
54
+ const text = await r.text();
55
+ try { return JSON.parse(text); } catch { return { error: `facilitator ${path}: HTTP ${r.status} ${text.slice(0, 200)}` }; }
56
+ }
57
+
58
+ async function sell(request, env, url) {
59
+ const P = CONFIG.paid;
60
+ const id = decodeURIComponent(url.pathname.slice("/paid/".length));
61
+ const record = await env.ASSETS.fetch(new URL(`/_paid/${await fileFor(id)}.json`, url));
62
+ // Pages answers an unknown path with index.html and a 200 (its single-page fallback), so
63
+ // "ok" is not "exists": only a JSON body is a record. Nobody is quoted a price for nothing.
64
+ if (!record.ok || !(record.headers.get("content-type") ?? "").includes("json")) return json({ error: `no paid record for ${id}` }, 404);
65
+ const requirements = {
66
+ scheme: "exact", network: P.network, maxAmountRequired: P.price, resource: url.href,
67
+ description: P.description, mimeType: "application/json", payTo: P.payTo,
68
+ maxTimeoutSeconds: 120, asset: P.asset, extra: { name: P.name, version: P.version },
69
+ };
70
+ const refuse = (error) => json({ x402Version: 1, error, accepts: [requirements] }, 402);
71
+ const header = request.headers.get("X-PAYMENT");
72
+ if (!header) return refuse("X-PAYMENT header is required");
73
+ let payment;
74
+ try { payment = JSON.parse(atob(header)); } catch { return refuse("X-PAYMENT is not base64 JSON"); }
75
+ const verified = await facilitator("verify", payment, requirements);
76
+ if (!verified.isValid) return refuse(verified.invalidReason ?? verified.error ?? "payment did not verify");
77
+ const settled = await facilitator("settle", payment, requirements);
78
+ if (!settled.success) return refuse(settled.errorReason ?? settled.error ?? "settlement failed");
79
+ return new Response(await record.text(), {
80
+ headers: { "content-type": "application/json", "x-payment-response": btoa(JSON.stringify(settled)), ...CORS },
81
+ });
82
+ }
83
+
84
+ export default {
85
+ async fetch(request, env) {
86
+ const url = new URL(request.url);
87
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
88
+ if (url.pathname.startsWith("/_paid/") || url.pathname === "/_worker.js") return new Response("not found", { status: 404 });
89
+ if (url.pathname === "/doc") return doc(request, url);
90
+ if (url.pathname.startsWith("/paid/")) return CONFIG.paid ? sell(request, env, url) : json({ error: "nothing is sold here" }, 404);
91
+
92
+ const res = await env.ASSETS.fetch(request);
93
+ const out = new Response(res.body, res);
94
+ const p = url.pathname;
95
+ if (p === "/.well-known/agent-card.json" || p.startsWith("/view/")) out.headers.set("access-control-allow-origin", "*");
96
+ if (p.startsWith("/assets/")) out.headers.set("cache-control", "public, max-age=31536000, immutable");
97
+ // Shards are content-named, but not forever: a retracted record's shard is deleted, and
98
+ // pages.dev's edge cache cannot be purged. An hour bounds how long a deleted one lingers.
99
+ else if (/^\/view\/cdn\/domains\/[^/]+\/shards\//.test(p))
100
+ out.headers.set("cache-control", (res.headers.get("content-type") ?? "").includes("html") ? "no-store" : "public, max-age=3600");
101
+ return out;
102
+ },
103
+ };