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