@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.
- package/README.md +289 -28
- package/dist/auth.d.ts +10 -2
- package/dist/auth.js +119 -36
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +163 -0
- package/dist/commonjs.cjs +49 -0
- package/dist/commonjs.d.cts +11 -0
- package/dist/index.d.ts +16 -2
- package/dist/index.js +127 -5
- package/dist/routes.d.ts +9 -1
- package/dist/routes.js +157 -24
- package/dist/stores.d.ts +12 -4
- package/dist/stores.js +60 -37
- package/dist/types.d.ts +157 -12
- package/dist/users.js +108 -58
- package/dist/util.d.ts +22 -2
- package/dist/util.js +103 -2
- package/examples/next-pages-cjs/README.md +30 -0
- package/examples/next-pages-cjs/instrumentation.js +16 -0
- package/examples/next-pages-cjs/lib/beagle.js +23 -0
- package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
- package/examples/next-pages-cjs/next.config.js +11 -0
- package/examples/next-pages-cjs/package.json +19 -0
- package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
- package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
- package/examples/next-pages-cjs/pages/api/notes.js +16 -0
- package/examples/next-pages-cjs/pages/index.js +52 -0
- package/examples/sign-in-from-script.mjs +37 -0
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
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.
|
|
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 {
|
|
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
|
|
5
|
-
*
|
|
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
|
|
8
|
-
const flush = () => atomicWriteJson(file, Object.fromEntries(users));
|
|
19
|
+
const shared = new SharedJsonFile(file);
|
|
9
20
|
return {
|
|
10
21
|
get: (userid) => {
|
|
11
|
-
const
|
|
12
|
-
return
|
|
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
|
-
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 (
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
},
|
|
66
|
+
return { write: true, result: true };
|
|
67
|
+
}),
|
|
54
68
|
has(ns, key) {
|
|
55
|
-
const
|
|
56
|
-
return
|
|
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
|
-
|
|
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
|
}
|