@michaelthielemann/kestrel 1.5.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.
- package/layers/access/server/utils/ip-allowlist.ts +4 -1
- 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/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 +3 -2
- package/layers/core/server/utils/crud.ts +6 -6
- package/layers/core/server/utils/kestrel-config.ts +3 -3
- package/layers/core/server/utils/populate.ts +3 -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 +12 -3
- 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 +1 -1
- 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/utils/link-resolve.ts +5 -2
- package/layers/public/server/utils/page-resolve.ts +23 -9
- package/layers/public/server/utils/publish/invalidation.ts +19 -4
- package/package.json +25 -25
- package/scripts/kestrel.mjs +41 -16
- package/scripts/lib/scaffold.mjs +16 -7
|
@@ -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
|
-
|
|
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(
|
|
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
|
}
|
|
@@ -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
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)
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
|
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
|
-
|
|
104
|
-
// config fallback rather than 500 the upload. Mirrors attachDeadRefs tolerating a missing record_refs.
|
|
105
|
-
row = undefined
|
|
109
|
+
return null
|
|
106
110
|
}
|
|
107
|
-
|
|
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' })
|
|
@@ -112,9 +119,11 @@ const head = buildPageHead({
|
|
|
112
119
|
useHead({
|
|
113
120
|
htmlAttrs: { lang: locale },
|
|
114
121
|
link: [
|
|
115
|
-
|
|
122
|
+
// `rel` needs the literal type: unhead keys its link union on it, and inside an array literal that
|
|
123
|
+
// reaches `link:` through a spread there is no contextual type to stop TS widening it to `string`.
|
|
124
|
+
...(head.canonical ? [{ rel: 'canonical' as const, href: head.canonical }] : []),
|
|
116
125
|
...head.links,
|
|
117
|
-
{ rel: 'alternate', type: 'text/markdown', href: '/llms.txt', title: 'llms.txt' },
|
|
126
|
+
{ rel: 'alternate' as const, type: 'text/markdown', href: '/llms.txt', title: 'llms.txt' },
|
|
118
127
|
],
|
|
119
128
|
})
|
|
120
129
|
useSeoMeta({
|
|
@@ -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
|
|
20
|
-
//
|
|
21
|
-
// `depth: 1` resolves the sharing image into `$media`;
|
|
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
|
-
|
|
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).
|
|
7
|
-
//
|
|
8
|
-
// is
|
|
9
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* page-like collections happen to share
|
|
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):
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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>",
|
|
@@ -59,27 +59,30 @@
|
|
|
59
59
|
"hash-password": "node scripts/hash-password.mjs"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@babel/parser": "^7.
|
|
63
|
-
"@internationalized/date": "^3.12.
|
|
64
|
-
"@tiptap/extension-highlight": "^3.
|
|
65
|
-
"@tiptap/extension-subscript": "^3.
|
|
66
|
-
"@tiptap/extension-superscript": "^3.
|
|
67
|
-
"@tiptap/extension-text-align": "^3.
|
|
68
|
-
"@tiptap/pm": "^3.
|
|
69
|
-
"@tiptap/starter-kit": "^3.
|
|
70
|
-
"@tiptap/vue-3": "^3.
|
|
62
|
+
"@babel/parser": "^7.29.8",
|
|
63
|
+
"@internationalized/date": "^3.12.3",
|
|
64
|
+
"@tiptap/extension-highlight": "^3.29.2",
|
|
65
|
+
"@tiptap/extension-subscript": "^3.29.2",
|
|
66
|
+
"@tiptap/extension-superscript": "^3.29.2",
|
|
67
|
+
"@tiptap/extension-text-align": "^3.29.2",
|
|
68
|
+
"@tiptap/pm": "^3.29.2",
|
|
69
|
+
"@tiptap/starter-kit": "^3.29.2",
|
|
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
|
-
"dompurify": "^3.4.
|
|
76
|
+
"dompurify": "^3.4.13",
|
|
74
77
|
"drizzle-orm": "^0.45.2",
|
|
75
78
|
"drizzle-zod": "^0.8.3",
|
|
76
79
|
"file-type": "^22.0.1",
|
|
77
80
|
"jsdom": "^29.1.1",
|
|
78
81
|
"nanoid": "^5.1.16",
|
|
79
|
-
"nuxt": "^4.
|
|
82
|
+
"nuxt": "^4.5.2",
|
|
80
83
|
"reka-ui": "^2.10.1",
|
|
81
|
-
"sanitize-html": "^2.17.
|
|
82
|
-
"sass": "^1.
|
|
84
|
+
"sanitize-html": "^2.17.6",
|
|
85
|
+
"sass": "^1.102.0",
|
|
83
86
|
"sharp": "^0.35.3",
|
|
84
87
|
"thumbhash": "^0.1.1",
|
|
85
88
|
"typescript": "^6.0.3",
|
|
@@ -107,20 +110,17 @@
|
|
|
107
110
|
}
|
|
108
111
|
},
|
|
109
112
|
"devDependencies": {
|
|
110
|
-
"@nuxt/kit": "^4.
|
|
111
|
-
"@nuxt/test-utils": "^
|
|
112
|
-
"@
|
|
113
|
-
"@types/jsdom": "^28.0.3",
|
|
114
|
-
"@types/sanitize-html": "^2.16.1",
|
|
115
|
-
"@vitejs/plugin-vue": "^6.0.7",
|
|
113
|
+
"@nuxt/kit": "^4.5.2",
|
|
114
|
+
"@nuxt/test-utils": "^4.1.0",
|
|
115
|
+
"@vitejs/plugin-vue": "^6.0.8",
|
|
116
116
|
"@vue/test-utils": "^2.4.11",
|
|
117
117
|
"drizzle-kit": "^0.31.10",
|
|
118
118
|
"h3": "^1.15.11",
|
|
119
|
-
"happy-dom": "^20.
|
|
120
|
-
"playwright-core": "^1.
|
|
121
|
-
"vitest": "^
|
|
119
|
+
"happy-dom": "^20.11.2",
|
|
120
|
+
"playwright-core": "^1.62.1",
|
|
121
|
+
"vitest": "^4.1.10",
|
|
122
122
|
"vue": "3.5.35",
|
|
123
|
-
"vue-router": "^5.
|
|
124
|
-
"vue-tsc": "^3.3.
|
|
123
|
+
"vue-router": "^5.2.0",
|
|
124
|
+
"vue-tsc": "^3.3.9"
|
|
125
125
|
}
|
|
126
126
|
}
|
package/scripts/kestrel.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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 =
|
|
117
|
-
|
|
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
|
|
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)
|
package/scripts/lib/scaffold.mjs
CHANGED
|
@@ -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
|
-
|
|
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] =
|
|
94
|
+
merged[key] = isPlainObject(existing[key]) ? { ...template[key], ...existing[key] } : template[key]
|
|
91
95
|
for (const name of Object.keys(template[key])) {
|
|
92
|
-
if (!
|
|
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
|
-
|
|
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
|
}
|