capsite/lib: MapStore and patterns work-alikes

Enough of @agoric/store's MapStore and @endo/patterns' M for the
credential-rotation design: makeMapStore(label, { keyShape, valueShape },
kv) keeps entries in a DO's sync KV under store:<label>:, and readOnly()
hands out the queries alone. Facets are workerd RpcTargets in place of
endo's Far.

Unlike endo, error messages never include keys or values: the values
here are credentials, and messages cross RPC into logs.

Generated-by: Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-09 02:32:22 -05:00
1 parent 1d8a220bbc
commit 9179bdbf38
10 files changed
+2586

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
{
"name": "capsite-lib",
"private": true,
"type": "module",
"scripts": {
"types": "wrangler types -c wrangler.tests.jsonc",
"lint": "wrangler types -c wrangler.tests.jsonc --check && tsc -p tsconfig.json && tsc -p tests/tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.3.6",
"typescript": "^5.9.0",
"vitest": "4.1.11",
"wrangler": "^4.148.0"
}
}
+2240
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
/**
* @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).
* @see https://github.com/endojs/endo/tree/master/packages/patterns
*/
type MatcherKind = "string" | "any";
/** 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;
const kinds = new WeakMap<object, MatcherKind>();
const makeMatcher = (kind: MatcherKind, label: string): Matcher => {
const matcher = Object.freeze({ toString: () => label });
kinds.set(matcher, kind);
return matcher;
};
const STRING = makeMatcher("string", "M.string()");
const ANY = makeMatcher("any", "M.any()");
export const M = Object.freeze({
string: (): Matcher => STRING,
any: (): Matcher => ANY,
});
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");
}
return Object.is(specimen, pattern);
}
/**
* throw unless `specimen` matches `pattern`. Unlike endo's, the message
* names the pattern and `label` but never the specimen: specimens here
* can be credentials, and messages cross RPC and land in logs.
*/
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}`);
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* @file a work-alike for enough of @agoric/store's MapStore: string keys,
* shapes checked with ./patterns.js, entries kept in a Durable Object's
* synchronous KV storage. The facets are RpcTargets (workerd's / capnweb's,
* in place of endo's `Far`), so a store or its read-only view can be
* handed out by reference.
* @see https://github.com/Agoric/agoric-sdk/tree/master/packages/store
*/
import { RpcTarget } from "cloudflare:workers";
import { M, mustMatch, type Pattern } from "./patterns.js";
/** the storage a store needs: a DO's `ctx.storage.kv`, or a stand-in */
export type Kv = Pick<SyncKvStorage, "get" | "put" | "delete" | "list">;
export type StoreShapes = { keyShape?: Pattern; valueShape?: Pattern };
/**
* Entries live under `store:<label>:<key>`, so the label also names the
* storage area (as a baggage key does for endo's durable stores): two
* stores with one label on one Kv are the same store.
*
* Error messages name the label and shapes, never keys or values.
*/
export function makeMapStore<V>(label: string, { keyShape = M.string(), valueShape = M.any() }: StoreShapes, kv: Kv) {
if (label.includes(":")) throw new TypeError("store label must not contain ':'");
const prefix = `store:${label}:`;
const core: Core<V> = {
label,
has: (key) => kv.get(prefix + key) !== undefined,
get: (key) => kv.get<V>(prefix + key),
put: (key, value) => kv.put(prefix + key, value),
delete: (key) => {
kv.delete(prefix + key);
},
entries: () => [...kv.list<V>({ prefix })].map(([k, v]): [string, V] => [k.slice(prefix.length), v]),
checkKey: (key) => mustMatch(key, keyShape, `${label}: key`),
checkValue: (value) => mustMatch(value, valueShape, `${label}: value`),
};
return new MapStore<V>(core);
}
type Core<V> = {
label: string;
has(key: string): boolean;
get(key: string): V | undefined;
put(key: string, value: V): void;
delete(key: string): void;
entries(): [string, V][];
checkKey(key: unknown): void;
checkValue(value: unknown): void;
};
/** the queries */
function reads<V>(core: Core<V>) {
return {
has: (key: string): boolean => core.has(key),
get: (key: string): V => {
const value = core.get(key);
if (value === undefined) throw new RangeError(`${core.label}: key not found`);
return value;
},
keys: (): string[] => core.entries().map(([k]) => k),
values: (): V[] => core.entries().map(([, v]) => v),
entries: (): [string, V][] => core.entries(),
getSize: (): number => core.entries().length,
};
}
/** a store's queries only: what a reader of a rotating credential holds */
export class ReadonlyMapStore<V> extends RpcTarget {
#reads: ReturnType<typeof reads<V>>;
constructor(core: Core<V>) {
super();
this.#reads = reads(core);
}
has(key: string): boolean {
return this.#reads.has(key);
}
get(key: string): V {
return this.#reads.get(key);
}
keys(): string[] {
return this.#reads.keys();
}
values(): V[] {
return this.#reads.values();
}
entries(): [string, V][] {
return this.#reads.entries();
}
getSize(): number {
return this.#reads.getSize();
}
}
/** a store's full authority: the queries plus `init`/`set`/`delete`, and
* {@link MapStore.readOnly} to hand out the queries alone */
export class MapStore<V> extends ReadonlyMapStore<V> {
#core: Core<V>;
constructor(core: Core<V>) {
super(core);
this.#core = core;
}
/** add a key that is not yet present */
init(key: string, value: V): void {
this.#core.checkKey(key);
this.#core.checkValue(value);
if (this.#core.has(key)) throw new RangeError(`${this.#core.label}: key already registered`);
this.#core.put(key, value);
}
/** replace the value of a key that is present */
set(key: string, value: V): void {
this.#core.checkKey(key);
this.#core.checkValue(value);
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
this.#core.put(key, value);
}
delete(key: string): void {
if (!this.#core.has(key)) throw new RangeError(`${this.#core.label}: key not found`);
this.#core.delete(key);
}
readOnly(): ReadonlyMapStore<V> {
return new ReadonlyMapStore(this.#core);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, expect, it } from "vitest";
import { M, matches, mustMatch } from "../src/patterns.js";
type TestEnv = { STORE_DO: DurableObjectNamespace<import("./test-worker.js").StoreHost> };
const testEnv = env as unknown as TestEnv;
const SECRET = "session=s3kr1t";
/** the message an RPC call fails with, or "" if it succeeds */
async function errorOf(call: Promise<unknown>): Promise<string> {
try {
await call;
return "";
} catch (err) {
return String(err);
}
}
describe("patterns", () => {
it("M.string() matches strings only; a literal matches only itself", () => {
expect(matches("x", M.string())).toBe(true);
expect(matches(1, M.string())).toBe(false);
expect(matches(undefined, M.any())).toBe(true);
expect(matches("cookie", "cookie")).toBe(true);
expect(matches("authorization", "cookie")).toBe(false);
});
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);
});
});
describe("MapStore over RPC", () => {
const fresh = () => testEnv.STORE_DO.get(testEnv.STORE_DO.newUniqueId());
it("rotates: the admin's set is what the read-only view sees next", async () => {
const store = await fresh().cookies();
const rd = await store.readOnly();
await store.init("cookie", "session=old");
expect(await rd.get("cookie")).toBe("session=old");
await store.set("cookie", "session=new");
expect(await rd.entries()).toEqual([["cookie", "session=new"]]);
});
it("the read-only view has no write methods", async () => {
const store = await fresh().cookies();
const rd = (await store.readOnly()) as unknown as { set(k: string, v: string): Promise<void> };
expect(await errorOf(rd.set("cookie", SECRET))).not.toBe("");
expect(await store.has("cookie")).toBe(false);
});
it("the key shape admits only `cookie`; errors carry no values", async () => {
const store = await fresh().cookies();
const err = await errorOf(store.init("authorization", SECRET));
expect(err).toContain('cookie: key: must match "cookie"');
expect(err).not.toContain(SECRET);
expect(await errorOf(store.init("cookie", 42 as unknown as string))).toContain("cookie: value: must match M.string()");
});
it("init refuses a present key; set and get refuse a missing one", async () => {
const store = await fresh().cookies();
expect(await errorOf(store.set("cookie", SECRET))).toContain("cookie: key not found");
expect(await errorOf(store.get("cookie"))).toContain("cookie: key not found");
await store.init("cookie", SECRET);
const err = await errorOf(store.init("cookie", SECRET));
expect(err).toContain("cookie: key already registered");
expect(err).not.toContain(SECRET);
});
it("survives eviction: entries live in DO storage", async () => {
const stub = fresh();
{
// disposed before eviction: a live store stub holds the DO open
using store = await stub.cookies();
await store.init("cookie", "session=durable");
}
await evictDurableObject(stub);
using again = await stub.cookies();
expect(await again.get("cookie")).toBe("session=durable");
expect(await again.getSize()).toBe(1);
await again.delete("cookie");
expect(await again.keys()).toEqual([]);
});
});
+15
View File
@@ -0,0 +1,15 @@
/** @file the tests' main module: a DO that hosts stores on its own storage. */
import { DurableObject } from "cloudflare:workers";
import { M } from "../src/patterns.js";
import { makeMapStore, type MapStore } from "../src/store.js";
/** hosts the store from the credential-rotation sketch: one key, `cookie` */
export class StoreHost extends DurableObject {
cookies(): MapStore<string> {
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
}
}
export default {
fetch: () => new Response("store tests", { status: 404 }),
};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/vitest-plugin/types"]
},
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable"],
"noEmit": true,
"types": []
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from "vitest/config";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
export default defineProject({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.tests.jsonc" } })],
test: {
include: ["tests/**/*.test.ts"],
},
});
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
// a host DO for the stores under test (lib ships no worker of its own)
"name": "capsite-lib-tests",
"main": "./tests/test-worker.ts",
"compatibility_date": "2026-09-29",
"durable_objects": {
"bindings": [{ "name": "STORE_DO", "class_name": "StoreHost" }]
},
"exports": {
"StoreHost": { "type": "durable-object", "storage": "sqlite" }
}
}