@michaelthielemann/kestrel 1.6.0 → 1.7.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.
@@ -42,7 +42,10 @@ function parseCidr(token: string): Cidr | null {
42
42
  const slash = token.indexOf('/')
43
43
  const base = ipv4ToInt(slash === -1 ? token : token.slice(0, slash))
44
44
  if (base === null) return null
45
- const bits = slash === -1 ? 32 : Number(token.slice(slash + 1))
45
+ // `Number()` alone would widen a malformed prefix into a mask: '' → 0 → 0.0.0.0/0, and '0x10'/'1e1' → 16/10.
46
+ const bitsRaw = slash === -1 ? '32' : token.slice(slash + 1).trim()
47
+ if (!/^\d+$/.test(bitsRaw)) return null
48
+ const bits = Number(bitsRaw)
46
49
  if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null
47
50
  const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
48
51
  return { base: (base & mask) >>> 0, mask }
@@ -8,10 +8,13 @@ import { getOne } from '../../../core/server/utils/crud'
8
8
  // stale/deleted id (getOne 404 → null via `skipMissing`, other errors propagate — fail-loud), and runs at
9
9
  // `depth - 1`, so `populateRow` bails at 0 and cycles terminate. Registered per-type; the shared field-tree
10
10
  // walker (fields layer) dispatches it.
11
+ // The reachability predicate is the registry-driven public set, NOT the guard's full decision — the guard
12
+ // also folds in `registeredGrants()`, which this omits. So a collection opened to anonymous by a registered
13
+ // grant is served on its own route but stays unexpanded here: fail-closed drift, never a widening.
11
14
  export default defineNitroPlugin(() => {
12
- registerFieldPopulator('relation', buildRelationFieldPopulator((collection, id, depth, locale) => {
15
+ registerFieldPopulator('relation', buildRelationFieldPopulator((collection, id, depth, locale, publicOnly) => {
13
16
  const built = getCollection(collection)
14
17
  if (!built) return null
15
- return skipMissing(() => getOne(useDb(), built, id, depth, locale, true) as Record<string, unknown>)
16
- }))
18
+ return skipMissing(() => getOne(useDb(), built, id, depth, locale, true, publicOnly) as Record<string, unknown>)
19
+ }, (collection) => isPubliclyReadable(collection, publicReadableResources())))
17
20
  })
@@ -11,6 +11,7 @@ export type ResolveRecord = (
11
11
  id: number,
12
12
  depth: number,
13
13
  locale: string,
14
+ publicOnly: boolean,
14
15
  ) => Record<string, unknown> | null
15
16
 
16
17
  /**
@@ -36,31 +37,43 @@ export function skipMissing(fetch: () => Record<string, unknown>): Record<string
36
37
  * fails the whole read. The related read passes `ctx.depth - 1`; `populateRow` bails at depth 0, so a
37
38
  * relation cycle terminates. Registered per-type via `registerFieldPopulator('relation', …)`; the shared
38
39
  * field-tree walker drives it over top-level fields, block props, slots, and repeater entries.
40
+ *
41
+ * Under `ctx.publicOnly` a relation into a collection `isPublicCollection` rejects is left unexpanded
42
+ * (raw id only, NO `$<name>` sibling at all — a relation field targets exactly one collection, so a
43
+ * `many` relation is all-or-nothing): expansion must not reach a record the caller could not have
44
+ * requested directly. The check runs BEFORE `resolve`, so a withheld target never enters the memo.
39
45
  */
40
- export function buildRelationFieldPopulator(resolveRecord: ResolveRecord): FieldPopulator {
41
- // The same target (collection+id+depth+locale) is resolved once — build-wide during a generate run
46
+ export function buildRelationFieldPopulator(
47
+ resolveRecord: ResolveRecord,
48
+ isPublicCollection: (collection: string) => boolean,
49
+ ): FieldPopulator {
50
+ // The same target (collection+id+depth+locale+scope) is resolved once — build-wide during a generate run
42
51
  // (memoDuringPrerender), request-/publish-run-wide via the resolve scope (which also budgets the
43
52
  // distinct fan-out of one live request and replays read-tags on hits, so publish deps stay complete).
44
53
  // memoResolver OUTERMOST: the per-scope budget verdict must stay scope-local. If memoDuringPrerender
45
54
  // wrapped memoResolver, a build-wide memoize would cache a budget-skip `null` and poison every later
46
55
  // page of a `nuxt generate`. With this order the build-wide memo only ever caches REAL resolver results.
47
- const key = (collection: string, id: number, depth: number, locale: string) => `rel:${collection}:${id}:${depth}:${locale}`
56
+ // `publicOnly` is part of the key because the same record populates DIFFERENTLY under it (its own
57
+ // non-public relations are withheld) — sharing one entry would serve one scope's record to the other.
58
+ const key = (collection: string, id: number, depth: number, locale: string, publicOnly: boolean) => `rel:${collection}:${id}:${depth}:${locale}:${publicOnly}`
48
59
  const resolve = memoResolver(memoDuringPrerender(resolveRecord, key), key)
49
60
  return (bag, key, field, ctx, keyMode) => {
50
61
  if (!fieldIs(field, 'relation')) return
51
62
  const collection = field.relation.collection
63
+ const publicOnly = ctx.publicOnly === true
64
+ if (publicOnly && !isPublicCollection(collection)) return
52
65
  const depth = ctx.depth - 1
53
66
  if (field.relation.many) {
54
67
  const ids = bag[key]
55
68
  if (Array.isArray(ids)) {
56
69
  bag['$' + key] = ids
57
70
  .filter((n): n is number => typeof n === 'number')
58
- .map((id) => resolve(collection, id, depth, ctx.locale))
71
+ .map((id) => resolve(collection, id, depth, ctx.locale, publicOnly))
59
72
  .filter((r): r is Record<string, unknown> => r != null)
60
73
  }
61
74
  } else {
62
75
  const id = bag[keyMode === 'columns' ? `${key}Id` : key]
63
- if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale)
76
+ if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale, publicOnly)
64
77
  }
65
78
  }
66
79
  }
@@ -4,5 +4,8 @@ export default defineEventHandler((event) => {
4
4
  const depth = Number(query.depth ?? 0)
5
5
  const locale = query.locale as string | undefined
6
6
  const publishedOnly = publishedOnlyForScope(event.context.readScope)
7
- return getOne(useDb(), collection, requireId(event), depth, locale, publishedOnly)
7
+ // See index.get.ts: the public-set restriction follows the ROLE, so the renderer keeps full population;
8
+ // a missing principal fails closed onto it.
9
+ const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
10
+ return getOne(useDb(), collection, requireId(event), depth, locale, publishedOnly, publicOnly)
8
11
  })
