diff --git a/diigo-cache/README.md b/diigo-cache/README.md index a833ebe..a25401d 100644 --- a/diigo-cache/README.md +++ b/diigo-cache/README.md @@ -33,20 +33,27 @@ goal 2 preamble). outcome}`. - **Publish later**: `POST /_publish` (cache id in the body) wires a public companion to a private-only cache. -- **Fill job**: `POST /_fill` (cache id in the body; `target=private|public`, - `action=start|stop`) walks every diigo page into a cache in the - background — DO alarms, a few pages per step, progress stored in the - DO (`startFill`/`fillStatus`/`stopFill`). The created page has the - buttons; the public page shows progress. +- **The webkey page and Cap'n Web**: `/cache/#` is a skeleton + page, the same for every key; its script reads the key from the + fragment and trades it in-band for a capability over + [Cap'n Web](https://github.com/cloudflare/capnweb) at `/api` + (`CacheApi.cache(key)` → a `CacheCap` facet: fill, status, the public + companion; `src/api.ts`). The worker serves capnweb's browser build + itself at `/_capnweb.js`. +- **Fill job**: walks every diigo page into a cache in the background — + DO alarms, a few pages per step, progress stored in the DO + (`startFill`/`fillStatus`/`stopFill`), driven from the webkey page; + the public page shows progress. - The `DiigoBridge` entrypoint for other workers (its cache-id source is the goal-2 wiring TODO). ## State surface Ids are **webkeys** ([the pattern](../docs/webkeys.md)): 256-bit bearer -secrets that never ride URLs — not query strings, not path segments. -Forms carry them in POST bodies; logs and pages show only a fingerprint -(a prefix of the id's one-way hash). +secrets that never ride a query string or path. The webkey page carries +the cache id in the URL fragment (never sent to the server) and passes +it in-band over Cap'n Web; forms carry ids in POST bodies; logs and pages +show only a fingerprint (a prefix of the id's one-way hash). - **The cache id**: the cache's read capability (bearer; not logged). Held by the user — saved to the password manager under a petname they @@ -69,7 +76,8 @@ diigo through *your* cookie session: each request may spend up to `FETCH_BUDGET` (40) diigo fetches on uncached pages (goal 1d), paced at `MS_PER_CALL`. Cached pages cost nothing. Before exposing a deployment: -- rate-limit the public routes — on Cloudflare, the +- rate-limit the public routes and `/api` (Cap'n Web pipelining makes + calls cheap to send) — on Cloudflare, the [Workers rate-limit binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) (keyed by client and/or username); on other workerd hosts, a rate limit in the proxy in front; diff --git a/diigo-cache/package.json b/diigo-cache/package.json index 4bead99..eb9b8fb 100644 --- a/diigo-cache/package.json +++ b/diigo-cache/package.json @@ -13,5 +13,8 @@ "typescript": "^5.9.0", "vitest": "4.1.11", "wrangler": "^4.148.0" + }, + "dependencies": { + "capnweb": "^0.12.0" } } diff --git a/diigo-cache/src/api.ts b/diigo-cache/src/api.ts new file mode 100644 index 0000000..ed360bf --- /dev/null +++ b/diigo-cache/src/api.ts @@ -0,0 +1,128 @@ +/** + * @file the Cap'n Web API at /api: trade a cache's webkey for a capability. + * + * The browser holds the key in a URL fragment (`/cache/#`, see + * docs/webkeys.md), so it never reaches the server in a URL. The skeleton + * page passes it *in-band* — `api.cache(key)` — and gets back a stub for a + * {@link CacheCap}: a facet exposing only what the holder should do (fill, + * status, the public companion), never the DO's wiring methods. + * Authorization is in-band, so cross-origin calls are safe (capnweb's + * note on `newWorkersRpcResponse`); every argument is checked at runtime, + * since TypeScript types are erased on the wire. + * + * @see https://github.com/cloudflare/capnweb + */ + +import { RpcTarget } from "capnweb"; +import type { DiigoCache, FillStatus, PublicCache } from "./cache.js"; +import { idFromWebkey, isWebkey, shashKey } from "./webkeys.js"; + +/** the bindings the API reaches (structural) */ +export type ApiEnv = { + CACHE_DO: DurableObjectNamespace; + PUBLIC_DO: DurableObjectNamespace; +}; + +/** the fill-job surface of one cache (private or public) */ +type Fillable = { + startFill(opts?: { pagesPerStep?: number }): Promise; + fillStatus(): Promise; + stopFill(): Promise; +}; + +/** a cache's fill job, and nothing else */ +export class FillCap extends RpcTarget { + #cache: Fillable; + + constructor(cache: Fillable) { + super(); + this.#cache = cache; + } + + /** start (or rejoin) the job; `pagesPerStep` 1–50 */ + async startFill(opts: unknown = {}): Promise { + const step = (opts as { pagesPerStep?: unknown } | null)?.pagesPerStep; + if (step !== undefined && typeof step !== "number") throw new Error("pagesPerStep: expected a number"); + return this.#cache.startFill(step === undefined ? {} : { pagesPerStep: step }); + } + + /** the job's progress, or undefined if none was started */ + async fillStatus(): Promise { + return this.#cache.fillStatus(); + } + + /** stop the job; cached pages stay */ + async stopFill(): Promise { + return this.#cache.stopFill(); + } +} + +/** what the holder of a private cache's key may do */ +export class CacheCap extends FillCap { + #key: string; + #publicId: string | undefined; + #env: ApiEnv; + + constructor(env: ApiEnv, key: string, cache: Fillable, publicId: string | undefined) { + super(cache); + this.#env = env; + this.#key = key; + this.#publicId = publicId; + } + + /** a prefix of the key's one-way hash: names it without granting it */ + async fingerprint(): Promise { + return (await shashKey(this.#key)).slice(0, 8); + } + + /** whether a public companion exists (see {@link publicCache}) */ + hasPublic(): boolean { + return this.#publicId !== undefined; + } + + /** the public companion's fill job; refused if never published (a + * stub, not a maybe-null, so calls on it pipeline) */ + publicCache(): FillCap { + if (!this.#publicId) throw new Error("no public cache: publish this cache first"); + const ns = this.#env.PUBLIC_DO; + return new FillCap(ns.get(ns.idFromString(idFromWebkey(this.#publicId)))); + } +} + +/** the main interface at /api */ +export class CacheApi extends RpcTarget { + #env: ApiEnv; + + constructor(env: ApiEnv) { + super(); + this.#env = env; + } + + /** + * trade a cache's webkey for its capability. Refused keys get one + * message for malformed and unknown alike, which never echoes the key. + */ + async cache(key: unknown): Promise { + const refused = () => new Error("no such cache"); + if (typeof key !== "string" || !isWebkey(key, 256)) { + console.log({ event: "api", refused: "cache", reason: "malformed key" }); + throw refused(); + } + const ns = this.#env.CACHE_DO; + let stub; + try { + // a well-formed id from another namespace throws here + stub = ns.get(ns.idFromString(idFromWebkey(key))); + } catch (err) { + // the key is never logged: it is the bearer secret + console.log({ event: "api", refused: "cache", reason: String(err) }); + throw refused(); + } + const wiring = await stub.wiring(); + if (!wiring) { + console.log({ event: "api", refused: "cache", reason: "not wired" }); + throw refused(); + } + return new CacheCap(this.#env, key, stub, wiring.publicId); + } +} diff --git a/diigo-cache/src/cache-page.ts b/diigo-cache/src/cache-page.ts new file mode 100644 index 0000000..1944619 --- /dev/null +++ b/diigo-cache/src/cache-page.ts @@ -0,0 +1,94 @@ +/** + * @file the webkey page at /cache/ — a skeleton, identical for every key + * (docs/webkeys.md): the key rides the URL fragment, which browsers never + * send to the server or in a Referer. The inline module reads it, trades + * it for a capability (`api.cache(key)`, Cap'n Web over /api, in-band), + * and drives the fill job. Each round trip is its own HTTP batch session: + * calls made before an await travel together. + */ + +export const CACHE_PAGE_HTML = ` + +diigo cache + +

