@uniweb/api 1.1.1 → 1.2.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 +60 -19
- package/package.json +2 -2
- package/src/client.js +43 -3
- package/src/elevation.js +268 -0
- package/src/hooks/useElevation.js +147 -0
- package/src/hooks/useRecords.js +2 -2
- package/src/hooks/useSession.js +5 -0
- package/src/index.js +6 -0
- package/src/mock/index.js +14 -1
- package/src/mock/store.js +69 -22
- package/src/wire.js +102 -23
package/README.md
CHANGED
|
@@ -52,13 +52,16 @@ function Account() {
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
`viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
|
|
55
|
-
a list of `{ role, scope_unit_id }`. ⭐ **Roles describe the session, and a
|
|
56
|
-
|
|
57
|
-
foundation's code with the signed-in member's session, so
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
a list of `{ role, scope_unit_id }`. ⭐ **Roles describe the session, and on a site a
|
|
56
|
+
session holds the account's roles as a reader's until it is elevated.** A site runs
|
|
57
|
+
your foundation's code with the signed-in member's session, so an administrator signed
|
|
58
|
+
in on it reads what their role reaches — the operator reads every member's content —
|
|
59
|
+
and changes nothing through the role until they confirm it is them (*Acting as an
|
|
60
|
+
administrator*, below). Their own content they edit, delete and share as always. An
|
|
61
|
+
ordinary member holds no role (`[]`). `viewer.workspace` — the site service's home org,
|
|
62
|
+
`{ handle: 'home', … }` — is the same for everyone. To decide whether to show an edit
|
|
63
|
+
control for a particular record, read that record: `entity.can_edit` is the backend's
|
|
64
|
+
own answer.
|
|
62
65
|
|
|
63
66
|
**Signing up** — `useSignUp().signUp({ username, email, password })` answers
|
|
64
67
|
`{ status: 'verification_required', email }`, the same answer for an address already in
|
|
@@ -146,6 +149,42 @@ function Invitations() {
|
|
|
146
149
|
- ⭐ Draw it wherever a signed-in member lands, not only after an `?offer=` link: an
|
|
147
150
|
invitation sent to an account comes with no link to follow.
|
|
148
151
|
|
|
152
|
+
**Acting as an administrator** — an administrator on the site reads what their role
|
|
153
|
+
reaches; to change something through it — remove a member's post, grade a submission —
|
|
154
|
+
they confirm it is them, and for a while the session acts through the role.
|
|
155
|
+
`useElevation()` offers it:
|
|
156
|
+
|
|
157
|
+
```jsx
|
|
158
|
+
import { useElevation } from '@uniweb/api'
|
|
159
|
+
|
|
160
|
+
function AdminMode() {
|
|
161
|
+
const { available, elevated, until, elevate, lower, error } = useElevation()
|
|
162
|
+
if (!available) return null
|
|
163
|
+
if (elevated) {
|
|
164
|
+
return <p>Acting as administrator until {new Date(until).toLocaleTimeString()}. <button onClick={lower}>Stop</button></p>
|
|
165
|
+
}
|
|
166
|
+
return <ConfirmItsYou onSubmit={(password, code) => elevate({ password, code })} refused={error?.kind === 'auth'} />
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `available` — the account holds a role that does more than read. Offer it only then.
|
|
171
|
+
- `elevate({ password, code })` — the account's password again, and `code` when it has a
|
|
172
|
+
second factor. Nothing tells you whether it has one, so offer the field to whoever
|
|
173
|
+
might. A wrong password or code is `error.kind === 'auth'`, one answer for both.
|
|
174
|
+
- It lasts as long as the backend decides; `until` says when it ends. `elevated` turns
|
|
175
|
+
false by itself then, and the session is asked again. Elevating again starts the time
|
|
176
|
+
again; nothing extends it on its own. `lower()` ends it early.
|
|
177
|
+
- While it lasts, `viewer.roles` names the role, and every record read before is read
|
|
178
|
+
again — `entity.can_edit` is the record's answer for the session as it is now.
|
|
179
|
+
- `rolesInFull` — draw controls that act through a role from this: true while elevated,
|
|
180
|
+
and always on a backend that asks for no elevation.
|
|
181
|
+
- An account with no password elevates through its provider:
|
|
182
|
+
`elevateWithProvider('google')` goes to the provider's sign-in and back to this page.
|
|
183
|
+
If the identity that comes back is not this account's, `error.extensions.reason` is
|
|
184
|
+
`'oauth_step_up_not_linked'`.
|
|
185
|
+
|
|
186
|
+
Elevating does not open the backend's administration — the Admin link does.
|
|
187
|
+
|
|
149
188
|
**The Admin link** — some of the site's members administer its backend, the operator
|
|
150
189
|
among them. For them, `adminHref` is the address that takes them to the backend's
|
|
151
190
|
administration, already signed in; for everyone else it is `null`, so draw the link
|
|
@@ -160,8 +199,8 @@ const { adminHref } = useSession()
|
|
|
160
199
|
⛔ **Navigate to it — a link, or `location.assign(adminHref)` — never `fetch` it.** It
|
|
161
200
|
answers with a redirect carrying a one-time credential that no script on the site is
|
|
162
201
|
to hold. The link decides what to offer, not what is allowed: on the site, an
|
|
163
|
-
administrator's session acts
|
|
164
|
-
end of the link.
|
|
202
|
+
administrator's session acts through their role only while elevated, and administering
|
|
203
|
+
the backend happens at the other end of the link.
|
|
165
204
|
|
|
166
205
|
## Reading
|
|
167
206
|
|
|
@@ -178,10 +217,10 @@ live source — no `backend` service, or nobody signed in — so render your sit
|
|
|
178
217
|
Showing "nothing yet" for the first case tells a visitor their content is missing
|
|
179
218
|
when it is simply not being asked for.
|
|
180
219
|
|
|
181
|
-
**Whose records.** Everyone signed in on the site
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
viewer's own.
|
|
220
|
+
**Whose records.** Everyone signed in on the site reads their own entities, what was
|
|
221
|
+
shared with them, and what a role of theirs reaches — the operator's reaches every
|
|
222
|
+
member's; other members' are private unless the site's service was set up to let members
|
|
223
|
+
read each other's. `scope: 'mine'` lists only the viewer's own.
|
|
185
224
|
|
|
186
225
|
A record is a summary: `record.brief` holds the fields of the Model's brief section. It
|
|
187
226
|
carries no items — read the entity for those. `hasMore` is true when a page came back
|
|
@@ -306,11 +345,12 @@ production, and nothing in your site's configuration changes between the two.
|
|
|
306
345
|
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
307
346
|
which sections are insert-only, the fields a section takes — and it is as strict as
|
|
308
347
|
the backend about requests, so a mistake fails here rather than in front of a user.
|
|
309
|
-
Everyone signed in
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
348
|
+
Everyone signed in reads and writes their own entities, and a member nothing of anyone
|
|
349
|
+
else's (`memberFloor: 'read'` in the seed models a service set up to let members read
|
|
350
|
+
each other's — or per Model, `memberFloor: { '@acme/course': 'read' }`, so the
|
|
351
|
+
operator's content is shared while each member's own records stay private). The
|
|
352
|
+
operator, as on a real site, reads every member's entities and changes them only once
|
|
353
|
+
elevated, with its password, for fifteen minutes. The mail the backend would send — a sign-up's
|
|
314
354
|
confirmation, a password reset — is printed where the mock runs, in the terminal of
|
|
315
355
|
`uniweb dev`, its link on your site's root as the backend's is, so following it is
|
|
316
356
|
trying your page; the messages are in `mock.outbox` too. To try an invitation, seed one:
|
|
@@ -332,7 +372,8 @@ npx uniweb-api-mock --port 8787 --site http://localhost:3000
|
|
|
332
372
|
|
|
333
373
|
`@uniweb/api/client` carries the same operations as plain functions and imports no
|
|
334
374
|
React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
|
|
335
|
-
`createEntity`, `deleteEntity`, `adminHref`, for
|
|
375
|
+
`createEntity`, `deleteEntity`, `adminHref`, for elevation, `elevate`, `lower`,
|
|
376
|
+
`elevateWithProvider`, `isElevated` and `rolesInFull`, for the links the backend mails,
|
|
336
377
|
`mailedLink`, `verifyEmail`, `claimOffer` and `forgetMailedLink`, and for invitations,
|
|
337
378
|
`listInvitations`, `acceptInvitation` and `declineInvitation`.
|
|
338
379
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/api",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=22.12"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@uniweb/core": "~1.
|
|
37
|
+
"@uniweb/core": "~1.2.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^19.0.0"
|
package/src/client.js
CHANGED
|
@@ -19,6 +19,7 @@ import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA
|
|
|
19
19
|
import { Ledger } from './ledger.js'
|
|
20
20
|
import { verifyEmailWith, claimOfferWith } from './mailed-links.js'
|
|
21
21
|
import { listInvitationsWith, acceptInvitationWith, declineInvitationWith } from './invitations.js'
|
|
22
|
+
import { viewerElevation, watchLapse, elevateWith, lowerWith, elevateWithProviderOf } from './elevation.js'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* The site service this package reads its base from — the only name it owns.
|
|
@@ -219,6 +220,8 @@ export class ApiClient {
|
|
|
219
220
|
const error = next.error ?? null
|
|
220
221
|
if (cur.status === next.status && cur.viewer === viewer && cur.error === error) return cur
|
|
221
222
|
this._session = Object.freeze({ status: next.status, viewer, error })
|
|
223
|
+
// An elevated session is asked again when its elevation runs out (`./elevation.js`).
|
|
224
|
+
watchLapse(this)
|
|
222
225
|
for (const fn of this._listeners) fn()
|
|
223
226
|
return this._session
|
|
224
227
|
}
|
|
@@ -356,6 +359,7 @@ export class ApiClient {
|
|
|
356
359
|
roles: Array.isArray(me?.roles) ? me.roles : [],
|
|
357
360
|
workspace: viewerWorkspace(me?.workspace),
|
|
358
361
|
adminApp: me?.[VIEWER.capabilities]?.[VIEWER.adminApp] === true,
|
|
362
|
+
elevation: viewerElevation(me?.[VIEWER.elevation]),
|
|
359
363
|
})
|
|
360
364
|
if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
|
|
361
365
|
return this.setSession({ status: 'authenticated', viewer, error: null })
|
|
@@ -427,6 +431,35 @@ export class ApiClient {
|
|
|
427
431
|
}
|
|
428
432
|
}
|
|
429
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Elevate the session — `{ password, code? }` — so it acts through the account's roles
|
|
436
|
+
* for a while. Answers `{ until }`. A refused credential throws `kind: 'auth'`, one
|
|
437
|
+
* answer for a wrong password or code (`./elevation.js`).
|
|
438
|
+
*
|
|
439
|
+
* @param {{ password: string, code?: string }} credentials
|
|
440
|
+
* @returns {Promise<{ until: string|null }>}
|
|
441
|
+
*/
|
|
442
|
+
elevate(credentials) {
|
|
443
|
+
return elevateWith(this, credentials)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Lower the session before its time runs out. */
|
|
447
|
+
lower() {
|
|
448
|
+
return lowerWith(this)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Elevate through the account's provider — a navigation to its sign-in, which comes
|
|
453
|
+
* back to the page elevated. For an account with no password.
|
|
454
|
+
*
|
|
455
|
+
* @param {string} provider
|
|
456
|
+
* @param {{ returnTo?: string }} [options]
|
|
457
|
+
* @returns {string|null} the address navigated to
|
|
458
|
+
*/
|
|
459
|
+
elevateWithProvider(provider, options) {
|
|
460
|
+
return elevateWithProviderOf(this, provider, options)
|
|
461
|
+
}
|
|
462
|
+
|
|
430
463
|
/**
|
|
431
464
|
* Sign up — `{ username, email, password }`, passed through unchanged.
|
|
432
465
|
*
|
|
@@ -688,9 +721,9 @@ export class ApiClient {
|
|
|
688
721
|
* a `401` too, never an empty list (so `records: []` means empty).
|
|
689
722
|
*
|
|
690
723
|
* `scope` decides whose: the default, `accessible`, is everything the viewer may
|
|
691
|
-
* read — signed in on a site, their own entities
|
|
692
|
-
*
|
|
693
|
-
* viewer owns.
|
|
724
|
+
* read — signed in on a site, their own entities, what was shared with them, and what
|
|
725
|
+
* a role of theirs reaches, elevated or not: the operator's, every member's (`SCOPE`
|
|
726
|
+
* in `./wire.js`). `mine` is only what the viewer owns.
|
|
694
727
|
*
|
|
695
728
|
* ## Paging, as far as it can honestly be absorbed
|
|
696
729
|
*
|
|
@@ -1023,6 +1056,12 @@ export const signIn = (credentials) => required().signIn(credentials)
|
|
|
1023
1056
|
export const completeChallenge = (code) => required().completeChallenge(code)
|
|
1024
1057
|
/** @see ApiClient#signOut */
|
|
1025
1058
|
export const signOut = () => required().signOut()
|
|
1059
|
+
/** @see ApiClient#elevate — on any instance, an older copy's too */
|
|
1060
|
+
export const elevate = (credentials) => elevateWith(required(), credentials)
|
|
1061
|
+
/** @see ApiClient#lower — on any instance, an older copy's too */
|
|
1062
|
+
export const lower = () => lowerWith(required())
|
|
1063
|
+
/** @see ApiClient#elevateWithProvider — on any instance, an older copy's too */
|
|
1064
|
+
export const elevateWithProvider = (provider, options) => elevateWithProviderOf(required(), provider, options)
|
|
1026
1065
|
/** @see ApiClient#signUp */
|
|
1027
1066
|
export const signUp = (fields) => required().signUp(fields)
|
|
1028
1067
|
/** @see ApiClient#requestPasswordReset */
|
|
@@ -1053,5 +1092,6 @@ export const deleteEntity = (args) => required().deleteEntity(args)
|
|
|
1053
1092
|
export const adminHref = () => adminHrefOf(getClient())
|
|
1054
1093
|
|
|
1055
1094
|
export { mailedLink, forgetMailedLink } from './mailed-links.js'
|
|
1095
|
+
export { isElevated, rolesInFull, elevationReturn } from './elevation.js'
|
|
1056
1096
|
export { ApiError, kindOf } from './errors.js'
|
|
1057
1097
|
export { Ledger } from './ledger.js'
|
package/src/elevation.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elevation — a site's session reads through its roles until it is elevated.
|
|
3
|
+
*
|
|
4
|
+
* On a site's backend, a session signed in on the site holds each of the account's roles
|
|
5
|
+
* as a reader until it is elevated: an administrator reads what their role reaches, and
|
|
6
|
+
* changes nothing through it. Their own content they edit, delete and share as always.
|
|
7
|
+
* To act through the role — remove a member's post, grade a submission — the session
|
|
8
|
+
* elevates, with the password again (and the second factor, when the account has one),
|
|
9
|
+
* for a while the backend decides (`ELEVATION` in `./wire.js`). No React in this module;
|
|
10
|
+
* `useElevation` is the hook over it.
|
|
11
|
+
*
|
|
12
|
+
* ⭐ **Functions of a client, not methods** — the page's client may have been created by
|
|
13
|
+
* an older copy of this package (`./mailed-links.js` says why). Everything here reaches
|
|
14
|
+
* the wire through `request` and keeps its state on the instance.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ApiError } from './errors.js'
|
|
18
|
+
import { composeUrl } from './http.js'
|
|
19
|
+
import { AUTH, ELEVATION } from './wire.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `/auth/me`'s `elevation` as the viewer carries it — `{ available, until }`, `until` an
|
|
23
|
+
* ISO time or `null` — or `null` when the backend sends none: a backend no site reaches,
|
|
24
|
+
* whose roles answer in full.
|
|
25
|
+
*
|
|
26
|
+
* @param {*} raw
|
|
27
|
+
* @returns {{ available: boolean, until: string|null } | null}
|
|
28
|
+
*/
|
|
29
|
+
export function viewerElevation(raw) {
|
|
30
|
+
if (!raw || typeof raw !== 'object') return null
|
|
31
|
+
const until = raw[ELEVATION.until]
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
available: raw[ELEVATION.available] === true,
|
|
34
|
+
until: typeof until === 'string' && until ? until : null,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a viewer's session is elevated — `until` still ahead, by this clock.
|
|
40
|
+
*
|
|
41
|
+
* @param {object|null} viewer - `useSession().viewer`
|
|
42
|
+
* @param {number} [now]
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isElevated(viewer, now = Date.now()) {
|
|
46
|
+
const until = viewer?.elevation?.until
|
|
47
|
+
return typeof until === 'string' && Date.parse(until) > now
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a viewer's session acts through its roles now: elevated — or on a backend that
|
|
52
|
+
* asks for no elevation, whose `/auth/me` carries none and whose roles always answer in
|
|
53
|
+
* full. False for nobody signed in, and for a viewer read by a copy of this package
|
|
54
|
+
* older than elevation, which cannot say.
|
|
55
|
+
*
|
|
56
|
+
* @param {object|null} viewer
|
|
57
|
+
* @param {number} [now]
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function rolesInFull(viewer, now = Date.now()) {
|
|
61
|
+
if (!viewer || !('elevation' in viewer)) return false
|
|
62
|
+
return viewer.elevation === null || isElevated(viewer, now)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a cached read is one entity's — whose `can_edit` is the session's answer. */
|
|
66
|
+
const isEntityRead = (spec) => spec?.uuid != null
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ask the backend again who the viewer is, after anything already asking: a probe that
|
|
70
|
+
* left before the change would answer for the session as it was.
|
|
71
|
+
*/
|
|
72
|
+
async function reprobe(client) {
|
|
73
|
+
const earlier = client._pending
|
|
74
|
+
if (earlier) await earlier.catch(() => {})
|
|
75
|
+
return client.refresh?.()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Elevate the session — the password again, and `code` when the account has a second
|
|
80
|
+
* factor. Answers `{ until }`, an ISO time. The session is asked again, so `roles` name
|
|
81
|
+
* what it holds now, and every entity read is read again, since what the viewer may do
|
|
82
|
+
* to an entity is its own answer (`can_edit`).
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ **A wrong password, a wrong code, a missing code and a session that has ended are
|
|
85
|
+
* one answer, `401`** (`kind: 'auth'`). The session is asked again before the refusal is
|
|
86
|
+
* thrown, so a session that ended shows as signed out, and one still signed in means
|
|
87
|
+
* the credential was refused. Nothing on the wire says whether the account has a second
|
|
88
|
+
* factor: offer the code field to whoever might have one.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} client
|
|
91
|
+
* @param {{ password: string, code?: string }} credentials
|
|
92
|
+
* @returns {Promise<{ until: string|null }>}
|
|
93
|
+
* @throws {ApiError} `auth` — refused, as above · `invalid` — no password given
|
|
94
|
+
*/
|
|
95
|
+
export async function elevateWith(client, { password, code } = {}) {
|
|
96
|
+
if (!password) {
|
|
97
|
+
throw ApiError.invalid(
|
|
98
|
+
'No Password',
|
|
99
|
+
'elevate needs the account’s password — an account with none elevates through its provider (elevateWithProvider)',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const body = { [ELEVATION.password]: password }
|
|
103
|
+
if (code != null && code !== '') body[ELEVATION.code] = code
|
|
104
|
+
let answer
|
|
105
|
+
try {
|
|
106
|
+
answer = await client.request('POST', AUTH.elevate, { body, onUnauthorized: 'ignore' })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.status === 401) await reprobe(client)
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
client.invalidate?.(isEntityRead)
|
|
112
|
+
await reprobe(client)
|
|
113
|
+
return { until: answer?.[ELEVATION.answer] ?? null }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lower the session before its time runs out. The session is asked again, and every
|
|
118
|
+
* entity read is read again.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} client
|
|
121
|
+
* @returns {Promise<{ until: null }>}
|
|
122
|
+
* @throws {ApiError} `auth` — nobody signed in
|
|
123
|
+
*/
|
|
124
|
+
export async function lowerWith(client) {
|
|
125
|
+
await client.request('DELETE', AUTH.elevate)
|
|
126
|
+
client.invalidate?.(isEntityRead)
|
|
127
|
+
await reprobe(client)
|
|
128
|
+
return { until: null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Marks a `state` as ours, so its echo can be taken off the address bar on the way back. */
|
|
132
|
+
const STATE_PREFIX = 'uw-elevate-'
|
|
133
|
+
|
|
134
|
+
/** The page's own address, without its query or fragment — where the provider returns to. */
|
|
135
|
+
function pageAddress() {
|
|
136
|
+
const location = globalThis.location
|
|
137
|
+
if (!location?.origin || location.origin === 'null') return null
|
|
138
|
+
return `${location.origin}${location.pathname}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The provider's elevation for this attempt — for an account with no password — or
|
|
143
|
+
* `null`: no backend, nobody signed in, no provider named, or no page to return to.
|
|
144
|
+
*
|
|
145
|
+
* ⛔ **A navigation, never a fetch**, and one address per attempt: each carries a fresh
|
|
146
|
+
* `state`, so make it when the viewer asks, never at render.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} client
|
|
149
|
+
* @param {string} provider - the provider's id, as the backend names it (`google`)
|
|
150
|
+
* @param {{ returnTo?: string }} [options] - the absolute address to come back to; the
|
|
151
|
+
* page the viewer is on, without its query or fragment, by default
|
|
152
|
+
* @returns {string|null}
|
|
153
|
+
*/
|
|
154
|
+
export function elevationAddressOf(client, provider, { returnTo } = {}) {
|
|
155
|
+
const base = client?.base ?? null
|
|
156
|
+
if (base === null || !provider || client.session?.status !== 'authenticated') return null
|
|
157
|
+
const redirect = returnTo || pageAddress()
|
|
158
|
+
if (!redirect) return null
|
|
159
|
+
const state = `${STATE_PREFIX}${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`
|
|
160
|
+
return composeUrl(base, ELEVATION.byProvider(provider), { [ELEVATION.redirect]: redirect, [ELEVATION.state]: state })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Elevate through the account's provider — go to its sign-in, and come back elevated.
|
|
165
|
+
* Nothing happens outside a browser, or when there is no address to go to
|
|
166
|
+
* (`elevationAddressOf`).
|
|
167
|
+
*
|
|
168
|
+
* @param {object} client
|
|
169
|
+
* @param {string} provider
|
|
170
|
+
* @param {{ returnTo?: string }} [options]
|
|
171
|
+
* @returns {string|null} the address navigated to
|
|
172
|
+
*/
|
|
173
|
+
export function elevateWithProviderOf(client, provider, options) {
|
|
174
|
+
const address = elevationAddressOf(client, provider, options)
|
|
175
|
+
if (address && typeof globalThis.location?.assign === 'function') globalThis.location.assign(address)
|
|
176
|
+
return address
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What a provider's elevation came back with, on the page it returned to:
|
|
181
|
+
* `{ ok: true }`, `{ ok: false, error: 'oauth_step_up_not_linked' }` — the identity that
|
|
182
|
+
* came back is not this account's — or `null` when the URL carries neither.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} [search] - a query string; the page's own by default
|
|
185
|
+
* @returns {{ ok: boolean, error?: string } | null}
|
|
186
|
+
*/
|
|
187
|
+
export function elevationReturn(search = globalThis.location?.search ?? '') {
|
|
188
|
+
let params
|
|
189
|
+
try {
|
|
190
|
+
params = new URLSearchParams(search)
|
|
191
|
+
} catch {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
if (params.get(ELEVATION.returned) === ELEVATION.ok) return Object.freeze({ ok: true })
|
|
195
|
+
if (params.get(ELEVATION.error) === ELEVATION.notLinked) return Object.freeze({ ok: false, error: ELEVATION.notLinked })
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Take a provider's answer, and our own `state`, off the address bar — `replaceState`,
|
|
201
|
+
* no navigation. The rest of the URL stays, another `state` or `error` included.
|
|
202
|
+
*/
|
|
203
|
+
export function forgetElevationReturn() {
|
|
204
|
+
const location = globalThis.location
|
|
205
|
+
const history = globalThis.history
|
|
206
|
+
if (!location || typeof history?.replaceState !== 'function') return
|
|
207
|
+
const params = new URLSearchParams(location.search)
|
|
208
|
+
let changed = false
|
|
209
|
+
if (params.get(ELEVATION.returned) === ELEVATION.ok) {
|
|
210
|
+
params.delete(ELEVATION.returned)
|
|
211
|
+
changed = true
|
|
212
|
+
}
|
|
213
|
+
if (params.get(ELEVATION.error) === ELEVATION.notLinked) {
|
|
214
|
+
params.delete(ELEVATION.error)
|
|
215
|
+
changed = true
|
|
216
|
+
}
|
|
217
|
+
if (params.get(ELEVATION.state)?.startsWith(STATE_PREFIX)) {
|
|
218
|
+
params.delete(ELEVATION.state)
|
|
219
|
+
changed = true
|
|
220
|
+
}
|
|
221
|
+
if (!changed) return
|
|
222
|
+
const query = params.toString()
|
|
223
|
+
history.replaceState(history.state, '', `${location.pathname}${query ? `?${query}` : ''}${location.hash}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A provider's answer, read ONCE per page and taken off the address bar — the same
|
|
228
|
+
* answer to every component that asks.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} client
|
|
231
|
+
* @returns {{ ok: boolean, error?: string } | null}
|
|
232
|
+
*/
|
|
233
|
+
export function takeElevationReturn(client) {
|
|
234
|
+
if (!client) return null
|
|
235
|
+
if (client._elevationReturn === undefined) {
|
|
236
|
+
client._elevationReturn = elevationReturn()
|
|
237
|
+
if (client._elevationReturn) forgetElevationReturn()
|
|
238
|
+
}
|
|
239
|
+
return client._elevationReturn
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Ask the backend again when the session's elevation runs out, so `roles` stop naming
|
|
244
|
+
* what it no longer holds — one timer per client, armed for the `until` the session
|
|
245
|
+
* carries, and let go when that changes or the session ends.
|
|
246
|
+
*
|
|
247
|
+
* ⚠️ `until` is the backend's clock and the timer is this one's. Behind, the probe comes
|
|
248
|
+
* late. Ahead, the probe finds the session still elevated at an `until` this clock has
|
|
249
|
+
* passed, and arms nothing: the page's next probe tells.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} client
|
|
252
|
+
*/
|
|
253
|
+
export function watchLapse(client) {
|
|
254
|
+
const until = client?.session?.viewer?.elevation?.until ?? null
|
|
255
|
+
if (client._lapse && client._lapse.until === until) return
|
|
256
|
+
if (client._lapse) clearTimeout(client._lapse.timer)
|
|
257
|
+
client._lapse = null
|
|
258
|
+
if (!until || typeof setTimeout !== 'function') return
|
|
259
|
+
const wait = Date.parse(until) - Date.now()
|
|
260
|
+
if (!(wait > 0)) return
|
|
261
|
+
// A second past `until`, and never longer than a timer can wait.
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
client._lapse = null
|
|
264
|
+
client.refresh?.()
|
|
265
|
+
}, Math.min(wait + 1000, 2 ** 31 - 1))
|
|
266
|
+
timer?.unref?.()
|
|
267
|
+
client._lapse = { until, timer }
|
|
268
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
import { ApiError } from '../errors.js'
|
|
4
|
+
import { elevateWith, lowerWith, elevateWithProviderOf, takeElevationReturn, rolesInFull } from '../elevation.js'
|
|
5
|
+
import { useAction } from './useAction.js'
|
|
6
|
+
|
|
7
|
+
const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
|
|
8
|
+
const noSubscribe = () => () => {}
|
|
9
|
+
const noSnapshot = () => NONE
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Elevation — whether the viewer's session acts through their roles, and the calls that
|
|
13
|
+
* change it.
|
|
14
|
+
*
|
|
15
|
+
* On a site's backend a session holds each of the account's roles as a reader until it
|
|
16
|
+
* is elevated: an administrator reads what their role reaches and changes nothing
|
|
17
|
+
* through it — their own content they edit, delete and share as always. To act through
|
|
18
|
+
* the role, they confirm it is them, and the session is elevated for a while.
|
|
19
|
+
*
|
|
20
|
+
* ```jsx
|
|
21
|
+
* const { available, elevated, until, elevate, lower, status, error } = useElevation()
|
|
22
|
+
* if (available && !elevated) return <ConfirmPassword onSubmit={(password, code) => elevate({ password, code })} />
|
|
23
|
+
* if (elevated) return <p>Acting as administrator until {new Date(until).toLocaleTimeString()}. <button onClick={lower}>Stop</button></p>
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* - **`available`** — the account holds a role that answers more than reading: offer
|
|
27
|
+
* elevation only then.
|
|
28
|
+
* - **`elevated`** — true while it lasts; `until` is when it ends, an ISO time. It turns
|
|
29
|
+
* false by itself when the time passes, and the session is asked again then. Elevating
|
|
30
|
+
* again starts the time again; nothing extends it on its own.
|
|
31
|
+
* - **`rolesInFull`** — whether the session acts through its roles now: elevated, or on a
|
|
32
|
+
* backend that never asks for it. Draw controls that act through a role from this, and
|
|
33
|
+
* ask each entity what the viewer may do to it — `useEntity`'s `entity.can_edit`, read
|
|
34
|
+
* again after every change here.
|
|
35
|
+
* - **`elevate({ password, code })`** — `code` when the account has a second factor.
|
|
36
|
+
* Nothing says whether it has one, so offer the field to whoever might. A refusal is
|
|
37
|
+
* `kind: 'auth'`, one answer for a wrong password or code; a session that has ended
|
|
38
|
+
* shows as signed out first.
|
|
39
|
+
* - **`elevateWithProvider(provider)`** — for an account with no password: a navigation
|
|
40
|
+
* to the provider's sign-in, which comes back to this page. If the identity that comes
|
|
41
|
+
* back is not this account's, `error` says so — `kind: 'auth'`,
|
|
42
|
+
* `error.extensions.reason === 'oauth_step_up_not_linked'`.
|
|
43
|
+
* - **`lower()`** — ends it early.
|
|
44
|
+
*
|
|
45
|
+
* Elevating does not open the backend's administration — the Admin link does
|
|
46
|
+
* (`useSession().adminHref`). On a site with no backend, or with nobody signed in,
|
|
47
|
+
* nothing is available.
|
|
48
|
+
*
|
|
49
|
+
* @returns {{
|
|
50
|
+
* available: boolean,
|
|
51
|
+
* elevated: boolean,
|
|
52
|
+
* until: string | null,
|
|
53
|
+
* rolesInFull: boolean,
|
|
54
|
+
* elevate: (credentials: { password: string, code?: string }) => Promise<{ until: string|null }>,
|
|
55
|
+
* elevateWithProvider: (provider: string, options?: { returnTo?: string }) => string | null,
|
|
56
|
+
* lower: () => Promise<{ until: null }>,
|
|
57
|
+
* status: 'idle' | 'submitting' | 'success' | 'error',
|
|
58
|
+
* error: Error | null,
|
|
59
|
+
* reset: () => void,
|
|
60
|
+
* }}
|
|
61
|
+
*/
|
|
62
|
+
export function useElevation() {
|
|
63
|
+
const client = getClient()
|
|
64
|
+
const session = useSyncExternalStore(
|
|
65
|
+
client ? client.subscribe : noSubscribe,
|
|
66
|
+
client ? () => client.session : noSnapshot,
|
|
67
|
+
client ? () => client.session : noSnapshot,
|
|
68
|
+
)
|
|
69
|
+
const viewer = session.status === 'authenticated' ? session.viewer : null
|
|
70
|
+
const elevation = viewer?.elevation ?? null
|
|
71
|
+
const until = elevation?.until ?? null
|
|
72
|
+
|
|
73
|
+
// Ask who the viewer is, as `useSession` does — this hook may be the only one asking.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (client && client.enabled) client.ensureSession()
|
|
76
|
+
}, [client])
|
|
77
|
+
|
|
78
|
+
// Render again when the elevation runs out, so `elevated` turns false on time.
|
|
79
|
+
const [, setTick] = useState(0)
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!until) return undefined
|
|
82
|
+
const wait = Date.parse(until) - Date.now()
|
|
83
|
+
if (!(wait > 0)) return undefined
|
|
84
|
+
const timer = setTimeout(() => setTick((n) => n + 1), Math.min(wait + 50, 2 ** 31 - 1))
|
|
85
|
+
return () => clearTimeout(timer)
|
|
86
|
+
}, [until])
|
|
87
|
+
|
|
88
|
+
const { run, status, error, reset } = useAction(
|
|
89
|
+
useCallback(
|
|
90
|
+
async (step, arg) => {
|
|
91
|
+
if (!client) throw ApiError.disabled()
|
|
92
|
+
return step === 'lower' ? lowerWith(client) : elevateWith(client, arg)
|
|
93
|
+
},
|
|
94
|
+
[client],
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// A provider's elevation returns to the page: read once, in an effect — the URL is the
|
|
99
|
+
// browser's, never the server render's. Coming back elevated needs nothing here: the
|
|
100
|
+
// session says so.
|
|
101
|
+
const [returned, setReturned] = useState(null)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const answer = takeElevationReturn(client)
|
|
104
|
+
if (answer && !answer.ok) {
|
|
105
|
+
setReturned(
|
|
106
|
+
new ApiError({
|
|
107
|
+
status: 0,
|
|
108
|
+
title: 'Not Linked',
|
|
109
|
+
detail: 'the identity the provider returned is not this account’s',
|
|
110
|
+
kind: 'auth',
|
|
111
|
+
extensions: { reason: answer.error },
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}, [client])
|
|
116
|
+
|
|
117
|
+
const elevate = useCallback((credentials) => run('elevate', credentials), [run])
|
|
118
|
+
const lower = useCallback(() => run('lower'), [run])
|
|
119
|
+
const elevateWithProvider = useCallback(
|
|
120
|
+
(provider, options) => {
|
|
121
|
+
setReturned(null)
|
|
122
|
+
return client ? elevateWithProviderOf(client, provider, options) : null
|
|
123
|
+
},
|
|
124
|
+
[client],
|
|
125
|
+
)
|
|
126
|
+
const clear = useCallback(() => {
|
|
127
|
+
setReturned(null)
|
|
128
|
+
reset()
|
|
129
|
+
}, [reset])
|
|
130
|
+
|
|
131
|
+
const elevated = typeof until === 'string' && Date.parse(until) > Date.now()
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
available: elevation?.available === true,
|
|
135
|
+
elevated,
|
|
136
|
+
until: elevated ? until : null,
|
|
137
|
+
rolesInFull: rolesInFull(viewer),
|
|
138
|
+
elevate,
|
|
139
|
+
elevateWithProvider,
|
|
140
|
+
lower,
|
|
141
|
+
status,
|
|
142
|
+
error: error ?? (status === 'idle' ? returned : null),
|
|
143
|
+
reset: clear,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default useElevation
|
package/src/hooks/useRecords.js
CHANGED
|
@@ -35,8 +35,8 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
|
|
|
35
35
|
* `hasMore` is true when the page came back full.
|
|
36
36
|
*
|
|
37
37
|
* The default scope is everything the viewer may read — signed in on a site, their
|
|
38
|
-
* own entities
|
|
39
|
-
* `scope: 'mine'` is only the viewer's own.
|
|
38
|
+
* own entities, what was shared with them, and what a role of theirs reaches: the
|
|
39
|
+
* operator's, every member's. `scope: 'mine'` is only the viewer's own.
|
|
40
40
|
*
|
|
41
41
|
* Cached under a key scoped to the viewer, so a sign-in re-reads the list for who
|
|
42
42
|
* is now looking, and a write through `useEntityWriter` drops it.
|
package/src/hooks/useSession.js
CHANGED
|
@@ -15,6 +15,11 @@ const noop = async () => NONE
|
|
|
15
15
|
* affordance on false. `error` is set when the backend could not be asked;
|
|
16
16
|
* `refresh()` asks again.
|
|
17
17
|
*
|
|
18
|
+
* `viewer.roles` describe the session, not the account: on a site's backend an
|
|
19
|
+
* administrator's session holds its roles as a reader until it is elevated, and
|
|
20
|
+
* `viewer.elevation` — `{ available, until }`, or `null` on a backend that never asks —
|
|
21
|
+
* says whether it can be, and until when it is. `useElevation` reads it and elevates.
|
|
22
|
+
*
|
|
18
23
|
* `adminHref` is the Admin link — set only for a signed-in account the backend's
|
|
19
24
|
* administration admits, `null` for everyone else, so draw the link only when it is
|
|
20
25
|
* set. ⛔ Navigate to it, never `fetch` it (`adminHrefOf` in `../client.js`).
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,11 @@ export {
|
|
|
18
18
|
signIn,
|
|
19
19
|
completeChallenge,
|
|
20
20
|
signOut,
|
|
21
|
+
elevate,
|
|
22
|
+
lower,
|
|
23
|
+
elevateWithProvider,
|
|
24
|
+
isElevated,
|
|
25
|
+
rolesInFull,
|
|
21
26
|
signUp,
|
|
22
27
|
requestPasswordReset,
|
|
23
28
|
confirmPasswordReset,
|
|
@@ -39,6 +44,7 @@ export {
|
|
|
39
44
|
} from './client.js'
|
|
40
45
|
|
|
41
46
|
export { useSession } from './hooks/useSession.js'
|
|
47
|
+
export { useElevation } from './hooks/useElevation.js'
|
|
42
48
|
export { useSignIn } from './hooks/useSignIn.js'
|
|
43
49
|
export { useSignUp } from './hooks/useSignUp.js'
|
|
44
50
|
export { usePasswordReset } from './hooks/usePasswordReset.js'
|
package/src/mock/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AUTH, ENTITIES, MODELS, OFFERS, INVITATIONS, 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, ELEVATION } from '../wire.js'
|
|
2
2
|
import { MockStore, isUuid, refuse } from './store.js'
|
|
3
3
|
import { DEFAULT_SEED } from './seed.js'
|
|
4
4
|
|
|
@@ -255,8 +255,21 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
|
|
|
255
255
|
const token = q.get('token') || ''
|
|
256
256
|
return token ? answer(store.verify(token)) : badRequest('verify requires `token`')
|
|
257
257
|
}
|
|
258
|
+
if (path.startsWith(`${AUTH.elevate}/`) && method === 'GET') {
|
|
259
|
+
// The provider's elevation — a navigation. The mock has no sign-in providers.
|
|
260
|
+
return problem({ status: 404, title: 'Not Found', detail: 'the mock has no sign-in providers — elevate with the password' })
|
|
261
|
+
}
|
|
258
262
|
const bad = unexpected(q, [])
|
|
259
263
|
if (bad) return bad
|
|
264
|
+
if (path === AUTH.elevate && method === 'POST') {
|
|
265
|
+
if (!store.account) return unauthorized()
|
|
266
|
+
const b = await body(request)
|
|
267
|
+
return fields(b, [ELEVATION.password], '{"password": ..., "code": ...}') ?? answer(store.elevate(b))
|
|
268
|
+
}
|
|
269
|
+
if (path === AUTH.elevate && method === 'DELETE') {
|
|
270
|
+
if (!store.account) return unauthorized()
|
|
271
|
+
return answer(store.lower())
|
|
272
|
+
}
|
|
260
273
|
if (path === AUTH.me && method === 'GET') {
|
|
261
274
|
const viewer = store.viewer()
|
|
262
275
|
return viewer ? json(200, viewer) : unauthorized()
|
package/src/mock/store.js
CHANGED
|
@@ -20,11 +20,14 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
20
20
|
* other's — the seed's `memberFloor` models that choice. *(Until 2026-09-18 the
|
|
21
21
|
* backend gave every member read access to every other member's entities.)*
|
|
22
22
|
* - **The operator** — the account that runs the site's service — owns what the
|
|
23
|
-
* seed gives it
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* seed gives it, and holds `system_admin` over the whole service. ⛔ **Signed in on
|
|
24
|
+
* the site, it holds that role as a reader until it is elevated**: it reads every
|
|
25
|
+
* member's entities (`via: rbac`) and changes none of them — a site runs third-party
|
|
26
|
+
* code with its member's session. Elevated, with its password again, its role answers
|
|
27
|
+
* in full until the window closes (`ELEVATION_WINDOW`) or it lowers it. *(Stated by
|
|
28
|
+
* the backend, 2026-10-14. From 2026-10-10 the operator's session held a member's
|
|
29
|
+
* rights only, and before that `system_admin` in full.)* A seeded account is the
|
|
30
|
+
* operator with `operator: true`.
|
|
28
31
|
* - **An offer binds when it is claimed, or when its address is confirmed** — and
|
|
29
32
|
* then its token answers `404`, as an unknown one does (backend, 2026-10-11). What
|
|
30
33
|
* it binds: its invitations, each waiting for the account's answer, to an
|
|
@@ -36,8 +39,10 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
|
|
|
36
39
|
* drifts and is believed anyway.
|
|
37
40
|
*
|
|
38
41
|
* ⛔ **What it does not model**: entitlements behind `via` (a `via` read is the
|
|
39
|
-
* same read), a Model whose creation is closed, nested sections, second factors
|
|
40
|
-
*
|
|
42
|
+
* same read), a Model whose creation is closed, nested sections, second factors —
|
|
43
|
+
* at a sign-in or an elevation, where a `code` is accepted and not checked —
|
|
44
|
+
* sign-in providers, and so no elevation through one, roles other than the operator's,
|
|
45
|
+
* step-up, reference fields, password rules,
|
|
41
46
|
* an offer's grants (a count, sharing nothing), what joining an organization
|
|
42
47
|
* gives — accepting an invitation answers, and opens nothing to read — and an
|
|
43
48
|
* organization withdrawing an invitation.
|
|
@@ -60,6 +65,15 @@ export const HOME_ORG = Object.freeze({
|
|
|
60
65
|
handle: 'home',
|
|
61
66
|
})
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How long an elevation lasts — the backend's window, 15 minutes on 2026-10-14, as the
|
|
70
|
+
* backend states it. The client never assumes it; it reads `until`.
|
|
71
|
+
*/
|
|
72
|
+
export const ELEVATION_WINDOW = 15 * 60 * 1000
|
|
73
|
+
|
|
74
|
+
/** The operator's role, as `/auth/me` names it — MEASURED 2026-09-18. */
|
|
75
|
+
const OPERATOR_ROLE = Object.freeze({ role: 'system_admin', scope_unit_id: null })
|
|
76
|
+
|
|
63
77
|
/** The gap between neighbouring items' order numbers, as the backend spaces them. */
|
|
64
78
|
const GAP = 1_000_000
|
|
65
79
|
|
|
@@ -209,7 +223,7 @@ export class MockStore {
|
|
|
209
223
|
const known = this.accounts.map((a) => a.username).join(', ') || '(none)'
|
|
210
224
|
throw new Error(`[uniweb/api mock] signedInAs: '${signedInAs}' is not a seeded account. Seeded: ${known}`)
|
|
211
225
|
}
|
|
212
|
-
this.session = { account }
|
|
226
|
+
this.session = { account, elevatedUntil: null }
|
|
213
227
|
}
|
|
214
228
|
}
|
|
215
229
|
|
|
@@ -234,24 +248,52 @@ export class MockStore {
|
|
|
234
248
|
return this.session?.account ?? null
|
|
235
249
|
}
|
|
236
250
|
|
|
251
|
+
/** Whether the session is elevated now — its window still open. */
|
|
252
|
+
get elevated() {
|
|
253
|
+
const until = this.session?.elevatedUntil
|
|
254
|
+
return until != null && Date.parse(until) > Date.now()
|
|
255
|
+
}
|
|
256
|
+
|
|
237
257
|
/**
|
|
238
|
-
* The viewer, in the shape `/auth/me` answers. `roles` are the session's
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* operator's yes
|
|
258
|
+
* The viewer, in the shape `/auth/me` answers. `roles` are the session's: the
|
|
259
|
+
* operator's `system_admin`, held as `content_reader` at the same scope until the
|
|
260
|
+
* session is elevated; a member's, none. `elevation` says whether it can be — the
|
|
261
|
+
* operator's yes — and until when it is. Whether the backend's administration admits
|
|
262
|
+
* the ACCOUNT is `capabilities.admin_app`: the operator's yes, a member's no.
|
|
242
263
|
*/
|
|
243
264
|
viewer(account = this.account) {
|
|
244
265
|
if (!account) return null
|
|
266
|
+
const elevated = this.elevated
|
|
267
|
+
const roles = account.operator ? [elevated ? { ...OPERATOR_ROLE } : { ...OPERATOR_ROLE, role: 'content_reader' }] : []
|
|
245
268
|
return {
|
|
246
269
|
account: this.identity(account),
|
|
247
|
-
roles
|
|
270
|
+
roles,
|
|
248
271
|
// A request that names no workspace — as this package's never do — lands in the
|
|
249
272
|
// home org, which every account belongs to. Both null would mean a non-member.
|
|
250
273
|
workspace: { ...HOME_ORG },
|
|
251
274
|
capabilities: { admin_app: account.operator === true },
|
|
275
|
+
elevation: { available: account.operator === true, ...(elevated ? { until: this.session.elevatedUntil } : {}) },
|
|
252
276
|
}
|
|
253
277
|
}
|
|
254
278
|
|
|
279
|
+
/**
|
|
280
|
+
* `POST /auth/elevate` — the password again, and the window opens: `elevated_until`.
|
|
281
|
+
* A wrong password is `401`, as the backend's one answer for a wrong password or code.
|
|
282
|
+
* An account with nothing to elevate is answered the same way — what the backend does
|
|
283
|
+
* then is not measured, and it changes nothing the account may do.
|
|
284
|
+
*/
|
|
285
|
+
elevate({ password }) {
|
|
286
|
+
if (!this.account || this.account.password !== password) return refuse(401, 'Unauthorized')
|
|
287
|
+
this.session.elevatedUntil = new Date(Date.now() + ELEVATION_WINDOW).toISOString()
|
|
288
|
+
return { body: { elevated_until: this.session.elevatedUntil } }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** `DELETE /auth/elevate` — the window closes now. */
|
|
292
|
+
lower() {
|
|
293
|
+
this.session.elevatedUntil = null
|
|
294
|
+
return { body: { elevated_until: null } }
|
|
295
|
+
}
|
|
296
|
+
|
|
255
297
|
identity(account) {
|
|
256
298
|
return { uuid: account.uuid, username: account.username, handle: account.handle }
|
|
257
299
|
}
|
|
@@ -260,7 +302,7 @@ export class MockStore {
|
|
|
260
302
|
const account = this.accounts.find((a) => a.username === username)
|
|
261
303
|
if (!account || account.password !== password) return refuse(401, 'Unauthorized')
|
|
262
304
|
if (!account.verified) return refuse(403, PROBLEM.notVerified, 'verify your email address before signing in')
|
|
263
|
-
this.session = { account }
|
|
305
|
+
this.session = { account, elevatedUntil: null }
|
|
264
306
|
const expires = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
|
|
265
307
|
// No `token`: a site's backend answers a sign-in with the session cookie alone.
|
|
266
308
|
return { body: { expires_at: expires, account: this.identity(account) } }
|
|
@@ -599,30 +641,35 @@ export class MockStore {
|
|
|
599
641
|
}
|
|
600
642
|
|
|
601
643
|
/**
|
|
602
|
-
* The owner — or
|
|
603
|
-
*
|
|
604
|
-
* administrator's rights.
|
|
644
|
+
* The owner — or the operator, whose role reads everything, elevated or not — or any
|
|
645
|
+
* member, when the service lets members read each other's.
|
|
605
646
|
*/
|
|
606
647
|
mayRead(entity) {
|
|
607
648
|
const me = this.account
|
|
608
|
-
return !!me && (entity.owner_id === me.id || this.floorFor(entity) != null)
|
|
649
|
+
return !!me && (entity.owner_id === me.id || me.operator === true || this.floorFor(entity) != null)
|
|
609
650
|
}
|
|
610
651
|
|
|
652
|
+
/**
|
|
653
|
+
* The owner — or the operator while its session is elevated — or any member, when the
|
|
654
|
+
* service lets members edit each other's.
|
|
655
|
+
*/
|
|
611
656
|
mayEdit(entity) {
|
|
612
657
|
const me = this.account
|
|
613
|
-
return !!me && (entity.owner_id === me.id || this.floorFor(entity) === 'edit')
|
|
658
|
+
return !!me && (entity.owner_id === me.id || (me.operator === true && this.elevated) || this.floorFor(entity) === 'edit')
|
|
614
659
|
}
|
|
615
660
|
|
|
616
661
|
/**
|
|
617
662
|
* Which branch lets the viewer read a row, as the backend names it — the first to
|
|
618
663
|
* answer in its order: `owner`, `grant`, `rbac`, `unit_member`. A session's own rows
|
|
619
|
-
* are `owner`, the operator's too
|
|
620
|
-
*
|
|
621
|
-
*
|
|
664
|
+
* are `owner`, the operator's too; the rest the operator reads through its role,
|
|
665
|
+
* `rbac`; a member reads the rest by the service's floor. *(From 2026-10-10 to
|
|
666
|
+
* 2026-10-14 the operator's session read nothing of a member's, and before that the
|
|
667
|
+
* rest as `rbac`, through `system_admin`.)*
|
|
622
668
|
*/
|
|
623
669
|
via(entity) {
|
|
624
670
|
const me = this.account
|
|
625
671
|
if (entity.owner_id === me?.id) return 'owner'
|
|
672
|
+
if (me?.operator === true) return 'rbac'
|
|
626
673
|
return 'unit_member'
|
|
627
674
|
}
|
|
628
675
|
|
package/src/wire.js
CHANGED
|
@@ -121,6 +121,10 @@ export const AUTH = {
|
|
|
121
121
|
* `detail`. **MEASURED** by backend, 2026-10-10.
|
|
122
122
|
*/
|
|
123
123
|
memberHandoff: '/auth/member-handoff',
|
|
124
|
+
/**
|
|
125
|
+
* `POST /auth/elevate` elevates a site's session; `DELETE` lowers it (`ELEVATION`).
|
|
126
|
+
*/
|
|
127
|
+
elevate: '/auth/elevate',
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
/**
|
|
@@ -148,7 +152,8 @@ export const HANDOFF_REFUSAL = Object.freeze({
|
|
|
148
152
|
* | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
|
|
149
153
|
* | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`, and every other session of the account ends; a bad token is `400 Validation`; `code` is required when a second factor is enrolled. A password the rule refuses is `400` with `reason: 'password_too_short'` and `min_length` — refused BEFORE the token is spent, so the same link works again (RULED by backend, 2026-10-11) |
|
|
150
154
|
* | logout | none | `204`; with no session to end, `401` |
|
|
151
|
-
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app } }` · `401` |
|
|
155
|
+
* | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app }, elevation? }` · `401` |
|
|
156
|
+
* | elevate | `{ password, code? }` | `200 { elevated_until }` · `401`, one answer for a wrong password or code (`ELEVATION`) |
|
|
152
157
|
*
|
|
153
158
|
* ⭐ **The session is the cookie, and only the cookie.** A site's backend answers a
|
|
154
159
|
* sign-in with no `token` (since 2026-10-10, MEASURED by backend; `token` was in the
|
|
@@ -161,6 +166,54 @@ export const AUTH_BODY = {
|
|
|
161
166
|
register: ['username', 'email', 'password'],
|
|
162
167
|
resetRequest: ['email'],
|
|
163
168
|
resetConfirm: ['token', 'new_password', 'code?'],
|
|
169
|
+
elevate: ['password', 'code?'],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Elevation. **Stated by the backend, 2026-10-14, and measured by its own end-to-end
|
|
174
|
+
* test; not yet measured here.**
|
|
175
|
+
*
|
|
176
|
+
* On a site's backend, a session signed in on the site holds each of the account's
|
|
177
|
+
* roles as `content_reader`, at the same scope, until it is ELEVATED: it reads wherever
|
|
178
|
+
* a role reached and changes nothing through one. Ownership and grants work as always —
|
|
179
|
+
* a member edits, deletes and shares their own. To act through a role — an
|
|
180
|
+
* administrator removing a member's post — the session elevates, for a while.
|
|
181
|
+
*
|
|
182
|
+
* | route | body | answer |
|
|
183
|
+
* |---|---|---|
|
|
184
|
+
* | `GET /auth/me` | none | carries `elevation: { available, until? }` on such a session — `available` when the account holds a role that answers more than `read`; `until`, an ISO time, while elevated. A backend no site reaches sends no `elevation`: its roles answer in full |
|
|
185
|
+
* | `POST /auth/elevate` | `{ password, code? }` — `code` when a second factor is live | `200 { elevated_until }`. A wrong password or code is `401`, one answer — and so is no session at all |
|
|
186
|
+
* | `DELETE /auth/elevate` | none | `200 { elevated_until: null }` |
|
|
187
|
+
* | `GET /auth/elevate/{provider}/authorize?redirect_uri=&state=` | — | ⛔ **a navigation**, for an account with no password: the provider's sign-in, then back to `redirect_uri` with `?elevate=ok`, or `?error=oauth_step_up_not_linked` when the identity that came back is not the account's |
|
|
188
|
+
*
|
|
189
|
+
* - **The window is the backend's** — 15 minutes on 2026-10-14 — and this package never
|
|
190
|
+
* assumes it: it reads `until`. Elevating again starts it again; nothing extends it.
|
|
191
|
+
* - **`roles` on `/auth/me` is what the session holds now** — `content_reader` rows until
|
|
192
|
+
* it is elevated, the account's roles while it is (`VIEWER`).
|
|
193
|
+
* - **Elevating is step-up's proof too** (`PROBLEM.stepUp`).
|
|
194
|
+
* - **It does not reach the backend's administration**, which takes a site's session as
|
|
195
|
+
* a reader, elevated or not. The Admin link does (`AUTH.memberHandoff`).
|
|
196
|
+
* - **What an elevated session may do to an entity is still the entity's answer** —
|
|
197
|
+
* `can_edit` on a read (`READ`), so a read made before elevating is read again.
|
|
198
|
+
*/
|
|
199
|
+
export const ELEVATION = {
|
|
200
|
+
/** `/auth/me`'s field, and its two keys. */
|
|
201
|
+
me: 'elevation',
|
|
202
|
+
available: 'available',
|
|
203
|
+
until: 'until',
|
|
204
|
+
/** The answer of `POST` and `DELETE /auth/elevate`: an ISO time, or `null`. */
|
|
205
|
+
answer: 'elevated_until',
|
|
206
|
+
password: 'password',
|
|
207
|
+
code: 'code',
|
|
208
|
+
/** The provider's elevation — a navigation, never a fetch. */
|
|
209
|
+
byProvider: (provider) => `/auth/elevate/${segment(provider)}/authorize`,
|
|
210
|
+
redirect: 'redirect_uri',
|
|
211
|
+
state: 'state',
|
|
212
|
+
/** What the provider's elevation returns with: `?elevate=ok`, or `?error=oauth_step_up_not_linked`. */
|
|
213
|
+
returned: 'elevate',
|
|
214
|
+
ok: 'ok',
|
|
215
|
+
error: 'error',
|
|
216
|
+
notLinked: 'oauth_step_up_not_linked',
|
|
164
217
|
}
|
|
165
218
|
|
|
166
219
|
/**
|
|
@@ -274,13 +327,20 @@ export const TOTP = {
|
|
|
274
327
|
* The viewer, as `/auth/me` answers. **MEASURED.**
|
|
275
328
|
*
|
|
276
329
|
* `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
|
|
277
|
-
* `unit_admin`, `content_editor`, `user
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* session
|
|
330
|
+
* `unit_admin`, `content_editor`, `content_reader`, `user`; the operator's is
|
|
331
|
+
* `{ role: 'system_admin', scope_unit_id: null }` (MEASURED 2026-09-18). ⭐ **They
|
|
332
|
+
* describe the session, not the account.** On a site's `backend` service a session
|
|
333
|
+
* signed in on the site holds each of the account's roles as `content_reader`, at the
|
|
334
|
+
* same scope, until it is elevated — so `roles` name an administrator there only while
|
|
335
|
+
* the session is (`ELEVATION`): a site runs third-party code with its member's session,
|
|
336
|
+
* and a bug in that code acts with whatever the session holds. An ordinary member holds
|
|
337
|
+
* none: `[]`. *(Since 2026-10-14, stated by backend. From 2026-10-10 such a session held
|
|
338
|
+
* no `system_admin` and no `unit_admin` of the home org and every other role whole, and
|
|
339
|
+
* before that the operator's session on a site held `system_admin`.)*
|
|
340
|
+
*
|
|
341
|
+
* `elevation` is `{ available, until? }` on a site's session (`ELEVATION`), absent on a
|
|
342
|
+
* backend no site reaches. The viewer carries it as `{ available, until }` — `until`
|
|
343
|
+
* `null` when not elevated — or `null` when absent.
|
|
284
344
|
*
|
|
285
345
|
* `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
|
|
286
346
|
* package's requests name none, and on a site's `backend` service such a request lands in
|
|
@@ -293,16 +353,16 @@ export const TOTP = {
|
|
|
293
353
|
*
|
|
294
354
|
* ⛔ **Neither is an operator signal.** Every member of a site's service works in the
|
|
295
355
|
* same home org, so `workspace` reads the same for the operator and for someone who
|
|
296
|
-
* signed up a minute ago, and on a site `roles` name
|
|
297
|
-
* thing instead — a single-entity read carries
|
|
298
|
-
* for this session.
|
|
356
|
+
* signed up a minute ago, and on a site `roles` name an administrator only while the
|
|
357
|
+
* session is elevated. Ask about the thing instead — a single-entity read carries
|
|
358
|
+
* `can_edit`, the write gate's own answer for this session.
|
|
299
359
|
*
|
|
300
360
|
* `capabilities.admin_app` is the one fact here about the ACCOUNT rather than the
|
|
301
361
|
* session: `true` when the backend's administration admits it — the operator, and
|
|
302
362
|
* whoever else administers the service. The viewer carries it as `adminApp`, and the
|
|
303
363
|
* package then offers the Admin link (`AUTH.memberHandoff`). It decides what to OFFER,
|
|
304
|
-
* never what is allowed on the site, where the session acts
|
|
305
|
-
*
|
|
364
|
+
* never what is allowed on the site, where the session acts through its roles only
|
|
365
|
+
* while elevated. `true` or `false` for every signed-in caller, at the same path as on
|
|
306
366
|
* `/session/bootstrap` (MEASURED by backend, 2026-10-10); anything else reads as
|
|
307
367
|
* `false`.
|
|
308
368
|
*/
|
|
@@ -312,6 +372,7 @@ export const VIEWER = {
|
|
|
312
372
|
workspace: 'workspace',
|
|
313
373
|
capabilities: 'capabilities',
|
|
314
374
|
adminApp: 'admin_app',
|
|
375
|
+
elevation: ELEVATION.me,
|
|
315
376
|
}
|
|
316
377
|
|
|
317
378
|
/**
|
|
@@ -383,12 +444,15 @@ export const PARAM = {
|
|
|
383
444
|
* The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
|
|
384
445
|
* — only what the viewer owns.
|
|
385
446
|
*
|
|
386
|
-
* What a session signed in on a site may read
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* 2026-
|
|
447
|
+
* What a session signed in on a site may read: its own entities, what was shared with
|
|
448
|
+
* it, and — for an account that holds a role — whatever that role reaches, elevated or
|
|
449
|
+
* not (`ELEVATION`): the operator's session reads every member's. An ordinary member's
|
|
450
|
+
* holds no role, and other members' entities are private to their owners unless the
|
|
451
|
+
* site's service was set up to let members read each other's. *(A site's service let
|
|
452
|
+
* every member read every other member's entities until 2026-09-18 — measured, and
|
|
453
|
+
* changed on the backend. The operator's session read everything until 2026-10-10,
|
|
454
|
+
* nothing of a member's from then, and since 2026-10-14 reads everything again and
|
|
455
|
+
* changes it only while elevated — stated by the backend.)*
|
|
392
456
|
*/
|
|
393
457
|
export const SCOPE = {
|
|
394
458
|
mine: 'mine',
|
|
@@ -503,9 +567,14 @@ export const CREATE = {
|
|
|
503
567
|
*
|
|
504
568
|
* `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
|
|
505
569
|
* `unit_member`, `entitlement`; treat an unknown value as "no opinion". A session's
|
|
506
|
-
* own rows are `owner` — the operator's too
|
|
507
|
-
*
|
|
508
|
-
*
|
|
570
|
+
* own rows are `owner` — the operator's too — and the rest the operator's session reads
|
|
571
|
+
* are `rbac`, through its role, elevated or not. *(From 2026-10-10 to 2026-10-14 the
|
|
572
|
+
* operator's session on a site read nothing of a member's.)* `rbac` is any
|
|
573
|
+
* administrative path — a role, and since 2026-10-14 a reader held at a unit over its
|
|
574
|
+
* people's content, and a reference whose field declares `readable_with` and whose
|
|
575
|
+
* target the viewer holds at that level *[backend, 2026-10-14; not measured here]* —
|
|
576
|
+
* while `grant` stays "shared with you". ⚠️ An empty list means empty: a lapsed session
|
|
577
|
+
* is a `401`, never `200 []`.
|
|
509
578
|
*/
|
|
510
579
|
export const LIST = {
|
|
511
580
|
records: 'entities',
|
|
@@ -603,4 +672,14 @@ export const PROBLEM = {
|
|
|
603
672
|
* `op-field-names` — see `FIELD` and `OP`.)* A new entry needs `we`, `from` and
|
|
604
673
|
* `breaks`; `tests/wire.test.js` pins the set, so adding one is deliberate.
|
|
605
674
|
*/
|
|
606
|
-
export const ASSUMPTIONS = [
|
|
675
|
+
export const ASSUMPTIONS = [
|
|
676
|
+
{
|
|
677
|
+
id: 'elevation-by-provider',
|
|
678
|
+
we:
|
|
679
|
+
"send the page's address without its query or fragment as `redirect_uri`, and a fresh `state` of our own per attempt, which we never read back — the session's own `elevation.until` says whether it worked; we then take `?elevate=ok`, `?error=oauth_step_up_not_linked` and our `state` off the address bar",
|
|
680
|
+
from:
|
|
681
|
+
'the backend names the route, its two parameters and the two answers (2026-10-14), and says it works as step-up\'s provider flow does; what `state` must be, and how an answer joins a `redirect_uri` that has a query of its own, it does not say — a sign-in through a provider sends a fresh `state` per attempt and gets it back',
|
|
682
|
+
breaks:
|
|
683
|
+
"a backend that refuses a `state` it did not mint, or this site's origin as a `redirect_uri`, answers the navigation with a page of its own: the viewer is not elevated, and nothing else changes",
|
|
684
|
+
},
|
|
685
|
+
]
|