@aweftjs/auth 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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +548 -0
  3. package/dist/auth-client.d.ts +248 -0
  4. package/dist/auth-client.js +287 -0
  5. package/dist/client-modules/Reset.d.ts +12 -0
  6. package/dist/client-modules/Reset.js +61 -0
  7. package/dist/client-modules/Session.d.ts +6 -0
  8. package/dist/client-modules/Session.js +59 -0
  9. package/dist/client-modules/SignIn.d.ts +13 -0
  10. package/dist/client-modules/SignIn.js +58 -0
  11. package/dist/client-modules/Verify.d.ts +12 -0
  12. package/dist/client-modules/Verify.js +54 -0
  13. package/dist/client-modules/stage-token.d.ts +3 -0
  14. package/dist/client-modules/stage-token.js +9 -0
  15. package/dist/client.d.ts +27 -0
  16. package/dist/client.js +33 -0
  17. package/dist/context.d.ts +11 -0
  18. package/dist/context.js +11 -0
  19. package/dist/cookie.d.ts +15 -0
  20. package/dist/cookie.js +39 -0
  21. package/dist/index.d.ts +35 -0
  22. package/dist/index.js +42 -0
  23. package/dist/links.d.ts +37 -0
  24. package/dist/links.js +77 -0
  25. package/dist/mail.d.ts +55 -0
  26. package/dist/mail.js +53 -0
  27. package/dist/modules/Check.d.ts +10 -0
  28. package/dist/modules/Check.js +20 -0
  29. package/dist/modules/Enter.d.ts +57 -0
  30. package/dist/modules/Enter.js +143 -0
  31. package/dist/modules/Gate.d.ts +6 -0
  32. package/dist/modules/Gate.js +43 -0
  33. package/dist/modules/Password.d.ts +34 -0
  34. package/dist/modules/Password.js +138 -0
  35. package/dist/modules/Roles.d.ts +32 -0
  36. package/dist/modules/Roles.js +135 -0
  37. package/dist/modules/Session.d.ts +39 -0
  38. package/dist/modules/Session.js +159 -0
  39. package/dist/modules/State.d.ts +8 -0
  40. package/dist/modules/State.js +22 -0
  41. package/dist/modules/Verify.d.ts +32 -0
  42. package/dist/modules/Verify.js +97 -0
  43. package/dist/names.d.ts +25 -0
  44. package/dist/names.js +44 -0
  45. package/dist/password.d.ts +4 -0
  46. package/dist/password.js +41 -0
  47. package/dist/props.d.ts +15 -0
  48. package/dist/props.js +35 -0
  49. package/dist/token.d.ts +3 -0
  50. package/dist/token.js +11 -0
  51. package/dist/users.d.ts +9 -0
  52. package/dist/users.js +12 -0
  53. package/errors.txt +29 -0
  54. package/package.json +62 -0
  55. package/src/auth-client.ts +531 -0
  56. package/src/client-modules/Reset.tsx +97 -0
  57. package/src/client-modules/Session.ts +70 -0
  58. package/src/client-modules/SignIn.tsx +110 -0
  59. package/src/client-modules/Verify.tsx +82 -0
  60. package/src/client-modules/stage-token.ts +11 -0
  61. package/src/client.ts +38 -0
  62. package/src/context.ts +21 -0
  63. package/src/cookie.ts +35 -0
  64. package/src/index.ts +53 -0
  65. package/src/links.ts +107 -0
  66. package/src/mail.ts +88 -0
  67. package/src/modules/Check.ts +31 -0
  68. package/src/modules/Enter.ts +189 -0
  69. package/src/modules/Gate.ts +47 -0
  70. package/src/modules/Password.ts +155 -0
  71. package/src/modules/Roles.ts +163 -0
  72. package/src/modules/Session.ts +196 -0
  73. package/src/modules/State.ts +30 -0
  74. package/src/modules/Verify.ts +118 -0
  75. package/src/names.ts +49 -0
  76. package/src/password.ts +43 -0
  77. package/src/props.ts +47 -0
  78. package/src/token.ts +15 -0
  79. package/src/users.ts +18 -0
  80. package/surface.txt +19 -0
  81. package/text.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Torrin Leonard
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 ADDED
@@ -0,0 +1,548 @@
1
+ # @aweftjs/auth
2
+
3
+ The first battery: server modules for who is on a connection and what they may do. A gate that
4
+ reads `public` and `needs`, sessions as documents in your store, sign-in and sign-up by email
5
+ and password, the names a person holds, and a per-user state document shared on every
6
+ connection of theirs. A second source, `mail`, adds email verification, password change,
7
+ forgot and reset, and needs the notify battery. `@aweftjs/auth/client` is the browser half: who
8
+ the page is, what they hold, signing in and out, that state document, the mail calls, and four
9
+ page modules a stage loads by name.
10
+
11
+ ## Quickstart
12
+
13
+ ```ts
14
+ import { auth, mail, paths } from '@aweftjs/auth';
15
+ import { fromDirectory } from '@aweftjs/modules/node';
16
+ import { notify } from '@aweftjs/notify';
17
+ import { createServer } from '@aweftjs/server';
18
+ import { node } from '@aweftjs/server/node';
19
+ import { createStore, memoryDriver } from '@aweftjs/store';
20
+
21
+ const store = createStore({ driver: memoryDriver(), declare: { ...paths, title: ['title'] } });
22
+
23
+ const server = createServer({
24
+ sources: [fromDirectory('./modules'), auth, mail, notify],
25
+ store,
26
+ gate: 'auth/Gate',
27
+ listener: node({ port: 8080 }),
28
+ });
29
+
30
+ await server.start();
31
+ ```
32
+
33
+ `auth` is a source, so `start` loads all six of these modules with everything else your
34
+ sources list, and each reads the `store` the server handed in. `gate: 'auth/Gate'` names the
35
+ gate; to keep this policy and add a rule of your own, write a module that `deps` on
36
+ `auth/Gate` and name that instead. `paths` is the three store declarations they query: `email`
37
+ on user documents, `user` and `expires` on session and link documents. Your own source goes
38
+ first, so a module of the same name in your directory replaces one of these. `mail` is the
39
+ two modules that send email, and `notify` is what they send through: leave both out and
40
+ `auth` alone loads as before; list `mail` without `notify` and `start` refuses with `missing`,
41
+ naming `notify/Send`.
42
+
43
+ ## The modules
44
+
45
+ | module | public | what it does |
46
+ |---|---|---|
47
+ | `auth/Gate` | | `identify` reads the session cookie; `access` allows a module that declares `public: true` to anyone, one that declares `needs` to a signed-in user holding the name, and any other module only to a signed-in user |
48
+ | `auth/Session` | yes | `issue(user)`, `revoke(token)`, `revokeAll(user, except?)`, `whoIs(request)`, `setCookie(token, request)`, a `call` answering `{ user }` for the asking connection, and `DELETE /api/session` |
49
+ | `auth/Roles` | | `may(user, name)`, `grant(user, ...names)`, `revoke(user, ...names)`, `names(user)`; shares `roles:<user>` read-only under the topic `roles`, and a `call` answering the `implies` table |
50
+ | `auth/Enter` | yes | `enter(email, password)` signs in, or signs up when nobody has the email; `checkPassword(password)` answers the reasons the rules refuse one; `POST /api/session` does the first and sets the cookie |
51
+ | `auth/Check` | yes | `exists(email)`, and a `call` answering `{ exists }` for `{ email }` |
52
+ | `auth/State` | | shares `state:<user>` from the store on the connection, under the topic `state` |
53
+
54
+ And in `mail`, both naming `notify/Send` in their `deps`:
55
+
56
+ | module | public | what it does |
57
+ |---|---|---|
58
+ | `auth/Verify` | yes | `send(user)` mails a one-time link; `confirm(token)` takes it, writes `emailVerified` and grants the name `verified`; `POST /api/verify/send` and `POST /api/verify` |
59
+ | `auth/Password` | yes | `change(user, current, password, keep?)`, `forgot(email)`, `reset(token, password)`; `POST /api/password`, `POST /api/password/forgot`, `POST /api/password/reset` |
60
+
61
+ **A module of yours declares `public: true`, `needs`, or nothing.** Absent means private: only
62
+ a connection with a user reaches it. `needs: 'reports'` (or a list, meaning every name in it)
63
+ means a signed-in user who holds that name; anonymous is refused `private` whatever else the
64
+ module declares, and a person lacking the name is refused `needs`. Those are this gate's words;
65
+ `@aweftjs/server` does not know them.
66
+
67
+ ## Names
68
+
69
+ A person holds **names**. A role and a feature are the same kind of thing: `admin`,
70
+ `verified`, `posts.delete`, `products.abc123`. A name is non-empty text with no whitespace.
71
+ Names are dotted, and holding one covers everything under it: `products` covers
72
+ `products.abc123.read`, `products.abc123` covers one product, and `*` covers everything.
73
+
74
+ ```ts
75
+ // modules/reports/Monthly.ts: only a person holding `reports` reaches it
76
+ export default () => ({ needs: 'reports', call: () => monthly() });
77
+
78
+ // modules/products/Read.ts: a rule finer than a module, asked of auth/Roles
79
+ import { codecError } from '@aweftjs/codec';
80
+
81
+ export const deps = ['auth/Roles'];
82
+ export default ({ imports }) => ({
83
+ call: async ({ id }, context) => {
84
+ if (!(await imports.Roles.may(context.user, `products.${id}.read`))) throw codecError('needs', `product ${id} is not yours to read`, 'Ask for access.');
85
+ return product(id);
86
+ },
87
+ });
88
+
89
+ // modules/admin/Grant.ts: who grants is yours; this one lets an administrator hand out names
90
+ export const deps = ['auth/Roles'];
91
+ export default ({ imports }) => ({
92
+ needs: 'admin',
93
+ call: ({ user, name }) => imports.Roles.grant(user, name),
94
+ });
95
+ ```
96
+
97
+ **A table says which names imply others**, and it is configuration on `auth/Roles`, the same
98
+ same-named-file way every battery module is configured. `first` is the names the first person
99
+ to sign up is granted, so a fresh application has an administrator without a script:
100
+
101
+ ```ts
102
+ // modules/auth/Roles.ts
103
+ export const config = {
104
+ implies: { admin: ['*'], moderator: ['posts.delete', 'posts.hide'], member: ['products.read'] },
105
+ first: ['admin'],
106
+ };
107
+ ```
108
+
109
+ `may(user, name)` is true when the person was granted the name, was granted one that covers
110
+ it, or was granted one the table implies it from, transitively. The table is keyed by the
111
+ exact name held: `admin.super` implies what `admin.super` lists, not what `admin` does, so a
112
+ hierarchy of roles is written into the table. `holds(granted, implies, name)` is the same
113
+ check as a plain function, exported from the root for code that already holds a list.
114
+
115
+ **Every check reads the store.** There is no cache and no per-connection snapshot: a grant or a
116
+ revoke is seen by the next call, request or connection the person makes, and by the page they
117
+ have open, through the share. A module with no `needs` costs nothing new; a gated one costs one
118
+ document read per check.
119
+
120
+ **Who grants is yours.** No route ships. `grant` and `revoke` are for a module of your own,
121
+ inside the process, which is the one place the security suite's administrator case says a
122
+ grant can come from. `first` goes to the first person to sign up after the module is loaded on
123
+ a store with nobody in it, and a marker document (`auth:first`) says it has happened; loaded on
124
+ a store where people already exist, the module writes the marker for nobody as it is made, so
125
+ an application that configures `first` once its people exist hands nothing to the next
126
+ stranger. Two sign-ups in the same instant in one process settle to one first; two processes
127
+ over one store can each see a first, the same way two sign-ups for one email can both succeed.
128
+
129
+ **`verified` is a name like any other.** `auth/Verify` grants it once the person has opened the
130
+ link, so a module that wants a verified person says `needs: 'verified'`.
131
+
132
+ ## Signing in and out
133
+
134
+ ```
135
+ POST /api/session { "email": "ada@example.com", "password": "..." }
136
+ 201 { "user": "<id>", "created": true } the email was new: signed up
137
+ 200 { "user": "<id>", "created": false } signed in
138
+ 401 { "reasons": [{ "code": "password", ... }] }
139
+ 400 { "reasons": [{ "code": "email" | "password", ... }] }
140
+ 403 { "reasons": [{ "code": ..., ... }] } a sign-up your refuseSignUp closed the door to
141
+ Set-Cookie: session=<token>; Path=/; HttpOnly; SameSite=Lax[; Secure][; Max-Age=...]
142
+
143
+ DELETE /api/session
144
+ 200 { "user": null }, and the cookie is cleared
145
+ ```
146
+
147
+ Identity is fixed for a connection's life, read from the cookie at the handshake. After
148
+ either route the client reconnects; a cookie cannot be set on an open socket.
149
+
150
+ **What the cookie means.** This gate refuses nobody at the door. Every cookie of the name is
151
+ read in the order the browser sent them (it sends every one whose scope matches, so one from
152
+ another path or another application on the host arrives beside ours): an empty value, a
153
+ value that is not a token, and a token that names no live session (a store reset, a
154
+ sign-out, a timeout) are skipped, the first live one wins, and none is anonymous. An
155
+ anonymous client can always reach `DELETE /api/session` to clear what it holds. A refusal at
156
+ the handshake is for a gate that keys on something a client cannot repair by signing in; the
157
+ server's README shows one.
158
+
159
+ **No lifetime ships.** A session lasts until revoked. To set one, configure `auth/Session`
160
+ the way any module is configured, with a same-named file in your own source:
161
+
162
+ ```ts
163
+ // modules/auth/Session.ts
164
+ export const config = { sessionMs: 30 * 24 * 3600 * 1000, cookie: 'session' };
165
+ ```
166
+
167
+ `sessionMs` that is not a positive number is refused when the module is made. `Secure` is on
168
+ the cookie whenever the request came over TLS as the listener sees it; behind a proxy that
169
+ terminates TLS, start the Node listener with `forwarded: true` so the scheme comes from
170
+ `x-forwarded-proto`. Where the site is served over TLS, name the cookie `__Host-session`: a
171
+ browser then refuses to take it from anywhere else, and refuses it over plain HTTP, which is
172
+ why that is not the name it ships with.
173
+
174
+ **A session that ended is swept.** `revoke` writes `expires` as the moment it happened, and
175
+ `auth/Session` removes every session whose end is more than `keep` days ago (30), when the
176
+ module is made and every `sweepMs` (an hour). A session with no lifetime that was never revoked
177
+ is never swept. Both are configuration beside `sessionMs`.
178
+
179
+ ## The bounds
180
+
181
+ The sign-in route counts before it hashes, so a flood buys no hashing. Each number is
182
+ configuration on `auth/Enter`, the same same-named-file way:
183
+
184
+ ```ts
185
+ // modules/auth/Enter.ts
186
+ export const config = {
187
+ attemptsPerEmail: 5, // per email inside the window; a sign-in that succeeds clears it
188
+ attemptsPerAddress: 20, // per peer address inside the window, whatever the emails
189
+ attemptsWindowMs: 900_000, // fifteen minutes
190
+ hashesInFlight: 8, // beyond it, 503 with Retry-After: 1 and no hash started
191
+ passwordMin: 8, // characters; shorter is 400 before anything is hashed
192
+ passwordMax: 256, // longer is 400 too, so a hash is never asked of a megabyte
193
+ refusePassword: null, // (password) => boolean | Promise<boolean>: true refuses with 400
194
+ refuseSignUp: null, // (signUp) => Refusal | undefined, sync or async: a refusal answers 403
195
+ };
196
+ ```
197
+
198
+ Over either attempt count the answer is 429 with a `Retry-After` in seconds and the reason
199
+ `attempts`. The address is what the gate put in the context, which is what the listener saw:
200
+ behind a proxy, start the Node listener with `forwarded` or every client is one address. The
201
+ counts are in memory and a restart clears them; each holds at most 65 536 keys, and past that
202
+ the oldest key under its count goes first, so a flood of fresh emails frees no locked email, and
203
+ a flood that locks that many does. `refusePassword` is where a breached-password list or a
204
+ lookup goes; the battery ships none. Any composition is taken: eight spaces are a password.
205
+
206
+ **Who may sign up is yours** (design 291). `refuseSignUp` is asked once per sign-up, after the
207
+ counts and the password rule and before anything is hashed, with `email` (normalised), `extra`
208
+ (every field of the body but `email` and `password`: an invite token, a role picked on the
209
+ form), `context` (what the gate identified: `user` null, the peer's `address`) and `store`, so
210
+ a rule that reads a document of yours needs no second way to reach it. A refusal it answers is
211
+ the route's 403 with that one reason, and nothing is made. A sign-in never asks it, and neither
212
+ does `enter()` called from a module of yours: the rule is the door's, not the function's. A rule
213
+ that throws is the route's 500. The battery ships none; an application that keeps invites in
214
+ its store writes the lookup here, and marks the invite used in the same function, since a
215
+ password the rule before it refused never reaches it.
216
+
217
+ ```ts
218
+ // modules/auth/Enter.ts: sign-up by invitation
219
+ export const config = {
220
+ refuseSignUp: async ({ email, extra, store }) => {
221
+ const token = typeof extra.invite === 'string' ? extra.invite : '';
222
+ if (token === '' || !(await redeem(store, token, email))) return { code: 'invite', message: 'sign-up is by invitation' };
223
+ return undefined;
224
+ },
225
+ };
226
+ ```
227
+
228
+ Hashing takes memory: scrypt at these parameters holds about 16 MiB per password, so
229
+ `hashesInFlight` is the memory bound and the attempt counts are the rate bound. The count on
230
+ every route before the gate is `@aweftjs/server`'s (`limits`).
231
+
232
+ **Enumeration is accepted.** `auth/Check` answers whether an email has an account, and a sign-up
233
+ answers 201 where a sign-in answers 200, so anyone can learn whether an address is registered.
234
+ A sign-in form asks before asking for a password, and one route signs up and in. What makes it
235
+ survivable is the per-email count: knowing an address exists buys five tries in fifteen minutes.
236
+
237
+ ## The mail flows
238
+
239
+ `mail` is `auth/Verify` and `auth/Password`. Both send through `notify/Send` with
240
+ `channels: ['email']`, so `notify` is in `sources` and configured with a mailer. Each needs one
241
+ setting with no default, because a battery never picks a URL: `url`, a function from the token
242
+ to the address of the page that takes it, which is where you put `auth/Verify` and
243
+ `auth/Reset` from the client half.
244
+
245
+ ```ts
246
+ // modules/auth/Verify.ts
247
+ export const config = {
248
+ url: (token) => `https://app.example/verify?token=${token}`,
249
+ subject: 'Verify your email address',
250
+ verifyMs: 86_400_000, // the link lives a day
251
+ sendsPerUser: 5, // mails one person may ask for inside sendsWindowMs (a day)
252
+ sendsWindowMs: 86_400_000,
253
+ resendMs: 60_000, // and a minute between two
254
+ sweepMs: 3_600_000, // how often expired links are removed
255
+ };
256
+
257
+ // modules/auth/Password.ts
258
+ export const config = {
259
+ url: (token) => `https://app.example/reset?token=${token}`,
260
+ subject: 'Reset your password',
261
+ resetMs: 3_600_000, // the link lives an hour
262
+ attemptsPerUser: 5, // change attempts inside attemptsWindowMs (fifteen minutes); the current password is a password being guessed
263
+ attemptsWindowMs: 900_000,
264
+ forgotPerEmail: 5, // forgot asks per email and per address inside forgotWindowMs (a day)
265
+ forgotPerAddress: 20,
266
+ forgotWindowMs: 86_400_000,
267
+ sweepMs: 3_600_000,
268
+ };
269
+ ```
270
+
271
+ ```
272
+ POST /api/verify/send signed in: mails the link
273
+ 200 { "ok": true } 401 private 409 verified 429 attempts 502 mail
274
+ POST /api/verify { "token": "..." } anyone: takes the link once
275
+ 200 { "user": "<id>" } 400 token | taken
276
+
277
+ POST /api/password { "current": "...", "password": "..." } signed in
278
+ 200 { "ok": true } 401 password (the current one is wrong) 400 password (the new one) 429 attempts
279
+ POST /api/password/forgot { "email": "..." } anyone: 200 whatever the address
280
+ 200 { "ok": true } 400 email 429 attempts 502 mail
281
+ POST /api/password/reset { "token": "...", "password": "..." } anyone
282
+ 200 { "user": "<id>" } 400 token | taken | password
283
+ ```
284
+
285
+ **A link is one use, and lives its lifetime.** A token is what a session token is, sixteen
286
+ random bytes; the document `verify:<token>` or `reset:<token>` names who it is for and when it
287
+ ends. Taking it marks the document `taken`, and the sweep removes it once it has expired,
288
+ taken or not. So a token names one of three things: a live link, a taken one (400 `taken`,
289
+ which for a verification link means the address is verified, and a page may say so), or
290
+ nothing (400 `token`, for a link nobody issued or one past its end; design 294). Taking a
291
+ verification link writes `emailVerified` on the user and grants `verified`. Taking a reset
292
+ link sets the password and ends every session of the person. A change needs the current
293
+ password, sets the new one, and ends every other session, keeping the one that asked. The new
294
+ password goes through `auth/Enter`'s `checkPassword`, so `passwordMin`, `passwordMax` and
295
+ `refusePassword` apply once.
296
+
297
+ **The mail says one thing and carries one link.** The body is the sentence and the address as
298
+ text, the HTML the same with the address as a link, under `subject`. For a mail of your own,
299
+ write a module that names `notify/Send` and calls `send` with your `html`; the two here own no
300
+ template. A mailer that answers anything but ok, or throws, is 502 with the reason `mail`, and
301
+ the link still stands, so the person asks again once the mailer is back. That reason carries
302
+ `detail` and `fix` beside `code` and `message`: `message` is the one sentence a page shows the
303
+ person, `detail` is what the mailer said, word for word, and `fix` tells whoever runs the server
304
+ where to look (design 293).
305
+
306
+ **`forgot` answers 200 for an address nobody has**, and sends nothing, although `auth/Check`
307
+ enumerates: the mail route is the one that costs a send, and a stranger typing addresses must
308
+ not steer it. While the mailer is down a known address answers 502 and an unknown one 200,
309
+ which tells them apart; `auth/Check` already does. The counts are in memory and a restart
310
+ clears them, as sign-in's are.
311
+
312
+ **A verification mail goes only when asked.** Nothing is sent at sign-up. A page asks for it
313
+ with `verify()`, or a module of yours calls `Verify.send(user)` after `enter`.
314
+
315
+ ## The client half
316
+
317
+ ```ts
318
+ import { createClient } from '@aweftjs/client';
319
+ import { createAuth } from '@aweftjs/auth/client';
320
+
321
+ const client = createClient(); // the page's own origin
322
+ const auth = createAuth(client); // the page's own origin, and global fetch
323
+
324
+ auth.user.effect((who) => header.textContent = who ?? 'signed out');
325
+
326
+ const outcome = await auth.enter('ada@example.com', 'correct horse battery staple');
327
+ if ('refused' in outcome) show(outcome.refused);
328
+ // A sign-up rule on the server reads a third argument: auth.enter(email, password, { invite })
329
+
330
+ const state = await auth.state<State>().ready;
331
+ state.theme = 'dark'; // applies here, and goes
332
+
333
+ deleteButton.hidden = auth.names.map(() => !auth.may('posts.delete')); // shown or hidden; the server refuses either way
334
+ await auth.verify(); // the verification mail
335
+ await auth.change(current, next); // the password; the socket stays signed in
336
+ await auth.forgot('ada@example.com'); // the reset mail, 200 whatever the address
337
+ await auth.reset(token, next); // from the link; every session ends, this page's too
338
+ ```
339
+
340
+ `createAuth(client, { origin?, fetch? })` adds identity to a connection. It never opens or
341
+ closes the connection for good; `@aweftjs/client` owns the socket. After sign-in and sign-out it
342
+ asks the client to reconnect, because identity is fixed per socket.
343
+
344
+ **`user` has three states.** `undefined` until the server has answered, `null` for an anonymous
345
+ connection, and the user's id otherwise. It is a read-only cell, so a page renders all three and
346
+ follows the value through sign-in and sign-out. It is asked again on every socket that opens,
347
+ because identity is read from the cookie at the handshake and is fixed for the connection's
348
+ life. A drop leaves the last value alone; the socket after it refreshes.
349
+
350
+ **`enter` and `leave` reconnect.** A browser cannot set a cookie on an open socket, so after
351
+ either route the client drops that socket and opens a new one, and the call resolves once `user`
352
+ is known again. `enter` answers `{ user, created }` on 200 or 201 and `{ refused }` on 400 or
353
+ 401, in the same shape `auth/Enter` uses on the server. `leave` resolves once the page is
354
+ anonymous again.
355
+
356
+ **`names` and `may` are for showing and hiding.** `names` is a read-only cell: `undefined`
357
+ until the server has answered, `[]` for an anonymous connection, the granted list otherwise,
358
+ following the `roles` share while the socket is open, so a name granted on the server appears
359
+ on the page with no reconnect. `may(name)` runs the same check the server's gate runs, over
360
+ those names and the table the `auth/Roles` call answered; false until `names` is known. A page
361
+ hides a button with it. The server's gate is what refuses, and a page that shows the button
362
+ anyway changes nothing.
363
+
364
+ **`verify`, `change`, `forgot` and `reset` are the mail routes**, each answering `{ ok: true }`
365
+ or `{ refused }` with the route's reasons, and rejecting `<name>-failed` for any other status.
366
+ `verify()` asks for the mail and `verify(token)` takes the link; neither reconnects, and
367
+ `verified` reaches `names` through the share. `change` keeps the session that asked. `reset`
368
+ reconnects, because every session of the person is over, this page's included when it was
369
+ theirs, so `user` reads `null` afterwards.
370
+
371
+ **`state()` is the signed-in user's own document.** Its `ready` rejects with `anonymous` when
372
+ there is no user, at once rather than waiting for a topic the server will never offer, and waits
373
+ for the answer when nobody has said yet. One connection carries one state document: the server
374
+ offers the topic once per socket, so asking twice hands back the same handle until `stop()`,
375
+ after which it refuses `stopped`, and only the socket `enter` or `leave` opens brings a new one.
376
+ `check(email)` asks `auth/Check`. `stop()` stops following the connection and leaves the client
377
+ running; every call after it refuses `stopped`.
378
+
379
+ **When `user` changes underneath the page, the handle it holds is stopped.** The server forgets
380
+ the session, or another user's cookie replaces it, and the client comes back on its own as
381
+ somebody else. The handle the page is still holding was made for the old user, so it is stopped
382
+ and follows the server no further; the next `state()` answers for whoever the connection is now,
383
+ and a page follows `user` to notice. `enter` and `leave` refuse with `closed` when their route
384
+ answered but the client has since been closed.
385
+
386
+ **Two seams outside a browser**, and a page needs neither. `origin` is where the session routes
387
+ are, the page's own by default and read when a route is first called, so making an auth outside a
388
+ page is fine and only calling one refuses `no-origin`. `fetch` makes the two HTTP calls, the
389
+ global by default; a Node program hands in one that carries the cookie, because Node's `fetch`
390
+ keeps no cookie jar. The client's own `open` seam is the third, and it is `@aweftjs/client`'s: a
391
+ Node program hands in a socket carrying the same cookie header.
392
+ [`recipes/client`](https://github.com/torrinworx/aweft/tree/main/recipes/client) runs all three.
393
+
394
+ ## The page modules
395
+
396
+ `@aweftjs/auth/client` also exports `authClient`, a source of four modules for a stage's
397
+ `sources`. Put it there and the sign-in form, the verification page and the reset form are
398
+ each one line in the acts map.
399
+
400
+ ```tsx
401
+ import { authClient } from '@aweftjs/auth/client';
402
+
403
+ <StageContext
404
+ sources={[app, authClient]}
405
+ client={client}
406
+ acts={{ '': Home, notes: 'notes/Page', join: 'auth/SignIn', verify: 'auth/Verify', reset: 'auth/Reset' }}
407
+ refused="join"
408
+ >
409
+ <Stage />
410
+ </StageContext>
411
+ ```
412
+
413
+ | module | what it is |
414
+ |---|---|
415
+ | `auth/Session` | `createAuth` over the `client` the stage handed the loader. Its instance is the `Auth` above, so any module of yours that needs to know who the page is names it in `deps` |
416
+ | `auth/SignIn` | an act module: the sign-in and sign-up form, in one, because `enter` does both |
417
+ | `auth/Verify` | an act module: with a `token` in the act's parameters or the URL's query it takes the link as it mounts and says what happened; without one it offers a signed-in person the mail |
418
+ | `auth/Reset` | an act module: without a `token` the forgot form, with one the new-password form |
419
+
420
+ **The battery never picks a URL.** `auth/SignIn` lands on the address you name it at, and
421
+ `refused: 'join'` is what puts it in front of a page your own gate module refused. `auth/Verify`
422
+ and `auth/Reset` land where you name them too, and the `url` you configure on the server's
423
+ `auth/Verify` and `auth/Password` is what points the mail at those addresses:
424
+ `verify: 'auth/Verify'` in the acts map goes with `url: (token) =>
425
+ 'https://app.example/verify?token=' + token` in `modules/auth/Verify.ts`.
426
+
427
+ **Configuring `auth/Session`** is the ordinary module thing: a file exporting only `config`, in a
428
+ source before this one. It reads `origin` and `fetch`, the same two seams `createAuth` takes.
429
+
430
+ ```ts
431
+ // modules/auth/Session.ts, in your own source
432
+ export const config = { origin: 'https://api.example.com' };
433
+ ```
434
+
435
+ **Replacing any of them** is the same rule every battery module has: a module of that name in
436
+ an earlier source wins. A sign-in form of your own is `auth/SignIn` in your own directory.
437
+
438
+ **`auth/SignIn` picks no URL after a successful `enter`.** The visitor stays on the address they
439
+ asked for. When the form is what `refused` put there, the stage handed it a `retry`, and it calls
440
+ that: the act the URL chose is built again in place, so the gated page appears with no navigation.
441
+ On a URL of its own the form has no `retry` and a success does nothing at all.
442
+
443
+ **No gate module ships.** "Allowed" means something different in every application, so the page
444
+ writes its own and the act that needs it names it in `deps`. A gate that wants a name waits for
445
+ `names` the same way it waits for `user`, and reads `may`:
446
+
447
+ `user` reads `undefined` until the first socket answers, so a gate waits for the first answer that
448
+ is not `undefined`. Reading `undefined` as "not signed in" would let a stranger in for as long as
449
+ the handshake takes. The wait is safe on a static render too, because there is no socket there and
450
+ identity is answered from the first read.
451
+
452
+ ```ts
453
+ import { codecError } from '@aweftjs/codec';
454
+
455
+ export const deps = ['auth/Session'];
456
+
457
+ /** The first value of a cell that is not `undefined`, which is the first real answer. */
458
+ const answered = (cell) => {
459
+ const held = cell.get();
460
+ return held !== undefined ? held : new Promise((done) => {
461
+ const off = cell.watch((now) => {
462
+ if (now === undefined) return;
463
+ off();
464
+ done(now);
465
+ });
466
+ });
467
+ };
468
+
469
+ export default ({ imports }) => ({
470
+ require: async (name) => {
471
+ const who = await answered(imports.Session.user);
472
+ if (who === null) {
473
+ throw codecError('anonymous', 'this page is for a signed-in user', 'Sign in first.');
474
+ }
475
+ await answered(imports.Session.names);
476
+ if (name !== undefined && !imports.Session.may(name)) {
477
+ throw codecError('needs', `this page is for someone holding ${name}`, 'Ask for access.');
478
+ }
479
+ return who;
480
+ },
481
+ });
482
+ ```
483
+
484
+ The act calls `require()`, or `require('reports')`, in its own factory, the load rejects, and
485
+ the stage shows whatever `refused` names. The check on the page is what decides which act
486
+ shows; the server's gate is what decides what the act can reach. [`recipes/client`](https://github.com/torrinworx/aweft/tree/main/recipes/client)
487
+ is the whole pattern in one small application.
488
+
489
+ **A static render has no connection.** Handed no `client`, `auth/Session` is anonymous at once:
490
+ `user` reads `null` and `names` reads `[]` from the first read, `may` is false, `state()`
491
+ rejects `anonymous`, and `enter`, `leave`, `check` and the four mail calls refuse `no-client`.
492
+ So it is an anonymous static render: a gated act shows the refused act, and a module reading a
493
+ document with no client renders its waiting state. Handed something that is not a client, it
494
+ refuses `no-client` too, naming what was missing.
495
+
496
+ ## What is stored
497
+
498
+ Six kinds of document in your store, named by prefix:
499
+
500
+ - `user:<id>`: `email`, `name`, `password` (the scrypt hash, with its parameters and salt
501
+ written into it; never the password), `emailVerified`, `createdAt`, `modifiedAt`. Never
502
+ shared on a link.
503
+ - `session:<token>`: `user`, `expires` (the lifetime's end, the moment of revocation, or null
504
+ for never), `status` (`active` or `revoked`), `createdAt`.
505
+ - `roles:<user>`: `names`, what the person was granted, and `modifiedAt`. Shared to its own
506
+ user under `roles`, refusing every commit. `auth:first` is the one-line marker that says a
507
+ first sign-up has been seen.
508
+ - `state:<user>`: whatever you keep per user. `auth/State` shares it under `state`, accepting
509
+ every commit, because it is theirs.
510
+ - `verify:<token>` and `reset:<token>`: `user`, `expires`, `createdAt`, and `taken` once it
511
+ has been. Removed by the sweep once expired.
512
+
513
+ The state document is an `@aweftjs/core` observable, as every document in the store is: put
514
+ a list in it with `createArray` from `@aweftjs/core`, an object with `createObject`, and group
515
+ writes with `atomic`; that package's README shows the shapes. **Keep its root an object.**
516
+ `paths` declares `email`, `user` and `expires` for every document in the store, and the store
517
+ refuses a declared path that meets an array, so a document whose root is an array cannot be
518
+ written to a store that declares them; a list goes in a field. A store that does not declare
519
+ them refuses `auth/Session` as it is made, naming the fix.
520
+
521
+ Ids are the stack's ids: twelve random bytes from the platform's secure source, sixteen
522
+ characters. A session token is sixteen random bytes of its own from the same source, twenty-two
523
+ characters, because a credential needs 128 bits and an id was never one. Passwords are hashed
524
+ with Node's own `scrypt` and compared with Node's own `timingSafeEqual`; there is no dependency,
525
+ and no test here measures timing.
526
+
527
+ Two sign-ups for one email in the same instant can both succeed, because a unique index is
528
+ the store driver's and none ships. A sign-in with a mistyped new email creates an account:
529
+ that is what one module for both means, and `auth/Check` exists so a form can ask first.
530
+
531
+ The gate puts the peer address in the context beside `user` and `session`, as the listener saw
532
+ it, so a route of yours can count by it the way `auth/Enter` does.
533
+
534
+ ## What it never does
535
+
536
+ Decide which names exist, beyond `verified`, or what any of them means. Grant over the wire.
537
+ Expire a name. Show another person's names to a page. Change an email address, and so decide
538
+ what one does to `verified`. Send a mail nobody asked for. Own a mail template. Pick a URL.
539
+ Cache a check: every `may` reads the store, and an application that loads this battery has
540
+ said the read is worth it.
541
+
542
+ `securityChecks()` from `@aweftjs/testing` runs against this battery from its own suite and from
543
+ `recipes/full-stack/tests/security.test.ts`; that file is how an application runs it against
544
+ itself.
545
+
546
+ The design notes are in
547
+ [`docs/design/`](https://github.com/torrinworx/aweft/tree/main/docs/design) 071 and 074, 185 and
548
+ 245 for the client half, 289 for names and 290 for the mail flows.