mikser-io 9.49.0 → 9.50.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 +29 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/changeset.js +67 -6
- package/src/database/index.js +79 -1
- package/src/roles.js +145 -0
package/docs/api-reference.md
CHANGED
|
@@ -844,6 +844,35 @@ 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
|
+
## Roles
|
|
848
|
+
|
|
849
|
+
Enforcement needs only the flat capability list. Explaining a refusal needs the
|
|
850
|
+
role — and without it, an admin token and a site with no roles configured are
|
|
851
|
+
indistinguishable from inside a session.
|
|
852
|
+
|
|
853
|
+
| Export | Does |
|
|
854
|
+
| --- | --- |
|
|
855
|
+
| `describeAuthority({ capabilities, roles, catalogue, summaries })` | everything a session can say about its own authority |
|
|
856
|
+
| `reachOf(capabilities)` | `{ writable, readOnly }` as collection names |
|
|
857
|
+
| `actingRole(held, catalogue)` | which role is in force |
|
|
858
|
+
| `otherRoles(held, catalogue, summaries)` | who to ask, and what they add |
|
|
859
|
+
| `explainRefusal({ capability, role, target, catalogue, summaries })` | the sentence an agent repeats |
|
|
860
|
+
|
|
861
|
+
`readOnly` is the field that makes a refusal explainable, and it is more useful
|
|
862
|
+
than the capabilities it comes from because it is already in the vocabulary the
|
|
863
|
+
person asking uses.
|
|
864
|
+
|
|
865
|
+
A principal can hold several roles. `actingRole` returns the one whose
|
|
866
|
+
capabilities cover the others — roles are normally written as widening tiers —
|
|
867
|
+
and `null` when none dominates, because the acting authority genuinely is the
|
|
868
|
+
union and naming half of it would be a lie.
|
|
869
|
+
|
|
870
|
+
> **Informational, permanently.** Naming the role that could do something is
|
|
871
|
+
> what makes a handoff possible. There is no way to request one and none should
|
|
872
|
+
> be added: a role is a decision about a person, taken by whoever configures the
|
|
873
|
+
> site, and an agent's part is to say what it cannot do and stop. `explainRefusal`
|
|
874
|
+
> deliberately suggests no retry, escalation or workaround — a test asserts it.
|
|
875
|
+
|
|
847
876
|
## Auth
|
|
848
877
|
|
|
849
878
|
Building a token-gated or loopback-only route.
|
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { default as runtime } from './src/runtime.js'
|
|
|
2
2
|
export * as constants from './src/constants.js'
|
|
3
3
|
export * from './src/utils.js'
|
|
4
4
|
export * from './src/auth.js'
|
|
5
|
+
export * from './src/roles.js'
|
|
5
6
|
export * from './src/report.js'
|
|
6
7
|
// The diagnostics behind --explain. Exported so a transport — the MCP tool
|
|
7
8
|
// surface, the api plugin's routes — can serve the same structured report the
|
package/package.json
CHANGED
package/src/changeset.js
CHANGED
|
@@ -55,7 +55,13 @@ registerSchema('change_sets', `
|
|
|
55
55
|
-- but there is nothing to revert FROM, which is a different answer
|
|
56
56
|
-- from "no such change set".
|
|
57
57
|
recorded_at INTEGER,
|
|
58
|
-
recorded_as TEXT
|
|
58
|
+
recorded_as TEXT,
|
|
59
|
+
-- Why a consumer could not record this set. A set that failed is not
|
|
60
|
+
-- a set that is waiting: without somewhere to put the reason, a
|
|
61
|
+
-- permanent failure and a pending commit look identical, and both
|
|
62
|
+
-- read as a null commit forever.
|
|
63
|
+
commit_error TEXT,
|
|
64
|
+
commit_attempts INTEGER NOT NULL DEFAULT 0
|
|
59
65
|
);
|
|
60
66
|
CREATE INDEX IF NOT EXISTS idx_mikser_change_sets_created
|
|
61
67
|
ON mikser_change_sets (created_at DESC);
|
|
@@ -185,6 +191,20 @@ function prune(handle) {
|
|
|
185
191
|
`).run(KEEP_SETS)
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
// Said once per process, not once per write: a broken log is one condition,
|
|
195
|
+
// and repeating it per write buries the builds that follow it.
|
|
196
|
+
let failureReported = false
|
|
197
|
+
function reportChangeSetFailure(err) {
|
|
198
|
+
if (failureReported) return
|
|
199
|
+
failureReported = true
|
|
200
|
+
const message = 'The change-set log could not be written (%s). Writes still land on disk, but they cannot be '
|
|
201
|
+
+ 'listed or undone until this is fixed.'
|
|
202
|
+
try {
|
|
203
|
+
runtime.engine?.logger?.error(message, err.message)
|
|
204
|
+
} catch { /* no logger yet — the console is what is left */ }
|
|
205
|
+
if (!runtime.engine?.logger) console.error(message.replace('%s', err.message))
|
|
206
|
+
}
|
|
207
|
+
|
|
188
208
|
function rowsToSets(handle, rows) {
|
|
189
209
|
const stmt = handle.prepare(
|
|
190
210
|
'SELECT path, operation FROM mikser_change_set_paths WHERE change_set = ? ORDER BY path')
|
|
@@ -200,6 +220,8 @@ function rowsToSets(handle, rows) {
|
|
|
200
220
|
closed: row.closed_at != null,
|
|
201
221
|
recordedAt: row.recorded_at ?? null,
|
|
202
222
|
recordedAs: row.recorded_as ?? null,
|
|
223
|
+
commitError: row.commit_error ?? null,
|
|
224
|
+
commitAttempts: row.commit_attempts ?? 0,
|
|
203
225
|
paths: paths.map(p => p.path),
|
|
204
226
|
deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
|
|
205
227
|
}
|
|
@@ -217,6 +239,8 @@ function memorySets(filter = () => true) {
|
|
|
217
239
|
closed: Boolean(set.closedAt),
|
|
218
240
|
recordedAt: set.recordedAt ?? null,
|
|
219
241
|
recordedAs: set.recordedAs ?? null,
|
|
242
|
+
commitError: set.commitError ?? null,
|
|
243
|
+
commitAttempts: set.commitAttempts ?? 0,
|
|
220
244
|
paths: [...set.paths.keys()],
|
|
221
245
|
deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
|
|
222
246
|
}))
|
|
@@ -283,7 +307,16 @@ export function recordChangeSetWrite({
|
|
|
283
307
|
|
|
284
308
|
const handle = db()
|
|
285
309
|
if (handle) {
|
|
286
|
-
try {
|
|
310
|
+
try {
|
|
311
|
+
persist(handle, set, rel, operation, entityId)
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// Loud. A swallowed failure here is invisible in exactly the way
|
|
314
|
+
// that matters: the write succeeds, an id comes back, and the log
|
|
315
|
+
// it points at silently never gains a row — which is how a stale
|
|
316
|
+
// column shape turned the whole feature into a no-op that looked
|
|
317
|
+
// like it was working.
|
|
318
|
+
reportChangeSetFailure(err)
|
|
319
|
+
}
|
|
287
320
|
}
|
|
288
321
|
return set.id
|
|
289
322
|
}
|
|
@@ -298,7 +331,9 @@ export function pendingChangeSets() {
|
|
|
298
331
|
SELECT * FROM mikser_change_sets WHERE recorded_at IS NULL ORDER BY created_at ASC
|
|
299
332
|
`).all()
|
|
300
333
|
return rowsToSets(handle, rows).filter(set => set.paths.length)
|
|
301
|
-
} catch {
|
|
334
|
+
} catch (err) {
|
|
335
|
+
reportChangeSetFailure(err)
|
|
336
|
+
}
|
|
302
337
|
}
|
|
303
338
|
return memorySets(set => !set.recordedAt).sort((a, b) => a.startedAt - b.startedAt)
|
|
304
339
|
}
|
|
@@ -313,7 +348,9 @@ export function listChangeSets({ limit = 20 } = {}) {
|
|
|
313
348
|
SELECT * FROM mikser_change_sets ORDER BY created_at DESC LIMIT ?
|
|
314
349
|
`).all(Math.max(1, Math.min(limit, 200)))
|
|
315
350
|
return rowsToSets(handle, rows)
|
|
316
|
-
} catch {
|
|
351
|
+
} catch (err) {
|
|
352
|
+
reportChangeSetFailure(err)
|
|
353
|
+
}
|
|
317
354
|
}
|
|
318
355
|
return memorySets().sort((a, b) => b.startedAt - a.startedAt).slice(0, limit)
|
|
319
356
|
}
|
|
@@ -338,13 +375,13 @@ export function markChangeSetsRecorded(ids = [], recordedAs = null) {
|
|
|
338
375
|
const at = Date.now()
|
|
339
376
|
for (const id of ids) {
|
|
340
377
|
const set = memory.get(id)
|
|
341
|
-
if (set) { set.recordedAt = at; set.recordedAs = recordedAs }
|
|
378
|
+
if (set) { set.recordedAt = at; set.recordedAs = recordedAs; set.commitError = null }
|
|
342
379
|
}
|
|
343
380
|
const handle = db()
|
|
344
381
|
if (!handle) return
|
|
345
382
|
try {
|
|
346
383
|
const stmt = handle.prepare(
|
|
347
|
-
'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as =
|
|
384
|
+
'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ?, commit_error = NULL WHERE id = ?')
|
|
348
385
|
for (const id of ids) stmt.run(at, recordedAs, id)
|
|
349
386
|
} catch { /* memory still holds it */ }
|
|
350
387
|
}
|
|
@@ -355,6 +392,30 @@ export function clearChangeSets(ids = [], recordedAs = null) {
|
|
|
355
392
|
markChangeSetsRecorded(ids, recordedAs)
|
|
356
393
|
}
|
|
357
394
|
|
|
395
|
+
// Record that a consumer tried and failed. The set stays pending — a failure
|
|
396
|
+
// is worth retrying, and a transient one usually succeeds next pass — but the
|
|
397
|
+
// reason is now visible instead of the set sitting at `committed: null` with
|
|
398
|
+
// nothing to say why.
|
|
399
|
+
export function markChangeSetFailed(id, error) {
|
|
400
|
+
const message = String(error?.stderr || error?.message || error || 'unknown error').slice(0, 500)
|
|
401
|
+
const set = memory.get(id)
|
|
402
|
+
if (set) {
|
|
403
|
+
set.commitError = message
|
|
404
|
+
set.commitAttempts = (set.commitAttempts ?? 0) + 1
|
|
405
|
+
}
|
|
406
|
+
const handle = db()
|
|
407
|
+
if (!handle) return
|
|
408
|
+
try {
|
|
409
|
+
handle.prepare(`
|
|
410
|
+
UPDATE mikser_change_sets
|
|
411
|
+
SET commit_error = ?, commit_attempts = commit_attempts + 1
|
|
412
|
+
WHERE id = ?
|
|
413
|
+
`).run(message, id)
|
|
414
|
+
} catch (err) {
|
|
415
|
+
reportChangeSetFailure(err)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
358
419
|
export function forgetAllChangeSets() {
|
|
359
420
|
memory.clear()
|
|
360
421
|
const handle = db()
|
package/src/database/index.js
CHANGED
|
@@ -104,6 +104,78 @@ let db = null
|
|
|
104
104
|
// duplicate detection. Same name twice = the later registration wins
|
|
105
105
|
// (with a warning). Convention: `<owner>` matching the table prefix
|
|
106
106
|
// (`catalog`, `manifest`, `vector`, etc.).
|
|
107
|
+
// Column names a CREATE TABLE body declares, in order.
|
|
108
|
+
//
|
|
109
|
+
// Only the leading identifier of each top-level comma-separated clause, and
|
|
110
|
+
// only when it is not a table constraint. Good enough for the schemas this
|
|
111
|
+
// engine registers, and deliberately not a SQL parser.
|
|
112
|
+
function columnsFrom(sqlScript, table) {
|
|
113
|
+
const re = new RegExp(
|
|
114
|
+
`CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?[\`"'\\[]?${table}[\`"'\\]]?\\s*\\(`, 'i')
|
|
115
|
+
const m = re.exec(sqlScript)
|
|
116
|
+
if (!m) return []
|
|
117
|
+
// Walk to the matching close paren so nested types and CHECK(...) do not
|
|
118
|
+
// end the body early.
|
|
119
|
+
let depth = 1
|
|
120
|
+
let i = m.index + m[0].length
|
|
121
|
+
const start = i
|
|
122
|
+
for (; i < sqlScript.length && depth > 0; i++) {
|
|
123
|
+
if (sqlScript[i] === '(') depth++
|
|
124
|
+
else if (sqlScript[i] === ')') depth--
|
|
125
|
+
}
|
|
126
|
+
const body = sqlScript.slice(start, i - 1)
|
|
127
|
+
|
|
128
|
+
const clauses = []
|
|
129
|
+
let current = ''
|
|
130
|
+
depth = 0
|
|
131
|
+
for (const ch of body) {
|
|
132
|
+
if (ch === '(') depth++
|
|
133
|
+
else if (ch === ')') depth--
|
|
134
|
+
if (ch === ',' && depth === 0) { clauses.push(current); current = '' } else current += ch
|
|
135
|
+
}
|
|
136
|
+
clauses.push(current)
|
|
137
|
+
|
|
138
|
+
const CONSTRAINTS = new Set(['primary', 'unique', 'foreign', 'check', 'constraint'])
|
|
139
|
+
return clauses
|
|
140
|
+
.map(clause => clause.replace(/--[^\n]*/g, '').trim())
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map(clause => clause.split(/\s+/)[0].replace(/["`\[\]]/g, ''))
|
|
143
|
+
.filter(name => name && !CONSTRAINTS.has(name.toLowerCase()))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Add columns a DURABLE table has grown since it was created.
|
|
147
|
+
//
|
|
148
|
+
// `CREATE TABLE IF NOT EXISTS` does nothing to a table that already exists, so
|
|
149
|
+
// re-applying a schema never adds a column. For a cache table that is
|
|
150
|
+
// invisible: the version bump wipes and recreates it. A durable table is
|
|
151
|
+
// exactly the one that SURVIVES the wipe, so it is the only kind that can go
|
|
152
|
+
// stale — and it goes stale silently, with every insert naming the new column
|
|
153
|
+
// failing against a table that still has the old shape.
|
|
154
|
+
function migrateDurableColumns(handle, schemas, logger) {
|
|
155
|
+
for (const value of schemas?.values?.() ?? []) {
|
|
156
|
+
const { sql, durable } = schemaEntry(value)
|
|
157
|
+
if (!durable) continue
|
|
158
|
+
for (const table of tableNamesFrom(sql)) {
|
|
159
|
+
let existing
|
|
160
|
+
try {
|
|
161
|
+
existing = new Set(handle.prepare(`PRAGMA table_info("${table}")`).all().map(c => c.name))
|
|
162
|
+
} catch { continue }
|
|
163
|
+
if (!existing.size) continue
|
|
164
|
+
for (const column of columnsFrom(sql, table)) {
|
|
165
|
+
if (existing.has(column)) continue
|
|
166
|
+
// Only ever ADD. Dropping or retyping a column in a durable
|
|
167
|
+
// table would discard data the whole flag exists to keep.
|
|
168
|
+
try {
|
|
169
|
+
handle.exec(`ALTER TABLE "${table}" ADD COLUMN "${column}"`)
|
|
170
|
+
logger?.info('Durable table %s gained column %s', table, column)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
logger?.warn('Could not add column %s to %s: %s', column, table, err.message)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
107
179
|
// Table names a schema script creates. Used to decide what a cache wipe
|
|
108
180
|
// must leave alone — the registry knows schema NAMES, and the wipe works in
|
|
109
181
|
// tables.
|
|
@@ -498,7 +570,8 @@ export function createSqliteDatabase({
|
|
|
498
570
|
}
|
|
499
571
|
|
|
500
572
|
// Apply each subsystem's registered schema script. Idempotent
|
|
501
|
-
// CREATE statements mean replay-safe across opens
|
|
573
|
+
// CREATE statements mean replay-safe across opens — but only for
|
|
574
|
+
// tables they can recreate; see migrateDurableColumns below.
|
|
502
575
|
for (const [name, value] of schemas) {
|
|
503
576
|
const { sql: sqlScript } = schemaEntry(value)
|
|
504
577
|
try {
|
|
@@ -511,6 +584,11 @@ export function createSqliteDatabase({
|
|
|
511
584
|
throw new Error(`Schema "${name}" failed to apply: ${err.message}`)
|
|
512
585
|
}
|
|
513
586
|
}
|
|
587
|
+
|
|
588
|
+
// A durable table survives the wipe, so re-applying its CREATE is not
|
|
589
|
+
// enough to give it a column the schema has grown. Reconciled here,
|
|
590
|
+
// additively.
|
|
591
|
+
migrateDurableColumns(handle, schemas, logger)
|
|
514
592
|
}
|
|
515
593
|
|
|
516
594
|
function close() {
|
package/src/roles.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// What a principal may do, in words rather than in capability strings.
|
|
2
|
+
//
|
|
3
|
+
// Enforcement only ever needs the flat list: does this credential carry
|
|
4
|
+
// `drive:layouts:write`, yes or no. That is enough to refuse a request and not
|
|
5
|
+
// nearly enough to EXPLAIN one. A session holding eighteen capabilities cannot
|
|
6
|
+
// tell whether those eighteen are a role called admin, whether narrower roles
|
|
7
|
+
// exist, or which one it is acting as — so an admin token and a site with no
|
|
8
|
+
// roles configured look exactly the same from inside.
|
|
9
|
+
//
|
|
10
|
+
// The difference shows up in what an agent says when it is stopped:
|
|
11
|
+
//
|
|
12
|
+
// "I got a 403 writing to styles/tokens/buttons.css."
|
|
13
|
+
// "I'm connected as editor, which does not include drive:styles:write.
|
|
14
|
+
// That file is the design system — this needs whoever built the site."
|
|
15
|
+
//
|
|
16
|
+
// The first invites working around the refusal. The second is a sentence the
|
|
17
|
+
// end user can forward to the person who can actually do it.
|
|
18
|
+
//
|
|
19
|
+
// INFORMATIONAL, deliberately and permanently. Naming the role that could do
|
|
20
|
+
// something is how a handoff is made possible; there is no way to ask for one
|
|
21
|
+
// and none should be added. A role is a decision about a person, taken by
|
|
22
|
+
// whoever configures the site, and an agent's part in it is to say what it
|
|
23
|
+
// cannot do and stop.
|
|
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)?$/
|
|
29
|
+
|
|
30
|
+
// 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.
|
|
34
|
+
export function reachOf(capabilities = []) {
|
|
35
|
+
const readable = new Set()
|
|
36
|
+
const writable = new Set()
|
|
37
|
+
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])
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
writable: [...writable].sort(),
|
|
45
|
+
readOnly: [...readable].filter(name => !writable.has(name)).sort(),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Which role is in force.
|
|
50
|
+
//
|
|
51
|
+
// A principal can hold several. Naming one of them anyway would be a lie, so
|
|
52
|
+
// the answer is the role whose capabilities cover every other role held —
|
|
53
|
+
// there usually is one, because roles are written as widening tiers. When none
|
|
54
|
+
// dominates, the acting authority genuinely is the union and `role` is null
|
|
55
|
+
// with `roles` naming the parts.
|
|
56
|
+
export function actingRole(held = [], catalogue = {}) {
|
|
57
|
+
const names = (held ?? []).filter(name => catalogue[name])
|
|
58
|
+
if (!names.length) return null
|
|
59
|
+
if (names.length === 1) return names[0]
|
|
60
|
+
const covers = (a, b) => {
|
|
61
|
+
const set = new Set(catalogue[a] ?? [])
|
|
62
|
+
return (catalogue[b] ?? []).every(capability => set.has(capability))
|
|
63
|
+
}
|
|
64
|
+
return names.find(candidate => names.every(other => covers(candidate, other))) ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The roles this principal does NOT hold, and what each would add.
|
|
68
|
+
//
|
|
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)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Everything a session should be able to say about its own authority.
|
|
96
|
+
export function describeAuthority({ capabilities, roles = [], catalogue = {}, summaries = {} } = {}) {
|
|
97
|
+
// No capability map configured at all: the credential is not
|
|
98
|
+
// capability-scoped and the endpoint's own operations are the only limit.
|
|
99
|
+
// Reporting a role here would invent one.
|
|
100
|
+
if (capabilities == null) {
|
|
101
|
+
return {
|
|
102
|
+
role: null,
|
|
103
|
+
roleSummary: 'This site has no roles configured, so this credential is limited only by what the '
|
|
104
|
+
+ 'endpoint itself allows.',
|
|
105
|
+
capabilities: null,
|
|
106
|
+
writable: null,
|
|
107
|
+
readOnly: null,
|
|
108
|
+
otherRoles: [],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const role = actingRole(roles, catalogue)
|
|
112
|
+
const { writable, readOnly } = reachOf(capabilities)
|
|
113
|
+
return {
|
|
114
|
+
role,
|
|
115
|
+
...(roles?.length && !role ? { roles } : {}),
|
|
116
|
+
...(summaries[role] ? { roleSummary: summaries[role] } : {}),
|
|
117
|
+
writable,
|
|
118
|
+
readOnly,
|
|
119
|
+
otherRoles: otherRoles(roles, catalogue, summaries),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The sentence an agent should repeat when a role stops it.
|
|
124
|
+
//
|
|
125
|
+
// Names the role, the capability it lacks and who has it, in that order,
|
|
126
|
+
// because that is the order the reader needs them: what I am, what is missing,
|
|
127
|
+
// who to ask. Deliberately without a suggestion to retry, escalate or work
|
|
128
|
+
// around it — the correct next step is a person, not another call.
|
|
129
|
+
export function explainRefusal({ capability, role, target, catalogue = {}, summaries = {} } = {}) {
|
|
130
|
+
const holders = Object.entries(catalogue)
|
|
131
|
+
.filter(([, capabilities]) => (capabilities ?? []).includes(capability))
|
|
132
|
+
.map(([name]) => name)
|
|
133
|
+
// Trim the summary's own full stop: it is a sentence in its own right and
|
|
134
|
+
// reads as a typo when a second one lands beside it.
|
|
135
|
+
const summary = summaries[holders[0]]?.replace(/\.\s*$/, '')
|
|
136
|
+
const who = holders.length
|
|
137
|
+
? `The ${holders.join(' or ')} role carries it${summary ? ` — ${summary}` : ''}.`
|
|
138
|
+
: 'No configured role carries it.'
|
|
139
|
+
return [
|
|
140
|
+
role ? `Connected as ${role}, which does not include ${capability}.` : `This credential lacks ${capability}.`,
|
|
141
|
+
target ? `That is what writing to ${target} needs.` : null,
|
|
142
|
+
who,
|
|
143
|
+
'Ask whoever set the site up; this is not something to work around.',
|
|
144
|
+
].filter(Boolean).join(' ')
|
|
145
|
+
}
|