@@ -3,9 +3,14 @@ export default defineEventHandler((event) => {
3
3
  const query = getQuery(event)
4
4
  const db = useDb()
5
5
  const publishedOnly = publishedOnlyForScope(event.context.readScope)
6
+ // Keyed on the ROLE, not the read scope: the renderer reads published-only too, but it produces the
7
+ // static site and must still see every relation the output embeds. Only a visitor the guard scopes to
8
+ // the public collection set is barred from reaching further through a populated relation. Fail-CLOSED
9
+ // on a missing principal, like `publishedOnlyForScope` above — an absent one is a guard regression.
10
+ const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
6
11
 
7
12
  if (collection.def.mode === 'single') {
8
- return getSingleton(db, collection, query.locale as string | undefined, publishedOnly, query.depth ? Number(query.depth) : 0)
13
+ return getSingleton(db, collection, query.locale as string | undefined, publishedOnly, query.depth ? Number(query.depth) : 0, publicOnly)
9
14
  }
10
15
 
11
16
  return list(db, collection, {
@@ -15,5 +20,5 @@ export default defineEventHandler((event) => {
15
20
  perPage: query.perPage ? Number(query.perPage) : undefined,
16
21
  filter: parseFilter(query as Record<string, unknown>),
17
22
  depth: query.depth ? Number(query.depth) : 0,
18
- }, publishedOnly)
23
+ }, publishedOnly, publicOnly)
19
24
  })
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { createError } from 'h3'
3
+ import Database from 'better-sqlite3'
4
+ import { drizzle } from 'drizzle-orm/better-sqlite3'
5
+ import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
+ import { defineCollection } from '../../utils/defineCollection'
7
+ import { create, getOne, getSingleton, list, parseFilter, putSingleton } from '../../utils/crud'
8
+ import { requireCollection, requireId } from '../../utils/http'
9
+ import { clearRegistry, registerCollection } from '../../utils/registry'
10
+ import { clearPopulator, registerPopulator, type PopulateCtx } from '../../utils/populate'
11
+ import { desiredSchema } from '../../schema/desired'
12
+ import { diffSchema } from '../../schema/diff'
13
+ import { renderSqlite } from '../../schema/render-sqlite'
14
+
15
+ const posts = buildCollection(defineCollection({
16
+ name: 'posts', mode: 'multi', translatable: false,
17
+ fields: { title: { type: 'text', required: true } },
18
+ }))
19
+ const settings = buildCollection(defineCollection({
20
+ name: 'settings', mode: 'single', translatable: false,
21
+ fields: { siteName: { type: 'text' } },
22
+ }))
23
+
24
+ interface FakeEvent {
25
+ query: Record<string, unknown>
26
+ context: { params: Record<string, string>; readScope?: string; principal?: { userId: string | null; role: string } }
27
+ }
28
+
29
+ let db: ReturnType<typeof drizzle>
30
+ const seen: PopulateCtx[] = []
31
+
32
+ // The handlers are Nitro routes: their auto-imported helpers are plain globals in a node test. Every read
33
+ // entry point is the REAL one, so this exercises the actual crud → populate threading.
34
+ Object.assign(globalThis, {
35
+ defineEventHandler: (handler: unknown) => handler,
36
+ createError,
37
+ getQuery: (event: FakeEvent) => event.query,
38
+ useDb: () => db,
39
+ requireCollection,
40
+ requireId,
41
+ list,
42
+ getOne,
43
+ getSingleton,
44
+ parseFilter,
45
+ // Published-only for EVERY role, so a flag that tracked the read scope instead of the role would be
46
+ // indistinguishable here — the renderer/admin cases below would then fail.
47
+ publishedOnlyForScope: () => true,
48
+ })
49
+
50
+ const listHandler = (await import('./index.get')).default as unknown as (event: FakeEvent) => unknown
51
+ const detailHandler = (await import('./[id].get')).default as unknown as (event: FakeEvent) => unknown
52
+
53
+ let postId: number
54
+
55
+ beforeEach(() => {
56
+ clearRegistry()
57
+ clearPopulator()
58
+ seen.length = 0
59
+ const sqlite = new Database(':memory:')
60
+ for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table, settings.table]), {}))) sqlite.exec(stmt)
61
+ db = drizzle(sqlite)
62
+ registerCollection(posts)
63
+ registerCollection(settings)
64
+ postId = (create(db, posts, { title: 'A' }) as Record<string, unknown>).id as number
65
+ putSingleton(db, settings, undefined, { siteName: 'Kestrel' })
66
+ registerPopulator((row, ctx) => { seen.push(ctx); return row })
67
+ })
68
+ afterEach(() => {
69
+ clearRegistry()
70
+ clearPopulator()
71
+ })
72
+
73
+ const eventFor = (collection: string, role: string | undefined, params: Record<string, string> = {}): FakeEvent => ({
74
+ query: { depth: 1 },
75
+ context: {
76
+ params: { collection, ...params },
77
+ readScope: 'published',
78
+ principal: role ? { userId: null, role } : undefined,
79
+ },
80
+ })
81
+
82
+ describe('read routes — public-only populate scope', () => {
83
+ it('marks an anonymous list read public-only', () => {
84
+ listHandler(eventFor('posts', 'anonymous'))
85
+ expect(seen[0]?.publicOnly).toBe(true)
86
+ })
87
+
88
+ it('marks an anonymous detail read public-only', () => {
89
+ detailHandler(eventFor('posts', 'anonymous', { id: String(postId) }))
90
+ expect(seen[0]?.publicOnly).toBe(true)
91
+ })
92
+
93
+ it('marks an anonymous singleton read public-only', () => {
94
+ listHandler(eventFor('settings', 'anonymous'))
95
+ expect(seen[0]?.publicOnly).toBe(true)
96
+ })
97
+
98
+ // The renderer produces the static site: stripping its relation sidecars would silently empty the
99
+ // generated HTML, so its read stays unrestricted even though it too is published-only.
100
+ it('leaves a renderer read unrestricted', () => {
101
+ listHandler(eventFor('posts', 'renderer'))
102
+ detailHandler(eventFor('posts', 'renderer', { id: String(postId) }))
103
+ expect(seen.map((c) => c.publicOnly)).toEqual([false, false])
104
+ })
105
+
106
+ it('leaves an admin read unrestricted', () => {
107
+ listHandler(eventFor('posts', 'admin'))
108
+ detailHandler(eventFor('posts', 'admin', { id: String(postId) }))
109
+ expect(seen.map((c) => c.publicOnly)).toEqual([false, false])
110
+ })
111
+
112
+ // A principal-less request is a guard regression, never a trusted caller — it must fail the same
113
+ // direction as the `publishedOnly` flag the handler derives beside it.
114
+ it('treats a missing principal as public-only', () => {
115
+ listHandler(eventFor('posts', undefined))
116
+ detailHandler(eventFor('posts', undefined, { id: String(postId) }))
117
+ expect(seen.map((c) => c.publicOnly)).toEqual([true, true])
118
+ })
119
+ })
@@ -1,3 +1,9 @@
1
1
  // Every reference in the index whose target is currently deleted or unpublished — the global broken-
2
2
  // references report. Admin-only (the `references` resource is not in the public set). Derived on read.
3
- export default defineEventHandler(() => findBrokenRefs(useDb()) ?? [])
3
+ // A `null` scan means the index itself could not be read (e.g. record_refs not migrated yet); answering
4
+ // `[]` there would report a verified-clean site when nothing was actually checked.
5
+ export default defineEventHandler(() => {
6
+ const rows = findBrokenRefs(useDb())
7
+ if (rows === null) throw createError({ statusCode: 503, statusMessage: 'Reference index unavailable' })
8
+ return rows
9
+ })
@@ -7,9 +7,11 @@ import { renderSqlite } from './render-sqlite'
7
7
  // `SchemaOp[]` over the normalized model, independent of any database. A `Dialect` is what binds that
8
8
  // core to a concrete backend — it knows how to read the backend's live schema back into the model
9
9
  // (`introspect`), how to turn ops into the backend's DDL (`render`), and how to quote an identifier.
