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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.49.1",
3
+ "version": "9.50.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
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 = ? WHERE id = ?')
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
+ }