diigo cache

+ + + +

← home

+ +`; diff --git a/diigo-cache/src/capnweb-browser.ts b/diigo-cache/src/capnweb-browser.ts new file mode 100644 index 0000000..d5d78e1 --- /dev/null +++ b/diigo-cache/src/capnweb-browser.ts @@ -0,0 +1,10 @@ +/** + * @file capnweb's browser build (a self-contained ES module), as text, for + * the worker to serve at /_capnweb.js; wrangler's Text rule for this path + * makes the import a string. Self-hosted: no third-party script on a page + * that holds a bearer key. + */ +// @ts-expect-error: a Text-rule import (wrangler.jsonc "rules"), not code +import source from "../node_modules/capnweb/dist/index.js"; + +export const CAPNWEB_BROWSER_JS: string = source; diff --git a/diigo-cache/src/index.ts b/diigo-cache/src/index.ts index 1a9da86..2b13d11 100644 --- a/diigo-cache/src/index.ts +++ b/diigo-cache/src/index.ts @@ -13,6 +13,10 @@ */ import { WorkerEntrypoint } from "cloudflare:workers"; +import { newWorkersRpcResponse } from "capnweb"; +import { CacheApi } from "./api.js"; +import { CAPNWEB_BROWSER_JS } from "./capnweb-browser.js"; +import { CACHE_PAGE_HTML } from "./cache-page.js"; import { DiigoCache, PublicCache } from "./cache.js"; import type { FillStatus } from "./cache.js"; import type { Item } from "./cache.js"; @@ -96,9 +100,17 @@ export default { return publishCache(request, env); } - // the fill job (goal 1e): owner-only, cache id in the body - if (url.pathname === "/_fill" && request.method === "POST") { - return fillRoute(request, env); + // a cache's webkey page: the key rides the fragment (never sent + // here); the page trades it for a capability over /api + if (url.pathname === "/cache/" && request.method === "GET") { + return new Response(CACHE_PAGE_HTML, { headers: { "content-type": "text/html;charset=utf-8" } }); + } + if (url.pathname === "/_capnweb.js" && request.method === "GET") { + return new Response(CAPNWEB_BROWSER_JS, { headers: { "content-type": "text/javascript;charset=utf-8" } }); + } + // Cap'n Web: in-band authorization (CacheApi.cache(key)) + if (url.pathname === "/api") { + return newWorkersRpcResponse(request, new CacheApi(env)); } if (url.pathname !== "/") return new Response("not found", { status: 404 }); @@ -108,9 +120,9 @@ export default { } // GET / is inert (dumps happen on POST submits). The cache id is a - // webkey: it never rides the URL (query strings leak into history/ - // Referer/logs — see docs/webkeys.md); the wired form POST is how - // it travels. + // webkey: it never rides a query string or path (they leak into + // history/Referer/logs — see docs/webkeys.md); the form POST is how + // it travels here. return home(env, { error: "GET is inert: dumps happen on POST submits" }, null); }, }; @@ -293,18 +305,6 @@ async function wire(request: Request, env: Env): Promise { ); } -/** the fill job's buttons: one per cache the holder can fill (the id - * rides a hidden field, never the URL) */ -function fillForms(cacheId: string, hasPublic: boolean): string { - const form = (target: string, label: string) => `
- - -
`; - return `

