From 357e4561c4c5e54abd1586f71a5ac5600b1d58ed Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 8 Oct 2026 15:45:21 -0500 Subject: [PATCH] capsite/lib: endo's own matcher data, smallcaps, and MapStoreDo Matchers are now exactly what @endo/patterns makes: tagged records as from pass-style's makeTagged (M.string() is match:string with payload [], M.any() is match:any with payload undefined). Structured clone drops their symbol-keyed and non-enumerable properties, so they cross DO storage and Workers RPC in marshal's smallcaps encoding (the subset patterns need). MapStoreDo is a MapStore as a DO of its own: its creator fixes label and (encoded) shapes once; its id is full authority, and readOnly() hands out the queries alone. Generated-by: Claude Opus 5.5 --- lib/src/patterns.ts | 122 ++++++++++++++++++++++++++++++++------- lib/src/store-do.ts | 60 +++++++++++++++++++ lib/tests/store.test.ts | 59 ++++++++++++++++++- lib/tests/test-worker.ts | 2 + lib/wrangler.tests.jsonc | 8 ++- 5 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 lib/src/store-do.ts diff --git a/lib/src/patterns.ts b/lib/src/patterns.ts index 5c9f844..4ae0d48 100644 --- a/lib/src/patterns.ts +++ b/lib/src/patterns.ts @@ -1,43 +1,67 @@ /** * @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`, * and literal patterns (a string, number, boolean, or null matches only - * itself, so `"cookie"` as a key shape admits exactly one key). + * itself, so `"cookie"` as a key shape admits exactly one key). Matchers + * are the same data endo makes: tagged records, as from @endo/pass-style's + * `makeTagged` (frozen here, where endo would harden). + * + * Structured clone (DO storage, Workers RPC) drops a tagged record's + * symbol-keyed and non-enumerable properties, so patterns cross those + * boundaries in endo's smallcaps encoding: see {@link toSmallcaps}. * @see https://github.com/endojs/endo/tree/master/packages/patterns + * @see https://github.com/endojs/endo/blob/master/packages/marshal/src/encodeToSmallcaps.js */ -type MatcherKind = "string" | "any"; +const PASS_STYLE = Symbol.for("passStyle"); -/** a matcher made by {@link M}; recognized by identity, so it is meaningful - * only in the realm that made it (it does not survive an RPC copy) */ -export type Matcher = Readonly<{ toString(): string }>; -export type Pattern = Matcher | string | number | boolean | null; +export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>; +export type Pattern = Tagged | string | number | boolean | null; -const kinds = new WeakMap(); +/** @endo/pass-style's `makeTagged` */ +export function makeTagged(tag: string, payload: unknown): Tagged { + return Object.freeze( + Object.create(Object.prototype, { + [PASS_STYLE]: { value: "tagged" }, + [Symbol.toStringTag]: { value: tag }, + payload: { value: payload, enumerable: true }, + }), + ) as Tagged; +} -const makeMatcher = (kind: MatcherKind, label: string): Matcher => { - const matcher = Object.freeze({ toString: () => label }); - kinds.set(matcher, kind); - return matcher; -}; +const isTagged = (x: unknown): x is Tagged => + typeof x === "object" && x !== null && (x as Record)[PASS_STYLE] === "tagged"; -const STRING = makeMatcher("string", "M.string()"); -const ANY = makeMatcher("any", "M.any()"); +const StringShape = makeTagged("match:string", Object.freeze([])); +const AnyShape = makeTagged("match:any", undefined); export const M = Object.freeze({ - string: (): Matcher => STRING, - any: (): Matcher => ANY, + string: (): Tagged => StringShape, + any: (): Tagged => AnyShape, }); export function matches(specimen: unknown, pattern: Pattern): boolean { if (typeof pattern === "object" && pattern !== null) { - const kind = kinds.get(pattern); - if (kind === "string") return typeof specimen === "string"; - if (kind === "any") return true; - throw new TypeError("not a pattern"); + const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined; + switch (tag) { + case "match:string": + return typeof specimen === "string"; + case "match:any": + return true; + default: + throw new TypeError("not a pattern"); + } } return Object.is(specimen, pattern); } +/** the pattern as written in code, e.g. `M.string()` or `"cookie"` */ +export function describe(pattern: Pattern): string { + if (typeof pattern === "object" && pattern !== null) { + return `M.${pattern[Symbol.toStringTag].replace(/^match:/, "")}()`; + } + return JSON.stringify(pattern); +} + /** * throw unless `specimen` matches `pattern`. Unlike endo's, the message * names the pattern and `label` but never the specimen: specimens here @@ -45,7 +69,61 @@ export function matches(specimen: unknown, pattern: Pattern): boolean { */ export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void { if (!matches(specimen, pattern)) { - const shape = typeof pattern === "object" && pattern !== null ? String(pattern) : JSON.stringify(pattern); - throw new TypeError(`${label}: must match ${shape}`); + throw new TypeError(`${label}: must match ${describe(pattern)}`); } } + +/** a smallcaps-encoded passable, as JSON-able data */ +export type Smallcaps = null | boolean | number | string | Smallcaps[] | { "#tag": string; payload: Smallcaps }; + +/** smallcaps quotes strings that start with one of these with `!` */ +const startsSpecial = (s: string) => /^[!-\-]/.test(s); + +/** + * the subset of @endo/marshal's smallcaps encoding that patterns here use: + * primitives, arrays, and tagged records (not a whole CapData: no `#` + * body prefix, no slots) + */ +export function toSmallcaps(passable: unknown): Smallcaps { + if (passable === undefined) return "#undefined"; + if (passable === null || typeof passable === "boolean") return passable; + if (typeof passable === "number") { + if (Number.isNaN(passable)) return "#NaN"; + if (passable === Infinity) return "#Infinity"; + if (passable === -Infinity) return "#-Infinity"; + return Object.is(passable, -0) ? 0 : passable; + } + if (typeof passable === "string") return startsSpecial(passable) ? `!${passable}` : passable; + if (Array.isArray(passable)) return passable.map(toSmallcaps); + if (isTagged(passable)) { + return { "#tag": toSmallcaps(passable[Symbol.toStringTag]) as string, payload: toSmallcaps(passable.payload) }; + } + throw new TypeError("toSmallcaps: unsupported passable"); +} + +/** the inverse of {@link toSmallcaps} */ +export function fromSmallcaps(encoding: Smallcaps): unknown { + if (encoding === null || typeof encoding === "boolean" || typeof encoding === "number") return encoding; + if (typeof encoding === "string") { + if (encoding.startsWith("!")) return encoding.slice(1); + switch (encoding) { + case "#undefined": + return undefined; + case "#NaN": + return NaN; + case "#Infinity": + return Infinity; + case "#-Infinity": + return -Infinity; + } + if (startsSpecial(encoding)) throw new TypeError("fromSmallcaps: unsupported special string"); + return encoding; + } + if (Array.isArray(encoding)) return Object.freeze(encoding.map(fromSmallcaps)); + if (typeof encoding === "object" && "#tag" in encoding) { + const { "#tag": tag, payload, ...rest } = encoding; + if (Object.keys(rest).length > 0) throw new TypeError("fromSmallcaps: extra properties on a tagged record"); + return makeTagged(fromSmallcaps(tag) as string, fromSmallcaps(payload)); + } + throw new TypeError("fromSmallcaps: unsupported encoding"); +} diff --git a/lib/src/store-do.ts b/lib/src/store-do.ts new file mode 100644 index 0000000..100ffea --- /dev/null +++ b/lib/src/store-do.ts @@ -0,0 +1,60 @@ +/** + * @file a MapStore as a Durable Object of its own, so a store can be held + * apart from whoever reads it: the DO id is the store's full authority + * (e.g. the account owner's, to rotate a credential), while a reader is + * handed only {@link MapStoreDo.readOnly}. + */ + +import { DurableObject } from "cloudflare:workers"; +import { fromSmallcaps, matches, toSmallcaps, type Pattern, type Smallcaps } from "./patterns.js"; +import { makeMapStore, type MapStore, type ReadonlyMapStore, type StoreShapes } from "./store.js"; + +/** store shapes as they cross RPC and sit in storage: smallcaps-encoded + * (structured clone would strip a matcher down to its payload) */ +export type EncodedShapes = { keyShape?: Smallcaps; valueShape?: Smallcaps }; + +export const encodeShapes = ({ keyShape, valueShape }: StoreShapes): EncodedShapes => ({ + ...(keyShape === undefined ? {} : { keyShape: toSmallcaps(keyShape) }), + ...(valueShape === undefined ? {} : { valueShape: toSmallcaps(valueShape) }), +}); + +const decodeShapes = ({ keyShape, valueShape }: EncodedShapes): StoreShapes => ({ + ...(keyShape === undefined ? {} : { keyShape: fromSmallcaps(keyShape) as Pattern }), + ...(valueShape === undefined ? {} : { valueShape: fromSmallcaps(valueShape) as Pattern }), +}); + +type Config = { label: string; shapes: EncodedShapes }; + +/** a store whose label and shapes its creator fixes, once */ +export class MapStoreDo extends DurableObject { + /** one-shot: fix the label and shapes (see {@link encodeShapes}); an + * identical re-configure is a no-op */ + configure(label: string, shapes: EncodedShapes): void { + const decoded = decodeShapes(shapes); + for (const shape of [decoded.keyShape, decoded.valueShape]) { + if (shape !== undefined) matches(undefined, shape); // throws on a non-pattern + } + makeMapStore(label, decoded, this.ctx.storage.kv); // throws on a bad label + const config: Config = { label, shapes: encodeShapes(decoded) }; + const existing = this.ctx.storage.kv.get("config"); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(config)) { + throw new Error("already configured with a different label or shapes"); + } + return; + } + this.ctx.storage.kv.put("config", config); + } + + /** the store, with full authority */ + store(): MapStore { + const config = this.ctx.storage.kv.get("config"); + if (!config) throw new Error("not configured: this store was never set up"); + return makeMapStore(config.label, decodeShapes(config.shapes), this.ctx.storage.kv); + } + + /** the store's queries alone */ + readOnly(): ReadonlyMapStore { + return this.store().readOnly(); + } +} diff --git a/lib/tests/store.test.ts b/lib/tests/store.test.ts index f058cfc..5672b29 100644 --- a/lib/tests/store.test.ts +++ b/lib/tests/store.test.ts @@ -1,9 +1,13 @@ import { env } from "cloudflare:workers"; import { evictDurableObject } from "cloudflare:test"; import { describe, expect, it } from "vitest"; -import { M, matches, mustMatch } from "../src/patterns.js"; +import { M, fromSmallcaps, makeTagged, matches, mustMatch, toSmallcaps } from "../src/patterns.js"; +import { encodeShapes } from "../src/store-do.js"; -type TestEnv = { STORE_DO: DurableObjectNamespace }; +type TestEnv = { + STORE_DO: DurableObjectNamespace; + MAP_STORE_DO: DurableObjectNamespace; +}; const testEnv = env as unknown as TestEnv; const SECRET = "session=s3kr1t"; @@ -27,6 +31,28 @@ describe("patterns", () => { expect(matches("authorization", "cookie")).toBe(false); }); + it("matchers are endo's tagged records", () => { + const s = M.string() as unknown as Record; + expect(s[Symbol.for("passStyle")]).toBe("tagged"); + expect(s[Symbol.toStringTag]).toBe("match:string"); + expect(Object.keys(s)).toEqual(["payload"]); + expect(s.payload).toEqual([]); + expect((M.any() as unknown as Record).payload).toBeUndefined(); + expect(Object.isFrozen(s)).toBe(true); + }); + + it("smallcaps: endo's encoding, round-tripped", () => { + expect(toSmallcaps(M.string())).toEqual({ "#tag": "match:string", payload: [] }); + expect(toSmallcaps(M.any())).toEqual({ "#tag": "match:any", payload: "#undefined" }); + expect(toSmallcaps("#cookie")).toBe("!#cookie"); + expect(toSmallcaps([NaN, -0, Infinity])).toEqual(["#NaN", 0, "#Infinity"]); + const back = fromSmallcaps(toSmallcaps(M.string())) as Parameters[1]; + expect(matches("x", back)).toBe(true); + expect(matches(1, back)).toBe(false); + expect(fromSmallcaps("!#cookie")).toBe("#cookie"); + expect(() => matches("x", makeTagged("match:nope", []))).toThrow("not a pattern"); + }); + it("mustMatch names the shape but not the specimen", () => { expect(() => mustMatch(SECRET, "cookie", "jar: key")).toThrow('jar: key: must match "cookie"'); expect(() => mustMatch(SECRET, "cookie", "jar: key")).not.toThrow(SECRET); @@ -85,3 +111,32 @@ describe("MapStore over RPC", () => { expect(await again.keys()).toEqual([]); }); }); + +describe("MapStoreDo", () => { + const COOKIE_SHAPES = encodeShapes({ keyShape: "cookie", valueShape: M.string() }); + const fresh = () => testEnv.MAP_STORE_DO.get(testEnv.MAP_STORE_DO.newUniqueId()); + + it("keeps the shapes its creator chose, across eviction", async () => { + const stub = fresh(); + await stub.configure("cookie", COOKIE_SHAPES); + await evictDurableObject(stub); + { + using store = await stub.store(); + expect(await errorOf(store.init("authorization", SECRET))).toContain('cookie: key: must match "cookie"'); + expect(await errorOf(store.init("cookie", 42))).toContain("cookie: value: must match M.string()"); + await store.init("cookie", "session=a"); + } + using rd = await stub.readOnly(); + expect(await rd.entries()).toEqual([["cookie", "session=a"]]); + }); + + it("configures once: the same shapes again is a no-op, others are refused", async () => { + const stub = fresh(); + await stub.configure("cookie", COOKIE_SHAPES); + await stub.configure("cookie", encodeShapes({ keyShape: "cookie", valueShape: M.string() })); + expect(await errorOf(stub.configure("cookie", encodeShapes({ keyShape: M.string() })))).toContain( + "already configured", + ); + expect(await errorOf(fresh().store())).toContain("not configured"); + }); +}); diff --git a/lib/tests/test-worker.ts b/lib/tests/test-worker.ts index 631f997..98fb5ae 100644 --- a/lib/tests/test-worker.ts +++ b/lib/tests/test-worker.ts @@ -3,6 +3,8 @@ import { DurableObject } from "cloudflare:workers"; import { M } from "../src/patterns.js"; import { makeMapStore, type MapStore } from "../src/store.js"; +export { MapStoreDo } from "../src/store-do.js"; + /** hosts the store from the credential-rotation sketch: one key, `cookie` */ export class StoreHost extends DurableObject { cookies(): MapStore { diff --git a/lib/wrangler.tests.jsonc b/lib/wrangler.tests.jsonc index 53603b6..6f28fe4 100644 --- a/lib/wrangler.tests.jsonc +++ b/lib/wrangler.tests.jsonc @@ -5,9 +5,13 @@ "main": "./tests/test-worker.ts", "compatibility_date": "2026-09-29", "durable_objects": { - "bindings": [{ "name": "STORE_DO", "class_name": "StoreHost" }] + "bindings": [ + { "name": "STORE_DO", "class_name": "StoreHost" }, + { "name": "MAP_STORE_DO", "class_name": "MapStoreDo" } + ] }, "exports": { - "StoreHost": { "type": "durable-object", "storage": "sqlite" } + "StoreHost": { "type": "durable-object", "storage": "sqlite" }, + "MapStoreDo": { "type": "durable-object", "storage": "sqlite" } } }