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