mikser-io 9.29.0 → 9.30.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.
@@ -20,6 +20,8 @@ engine source, the entry point is missing and belongs on this page.
20
20
  | Which layout claimed this document, and why that one? | [`layouts.inspect()`](#layoutsinspect) |
21
21
  | Why does this page's output look stale? | [`runtime.manifest`](#runtimemanifest) |
22
22
  | Two files seem to fight over one output | [`--explain`](#--explain-entity), [`--verify`](#--verify) |
23
+ | Which source file produced this built output? | [`runtime.manifest`](#runtimemanifest), `mikser_which` |
24
+ | What would break if I changed this file? | [`runtime.manifest`](#runtimemanifest) `affectedBy` |
23
25
  | Did my schema validate anything at all? | [`schemas.names()`](#schemasnames--schemaslookup) |
24
26
 
25
27
  ## Command line
@@ -297,11 +299,16 @@ an SDK, or an agent speaking MCP actually has.
297
299
 
298
300
  **MCP** — `mikser_explain`, `mikser_build_report`, `mikser_verify`,
299
301
  alongside the existing `mikser_refs_*`, `mikser_layouts_inspect` and the
300
- `mikser://logs/recent` resource. Two more answer the questions a shell
302
+ `mikser://logs/recent` resource. Four more answer the questions a shell
301
303
  would otherwise be needed for: `mikser_search` finds a string across
302
- entity meta and source files in one call, and `mikser_read_output` reads
303
- the bytes currently on disk for a destination — which is a different
304
- question from what the catalog or the manifest says should be there.
304
+ entity meta, source files and — with `in: ["output"]` — the built files,
305
+ reporting occurrences per destination; `mikser_read_output` reads the
306
+ bytes currently on disk for a destination, which is a different question
307
+ from what the catalog or the manifest says should be there;
308
+ `mikser_which` goes the other way, from a built destination back to the
309
+ source that produced it and the line that defines a given selector; and
310
+ `mikser_update_entity({ dryRun: true })` reports the blast radius of an
311
+ edit before making it.
305
312
 
306
313
  **REST** — on the `api` plugin, gated on their own `diagnostics`
307
314
  operation:
@@ -430,6 +437,8 @@ What was rendered and whether it needs redoing.
430
437
  | `skipDecision(entity, …)` | `{ skip, reason }` — the same reason `--json` reports |
431
438
  | `recordedHashes()` | the dep-hashes dependents last saw |
432
439
  | `queryAffected(mutated)` | which query-dependent snapshots this mutation hits |
440
+ | `snapshotsAt(destination)` | every snapshot claiming a destination — the reverse of `snapshotsFor`, and the way back from a built file to what produced it |
441
+ | `affectedBy(entity)` | which destinations would re-render if this entity changed, each with the same `reason` the build report uses |
433
442
  | `verify({outputFolder})` | `{ verdict, missing, mismatched, unverifiable, orphaned, collisions }` — what `--verify` reports; pure, no mutations |
434
443
  | `collisions()` | destinations claimed by more than one entity, with the ids claiming each |
435
444
  | `writerOf(destination, outputHash)` | which of several claimants wrote the bytes now on disk, when the hashes can tell them apart |
@@ -440,6 +449,14 @@ destinations and a caller asking "what happened to this?" does not know
440
449
  them in advance — which is exactly the position you are in when a page
441
450
  did not change and you want to know why.
442
451
 
452
+ `affectedBy(entity)` answers the same question one step earlier: *before*
453
+ editing a shared file, which outputs does this reach? It runs the real
454
+ `skipDecision` against each candidate rather than reimplementing the
455
+ rule, so the preview and the cycle it previews cannot disagree. What it
456
+ cannot model is how the entity's own frontmatter would change — that is
457
+ parsed during import, so an edit that moves `meta.layout` moves the
458
+ destination too, and this does not see it.
459
+
443
460
  ### `layouts.inspect()`
444
461
 
445
462
  Exposed by `mikser-io-layouts` at `runtime.options.layouts.inspect(id)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.29.0",
3
+ "version": "9.30.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/auth.js CHANGED
@@ -112,6 +112,11 @@ export function reachabilityOf({ auth, token, allowRemote } = {}) {
112
112
  // loopback, or allowRemote → allow
113
113
  // otherwise → 403
114
114
  //
115
+ // A verifier may refine the "presented, invalid" case through an optional
116
+ // `rejectionFor(req)` returning `{ status, code, description }` — see the
117
+ // call site. It can only narrow a denial that already happened; there is no
118
+ // return value from it that turns a rejection into an acceptance.
119
+ //
115
120
  // `trustLoopback: true` restores the older mikser behaviour where a
116
121
  // token-gated endpoint stayed open to localhost. It exists so the api and
117
122
  // mcp plugins can keep their documented semantics for a plain `token:`
@@ -123,10 +128,30 @@ export async function authorize(req, verifier, { allowRemote = false, trustLoopb
123
128
  const result = await verifier.verify(req)
124
129
  if (result) return { ok: true, principal: result }
125
130
  if (result === false) {
126
- return { ok: false, status: 401, reason: 'invalid',
127
- error: 'Invalid credential' }
131
+ // A rejected credential is not one thing. An EXPIRED token means
132
+ // "exchange your refresh token and retry" — a client does that
133
+ // silently. A token whose subject lacks the capability means
134
+ // "refreshing will not help", and a client that refreshes on it
135
+ // loops. Told apart only by the verifier, which is the only thing
136
+ // that looked at the credential, so it gets to refine the answer.
137
+ //
138
+ // Absent (every verifier before this existed), the answer is
139
+ // today's: 401 invalid_token, which is right for the common case
140
+ // and is what a client needs in order to refresh at all.
141
+ const refined = verifier.rejectionFor?.(req)
142
+ return {
143
+ ok: false,
144
+ status: refined?.status ?? 401,
145
+ reason: 'invalid',
146
+ code: refined?.code ?? 'invalid_token',
147
+ description: refined?.description,
148
+ error: refined?.description ?? 'Invalid credential',
149
+ }
128
150
  }
129
- // Nothing presented.
151
+ // Nothing presented. No `code`: RFC 6750 §3.1 says a challenge to a
152
+ // request that carried no credential omits `error` entirely, and the
153
+ // omission is the signal — it is how a client tells "you have never
154
+ // authenticated here" from "the token you hold went stale".
130
155
  if (trustLoopback && local) return { ok: true, principal: { subject: 'loopback' } }
131
156
  return { ok: false, status: 401, reason: 'missing',
132
157
  error: 'Authentication required' }
@@ -153,7 +178,12 @@ export function requireAuth(verifier, options = {}) {
153
178
  req.principal = outcome.principal
154
179
  return next()
155
180
  }
156
- if (outcome.status === 401) verifier?.challenge?.(req, res)
181
+ // 403 carries a challenge too: RFC 6750 §3.1 puts insufficient_scope
182
+ // there, and a client that only reads the header on a 401 is exactly
183
+ // the client that cannot tell the two apart.
184
+ if (outcome.status === 401 || outcome.status === 403) {
185
+ verifier?.challenge?.(req, res, outcome)
186
+ }
157
187
  res.status(outcome.status).json({ error: outcome.error })
158
188
  }
159
189
  }
@@ -202,12 +232,24 @@ export function anyOf(...verifiers) {
202
232
  return rejected ? false : null
203
233
  },
204
234
 
235
+ // Whichever member actually judged the credential gets to say why it
236
+ // failed. Without forwarding this, composing a static token with an
237
+ // OAuth verifier silently downgrades every expiry to a bare 401 and
238
+ // the refresh signal is lost precisely on the surfaces that have one.
239
+ rejectionFor(req) {
240
+ for (const verifier of list) {
241
+ const refined = verifier.rejectionFor?.(req)
242
+ if (refined) return refined
243
+ }
244
+ return undefined
245
+ },
246
+
205
247
  // Challenge with the verifier that can actually be satisfied
206
248
  // interactively — pointing a browser at "Bearer" when the real
207
249
  // option is OAuth discovery helps nobody.
208
- challenge(req, res) {
250
+ challenge(req, res, outcome) {
209
251
  const chooser = discovering ?? list.find(v => v.challenge)
210
- chooser?.challenge?.(req, res)
252
+ chooser?.challenge?.(req, res, outcome)
211
253
  },
212
254
  }
213
255
  }
package/src/manifest.js CHANGED
@@ -56,7 +56,7 @@ import { useLogger } from './engine.js'
56
56
  import { onLoaded, onFinalize } from './lifecycle.js'
57
57
  import { useJournal } from './journal.js'
58
58
  import { OPERATION } from './constants.js'
59
- import { extractRefs, inputHashOf, inputPartsOf, diffInputParts } from './utils.js'
59
+ import { extractRefs, inputHashOf, inputPartsOf, diffInputParts, lookupKeys } from './utils.js'
60
60
  import { filterKey } from './track.js'
61
61
  import { findById } from './catalog.js'
62
62
  import { useDatabase, registerSchema } from './database/index.js'
@@ -295,6 +295,10 @@ export function createManifest(db) {
295
295
  const stmtSelectByDestination = db.prepare(`
296
296
  SELECT id FROM mikser_snapshots WHERE destination = ?
297
297
  `)
298
+ const stmtLookupByDestination = db.prepare(`
299
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
300
+ FROM mikser_snapshots WHERE destination = ?
301
+ `)
298
302
  const stmtDeleteByDestination = db.prepare(`
299
303
  DELETE FROM mikser_snapshots WHERE destination = ?
300
304
  `)
@@ -392,6 +396,28 @@ export function createManifest(db) {
392
396
  WHERE refClosure LIKE '%"kind":"query"%'
393
397
  `)
394
398
 
399
+ // Snapshots holding a non-query edge that names any of the given keys,
400
+ // by the name asked for OR by the entity it bound to. Both, for the same
401
+ // reason skipDecision reads both — a name survives a rename only through
402
+ // the binding, and a forward edge to a page that does not exist yet has
403
+ // only the name.
404
+ //
405
+ // A prefilter, not the answer: it narrows a corpus-wide walk to the
406
+ // handful of snapshots that could possibly care, and the real
407
+ // skipDecision then judges each one.
408
+ const edgeCandidates = (keys) => {
409
+ if (!keys.length) return []
410
+ const holes = keys.map(() => '?').join(',')
411
+ return db.prepare(`
412
+ SELECT DISTINCT s.id AS id, s.destination AS destination
413
+ FROM mikser_snapshots s, json_each(s.refClosure) j
414
+ WHERE s.refClosure IS NOT NULL
415
+ AND json_extract(j.value, '$.kind') != 'query'
416
+ AND (json_extract(j.value, '$.target') IN (${holes})
417
+ OR json_extract(j.value, '$.targetId') IN (${holes}))
418
+ `).all(...keys, ...keys)
419
+ }
420
+
395
421
  const manifest = {
396
422
  // Look up a previously-recorded entry by entity (or by an
397
423
  // object with `{id, destination}`). Returns the snapshot, or
@@ -414,6 +440,78 @@ export function createManifest(db) {
414
440
  return stmtLookupById.all(id).map(rowToSnap)
415
441
  },
416
442
 
443
+ // Every snapshot that claims a destination — the reverse of
444
+ // snapshotsFor, and the entry point for "what produced this file?".
445
+ // More than one means a collision; see collisions().
446
+ snapshotsAt(destination) {
447
+ if (!destination) return []
448
+ return stmtLookupByDestination.all(destination).map(rowToSnap)
449
+ },
450
+
451
+ // Which destinations would re-render if this entity changed.
452
+ //
453
+ // Answered by running the REAL skipDecision against each candidate,
454
+ // with the mutation maps the render loop would build for exactly this
455
+ // one entity. A second implementation of the invalidation rule would
456
+ // be a preview that disagrees with the cycle it is previewing, which
457
+ // is worse than no preview: it would be trusted.
458
+ //
459
+ // What it cannot model, and says so at its caller: how the entity's
460
+ // OWN meta would change. Frontmatter is parsed during import, not
461
+ // here, so a change that alters meta.layout (and therefore the
462
+ // destination itself) is outside what this can see. Its own snapshots
463
+ // are reported as affected regardless, which is the safe direction.
464
+ affectedBy(entity) {
465
+ if (!entity?.id) return []
466
+ const lang = entity?.meta?.lang ?? null
467
+ const hash = inputHashOf(entity)
468
+ const keys = lookupKeys(entity)
469
+ const mutatedRefs = new Map(keys.map(key => [key, new Set([lang])]))
470
+ const currentHashes = new Map(keys.map(key => [key, hash]))
471
+ const mutatedEntities = new Map([[entity.id, entity]])
472
+
473
+ // Three ways a snapshot can care, unioned before judging so a
474
+ // snapshot reachable by two of them is judged once.
475
+ const candidates = new Map()
476
+ const consider = (id, destination) => {
477
+ if (!id || !destination) return
478
+ candidates.set(`${id}\u0000${destination}`, { id, destination })
479
+ }
480
+ for (const snap of this.snapshotsFor(entity.id)) consider(snap.id, snap.destination)
481
+ for (const row of edgeCandidates(keys)) consider(row.id, row.destination)
482
+ for (const id of this.queryAffected(mutatedEntities)) {
483
+ for (const snap of this.snapshotsFor(id)) consider(snap.id, snap.destination)
484
+ }
485
+
486
+ const affected = []
487
+ for (const { id, destination } of candidates.values()) {
488
+ // Its own renders: the premise of the question is that this
489
+ // entity changed, so asking skipDecision — which compares the
490
+ // hash of the entity as it stands NOW — would answer
491
+ // "unchanged" and hide the one destination the caller is
492
+ // certainly touching.
493
+ if (id === entity.id) {
494
+ affected.push({ id, destination, reason: 'inputs-changed', why: 'this entity\'s own render' })
495
+ continue
496
+ }
497
+ const dependent = findById(id)
498
+ if (!dependent) continue
499
+ const decision = this.skipDecision(
500
+ { ...dependent, destination }, mutatedRefs, currentHashes, mutatedEntities)
501
+ if (decision.skip) continue
502
+ // The same provenance the build report carries. A bare list of
503
+ // destinations answers "how many" and not "why this one",
504
+ // which is the half that makes it checkable.
505
+ affected.push({
506
+ id, destination, reason: decision.reason,
507
+ ...(decision.changed?.length ? { changed: decision.changed } : {}),
508
+ ...(decision.matched ? { matched: decision.matched } : {}),
509
+ ...(decision.dependency ? { dependency: decision.dependency } : {}),
510
+ })
511
+ }
512
+ return affected
513
+ },
514
+
417
515
  // Should this render be skipped? See the original docstring in
418
516
  // the prior NDJSON-backed implementation — logic is unchanged,
419
517
  // backing storage is the only thing that changed.