@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.
- package/LICENSE +21 -0
- package/README.md +720 -2
- package/dist/agent.d.ts +18 -0
- package/dist/agent.js +448 -0
- package/dist/auth.d.ts +28 -0
- package/dist/auth.js +340 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +163 -0
- package/dist/commonjs.cjs +49 -0
- package/dist/commonjs.d.cts +11 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +191 -0
- package/dist/outbox.d.ts +63 -0
- package/dist/outbox.js +314 -0
- package/dist/routes.d.ts +29 -0
- package/dist/routes.js +454 -0
- package/dist/stores.d.ts +21 -0
- package/dist/stores.js +92 -0
- package/dist/types.d.ts +641 -0
- package/dist/types.js +1 -0
- package/dist/users.d.ts +16 -0
- package/dist/users.js +429 -0
- package/dist/util.d.ts +48 -0
- package/dist/util.js +208 -0
- package/examples/minimal-site/index.html +91 -0
- package/examples/minimal-site/server.mjs +64 -0
- package/examples/next-pages-cjs/README.md +30 -0
- package/examples/next-pages-cjs/instrumentation.js +16 -0
- package/examples/next-pages-cjs/lib/beagle.js +23 -0
- package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
- package/examples/next-pages-cjs/next.config.js +11 -0
- package/examples/next-pages-cjs/package.json +19 -0
- package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
- package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
- package/examples/next-pages-cjs/pages/api/notes.js +16 -0
- package/examples/next-pages-cjs/pages/index.js +52 -0
- package/examples/sign-in-from-script.mjs +37 -0
- package/package.json +67 -4
package/README.md
CHANGED
|
@@ -1,3 +1,721 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @decentnetwork/beagle-service
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
```
|