@uniweb/api 0.8.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 CHANGED
@@ -63,13 +63,49 @@ record, read that record: `entity.can_edit` is the backend's own answer.
63
63
  **Signing up** — `useSignUp().signUp({ username, email, password })` answers
64
64
  `{ status: 'verification_required', email }`, the same answer for an address already in
65
65
  use. The account cannot sign in until the address is confirmed from the email the
66
- backend sends; until then `signIn` fails with `error.kind === 'unverified'`. A username
67
- 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`.
68
68
 
69
- **Password reset** — `usePasswordReset()`: `request({ email })`, then
70
- `confirm({ token, new_password })` with the token the viewer was sent (and `code` when a
71
- second factor is enrolled). **Second factor** — when `signIn` answers
72
- `{ ok: false, challenge: { kind: 'totp' } }`, finish with `completeChallenge(code)`.
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.
73
109
 
74
110
  **The Admin link** — some of the site's members administer its backend, the operator
75
111
  among them. For them, `adminHref` is the address that takes them to the backend's
@@ -235,22 +271,27 @@ Everyone signed in — the operator too, as on a real site — reads and writes
235
271
  entities and nothing of anyone else's (`memberFloor: 'read'` in the seed models a
236
272
  service set up to let members read each other's — or per Model,
237
273
  `memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
238
- each member's own records stay private). A new sign-up must be
239
- verified: the link is in `mock.outbox` (the standalone server prints it). The
240
- operator's Admin link answers too, landing on a page of the mock's own — the backend's
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
241
280
  administration is not part of it. State is in memory: restart to reset.
242
281
 
243
- 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:
244
284
 
245
285
  ```bash
246
- npx uniweb-api-mock --port 8787
286
+ npx uniweb-api-mock --port 8787 --site http://localhost:3000
247
287
  ```
248
288
 
249
289
  ## Outside React
250
290
 
251
291
  `@uniweb/api/client` carries the same operations as plain functions and imports no
252
292
  React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
253
- `createEntity`, `deleteEntity`, `adminHref`.
293
+ `createEntity`, `deleteEntity`, `adminHref`, and for the links the backend mails,
294
+ `mailedLink`, `verifyEmail`, `claimOffer` and `forgetMailedLink`.
254
295
 
255
296
  ## License
256
297
 
@@ -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 `api` address for it is the URL it prints. Mail the backend would
12
- * send — a sign-up's verification, a password reset — is printed here instead.
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
- const mock = createMockBackend({ ...(seed ? { seed } : {}) })
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.8.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": {
package/src/client.js CHANGED
@@ -17,6 +17,7 @@ import { ApiError } from './errors.js'
17
17
  import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
18
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.
@@ -450,6 +451,31 @@ export class ApiClient {
450
451
  return this.request('POST', AUTH.resetConfirm, { body: fields, onUnauthorized: 'ignore' })
451
452
  }
452
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
+
453
479
  // ── The cache ─────────────────────────────────────────────────────────────
454
480
 
455
481
  /**
@@ -967,6 +993,10 @@ export const signUp = (fields) => required().signUp(fields)
967
993
  export const requestPasswordReset = (fields) => required().requestPasswordReset(fields)
968
994
  /** @see ApiClient#confirmPasswordReset */
969
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)
970
1000
  /** @see ApiClient#readEntity */
971
1001
  export const readEntity = (args) => required().readEntity(args)
972
1002
  /** @see ApiClient#listEntities */
@@ -980,5 +1010,6 @@ export const deleteEntity = (args) => required().deleteEntity(args)
980
1010
  /** The Admin link for the current viewer, or `null` — never throws; see `adminHrefOf`. */
981
1011
  export const adminHref = () => adminHrefOf(getClient())
982
1012
 
1013
+ export { mailedLink, forgetMailedLink } from './mailed-links.js'
983
1014
  export { ApiError, kindOf } from './errors.js'
984
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/index.js CHANGED
@@ -21,6 +21,10 @@ 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,
@@ -35,6 +39,7 @@ export { useSession } from './hooks/useSession.js'
35
39
  export { useSignIn } from './hooks/useSignIn.js'
36
40
  export { useSignUp } from './hooks/useSignUp.js'
