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

+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);
}
}