@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/README.md CHANGED
@@ -15,9 +15,11 @@ Three parts. Use the first alone, or all three:
15
15
  | [`agent`](#3-agent-the-sites-own-carrier-identity) | The site's own Carrier identity, in your process: who may become a friend, requests kept on disk, friend requests from your side, a persistent outbox, incoming messages with the sender's record. |
16
16
 
17
17
  ```bash
18
- npm install @decentnetwork/beagle-service # Node 20+, ESM, MIT, types included
18
+ npm install @decentnetwork/beagle-service # Node 20+, import or require, MIT, types included
19
19
  ```
20
20
 
21
+ Coming from 0.1.0? See [Upgrading from 0.1.0](#upgrading-from-010): no code has to change.
22
+
21
23
  The browser side is [`@decentnetwork/beagle-connect`](https://www.npmjs.com/package/@decentnetwork/beagle-connect)
22
24
  (`signIn`, `readLaunch`, `addFriend`). This package is the server those calls talk to.
23
25
 
@@ -46,6 +48,18 @@ await svc.users.block(userid, "spam");
46
48
  ```
47
49
 
48
50
  A complete site in one file, with its page: [`examples/minimal-site`](examples/minimal-site).
51
+ A CommonJS Next.js site that also has NextAuth: [`examples/next-pages-cjs`](examples/next-pages-cjs).
52
+
53
+ ### From CommonJS
54
+
55
+ ```js
56
+ const { createBeagleService, sharedBeagleService, load } = require("@decentnetwork/beagle-service");
57
+ ```
58
+
59
+ `require` gives the three functions a site starts from. The library itself is an ES module (so are the two packages
60
+ it checks signatures with), which is why everything else comes through one more call:
61
+ `const { createUsers, jsonFileStateStore } = await load();`. That is the same module `import` gives; there is one copy
62
+ of the library in the process whichever way it was loaded. TypeScript reads the CommonJS declarations from 5.3 on.
49
63
 
50
64
  ---
51
65
 
@@ -85,9 +99,12 @@ button.onclick = async () => {
85
99
  - **Origin**: the request's `Origin` header (set by the browser, not by page script) against your exact list.
86
100
  No suffix matching, no wildcard, never an origin from the body. `http://localhost:3000` counts only if you list it.
87
101
  A malformed entry in `origins` (a bare hostname, a path, a `*`) throws at start.
88
- - **Nonce**: single use, 120 s. It is spent the first time it is presented, whether or not the signature verifies.
89
- A nonce carries its expiry and the server's MAC, so issuing one stores nothing; using one records it in the state
90
- store, so it stays spent across a restart.
102
+ - **Nonce**: single use, 120 s. A nonce carries its expiry and the server's MAC, so issuing one stores nothing. It is
103
+ spent by the sign-in that verifies, and recorded in the state store then, so it stays spent across a restart. A
104
+ request whose signature does not verify writes nothing: nobody without a key can grow the used list, or use up a
105
+ nonce a visitor still holds. Of two requests with the same valid assertion, one signs in.
106
+ - **Rate limit** on `sign-in` and `launch`, on by default: 30 attempts a minute from one address and 600 a minute
107
+ from everybody, then 429 `rate-limited` with `Retry-After`. See [`rateLimit`](#rate-limit).
91
108
  - **`decent-auth` and `decent-launch`** are verified with `beagle-connect`'s `verifySignIn` / `verifyLaunch`
92
109
  (XEdDSA over the X25519 identity key). The two prefixes differ, so a launch assertion never signs in, nor the reverse.
93
110
  - **Launch**: `ts` within ±120 s, then the signature, then replay, last, so a forged repeat cannot burn an assertion a
@@ -116,7 +133,93 @@ who.user; // the full record
116
133
  who.registration; // { status, missing, due, refused, … }
117
134
  ```
118
135
 
119
- `authenticate(req, { allowUnregistered: true })` lets a visitor past the limit through (the register route uses it).
136
+ **Reading a session is asynchronous**, and `authenticate()` never throws and never rejects. No session, a bad one, a
137
+ blocked user and a store that could not be read all come back as `ok: false` with a status (the last is 500
138
+ `server-error`). Decide on `who.ok` and on nothing else. Code that wraps the call in `try { … } catch { /* treat as
139
+ public */ }`, or that forgets the `await` and tests the promise, would be deciding without knowing who asks, and a
140
+ private page would be served to anybody.
141
+
142
+ Options, all off unless given:
143
+
144
+ | `authenticate(req, { … })` | |
145
+ |---|---|
146
+ | `allowUnregistered: true` | lets a visitor past the limit through (the register route uses it) |
147
+ | `touch: false` | read only. By default the call notes the user's activity (`lastSeen`, `visits`): one write to the users store per user, at most once a minute, however many requests arrive. `session: { touch: false }` turns that off for every call; the visitor limit by `maxVisits` then counts sign-ins only. |
148
+ | `checkOrigin: true` | for your routes that change something: a session that came in a **cookie** is accepted only when the request's `Origin` header is on your list (else 403 `origin-not-allowed`), the same rule the library applies to its own `logout` and `register`. A bearer token needs no `Origin`. When `req.method` is GET, HEAD or OPTIONS nothing is checked, so the option can be passed on every call. |
149
+
150
+ ### Your part of a sign-in
151
+
152
+ ```js
153
+ onSignIn: async (user, { origin, door, route, first, request }) => {
154
+ await permits.refresh(user.userid); // awaited before the answer goes out
155
+ if (await banned(user.userid)) return { refuse: "no-permit", status: 403, message: "A permit is needed." };
156
+ return { role: await roleOf(user.userid) }; // added to the sign-in answer
157
+ },
158
+ ```
159
+
160
+ `onSignIn` runs after the assertion is verified and the user record is written, and before the session is issued.
161
+ It gets the user, the `origin` that was signed for, the `door` (the Beagle that answered, or `"launch"`), the `route`
162
+ (`"sign-in"` or `"launch"`), whether this is the userid's `first` sign-in, and the `request` (`request.raw` is the
163
+ framework's own request object). An object it returns is merged into the answer; the library's own fields (`ok`,
164
+ `user`, `registration`, `expiresAt`, `token`, `agent`) are not replaced. `{ refuse }` refuses: no session is issued
165
+ and the page gets your `error` code. If the hook throws, the sign-in answers 500 and no session is issued. A refused
166
+ sign-in still leaves the user record, with the sign-in counted.
167
+
168
+ ### Origins that change while the server runs
169
+
170
+ ```js
171
+ origins: () => ["https://your.app", `http://${currentLanAddress()}:3000`], // asked on each request; keep it cheap
172
+ svc.auth.setOrigins(["https://your.app", "https://new.your.app"]); // or set it from outside
173
+ svc.auth.origins(); // the list in force
174
+ ```
175
+
176
+ Used nonces, used launch assertions and sessions are not touched by a change. A malformed entry throws when the
177
+ service is created and from `setOrigins()`; if the function returns one later, it is logged and the last good list
178
+ stays in force.
179
+
180
+ ### Rate limit
181
+
182
+ ```js
183
+ rateLimit: { perClient: 30, total: 600, windowSeconds: 60 } // the defaults
184
+ rateLimit: { trustProxy: true } // one proxy in front: take the address from X-Forwarded-For
185
+ rateLimit: { key: (request) => request.headers["cf-connecting-ip"] }
186
+ rateLimit: false
187
+ ```
188
+
189
+ Counted in memory, per server process, for `sign-in` and `launch` together. The client is the connection's address
190
+ (`req.ip` under Express). A connection from the machine itself, which is what a reverse proxy on the same host looks
191
+ like, is not a client: it counts against `total` only, until you set `trustProxy` or `key`. The same goes for a
192
+ request you pass to `handlers.*` yourself without an `ip`.
193
+
194
+ ### A script that signs in
195
+
196
+ A browser adds the `Origin` header by itself; a script has to send it. The server takes the origin from that header
197
+ only, never from the body. Name the site's own origin in the header and in the string that is signed:
198
+
199
+ ```js
200
+ const { nonce } = await (await fetch(`${site}/api/auth/nonce`)).json();
201
+ const sig = Buffer.from(signDetached(secretKey, new TextEncoder().encode(`decent-auth\n${site}\n${nonce}`))).toString("hex");
202
+ const res = await fetch(`${site}/api/auth/sign-in`, { method: "POST", headers, body: JSON.stringify({ userid, nonce, sig }) });
203
+ ```
204
+
205
+ with `headers = { Origin: site, "content-type": "application/json" }` and `signDetached` from `@decentnetwork/peer`.
206
+ Without `Origin` the answer is 403 `origin-not-allowed`. The whole script:
207
+ [`examples/sign-in-from-script.mjs`](examples/sign-in-from-script.mjs).
208
+
209
+ ### Several servers
210
+
211
+ One `sessionSecret` on all of them makes a session from one valid on the others. What is used once, and what a
212
+ sign-out reaches, depends on the `state` store:
213
+
214
+ | | A nonce is used once | A launch assertion is used once | A sign-out is known |
215
+ |---|---|---|---|
216
+ | a shared `state` store | overall | overall | on every server |
217
+ | separate stores, `serverId` set on each | overall: only the server that issued it accepts it | per server, within its two minutes | on the server where it happened |
218
+ | separate stores, no `serverId` | per server | per server, within its two minutes | on the server where it happened |
219
+
220
+ `serverId: "eu-1"` is for servers that cannot reach a common database. It goes into the nonce's MAC and nowhere
221
+ else, so the page has to post the sign-in to the server it got the nonce from. Blocking is per users store: with
222
+ separate stores, block on each.
120
223
 
121
224
  ### Frameworks
122
225
 
@@ -127,24 +230,88 @@ http.createServer(svc.nodeHandler({ prefix: "/api/auth" }));
127
230
  // Express: other paths go to next()
128
231
  app.use(svc.expressHandler({ prefix: "/api/auth" }));
129
232
 
130
- // Next.js: one service for the whole server, in lib/beagle.js
131
- globalThis.__beagle ??= createBeagleService({ origins: ["https://your.app"], dataDir: "./data" });
132
- export const beagle = globalThis.__beagle;
233
+ // One route as a (req, res) handler, on whatever path you mount it
234
+ app.post("/login/beagle", svc.route("signIn")); // nonce, signIn, launch, me, logout, register
235
+
236
+ // Anything else
237
+ const { status, headers, body } = await svc.handle({ method, path: "/sign-in", headers, body, ip });
238
+ await svc.handlers.signIn({ method, headers, body, ip });
239
+ ```
240
+
241
+ Request bodies are JSON and at most 64 KB. The routes answer; they do not throw at the code that called them.
242
+
243
+ ### Next.js
133
244
 
134
- // Next.js API route: pages/api/auth/[...beagle].js
135
- export default async (req, res) => (await beagle).nextApiHandler({ prefix: "/api/auth" })(req, res);
245
+ One service for the whole server, in `lib/beagle.js`. `sharedBeagleService` returns at once, creates the service on
246
+ first use, and is the same service for every caller in the process: Next.js bundles pages, API routes and
247
+ `instrumentation.js` apart and may evaluate this file more than once.
136
248
 
137
- // Next.js App Router (or any Web Request/Response host): app/api/auth/[...beagle]/route.js
138
- const handler = async (request) => (await beagle).fetchHandler({ prefix: "/api/auth" })(request);
249
+ ```js
250
+ // lib/beagle.js: CommonJS shown; with ES modules, `import { sharedBeagleService } from …` and `export const beagle`
251
+ const { sharedBeagleService } = require("@decentnetwork/beagle-service");
252
+
253
+ const beagle = sharedBeagleService(() => ({
254
+ origins: process.env.BEAGLE_ORIGINS.split(","),
255
+ dataDir: "./data",
256
+ }));
257
+ module.exports = { beagle };
258
+ ```
259
+
260
+ `beagle.route(name)`, `beagle.authenticate(req, opts)`, `beagle.nextApiHandler()`, `beagle.fetchHandler()` and
261
+ `beagle.handle()` work like the service's own and never throw: while the service cannot start (a malformed origin),
262
+ the routes answer 500 `server-error` and `authenticate` answers `ok: false`. A failed start is not kept; it is tried
263
+ again (at most every 5 s, `retryMs`), so a rejected promise does not sit on `globalThis` for the life of the
264
+ process. `await beagle.ready()` gives the service itself (`users`, `auth`, `agent`), and rejects with the reason
265
+ when it cannot be created.
266
+
267
+ **Find a wrong configuration at start.** Nothing in a Next.js site runs before the first request unless you say so:
268
+
269
+ ```js
270
+ // instrumentation.js (Next.js 15; next to pages/)
271
+ export async function register() {
272
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
273
+ await require("./lib/beagle").beagle.ready();
274
+ }
275
+ ```
276
+
277
+ With a bad entry in `origins`, `next start` then prints the reason at once ("Failed to prepare server … is not an
278
+ exact origin") and answers 500 to every request, instead of failing the first sign-in.
279
+
280
+ **A site that already has NextAuth** has `pages/api/auth/[...nextauth].js`, and two catch-all routes in one directory
281
+ do not build. Give each Beagle route its own file beside it; a named file wins over the catch-all, and none of these
282
+ names is one of NextAuth's:
283
+
284
+ ```js
285
+ // pages/api/auth/sign-in.js (and nonce.js, launch.js, me.js, logout.js, register.js; any file names you like)
286
+ const { beagle } = require("../../../lib/beagle");
287
+ export default beagle.route("signIn");
288
+ ```
289
+
290
+ `route(name)` is `handlers[name]` with the request read and the answer written for you. When a route of yours has
291
+ to do more than that, call the handler itself and send what it returns:
292
+ `const { status, headers, body } = await (await beagle.ready()).handlers.signIn({ method: req.method, headers: req.headers, body: req.body, ip })`.
293
+ For the usual reason to do that, adding the site's fields to a sign-in, use [`onSignIn`](#your-part-of-a-sign-in).
294
+
295
+ **A site with no catch-all under `/api/auth`** can use one file instead:
296
+
297
+ ```js
298
+ // pages/api/auth/[...beagle].js
299
+ const { beagle } = require("../../../lib/beagle");
300
+ export default beagle.nextApiHandler({ prefix: "/api/auth" });
301
+
302
+ // App Router (or any Web Request/Response host): app/api/auth/[...beagle]/route.js
303
+ const handler = beagle.fetchHandler({ prefix: "/api/auth" });
139
304
  export { handler as GET, handler as POST, handler as OPTIONS };
305
+ ```
140
306
 
141
- // Anything else
142
- const { status, headers, body } = await svc.handle({ method, path: "/sign-in", headers, body });
143
- await svc.handlers.signIn({ method, headers, body }); // nonce, signIn, launch, me, logout, register
307
+ In your own API routes and in `getServerSideProps`:
308
+
309
+ ```js
310
+ const who = await beagle.authenticate(req, { checkOrigin: true });
311
+ if (!who.ok) return res.status(who.status).json({ error: who.error });
144
312
  ```
145
313
 
146
- In Next.js the service is kept on `globalThis` because the pages and app routes are bundled apart and a module can be
147
- evaluated more than once. Request bodies are JSON and at most 64 KB.
314
+ All of this, built with `next build` and run with `next start`: [`examples/next-pages-cjs`](examples/next-pages-cjs).
148
315
 
149
316
  The pieces are also there on their own: `svc.auth.issueNonce()`, `verifySignIn(origin, body)`,
150
317
  `verifyLaunch(origin, body)`, `mintSession(userid)`, `readToken(token)`, `revoke(token)`, `cookie(token, origin)`.
@@ -159,6 +326,7 @@ One record per userid, created at the first sign-in:
159
326
  {
160
327
  userid, address, name, avatar, punkId, // address/name/avatar/punkId are unsigned hints from the visitor's Beagle
161
328
  door, // which Beagle answered: its origin, or "launch"
329
+ origin, // the origin of your page the last sign-in was signed for
162
330
  status, // "visitor" | "registered" | "blocked"
163
331
  profile: { email: "…" }, // your registration fields
164
332
  firstSeen, lastSeen, visits, signIns, registeredAt,
@@ -229,31 +397,79 @@ again.
229
397
  ### Events
230
398
 
231
399
  ```js
232
- svc.users.on("sign-in", (user, { first, door }) => {});
400
+ svc.users.on("sign-in", (user, { first, door, origin }) => {}); // origin: the one that was signed for
233
401
  svc.users.on("registered", (user) => {}); // also fires at first sign-in when nothing is required
234
402
  svc.users.on("blocked", (user) => {});
235
403
  svc.users.on("unblocked", (user) => {});
236
404
  ```
237
405
 
406
+ Events are not awaited: the answer does not wait for a listener. For something that must happen before the sign-in
407
+ answer goes out, use [`onSignIn`](#your-part-of-a-sign-in).
408
+
409
+ ### The operator's command
410
+
411
+ ```bash
412
+ npx beagle-service users list [--status blocked] [--json]
413
+ npx beagle-service users show <userid>
414
+ npx beagle-service users block <userid> [why…]
415
+ npx beagle-service users unblock <userid>
416
+ ```
417
+
418
+ Run it in the site's directory, beside the running server; `--data <dir>` names the `dataDir` when it is not
419
+ `./data`. With the default file store a block takes effect at once, on sign-in and on every session that userid
420
+ holds, with no restart: the server reads the file again when it was replaced.
421
+
422
+ For a site with its own `store`, put a `beagle-service.config.js` (or `.cjs`, `.mjs`) in that directory, or name one
423
+ with `--config <file>`. It exports what the site passes to the library: `{ store, register, visitor }`, or a
424
+ function that returns it, or `{ users }` for a `users` object the site built itself:
425
+
426
+ ```js
427
+ // beagle-service.config.js
428
+ module.exports = { store: require("./lib/users-store"), register: { required: ["email"] } };
429
+ ```
430
+
238
431
  ### Storage
239
432
 
240
433
  The default is `<dataDir>/users.json`, rewritten atomically (temp file, fsync, rename) on every change and held in
241
- memory: right for one server process and up to some tens of thousands of users. A file that does not parse stops the
242
- start instead of being treated as empty.
434
+ memory: right for one machine and up to some tens of thousands of users. A file that does not parse stops the start
435
+ instead of being treated as empty. The file is read again when another writer replaced it, and a change is made
436
+ under a lock file on the record that is on disk at that moment, field by field. So the operator's command, a script,
437
+ or a second server process on the same directory can work beside the server, and two updates to different fields of
438
+ one record at the same moment both survive.
243
439
 
244
- For Postgres, sqlite or anything else, implement three calls; `put` gets the whole record and must keep all of it:
440
+ For Postgres, sqlite or anything else, implement three calls:
245
441
 
246
442
  ```js
247
443
  const store = {
248
444
  get: async (userid) => row ?? null,
249
- put: async (user) => { /* upsert by user.userid, store the whole object */ },
445
+ put: async (user, change) => { /* see below */ },
250
446
  list: async () => rows,
251
447
  };
252
448
  await createBeagleService({ origins, store });
253
449
  ```
254
450
 
451
+ `put` gets the whole record, and as a second argument what this write changed:
452
+ `{ prev, set: { field: value, … }, unset: ["field", …] }`, top-level fields, with `prev` the record as the library
453
+ read it (`null` for a new one). There are two ways to write a `put`, and both are correct:
454
+
455
+ - **Store the whole record** and ignore `change` (every adapter written for 0.1.0 does). Right when nothing but
456
+ the library writes those records between its read and its write. When `get` and `put` are synchronous, the library
457
+ calls them in the same turn of the event loop with nothing in between, so other code in the same process cannot
458
+ get in between either.
459
+ - **Store what changed**: set the fields in `change.set`, remove those in `change.unset`, insert `user` when there is
460
+ no row. Do this when other code or other processes write the same rows (`UPDATE … SET col = …` for the changed
461
+ columns, or `data = data || $set` for a JSON column). A field somebody else wrote since the library's read is then
462
+ not written back from an old copy. `applyUserChange(row, change)` is that merge for a plain object.
463
+
464
+ **The minimum a store may return** from `get` and `list` is an object with the `userid`: `{ userid }`. A record that
465
+ is older than the library, or that another program wrote, is fine. Fields the library needs and does not find are
466
+ filled in when it reads (`status: "visitor"`, `profile: {}`, `visits: 0`, `signIns: 0`, `null` for the rest), in
467
+ memory only; every other field passes through untouched and is kept on every write.
468
+
255
469
  Used nonces, used launch assertions and revoked sessions live in a second small store (`<dataDir>/state.json` by
256
- default). With more than one server process, put it in the shared database too and set one `sessionSecret`:
470
+ default, shared the same way between processes on one machine). With servers on more than one machine, put it in
471
+ the shared database too and set one `sessionSecret`; when they cannot share one, see
472
+ [Several servers](#several-servers):
257
473
 
258
474
  ```js
259
475
  const state = {
@@ -364,16 +580,24 @@ copy is kept on disk so a restart works while that URL is down. Pass `agent.serv
364
580
 
365
581
  | Key | Default | Meaning |
366
582
  |---|---|---|
367
- | `origins` | required | Exact origins of your pages. |
583
+ | `origins` | required | Exact origins of your pages: a list, or a function that returns the list as it is now. |
368
584
  | `dataDir` | `"./data"` | Where the library keeps its files. |
369
585
  | `store` | `<dataDir>/users.json` | Users backend (`get`, `put`, `list`). |
370
586
  | `state` | `<dataDir>/state.json` | Replay and revocation backend (`addOnce`, `has`). |
371
587
  | `sessionSecret` | generated into `<dataDir>/session-secret` | Signs sessions and nonces. Set it when several processes share users. |
588
+ | `serverId` | none | Names this server among several with one secret and separate `state` stores: a nonce is accepted only where it was issued. |
589
+ | `onSignIn` | none | `(user, { origin, door, route, first, request }) => fields \| { refuse }`, awaited before the sign-in answer. |
590
+ | `rateLimit` | on | `false`, or `{ perClient, total, windowSeconds, trustProxy, key }` for `sign-in` and `launch`. |
591
+ | `rateLimit.perClient`, `total` | `30`, `600` | Attempts per window from one address, and from everybody. |
592
+ | `rateLimit.windowSeconds` | `60` | |
593
+ | `rateLimit.trustProxy` | `false` | `true` (one proxy) or a number: take the address from `X-Forwarded-For`. |
594
+ | `rateLimit.key` | the connection's address | `(request) => string \| null`: name the client yourself. |
372
595
  | `session.ttlDays` | `30` | Session lifetime. |
373
596
  | `session.transport` | `"cookie"` | `"cookie"`, `"bearer"` or `"both"`. |
374
597
  | `session.cookieName` | `"beagle_session"` | |
375
598
  | `session.sameSite` | `"Lax"` | `"Lax"`, `"Strict"` or `"None"`. |
376
599
  | `session.cookieDomain`, `session.cookiePath` | none, `"/"` | |
600
+ | `session.touch` | `true` | `authenticate()` notes activity: at most one users write per user per minute. `false`: it only reads. |
377
601
  | `register.required` | `[]` | Fields a visitor must give to be `registered`. Empty: registered at once. |
378
602
  | `register.optional` | `[]` | Fields the register route also stores. |
379
603
  | `register.verify` | none | `(field, value, user, submitted) => true \| false \| "reason" \| { value }`. |
@@ -406,15 +630,19 @@ copy is kept on disk so a restart works while that URL is down. Pass `agent.serv
406
630
  | `launch-replayed` | 401 | The assertion was accepted before. |
407
631
  | `blocked` | 403 | The userid is on the blacklist. |
408
632
  | `register-required` | 403 | A visitor past the limit under `then: "refuse"`. |
633
+ | `sign-in-refused`, or your own code | 403, or your own | `onSignIn` returned `{ refuse }`. |
634
+ | `rate-limited` | 429 | Too many `sign-in` / `launch` attempts; `Retry-After` says how long. |
409
635
  | `not-signed-in` | 401 | No session, or an expired or revoked one. |
410
636
  | `invalid-fields` | 400 | Registration fields missing or refused; see `fields`. |
411
- | `bad-request`, `too-large`, `not-found`, `method-not-allowed`, `server-error` | 400, 413, 404, 405, 500 | |
637
+ | `bad-request`, `too-large`, `not-found`, `method-not-allowed` | 400, 413, 404, 405 | |
638
+ | `server-error` | 500 | A store or the `onSignIn` hook failed, or (shared service) the service cannot start. Also `authenticate()`'s answer then. |
412
639
 
413
640
  ## Files in `dataDir`
414
641
 
415
642
  ```text
416
643
  users.json the users (default store)
417
644
  state.json used nonces, used launch assertions, revoked sessions
645
+ *.json.lock there for the few milliseconds of a write
418
646
  session-secret the signing secret, mode 0600
419
647
  agent/peer.save the agent's key, plus the peer's own friend store beside it
420
648
  agent/pending.json friend requests held
@@ -445,6 +673,36 @@ The design note this package was built from gave a shape "as a guide". What chan
445
673
  because a replay check needs an atomic insert, not a read and a write.
446
674
  - **A request from an origin that is not listed answers 403**, where the published sign-in guide's sample answers 400.
447
675
 
676
+ ## Upgrading from 0.1.0
677
+
678
+ A site written for 0.1.0 runs on 0.2.0 without a code change, with a `store` and `state` of its own or the default
679
+ ones (run: a 0.1.0-style site in `test/upgrade.test.mjs`). What behaves differently, all of it:
680
+
681
+ - **A sign-in whose signature does not verify no longer spends the nonce** and writes nothing. The nonce is spent
682
+ by the sign-in that verifies. A page that asked for a new nonce after a failure keeps working.
683
+ - **Sign-in and launch are rate-limited by default**: 30 attempts a minute per client address and 600 a minute in
684
+ total, then 429 `rate-limited`. Behind a proxy on another machine every visitor has the proxy's address: set
685
+ `rateLimit: { trustProxy: true }` (or `key`), or `rateLimit: false` for the 0.1.0 behaviour.
686
+ - **`authenticate()` and `handlers.*` no longer reject.** When a store throws they answer status 500
687
+ `server-error` (`ok: false` from `authenticate`). Code that relied on a `catch` now gets an answer to check.
688
+ - **The user record has a new field, `origin`**, written at each sign-in; the `sign-in` event's info has `origin`.
689
+ A store with fixed columns that drops unknown fields loses nothing it had before.
690
+ - **`store.put` is called with a second argument**, and with a store whose calls are synchronous the library's
691
+ `get` and `put` now run back to back. `users.get()` and `users.list()` fill in missing fields of old records
692
+ (in memory), and `list()` leaves out entries that are not objects with a `userid`.
693
+ - **The default file stores are read again when the file changed on disk**, and write under `<file>.lock`. Editing
694
+ `users.json` by hand while the server runs now takes effect (it used to be overwritten by the next write).
695
+ - **`authenticate()` skips its activity write** when `lastSeen` is less than a minute old, whichever process wrote it.
696
+ - **`require("@decentnetwork/beagle-service")` works.** `package.json` `main` now names the CommonJS file;
697
+ `import` resolves as before.
698
+ - **TypeScript**: `Auth` gained `origins()` and `setOrigins()`, `BeagleService` gained `route()`, `ErrorCode` gained
699
+ `"sign-in-refused"` and `"rate-limited"`, `BeagleServiceConfig.origins` also accepts a function. An object you
700
+ typed as one of the first two by hand (a test double) needs the new members.
701
+
702
+ New and off unless you use them: `onSignIn`, `origins` as a function and `auth.setOrigins()`, `serverId`,
703
+ `session.touch`, `authenticate(req, { checkOrigin })`, `svc.route(name)`, `sharedBeagleService()`, the
704
+ `beagle-service users` command.
705
+
448
706
  ## Not in this version
449
707
 
450
708
  The wallet door, gates and permits; sending the confirmation email or SMS; files, calls and groups; quotas and
@@ -454,7 +712,10 @@ browser-side sign-in picker.
454
712
  ## Tests
455
713
 
456
714
  ```bash
457
- npm test # 45 tests: every sign-in case through real HTTP routes, a kill-and-restart replay check, users, agent;
458
- # plus a typecheck of a consumer file against the shipped declarations
459
- npm run e2e # the agent on the public network with throwaway identities (about 8 minutes)
715
+ npm test # every sign-in case through real HTTP routes, a kill-and-restart replay check, users, agent,
716
+ # the gaps the first site found (test/gaps.test.mjs), a 0.1.0-style site (test/upgrade.test.mjs);
717
+ # plus typechecks of an ES module and a CommonJS consumer against the shipped declarations
718
+ npm run test:gaps-on-0.1.0 # the gap tests against the published 0.1.0: every one must fail there
719
+ npm run e2e:next # examples/next-pages-cjs with the packed package: next build, next start (about 2 minutes)
720
+ npm run e2e # the agent on the public network with throwaway identities (about 8 minutes)
460
721
  ```
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Auth, HeadersCarrier, SessionConfig, StateStore, Users } from "./types.js";
1
+ import type { Auth, HeadersCarrier, OriginsSource, SessionConfig, StateStore, Users } from "./types.js";
2
+ import { type Logger } from "./util.js";
2
3
  /** How long a nonce lives, and how far a launch timestamp may be from now. */
3
4
  export declare const NONCE_TTL_MS = 120000;
4
5
  export declare const LAUNCH_MAX_AGE_MS = 120000;
@@ -6,15 +7,22 @@ export declare const LAUNCH_MAX_AGE_MS = 120000;
6
7
  * not `scheme://host[:port]`, so a typo is found at start, not as a sign-in
7
8
  * that never verifies. */
8
9
  export declare function normalizeOrigins(list: unknown): Set<string>;
10
+ /** Throw now if the configured origins cannot be used. A function is called once. */
11
+ export declare function checkOrigins(source: OriginsSource | Set<string>): void;
9
12
  /** The signing secret: the configured one, else a 32-byte one kept in `file`. */
10
13
  export declare function loadSecret(configured: string | undefined, file: string): string;
11
14
  export declare function headerOf(req: HeadersCarrier | undefined, name: string): string | undefined;
12
15
  export interface AuthOptions {
13
- origins: Set<string>;
16
+ /** A list, a function that returns the list as it is now, or the set
17
+ * `normalizeOrigins` made. */
18
+ origins: OriginsSource | Set<string>;
14
19
  state: StateStore;
15
20
  users: Users;
16
21
  secret: string;
22
+ /** Binds nonces to this server; see `BeagleServiceConfig.serverId`. */
23
+ serverId?: string;
17
24
  session?: SessionConfig;
18
25
  now?: () => number;
26
+ log?: Logger;
19
27
  }
20
28
  export declare function createAuth(opts: AuthOptions): Auth;