@decentnetwork/beagle-service 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +720 -2
  3. package/dist/agent.d.ts +18 -0
  4. package/dist/agent.js +448 -0
  5. package/dist/auth.d.ts +28 -0
  6. package/dist/auth.js +340 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +163 -0
  9. package/dist/commonjs.cjs +49 -0
  10. package/dist/commonjs.d.cts +11 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.js +191 -0
  13. package/dist/outbox.d.ts +63 -0
  14. package/dist/outbox.js +314 -0
  15. package/dist/routes.d.ts +29 -0
  16. package/dist/routes.js +454 -0
  17. package/dist/stores.d.ts +21 -0
  18. package/dist/stores.js +92 -0
  19. package/dist/types.d.ts +641 -0
  20. package/dist/types.js +1 -0
  21. package/dist/users.d.ts +16 -0
  22. package/dist/users.js +429 -0
  23. package/dist/util.d.ts +48 -0
  24. package/dist/util.js +208 -0
  25. package/examples/minimal-site/index.html +91 -0
  26. package/examples/minimal-site/server.mjs +64 -0
  27. package/examples/next-pages-cjs/README.md +30 -0
  28. package/examples/next-pages-cjs/instrumentation.js +16 -0
  29. package/examples/next-pages-cjs/lib/beagle.js +23 -0
  30. package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
  31. package/examples/next-pages-cjs/next.config.js +11 -0
  32. package/examples/next-pages-cjs/package.json +19 -0
  33. package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
  34. package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
  35. package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
  36. package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
  37. package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
  38. package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
  39. package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
  40. package/examples/next-pages-cjs/pages/api/notes.js +16 -0
  41. package/examples/next-pages-cjs/pages/index.js +52 -0
  42. package/examples/sign-in-from-script.mjs +37 -0
  43. package/package.json +67 -4
