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.
- package/docs/api-reference.md +66 -1
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/inventory.js +93 -0
- package/src/plugins/api.js +29 -0
- package/src/plugins/render/hbs.js +30 -11
- package/src/roles.js +94 -42
package/docs/api-reference.md
CHANGED
|
@@ -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
|
-
| `
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|
package/src/inventory.js
ADDED
|
@@ -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
|
+
}
|
package/src/plugins/api.js
CHANGED
|
@@ -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)
|
|
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
|
|
159
|
-
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// to
|
|
28
|
-
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
|
80
|
+
const readable = new Map()
|
|
36
81
|
const writable = new Set()
|
|
82
|
+
const also = []
|
|
37
83
|
for (const capability of capabilities ?? []) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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: [...
|
|
45
|
-
readOnly: [...readable].filter(
|
|
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
|
-
//
|
|
121
|
+
// Every role and what it may do, with the acting one marked.
|
|
68
122
|
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
+
roles: rolesIn(catalogue, { acting: role, summaries }),
|
|
120
172
|
}
|
|
121
173
|
}
|
|
122
174
|
|