WIP: webapp: rotate the diigo cookie in place #1
No files matched your search
+99
-21
@@ -1,43 +1,67 @@
|
|||||||
/**
|
/**
|
||||||
* @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`,
|
* @file a work-alike for enough of @endo/patterns: `M.string()`, `M.any()`,
|
||||||
* and literal patterns (a string, number, boolean, or null matches only
|
* 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/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
|
export type Tagged = Readonly<{ [Symbol.toStringTag]: string; payload: unknown }>;
|
||||||
* only in the realm that made it (it does not survive an RPC copy) */
|
export type Pattern = Tagged | string | number | boolean | null;
|
||||||
export type Matcher = Readonly<{ toString(): string }>;
|
|
||||||
export type Pattern = Matcher | string | number | boolean | null;
|
|
||||||
|
|
||||||
const kinds = new WeakMap<object, MatcherKind>();
|
/** @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 isTagged = (x: unknown): x is Tagged =>
|
||||||
const matcher = Object.freeze({ toString: () => label });
|
typeof x === "object" && x !== null && (x as Record<symbol, unknown>)[PASS_STYLE] === "tagged";
|
||||||
kinds.set(matcher, kind);
|
|
||||||
return matcher;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STRING = makeMatcher("string", "M.string()");
|
const StringShape = makeTagged("match:string", Object.freeze([]));
|
||||||
const ANY = makeMatcher("any", "M.any()");
|
const AnyShape = makeTagged("match:any", undefined);
|
||||||
|
|
||||||
export const M = Object.freeze({
|
export const M = Object.freeze({
|
||||||
string: (): Matcher => STRING,
|
string: (): Tagged => StringShape,
|
||||||
any: (): Matcher => ANY,
|
any: (): Tagged => AnyShape,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function matches(specimen: unknown, pattern: Pattern): boolean {
|
export function matches(specimen: unknown, pattern: Pattern): boolean {
|
||||||
if (typeof pattern === "object" && pattern !== null) {
|
if (typeof pattern === "object" && pattern !== null) {
|
||||||
const kind = kinds.get(pattern);
|
const tag = isTagged(pattern) ? pattern[Symbol.toStringTag] : undefined;
|
||||||
if (kind === "string") return typeof specimen === "string";
|
switch (tag) {
|
||||||
if (kind === "any") return true;
|
case "match:string":
|
||||||
|
return typeof specimen === "string";
|
||||||
|
case "match:any":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
throw new TypeError("not a pattern");
|
throw new TypeError("not a pattern");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return Object.is(specimen, 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
|
* throw unless `specimen` matches `pattern`. Unlike endo's, the message
|
||||||
* names the pattern and `label` but never the specimen: specimens here
|
* 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 {
|
export function mustMatch(specimen: unknown, pattern: Pattern, label: string): void {
|
||||||
if (!matches(specimen, pattern)) {
|
if (!matches(specimen, pattern)) {
|
||||||
const shape = typeof pattern === "object" && pattern !== null ? String(pattern) : JSON.stringify(pattern);
|
throw new TypeError(`${label}: must match ${describe(pattern)}`);
|
||||||
throw new TypeError(`${label}: must match ${shape}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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");
|
||||||
|
}
|
||||||
@@ -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>("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<unknown> {
|
||||||
|
const config = this.ctx.storage.kv.get<Config>("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<unknown> {
|
||||||
|
return this.store().readOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-2
@@ -1,9 +1,13 @@
|
|||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { evictDurableObject } from "cloudflare:test";
|
import { evictDurableObject } from "cloudflare:test";
|
||||||
import { describe, expect, it } from "vitest";
|
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<import("./test-worker.js").StoreHost> };
|
type TestEnv = {
|
||||||
|
STORE_DO: DurableObjectNamespace<import("./test-worker.js").StoreHost>;
|
||||||
|
MAP_STORE_DO: DurableObjectNamespace<import("../src/store-do.js").MapStoreDo>;
|
||||||
|
};
|
||||||
const testEnv = env as unknown as TestEnv;
|
const testEnv = env as unknown as TestEnv;
|
||||||
|
|
||||||
const SECRET = "session=s3kr1t";
|
const SECRET = "session=s3kr1t";
|
||||||
@@ -27,6 +31,28 @@ describe("patterns", () => {
|
|||||||
expect(matches("authorization", "cookie")).toBe(false);
|
expect(matches("authorization", "cookie")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matchers are endo's tagged records", () => {
|
||||||
|
const s = M.string() as unknown as Record<string | symbol, unknown>;
|
||||||
|
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<string, unknown>).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<typeof matches>[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", () => {
|
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")).toThrow('jar: key: must match "cookie"');
|
||||||
expect(() => mustMatch(SECRET, "cookie", "jar: key")).not.toThrow(SECRET);
|
expect(() => mustMatch(SECRET, "cookie", "jar: key")).not.toThrow(SECRET);
|
||||||
@@ -85,3 +111,32 @@ describe("MapStore over RPC", () => {
|
|||||||
expect(await again.keys()).toEqual([]);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { DurableObject } from "cloudflare:workers";
|
|||||||
import { M } from "../src/patterns.js";
|
import { M } from "../src/patterns.js";
|
||||||
import { makeMapStore, type MapStore } from "../src/store.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` */
|
/** hosts the store from the credential-rotation sketch: one key, `cookie` */
|
||||||
export class StoreHost extends DurableObject {
|
export class StoreHost extends DurableObject {
|
||||||
cookies(): MapStore<string> {
|
cookies(): MapStore<string> {
|
||||||
|
|||||||
@@ -5,9 +5,13 @@
|
|||||||
"main": "./tests/test-worker.ts",
|
"main": "./tests/test-worker.ts",
|
||||||
"compatibility_date": "2026-09-29",
|
"compatibility_date": "2026-09-29",
|
||||||
"durable_objects": {
|
"durable_objects": {
|
||||||
"bindings": [{ "name": "STORE_DO", "class_name": "StoreHost" }]
|
"bindings": [
|
||||||
|
{ "name": "STORE_DO", "class_name": "StoreHost" },
|
||||||
|
{ "name": "MAP_STORE_DO", "class_name": "MapStoreDo" }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
"StoreHost": { "type": "durable-object", "storage": "sqlite" }
|
"StoreHost": { "type": "durable-object", "storage": "sqlite" },
|
||||||
|
"MapStoreDo": { "type": "durable-object", "storage": "sqlite" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user