@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.
- package/README.md +8 -4
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/access/server/utils/ip-allowlist.ts +4 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +6 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
- package/layers/collections/server/utils/populate-relations.ts +18 -5
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/api/[collection]/[id].get.ts +4 -1
- package/layers/core/server/api/[collection]/index.get.ts +7 -2
- package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
- package/layers/core/server/api/references/broken.get.ts +7 -1
- package/layers/core/server/schema/dialect.ts +5 -3
- package/layers/core/server/schema/introspect.ts +4 -3
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +11 -10
- package/layers/core/server/utils/kestrel-config.ts +10 -3
- package/layers/core/server/utils/populate.ts +3 -1
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/media/server/utils/backfill.ts +24 -12
- package/layers/media/server/utils/variants.ts +27 -12
- package/layers/public/app/pages/[...slug].vue +35 -7
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/server/api/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/api/route.get.ts +26 -6
- package/layers/public/server/plugins/02.register-links.ts +4 -4
- package/layers/public/server/plugins/zz.publish.ts +13 -4
- package/layers/public/server/routes/llms.txt.get.ts +5 -2
- package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/link-resolve.ts +5 -2
- package/layers/public/server/utils/page-resolve.ts +23 -9
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +43 -4
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +74 -9
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +9 -4
- package/scripts/kestrel.mjs +48 -19
- package/scripts/lib/scaffold.mjs +39 -8
- 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(
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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')
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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,
|
|
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)
|
|
5
|
-
//
|
|
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
|
|
@@ -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` —
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
126
|
-
*
|
|
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
|
-
|
|
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 ()
|
|
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
|
)
|