fetch every page now, in the background (a few minutes for thousands of bookmarks):

-${form("private", "fill the private cache")} -${hasPublic ? form("public", "fill the public cache") : ""}`; -} - /** the created-cache page: ids + save/copy buttons + the pointer home */ async function wiredPage(cacheId: string, note: string, hasPublic: boolean): Promise { const fingerprint = await nick(cacheId); @@ -319,11 +319,13 @@ async function wiredPage(cacheId: string, note: string, hasPublic: boolean): Pro

${note}

-${fillForms(cacheId, hasPublic)} +

open this cache — its webkey +(bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.

the cache id is the only way back to this cache — saved (above) it lives -with the rest of your credentials. it is a webkey: it never travels -in URLs (query strings leak to history/Referer/logs); the dump POST carries -it in its body instead. the webapp id rides inside the cache's wiring; +with the rest of your credentials. it is a webkey: it never rides a +query string or path (those leak to history/Referer/logs). the link above +carries it in the fragment, which browsers never send; the dump POST +carries it in its body. the webapp id rides inside the cache's wiring; hold it separately only if you want another cache off the same session.

← home (paste the cache id into the dump form) @@ -343,55 +345,6 @@ function fillLine(fill: FillStatus): string { return `

${esc(said)}

`; } -/** POST /_fill: start or stop the fill job of the holder's private cache - * (target=private, the default) or of its public companion - * (target=public). JSON in → JSON out; a form post gets a small page. */ -async function fillRoute(request: Request, env: Env): Promise { - const ct = request.headers.get("content-type") ?? ""; - const wantsJson = ct.includes("application/json"); - let fields: Record; - try { - fields = wantsJson - ? Object.fromEntries(Object.entries((await request.json()) as Record).map(([k, v]) => [k, String(v ?? "")])) - : Object.fromEntries([...(await request.formData()).entries()].map(([k, v]) => [k, String(v)])); - } catch (err) { - return bad(`bad input: ${String(err)}`); - } - const { cacheId = "", target = "private", action = "start" } = fields; - if (!isWebkey(cacheId)) return bad("no cache id supplied (POST body: cacheId)"); - if (target !== "private" && target !== "public") return bad("target: expected private or public"); - if (action !== "start" && action !== "stop") return bad("action: expected start or stop"); - - const cache = env.CACHE_DO.get(env.CACHE_DO.idFromString(idFromWebkey(cacheId))); - const wiring = await cache.wiring(); - if (!wiring) return Response.json({ error: "unknown cache id" }, { status: 404 }); - let stub: { startFill(): Promise; stopFill(): Promise } = cache; - if (target === "public") { - if (!wiring.publicId) return bad("this cache has no public companion; publish it first"); - stub = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromString(idFromWebkey(wiring.publicId))); - } - let status: FillStatus; - try { - status = action === "start" ? await stub.startFill() : await stub.stopFill(); - } catch (err) { - console.error({ event: "fill", target, action, error: String(err) }); - return bad(`could not ${action} the fill: ${String(err)}`); - } - const code = action === "start" ? 202 : 200; - if (wantsJson) return Response.json(status, { status: code }); - return new Response( - ` -diigo cache: fill - -

