@decentnetwork/beagle-service 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Decent Network contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,460 @@
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+, ESM, MIT, types included
19
+ ```
20
+
21
+ The browser side is [`@decentnetwork/beagle-connect`](https://www.npmjs.com/package/@decentnetwork/beagle-connect)
22
+ (`signIn`, `readLaunch`, `addFriend`). This package is the server those calls talk to.
23
+
24
+ ## Quick start
25
+
26
+ ```js
27
+ import http from "node:http";
28
+ import { createBeagleService } from "@decentnetwork/beagle-service";
29
+
30
+ const svc = await createBeagleService({
31
+ origins: ["https://your.app"], // exact: scheme, host, port
32
+ dataDir: "./data",
33
+ register: { required: ["email"], optional: ["phone", "location"] },
34
+ visitor: { maxDays: 14, then: "refuse" },
35
+ agent: { // omit if the site only needs sign-in
36
+ nickname: "Your App",
37
+ accept: "signed-in",
38
+ befriendOn: "register",
39
+ },
40
+ });
41
+
42
+ http.createServer(svc.nodeHandler({ prefix: "/api/auth" })).listen(3000);
43
+
44
+ svc.agent.onMessage(({ user, reply }) => reply(`hello ${user?.name ?? ""}`));
45
+ await svc.users.block(userid, "spam");
46
+ ```
47
+
48
+ A complete site in one file, with its page: [`examples/minimal-site`](examples/minimal-site).
49
+
50
+ ---
51
+
52
+ ## 1. `auth`: sign-in on the server
53
+
54
+ ### Routes
55
+
56
+ Mounted under a prefix (default `/api/auth`). All answers are JSON; errors are `{ ok: false, error, message }`.
57
+
58
+ | Route | Body | Answers |
59
+ |---|---|---|
60
+ | `GET /nonce` | | `{ nonce, expiresIn: 120 }` |
61
+ | `POST /sign-in` (also `/beagle`) | what `signIn()` resolved: `{ userid, nonce, sig, address, name, avatar, uiOrigin }` | `{ user, registration, expiresAt, agent? }` and the session |
62
+ | `POST /launch` (also `/beagle-launch`) | what `readLaunch()` returned: `{ userid, ts, sig, address, name, punkId }` | the same |
63
+ | `GET /me` | | `{ user, registration, agent? }`, or 401 |
64
+ | `POST /logout` | | `{ ok: true }`; the token is revoked, not only the cookie cleared |
65
+ | `POST /register` | `{ profile: { email: "…" } }` | `{ user, registration }`, or 400 `invalid-fields` with `fields: { email: "required" }` |
66
+
67
+ The page side, with `beagle-connect`:
68
+
69
+ ```js
70
+ import { signIn, readLaunch } from "@decentnetwork/beagle-connect";
71
+
72
+ const launch = await readLaunch(); // arrived from Beagle's Apps tab?
73
+ if (launch) await post("/api/auth/launch", launch);
74
+
75
+ button.onclick = async () => {
76
+ const win = window.open("about:blank", "beagle-connect", "width=420,height=560"); // inside the click
77
+ const { nonce } = await (await fetch("/api/auth/nonce")).json();
78
+ const who = await signIn({ nonce, existingWindow: win });
79
+ await post("/api/auth/sign-in", who);
80
+ };
81
+ ```
82
+
83
+ ### What is checked
84
+
85
+ - **Origin**: the request's `Origin` header (set by the browser, not by page script) against your exact list.
86
+ No suffix matching, no wildcard, never an origin from the body. `http://localhost:3000` counts only if you list it.
87
+ 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.
91
+ - **`decent-auth` and `decent-launch`** are verified with `beagle-connect`'s `verifySignIn` / `verifyLaunch`
92
+ (XEdDSA over the X25519 identity key). The two prefixes differ, so a launch assertion never signs in, nor the reverse.
93
+ - **Launch**: `ts` within ±120 s, then the signature, then replay, last, so a forged repeat cannot burn an assertion a
94
+ real visitor still holds. The replay store is on disk and is written before the answer goes out. It is keyed on what
95
+ was signed (userid, origin, `ts`), not on the signature's bytes: the same assertion with its hex in another case, or
96
+ any second signature over the same message, is a repeat too.
97
+ - **POST from a cookie session** must come from a listed origin (or carry a bearer token).
98
+
99
+ ### Sessions
100
+
101
+ A signed token with the expiry inside it (`ttlDays`, default 30) and a session id. By default it is a cookie:
102
+ `HttpOnly; Secure; SameSite=Lax`. `Secure` is left off only for an `http://` origin you listed yourself (a localhost
103
+ during development). With `session: { transport: "bearer" }` the token is returned in the sign-in answer and sent back
104
+ as `Authorization: Bearer …` (for an API on another origin than the page); `"both"` does both. CORS headers are sent
105
+ for the listed origins only.
106
+
107
+ Logout revokes the session id until the token would have expired. Blocking a user, or `users.revokeSessions(userid)`,
108
+ ends every session of that user.
109
+
110
+ ### In your own routes
111
+
112
+ ```js
113
+ const who = await svc.auth.authenticate(req); // any object with `headers`
114
+ if (!who.ok) return send(res, who.status, { error: who.error }); // 401 not-signed-in, 403 blocked / register-required
115
+ who.user; // the full record
116
+ who.registration; // { status, missing, due, refused, … }
117
+ ```
118
+
119
+ `authenticate(req, { allowUnregistered: true })` lets a visitor past the limit through (the register route uses it).
120
+
121
+ ### Frameworks
122
+
123
+ ```js
124
+ // node:http
125
+ http.createServer(svc.nodeHandler({ prefix: "/api/auth" }));
126
+
127
+ // Express: other paths go to next()
128
+ app.use(svc.expressHandler({ prefix: "/api/auth" }));
129
+
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;
133
+
134
+ // Next.js API route: pages/api/auth/[...beagle].js
135
+ export default async (req, res) => (await beagle).nextApiHandler({ prefix: "/api/auth" })(req, res);
136
+
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);
139
+ export { handler as GET, handler as POST, handler as OPTIONS };
140
+
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
144
+ ```
145
+
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.
148
+
149
+ The pieces are also there on their own: `svc.auth.issueNonce()`, `verifySignIn(origin, body)`,
150
+ `verifyLaunch(origin, body)`, `mintSession(userid)`, `readToken(token)`, `revoke(token)`, `cookie(token, origin)`.
151
+
152
+ ---
153
+
154
+ ## 2. `users`: visitor, registered, blocked
155
+
156
+ One record per userid, created at the first sign-in:
157
+
158
+ ```js
159
+ {
160
+ userid, address, name, avatar, punkId, // address/name/avatar/punkId are unsigned hints from the visitor's Beagle
161
+ door, // which Beagle answered: its origin, or "launch"
162
+ status, // "visitor" | "registered" | "blocked"
163
+ profile: { email: "…" }, // your registration fields
164
+ firstSeen, lastSeen, visits, signIns, registeredAt,
165
+ blocked: { at, why, was }, // while blocked
166
+ }
167
+ ```
168
+
169
+ `address` is stored only when it parses as a Carrier address of that userid, and a blank or wrong hint never replaces
170
+ one already held: it is what a friend request needs, and no later sign-in is sure to carry it.
171
+
172
+ **An update merges.** `svc.users.update(userid, { plan: "paid", profile: { resume: "cv.pdf" } })` changes those keys
173
+ and keeps every other field, including fields this library has never heard of. Sign-in, registration, block and
174
+ unblock all work the same way; nothing rebuilds a record from a fixed list. The routes return only the library's own
175
+ fields as `user`; pass `publicUser: (user) => ({ … })` to choose what the page sees.
176
+
177
+ ### Registration is your choice
178
+
179
+ ```js
180
+ register: { required: [], optional: [] } // default: every visitor is a user at once
181
+ register: { required: ["email"] }
182
+ register: { required: ["email", "phone", "location"], optional: ["company"] }
183
+ ```
184
+
185
+ A visitor who has given every required field is `registered`. `POST /register` checks and stores the fields; it
186
+ answers 400 `invalid-fields` and stores nothing when a required field is missing or a field is refused. `email` and
187
+ `phone` get a loose shape check. Anything stricter is yours:
188
+
189
+ ```js
190
+ register: {
191
+ required: ["email"],
192
+ // true accepts, false or a string refuses (the string goes to the page), { value } stores a normalised value.
193
+ verify: async (field, value, user, submitted) => {
194
+ if (field !== "email") return true;
195
+ if (await codes.matches(user.userid, value, submitted.emailCode)) return { value: value.toLowerCase() };
196
+ await mailer.sendCode(user.userid, value); // sending the email or SMS is your part
197
+ return "confirmation-code-needed";
198
+ },
199
+ }
200
+ ```
201
+
202
+ If you add a required field later, users who lack it are visitors again at their next sign-in.
203
+
204
+ ### The visitor limit
205
+
206
+ ```js
207
+ visitor: { maxDays: 14, maxVisits: 10, then: "ask" } // or then: "refuse"
208
+ ```
209
+
210
+ Past either limit a visitor's `registration.due` is true. With `"ask"` the site keeps answering and the page shows
211
+ its form. With `"refuse"`, `/me` and `authenticate()` answer 403 `register-required` until they register. The sign-in
212
+ answer is 403 too, and still carries a session: without one they could not prove who is registering. That session
213
+ opens `register` and `logout` only.
214
+
215
+ A visit is a burst of activity; 30 minutes of silence (`visitGapMinutes`) ends it.
216
+
217
+ ### The blacklist
218
+
219
+ ```js
220
+ await svc.users.block(userid, "spam"); // also works before the userid has ever signed in
221
+ await svc.users.unblock(userid);
222
+ await svc.users.list({ status: "blocked" });
223
+ ```
224
+
225
+ A blocked userid is refused at sign-in and launch (403 `blocked`), its sessions stop working at once, and the agent
226
+ refuses its friend requests, removes it as a friend and drops what was queued for it. After `unblock` they sign in
227
+ again.
228
+
229
+ ### Events
230
+
231
+ ```js
232
+ svc.users.on("sign-in", (user, { first, door }) => {});
233
+ svc.users.on("registered", (user) => {}); // also fires at first sign-in when nothing is required
234
+ svc.users.on("blocked", (user) => {});
235
+ svc.users.on("unblocked", (user) => {});
236
+ ```
237
+
238
+ ### Storage
239
+
240
+ 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.
243
+
244
+ For Postgres, sqlite or anything else, implement three calls; `put` gets the whole record and must keep all of it:
245
+
246
+ ```js
247
+ const store = {
248
+ get: async (userid) => row ?? null,
249
+ put: async (user) => { /* upsert by user.userid, store the whole object */ },
250
+ list: async () => rows,
251
+ };
252
+ await createBeagleService({ origins, store });
253
+ ```
254
+
255
+ 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`:
257
+
258
+ ```js
259
+ const state = {
260
+ addOnce: async (ns, key, expiresAt) => /* true if inserted, false if (ns, key) was already there */,
261
+ has: async (ns, key) => /* true if there and not expired */,
262
+ };
263
+ ```
264
+
265
+ ### Published profiles
266
+
267
+ `importProfile: true` reads the userid's `beagles.eth` record after sign-in
268
+ (`GET <gateway>/getAddress/<userid>`, cached for 24 h) and stores it under `user.published`
269
+ (`name`, `displayName`, `description`, `avatar`, `url`, `owner`). A record proves only what its owner signed.
270
+
271
+ ---
272
+
273
+ ## 3. `agent`: the site's own Carrier identity
274
+
275
+ One identity for the site's role, held in your process with `@decentnetwork/peer`. People add it like any contact;
276
+ nothing is delivered either way before a friend request is accepted. The key is created on first start in
277
+ `<dataDir>/agent/peer.save`: back it up, it *is* the identity.
278
+
279
+ ```js
280
+ svc.agent.userid; svc.agent.address; // show the address on your site; the routes return it as `agent`
281
+ await svc.agent.ready; // joined the network (sign-in does not wait for this)
282
+ ```
283
+
284
+ ### Who may become a friend
285
+
286
+ | `accept` | A friend request is accepted when |
287
+ |---|---|
288
+ | `"all"` | always |
289
+ | `"signed-in"` (default) | the userid has signed in on the site |
290
+ | `"registered"` | the user's status is `registered` |
291
+ | `"manual"` | you call `svc.agent.accept(userid)` |
292
+ | `(request, user) => …` | it returns `true`; `false` holds the request, `"reject"` refuses it |
293
+
294
+ A blocked userid is always refused. A request from someone who does not qualify yet is **held on disk** and accepted
295
+ the moment they sign in, register or are unblocked; a restart loses none. Held requests are dropped after `holdDays`
296
+ (default 7). `svc.agent.pending()` lists them; `accept(userid)` / `reject(userid)` answer one by hand.
297
+
298
+ ### Friend requests from your side
299
+
300
+ ```js
301
+ befriendOn: "register" // or "sign-in", or "never" (default)
302
+ await svc.agent.befriend(userid); // → { ok: true, how: "requested" | "accepted" | "already-friends" | "already-asked" }
303
+ ```
304
+
305
+ The request goes to the user's stored `address` (a userid alone is not enough to send one). Each user is asked once;
306
+ `{ force: true }` asks again. If they had already asked, their request is accepted instead.
307
+
308
+ ### Sending
309
+
310
+ ```js
311
+ const receipt = await svc.agent.send(userid, "Your order shipped.");
312
+ // { id, deliveryIds: ["bs-…-0"], state: "queued" }
313
+ svc.agent.on("delivered", ({ deliveryId, to, via, confirmed }) => {});
314
+ svc.agent.on("dead-letter", ({ why, item }) => {});
315
+ ```
316
+
317
+ `send` resolves when the message is on disk (`<dataDir>/agent/outbox.json`); delivery starts on the next turn of the
318
+ event loop.
319
+
320
+ - The `deliveryId` is fixed when the message is queued. A retry, a second road or a send repeated after a crash
321
+ carries the same id. Whether the friend's client sees it is decided by the peer: it wraps a text in the
322
+ id-carrying envelope only for a friend whose protocol version it has learned (2 or more, from the profile the
323
+ friend's client sends when a session comes up). Such a friend shows a repeated message once. For any other
324
+ friend (a phone app, or a client whose profile has not arrived yet) the text goes out plain and the id is your
325
+ own handle for the message.
326
+ - A session that has just proven itself (something arrived from the friend in the last 15 s) gets the message live,
327
+ with 4 s for the confirmation. Otherwise, or when no confirmation comes, it is posted to an Express node at once
328
+ (`sendTextViaExpress`) and the friend collects it when they are back. `via` says which road took it; `confirmed` is
329
+ true only for a live delivery the friend's side acknowledged.
330
+ - When neither road takes it, the message waits: 5 s, 10 s, 20 s … up to 10 minutes between tries. A queue is per
331
+ recipient: one unreachable friend never holds up another, and one recipient's messages keep their order.
332
+ - A message to someone with a friend request still open waits for the acceptance.
333
+ - Dead letters (`<dataDir>/agent/dead-letter.jsonl`): `not-a-friend` (no friendship and no request open),
334
+ `bad-address` (not a userid), `blocked`, `expired` (`maxQueueHours`, default 72).
335
+ - A text over one message (about 700 bytes of UTF-8) is split at line breaks or spaces; each part has its own
336
+ `deliveryId`.
337
+
338
+ ### Receiving
339
+
340
+ ```js
341
+ svc.agent.onMessage(async ({ userid, user, text, deliveryId, via, reply }) => {
342
+ if (user?.status !== "registered") return reply("Please register on the site first.");
343
+ await reply(await answer(user, text));
344
+ });
345
+ ```
346
+
347
+ `user` is the sender's record, or `null` for a friend who never signed in (possible under `accept: "all"` or a
348
+ function). Act on `userid`, the key the message came from, never on a userid written in the text. Messages from a
349
+ blocked userid are not delivered to the handler.
350
+
351
+ ### Other
352
+
353
+ `svc.agent.isFriend(userid)`, `unfriend(userid)`, `outbox()`, `deadLetters()`, and events `request-held`,
354
+ `friend-accepted`, `friend-refused`, `friend-removed`. For what this library leaves out (files, calls, groups), the
355
+ underlying peer is `svc.agent.peer`. `await svc.close()` stops the agent.
356
+
357
+ Bootstrap and Express nodes come from the public list the Beagle apps read (`DEFAULT_SERVERS_URL`); the last good
358
+ copy is kept on disk so a restart works while that URL is down. Pass `agent.servers` (a URL in the same format, or
359
+ `{ bootstrapNodes, expressNodes }`) to use your own. Without an Express node there is no offline delivery.
360
+
361
+ ---
362
+
363
+ ## Config
364
+
365
+ | Key | Default | Meaning |
366
+ |---|---|---|
367
+ | `origins` | required | Exact origins of your pages. |
368
+ | `dataDir` | `"./data"` | Where the library keeps its files. |
369
+ | `store` | `<dataDir>/users.json` | Users backend (`get`, `put`, `list`). |
370
+ | `state` | `<dataDir>/state.json` | Replay and revocation backend (`addOnce`, `has`). |
371
+ | `sessionSecret` | generated into `<dataDir>/session-secret` | Signs sessions and nonces. Set it when several processes share users. |
372
+ | `session.ttlDays` | `30` | Session lifetime. |
373
+ | `session.transport` | `"cookie"` | `"cookie"`, `"bearer"` or `"both"`. |
374
+ | `session.cookieName` | `"beagle_session"` | |
375
+ | `session.sameSite` | `"Lax"` | `"Lax"`, `"Strict"` or `"None"`. |
376
+ | `session.cookieDomain`, `session.cookiePath` | none, `"/"` | |
377
+ | `register.required` | `[]` | Fields a visitor must give to be `registered`. Empty: registered at once. |
378
+ | `register.optional` | `[]` | Fields the register route also stores. |
379
+ | `register.verify` | none | `(field, value, user, submitted) => true \| false \| "reason" \| { value }`. |
380
+ | `visitor.maxDays` | none | Days a visitor may stay unregistered. |
381
+ | `visitor.maxVisits` | none | Visits a visitor may make unregistered. |
382
+ | `visitor.then` | `"ask"` | `"ask"` or `"refuse"` (403 `register-required`). |
383
+ | `visitor.visitGapMinutes` | `30` | Silence that separates two visits. |
384
+ | `importProfile` | `false` | `true`, or `{ gateway, ttlHours }`. |
385
+ | `publicUser` | library fields only | What the routes return as `user`. |
386
+ | `agent` | none | The site's Carrier identity; omit for sign-in only. |
387
+ | `agent.keyFile` | `<dataDir>/agent/peer.save` | The identity's key. |
388
+ | `agent.nickname`, `statusMessage`, `punkId`, `avatarUrl` | none | The agent's profile; it is advertised with `platform: "agent"`. |
389
+ | `agent.accept` | `"signed-in"` | `"all"`, `"signed-in"`, `"registered"`, `"manual"` or a function. |
390
+ | `agent.befriendOn` | `"never"` | `"sign-in"`, `"register"` or `"never"`. |
391
+ | `agent.hello` | the nickname | Greeting in friend requests the agent sends. |
392
+ | `agent.holdDays` | `7` | How long an unanswered request is kept. |
393
+ | `agent.maxQueueHours` | `72` | How long a message may wait before it is dead-lettered. |
394
+ | `agent.servers` | `DEFAULT_SERVERS_URL` | A server-list URL, or `{ bootstrapNodes, expressNodes }`. |
395
+ | `agent.peerOptions` | none | Extra options for `Peer.create` (for example `udpPort`). |
396
+ | `log` | `console.warn` | Warnings and errors. |
397
+
398
+ ## Errors
399
+
400
+ | `error` | Status | When |
401
+ |---|---|---|
402
+ | `origin-not-allowed` | 403 | The `Origin` header is missing or not on the list. |
403
+ | `bad-nonce` | 401 | Unknown, expired or already used. |
404
+ | `bad-signature` | 401 | The signature does not verify for that userid, origin and nonce or `ts`. |
405
+ | `launch-expired` | 401 | `ts` is more than 120 s from now, either way. |
406
+ | `launch-replayed` | 401 | The assertion was accepted before. |
407
+ | `blocked` | 403 | The userid is on the blacklist. |
408
+ | `register-required` | 403 | A visitor past the limit under `then: "refuse"`. |
409
+ | `not-signed-in` | 401 | No session, or an expired or revoked one. |
410
+ | `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 | |
412
+
413
+ ## Files in `dataDir`
414
+
415
+ ```text
416
+ users.json the users (default store)
417
+ state.json used nonces, used launch assertions, revoked sessions
418
+ session-secret the signing secret, mode 0600
419
+ agent/peer.save the agent's key, plus the peer's own friend store beside it
420
+ agent/pending.json friend requests held
421
+ agent/asked.json who the agent has sent a request to
422
+ agent/outbox.json messages not delivered yet
423
+ agent/dead-letter.jsonl
424
+ agent/servers.json last good copy of the server list
425
+ ```
426
+
427
+ All of it is runtime state: keep it out of git, back up `agent/peer.save` and `users.json`.
428
+
429
+ ## Where this differs from the first sketch of the API
430
+
431
+ The design note this package was built from gave a shape "as a guide". What changed, and why:
432
+
433
+ - **`onMessage` passes `userid` and `reply()` beside `user`.** `user` is `null` for a friend who never signed in, so
434
+ a handler written as `send(user.userid, …)` would throw for exactly those senders.
435
+ - **`agent.send` resolves when the message is on disk**, with a receipt, not when it is delivered. Delivery can take
436
+ days for an offline friend; it is reported by the `delivered` and `dead-letter` events.
437
+ - **The launch replay store is keyed on the signed message** (userid, origin, `ts`) instead of the signature bytes.
438
+ A store keyed on the hex string lets the same assertion through again in another letter case.
439
+ - **Nonces are not kept in a table.** Each carries its expiry and a MAC; only used ones are recorded. They survive a
440
+ restart and work across processes.
441
+ - **`then: "refuse"` at sign-in is a 403 that still carries a session**, limited to `register` and `logout`.
442
+ Otherwise a refused visitor could never register.
443
+ - **A policy function may return `"reject"`** as well as `true` (accept) and `false` (hold).
444
+ - **`store` is the users backend only**; the replay and revocation sets have their own small adapter, `state`,
445
+ because a replay check needs an atomic insert, not a read and a write.
446
+ - **A request from an origin that is not listed answers 403**, where the published sign-in guide's sample answers 400.
447
+
448
+ ## Not in this version
449
+
450
+ The wallet door, gates and permits; sending the confirmation email or SMS; files, calls and groups; quotas and
451
+ billing; a chat history store; several agent roles in one process; linking several keys to one person; the
452
+ browser-side sign-in picker.
453
+
454
+ ## Tests
455
+
456
+ ```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)
460
+ ```
@@ -0,0 +1,18 @@
1
+ import type { Agent, AgentConfig, AgentServers, Users } from "./types.js";
2
+ import { type Logger } from "./util.js";
3
+ /** The public server list the Beagle apps read: bootstrap and Express nodes
4
+ * of the Carrier network. Any list in the same format can stand in for it. */
5
+ export declare const DEFAULT_SERVERS_URL = "https://beagle.chat/assets/bgservers.json";
6
+ export interface AgentOptions {
7
+ config: AgentConfig;
8
+ users: Users;
9
+ /** Directory for the agent's files. */
10
+ dir: string;
11
+ now?: () => number;
12
+ log?: Logger;
13
+ fetch?: typeof fetch;
14
+ }
15
+ /** Bootstrap and Express nodes from a `bgservers.json`-format list. The last
16
+ * good answer is kept on disk, so a restart works while the URL is down. */
17
+ export declare function loadServers(source: AgentServers | string | undefined, cacheFile: string, doFetch: typeof fetch, log: Logger): Promise<AgentServers>;
18
+ export declare function createAgent(o: AgentOptions): Promise<Agent>;