@uniweb/api 0.9.0 → 1.0.1

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
@@ -81,15 +81,15 @@ import { useMailedLink } from '@uniweb/api'
81
81
 
82
82
  // Wherever the site's root renders it — a header or a layout is on every page.
83
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>
84
+ const { step, status, error, confirm } = useMailedLink()
86
85
  if (step === 'reset' && status !== 'done') {
87
86
  return <NewPassword onSave={(password) => confirm({ new_password: password })} error={error} />
88
87
  }
88
+ if (step === 'verify' && status === 'done') return <p>Your address is confirmed. Sign in.</p>
89
+ if (step === 'reset' && status === 'done') return <p>Your password is changed. Sign in.</p>
89
90
  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
91
+ if (error?.kind === 'invalid' || error?.kind === 'absent') return <p>This link can no longer be used.</p>
92
+ return null // a claimed offer: what it holds is listed by useInvitations, below
93
93
  }
94
94
  ```
95
95
 
@@ -99,14 +99,53 @@ function FromYourEmail() {
99
99
  `min_length`), and the same link works again. `offer` waits until someone is signed
100
100
  in — whatever their account's own address — and then claims by itself.
101
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
102
+ account's answer, and entities now shared with it — never names. Both `0` means it
103
103
  bound nothing. ⛔ A token unknown, revoked or already used is ONE answer,
104
- `error.kind === 'absent'`: say one sentence, naming no cause.
104
+ `error.kind === 'absent'`: say one sentence, naming no cause. It is not "no
105
+ invitation": a member who confirmed the address the offer was sent to holds what it
106
+ bound already, and its link then answers `absent`.
105
107
  - Once the backend has judged the token, it leaves the address bar, so a reload asks
106
108
  nothing again; a link the backend could not be reached about (`kind: 'unavailable'`)
107
109
  stays, and works on a reload. However many components use the hook, the link is
108
110
  finished once. `dismiss()` forgets it unfinished.
109
111
 
112
+ **Invitations** — to join an organization on the site's backend. One sent to a member's
113
+ account waits at once (its email is a plain link to your site: they sign in); one sent to
114
+ an address with no account here waits once its `?offer=` link is claimed, or once someone
115
+ signs up with that address and confirms it. `useInvitations()` lists the ones waiting,
116
+ and answers them:
117
+
118
+ ```jsx
119
+ import { useInvitations } from '@uniweb/api'
120
+
121
+ function Invitations() {
122
+ const { status, invitations, accept, decline, answering } = useInvitations()
123
+ if (status !== 'ready') return null
124
+ return invitations.map((invitation) => (
125
+ <p key={invitation.id}>
126
+ {invitation.unit_name ?? 'An organization'} invited you.
127
+ <button disabled={answering === invitation.id} onClick={() => accept(invitation.id)}>Join</button>
128
+ <button disabled={answering === invitation.id} onClick={() => decline(invitation.id)}>Decline</button>
129
+ </p>
130
+ ))
131
+ }
132
+ ```
133
+
134
+ - An invitation is `{ id, unit_uuid, unit_handle, unit_name, invited_by_account_id,
135
+ created_at }`, as the backend lists it; `unit_handle` and `unit_name` may be `null`.
136
+ - `absent` — no backend, or nobody signed in. `ready` with `invitations: []` — none
137
+ waiting.
138
+ - `accept(id)` and `decline(id)` answer one and reject as the backend does; `answering`
139
+ is the id being answered, `answerError` the last refusal. After either the list reads
140
+ again. After an accept, so does everything else the viewer has read, and the session
141
+ is asked again.
142
+ - An invitation that no longer waits — answered in another tab, or withdrawn by its
143
+ organization — is refused with `kind: 'rule'`, and the list reads again without it.
144
+ Its `detail` says which, for people: branch on `kind`, never on the words. `absent`
145
+ means the id is not this member's to answer.
146
+ - ⭐ Draw it wherever a signed-in member lands, not only after an `?offer=` link: an
147
+ invitation sent to an account comes with no link to follow.
148
+
110
149
  **The Admin link** — some of the site's members administer its backend, the operator
111
150
  among them. For them, `adminHref` is the address that takes them to the backend's
112
151
  administration, already signed in; for everyone else it is `null`, so draw the link
@@ -275,7 +314,10 @@ each member's own records stay private). The mail the backend would send — a s
275
314
  confirmation, a password reset — is printed where the mock runs, in the terminal of
276
315
  `uniweb dev`, its link on your site's root as the backend's is, so following it is
277
316
  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.
317
+ `offers: [{ email: 'guest@example.test', invitations: 1 }]` is mailed when the mock starts,
318
+ and claiming it — or signing up with that address and confirming it — leaves the
319
+ invitation waiting. An offer to an address a seeded account holds waits for that account
320
+ from the start.
279
321
  The operator's Admin link answers too, landing on a page of the mock's own — the backend's
280
322
  administration is not part of it. State is in memory: restart to reset.
281
323
 
@@ -290,8 +332,9 @@ npx uniweb-api-mock --port 8787 --site http://localhost:3000
290
332
 
291
333
  `@uniweb/api/client` carries the same operations as plain functions and imports no
292
334
  React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
293
- `createEntity`, `deleteEntity`, `adminHref`, and for the links the backend mails,
294
- `mailedLink`, `verifyEmail`, `claimOffer` and `forgetMailedLink`.
335
+ `createEntity`, `deleteEntity`, `adminHref`, for the links the backend mails,
336
+ `mailedLink`, `verifyEmail`, `claimOffer` and `forgetMailedLink`, and for invitations,
337
+ `listInvitations`, `acceptInvitation` and `declineInvitation`.
295
338
 
296
339
  ## License
297
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.9.0",
3
+ "version": "1.0.1",
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.1"
37
+ "@uniweb/core": "~1.0.1"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.0.0"
package/src/client.js CHANGED
@@ -18,6 +18,7 @@ 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
20
  import { verifyEmailWith, claimOfferWith } from './mailed-links.js'
21
+ import { listInvitationsWith, acceptInvitationWith, declineInvitationWith } from './invitations.js'
21
22
 
22
23
  /**
23
24
  * The site service this package reads its base from — the only name it owns.
@@ -476,6 +477,39 @@ export class ApiClient {
476
477
  return claimOfferWith(this, token)
477
478
  }
478
479
 
480
+ /**
481
+ * The invitations waiting for the signed-in account's answer — to join an
482
+ * organization — each as the backend lists it: `{ id, unit_uuid, unit_handle,
483
+ * unit_name, invited_by_account_id, created_at }`. Nobody signed in throws `auth`.
484
+ *
485
+ * @param {{ signal?: AbortSignal }} [options]
486
+ * @returns {Promise<object[]>}
487
+ */
488
+ listInvitations(options) {
489
+ return listInvitationsWith(this, options)
490
+ }
491
+
492
+ /**
493
+ * Accept an invitation by its `id`. What the viewer has read is read again, and the
494
+ * session is asked again.
495
+ *
496
+ * @param {number} id
497
+ * @returns {Promise<object>}
498
+ */
499
+ acceptInvitation(id) {
500
+ return acceptInvitationWith(this, id)
501
+ }
502
+
503
+ /**
504
+ * Decline an invitation by its `id`.
505
+ *
506
+ * @param {number} id
507
+ * @returns {Promise<object>}
508
+ */
509
+ declineInvitation(id) {
510
+ return declineInvitationWith(this, id)
511
+ }
512
+
479
513
  // ── The cache ─────────────────────────────────────────────────────────────
480
514
 
481
515
  /**
@@ -997,6 +1031,12 @@ export const confirmPasswordReset = (fields) => required().confirmPasswordReset(
997
1031
  export const verifyEmail = (token) => verifyEmailWith(required(), token)
998
1032
  /** @see ApiClient#claimOffer — on any instance, an older copy's too */
999
1033
  export const claimOffer = (token) => claimOfferWith(required(), token)
1034
+ /** @see ApiClient#listInvitations — on any instance, an older copy's too */
1035
+ export const listInvitations = (options) => listInvitationsWith(required(), options)
1036
+ /** @see ApiClient#acceptInvitation — on any instance, an older copy's too */
1037
+ export const acceptInvitation = (id) => acceptInvitationWith(required(), id)
1038
+ /** @see ApiClient#declineInvitation — on any instance, an older copy's too */
1039
+ export const declineInvitation = (id) => declineInvitationWith(required(), id)
1000
1040
  /** @see ApiClient#readEntity */
1001
1041
  export const readEntity = (args) => required().readEntity(args)
1002
1042
  /** @see ApiClient#listEntities */
package/src/errors.js CHANGED
@@ -24,8 +24,9 @@
24
24
  * ONLY this. Retrying blind would overwrite their change
25
25
  * rule the write breaks a rule the Model declares — an insert-only
26
26
  * section, a section that holds one item, a reference that still
27
- * points here. `detail` says which; the same write fails the same
28
- * way again
27
+ * points here — or the backend's own: an invitation is answered
28
+ * once. `detail` says which; the same write fails the same way
29
+ * again
29
30
  * csrf the mutation lacked the header this package always sends —
30
31
  * a bug, not a state to handle
31
32
  * step-up the credential must be re-proven for this mutation
@@ -49,7 +50,8 @@ const TITLE_KINDS = {
49
50
  *
50
51
  * ⛔ **A `409` is a `conflict` only when it carries `current_updated_at`.** The
51
52
  * backend answers `409` for several unrelated refusals — an insert-only section,
52
- * a second item in a one-item section, a reference that pins an entity — and
53
+ * a second item in a one-item section, a reference that pins an entity, an
54
+ * invitation that no longer waits — and
53
55
  * reporting those as "someone else changed this first" tells a person to reload
54
56
  * and try again when nothing they do will make the write succeed. The token is
55
57
  * what marks the stale case; nothing else carries it (measured 2026-09-18).
@@ -0,0 +1,144 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
2
+ import { getClient } from '../client.js'
3
+ import { ApiError } from '../errors.js'
4
+ import { INVITATIONS_SPEC, listInvitationsWith, acceptInvitationWith, declineInvitationWith } from '../invitations.js'
5
+
6
+ const noSubscribe = () => () => {}
7
+ const noSnapshot = () => null
8
+ const NONE = Object.freeze([])
9
+ const QUIET = Object.freeze({ answering: null, answerError: null })
10
+
11
+ /**
12
+ * The invitations waiting for the viewer's answer — to join an organization of the
13
+ * site's backend — and the answers.
14
+ *
15
+ * ```jsx
16
+ * const { status, invitations, accept, decline, answering } = useInvitations()
17
+ * if (status !== 'ready') return null
18
+ * return invitations.map((invitation) => (
19
+ * <p key={invitation.id}>
20
+ * {invitation.unit_name ?? 'An organization'} invited you.
21
+ * <button disabled={answering === invitation.id} onClick={() => accept(invitation.id)}>Join</button>
22
+ * <button disabled={answering === invitation.id} onClick={() => decline(invitation.id)}>Decline</button>
23
+ * </p>
24
+ * ))
25
+ * ```
26
+ *
27
+ * - `absent` — no backend, or nobody signed in: there is nobody to invite. `ready` with
28
+ * `invitations: []` — none waiting.
29
+ * - Each invitation is as the backend lists it: `{ id, unit_uuid, unit_handle,
30
+ * unit_name, invited_by_account_id, created_at }` — `unit_handle` and `unit_name`
31
+ * may be `null`.
32
+ * - `accept(id)` and `decline(id)` answer one, and reject as the backend does;
33
+ * `answering` is the id being answered, and `answerError` the last refusal. After
34
+ * either the list reads again; after an accept, so does everything else the viewer
35
+ * has read, and the session is asked again.
36
+ * - An invitation that no longer waits — answered in another tab, or withdrawn by its
37
+ * organization — is refused with `kind: 'rule'`, its `detail` saying which, for
38
+ * people, and the list reads again without it. `absent` — the id is not this
39
+ * account's to answer.
40
+ *
41
+ * ⭐ **Draw it wherever a signed-in member lands, not only after an `?offer=` link.**
42
+ * An invitation sent to an account waits with no link to follow, and a member who
43
+ * confirmed the address an offer was sent to holds its invitation already — its link
44
+ * then answers `absent` (`useMailedLink`).
45
+ *
46
+ * Cached under a key scoped to the viewer, so every component that uses this shares
47
+ * one request, a sign-in reads the list for who is now looking, and a claim
48
+ * (`claimOffer`) that leaves invitations waiting reads it again.
49
+ *
50
+ * @returns {{
51
+ * status: 'absent' | 'loading' | 'ready' | 'error',
52
+ * invitations: object[],
53
+ * error: Error | null,
54
+ * accept: (id: number) => Promise<object>,
55
+ * decline: (id: number) => Promise<object>,
56
+ * answering: number | null,
57
+ * answerError: Error | null,
58
+ * refresh: () => void,
59
+ * }}
60
+ */
61
+ export function useInvitations() {
62
+ const client = getClient()
63
+ const store = client?.website?.dataStore ?? null
64
+
65
+ const session = useSyncExternalStore(
66
+ client ? client.subscribe : noSubscribe,
67
+ client ? () => client.session : noSnapshot,
68
+ client ? () => client.session : noSnapshot,
69
+ )
70
+
71
+ // Nothing waits for nobody, so the list waits for the session to be known.
72
+ useEffect(() => {
73
+ if (client?.enabled) client.ensureSession()
74
+ }, [client])
75
+
76
+ const active = !!(client && client.enabled && store && session?.status === 'authenticated')
77
+ const key = active ? client.cacheKey(INVITATIONS_SPEC) : null
78
+
79
+ const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
80
+ const entry = useSyncExternalStore(
81
+ subscribe,
82
+ () => (key ? store.get(key) : null),
83
+ () => (key ? store.get(key) : null),
84
+ )
85
+
86
+ const [error, setError] = useState(null)
87
+ const [attempt, setAttempt] = useState(0)
88
+ const [answer, setAnswer] = useState(QUIET)
89
+
90
+ const live = useRef(true)
91
+ useEffect(() => {
92
+ live.current = true
93
+ return () => {
94
+ live.current = false
95
+ }
96
+ }, [])
97
+ // An answer in flight must not be reported by a later render of a stale closure.
98
+ const seq = useRef(0)
99
+
100
+ useEffect(() => {
101
+ if (!key || entry) return undefined
102
+ let current = true
103
+ setError(null)
104
+ client.load(key, () => listInvitationsWith(client), INVITATIONS_SPEC).catch((err) => {
105
+ if (current) setError(err)
106
+ })
107
+ return () => {
108
+ current = false
109
+ }
110
+ }, [client, key, entry, attempt])
111
+
112
+ const respond = useCallback(
113
+ async (run, id) => {
114
+ if (!client || !client.enabled) throw ApiError.disabled()
115
+ const mine = (seq.current += 1)
116
+ setAnswer({ answering: id, answerError: null })
117
+ try {
118
+ const result = await run(client, id)
119
+ if (live.current && seq.current === mine) setAnswer(QUIET)
120
+ return result
121
+ } catch (err) {
122
+ if (live.current && seq.current === mine) setAnswer({ answering: null, answerError: err })
123
+ throw err
124
+ }
125
+ },
126
+ [client],
127
+ )
128
+
129
+ const accept = useCallback((id) => respond(acceptInvitationWith, id), [respond])
130
+ const decline = useCallback((id) => respond(declineInvitationWith, id), [respond])
131
+
132
+ const refresh = useCallback(() => {
133
+ if (key && store) store.delete(key)
134
+ setAttempt((n) => n + 1)
135
+ }, [store, key])
136
+
137
+ const actions = { accept, decline, ...answer, refresh }
138
+ if (!active) return { status: 'absent', invitations: NONE, error: null, ...actions }
139
+ if (entry) return { status: 'ready', invitations: entry.data, error: null, ...actions }
140
+ if (error) return { status: 'error', invitations: NONE, error, ...actions }
141
+ return { status: 'loading', invitations: NONE, error: null, ...actions }
142
+ }
143
+
144
+ export default useInvitations
@@ -22,7 +22,7 @@ const noSnapshot = () => null
22
22
  * // link.status: 'idle' | 'working' | 'done' | 'error'
23
23
  * if (link.step === 'verify' && link.status === 'done') return <p>Your address is confirmed. Sign in.</p>
24
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>
25
+ * if (link.step === 'offer' && link.status === 'idle') return <p>Sign in, or create an account, to take up your invitation.</p>
26
26
  * ```
