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
205 lines
6.7 KiB
TypeScript
205 lines
6.7 KiB
TypeScript
/**
|
||
* @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));
|
||
}
|