@decentnetwork/beagle-service 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +289 -28
  2. package/dist/auth.d.ts +10 -2
  3. package/dist/auth.js +119 -36
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +163 -0
  6. package/dist/commonjs.cjs +49 -0
  7. package/dist/commonjs.d.cts +11 -0
  8. package/dist/index.d.ts +16 -2
  9. package/dist/index.js +127 -5
  10. package/dist/routes.d.ts +9 -1
  11. package/dist/routes.js +157 -24
  12. package/dist/stores.d.ts +12 -4
  13. package/dist/stores.js +60 -37
  14. package/dist/types.d.ts +157 -12
  15. package/dist/users.js +108 -58
  16. package/dist/util.d.ts +22 -2
  17. package/dist/util.js +103 -2
  18. package/examples/next-pages-cjs/README.md +30 -0
  19. package/examples/next-pages-cjs/instrumentation.js +16 -0
  20. package/examples/next-pages-cjs/lib/beagle.js +23 -0
  21. package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
  22. package/examples/next-pages-cjs/next.config.js +11 -0
  23. package/examples/next-pages-cjs/package.json +19 -0
  24. package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
  25. package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
  26. package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
  27. package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
  28. package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
  29. package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
  30. package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
  31. package/examples/next-pages-cjs/pages/api/notes.js +16 -0
  32. package/examples/next-pages-cjs/pages/index.js +52 -0
  33. package/examples/sign-in-from-script.mjs +37 -0
  34. package/package.json +19 -7
package/dist/routes.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AdapterOptions, Agent, Auth, Handlers, NodeHandler, RouteHandler, SessionConfig, UserRecord, Users } from "./types.js";
1
+ import type { AdapterOptions, Agent, Auth, Handlers, NodeHandler, RateLimitConfig, RouteHandler, RouteName, ServiceRequest, SessionConfig, SignInHook, UserRecord, Users } from "./types.js";
2
2
  import { type Logger } from "./util.js";
3
3
  /** The library's own fields only: a site's extra fields (a plan, notes) do
4
4
  * not reach the page unless the site says so with `publicUser`. */
@@ -8,9 +8,16 @@ export interface RoutesOptions {
8
8
  users: Users;
9
9
  session?: SessionConfig;
10
10
  publicUser?: (user: UserRecord) => Record<string, unknown>;
11
+ onSignIn?: SignInHook;
12
+ rateLimit?: RateLimitConfig | false;
11
13
  agent: () => Agent | undefined;
12
14
  log: Logger;
15
+ now?: () => number;
13
16
  }
