@decentnetwork/beagle-service 0.0.0-stage → 0.1.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/dist/outbox.js ADDED
@@ -0,0 +1,314 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { atomicWriteJson, msgOf, readJsonFile } from "./util.js";
5
+ // ── splitting ───────────────────────────────────────────────────────────────
6
+ //
7
+ // A Carrier friend message that stays one packet carries at most 1024 bytes of
8
+ // text. A friend that reads the delivery envelope gets
9
+ // "\x1eDNPACK1:" + base64url(JSON {t:"msg", id, text}), so the JSON may be at
10
+ // most floor((1024 - 9) * 3 / 4) = 761 bytes; an envelope-less friend gets the
11
+ // text itself. Each part is cut to fit the tighter of the two, so it travels
12
+ // as one packet on either road and to either kind of friend.
13
+ const ENVELOPE_JSON_MAX = 761;
14
+ const ID_RESERVE = "x".repeat(48);
15
+ const fits = (text) => Buffer.byteLength(JSON.stringify({ t: "msg", id: ID_RESERVE, text })) <= ENVELOPE_JSON_MAX;
16
+ /** Cut `text` into parts that each fit one message (about 700 bytes of
17
+ * UTF-8), at a line break or a space where one is near. The parts joined
18
+ * together are the text, unchanged. */
19
+ export function splitText(text) {
20
+ if (fits(text))
21
+ return [text];
22
+ const cps = Array.from(text); // code points: never cut inside a surrogate pair
23
+ const parts = [];
24
+ let start = 0;
25
+ while (start < cps.length) {
26
+ let lo = 1;
27
+ let hi = Math.min(cps.length - start, ENVELOPE_JSON_MAX);
28
+ while (lo < hi) {
29
+ const mid = (lo + hi + 1) >> 1;
30
+ if (fits(cps.slice(start, start + mid).join("")))
31
+ lo = mid;
32
+ else
33
+ hi = mid - 1;
34
+ }
35
+ let n = lo;
36
+ if (start + n < cps.length) {
37
+ // Prefer to end the part after a line break, else after a space, when
38
+ // one lies in its last third.
39
+ const floor = Math.floor(n * (2 / 3));
40
+ let cut = -1;
41
+ for (let i = n - 1; i >= floor; i--) {
42
+ if (cps[start + i] === "\n") {
43
+ cut = i + 1;
44
+ break;
45
+ }
46
+ }
47
+ if (cut < 0) {
48
+ for (let i = n - 1; i >= floor; i--) {
49
+ if (cps[start + i] === " ") {
50
+ cut = i + 1;
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ if (cut > 0)
56
+ n = cut;
57
+ }
58
+ parts.push(cps.slice(start, start + n).join(""));
59
+ start += n;
60
+ }
61
+ return parts;
62
+ }
63
+ /**
64
+ * Messages on their way out, kept in one JSON file.
65
+ *
66
+ * - A message is written to disk before `enqueue` returns and removed only
67
+ * after a road took it, so a restart loses nothing.
68
+ * - Its `deliveryId` is fixed when it is queued. A retry, a second road or a
69
+ * send repeated after a crash carries the same id, and a friend that reads
70
+ * the delivery envelope shows it once.
71
+ * - One queue per recipient: a recipient's messages go in order, and an
72
+ * unreachable recipient never holds up another.
73
+ * - The road (the rule the agentnet daemon uses): a session that has just
74
+ * proven itself gets the message live, with a short wait for the
75
+ * confirmation; otherwise, or when no confirmation comes, it is posted to
76
+ * an Express node at once. Only when neither road takes it does it wait,
77
+ * with a delay that doubles up to `maxDelayMs`.
78
+ * - What can never arrive is moved to the dead-letter file with the reason.
79
+ */
80
+ export class Outbox {
81
+ #o;
82
+ #items;
83
+ #busy = new Set();
84
+ /** The message being sent to each recipient right now. */
85
+ #inFlight = new Map();
86
+ /** Recipients whose queue was dropped while a send was in flight, and why. */
87
+ #dropAfter = new Map();
88
+ #stopped = false;
89
+ #now;
90
+ #log;
91
+ #base;
92
+ #max;
93
+ #maxAge;
94
+ constructor(o) {
95
+ this.#o = o;
96
+ this.#now = o.now ?? Date.now;
97
+ this.#log = o.log ?? (() => { });
98
+ this.#base = o.baseDelayMs ?? 5_000;
99
+ this.#max = o.maxDelayMs ?? 10 * 60_000;
100
+ this.#maxAge = o.maxAgeMs ?? 72 * 3_600_000;
101
+ const loaded = readJsonFile(o.file, []);
102
+ this.#items = Array.isArray(loaded) ? loaded.filter((i) => i && typeof i.to === "string" && typeof i.text === "string" && typeof i.deliveryId === "string") : [];
103
+ // Whatever delay was running when the process ended is over.
104
+ for (const i of this.#items)
105
+ i.nextAt = 0;
106
+ }
107
+ list() {
108
+ return this.#items.map((i) => ({ ...i }));
109
+ }
110
+ deadLetters(limit = 500) {
111
+ let raw = "";
112
+ try {
113
+ raw = readFileSync(this.#o.deadFile, "utf8");
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ const out = [];
119
+ for (const line of raw.split("\n")) {
120
+ if (!line)
121
+ continue;
122
+ try {
123
+ out.push(JSON.parse(line));
124
+ }
125
+ catch {
126
+ /* a torn last line */
127
+ }
128
+ }
129
+ return out.slice(-limit);
130
+ }
131
+ #make(to, text) {
132
+ const id = `${this.#now().toString(36)}${randomBytes(9).toString("base64url")}`;
133
+ const texts = splitText(text);
134
+ const createdAt = new Date(this.#now()).toISOString();
135
+ return texts.map((t, part) => ({
136
+ id,
137
+ part,
138
+ parts: texts.length,
139
+ to,
140
+ text: t,
141
+ deliveryId: `bs-${id}-${part}`,
142
+ createdAt,
143
+ attempts: 0,
144
+ nextAt: 0,
145
+ }));
146
+ }
147
+ /** Put a text on disk for `to`. Synchronous: when it returns, the message survives a crash. */
148
+ enqueue(to, text) {
149
+ const items = this.#make(to, text);
150
+ this.#items.push(...items);
151
+ this.#persist();
152
+ return items.map((i) => ({ ...i }));
153
+ }
154
+ /** Record a message that cannot be sent at all, without queueing it. */
155
+ refuse(to, text, why) {
156
+ const items = this.#make(to, text);
157
+ for (const i of items)
158
+ this.#deadLetter(i, why);
159
+ return items;
160
+ }
161
+ /** Dead-letter everything queued for `to`. */
162
+ dropFor(to, why) {
163
+ // A message already on the wire is not called dead: it may well arrive.
164
+ // Its attempt finishes; only if it fails is it dead-lettered too.
165
+ const flying = this.#inFlight.get(to);
166
+ if (flying)
167
+ this.#dropAfter.set(to, why);
168
+ const mine = this.#items.filter((i) => i.to === to && i !== flying);
169
+ if (!mine.length)
170
+ return 0;
171
+ this.#items = this.#items.filter((i) => i.to !== to || i === flying);
172
+ this.#persist();
173
+ for (const i of mine)
174
+ this.#deadLetter(i, why);
175
+ return mine.length;
176
+ }
177
+ /** Something changed for `to` (accepted, connected): try now, not after the delay. */
178
+ kick(to) {
179
+ for (const i of this.#items)
180
+ if (i.to === to)
181
+ i.nextAt = 0;
182
+ void this.pump();
183
+ }
184
+ stop() {
185
+ this.#stopped = true;
186
+ }
187
+ /** Try the head of every recipient's queue that is due. */
188
+ async pump() {
189
+ if (this.#stopped)
190
+ return;
191
+ const t = this.#now();
192
+ const due = new Set();
193
+ const seen = new Set();
194
+ for (const i of this.#items) {
195
+ if (seen.has(i.to))
196
+ continue;
197
+ seen.add(i.to);
198
+ if (!this.#busy.has(i.to) && i.nextAt <= t)
199
+ due.add(i.to);
200
+ }
201
+ await Promise.all([...due].map((to) => this.#drain(to)));
202
+ }
203
+ async #drain(to) {
204
+ this.#busy.add(to);
205
+ try {
206
+ for (;;) {
207
+ if (this.#stopped)
208
+ return;
209
+ const head = this.#items.find((i) => i.to === to);
210
+ if (!head || head.nextAt > this.#now())
211
+ return;
212
+ if (!(await this.#deliver(head)))
213
+ return;
214
+ }
215
+ }
216
+ catch (e) {
217
+ this.#log(`beagle-service: outbox for ${to.slice(0, 8)} failed: ${msgOf(e)}`);
218
+ }
219
+ finally {
220
+ this.#busy.delete(to);
221
+ }
222
+ }
223
+ /** true when the item left the queue (delivered or dead-lettered). */
224
+ async #deliver(item) {
225
+ const t = this.#o.transport;
226
+ const relation = t.relation(item.to);
227
+ if (relation === "none")
228
+ return this.#settleDead(item, "not-a-friend");
229
+ if (this.#now() - Date.parse(item.createdAt) > this.#maxAge) {
230
+ return this.#settleDead(item, relation === "pending" ? "not-a-friend" : "expired", item.lastError);
231
+ }
232
+ if (relation === "pending")
233
+ return this.#wait(item, "the friend request is not accepted yet");
234
+ let result;
235
+ this.#inFlight.set(item.to, item);
236
+ try {
237
+ result = await this.#road(item);
238
+ }
239
+ catch (e) {
240
+ const err = msgOf(e);
241
+ const dropped = this.#dropAfter.get(item.to);
242
+ if (dropped)
243
+ return this.#settleDead(item, dropped, err);
244
+ // The far side dropped us between the check and the send.
245
+ if (/not a friend/i.test(err) && t.relation(item.to) === "none")
246
+ return this.#settleDead(item, "not-a-friend", err);
247
+ return this.#wait(item, err);
248
+ }
249
+ finally {
250
+ this.#inFlight.delete(item.to);
251
+ this.#dropAfter.delete(item.to);
252
+ }
253
+ this.#remove(item);
254
+ this.#o.onDelivered?.({ id: item.id, deliveryId: item.deliveryId, to: item.to, ...result });
255
+ return true;
256
+ }
257
+ async #road(item) {
258
+ const t = this.#o.transport;
259
+ const { to, text, deliveryId } = item;
260
+ const live = async () => {
261
+ const how = await t.sendLive(to, text, deliveryId);
262
+ return { via: how === "offline" ? "offline" : "online", confirmed: how === "acked" };
263
+ };
264
+ const express = async () => {
265
+ await t.sendExpress(to, text, deliveryId);
266
+ return { via: "offline", confirmed: false };
267
+ };
268
+ const [first, second, a, b] = t.proven(to) ? [live, express, "live", "express"] : [express, live, "express", "live"];
269
+ try {
270
+ return await first();
271
+ }
272
+ catch (e1) {
273
+ try {
274
+ return await second();
275
+ }
276
+ catch (e2) {
277
+ throw new Error(`${a}: ${msgOf(e1)}; ${b}: ${msgOf(e2)}`);
278
+ }
279
+ }
280
+ }
281
+ #wait(item, error) {
282
+ item.attempts += 1;
283
+ item.lastError = error;
284
+ item.nextAt = this.#now() + Math.min(this.#max, this.#base * 2 ** Math.min(item.attempts - 1, 20));
285
+ this.#persist();
286
+ return false;
287
+ }
288
+ #settleDead(item, why, error) {
289
+ this.#remove(item);
290
+ this.#deadLetter(item, why, error);
291
+ return true;
292
+ }
293
+ #remove(item) {
294
+ const i = this.#items.indexOf(item);
295
+ if (i >= 0)
296
+ this.#items.splice(i, 1);
297
+ this.#persist();
298
+ }
299
+ #deadLetter(item, why, error) {
300
+ const letter = { at: new Date(this.#now()).toISOString(), why, ...(error ? { error } : {}), item: { ...item } };
301
+ try {
302
+ mkdirSync(dirname(this.#o.deadFile), { recursive: true });
303
+ appendFileSync(this.#o.deadFile, JSON.stringify(letter) + "\n", { mode: 0o600 });
304
+ }
305
+ catch (e) {
306
+ this.#log(`beagle-service: could not write the dead-letter file: ${msgOf(e)}`);
307
+ }
308
+ this.#log(`beagle-service: dead-letter (${why}) for ${item.to.slice(0, 8)}${error ? `: ${error}` : ""}`);
309
+ this.#o.onDead?.(letter);
310
+ }
311
+ #persist() {
312
+ atomicWriteJson(this.#o.file, this.#items);
313
+ }
314
+ }
@@ -0,0 +1,21 @@
1
+ import type { AdapterOptions, Agent, Auth, Handlers, NodeHandler, RouteHandler, SessionConfig, UserRecord, Users } from "./types.js";
2
+ import { type Logger } from "./util.js";
3
+ /** The library's own fields only: a site's extra fields (a plan, notes) do
4
+ * not reach the page unless the site says so with `publicUser`. */
5
+ export declare function defaultPublicUser(user: UserRecord): Record<string, unknown>;
6
+ export interface RoutesOptions {
7
+ auth: Auth;
8
+ users: Users;
9
+ session?: SessionConfig;
10
+ publicUser?: (user: UserRecord) => Record<string, unknown>;
11
+ agent: () => Agent | undefined;
12
+ log: Logger;
13
+ }
14
+ export declare function createRoutes(opts: RoutesOptions): {
15
+ handle: RouteHandler;
16
+ handlers: Handlers;
17
+ nodeHandler: (o?: AdapterOptions) => NodeHandler;
18
+ expressHandler: (o?: AdapterOptions) => NodeHandler;
19
+ nextApiHandler: (o?: AdapterOptions) => NodeHandler;
20
+ fetchHandler: (o?: AdapterOptions) => (request: Request) => Promise<Response>;
21
+ };
package/dist/routes.js ADDED
@@ -0,0 +1,321 @@
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
+ "not-signed-in": "Not signed in.",
14
+ "invalid-fields": "Some registration fields are missing or not accepted.",
15
+ "not-found": "No such route.",
16
+ "method-not-allowed": "Wrong method for this route.",
17
+ "too-large": "The request body is too large.",
18
+ "server-error": "The server could not answer.",
19
+ };
20
+ /** The library's own fields only: a site's extra fields (a plan, notes) do
21
+ * not reach the page unless the site says so with `publicUser`. */
22
+ export function defaultPublicUser(user) {
23
+ const { userid, address, name, avatar, punkId, door, status, profile, firstSeen, lastSeen } = user;
24
+ return { userid, address, name, avatar, punkId, door, status, profile, firstSeen, lastSeen };
25
+ }
26
+ export function createRoutes(opts) {
27
+ const { auth, users, log } = opts;
28
+ const transport = opts.session?.transport ?? "cookie";
29
+ const publicUser = opts.publicUser ?? defaultPublicUser;
30
+ const lower = (req) => {
31
+ const headers = {};
32
+ for (const [k, v] of Object.entries(req.headers ?? {}))
33
+ headers[k.toLowerCase()] = v;
34
+ return { ...req, method: String(req.method || "GET").toUpperCase(), headers };
35
+ };
36
+ function baseHeaders(req) {
37
+ const h = {
38
+ "content-type": "application/json; charset=utf-8",
39
+ "cache-control": "no-store",
40
+ vary: "Origin",
41
+ };
42
+ // CORS for the listed origins only, so a page on one origin can call an
43
+ // API on another (with the cookie, or with a bearer token).
44
+ const origin = auth.allowedOrigin(req);
45
+ if (origin) {
46
+ h["access-control-allow-origin"] = origin;
47
+ h["access-control-allow-credentials"] = "true";
48
+ }
49
+ return h;
50
+ }
51
+ const answer = (req, status, body, extra = {}) => ({
52
+ status,
53
+ headers: { ...baseHeaders(req), ...extra },
54
+ body,
55
+ });
56
+ const refuse = (req, status, error, more = {}, extra = {}) => answer(req, status, { ok: false, error, message: MESSAGES[error], ...more }, extra);
57
+ const agentInfo = () => {
58
+ const a = opts.agent();
59
+ return a ? { agent: { userid: a.userid, address: a.address, name: a.nickname ?? null } } : {};
60
+ };
61
+ const wrongMethod = (req, want) => (req.method === want ? null : refuse(req, 405, "method-not-allowed", {}, { allow: `${want}, OPTIONS` }));
62
+ /** A state-changing request must come from one of the site's pages (the
63
+ * browser sets Origin; page script cannot), or carry a bearer token, which
64
+ * no other site can make a browser send. */
65
+ const fromOurPage = (req) => auth.allowedOrigin(req) !== null || /^bearer /i.test(headerOf(req, "authorization") ?? "");
66
+ // ── the six routes ────────────────────────────────────────────────────────
67
+ const nonce = async (raw) => {
68
+ const req = lower(raw);
69
+ return wrongMethod(req, "GET") ?? answer(req, 200, { ok: true, nonce: auth.issueNonce(), expiresIn: 120 });
70
+ };
71
+ async function establish(req, v) {
72
+ if (!v.ok)
73
+ return refuse(req, v.status, v.error);
74
+ const known = await users.get(v.userid);
75
+ if (known?.status === "blocked")
76
+ return refuse(req, 403, "blocked");
77
+ const user = await users.recordSignIn(v.userid, v.hints, { door: v.door });
78
+ if (user.status === "blocked")
79
+ return refuse(req, 403, "blocked");
80
+ const session = auth.mintSession(user.userid);
81
+ const registration = users.registration(user);
82
+ const extra = {};
83
+ if (transport !== "bearer")
84
+ extra["set-cookie"] = [auth.cookie(session.token, auth.allowedOrigin(req))];
85
+ const body = {
86
+ user: publicUser(user),
87
+ registration,
88
+ expiresAt: new Date(session.expiresAt).toISOString(),
89
+ ...(transport !== "cookie" ? { token: session.token } : {}),
90
+ ...agentInfo(),
91
+ };
92
+ // A visitor past the limit under `then: "refuse"` is told 403, and still
93
+ // gets the session: without it they could never prove who is registering.
94
+ // That session opens `register`, `logout` and nothing else.
95
+ if (registration.refused)
96
+ return refuse(req, 403, "register-required", body, extra);
97
+ return answer(req, 200, { ok: true, ...body }, extra);
98
+ }
99
+ const signIn = async (raw) => {
100
+ const req = lower(raw);
101
+ return wrongMethod(req, "POST") ?? establish(req, await auth.verifySignIn(auth.allowedOrigin(req), req.body));
102
+ };
103
+ const launch = async (raw) => {
104
+ const req = lower(raw);
105
+ return wrongMethod(req, "POST") ?? establish(req, await auth.verifyLaunch(auth.allowedOrigin(req), req.body));
106
+ };
107
+ const me = async (raw) => {
108
+ const req = lower(raw);
109
+ const bad = wrongMethod(req, "GET");
110
+ if (bad)
111
+ return bad;
112
+ const who = await auth.authenticate(req);
113
+ if (!who.ok) {
114
+ const more = who.user ? { user: publicUser(who.user), registration: who.registration, ...agentInfo() } : {};
115
+ return refuse(req, who.status, who.error, more);
116
+ }
117
+ return answer(req, 200, { ok: true, user: publicUser(who.user), registration: who.registration, ...agentInfo() });
118
+ };
119
+ const logout = async (raw) => {
120
+ const req = lower(raw);
121
+ const bad = wrongMethod(req, "POST");
122
+ if (bad)
123
+ return bad;
124
+ if (!fromOurPage(req))
125
+ return refuse(req, 403, "origin-not-allowed");
126
+ const token = auth.tokenOf(req);
127
+ if (token)
128
+ await auth.revoke(token);
129
+ const extra = {};
130
+ if (transport !== "bearer")
131
+ extra["set-cookie"] = [auth.cookie(null, auth.allowedOrigin(req))];
132
+ return answer(req, 200, { ok: true }, extra);
133
+ };
134
+ const register = async (raw) => {
135
+ const req = lower(raw);
136
+ const bad = wrongMethod(req, "POST");
137
+ if (bad)
138
+ return bad;
139
+ if (!fromOurPage(req))
140
+ return refuse(req, 403, "origin-not-allowed");
141
+ const who = await auth.authenticate(req, { allowUnregistered: true });
142
+ if (!who.ok)
143
+ return refuse(req, who.status, who.error);
144
+ if (!req.body || typeof req.body !== "object")
145
+ return refuse(req, 400, "bad-request");
146
+ const b = req.body;
147
+ const submitted = b.profile && typeof b.profile === "object" ? b.profile : b;
148
+ const r = await users.register(who.user.userid, submitted);
149
+ if (!r.ok) {
150
+ if (r.error === "invalid-fields")
151
+ return refuse(req, 400, "invalid-fields", { fields: r.fields });
152
+ return refuse(req, r.error === "blocked" ? 403 : 401, r.error === "blocked" ? "blocked" : "not-signed-in");
153
+ }
154
+ return answer(req, 200, { ok: true, user: publicUser(r.user), registration: users.registration(r.user), ...agentInfo() });
155
+ };
156
+ const handlers = { nonce, signIn, launch, me, logout, register };
157
+ // `/beagle` and `/beagle-launch` are the paths the Sign in with Beagle
158
+ // guide uses; a page written from it works unchanged.
159
+ const table = {
160
+ "/nonce": nonce,
161
+ "/sign-in": signIn,
162
+ "/beagle": signIn,
163
+ "/launch": launch,
164
+ "/beagle-launch": launch,
165
+ "/me": me,
166
+ "/logout": logout,
167
+ "/register": register,
168
+ };
169
+ const handle = async (raw) => {
170
+ const req = lower(raw);
171
+ try {
172
+ const path = "/" + String(req.path ?? "").split("?")[0].replace(/^\/+|\/+$/g, "");
173
+ const route = table[path];
174
+ if (!route)
175
+ 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
+ }
188
+ return await route(req);
189
+ }
190
+ catch (e) {
191
+ log(`beagle-service: ${req.method} ${req.path} failed: ${e?.stack ?? msgOf(e)}`);
192
+ return refuse(req, 500, "server-error");
193
+ }
194
+ };
195
+ // ── adapters ──────────────────────────────────────────────────────────────
196
+ /** "/nonce" for "/api/auth/nonce" under prefix "/api/auth"; null outside it. */
197
+ function below(prefix, url) {
198
+ const p = "/" + prefix.replace(/^\/+|\/+$/g, "");
199
+ let pathname;
200
+ try {
201
+ pathname = new URL(url, "http://x").pathname;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ if (p === "/")
207
+ return pathname;
208
+ if (pathname === p)
209
+ return "/";
210
+ return pathname.startsWith(p + "/") ? pathname.slice(p.length) : null;
211
+ }
212
+ function readBody(req) {
213
+ // Express (`express.json()`) and Next.js have parsed it already.
214
+ if (req.body !== undefined && req.body !== null && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
215
+ return Promise.resolve({ ok: true, body: req.body });
216
+ }
217
+ if (typeof req.body === "string" || Buffer.isBuffer(req.body))
218
+ return Promise.resolve(parse(String(req.body)));
219
+ return new Promise((resolve) => {
220
+ const chunks = [];
221
+ let size = 0;
222
+ let done = false;
223
+ const finish = (r) => {
224
+ if (!done) {
225
+ done = true;
226
+ resolve(r);
227
+ }
228
+ };
229
+ req.on("data", (c) => {
230
+ size += c.length;
231
+ if (size > MAX_BODY_BYTES) {
232
+ finish({ ok: false, error: "too-large", status: 413 });
233
+ req.resume?.();
234
+ }
235
+ else
236
+ chunks.push(c);
237
+ });
238
+ req.on("end", () => finish(parse(Buffer.concat(chunks).toString("utf8"))));
239
+ req.on("error", () => finish({ ok: false, error: "bad-request", status: 400 }));
240
+ });
241
+ }
242
+ function parse(raw) {
243
+ if (raw.length > MAX_BODY_BYTES)
244
+ return { ok: false, error: "too-large", status: 413 };
245
+ if (raw.trim() === "")
246
+ return { ok: true, body: undefined };
247
+ try {
248
+ return { ok: true, body: JSON.parse(raw) };
249
+ }
250
+ catch {
251
+ return { ok: false, error: "bad-request", status: 400 };
252
+ }
253
+ }
254
+ function write(res, r) {
255
+ res.statusCode = r.status;
256
+ for (const [k, v] of Object.entries(r.headers))
257
+ res.setHeader(k, v);
258
+ res.end(r.body === null ? undefined : JSON.stringify(r.body));
259
+ }
260
+ function nodeAdapter(o, fallThrough) {
261
+ const prefix = o.prefix ?? "/api/auth";
262
+ return async (req, res, next) => {
263
+ const headers = req.headers ?? {};
264
+ // Express strips the mount path from `url`; `originalUrl` keeps it.
265
+ const path = below(prefix, req.originalUrl ?? req.url ?? "/");
266
+ if (path === null) {
267
+ if (fallThrough && typeof next === "function")
268
+ return void next();
269
+ return write(res, refuse(lower({ method: req.method, headers }), 404, "not-found"));
270
+ }
271
+ try {
272
+ let body;
273
+ if (req.method === "POST") {
274
+ const b = await readBody(req);
275
+ if (!b.ok)
276
+ return write(res, refuse(lower({ method: req.method, headers }), b.status, b.error));
277
+ body = b.body;
278
+ }
279
+ write(res, await handle({ method: req.method, path, headers, body }));
280
+ }
281
+ catch (e) {
282
+ log(`beagle-service: ${req.method} ${req.url} failed: ${e?.stack ?? msgOf(e)}`);
283
+ if (!res.headersSent)
284
+ write(res, refuse(lower({ method: req.method, headers }), 500, "server-error"));
285
+ }
286
+ };
287
+ }
288
+ function fetchHandler(o = {}) {
289
+ const prefix = o.prefix ?? "/api/auth";
290
+ return async (request) => {
291
+ const headers = {};
292
+ request.headers.forEach((v, k) => (headers[k] = v));
293
+ const reply = (r) => {
294
+ const h = new Headers();
295
+ for (const [k, v] of Object.entries(r.headers))
296
+ for (const one of Array.isArray(v) ? v : [v])
297
+ h.append(k, one);
298
+ return new Response(r.body === null ? null : JSON.stringify(r.body), { status: r.status, headers: h });
299
+ };
300
+ const path = below(prefix, request.url);
301
+ if (path === null)
302
+ return reply(refuse(lower({ method: request.method, headers }), 404, "not-found"));
303
+ let body;
304
+ if (request.method === "POST") {
305
+ const b = parse(await request.text());
306
+ if (!b.ok)
307
+ return reply(refuse(lower({ method: request.method, headers }), b.status, b.error));
308
+ body = b.body;
309
+ }
310
+ return reply(await handle({ method: request.method, path, headers, body }));
311
+ };
312
+ }
313
+ return {
314
+ handle,
315
+ handlers,
316
+ nodeHandler: (o = {}) => nodeAdapter(o, false),
317
+ expressHandler: (o = {}) => nodeAdapter(o, true),
318
+ nextApiHandler: (o = {}) => nodeAdapter(o, false),
319
+ fetchHandler,
320
+ };
321
+ }
@@ -0,0 +1,13 @@
1
+ import type { StateStore, UserStore } from "./types.js";
2
+ /** 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. */
5
+ export declare function jsonFileUserStore(file: string): UserStore;
6
+ /** Users in memory; gone when the process ends. For tests. */
7
+ export declare function memoryUserStore(): UserStore;
8
+ /** Used nonces, used launch assertions and revoked sessions in one JSON file,
9
+ * written atomically before each answer. One server process. */
10
+ export declare function jsonFileStateStore(file: string, now?: () => number): StateStore;
11
+ /** The same in memory. A restart forgets it, which reopens launch replay for
12
+ * two minutes; for tests. */
13
+ export declare function memoryStateStore(now?: () => number): StateStore;