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.
- package/docs/diagnostics.md +21 -4
- package/package.json +1 -1
- package/src/auth.js +48 -6
- package/src/manifest.js +99 -1
package/docs/diagnostics.md
CHANGED
|
@@ -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.
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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.
|