27
27
  *
28
28
  * - **`verify`** finishes by itself: `working`, then `done` (`result: { verified: true }`),
@@ -34,10 +34,12 @@ const noSnapshot = () => null
34
34
  * 'password_too_short'` and `min_length`, and the same link works again.
35
35
  * - **`offer`** waits for a sign-in — `idle` until someone is signed in, so offer the
36
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.
37
+ * grants }`, COUNTS — invitations now waiting for this account's answer, which
38
+ * `useInvitations` lists and answers, and entities now shared with it — or `error`,
39
+ * `kind: 'absent'` for a token unknown, revoked or already used. ⛔ One sentence for
40
+ * that, naming no cause: the backend keeps them indistinguishable on purpose — and
41
+ * it is not "no invitation": a member who confirmed the address the offer was sent to
42
+ * holds its invitation already, and the link then answers `absent`.
41
43
  *
42
44
  * Once the backend has judged the token, its parameter leaves the address bar, so a
43
45
  * reload asks nothing again. `dismiss()` forgets the link without finishing it. On a
@@ -84,9 +86,16 @@ export function useMailedLink() {
84
86
  if (client?.enabled) setLink(mailedLink())
85
87
  }, [client])
86
88
 
89
+ // An offer waits for a session — so ask whether there is one, rather than wait for
90
+ // another component to.
91
+ const offer = link?.step === LINK.offer
92
+ useEffect(() => {
93
+ if (offer) client.ensureSession()
94
+ }, [client, offer])
95
+
87
96
  // An offer is claimed for whoever is signed in — and again, for another account.
