@uniweb/api 0.7.0 → 0.8.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,11 +52,13 @@ 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 }`. A member holds no roles (`[]`); the site's operator
56
- holds `system_admin`. ⚠️ `viewer.workspace` — the site service's home org,
57
- `{ handle: 'home', … }` — is the same for every member, so it cannot tell an operator
58
- from a member. To decide whether to show an edit control for
59
- a particular 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 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.
60
62
 
61
63
  **Signing up** — `useSignUp().signUp({ username, email, password })` answers
62
64
  `{ status: 'verification_required', email }`, the same answer for an address already in
@@ -69,6 +71,23 @@ already taken fails with status `409`.
69
71
  second factor is enrolled). **Second factor** — when `signIn` answers
70
72
  `{ ok: false, challenge: { kind: 'totp' } }`, finish with `completeChallenge(code)`.
71
73
 
74
+ **The Admin link** — some of the site's members administer its backend, the operator
75
+ among them. For them, `adminHref` is the address that takes them to the backend's
76
+ administration, already signed in; for everyone else it is `null`, so draw the link
77
+ only when it is set:
78
+
79
+ ```jsx
80
+ const { adminHref } = useSession()
81
+
82
+ {adminHref && <a href={adminHref}>Admin</a>}
83
+ ```
84
+
85
+ ⛔ **Navigate to it — a link, or `location.assign(adminHref)` — never `fetch` it.** It
86
+ answers with a redirect carrying a one-time credential that no script on the site is
87
+ to hold. The link decides what to offer, not what is allowed: on the site, an
88
+ administrator's session acts as a member's, and administering happens at the other
89
+ end of the link.
90
+
72
91
  ## Reading
73
92
 
74
93
  ```jsx
@@ -84,9 +103,9 @@ live source — no `backend` service, or nobody signed in — so render your sit
84
103
  Showing "nothing yet" for the first case tells a visitor their content is missing
85
104
  when it is simply not being asked for.
86
105
 
87
- **Whose records.** A member reads their own entities and what was shared with them;
88
- other members' are private unless the site's service was set up to let members read
89
- each other's. The site's operator reads everything. `scope: 'mine'` lists only the
106
+ **Whose records.** Everyone signed in on the site — its operator too — reads their own
107
+ entities and what was shared with them; other members' are private unless the site's
108
+ service was set up to let members read each other's. `scope: 'mine'` lists only the
90
109
  viewer's own.
91
110
 
92
111
  A record is a summary: `record.brief` holds the fields of the Model's brief section. It
