WIP: webapp: rotate the diigo cookie in place #1
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2240
File diff suppressed because it is too large.
Load diff
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-plugin/types"]
|
||||
},
|
||||
"include": ["./**/*", "../src/**/*", "../worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user