mikser-io 9.50.2 → 9.54.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.
@@ -844,6 +844,36 @@ AsyncLocalStorage than the engine's, queries record no edges, and index pages,
844
844
  sitemaps and feeds silently stop rebuilding. Production consumers resolve both
845
845
  from their own tree, so the problem is local to the dev workspace.
846
846
 
847
+ ## Inventory
848
+
849
+ What this mikser is made of.
850
+
851
+ ### `inventory({ workingFolder } = {})`
852
+
853
+ Every installed mikser package with its version, purpose and links, and
854
+ `active: true` on the ones actually running.
855
+
856
+ ```js
857
+ [
858
+ { name: 'mikser-io-drive', version: '0.14.0', active: true,
859
+ summary: 'WebDAV for mikser-io. Exposes working-folder directories as …',
860
+ homepage: '…', repository: 'https://github.com/…', npm: 'https://www.npmjs.com/package/…' },
861
+ ]
862
+ ```
863
+
864
+ **Derived, never declared.** Every plugin already carries a description, a
865
+ homepage and a repository in its own `package.json`, kept current because npm
866
+ publishes from it. A second summary held elsewhere would be one more thing to
867
+ drift — and the first time it drifted it would be describing a plugin that had
868
+ changed underneath it.
869
+
870
+ **Installed and active are different facts.** A package in `node_modules` that
871
+ no config loads explains nothing about a site's behaviour, and an agent told
872
+ otherwise looks for a feature that is not switched on. `active` is reported
873
+ only where it can be established — a plugin that mounts a route names itself
874
+ there, a renderer registers under a known name — and is absent rather than
875
+ `false` where it cannot, because `false` would be a claim.
876
+
847
877
  ## Roles
848
878
 
849
879
  Enforcement needs only the flat capability list. Explaining a refusal needs the
@@ -855,7 +885,37 @@ indistinguishable from inside a session.
855
885
  | `describeAuthority({ capabilities, roles, catalogue, summaries })` | everything a session can say about its own authority |
856
886
  | `reachOf(capabilities)` | `{ writable, readOnly }` as collection names |
857
887
  | `actingRole(held, catalogue)` | which role is in force |
858
- | `otherRoles(held, catalogue, summaries)` | who to ask, and what they add |
888
+ | `rolesIn(catalogue, { acting, summaries })` | every role and its reach, the acting one marked |
889
+ | `registerCapability(capability, meaning)` | declare what a capability protects |
890
+ | `capabilityMeaning(capability)` | what is known about one |
891
+
892
+ ### Capabilities describe themselves
893
+
894
+ Core does not know what `drive:layouts` protects, where that folder is, or what
895
+ it is for — the plugin enforcing it does. So the plugin declares it, the same
896
+ way it declares a route or a schema:
897
+
898
+ ```js
899
+ registerCapability('drive:documents', {
900
+ plugin: 'drive',
901
+ grants: 'read', // 'read' | 'write' | 'operate'
902
+ resource: {
903
+ kind: 'collection',
904
+ name: 'documents',
905
+ folder: 'documents', // where it is on disk
906
+ summary: 'the words on the pages' // what it is FOR
907
+ },
908
+ })
909
+ ```
910
+
911
+ `reachOf` then describes a credential in terms of the SITE rather than of
912
+ verbs — a collection, its folder and its purpose — which is what an agent needs
913
+ to reason about where it is working, not merely whether a call will be refused.
914
+ A capability nothing has declared lands in `also` rather than being dropped: a
915
+ role described only by the part of it that maps to folders is not described.
916
+
917
+ The `drive:<name>[:write]` convention is still recognised for deployments whose
918
+ plugins predate the registry, so they keep their answer.
859
919
  | `explainRefusal({ capability, role, target, catalogue, summaries })` | the sentence an agent repeats |
860
920
 
861
921
  `readOnly` is the field that makes a refusal explainable, and it is more useful
@@ -867,6 +927,11 @@ capabilities cover the others — roles are normally written as widening tiers
867
927
  and `null` when none dominates, because the acting authority genuinely is the
868
928
  union and naming half of it would be a lie.
869
929
 
930
+ `roles` lists every role, not only the ones the session lacks. One field has to
931
+ serve two readers: someone deciding who to ask, and someone holding the widest
932
+ role trying to see what exists at all — and a "roles you do not have" field
933
+ tells the second one nothing, since for an admin it is always empty.
934
+
870
935
  > **Informational, permanently.** Naming the role that could do something is
