mikser-io 9.49.1 → 9.50.1
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 +79 -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,19 @@ 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,
|
|
65
|
+
-- How the set finished. A claimed set had exactly two exits,
|
|
66
|
+
-- committed or failed, and a set whose changes cancel out qualifies
|
|
67
|
+
-- for neither: there is genuinely nothing to write, which is not an
|
|
68
|
+
-- error. Without a third outcome it was re-claimed every pass forever
|
|
69
|
+
-- and reported a null commit that looked like a fault.
|
|
70
|
+
outcome TEXT
|
|
59
71
|
);
|
|
60
72
|
CREATE INDEX IF NOT EXISTS idx_mikser_change_sets_created
|
|
61
73
|
ON mikser_change_sets (created_at DESC);
|
|
@@ -214,6 +226,9 @@ function rowsToSets(handle, rows) {
|
|
|
214
226
|
closed: row.closed_at != null,
|
|
215
227
|
recordedAt: row.recorded_at ?? null,
|
|
216
228
|
recordedAs: row.recorded_as ?? null,
|
|
229
|
+
commitError: row.commit_error ?? null,
|
|
230
|
+
commitAttempts: row.commit_attempts ?? 0,
|
|
231
|
+
outcome: row.outcome ?? null,
|
|
217
232
|
paths: paths.map(p => p.path),
|
|
218
233
|
deletions: paths.filter(p => p.operation === 'delete').map(p => p.path),
|
|
219
234
|
}
|
|
@@ -231,6 +246,9 @@ function memorySets(filter = () => true) {
|
|
|
231
246
|
closed: Boolean(set.closedAt),
|
|
232
247
|
recordedAt: set.recordedAt ?? null,
|
|
233
248
|
recordedAs: set.recordedAs ?? null,
|
|
249
|
+
commitError: set.commitError ?? null,
|
|
250
|
+
commitAttempts: set.commitAttempts ?? 0,
|
|
251
|
+
outcome: set.outcome ?? null,
|
|
234
252
|
paths: [...set.paths.keys()],
|
|
235
253
|
deletions: [...set.paths.entries()].filter(([, op]) => op === 'delete').map(([p]) => p),
|
|
236
254
|
}))
|
|
@@ -365,13 +383,20 @@ export function markChangeSetsRecorded(ids = [], recordedAs = null) {
|
|
|
365
383
|
const at = Date.now()
|
|
366
384
|
for (const id of ids) {
|
|
367
385
|
const set = memory.get(id)
|
|
368
|
-
if (set) {
|
|
386
|
+
if (set) {
|
|
387
|
+
set.recordedAt = at
|
|
388
|
+
set.recordedAs = recordedAs
|
|
389
|
+
set.commitError = null
|
|
390
|
+
set.outcome = 'committed'
|
|
391
|
+
}
|
|
369
392
|
}
|
|
370
393
|
const handle = db()
|
|
371
394
|
if (!handle) return
|
|
372
395
|
try {
|
|
373
396
|
const stmt = handle.prepare(
|
|
374
|
-
|
|
397
|
+
`UPDATE mikser_change_sets
|
|
398
|
+
SET recorded_at = ?, recorded_as = ?, commit_error = NULL, outcome = 'committed'
|
|
399
|
+
WHERE id = ?`)
|
|
375
400
|
for (const id of ids) stmt.run(at, recordedAs, id)
|
|
376
401
|
} catch { /* memory still holds it */ }
|
|
377
402
|
}
|
|
@@ -382,6 +407,57 @@ export function clearChangeSets(ids = [], recordedAs = null) {
|
|
|
382
407
|
markChangeSetsRecorded(ids, recordedAs)
|
|
383
408
|
}
|
|
384
409
|
|
|
410
|
+
// Record that a consumer tried and failed. The set stays pending — a failure
|
|
411
|
+
// is worth retrying, and a transient one usually succeeds next pass — but the
|
|
412
|
+
// reason is now visible instead of the set sitting at `committed: null` with
|
|
413
|
+
// nothing to say why.
|
|
414
|
+
export function markChangeSetFailed(id, error) {
|
|
415
|
+
const message = String(error?.stderr || error?.message || error || 'unknown error').slice(0, 500)
|
|
416
|
+
const set = memory.get(id)
|
|
417
|
+
if (set) {
|
|
418
|
+
set.commitError = message
|
|
419
|
+
set.commitAttempts = (set.commitAttempts ?? 0) + 1
|
|
420
|
+
}
|
|
421
|
+
const handle = db()
|
|
422
|
+
if (!handle) return
|
|
423
|
+
try {
|
|
424
|
+
handle.prepare(`
|
|
425
|
+
UPDATE mikser_change_sets
|
|
426
|
+
SET commit_error = ?, commit_attempts = commit_attempts + 1
|
|
427
|
+
WHERE id = ?
|
|
428
|
+
`).run(message, id)
|
|
429
|
+
} catch (err) {
|
|
430
|
+
reportChangeSetFailure(err)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Finish a set that produced no commit, because there was nothing to write.
|
|
435
|
+
//
|
|
436
|
+
// The third outcome. A set whose adds and removals cancel out — an undo of a
|
|
437
|
+
// create, a probe that added and then deleted its own files — leaves an empty
|
|
438
|
+
// diff, and git correctly makes no commit for it. It is not pending and it did
|
|
439
|
+
// not fail: it is DONE, and saying so is what stops it being re-claimed every
|
|
440
|
+
// pass forever while a column of nulls suggests a broken pipeline.
|
|
441
|
+
//
|
|
442
|
+
// `outcome` keeps the distinction that matters to undo: reverting a set that
|
|
443
|
+
// never produced a commit is not the same operation as reverting one that did.
|
|
444
|
+
export function markChangeSetSettled(id, outcome = 'empty') {
|
|
445
|
+
const at = Date.now()
|
|
446
|
+
const set = memory.get(id)
|
|
447
|
+
if (set) { set.recordedAt = at; set.recordedAs = null; set.outcome = outcome }
|
|
448
|
+
const handle = db()
|
|
449
|
+
if (!handle) return
|
|
450
|
+
try {
|
|
451
|
+
handle.prepare(`
|
|
452
|
+
UPDATE mikser_change_sets
|
|
453
|
+
SET recorded_at = COALESCE(recorded_at, ?), outcome = COALESCE(outcome, ?)
|
|
454
|
+
WHERE id = ?
|
|
455
|
+
`).run(at, outcome, id)
|
|
456
|
+
} catch (err) {
|
|
457
|
+
reportChangeSetFailure(err)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
385
461
|
export function forgetAllChangeSets() {
|
|
386
462
|
memory.clear()
|
|
387
463
|
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
|
+
}
|