diigo-cache: fill from a webkey page over Cap'n Web

Starting a fill no longer POSTs the cache id to /_fill. Instead:

- the created page links the cache's webkey, /cache/#<cache id>: a
  skeleton page, the same for every key, that reads the key from the
  fragment (never sent to the server or in a Referer)
- its script trades the key in-band for a capability over Cap'n Web at
  /api: CacheApi.cache(key) returns a CacheCap facet (fill, status,
  fingerprint, the public companion's fill) and never the DO's wiring
  methods; malformed and unknown keys get one refusal that never echoes
  the key, and each refusal is logged without it
- then calls startFill(): trade and call pipeline into one round trip
- the worker serves capnweb's browser build itself at /_capnweb.js (a
  wrangler Text rule), so no third-party script sits beside the key

The /_fill route and its forms are gone. Tests run the real capnweb
client against the worker (fetch routed to worker.fetch).

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 04:32:44 -05:00
1 parent fbe2538345
commit 8c221abe6f
9 files changed
+397 -112

No files matched your search

+128
View File
@@ -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/#<key>`, 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<DiigoCache>;
PUBLIC_DO: DurableObjectNamespace<PublicCache>;
};
/** the fill-job surface of one cache (private or public) */
type Fillable = {
startFill(opts?: { pagesPerStep?: number }): Promise<FillStatus>;
fillStatus(): Promise<FillStatus | undefined>;
stopFill(): Promise<FillStatus>;
};
/** 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<FillStatus> {
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<FillStatus | undefined> {
return this.#cache.fillStatus();
}
/** stop the job; cached pages stay */
async stopFill(): Promise<FillStatus> {
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<string> {
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<CacheCap> {
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);
}
}
+94
View File
@@ -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 = `<!doctype html>
<html><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">
<title>diigo cache</title></head>
<body>
<h1>diigo cache <code id="fingerprint"></code></h1>
<p id="problem" hidden></p>
<section id="private" hidden>
<h2>private cache</h2>
<p id="private-status">no fill job yet</p>
<button type="button" data-target="private" data-action="start">fill</button>
<button type="button" data-target="private" data-action="stop">stop</button>
</section>
<section id="public" hidden>
<h2>public cache</h2>
<p id="public-status">no fill job yet</p>
<button type="button" data-target="public" data-action="start">fill</button>
<button type="button" data-target="public" data-action="stop">stop</button>
</section>
<p><a href="/">← home</a></p>
<script type="module">
import { newHttpBatchRpcSession } from "/_capnweb.js";
const key = location.hash.slice(1);
const $ = (id) => document.getElementById(id);
const problem = (text) => {
$("problem").textContent = text;
$("problem").hidden = false;
};
/** a fresh batch session, traded for this key's capability */
const cap = () => newHttpBatchRpcSession("/api").cache(key);
/** the fill-job stub for a target */
const job = (target) => (target === "public" ? cap().publicCache() : cap());
const describe = (s) => {
if (!s) return "no fill job yet";
const of = Math.max(0, s.nextPage - 1) + " of " + (s.pages ?? "?") + " pages";
return {
running: "filling: " + of,
done: "filled: " + of,
stopped: "fill stopped: " + of,
failed: "fill failed at page " + s.nextPage + ": " + (s.error ?? "unknown error"),
}[s.state];
};
let polling = false;
async function refresh(hasPublic) {
const targets = hasPublic ? ["private", "public"] : ["private"];
const statuses = await Promise.all(targets.map((t) => job(t).fillStatus()));
targets.forEach((t, i) => ($(t + "-status").textContent = describe(statuses[i])));
const running = statuses.some((s) => s?.state === "running");
if (running && !polling) {
polling = true;
setTimeout(() => {
polling = false;
refresh(hasPublic).catch(report);
}, 3000);
}
}
function report(err) {
console.error({ event: "cache-page", error: String(err) });
problem(String(err));
}
async function main() {
if (!key) return problem("no key: this page's address should end in #<cache id>");
const c = cap();
const [fingerprint, hasPublic] = await Promise.all([c.fingerprint(), c.hasPublic()]);
$("fingerprint").textContent = fingerprint;
$("private").hidden = false;
$("public").hidden = !hasPublic;
for (const button of document.querySelectorAll("button[data-target]")) {
button.addEventListener("click", () => {
const { target, action } = button.dataset;
const j = job(target);
(action === "start" ? j.startFill() : j.stopFill())
.then(() => refresh(hasPublic))
.catch(report);
});
}
await refresh(hasPublic);
}
main().catch(report);
</script>
</body></html>`;
+10
View File
@@ -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;
+24 -71
View File
@@ -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<Response> {
);
}
/** 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) => `<form method="post" action="/_fill">
<input type="hidden" name="cacheId" value="${esc(cacheId)}">
<input type="hidden" name="target" value="${target}">
<button type="submit">${label}</button></form>`;
return `<p>fetch every page now, in the background (a few minutes for thousands of bookmarks):</p>
${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<Response> {
const fingerprint = await nick(cacheId);
@@ -319,11 +319,13 @@ async function wiredPage(cacheId: string, note: string, hasPublic: boolean): Pro
<script>${PETNAME_TIME_JS}</script>
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>save to password manager</button></p>
<p>${note}</p>
${fillForms(cacheId, hasPublic)}
<p><a href="/cache/#${esc(cacheId)}">open this cache</a> — its <b>webkey</b>
(bookmark it, or save the id above): fill it, watch progress${hasPublic ? ", fill its public companion" : ""}.</p>
<p>the cache id is the only way back to this cache — saved (above) it lives
with the rest of your credentials. it is a <b>webkey</b>: 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 <b>webkey</b>: 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.</p>
<a href="/">← home (paste the cache id into the dump form)</a>
<script>${PM_SAVE_JS}</script>
@@ -343,55 +345,6 @@ function fillLine(fill: FillStatus): string {
return `<p>${esc(said)}</p>`;
}
/** 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<Response> {
const ct = request.headers.get("content-type") ?? "";
const wantsJson = ct.includes("application/json");
let fields: Record<string, string>;
try {
fields = wantsJson
? Object.fromEntries(Object.entries((await request.json()) as Record<string, unknown>).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<FillStatus>; stopFill(): Promise<FillStatus> } = 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(
`<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: fill</title></head>
<body>
<h1>fill (${esc(target)} cache)</h1>
${fillLine(status)}
<p>it runs in the background; the progress shows on the public page, or post this form again to check.</p>
<a href="/">← home</a>
</body></html>`,
{ 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 */