Files
captoys/diigo-cache/src/index.ts
T
dckc 279ddb4caa fixup! diigo-cache: the public page only reads
Comments written for the reader: no restating what the shebang says, no traces of the design conversation; the publish comment no longer mentions a projection that no longer exists.

Generated-by: Claude Opus 5.5
2026-10-07 09:46:21 -05:00

591 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file diigo-cache worker: the create flow, the webkey page and its API,
* and public pages. See the default export for its routes.
*/
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 { DiigoCacheLogic, PublicCacheLogic } from "./cache.js";
import { PAGE_SIZE } from "./cache.js";
import type { CacheEnv, Clock, FillStatus } from "./cache.js";
import type { Item } from "./cache.js";
import { updatedISO, usernameOf } from "./items.js";
import { toWebkey, idFromWebkey, isWebkey, shashKey } from "./webkeys.js";
import { pmSave } from "./pm-save.js";
/** the live clock: the worker's entry module reads ambient time and hands
* it to the cache logic, which uses only the clock it is given */
function liveClock(): Clock {
return { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
}
/**
* the private cache, as the runtime constructs it. DO classes must be
* exports of the main module, and the runtime passes only (ctx, env): a
* Cloudflare constraint, met here at the edge by supplying the live clock.
*/
export class DiigoCache extends DiigoCacheLogic {
constructor(ctx: DurableObjectState, env: CacheEnv) {
super(ctx, env, liveClock());
}
}
/** the public cache, as the runtime constructs it (see {@link DiigoCache}) */
export class PublicCache extends PublicCacheLogic {
constructor(ctx: DurableObjectState, env: CacheEnv) {
super(ctx, env, liveClock());
}
}
/** worker bindings (structural until wrangler types regenerates) */
type Env = {
WEBAPP: {
fetch(request: Request): Promise<Response>;
};
CACHE_DO: DurableObjectNamespace<DiigoCache>;
PUBLIC_DO: DurableObjectNamespace<PublicCache>;
DIIGO_CACHE_ID?: string;
};
const UA = "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/141.0";
const DIIGO_INTERACT = "https://www.diigo.com/interact_api/";
/** the diigo export (DiigoBridge lives on; the dumpNdjson cache-id source
* is TODO(2-i): ride the create form's handoff instead of a worker secret) */
export class DiigoBridge extends WorkerEntrypoint {
/** bulk dump from the caller's address space: `pages * count` bookmarks
* through the cache (cache-hits are storage reads) */
async dumpNdjson(pages: number, count = PAGE_SIZE): Promise<string> {
const ns = (this.env as unknown as Env).CACHE_DO;
const cacheId = (this.env as unknown as Env).DIIGO_CACHE_ID;
if (!cacheId) throw new Error("no cache id supplied (caller config error)");
const cache = ns.get(ns.idFromString(idFromWebkey(cacheId)));
const lines: string[] = [];
for (let p = 1; p <= pages; p += 1) {
const page = await cache.page(p, count);
lines.push(...(p === 1 ? page.items.slice(0, count) : page.items));
}
return lines.join("\n");
}
}
/**
* the worker's routes:
*
* - `GET /`: home ({@link home}); `POST /`: dump a page by cache id ({@link dump})
* - `GET /_connect`: webapp's mint form, relayed ({@link mintPage});
* `POST /_mint`: the mint ({@link proxyMint})
* - `POST /_wire`: wire a cache to a webapp id ({@link wire});
* `POST /_publish`: add a public companion later ({@link publishCache})
* - `GET /cache/`: the webkey page ({@link CACHE_PAGE_HTML});
* `/api`: its Cap'n Web API ({@link CacheApi}); `GET /_capnweb.js`
* - `GET /public/<username>`: public pages, read-only ({@link publicPage})
*
* Cache ids are webkeys (docs/webkeys.md): they ride POST bodies and URL
* fragments, never query strings or paths.
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const pub = url.pathname.match(/^\/public\/([A-Za-z0-9_-]+)$/);
if (pub) return publicPage(request, env, pub[1]);
if (url.pathname === "/_connect" && request.method === "GET") {
return mintPage(env);
}
if (url.pathname === "/_mint" && request.method === "POST") {
return proxyMint(request, env);
}
if (url.pathname === "/_wire" && request.method === "POST") {
return wire(request, env);
}
if (url.pathname === "/_publish" && request.method === "POST") {
return publishCache(request, env);
}
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" } });
}
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new CacheApi(env));
}
if (url.pathname !== "/") return new Response("not found", { status: 404 });
if (request.method === "POST") {
return dump(request, env);
}
// GET is inert: a cache id never rides a query string
return home(env, { error: "GET is inert: dumps happen on POST submits" }, null);
},
};
/**
* the connect page: webapp's own mint form HTML, fetched over the service
* binding with diigo's prefill (base URL, UA headers) and its action
* rewritten to our relay. Served here because wrangler's multi-config dev
* doesn't serve an aux worker's HTTP. diigo-cache authors no fields of its
* own; the init params stay editable, webapp's.
*/
async function mintPage(env: Env): Promise<Response> {
const prefill =
`?render=1&title=${encodeURIComponent("diigo cache: mint the session")}` +
`&url=${encodeURIComponent(DIIGO_INTERACT)}` +
`&headers=${encodeURIComponent(JSON.stringify({ "user-agent": UA, "x-requested-with": "XMLHttpRequest" }))}` +
`&msPerCall=500`;
let html: string;
try {
// a plain fetch() from inside the worker goes to the internet, not
// to the service binding — binding calls need a Request handed to
// the stub (workerd's entrypoint-fetch protocol)
const res = await env.WEBAPP.fetch(
new Request(`https://form${prefill}`, { redirect: "manual" }),
);
html = await res.text();
html = html.replace('action="ACTION"', 'action="/_mint"');
} catch (err) {
console.error({ event: "mint-page", error: String(err) });
return home(env, { error: "mint form unreachable (webapp worker down?)" }, null);
}
return new Response(html, { headers: { "content-type": "text/html;charset=utf-8" } });
}
/** the mint relay: the raw form body passes through untouched into the
* WEBAPP entrypoint's HTTP surface (webapp parses its own fields; the
* cookie crosses browser→here→binding→webapp's DO and no code on this
* side opens it) — a true conduit */
async function proxyMint(request: Request, env: Env): Promise<Response> {
try {
const res = await env.WEBAPP.fetch(
new Request("https://mint/", {
method: "POST",
headers: request.headers,
body: await request.arrayBuffer(),
}),
);
if (!res.ok) {
const detail = (await res.text()).slice(0, 200);
return home(env, { error: `mint said ${res.status}: ${detail}` }, null);
}
// webapp answers {id} (+ x-webapp-id); the id rides the header, the
// wire page is ours
const id = res.headers.get("x-webapp-id");
if (!id) return home(env, { error: "mint produced no id" }, null);
return await wirePage(id);
} catch (err) {
console.error({ event: "mint", error: String(err) });
return home(env, { error: `mint failed: ${String(err)}` }, null);
}
}
/** the credential-save helper, once per page (see ./pm-save.ts) */
const PM_SAVE_JS = pmSave.toString();
/** the suggested petname gets the holder's local hh:mm (the worker only
* knows UTC), so two saves the same day are told apart at a glance */
const PETNAME_TIME_JS = `{
const p = document.getElementById("petname");
const d = new Date();
p.value += " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
}`;
/** the onclick that hands pmSave its browser powers */
function pmSaveClick(value: string): string {
return `pmSave(this, ${JSON.stringify(value)}, { petname: document.getElementById("petname"), credentials: navigator.credentials, PasswordCredential: window.PasswordCredential, log: console })`;
}
/** the wire step, with the freshly-minted webapp id pre-filled */
async function wirePage(webappId: string): Promise<Response> {
const fingerprint = await nick(webappId);
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: step 2 — wire the cache</title></head>
<body>
<h1>step 2: wire the cache</h1>
<p>minted: webapp id, fingerprint <code>${esc(fingerprint)}</code>
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(webappId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo webapp ${fingerprint}`)}"></label>
<small>(private to you: the password-manager username; never sent to the server)</small></p>
<script>${PETNAME_TIME_JS}</script>
<p><button type="button" onclick='${pmSaveClick(webappId)}'>save to password manager</button></p>
<p>choose the cache mode, then <b>wire</b> — the next page shows the
<b>cache id</b> (copy that too), after which you land back on home:</p>
<form method="post" action="/_wire">
<input type="hidden" name="webappId" value="${esc(webappId)}">
<p><label>mode <select name="mode"><option value="private">private</option><option value="public">public (serves /public/&lt;your-diigo-username&gt;)</option></select></label> <button type="submit">wire →</button></p>
</form>
<p>change your mind? <a href="/_connect">mint again</a> · <a href="/">home</a></p>
<script>${PM_SAVE_JS}</script>
</body></html>`;
return new Response(body, {
headers: {
"content-type": "text/html;charset=utf-8",
"x-webapp-id": webappId,
},
});
}
/**
* the wire step: a webapp id plus the public/private choice. The cache is
* minted, wired to {webappId, publicId}, and page 1 is stored. In public
* mode the diigo username is read from the fetched items (the cookie's
* own account, never form input), and the public cache is wired to the
* same webapp id and registered under it. No cookie arrives here.
*/
async function wire(request: Request, env: Env): Promise<Response> {
const ct = request.headers.get("content-type") ?? "";
let webappId = "";
let mode = "private";
let wantsJson = false;
try {
if (ct.includes("application/json")) {
const body = (await request.json()) as { webappId?: string; mode?: string };
webappId = String(body.webappId ?? "");
mode = String(body.mode ?? "private");
wantsJson = true;
} else {
const form = await request.formData();
webappId = String(form.get("webappId") || "");
mode = String(form.get("mode") || "private");
// a browser form POST stays on the html path; only explicit JSON
// (service callers) gets the bare-JSON response
wantsJson = false;
}
} catch (err) {
return bad(`bad input: ${String(err)}`);
}
if (!/^[A-Za-z0-9-]+$/.test(webappId)) return bad("no webapp id supplied");
const publicId = mode === "public" ? toWebkey(env.PUBLIC_DO.newUniqueId()) : undefined;
const cacheId = toWebkey(env.CACHE_DO.newUniqueId());
const cacheStub = env.CACHE_DO.get(env.CACHE_DO.idFromString(idFromWebkey(cacheId)));
await cacheStub.setup({ webappId, publicId });
const first = await cacheStub.page(1, PAGE_SIZE);
if (publicId) {
const firstItem = first.items[0] ? (JSON.parse(first.items[0]) as Item) : undefined;
const username = usernameOf(firstItem);
if (!username) {
// diagnostic context without item contents (privacy: items may
// carry URL/titles; the count + cached flag are safe)
console.error({
event: "wire",
stage: "username-derive",
// field names, not values: says which shape we got
itemFields: firstItem ? Object.keys(firstItem) : null,
itemsOnPage1: first.items.length,
total: first.total,
cached: first.cached,
});
return bad("could not read your diigo username (u_name) from the fetched items");
}
const index = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromName("index"));
const pubStub = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromString(idFromWebkey(publicId)));
await pubStub.setup({ webappId });
await index.register(username, publicId);
if (wantsJson) return Response.json({ cacheId, publicId, username });
return wiredPage(cacheId, `public cache live at <a href="/public/${esc(encodeURIComponent(username))}">/public/${esc(username)}</a>`, true);
}
if (wantsJson) {
return Response.json({
cacheId,
publishLater: { method: "POST", url: `${new URL(request.url).origin}/_publish`, body: { cacheId } },
});
}
return wiredPage(
cacheId,
`private-only for now — to add the public cache later:
<form method="post" action="/_publish" style="display:inline">
<input type="hidden" name="cacheId" value="${esc(cacheId)}">
<button type="submit">publish</button></form>
(or <code>POST /_publish</code> with <code>cacheId</code> in the body)`,
false,
);
}
/** 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);
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache: created</title></head>
<body>
<h1>cache created</h1>
<p><b>cache id</b>, fingerprint <code>${esc(fingerprint)}</code>
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(cacheId)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
<p><label>your petname for it <input id="petname" autocomplete="off" size="30" value="${esc(`diigo cache ${fingerprint}`)}"></label>
<small>(private to you: the password-manager username; never sent to the server)</small></p>
<script>${PETNAME_TIME_JS}</script>
<p><button type="button" onclick='${pmSaveClick(cacheId)}'>save to password manager</button></p>
<p>${note}</p>
<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 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>
</body></html>`;
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
}
/** a fill job's progress, as one line of page text */
function fillLine(fill: FillStatus): string {
const of = `${Math.max(0, fill.nextPage - 1)} of ${fill.pages ?? "?"} pages`;
const said = {
running: `filling: ${of}`,
done: `filled: ${of}`,
stopped: `fill stopped: ${of}`,
failed: `fill failed at page ${fill.nextPage}: ${fill.error ?? "unknown error"}`,
}[fill.state];
return `<p>${esc(said)}</p>`;
}
/**
* publish, later: adds a public companion to a cache wired private-only.
* Its wiring already holds the webapp id, so no re-mint; the username
* comes from the items already cached. The cache id rides the POST body.
*/
async function publishCache(request: Request, env: Env): Promise<Response> {
let cacheId = "";
try {
const ct = request.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
const body = (await request.json()) as { cacheId?: string };
cacheId = String(body.cacheId ?? "");
} else {
const form = await request.formData();
cacheId = String(form.get("cacheId") || "");
}
} catch (err) {
return bad(`bad input: ${String(err)}`);
}
if (!isWebkey(cacheId) && !/^[0-9a-fA-F]{64}$/.test(cacheId)) {
return bad("no cache id supplied (POST body: cacheId)");
}
try {
const ns = env.CACHE_DO;
const cache = ns.get(ns.idFromString(idFromWebkey(cacheId)));
const wiring = await cache.wiring();
if (!wiring) {
return Response.json({ error: "unknown cache id" }, { status: 404 });
}
if (wiring.publicId) {
return Response.json({ cacheId, publicId: wiring.publicId, status: "already published" });
}
// the username comes from the items: the cookie's own account
const firstPage = await cache.page(1, PAGE_SIZE);
const firstItem = firstPage.items[0] ? (JSON.parse(firstPage.items[0]) as Item) : undefined;
const username = usernameOf(firstItem);
if (!username) {
console.error({
event: "publish",
stage: "username-derive",
// field names, not values: says which shape we got
itemFields: firstItem ? Object.keys(firstItem) : null,
itemsOnPage1: firstPage.items.length,
total: firstPage.total,
cached: firstPage.cached,
});
return Response.json(
{ error: "could not read your diigo username (u_name) from the cached items" },
{ status: 400 },
);
}
const publicId = toWebkey(env.PUBLIC_DO.newUniqueId());
const pubStub = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromString(idFromWebkey(publicId)));
await pubStub.setup({ webappId: wiring.webappId });
await cache.addPublic(publicId);
const index = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromName("index"));
await index.register(username, publicId);
return Response.json({ cacheId, publicId, username, public: `/public/${username}` });
} catch (err) {
// no cacheId in the log: it is the bearer secret
console.error({ event: "publish", error: String(err) });
return Response.json({ error: `publish failed: ${String(err)}` }, { status: 400 });
}
}
function bad(message: string): Response {
return Response.json({ error: message }, { status: 400 });
}
/** the private dump POST: cache id from the (client-wired) form value */
async function dump(request: Request, env: Env): Promise<Response> {
const form = await request.formData();
const cacheId = String(form.get("cacheId") || "");
const raw = form.get("page");
const pageN = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
if (!isWebkey(cacheId) && !/^[0-9a-fA-F]{64}$/.test(cacheId)) {
return home(env, { error: "wire a cache id first (create flow, then copy/paste)" }, null);
}
if (Number.isNaN(pageN) || pageN < 1 || pageN > 10000) {
return home(env, { error: "enter a page number (1 = most recently updated)" }, null);
}
try {
const ns = env.CACHE_DO;
const cache = ns.get(ns.idFromString(idFromWebkey(cacheId)));
const page = await cache.page(pageN, PAGE_SIZE);
const data = `total: ${page.total}\nitems: ${page.items.length}\ncache: ${page.cached ? "HIT (from storage)" : "MISS (etched now)"}` +
`\n\n${page.items.join("\n")}`;
return home(env, null, data, cacheId);
} catch (err) {
console.error({ event: "dump", page: pageN, error: String(err) });
return home(env, null, String(err), cacheId);
}
}
const PUBLIC_PER_PAGE = PAGE_SIZE;
/**
* the public page for a username: stored pages only, through the cache's
* read-only `viewer()` (it can't fetch or write). HTML with a page-number
* form, or JSON for `Accept: application/json`. `?page=` in the URL is
* fine here: nothing on this page is secret.
*/
async function publicPage(request: Request, env: Env, username: string): Promise<Response> {
const index = env.PUBLIC_DO.get(env.PUBLIC_DO.idFromName("index"));
const publicId = await index.lookup(username);
if (!publicId) return new Response("no such public cache", { status: 404 });
const ns = env.PUBLIC_DO;
const cache = ns.get(ns.idFromString(idFromWebkey(publicId)));
const raw = new URL(request.url).searchParams.get("page");
const pageN = raw === null ? 1 : Number.parseInt(raw, 10);
const valid = !Number.isNaN(pageN) && pageN >= 1 && pageN <= 10000;
const viewer = await cache.viewer();
let page, total, fill;
try {
page = await viewer.page(valid ? pageN : 1, PUBLIC_PER_PAGE);
total = page ? page.total : await viewer.knownTotal();
fill = await viewer.fillStatus();
} catch (err) {
console.error({ event: "public-page", page: pageN, error: String(err) });
return new Response(`could not load page ${valid ? pageN : 1}: ${String(err)}`, { status: 502 });
} finally {
viewer[Symbol.dispose]();
}
// total and pages count all of diigo's items: public page n is diigo
// page n with the private rows dropped
const pages = total === null ? 1 : Math.max(1, Math.ceil(total / PUBLIC_PER_PAGE));
const items = page ? page.items.map((j) => JSON.parse(j) as Item) : [];
const accept = request.headers.get("accept") ?? "";
if (accept.includes("application/json") && !accept.includes("text/html")) {
return Response.json({ username, page: pageN, pages, total, stored: page !== null, etchedAt: page?.etchedAt ?? null, items });
}
let notice = "";
if (!valid || pageN > pages) {
console.log({ event: "notice", page: "public", text: "page out of range", pages });
notice = `<p><em>no page ${esc(String(raw))}: pages run 1–${pages}</em></p>`;
}
const here = `/public/${encodeURIComponent(username)}`;
const nav = (n: number, label: string) =>
n >= 1 && n <= pages ? `<a href="${here}?page=${n}">${label}</a>` : `<span>${label}</span>`;
const list = notice
? ""
: `<ol>
${items
.map((it) => {
const url = typeof it.url === "string" ? it.url : "";
const title = typeof it.title === "string" && it.title ? it.title : url;
const desc = typeof it.description === "string" && it.description ? `<br><small>${esc(it.description)}</small>` : "";
const href = /^https?:\/\//i.test(url) ? url : "#";
const when = updatedISO(it);
const date = when ? `<time datetime="${when}">${when}</time>` : "(no date)";
return `<li>${date} <a href="${esc(href)}">${esc(title)}</a>${desc}</li>`;
})
.join("\n")}
</ol>`;
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>${esc(username)}'s shared bookmarks</title></head>
<body>
<h1>${esc(username)}'s shared bookmarks</h1>
<p>${pages} page${pages === 1 ? "" : "s"} (diigo's, ${PUBLIC_PER_PAGE} bookmarks each; private ones are left out, so a page may show fewer).</p>
<p>${page ? `<strong>stored ${esc(page.etchedAt ?? "")}</strong> — page ${valid ? pageN : 1}, ${items.length} shared` : `<strong>not fetched yet</strong> — page ${valid ? pageN : 1}; the cache's owner fills it`}</p>
${fill ? fillLine(fill) : ""}
<form method="get" action="${here}">
<p>${nav(pageN - 1, "← prev")}
<label>page <input type="number" name="page" min="1" value="${Number.isNaN(pageN) ? 1 : pageN}"></label>
<button type="submit">go</button>
${nav(pageN + 1, "next →")}</p>
</form>
${notice}${list}
</body></html>`;
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
}
/** home page: the connect link (webapp's cookie form, diigo-prefilled) +
* the wire step for an already-minted id + the dump form. `home` renders
* the error path too, so it takes env for the connect link. Notices (the
* inert-GET explainer, probe failures) log at info — exceptional errors
* log at error in their own handlers. */
async function home(env: Env, opts: { error?: string } | null, data: string | null, cacheId?: string): Promise<Response> {
if (opts?.error) {
console.log({ event: "notice", page: "home", text: opts.error });
}
const cacheState = cacheId
? `<p>cache id, fingerprint <code>${esc(await nick(cacheId))}</code></p>`
: `<p>no cache id wired yet — use the create flow below (or paste one).</p>`;
const dumpForm = `<form method="post" action="/">
${cacheId ? `<input type="hidden" name="cacheId" value="${esc(cacheId)}">` : `<p><label>petname <input autocomplete="username" size="24"></label>
<label>cache id <input type="password" name="cacheId" autocomplete="current-password" size="24"></label></p>`}
<p><label>page <input type="number" name="page" min="1"></label> <button type="submit">dump</button></p>
</form>`;
const result = data
? `<pre readonly style="white-space:pre-wrap;border:1px solid #ccc;padding:1rem;max-height:60vh;overflow:auto">${escapeHtml(data)}</pre>`
: opts && opts.error
? `<p><em>${escapeHtml(opts.error)}</em></p>`
: "";
return new Response(
`<!doctype html>
<html><head><meta charset="utf-8"><title>diigo cache</title></head>
<body>
<h1>diigo cache</h1>
${cacheState}
<h2>create (two steps)</h2>
<ol>
<li><a href="/_connect">mint the diigo session at webapp</a> — webapp's
own mint form (diigo-prefilled); paste the Cookie header there. The
cookie transits this worker only as an opaque relay into webapp's DO;
nobody here opens it. Mint lands you on the wire step, id pre-filled.</li>
<li>wire the id into a cache (mode: public|private):</li>
</ol>
<form method="post" action="/_wire">
<p><label>petname <input autocomplete="username" size="24"></label>
<label>webapp id <input type="password" name="webappId" size="24" autocomplete="current-password"></label>
<label>mode <select name="mode"><option value="private">private</option><option value="public">public (serves /public/&lt;your-diigo-username&gt;)</option></select></label></p>
<p><button type="submit">wire</button></p>
</form>
<p>the ids this mints are for <b>you</b>: copy them somewhere you control (password manager)</p>
<p><a href="/cache/">open a cache</a> — fill it, watch progress (your password manager can fill in the cache id)</p>
${dumpForm}
${result}
</body></html>`,
{ headers: { "content-type": "text/html;charset=utf-8" } },
);
}
/** a short display name for a bearer id: a prefix of its swissHash
* (identity without authority), so labels and pages never show any
* glyphs of the key itself */
async function nick(id: string): Promise<string> {
return (await shashKey(id)).slice(0, 8);
}
function esc(s: string): string {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
const escapeHtml = esc;