@uniweb/api 0.7.0 → 0.9.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 +87 -26
- package/bin/uniweb-api-mock.js +8 -19
- package/package.json +2 -2
- package/src/client.js +60 -3
- package/src/hooks/useMailedLink.js +149 -0
- package/src/hooks/useRecords.js +2 -2
- package/src/hooks/useSession.js +7 -1
- package/src/index.js +6 -0
- package/src/mailed-links.js +132 -0
- package/src/mock/index.js +99 -11
- package/src/mock/seed.js +3 -0
- package/src/mock/store.js +75 -20
- package/src/wire.js +130 -21
package/README.md
CHANGED
|
@@ -52,22 +52,77 @@ function Account() {
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
`viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
|
|
55
|
-
a list of `{ role, scope_unit_id }`.
|
|
56
|
-
holds
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
a list of `{ role, scope_unit_id }`. ⭐ **Roles describe the session, and a session
|
|
56
|
+
signed in on a site holds a member's rights — the operator's too.** A site runs your
|
|
57
|
+
foundation's code with the signed-in member's session, so no administrator's role
|
|
58
|
+
rides with it: `viewer.roles` never names one there, and an ordinary member holds none
|
|
59
|
+
(`[]`). `viewer.workspace` — the site service's home org, `{ handle: 'home', … }` — is
|
|
60
|
+
the same for everyone. To decide whether to show an edit control for a particular
|
|
61
|
+
record, read that record: `entity.can_edit` is the backend's own answer.
|
|
60
62
|
|
|
61
63
|
**Signing up** — `useSignUp().signUp({ username, email, password })` answers
|
|
62
64
|
`{ status: 'verification_required', email }`, the same answer for an address already in
|
|
63
65
|
use. The account cannot sign in until the address is confirmed from the email the
|
|
64
|
-
backend sends; until then `signIn` fails with
|
|
65
|
-
already taken fails with status `409`.
|
|
66
|
+
backend sends — its link lands on your site (below); until then `signIn` fails with
|
|
67
|
+
`error.kind === 'unverified'`. A username already taken fails with status `409`.
|
|
66
68
|
|
|
67
|
-
**Password reset** — `usePasswordReset()
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`
|
|
69
|
+
**Password reset** — `usePasswordReset().request({ email })` asks for one; the email's
|
|
70
|
+
link lands on your site, where `useMailedLink` finishes it. **Second factor** — when
|
|
71
|
+
`signIn` answers `{ ok: false, challenge: { kind: 'totp' } }`, finish with
|
|
72
|
+
`completeChallenge(code)`.
|
|
73
|
+
|
|
74
|
+
**The links the backend mails** — a sign-up's confirmation, a password reset, an
|
|
75
|
+
invitation sent to an address with no account here: each is a link to your site's root,
|
|
76
|
+
a token in the query (`?verify=`, `?reset=`, `?offer=`), and the page it lands on
|
|
77
|
+
finishes the step. `useMailedLink()` says which step, and finishes it:
|
|
78
|
+
|
|
79
|
+
```jsx
|
|
80
|
+
import { useMailedLink } from '@uniweb/api'
|
|
81
|
+
|
|
82
|
+
// Wherever the site's root renders it — a header or a layout is on every page.
|
|
83
|
+
function FromYourEmail() {
|
|
84
|
+
const { step, status, result, error, confirm } = useMailedLink()
|
|
85
|
+
if (step === 'verify' && status === 'done') return <p>Your address is confirmed. Sign in.</p>
|
|
86
|
+
if (step === 'reset' && status !== 'done') {
|
|
87
|
+
return <NewPassword onSave={(password) => confirm({ new_password: password })} error={error} />
|
|
88
|
+
}
|
|
89
|
+
if (step === 'offer' && status === 'idle') return <p>Sign in, or create an account, to take up your invitation.</p>
|
|
90
|
+
if (step === 'offer' && status === 'done' && result.invitations + result.grants > 0) return <p>Your invitation is in.</p>
|
|
91
|
+
if (status === 'error' || status === 'done') return <p>This link can no longer be used.</p>
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `verify` finishes by itself. `reset` waits for `confirm({ new_password, code? })` —
|
|
97
|
+
`code` when the account has a second factor — and supplies the token; a password the
|
|
98
|
+
backend's rule refuses is `error.extensions.reason === 'password_too_short'` (with
|
|
99
|
+
`min_length`), and the same link works again. `offer` waits until someone is signed
|
|
100
|
+
in — whatever their account's own address — and then claims by itself.
|
|
101
|
+
- An offer answers counts, `{ invitations, grants }` — invitations now waiting for this
|
|
102
|
+
account to accept, and entities now shared with it — never names. Both `0` means it
|
|
103
|
+
bound nothing. ⛔ A token unknown, revoked or already used is ONE answer,
|
|
104
|
+
`error.kind === 'absent'`: say one sentence, naming no cause.
|
|
105
|
+
- Once the backend has judged the token, it leaves the address bar, so a reload asks
|
|
106
|
+
nothing again; a link the backend could not be reached about (`kind: 'unavailable'`)
|
|
107
|
+
stays, and works on a reload. However many components use the hook, the link is
|
|
108
|
+
finished once. `dismiss()` forgets it unfinished.
|
|
109
|
+
|
|
110
|
+
**The Admin link** — some of the site's members administer its backend, the operator
|
|
111
|
+
among them. For them, `adminHref` is the address that takes them to the backend's
|
|
112
|
+
administration, already signed in; for everyone else it is `null`, so draw the link
|
|
113
|
+
only when it is set:
|
|
114
|
+
|
|
115
|
+
```jsx
|
|
116
|
+
const { adminHref } = useSession()
|
|
117
|
+
|
|
118
|
+
{adminHref && <a href={adminHref}>Admin</a>}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
⛔ **Navigate to it — a link, or `location.assign(adminHref)` — never `fetch` it.** It
|
|
122
|
+
answers with a redirect carrying a one-time credential that no script on the site is
|
|
123
|
+
to hold. The link decides what to offer, not what is allowed: on the site, an
|
|
124
|
+
administrator's session acts as a member's, and administering happens at the other
|
|
125
|
+
end of the link.
|
|
71
126
|
|
|
72
127
|
## Reading
|
|
73
128
|
|
|
@@ -84,9 +139,9 @@ live source — no `backend` service, or nobody signed in — so render your sit
|
|
|
84
139
|
Showing "nothing yet" for the first case tells a visitor their content is missing
|
|
85
140
|
when it is simply not being asked for.
|
|
86
141
|
|
|
87
|
-
**Whose records.**
|
|
88
|
-
other members' are private unless the site's
|
|
89
|
-
|
|
142
|
+
**Whose records.** Everyone signed in on the site — its operator too — reads their own
|
|
143
|
+
entities and what was shared with them; other members' are private unless the site's
|
|
144
|
+
service was set up to let members read each other's. `scope: 'mine'` lists only the
|
|
90
145
|
viewer's own.
|
|
91
146
|
|
|
92
147
|
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
@@ -182,7 +237,7 @@ import { createMockBackend } from '@uniweb/api/mock'
|
|
|
182
237
|
export default createMockBackend({
|
|
183
238
|
seed: {
|
|
184
239
|
accounts: [
|
|
185
|
-
{ username: 'me', password: 'me', operator: true }, // the site
|
|
240
|
+
{ username: 'me', password: 'me', operator: true }, // runs the site, and gets the Admin link
|
|
186
241
|
{ username: 'member', password: 'member' },
|
|
187
242
|
],
|
|
188
243
|
schemas: {
|
|
@@ -210,27 +265,33 @@ production, and nothing in your site's configuration changes between the two.
|
|
|
210
265
|
|
|
211
266
|
⭐ **The mock answers what the backend answers** — the same statuses, bodies and
|
|
212
267
|
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
268
|
+
which sections are insert-only, the fields a section takes — and it is as strict as
|
|
269
|
+
the backend about requests, so a mistake fails here rather than in front of a user.
|
|
270
|
+
Everyone signed in — the operator too, as on a real site — reads and writes their own
|
|
271
|
+
entities and nothing of anyone else's (`memberFloor: 'read'` in the seed models a
|
|
272
|
+
service set up to let members read each other's — or per Model,
|
|
218
273
|
`memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
|
|
219
|
-
each member's own records stay private).
|
|
220
|
-
|
|
221
|
-
|
|
274
|
+
each member's own records stay private). The mail the backend would send — a sign-up's
|
|
275
|
+
confirmation, a password reset — is printed where the mock runs, in the terminal of
|
|
276
|
+
`uniweb dev`, its link on your site's root as the backend's is, so following it is
|
|
277
|
+
trying your page; the messages are in `mock.outbox` too. To try an invitation, seed one:
|
|
278
|
+
`offers: [{ email: 'guest@example.test', invitations: 1 }]` is mailed when the mock starts.
|
|
279
|
+
The operator's Admin link answers too, landing on a page of the mock's own — the backend's
|
|
280
|
+
administration is not part of it. State is in memory: restart to reset.
|
|
222
281
|
|
|
223
|
-
There is also a standalone server, for a frontend that is not a Uniweb site
|
|
282
|
+
There is also a standalone server, for a frontend that is not a Uniweb site — `--site`
|
|
283
|
+
names that frontend's origin, where the mail's links land:
|
|
224
284
|
|
|
225
285
|
```bash
|
|
226
|
-
npx uniweb-api-mock --port 8787
|
|
286
|
+
npx uniweb-api-mock --port 8787 --site http://localhost:3000
|
|
227
287
|
```
|
|
228
288
|
|
|
229
289
|
## Outside React
|
|
230
290
|
|
|
231
291
|
`@uniweb/api/client` carries the same operations as plain functions and imports no
|
|
232
292
|
React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
|
|
233
|
-
`createEntity`, `deleteEntity
|
|
293
|
+
`createEntity`, `deleteEntity`, `adminHref`, and for the links the backend mails,
|
|
294
|
+
`mailedLink`, `verifyEmail`, `claimOffer` and `forgetMailedLink`.
|
|
234
295
|
|
|
235
296
|
## License
|
|
236
297
|
|
package/bin/uniweb-api-mock.js
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* then same-origin with the site, which is what a real deployment looks like. Use
|
|
7
7
|
* this when the frontend is not a Uniweb site, or when proxying to it.
|
|
8
8
|
*
|
|
9
|
-
* uniweb-api-mock [--port 8787] [--prefix /_api] [--seed ./seed.js]
|
|
9
|
+
* uniweb-api-mock [--port 8787] [--prefix /_api] [--seed ./seed.js] [--site http://localhost:3000]
|
|
10
10
|
*
|
|
11
|
-
* The site's `
|
|
12
|
-
* send — a sign-up's
|
|
11
|
+
* The site's `backend` address for it is the URL it prints. Mail the backend would
|
|
12
|
+
* send — a sign-up's confirmation, a password reset, an invitation — is printed here
|
|
13
|
+
* instead, its link on the site's root as the backend mails it: on `--site`, the
|
|
14
|
+
* frontend's origin, or this server's when none is given.
|
|
13
15
|
*/
|
|
14
16
|
import { createMockBackend } from '../src/mock/index.js'
|
|
15
17
|
import { serve } from '../src/mock/node.js'
|
|
@@ -23,23 +25,10 @@ const flag = (name, fallback) => {
|
|
|
23
25
|
const seedPath = flag('seed', null)
|
|
24
26
|
const seed = seedPath ? (await import(new URL(seedPath, `file://${process.cwd()}/`).href)).default : undefined
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
// The mock prints each message as it is sent (`printMail`), its link on the site's origin.
|
|
29
|
+
const mock = createMockBackend({ ...(seed ? { seed } : {}), siteOrigin: flag('site', null) })
|
|
27
30
|
const prefix = flag('prefix', '')
|
|
28
|
-
|
|
29
|
-
// Print each message the moment it is "sent", with what finishes it.
|
|
30
|
-
let printed = 0
|
|
31
|
-
const serving = {
|
|
32
|
-
fetch: async (request) => {
|
|
33
|
-
const response = await mock.fetch(request)
|
|
34
|
-
for (const message of mock.outbox.slice(printed)) {
|
|
35
|
-
console.log(` ✉ ${message.subject} → ${message.to}`)
|
|
36
|
-
console.log(message.verify ? ` verify: GET ${prefix}${message.verify}` : ` token: ${message.token}`)
|
|
37
|
-
}
|
|
38
|
-
printed = mock.outbox.length
|
|
39
|
-
return response
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
const server = await serve(serving, { port: Number(flag('port', 8787)), prefix })
|
|
31
|
+
const server = await serve(mock, { port: Number(flag('port', 8787)), prefix })
|
|
43
32
|
|
|
44
33
|
console.log(`uniweb-api-mock listening on ${server.url}`)
|
|
45
34
|
console.log(` accounts: ${mock.store.accounts.map((a) => `${a.username}${a.operator ? ' (operator)' : ''}`).join(', ')}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=20.19"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@uniweb/core": "^0.38.
|
|
37
|
+
"@uniweb/core": "^0.38.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^19.0.0"
|
package/src/client.js
CHANGED
|
@@ -15,8 +15,9 @@ import { getUniweb, deriveCacheKey } from '@uniweb/core'
|
|
|
15
15
|
import { resolveService } from '@uniweb/core'
|
|
16
16
|
import { ApiError } from './errors.js'
|
|
17
17
|
import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
|
|
18
|
-
import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP } from './wire.js'
|
|
18
|
+
import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP, VIEWER } from './wire.js'
|
|
19
19
|
import { Ledger } from './ledger.js'
|
|
20
|
+
import { verifyEmailWith, claimOfferWith } from './mailed-links.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* The site service this package reads its base from — the only name it owns.
|
|
@@ -353,6 +354,7 @@ export class ApiClient {
|
|
|
353
354
|
...account,
|
|
354
355
|
roles: Array.isArray(me?.roles) ? me.roles : [],
|
|
355
356
|
workspace: viewerWorkspace(me?.workspace),
|
|
357
|
+
adminApp: me?.[VIEWER.capabilities]?.[VIEWER.adminApp] === true,
|
|
356
358
|
})
|
|
357
359
|
if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
|
|
358
360
|
return this.setSession({ status: 'authenticated', viewer, error: null })
|
|
@@ -449,6 +451,31 @@ export class ApiClient {
|
|
|
449
451
|
return this.request('POST', AUTH.resetConfirm, { body: fields, onUnauthorized: 'ignore' })
|
|
450
452
|
}
|
|
451
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Confirm an address with the token of the link the backend mailed for it — `?verify=`
|
|
456
|
+
* on the site's root (`mailedLink`). Starts no session: once confirmed, the member
|
|
457
|
+
* signs in. A link used, expired or never valid throws `kind: 'invalid'`.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} token
|
|
460
|
+
* @returns {Promise<{ verified: true }>}
|
|
461
|
+
*/
|
|
462
|
+
verifyEmail(token) {
|
|
463
|
+
return verifyEmailWith(this, token)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Take up an invitation sent to an address with no account here — with the token of
|
|
468
|
+
* its mailed link, `?offer=` — for the account signed in, whatever its own address.
|
|
469
|
+
* Answers counts: `{ invitations, grants }`. A token unknown, revoked or already
|
|
470
|
+
* claimed throws `kind: 'absent'`, one answer by design; nobody signed in, `auth`.
|
|
471
|
+
*
|
|
472
|
+
* @param {string} token
|
|
473
|
+
* @returns {Promise<{ invitations: number, grants: number }>}
|
|
474
|
+
*/
|
|
475
|
+
claimOffer(token) {
|
|
476
|
+
return claimOfferWith(this, token)
|
|
477
|
+
}
|
|
478
|
+
|
|
452
479
|
// ── The cache ─────────────────────────────────────────────────────────────
|
|
453
480
|
|
|
454
481
|
/**
|
|
@@ -627,8 +654,9 @@ export class ApiClient {
|
|
|
627
654
|
* a `401` too, never an empty list (so `records: []` means empty).
|
|
628
655
|
*
|
|
629
656
|
* `scope` decides whose: the default, `accessible`, is everything the viewer may
|
|
630
|
-
* read —
|
|
631
|
-
*
|
|
657
|
+
* read — signed in on a site, their own entities and what was shared with them, the
|
|
658
|
+
* operator's session included (`VIEWER` in `./wire.js`). `mine` is only what the
|
|
659
|
+
* viewer owns.
|
|
632
660
|
*
|
|
633
661
|
* ## Paging, as far as it can honestly be absorbed
|
|
634
662
|
*
|
|
@@ -921,6 +949,28 @@ export function getClient() {
|
|
|
921
949
|
return client
|
|
922
950
|
}
|
|
923
951
|
|
|
952
|
+
/**
|
|
953
|
+
* The Admin link — the address that takes the viewer to the backend's administration,
|
|
954
|
+
* signed in — or `null` when there is none to offer: no backend, nobody signed in, or
|
|
955
|
+
* an account the administration does not admit (`viewer.adminApp`).
|
|
956
|
+
*
|
|
957
|
+
* ⛔ **For a navigation — an `<a href>`, `location.assign` — never a `fetch`.** It
|
|
958
|
+
* answers with a redirect carrying a one-time credential that no script on a site is to
|
|
959
|
+
* hold, and a fetch that follows it spends the code (`AUTH.memberHandoff`).
|
|
960
|
+
*
|
|
961
|
+
* A function of the client's base and a session snapshot, not a method: an instance an
|
|
962
|
+
* older copy of this package created has neither, and works here all the same.
|
|
963
|
+
*
|
|
964
|
+
* @param {ApiClient|null} client
|
|
965
|
+
* @param {object} [session] - a snapshot; the client's current one by default
|
|
966
|
+
* @returns {string|null}
|
|
967
|
+
*/
|
|
968
|
+
export function adminHrefOf(client, session = client?.session) {
|
|
969
|
+
const base = client?.base ?? null
|
|
970
|
+
if (base === null || session?.status !== 'authenticated' || session.viewer?.adminApp !== true) return null
|
|
971
|
+
return composeUrl(base, AUTH.memberHandoff)
|
|
972
|
+
}
|
|
973
|
+
|
|
924
974
|
// ── The functions — the same client, outside React ───────────────────────────
|
|
925
975
|
|
|
926
976
|
function required() {
|
|
@@ -943,6 +993,10 @@ export const signUp = (fields) => required().signUp(fields)
|
|
|
943
993
|
export const requestPasswordReset = (fields) => required().requestPasswordReset(fields)
|
|
944
994
|
/** @see ApiClient#confirmPasswordReset */
|
|
945
995
|
export const confirmPasswordReset = (fields) => required().confirmPasswordReset(fields)
|
|
996
|
+
/** @see ApiClient#verifyEmail — on any instance, an older copy's too */
|
|
997
|
+
export const verifyEmail = (token) => verifyEmailWith(required(), token)
|
|
998
|
+
/** @see ApiClient#claimOffer — on any instance, an older copy's too */
|
|
999
|
+
export const claimOffer = (token) => claimOfferWith(required(), token)
|
|
946
1000
|
/** @see ApiClient#readEntity */
|
|
947
1001
|
export const readEntity = (args) => required().readEntity(args)
|
|
948
1002
|
/** @see ApiClient#listEntities */
|
|
@@ -953,6 +1007,9 @@ export const writeItems = (args) => required().writeItems(args)
|
|
|
953
1007
|
export const createEntity = (args) => required().createEntity(args)
|
|
954
1008
|
/** @see ApiClient#deleteEntity */
|
|
955
1009
|
export const deleteEntity = (args) => required().deleteEntity(args)
|
|
1010
|
+
/** The Admin link for the current viewer, or `null` — never throws; see `adminHrefOf`. */
|
|
1011
|
+
export const adminHref = () => adminHrefOf(getClient())
|
|
956
1012
|
|
|
1013
|
+
export { mailedLink, forgetMailedLink } from './mailed-links.js'
|
|
957
1014
|
export { ApiError, kindOf } from './errors.js'
|
|
958
1015
|
export { Ledger } from './ledger.js'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { ApiError } from '../errors.js'
|
|
4
|
+
import { LINK } from '../wire.js'
|
|
5
|
+
import { mailedLink, forgetMailedLink, finishMailedLink, spends } from '../mailed-links.js'
|
|
6
|
+
|
|
7
|
+
const IDLE = Object.freeze({ status: 'idle', result: null, error: null })
|
|
8
|
+
const noSubscribe = () => () => {}
|
|
9
|
+
const noSnapshot = () => null
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The step a link the site's backend mailed came to finish — on the page it lands on.
|
|
13
|
+
*
|
|
14
|
+
* A site's backend mails its members links to the site's root, a token in the query:
|
|
15
|
+
* `?verify=` to confirm a new account's address, `?reset=` to choose a new password,
|
|
16
|
+
* `?offer=` to take up an invitation sent to an address with no account here. Draw the
|
|
17
|
+
* member area from `step` and `status`:
|
|
18
|
+
*
|
|
19
|
+
* ```jsx
|
|
20
|
+
* const link = useMailedLink()
|
|
21
|
+
* // link.step: null | 'verify' | 'reset' | 'offer'
|
|
22
|
+
* // link.status: 'idle' | 'working' | 'done' | 'error'
|
|
23
|
+
* if (link.step === 'verify' && link.status === 'done') return <p>Your address is confirmed. Sign in.</p>
|
|
24
|
+
* if (link.step === 'reset' && link.status !== 'done') return <NewPassword onSubmit={(p) => link.confirm({ new_password: p })} />
|
|
25
|
+
* if (link.step === 'offer' && link.status === 'done') return <p>{link.result.invitations} invitation(s) waiting for you.</p>
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* - **`verify`** finishes by itself: `working`, then `done` (`result: { verified: true }`),
|
|
29
|
+
* or `error` — `kind: 'invalid'` for a link used, expired or never valid.
|
|
30
|
+
* - **`reset`** waits for the new password: `confirm({ new_password, code? })` — `code`
|
|
31
|
+
* when the account has a second factor — and the token is supplied. `done` ends every
|
|
32
|
+
* other session of the account, so the session is asked again. A password the rule
|
|
33
|
+
* refuses is `error`, `kind: 'invalid'` with `error.extensions.reason ===
|
|
34
|
+
* 'password_too_short'` and `min_length`, and the same link works again.
|
|
35
|
+
* - **`offer`** waits for a sign-in — `idle` until someone is signed in, so offer the
|
|
36
|
+
* sign-in and sign-up — then claims by itself: `done` with `result: { invitations,
|
|
37
|
+
* grants }`, COUNTS — invitations waiting for this account to accept, entities now
|
|
38
|
+
* shared with it — or `error`, `kind: 'absent'` for a token unknown, revoked or
|
|
39
|
+
* already claimed. ⛔ One sentence for that, naming no cause: the backend keeps the
|
|
40
|
+
* three indistinguishable on purpose.
|
|
41
|
+
*
|
|
42
|
+
* Once the backend has judged the token, its parameter leaves the address bar, so a
|
|
43
|
+
* reload asks nothing again. `dismiss()` forgets the link without finishing it. On a
|
|
44
|
+
* site with no backend, `step` is always null.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ **Keep the token on the URL, nowhere else.** A sign-up switches what the page shows
|
|
47
|
+
* without navigating, so an offer's token is still there when the new member signs in.
|
|
48
|
+
* A member who signs up under another address confirms it from a link of its own; the
|
|
49
|
+
* offer then waits in the email it came in — a token is a credential, and this package
|
|
50
|
+
* puts it in no storage.
|
|
51
|
+
*
|
|
52
|
+
* ⭐ **One request per link, however many components use this.** The first finishes it,
|
|
53
|
+
* and the others are given the same answer.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{
|
|
56
|
+
* step: 'verify' | 'reset' | 'offer' | null,
|
|
57
|
+
* status: 'idle' | 'working' | 'done' | 'error',
|
|
58
|
+
* result: object | null,
|
|
59
|
+
* error: Error | null,
|
|
60
|
+
* confirm: (fields: { new_password: string, code?: string }) => Promise<object>,
|
|
61
|
+
* dismiss: () => void,
|
|
62
|
+
* }}
|
|
63
|
+
*/
|
|
64
|
+
export function useMailedLink() {
|
|
65
|
+
const client = getClient()
|
|
66
|
+
const session = useSyncExternalStore(
|
|
67
|
+
client ? client.subscribe : noSubscribe,
|
|
68
|
+
client ? () => client.session : noSnapshot,
|
|
69
|
+
client ? () => client.session : noSnapshot,
|
|
70
|
+
)
|
|
71
|
+
const [link, setLink] = useState(null)
|
|
72
|
+
const [state, setState] = useState(IDLE)
|
|
73
|
+
|
|
74
|
+
const live = useRef(true)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
live.current = true
|
|
77
|
+
return () => {
|
|
78
|
+
live.current = false
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
// Read once mounted: the URL is the browser's, and the first render stays the server's.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (client?.enabled) setLink(mailedLink())
|
|
85
|
+
}, [client])
|
|
86
|
+
|
|
87
|
+
// An offer is claimed for whoever is signed in — and again, for another account.
|
|
88
|
+
const signedIn = session?.status === 'authenticated' ? (session.viewer?.uuid ?? 'signed-in') : null
|
|
89
|
+
const claimant = link?.step === LINK.offer ? signedIn : null
|
|
90
|
+
const finishes = !!link && (link.step === LINK.verify || claimant !== null)
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!finishes) return
|
|
94
|
+
let current = true
|
|
95
|
+
setState({ status: 'working', result: null, error: null })
|
|
96
|
+
finishMailedLink(client, link).then(
|
|
97
|
+
(result) => {
|
|
98
|
+
forgetMailedLink(link)
|
|
99
|
+
if (current) setState({ status: 'done', result, error: null })
|
|
100
|
+
},
|
|
101
|
+
(error) => {
|
|
102
|
+
if (spends(error)) forgetMailedLink(link)
|
|
103
|
+
if (current) setState({ status: 'error', result: null, error })
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
return () => {
|
|
107
|
+
current = false
|
|
108
|
+
}
|
|
109
|
+
}, [client, link, finishes, claimant])
|
|
110
|
+
|
|
111
|
+
const confirm = useCallback(
|
|
112
|
+
async (fields = {}) => {
|
|
113
|
+
if (!client) throw ApiError.disabled()
|
|
114
|
+
if (link?.step !== LINK.reset) {
|
|
115
|
+
throw ApiError.invalid('No Reset Link', 'confirm finishes a mailed reset link, and this page has none')
|
|
116
|
+
}
|
|
117
|
+
setState({ status: 'working', result: null, error: null })
|
|
118
|
+
try {
|
|
119
|
+
const result = await client.confirmPasswordReset({ ...fields, token: link.token })
|
|
120
|
+
forgetMailedLink(link)
|
|
121
|
+
if (live.current) setState({ status: 'done', result, error: null })
|
|
122
|
+
// Every other session of the account has ended — this page's may be one of them.
|
|
123
|
+
client.refresh()
|
|
124
|
+
return result
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (live.current) setState({ status: 'error', result: null, error })
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[client, link],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const dismiss = useCallback(() => {
|
|
134
|
+
forgetMailedLink(link)
|
|
135
|
+
setLink(null)
|
|
136
|
+
setState(IDLE)
|
|
137
|
+
}, [link])
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
step: link?.step ?? null,
|
|
141
|
+
status: state.status,
|
|
142
|
+
result: state.result,
|
|
143
|
+
error: state.error,
|
|
144
|
+
confirm,
|
|
145
|
+
dismiss,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default useMailedLink
|
package/src/hooks/useRecords.js
CHANGED
|
@@ -34,8 +34,8 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
34
34
|
* entity for those. ⚠️ `matched` counts the records in this answer, not a total:
|
|
35
35
|
* `hasMore` is true when the page came back full.
|
|
36
36
|
*
|
|
37
|
-
* The default scope is everything the viewer may read —
|
|
38
|
-
* entities and what was shared with them
|
|
37
|
+
* The default scope is everything the viewer may read — signed in on a site, their
|
|
38
|
+
* own entities and what was shared with them, the operator included.
|
|
39
39
|
* `scope: 'mine'` is only the viewer's own.
|
|
40
40
|
*
|
|
41
41
|
* Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
|
package/src/hooks/useSession.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
|
2
|
-
import { getClient } from '../client.js'
|
|
2
|
+
import { getClient, adminHrefOf } from '../client.js'
|
|
3
3
|
|
|
4
4
|
const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
|
|
5
5
|
const noSubscribe = () => () => {}
|
|
@@ -15,6 +15,10 @@ const noop = async () => NONE
|
|
|
15
15
|
* affordance on false. `error` is set when the backend could not be asked;
|
|
16
16
|
* `refresh()` asks again.
|
|
17
17
|
*
|
|
18
|
+
* `adminHref` is the Admin link — set only for a signed-in account the backend's
|
|
19
|
+
* administration admits, `null` for everyone else, so draw the link only when it is
|
|
20
|
+
* set. ⛔ Navigate to it, never `fetch` it (`adminHrefOf` in `../client.js`).
|
|
21
|
+
*
|
|
18
22
|
* Reads the shared snapshot through `useSyncExternalStore`, so every copy of
|
|
19
23
|
* this package on the page sees one session, and the first render on the
|
|
20
24
|
* server matches the first render in the browser.
|
|
@@ -24,6 +28,7 @@ const noop = async () => NONE
|
|
|
24
28
|
* viewer: object | null,
|
|
25
29
|
* error: Error | null,
|
|
26
30
|
* canSignIn: boolean,
|
|
31
|
+
* adminHref: string | null,
|
|
27
32
|
* signOut: () => Promise<void>,
|
|
28
33
|
* refresh: () => Promise<object>,
|
|
29
34
|
* }}
|
|
@@ -48,6 +53,7 @@ export function useSession() {
|
|
|
48
53
|
viewer: session.viewer,
|
|
49
54
|
error: session.error,
|
|
50
55
|
canSignIn: !!(client && client.enabled),
|
|
56
|
+
adminHref: adminHrefOf(client, session),
|
|
51
57
|
signOut,
|
|
52
58
|
refresh,
|
|
53
59
|
}
|
package/src/index.js
CHANGED
|
@@ -21,11 +21,16 @@ export {
|
|
|
21
21
|
signUp,
|
|
22
22
|
requestPasswordReset,
|
|
23
23
|
confirmPasswordReset,
|
|
24
|
+
verifyEmail,
|
|
25
|
+
claimOffer,
|
|
26
|
+
mailedLink,
|
|
27
|
+
forgetMailedLink,
|
|
24
28
|
readEntity,
|
|
25
29
|
listEntities,
|
|
26
30
|
writeItems,
|
|
27
31
|
createEntity,
|
|
28
32
|
deleteEntity,
|
|
33
|
+
adminHref,
|
|
29
34
|
ApiError,
|
|
30
35
|
Ledger,
|
|
31
36
|
} from './client.js'
|
|
@@ -34,6 +39,7 @@ export { useSession } from './hooks/useSession.js'
|
|
|
34
39
|
export { useSignIn } from './hooks/useSignIn.js'
|
|
35
40
|
export { useSignUp } from './hooks/useSignUp.js'
|
|
36
41
|
export { usePasswordReset } from './hooks/usePasswordReset.js'
|
|
42
|
+
export { useMailedLink } from './hooks/useMailedLink.js'
|
|
37
43
|
export { useEntity } from './hooks/useEntity.js'
|
|
38
44
|
export { useRecords } from './hooks/useRecords.js'
|
|
39
45
|
export { useEntityWriter } from './hooks/useEntityWriter.js'
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The links a site's backend mails — and the steps they come to finish.
|
|
3
|
+
*
|
|
4
|
+
* A site's backend mails its members: to confirm an address, to reset a password, to
|
|
5
|
+
* invite them. Every link lands on the site's root, with a token in one parameter
|
|
6
|
+
* (`LINK` in `./wire.js`), and the page finishes the step there. No React in this
|
|
7
|
+
* module; `useMailedLink` is the hook over it.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ **Functions of a client, not methods.** The page holds one client, created by
|
|
10
|
+
* whichever copy of this package asked first (`getClient`), so an instance an older
|
|
11
|
+
* copy created may lack an operation added since. Everything here reaches the wire
|
|
12
|
+
* through `request`, which every instance has, and keeps its state on the instance.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ApiError } from './errors.js'
|
|
16
|
+
import { AUTH, CLAIM, LINK, OFFERS, PARAM } from './wire.js'
|
|
17
|
+
|
|
18
|
+
const STEPS = Object.values(LINK)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The step a link the site's backend mailed came to finish — `{ step, token }`, where
|
|
22
|
+
* `step` is `'verify'`, `'reset'` or `'offer'` — or `null` when the URL carries none.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} [search] - a query string; the page's own by default
|
|
25
|
+
* @returns {{ step: string, token: string } | null}
|
|
26
|
+
*/
|
|
27
|
+
export function mailedLink(search = globalThis.location?.search ?? '') {
|
|
28
|
+
let params
|
|
29
|
+
try {
|
|
30
|
+
params = new URLSearchParams(search)
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
for (const step of STEPS) {
|
|
35
|
+
const token = params.get(step)
|
|
36
|
+
if (token) return Object.freeze({ step, token })
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Take a mailed link's parameter off the address bar, so a reload does not spend a used
|
|
43
|
+
* token again and the token leaves the page's URL. The rest of the URL stays. Nothing
|
|
44
|
+
* happens outside a browser, or when the URL no longer carries that token.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ **`history.replaceState`, not a navigation** — no route change, no scroll, no new
|
|
47
|
+
* history entry, and the router's own state is kept.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ step: string, token: string } | null} [link] - the page's own by default
|
|
50
|
+
*/
|
|
51
|
+
export function forgetMailedLink(link = mailedLink()) {
|
|
52
|
+
const location = globalThis.location
|
|
53
|
+
const history = globalThis.history
|
|
54
|
+
if (!link || !location || typeof history?.replaceState !== 'function') return
|
|
55
|
+
const params = new URLSearchParams(location.search)
|
|
56
|
+
if (params.get(link.step) !== link.token) return
|
|
57
|
+
params.delete(link.step)
|
|
58
|
+
const query = params.toString()
|
|
59
|
+
history.replaceState(history.state, '', `${location.pathname}${query ? `?${query}` : ''}${location.hash}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Confirm an address with the token of its mailed link (`?verify=`). No session needed,
|
|
64
|
+
* and it starts none: once confirmed, the member signs in.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} client
|
|
67
|
+
* @param {string} token
|
|
68
|
+
* @returns {Promise<{ verified: true }>}
|
|
69
|
+
* @throws {ApiError} `invalid` — the link was used, has expired, or was never valid
|
|
70
|
+
*/
|
|
71
|
+
export async function verifyEmailWith(client, token) {
|
|
72
|
+
if (!token) throw ApiError.invalid('No Token', 'verifyEmail needs the token of the mailed link')
|
|
73
|
+
return client.request('GET', AUTH.verify, { query: { [PARAM.token]: token }, onUnauthorized: 'ignore' })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Claim an offer with the token of its mailed link (`?offer=`), for the account signed
|
|
78
|
+
* in. Answers counts (`CLAIM`): invitations now waiting for this account to accept, and
|
|
79
|
+
* entities now shared with it — so when there are any, the viewer's cached reads go,
|
|
80
|
+
* and what is mounted reads again.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} client
|
|
83
|
+
* @param {string} token
|
|
84
|
+
* @returns {Promise<{ invitations: number, grants: number }>}
|
|
85
|
+
* @throws {ApiError} `absent` — unknown, revoked or already claimed, one answer by design ·
|
|
86
|
+
* `auth` — nobody signed in
|
|
87
|
+
*/
|
|
88
|
+
export async function claimOfferWith(client, token) {
|
|
89
|
+
if (!token) throw ApiError.invalid('No Token', 'claimOffer needs the token of the mailed link')
|
|
90
|
+
const answer = await client.request('POST', OFFERS.claim, { body: { [CLAIM.token]: token } })
|
|
91
|
+
if (answer?.[CLAIM.grants] > 0) client.invalidate?.(() => true)
|
|
92
|
+
return answer
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Finish a `verify` or an `offer` link ONCE per page, however many components ask: the
|
|
97
|
+
* token is spent by the first request, so a second would be refused. A step that
|
|
98
|
+
* succeeded answers the same again; one that failed may be asked again. An offer is
|
|
99
|
+
* claimed per viewer — another account signed in afterwards asks the backend itself.
|
|
100
|
+
* A `reset` needs the new password, so it is finished with `confirmPasswordReset`.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} client
|
|
103
|
+
* @param {{ step: string, token: string }} link
|
|
104
|
+
* @returns {Promise<object>} the backend's answer
|
|
105
|
+
*/
|
|
106
|
+
export function finishMailedLink(client, link) {
|
|
107
|
+
if (link?.step !== LINK.verify && link?.step !== LINK.offer) {
|
|
108
|
+
return Promise.reject(ApiError.invalid('Not Finished Here', `a ${link?.step} link needs the member's input`))
|
|
109
|
+
}
|
|
110
|
+
const runs = (client._mailedLinks ??= new Map())
|
|
111
|
+
const key = link.step === LINK.offer ? `${link.step}:${link.token}:${client.viewerId}` : `${link.step}:${link.token}`
|
|
112
|
+
if (runs.has(key)) return runs.get(key)
|
|
113
|
+
const run = link.step === LINK.verify ? verifyEmailWith(client, link.token) : claimOfferWith(client, link.token)
|
|
114
|
+
const pending = run.catch((error) => {
|
|
115
|
+
runs.delete(key)
|
|
116
|
+
throw error
|
|
117
|
+
})
|
|
118
|
+
runs.set(key, pending)
|
|
119
|
+
return pending
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a refusal settles the link for good — the backend judged the token — so it
|
|
124
|
+
* leaves the address bar. A link the backend could not be asked about, or asked
|
|
125
|
+
* without a session, may still work.
|
|
126
|
+
*
|
|
127
|
+
* @param {ApiError} error
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
export function spends(error) {
|
|
131
|
+
return error?.kind === 'invalid' || error?.kind === 'absent'
|
|
132
|
+
}
|
package/src/mock/index.js
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
|
-
import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS } from '../wire.js'
|
|
1
|
+
import { AUTH, ENTITIES, MODELS, OFFERS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } from '../wire.js'
|
|
2
2
|
import { MockStore, isUuid, refuse } from './store.js'
|
|
3
3
|
import { DEFAULT_SEED } from './seed.js'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The mock's postman: each message, as it is sent, where the mock runs — the dev
|
|
7
|
+
* server's terminal, under `uniweb dev` — with its link on the site's origin.
|
|
8
|
+
*/
|
|
9
|
+
export function printMail(message, url) {
|
|
10
|
+
console.log(`[uniweb/api mock] ✉ ${message.subject} → ${message.to}\n ${url}`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the mock's Admin link lands. The backend's administration lives on its own
|
|
15
|
+
* origin, outside the API, and the mock models none of it: the handoff's redirect
|
|
16
|
+
* comes here instead, to a page that says so. A path of the mock's, not the backend's.
|
|
17
|
+
*/
|
|
18
|
+
const ADMIN_PAGE = '/admin'
|
|
19
|
+
|
|
20
|
+
const escapeHtml = (value) =>
|
|
21
|
+
String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
22
|
+
|
|
23
|
+
function adminPage(account) {
|
|
24
|
+
const who = account ? `Signed in as <b>${escapeHtml(account.handle)}</b>. ` : ''
|
|
25
|
+
return `<!doctype html><meta charset="utf-8"><title>Administration</title>
|
|
26
|
+
<p>${who}On a live site, the Admin link opens the backend's administration here, signed in. The local mock has none to open.</p>
|
|
27
|
+
`
|
|
28
|
+
}
|
|
29
|
+
|
|
5
30
|
/**
|
|
6
31
|
* A mock of a site's `backend` service, for local development.
|
|
7
32
|
*
|
|
@@ -36,22 +61,57 @@ import { DEFAULT_SEED } from './seed.js'
|
|
|
36
61
|
*
|
|
37
62
|
* ## What it models, and what it does not
|
|
38
63
|
*
|
|
39
|
-
* One operator (`operator: true` on a seeded account) and members;
|
|
40
|
-
* and writes their own entities and
|
|
41
|
-
* `memberFloor` says the service lets them)
|
|
64
|
+
* One operator (`operator: true` on a seeded account) and members; everyone signed
|
|
65
|
+
* in — the operator too, as on a real site — reads and writes their own entities and
|
|
66
|
+
* nothing of another's (unless the seed's `memberFloor` says the service lets them);
|
|
42
67
|
* anyone signed in may create entries of any Model (Models are open); sign-up
|
|
43
|
-
* leaves an account unverified until
|
|
44
|
-
*
|
|
68
|
+
* leaves an account unverified until its address is confirmed. The operator is the
|
|
69
|
+
* account the backend's administration admits, so its Admin link answers — with a
|
|
70
|
+
* redirect to a page of the mock's own, since the administration itself is not
|
|
71
|
+
* modelled. It does not model entitlements behind `via`, nested sections, second
|
|
72
|
+
* factors, password rules, or what an offer binds.
|
|
73
|
+
*
|
|
74
|
+
* ## The mail it sends
|
|
75
|
+
*
|
|
76
|
+
* A sign-up's confirmation, a password reset, and an invitation to an address with no
|
|
77
|
+
* account (`seed.offers`) are mailed as the backend mails them: a link to the site's
|
|
78
|
+
* root carrying a token — `/?verify=…`, `/?reset=…`, `/?offer=…` — which the site's
|
|
79
|
+
* own pages finish (`useMailedLink`). Each message is kept in `outbox` and, as it is
|
|
80
|
+
* sent, handed to `onMail` — printed, by default, where the mock runs.
|
|
45
81
|
*
|
|
46
82
|
* @param {object} [options]
|
|
47
|
-
* @param {object} [options.seed] - accounts, schemas and
|
|
83
|
+
* @param {object} [options.seed] - accounts, schemas, entities and offers to start from (see `MockStore`)
|
|
48
84
|
* @param {string} [options.signedInAs] - a seeded `username` to start signed in as. Throws if not seeded.
|
|
49
85
|
* @param {string} [options.prefix] - the path the API is mounted under (default `/_api`), stripped when present
|
|
86
|
+
* @param {string} [options.siteOrigin] - the site's origin, on which the mail's links land. By
|
|
87
|
+
* default, the origin of the request that sent the mail — the site's own when the mock is
|
|
88
|
+
* mounted in the site's dev server, as `uniweb dev` mounts it
|
|
89
|
+
* @param {((message: object, url: string) => void) | null} [options.onMail] - called with each
|
|
90
|
+
* message as it is sent, and its link on the site's origin. `printMail` by default; `null`
|
|
91
|
+
* sends quietly
|
|
50
92
|
* @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[], outbox: object[] }}
|
|
51
93
|
*/
|
|
52
|
-
export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signedInAs = null } = {}) {
|
|
94
|
+
export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signedInAs = null, siteOrigin = null, onMail = printMail } = {}) {
|
|
53
95
|
const store = new MockStore(seed, { signedInAs })
|
|
54
96
|
|
|
97
|
+
// Mail is delivered after the request that sent it, so its link can name that
|
|
98
|
+
// request's origin. Mail the seed sent waits for the first request.
|
|
99
|
+
let delivered = 0
|
|
100
|
+
function deliver(request) {
|
|
101
|
+
const fresh = store.outbox.slice(delivered)
|
|
102
|
+
delivered = store.outbox.length
|
|
103
|
+
if (typeof onMail !== 'function' || !fresh.length) return
|
|
104
|
+
let origin = siteOrigin
|
|
105
|
+
if (!origin) {
|
|
106
|
+
try {
|
|
107
|
+
origin = new URL(request.url).origin
|
|
108
|
+
} catch {
|
|
109
|
+
origin = null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const message of fresh) onMail(message, origin ? new URL(message.link, origin).href : message.link)
|
|
113
|
+
}
|
|
114
|
+
|
|
55
115
|
const json = (status, body) =>
|
|
56
116
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
|
57
117
|
status,
|
|
@@ -199,6 +259,14 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
199
259
|
const viewer = store.viewer()
|
|
200
260
|
return viewer ? json(200, viewer) : unauthorized()
|
|
201
261
|
}
|
|
262
|
+
if (path === AUTH.memberHandoff && method === 'GET') {
|
|
263
|
+
if (!store.account) return unauthorized()
|
|
264
|
+
if (!store.account.operator) return problem(HANDOFF_REFUSAL)
|
|
265
|
+
// The backend redirects to its administration on its own origin. The mock has
|
|
266
|
+
// none, so the redirect lands on a page of its own that says so (`ADMIN_PAGE`).
|
|
267
|
+
// Relative, so it lands under whatever path the mock is mounted at.
|
|
268
|
+
return new Response(null, { status: 302, headers: { location: `..${ADMIN_PAGE}` } })
|
|
269
|
+
}
|
|
202
270
|
if (path === AUTH.login && method === 'POST') {
|
|
203
271
|
const b = await body(request)
|
|
204
272
|
return fields(b, ['username', 'password'], '{"username": ..., "password": ...}') ?? answer(store.signIn(b))
|
|
@@ -230,6 +298,15 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
230
298
|
}
|
|
231
299
|
}
|
|
232
300
|
|
|
301
|
+
// ── Offers ──────────────────────────────────────────────────────────────
|
|
302
|
+
if (path === OFFERS.claim && method === 'POST') {
|
|
303
|
+
const bad = unexpected(q, [])
|
|
304
|
+
if (bad) return bad
|
|
305
|
+
if (!store.account) return unauthorized()
|
|
306
|
+
const b = await body(request)
|
|
307
|
+
return fields(b, ['token'], '{"token": ...}') ?? answer(store.claimOffer(b.token))
|
|
308
|
+
}
|
|
309
|
+
|
|
233
310
|
// ── A Model's definition ─────────────────────────────────────────────────
|
|
234
311
|
if (path.startsWith(`${MODELS}/`) && method === 'GET') {
|
|
235
312
|
const bad = unexpected(q, [])
|
|
@@ -391,6 +468,11 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
391
468
|
}
|
|
392
469
|
}
|
|
393
470
|
|
|
471
|
+
// ── Where the Admin link lands ──────────────────────────────────────────
|
|
472
|
+
if (path === ADMIN_PAGE && method === 'GET') {
|
|
473
|
+
return new Response(adminPage(store.account), { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
474
|
+
}
|
|
475
|
+
|
|
394
476
|
return problem({ status: 404, title: 'Not Found', detail: `no route for ${method} ${path}` })
|
|
395
477
|
}
|
|
396
478
|
|
|
@@ -403,18 +485,24 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
403
485
|
get diagnostics() {
|
|
404
486
|
return store.diagnostics
|
|
405
487
|
},
|
|
406
|
-
/**
|
|
488
|
+
/**
|
|
489
|
+
* The mail the backend would have sent — `{ to, subject, token, link }`, `link` a
|
|
490
|
+
* site-root link carrying the token (`/?verify=…`, `/?reset=…`, `/?offer=…`).
|
|
491
|
+
*/
|
|
407
492
|
get outbox() {
|
|
408
493
|
return store.outbox
|
|
409
494
|
},
|
|
410
495
|
async fetch(request) {
|
|
496
|
+
let response
|
|
411
497
|
try {
|
|
412
|
-
|
|
498
|
+
response = await route(request)
|
|
413
499
|
} catch (err) {
|
|
414
500
|
// A mock that throws leaves the caller staring at a network error and
|
|
415
501
|
// blaming their own code. Answer, and say it was us.
|
|
416
|
-
|
|
502
|
+
response = problem({ status: 500, title: 'MockFailure', detail: err?.message || 'the mock threw' })
|
|
417
503
|
}
|
|
504
|
+
deliver(request)
|
|
505
|
+
return response
|
|
418
506
|
},
|
|
419
507
|
}
|
|
420
508
|
}
|
package/src/mock/seed.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* - **`entities`** — `uuid` must be a UUID, as it is on the wire; content is items,
|
|
14
14
|
* each naming its section. The entity's `brief` is derived from its brief
|
|
15
15
|
* section's item, as the backend derives it.
|
|
16
|
+
* - **`offers`** — optional, and none here: `{ email, invitations?, grants? }`, an
|
|
17
|
+
* invitation sent to an address with no account, mailed when the mock starts. Its
|
|
18
|
+
* link (`/?offer=…`) is how a site's pages are tried with one.
|
|
16
19
|
*/
|
|
17
20
|
export const DEFAULT_SEED = {
|
|
18
21
|
accounts: [
|
package/src/mock/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FIELD, OP, PROBLEM } from '../wire.js'
|
|
1
|
+
import { CLAIM, FIELD, LINK, OP, PROBLEM } from '../wire.js'
|
|
2
2
|
import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -19,19 +19,27 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
19
19
|
* unless shared. A service can be set up to let members read (or edit) each
|
|
20
20
|
* other's — the seed's `memberFloor` models that choice. *(Until 2026-09-18 the
|
|
21
21
|
* backend gave every member read access to every other member's entities.)*
|
|
22
|
-
* - **The operator** — the account that runs the site's service —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* - **The operator** — the account that runs the site's service — owns what the
|
|
23
|
+
* seed gives it. ⛔ **Signed in on the site it holds a member's rights**, like
|
|
24
|
+
* everyone: no `system_admin` in `roles`, and nothing of another member's unless
|
|
25
|
+
* shared — a site runs third-party code with its member's session. *(Until
|
|
26
|
+
* 2026-10-10 the operator's session held `system_admin` and read and wrote
|
|
27
|
+
* everything.)* A seeded account is the operator with `operator: true`.
|
|
26
28
|
*
|
|
27
29
|
* ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
|
|
28
30
|
* A mock that grows a schema and migrations becomes a second implementation that
|
|
29
31
|
* drifts and is believed anyway.
|
|
30
32
|
*
|
|
31
33
|
* ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
|
|
32
|
-
* same read), nested sections, second factors, reference fields
|
|
34
|
+
* same read), nested sections, second factors, reference fields, password rules,
|
|
35
|
+
* and what an offer binds — a seeded offer answers the counts its seed gives, and
|
|
36
|
+
* confirming the address it was sent to takes up nothing (the backend binds every
|
|
37
|
+
* pending offer then).
|
|
33
38
|
*/
|
|
34
39
|
|
|
40
|
+
/** A mailed link — the site's root, the step's parameter, the token. */
|
|
41
|
+
const linkTo = (step, token) => `/?${step}=${encodeURIComponent(token)}`
|
|
42
|
+
|
|
35
43
|
/** The unit every account of a site acts in — an entity's `unit_id`. */
|
|
36
44
|
export const SITE_UNIT = 1
|
|
37
45
|
|
|
@@ -121,6 +129,9 @@ export class MockStore {
|
|
|
121
129
|
* member's are private. A string applies to every Model; a map `{ '@scope/name': 'read' }`
|
|
122
130
|
* applies per Model, so a service can open the operator's content (courses, lessons) to
|
|
123
131
|
* its members while their own records (progress, submissions) stay theirs alone.
|
|
132
|
+
* @param {object[]} [seed.offers] - `{ email, invitations?, grants? }`: an invitation sent
|
|
133
|
+
* to an address with no account, mailed when the mock starts. Claiming it answers the
|
|
134
|
+
* counts given (default `0`), once.
|
|
124
135
|
* @param {object} [options]
|
|
125
136
|
* @param {string} [options.signedInAs] - start with this account already signed in.
|
|
126
137
|
*/
|
|
@@ -165,9 +176,16 @@ export class MockStore {
|
|
|
165
176
|
* seed lists as `migration_debt`. Empty is the good state.
|
|
166
177
|
*/
|
|
167
178
|
this.diagnostics = []
|
|
168
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Mail the backend would send: `{ to, subject, token, link }`, newest last. `link` is
|
|
181
|
+
* the mail's link without its origin — the site's root, its step's parameter and the
|
|
182
|
+
* token (`/?verify=…`); the backend's carries the site's origin.
|
|
183
|
+
*/
|
|
169
184
|
this.outbox = []
|
|
170
185
|
this.pending = { verify: new Map(), reset: new Map() }
|
|
186
|
+
/** Offers by token: `{ email, invitations, grants, claimed }`. */
|
|
187
|
+
this.offers = new Map()
|
|
188
|
+
for (const offer of seed.offers || []) this.addOffer(offer)
|
|
171
189
|
this.entities = new Map()
|
|
172
190
|
for (const e of seed.entities || []) this.seedEntity(e)
|
|
173
191
|
|
|
@@ -207,15 +225,21 @@ export class MockStore {
|
|
|
207
225
|
return this.session?.account ?? null
|
|
208
226
|
}
|
|
209
227
|
|
|
210
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* The viewer, in the shape `/auth/me` answers. `roles` are the session's, and a
|
|
230
|
+
* session on a site holds no administrator's — the operator's neither. Whether the
|
|
231
|
+
* backend's administration admits the ACCOUNT is `capabilities.admin_app`: the
|
|
232
|
+
* operator's yes, a member's no.
|
|
233
|
+
*/
|
|
211
234
|
viewer(account = this.account) {
|
|
212
235
|
if (!account) return null
|
|
213
236
|
return {
|
|
214
237
|
account: this.identity(account),
|
|
215
|
-
roles:
|
|
238
|
+
roles: [],
|
|
216
239
|
// A request that names no workspace — as this package's never do — lands in the
|
|
217
240
|
// home org, which every account belongs to. Both null would mean a non-member.
|
|
218
241
|
workspace: { ...HOME_ORG },
|
|
242
|
+
capabilities: { admin_app: account.operator === true },
|
|
219
243
|
}
|
|
220
244
|
}
|
|
221
245
|
|
|
@@ -229,7 +253,8 @@ export class MockStore {
|
|
|
229
253
|
if (!account.verified) return refuse(403, PROBLEM.notVerified, 'verify your email address before signing in')
|
|
230
254
|
this.session = { account }
|
|
231
255
|
const expires = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
|
|
232
|
-
|
|
256
|
+
// No `token`: a site's backend answers a sign-in with the session cookie alone.
|
|
257
|
+
return { body: { expires_at: expires, account: this.identity(account) } }
|
|
233
258
|
}
|
|
234
259
|
|
|
235
260
|
signOut() {
|
|
@@ -246,7 +271,7 @@ export class MockStore {
|
|
|
246
271
|
const account = this.addAccount({ username, email, password }, { verified: false })
|
|
247
272
|
const token = newUuid()
|
|
248
273
|
this.pending.verify.set(token, account)
|
|
249
|
-
this.outbox.push({ to: email, subject: 'Confirm your email address', token,
|
|
274
|
+
this.outbox.push({ to: email, subject: 'Confirm your email address', token, link: linkTo(LINK.verify, token) })
|
|
250
275
|
}
|
|
251
276
|
return { status: 202, body: { status: 'verification_required', email } }
|
|
252
277
|
}
|
|
@@ -264,7 +289,7 @@ export class MockStore {
|
|
|
264
289
|
if (account) {
|
|
265
290
|
const token = newUuid()
|
|
266
291
|
this.pending.reset.set(token, account)
|
|
267
|
-
this.outbox.push({ to: email, subject: 'Reset your password', token })
|
|
292
|
+
this.outbox.push({ to: email, subject: 'Reset your password', token, link: linkTo(LINK.reset, token) })
|
|
268
293
|
}
|
|
269
294
|
return { status: 202, body: { status: 'reset_requested' } }
|
|
270
295
|
}
|
|
@@ -279,6 +304,33 @@ export class MockStore {
|
|
|
279
304
|
return { body: { reset: true } }
|
|
280
305
|
}
|
|
281
306
|
|
|
307
|
+
// ── Offers ──────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/** An invitation to an address with no account here — its token mailed at once. */
|
|
310
|
+
addOffer({ email, invitations = 0, grants = 0 } = {}) {
|
|
311
|
+
const count = (n) => Number.isInteger(n) && n >= 0
|
|
312
|
+
if (typeof email !== 'string' || !email || !count(invitations) || !count(grants)) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[uniweb/api mock] an offer is { email, invitations?, grants? }, the counts whole numbers — got ${JSON.stringify({ email, invitations, grants })}`
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
const token = newUuid()
|
|
318
|
+
this.offers.set(token, { email, invitations, grants, claimed: false })
|
|
319
|
+
this.outbox.push({ to: email, subject: "You've been invited", token, link: linkTo(LINK.offer, token) })
|
|
320
|
+
return token
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Claim an offer for the account signed in. Unknown, revoked and already claimed are
|
|
325
|
+
* one answer, `404`, as on the backend.
|
|
326
|
+
*/
|
|
327
|
+
claimOffer(token) {
|
|
328
|
+
const offer = this.offers.get(token)
|
|
329
|
+
if (!offer || offer.claimed) return refuse(404, 'Not Found', 'offer not found')
|
|
330
|
+
offer.claimed = true
|
|
331
|
+
return { body: { [CLAIM.invitations]: offer.invitations, [CLAIM.grants]: offer.grants } }
|
|
332
|
+
}
|
|
333
|
+
|
|
282
334
|
// ── Models ──────────────────────────────────────────────────────────────────
|
|
283
335
|
|
|
284
336
|
defineModel(name, decl = {}, itemSections = new Set()) {
|
|
@@ -468,28 +520,31 @@ export class MockStore {
|
|
|
468
520
|
return floor[entity.model] ?? null
|
|
469
521
|
}
|
|
470
522
|
|
|
471
|
-
/**
|
|
523
|
+
/**
|
|
524
|
+
* The owner — or any member, when the service lets members read each other's. The
|
|
525
|
+
* operator is a member here like everyone: a session on a site holds no
|
|
526
|
+
* administrator's rights.
|
|
527
|
+
*/
|
|
472
528
|
mayRead(entity) {
|
|
473
529
|
const me = this.account
|
|
474
|
-
return !!me && (
|
|
530
|
+
return !!me && (entity.owner_id === me.id || this.floorFor(entity) != null)
|
|
475
531
|
}
|
|
476
532
|
|
|
477
533
|
mayEdit(entity) {
|
|
478
534
|
const me = this.account
|
|
479
|
-
return !!me && (
|
|
535
|
+
return !!me && (entity.owner_id === me.id || this.floorFor(entity) === 'edit')
|
|
480
536
|
}
|
|
481
537
|
|
|
482
538
|
/**
|
|
483
539
|
* Which branch lets the viewer read a row, as the backend names it — the first to
|
|
484
|
-
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`.
|
|
485
|
-
* are `owner
|
|
486
|
-
* 2026-
|
|
487
|
-
*
|
|
540
|
+
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`. A session's own rows
|
|
541
|
+
* are `owner`, the operator's too, and the rest it reads by the service's floor.
|
|
542
|
+
* *(Until 2026-10-10 the operator's session read the rest as `rbac`, through
|
|
543
|
+
* `system_admin`.)*
|
|
488
544
|
*/
|
|
489
545
|
via(entity) {
|
|
490
546
|
const me = this.account
|
|
491
547
|
if (entity.owner_id === me?.id) return 'owner'
|
|
492
|
-
if (me?.operator) return 'rbac'
|
|
493
548
|
return 'unit_member'
|
|
494
549
|
}
|
|
495
550
|
|
package/src/wire.js
CHANGED
|
@@ -91,16 +91,42 @@ export const AUTH = {
|
|
|
91
91
|
logout: '/auth/logout',
|
|
92
92
|
register: '/auth/register',
|
|
93
93
|
/**
|
|
94
|
-
* `GET /auth/verify?token=` → `{ verified: true }
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
94
|
+
* `GET /auth/verify?token=` → `{ verified: true }`: the address is confirmed and the
|
|
95
|
+
* member may sign in — it starts no session. A token that was used, has expired or
|
|
96
|
+
* was never valid is `400 Validation`. The token comes from the link the backend
|
|
97
|
+
* mails, which lands on the site's root (`LINK.verify`), and `verifyEmail` finishes
|
|
98
|
+
* it. *(Until 2026-10-11 that link named the backend's own sign-in page, and this
|
|
99
|
+
* package did not call the route.)*
|
|
98
100
|
*/
|
|
99
101
|
verify: '/auth/verify',
|
|
100
102
|
resetRequest: '/auth/reset/request',
|
|
101
103
|
resetConfirm: '/auth/reset/confirm',
|
|
104
|
+
/**
|
|
105
|
+
* `GET /auth/member-handoff` — the Admin link (`adminHref` in `./client.js`).
|
|
106
|
+
* ⛔ **A navigation, never a fetch.** For an account the backend's administration
|
|
107
|
+
* admits (`VIEWER`'s `admin_app`), it answers `302` toward that administration, on
|
|
108
|
+
* the backend's own origin, carrying a one-time credential no script on a site is to
|
|
109
|
+
* hold — a fetch learns nothing from the redirect, and one that follows it spends
|
|
110
|
+
* the code. `401` no session · `403` an account it does not admit
|
|
111
|
+
* (`HANDOFF_REFUSAL`) · `404` a backend no site reaches · `503` a backend never told
|
|
112
|
+
* its own origin — each problem JSON, branched on by `title` or `reason`, never
|
|
113
|
+
* `detail`. **MEASURED** by backend, 2026-10-10.
|
|
114
|
+
*/
|
|
115
|
+
memberHandoff: '/auth/member-handoff',
|
|
102
116
|
}
|
|
103
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The handoff's `403`, as sent — an account the administration does not admit.
|
|
120
|
+
* **MEASURED** by backend, 2026-10-10. This package never reads it: the handoff is a
|
|
121
|
+
* navigation, so the browser shows it. The mock answers it.
|
|
122
|
+
*/
|
|
123
|
+
export const HANDOFF_REFUSAL = Object.freeze({
|
|
124
|
+
status: 403,
|
|
125
|
+
title: 'Admin Role Required',
|
|
126
|
+
detail: "the admin app admits a system_admin or an administrator of this deployment's home org",
|
|
127
|
+
reason: 'admin_role_required',
|
|
128
|
+
})
|
|
129
|
+
|
|
104
130
|
/**
|
|
105
131
|
* The bodies of the auth routes. **MEASURED.** This package hands a caller's
|
|
106
132
|
* fields to the backend unchanged, so these are the names a caller must use — a
|
|
@@ -108,13 +134,18 @@ export const AUTH = {
|
|
|
108
134
|
*
|
|
109
135
|
* | route | body | answer |
|
|
110
136
|
* |---|---|---|
|
|
111
|
-
* | login | `{ username, password }` | `200 {
|
|
137
|
+
* | login | `{ username, password }` | `200 { expires_at, account }` + the session cookie · `200 { status: 'totp_required', challenge_token }` · `401` wrong credential · `403 Email Not Verified` |
|
|
112
138
|
* | challenge | `{ challenge_token, code }` | as login's `200`; a bad token or code is `401` |
|
|
113
139
|
* | register | `{ username, email, password }` | `202 { status: 'verification_required', email }` — the same answer for an address already taken; a taken **username** is `409` |
|
|
114
140
|
* | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
|
|
115
|
-
* | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }
|
|
141
|
+
* | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`, and every other session of the account ends; a bad token is `400 Validation`; `code` is required when a second factor is enrolled. A password the rule refuses is `400` with `reason: 'password_too_short'` and `min_length` — refused BEFORE the token is spent, so the same link works again (RULED by backend, 2026-10-11) |
|
|
116
142
|
* | logout | none | `204`; with no session to end, `401` |
|
|
117
|
-
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle } }` · `401` |
|
|
143
|
+
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app } }` · `401` |
|
|
144
|
+
*
|
|
145
|
+
* ⭐ **The session is the cookie, and only the cookie.** A site's backend answers a
|
|
146
|
+
* sign-in with no `token` (since 2026-10-10, MEASURED by backend; `token` was in the
|
|
147
|
+
* reply until then), and this package never read one: it signs in, then asks
|
|
148
|
+
* `/auth/me` who the viewer is.
|
|
118
149
|
*/
|
|
119
150
|
export const AUTH_BODY = {
|
|
120
151
|
login: ['username', 'password'],
|
|
@@ -124,6 +155,56 @@ export const AUTH_BODY = {
|
|
|
124
155
|
resetConfirm: ['token', 'new_password', 'code?'],
|
|
125
156
|
}
|
|
126
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The links a site's backend mails. **RULED** by backend, 2026-10-11.
|
|
160
|
+
*
|
|
161
|
+
* Every link in the mail a site's backend sends lands on the site's root — `/` on the
|
|
162
|
+
* site's primary origin — with at most one of these parameters, carrying a token:
|
|
163
|
+
*
|
|
164
|
+
* | parameter | the step it starts | finished with |
|
|
165
|
+
* |---|---|---|
|
|
166
|
+
* | `verify` | confirm a new account's address | `GET /auth/verify?token=` |
|
|
167
|
+
* | `reset` | choose a new password — and give a code, with a second factor | `POST /auth/reset/confirm` |
|
|
168
|
+
* | `offer` | take up an invitation sent to this address | `POST /offers/claim`, signed in |
|
|
169
|
+
*
|
|
170
|
+
* The root with none — a notice, such as a password changed or an invitation waiting —
|
|
171
|
+
* has no step to finish: the member signs in. The names are the ones all of the
|
|
172
|
+
* backend's mail uses, so a token means the same thing wherever its link lands.
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ A root that redirects drops the query unless the redirect carries it.
|
|
175
|
+
* `@uniweb/runtime` carries it on its own redirects since 0.29.6.
|
|
176
|
+
*/
|
|
177
|
+
export const LINK = {
|
|
178
|
+
verify: 'verify',
|
|
179
|
+
reset: 'reset',
|
|
180
|
+
offer: 'offer',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Offers. **MEASURED** by backend, 2026-09-15.
|
|
185
|
+
*
|
|
186
|
+
* Inviting an address nobody here holds — to an organization, or to share an entity —
|
|
187
|
+
* mints an offer, and the backend mails its token to that address (`LINK.offer`).
|
|
188
|
+
* Whoever holds the token claims it, signed in, for that account — whatever the
|
|
189
|
+
* account's own address — and what the offer held binds to it.
|
|
190
|
+
*
|
|
191
|
+
* `POST /offers/claim` `{ token }` → `200 { invitations, grants }` (`CLAIM`) —
|
|
192
|
+
* COUNTS, not names: invitations now waiting for this account to accept, and entities
|
|
193
|
+
* now shared with it. ⛔ **An unknown, revoked or already-claimed token is ONE answer,
|
|
194
|
+
* `404`**, so the route never says whether a token was ever real. A missing `token`
|
|
195
|
+
* is `400`.
|
|
196
|
+
*/
|
|
197
|
+
export const OFFERS = {
|
|
198
|
+
claim: '/offers/claim',
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The claim's body and answer. MEASURED. */
|
|
202
|
+
export const CLAIM = {
|
|
203
|
+
token: 'token',
|
|
204
|
+
invitations: 'invitations',
|
|
205
|
+
grants: 'grants',
|
|
206
|
+
}
|
|
207
|
+
|
|
127
208
|
/** A sign-in that needs a second factor. MEASURED. */
|
|
128
209
|
export const TOTP = {
|
|
129
210
|
status: 'totp_required',
|
|
@@ -135,7 +216,13 @@ export const TOTP = {
|
|
|
135
216
|
* The viewer, as `/auth/me` answers. **MEASURED.**
|
|
136
217
|
*
|
|
137
218
|
* `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
|
|
138
|
-
* `unit_admin`, `content_editor`, `user`.
|
|
219
|
+
* `unit_admin`, `content_editor`, `user`. ⭐ **They describe the session, not the
|
|
220
|
+
* account.** On a site's `backend` service a session signed in on the site holds no
|
|
221
|
+
* `system_admin` and no `unit_admin` of the home org — the operator's neither — so
|
|
222
|
+
* `roles` never name an administrator there: a site runs third-party code with its
|
|
223
|
+
* member's session, and a bug in that code must not act as one. An ordinary member
|
|
224
|
+
* holds none: `[]`. *(Since 2026-10-10, MEASURED by backend; until then the operator's
|
|
225
|
+
* session on a site held `system_admin`.)*
|
|
139
226
|
*
|
|
140
227
|
* `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
|
|
141
228
|
* package's requests name none, and on a site's `backend` service such a request lands in
|
|
@@ -147,14 +234,26 @@ export const TOTP = {
|
|
|
147
234
|
* function the route serializes, 2026-09-23.)*
|
|
148
235
|
*
|
|
149
236
|
* ⛔ **Neither is an operator signal.** Every member of a site's service works in the
|
|
150
|
-
* same home org, so
|
|
151
|
-
* minute ago
|
|
152
|
-
* thing — a single-entity read carries `can_edit`, the write gate's own answer
|
|
237
|
+
* same home org, so `workspace` reads the same for the operator and for someone who
|
|
238
|
+
* signed up a minute ago, and on a site `roles` name no administrator. Ask about the
|
|
239
|
+
* thing instead — a single-entity read carries `can_edit`, the write gate's own answer
|
|
240
|
+
* for this session.
|
|
241
|
+
*
|
|
242
|
+
* `capabilities.admin_app` is the one fact here about the ACCOUNT rather than the
|
|
243
|
+
* session: `true` when the backend's administration admits it — the operator, and
|
|
244
|
+
* whoever else administers the service. The viewer carries it as `adminApp`, and the
|
|
245
|
+
* package then offers the Admin link (`AUTH.memberHandoff`). It decides what to OFFER,
|
|
246
|
+
* never what is allowed on the site, where the session acts as a member whatever the
|
|
247
|
+
* account holds. `true` or `false` for every signed-in caller, at the same path as on
|
|
248
|
+
* `/session/bootstrap` (MEASURED by backend, 2026-10-10); anything else reads as
|
|
249
|
+
* `false`.
|
|
153
250
|
*/
|
|
154
251
|
export const VIEWER = {
|
|
155
252
|
account: 'account',
|
|
156
253
|
roles: 'roles',
|
|
157
254
|
workspace: 'workspace',
|
|
255
|
+
capabilities: 'capabilities',
|
|
256
|
+
adminApp: 'admin_app',
|
|
158
257
|
}
|
|
159
258
|
|
|
160
259
|
/**
|
|
@@ -194,6 +293,7 @@ export const ROUTES = {
|
|
|
194
293
|
* | items | `model` (required) · `readback` |
|
|
195
294
|
* | create | `model` (required) |
|
|
196
295
|
* | remove | `rev_ref_policy` |
|
|
296
|
+
* | verify (`AUTH.verify`) | `token` (required) |
|
|
197
297
|
*
|
|
198
298
|
* ✅ **`via` and `depth` compose** (an assumption retired 2026-09-18): both are
|
|
199
299
|
* parameters of the same read and answer different questions — `via` *who may
|
|
@@ -218,17 +318,19 @@ export const PARAM = {
|
|
|
218
318
|
maxDepth: 'max_depth',
|
|
219
319
|
readback: 'readback',
|
|
220
320
|
revRefPolicy: 'rev_ref_policy',
|
|
321
|
+
token: 'token',
|
|
221
322
|
}
|
|
222
323
|
|
|
223
324
|
/**
|
|
224
325
|
* The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
|
|
225
326
|
* — only what the viewer owns.
|
|
226
327
|
*
|
|
227
|
-
* What a
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* entities until 2026-09-18
|
|
328
|
+
* What a session signed in on a site may read — any member's, the operator's
|
|
329
|
+
* included (`VIEWER`): its own entities, and what was shared with it. Other members'
|
|
330
|
+
* entities are private to their owners unless the site's service was set up to let
|
|
331
|
+
* members read each other's. *(A site's service let every member read every other
|
|
332
|
+
* member's entities until 2026-09-18, and the operator's session read everything until
|
|
333
|
+
* 2026-10-10 — both measured, and changed on the backend.)*
|
|
232
334
|
*/
|
|
233
335
|
export const SCOPE = {
|
|
234
336
|
mine: 'mine',
|
|
@@ -342,9 +444,9 @@ export const CREATE = {
|
|
|
342
444
|
* paging; a page that came back full is the only sign there may be more.
|
|
343
445
|
*
|
|
344
446
|
* `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
|
|
345
|
-
* `unit_member`, `entitlement`; treat an unknown value as "no opinion".
|
|
346
|
-
*
|
|
347
|
-
*
|
|
447
|
+
* `unit_member`, `entitlement`; treat an unknown value as "no opinion". A session's
|
|
448
|
+
* own rows are `owner` — the operator's too. *(Until 2026-10-10 the operator's session
|
|
449
|
+
* on a site read the rest as `rbac`, through `system_admin`.)* ⚠️ An empty list means
|
|
348
450
|
* empty: a lapsed session is a `401`, never `200 []`.
|
|
349
451
|
*/
|
|
350
452
|
export const LIST = {
|
|
@@ -424,7 +526,7 @@ export const PROBLEM = {
|
|
|
424
526
|
* ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
|
|
425
527
|
* confirmed. Each entry says what we do, and what breaks if we are wrong.
|
|
426
528
|
*
|
|
427
|
-
*
|
|
529
|
+
* Empty from 2026-09-18 to 2026-10-11, when the last two were answered by measurement:
|
|
428
530
|
*
|
|
429
531
|
* | retired | answered as |
|
|
430
532
|
* |---|---|
|
|
@@ -435,4 +537,11 @@ export const PROBLEM = {
|
|
|
435
537
|
* `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
|
|
436
538
|
* `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
|
|
437
539
|
*/
|
|
438
|
-
export const ASSUMPTIONS = [
|
|
540
|
+
export const ASSUMPTIONS = [
|
|
541
|
+
{
|
|
542
|
+
id: 'offer-claim-needs-a-session',
|
|
543
|
+
we: 'claim an offer only once signed in (`useMailedLink`), and answer a claim with no session `401` in the mock',
|
|
544
|
+
from: 'the backend: the member signs up or signs in, then claims (2026-10-11). Nobody has recorded what a claim with no session answers',
|
|
545
|
+
breaks: 'nothing the hook does, since it never claims signed out — but the mock would answer a signed-out claim unlike the backend',
|
|
546
|
+
},
|
|
547
|
+
]
|