37
41
  export { usePasswordReset } from './hooks/usePasswordReset.js'
42
+ export { useMailedLink } from './hooks/useMailedLink.js'
38
43
  export { useEntity } from './hooks/useEntity.js'
39
44
  export { useRecords } from './hooks/useRecords.js'
40
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,15 @@
1
- import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } 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
+
5
13
  /**
6
14
  * Where the mock's Admin link lands. The backend's administration lives on its own
7
15
  * origin, outside the API, and the mock models none of it: the handoff's redirect
@@ -57,21 +65,53 @@ function adminPage(account) {
57
65
  * in — the operator too, as on a real site — reads and writes their own entities and
58
66
  * nothing of another's (unless the seed's `memberFloor` says the service lets them);
59
67
  * anyone signed in may create entries of any Model (Models are open); sign-up
60
- * leaves an account unverified until the link in `mock.outbox` is followed. The
61
- * operator is the account the backend's administration admits, so its Admin link
62
- * answers — with a redirect to a page of the mock's own, since the administration
63
- * itself is not modelled. It does not model entitlements behind `via`, nested
64
- * sections, or second factors.
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.
65
81
  *
66
82
  * @param {object} [options]
67
- * @param {object} [options.seed] - accounts, schemas and entities to start from (see `MockStore`)
83
+ * @param {object} [options.seed] - accounts, schemas, entities and offers to start from (see `MockStore`)
68
84
  * @param {string} [options.signedInAs] - a seeded `username` to start signed in as. Throws if not seeded.
69
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
70
92
  * @returns {{ fetch: (request: Request) => Promise<Response>, store: MockStore, diagnostics: object[], outbox: object[] }}
71
93
  */
72
- 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 } = {}) {
73
95
  const store = new MockStore(seed, { signedInAs })
74
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
+
75
115
  const json = (status, body) =>
76
116
  new Response(body === undefined ? null : JSON.stringify(body), {
77
117
  status,
@@ -258,6 +298,15 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
258
298
  }
259
299
  }
260
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
+
261
310
  // ── A Model's definition ─────────────────────────────────────────────────
262
311
  if (path.startsWith(`${MODELS}/`) && method === 'GET') {
263
312
  const bad = unexpected(q, [])
@@ -436,18 +485,24 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
436
485
  get diagnostics() {
437
486
  return store.diagnostics
438
487
  },
439
- /** The mail the backend would have sent — sign-up verification and password-reset tokens. */
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
+ */
440
492
  get outbox() {
441
493
  return store.outbox
442
494
  },
443
495
  async fetch(request) {
496
+ let response
444
497
  try {
445
- return await route(request)
498
+ response = await route(request)
446
499
  } catch (err) {
447
500
  // A mock that throws leaves the caller staring at a network error and
448
501
  // blaming their own code. Answer, and say it was us.
449
- return problem({ status: 500, title: 'MockFailure', detail: err?.message || 'the mock threw' })
502
+ response = problem({ status: 500, title: 'MockFailure', detail: err?.message || 'the mock threw' })
450
503
  }
504
+ deliver(request)
505
+ return response
451
506
  },
452
507
  }
453
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
  /**
@@ -31,9 +31,15 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
31
31
  * drifts and is believed anyway.
32
32
  *
33
33
  * ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
34
- * 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).
35
38
  */
36
39
 
40
+ /** A mailed link — the site's root, the step's parameter, the token. */
41
+ const linkTo = (step, token) => `/?${step}=${encodeURIComponent(token)}`
42
+
37
43
  /** The unit every account of a site acts in — an entity's `unit_id`. */
38
44
  export const SITE_UNIT = 1
39
45
 
@@ -123,6 +129,9 @@ export class MockStore {
123
129
  * member's are private. A string applies to every Model; a map `{ '@scope/name': 'read' }`
124
130
  * applies per Model, so a service can open the operator's content (courses, lessons) to
125
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.
126
135
  * @param {object} [options]
127
136
  * @param {string} [options.signedInAs] - start with this account already signed in.
128
137
  */
@@ -167,9 +176,16 @@ export class MockStore {
167
176
  * seed lists as `migration_debt`. Empty is the good state.
168
177
  */
169
178
  this.diagnostics = []
170
- /** Mail the backend would send: `{ to, subject, token, verify? }`, newest last. */
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
+ */
171
184
  this.outbox = []
172
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)
173
189
  this.entities = new Map()
174
190
  for (const e of seed.entities || []) this.seedEntity(e)
175
191
 
@@ -255,7 +271,7 @@ export class MockStore {
255
271
  const account = this.addAccount({ username, email, password }, { verified: false })
256
272
  const token = newUuid()
257
273
  this.pending.verify.set(token, account)
258
- this.outbox.push({ to: email, subject: 'Confirm your email address', token, verify: `/auth/verify?token=${token}` })
274
+ this.outbox.push({ to: email, subject: 'Confirm your email address', token, link: linkTo(LINK.verify, token) })
259
275
  }
260
276
  return { status: 202, body: { status: 'verification_required', email } }
261
277
  }
@@ -273,7 +289,7 @@ export class MockStore {
273
289
  if (account) {
274
290
  const token = newUuid()
275
291
  this.pending.reset.set(token, account)
276
- 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) })
277
293
  }
