@decentnetwork/beagle-service 0.0.0-stage → 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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +720 -2
  3. package/dist/agent.d.ts +18 -0
  4. package/dist/agent.js +448 -0
  5. package/dist/auth.d.ts +28 -0
  6. package/dist/auth.js +340 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +163 -0
  9. package/dist/commonjs.cjs +49 -0
  10. package/dist/commonjs.d.cts +11 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.js +191 -0
  13. package/dist/outbox.d.ts +63 -0
  14. package/dist/outbox.js +314 -0
  15. package/dist/routes.d.ts +29 -0
  16. package/dist/routes.js +454 -0
  17. package/dist/stores.d.ts +21 -0
  18. package/dist/stores.js +92 -0
  19. package/dist/types.d.ts +641 -0
  20. package/dist/types.js +1 -0
  21. package/dist/users.d.ts +16 -0
  22. package/dist/users.js +429 -0
  23. package/dist/util.d.ts +48 -0
  24. package/dist/util.js +208 -0
  25. package/examples/minimal-site/index.html +91 -0
  26. package/examples/minimal-site/server.mjs +64 -0
  27. package/examples/next-pages-cjs/README.md +30 -0
  28. package/examples/next-pages-cjs/instrumentation.js +16 -0
  29. package/examples/next-pages-cjs/lib/beagle.js +23 -0
  30. package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
  31. package/examples/next-pages-cjs/next.config.js +11 -0
  32. package/examples/next-pages-cjs/package.json +19 -0
  33. package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
  34. package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
  35. package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
  36. package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
  37. package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
  38. package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
  39. package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
  40. package/examples/next-pages-cjs/pages/api/notes.js +16 -0
  41. package/examples/next-pages-cjs/pages/index.js +52 -0
  42. package/examples/sign-in-from-script.mjs +37 -0
  43. package/package.json +67 -4
