WIP: webapp: rotate the diigo cookie in place #1
No files matched your search
@@ -109,10 +109,10 @@ narrative.
|
||||
weekly page renders against prod.
|
||||
- [ ] **6. Cookie expiry**: when diigo's session ends, head refreshes
|
||||
fail; the public page keeps serving what's stored (say so on the
|
||||
page, don't fail silently). Rotation is manual: re-mint via
|
||||
`/_connect`. Open question: can an existing cache be re-wired to
|
||||
a new webapp id (keeping its cache id and public username), or
|
||||
does re-mint mean a new cache? Prefer re-wire — durable ids.
|
||||
page, don't fail silently). Rotation is in place, keeping the
|
||||
webapp id (so the cache's wiring and ids hold): `POST
|
||||
/at/<id>/cookie` with the new cookie (webapp README, "The cookie
|
||||
store"). Still open: a rotate form in diigo-cache's UI.
|
||||
- [ ] **7. bib sync #195**: repeat the pattern for the next
|
||||
read-access need (webapp mints for any origin; the cache's
|
||||
item shape is diigo-specific): [the zotero goal tree](zotero-checklist.md).
|
||||
|
||||
@@ -14,6 +14,7 @@ grows past a form or two.
|
||||
- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay.
|
||||
- `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority.
|
||||
- `POST /at/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values).
|
||||
- `POST /at/<id>/cookie` — rotate the instance's cookie in place: JSON `{cookie}` → `{ok: true}`. The id that drives an instance can also replace its cookie; the account owner revokes by logging out.
|
||||
|
||||
## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`)
|
||||
|
||||
@@ -22,9 +23,20 @@ grows past a form or two.
|
||||
promise pipelining makes a whole `pathjoin().query().get()` chain a
|
||||
single round trip
|
||||
- `at(id)` — the fluent stub for a previously-minted instance
|
||||
- `cookies(id)` — the instance's cookie store (a MapStore from
|
||||
[the capsite lib](../lib/src/store.ts) with key shape `"cookie"`):
|
||||
`set("cookie", …)` rotates it under every handle already out
|
||||
|
||||
## The cookie store
|
||||
|
||||
An instance is fixed at creation except for its cookie, which lives in a
|
||||
store rather than in `init`; every send reads it. Instances minted before
|
||||
the store held their cookie in `init.headers`; the first `handle()` moves
|
||||
it over in one transaction.
|
||||
|
||||
## Run
|
||||
|
||||
pnpm test # cookie store + rotation (vitest, workerd pool)
|
||||
npx wrangler dev # → its own ready line (usually :8787 with the
|
||||
# two-config diigo-cache run; standalone, :8788)
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "webapp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"types": "wrangler types",
|
||||
"lint": "wrangler types --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
+115
-10
@@ -7,13 +7,21 @@
|
||||
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
|
||||
// in worker-configuration.d.ts (not exports of "cloudflare:workers")
|
||||
import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { M } from "../../lib/src/patterns.js";
|
||||
import { makeMapStore, type MapStore } from "../../lib/src/store.js";
|
||||
|
||||
export type Query = Record<string, string | number>;
|
||||
type Headers = Record<string, string>;
|
||||
|
||||
/** headers looked up at each send, e.g. a store's read-only view: whoever
|
||||
* holds the store can rotate a credential under handles already out */
|
||||
export type HeaderMap = { entries(): [string, unknown][] | Promise<[string, unknown][]> };
|
||||
|
||||
/**
|
||||
* init for a new instance — fixed at creation. An instance is immutable
|
||||
* authority: no re-config methods, only attenuation via the fluent chain.
|
||||
* init for a new instance — fixed at creation, except that a `cookie`
|
||||
* header goes to the instance's rotatable cookie store (see
|
||||
* {@link WebAppDo}). Otherwise immutable authority: no re-config methods,
|
||||
* only attenuation via the fluent chain.
|
||||
*/
|
||||
export interface WebAppInit {
|
||||
url: string;
|
||||
@@ -29,6 +37,7 @@ export interface FluentGet {
|
||||
withHeaders(h: Headers): FluentGet;
|
||||
withHeader(key: string, value: string): FluentGet;
|
||||
cookies(cookie: string): FluentGet;
|
||||
withHeaderMap(map: HeaderMap): FluentGet;
|
||||
get(): Promise<string>;
|
||||
post(body: string): Promise<string>;
|
||||
}
|
||||
@@ -57,16 +66,19 @@ export interface FluentGet {
|
||||
export class WebApp extends RpcTarget implements FluentGet {
|
||||
#url: string;
|
||||
#headers: Headers;
|
||||
#maps: readonly HeaderMap[];
|
||||
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
headers: Headers,
|
||||
maps: readonly HeaderMap[],
|
||||
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
|
||||
) {
|
||||
super();
|
||||
this.#url = url;
|
||||
this.#headers = headers;
|
||||
this.#maps = maps;
|
||||
this.#send = send;
|
||||
}
|
||||
|
||||
@@ -85,13 +97,13 @@ export class WebApp extends RpcTarget implements FluentGet {
|
||||
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
|
||||
}
|
||||
const baseParams: Query = Object.fromEntries(here.searchParams);
|
||||
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#send);
|
||||
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#maps, this.#send);
|
||||
}
|
||||
|
||||
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
|
||||
* different value throws; the same value is fine. */
|
||||
withHeaders(h: Headers): WebApp {
|
||||
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send);
|
||||
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#maps, this.#send);
|
||||
}
|
||||
|
||||
/** one key, flower-style (see {@link WebApp}) */
|
||||
@@ -105,15 +117,34 @@ export class WebApp extends RpcTarget implements FluentGet {
|
||||
|
||||
/** Union params into the existing query, flower-style (see {@link WebApp}). */
|
||||
query(params: Query): WebApp {
|
||||
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#send);
|
||||
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#maps, this.#send);
|
||||
}
|
||||
|
||||
/** Add headers read from `map` at each send, unioned flower-style (see
|
||||
* {@link WebApp}) with the rest — so a contradiction surfaces at send. */
|
||||
withHeaderMap(map: HeaderMap): WebApp {
|
||||
return new WebApp(this.#url, this.#headers, [...this.#maps, map], this.#send);
|
||||
}
|
||||
|
||||
async get(): Promise<string> {
|
||||
return this.#send(this.#url, this.#headers, "GET");
|
||||
return this.#send(this.#url, await this.#headersNow(), "GET");
|
||||
}
|
||||
|
||||
async post(body: string): Promise<string> {
|
||||
return this.#send(this.#url, this.#headers, "POST", body);
|
||||
return this.#send(this.#url, await this.#headersNow(), "POST", body);
|
||||
}
|
||||
|
||||
async #headersNow(): Promise<Headers> {
|
||||
let headers = this.#headers;
|
||||
for (const map of this.#maps) {
|
||||
const entries = await map.entries();
|
||||
for (const [key, value] of entries) {
|
||||
// key name only: header values must not appear in Error messages
|
||||
if (typeof value !== "string") throw new Error(`headers: ${key} is not a string`);
|
||||
}
|
||||
headers = unionHeaders(headers, Object.fromEntries(entries) as Headers);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +180,12 @@ class Gate {
|
||||
* storage, surviving eviction (instance memory evaporates when the DO naps;
|
||||
* storage is where "this instance means this URL" durably lives).
|
||||
*
|
||||
* The cookie is the exception to "fixed at creation": it lives in a store
|
||||
* (`store:cookie:cookie`) rather than in `init`, and every send reads it,
|
||||
* so whoever holds the instance id can rotate it (see {@link cookies})
|
||||
* without a re-mint. Instances minted before that kept it in
|
||||
* `init.headers`; {@link handle} moves it over on first use.
|
||||
*
|
||||
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
|
||||
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
|
||||
* any worker with the namespace binding can recompute it
|
||||
@@ -172,11 +209,21 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
||||
}
|
||||
return; // idempotent for an identical re-configure
|
||||
}
|
||||
await this.ctx.storage.put("init", init);
|
||||
const { cookie, rest } = splitCookie(init.headers ?? {});
|
||||
this.ctx.storage.transactionSync(() => {
|
||||
this.ctx.storage.kv.put("init", { ...init, headers: rest });
|
||||
if (cookie !== undefined) this.cookies().init("cookie", cookie);
|
||||
});
|
||||
})();
|
||||
await this.#ready;
|
||||
}
|
||||
|
||||
/** the instance's cookie, with full authority: `set` rotates it under
|
||||
* every handle already out */
|
||||
cookies(): MapStore<string> {
|
||||
return makeMapStore<string>("cookie", { keyShape: "cookie", valueShape: M.string() }, this.ctx.storage.kv);
|
||||
}
|
||||
|
||||
/** load init from storage; rejects while unconfigured */
|
||||
async #load(): Promise<WebAppInit> {
|
||||
const init = await this.ctx.storage.get<WebAppInit>("init");
|
||||
@@ -189,7 +236,7 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
||||
/** the fluent stub handed to callers, wired to a fresh gate per
|
||||
* activation (handles within one activation share it) */
|
||||
async handle(): Promise<FluentGet> {
|
||||
const init = await this.#load();
|
||||
const init = this.#migrateCookie(await this.#load());
|
||||
const gate = new Gate(() => sleep(init.msPerCall ?? 0));
|
||||
const send = async (
|
||||
url: string,
|
||||
@@ -207,8 +254,33 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
|
||||
}
|
||||
return res.text();
|
||||
};
|
||||
return new WebApp(init.url, init.headers ?? {}, send);
|
||||
return new WebApp(init.url, init.headers ?? {}, [this.cookies().readOnly()], send);
|
||||
}
|
||||
|
||||
/** move a cookie stored in `init.headers` to the cookie store, in one
|
||||
* transaction; returns init without it */
|
||||
#migrateCookie(init: WebAppInit): WebAppInit {
|
||||
const { cookie, rest } = splitCookie(init.headers ?? {});
|
||||
if (cookie === undefined) return init;
|
||||
const migrated = { ...init, headers: rest };
|
||||
this.ctx.storage.transactionSync(() => {
|
||||
const cookies = this.cookies();
|
||||
if (!cookies.has("cookie")) cookies.init("cookie", cookie);
|
||||
this.ctx.storage.kv.put("init", migrated);
|
||||
});
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
/** split the cookie (any case of the header name) from the other headers */
|
||||
function splitCookie(headers: Headers): { cookie?: string; rest: Headers } {
|
||||
const rest: Headers = {};
|
||||
let cookie: string | undefined;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === "cookie") cookie = value;
|
||||
else rest[key] = value;
|
||||
}
|
||||
return { cookie, rest };
|
||||
}
|
||||
|
||||
/** the worker's whole HTTP surface (the default export and the
|
||||
@@ -246,6 +318,13 @@ const defaultFetch = async (
|
||||
return drive(await app, request);
|
||||
}
|
||||
|
||||
// rotate: POST /at/<id>/cookie with JSON {cookie}; the id that drives
|
||||
// an instance can also replace its cookie
|
||||
const rotate = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)\/cookie$/);
|
||||
if (rotate && request.method === "POST") {
|
||||
return rotateCookie(env.WEBAPP_DO.get(env.WEBAPP_DO.idFromString(rotate[1])), request);
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
@@ -368,6 +447,25 @@ async function drive(app: FluentGet, request: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
/** the /at/<id>/cookie protocol: JSON {cookie} → {ok} (or {error}; no values) */
|
||||
async function rotateCookie(instance: DurableObjectStub<WebAppDo>, request: Request): Promise<Response> {
|
||||
let cookie: unknown;
|
||||
try {
|
||||
({ cookie } = (await request.json()) as { cookie?: unknown });
|
||||
} catch (err) {
|
||||
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
|
||||
}
|
||||
if (typeof cookie !== "string" || !cookie) {
|
||||
return Response.json({ error: "bad input: cookie must be a non-empty string" }, { status: 400 });
|
||||
}
|
||||
using cookies = await instance.cookies();
|
||||
if (await cookies.has("cookie")) await cookies.set("cookie", cookie);
|
||||
else await cookies.init("cookie", cookie);
|
||||
// note: neither id nor cookie logged
|
||||
console.log({ event: "rotate" });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
function unionHeaders(base: Headers, more: Headers): Headers {
|
||||
const merged = { ...base };
|
||||
for (const [key, value] of Object.entries(more)) {
|
||||
@@ -434,6 +532,13 @@ export class WebAppFactory extends WorkerEntrypoint {
|
||||
const ns = this.env.WEBAPP_DO;
|
||||
return ns.get(ns.idFromString(id)).handle();
|
||||
}
|
||||
|
||||
/** the cookie store of a previously-obtained id: holding the id is
|
||||
* authority to rotate the cookie, not just to use it */
|
||||
async cookies(id: string) {
|
||||
const ns = this.env.WEBAPP_DO;
|
||||
return ns.get(ns.idFromString(id)).cookies();
|
||||
}
|
||||
}
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { runInDurableObject } from "cloudflare:test";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import worker, { type WebAppInit } from "../src/index.js";
|
||||
|
||||
type TestEnv = { WEBAPP_DO: DurableObjectNamespace<import("../src/index.js").WebAppDo> };
|
||||
const testEnv = env as unknown as TestEnv;
|
||||
|
||||
const URL_ = "https://diigo.example/api/";
|
||||
|
||||
/** the cookie header of each outbound fetch */
|
||||
function spyCookies(): (string | null)[] {
|
||||
const seen: (string | null)[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
|
||||
seen.push(new Headers(init?.headers).get("cookie"));
|
||||
return new Response("ok");
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const fresh = () => testEnv.WEBAPP_DO.get(testEnv.WEBAPP_DO.newUniqueId());
|
||||
|
||||
/** `init` as stored */
|
||||
const storedInit = (stub: DurableObjectStub) =>
|
||||
runInDurableObject(stub, (_obj, state) => state.storage.kv.get<WebAppInit>("init"));
|
||||
|
||||
describe("cookie rotation", () => {
|
||||
it("mint keeps the cookie out of init; sends carry it", async () => {
|
||||
const seen = spyCookies();
|
||||
const stub = fresh();
|
||||
await stub.configure({ url: URL_, headers: { Cookie: "session=a", accept: "application/json" } });
|
||||
expect(await storedInit(stub)).toEqual({ url: URL_, headers: { accept: "application/json" } });
|
||||
using app = await stub.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=a"]);
|
||||
});
|
||||
|
||||
it("a rotation reaches handles already out", async () => {
|
||||
const seen = spyCookies();
|
||||
const stub = fresh();
|
||||
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await stub.handle();
|
||||
await app.pathjoin("x").get();
|
||||
using cookies = await stub.cookies();
|
||||
await cookies.set("cookie", "session=b");
|
||||
await app.pathjoin("x").get();
|
||||
expect(seen).toEqual(["session=a", "session=b"]);
|
||||
});
|
||||
|
||||
it("an instance minted before rotation moves its cookie to the store on first use", async () => {
|
||||
const seen = spyCookies();
|
||||
const stub = fresh();
|
||||
// the shape configure() stored until the cookie store
|
||||
await runInDurableObject(stub, (_obj, state) =>
|
||||
state.storage.put("init", { url: URL_, headers: { cookie: "session=old" }, msPerCall: 0 }),
|
||||
);
|
||||
using app = await stub.handle();
|
||||
await app.get();
|
||||
expect(await storedInit(stub)).toEqual({ url: URL_, headers: {}, msPerCall: 0 });
|
||||
using cookies = await stub.cookies();
|
||||
expect(await cookies.get("cookie")).toBe("session=old");
|
||||
await cookies.set("cookie", "session=new");
|
||||
using again = await stub.handle();
|
||||
await again.get();
|
||||
expect(seen).toEqual(["session=old", "session=new"]);
|
||||
});
|
||||
|
||||
it("POST /at/<id>/cookie rotates, by the same id that drives", async () => {
|
||||
const seen = spyCookies();
|
||||
const id = testEnv.WEBAPP_DO.newUniqueId();
|
||||
const stub = testEnv.WEBAPP_DO.get(id);
|
||||
await stub.configure({ url: URL_ });
|
||||
const rotate = (cookie: string) =>
|
||||
worker.fetch(
|
||||
new Request(`https://webapp.example/at/${id}/cookie`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ cookie }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
testEnv,
|
||||
);
|
||||
expect(await (await rotate("session=first")).json()).toEqual({ ok: true });
|
||||
expect(await (await rotate("session=second")).json()).toEqual({ ok: true });
|
||||
using app = await stub.handle();
|
||||
await app.get();
|
||||
expect(seen).toEqual(["session=second"]);
|
||||
});
|
||||
|
||||
it("a fixed header contradicting the stored cookie fails at send, naming no values", async () => {
|
||||
spyCookies();
|
||||
const stub = fresh();
|
||||
await stub.configure({ url: URL_, headers: { cookie: "session=a" } });
|
||||
using app = await stub.handle();
|
||||
const err = await app
|
||||
.withHeader("cookie", "session=mine")
|
||||
.get()
|
||||
.then(
|
||||
() => "",
|
||||
(e: unknown) => String(e),
|
||||
);
|
||||
expect(err).toContain("headers: contradicts on cookie");
|
||||
expect(err).not.toContain("session=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-plugin/types"]
|
||||
},
|
||||
"include": ["./**/*", "../worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "ESNext.Disposable"],
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
||||
|
||||
export default defineProject({
|
||||
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })],
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user