@michaelthielemann/kestrel 1.6.0 → 2.0.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.
Files changed (79) hide show
  1. package/README.md +8 -4
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/access/server/utils/ip-allowlist.ts +4 -1
  4. package/layers/admin/app/components/BlocksBody.vue +2 -1
  5. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  6. package/layers/admin/app/components/CollectionList.vue +8 -4
  7. package/layers/admin/app/components/EditorStatus.vue +11 -0
  8. package/layers/admin/app/components/SeoFields.vue +1 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +6 -3
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/editor-expose.ts +8 -0
  17. package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
  18. package/layers/collections/server/utils/populate-relations.ts +18 -5
  19. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  20. package/layers/core/modules/kestrel/index.ts +1 -0
  21. package/layers/core/server/api/[collection]/[id].get.ts +4 -1
  22. package/layers/core/server/api/[collection]/index.get.ts +7 -2
  23. package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
  24. package/layers/core/server/api/references/broken.get.ts +7 -1
  25. package/layers/core/server/schema/dialect.ts +5 -3
  26. package/layers/core/server/schema/introspect.ts +4 -3
  27. package/layers/core/server/schema/sync.ts +1 -1
  28. package/layers/core/server/utils/crud.ts +11 -10
  29. package/layers/core/server/utils/kestrel-config.ts +10 -3
  30. package/layers/core/server/utils/populate.ts +3 -1
  31. package/layers/fields/server/field-registry/index.ts +2 -1
  32. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  33. package/layers/media/app/components/MediaLibrary.vue +4 -1
  34. package/layers/media/app/components/MediaToolbar.vue +1 -1
  35. package/layers/media/app/components/field/Media.vue +2 -0
  36. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  37. package/layers/media/server/utils/backfill.ts +24 -12
  38. package/layers/media/server/utils/variants.ts +27 -12
  39. package/layers/public/app/pages/[...slug].vue +35 -7
  40. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  41. package/layers/public/app/utils/preview-protocol.ts +36 -0
  42. package/layers/public/server/api/preview.get.ts +28 -0
  43. package/layers/public/server/api/preview.post.ts +93 -0
  44. package/layers/public/server/api/publish-status.get.ts +23 -9
  45. package/layers/public/server/api/publish.post.ts +84 -0
  46. package/layers/public/server/api/route.get.ts +26 -6
  47. package/layers/public/server/plugins/02.register-links.ts +4 -4
  48. package/layers/public/server/plugins/zz.publish.ts +13 -4
  49. package/layers/public/server/routes/llms.txt.get.ts +5 -2
  50. package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
  51. package/layers/public/server/tasks/publish/run.ts +2 -1
  52. package/layers/public/server/utils/link-resolve.ts +5 -2
  53. package/layers/public/server/utils/page-resolve.ts +23 -9
  54. package/layers/public/server/utils/preview-token.ts +109 -0
  55. package/layers/public/server/utils/publish/invalidation.ts +43 -4
  56. package/layers/public/server/utils/publish/pending.ts +74 -0
  57. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  58. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  59. package/layers/public/server/utils/publish/publisher.ts +74 -9
  60. package/layers/ui/app/components/field/Datetime.vue +2 -0
  61. package/layers/ui/app/components/field/Repeater.vue +2 -0
  62. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  63. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  64. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  65. package/layers/ui/app/components/ui/Field.vue +1 -0
  66. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  67. package/layers/ui/app/components/ui/Icon.vue +2 -2
  68. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  69. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  70. package/layers/ui/app/components/ui/Select.vue +1 -0
  71. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  72. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  73. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  74. package/layers/ui/app/i18n/de.ts +10 -0
  75. package/layers/ui/app/i18n/en.ts +10 -0
  76. package/package.json +9 -4
  77. package/scripts/kestrel.mjs +48 -19
  78. package/scripts/lib/scaffold.mjs +39 -8
  79. package/templates/starter/app/blocks/Prose.vue +1 -0
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useState } from '#imports'
3
+ import { flushPromises } from '@vue/test-utils'
4
+ import { readBody } from 'h3'
5
+ import { mountSuspended, registerEndpoint, mockNuxtImport } from '@nuxt/test-utils/runtime'
6
+ import RecordPage from './[id].vue'
7
+
8
+ // A pageLike, status-bearing collection — the page-builder case the Save/Publish split is for.
9
+ const pagesSchema = {
10
+ name: 'pages', mode: 'multi', translatable: false, pageLike: true, seo: false, status: true,
11
+ blocks: { enabled: false }, label: { singular: 'Page', plural: 'Pages' },
12
+ fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
13
+ }
14
+ registerEndpoint('/api/collections', () => ({ data: [pagesSchema] }))
15
+
16
+ const calls = vi.hoisted(() => ({
17
+ publishOnSave: false,
18
+ writes: [] as Record<string, unknown>[],
19
+ publishes: [] as Record<string, unknown>[],
20
+ tickets: [] as Record<string, unknown>[],
21
+ }))
22
+
23
+ registerEndpoint('/api/pages/1', async (event) => {
24
+ if (event.method === 'PATCH') {
25
+ const b = await readBody(event)
26
+ calls.writes.push(b)
27
+ return { id: 1, ...b, updatedAt: '2026-08-13T10:00:00.000Z' }
28
+ }
29
+ return { id: 1, title: 'Existing', path: '/existing', status: 'draft', updatedAt: '2026-08-13T09:00:00.000Z' }
30
+ })
31
+ registerEndpoint('/api/pages', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
32
+ registerEndpoint('/api/pages/1/dead-refs', () => [])
33
+ registerEndpoint('/api/publish-status', () => ({ route: '/existing', status: 'success', pending: !calls.publishOnSave, generates: true, publishOnSave: calls.publishOnSave, updatedAt: '2026-08-13T09:30:00.000Z' }))
34
+ registerEndpoint('/api/publish', async (event) => {
35
+ calls.publishes.push(await readBody(event))
36
+ return { queued: true, generates: true, routes: ['/existing'], pruned: [], drafts: [] }
37
+ })
38
+ registerEndpoint('/api/preview', async (event) => {
39
+ if (event.method === 'POST') {
40
+ calls.tickets.push(await readBody(event))
41
+ return { token: 'pv_abc', expiresAt: Date.now() + 600_000 }
42
+ }
43
+ return null
44
+ })
45
+
46
+ const h = vi.hoisted(() => ({ params: { collection: 'pages', id: '1' }, nav: [] as unknown[] }))
47
+ mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
48
+ mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
49
+
50
+ let opened: Array<{ url: string }>
51
+ let lastTab: { location: { replace: (url: string) => void }; close: () => void } | null
52
+
53
+ beforeEach(() => {
54
+ h.params = { collection: 'pages', id: '1' }
55
+ h.nav.length = 0
56
+ calls.publishOnSave = false
57
+ calls.writes.length = 0
58
+ calls.publishes.length = 0
59
+ calls.tickets.length = 0
60
+ opened = []
61
+ lastTab = null
62
+ useState('kestrel-collections').value = null
63
+ useState('kestrel-blocks').value = null
64
+ vi.spyOn(window, 'open').mockImplementation(((url?: string) => {
65
+ // The real code opens a blank tab synchronously and redirects it once the ticket is minted.
66
+ if (url) { opened.push({ url }); return null }
67
+ lastTab = { location: { replace: (to: string) => opened.push({ url: to }) }, close: () => {} }
68
+ return lastTab as unknown as Window
69
+ }) as typeof window.open)
70
+ })
71
+
72
+ const settle = async () => {
73
+ await new Promise((r) => setTimeout(r, 20))
74
+ await flushPromises()
75
+ }
76
+ const button = (w: Awaited<ReturnType<typeof mountSuspended>>, label: string) =>
77
+ w.findAll('.ui-button').find((b) => b.text() === label)
78
+
79
+ describe('record editor — Save and Publish are separate actions', () => {
80
+ it('offers Publish next to Save', async () => {
81
+ const w = await mountSuspended(RecordPage)
82
+ await flushPromises()
83
+ const labels = w.find('.record__actions').findAll('.ui-button').map((b) => b.text()).filter(Boolean)
84
+ expect(labels).toContain('Publish')
85
+ expect(labels.indexOf('Publish')).toBe(labels.indexOf('Save') + 1)
86
+ })
87
+
88
+ // `output.publishOnSave` is the way back to the pre-2.0 model, where a save republished on its own.
89
+ it('drops the Publish button when the consumer turned the split off', async () => {
90
+ calls.publishOnSave = true
91
+ const w = await mountSuspended(RecordPage)
92
+ await settle()
93
+ expect(w.find('.record__actions').findAll('.ui-button').map((b) => b.text())).not.toContain('Publish')
94
+ })
95
+
96
+ it('Save writes the record and asks for no publish at all', async () => {
97
+ const w = await mountSuspended(RecordPage)
98
+ await flushPromises()
99
+ await w.find('input[type="text"]').setValue('Edited')
100
+ // The header Save is a `type=submit` bound to the form by id — which only the real browser follows.
101
+ await w.find('form.editor').trigger('submit')
102
+ await settle()
103
+ expect(calls.writes).toHaveLength(1)
104
+ expect(calls.writes[0]).toMatchObject({ title: 'Edited', status: 'draft' }) // status untouched by a save
105
+ expect(calls.publishes).toEqual([])
106
+ })
107
+
108
+ it('Publish saves first, promotes the draft, and then writes the static output', async () => {
109
+ const w = await mountSuspended(RecordPage)
110
+ await flushPromises()
111
+ await w.find('input[type="text"]').setValue('Ready to go')
112
+ await button(w, 'Publish')!.trigger('click')
113
+ await settle()
114
+ expect(calls.writes[0]).toMatchObject({ title: 'Ready to go', status: 'published' })
115
+ expect(calls.publishes).toEqual([{ collection: 'pages', id: 1 }])
116
+ })
117
+ })
118
+
119
+ describe('record editor — previewing in a new tab', () => {
120
+ it('opens the saved URL directly when there is nothing unsaved', async () => {
121
+ const w = await mountSuspended(RecordPage)
122
+ await flushPromises()
123
+ const preview = w.findAll('.ui-button').find((b) => b.attributes('aria-label') === 'Open in new tab')!
124
+ await preview.trigger('click')
125
+ await settle()
126
+ expect(opened.map((o) => o.url)).toContain('/existing')
127
+ expect(calls.tickets).toEqual([])
128
+ })
129
+
130
+ it('mints a ticket for unsaved changes instead of saving them', async () => {
131
+ const w = await mountSuspended(RecordPage)
132
+ await flushPromises()
133
+ await w.find('input[type="text"]').setValue('Not saved')
134
+ await flushPromises()
135
+ const preview = w.findAll('.ui-button').find((b) => b.attributes('aria-label') === 'Preview unsaved changes in a new tab')!
136
+ await preview.trigger('click')
137
+ await settle()
138
+ expect(calls.writes).toEqual([]) // the whole point: no save
139
+ expect(calls.tickets[0]).toMatchObject({ collection: 'pages', id: 1, values: { title: 'Not saved' } })
140
+ expect(opened.at(-1)!.url).toBe('/existing?kestrel-preview-token=pv_abc')
141
+ })
142
+ })
@@ -23,4 +23,12 @@ export interface EditorExpose {
23
23
  live: PublishStatusData | null
24
24
  /** The record's own title (see `recordTitle`), or `''` — the header then shows "Edit {collection} #{id}". */
25
25
  recordTitle: string
26
+ /** Save, then write the static output (ADR-0008) — a draft is promoted to published on the way. */
27
+ publish: () => Promise<void>
28
+ publishing: boolean
29
+ /** False when `output.publishOnSave` is on — a save republishes, so the hosts hide the Publish button. */
30
+ canPublish: boolean
31
+ /** Open the record in a new tab: the saved URL, or the unsaved state carried by a preview ticket. */
32
+ openPreview: () => Promise<void>
33
+ previewOpening: boolean
26
34
  }
@@ -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
  }