871
936
  > what makes a handoff possible. There is no way to request one and none should
872
937
  > be added: a role is a decision about a person, taken by whoever configures the
package/index.js CHANGED
@@ -3,6 +3,7 @@ export * as constants from './src/constants.js'
3
3
  export * from './src/utils.js'
4
4
  export * from './src/auth.js'
5
5
  export * from './src/roles.js'
6
+ export * from './src/inventory.js'
6
7
  export * from './src/report.js'
7
8
  // The diagnostics behind --explain. Exported so a transport — the MCP tool
8
9
  // surface, the api plugin's routes — can serve the same structured report the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.50.2",
4
- "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
3
+ "version": "9.54.0",
4
+ "description": "A mixer for content: entities in, configurable render pipelines, outputs of any kind. Static sites are the canonical recipe, not the definition — the same engine renders PDFs, emails and whatever a renderer plugin produces. Files are the source of truth, every lifecycle phase is observable, and the build graph is queryable by an agent.",
5
5
  "main": "index.js",
6
6
  "exports": {
7
7
  ".": "./index.js",
@@ -0,0 +1,93 @@
1
+ // What this mikser is made of.
2
+ //
3
+ // An agent arriving at a site can see what it may write and what it may not,
4
+ // and nothing at all about the machine doing the writing: which plugins are
5
+ // installed, what each one is for, or where to read about them. So it reasons
6
+ // about a system it cannot name — and a capability like `drive:layouts` means
7
+ // nothing until you know a plugin called mikser-io-drive exists and what it
8
+ // does.
9
+ //
10
+ // DERIVED, not declared. Every plugin already carries a description, a
11
+ // homepage and a repository in its own package.json, kept current because npm
12
+ // publishes from it. A second hand-written summary somewhere else would be one
13
+ // more thing to drift, and the first time it drifted it would be describing a
14
+ // plugin that had changed underneath it.
15
+
16
+ import path from 'node:path'
17
+ import { readdirSync, readFileSync } from 'node:fs'
18
+ import runtime from './runtime.js'
19
+
20
+ // A repository field is a string or an object, and either may be a shorthand
21
+ // or a git URL. Normalised to something a reader can open.
22
+ function repositoryUrl(repository) {
23
+ const raw = typeof repository === 'string' ? repository : repository?.url
24
+ if (!raw) return null
25
+ const shorthand = /^(?:github:)?([\w.-]+\/[\w.-]+)$/.exec(raw)
26
+ if (shorthand) return `https://github.com/${shorthand[1]}`
27
+ return raw
28
+ .replace(/^git\+/, '')
29
+ .replace(/^git:\/\//, 'https://')
30
+ .replace(/\.git$/, '')
31
+ }
32
+
33
+ // Which plugins are actually RUNNING, as opposed to merely installed.
34
+ //
35
+ // The distinction is the useful half: a package in node_modules that no config
36
+ // loads explains nothing about the site's behaviour, and an agent told
37
+ // otherwise will look for a feature that is not switched on.
38
+ function activeNames() {
39
+ const active = new Set()
40
+ const named = (short) => (short.startsWith('mikser-io') ? short : `mikser-io-${short}`)
41
+
42
+ // Every plugin that mounts a route already names itself there — the
43
+ // strongest signal available, and one that stays correct as plugins are
44
+ // added, because registerRoute requires it.
45
+ for (const route of runtime.routes ?? []) {
46
+ if (route?.plugin) active.add(named(route.plugin))
47
+ }
48
+ // Renderers and postprocessors are registered by name rather than by
49
+ // package, and the package name is that name in a fixed shape.
50
+ for (const name of runtime.renderers?.keys() ?? []) active.add(`mikser-io-render-${name}`)
51
+ for (const name of runtime.postprocessors?.keys() ?? []) active.add(`mikser-io-post-${name}`)
52
+ // A lifecycle plugin that mounts nothing is recognised by the surface it
53
+ // publishes on the runtime.
54
+ if (runtime.options?.layouts) active.add('mikser-io-layouts')
55
+ if (runtime.options?.preview) active.add('mikser-io-preview')
56
+ // The engine itself is always running; saying otherwise would be odd.
57
+ active.add('mikser-io')
58
+ return active
59
+ }
60
+
61
+ // Every mikser package installed beside this one, described.
62
+ //
63
+ // `active` is reported only where it can be established: a lifecycle plugin
64
+ // that publishes nothing on the runtime cannot be detected, and saying `false`
65
+ // for it would be a claim rather than an absence of one.
66
+ export function inventory({ workingFolder = runtime.options?.workingFolder } = {}) {
67
+ const root = path.join(workingFolder ?? '.', 'node_modules')
68
+ let names = []
69
+ try {
70
+ names = readdirSync(root).filter(n => n === 'mikser-io' || n.startsWith('mikser-io-'))
71
+ } catch {
72
+ return []
73
+ }
74
+
75
+ const active = activeNames()
76
+ const plugins = []
77
+ for (const name of names.sort()) {
78
+ try {
79
+ const manifest = JSON.parse(readFileSync(path.join(root, name, 'package.json'), 'utf8'))
80
+ const repository = repositoryUrl(manifest.repository)
81
+ plugins.push({
82
+ name,
83
+ version: manifest.version ?? null,
84
+ ...(manifest.description ? { summary: manifest.description } : {}),
85
+ ...(active.has(name) ? { active: true } : {}),
86
+ ...(manifest.homepage ? { homepage: manifest.homepage } : {}),
87
+ ...(repository ? { repository } : {}),
88
+ npm: `https://www.npmjs.com/package/${name}`,
89
+ })
90
+ } catch { /* unreadable manifest — say nothing rather than something wrong */ }
91
+ }
92
+ return plugins
93
+ }
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'
4
4
  import _ from 'lodash'
5
5
  import sift from 'sift'
6
6
  import { resolveAuth, requireAuth, hasCapability, reachabilityOf } from '../auth.js'
7
+ import { registerCapability } from '../roles.js'
7
8
  import { explain } from '../explain.js'
8
9
  import { buildReport, requestReport } from '../report.js'
9
10
  import { useRenderer } from '../render.js'
@@ -316,6 +317,17 @@ function sseSend(res, eventName, payload) {
316
317
  } catch { /* connection dropped — cleanup runs via 'close' */ }
317
318
  }
318
319
 
320
+ // What each operation is, in words. Read by the capability registry so a
321
+ // session can be told what `api:render` means without knowing this plugin.
322
+ const API_OPERATIONS = {
323
+ list: 'read entities from the catalogue over HTTP',
324
+ update: 'create or change entities over HTTP',
325
+ delete: 'remove entities over HTTP',
326
+ render: 'trigger a render',
327
+ subscribe: 'hold an open connection for live changes',
328
+ diagnostics: 'read the build report and engine diagnostics',
329
+ }
330
+
319
331
  export function api(options = {}) {
320
332
  return ({
321
333
  runtime,
@@ -445,6 +457,23 @@ export function api(options = {}) {
445
457
  ? ['list', 'update', 'delete', 'render', 'subscribe']
446
458
  : ['list']
447
459
  const allowedOps = new Set(ep.operations ?? defaultOps)
460
+
461
+ // What each api capability lets a caller DO, in the vocabulary of
462
+ // the catalogue rather than of HTTP. Declared here because this is
463
+ // where they are enforced — core should not have to know one
464
+ // plugin's operation names to explain a credential that holds
465
+ // them.
466
+ for (const op of allowedOps) {
467
+ registerCapability(`api:${op}`, {
468
+ plugin: 'api',
469
+ grants: 'operate',
470
+ resource: {
471
+ kind: 'operation',
472
+ name: `api:${op}`,
473
+ summary: API_OPERATIONS[op] ?? `the api's ${op} operation`,
474
+ },
475
+ })
476
+ }
448
477
  // Recording the build report costs one entry per entity per
449
478
  // cycle, so it is off unless something can read it. An endpoint
450
479
  // exposing /report is that something.
@@ -45,7 +45,7 @@ function* walkPartials(node) {
45
45
  // helper args) can surface them under additional keys.
46
46
  export function parseReferences(source) {
47
47
  if (typeof source !== 'string' || !source) {
48
- return { variables: [], partials: [], iterations: [], assigns: [], helpers: [] }
48
+ return { variables: [], partials: [], iterations: [], assigns: [], optional: [], helpers: [] }
49
49
  }
50
50
  let ast
51
51
  try {
@@ -53,7 +53,7 @@ export function parseReferences(source) {
53
53
  } catch (err) {
54
54
  // Author-side parse failure shouldn't kill inspect(). Surface
55
55
  // the message and an empty result.
56
- return { variables: [], partials: [], iterations: [], assigns: [], helpers: [], parseError: err.message }
56
+ return { variables: [], partials: [], iterations: [], assigns: [], optional: [], helpers: [], parseError: err.message }
57
57
  }
58
58
 
59
59
  const variables = new Set()
@@ -62,6 +62,12 @@ export function parseReferences(source) {
62
62
  const partials = new Map()
63
63
  const iterations = []
64
64
  const helpers = new Set()
65
+ // Keys the template reads only behind a guard, so their absence is
66
+ // tolerated rather than a gap. The same distinction liquid draws: a
67
+ // contract that reports every key as required makes an optional section
68
+ // look like a missing one, and an editor chases a field the page never
69
+ // needed.
70
+ const optional = new Set()
65
71
 
66
72
  // Handlebars has no file-scoped assignment; aliases come from block
67
73
  // params (`as |x|`), which are scoped to their block. That scope is known
@@ -73,9 +79,12 @@ export function parseReferences(source) {
73
79
  const base = scope[head]
74
80
  return base ? [base, ...rest].join('.') : path
75
81
  }
76
- const record = (path, scope) => {
82
+ const record = (path, scope, guarded = false) => {
77
83
  const resolved = deref(path, scope)
78
- if (resolved) variables.add(resolved)
84
+ if (resolved) {
85
+ variables.add(resolved)
86
+ if (guarded) optional.add(resolved)
87
+ }
79
88
  return resolved
80
89
  }
81
90
  // A partial argument's path, or null when it is a literal — a quoted
@@ -86,16 +95,16 @@ export function parseReferences(source) {
86
95
  // pollute the helpers set. `each` shows up as an iteration instead.
87
96
  const BUILTIN_BLOCKS = new Set(['if', 'unless', 'each', 'with', 'lookup'])
88
97
 
89
- function visit(node, scope = {}) {
98
+ function visit(node, scope = {}, guarded = false) {
90
99
  if (!node || typeof node !== 'object') return
91
100
  switch (node.type) {
92
101
  case 'Program':
93
- for (const child of node.body ?? []) visit(child, scope)
102
+ for (const child of node.body ?? []) visit(child, scope, guarded)
94
103
  break
95
104
  case 'MustacheStatement':
96
105
  // {{path.to.var}} — record the path.
97
106
  if (node.path?.type === 'PathExpression' && !BUILTIN_BLOCKS.has(node.path.original)) {
98
- record(node.path.original, scope)
107
+ record(node.path.original, scope, guarded)
99
108
  }
100
109
  // {{helper arg1 arg2}} with args → it's a helper call.
101
110
  if (node.params?.length && node.path?.original && !BUILTIN_BLOCKS.has(node.path.original)) {
@@ -103,8 +112,8 @@ export function parseReferences(source) {
103
112
  }
104
113
  // Walk param paths too — they're variable refs.
105
114
  for (const param of node.params ?? []) {
106
- if (param?.type === 'PathExpression') record(param.original, scope)
107
- if (param?.type === 'SubExpression') visit(param, scope)
115
+ if (param?.type === 'PathExpression') record(param.original, scope, guarded)
116
+ if (param?.type === 'SubExpression') visit(param, scope, guarded)
108
117
  }
109
118
  break
110
119
  case 'SubExpression':
@@ -155,8 +164,16 @@ export function parseReferences(source) {
155
164
  if (param?.type === 'SubExpression') visit(param, scope)
156
165
  }
157
166
  }
158
- if (node.program) visit(node.program, inner)
159
- if (node.inverse) visit(node.inverse, scope)
167
+ // `if` and `unless` are what make the content inside them
168
+ // optional; `each` and `with` are not — they narrow scope, and
169
+ // a key read inside one is read whenever the block runs at all.
170
+ //
171
+ // The CONDITION itself stays required: `{{#if hero}}` reads
172
+ // `hero` unconditionally to decide, exactly as liquid's `case`
173
+ // subject does.
174
+ const guards = node.path?.original === 'if' || node.path?.original === 'unless'
175
+ if (node.program) visit(node.program, inner, guarded || guards)
176
+ if (node.inverse) visit(node.inverse, scope, guarded || guards)
160
177
  break
161
178
  }
162
179
  case 'PartialStatement':
@@ -200,6 +217,8 @@ export function parseReferences(source) {
200
217
  // params, already resolved above. Present and empty so every engine
201
218
  // returns the same shape and no caller has to branch on the engine.
202
219
  assigns: [],
220
+ // Read only behind `{{#if}}` / `{{#unless}}`, so absence is tolerated.
221
+ optional: Array.from(optional).sort(),
203
222
  helpers: Array.from(helpers).sort(),
204
223
  }
205
224
  }
package/src/roles.js CHANGED
@@ -22,27 +22,81 @@
22
22
  // whoever configures the site, and an agent's part in it is to say what it
23
23
  // cannot do and stop.
24
24
 
25
- // Capabilities follow `drive:<name>` to read and `drive:<name>:write` to
26
- // write. That convention is the whole mapping — it needs no list of endpoints
27
- // to stay correct as collections are added.
28
- const DRIVE = /^drive:([^:]+)(?::write)?$/
25
+ // What each capability MEANS, declared by whoever owns it.
26
+ //
27
+ // Core used to carry a regex for `drive:<name>[:write]` — one plugin's naming
28
+ // convention, hardcoded in the engine. It answered for drive and nothing else:
29
+ // `api:list` and `mcp:use` came back as bare strings, a plugin adding its own
30
+ // capability got no explanation at all, and the FOLDER behind a collection —
31
+ // the thing an agent actually needs to reason about the site — was knowledge
32
+ // core never had.
33
+ //
34
+ // A registry instead, the same shape as routes and schemas: the plugin that
35
+ // enforces a capability is the one that can say what it protects, where it
36
+ // lives and what it is for.
37
+ const capabilityMeanings = new Map()
38
+
39
+ // Declare one capability. Called by the plugin that enforces it.
40
+ //
41
+ // registerCapability('drive:documents', {
42
+ // plugin: 'drive',
43
+ // grants: 'read', // 'read' | 'write' | 'operate'
44
+ // resource: {
45
+ // kind: 'collection',
46
+ // name: 'documents',
47
+ // folder: 'documents', // where it is on disk
48
+ // summary: 'the words on the pages' // what it is FOR
49
+ // },
50
+ // })
51
+ export function registerCapability(capability, meaning = {}) {
52
+ if (!capability) return
53
+ capabilityMeanings.set(capability, { capability, ...meaning })
54
+ }
55
+
56
+ export function capabilityMeaning(capability) {
57
+ const known = capabilityMeanings.get(capability)
58
+ if (known) return known
59
+ // Nothing declared it. The drive convention is still recognised, so a
60
+ // deployment whose plugins predate the registry keeps its answer rather
61
+ // than reporting every collection as an unexplained string.
62
+ const m = /^drive:([^:]+?)(:write)?$/.exec(capability)
63
+ if (!m) return null
64
+ return {
65
+ capability,
66
+ plugin: 'drive',
67
+ grants: m[2] ? 'write' : 'read',
68
+ resource: { kind: 'collection', name: m[1] },
69
+ }
70
+ }
29
71
 
30
72
  // Split a capability list into what it can change and what it can only look
31
- // at. `readOnly` is the field that makes a refusal explainable, and it is more
32
- // useful than the capabilities it is derived from because it is already in the
33
- // vocabulary the person asking uses: collection names, not verbs.
73
+ // at, described rather than merely named.
74
+ //
75
+ // `readOnly` is the field that makes a refusal explainable, and it is more
76
+ // useful than the capabilities it comes from because it is already in the
77
+ // vocabulary the person asking uses — a collection, the folder it lives in and
78
+ // what that folder is for, not a verb.
34
79
  export function reachOf(capabilities = []) {
35
- const readable = new Set()
80
+ const readable = new Map()
36
81
  const writable = new Set()
82
+ const also = []
37
83
  for (const capability of capabilities ?? []) {
38
- const m = DRIVE.exec(capability)
39
- if (!m) continue
40
- readable.add(m[1])
41
- if (capability.endsWith(':write')) writable.add(m[1])
84
+ const meaning = capabilityMeaning(capability)
85
+ const resource = meaning?.resource
86
+ if (!resource?.name) { also.push(capability); continue }
87
+ readable.set(resource.name, {
88
+ name: resource.name,
89
+ ...(resource.folder ? { folder: resource.folder } : {}),
90
+ ...(resource.summary ? { summary: resource.summary } : {}),
91
+ ...(resource.kind && resource.kind !== 'collection' ? { kind: resource.kind } : {}),
92
+ })
93
+ if (meaning.grants === 'write') writable.add(resource.name)
42
94
  }
95
+ const byName = (a, b) => a.name.localeCompare(b.name)
43
96
  return {
44
- writable: [...writable].sort(),
45
- readOnly: [...readable].filter(name => !writable.has(name)).sort(),
97
+ writable: [...readable.values()].filter(r => writable.has(r.name)).sort(byName),
98
+ readOnly: [...readable.values()].filter(r => !writable.has(r.name)).sort(byName),
99
+ also: also.sort(),
46
100
  }
47
101
  }
48
102
 
@@ -64,32 +118,28 @@ export function actingRole(held = [], catalogue = {}) {
64
118
  return names.find(candidate => names.every(other => covers(candidate, other))) ?? null
65
119
  }
66
120
 
67
- // The roles this principal does NOT hold, and what each would add.
121
+ // Every role and what it may do, with the acting one marked.
68
122
  //
69
- // Named so an agent can say WHO to ask. Roles are not credentials — listing
70
- // them reveals that a `developers` role exists, which is exactly what makes a
71
- // handoff possible, and nothing about how to obtain it.
72
- export function otherRoles(held = [], catalogue = {}, summaries = {}) {
73
- const mine = new Set(held ?? [])
74
- const have = new Set((held ?? []).flatMap(name => catalogue[name] ?? []))
75
- return Object.entries(catalogue)
76
- .filter(([name]) => !mine.has(name))
77
- .map(([name, capabilities]) => {
78
- const adds = (capabilities ?? []).filter(capability => !have.has(capability))
79
- // A role that adds nothing this principal already has is noise in
80
- // a handoff — there is nobody to ask, because it can do no more.
81
- if (!adds.length) return null
82
- const reach = reachOf(adds)
83
- return {
84
- name,
85
- // Expressed as collections where the capabilities allow it,
86
- // because "layouts, styles, scripts" is what a person asking
87
- // for help can act on and `drive:layouts:write` is not.
88
- adds: reach.writable.length ? reach.writable : adds,
89
- ...(summaries[name] ? { summary: summaries[name] } : {}),
90
- }
91
- })
92
- .filter(Boolean)
123
+ // One list rather than two. A "roles you do not have" field cannot describe
124
+ // the site to whoever holds the widest one — an admin sees an empty array and
125
+ // concludes no other roles exist — and a reader comparing their own reach
126
+ // against someone else's needs both sides in the same shape anyway.
127
+ //
128
+ // Roles are not credentials. Naming them, and saying what each can reach, is
129
+ // what makes a handoff possible: it tells an agent who to ask. It reveals
130
+ // nothing about how to become one, and there is no way to ask for one.
131
+ export function rolesIn(catalogue = {}, { acting = null, summaries = {} } = {}) {
132
+ return Object.entries(catalogue).map(([name, capabilities]) => {
133
+ const { writable, readOnly, also } = reachOf(capabilities)
134
+ return {
135
+ name,
136
+ ...(name === acting ? { acting: true } : {}),
137
+ ...(summaries[name] ? { summary: summaries[name] } : {}),
138
+ writable,
139
+ readOnly,
140
+ ...(also.length ? { also } : {}),
141
+ }
142
+ })
93
143
  }
94
144
 
95
145
  // Everything a session should be able to say about its own authority.
@@ -105,18 +155,20 @@ export function describeAuthority({ capabilities, roles = [], catalogue = {}, su
105
155
  capabilities: null,
106
156
  writable: null,
107
157
  readOnly: null,
108
- otherRoles: [],
158
+ roles: [],
109
159
  }
110
160
  }
111
161
  const role = actingRole(roles, catalogue)
112
162
  const { writable, readOnly } = reachOf(capabilities)
113
163
  return {
114
164
  role,
115
- ...(roles?.length && !role ? { roles } : {}),
165
+ // Only when no single role covers the others: the acting authority is
166
+ // then the union, and naming one of them would be a lie.
167
+ ...(roles?.length && !role ? { heldRoles: roles } : {}),
116
168
  ...(summaries[role] ? { roleSummary: summaries[role] } : {}),
117
169
  writable,
118
170
  readOnly,
119
- otherRoles: otherRoles(roles, catalogue, summaries),
171
+ roles: rolesIn(catalogue, { acting: role, summaries }),
120
172
  }
121
173
  }
122
174