88
97
  const signedIn = session?.status === 'authenticated' ? (session.viewer?.uuid ?? 'signed-in') : null
89
- const claimant = link?.step === LINK.offer ? signedIn : null
98
+ const claimant = offer ? signedIn : null
90
99
  const finishes = !!link && (link.step === LINK.verify || claimant !== null)
91
100
 
92
101
  useEffect(() => {
package/src/index.js CHANGED
@@ -23,6 +23,9 @@ export {
23
23
  confirmPasswordReset,
24
24
  verifyEmail,
25
25
  claimOffer,
26
+ listInvitations,
27
+ acceptInvitation,
28
+ declineInvitation,
26
29
  mailedLink,
27
30
  forgetMailedLink,
28
31
  readEntity,
@@ -40,6 +43,7 @@ export { useSignIn } from './hooks/useSignIn.js'
40
43
  export { useSignUp } from './hooks/useSignUp.js'
41
44
  export { usePasswordReset } from './hooks/usePasswordReset.js'
42
45
  export { useMailedLink } from './hooks/useMailedLink.js'
46
+ export { useInvitations } from './hooks/useInvitations.js'
43
47
  export { useEntity } from './hooks/useEntity.js'
44
48
  export { useRecords } from './hooks/useRecords.js'
45
49
  export { useEntityWriter } from './hooks/useEntityWriter.js'
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Invitations — to join an organization of the site's backend, waiting for the
3
+ * member's answer.
4
+ *
5
+ * An invitation reaches a member two ways: sent to their account, it waits at once;
6
+ * sent to an address with no account here, it is an offer until the member claims it
7
+ * from its mailed link or confirms that address (`./mailed-links.js`). Either way it
8
+ * waits in one list, and is accepted or declined one by one. No React in this module;
9
+ * `useInvitations` is the hook over it.
10
+ *
11
+ * ⭐ **Functions of a client, not methods** — the page's client may have been created
12
+ * by an older copy of this package (`./mailed-links.js` says why). Everything here
13
+ * reaches the wire through `request` and keeps its state on the instance.
14
+ */
15
+
16
+ import { ApiError } from './errors.js'
17
+ import { INVITATION, INVITATIONS } from './wire.js'
18
+
19
+ /** What the list is cached under — so a claim or an answer can drop it. */
20
+ export const INVITATIONS_SPEC = Object.freeze({ endpoint: INVITATIONS.mine })
21
+
22
+ /** Whether a cached read is the invitations list. */
23
+ export const isInvitations = (spec) => spec?.endpoint === INVITATIONS.mine
24
+
25
+ /**
26
+ * The invitations waiting for the signed-in account's answer — each as the backend
27
+ * lists it: `{ id, unit_uuid, unit_handle, unit_name, invited_by_account_id,
28
+ * created_at }`, `unit_handle` and `unit_name` possibly `null`.
29
+ *
30
+ * @param {object} client
31
+ * @param {{ signal?: AbortSignal }} [options]
32
+ * @returns {Promise<object[]>}
33
+ * @throws {ApiError} `auth` — nobody signed in
34
+ */
35
+ export async function listInvitationsWith(client, { signal } = {}) {
36
+ const body = await client.request('GET', INVITATIONS.mine, { signal })
37
+ return Array.isArray(body?.[INVITATION.list]) ? body[INVITATION.list] : []
38
+ }
39
+
40
+ /**
41
+ * Accept an invitation — join the organization. The list is read again, and so is
42
+ * everything else the viewer has read, since what they may read can follow what they
43
+ * belong to (a list row's `via` can be `unit_member`); the session is asked again too,
44
+ * so whatever it carries is current.
45
+ *
46
+ * @param {object} client
47
+ * @param {number} id - the invitation's `id`, from the list
48
+ * @returns {Promise<object>} the backend's answer — the invitation, `status: 'accepted'`
49
+ * @throws {ApiError} `rule` — it no longer waits, and the list reads again · `absent` —
50
+ * the id is not this account's to answer · `auth` — nobody signed in
51
+ */
52
+ export async function acceptInvitationWith(client, id) {
53
+ const answer = await answered(client, INVITATIONS.accept(required(id, 'acceptInvitation')))
54
+ client.invalidate?.(() => true)
55
+ client.refresh?.()
56
+ return answer
57
+ }
58
+
59
+ /**
60
+ * Decline an invitation. The list is read again.
61
+ *
62
+ * @param {object} client
63
+ * @param {number} id - the invitation's `id`, from the list
64
+ * @returns {Promise<object>} the backend's answer — the invitation, `status: 'declined'`
65
+ * @throws {ApiError} as `acceptInvitationWith` does
66
+ */
67
+ export async function declineInvitationWith(client, id) {
68
+ const answer = await answered(client, INVITATIONS.decline(required(id, 'declineInvitation')))
69
+ client.invalidate?.(isInvitations)
70
+ return answer
71
+ }
72
+
73
+ /**
74
+ * An answer, sent. ⭐ **One that no longer waits is still in the list this page holds**
75
+ * — answered in another tab, or withdrawn by its organization — so that refusal reads
76
+ * the list again, and the invitation leaves it. Which of those it was is the refusal's
77
+ * `detail`, for people; nothing here branches on it. Any other refusal leaves the cache.
78
+ */
79
+ async function answered(client, route) {
80
+ try {
81
+ return await client.request('POST', route)
82
+ } catch (error) {
83
+ if (error?.status === INVITATION.notWaiting) client.invalidate?.(isInvitations)
84
+ throw error
85
+ }
86
+ }
87
+
88
+ function required(id, name) {
89
+ if (id == null || id === '') throw ApiError.invalid('No Invitation', `${name} needs the id of an invitation, from the list`)
90
+ return id
91
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { ApiError } from './errors.js'
16
16
  import { AUTH, CLAIM, LINK, OFFERS, PARAM } from './wire.js'
17
+ import { isInvitations } from './invitations.js'
17
18
 
18
19
  const STEPS = Object.values(LINK)
19
20
 
@@ -75,20 +76,26 @@ export async function verifyEmailWith(client, token) {
75
76
 
76
77
  /**
77
78
  * 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.
79
+ * in. Answers counts (`CLAIM`): invitations now waiting for this account to answer
80
+ * (`./invitations.js`), and entities now shared with it — so the invitations list reads
81
+ * again when there are any of the first, and every cached read of the viewer's when
82
+ * there are any of the second.
83
+ *
84
+ * ⛔ **`absent` does not mean nothing is waiting.** A member who confirmed the address
85
+ * the offer was sent to holds what it bound already, and its token then answers
86
+ * `absent` like any other it cannot use.
81
87
  *
82
88
  * @param {object} client
83
89
  * @param {string} token
84
90
  * @returns {Promise<{ invitations: number, grants: number }>}
85
91
  * @throws {ApiError} `absent` — unknown, revoked or already claimed, one answer by design ·
86
- * `auth` — nobody signed in
92
+ * `auth` — nobody signed in, and the token is not spent
87
93
  */
88
94
  export async function claimOfferWith(client, token) {
89
95
  if (!token) throw ApiError.invalid('No Token', 'claimOffer needs the token of the mailed link')
90
96
  const answer = await client.request('POST', OFFERS.claim, { body: { [CLAIM.token]: token } })
91
- if (answer?.[CLAIM.grants] > 0) client.invalidate?.(() => true)
97
+ const shared = answer?.[CLAIM.grants] > 0
98
+ if (shared || answer?.[CLAIM.invitations] > 0) client.invalidate?.((spec) => shared || isInvitations(spec))
92
99
  return answer
93
100
  }
94
101
 
package/src/mock/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AUTH, ENTITIES, MODELS, OFFERS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } from '../wire.js'
1
+ import { AUTH, ENTITIES, MODELS, OFFERS, INVITATIONS, 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
 
@@ -68,8 +68,10 @@ function adminPage(account) {
68
68
  * leaves an account unverified until its address is confirmed. The operator is the
69
69
  * account the backend's administration admits, so its Admin link answers — with a
70
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.
71
+ * modelled. An offer, claimed or bound by confirming its address, leaves its
72
+ * invitations waiting, each to answer once. It does not model entitlements behind
73
+ * `via`, nested sections, second factors, password rules, an offer's grants, what
74
+ * joining an organization gives, or an organization withdrawing an invitation.
73
75
  *
74
76
  * ## The mail it sends
75
77
  *
@@ -307,6 +309,25 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
307
309
  return fields(b, ['token'], '{"token": ...}') ?? answer(store.claimOffer(b.token))
308
310
  }
309
311
 
312
+ // ── Invitations ─────────────────────────────────────────────────────────
313
+ if (path === INVITATIONS.mine && method === 'GET') {
314
+ const bad = unexpected(q, [])
315
+ if (bad) return bad
316
+ if (!store.account) return unauthorized()
317
+ return json(200, store.invitationsOf(store.account))
318
+ }
319
+ const invitation = path.match(/^\/unit\/invitations\/([^/]+)\/[^/]+$/)
320
+ if (invitation && method === 'POST') {
321
+ const id = invitation[1]
322
+ const status = path === INVITATIONS.accept(id) ? 'accepted' : path === INVITATIONS.decline(id) ? 'declined' : null
323
+ if (status) {
324
+ const bad = unexpected(q, [])
325
+ if (bad) return bad
326
+ if (!store.account) return unauthorized()
327
+ return answer(store.answerInvitation(id, status))
328
+ }
329
+ }
330
+
310
331
  // ── A Model's definition ─────────────────────────────────────────────────
311
332
  if (path.startsWith(`${MODELS}/`) && method === 'GET') {
312
333
  const bad = unexpected(q, [])
package/src/mock/seed.js CHANGED
@@ -15,7 +15,10 @@
15
15
  * section's item, as the backend derives it.
16
16
  * - **`offers`** — optional, and none here: `{ email, invitations?, grants? }`, an
17
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.
18
+ * link (`/?offer=…`) is how a site's pages are tried with one. Claimed — or bound,
19
+ * by signing up with that address and confirming it — it leaves `invitations`
20
+ * waiting to be answered. One to an address a seeded account holds is that
21
+ * account's from the start, and is not mailed: a way to open with one waiting.
19
22
  */
20
23
  export const DEFAULT_SEED = {
21
24
  accounts: [
package/src/mock/store.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CLAIM, FIELD, LINK, OP, PROBLEM } from '../wire.js'
1
+ import { CLAIM, FIELD, INVITATION, LINK, OP, PROBLEM } from '../wire.js'
2
2
  import { checkItemWrite, OUTCOME } from './schema-shape.js'
3
3
 
4
4
  /**
@@ -25,6 +25,11 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
25
25
  * shared — a site runs third-party code with its member's session. *(Until
26
26
  * 2026-10-10 the operator's session held `system_admin` and read and wrote
27
27
  * everything.)* A seeded account is the operator with `operator: true`.
28
+ * - **An offer binds when it is claimed, or when its address is confirmed** — and
29
+ * then its token answers `404`, as an unknown one does (backend, 2026-10-11). What
30
+ * it binds: its invitations, each waiting for the account's answer, to an
31
+ * organization of its own with no handle and no name. A seeded offer to an address a
32
+ * seeded account holds is bound to that account from the start, and is not mailed.
28
33
  *
29
34
  * ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
30
35
  * A mock that grows a schema and migrations becomes a second implementation that
@@ -32,9 +37,9 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
32
37
  *
33
38
  * ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
34
39
  * 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).
40
+ * an offer's grants (a count, sharing nothing), what joining an organization
41
+ * gives — accepting an invitation answers, and opens nothing to read — and an
42
+ * organization withdrawing an invitation.
38
43
  */
39
44
 
40
45
  /** A mailed link — the site's root, the step's parameter, the token. */
@@ -131,13 +136,14 @@ export class MockStore {
131
136
  * its members while their own records (progress, submissions) stay theirs alone.
132
137
  * @param {object[]} [seed.offers] - `{ email, invitations?, grants? }`: an invitation sent
133
138
  * to an address with no account, mailed when the mock starts. Claiming it answers the
134
- * counts given (default `0`), once.
139
+ * counts given (default `0`), once, and leaves that many invitations waiting. One to
140
+ * an address a seeded account holds is that account's from the start.
135
141
  * @param {object} [options]
136
142
  * @param {string} [options.signedInAs] - start with this account already signed in.
137
143
  */
138
144
  constructor(seed = {}, { signedInAs = null } = {}) {
139
145
  this.clock = makeClock()
140
- this.ids = { account: 1, model: 0, section: 0, entity: 0, item: 0 }
146
+ this.ids = { account: 1, model: 0, section: 0, entity: 0, item: 0, invitation: 0, unit: SITE_UNIT }
141
147
  this.accounts = []
142
148
  for (const a of seed.accounts || []) this.addAccount(a, { verified: true })
143
149
 
@@ -185,6 +191,8 @@ export class MockStore {
185
191
  this.pending = { verify: new Map(), reset: new Map() }
186
192
  /** Offers by token: `{ email, invitations, grants, claimed }`. */
187
193
  this.offers = new Map()
194
+ /** Invitations: `{ id, unit, invitedBy, to, status, createdAt, resolvedAt }` — `to` an account id. */
195
+ this.invitations = []
188
196
  for (const offer of seed.offers || []) this.addOffer(offer)
189
197
  this.entities = new Map()
190
198
  for (const e of seed.entities || []) this.seedEntity(e)
@@ -281,6 +289,8 @@ export class MockStore {
281
289
  if (!account) return refuse(400, 'Validation', 'invalid or expired verification token')
282
290
  this.pending.verify.delete(token)
283
291
  account.verified = true
292
+ // Every offer sent to the address it confirmed is the account's now — and its token spent.
293
+ for (const offer of this.offers.values()) if (!offer.claimed && offer.email === account.email) this.bind(offer, account)
284
294
  return { body: { verified: true } }
285
295
  }
286
296
 
@@ -306,7 +316,11 @@ export class MockStore {
306
316
 
307
317
  // ── Offers ──────────────────────────────────────────────────────────────────
308
318
 
309
- /** An invitation to an address with no account here — its token mailed at once. */
319
+ /**
320
+ * An invitation to an address with no account here — its token mailed at once. To an
321
+ * address a verified account holds, it is bound to that account at once, as confirming
322
+ * the address would have bound it, and nothing is mailed.
323
+ */
310
324
  addOffer({ email, invitations = 0, grants = 0 } = {}) {
311
325
  const count = (n) => Number.isInteger(n) && n >= 0
312
326
  if (typeof email !== 'string' || !email || !count(invitations) || !count(grants)) {
@@ -315,22 +329,84 @@ export class MockStore {
315
329
  )
316
330
  }
317
331
  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) })
332
+ const offer = { email, invitations, grants, claimed: false }
333
+ this.offers.set(token, offer)
334
+ const holder = this.accounts.find((a) => a.email === email && a.verified)
335
+ if (holder) this.bind(offer, holder)
336
+ else this.outbox.push({ to: email, subject: "You've been invited", token, link: linkTo(LINK.offer, token) })
320
337
  return token
321
338
  }
322
339
 
323
340
  /**
324
- * Claim an offer for the account signed in. Unknown, revoked and already claimed are
341
+ * Claim an offer for the account signed in. Unknown, revoked, claimed and bound are
325
342
  * one answer, `404`, as on the backend.
326
343
  */
327
344
  claimOffer(token) {
328
345
  const offer = this.offers.get(token)
329
346
  if (!offer || offer.claimed) return refuse(404, 'Not Found', 'offer not found')
330
- offer.claimed = true
347
+ this.bind(offer, this.account)
331
348
  return { body: { [CLAIM.invitations]: offer.invitations, [CLAIM.grants]: offer.grants } }
332
349
  }
333
350
 
351
+ /** An offer becomes the account's: its token is spent, and its invitations wait. */
352
+ bind(offer, account) {
353
+ offer.claimed = true
354
+ for (let n = 0; n < offer.invitations; n += 1) {
355
+ this.invitations.push({
356
+ id: (this.ids.invitation += 1),
357
+ unit: { id: (this.ids.unit += 1), uuid: newUuid(), handle: null, name: null },
358
+ invitedBy: this.operator()?.id ?? null,
359
+ to: account.id,
360
+ status: 'pending',
361
+ createdAt: this.clock(),
362
+ resolvedAt: null,
363
+ })
364
+ }
365
+ }
366
+
367
+ // ── Invitations ─────────────────────────────────────────────────────────────
368
+
369
+ /** `GET /invitations/me` — the ones waiting for this account's answer, in the backend's shape. */
370
+ invitationsOf(account) {
371
+ return {
372
+ [INVITATION.list]: this.invitations
373
+ .filter((i) => i.to === account.id && i.status === 'pending')
374
+ .map((i) => ({
375
+ id: i.id,
376
+ unit_uuid: i.unit.uuid,
377
+ unit_handle: i.unit.handle,
378
+ unit_name: i.unit.name,
379
+ invited_by_account_id: i.invitedBy,
380
+ created_at: i.createdAt,
381
+ })),
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Accept or decline an invitation waiting for the account signed in — `accepted` or
387
+ * `declined` — as the backend answers (2026-10-12): `404` for an id that is not this
388
+ * account's to answer, unknown or another's alike, and `409` for one of its own that
389
+ * no longer waits, its `detail` naming what it became.
390
+ */
391
+ answerInvitation(id, status) {
392
+ const invitation = this.invitations.find((i) => String(i.id) === String(id) && i.to === this.account?.id)
393
+ if (!invitation) return refuse(404, 'Not Found', `invitation ${id} not found`, { kind: 'invitation', key: String(id) })
394
+ if (invitation.status !== 'pending') return refuse(INVITATION.notWaiting, 'Conflict', `this invitation was already ${invitation.status}`)
395
+ invitation.status = status
396
+ invitation.resolvedAt = this.clock()
397
+ return {
398
+ body: {
399
+ id: invitation.id,
400
+ unit_id: invitation.unit.id,
401
+ to_account_id: invitation.to,
402
+ invited_by: invitation.invitedBy,
403
+ status,
404
+ created_at: invitation.createdAt,
405
+ resolved_at: invitation.resolvedAt,
406
+ },
407
+ }
408
+ }
409
+
334
410
  // ── Models ──────────────────────────────────────────────────────────────────
335
411
 
336
412
  defineModel(name, decl = {}, itemSections = new Set()) {
package/src/wire.js CHANGED
@@ -49,7 +49,13 @@
49
49
  * `/sites/*`. Those routes create sites, which is the app's job, not a
50
50
  * foundation's, and a site's own `backend` service has no site to address.
51
51
  *
52
- * ⛔ Do not add a route that is not under `/entities`, `/models` or `/auth`.
52
+ * Its routes are under `/entities`, `/models` and `/auth`, plus what a member does
53
+ * with an invitation — claim an offer (`OFFERS`), answer an invitation
54
+ * (`INVITATIONS`) — the steps the backend's mail asks a member to finish on the site
55
+ * (2026-10-11). ⛔ Never `/sites/*`, and nothing that administers: the backend's
56
+ * administration is reached through the Admin link (`AUTH.memberHandoff`), never
57
+ * wrapped. *(This said "no route that is not under `/entities`, `/models` or
58
+ * `/auth`" until 2026-10-11.)*
53
59
  */
54
60
  export const ENTITIES = '/entities'
55
61
 
@@ -92,11 +98,13 @@ export const AUTH = {
92
98
  register: '/auth/register',
93
99
  /**
94
100
  * `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.)*
101
+ * member may sign in — it starts no session — and every offer sent to that address
102
+ * binds to the account, its invitations waiting (`OFFERS`, `INVITATIONS`; MEASURED by
103
+ * backend, 2026-10-11). A token that was used, has expired or was never valid is
104
+ * `400 Validation`. The token comes from the link the backend mails, which lands on
105
+ * the site's root (`LINK.verify`), and `verifyEmail` finishes it. *(Until 2026-10-11
106
+ * that link named the backend's own sign-in page, and this package did not call the
107
+ * route.)*
100
108
  */
101
109
  verify: '/auth/verify',
102
110
  resetRequest: '/auth/reset/request',
@@ -181,7 +189,7 @@ export const LINK = {
181
189
  }
182
190
 
183
191
  /**
184
- * Offers. **MEASURED** by backend, 2026-09-15.
192
+ * Offers. **MEASURED** by backend, 2026-09-15 and 2026-10-11.
185
193
  *
186
194
  * Inviting an address nobody here holds — to an organization, or to share an entity —
187
195
  * mints an offer, and the backend mails its token to that address (`LINK.offer`).
@@ -189,10 +197,18 @@ export const LINK = {
189
197
  * account's own address — and what the offer held binds to it.
190
198
  *
191
199
  * `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`.
200
+ * COUNTS, not names: invitations now waiting for this account to answer
201
+ * (`INVITATIONS`), and entities now shared with it, live at once. ⛔ **An unknown,
202
+ * revoked or already-claimed token is ONE answer, `404`**, so the route never says
203
+ * whether a token was ever real. A missing `token` is `400`.
204
+ *
205
+ * - **With no session, `401 { status: 401, title: 'Unauthorized' }`, and the token is
206
+ * not spent** — the same link works once someone signs in (2026-10-11).
207
+ * - **The token is spent by the claim — or earlier, by the member confirming the
208
+ * address the offer was sent to** (`AUTH.verify`), which binds the offer to that
209
+ * account. From then on it answers `404`, as an unknown token does — even to the
210
+ * account that holds what it bound (2026-10-11). ⇒ A `404` on a claim does not mean
211
+ * no invitation is waiting: list them.
196
212
  */
197
213
  export const OFFERS = {
198
214
  claim: '/offers/claim',
@@ -205,6 +221,48 @@ export const CLAIM = {
205
221
  grants: 'grants',
206
222
  }
207
223
 
224
+ /**
225
+ * Invitations to join an organization — a unit, in the backend's word — waiting for the
226
+ * member's answer. **MEASURED** by backend, 2026-10-11 and 2026-10-12.
227
+ *
228
+ * One sent to an address with no account here is an offer until it is claimed or its
229
+ * address confirmed (`OFFERS`); one sent to an account is waiting at once, and its mail
230
+ * is the site's root with no parameter — the member signs in (`LINK`). Either way it
231
+ * waits here, and the claim's answer names none of them, so the list is how a member
232
+ * area finds one:
233
+ *
234
+ * | route | answer |
235
+ * |---|---|
236
+ * | `GET /invitations/me` | `{ invitations: [{ id, unit_uuid, unit_handle, unit_name, invited_by_account_id, created_at }] }` — `id` an integer; `unit_handle` and `unit_name` may be `null`. **Only the invitations still waiting**: one answered, or withdrawn by its organization, leaves the list, which is why a row carries no status. With no session, `401 { status: 401, title: 'Unauthorized' }` |
237
+ * | `POST /unit/invitations/{id}/accept` | `{ id, unit_id, to_account_id, invited_by, status: 'accepted', created_at, resolved_at }` |
238
+ * | `POST /unit/invitations/{id}/decline` | the same row, `status: 'declined'` |
239
+ *
240
+ * Both answers are sent with no body, as the backend's tests send them. Two refusals:
241
+ *
242
+ * - **`404 Not Found` + `kind: 'invitation'`, `key`** — the id is not this account's to
243
+ * answer: unknown, or another account's, one answer by design.
244
+ * - **`409 Conflict` (`INVITATION.notWaiting`)** — it no longer waits: this account
245
+ * answered it, or its organization withdrew it. Its `detail` names what it became —
246
+ * `this invitation was already accepted` (`declined`, `revoked`) — and is for people:
247
+ * ⛔ branch on the status, never the detail. It carries no `current_updated_at`, so
248
+ * its kind is `rule` (`./errors.js`): the same answer fails the same way again.
249
+ */
250
+ export const INVITATIONS = {
251
+ mine: '/invitations/me',
252
+ accept: (id) => `/unit/invitations/${segment(id)}/accept`,
253
+ decline: (id) => `/unit/invitations/${segment(id)}/decline`,
254
+ }
255
+
256
+ /**
257
+ * The list's key — all this package reads of an answer here, since the rows go to the
258
+ * caller as they are — and the status of an answer to an invitation that no longer
259
+ * waits. MEASURED.
260
+ */
261
+ export const INVITATION = {
262
+ list: 'invitations',
263
+ notWaiting: 409,
264
+ }
265
+
208
266
  /** A sign-in that needs a second factor. MEASURED. */
209
267
  export const TOTP = {
210
268
  status: 'totp_required',
@@ -506,6 +564,7 @@ export const SCHEMA = {
506
564
  * | answer | means |
507
565
  * |---|---|
508
566
  * | `409 Conflict` + `item_id`, `current_updated_at` | that item changed since this viewer's token — the ONLY stale-token answer. `item_id` since 2026-09-18; an older backend sends the token alone |
567
+ * | `409 Conflict`, no token | an invitation that no longer waits — answered, or withdrawn (`INVITATIONS`, 2026-10-12) |
509
568
  * | `409 Append-Only Section` + `section` | an insert-only section: its items can be added and moved, not edited or deleted |
510
569
  * | `409 Schema Rule Violation` | a rule of the Model: a second item in a one-item section, a section of another Model, a reference that still points here |
511
570
  * | `403 Forbidden` + `op`, `target` | may read it, may not do this to it — `edit`, `delete`, `use_model` (create) |
@@ -526,22 +585,18 @@ export const PROBLEM = {
526
585
  * ⛔ THE LIST TO HAND BACKEND — everything this package asserts that nobody has
527
586
  * confirmed. Each entry says what we do, and what breaks if we are wrong.
528
587
  *
529
- * Empty from 2026-09-18 to 2026-10-11, when the last two were answered by measurement:
588
+ * Retired by measurement:
530
589
  *
531
590
  * | retired | answered as |
532
591
  * |---|---|
533
- * | `viewer-unit-signal` | ⛔ false — every member of a site acts in the same unit; see `VIEWER` |
534
- * | `via-and-depth-compose` | ✅ true — see `PARAM` |
592
+ * | `viewer-unit-signal` | ⛔ false — every member of a site acts in the same unit; see `VIEWER` (2026-09-18) |
593
+ * | `via-and-depth-compose` | ✅ true — see `PARAM` (2026-09-18) |
594
+ * | `offer-claim-needs-a-session` | ✅ true — `401` with no session, and the token is not spent; see `OFFERS` (2026-10-11) |
595
+ * | `invitations-list-holds-the-waiting` | ✅ true — only the invitations still waiting, and `401` with no session; see `INVITATIONS` (2026-10-12) |
596
+ * | `invitation-answer-refusals` | ⚠️ half — decline answers as the mock did, but one that no longer waits is `409`, not the `404` an id not this account's gets; see `INVITATIONS` (2026-10-12) |
535
597
  *
536
598
  * *(Retired 2026-09-17: `write-response-fields`, `move-exists`, `move-position`,
537
599
  * `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
538
600
  * `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
539
601
  */
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
- ]
602
+ export const ASSUMPTIONS = []