@@ -100,7 +100,9 @@ function evalObjectExpr(content: string, node: Node, scope: Record<string, unkno
100
100
  const src = content.slice(node.start, node.end)
101
101
  const names = Object.keys(scope)
102
102
  try {
103
- // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
103
+ // `src` is a slice of the CONSUMER's own block-SFC source, evaluated once here at build/discovery time
104
+ // to recover the object literal's runtime shape — never attacker- or request-supplied, so this is not
105
+ // an eval-injection surface.
104
106
  const fn = new Function(...names, `return (${src})`)
105
107
  const out = fn(...names.map((k) => scope[k]))
106
108
  if (!out || typeof out !== 'object') throw new Error('expected an object literal')
@@ -109,6 +111,7 @@ function evalObjectExpr(content: string, node: Node, scope: Record<string, unkno
109
111
  throw new Error(
110
112
  `${where}: could not evaluate the block declaration. Field/block args must be self-contained literals + ` +
111
113
  `field-factory calls (no imported constants, computed values, or TS type-args). Cause: ${(e as Error).message}`,
114
+ { cause: e },
112
115
  )
113
116
  }
114
117
  }
@@ -126,7 +129,7 @@ export function extractBlockDef(sfcSource: string, fileBase: string): ExtractedB
126
129
  try {
127
130
  ast = parse(content, { sourceType: 'module', plugins: ['typescript'] })
128
131
  } catch (e) {
129
- throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`)
132
+ throw new Error(`${fileBase}: could not parse <script setup> — ${(e as Error).message}`, { cause: e })
130
133
  }
131
134
 
132
135
  const props = macroCall(ast, 'defineProps')
@@ -74,6 +74,7 @@ export default defineNuxtModule<KestrelConfig>({
74
74
  dir: c.output.dir,
75
75
  publicDir: c.output.publicDir,
76
76
  auto: c.output.auto,
77
+ publishOnSave: c.output.publishOnSave,
77
78
  reconcileMinutes: c.output.reconcileMinutes,
78
79
  verbose: c.output.verbose,
79
80
  s3: {
@@ -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
- import type { ColumnShape, IndexShape, TableShape, SchemaSnapshot } from './model'
1
+ import type { ColumnShape, IndexShape, 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 {
@@ -1,4 +1,4 @@
1
- import { type IntrospectDb } from './introspect'
1
+ import type { IntrospectDb } from './introspect'
2
2
  import { diffSchema } from './diff'
3
3
  import { sqlite, type Dialect } from './dialect'
4
4
  import type { SchemaSnapshot, SchemaOp } from './model'
@@ -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
@@ -465,10 +465,11 @@ export function removeMany(db: DB, c: BuiltCollection, ids: number[]): { count:
465
465
  }
466
466
 
467
467
  /**
468
- * Publish / unpublish a batch of rows by persisting their `status` — NOT a separate publish path. Writing
469
- * `status` and emitting the SAME write event the editor save emits IS the publish: classifyWrite
470
- * planInvalidation the publish queue coalesces N emits into one incremental publish (PUBLISH renders the
471
- * self route, UNPUBLISH prunes the old route). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
468
+ * Publish / unpublish a batch of rows by persisting their `status` — the record's public INTENT, not the
469
+ * static file. Since ADR-0008 those are two steps: this write emits the same event an editor save emits,
470
+ * and the public layer's listener acts on the removal half only (UNPUBLISH prunes the route at once, so a
471
+ * page taken offline can never stay live). Making a page appear is the explicit publish action
472
+ * (`POST /api/publish`). ALL-OR-NOTHING like `removeMany` (a missing id 404s before
472
473
  * any write). Validation (`assertConditions`) runs on PUBLISH ONLY — unpublishing must never be blockable
473
474
  * (you must always be able to take a broken page offline). Omits `update()`'s slug/transform branches,
474
475
  * which are provably inert for a status-only change.
@@ -516,7 +517,7 @@ export function resolveTranslations(db: DB, c: BuiltCollection, id: number): Rec
516
517
  return result
517
518
  }
518
519
 
519
- export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publishedOnly = false, depth = 0): Row | null {
520
+ export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publishedOnly = false, depth = 0, publicOnly = false): Row | null {
520
521
  captureRead(c.def.name) // a singleton (nav/settings/footer) is global — any page that reads it depends on it
521
522
  const cols = columns(c)
522
523
  const loc = c.def.translatable ? resolveLocale(locale) : primaryLocale()
@@ -527,7 +528,7 @@ export function getSingleton(db: DB, c: BuiltCollection, locale?: string, publis
527
528
  // Populate like list()/getOne() so a singleton's media/relation/link fields resolve at depth > 0 — the
528
529
  // canonical settings-singleton (site logo, nav link repeater) relies on this exactly as any collection does.
529
530
  return withResolveScope(
530
- () => populateRow(row as Record<string, unknown>, { depth: clampDepth(depth), locale: loc, def: c.def }),
531
+ () => populateRow(row as Record<string, unknown>, { depth: clampDepth(depth), locale: loc, def: c.def, publicOnly }),
531
532
  resolveBudgetFor(1),
532
533
  `singleton ${c.def.name}`,
533
534
  ) 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,12 @@ 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
+ /** Opt out of the save/publish split (ADR-0008): `true` makes every content write republish the pages
127
+ * it affects, as before 2.0 the editor's Publish button then has nothing left to do and is hidden.
128
+ * Default false: saving writes the DB, publishing writes the static files.
129
+ * Env `KESTREL_OUTPUT_PUBLISH_ON_SAVE`. */
130
+ publishOnSave?: boolean
131
+ /** Run a FULL reconcile every N minutes (default 0 = off) — self-heals a missed invalidation. */
127
132
  reconcileMinutes?: number
128
133
  /** Verbose publish logging: emit a timestamped per-route line (rendered / pruned) on each incremental
129
134
  * republish, on top of the summary line. Default false (`KESTREL_OUTPUT_VERBOSE`). */
@@ -158,6 +163,7 @@ export interface ResolvedKestrel {
158
163
  dir: string
159
164
  publicDir: string
160
165
  auto: boolean
166
+ publishOnSave: boolean
161
167
  reconcileMinutes: number
162
168
  verbose: boolean
163
169
  s3: ResolvedS3Settings
@@ -394,6 +400,7 @@ export function resolveKestrel(config: KestrelConfig | undefined, env: Env, root
394
400
  dir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_DIR) ?? clean(o.dir) ?? '.data/published'),
395
401
  publicDir: resolveMaybe(rootDir, clean(env.KESTREL_OUTPUT_PUBLIC_DIR) ?? clean(o.publicDir) ?? '.output/public'),
396
402
  auto: envBool(env.KESTREL_OUTPUT_AUTO, o.auto ?? true),
403
+ publishOnSave: envBool(env.KESTREL_OUTPUT_PUBLISH_ON_SAVE, o.publishOnSave ?? false),
397
404
  reconcileMinutes: resolveNonNegInt(o.reconcileMinutes, env.KESTREL_OUTPUT_RECONCILE_MINUTES),
398
405
  verbose: envBool(env.KESTREL_OUTPUT_VERBOSE, o.verbose ?? false),
399
406
  s3: resolveS3Settings(o.s3, env, 'KESTREL_OUTPUT_S3'),
@@ -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`,
@@ -161,6 +161,7 @@ export const fieldTypes: Record<string, FieldTypeDescriptor> = {
161
161
  type: z.literal('external'),
162
162
  // http(s) only, no control chars, no embedded credentials — the value ends up in a static <a href>.
163
163
  url: z.string().trim().pipe(z.url({ protocol: /^https?$/ })).refine((v) => {
164
+ // eslint-disable-next-line no-control-regex -- deliberately rejects control characters embedded in a URL destined for a static <a href>
164
165
  if (/[\u0000-\u001f]/.test(v)) return false
165
166
  const u = new URL(v)
166
167
  return !u.username && !u.password
@@ -168,7 +169,7 @@ export const fieldTypes: Record<string, FieldTypeDescriptor> = {
168
169
  label,
169
170
  }),
170
171
  z.object({ type: z.literal('email'), email: z.string().trim().pipe(z.email()), label }),
171
- z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-.\/]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
172
+ z.object({ type: z.literal('tel'), tel: z.string().trim().min(1).regex(/^[+0-9 ()\-./]+$/).refine((v) => /[0-9]/.test(v), 'Tel must contain at least one digit'), label }),
172
173
  ]),
173
174
  f,
174
175
  )