@@ -182,7 +201,7 @@ import { createMockBackend } from '@uniweb/api/mock'
182
201
  export default createMockBackend({
183
202
  seed: {
184
203
  accounts: [
185
- { username: 'me', password: 'me', operator: true }, // the site's operator
204
+ { username: 'me', password: 'me', operator: true }, // runs the site, and gets the Admin link
186
205
  { username: 'member', password: 'member' },
187
206
  ],
188
207
  schemas: {
@@ -210,15 +229,16 @@ production, and nothing in your site's configuration changes between the two.
210
229
 
211
230
  ⭐ **The mock answers what the backend answers** — the same statuses, bodies and
212
231
  refusals, for every route this package uses. It enforces what your schemas declare —
213
- who may create entries, which sections are insert-only, the fields a section takes —
214
- and it is as strict as the backend about requests, so a mistake fails here rather
215
- than in front of a user. Members read and write their own entities and nothing of each
216
- other's; the operator reads and writes everything (`memberFloor: 'read'` in the seed
217
- models a service set up to let members read each other's — or per Model,
232
+ which sections are insert-only, the fields a section takes — and it is as strict as
233
+ the backend about requests, so a mistake fails here rather than in front of a user.
234
+ Everyone signed in — the operator too, as on a real site — reads and writes their own
235
+ entities and nothing of anyone else's (`memberFloor: 'read'` in the seed models a
236
+ service set up to let members read each other's — or per Model,
218
237
  `memberFloor: { '@acme/course': 'read' }`, so the operator's content is shared while
219
238
  each member's own records stay private). A new sign-up must be
220
- verified: the link is in `mock.outbox` (the standalone server prints it). State is in
221
- memory: restart to reset.
239
+ verified: the link is in `mock.outbox` (the standalone server prints it). The
240
+ operator's Admin link answers too, landing on a page of the mock's own — the backend's
241
+ administration is not part of it. State is in memory: restart to reset.
222
242
 
223
243
  There is also a standalone server, for a frontend that is not a Uniweb site:
224
244
 
@@ -230,7 +250,7 @@ npx uniweb-api-mock --port 8787
230
250
 
231
251
  `@uniweb/api/client` carries the same operations as plain functions and imports no
232
252
  React: `probeSession`, `signIn`, `listEntities`, `readEntity`, `writeItems`,
233
- `createEntity`, `deleteEntity`.
253
+ `createEntity`, `deleteEntity`, `adminHref`.
234
254
 
235
255
  ## License
236
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": ">=20.19"
35
35
  },
36
36
  "dependencies": {
37
- "@uniweb/core": "^0.38.0"
37
+ "@uniweb/core": "^0.38.1"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.0.0"
package/src/client.js CHANGED
@@ -15,7 +15,7 @@ import { getUniweb, deriveCacheKey } from '@uniweb/core'
15
15
  import { resolveService } from '@uniweb/core'
16
16
  import { ApiError } from './errors.js'
17
17
  import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
18
- import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP } from './wire.js'
18
+ import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP, VIEWER } from './wire.js'
19
19
  import { Ledger } from './ledger.js'
20
20
 
21
21
  /**
@@ -353,6 +353,7 @@ export class ApiClient {
353
353
  ...account,
354
354
  roles: Array.isArray(me?.roles) ? me.roles : [],
355
355
  workspace: viewerWorkspace(me?.workspace),
356
+ adminApp: me?.[VIEWER.capabilities]?.[VIEWER.adminApp] === true,
356
357
  })
357
358
  if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
358
359
  return this.setSession({ status: 'authenticated', viewer, error: null })
@@ -627,8 +628,9 @@ export class ApiClient {
627
628
  * a `401` too, never an empty list (so `records: []` means empty).
628
629
  *
629
630
  * `scope` decides whose: the default, `accessible`, is everything the viewer may
630
- * read — for a member of a site, their own entities and what was shared with them;
631
- * for the operator, everything. `mine` is only what the viewer owns.
631
+ * read — signed in on a site, their own entities and what was shared with them, the
632
+ * operator's session included (`VIEWER` in `./wire.js`). `mine` is only what the
633
+ * viewer owns.
632
634
  *
633
635
  * ## Paging, as far as it can honestly be absorbed
634
636
  *
@@ -921,6 +923,28 @@ export function getClient() {
921
923
  return client
922
924
  }
923
925
 
926
+ /**
927
+ * The Admin link — the address that takes the viewer to the backend's administration,
928
+ * signed in — or `null` when there is none to offer: no backend, nobody signed in, or
929
+ * an account the administration does not admit (`viewer.adminApp`).
930
+ *
931
+ * ⛔ **For a navigation — an `<a href>`, `location.assign` — never a `fetch`.** It
932
+ * answers with a redirect carrying a one-time credential that no script on a site is to
933
+ * hold, and a fetch that follows it spends the code (`AUTH.memberHandoff`).
934
+ *
935
+ * A function of the client's base and a session snapshot, not a method: an instance an
936
+ * older copy of this package created has neither, and works here all the same.
937
+ *
938
+ * @param {ApiClient|null} client
939
+ * @param {object} [session] - a snapshot; the client's current one by default
940
+ * @returns {string|null}
941
+ */
942
+ export function adminHrefOf(client, session = client?.session) {
943
+ const base = client?.base ?? null
944
+ if (base === null || session?.status !== 'authenticated' || session.viewer?.adminApp !== true) return null
945
+ return composeUrl(base, AUTH.memberHandoff)
946
+ }
947
+
924
948
  // ── The functions — the same client, outside React ───────────────────────────
925
949
 
926
950
  function required() {
@@ -953,6 +977,8 @@ export const writeItems = (args) => required().writeItems(args)
953
977
  export const createEntity = (args) => required().createEntity(args)
954
978
  /** @see ApiClient#deleteEntity */
955
979
  export const deleteEntity = (args) => required().deleteEntity(args)
980
+ /** The Admin link for the current viewer, or `null` — never throws; see `adminHrefOf`. */
981
+ export const adminHref = () => adminHrefOf(getClient())
956
982
 
957
983
  export { ApiError, kindOf } from './errors.js'
958
984
  export { Ledger } from './ledger.js'
@@ -34,8 +34,8 @@ const DISABLED = Object.freeze({ status: 'absent', records: NONE, matched: 0, ha
34
34
  * entity for those. ⚠️ `matched` counts the records in this answer, not a total:
35
35
  * `hasMore` is true when the page came back full.
36
36
  *
37
- * The default scope is everything the viewer may read — for a member, their own
38
- * entities and what was shared with them; for the operator, everything.
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
39
  * `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
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useSyncExternalStore } from 'react'
2
- import { getClient } from '../client.js'
2
+ import { getClient, adminHrefOf } from '../client.js'
3
3
 
4
4
  const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
5
5
  const noSubscribe = () => () => {}
@@ -15,6 +15,10 @@ 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
+ * `adminHref` is the Admin link — set only for a signed-in account the backend's
19
+ * administration admits, `null` for everyone else, so draw the link only when it is
20
+ * set. ⛔ Navigate to it, never `fetch` it (`adminHrefOf` in `../client.js`).
21
+ *
18
22
  * Reads the shared snapshot through `useSyncExternalStore`, so every copy of
19
23
  * this package on the page sees one session, and the first render on the
20
24
  * server matches the first render in the browser.
@@ -24,6 +28,7 @@ const noop = async () => NONE
24
28
  * viewer: object | null,
25
29
  * error: Error | null,
26
30
  * canSignIn: boolean,
31
+ * adminHref: string | null,
27
32
  * signOut: () => Promise<void>,
28
33
  * refresh: () => Promise<object>,
29
34
  * }}
@@ -48,6 +53,7 @@ export function useSession() {
48
53
  viewer: session.viewer,
49
54
  error: session.error,
50
55
  canSignIn: !!(client && client.enabled),
56
+ adminHref: adminHrefOf(client, session),
51
57
  signOut,
52
58
  refresh,
53
59
  }
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  writeItems,
27
27
  createEntity,
28
28
  deleteEntity,
29
+ adminHref,
29
30
  ApiError,
30
31
  Ledger,
31
32
  } from './client.js'
package/src/mock/index.js CHANGED
@@ -1,7 +1,24 @@
1
- import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS } from '../wire.js'
1
+ import { AUTH, ENTITIES, MODELS, PARAM, OP, FIELD, CREATE, PAGE, SCOPE, PROBLEM, GUARDED_OPS, HANDOFF_REFUSAL } from '../wire.js'
2
2
  import { MockStore, isUuid, refuse } from './store.js'
3
3
  import { DEFAULT_SEED } from './seed.js'
4
4
 
5
+ /**
6
+ * Where the mock's Admin link lands. The backend's administration lives on its own
7
+ * origin, outside the API, and the mock models none of it: the handoff's redirect
8
+ * comes here instead, to a page that says so. A path of the mock's, not the backend's.
9
+ */
10
+ const ADMIN_PAGE = '/admin'
11
+
12
+ const escapeHtml = (value) =>
13
+ String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
14
+
15
+ function adminPage(account) {
16
+ const who = account ? `Signed in as <b>${escapeHtml(account.handle)}</b>. ` : ''
17
+ return `<!doctype html><meta charset="utf-8"><title>Administration</title>
18
+ <p>${who}On a live site, the Admin link opens the backend's administration here, signed in. The local mock has none to open.</p>
19
+ `
20
+ }
21
+
5
22
  /**
6
23
  * A mock of a site's `backend` service, for local development.
7
24
  *
@@ -36,12 +53,15 @@ import { DEFAULT_SEED } from './seed.js'
36
53
  *
37
54
  * ## What it models, and what it does not
38
55
  *
39
- * One operator (`operator: true` on a seeded account) and members; a member reads
40
- * and writes their own entities and nothing of another's (unless the seed's
41
- * `memberFloor` says the service lets them), the operator reads and writes all;
56
+ * One operator (`operator: true` on a seeded account) and members; everyone signed
57
+ * in — the operator too, as on a real site — reads and writes their own entities and
58
+ * nothing of another's (unless the seed's `memberFloor` says the service lets them);
42
59
  * anyone signed in may create entries of any Model (Models are open); sign-up
43
- * leaves an account unverified until the link in `mock.outbox` is followed. It
44
- * does not model entitlements behind `via`, nested sections, or second factors.
60
+ * leaves an account unverified until the link in `mock.outbox` is followed. The
61
+ * operator is the account the backend's administration admits, so its Admin link
62
+ * answers — with a redirect to a page of the mock's own, since the administration
63
+ * itself is not modelled. It does not model entitlements behind `via`, nested
64
+ * sections, or second factors.
45
65
  *
46
66
  * @param {object} [options]
47
67
  * @param {object} [options.seed] - accounts, schemas and entities to start from (see `MockStore`)
@@ -199,6 +219,14 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
199
219
  const viewer = store.viewer()
200
220
  return viewer ? json(200, viewer) : unauthorized()
201
221
  }
222
+ if (path === AUTH.memberHandoff && method === 'GET') {
223
+ if (!store.account) return unauthorized()
224
+ if (!store.account.operator) return problem(HANDOFF_REFUSAL)
225
+ // The backend redirects to its administration on its own origin. The mock has
226
+ // none, so the redirect lands on a page of its own that says so (`ADMIN_PAGE`).
227
+ // Relative, so it lands under whatever path the mock is mounted at.
228
+ return new Response(null, { status: 302, headers: { location: `..${ADMIN_PAGE}` } })
229
+ }
202
230
  if (path === AUTH.login && method === 'POST') {
203
231
  const b = await body(request)
204
232
  return fields(b, ['username', 'password'], '{"username": ..., "password": ...}') ?? answer(store.signIn(b))
@@ -391,6 +419,11 @@ export function createMockBackend({ seed = DEFAULT_SEED, prefix = '/_api', signe
391
419
  }
392
420
  }
393
421
 
422
+ // ── Where the Admin link lands ──────────────────────────────────────────
423
+ if (path === ADMIN_PAGE && method === 'GET') {
424
+ return new Response(adminPage(store.account), { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } })
425
+ }
426
+
394
427
  return problem({ status: 404, title: 'Not Found', detail: `no route for ${method} ${path}` })
395
428
  }
396
429
 
package/src/mock/store.js CHANGED
@@ -19,10 +19,12 @@ import { checkItemWrite, OUTCOME } from './schema-shape.js'
19
19
  * unless shared. A service can be set up to let members read (or edit) each
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
- * - **The operator** — the account that runs the site's service — holds
23
- * `system_admin`, creates the Models only the operator may, and may write
24
- * anything. It is a member of the site's unit like everyone else, so what it
25
- * makes is its own. A seeded account is the operator with `operator: true`.
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`.
26
28
  *
27
29
  * ⛔ **Seeded fixtures plus in-memory mutation, and deliberately not a database.**
28
30
  * A mock that grows a schema and migrations becomes a second implementation that
@@ -207,15 +209,21 @@ export class MockStore {
207
209
  return this.session?.account ?? null
208
210
  }
209
211
 
210
- /** The viewer, in the shape `/auth/me` answers. */
212
+ /**
213
+ * The viewer, in the shape `/auth/me` answers. `roles` are the session's, and a
214
+ * session on a site holds no administrator's — the operator's neither. Whether the
215
+ * backend's administration admits the ACCOUNT is `capabilities.admin_app`: the
216
+ * operator's yes, a member's no.
217
+ */
211
218
  viewer(account = this.account) {
212
219
  if (!account) return null
213
220
  return {
214
221
  account: this.identity(account),
215
- roles: account.operator ? [{ role: 'system_admin', scope_unit_id: null }] : [],
222
+ roles: [],
216
223
  // A request that names no workspace — as this package's never do — lands in the
217
224
  // home org, which every account belongs to. Both null would mean a non-member.
218
225
  workspace: { ...HOME_ORG },
226
+ capabilities: { admin_app: account.operator === true },
219
227
  }
220
228
  }
221
229
 
@@ -229,7 +237,8 @@ export class MockStore {
229
237
  if (!account.verified) return refuse(403, PROBLEM.notVerified, 'verify your email address before signing in')
230
238
  this.session = { account }
231
239
  const expires = new Date(Date.now() + 30 * 24 * 3600 * 1000).toISOString()
232
- return { body: { token: `mock-${account.uuid}`, expires_at: expires, account: this.identity(account) } }
240
+ // No `token`: a site's backend answers a sign-in with the session cookie alone.
241
+ return { body: { expires_at: expires, account: this.identity(account) } }
233
242
  }
234
243
 
235
244
  signOut() {
@@ -468,28 +477,31 @@ export class MockStore {
468
477
  return floor[entity.model] ?? null
469
478
  }
470
479
 
471
- /** The operator, the owner — or any member, when the service lets members read each other's. */
480
+ /**
481
+ * The owner — or any member, when the service lets members read each other's. The
482
+ * operator is a member here like everyone: a session on a site holds no
483
+ * administrator's rights.
484
+ */
472
485
  mayRead(entity) {
473
486
  const me = this.account
474
- return !!me && (me.operator || entity.owner_id === me.id || this.floorFor(entity) != null)
487
+ return !!me && (entity.owner_id === me.id || this.floorFor(entity) != null)
475
488
  }
476
489
 
477
490
  mayEdit(entity) {
478
491
  const me = this.account
479
- return !!me && (me.operator || entity.owner_id === me.id || this.floorFor(entity) === 'edit')
492
+ return !!me && (entity.owner_id === me.id || this.floorFor(entity) === 'edit')
480
493
  }
481
494
 
482
495
  /**
483
496
  * Which branch lets the viewer read a row, as the backend names it — the first to
484
- * answer in its order: `owner`, `grant`, `rbac`, `unit_member`. The operator's own rows
485
- * are `owner` like anyone's, and the rest it reads as `system_admin`. *(Until
486
- * 2026-09-18 the operator was not a member of the site's unit, and ownership speaks
487
- * only for a member — so its own rows answered `rbac` too.)*
497
+ * answer in its order: `owner`, `grant`, `rbac`, `unit_member`. A session's own rows
498
+ * are `owner`, the operator's too, and the rest it reads by the service's floor.
499
+ * *(Until 2026-10-10 the operator's session read the rest as `rbac`, through
500
+ * `system_admin`.)*
488
501
  */
489
502
  via(entity) {
490
503
  const me = this.account
491
504
  if (entity.owner_id === me?.id) return 'owner'
492
- if (me?.operator) return 'rbac'
493
505
  return 'unit_member'
494
506
  }
495
507
 
package/src/wire.js CHANGED
@@ -99,8 +99,32 @@ export const AUTH = {
99
99
  verify: '/auth/verify',
100
100
  resetRequest: '/auth/reset/request',
101
101
  resetConfirm: '/auth/reset/confirm',
102
+ /**
103
+ * `GET /auth/member-handoff` — the Admin link (`adminHref` in `./client.js`).
104
+ * ⛔ **A navigation, never a fetch.** For an account the backend's administration
105
+ * admits (`VIEWER`'s `admin_app`), it answers `302` toward that administration, on
106
+ * the backend's own origin, carrying a one-time credential no script on a site is to
107
+ * hold — a fetch learns nothing from the redirect, and one that follows it spends
108
+ * the code. `401` no session · `403` an account it does not admit
109
+ * (`HANDOFF_REFUSAL`) · `404` a backend no site reaches · `503` a backend never told
110
+ * its own origin — each problem JSON, branched on by `title` or `reason`, never
111
+ * `detail`. **MEASURED** by backend, 2026-10-10.
112
+ */
113
+ memberHandoff: '/auth/member-handoff',
102
114
  }
103
115
 
116
+ /**
117
+ * The handoff's `403`, as sent — an account the administration does not admit.
118
+ * **MEASURED** by backend, 2026-10-10. This package never reads it: the handoff is a
119
+ * navigation, so the browser shows it. The mock answers it.
120
+ */
121
+ export const HANDOFF_REFUSAL = Object.freeze({
122
+ status: 403,
123
+ title: 'Admin Role Required',
124
+ detail: "the admin app admits a system_admin or an administrator of this deployment's home org",
125
+ reason: 'admin_role_required',
126
+ })
127
+
104
128
  /**
105
129
  * The bodies of the auth routes. **MEASURED.** This package hands a caller's
106
130
  * fields to the backend unchanged, so these are the names a caller must use — a
@@ -108,13 +132,18 @@ export const AUTH = {
108
132
  *
109
133
  * | route | body | answer |
110
134
  * |---|---|---|
111
- * | login | `{ username, password }` | `200 { token, expires_at, account }` + the session cookie · `200 { status: 'totp_required', challenge_token }` · `401` wrong credential · `403 Email Not Verified` |
135
+ * | login | `{ username, password }` | `200 { expires_at, account }` + the session cookie · `200 { status: 'totp_required', challenge_token }` · `401` wrong credential · `403 Email Not Verified` |
112
136
  * | challenge | `{ challenge_token, code }` | as login's `200`; a bad token or code is `401` |
113
137
  * | register | `{ username, email, password }` | `202 { status: 'verification_required', email }` — the same answer for an address already taken; a taken **username** is `409` |
114
138
  * | reset request | `{ email }` | `202 { status: 'reset_requested' }` whether or not the address is known |
115
139
  * | reset confirm | `{ token, new_password, code? }` | `200 { reset: true }`; a bad token is `400 Validation`; `code` is required when a second factor is enrolled |
116
140
  * | logout | none | `204`; with no session to end, `401` |
117
- * | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle } }` · `401` |
141
+ * | me | none | `200 { account: { uuid, username, handle }, roles, workspace: { unit_uuid, handle }, capabilities: { admin_app } }` · `401` |
142
+ *
143
+ * ⭐ **The session is the cookie, and only the cookie.** A site's backend answers a
144
+ * sign-in with no `token` (since 2026-10-10, MEASURED by backend; `token` was in the
145
+ * reply until then), and this package never read one: it signs in, then asks
146
+ * `/auth/me` who the viewer is.
118
147
  */
119
148
  export const AUTH_BODY = {
120
149
  login: ['username', 'password'],
@@ -135,7 +164,13 @@ export const TOTP = {
135
164
  * The viewer, as `/auth/me` answers. **MEASURED.**
136
165
  *
137
166
  * `roles` is a list of `{ role, scope_unit_id }` — `role` one of `system_admin`,
138
- * `unit_admin`, `content_editor`, `user`. An ordinary member holds none: `[]`.
167
+ * `unit_admin`, `content_editor`, `user`. ⭐ **They describe the session, not the
168
+ * account.** On a site's `backend` service a session signed in on the site holds no
169
+ * `system_admin` and no `unit_admin` of the home org — the operator's neither — so
170
+ * `roles` never name an administrator there: a site runs third-party code with its
171
+ * member's session, and a bug in that code must not act as one. An ordinary member
172
+ * holds none: `[]`. *(Since 2026-10-10, MEASURED by backend; until then the operator's
173
+ * session on a site held `system_admin`.)*
139
174
  *
140
175
  * `workspace` is `{ unit_uuid, handle }` — **the workspace the request works in**. This
141
176
  * package's requests name none, and on a site's `backend` service such a request lands in
@@ -147,14 +182,26 @@ export const TOTP = {
147
182
  * function the route serializes, 2026-09-23.)*
148
183
  *
149
184
  * ⛔ **Neither is an operator signal.** Every member of a site's service works in the
150
- * same home org, so it reads the same for the operator and for someone who signed up a
151
- * minute ago. The operator holds `system_admin`. Better than either: ask about the
152
- * thing — a single-entity read carries `can_edit`, the write gate's own answer.
185
+ * same home org, so `workspace` reads the same for the operator and for someone who
186
+ * signed up a minute ago, and on a site `roles` name no administrator. Ask about the
187
+ * thing instead — a single-entity read carries `can_edit`, the write gate's own answer
188
+ * for this session.
189
+ *
190
+ * `capabilities.admin_app` is the one fact here about the ACCOUNT rather than the
191
+ * session: `true` when the backend's administration admits it — the operator, and
192
+ * whoever else administers the service. The viewer carries it as `adminApp`, and the
193
+ * package then offers the Admin link (`AUTH.memberHandoff`). It decides what to OFFER,
194
+ * never what is allowed on the site, where the session acts as a member whatever the
195
+ * account holds. `true` or `false` for every signed-in caller, at the same path as on
196
+ * `/session/bootstrap` (MEASURED by backend, 2026-10-10); anything else reads as
197
+ * `false`.
153
198
  */
154
199
  export const VIEWER = {
155
200
  account: 'account',
156
201
  roles: 'roles',
157
202
  workspace: 'workspace',
203
+ capabilities: 'capabilities',
204
+ adminApp: 'admin_app',
158
205
  }
159
206
 
160
207
  /**
@@ -224,11 +271,12 @@ export const PARAM = {
224
271
  * The list's `scope`. Default `accessible` — everything the viewer may read; `mine`
225
272
  * — only what the viewer owns.
226
273
  *
227
- * What a member of a site may read: their own entities, and what was shared with
228
- * them. Other members' entities are private to their owners unless the site's
229
- * service was set up to let members read each other's. The operator reads
230
- * everything. *(A site's service let every member read every other member's
231
- * entities until 2026-09-18 — measured, and changed on the backend.)*
274
+ * What a session signed in on a site may read — any member's, the operator's
275
+ * included (`VIEWER`): its own entities, and what was shared with it. Other members'
276
+ * entities are private to their owners unless the site's service was set up to let
277
+ * members read each other's. *(A site's service let every member read every other
278
+ * member's entities until 2026-09-18, and the operator's session read everything until
279
+ * 2026-10-10 — both measured, and changed on the backend.)*
232
280
  */
233
281
  export const SCOPE = {
234
282
  mine: 'mine',
@@ -342,9 +390,9 @@ export const CREATE = {
342
390
  * paging; a page that came back full is the only sign there may be more.
343
391
  *
344
392
  * `via` names why the viewer can read the row — `owner`, `grant`, `rbac`,
345
- * `unit_member`, `entitlement`; treat an unknown value as "no opinion". The
346
- * operator's own rows are `owner` and the rest `rbac` — since 2026-09-18; an older
347
- * backend answers `rbac` on the operator's own rows too. ⚠️ An empty list means
393
+ * `unit_member`, `entitlement`; treat an unknown value as "no opinion". A session's
394
+ * own rows are `owner` — the operator's too. *(Until 2026-10-10 the operator's session
395
+ * on a site read the rest as `rbac`, through `system_admin`.)* ⚠️ An empty list means
348
396
  * empty: a lapsed session is a `401`, never `200 []`.
349
397
  */
350
398
  export const LIST = {