mikser-io 9.49.1 → 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 +37 -3
- 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);
|
|
@@ -214,6 +220,8 @@ function rowsToSets(handle, rows) {
|
|
|
214
220
|
closed: row.closed_at != null,
|
|
215
221
|
recordedAt: row.recorded_at ?? null,
|
|
216
222
|
recordedAs: row.recorded_as ?? null,
|
|
223
|
+
commitError: row.commit_error ?? null,
|
|
224
|
+
commitAttempts: row.commit_attempts ?? 0,
|
|
217
225
|
paths: paths.map(p => p.path),
|
|
218
226
|
deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
|
|
219
227
|
}
|
|
@@ -231,6 +239,8 @@ function memorySets(filter = () => true) {
|
|
|
231
239
|
closed: Boolean(set.closedAt),
|
|
232
240
|
recordedAt: set.recordedAt ?? null,
|
|
233
241
|
recordedAs: set.recordedAs ?? null,
|
|
242
|
+
commitError: set.commitError ?? null,
|
|
243
|
+
commitAttempts: set.commitAttempts ?? 0,
|
|
234
244
|
paths: [...set.paths.keys()],
|
|
235
245
|
deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
|
|
236
246
|
}))
|
|
@@ -365,13 +375,13 @@ export function markChangeSetsRecorded(ids = [], recordedAs = null) {
|
|
|
365
375
|
const at = Date.now()
|
|
366
376
|
for (const id of ids) {
|
|
367
377
|
const set = memory.get(id)
|
|
368
|
-
if (set) { set.recordedAt = at; set.recordedAs = recordedAs }
|
|
378
|
+
if (set) { set.recordedAt = at; set.recordedAs = recordedAs; set.commitError = null }
|
|
369
379
|
}
|
|
370
380
|
const handle = db()
|
|
371
381
|
if (!handle) return
|
|
372
382
|
try {
|
|
373
383
|
const stmt = handle.prepare(
|
|
374
|
-
'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as =
|
|
384
|
+
'UPDATE mikser_change_sets SET recorded_at = ?, recorded_as = ?, commit_error = NULL WHERE id = ?')
|
|
375
385
|
for (const id of ids) stmt.run(at, recordedAs, id)
|
|
376
386
|
} catch { /* memory still holds it */ }
|
|
377
387
|
}
|
|
@@ -382,6 +392,30 @@ export function clearChangeSets(ids = [], recordedAs = null) {
|
|
|
382
392
|
markChangeSetsRecorded(ids, recordedAs)
|
|
383
393
|
}
|
|
384
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
|
+
|
|
385
419
|
export function forgetAllChangeSets() {
|
|
386
420
|
memory.clear()
|
|
387
421
|
const handle = db()
|
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
|
+
}
|