10
- // `sync.ts` is threaded with a Dialect (defaulting to `sqlite`), so adding a second backend means writing
11
- // one new Dialect not touching diff/sync. SQLite is the only implementation today; `postgres` is a
12
- // reserved, fail-loud slot.
10
+ // `sync.ts` is threaded with a Dialect (defaulting to `sqlite`), so DDL rendering, introspection and
11
+ // identifier quoting are swappable without touching the diff. The seam stops there: `diff.ts` emits
12
+ // `rebuild_table` because SQLite has no ALTER/DROP COLUMN, and sync's pre-flight feasibility probes are
13
+ // SQLite SQL (`PRAGMA table_info`, `json_extract`) — a second backend needs both adjusted alongside its
14
+ // new Dialect. SQLite is the only implementation today; `postgres` is a reserved, fail-loud slot.
13
15
  export interface Dialect {
14
16
  /** Stable identifier, e.g. `sqlite` | `postgres`. */
15
17
  readonly name: string
@@ -1,8 +1,9 @@
1
1
  import type { ColumnShape, IndexShape, TableShape, SchemaSnapshot } from './model'
2
2
 
3
3
  // Read the *actual* schema of a live SQLite database into the normalized model, so `diffSchema` can
4
- // compare it against the desired schema (ADR-0002). Typed structurally (prepare + pragma) so it accepts
5
- // any better-sqlite3 connection without importing the native module here.
4
+ // compare it against the desired schema (ADR-0002). Typed structurally (prepare + pragma) to keep the
5
+ // native module out of this file — but the shape is narrower than better-sqlite3's own (which types
6
+ // `pragma` as returning `unknown`), so a real connection only reaches these entry points through a cast.
6
7
 
7
8
  interface Row { [key: string]: unknown }
8
9
  export interface IntrospectDb {
@@ -123,7 +123,7 @@ function filterKindMap(c: BuiltCollection): Record<string, FilterKind> {
123
123
  return map
124
124
  }
125
125
 
126
- export function list(db: DB, c: BuiltCollection, q: ListQuery, publishedOnly = false) {
126
+ export function list(db: DB, c: BuiltCollection, q: ListQuery, publishedOnly = false, publicOnly = false) {
127
127
  const cols = columns(c)
128
128
  // A listing depends on the whole collection (any add/remove/edit changes it) — tag it collection-level.
129
129
  if (q.capture !== false) captureRead(c.def.name)
@@ -187,7 +187,7 @@ export function list(db: DB, c: BuiltCollection, q: ListQuery, publishedOnly = f
187
187
  // fan-out is budgeted — an anonymous `?depth=10&perPage=500` read can no longer multiply into an
188
188
  // unbounded number of synchronous DB reads (each blocks the single event-loop thread).
189
189
  const data = withResolveScope(
190
- () => rawData.map((r) => populateRow(r as Record<string, unknown>, { depth, locale: populateLocale, def: c.def })),
190
+ () => rawData.map((r) => populateRow(r as Record<string, unknown>, { depth, locale: populateLocale, def: c.def, publicOnly })),
191
191
  resolveBudgetFor(perPage), // scale the ceiling with the page size so a full legitimate page always populates
192
192
  `list ${c.def.name}`,
193
193
  ) as Row[]
@@ -258,7 +258,7 @@ function attachTranslationStatus(db: DB, c: BuiltCollection, rows: Row[], publis
258
258
  }
259
259
  }
260
260
 
261
- export function getOne(db: DB, c: BuiltCollection, id: number, depth = 0, locale?: string, publishedOnly = false): Row {
261
+ export function getOne(db: DB, c: BuiltCollection, id: number, depth = 0, locale?: string, publishedOnly = false, publicOnly = false): Row {
262
262
  captureRead(c.def.name, id) // a detail read depends on exactly this record
263
263
  const cols = columns(c)
264
264
  const row = db.select().from(table(c)).where(eq(cols.id, id)).get() as Row | undefined
@@ -270,7 +270,7 @@ export function getOne(db: DB, c: BuiltCollection, id: number, depth = 0, locale
270
270
  const loc = c.def.translatable ? resolveLocale(locale) : primaryLocale()
271
271
  // Nested reads (the relation populator's recursive getOne) reuse the enclosing request's scope.
272
272
  return withResolveScope(
273
- () => populateRow(row as Record<string, unknown>, { depth: safeDepth, locale: loc, def: c.def }),
273
+ () => populateRow(row as Record<string, unknown>, { depth: safeDepth, locale: loc, def: c.def, publicOnly }),
274
274
  resolveBudgetFor(1),
275
275
  `get ${c.def.name}:${id}`,
276
276
  ) as Row
@@ -516,7 +516,7 @@ export function resolveTranslations(db: DB, c: BuiltCollection, id: number): Rec
516
516
  return result
517
517
  }
518
518
 
519
- export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publishedOnly = false, depth = 0): Row | null {
519
+ export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publishedOnly = false, depth = 0, publicOnly = false): Row | null {
520
520
  captureRead(c.def.name) // a singleton (nav/settings/footer) is global — any page that reads it depends on it
521
521
  const cols = columns(c)
522
522
  const loc = c.def.translatable ? resolveLocale(locale) : primaryLocale()
@@ -527,7 +527,7 @@ export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publis
527
527
  // Populate like list()/getOne() so a singleton's media/relation/link fields resolve at depth > 0 — the
528
528
  // canonical settings-singleton (site logo, nav link repeater) relies on this exactly as any collection does.
529
529
  return withResolveScope(
530
- () => populateRow(row as Record<string, unknown>, { depth: clampDepth(depth), locale: loc, def: c.def }),
530
+ () => populateRow(row as Record<string, unknown>, { depth: clampDepth(depth), locale: loc, def: c.def, publicOnly }),
531
531
  resolveBudgetFor(1),
532
532
  `singleton ${c.def.name}`,
533
533
  ) as Row
@@ -6,7 +6,8 @@ import { resolve } from 'node:path'
6
6
  /**
7
7
  * Non-secret S3 settings (used when `media.driver === 's3'`). The access-key id and secret are
8
8
  * deliberately **absent** here — they are env-only (`KESTREL_S3_ACCESS_KEY_ID` /
9
- * `KESTREL_S3_SECRET_ACCESS_KEY`), read at driver construction, never in committed config.
9
+ * `KESTREL_S3_SECRET_ACCESS_KEY`), read at module setup — unconditionally, whatever the driver — and
10
+ * frozen into `runtimeConfig`, never in committed config.
10
11
  */
11
12
  export interface KestrelS3Config {
12
13
  /** Target bucket name. */
@@ -122,8 +123,7 @@ export interface KestrelConfig {
122
123
  publicDir?: string
123
124
  /** Auto-publish affected pages on every content write (default true). */
124
125
  auto?: boolean
125
- /** Run a FULL reconcile every N minutes (default 0 = off) — self-heals missed invalidations and
126
- * picks up time-based `publishDate` publishing that no write event would trigger. */
126
+ /** Run a FULL reconcile every N minutes (default 0 = off) — self-heals a missed invalidation. */
127
127
  reconcileMinutes?: number
128
128
  /** Verbose publish logging: emit a timestamped per-route line (rendered / pruned) on each incremental
129
129
  * republish, on top of the summary line. Default false (`KESTREL_OUTPUT_VERBOSE`). */
@@ -1,6 +1,8 @@
1
1
  import type { CollectionDef, FieldDef } from './defineCollection'
2
2
 
3
- export interface PopulateCtx { depth: number; locale: string; def: CollectionDef }
3
+ /** `publicOnly`: the read is served to a principal that may only reach the public collection set, so a
4
+ * populator must not expand a reference into a collection the guard would have refused it directly. */
5
+ export interface PopulateCtx { depth: number; locale: string; def: CollectionDef; publicOnly?: boolean }
4
6
  export type Populator = (row: Record<string, unknown>, ctx: PopulateCtx) => Record<string, unknown>
5
7
 
6
8
  // A composed list: each registered populator runs in turn over the row (e.g. media attaches `$media`,
@@ -5,7 +5,7 @@ import type { StorageDriver } from '../../../core/server/utils/storage'
5
5
  import { media } from '../collections/media'
6
6
  import { deriveImage, RASTER } from './derive'
7
7
  import { derivativeKey, type DerivativeManifest } from './record'
8
- import { activeVariants } from './variants'
8
+ import { readVariantRegistry, resolveActiveVariants } from './variants'
9
9
  import { withLock, mediaLockKey } from '../../../core/server/utils/key-lock'
10
10
  import { emitMediaWrite } from './media-write'
11
11
 
@@ -16,7 +16,8 @@ const MEDIA_CACHE_CONTROL = 'public, max-age=31536000'
16
16
  export interface BackfillPlan {
17
17
  /** Specs, each carrying ONLY its missing formats, to derive + add. */
18
18
  missing: ResolvedVariant[]
19
- /** Object keys of manifest entries no longer in the active set (deregistered) — to prune. */
19
+ /** Object keys of manifest entries no longer in the active set (deregistered) — to prune. Always empty
20
+ * when the caller passes `prune: false`, i.e. the active set is a fallback rather than the registry. */
20
21
  orphanKeys: string[]
21
22
  }
22
23
 
@@ -26,7 +27,9 @@ export interface BackfillPlan {
26
27
  * never be satisfied). Orphans are keyed per `<name>.<format>`, so a deregistered FORMAT of a still-active
27
28
  * name is pruned too.
28
29
  */
29
- export function planBackfill(row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[]): BackfillPlan {
30
+ export function planBackfill(
31
+ row: { width: number | null; derivatives: DerivativeManifest | null }, specs: ResolvedVariant[], prune = true,
32
+ ): BackfillPlan {
30
33
  const manifest = row.derivatives ?? {}
31
34
  const activeKeys = new Set<string>()
32
35
  const missing: ResolvedVariant[] = []
@@ -40,7 +43,7 @@ export function planBackfill(row: { width: number | null; derivatives: Derivativ
40
43
  if (missingFormats.length) missing.push({ ...spec, formats: missingFormats })
41
44
  }
42
45
  const orphanKeys: string[] = []
43
- for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
46
+ if (prune) for (const [k, entry] of Object.entries(manifest)) if (!activeKeys.has(k) && entry.key) orphanKeys.push(entry.key)
44
47
  return { missing, orphanKeys }
45
48
  }
46
49
 
@@ -53,9 +56,9 @@ interface BackfillRow { id: number; storageKey: string; mime: string; width: num
53
56
  * (that is Slice 9's published-media GC, a different concern).
54
57
  */
55
58
  export async function backfillRow(
56
- db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy,
59
+ db: BetterSQLite3Database, driver: StorageDriver, row: BackfillRow, specs: ResolvedVariant[], policy: ResolvedImagePolicy, prune = true,
57
60
  ): Promise<{ generated: number; pruned: number }> {
58
- const plan = planBackfill(row, specs)
61
+ const plan = planBackfill(row, specs, prune)
59
62
  if (!plan.missing.length && !plan.orphanKeys.length) return { generated: 0, pruned: 0 }
60
63
 
61
64
  const orphaned = new Set(plan.orphanKeys)
@@ -84,7 +87,12 @@ export async function backfillRow(
84
87
  return { generated, pruned: plan.orphanKeys.length }
85
88
  }
86
89
 
87
- export interface BackfillReport { rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean }
90
+ export interface BackfillReport {
91
+ rows: number; rowsChanged: number; generated: number; pruned: number; check: boolean
92
+ /** The run declined to prune because the active set is the config fallback, not the registry — so a
93
+ * `pruned: 0` here means "could not tell what is registered", not "nothing was deregistered". */
94
+ pruneWithheld: boolean
95
+ }
88
96
 
89
97
  /**
90
98
  * Iterate every raster media row, reconciling each to the active variant set. Sequential (sharp CPU + a
@@ -94,14 +102,18 @@ export interface BackfillReport { rows: number; rowsChanged: number; generated:
94
102
  export async function runBackfill(
95
103
  db: BetterSQLite3Database, driver: StorageDriver, policy: ResolvedImagePolicy, opts: { check?: boolean } = {},
96
104
  ): Promise<BackfillReport> {
97
- const specs = activeVariants(db, policy.variants, policy.presets)
105
+ // Deriving a superset is harmless, deleting against one is not, so the prune follows the resolver's own
106
+ // verdict: an unmigrated, unreadable, empty or wholly-rejected registry resolves to the CONFIG fallback,
107
+ // under which every registered variant of every row looks deregistered.
108
+ const { specs, fromRegistry: prune } = resolveActiveVariants(readVariantRegistry(db), policy.variants, policy.presets)
109
+ if (!prune) console.warn('[kestrel] backfill: no usable variant registry — generating against the config fallback and withholding the prune. Run a full publish so the prerender scan populates media_settings.')
98
110
  const rows = db.select().from(media).all() as BackfillRow[]
99
- const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check }
111
+ const report: BackfillReport = { rows: rows.length, rowsChanged: 0, generated: 0, pruned: 0, check: !!opts.check, pruneWithheld: !prune }
100
112
  for (const snapshot of rows) {
101
113
  if (!RASTER.has(snapshot.mime)) continue
102
114
  if (opts.check) {
103
115
  // Dry-run reads only (against the snapshot) — no mutation, so no lock.
104
- const plan = planBackfill(snapshot, specs)
116
+ const plan = planBackfill(snapshot, specs, prune)
105
117
  const toGenerate = plan.missing.reduce((n, s) => n + s.formats.length, 0)
106
118
  if (!toGenerate && !plan.orphanKeys.length) continue
107
119
  report.rowsChanged++
@@ -116,11 +128,11 @@ export async function runBackfill(
116
128
  const cols = getTableColumns(media) as Record<string, never>
117
129
  const row = db.select().from(media).where(eq(cols.id, snapshot.id)).get() as BackfillRow | undefined
118
130
  if (!row || !RASTER.has(row.mime)) return
119
- const plan = planBackfill(row, specs)
131
+ const plan = planBackfill(row, specs, prune)
120
132
  if (!plan.missing.length && !plan.orphanKeys.length) return
121
133
  report.rowsChanged++
122
134
  try {
123
- const r = await backfillRow(db, driver, row, specs, policy)
135
+ const r = await backfillRow(db, driver, row, specs, policy, prune)
124
136
  report.generated += r.generated
125
137
  report.pruned += r.pruned
126
138
  } catch (error) {
@@ -28,11 +28,16 @@ const rank = (v: StoredVariant): number => (v.pinned || v.source === 'manual' ?
28
28
  * config policy variants) when the registry is empty/absent. `presets` are config-authored named variants
29
29
  * (`image.variants`): explicit, name-referenced declarations that are never scan-discovered, so they stay
30
30
  * active through usage-driven narrowing (unioned into a non-empty registry, winning any name collision).
31
+ *
32
+ * `fromRegistry` is the verdict a DELETING caller (the backfill prune) must gate on: true only when at least
33
+ * one stored entry SURVIVED validation, so the set really is the registered one. Absent, unread, empty and
34
+ * wholly-rejected registries all resolve to the same superset fallback, under which every registered
35
+ * derivative looks deregistered — the row count alone cannot tell those apart from a narrowed set.
31
36
  * Pure — the load-bearing logic, unit-tested without a DB.
32
37
  */
33
38
  export function resolveActiveVariants(
34
39
  stored: StoredVariant[] | null | undefined, fallback: ResolvedVariant[], presets: ResolvedVariant[] = [],
35
- ): ResolvedVariant[] {
40
+ ): { specs: ResolvedVariant[]; fromRegistry: boolean } {
36
41
  // A registry row is hand-authorable via the media_settings JSON PATCH; reject a name outside the
37
42
  // derivative-key charset ([A-Za-z0-9_-]) — an out-of-charset char breaks the URL / the prune-media
38
43
  // referencedKeys regex (its derivative is pruned though pages reference it) and a `/` nests a pseudo-folder.
@@ -41,7 +46,7 @@ export function resolveActiveVariants(
41
46
  (v): v is StoredVariant => !!v && safeName(v.name) && Number.isFinite(v.width) && v.width >= 1,
42
47
  )
43
48
  // Empty registry ⇒ the fallback already contains the presets (resolveVariants unions them), so return it as-is.
44
- if (!list.length) return fallback
49
+ if (!list.length) return { specs: fallback, fromRegistry: false }
45
50
  const byName = new Map<string, StoredVariant>()
46
51
  for (const v of list) {
47
52
  const prev = byName.get(v.name)
@@ -50,7 +55,7 @@ export function resolveActiveVariants(
50
55
  for (const p of presets) {
51
56
  if (p && typeof p.name === 'string' && p.name !== '' && Number.isFinite(p.width) && p.width >= 1) byName.set(p.name, { ...p, source: 'manual' })
52
57
  }
53
- return [...byName.values()].map((v): ResolvedVariant => ({
58
+ const specs = [...byName.values()].map((v): ResolvedVariant => ({
54
59
  name: v.name,
55
60
  width: Math.floor(v.width),
56
61
  // coerce a garbage/stale height to null so it never reaches sharp's crop resize and 500s the upload
@@ -62,6 +67,7 @@ export function resolveActiveVariants(
62
67
  position: typeof v.position === 'string' && SHARP_POSITIONS.has(v.position) ? v.position : 'centre',
63
68
  formats: v.formats?.length ? v.formats : ['webp'],
64
69
  }))
70
+ return { specs, fromRegistry: true }
65
71
  }
66
72
 
67
73
  /**
@@ -87,12 +93,12 @@ export function reconcileVariants(existing: StoredVariant[], discovered: Resolve
87
93
  }
88
94
 
89
95
  /**
90
- * Read the persisted variant registry (the `media_settings` singleton) and resolve the active set,
91
- * falling back to `fallback` (the resolved config policy variants) when nothing is stored yet. `presets`
92
- * (config-authored named variants) stay active regardless of the stored set. The upload path calls this
93
- * so it derives exactly the currently-registered set + presets (narrow generation).
96
+ * The raw stored registry rows (the `media_settings` singleton), or `null` when there are none to resolve
97
+ * from the read threw (media_settings not migrated) or nothing is stored. Whether the resolved set may be
98
+ * DELETED against is not decidable here: that is `resolveActiveVariants`' `fromRegistry` verdict, which only
99
+ * a validation pass over these rows can give.
94
100
  */
95
- export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
101
+ export function readVariantRegistry(db: BetterSQLite3Database): StoredVariant[] | null {
96
102
  const cols = getTableColumns(mediaSettings) as Record<string, never>
97
103
  let row: { variants?: StoredVariant[] | null } | undefined
98
104
  try {
@@ -100,9 +106,18 @@ export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVari
100
106
  | { variants?: StoredVariant[] | null }
101
107
  | undefined
102
108
  } catch {
103
- // media_settings not migrated yet (a DB provisioned by committed migrations alone) — degrade to the
104
- // config fallback rather than 500 the upload. Mirrors attachDeadRefs tolerating a missing record_refs.
105
- row = undefined
109
+ return null
106
110
  }
107
- return resolveActiveVariants(row?.variants ?? null, fallback, presets)
111
+ const stored = row?.variants
112
+ return Array.isArray(stored) ? stored : null
113
+ }
114
+
115
+ /**
116
+ * Resolves the active variant set for the upload path (narrow generation: registered set + presets).
117
+ * Deliberately forgiving: an unreadable registry degrades to the config fallback rather than 500 the
118
+ * upload (mirrors attachDeadRefs tolerating a missing record_refs). Discards `fromRegistry` — never use
119
+ * this to decide what may be pruned; a deleting caller needs `resolveActiveVariants` directly.
120
+ */
121
+ export function activeVariants(db: BetterSQLite3Database, fallback: ResolvedVariant[], presets: ResolvedVariant[] = []): ResolvedVariant[] {
122
+ return resolveActiveVariants(readVariantRegistry(db), fallback, presets).specs
108
123
  }
@@ -35,7 +35,7 @@ const { locale, path } = resolvePublicRoute(route.path.split('/').filter(Boolean
35
35
  // `/api/route` and a draft renders at its real URL (the live preview); anonymous + the static render
36
36
  // stay published-only (the handler enforces it).
37
37
  const requestFetch = useRequestFetch()
38
- const { data: resolved } = await useAsyncData(`page:${locale}:${path}`, () =>
38
+ const { data: resolved, error: resolveError } = await useAsyncData(`page:${locale}:${path}`, () =>
39
39
  requestFetch('/api/route', {
40
40
  query: { path, locale },
41
41
  }).then((r) => r as {
@@ -79,6 +79,13 @@ const { data: previewSession } = previewRequested
79
79
  : { data: ref<{ authenticated: boolean } | null>(null) }
80
80
  const previewActive = computed(() => previewRequested && previewSession.value?.authenticated === true)
81
81
 
82
+ // `useAsyncData` resolves even when the fetch threw, so the resolver's own failure has to be re-raised here
83
+ // or the root's empty document (a 200 WITH a body) reads as a successful render and the publisher bakes it
84
+ // over the live page. Unconditional on purpose, not just for `/`: on any other path the failure would fall
85
+ // through to the 404 below, which asserts "no such page" from a lookup that never completed — a claim
86
+ // crawlers and caches act on, and one the publisher files as a skip instead of the error the editor shows.
87
+ if (resolveError.value) throw resolveError.value
88
+
82
89
  // The site root stays reachable (empty document) before a home page is published;
83
90
  // any other unmatched path is a genuine 404.
84
91
  if (!page.value && path !== '/') throw createError({ statusCode: 404, statusMessage: 'Page not found' })
@@ -14,13 +14,33 @@ export default defineEventHandler((event) => {
14
14
  const isStaticRender = import.meta.prerender === true || isRendererContext()
15
15
  const publishedOnly = isStaticRender || event.context.readScope !== 'all'
16
16
  const db = useDb()
17
- const resolved = resolvePage(db, allCollections(), path, locale, publishedOnly)
17
+ const { page: resolved, failed } = resolvePage(db, allCollections(), path, locale, publishedOnly)
18
18
  // The site-wide head tier rides along on the fetch the page already awaits, so it reaches SSR and the
19
- // prerender on a path that is known to work. Looked up through the registry, not imported, so a consumer
20
- // that disables the collection gets `null` instead of a query against a table the schema never created.
21
- // `depth: 1` resolves the sharing image into `$media`; `getSingleton` captures the read, so an edit
22
- // re-publishes every route that embedded it.
19
+ // prerender on a path that is known to work. Looked up through the registry, not imported, so an
20
+ // installation whose registry never received the built-in simply has the tier off (`null`) instead of
21
+ // querying a table the schema never created. `depth: 1` resolves the sharing image into `$media`;
22
+ // `getSingleton` captures the read, so an edit re-publishes every route that embedded it.
23
23
  const siteCollection = getCollection('site')
24
- const site = siteCollection ? getSingleton(db, siteCollection, locale, false, 1) : null
24
+ let site: ReturnType<typeof getSingleton> = null
25
+ let siteUnreadable = false
26
+ if (siteCollection) {
27
+ try { site = getSingleton(db, siteCollection, locale, false, 1) }
28
+ catch (error) {
29
+ // Registered but unreadable (its migration hasn't been run) — indistinguishable in the response from
30
+ // the off state above, so it joins the incomplete-read channel rather than degrading silently.
31
+ siteUnreadable = true
32
+ console.error('[kestrel] route: the site singleton could not be read:', (error as Error)?.message ?? error)
33
+ }
34
+ }
35
+ // One rule for every incomplete read: never answer 200. The publisher classifies a 200-with-body as a
36
+ // successful render, writes it over the live file and records success — so an unreadable page collection
37
+ // would bake the catch-all's empty document over a real page (the record may well live in the collection
38
+ // that failed, which is why this is not a 404), and an unreadable head tier would strip the composed
39
+ // title, default description and sharing image from every route it touches. Both are unrecoverable
40
+ // without a full re-publish and neither leaves a mark. A 5xx keeps the existing artifact and turns the
41
+ // editor's status red. The head tier is site-wide, so it fails the request even when a page did resolve.
42
+ if (siteUnreadable || (failed.length && !resolved)) {
43
+ throw createError({ statusCode: 503, statusMessage: 'Route lookup incomplete' })
44
+ }
25
45
  return { collection: resolved?.collection ?? null, page: resolved?.page ?? null, alternates: resolved?.alternates ?? [], site }
26
46
  })
@@ -3,10 +3,10 @@ import { buildLinkFieldPopulators } from '../utils/populate-links'
3
3
  import { resolveInternalHref } from '../utils/link-resolve'
4
4
 
5
5
  // Resolve internal links to the target record's localized public path at read time (page-like targets;
6
- // external/email/tel pass through). NOTE: the resolver does NOT status-gate the target a link to a DRAFT
7
- // emits the draft's real (not-yet-generated) path, disclosing its slug and shipping a 404 until publish. This
8
- // is deliberate: it keeps links stable without re-rendering every referrer when a target's status flips, and
9
- // the editor warns about draft/dead links instead. (See link-resolve.ts for the rationale.)
6
+ // external/email/tel pass through). The resolver IS status-gated: a link to a DRAFT resolves to nothing and
7
+ // bakes `'#'`, so an unpublished slug never reaches published HTML. The href therefore encodes availability,
8
+ // which is why flipping a target's status re-renders its referrers; the editor warns about the resulting
9
+ // draft/dead links separately. (See link-resolve.ts.)
10
10
  //
11
11
  // Registered as `link` + `richtext` per-type populators; the shared field-tree walker dispatches them over
12
12
  // top-level fields, block props, slots, and repeater entries.
@@ -15,7 +15,7 @@ import { registerWriteListener } from '../../../core/server/utils/write-events'
15
15
  * DETACHED (`runNitroPlugins` is synchronous + unawaited; `localFetch` is already wired before plugins
16
16
  * run + before the server listens), so it never blocks boot.
17
17
  * 3. RECONCILER — an optional periodic full publish (`output.reconcileMinutes`) self-heals any missed
18
- * invalidation and picks up time-based `publishDate` publishing that no write event would trigger.
18
+ * invalidation.
19
19
  */
20
20
  export default defineNitroPlugin(() => {
21
21
  if (import.meta.dev) return
@@ -44,8 +44,11 @@ export default defineEventHandler((event) => {
44
44
  let rows: Record<string, unknown>[]
45
45
  try {
46
46
  rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
47
- } catch {
48
- continue // table not migrated yet (e.g. a bare prerender DB)
47
+ } catch (error) {
48
+ // Skipping keeps a bare prerender DB publishable, but a drifted table drops the whole section — a
49
+ // silent gap the publisher would write straight over the live artifact.
50
+ console.error(`[kestrel] llms.txt: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
51
+ continue
49
52
  }
50
53
  const entries: LlmsEntry[] = []
51
54
  for (const row of rows) {
@@ -39,8 +39,11 @@ export default defineEventHandler((event) => {
39
39
  let rows: Record<string, unknown>[]
40
40
  try {
41
41
  rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[]
42
- } catch {
43
- continue // table not migrated yet (e.g. a bare prerender DB)
42
+ } catch (error) {
43
+ // Skipping keeps a bare prerender DB publishable, but a drifted table de-indexes every page of the
44
+ // collection — a silent gap the publisher would write straight over the live sitemap.
45
+ console.error(`[kestrel] sitemap.xml: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
46
+ continue
44
47
  }
45
48
  for (const row of rows) {
46
49
  if (c.def.status && row.status !== 'published') continue
@@ -27,8 +27,11 @@ export function resolveInternalHref(collection: string, id: number, db = useDb()
27
27
  let row: Record<string, unknown> | undefined
28
28
  try {
29
29
  row = db.select().from(c.table).where(eq(cols.id, id)).get() as Record<string, unknown> | undefined
30
- } catch {
31
- return null // table not migrated yet (e.g. a bare prerender DB)
30
+ } catch (error) {
31
+ // Still null (a throw would 500 every page holding an internal link on a bare prerender DB), but the
32
+ // memo caches that null run-wide as "not linkable", so the dead link needs a trace to be diagnosable.
33
+ console.error(`[kestrel] resolveInternalHref: ${collection}:${id} unreadable:`, (error as Error)?.message ?? error)
34
+ return null
32
35
  }
33
36
  if (!isPubliclyLinkable(row, Object.hasOwn(cols, 'status'))) return null
34
37
  return pageRowHref(row, primaryLocale(), prefixPrimaryLocale())
@@ -1,12 +1,20 @@
1
1
  import { asc, eq, getTableColumns } from 'drizzle-orm'
2
2
  import { list } from '../../../core/server/utils/crud'
3
3
  import { captureRead } from '../../../core/server/utils/read-capture'
4
+ import { translationGroupTag } from './publish/invalidation'
4
5
  import type { BuiltCollection } from '../../../core/server/utils/collection-types'
5
6
  import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
6
7
 
7
8
  export interface PageAlternate { locale: string; path: string }
8
9
  export interface ResolvedPage { collection: string; page: Record<string, unknown>; alternates: PageAlternate[] }
9
10
 
11
+ /** The matched page (or null) plus the collections whose lookup threw. `failed` is non-empty ⇒ the scan
12
+ * was INCOMPLETE, so `page: null` must never be treated as an authoritative "no such page". */
13
+ export interface PageResolution {
14
+ page: ResolvedPage | null
15
+ failed: string[]
16
+ }
17
+
10
18
  /**
11
19
  * The page's published, INDEXABLE translation siblings (self included) as locale→path pairs — the hreflang
12
20
  * set the public head emits. Mirrors the sitemap's rules exactly so the two never disagree: published-only
@@ -14,12 +22,15 @@ export interface ResolvedPage { collection: string; page: Record<string, unknown
14
22
  * hreflang to a noindexed page is a conflicting signal), null-path rows skipped, and a single-member group
15
23
  * returns [] (hreflang is meaningless for a lone page). EVERY sibling in the group is `captureRead`-tagged,
16
24
  * filtered-out ones included, so an incremental publish re-renders every group member when a sibling is
17
- * renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href.
25
+ * renamed/published/unpublished — otherwise a baked page would keep a stale/dead hreflang href. The GROUP
26
+ * itself is tagged too: a sibling that does not exist yet has no id to have been captured, so the group tag
27
+ * is the only edge a later CREATE can match.
18
28
  */
19
29
  function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page: Record<string, unknown>): PageAlternate[] {
20
30
  if (c.def.mode !== 'multi' || !c.def.translatable) return []
21
31
  const group = page.translationGroup
22
32
  if (typeof group !== 'string' || !group) return []
33
+ captureRead(translationGroupTag(c.def.name, group))
23
34
  const cols = getTableColumns(c.table) as Record<string, never>
24
35
  // A status-less pageLike collection has no draft state — every sibling is "published".
25
36
  const hasStatus = Object.hasOwn(cols, 'status')
@@ -54,13 +65,15 @@ function publishedAlternates(db: BetterSQLite3Database, c: BuiltCollection, page
54
65
 
55
66
  /**
56
67
  * The first page-like record (across all collections, in registration order) whose `path` matches,
57
- * populated at depth 1 — or null. Reuses the access-scoped `list()` so media/links populate exactly as
58
- * a direct collection read would. `publishedOnly` defaults true: a static render (prerender / runtime
59
- * publisher) and an anonymous live request must never see drafts. The authenticated-admin live preview
60
- * passes false to surface a draft at its real URL. Registration order is the precedence rule when two
61
- * page-like collections happen to share a path.
68
+ * populated at depth 1 — or null, alongside the collections that could not be read at all. Reuses the
69
+ * access-scoped `list()` so media/links populate exactly as a direct collection read would.
70
+ * `publishedOnly` defaults true: a static render (prerender / runtime publisher) and an anonymous live
71
+ * request must never see drafts. The authenticated-admin live preview passes false to surface a draft at
72
+ * its real URL. Registration order is the precedence rule when two page-like collections happen to share
73
+ * a path.
62
74
  */
63
- export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): ResolvedPage | null {
75
+ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollection[], path: string, locale?: string, publishedOnly = true): PageResolution {
76
+ const failed: string[] = []
64
77
  for (const c of collections) {
65
78
  if (!c.def.pageLike) continue
66
79
  // withTotal:false — read only the first row, skip count(). capture:false — don't tag the whole
@@ -73,14 +86,15 @@ export function resolvePage(db: BetterSQLite3Database, collections: BuiltCollect
73
86
  // Table not migrated yet (e.g. a bare prerender DB) — isolate this collection's drift from the rest,
74
87
  // but never silently: an unread collection is indistinguishable from one with no matching page, and
75
88
  // the publisher would write a 404 over every one of its routes.
89
+ failed.push(c.def.name)
76
90
  console.error(`[kestrel] resolvePage: skipped collection ${c.def.name}:`, (error as Error)?.message ?? error)
77
91
  continue
78
92
  }
79
93
  const { data } = result
80
94
  if (data.length) {
81
95
  captureRead(c.def.name, (data[0] as { id?: number }).id ?? null)
82
- return { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }
96
+ return { page: { collection: c.def.name, page: data[0]!, alternates: publishedAlternates(db, c, data[0]!) }, failed }
83
97
  }
84
98
  }
85
- return null
99
+ return { page: null, failed }
86
100
  }
@@ -27,6 +27,12 @@ export interface WriteClassification {
27
27
  /** The page's own public route from the OLD row (pageLike + had a path), else null — the route whose
28
28
  * static file must be pruned on a slug change / unpublish / delete (symmetric to `selfRoute`). */
29
29
  oldRoute: string | null
30
+ groupTag: string | null
31
+ }
32
+
33
+ /** The data tag naming a translation group. `#` keeps it clear of the `<coll>:<id>` record namespace. */
34
+ export function translationGroupTag(coll: string, group: string): string {
35
+ return `${coll}#group:${group}`
30
36
  }
31
37
 
32
38
  /** What to republish for a write. Routes are resolved from `tags` against the captured deps index. */
@@ -71,13 +77,19 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
71
77
  // change (path unchanged) still moves the route — the old-locale file must be pruned.
72
78
  const pathChanged = pageLike && status === 'updated' && oldRoute !== selfRoute
73
79
 
74
- return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute }
80
+ // `update` refuses to move a row between groups, so the surviving row's group is the group either way.
81
+ const group = row?.translationGroup
82
+ const groupTag = typeof group === 'string' && group ? translationGroupTag(def.name, group) : null
83
+
84
+ return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
75
85
  }
76
86
 
77
87
  /**
78
- * Decide what a write invalidates, per the maintainer-agreed model. Two notions of "dependent":
88
+ * Decide what a write invalidates, per the maintainer-agreed model. Three notions of "dependent":
79
89
  * - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
80
90
  * - EXPLICIT REFERRERS — pages that LINK/EMBED/relate-to a specific record → captured as `<coll>:<id>`.
91
+ * - TRANSLATION SIBLINGS — every member of a group bakes the group's hreflang set → captured as the
92
+ * group tag, the only edge that reaches members which rendered before this row existed.
81
93
  *
82
94
  * Two principles drive the split:
83
95
  * 1. FRESHENING (content or path changed) re-renders BOTH listings and explicit referrers (`[coll, coll:id]`)
@@ -94,7 +106,10 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
94
106
  export function planInvalidation(ev: WriteClassification): Invalidation {
95
107
  const coll = ev.collection
96
108
  const recordTag = ev.id != null ? `${coll}:${ev.id}` : null
97
- const tags = recordTag ? [coll, recordTag] : [coll]
109
+ // Unlike recordTag (dropped on create — no referrer can target a brand-new id), groupTag is included even
110
+ // there: a new sibling still changes every existing member's hreflang set.
111
+ const groupTags = ev.groupTag ? [ev.groupTag] : []
112
+ const tags = recordTag ? [coll, recordTag, ...groupTags] : [coll, ...groupTags]
98
113
  const selfRender = ev.pageLike && ev.selfRoute ? [ev.selfRoute] : []
99
114
 
100
115
  // DELETE — leaves the collection. Listings re-render, referrers too (their baked link/hreflang now points
@@ -108,7 +123,7 @@ export function planInvalidation(ev: WriteClassification): Invalidation {
108
123
  // record re-renders listings + its own route. No referrer can point at a brand-new id, so no `coll:id`.
109
124
  if (ev.status === 'created') {
110
125
  if (!ev.isPublished) return { type: 'noop' }
111
- return { type: 'tags', tags: [coll], render: selfRender, prune: [] }
126
+ return { type: 'tags', tags: [coll, ...groupTags], render: selfRender, prune: [] }
112
127
  }
113
128
 
114
129
  // UNPUBLISH — leaves the published set. Listings re-render; referrers re-render so their link falls back to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelthielemann/kestrel",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",
@@ -68,6 +68,9 @@
68
68
  "@tiptap/pm": "^3.29.2",
69
69
  "@tiptap/starter-kit": "^3.29.2",
70
70
  "@tiptap/vue-3": "^3.29.2",
71
+ "@types/better-sqlite3": "^7.6.13",
72
+ "@types/jsdom": "^28.0.3",
73
+ "@types/sanitize-html": "^2.16.1",
71
74
  "aws4fetch": "^1.0.20",
72
75
  "better-sqlite3": "^12.11.1",
73
76
  "dompurify": "^3.4.13",
@@ -109,9 +112,6 @@
109
112
  "devDependencies": {
110
113
  "@nuxt/kit": "^4.5.2",
111
114
  "@nuxt/test-utils": "^4.1.0",
112
- "@types/better-sqlite3": "^7.6.13",
113
- "@types/jsdom": "^28.0.3",
114
- "@types/sanitize-html": "^2.16.1",
115
115
  "@vitejs/plugin-vue": "^6.0.8",
116
116
  "@vue/test-utils": "^2.4.11",
117
117
  "drizzle-kit": "^0.31.10",
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { join, resolve, basename, relative } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { hashPassword, sessionSecret } from './lib/password.mjs'
6
- import { PACKAGE_NAME, diagnoseProject, mergeEnv, mergePackageJson, renderTemplate, targetName, toPackageName } from './lib/scaffold.mjs'
6
+ import { PACKAGE_NAME, diagnoseProject, envValue, isPlainObject, mergeEnv, mergePackageJson, renderTemplate, targetName, toPackageName } from './lib/scaffold.mjs'
7
7
  import { Cancelled, MIN_PASSWORD_LENGTH, makePaint, out, parseArgs, promptPassword, readIf, readStdin, walk, write } from './lib/cli.mjs'
8
8
 
9
9
  // Node builtins only, no build step: runs the same from a checkout, from node_modules and via `pnpm dlx`.
@@ -46,18 +46,25 @@ async function init(positional, flags) {
46
46
  const templateDir = join(TEMPLATES, 'starter')
47
47
  if (!existsSync(templateDir)) fail(`template payload missing at ${templateDir} — reinstall ${PACKAGE_NAME}.`)
48
48
 
49
- // Validate before touching disk: a half-scaffolded project is worse than a refused one.
49
+ // Read the target before touching disk: a half-scaffolded project is worse than a refused one.
50
50
  const manifestPath = join(target, 'package.json')
51
51
  const existingManifest = readIf(manifestPath)
52
52
  if (existingManifest !== null) {
53
+ let parsed
53
54
  try {
54
- JSON.parse(existingManifest)
55
+ parsed = JSON.parse(existingManifest)
55
56
  } catch {
56
57
  fail(`${manifestPath} is not valid JSON — fix it first; refusing to scaffold over a broken manifest.`)
57
58
  }
59
+ if (!isPlainObject(parsed)) fail(`${manifestPath} is not a JSON object — fix it first; refusing to scaffold over a broken manifest.`)
58
60
  }
61
+ const envPath = join(target, '.env')
62
+ const existingEnv = readIf(envPath)
59
63
 
60
- let password = typeof flags.password === 'string' ? flags.password : undefined
64
+ // No flag swallows a `-`-prefixed token, so a dash-leading password reaches init as no value at all.
65
+ if (flags.password === true) fail('--password needs a value — write --password=<pw> if it starts with a dash.')
66
+ const supplied = typeof flags.password === 'string'
67
+ let password = supplied ? flags.password : undefined
61
68
  if (password !== undefined && password.length < MIN_PASSWORD_LENGTH) {
62
69
  fail(`--password must be at least ${MIN_PASSWORD_LENGTH} characters (an empty one would leave /admin open).`)
63
70
  }
@@ -70,13 +77,21 @@ async function init(positional, flags) {
70
77
  out()
71
78
 
72
79
  if (password === undefined && !flags.yes && process.stdin.isTTY) {
73
- try {
74
- password = await promptPassword({ warn: (m) => out(yellow(m)), note: (m) => out(dim(m)) })
75
- } catch (err) {
76
- if (err instanceof Cancelled) fail('cancelled')
77
- throw err
80
+ // The prompt loops until a valid password is typed twice, so it cannot be declined, and the hash is
81
+ // folded into the session signing key asking on a project that already has one would rotate the
82
+ // password, and sign everyone out, without ever offering the operator a way to say no.
83
+ if (envValue(existingEnv ?? '', 'KESTREL_ADMIN_PASSWORD_HASH')) {
84
+ out(dim('KESTREL_ADMIN_PASSWORD_HASH is already set — pass --password to change it.'))
85
+ out()
86
+ } else {
87
+ try {
88
+ password = await promptPassword({ warn: (m) => out(yellow(m)), note: (m) => out(dim(m)) })
89
+ } catch (err) {
90
+ if (err instanceof Cancelled) fail('cancelled')
91
+ throw err
92
+ }
93
+ out()
78
94
  }
79
- out()
80
95
  }
81
96
 
82
97
  const vars = templateVars(projectName)
@@ -109,12 +124,14 @@ async function init(positional, flags) {
109
124
  }
110
125
 
111
126
  // Seed from `.env.example` so the generated file keeps its per-key comments.
112
- const envPath = join(target, '.env')
113
- const hadEnv = existsSync(envPath)
127
+ const hadEnv = existingEnv !== null
114
128
  const envEntries = { KESTREL_SESSION_SECRET: sessionSecret(), KESTREL_SECURE_COOKIES: 'false' }
115
129
  if (password !== undefined) envEntries.KESTREL_ADMIN_PASSWORD_HASH = hashPassword(password)
116
- const seed = hadEnv ? readFileSync(envPath, 'utf8') : (readIf(join(target, '.env.example')) ?? '')
117
- const { text, written } = mergeEnv(seed, envEntries)
130
+ const seed = existingEnv ?? readIf(join(target, '.env.example')) ?? ''
131
+ // Only an explicitly supplied password replaces a hash that is already set. That is not session-safety:
132
+ // the hash is folded into the cookie signing key, so any rotation signs every user out. What keeps a
133
+ // plain re-run harmless is that it changes neither the hash nor the secret.
134
+ const { text, written } = mergeEnv(seed, envEntries, supplied ? ['KESTREL_ADMIN_PASSWORD_HASH'] : [])
118
135
  if (written.length) {
119
136
  write(envPath, text, 0o600)
120
137
  ;(hadEnv ? merged : created).push(`.env ${dim(`(${written.join(', ')})`)}`)
@@ -191,7 +208,7 @@ ${bold('kestrel')} ${dim(`v${pkg.version}`)}
191
208
 
192
209
  ${bold('init flags')}
193
210
  --name <name> package name (default: the directory name, slugified)
194
- --password <pw> set the admin password without prompting
211
+ --password <pw> set or change the admin password without prompting
195
212
  --yes never prompt; leaves KESTREL_ADMIN_PASSWORD_HASH for you to fill in
196
213
  --force overwrite existing files (package.json and .env are always merged, never replaced)
197
214
 
@@ -199,9 +216,17 @@ ${dim('Existing files are kept and re-running init is safe. To create a NEW proj
199
216
  `)
200
217
  }
201
218
 
202
- const { flags, positional } = parseArgs(process.argv.slice(2), ['yes', 'force', 'help', 'version'])
219
+ const argv = process.argv.slice(2)
220
+ const { flags, positional } = parseArgs(argv, ['yes', 'force', 'help', 'version'])
203
221
  const command = positional.shift()
204
222
 
223
+ // Only these two read a positional as a directory, and an option that took no value lands there: a
224
+ // `--password -pw` would otherwise put the cleartext on disk as a directory name. `--` is how the caller
225
+ // says a dash-leading positional is what they meant.
226
+ if (!argv.includes('--') && (command === 'init' || command === 'doctor') && positional[0]?.startsWith('-')) {
227
+ fail(`unknown option "${positional[0]}" — run \`kestrel help\`.`)
228
+ }
229
+
205
230
  if (flags.version || command === 'version') out(pkg.version)
206
231
  else if (flags.help || !command || command === 'help') help()
207
232
  else if (command === 'init') process.exitCode = await init(positional, flags)
@@ -31,9 +31,12 @@ const KEY_RE = /^\s*([A-Z][A-Z0-9_]*)\s*=/
31
31
 
32
32
  /**
33
33
  * Fills only keys that are absent or empty, so re-running `init` never rotates a live session secret.
34
+ * A key in `force` replaces a value that is already there — for a secret the caller supplied by hand,
35
+ * where being handed one at all is the instruction to change it.
34
36
  * Returns the new text plus the keys that changed.
35
37
  */
36
- export function mergeEnv(existing, entries) {
38
+ export function mergeEnv(existing, entries, force = []) {
39
+ const forced = new Set(force)
37
40
  const pending = new Map(Object.entries(entries))
38
41
  const lines = existing === '' ? [] : existing.split('\n')
39
42
  const written = []
@@ -51,7 +54,7 @@ export function mergeEnv(existing, entries) {
51
54
  if (!key || !pending.has(key) || lastIndex.get(key) !== i) return line
52
55
  const value = pending.get(key)
53
56
  pending.delete(key)
54
- if (line.slice(line.indexOf('=') + 1).trim() !== '') return line
57
+ if (line.slice(line.indexOf('=') + 1).trim() !== '' && !forced.has(key)) return line
55
58
  written.push(key)
56
59
  return `${key}=${value}`
57
60
  })
@@ -68,7 +71,8 @@ export function mergeEnv(existing, entries) {
68
71
  return { text: out.join('\n'), written }
69
72
  }
70
73
 
71
- const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
74
+ /** `typeof null === 'object'`, and arrays are objects too — both need explicit exclusion for a plain object check. */
75
+ export const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
72
76
 
73
77
  const NESTED = ['scripts', 'dependencies', 'devDependencies']
74
78
  // `type` decides whether every .js in the project is ESM or CJS, so injecting it silently could break a
@@ -87,14 +91,20 @@ export function mergePackageJson(existing, template) {
87
91
  }
88
92
  for (const key of NESTED) {
89
93
  if (!template[key]) continue
90
- merged[key] = isObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
94
+ merged[key] = isPlainObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
91
95
  for (const name of Object.keys(template[key])) {
92
- if (!isObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
96
+ if (!isPlainObject(existing[key]) || !(name in existing[key])) added.push(`${key}.${name}`)
93
97
  }
94
98
  }
95
99
  return { merged, added }
96
100
  }
97
101
 
102
+ /**
103
+ * The value a dotenv key carries, or `undefined`/`''` when it carries none.
104
+ * Horizontal whitespace only: `\s*` would let an empty assignment match the NEXT line's value.
105
+ */
106
+ export const envValue = (env, key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
107
+
98
108
  const withoutComments = (src) => src.replace(/<!--[\s\S]*?-->/g, '')
99
109
  // `<nuxt-page />` is as valid as `<NuxtPage />`; matching only the Pascal spelling would flag a working app.
100
110
  const kebab = (tag) => tag.replace(/(?!^)([A-Z])/g, '-$1').toLowerCase()
@@ -160,8 +170,7 @@ export function diagnoseProject({ packageJson, nuxtConfig, appVue, env }) {
160
170
  if (env === null) {
161
171
  add('error', 'no .env — sign-in at /admin answers 503 until KESTREL_ADMIN_PASSWORD_HASH is set. Run `kestrel init`.')
162
172
  } else {
163
- // Horizontal whitespace only: `\s*` would let an empty assignment match the NEXT line's value.
164
- const value = (key) => new RegExp(`^[^\\S\\n]*${key}[^\\S\\n]*=[^\\S\\n]*(.+)$`, 'm').exec(env)?.[1].trim()
173
+ const value = (key) => envValue(env, key)
165
174
  if (!value('KESTREL_ADMIN_PASSWORD_HASH')) {
166
175
  add('error', 'KESTREL_ADMIN_PASSWORD_HASH is unset — /admin renders but sign-in answers 503. Run `kestrel hash-password`.')
167
176
  }