@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/auth.js CHANGED
@@ -2,7 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { verifyLaunch as connectVerifyLaunch, verifySignIn as connectVerifySignIn } from "@decentnetwork/beagle-connect";
5
- import { isUserid } from "./util.js";
5
+ import { isUserid, msgOf } from "./util.js";
6
6
  /** How long a nonce lives, and how far a launch timestamp may be from now. */
7
7
  export const NONCE_TTL_MS = 120_000;
8
8
  export const LAUNCH_MAX_AGE_MS = 120_000;
@@ -32,6 +32,11 @@ export function normalizeOrigins(list) {
32
32
  }
33
33
  return out;
34
34
  }
35
+ /** Throw now if the configured origins cannot be used. A function is called once. */
36
+ export function checkOrigins(source) {
37
+ if (!(source instanceof Set))
38
+ normalizeOrigins(typeof source === "function" ? source() : source);
39
+ }
35
40
  /** The signing secret: the configured one, else a 32-byte one kept in `file`. */
36
41
  export function loadSecret(configured, file) {
37
42
  if (configured) {
@@ -69,12 +74,61 @@ export function headerOf(req, name) {
69
74
  }
70
75
  const isSigHex = (v) => typeof v === "string" && /^[0-9a-fA-F]{128}$/.test(v);
71
76
  export function createAuth(opts) {
72
- const { origins, state, users, secret } = opts;
77
+ const { state, users, secret } = opts;
73
78
  const now = opts.now ?? Date.now;
79
+ const log = opts.log ?? (() => { });
74
80
  const cfg = opts.session ?? {};
75
81
  const ttlMs = (cfg.ttlDays ?? 30) * DAY_MS;
76
82
  const cookieName = cfg.cookieName ?? "beagle_session";
77
83
  const sameSite = cfg.sameSite ?? "Lax";
84
+ if (opts.serverId !== undefined && (typeof opts.serverId !== "string" || !opts.serverId || opts.serverId.length > 64)) {
85
+ throw new TypeError("beagle-service: `serverId` must be a string of 1 to 64 characters");
86
+ }
87
+ // Only the nonce MAC carries the server's name: a session is signed the
88
+ // same way on every server that has the secret.
89
+ const noncePurpose = opts.serverId ? `nonce@${opts.serverId}` : "nonce";
90
+ // ── which origins may ask ─────────────────────────────────────────────────
91
+ // A fixed set, or a function asked on each request. The function's answer
92
+ // is parsed again only when it differs from the last one; an answer that
93
+ // does not parse is logged and the last good list stays in force.
94
+ let fixed = new Set();
95
+ let source = null;
96
+ let sourceKey = null;
97
+ let complainedAt = 0;
98
+ function setOrigins(next) {
99
+ if (typeof next === "function") {
100
+ const list = next();
101
+ const set = normalizeOrigins(list); // throws: the caller learns it now
102
+ fixed = set;
103
+ source = next;
104
+ sourceKey = JSON.stringify(list);
105
+ }
106
+ else {
107
+ fixed = next instanceof Set ? new Set(next) : normalizeOrigins(next);
108
+ source = null;
109
+ sourceKey = null;
110
+ }
111
+ }
112
+ setOrigins(opts.origins);
113
+ function current() {
114
+ if (!source)
115
+ return fixed;
116
+ try {
117
+ const list = source();
118
+ const key = JSON.stringify(list);
119
+ if (key !== sourceKey) {
120
+ fixed = normalizeOrigins(list);
121
+ sourceKey = key;
122
+ }
123
+ }
124
+ catch (e) {
125
+ if (Date.now() - complainedAt > 60_000) {
126
+ complainedAt = Date.now();
127
+ log(`beagle-service: the origins function gave a list that cannot be used (${msgOf(e)}); keeping ${[...fixed].join(", ")}`);
128
+ }
129
+ }
130
+ return fixed;
131
+ }
78
132
  const mac = (purpose, body) => createHmac("sha256", secret).update(`${purpose}\n${body}`).digest("base64url");
79
133
  const macOk = (purpose, body, got) => {
80
134
  const want = Buffer.from(mac(purpose, body));
@@ -88,23 +142,25 @@ export function createAuth(opts) {
88
142
  // across a restart.
89
143
  function issueNonce() {
90
144
  const body = `${randomBytes(18).toString("base64url")}.${(now() + NONCE_TTL_MS).toString(36)}`;
91
- return `${body}.${mac("nonce", body).slice(0, 22)}`;
145
+ return `${body}.${mac(noncePurpose, body).slice(0, 22)}`;
92
146
  }
93
- async function consumeNonce(nonce) {
147
+ /** The nonce's id and expiry when it is ours (this server's, with
148
+ * `serverId`) and has not expired. Reads nothing, writes nothing. */
149
+ function readNonce(nonce) {
94
150
  if (typeof nonce !== "string" || nonce.length > 128)
95
- return false;
151
+ return null;
96
152
  const parts = nonce.split(".");
97
153
  if (parts.length !== 3)
98
- return false;
154
+ return null;
99
155
  const body = `${parts[0]}.${parts[1]}`;
100
- const want = Buffer.from(mac("nonce", body).slice(0, 22));
156
+ const want = Buffer.from(mac(noncePurpose, body).slice(0, 22));
101
157
  const have = Buffer.from(parts[2]);
102
158
  if (want.length !== have.length || !timingSafeEqual(want, have))
103
- return false;
159
+ return null;
104
160
  const exp = parseInt(parts[1], 36);
105
161
  if (!Number.isFinite(exp) || exp < now())
106
- return false;
107
- return await state.addOnce("nonce", parts[0], exp);
162
+ return null;
163
+ return { id: parts[0], exp };
108
164
  }
109
165
  // ── the two assertions ────────────────────────────────────────────────────
110
166
  const fail = (status, error) => ({ ok: false, status, error });
@@ -122,25 +178,31 @@ export function createAuth(opts) {
122
178
  }
123
179
  function allowedOrigin(req) {
124
180
  const origin = headerOf(req, "origin");
125
- return origin && origins.has(origin) ? origin : null;
181
+ return origin && current().has(origin) ? origin : null;
126
182
  }
127
183
  async function verifySignIn(origin, body) {
128
- if (!origin || !origins.has(origin))
184
+ if (!origin || !current().has(origin))
129
185
  return fail(403, "origin-not-allowed");
130
186
  if (!body || typeof body !== "object")
131
187
  return fail(400, "bad-request");
132
188
  const b = body;
133
- // First, and whatever the signature turns out to be: a nonce that has been
134
- // presented once is spent.
135
- if (!(await consumeNonce(b.nonce)))
189
+ const nonce = readNonce(b.nonce);
190
+ if (!nonce || (await state.has("nonce", nonce.id)))
136
191
  return fail(401, "bad-nonce");
137
192
  if (!isUserid(b.userid) || !isSigHex(b.sig) || !connectVerifySignIn(b.userid, origin, b.nonce, b.sig)) {
138
193
  return fail(401, "bad-signature");
139
194
  }
195
+ // Spent here, by the sign-in that verified, and only here: a request with
196
+ // no valid signature costs no write and adds nothing to the used list, so
197
+ // nobody without a key can grow it, or burn a nonce a visitor still holds.
198
+ // `addOnce` is the single-use guarantee: of two requests with the same
199
+ // valid assertion, one gets true.
200
+ if (!(await state.addOnce("nonce", nonce.id, nonce.exp)))
201
+ return fail(401, "bad-nonce");
140
202
  return { ok: true, userid: b.userid, hints: hintsOf(b), door: doorOf(b.uiOrigin) };
141
203
  }
142
204
  async function verifyLaunch(origin, body) {
143
- if (!origin || !origins.has(origin))
205
+ if (!origin || !current().has(origin))
144
206
  return fail(403, "origin-not-allowed");
145
207
  if (!body || typeof body !== "object")
146
208
  return fail(400, "bad-request");
@@ -202,29 +264,48 @@ export function createAuth(opts) {
202
264
  }
203
265
  return null;
204
266
  }
267
+ const SAFE_METHOD = /^(GET|HEAD|OPTIONS)$/i;
205
268
  async function authenticate(req, o = {}) {
206
269
  const none = { ok: false, status: 401, error: "not-signed-in" };
207
- const found = tokenFrom(req);
208
- if (!found)
209
- return none;
210
- const t = readToken(found.token);
211
- if (!t)
212
- return none;
213
- if (await state.has("revoked", t.sid))
214
- return none;
215
- const user = o.touch === false ? await users.get(t.userid) : await users.touch(t.userid);
216
- if (!user)
217
- return none;
218
- if (user.status === "blocked")
219
- return { ok: false, status: 403, error: "blocked" };
220
- if (user.sessionsValidFrom && t.issuedAt < user.sessionsValidFrom)
221
- return none;
222
- const session = { ...t, via: found.via };
223
- const registration = users.registration(user);
224
- if (registration.refused && !o.allowUnregistered) {
225
- return { ok: false, status: 403, error: "register-required", user, session, registration };
270
+ try {
271
+ const found = tokenFrom(req);
272
+ if (!found)
273
+ return none;
274
+ const t = readToken(found.token);
275
+ if (!t)
276
+ return none;
277
+ if (await state.has("revoked", t.sid))
278
+ return none;
279
+ // A cookie is sent by the browser whoever wrote the page; the Origin
280
+ // header says who did. A bearer token is put there by the caller.
281
+ if (o.checkOrigin && found.via === "cookie") {
282
+ const method = req?.method;
283
+ if (!(typeof method === "string" && SAFE_METHOD.test(method)) && !allowedOrigin(req)) {
284
+ return { ok: false, status: 403, error: "origin-not-allowed" };
285
+ }
286
+ }
287
+ const touch = o.touch ?? cfg.touch ?? true;
288
+ const user = touch ? await users.touch(t.userid) : await users.get(t.userid);
289
+ if (!user)
290
+ return none;
291
+ if (user.status === "blocked")
292
+ return { ok: false, status: 403, error: "blocked" };
293
+ if (user.sessionsValidFrom && t.issuedAt < user.sessionsValidFrom)
294
+ return none;
295
+ const session = { ...t, via: found.via };
296
+ const registration = users.registration(user);
297
+ if (registration.refused && !o.allowUnregistered) {
298
+ return { ok: false, status: 403, error: "register-required", user, session, registration };
299
+ }
300
+ return { ok: true, user, session, registration };
301
+ }
302
+ catch (e) {
303
+ // A store that cannot be read is not "nobody is signed in" and it is
304
+ // not an exception either: a caller that wraps this in try/catch and
305
+ // goes on would be deciding without knowing who asks.
306
+ log(`beagle-service: authenticate could not read the session: ${e?.stack ?? msgOf(e)}`);
307
+ return { ok: false, status: 500, error: "server-error" };
226
308
  }
227
- return { ok: true, user, session, registration };
228
309
  }
229
310
  async function revoke(token) {
230
311
  const t = readToken(token);
@@ -245,6 +326,8 @@ export function createAuth(opts) {
245
326
  return {
246
327
  issueNonce,
247
328
  allowedOrigin,
329
+ origins: () => [...current()],
330
+ setOrigins,
248
331
  verifySignIn,
249
332
  verifyLaunch,
250
333
  authenticate,
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // beagle-service: the operator's command.
3
+ //
4
+ // npx beagle-service users list [--status visitor|registered|blocked] [--json]
5
+ // npx beagle-service users show <userid>
6
+ // npx beagle-service users block <userid> [why…]
7
+ // npx beagle-service users unblock <userid>
8
+ //
9
+ // It changes the same records the running site reads, through the library's
10
+ // own `users`, never by editing a file by hand. With the default file store a
11
+ // block takes effect at once, on sign-in and on every session that userid
12
+ // holds: the site reads the file again when it was replaced. No restart.
13
+ //
14
+ // Where the users are:
15
+ // --data <dir> the site's dataDir, for the default store (default ./data)
16
+ // --config <file> a module that exports the site's own `store` (and its
17
+ // `register` / `visitor` settings), or a ready `users`;
18
+ // default: beagle-service.config.{mjs,cjs,js} in this directory
19
+ import { existsSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
+ import { pathToFileURL } from "node:url";
22
+ import { jsonFileUserStore } from "./stores.js";
23
+ import { createUsers } from "./users.js";
24
+ const USAGE = `usage: beagle-service users list [--status visitor|registered|blocked] [--json]
25
+ beagle-service users show <userid>
26
+ beagle-service users block <userid> [why…]
27
+ beagle-service users unblock <userid>
28
+
29
+ options: --data <dir> the site's dataDir, for the default file store (default ./data)
30
+ --config <file> a module exporting { store, register?, visitor? } or { users }
31
+ (default: beagle-service.config.mjs / .cjs / .js here, if there is one)`;
32
+ class Usage extends Error {
33
+ }
34
+ function parse(argv) {
35
+ const words = [];
36
+ const flags = new Map();
37
+ const takesValue = new Set(["data", "config", "status"]);
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (!a.startsWith("--")) {
41
+ words.push(a);
42
+ continue;
43
+ }
44
+ const eq = a.indexOf("=");
45
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
46
+ if (eq > 0)
47
+ flags.set(name, a.slice(eq + 1));
48
+ else if (takesValue.has(name)) {
49
+ const v = argv[++i];
50
+ if (v === undefined)
51
+ throw new Usage(`--${name} needs a value`);
52
+ flags.set(name, v);
53
+ }
54
+ else
55
+ flags.set(name, true);
56
+ }
57
+ return { words, flags };
58
+ }
59
+ async function usersFrom(flags) {
60
+ const named = flags.get("config");
61
+ let file = typeof named === "string" ? resolve(named) : null;
62
+ if (file && !existsSync(file))
63
+ throw new Error(`no such config file: ${file}`);
64
+ if (!file) {
65
+ for (const name of ["beagle-service.config.mjs", "beagle-service.config.cjs", "beagle-service.config.js"]) {
66
+ if (existsSync(resolve(name))) {
67
+ file = resolve(name);
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ let config = {};
73
+ if (file) {
74
+ const mod = await import(pathToFileURL(file).href);
75
+ config = mod.default ?? mod;
76
+ if (typeof config === "function")
77
+ config = await config();
78
+ if (!config || typeof config !== "object")
79
+ throw new Error(`${file} must export an object, or a function that returns one`);
80
+ if (config.users && typeof config.users.block === "function")
81
+ return config.users;
82
+ }
83
+ const data = flags.get("data");
84
+ const dataDir = typeof data === "string" ? data : (config.dataDir ?? "./data");
85
+ if (!config.store && !existsSync(join(dataDir, "users.json"))) {
86
+ throw new Error(`no users file at ${join(dataDir, "users.json")}; name the site's data directory with --data, or its own store with --config`);
87
+ }
88
+ return createUsers({
89
+ store: config.store ?? jsonFileUserStore(join(dataDir, "users.json")),
90
+ register: config.register,
91
+ visitor: config.visitor,
92
+ log: (m) => console.error(m),
93
+ });
94
+ }
95
+ const line = (u) => [
96
+ u.userid,
97
+ u.status.padEnd(10),
98
+ String(u.signIns || 0).padStart(5),
99
+ (u.lastSeen || "-").padEnd(24),
100
+ u.name || "",
101
+ u.blocked ? `[blocked ${u.blocked.at}: ${u.blocked.why}]` : "",
102
+ ]
103
+ .join(" ")
104
+ .trimEnd();
105
+ async function main(argv) {
106
+ const { words, flags } = parse(argv);
107
+ if (flags.has("help") || words.length === 0) {
108
+ console.log(USAGE);
109
+ return words.length === 0 && !flags.has("help") ? 2 : 0;
110
+ }
111
+ const [group, cmd, userid, ...rest] = words;
112
+ if (group !== "users" || !cmd)
113
+ throw new Usage(`unknown command: ${words.join(" ")}`);
114
+ if (cmd === "list") {
115
+ const status = flags.get("status");
116
+ if (status !== undefined && status !== "visitor" && status !== "registered" && status !== "blocked") {
117
+ throw new Usage("--status is visitor, registered or blocked");
118
+ }
119
+ const all = await (await usersFrom(flags)).list(status ? { status: status } : undefined);
120
+ if (flags.has("json"))
121
+ console.log(JSON.stringify(all, null, 2));
122
+ else {
123
+ for (const u of all)
124
+ console.log(line(u));
125
+ console.log(`${all.length} user(s)`);
126
+ }
127
+ return 0;
128
+ }
129
+ if (!userid || !["show", "block", "unblock"].includes(cmd))
130
+ throw new Usage(cmd === "show" || cmd === "block" || cmd === "unblock" ? `${cmd} needs a userid` : `unknown command: users ${cmd}`);
131
+ const users = await usersFrom(flags);
132
+ if (cmd === "show") {
133
+ const u = await users.get(userid);
134
+ if (!u) {
135
+ console.error("no such user");
136
+ return 1;
137
+ }
138
+ console.log(JSON.stringify(u, null, 2));
139
+ return 0;
140
+ }
141
+ if (cmd === "block") {
142
+ console.log(line(await users.block(userid, rest.join(" "))));
143
+ return 0;
144
+ }
145
+ const u = await users.unblock(userid);
146
+ if (!u) {
147
+ console.error("no such user");
148
+ return 1;
149
+ }
150
+ console.log(line(u));
151
+ return 0;
152
+ }
153
+ // Exit by hand once the output is flushed: a site's own store may hold a
154
+ // connection open that would keep the process alive.
155
+ const exit = (code) => process.stdout.write("", () => process.stderr.write("", () => process.exit(code)));
156
+ main(process.argv.slice(2)).then(exit, (e) => {
157
+ if (e instanceof Usage) {
158
+ console.error(`${e.message}\n\n${USAGE}`);
159
+ return exit(2);
160
+ }
161
+ console.error(String(e?.message ?? e));
162
+ exit(1);
163
+ });
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.load = load;
4
+ exports.createBeagleService = createBeagleService;
5
+ exports.sharedBeagleService = sharedBeagleService;
6
+ let loading;
7
+ /** The whole library, as `import("@decentnetwork/beagle-service")` gives it:
8
+ * `const { createUsers, jsonFileStateStore } = await load();` */
9
+ function load() {
10
+ return (loading ??= import("./index.js"));
11
+ }
12
+ /** As the ES module's `createBeagleService`. */
13
+ function createBeagleService(config) {
14
+ return load().then((lib) => lib.createBeagleService(config));
15
+ }
16
+ const UNAVAILABLE = { ok: false, error: "server-error", message: "Sign-in is not available on this server." };
17
+ /** As the ES module's `sharedBeagleService`: returns at once, never throws,
18
+ * and is the same service for every caller in the process. */
19
+ function sharedBeagleService(config, opts) {
20
+ const real = () => load().then((lib) => lib.sharedBeagleService(config, opts));
21
+ const down = (res) => {
22
+ if (res.headersSent)
23
+ return;
24
+ res.statusCode = 500;
25
+ res.setHeader("content-type", "application/json; charset=utf-8");
26
+ res.end(JSON.stringify(UNAVAILABLE));
27
+ };
28
+ const node = (pick) => async (req, res, next) => {
29
+ let svc;
30
+ try {
31
+ svc = await real();
32
+ }
33
+ catch {
34
+ return down(res);
35
+ }
36
+ return pick(svc)(req, res, next);
37
+ };
38
+ return {
39
+ ready: () => real().then((svc) => svc.ready()),
40
+ authenticate: (req, o) => real().then((svc) => svc.authenticate(req, o), () => ({ ok: false, status: 500, error: "server-error" })),
41
+ handle: (req) => real().then((svc) => svc.handle(req), () => ({ status: 500, headers: { "content-type": "application/json; charset=utf-8" }, body: UNAVAILABLE })),
42
+ route: (name) => node((svc) => svc.route(name)),
43
+ nodeHandler: (o) => node((svc) => svc.nodeHandler(o)),
44
+ expressHandler: (o) => node((svc) => svc.expressHandler(o)),
45
+ nextApiHandler: (o) => node((svc) => svc.nextApiHandler(o)),
46
+ fetchHandler: (o) => (request) => real().then((svc) => svc.fetchHandler(o)(request)),
47
+ close: () => real().then((svc) => svc.close()),
48
+ };
49
+ }
@@ -0,0 +1,11 @@
1
+ import type * as Library from "./index.js" with { "resolution-mode": "import" };
2
+ type Lib = typeof Library;
3
+ /** The whole library, as `import("@decentnetwork/beagle-service")` gives it:
4
+ * `const { createUsers, jsonFileStateStore } = await load();` */
5
+ export declare function load(): Promise<Lib>;
6
+ /** As the ES module's `createBeagleService`. */
7
+ export declare function createBeagleService(config: Library.BeagleServiceConfig): Promise<Library.BeagleService>;
8
+ /** As the ES module's `sharedBeagleService`: returns at once, never throws,
9
+ * and is the same service for every caller in the process. */
10
+ export declare function sharedBeagleService(config: Library.BeagleServiceConfig | (() => Library.BeagleServiceConfig), opts?: Library.SharedOptions): Library.SharedBeagleService;
11
+ export type * from "./types.js" with { "resolution-mode": "import" };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,23 @@
1
- import type { BeagleService, BeagleServiceConfig } from "./types.js";
1
+ import type { BeagleService, BeagleServiceConfig, SharedBeagleService, SharedOptions } from "./types.js";
2
2
  export declare function createBeagleService(config: BeagleServiceConfig): Promise<BeagleService>;
3
+ /** The service for this process: created on first use, and the same one for
4
+ * every caller with the same `key`, however many times the calling module is
5
+ * evaluated (Next.js bundles pages and API routes apart) and whichever of
6
+ * `import` and `require` loaded this package.
7
+ *
8
+ * Returns at once and never throws. A configuration that is wrong shows in
9
+ * `ready()`, which rejects; the routes then answer 500 and `authenticate`
10
+ * answers `ok: false`. A failed start is not kept: it is tried again, at most
11
+ * every `retryMs`.
12
+ *
13
+ * `config` may be a function, so that reading the environment happens at
14
+ * first use and not when the module is loaded. The first call for a key
15
+ * decides the configuration. */
16
+ export declare function sharedBeagleService(config: BeagleServiceConfig | (() => BeagleServiceConfig), opts?: SharedOptions): SharedBeagleService;
3
17
  export { createAuth, normalizeOrigins, NONCE_TTL_MS, LAUNCH_MAX_AGE_MS } from "./auth.js";
4
18
  export { createUsers, DEFAULT_PROFILE_GATEWAY } from "./users.js";
5
19
  export { createAgent, loadServers, DEFAULT_SERVERS_URL } from "./agent.js";
6
20
  export { Outbox, splitText } from "./outbox.js";
7
- export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore } from "./stores.js";
21
+ export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore, applyUserChange } from "./stores.js";
8
22
  export { defaultPublicUser } from "./routes.js";
9
23
  export * from "./types.js";
package/dist/index.js CHANGED
@@ -12,17 +12,20 @@
12
12
  // Nothing here makes one site special. Every key is an equal identity; a site
13
13
  // that uses this package is one Carrier service provider among any number.
14
14
  import { join } from "node:path";
15
- import { createAuth, loadSecret, normalizeOrigins } from "./auth.js";
15
+ import { checkOrigins, createAuth, loadSecret } from "./auth.js";
16
+ import { msgOf } from "./util.js";
16
17
  import { createRoutes } from "./routes.js";
17
18
  import { jsonFileStateStore, jsonFileUserStore } from "./stores.js";
18
19
  import { createUsers } from "./users.js";
19
20
  export async function createBeagleService(config) {
20
21
  if (!config || typeof config !== "object")
21
22
  throw new TypeError("beagle-service: createBeagleService needs a config object");
22
- const origins = normalizeOrigins(config.origins);
23
23
  const dataDir = config.dataDir ?? "./data";
24
24
  const now = config.now ?? Date.now;
25
25
  const log = config.log ?? ((m) => console.warn(m));
26
+ // A malformed origin is found here, when the service is created, before a
27
+ // file is made or a request is answered.
28
+ checkOrigins(config.origins);
26
29
  const store = config.store ?? jsonFileUserStore(join(dataDir, "users.json"));
27
30
  const state = config.state ?? jsonFileStateStore(join(dataDir, "state.json"), now);
28
31
  const secret = loadSecret(config.sessionSecret, join(dataDir, "session-secret"));
@@ -35,9 +38,19 @@ export async function createBeagleService(config) {
35
38
  log,
36
39
  fetch: config.fetch,
37
40
  });
38
- const auth = createAuth({ origins, state, users, secret, session: config.session, now });
41
+ const auth = createAuth({ origins: config.origins, state, users, secret, serverId: config.serverId, session: config.session, now, log });
39
42
  let agent;
40
- const routes = createRoutes({ auth, users, session: config.session, publicUser: config.publicUser, agent: () => agent, log });
43
+ const routes = createRoutes({
44
+ auth,
45
+ users,
46
+ session: config.session,
47
+ publicUser: config.publicUser,
48
+ onSignIn: config.onSignIn,
49
+ rateLimit: config.rateLimit,
50
+ agent: () => agent,
51
+ log,
52
+ now,
53
+ });
41
54
  if (config.agent) {
42
55
  // Only when asked for: a site that needs sign-in alone starts no peer.
43
56
  const { createAgent } = await import("./agent.js");
@@ -53,16 +66,125 @@ export async function createBeagleService(config) {
53
66
  expressHandler: routes.expressHandler,
54
67
  nextApiHandler: routes.nextApiHandler,
55
68
  fetchHandler: routes.fetchHandler,
69
+ route: routes.route,
56
70
  close: async () => {
57
71
  await agent?.stop();
58
72
  },
59
73
  };
60
74
  }
75
+ // ── one service per process ─────────────────────────────────────────────────
76
+ const SHARED = Symbol.for("@decentnetwork/beagle-service/shared");
77
+ const UNAVAILABLE = {
78
+ status: 500,
79
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
80
+ body: { ok: false, error: "server-error", message: "Sign-in is not available on this server." },
81
+ };
82
+ /** The service for this process: created on first use, and the same one for
83
+ * every caller with the same `key`, however many times the calling module is
84
+ * evaluated (Next.js bundles pages and API routes apart) and whichever of
85
+ * `import` and `require` loaded this package.
86
+ *
87
+ * Returns at once and never throws. A configuration that is wrong shows in
88
+ * `ready()`, which rejects; the routes then answer 500 and `authenticate`
89
+ * answers `ok: false`. A failed start is not kept: it is tried again, at most
90
+ * every `retryMs`.
91
+ *
92
+ * `config` may be a function, so that reading the environment happens at
93
+ * first use and not when the module is loaded. The first call for a key
94
+ * decides the configuration. */
95
+ export function sharedBeagleService(config, opts = {}) {
96
+ const registry = (globalThis[SHARED] ??= new Map());
97
+ const key = opts.key ?? "default";
98
+ const held = registry.get(key);
99
+ if (held)
100
+ return held;
101
+ const retryMs = opts.retryMs ?? 5000;
102
+ let starting = null;
103
+ let failure = null;
104
+ let complainedAt = 0;
105
+ function ready() {
106
+ if (starting)
107
+ return starting;
108
+ if (failure && Date.now() - failure.at < retryMs)
109
+ return Promise.reject(failure.error);
110
+ const attempt = Promise.resolve().then(() => createBeagleService(typeof config === "function" ? config() : config));
111
+ starting = attempt;
112
+ attempt.then(() => (failure = null), (error) => {
113
+ // Not kept: the next call after `retryMs` starts again.
114
+ if (starting === attempt)
115
+ starting = null;
116
+ failure = { error, at: Date.now() };
117
+ if (Date.now() - complainedAt > 60_000) {
118
+ complainedAt = Date.now();
119
+ console.error(`beagle-service: the service could not start: ${msgOf(error)}`);
120
+ }
121
+ });
122
+ return attempt;
123
+ }
124
+ const down = (res) => {
125
+ if (res.headersSent)
126
+ return;
127
+ res.statusCode = UNAVAILABLE.status;
128
+ for (const [k, v] of Object.entries(UNAVAILABLE.headers))
129
+ res.setHeader(k, v);
130
+ res.end(JSON.stringify(UNAVAILABLE.body));
131
+ };
132
+ const node = (pick) => async (req, res, next) => {
133
+ let svc;
134
+ try {
135
+ svc = await ready();
136
+ }
137
+ catch {
138
+ return down(res);
139
+ }
140
+ return pick(svc)(req, res, next);
141
+ };
142
+ const shared = {
143
+ ready,
144
+ async authenticate(req, o) {
145
+ try {
146
+ return await (await ready()).auth.authenticate(req, o);
147
+ }
148
+ catch {
149
+ return { ok: false, status: 500, error: "server-error" };
150
+ }
151
+ },
152
+ async handle(req) {
153
+ try {
154
+ return await (await ready()).handle(req);
155
+ }
156
+ catch {
157
+ return UNAVAILABLE;
158
+ }
159
+ },
160
+ route: (name) => node((svc) => svc.route(name)),
161
+ nodeHandler: (o) => node((svc) => svc.nodeHandler(o)),
162
+ expressHandler: (o) => node((svc) => svc.expressHandler(o)),
163
+ nextApiHandler: (o) => node((svc) => svc.nextApiHandler(o)),
164
+ fetchHandler: (o) => async (request) => {
165
+ try {
166
+ return await (await ready()).fetchHandler(o)(request);
167
+ }
168
+ catch {
169
+ return new Response(JSON.stringify(UNAVAILABLE.body), { status: UNAVAILABLE.status, headers: UNAVAILABLE.headers });
170
+ }
171
+ },
172
+ async close() {
173
+ if (registry.get(key) === shared)
174
+ registry.delete(key);
175
+ const svc = await starting?.catch(() => null);
176
+ starting = null;
177
+ await svc?.close();
178
+ },
179
+ };
180
+ registry.set(key, shared);
181
+ return shared;
182
+ }
61
183
  export { createAuth, normalizeOrigins, NONCE_TTL_MS, LAUNCH_MAX_AGE_MS } from "./auth.js";
62
184
  export { createUsers, DEFAULT_PROFILE_GATEWAY } from "./users.js";
63
185
  export { createAgent, loadServers, DEFAULT_SERVERS_URL } from "./agent.js";
64
186
  export { Outbox, splitText } from "./outbox.js";
65
- export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore } from "./stores.js";
187
+ export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore, applyUserChange } from "./stores.js";
66
188
  export { defaultPublicUser } from "./routes.js";
67
189
  // `export *` rather than `export type *`: types.ts holds types only, and this
68
190
  // form is read by TypeScript versions before 5.0 too.