@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 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 session
56
- signed in on a site holds a member's rights — the operator's too.** A site runs your
57
- foundation's code with the signed-in member's session, so no administrator's role
58
- rides with it: `viewer.roles` never names one there, and an ordinary member holds none
59
- (`[]`). `viewer.workspace` — the site service's home org, `{ handle: 'home', … }` — is
60
- the same for everyone. To decide whether to show an edit control for a particular
61
- record, read that record: `entity.can_edit` is the backend's own answer.
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 as a member's, and administering happens at the other
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 — its operator too — reads their own
182
- entities and what was shared with them; other members' are private unless the site's
183
- service was set up to let members read each other's. `scope: 'mine'` lists only the
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 — the operator too, as on a real site — reads and writes their own
310
- entities and nothing of anyone else's (`memberFloor: 'read'` in the seed models a
311
- service set up to let members read each other's — or per Model,
312
- `memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
313
- each member's own records stay private). The mail the backend would send — a sign-up's
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 the links the backend mails,
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.1.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.1.0"
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 and what was shared with them, the
692
- * operator's session included (`VIEWER` in `./wire.js`). `mine` is only what the
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'
@@ -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
@@ -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 and what was shared with them, the operator included.
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.
@@ -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. ⛔ **Signed in on the site it holds a member's rights**, like
24
- * everyone: no `system_admin` in `roles`, and nothing of another member's unless
25
- * shared — a site runs third-party code with its member's session. *(Until
26
- * 2026-10-10 the operator's session held `system_admin` and read and wrote
27
- * everything.)* A seeded account is the operator with `operator: true`.
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
- * reference fields, password rules,
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, and a
239
- * session on a site holds no administrator's — the operator's neither. Whether the
240
- * backend's administration admits the ACCOUNT is `capabilities.admin_app`: the
241
- * operator's yes, a member's no.
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 any member, when the service lets members read each other's. The
603
- * operator is a member here like everyone: a session on a site holds no
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, and the rest it reads by the service's floor.
620
- * *(Until 2026-10-10 the operator's session read the rest as `rbac`, through
621
- * `system_admin`.)*
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`. ⭐ **They describe the session, not the
278
- * account.** On a site's `backend` service a session signed in on the site holds no
279
- * `system_admin` and no `unit_admin` of the home org — the operator's neither — so
280
- * `roles` never name an administrator there: a site runs third-party code with its
281
- * member's session, and a bug in that code must not act as one. An ordinary member
282
- * holds none: `[]`. *(Since 2026-10-10, MEASURED by backend; until then the operator's
283
- * session on a site held `system_admin`.)*
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 no administrator. Ask about the
297
- * thing instead — a single-entity read carries `can_edit`, the write gate's own answer
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 as a member whatever the
305
- * account holds. `true` or `false` for every signed-in caller, at the same path as on
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 — any member's, the operator's
387
- * included (`VIEWER`): its own entities, and what was shared with it. Other members'
388
- * entities are private to their owners unless the site's service was set up to let
389
- * members read each other's. *(A site's service let every member read every other
390
- * member's entities until 2026-09-18, and the operator's session read everything until
391
- * 2026-10-10 — both measured, and changed on the backend.)*
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. *(Until 2026-10-10 the operator's session
507
- * on a site read the rest as `rbac`, through `system_admin`.)* ⚠️ An empty list means
508
- * empty: a lapsed session is a `401`, never `200 []`.
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
+ ]