17
+ /** Counts sign-in and launch attempts in fixed windows, per client and for
18
+ * all clients together. Returns the seconds to wait when a request is over
19
+ * the limit, else 0. In memory: each server process counts for itself. */
20
+ export declare function createRateLimit(config: RateLimitConfig | false | undefined, now: () => number): (req: ServiceRequest) => number;
14
21
  export declare function createRoutes(opts: RoutesOptions): {
15
22
  handle: RouteHandler;
16
23
  handlers: Handlers;
@@ -18,4 +25,5 @@ export declare function createRoutes(opts: RoutesOptions): {
18
25
  expressHandler: (o?: AdapterOptions) => NodeHandler;
19
26
  nextApiHandler: (o?: AdapterOptions) => NodeHandler;
20
27
  fetchHandler: (o?: AdapterOptions) => (request: Request) => Promise<Response>;
28
+ route: (name: RouteName) => NodeHandler;
21
29
  };
package/dist/routes.js CHANGED
@@ -10,6 +10,8 @@ const MESSAGES = {
10
10
  "launch-replayed": "This launch assertion was already used.",
11
11
  blocked: "This identity may not use the site.",
12
12
  "register-required": "Registration is required to go on.",
13
+ "sign-in-refused": "This site did not accept the sign-in.",
14
+ "rate-limited": "Too many sign-in attempts. Try again shortly.",
13
15
  "not-signed-in": "Not signed in.",
14
16
  "invalid-fields": "Some registration fields are missing or not accepted.",
15
17
  "not-found": "No such route.",
@@ -23,10 +25,61 @@ export function defaultPublicUser(user) {
23
25
  const { userid, address, name, avatar, punkId, door, status, profile, firstSeen, lastSeen } = user;
24
26
  return { userid, address, name, avatar, punkId, door, status, profile, firstSeen, lastSeen };
25
27
  }
28
+ const LOOPBACK = /^(127\.|::1$|::ffff:127\.)/;
29
+ /** Counts sign-in and launch attempts in fixed windows, per client and for
30
+ * all clients together. Returns the seconds to wait when a request is over
31
+ * the limit, else 0. In memory: each server process counts for itself. */
32
+ export function createRateLimit(config, now) {
33
+ if (config === false)
34
+ return () => 0;
35
+ const cfg = config ?? {};
36
+ const windowMs = Math.max(1, cfg.windowSeconds ?? 60) * 1000;
37
+ const perClient = cfg.perClient ?? 30;
38
+ const total = cfg.total ?? 600;
39
+ const hops = cfg.trustProxy === true ? 1 : typeof cfg.trustProxy === "number" ? Math.max(0, Math.floor(cfg.trustProxy)) : 0;
40
+ const windows = new Map();
41
+ function clientOf(req) {
42
+ if (cfg.key)
43
+ return cfg.key(req) || null;
44
+ if (hops > 0) {
45
+ // Each proxy appends the address it saw; the entry `hops` from the
46
+ // right is the one written by the outermost proxy we trust.
47
+ const list = String(headerOf(req, "x-forwarded-for") ?? "").split(",").map((x) => x.trim()).filter(Boolean);
48
+ const seen = list[list.length - hops];
49
+ if (seen)
50
+ return seen;
51
+ }
52
+ const ip = typeof req.ip === "string" ? req.ip : "";
53
+ // A connection from this machine is a proxy in front of the server, or
54
+ // the operator: one address standing for everybody is not a client.
55
+ return ip && !LOOPBACK.test(ip) ? ip : null;
56
+ }
57
+ function over(key, max, t) {
58
+ let w = windows.get(key);
59
+ if (!w || t - w.start >= windowMs)
60
+ windows.set(key, (w = { start: t, n: 0 }));
61
+ if (w.n >= max)
62
+ return Math.max(1, Math.ceil((w.start + windowMs - t) / 1000));
63
+ w.n++;
64
+ return 0;
65
+ }
66
+ return (req) => {
67
+ const t = now();
68
+ if (windows.size > 20_000)
69
+ for (const [k, w] of windows)
70
+ if (t - w.start >= windowMs)
71
+ windows.delete(k);
72
+ const who = clientOf(req);
73
+ // A client over its own limit is turned away before it can use up the
74
+ // allowance everybody shares.
75
+ return (who ? over(`c:${who}`, perClient, t) : 0) || over("*", total, t);
76
+ };
77
+ }
26
78
  export function createRoutes(opts) {
27
79
  const { auth, users, log } = opts;
28
80
  const transport = opts.session?.transport ?? "cookie";
29
81
  const publicUser = opts.publicUser ?? defaultPublicUser;
82
+ const limited = createRateLimit(opts.rateLimit, opts.now ?? Date.now);
30
83
  const lower = (req) => {
31
84
  const headers = {};
32
85
  for (const [k, v] of Object.entries(req.headers ?? {}))
@@ -53,7 +106,8 @@ export function createRoutes(opts) {
53
106
  headers: { ...baseHeaders(req), ...extra },
54
107
  body,
55
108
  });
56
- const refuse = (req, status, error, more = {}, extra = {}) => answer(req, status, { ok: false, error, message: MESSAGES[error], ...more }, extra);
109
+ // `ok`, `error` and `message` come first and are the library's, whatever `more` holds.
110
+ const refuse = (req, status, error, more = {}, extra = {}) => answer(req, status, Object.assign({ ok: false, error, message: MESSAGES[error] }, more, { ok: false, error, message: MESSAGES[error] }), extra);
57
111
  const agentInfo = () => {
58
112
  const a = opts.agent();
59
113
  return a ? { agent: { userid: a.userid, address: a.address, name: a.nickname ?? null } } : {};
@@ -68,20 +122,44 @@ export function createRoutes(opts) {
68
122
  const req = lower(raw);
69
123
  return wrongMethod(req, "GET") ?? answer(req, 200, { ok: true, nonce: auth.issueNonce(), expiresIn: 120 });
70
124
  };
125
+ const isRefusal = (r) => !!r && typeof r === "object" && !!r.refuse;
71
126
  async function establish(req, v) {
72
127
  if (!v.ok)
73
128
  return refuse(req, v.status, v.error);
129
+ const origin = auth.allowedOrigin(req);
74
130
  const known = await users.get(v.userid);
75
131
  if (known?.status === "blocked")
76
132
  return refuse(req, 403, "blocked");
77
- const user = await users.recordSignIn(v.userid, v.hints, { door: v.door });
133
+ let user = await users.recordSignIn(v.userid, v.hints, { door: v.door, origin });
78
134
  if (user.status === "blocked")
79
135
  return refuse(req, 403, "blocked");
136
+ // The site's part, before any session exists: it may add to the answer,
137
+ // or refuse. The record is written by now (the hook can store on it).
138
+ let own = {};
139
+ if (opts.onSignIn) {
140
+ const r = await opts.onSignIn(user, {
141
+ origin: origin ?? "",
142
+ door: user.door,
143
+ route: v.door === "launch" ? "launch" : "sign-in",
144
+ first: !known || !known.firstSeen,
145
+ request: req,
146
+ });
147
+ if (isRefusal(r)) {
148
+ const error = typeof r.refuse === "string" ? r.refuse : "sign-in-refused";
149
+ return answer(req, r.status ?? 403, { ok: false, error, message: r.message ?? MESSAGES["sign-in-refused"] });
150
+ }
151
+ if (r && typeof r === "object")
152
+ own = r;
153
+ // What the hook stored on the record belongs in the answer too.
154
+ user = (await users.get(user.userid)) ?? user;
155
+ if (user.status === "blocked")
156
+ return refuse(req, 403, "blocked");
157
+ }
80
158
  const session = auth.mintSession(user.userid);
81
159
  const registration = users.registration(user);
82
160
  const extra = {};
83
161
  if (transport !== "bearer")
84
- extra["set-cookie"] = [auth.cookie(session.token, auth.allowedOrigin(req))];
162
+ extra["set-cookie"] = [auth.cookie(session.token, origin)];
85
163
  const body = {
86
164
  user: publicUser(user),
87
165
  registration,
@@ -92,17 +170,22 @@ export function createRoutes(opts) {
92
170
  // A visitor past the limit under `then: "refuse"` is told 403, and still
93
171
  // gets the session: without it they could never prove who is registering.
94
172
  // That session opens `register`, `logout` and nothing else.
173
+ // The site's fields first: the library's own are never replaced by them.
95
174
  if (registration.refused)
96
- return refuse(req, 403, "register-required", body, extra);
97
- return answer(req, 200, { ok: true, ...body }, extra);
175
+ return refuse(req, 403, "register-required", { ...own, ...body }, extra);
176
+ return answer(req, 200, Object.assign({ ok: true }, own, body, { ok: true }), extra);
98
177
  }
178
+ const tooMany = (req) => {
179
+ const wait = limited(req);
180
+ return wait ? refuse(req, 429, "rate-limited", {}, { "retry-after": String(wait) }) : null;
181
+ };
99
182
  const signIn = async (raw) => {
100
183
  const req = lower(raw);
101
- return wrongMethod(req, "POST") ?? establish(req, await auth.verifySignIn(auth.allowedOrigin(req), req.body));
184
+ return wrongMethod(req, "POST") ?? tooMany(req) ?? establish(req, await auth.verifySignIn(auth.allowedOrigin(req), req.body));
102
185
  };
103
186
  const launch = async (raw) => {
104
187
  const req = lower(raw);
105
- return wrongMethod(req, "POST") ?? establish(req, await auth.verifyLaunch(auth.allowedOrigin(req), req.body));
188
+ return wrongMethod(req, "POST") ?? tooMany(req) ?? establish(req, await auth.verifyLaunch(auth.allowedOrigin(req), req.body));
106
189
  };
107
190
  const me = async (raw) => {
108
191
  const req = lower(raw);
@@ -153,7 +236,34 @@ export function createRoutes(opts) {
153
236
  }
154
237
  return answer(req, 200, { ok: true, user: publicUser(r.user), registration: users.registration(r.user), ...agentInfo() });
155
238
  };
156
- const handlers = { nonce, signIn, launch, me, logout, register };
239
+ /** A route answers; it does not throw at the site that called it. */
240
+ const guarded = (name, route) => async (raw) => {
241
+ try {
242
+ return await route(raw);
243
+ }
244
+ catch (e) {
245
+ log(`beagle-service: ${name} failed: ${e?.stack ?? msgOf(e)}`);
246
+ return refuse(lower(raw), 500, "server-error");
247
+ }
248
+ };
249
+ const handlers = {
250
+ nonce: guarded("nonce", nonce),
251
+ signIn: guarded("sign-in", signIn),
252
+ launch: guarded("launch", launch),
253
+ me: guarded("me", me),
254
+ logout: guarded("logout", logout),
255
+ register: guarded("register", register),
256
+ };
257
+ const preflight = (req) => ({
258
+ status: 204,
259
+ headers: {
260
+ ...baseHeaders(req),
261
+ "access-control-allow-methods": "GET, POST, OPTIONS",
262
+ "access-control-allow-headers": "content-type, authorization",
263
+ "access-control-max-age": "600",
264
+ },
265
+ body: null,
266
+ });
157
267
  // `/beagle` and `/beagle-launch` are the paths the Sign in with Beagle
158
268
  // guide uses; a page written from it works unchanged.
159
269
  const table = {
@@ -173,18 +283,8 @@ export function createRoutes(opts) {
173
283
  const route = table[path];
174
284
  if (!route)
175
285
  return refuse(req, 404, "not-found");
176
- if (req.method === "OPTIONS") {
177
- return {
178
- status: 204,
179
- headers: {
180
- ...baseHeaders(req),
181
- "access-control-allow-methods": "GET, POST, OPTIONS",
182
- "access-control-allow-headers": "content-type, authorization",
183
- "access-control-max-age": "600",
184
- },
185
- body: null,
186
- };
187
- }
286
+ if (req.method === "OPTIONS")
287
+ return preflight(req);
188
288
  return await route(req);
189
289
  }
190
290
  catch (e) {
@@ -253,10 +353,42 @@ export function createRoutes(opts) {
253
353
  }
254
354
  function write(res, r) {
255
355
  res.statusCode = r.status;
256
- for (const [k, v] of Object.entries(r.headers))
257
- res.setHeader(k, v);
356
+ for (const [k, v] of Object.entries(r.headers)) {
357
+ // A cookie the site set on this response before calling us stays.
358
+ const held = k === "set-cookie" && typeof res.getHeader === "function" ? res.getHeader("set-cookie") : undefined;
359
+ res.setHeader(k, held ? [].concat(held, v) : v);
360
+ }
258
361
  res.end(r.body === null ? undefined : JSON.stringify(r.body));
259
362
  }
363
+ /** Express computes `req.ip` from its own "trust proxy" setting; elsewhere
364
+ * it is the address of the connection. */
365
+ const ipOf = (req) => (typeof req.ip === "string" && req.ip) || req.socket?.remoteAddress || req.connection?.remoteAddress || null;
366
+ /** One route, whatever path the framework mounted it on. */
367
+ function routeHandler(name) {
368
+ const route = handlers[name];
369
+ if (!route)
370
+ throw new TypeError(`beagle-service: no route "${String(name)}"; the routes are ${Object.keys(handlers).join(", ")}`);
371
+ return async (req, res) => {
372
+ const headers = req.headers ?? {};
373
+ try {
374
+ if (req.method === "OPTIONS")
375
+ return write(res, preflight(lower({ method: "OPTIONS", headers })));
376
+ let body;
377
+ if (req.method === "POST") {
378
+ const b = await readBody(req);
379
+ if (!b.ok)
380
+ return write(res, refuse(lower({ method: req.method, headers }), b.status, b.error));
381
+ body = b.body;
382
+ }
383
+ write(res, await route({ method: req.method, headers, body, ip: ipOf(req), raw: req }));
384
+ }
385
+ catch (e) {
386
+ log(`beagle-service: ${req.method} ${req.url} failed: ${e?.stack ?? msgOf(e)}`);
387
+ if (!res.headersSent)
388
+ write(res, refuse(lower({ method: req.method, headers }), 500, "server-error"));
389
+ }
390
+ };
391
+ }
260
392
  function nodeAdapter(o, fallThrough) {
261
393
  const prefix = o.prefix ?? "/api/auth";
262
394
  return async (req, res, next) => {
@@ -276,7 +408,7 @@ export function createRoutes(opts) {
276
408
  return write(res, refuse(lower({ method: req.method, headers }), b.status, b.error));
277
409
  body = b.body;
278
410
  }
279
- write(res, await handle({ method: req.method, path, headers, body }));
411
+ write(res, await handle({ method: req.method, path, headers, body, ip: ipOf(req), raw: req }));
280
412
  }
281
413
  catch (e) {
282
414
  log(`beagle-service: ${req.method} ${req.url} failed: ${e?.stack ?? msgOf(e)}`);
@@ -307,7 +439,7 @@ export function createRoutes(opts) {
307
439
  return reply(refuse(lower({ method: request.method, headers }), b.status, b.error));
308
440
  body = b.body;
309
441
  }
310
- return reply(await handle({ method: request.method, path, headers, body }));
442
+ return reply(await handle({ method: request.method, path, headers, body, raw: request }));
311
443
  };
312
444
  }
313
445
  return {
@@ -317,5 +449,6 @@ export function createRoutes(opts) {
317
449
  expressHandler: (o = {}) => nodeAdapter(o, true),
318
450
  nextApiHandler: (o = {}) => nodeAdapter(o, false),
319
451
  fetchHandler,
452
+ route: routeHandler,
320
453
  };
321
454
  }
package/dist/stores.d.ts CHANGED
@@ -1,12 +1,20 @@
1
- import type { StateStore, UserStore } from "./types.js";
1
+ import type { StateStore, UserChange, UserRecord, UserStore } from "./types.js";
2
+ /** `record` with the fields a change names set or removed, and every other
3
+ * field as it is. What a store does with the second argument of `put`. */
4
+ export declare function applyUserChange(record: UserRecord, change: Pick<UserChange, "set" | "unset">): UserRecord;
2
5
  /** Users in one JSON file, `{ [userid]: record }`, rewritten atomically on
3
- * every change. The whole table is held in memory; good for a site with up to
4
- * some tens of thousands of users and one server process. */
6
+ * every change. The whole table is held in memory and read again when the
7
+ * file was replaced by another writer, so the operator's command, or a second
8
+ * store on the same file, works beside a running server. A change is applied
9
+ * field by field to the record that is on disk at that moment. Good for a
10
+ * site with up to some tens of thousands of users on one machine. */
5
11
  export declare function jsonFileUserStore(file: string): UserStore;
6
12
  /** Users in memory; gone when the process ends. For tests. */
7
13
  export declare function memoryUserStore(): UserStore;
8
14
  /** Used nonces, used launch assertions and revoked sessions in one JSON file,
9
- * written atomically before each answer. One server process. */
15
+ * written atomically before each answer. Like the users file it is read again
16
+ * when another writer replaced it, so two stores on one file (a module
17
+ * evaluated twice, two processes on one machine) see each other's entries. */
10
18
  export declare function jsonFileStateStore(file: string, now?: () => number): StateStore;
11
19
  /** The same in memory. A restart forgets it, which reopens launch replay for
12
20
  * two minutes; for tests. */
package/dist/stores.js CHANGED
@@ -1,21 +1,33 @@
1
- import { atomicWriteJson, readJsonFile } from "./util.js";
1
+ import { SharedJsonFile } from "./util.js";
2
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
3
+ /** `record` with the fields a change names set or removed, and every other
4
+ * field as it is. What a store does with the second argument of `put`. */
5
+ export function applyUserChange(record, change) {
6
+ const next = { ...record, ...structuredClone(change.set) };
7
+ for (const k of change.unset)
8
+ delete next[k];
9
+ return next;
10
+ }
2
11
  // ── users ───────────────────────────────────────────────────────────────────
3
12
  /** Users in one JSON file, `{ [userid]: record }`, rewritten atomically on
4
- * every change. The whole table is held in memory; good for a site with up to
5
- * some tens of thousands of users and one server process. */
13
+ * every change. The whole table is held in memory and read again when the
14
+ * file was replaced by another writer, so the operator's command, or a second
15
+ * store on the same file, works beside a running server. A change is applied
16
+ * field by field to the record that is on disk at that moment. Good for a
17
+ * site with up to some tens of thousands of users on one machine. */
6
18
  export function jsonFileUserStore(file) {
7
- const users = new Map(Object.entries(readJsonFile(file, {})));
8
- const flush = () => atomicWriteJson(file, Object.fromEntries(users));
19
+ const shared = new SharedJsonFile(file);
9
20
  return {
10
21
  get: (userid) => {
11
- const u = users.get(userid);
12
- return u ? structuredClone(u) : null;
13
- },
14
- put: (user) => {
15
- users.set(user.userid, structuredClone(user));
16
- flush();
22
+ const table = shared.read();
23
+ return has(table, userid) ? structuredClone(table[userid]) : null;
17
24
  },
18
- list: () => [...users.values()].map((u) => structuredClone(u)),
25
+ put: (user, change) => shared.update((table) => {
26
+ const current = has(table, user.userid) ? table[user.userid] : null;
27
+ table[user.userid] = current && change ? applyUserChange(current, change) : structuredClone(user);
28
+ return { write: true, result: undefined };
29
+ }),
30
+ list: () => Object.values(shared.read()).map((u) => structuredClone(u)),
19
31
  };
20
32
  }
21
33
  /** Users in memory; gone when the process ends. For tests. */
@@ -30,40 +42,51 @@ export function memoryUserStore() {
30
42
  list: () => [...users.values()].map((u) => structuredClone(u)),
31
43
  };
32
44
  }
33
- function setStore(load, save, now) {
34
- const sets = load();
35
- const prune = () => {
36
- const t = now();
37
- for (const ns of Object.keys(sets)) {
38
- for (const [k, exp] of Object.entries(sets[ns]))
39
- if (exp < t)
40
- delete sets[ns][k];
41
- }
42
- };
45
+ function prune(sets, t) {
46
+ for (const ns of Object.keys(sets)) {
47
+ for (const [k, exp] of Object.entries(sets[ns]))
48
+ if (exp < t)
49
+ delete sets[ns][k];
50
+ }
51
+ }
52
+ /** Used nonces, used launch assertions and revoked sessions in one JSON file,
53
+ * written atomically before each answer. Like the users file it is read again
54
+ * when another writer replaced it, so two stores on one file (a module
55
+ * evaluated twice, two processes on one machine) see each other's entries. */
56
+ export function jsonFileStateStore(file, now = Date.now) {
57
+ const shared = new SharedJsonFile(file);
43
58
  return {
44
- addOnce(ns, key, expiresAt) {
45
- prune();
59
+ addOnce: (ns, key, expiresAt) => shared.update((sets) => {
60
+ prune(sets, now());
46
61
  const set = (sets[ns] ??= {});
47
- if (Object.prototype.hasOwnProperty.call(set, key))
48
- return false;
62
+ if (has(set, key))
63
+ return { write: false, result: false };
49
64
  set[key] = expiresAt;
50
65
  // Written before the answer goes out: a restart must not forget it.
51
- save(sets);
52
- return true;
53
- },
66
+ return { write: true, result: true };
67
+ }),
54
68
  has(ns, key) {
55
- const exp = sets[ns]?.[key];
56
- return exp !== undefined && exp >= now();
69
+ const set = shared.read()[ns];
70
+ return !!set && has(set, key) && set[key] >= now();
57
71
  },
58
72
  };
59
73
  }
60
- /** Used nonces, used launch assertions and revoked sessions in one JSON file,
61
- * written atomically before each answer. One server process. */
62
- export function jsonFileStateStore(file, now = Date.now) {
63
- return setStore(() => readJsonFile(file, {}), (s) => atomicWriteJson(file, s), now);
64
- }
65
74
  /** The same in memory. A restart forgets it, which reopens launch replay for
66
75
  * two minutes; for tests. */
67
76
  export function memoryStateStore(now = Date.now) {
68
- return setStore(() => ({}), () => { }, now);
77
+ const sets = {};
78
+ return {
79
+ addOnce(ns, key, expiresAt) {
80
+ prune(sets, now());
81
+ const set = (sets[ns] ??= {});
82
+ if (has(set, key))
83
+ return false;
84
+ set[key] = expiresAt;
85
+ return true;
86
+ },
87
+ has(ns, key) {
88
+ const set = sets[ns];
89
+ return !!set && has(set, key) && set[key] >= now();
90
+ },
91
+ };
69
92
  }