package/README.md CHANGED
@@ -1,3 +1,721 @@
1
- # Temporary Holding Version
1
+ # @decentnetwork/beagle-service
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The web backend for a site whose users sign in with their Beagle identity.
4
+
5
+ A visitor's identity is a key pair made on their own device; the `userid` is the public key. Your site learns who
6
+ they are from a signature, keeps its own record of them, and can talk to them through its own Carrier identity.
7
+ There is no account server and no provider in the middle: any site can do this, and every key is an equal identity.
8
+
9
+ Three parts. Use the first alone, or all three:
10
+
11
+ | Part | What it does |
12
+ |---|---|
13
+ | [`auth`](#1-auth-sign-in-on-the-server) | Sign in with Beagle on the server: nonce, exact origin check, both assertions, a launch replay store that survives a restart, sessions with expiry and logout, routes for `node:http`, Express and Next.js. |
14
+ | [`users`](#2-users-visitor-registered-blocked) | One record per userid: `visitor`, `registered` or `blocked`. Registration fields you choose, a limit for visitors who never register, a blacklist, a JSON-file store and an adapter interface. |
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
+
17
+ ```bash
18
+ npm install @decentnetwork/beagle-service # Node 20+, import or require, MIT, types included
19
+ ```
20
+
21
+ Coming from 0.1.0? See [Upgrading from 0.1.0](#upgrading-from-010): no code has to change.
22
+
23
+ The browser side is [`@decentnetwork/beagle-connect`](https://www.npmjs.com/package/@decentnetwork/beagle-connect)
24
+ (`signIn`, `readLaunch`, `addFriend`). This package is the server those calls talk to.
25
+
26
+ ## Quick start
27
+
28
+ ```js
29
+ import http from "node:http";
30
+ import { createBeagleService } from "@decentnetwork/beagle-service";
31
+
32
+ const svc = await createBeagleService({
33
+ origins: ["https://your.app"], // exact: scheme, host, port
34
+ dataDir: "./data",
35
+ register: { required: ["email"], optional: ["phone", "location"] },
36
+ visitor: { maxDays: 14, then: "refuse" },
37
+ agent: { // omit if the site only needs sign-in
38
+ nickname: "Your App",
39
+ accept: "signed-in",
40
+ befriendOn: "register",
41
+ },
42
+ });
43
+
44
+ http.createServer(svc.nodeHandler({ prefix: "/api/auth" })).listen(3000);
45
+
46
+ svc.agent.onMessage(({ user, reply }) => reply(`hello ${user?.name ?? ""}`));
47
+ await svc.users.block(userid, "spam");
48
+ ```
49
+
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.
63
+
64
+ ---
65
+
66
+ ## 1. `auth`: sign-in on the server
67
+
68
+ ### Routes
69
+
70
+ Mounted under a prefix (default `/api/auth`). All answers are JSON; errors are `{ ok: false, error, message }`.
71
+
72
+ | Route | Body | Answers |
73
+ |---|---|---|
74
+ | `GET /nonce` | | `{ nonce, expiresIn: 120 }` |
75
+ | `POST /sign-in` (also `/beagle`) | what `signIn()` resolved: `{ userid, nonce, sig, address, name, avatar, uiOrigin }` | `{ user, registration, expiresAt, agent? }` and the session |
76
+ | `POST /launch` (also `/beagle-launch`) | what `readLaunch()` returned: `{ userid, ts, sig, address, name, punkId }` | the same |
77
+ | `GET /me` | | `{ user, registration, agent? }`, or 401 |
78
+ | `POST /logout` | | `{ ok: true }`; the token is revoked, not only the cookie cleared |
79
+ | `POST /register` | `{ profile: { email: "…" } }` | `{ user, registration }`, or 400 `invalid-fields` with `fields: { email: "required" }` |
80
+
81
+ The page side, with `beagle-connect`:
82
+
83
+ ```js
84
+ import { signIn, readLaunch } from "@decentnetwork/beagle-connect";
85
+
86
+ const launch = await readLaunch(); // arrived from Beagle's Apps tab?
87
+ if (launch) await post("/api/auth/launch", launch);
88
+
89
+ button.onclick = async () => {
90
+ const win = window.open("about:blank", "beagle-connect", "width=420,height=560"); // inside the click
91
+ const { nonce } = await (await fetch("/api/auth/nonce")).json();
92
+ const who = await signIn({ nonce, existingWindow: win });
93
+ await post("/api/auth/sign-in", who);
94
+ };
95
+ ```
96
+
97
+ ### What is checked
98
+
99
+ - **Origin**: the request's `Origin` header (set by the browser, not by page script) against your exact list.
100
+ No suffix matching, no wildcard, never an origin from the body. `http://localhost:3000` counts only if you list it.
101
+ A malformed entry in `origins` (a bare hostname, a path, a `*`) throws at start.
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).
108
+ - **`decent-auth` and `decent-launch`** are verified with `beagle-connect`'s `verifySignIn` / `verifyLaunch`
109
+ (XEdDSA over the X25519 identity key). The two prefixes differ, so a launch assertion never signs in, nor the reverse.
110
+ - **Launch**: `ts` within ±120 s, then the signature, then replay, last, so a forged repeat cannot burn an assertion a
111
+ real visitor still holds. The replay store is on disk and is written before the answer goes out. It is keyed on what
112
+ was signed (userid, origin, `ts`), not on the signature's bytes: the same assertion with its hex in another case, or
113
+ any second signature over the same message, is a repeat too.
114
+ - **POST from a cookie session** must come from a listed origin (or carry a bearer token).
115
+
116
+ ### Sessions
117
+
118
+ A signed token with the expiry inside it (`ttlDays`, default 30) and a session id. By default it is a cookie:
119
+ `HttpOnly; Secure; SameSite=Lax`. `Secure` is left off only for an `http://` origin you listed yourself (a localhost
120
+ during development). With `session: { transport: "bearer" }` the token is returned in the sign-in answer and sent back
121
+ as `Authorization: Bearer …` (for an API on another origin than the page); `"both"` does both. CORS headers are sent
122
+ for the listed origins only.
123
+
124
+ Logout revokes the session id until the token would have expired. Blocking a user, or `users.revokeSessions(userid)`,
125
+ ends every session of that user.
126
+
127
+ ### In your own routes
128
+
129
+ ```js
130
+ const who = await svc.auth.authenticate(req); // any object with `headers`
131
+ if (!who.ok) return send(res, who.status, { error: who.error }); // 401 not-signed-in, 403 blocked / register-required
132
+ who.user; // the full record
133
+ who.registration; // { status, missing, due, refused, … }
134
+ ```
135
+
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.
223
+
224
+ ### Frameworks
225
+
226
+ ```js
227
+ // node:http
228
+ http.createServer(svc.nodeHandler({ prefix: "/api/auth" }));
229
+
230
+ // Express: other paths go to next()
231
+ app.use(svc.expressHandler({ prefix: "/api/auth" }));
232
+
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
244
+
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.
248
+
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" });
304
+ export { handler as GET, handler as POST, handler as OPTIONS };
305
+ ```
306
+
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 });
312
+ ```
313
+
314
+ All of this, built with `next build` and run with `next start`: [`examples/next-pages-cjs`](examples/next-pages-cjs).
315
+
316
+ The pieces are also there on their own: `svc.auth.issueNonce()`, `verifySignIn(origin, body)`,
317
+ `verifyLaunch(origin, body)`, `mintSession(userid)`, `readToken(token)`, `revoke(token)`, `cookie(token, origin)`.
318
+
319
+ ---
320
+
321
+ ## 2. `users`: visitor, registered, blocked
322
+
323
+ One record per userid, created at the first sign-in:
324
+
325
+ ```js
326
+ {
327
+ userid, address, name, avatar, punkId, // address/name/avatar/punkId are unsigned hints from the visitor's Beagle
328
+ door, // which Beagle answered: its origin, or "launch"
329
+ origin, // the origin of your page the last sign-in was signed for
330
+ status, // "visitor" | "registered" | "blocked"
331
+ profile: { email: "…" }, // your registration fields
332
+ firstSeen, lastSeen, visits, signIns, registeredAt,
333
+ blocked: { at, why, was }, // while blocked
334
+ }
335
+ ```
336
+
337
+ `address` is stored only when it parses as a Carrier address of that userid, and a blank or wrong hint never replaces
338
+ one already held: it is what a friend request needs, and no later sign-in is sure to carry it.
339
+
340
+ **An update merges.** `svc.users.update(userid, { plan: "paid", profile: { resume: "cv.pdf" } })` changes those keys
341
+ and keeps every other field, including fields this library has never heard of. Sign-in, registration, block and
342
+ unblock all work the same way; nothing rebuilds a record from a fixed list. The routes return only the library's own
343
+ fields as `user`; pass `publicUser: (user) => ({ … })` to choose what the page sees.
344
+
345
+ ### Registration is your choice
346
+
347
+ ```js
348
+ register: { required: [], optional: [] } // default: every visitor is a user at once
349
+ register: { required: ["email"] }
350
+ register: { required: ["email", "phone", "location"], optional: ["company"] }
351
+ ```
352
+
353
+ A visitor who has given every required field is `registered`. `POST /register` checks and stores the fields; it
354
+ answers 400 `invalid-fields` and stores nothing when a required field is missing or a field is refused. `email` and
355
+ `phone` get a loose shape check. Anything stricter is yours:
356
+
357
+ ```js
358
+ register: {
359
+ required: ["email"],
360
+ // true accepts, false or a string refuses (the string goes to the page), { value } stores a normalised value.
361
+ verify: async (field, value, user, submitted) => {
362
+ if (field !== "email") return true;
363
+ if (await codes.matches(user.userid, value, submitted.emailCode)) return { value: value.toLowerCase() };
364
+ await mailer.sendCode(user.userid, value); // sending the email or SMS is your part
365
+ return "confirmation-code-needed";
366
+ },
367
+ }
368
+ ```
369
+
370
+ If you add a required field later, users who lack it are visitors again at their next sign-in.
371
+
372
+ ### The visitor limit
373
+
374
+ ```js
375
+ visitor: { maxDays: 14, maxVisits: 10, then: "ask" } // or then: "refuse"
376
+ ```
377
+
378
+ Past either limit a visitor's `registration.due` is true. With `"ask"` the site keeps answering and the page shows
379
+ its form. With `"refuse"`, `/me` and `authenticate()` answer 403 `register-required` until they register. The sign-in
380
+ answer is 403 too, and still carries a session: without one they could not prove who is registering. That session
381
+ opens `register` and `logout` only.
382
+
383
+ A visit is a burst of activity; 30 minutes of silence (`visitGapMinutes`) ends it.
384
+
385
+ ### The blacklist
386
+
387
+ ```js
388
+ await svc.users.block(userid, "spam"); // also works before the userid has ever signed in
389
+ await svc.users.unblock(userid);
390
+ await svc.users.list({ status: "blocked" });
391
+ ```
392
+
393
+ A blocked userid is refused at sign-in and launch (403 `blocked`), its sessions stop working at once, and the agent
394
+ refuses its friend requests, removes it as a friend and drops what was queued for it. After `unblock` they sign in
395
+ again.
396
+
397
+ ### Events
398
+
399
+ ```js
400
+ svc.users.on("sign-in", (user, { first, door, origin }) => {}); // origin: the one that was signed for
401
+ svc.users.on("registered", (user) => {}); // also fires at first sign-in when nothing is required
402
+ svc.users.on("blocked", (user) => {});
403
+ svc.users.on("unblocked", (user) => {});
404
+ ```
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
+
431
+ ### Storage
432
+
433
+ The default is `<dataDir>/users.json`, rewritten atomically (temp file, fsync, rename) on every change and held in
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.
439
+
440
+ For Postgres, sqlite or anything else, implement three calls:
441
+
442
+ ```js
443
+ const store = {
444
+ get: async (userid) => row ?? null,
445
+ put: async (user, change) => { /* see below */ },
446
+ list: async () => rows,
447
+ };
448
+ await createBeagleService({ origins, store });
449
+ ```
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
+
469
+ Used nonces, used launch assertions and revoked sessions live in a second small store (`<dataDir>/state.json` by
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):
473
+
474
+ ```js
475
+ const state = {
476
+ addOnce: async (ns, key, expiresAt) => /* true if inserted, false if (ns, key) was already there */,
477
+ has: async (ns, key) => /* true if there and not expired */,
478
+ };
479
+ ```
480
+
481
+ ### Published profiles
482
+
483
+ `importProfile: true` reads the userid's `beagles.eth` record after sign-in
484
+ (`GET <gateway>/getAddress/<userid>`, cached for 24 h) and stores it under `user.published`
485
+ (`name`, `displayName`, `description`, `avatar`, `url`, `owner`). A record proves only what its owner signed.
486
+
487
+ ---
488
+
489
+ ## 3. `agent`: the site's own Carrier identity
490
+
491
+ One identity for the site's role, held in your process with `@decentnetwork/peer`. People add it like any contact;
492
+ nothing is delivered either way before a friend request is accepted. The key is created on first start in
493
+ `<dataDir>/agent/peer.save`: back it up, it *is* the identity.
494
+
495
+ ```js
496
+ svc.agent.userid; svc.agent.address; // show the address on your site; the routes return it as `agent`
497
+ await svc.agent.ready; // joined the network (sign-in does not wait for this)
498
+ ```
499
+
500
+ ### Who may become a friend
501
+
502
+ | `accept` | A friend request is accepted when |
503
+ |---|---|
504
+ | `"all"` | always |
505
+ | `"signed-in"` (default) | the userid has signed in on the site |
506
+ | `"registered"` | the user's status is `registered` |
507
+ | `"manual"` | you call `svc.agent.accept(userid)` |
508
+ | `(request, user) => …` | it returns `true`; `false` holds the request, `"reject"` refuses it |
509
+
510
+ A blocked userid is always refused. A request from someone who does not qualify yet is **held on disk** and accepted
511
+ the moment they sign in, register or are unblocked; a restart loses none. Held requests are dropped after `holdDays`
512
+ (default 7). `svc.agent.pending()` lists them; `accept(userid)` / `reject(userid)` answer one by hand.
513
+
514
+ ### Friend requests from your side
515
+
516
+ ```js
517
+ befriendOn: "register" // or "sign-in", or "never" (default)
518
+ await svc.agent.befriend(userid); // → { ok: true, how: "requested" | "accepted" | "already-friends" | "already-asked" }
519
+ ```
520
+
521
+ The request goes to the user's stored `address` (a userid alone is not enough to send one). Each user is asked once;
522
+ `{ force: true }` asks again. If they had already asked, their request is accepted instead.
523
+
524
+ ### Sending
525
+
526
+ ```js
527
+ const receipt = await svc.agent.send(userid, "Your order shipped.");
528
+ // { id, deliveryIds: ["bs-…-0"], state: "queued" }
529
+ svc.agent.on("delivered", ({ deliveryId, to, via, confirmed }) => {});
530
+ svc.agent.on("dead-letter", ({ why, item }) => {});
531
+ ```
532
+
533
+ `send` resolves when the message is on disk (`<dataDir>/agent/outbox.json`); delivery starts on the next turn of the
534
+ event loop.
535
+
536
+ - The `deliveryId` is fixed when the message is queued. A retry, a second road or a send repeated after a crash
537
+ carries the same id. Whether the friend's client sees it is decided by the peer: it wraps a text in the
538
+ id-carrying envelope only for a friend whose protocol version it has learned (2 or more, from the profile the
539
+ friend's client sends when a session comes up). Such a friend shows a repeated message once. For any other
540
+ friend (a phone app, or a client whose profile has not arrived yet) the text goes out plain and the id is your
541
+ own handle for the message.
542
+ - A session that has just proven itself (something arrived from the friend in the last 15 s) gets the message live,
543
+ with 4 s for the confirmation. Otherwise, or when no confirmation comes, it is posted to an Express node at once
544
+ (`sendTextViaExpress`) and the friend collects it when they are back. `via` says which road took it; `confirmed` is
545
+ true only for a live delivery the friend's side acknowledged.
546
+ - When neither road takes it, the message waits: 5 s, 10 s, 20 s … up to 10 minutes between tries. A queue is per
547
+ recipient: one unreachable friend never holds up another, and one recipient's messages keep their order.
548
+ - A message to someone with a friend request still open waits for the acceptance.
549
+ - Dead letters (`<dataDir>/agent/dead-letter.jsonl`): `not-a-friend` (no friendship and no request open),
550
+ `bad-address` (not a userid), `blocked`, `expired` (`maxQueueHours`, default 72).
551
+ - A text over one message (about 700 bytes of UTF-8) is split at line breaks or spaces; each part has its own
552
+ `deliveryId`.
553
+
554
+ ### Receiving
555
+
556
+ ```js
557
+ svc.agent.onMessage(async ({ userid, user, text, deliveryId, via, reply }) => {
558
+ if (user?.status !== "registered") return reply("Please register on the site first.");
559
+ await reply(await answer(user, text));
560
+ });
561
+ ```
562
+
563
+ `user` is the sender's record, or `null` for a friend who never signed in (possible under `accept: "all"` or a
564
+ function). Act on `userid`, the key the message came from, never on a userid written in the text. Messages from a
565
+ blocked userid are not delivered to the handler.
566
+
567
+ ### Other
568
+
569
+ `svc.agent.isFriend(userid)`, `unfriend(userid)`, `outbox()`, `deadLetters()`, and events `request-held`,
570
+ `friend-accepted`, `friend-refused`, `friend-removed`. For what this library leaves out (files, calls, groups), the
571
+ underlying peer is `svc.agent.peer`. `await svc.close()` stops the agent.
572
+
573
+ Bootstrap and Express nodes come from the public list the Beagle apps read (`DEFAULT_SERVERS_URL`); the last good
574
+ copy is kept on disk so a restart works while that URL is down. Pass `agent.servers` (a URL in the same format, or
575
+ `{ bootstrapNodes, expressNodes }`) to use your own. Without an Express node there is no offline delivery.
576
+
577
+ ---
578
+
579
+ ## Config
580
+
581
+ | Key | Default | Meaning |
582
+ |---|---|---|
583
+ | `origins` | required | Exact origins of your pages: a list, or a function that returns the list as it is now. |
584
+ | `dataDir` | `"./data"` | Where the library keeps its files. |
585
+ | `store` | `<dataDir>/users.json` | Users backend (`get`, `put`, `list`). |
586
+ | `state` | `<dataDir>/state.json` | Replay and revocation backend (`addOnce`, `has`). |
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. |
595
+ | `session.ttlDays` | `30` | Session lifetime. |
596
+ | `session.transport` | `"cookie"` | `"cookie"`, `"bearer"` or `"both"`. |
597
+ | `session.cookieName` | `"beagle_session"` | |
598
+ | `session.sameSite` | `"Lax"` | `"Lax"`, `"Strict"` or `"None"`. |
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. |
601
+ | `register.required` | `[]` | Fields a visitor must give to be `registered`. Empty: registered at once. |
602
+ | `register.optional` | `[]` | Fields the register route also stores. |
603
+ | `register.verify` | none | `(field, value, user, submitted) => true \| false \| "reason" \| { value }`. |
604
+ | `visitor.maxDays` | none | Days a visitor may stay unregistered. |
605
+ | `visitor.maxVisits` | none | Visits a visitor may make unregistered. |
606
+ | `visitor.then` | `"ask"` | `"ask"` or `"refuse"` (403 `register-required`). |
607
+ | `visitor.visitGapMinutes` | `30` | Silence that separates two visits. |
608
+ | `importProfile` | `false` | `true`, or `{ gateway, ttlHours }`. |
609
+ | `publicUser` | library fields only | What the routes return as `user`. |
610
+ | `agent` | none | The site's Carrier identity; omit for sign-in only. |
611
+ | `agent.keyFile` | `<dataDir>/agent/peer.save` | The identity's key. |
612
+ | `agent.nickname`, `statusMessage`, `punkId`, `avatarUrl` | none | The agent's profile; it is advertised with `platform: "agent"`. |
613
+ | `agent.accept` | `"signed-in"` | `"all"`, `"signed-in"`, `"registered"`, `"manual"` or a function. |
614
+ | `agent.befriendOn` | `"never"` | `"sign-in"`, `"register"` or `"never"`. |
615
+ | `agent.hello` | the nickname | Greeting in friend requests the agent sends. |
616
+ | `agent.holdDays` | `7` | How long an unanswered request is kept. |
617
+ | `agent.maxQueueHours` | `72` | How long a message may wait before it is dead-lettered. |
618
+ | `agent.servers` | `DEFAULT_SERVERS_URL` | A server-list URL, or `{ bootstrapNodes, expressNodes }`. |
619
+ | `agent.peerOptions` | none | Extra options for `Peer.create` (for example `udpPort`). |
620
+ | `log` | `console.warn` | Warnings and errors. |
621
+
622
+ ## Errors
623
+
624
+ | `error` | Status | When |
625
+ |---|---|---|
626
+ | `origin-not-allowed` | 403 | The `Origin` header is missing or not on the list. |
627
+ | `bad-nonce` | 401 | Unknown, expired or already used. |
628
+ | `bad-signature` | 401 | The signature does not verify for that userid, origin and nonce or `ts`. |
629
+ | `launch-expired` | 401 | `ts` is more than 120 s from now, either way. |
630
+ | `launch-replayed` | 401 | The assertion was accepted before. |
631
+ | `blocked` | 403 | The userid is on the blacklist. |
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. |
635
+ | `not-signed-in` | 401 | No session, or an expired or revoked one. |
636
+ | `invalid-fields` | 400 | Registration fields missing or refused; see `fields`. |
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. |
639
+
640
+ ## Files in `dataDir`
641
+
642
+ ```text
643
+ users.json the users (default store)
644
+ state.json used nonces, used launch assertions, revoked sessions
645
+ *.json.lock there for the few milliseconds of a write
646
+ session-secret the signing secret, mode 0600
647
+ agent/peer.save the agent's key, plus the peer's own friend store beside it
648
+ agent/pending.json friend requests held
649
+ agent/asked.json who the agent has sent a request to
650
+ agent/outbox.json messages not delivered yet
651
+ agent/dead-letter.jsonl
652
+ agent/servers.json last good copy of the server list
653
+ ```
654
+
655
+ All of it is runtime state: keep it out of git, back up `agent/peer.save` and `users.json`.
656
+
657
+ ## Where this differs from the first sketch of the API
658
+
659
+ The design note this package was built from gave a shape "as a guide". What changed, and why:
660
+
661
+ - **`onMessage` passes `userid` and `reply()` beside `user`.** `user` is `null` for a friend who never signed in, so
662
+ a handler written as `send(user.userid, …)` would throw for exactly those senders.
663
+ - **`agent.send` resolves when the message is on disk**, with a receipt, not when it is delivered. Delivery can take
664
+ days for an offline friend; it is reported by the `delivered` and `dead-letter` events.
665
+ - **The launch replay store is keyed on the signed message** (userid, origin, `ts`) instead of the signature bytes.
666
+ A store keyed on the hex string lets the same assertion through again in another letter case.
667
+ - **Nonces are not kept in a table.** Each carries its expiry and a MAC; only used ones are recorded. They survive a
668
+ restart and work across processes.
669
+ - **`then: "refuse"` at sign-in is a 403 that still carries a session**, limited to `register` and `logout`.
670
+ Otherwise a refused visitor could never register.
671
+ - **A policy function may return `"reject"`** as well as `true` (accept) and `false` (hold).
672
+ - **`store` is the users backend only**; the replay and revocation sets have their own small adapter, `state`,
673
+ because a replay check needs an atomic insert, not a read and a write.
674
+ - **A request from an origin that is not listed answers 403**, where the published sign-in guide's sample answers 400.
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
+
706
+ ## Not in this version
707
+
708
+ The wallet door, gates and permits; sending the confirmation email or SMS; files, calls and groups; quotas and
709
+ billing; a chat history store; several agent roles in one process; linking several keys to one person; the
710
+ browser-side sign-in picker.
711
+
712
+ ## Tests
713
+
714
+ ```bash
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)
721
+ ```