@setzkasten-cms/astro-admin 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +13 -6
  2. package/src/admin-page.astro +8 -7
  3. package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
  4. package/src/api-routes/__tests__/github-cache.test.ts +100 -0
  5. package/src/api-routes/__tests__/pages.test.ts +72 -0
  6. package/src/api-routes/_auth-guard.ts +32 -0
  7. package/src/api-routes/_commit-trailers.ts +16 -0
  8. package/src/api-routes/_github-cache.ts +32 -0
  9. package/src/api-routes/auth-callback.ts +17 -48
  10. package/src/api-routes/auth-login.ts +18 -65
  11. package/src/api-routes/auth-setzkasten-login.ts +60 -0
  12. package/src/api-routes/catalog-add.ts +10 -1
  13. package/src/api-routes/config.ts +5 -0
  14. package/src/api-routes/editors.ts +136 -0
  15. package/src/api-routes/global-config.ts +132 -0
  16. package/src/api-routes/init-add-section.ts +8 -5
  17. package/src/api-routes/init-apply.ts +2 -1
  18. package/src/api-routes/init-migrate.ts +2 -1
  19. package/src/api-routes/pages.ts +23 -5
  20. package/src/api-routes/section-add.ts +9 -1
  21. package/src/api-routes/section-commit-pending.ts +11 -1
  22. package/src/api-routes/section-delete.ts +10 -1
  23. package/src/api-routes/section-duplicate.ts +11 -1
  24. package/src/api-routes/section-prepare.ts +4 -0
  25. package/src/api-routes/updater-check.ts +49 -0
  26. package/src/api-routes/updater-register.ts +107 -0
  27. package/src/api-routes/updater-transfer.ts +62 -0
  28. package/src/api-routes/updater-unbind.ts +59 -0
  29. package/src/init/__tests__/page-level.test.ts +47 -0
  30. package/src/init/__tests__/section-pipeline.test.ts +3 -1
  31. package/src/init/astro-section-analyzer-v2.ts +29 -2
  32. package/src/init/template-patcher-v2.ts +67 -0
@@ -0,0 +1,60 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
3
+ import { readEditorsFile } from './editors'
4
+ import { readGlobalConfig } from './global-config'
5
+ import { resolveStorageConfig } from './_storage-config'
6
+
7
+ /**
8
+ * POST /api/setzkasten/auth/setzkasten-login
9
+ * Body: { idToken: string } — Firebase ID token from signInWithPopup
10
+ *
11
+ * Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
12
+ * Access is gated exclusively by _editors.json (fail-closed).
13
+ */
14
+ export const POST: APIRoute = async ({ request, cookies }) => {
15
+ const body = await request.json().catch(() => null)
16
+ const idToken = body?.idToken as string | undefined
17
+
18
+ if (!idToken) {
19
+ return new Response('Missing idToken', { status: 400 })
20
+ }
21
+
22
+ const storage = resolveStorageConfig()
23
+ if (!storage) {
24
+ return new Response('Storage not configured', { status: 500 })
25
+ }
26
+ const { owner, repo, branch } = storage
27
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
28
+ const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
29
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''
30
+
31
+ // Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
32
+ const globalCfg = await readGlobalConfig().catch(() => null)
33
+ if (!globalCfg?.firebaseConfig) {
34
+ return new Response('SetzKastenLogin not configured', { status: 500 })
35
+ }
36
+
37
+ // Read editors list — fail-closed: if unreadable, deny all logins
38
+ const editors = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
39
+ if (editors === null) {
40
+ return new Response('Editors list unavailable — no access granted', { status: 503 })
41
+ }
42
+
43
+ const allowedEmails = editors.map((e) => e.email)
44
+ const result = await verifyFirebaseJwt(idToken, allowedEmails)
45
+
46
+ if (!result.ok) {
47
+ return new Response(result.error.message, { status: 403 })
48
+ }
49
+
50
+ const session = result.value
51
+ cookies.set('setzkasten_session', JSON.stringify({ user: session.user, expiresAt: session.expiresAt }), {
52
+ httpOnly: true,
53
+ secure: import.meta.env.PROD,
54
+ sameSite: 'lax',
55
+ path: '/',
56
+ maxAge: 60 * 60 * 24 * 7,
57
+ })
58
+
59
+ return Response.json({ ok: true })
60
+ }
@@ -3,6 +3,8 @@ import { registry } from '@setzkasten-cms/catalog'
3
3
  import { resolveStorageConfig, prefixPath } from './_storage-config'
