@uniweb/api 0.9.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -10
- package/package.json +3 -3
- package/src/client.js +40 -0
- package/src/errors.js +5 -3
- package/src/hooks/useInvitations.js +144 -0
- package/src/hooks/useMailedLink.js +15 -6
- package/src/index.js +4 -0
- package/src/invitations.js +91 -0
- package/src/mailed-links.js +12 -5
- package/src/mock/index.js +24 -3
- package/src/mock/seed.js +4 -1
- package/src/mock/store.js +87 -11
- package/src/wire.js +77 -22
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,
|
|
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 (
|
|
91
|
-
|
|
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
|
|
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`,
|
|
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": "
|
|
3
|
+
"version": "1.1.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": {
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://github.com/uniweb/api#readme",
|
|
33
33
|
"engines": {
|
|
34
|
-
"node": ">=
|
|
34
|
+
"node": ">=22.12"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@uniweb/core": "~
|
|
37
|
+
"@uniweb/core": "~1.1.0"
|
|
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
|
|
28
|
-
* way
|
|
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
|
|
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 === '
|
|
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
|
|
38
|
-
* shared with it — or `error`,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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 =
|
|
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
|
+
}
|
package/src/mailed-links.js
CHANGED
|
@@ -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
|
|
79
|
-
* entities now shared with it — so
|
|
80
|
-
* and
|
|
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
|
-
|
|
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.
|
|
72
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
319
|
-
this.
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
193
|
-
* now shared with it. ⛔ **An unknown,
|
|
194
|
-
* `404`**, so the route never says
|
|
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
|
-
*
|
|
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 = []
|