Files
captoys/diigo-cache/src/webkeys.ts
T
dckc 6f409fc83a 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
2026-10-07 04:19:54 -05:00

205 lines
6.7 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 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));
}