4
4
  import { validateCatalogAddBody, buildCatalogAddCommit } from './catalog-helpers'
5
5
  import { generateAddKey, addToPageConfig } from './section-management'
6
+ import { parseSession, guardPageAccess } from './_auth-guard'
7
+ import { withTrailers } from './_commit-trailers'
6
8
 
7
9
  /**
8
10
  * POST /api/setzkasten/catalog/add
@@ -37,8 +39,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
37
39
  const { owner, repo, branch, projectPrefix } = storage
38
40
 
39
41
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
42
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
40
43
  const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
41
44
 
45
+ const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
46
+ if (denied) return denied
47
+
42
48
  const headers = {
43
49
  Authorization: `Bearer ${githubToken}`,
44
50
  Accept: 'application/vnd.github+json',
@@ -82,7 +88,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
82
88
  { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
83
89
  { path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
84
90
  ],
85
- `content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
91
+ withTrailers(
92
+ `content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
93
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
94
+ ),
86
95
  headers,
87
96
  )
88
97
 
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { readGlobalConfig } from './global-config'
2
3
 
3
4
  /**
4
5
  * Returns the full SetzKastenConfig as JSON.
@@ -10,6 +11,8 @@ export const GET: APIRoute = async () => {
10
11
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
11
12
  const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
12
13
 
14
+ const globalCfg = await readGlobalConfig().catch(() => null)
15
+
13
16
  const result = {
14
17
  storage: { kind: 'github' },
15
18
  auth: { providers: ['github'] },
@@ -21,6 +24,8 @@ export const GET: APIRoute = async () => {
21
24
  _storage: ssrConfig?.storage ?? undefined,
22
25
  _hasGitHub: ssrConfig?.hasGitHub ?? false,
23
26
  _hasGoogle: ssrConfig?.hasGoogle ?? false,
27
+ // SetzKastenLogin Firebase config (present only when license is valid)
28
+ _firebaseConfig: globalCfg?.firebaseConfig ?? null,
24
29
  }
25
30
 
26
31
  return new Response(JSON.stringify(result), {
@@ -0,0 +1,136 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfig } from './_storage-config'
3
+ import { parseSession } from './_auth-guard'
4
+ import type { ContentEditorConfig } from '@setzkasten-cms/core'
5
+ import { cachedFetch, invalidateCache } from './_github-cache'
6
+ import { withTrailers } from './_commit-trailers'
7
+
8
+ const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // GET /api/setzkasten/editors
12
+ // Returns the current editors list from _editors.json.
13
+ // Any authenticated user may read this (needed for the page-filter in the UI).
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export const GET: APIRoute = async ({ cookies }) => {
17
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
18
+ if (!session) return new Response('Unauthorized', { status: 401 })
19
+
20
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
21
+ if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
22
+
23
+ const storage = resolveStorageConfig()
24
+ if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
25
+
26
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
27
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
28
+ const { owner, repo, branch } = storage
29
+
30
+ const raw = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
31
+ return Response.json(raw ?? [])
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // PUT /api/setzkasten/editors
36
+ // Replaces the editors list. Admin-only.
37
+ // Body: ContentEditorConfig[]
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export const PUT: APIRoute = async ({ request, cookies }) => {
41
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
42
+ if (!session) return new Response('Unauthorized', { status: 401 })
43
+ if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
44
+
45
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
46
+ if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
47
+
48
+ const storage = resolveStorageConfig()
49
+ if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
50
+
51
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
52
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
53
+ const { owner, repo, branch } = storage
54
+
55
+ let editors: ContentEditorConfig[]
56
+ try {
57
+ editors = (await request.json()) as ContentEditorConfig[]
58
+ if (!Array.isArray(editors)) throw new Error('Expected array')
59
+ } catch {
60
+ return Response.json({ error: 'Invalid request body' }, { status: 400 })
61
+ }
62
+
63
+ const filePath = EDITORS_FILE(contentPath)
64
+ const fileContent = JSON.stringify(editors, null, 2)
65
+ const headers = {
66
+ Authorization: `Bearer ${githubToken}`,
67
+ Accept: 'application/vnd.github+json',
68
+ 'X-GitHub-Api-Version': '2022-11-28',
69
+ 'Content-Type': 'application/json',
70
+ }
71
+
72
+ // Get current SHA if the file already exists (needed for updates)
73
+ const existing = await fetchFileSha(owner, repo, branch, filePath, headers)
74
+
75
+ const body: Record<string, unknown> = {
76
+ message: withTrailers('chore(editors): update content editor permissions'),
77
+ content: Buffer.from(fileContent).toString('base64'),
78
+ branch,
79
+ }
80
+ if (existing) body.sha = existing
81
+
82
+ const res = await fetch(
83
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
84
+ { method: 'PUT', headers, body: JSON.stringify(body) },
85
+ )
86
+
87
+ if (!res.ok) {
88
+ const text = await res.text()
89
+ return Response.json({ error: `GitHub write failed: ${text}` }, { status: 502 })
90
+ }
91
+
92
+ invalidateCache(`editors:${owner}/${repo}:${branch}`)
93
+ return Response.json({ ok: true })
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Helpers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ async function fetchFileSha(
101
+ owner: string,
102
+ repo: string,
103
+ branch: string,
104
+ path: string,
105
+ headers: Record<string, string>,
106
+ ): Promise<string | null> {
107
+ try {
108
+ const res = await fetch(
109
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
110
+ { headers },
111
+ )
112
+ if (!res.ok) return null
113
+ const data = await res.json() as { sha: string }
114
+ return data.sha ?? null
115
+ } catch { return null }
116
+ }
117
+
118
+ export async function readEditorsFile(
119
+ owner: string,
120
+ repo: string,
121
+ branch: string,
122
+ contentPath: string,
123
+ token: string,
124
+ ): Promise<ContentEditorConfig[] | null> {
125
+ const key = `editors:${owner}/${repo}:${branch}`
126
+ return cachedFetch(key, 2 * 60_000, async () => {
127
+ const res = await fetch(
128
+ `https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
129
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
130
+ )
131
+ if (!res.ok) return null
132
+ const data = await res.json() as { content: string; encoding: string }
133
+ const raw = data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
134
+ return JSON.parse(raw) as ContentEditorConfig[]
135
+ })
136
+ }
@@ -0,0 +1,132 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { parseSession } from './_auth-guard'
3
+ import { resolveStorageConfig } from './_storage-config'
4
+ import { cachedFetch, invalidateCache } from './_github-cache'
5
+ import { withTrailers } from './_commit-trailers'
6
+
7
+ const GLOBAL_CONFIG_FILE = (contentPath: string) => `${contentPath}/_global_config.json`
8
+
9
+ export interface GlobalConfig {
10
+ firebaseConfig?: {
11
+ apiKey: string
12
+ authDomain: string
13
+ projectId: string
14
+ }
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // GET /api/setzkasten/global-config — any authenticated user
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export const GET: APIRoute = async ({ cookies }) => {
22
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
23
+ if (!session) return new Response('Unauthorized', { status: 401 })
24
+
25
+ const cfg = await readGlobalConfig()
26
+ return Response.json(cfg ?? {})
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // PUT /api/setzkasten/global-config — admin only
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const PUT: APIRoute = async ({ request, cookies }) => {
34
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
35
+ if (!session) return new Response('Unauthorized', { status: 401 })
36
+ if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
37
+
38
+ let patch: Partial<GlobalConfig>
39
+ try {
40
+ patch = (await request.json()) as Partial<GlobalConfig>
41
+ } catch {
42
+ return Response.json({ error: 'Invalid request body' }, { status: 400 })
43
+ }
44
+
45
+ const current = await readGlobalConfig() ?? {}
46
+ const next: GlobalConfig = { ...current }
47
+ for (const [k, v] of Object.entries(patch)) {
48
+ if (v === null) delete (next as Record<string, unknown>)[k]
49
+ else (next as Record<string, unknown>)[k] = v
50
+ }
51
+ await writeGlobalConfig(next)
52
+ return Response.json({ ok: true })
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function getStorageParams() {
60
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
61
+ const storage = resolveStorageConfig()
62
+ if (!storage) return null
63
+ return {
64
+ owner: storage.owner,
65
+ repo: storage.repo,
66
+ branch: storage.branch,
67
+ contentPath: serverConfig?.storage?.contentPath ?? 'content',
68
+ token: (import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? '') as string,
69
+ }
70
+ }
71
+
72
+ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
73
+ const params = getStorageParams()
74
+ if (!params) return null
75
+ const { owner, repo, branch, contentPath, token } = params
76
+ const key = `global-config:${owner}/${repo}:${branch}`
77
+ return cachedFetch(key, 5 * 60_000, async () => {
78
+ const res = await fetch(
79
+ `https://api.github.com/repos/${owner}/${repo}/contents/${GLOBAL_CONFIG_FILE(contentPath)}?ref=${branch}`,
80
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
81
+ )
82
+ if (!res.ok) return null
83
+ const data = await res.json() as { content: string; encoding: string }
84
+ const raw = data.encoding === 'base64'
85
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
86
+ : data.content
87
+ return JSON.parse(raw) as GlobalConfig
88
+ })
89
+ }
90
+
91
+ export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
92
+ const params = getStorageParams()
93
+ if (!params) throw new Error('Storage not configured')
94
+ const { owner, repo, branch, contentPath, token } = params
95
+ invalidateCache(`global-config:${owner}/${repo}:${branch}`)
96
+ const filePath = GLOBAL_CONFIG_FILE(contentPath)
97
+ const headers = {
98
+ Authorization: `Bearer ${token}`,
99
+ Accept: 'application/vnd.github+json',
100
+ 'X-GitHub-Api-Version': '2022-11-28',
101
+ 'Content-Type': 'application/json',
102
+ }
103
+
104
+ // Get current SHA if file exists
105
+ let sha: string | undefined
106
+ try {
107
+ const existing = await fetch(
108
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
109
+ { headers },
110
+ )
111
+ if (existing.ok) {
112
+ const data = await existing.json() as { sha: string }
113
+ sha = data.sha
114
+ }
115
+ } catch { /* file doesn't exist yet */ }
116
+
117
+ const body: Record<string, unknown> = {
118
+ message: withTrailers('chore(config): update global config'),
119
+ content: Buffer.from(JSON.stringify(config, null, 2)).toString('base64'),
120
+ branch,
121
+ }
122
+ if (sha) body.sha = sha
123
+
124
+ const res = await fetch(
125
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
126
+ { method: 'PUT', headers, body: JSON.stringify(body) },
127
+ )
128
+ if (!res.ok) {
129
+ const text = await res.text()
130
+ throw new Error(`GitHub write failed: ${text}`)
131
+ }
132
+ }
@@ -4,6 +4,7 @@ import { addSectionToConfig } from '@setzkasten-cms/core/init'
4
4
  import { resolveStorageConfig, prefixPath } from './_storage-config'