278
294
  return { status: 202, body: { status: 'reset_requested' } }
279
295
  }
@@ -288,6 +304,33 @@ export class MockStore {
288
304
  return { body: { reset: true } }
289
305
  }
290
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
+
291
334
  // ── Models ──────────────────────────────────────────────────────────────────
292
335
 
293
336
  defineModel(name, decl = {}, itemSections = new Set()) {
package/src/wire.js CHANGED
@@ -91,10 +91,12 @@ export const AUTH = {
91
91
  logout: '/auth/logout',
92
92
  register: '/auth/register',
93
93
  /**
94
- * `GET /auth/verify?token=` → `{ verified: true }`; a bad token is `400 Validation`.
95
- * ⚠️ This package does not call it: the link in the verification email is the
96
- * backend's own, not a page of the site. The mock serves it so a sign-up can be
97
- * finished locally.
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',
@@ -136,7 +138,7 @@ export const HANDOFF_REFUSAL = Object.freeze({
136
138
  * | challenge | `{ challenge_token, code }` | as login's `200`; a bad token or code is `401` |
137
139
  * | register | `{ username, email, password }` | `202 { status: 'verification_required', email }` — the same answer for an address already taken; a taken **username** is `409` |
138
140
  * | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
139
- * | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`; a bad token is `400 Validation`; `code` is required when a second factor is enrolled |
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) |
140
142
  * | logout | none | `204`; with no session to end, `401` |
141
143
  * | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app } }` · `401` |
142
144
  *
@@ -153,6 +155,56 @@ export const AUTH_BODY = {
153
155
  resetConfirm: ['token', 'new_password', 'code?'],
154
156
  }
155
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
+
156
208
  /** A sign-in that needs a second factor. MEASURED. */
157
209
  export const TOTP = {
158
210
  status: 'totp_required',
@@ -241,6 +293,7 @@ export const ROUTES = {
241
293
  * | items | `model` (required) · `readback` |
242
294
  * | create | `model` (required) |
243
295
  * | remove | `rev_ref_policy` |
296
+ * | verify (`AUTH.verify`) | `token` (required) |
244
297
  *
245
298
  * ✅ **`via` and `depth` compose** (an assumption retired 2026-09-18): both are
246
299
  * parameters of the same read and answer different questions — `via` *who may
@@ -265,6 +318,7 @@ export const PARAM = {
265
318
  maxDepth: 'max_depth',
266
319
  readback: 'readback',
267
320
  revRefPolicy: 'rev_ref_policy',
321
+ token: 'token',
268
322
  }
269
323
 
270
324
  /**
@@ -472,7 +526,7 @@ export const PROBLEM = {
472
526
  * ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
473
527
  * confirmed. Each entry says what we do, and what breaks if we are wrong.
474
528
  *
475
- * ⭐ **Empty since 2026-09-18.** The last two were answered by measurement:
529
+ * Empty from 2026-09-18 to 2026-10-11, when the last two were answered by measurement:
476
530
  *
477
531
  * | retired | answered as |
478
532
  * |---|---|
@@ -483,4 +537,11 @@ export const PROBLEM = {
483
537
  * `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
484
538
  * `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
485
539
  */
486
- 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
+ ]