fill (${esc(target)} cache)

-${fillLine(status)} -

it runs in the background; the progress shows on the public page, or post this form again to check.

-← home -`, - { status: code, headers: { "content-type": "text/html;charset=utf-8" } }, - ); -} - /** publish, later: the private cache's wiring already holds the webapp * id, so the public companion needs no re-mint; the username comes from * the items already in the cache */ diff --git a/diigo-cache/tests/fill.test.ts b/diigo-cache/tests/fill.test.ts index 3d3b76a..8ff6b9c 100644 --- a/diigo-cache/tests/fill.test.ts +++ b/diigo-cache/tests/fill.test.ts @@ -2,6 +2,8 @@ import { env } from "cloudflare:workers"; import { evictDurableObject, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; import { describe, expect, it, vi } from "vitest"; import worker from "../src/index.js"; +import { newHttpBatchRpcSession } from "capnweb"; +import type { CacheApi } from "../src/api.js"; import { toWebkey } from "../src/webkeys.js"; // goal 1e (capsite checklist): a job that grabs every page. Fake @@ -117,56 +119,139 @@ describe("fill job (goal 1e)", () => { }); }); -describe("fill routes (goal 1e)", () => { - const call = (path: string, init?: RequestInit) => - worker.fetch(new Request(`https://diigo-cache.test${path}`, init), env as never); - const post = (body: Record) => - call("/_fill", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); +describe("fill via webkey and Cap'n Web (goal 1e)", () => { + const ORIGIN = "https://diigo-cache.test"; + const call = (path: string, init?: RequestInit) => worker.fetch(new Request(`${ORIGIN}${path}`, init), env as never); - it("POST /_fill starts the owner's job (cache id in the body) and answers 202 with its status", async () => { - const id = testEnv.CACHE_DO.newUniqueId(); - await testEnv.CACHE_DO.get(id).setup({ webappId: "fake-webapp-id" }); - const res = await post({ cacheId: toWebkey(id) }); - expect(res.status).toBe(202); - expect(await res.json()).toMatchObject({ state: "running" }); - }); + /** a Cap'n Web batch session whose fetch goes to the worker; counts round trips */ + const session = () => { + const trips = { n: 0 }; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + trips.n += 1; + return worker.fetch(new Request(input as RequestInfo, init), env as never); + }); + const api = newHttpBatchRpcSession(`${ORIGIN}/api`); + return { api, trips, done: () => fetchSpy.mockRestore() }; + }; - it("target=public fills the cache's public companion; action=stop stops", async () => { - const wiredRes = await call("/_wire", { + const wireJson = async (mode: "private" | "public") => { + const res = await call("/_wire", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ webappId: "fake-webapp-id", mode: "public" }), + body: JSON.stringify({ webappId: "fake-webapp-id", mode }), }); - const { cacheId } = (await wiredRes.json()) as { cacheId: string }; - expect((await post({ cacheId, target: "public" })).status).toBe(202); - const stopped = await post({ cacheId, target: "public", action: "stop" }); - expect(await stopped.json()).toMatchObject({ state: "stopped" }); + return (await res.json()) as { cacheId: string; username?: string }; + }; + + it("trading the key and starting the fill pipeline into one round trip", async () => { + const { cacheId } = await wireJson("private"); + const { api, trips, done } = session(); + try { + const status = await api.cache(cacheId).startFill(); + expect(status).toMatchObject({ state: "running" }); + expect(trips.n).toBe(1); + } finally { + done(); + } }); - it("refuses an unknown cache id (404) and never takes the id from the URL", async () => { - expect((await post({ cacheId: toWebkey(testEnv.CACHE_DO.newUniqueId()) })).status).toBe(404); - expect((await call(`/_fill/${toWebkey(testEnv.CACHE_DO.newUniqueId())}`, { method: "POST" })).status).toBe(404); + it("the capability is a facet: fill and status, never the DO's wiring", async () => { + const { cacheId } = await wireJson("private"); + const { api, done } = session(); + try { + const cap = api.cache(cacheId) as unknown as Record Promise>; + await expect(cap.setup()).rejects.toThrow(); + } finally { + done(); + } }); - it("the created page offers a fetch-everything button, the id in a hidden field", async () => { + it("unknown and malformed keys get one refusal that never echoes the key", async () => { + const unknown = toWebkey(testEnv.CACHE_DO.newUniqueId()); + for (const key of [unknown, "not-a-key", unknown.slice(0, 26)]) { + const { api, done } = session(); + try { + const err = await api.cache(key).fillStatus().then(() => null, (e: unknown) => e); + expect(String(err)).toMatch(/no such cache/); + expect(String(err)).not.toContain(key); + } finally { + done(); + } + } + }); + + it("publicCache() reaches the public companion's fill; refused when never published", async () => { + const pub = await wireJson("public"); + const s1 = session(); + try { + expect(await s1.api.cache(pub.cacheId).hasPublic()).toBe(true); + } finally { + s1.done(); + } + const s2 = session(); + try { + expect(await s2.api.cache(pub.cacheId).publicCache().startFill()).toMatchObject({ state: "running" }); + } finally { + s2.done(); + } + const s3 = session(); + try { + expect(await s3.api.cache(pub.cacheId).publicCache().stopFill()).toMatchObject({ state: "stopped" }); + } finally { + s3.done(); + } + const priv = await wireJson("private"); + const s4 = session(); + try { + expect(await s4.api.cache(priv.cacheId).hasPublic()).toBe(false); + } finally { + s4.done(); + } + // a batch session ends after its round trip: a fresh one per await + const s5 = session(); + try { + await expect(s5.api.cache(priv.cacheId).publicCache().fillStatus()).rejects.toThrow(/no public cache/); + } finally { + s5.done(); + } + }); + + it("the webkey page is a skeleton: the same for every key, which it reads from the fragment", async () => { + const res = await call("/cache/"); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("location.hash"); + expect(html).toContain('from "/_capnweb.js"'); + expect(html).toContain('newHttpBatchRpcSession("/api")'); + expect(html).toContain(''); + }); + + it("serves capnweb's browser build itself (no third-party script beside the key)", async () => { + const res = await call("/_capnweb.js"); + expect(res.headers.get("content-type")).toMatch(/javascript/); + expect(await res.text()).toMatch(/export \{[^}]*newHttpBatchRpcSession/); + }); + + it("the created page links its webkey (key in the fragment); no cache id rides a form to /_fill", async () => { const res = await call("/_wire", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ webappId: "fake-webapp-id", mode: "private" }), }); const html = await res.text(); - expect(html).toMatch(/
/); - expect(html).toMatch(//); + expect(html).not.toContain("/_fill"); + expect((await call("/_fill", { method: "POST" })).status).toBe(404); }); it("the public page shows a running fill's progress", async () => { - const wiredRes = await call("/_wire", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ webappId: "fake-webapp-id", mode: "public" }), - }); - const { cacheId, username } = (await wiredRes.json()) as { cacheId: string; username: string }; - await post({ cacheId, target: "public" }); + const { cacheId, username } = await wireJson("public"); + const { api, done } = session(); + try { + await api.cache(cacheId).publicCache().startFill(); + } finally { + done(); + } const html = await (await call(`/public/${username}`)).text(); expect(html).toMatch(/fill(ed|ing)[^<]*\d+ of \d+ pages/); }); diff --git a/diigo-cache/wrangler.jsonc b/diigo-cache/wrangler.jsonc index 798c1fd..b3b4d05 100644 --- a/diigo-cache/wrangler.jsonc +++ b/diigo-cache/wrangler.jsonc @@ -4,6 +4,9 @@ "main": "src/index.ts", "compatibility_date": "2026-09-29", "observability": { "enabled": true }, + // capnweb's browser build, served as text at /_capnweb.js (the worker + // itself imports capnweb's workerd build as code) + "rules": [{ "type": "Text", "globs": ["**/capnweb/dist/index.js"], "fallthrough": true }], "services": [ { "binding": "WEBAPP", diff --git a/diigo-cache/wrangler.tests.jsonc b/diigo-cache/wrangler.tests.jsonc index 49798e9..e14cd1e 100644 --- a/diigo-cache/wrangler.tests.jsonc +++ b/diigo-cache/wrangler.tests.jsonc @@ -5,6 +5,7 @@ "name": "diigo-cache-tests", "main": "./src/index.ts", "compatibility_date": "2026-09-29", + "rules": [{ "type": "Text", "globs": ["**/capnweb/dist/index.js"], "fallthrough": true }], "services": [ { "binding": "WEBAPP", "service": "fake-webapp", "entrypoint": "WebAppFactory" } ],