5
5
  import { patchTemplateForFields, stripTemplateFallbacks } from '../init/template-patcher-v2'
6
6
  import type { RepeatedGroup } from '../init/analyzer-types'
7
+ import { withTrailers } from './_commit-trailers'
7
8
 
8
9
  /**
9
10
  * POST /api/setzkasten/init/add-section
@@ -84,7 +85,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
84
85
  // Only add values for fields that don't already exist
85
86
  for (const field of section.fields) {
86
87
  if (!(field.key in sectionData)) {
87
- sectionData[field.key] = field.defaultValue ?? getDefaultValue(field.type)
88
+ let value = field.defaultValue ?? getDefaultValue(field.type)
89
+ if (Array.isArray(value)) value = value.filter((item: unknown) => item != null)
90
+ sectionData[field.key] = value
88
91
  }
89
92
  }
90
93
 
@@ -208,7 +211,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
208
211
  for (const g of repeatedGroups) {
209
212
  const topField = fields.find(f => f.key === g.fieldKey)
210
213
  if (!topField || !Array.isArray(topField.defaultValue)) continue
211
- sectionData[g.fieldKey] = topField.defaultValue
214
+ sectionData[g.fieldKey] = (topField.defaultValue as unknown[]).filter(item => item != null)
212
215
  const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
213
216
  if (jsonIdx !== -1) {
214
217
  filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
@@ -232,7 +235,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
232
235
  for (const g of repeatedGroups) {
233
236
  const topField = fields.find(f => f.key === g.fieldKey)
234
237
  if (!topField || !Array.isArray(topField.defaultValue)) continue
235
- const items = topField.defaultValue as Array<Record<string, unknown>>
238
+ const items = (topField.defaultValue as Array<Record<string, unknown>>).filter(item => item != null)
236
239
 
237
240
  // Update sectionData with the enriched items array
238
241
  sectionData[g.fieldKey] = items
@@ -279,9 +282,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
279
282
  repo,
280
283
  branch,
281
284
  filesToCommit,
282
- existingSectionJson
285
+ withTrailers(existingSectionJson
283
286
  ? `content: update ${section.key} section — add new fields`
284
- : `content: add ${section.key} section to Setzkasten`,
287
+ : `content: add ${section.key} section to Setzkasten`),
285
288
  headers,
286
289
  )
287
290
 
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'
2
2
  import { generateConfigFile, type InferredSection, type ConfigGeneratorInput } from '@setzkasten-cms/core/init'
3
3
  import { patchAstroConfig } from '../init/astro-config-patcher'
4
4
  import { patchTemplateForFields } from '../init/template-patcher-v2'
5
+ import { withTrailers } from './_commit-trailers'
5
6
 
6
7
  interface ApplyRequest {
7
8
  owner: string
@@ -115,7 +116,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
115
116
  repo,
116
117
  branch,
117
118
  filesToCommit,
118
- 'feat: initialize Setzkasten CMS',
119
+ withTrailers('feat: initialize Setzkasten CMS'),
119
120
  githubToken,
120
121
  )
121
122
 
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'
2
2
  import type { SetzKastenConfig } from '@setzkasten-cms/core'
3
3
  import { resolveStorageConfig, prefixPath } from './_storage-config'
4
4
  import { patchTemplateForFields } from '../init/template-patcher-v2'
5
+ import { withTrailers } from './_commit-trailers'
5
6
 
6
7
  /**
7
8
  * POST /api/setzkasten/init/migrate
@@ -108,7 +109,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
108
109
  repo,
109
110
  branch,
110
111
  [{ path: componentPath, content: patched }],
111
- `chore: add live-preview bindings to ${sectionKey} section`,
112
+ withTrailers(`chore: add live-preview bindings to ${sectionKey} section`),
112
113
  headers,
113
114
  )
114
115
 
@@ -1,14 +1,32 @@
1
1
  import type { APIRoute } from 'astro'
2
2
 
3
+ interface PageInfo {
4
+ path: string
5
+ pageKey: string
6
+ label: string
7
+ hasConfig: boolean
8
+ }
9
+
10
+ // Build-time constant injected by the Vite define plugin — always available in
11
+ // compiled API routes (unlike page-ssr injectScript which only runs for SSR pages).
12
+ declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
13
+
14
+ /**
15
+ * Returns the list of pages scanned at build time.
16
+ * Reads the Vite build-time constant first; falls back to globalThis for
17
+ * local dev / test environments where the define is not applied.
18
+ */
19
+ export function resolvePages(): PageInfo[] {
20
+ const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
21
+ return buildPages ?? (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ as PageInfo[] ?? []
22
+ }
23
+
3
24
  /**
4
- * Returns the list of pages detected at build time.
5
- * The pages are scanned from src/pages/ by the integration hook
6
- * and injected into globalThis.__SETZKASTEN_PAGES__.
7
- *
8
25
  * GET /api/setzkasten/pages
26
+ * Returns the list of pages detected at build time.
9
27
  */
10
28
  export const GET: APIRoute = async () => {
11
- const pages = (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ ?? []
29
+ const pages = resolvePages()
12
30
 
13
31
  return new Response(JSON.stringify({ pages }), {
14
32
  status: 200,
@@ -1,6 +1,8 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { resolveStorageConfig, prefixPath } from './_storage-config'
3
3
  import { generateAddKey, addToPageConfig } from './section-management'
4
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
4
6
 
5
7
  /**
6
8
  * POST /api/setzkasten/sections/add
@@ -47,6 +49,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
47
49
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
48
50
  }
49
51
 
52
+ const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
53
+ if (denied) return denied
54
+
50
55
  const headers = {
51
56
  Authorization: `Bearer ${githubToken}`,
52
57
  Accept: 'application/vnd.github+json',
@@ -97,7 +102,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
97
102
  { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
98
103
  { path: sectionJsonPath, content: JSON.stringify(defaultContent, null, 2) },
99
104
  ],
100
- `content: add ${sectionType} section "${newKey}" to ${pageKey}`,
105
+ withTrailers(
106
+ `content: add ${sectionType} section "${newKey}" to ${pageKey}`,
107
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
108
+ ),
101
109
  headers,
102
110
  )
103
111
 
@@ -2,6 +2,8 @@ import type { APIRoute } from 'astro'
2
2
  import { writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { resolveStorageConfig } from './_storage-config'
5
+ import { parseSession, guardPageAccess } from './_auth-guard'
6
+ import { withTrailers } from './_commit-trailers'
5
7
 
6
8
  /**
7
9
  * POST /api/setzkasten/sections/commit-pending
@@ -40,6 +42,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
40
42
  const { owner, repo, branch } = storage
41
43
 
42
44
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
45
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
43
46
  const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
44
47
  const { pageKey, pageConfig, sections, edits = [] } = body
45
48
 
@@ -47,6 +50,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
47
50
  return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
48
51
  }
49
52
 
53
+ const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
54
+ if (denied) return denied
55
+
50
56
  const headers = {
51
57
  Authorization: `Bearer ${githubToken}`,
52
58
  Accept: 'application/vnd.github+json',
@@ -79,7 +85,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
79
85
  const keys = edits.map(s => s.key).join(', ')
80
86
  parts.push(`update ${edits.length} section${edits.length > 1 ? 's' : ''} (${keys})`)
81
87
  }
82
- const commitMessage = `content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`
88
+ const editorEmail = parseSession(cookies.get('setzkasten_session')?.value)?.user?.email
89
+ const commitMessage = withTrailers(
90
+ `content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`,
91
+ editorEmail,
92
+ )
83
93
  const commitResult = await batchCommit(owner, repo, branch, files, commitMessage, headers)
84
94
 
85
95
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
@@ -1,6 +1,8 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { resolveStorageConfig, prefixPath } from './_storage-config'
3
3
  import { removeFromPageConfig } from './section-management'
4
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
4
6
 
5
7
  /**
6
8
  * DELETE /api/setzkasten/sections
@@ -34,6 +36,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
34
36
  const { owner, repo, branch, projectPrefix } = storage
35
37
 
36
38
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
39
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
37
40
  const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
38
41
  const { pageKey, sectionKey } = body
39
42
 
@@ -41,6 +44,9 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
41
44
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
42
45
  }
43
46
 
47
+ const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
48
+ if (denied) return denied
49
+
44
50
  const headers = {
45
51
  Authorization: `Bearer ${githubToken}`,
46
52
  Accept: 'application/vnd.github+json',
@@ -65,7 +71,10 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
65
71
  owner, repo, branch,
66
72
  [{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) }],
67
73
  [sectionJsonPath],
68
- `content: remove ${sectionKey} section from ${pageKey}`,
74
+ withTrailers(
75
+ `content: remove ${sectionKey} section from ${pageKey}`,
76
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
77
+ ),
69
78
  headers,
70
79
  )
71
80
 
@@ -1,6 +1,8 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { resolveStorageConfig, prefixPath } from './_storage-config'
3
3
  import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
4
6
 
5
7
  /**
6
8
  * POST /api/setzkasten/sections/duplicate
@@ -34,6 +36,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
34
36
  const { owner, repo, branch, projectPrefix } = storage
35
37
 
36
38
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
39
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
37
40
  const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
38
41
  const { pageKey, sectionKey } = body
39
42
 
@@ -41,6 +44,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
41
44
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
42
45
  }
43
46
 
47
+ const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
48
+ if (denied) return denied
49
+
44
50
  const headers = {
45
51
  Authorization: `Bearer ${githubToken}`,
46
52
  Accept: 'application/vnd.github+json',
@@ -77,7 +83,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
77
83
  }
78
84
 
79
85
  const commitResult = await batchCommit(owner, repo, branch, filesToCommit,
80
- `content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`, headers)
86
+ withTrailers(
87
+ `content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`,
88
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
89
+ ),
90
+ headers)
81
91
 
82
92
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
83
93