package/dist/routes.js ADDED
@@ -0,0 +1,454 @@
1
+ import { headerOf } from "./auth.js";
2
+ import { msgOf } from "./util.js";
3
+ const MAX_BODY_BYTES = 64 * 1024;
4
+ const MESSAGES = {
5
+ "origin-not-allowed": "This origin is not on the site's list.",
6
+ "bad-request": "The request body is not what this route expects.",
7
+ "bad-nonce": "The nonce is unknown, expired or already used. Ask for a new one.",
8
+ "bad-signature": "The signature did not verify.",
9
+ "launch-expired": "The launch assertion is more than two minutes from now.",
10
+ "launch-replayed": "This launch assertion was already used.",
11
+ blocked: "This identity may not use the site.",
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.",
15
+ "not-signed-in": "Not signed in.",
16
+ "invalid-fields": "Some registration fields are missing or not accepted.",
17
+ "not-found": "No such route.",
18
+ "method-not-allowed": "Wrong method for this route.",
19
+ "too-large": "The request body is too large.",
20
+ "server-error": "The server could not answer.",
21
+ };
22
+ /** The library's own fields only: a site's extra fields (a plan, notes) do
23
+ * not reach the page unless the site says so with `publicUser`. */
24
+ export function defaultPublicUser(user) {
25
+ const { userid, address, name, avatar, punkId, door, status, profile, firstSeen, lastSeen } = user;
26
+ return { userid, address, name, avatar, punkId, door, status, profile, firstSeen, lastSeen };
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
+ }
78
+ export function createRoutes(opts) {
79
+ const { auth, users, log } = opts;
80
+ const transport = opts.session?.transport ?? "cookie";
81
+ const publicUser = opts.publicUser ?? defaultPublicUser;
82
+ const limited = createRateLimit(opts.rateLimit, opts.now ?? Date.now);
83
+ const lower = (req) => {
84
+ const headers = {};
85
+ for (const [k, v] of Object.entries(req.headers ?? {}))
86
+ headers[k.toLowerCase()] = v;
87
+ return { ...req, method: String(req.method || "GET").toUpperCase(), headers };
88
+ };
89
+ function baseHeaders(req) {
90
+ const h = {
91
+ "content-type": "application/json; charset=utf-8",
92
+ "cache-control": "no-store",
93
+ vary: "Origin",
94
+ };
95
+ // CORS for the listed origins only, so a page on one origin can call an
96
+ // API on another (with the cookie, or with a bearer token).
97
+ const origin = auth.allowedOrigin(req);
98
+ if (origin) {
99
+ h["access-control-allow-origin"] = origin;
100
+ h["access-control-allow-credentials"] = "true";
101
+ }
102
+ return h;
103
+ }
104
+ const answer = (req, status, body, extra = {}) => ({
105
+ status,
106
+ headers: { ...baseHeaders(req), ...extra },
107
+ body,
108
+ });
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);
111
+ const agentInfo = () => {
112
+ const a = opts.agent();
113
+ return a ? { agent: { userid: a.userid, address: a.address, name: a.nickname ?? null } } : {};
114
+ };
115
+ const wrongMethod = (req, want) => (req.method === want ? null : refuse(req, 405, "method-not-allowed", {}, { allow: `${want}, OPTIONS` }));
116
+ /** A state-changing request must come from one of the site's pages (the
117
+ * browser sets Origin; page script cannot), or carry a bearer token, which
118
+ * no other site can make a browser send. */
119
+ const fromOurPage = (req) => auth.allowedOrigin(req) !== null || /^bearer /i.test(headerOf(req, "authorization") ?? "");
120
+ // ── the six routes ────────────────────────────────────────────────────────
121
+ const nonce = async (raw) => {
122
+ const req = lower(raw);
123
+ return wrongMethod(req, "GET") ?? answer(req, 200, { ok: true, nonce: auth.issueNonce(), expiresIn: 120 });
124
+ };
125
+ const isRefusal = (r) => !!r && typeof r === "object" && !!r.refuse;
126
+ async function establish(req, v) {
127
+ if (!v.ok)
128
+ return refuse(req, v.status, v.error);
129
+ const origin = auth.allowedOrigin(req);
130
+ const known = await users.get(v.userid);
131
+ if (known?.status === "blocked")
132
+ return refuse(req, 403, "blocked");
133
+ let user = await users.recordSignIn(v.userid, v.hints, { door: v.door, origin });
134
+ if (user.status === "blocked")
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
+ }
158
+ const session = auth.mintSession(user.userid);
159
+ const registration = users.registration(user);
160
+ const extra = {};
161
+ if (transport !== "bearer")
162
+ extra["set-cookie"] = [auth.cookie(session.token, origin)];
163
+ const body = {
164
+ user: publicUser(user),
165
+ registration,
166
+ expiresAt: new Date(session.expiresAt).toISOString(),
167
+ ...(transport !== "cookie" ? { token: session.token } : {}),
168
+ ...agentInfo(),
169
+ };
170
+ // A visitor past the limit under `then: "refuse"` is told 403, and still
171
+ // gets the session: without it they could never prove who is registering.
172
+ // That session opens `register`, `logout` and nothing else.
173
+ // The site's fields first: the library's own are never replaced by them.
174
+ if (registration.refused)
175
+ return refuse(req, 403, "register-required", { ...own, ...body }, extra);
176
+ return answer(req, 200, Object.assign({ ok: true }, own, body, { ok: true }), extra);
177
+ }
178
+ const tooMany = (req) => {
179
+ const wait = limited(req);
180
+ return wait ? refuse(req, 429, "rate-limited", {}, { "retry-after": String(wait) }) : null;
181
+ };
182
+ const signIn = async (raw) => {
183
+ const req = lower(raw);
184
+ return wrongMethod(req, "POST") ?? tooMany(req) ?? establish(req, await auth.verifySignIn(auth.allowedOrigin(req), req.body));
185
+ };
186
+ const launch = async (raw) => {
187
+ const req = lower(raw);
188
+ return wrongMethod(req, "POST") ?? tooMany(req) ?? establish(req, await auth.verifyLaunch(auth.allowedOrigin(req), req.body));
189
+ };
190
+ const me = async (raw) => {
191
+ const req = lower(raw);
192
+ const bad = wrongMethod(req, "GET");
193
+ if (bad)
194
+ return bad;
195
+ const who = await auth.authenticate(req);
196
+ if (!who.ok) {
197
+ const more = who.user ? { user: publicUser(who.user), registration: who.registration, ...agentInfo() } : {};
198
+ return refuse(req, who.status, who.error, more);
199
+ }
200
+ return answer(req, 200, { ok: true, user: publicUser(who.user), registration: who.registration, ...agentInfo() });
201
+ };
202
+ const logout = async (raw) => {
203
+ const req = lower(raw);
204
+ const bad = wrongMethod(req, "POST");
205
+ if (bad)
206
+ return bad;
207
+ if (!fromOurPage(req))
208
+ return refuse(req, 403, "origin-not-allowed");
209
+ const token = auth.tokenOf(req);
210
+ if (token)
211
+ await auth.revoke(token);
212
+ const extra = {};
213
+ if (transport !== "bearer")
214
+ extra["set-cookie"] = [auth.cookie(null, auth.allowedOrigin(req))];
215
+ return answer(req, 200, { ok: true }, extra);
216
+ };
217
+ const register = async (raw) => {
218
+ const req = lower(raw);
219
+ const bad = wrongMethod(req, "POST");
220
+ if (bad)
221
+ return bad;
222
+ if (!fromOurPage(req))
223
+ return refuse(req, 403, "origin-not-allowed");
224
+ const who = await auth.authenticate(req, { allowUnregistered: true });
225
+ if (!who.ok)
226
+ return refuse(req, who.status, who.error);
227
+ if (!req.body || typeof req.body !== "object")
228
+ return refuse(req, 400, "bad-request");
229
+ const b = req.body;
230
+ const submitted = b.profile && typeof b.profile === "object" ? b.profile : b;
231
+ const r = await users.register(who.user.userid, submitted);
232
+ if (!r.ok) {
233
+ if (r.error === "invalid-fields")
234
+ return refuse(req, 400, "invalid-fields", { fields: r.fields });
235
+ return refuse(req, r.error === "blocked" ? 403 : 401, r.error === "blocked" ? "blocked" : "not-signed-in");
236
+ }
237
+ return answer(req, 200, { ok: true, user: publicUser(r.user), registration: users.registration(r.user), ...agentInfo() });
238
+ };
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
+ });
267
+ // `/beagle` and `/beagle-launch` are the paths the Sign in with Beagle
268
+ // guide uses; a page written from it works unchanged.
269
+ const table = {
270
+ "/nonce": nonce,
271
+ "/sign-in": signIn,
272
+ "/beagle": signIn,
273
+ "/launch": launch,
274
+ "/beagle-launch": launch,
275
+ "/me": me,
276
+ "/logout": logout,
277
+ "/register": register,
278
+ };
279
+ const handle = async (raw) => {
280
+ const req = lower(raw);
281
+ try {
282
+ const path = "/" + String(req.path ?? "").split("?")[0].replace(/^\/+|\/+$/g, "");
283
+ const route = table[path];
284
+ if (!route)
285
+ return refuse(req, 404, "not-found");
286
+ if (req.method === "OPTIONS")
287
+ return preflight(req);
288
+ return await route(req);
289
+ }
290
+ catch (e) {
291
+ log(`beagle-service: ${req.method} ${req.path} failed: ${e?.stack ?? msgOf(e)}`);
292
+ return refuse(req, 500, "server-error");
293
+ }
294
+ };
295
+ // ── adapters ──────────────────────────────────────────────────────────────
296
+ /** "/nonce" for "/api/auth/nonce" under prefix "/api/auth"; null outside it. */
297
+ function below(prefix, url) {
298
+ const p = "/" + prefix.replace(/^\/+|\/+$/g, "");
299
+ let pathname;
300
+ try {
301
+ pathname = new URL(url, "http://x").pathname;
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ if (p === "/")
307
+ return pathname;
308
+ if (pathname === p)
309
+ return "/";
310
+ return pathname.startsWith(p + "/") ? pathname.slice(p.length) : null;
311
+ }
312
+ function readBody(req) {
313
+ // Express (`express.json()`) and Next.js have parsed it already.
314
+ if (req.body !== undefined && req.body !== null && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
315
+ return Promise.resolve({ ok: true, body: req.body });
316
+ }
317
+ if (typeof req.body === "string" || Buffer.isBuffer(req.body))
318
+ return Promise.resolve(parse(String(req.body)));
319
+ return new Promise((resolve) => {
320
+ const chunks = [];
321
+ let size = 0;
322
+ let done = false;
323
+ const finish = (r) => {
324
+ if (!done) {
325
+ done = true;
326
+ resolve(r);
327
+ }
328
+ };
329
+ req.on("data", (c) => {
330
+ size += c.length;
331
+ if (size > MAX_BODY_BYTES) {
332
+ finish({ ok: false, error: "too-large", status: 413 });
333
+ req.resume?.();
334
+ }
335
+ else
336
+ chunks.push(c);
337
+ });
338
+ req.on("end", () => finish(parse(Buffer.concat(chunks).toString("utf8"))));
339
+ req.on("error", () => finish({ ok: false, error: "bad-request", status: 400 }));
340
+ });
341
+ }
342
+ function parse(raw) {
343
+ if (raw.length > MAX_BODY_BYTES)
344
+ return { ok: false, error: "too-large", status: 413 };
345
+ if (raw.trim() === "")
346
+ return { ok: true, body: undefined };
347
+ try {
348
+ return { ok: true, body: JSON.parse(raw) };
349
+ }
350
+ catch {
351
+ return { ok: false, error: "bad-request", status: 400 };
352
+ }
353
+ }
354
+ function write(res, r) {
355
+ res.statusCode = r.status;
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
+ }
361
+ res.end(r.body === null ? undefined : JSON.stringify(r.body));
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
+ }
392
+ function nodeAdapter(o, fallThrough) {
393
+ const prefix = o.prefix ?? "/api/auth";
394
+ return async (req, res, next) => {
395
+ const headers = req.headers ?? {};
396
+ // Express strips the mount path from `url`; `originalUrl` keeps it.
397
+ const path = below(prefix, req.originalUrl ?? req.url ?? "/");
398
+ if (path === null) {
399
+ if (fallThrough && typeof next === "function")
400
+ return void next();
401
+ return write(res, refuse(lower({ method: req.method, headers }), 404, "not-found"));
402
+ }
403
+ try {
404
+ let body;
405
+ if (req.method === "POST") {
406
+ const b = await readBody(req);
407
+ if (!b.ok)
408
+ return write(res, refuse(lower({ method: req.method, headers }), b.status, b.error));
409
+ body = b.body;
410
+ }
411
+ write(res, await handle({ method: req.method, path, headers, body, ip: ipOf(req), raw: req }));
412
+ }
413
+ catch (e) {
414
+ log(`beagle-service: ${req.method} ${req.url} failed: ${e?.stack ?? msgOf(e)}`);
415
+ if (!res.headersSent)
416
+ write(res, refuse(lower({ method: req.method, headers }), 500, "server-error"));
417
+ }
418
+ };
419
+ }
420
+ function fetchHandler(o = {}) {
421
+ const prefix = o.prefix ?? "/api/auth";
422
+ return async (request) => {
423
+ const headers = {};
424
+ request.headers.forEach((v, k) => (headers[k] = v));
425
+ const reply = (r) => {
426
+ const h = new Headers();
427
+ for (const [k, v] of Object.entries(r.headers))
428
+ for (const one of Array.isArray(v) ? v : [v])
429
+ h.append(k, one);
430
+ return new Response(r.body === null ? null : JSON.stringify(r.body), { status: r.status, headers: h });
431
+ };
432
+ const path = below(prefix, request.url);
433
+ if (path === null)
434
+ return reply(refuse(lower({ method: request.method, headers }), 404, "not-found"));
435
+ let body;
436
+ if (request.method === "POST") {
437
+ const b = parse(await request.text());
438
+ if (!b.ok)
439
+ return reply(refuse(lower({ method: request.method, headers }), b.status, b.error));
440
+ body = b.body;
441
+ }
442
+ return reply(await handle({ method: request.method, path, headers, body, raw: request }));
443
+ };
444
+ }
445
+ return {
446
+ handle,
447
+ handlers,
448
+ nodeHandler: (o = {}) => nodeAdapter(o, false),
449
+ expressHandler: (o = {}) => nodeAdapter(o, true),
450
+ nextApiHandler: (o = {}) => nodeAdapter(o, false),
451
+ fetchHandler,
452
+ route: routeHandler,
453
+ };
454
+ }
@@ -0,0 +1,21 @@
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;
5
+ /** Users in one JSON file, `{ [userid]: record }`, rewritten atomically on
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. */
11
+ export declare function jsonFileUserStore(file: string): UserStore;
12
+ /** Users in memory; gone when the process ends. For tests. */
13
+ export declare function memoryUserStore(): UserStore;
14
+ /** Used nonces, used launch assertions and revoked sessions in one JSON file,
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. */
18
+ export declare function jsonFileStateStore(file: string, now?: () => number): StateStore;
19
+ /** The same in memory. A restart forgets it, which reopens launch replay for
20
+ * two minutes; for tests. */
21
+ export declare function memoryStateStore(now?: () => number): StateStore;
package/dist/stores.js ADDED
@@ -0,0 +1,92 @@
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
+ }
11
+ // ── users ───────────────────────────────────────────────────────────────────
12
+ /** Users in one JSON file, `{ [userid]: record }`, rewritten atomically on
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. */
18
+ export function jsonFileUserStore(file) {
19
+ const shared = new SharedJsonFile(file);
20
+ return {
21
+ get: (userid) => {
22
+ const table = shared.read();
23
+ return has(table, userid) ? structuredClone(table[userid]) : null;
24
+ },
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)),
31
+ };
32
+ }
33
+ /** Users in memory; gone when the process ends. For tests. */
34
+ export function memoryUserStore() {
35
+ const users = new Map();
36
+ return {
37
+ get: (userid) => {
38
+ const u = users.get(userid);
39
+ return u ? structuredClone(u) : null;
40
+ },
41
+ put: (user) => void users.set(user.userid, structuredClone(user)),
42
+ list: () => [...users.values()].map((u) => structuredClone(u)),
43
+ };
44
+ }
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);
58
+ return {
59
+ addOnce: (ns, key, expiresAt) => shared.update((sets) => {
60
+ prune(sets, now());
61
+ const set = (sets[ns] ??= {});
62
+ if (has(set, key))
63
+ return { write: false, result: false };
64
+ set[key] = expiresAt;
65
+ // Written before the answer goes out: a restart must not forget it.
66
+ return { write: true, result: true };
67
+ }),
68
+ has(ns, key) {
69
+ const set = shared.read()[ns];
70
+ return !!set && has(set, key) && set[key] >= now();
71
+ },
72
+ };
73
+ }
74
+ /** The same in memory. A restart forgets it, which reopens launch replay for
75
+ * two minutes; for tests. */
76
+ export function memoryStateStore(now = Date.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
+ };
92
+ }