diigo-cache: bookmark cache in Durable Objects (goal 1c)

The dump worker becomes a page cache: a hit is served from DO SQLite
storage with no diigo call; a miss asks the webapp instance in the
cache's wiring and etches the page.

- two independent caches, one behavior (PagedCache): DiigoCache keeps
  every item; PublicCache keeps shared items only, dropping private
  rows before they are stored; served at /public/<username> (HTML page
  form, or JSON)
- create flow on one port: /_connect relays webapp's mint form, /_mint
  proxies the mint, /_wire wires a private or public cache; /_publish
  adds a public cache later
- ids are webkeys: kept out of URLs (POST bodies), shown and logged
  only as a hash fingerprint; saved to the password manager under the
  holder's petname (never sent to the server)
- items keyed on diigo's link_id; username from u_name; unkeyable
  items and every failure path are logged; each page read logs hit/miss
- tests run the real worker + DOs in the vitest pool against a fake
  webapp (tests/fake-webapp.js)

Generated-by: opencode (GLM)
Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 04:19:54 -05:00
1 parent c5e433cc9c
commit 6f409fc83a
15 files changed
+1664 -211

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
/**
* @file webkeys: URL-borne bearer permissions, per Tyler Close's
* ["web-key: Mashing with permission"](https://waterken.sourceforge.net/web-key/)
* (IEEE W2SP 2008); the design abstract lives in
* [docs/webkeys.md](../../docs/webkeys.md), with the Waterken precursor
* code in `kpreid/e-on-java` as provenance (YURL32 = `org.waterken.uri.
* Base32` / `BigIntegerSugar.toYURL32`; bearer invariant = `EARL.java`;
* identity-without-authority = `SwissTable`'s swissHash).
*
* A webkey separates secret from address:
* - the **secret bits**: supplied directly by the caller as a Worker
* unique ID (e.g. `DurableObjectId` from `ns.newUniqueId()`), or raw
* bytes (`Uint8Array`). Per [OCap discipline]
* (.opencode/skills/ocap-discipline/SKILL.md), functions here are pure:
* no ambient RNG calls, no callback indirection. The caller holds the
* minting authority at the application boundary and supplies the result.
* - the **encoding**: YURL32 — lowercase `a–z` + `2–7`, 5 bits/char,
* URL-safe by construction (no `-`, no `_`, no case to misread).
*
* Cloudflare Worker `DurableObjectId`s are 256 bits (32 bytes), rendered
* as 64 hex characters by `id.toString()`. {@link toWebkey} encodes that
* ID into 52 YURL32 glyphs; {@link idFromWebkey} decodes it back to the
* 64-hex string expected by `ns.idFromString()`.
*
* {@link shashKey} gives the identity-without-authority twin (the
* swissHash discipline) for log-correlation: one-way, on SHA-256.
*/
/** the YURL32 alphabet: `a–z` then `2–7` (32 glyphs, 5 bits each) */
const YURL32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" as const;
/** encode bytes as YURL32 (`a–z2–7`, 5 bits/char; no padding, the tail
* byte's leftover bits shift-left, as `org.waterken.uri.Base32.encode`
* does) */
export function yurl32(bytes: Uint8Array): string {
let out = "";
let buffer = 0;
let bits = 0;
for (const byte of bytes) {
buffer = (buffer << 8) | byte;
bits += 8;
while (bits >= 5) {
out += YURL32_ALPHABET[(buffer >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
out += YURL32_ALPHABET[(buffer << (5 - bits)) & 31];
}
return out;
}
/** decode YURL32 back to bytes (inverse of {@link yurl32}); throws on
* any character outside the alphabet — a webkey is either a webkey or
* it is not */
export function unyurl32(text: string): Uint8Array {
let buffer = 0;
let bits = 0;
const out: number[] = [];
for (const ch of text) {
const v = YURL32_ALPHABET.indexOf(ch);
if (v < 0) throw new Error(`not a webkey: ${JSON.stringify(ch)} is not YURL32`);
buffer = (buffer << 5) | v;
bits += 5;
if (bits >= 8) {
out.push((buffer >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
// leftover bits (<8) are the encoder's zero-padding: validate, don't
// carry them
if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) {
throw new Error("not a webkey: non-zero tail bits");
}
return new Uint8Array(out);
}
/** parse a hexadecimal string to raw bytes */
export function hexToBytes(hex: string): Uint8Array {
const trimmed = hex.trim();
if (trimmed.length % 2 !== 0) throw new Error("invalid hex length");
const bytes = new Uint8Array(trimmed.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
const val = Number.parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(val)) throw new Error(`invalid hex byte at offset ${i * 2}`);
bytes[i] = val;
}
return bytes;
}
/** encode raw bytes to a lowercase hexadecimal string */
export function bytesToHex(bytes: Uint8Array): string {
let hex = "";
for (const b of bytes) {
hex += b.toString(16).padStart(2, "0");
}
return hex;
}
export const BITS_PER_GLYPH = 5;
/** chars needed for n bits in YURL32 */
export function glyphsFor(bits: number): number {
return Math.ceil(bits / BITS_PER_GLYPH);
}
/** YURL32 glyphs → bytes recoverable via {@link unyurl32} */
export function bytesInGlyphs(glyphs: number): number {
return Math.floor((glyphs * BITS_PER_GLYPH) / 8);
}
/**
* Structural type for a Cloudflare Worker unique ID (such as `DurableObjectId`),
* matching `id.toString()` which returns the 64-hex string.
*/
export interface DurableObjectIdLike {
toString(): string;
equals?(other: unknown): boolean;
readonly name?: string;
}
/** A Worker unique ID input: a `DurableObjectId`, 64-char hex string, or raw bytes. */
export type WorkerUniqueId = DurableObjectIdLike | Uint8Array;
/**
* Convert a Worker unique ID (or raw bytes) to a YURL32 webkey string.
*
* @param id A `DurableObjectId` (from `ns.newUniqueId()`), a 64-char hex ID,
* or raw bytes (`Uint8Array`).
* @returns A YURL32 webkey string (52 glyphs for 256-bit `DurableObjectId`,
* 26 glyphs for 128-bit raw bytes).
*/
export function toWebkey(id: WorkerUniqueId | string): string {
if (id instanceof Uint8Array) {
return yurl32(id);
}
const str = typeof id === "string" ? id.trim() : id.toString();
if (/^[0-9a-fA-F]{64}$/.test(str)) {
return yurl32(hexToBytes(str));
}
if (isWebkey(str)) {
return str;
}
throw new Error("cannot convert ID to webkey: unrecognized format");
}
/**
* Convert a YURL32 webkey string back to the 64-character hex ID string
* expected by `DurableObjectNamespace.idFromString(hex)`.
*
* If the input is already a 64-character hex ID, it is validated and returned
* in lowercase for backwards compatibility.
*/
export function idFromWebkey(webkey: string): string {
const trimmed = webkey.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
return trimmed.toLowerCase();
}
const bytes = unyurl32(trimmed);
if (bytes.length !== 32) {
throw new Error(
`expected 32 bytes (256 bits) for Worker unique ID, got ${bytes.length}`,
);
}
return bytesToHex(bytes);
}
/**
* Is `text` a well-formed webkey of the expected size?
*
* When `bits` is omitted, accepts standard 256-bit Worker unique ID webkeys
* (52 glyphs), 128-bit webkeys (26 glyphs), or 64-bit webkeys (13 glyphs).
*/
export function isWebkey(text: string, bits?: number): boolean {
if (bits !== undefined) {
if (text.length !== glyphsFor(bits)) return false;
try {
return unyurl32(text).length === Math.floor(bits / 8);
} catch {
return false;
}
}
if (text.length === 52 || text.length === 26 || text.length === 13) {
try {
const b = unyurl32(text);
return b.length === 32 || b.length === 16 || b.length === 8;
} catch {
return false;
}
}
return false;
}
/**
* The identity-without-authority twin (the swissHash discipline):
* SHA-256 hash of the webkey, YURL32-encoded (52 glyphs).
*
* Safe for logs/metrics/joins; never accepted as a credential.
*/
export async function shashKey(key: string | WorkerUniqueId): Promise<string> {
const text = typeof key === "string" ? key : toWebkey(key);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return yurl32(new Uint8Array(digest));
}