@setzkasten-cms/astro-admin 1.4.6 → 1.5.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 (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +113 -47
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-Q3N336KR.js → chunk-CDXCYYQR.js} +29 -24
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-TD76R3A6.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +59 -25
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +174 -79
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +12 -3
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +53 -19
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +218 -97
@@ -1,7 +1,7 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { exportTemplate } from '@setzkasten-cms/catalog'
3
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
2
+ import type { APIRoute } from 'astro'
4
3
  import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
5
5
 
6
6
  /**
7
7
  * POST /api/setzkasten/catalog/export
@@ -22,7 +22,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
22
22
  const githubToken = tokenResult.value
23
23
 
24
24
  try {
25
- const body = await request.json() as {
25
+ const body = (await request.json()) as {
26
26
  sectionKey: string
27
27
  owner?: string
28
28
  repo?: string
@@ -46,14 +46,18 @@ export const POST: APIRoute = async ({ request, cookies }) => {
46
46
  // 1. Read section content JSON
47
47
  const sectionJsonPath = prefixPath(`${contentPath}/_sections/${sectionKey}.json`, projectPrefix)
48
48
  const contentRaw = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
49
- if (!contentRaw) return Response.json({ error: `Section content not found: ${sectionKey}` }, { status: 404 })
49
+ if (!contentRaw)
50
+ return Response.json({ error: `Section content not found: ${sectionKey}` }, { status: 404 })
50
51
 
51
52
  const content = JSON.parse(contentRaw) as Record<string, unknown>
52
53
 
53
54
  // 2. Find section definition from full config
54
55
  const sectionDef = findSectionDef(fullConfig, sectionKey)
55
56
  if (!sectionDef) {
56
- return Response.json({ error: `Section definition not found for key: ${sectionKey}` }, { status: 404 })
57
+ return Response.json(
58
+ { error: `Section definition not found for key: ${sectionKey}` },
59
+ { status: 404 },
60
+ )
57
61
  }
58
62
 
59
63
  // 3. Export to template format
@@ -77,14 +81,30 @@ function findSectionDef(fullConfig: any, sectionKey: string): any {
77
81
  return null
78
82
  }
79
83
 
80
- async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
84
+ async function fetchFileContent(
85
+ owner: string,
86
+ repo: string,
87
+ branch: string,
88
+ path: string,
89
+ token: string,
90
+ ): Promise<string | null> {
81
91
  try {
82
92
  const res = await fetch(
83
93
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
84
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
94
+ {
95
+ headers: {
96
+ Authorization: `Bearer ${token}`,
97
+ Accept: 'application/vnd.github+json',
98
+ 'X-GitHub-Api-Version': '2022-11-28',
99
+ },
100
+ },
85
101
  )
86
102
  if (!res.ok) return null
87
- const data = await res.json() as { content: string; encoding: string }
88
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
89
- } catch { return null }
103
+ const data = (await res.json()) as { content: string; encoding: string }
104
+ return data.encoding === 'base64'
105
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
106
+ : data.content
107
+ } catch {
108
+ return null
109
+ }
90
110
  }
@@ -1,22 +1,55 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { parseSession } from './_auth-guard'
2
3
  import { readGlobalConfig } from './global-config'
3
4
 
4
5
  /**
5
6
  * GET /api/setzkasten/config
6
7
  *
7
- * Returns the full SetzKastenConfig as JSON. Fields from GlobalConfig
8
- * (stored in _global_config.json) are merged over the static config so
9
- * admins can change theme and Firebase settings without a code deployment.
8
+ * Returns the SetzKastenConfig as JSON. The login screen (pre-session)
9
+ * needs to know which auth providers are enabled and Firebase project
10
+ * id, so a *minimal public subset* is exposed unauthenticated. The full
11
+ * config (storage params, products, page schema, etc.) is only served
12
+ * to authenticated users — pre-fix anyone could harvest the deployment
13
+ * blueprint.
14
+ *
15
+ * Fields from GlobalConfig (`_global_config.json`) are merged over the
16
+ * static config so admins can change theme and Firebase settings
17
+ * without a code deployment.
10
18
  */
11
- export const GET: APIRoute = async () => {
19
+ export const GET: APIRoute = async ({ cookies }) => {
12
20
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
13
- const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
21
+ const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
22
+ | Record<string, unknown>
23
+ | undefined
14
24
 
15
25
  const globalCfg = await readGlobalConfig().catch(() => null)
16
26
 
17
- const staticTheme = (config as any).theme ?? {}
27
+ const staticTheme = (config as Record<string, unknown>).theme ?? {}
18
28
  const globalTheme = globalCfg?.theme ?? {}
19
29
 
30
+ const session = parseSession(cookies?.get('setzkasten_session')?.value)
31
+
32
+ // Public subset for the login screen. Provider list is intentionally
33
+ // visible — the SPA needs to render the right buttons. Firebase config
34
+ // is needed for `setzkasten-login` (Google IdP) before the user has a
35
+ // session cookie. Theme is cosmetic. Everything else is gated.
36
+ if (!session) {
37
+ return new Response(
38
+ JSON.stringify({
39
+ auth: (config as Record<string, unknown>).auth ?? { providers: ['github'] },
40
+ theme: { ...staticTheme, ...globalTheme },
41
+ adminPath: ssrConfig?.adminPath ?? '/admin',
42
+ _firebaseConfig: globalCfg?.firebaseConfig ?? null,
43
+ _hasGitHub: ssrConfig?.hasGitHub ?? false,
44
+ _hasGoogle: ssrConfig?.hasGoogle ?? false,
45
+ }),
46
+ {
47
+ status: 200,
48
+ headers: { 'Content-Type': 'application/json' },
49
+ },
50
+ )
51
+ }
52
+
20
53
  const result = {
21
54
  // Default fallback when no config is injected at build time. Real
22
55
  // values are spread from `config` below.
@@ -15,9 +15,11 @@ export const POST: APIRoute = async ({ cookies }) => {
15
15
  return new Response('Unauthorized', { status: 401 })
16
16
  }
17
17
 
18
- const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
19
- deployHook?: { url: string; secret?: string }
20
- } | undefined
18
+ const config = (globalThis as any).__SETZKASTEN_CONFIG__ as
19
+ | {
20
+ deployHook?: { url: string; secret?: string }
21
+ }
22
+ | undefined
21
23
 
22
24
  if (!config?.deployHook?.url) {
23
25
  return new Response(JSON.stringify({ skipped: true, reason: 'Kein deployHook konfiguriert' }), {
@@ -49,10 +51,10 @@ export const POST: APIRoute = async ({ cookies }) => {
49
51
 
50
52
  if (!response.ok) {
51
53
  console.warn(`[setzkasten] Deploy hook antwortete mit ${response.status}: ${url}`)
52
- return new Response(
53
- JSON.stringify({ ok: false, status: response.status }),
54
- { status: 200, headers: { 'Content-Type': 'application/json' } },
55
- )
54
+ return new Response(JSON.stringify({ ok: false, status: response.status }), {
55
+ status: 200,
56
+ headers: { 'Content-Type': 'application/json' },
57
+ })
56
58
  }
57
59
 
58
60
  return new Response(JSON.stringify({ ok: true }), {
@@ -61,9 +63,9 @@ export const POST: APIRoute = async ({ cookies }) => {
61
63
  })
62
64
  } catch (error) {
63
65
  console.error('[setzkasten] Deploy hook fehlgeschlagen:', error)
64
- return new Response(
65
- JSON.stringify({ ok: false, error: String(error) }),
66
- { status: 200, headers: { 'Content-Type': 'application/json' } },
67
- )
66
+ return new Response(JSON.stringify({ ok: false, error: String(error) }), {
67
+ status: 200,
68
+ headers: { 'Content-Type': 'application/json' },
69
+ })
68
70
  }
69
71
  }
@@ -1,12 +1,12 @@
1
- import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig } from './_storage-config'
3
- import { parseSession } from './_auth-guard'
4
- import { resolveConfigRepoToken } from './_github-token'
5
1
  import type { ContentEditorConfig } from '@setzkasten-cms/core'
6
2
  import { validateEditorsUpdate } from '@setzkasten-cms/core'
7
- import { cachedFetch, invalidateCache } from './_github-cache'
3
+ import type { APIRoute } from 'astro'
4
+ import { parseSession } from './_auth-guard'
8
5
  import { withTrailers } from './_commit-trailers'
9
6
  import { gateFeature } from './_feature-gate'
7
+ import { cachedFetch, invalidateCache } from './_github-cache'
8
+ import { resolveConfigRepoToken } from './_github-token'
9
+ import { resolveStorageConfig } from './_storage-config'
10
10
 
11
11
  const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
12
12
 
@@ -36,8 +36,9 @@ export const GET: APIRoute = async ({ cookies }) => {
36
36
  const storage = configRepoStorage()
37
37
  if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
38
38
 
39
- const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
40
- .__SETZKASTEN_CONFIG__
39
+ const serverConfig = (
40
+ globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } }
41
+ ).__SETZKASTEN_CONFIG__
41
42
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
42
43
  const { owner, repo, branch } = storage
43
44
 
@@ -67,8 +68,9 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
67
68
  const storage = configRepoStorage()
68
69
  if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
69
70
 
70
- const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
71
- .__SETZKASTEN_CONFIG__
71
+ const serverConfig = (
72
+ globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } }
73
+ ).__SETZKASTEN_CONFIG__
72
74
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
73
75
  const { owner, repo, branch } = storage
74
76
 
@@ -82,10 +84,7 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
82
84
 
83
85
  const validation = validateEditorsUpdate(editors, session.user.email)
84
86
  if (!validation.ok) {
85
- return Response.json(
86
- { error: validation.message, code: validation.code },
87
- { status: 400 },
88
- )
87
+ return Response.json({ error: validation.message, code: validation.code }, { status: 400 })
89
88
  }
90
89
 
91
90
  const filePath = EDITORS_FILE(contentPath)
@@ -107,10 +106,11 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
107
106
  }
108
107
  if (existing) body.sha = existing
109
108
 
110
- const res = await fetch(
111
- `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
112
- { method: 'PUT', headers, body: JSON.stringify(body) },
113
- )
109
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, {
110
+ method: 'PUT',
111
+ headers,
112
+ body: JSON.stringify(body),
113
+ })
114
114
 
115
115
  if (!res.ok) {
116
116
  const text = await res.text()
@@ -138,9 +138,11 @@ async function fetchFileSha(
138
138
  { headers },
139
139
  )
140
140
  if (!res.ok) return null
141
- const data = await res.json() as { sha: string }
141
+ const data = (await res.json()) as { sha: string }
142
142
  return data.sha ?? null
143
- } catch { return null }
143
+ } catch {
144
+ return null
145
+ }
144
146
  }
145
147
 
146
148
  export async function readEditorsFile(
@@ -154,11 +156,20 @@ export async function readEditorsFile(
154
156
  return cachedFetch(key, 2 * 60_000, async () => {
155
157
  const res = await fetch(
156
158
  `https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
157
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
159
+ {
160
+ headers: {
161
+ Authorization: `Bearer ${token}`,
162
+ Accept: 'application/vnd.github+json',
163
+ 'X-GitHub-Api-Version': '2022-11-28',
164
+ },
165
+ },
158
166
  )
159
167
  if (!res.ok) return null
160
- const data = await res.json() as { content: string; encoding: string }
161
- const raw = data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
168
+ const data = (await res.json()) as { content: string; encoding: string }
169
+ const raw =
170
+ data.encoding === 'base64'
171
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
172
+ : data.content
162
173
  return JSON.parse(raw) as ContentEditorConfig[]
163
174
  })
164
175
  }
@@ -1,7 +1,9 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { writeFile } from 'node:fs/promises'
3
2
  import { join } from 'node:path'
3
+ import type { APIRoute } from 'astro'
4
+ import { parseSession } from './_auth-guard'
4
5
  import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { resolveStorageConfigForRequest } from './_storage-config'
5
7
 
6
8
  /**
7
9
  * Server-side proxy for GitHub API calls.
@@ -12,7 +14,7 @@ import { resolveGitHubTokenForRequest } from './_github-token'
12
14
  */
13
15
  export const ALL: APIRoute = async ({ params, request, cookies }) => {
14
16
  // Verify session
15
- const session = cookies.get('setzkasten_session')?.value
17
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
16
18
  if (!session) {
17
19
  return new Response('Unauthorized', { status: 401 })
18
20
  }
@@ -22,6 +24,22 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => {
22
24
  return new Response('Missing path', { status: 400 })
23
25
  }
24
26
 
27
+ // Restrict the proxy to the resolved website's repo. Pre-fix any
28
+ // editor (or anyone post-C1 forgery) could call
29
+ // `repos/{any-app-installed-repo}/contents/...` and write through the
30
+ // App's contents:write token. Now the prefix must match exactly.
31
+ const storage = await resolveStorageConfigForRequest(request)
32
+ if (!storage) {
33
+ return new Response('Could not resolve owner/repo', { status: 400 })
34
+ }
35
+ const allowedPrefix = `repos/${storage.owner}/${storage.repo}/`
36
+ const allowedExact = `repos/${storage.owner}/${storage.repo}`
37
+ if (githubPath !== allowedExact && !githubPath.startsWith(allowedPrefix)) {
38
+ return new Response(`Forbidden: proxy is scoped to ${storage.owner}/${storage.repo}`, {
39
+ status: 403,
40
+ })
41
+ }
42
+
25
43
  const tokenResult = await resolveGitHubTokenForRequest(request)
26
44
  if (!tokenResult.ok) {
27
45
  return new Response(tokenResult.error.message, { status: 500 })
@@ -45,9 +63,7 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => {
45
63
  }
46
64
 
47
65
  const body =
48
- request.method !== 'GET' && request.method !== 'HEAD'
49
- ? await request.text()
50
- : undefined
66
+ request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined
51
67
 
52
68
  const response = await fetch(githubUrl, {
53
69
  method: request.method,
@@ -59,11 +75,7 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => {
59
75
  const responseHeaders = new Headers()
60
76
  responseHeaders.set('Content-Type', response.headers.get('content-type') ?? 'application/json')
61
77
 
62
- const rateLimitHeaders = [
63
- 'x-ratelimit-limit',
64
- 'x-ratelimit-remaining',
65
- 'x-ratelimit-reset',
66
- ]
78
+ const rateLimitHeaders = ['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset']
67
79
  for (const header of rateLimitHeaders) {
68
80
  const value = response.headers.get(header)
69
81
  if (value) responseHeaders.set(header, value)
@@ -90,7 +102,9 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => {
90
102
  const parsed = JSON.parse(body) as { content?: string }
91
103
  if (parsed.content) {
92
104
  // GitHub API sends base64 with possible line breaks
93
- const decoded = Buffer.from(parsed.content.replace(/\s/g, ''), 'base64').toString('utf-8')
105
+ const decoded = Buffer.from(parsed.content.replace(/\s/g, ''), 'base64').toString(
106
+ 'utf-8',
107
+ )
94
108
  await writeFile(join(repoRoot, filePath), decoded, 'utf-8').catch(() => {})
95
109
  }
96
110
  }
@@ -1,9 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { parseSession } from './_auth-guard'
3
- import { resolveStorageConfig } from './_storage-config'
4
- import { resolveConfigRepoToken } from './_github-token'
5
- import { cachedFetch, invalidateCache } from './_github-cache'
6
3
  import { withTrailers } from './_commit-trailers'
4
+ import { cachedFetch, invalidateCache } from './_github-cache'
5
+ import { resolveConfigRepoToken } from './_github-token'
6
+ import { resolveStorageConfig } from './_storage-config'
7
7
 
8
8
  const GLOBAL_CONFIG_FILE = (contentPath: string) => `${contentPath}/_global_config.json`
9
9
 
@@ -41,13 +41,19 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
41
41
  if (!session) return new Response('Unauthorized', { status: 401 })
42
42
  if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
43
43
 
44
- let patch: Partial<GlobalConfig>
44
+ let raw: unknown
45
45
  try {
46
- patch = (await request.json()) as Partial<GlobalConfig>
46
+ raw = await request.json()
47
47
  } catch {
48
48
  return Response.json({ error: 'Invalid request body' }, { status: 400 })
49
49
  }
50
50
 
51
+ const validated = validateGlobalConfigPatch(raw)
52
+ if (!validated.ok) {
53
+ return Response.json({ error: validated.error }, { status: 400 })
54
+ }
55
+ const patch = validated.value
56
+
51
57
  const current = (await readGlobalConfig()) ?? {}
52
58
  const next: GlobalConfig = { ...current }
53
59
  for (const [k, v] of Object.entries(patch)) {
@@ -58,6 +64,74 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
58
64
  return Response.json({ ok: true })
59
65
  }
60
66
 
67
+ /**
68
+ * Schema-validates the PUT payload before merging into `_global_config.json`.
69
+ * Pre-fix the route accepted arbitrary JSON via `as Partial<GlobalConfig>`;
70
+ * a malformed payload (wrong types, non-object firebaseConfig) could
71
+ * break login on next boot. Every accepted field is now type-checked
72
+ * inline.
73
+ */
74
+ function validateGlobalConfigPatch(
75
+ input: unknown,
76
+ ): { ok: true; value: Partial<GlobalConfig> } | { ok: false; error: string } {
77
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
78
+ return { ok: false, error: 'Body must be a JSON object' }
79
+ }
80
+ const o = input as Record<string, unknown>
81
+ const out: Partial<GlobalConfig> = {}
82
+
83
+ if ('firebaseConfig' in o) {
84
+ if (o.firebaseConfig === null) {
85
+ ;(out as Record<string, unknown>).firebaseConfig = null
86
+ } else {
87
+ if (!o.firebaseConfig || typeof o.firebaseConfig !== 'object') {
88
+ return { ok: false, error: 'firebaseConfig must be an object or null' }
89
+ }
90
+ const fc = o.firebaseConfig as Record<string, unknown>
91
+ for (const k of ['apiKey', 'authDomain', 'projectId'] as const) {
92
+ if (typeof fc[k] !== 'string' || !fc[k]) {
93
+ return { ok: false, error: `firebaseConfig.${k} must be a non-empty string` }
94
+ }
95
+ }
96
+ out.firebaseConfig = {
97
+ apiKey: fc.apiKey as string,
98
+ authDomain: fc.authDomain as string,
99
+ projectId: fc.projectId as string,
100
+ }
101
+ }
102
+ }
103
+
104
+ if ('theme' in o) {
105
+ if (o.theme === null) {
106
+ ;(out as Record<string, unknown>).theme = null
107
+ } else {
108
+ if (!o.theme || typeof o.theme !== 'object') {
109
+ return { ok: false, error: 'theme must be an object or null' }
110
+ }
111
+ const t = o.theme as Record<string, unknown>
112
+ const theme: NonNullable<GlobalConfig['theme']> = {}
113
+ for (const k of ['primaryColor', 'brandName', 'logo'] as const) {
114
+ if (k in t) {
115
+ if (typeof t[k] !== 'string') {
116
+ return { ok: false, error: `theme.${k} must be a string` }
117
+ }
118
+ theme[k] = t[k] as string
119
+ }
120
+ }
121
+ out.theme = theme
122
+ }
123
+ }
124
+
125
+ // Reject unknown top-level keys so a forged-admin attacker can't
126
+ // smuggle fields the schema will later trust.
127
+ for (const k of Object.keys(o)) {
128
+ if (k !== 'firebaseConfig' && k !== 'theme') {
129
+ return { ok: false, error: `unknown top-level field: ${k}` }
130
+ }
131
+ }
132
+ return { ok: true, value: out }
133
+ }
134
+
61
135
  // ---------------------------------------------------------------------------
62
136
  // Helpers
63
137
  //
@@ -71,8 +145,9 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
71
145
  // ---------------------------------------------------------------------------
72
146
 
73
147
  async function getStorageParams() {
74
- const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
75
- .__SETZKASTEN_CONFIG__
148
+ const serverConfig = (
149
+ globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } }
150
+ ).__SETZKASTEN_CONFIG__
76
151
  const storage = resolveStorageConfig()
77
152
  if (!storage) return null
78
153
  const tokenResult = await resolveConfigRepoToken()
@@ -94,13 +169,20 @@ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
94
169
  return cachedFetch(key, 5 * 60_000, async () => {
95
170
  const res = await fetch(
96
171
  `https://api.github.com/repos/${owner}/${repo}/contents/${GLOBAL_CONFIG_FILE(contentPath)}?ref=${branch}`,
97
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
172
+ {
173
+ headers: {
174
+ Authorization: `Bearer ${token}`,
175
+ Accept: 'application/vnd.github+json',
176
+ 'X-GitHub-Api-Version': '2022-11-28',
177
+ },
178
+ },
98
179
  )
99
180
  if (!res.ok) return null
100
- const data = await res.json() as { content: string; encoding: string }
101
- const raw = data.encoding === 'base64'
102
- ? Buffer.from(data.content, 'base64').toString('utf-8')
103
- : data.content
181
+ const data = (await res.json()) as { content: string; encoding: string }
182
+ const raw =
183
+ data.encoding === 'base64'
184
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
185
+ : data.content
104
186
  return JSON.parse(raw) as GlobalConfig
105
187
  })
106
188
  }
@@ -126,10 +208,12 @@ export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
126
208
  { headers },
127
209
  )
128
210
  if (existing.ok) {
129
- const data = await existing.json() as { sha: string }
211
+ const data = (await existing.json()) as { sha: string }
130
212
  sha = data.sha
131
213
  }
132
- } catch { /* file doesn't exist yet */ }
214
+ } catch {
215
+ /* file doesn't exist yet */
216
+ }
133
217
 
134
218
  const body: Record<string, unknown> = {
135
219
  message: withTrailers('chore(config): update global config'),
@@ -138,10 +222,11 @@ export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
138
222
  }
139
223
  if (sha) body.sha = sha
140
224
 
141
- const res = await fetch(
142
- `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
143
- { method: 'PUT', headers, body: JSON.stringify(body) },
144
- )
225
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, {
226
+ method: 'PUT',
227
+ headers,
228
+ body: JSON.stringify(body),
229
+ })
145
230
  if (!res.ok) {
146
231
  const text = await res.text()
147
232
  throw new Error(`GitHub write failed: ${text}`)
@@ -1,9 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfigForRequest } from './_storage-config'
3
- import { resolveGitHubTokenForRequest } from './_github-token'
4
2
  import { parseSession, requireAdmin } from './_auth-guard'
5
3
  import { withTrailers } from './_commit-trailers'
6
4
  import { invalidateCache } from './_github-cache'
5
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { resolveStorageConfigForRequest } from './_storage-config'
7
7
 
8
8
  interface RollbackBody {
9
9
  path?: string
@@ -57,6 +57,21 @@ export const POST: APIRoute = async ({ request, cookies }) => {
57
57
  }
58
58
  const { owner, repo, branch } = storage
59
59
 
60
+ // Restrict rollback to the content tree. Pre-fix, an admin (or anyone
61
+ // who could forge an admin cookie pre-C1) could roll back any path —
62
+ // including `.github/workflows/deploy.yml`, `setzkasten.config.ts`,
63
+ // or arbitrary source — to any historical SHA.
64
+ const serverConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
65
+ | { storage?: { contentPath?: string } }
66
+ | undefined
67
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
68
+ if (!isPathInsideContent(path, contentPath)) {
69
+ return Response.json(
70
+ { error: `Rollback restricted to the content folder (${contentPath}/...)` },
71
+ { status: 400 },
72
+ )
73
+ }
74
+
60
75
  const headers = {
61
76
  Authorization: `Bearer ${tokenResult.value}`,
62
77
  Accept: 'application/vnd.github+json',
@@ -76,10 +91,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
76
91
  )
77
92
  }
78
93
  if (!versionRes.ok) {
79
- return Response.json(
80
- { error: `Failed to read version: ${versionRes.status}` },
81
- { status: 502 },
82
- )
94
+ return Response.json({ error: `Failed to read version: ${versionRes.status}` }, { status: 502 })
83
95
  }
84
96
  const versionData = (await versionRes.json()) as {
85
97
  content: string
@@ -114,10 +126,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
114
126
  // 3. Write new commit with the historical content
115
127
  const shortSha = sha.slice(0, 7)
116
128
  const fileName = path.split('/').pop() ?? path
117
- const message = withTrailers(
118
- `revert(${fileName}): rollback to ${shortSha}`,
119
- session.user.email,
120
- )
129
+ const message = withTrailers(`revert(${fileName}): rollback to ${shortSha}`, session.user.email)
121
130
 
122
131
  const putBody: Record<string, unknown> = {
123
132
  message,
@@ -126,10 +135,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
126
135
  }
127
136
  if (currentSha) putBody.sha = currentSha
128
137
 
129
- const putRes = await fetch(
130
- `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
131
- { method: 'PUT', headers, body: JSON.stringify(putBody) },
132
- )
138
+ const putRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
139
+ method: 'PUT',
140
+ headers,
141
+ body: JSON.stringify(putBody),
142
+ })
133
143
 
134
144
  if (!putRes.ok) {
135
145
  const text = await putRes.text()
@@ -142,3 +152,20 @@ export const POST: APIRoute = async ({ request, cookies }) => {
142
152
  const putData = (await putRes.json()) as { commit: { sha: string } }
143
153
  return Response.json({ ok: true, commitSha: putData.commit.sha })
144
154
  }
155
+
156
+ /** True when `target` is a sub-path of `base` after path normalisation.
157
+ * Forbids `..` segments, leading/embedded NUL, absolute paths, and
158
+ * Windows-style backslashes (the API accepts forward slashes only). */
159
+ function isPathInsideContent(target: string, base: string): boolean {
160
+ if (typeof target !== 'string' || target.length === 0) return false
161
+ if (target.includes('\0') || target.includes('\\')) return false
162
+ if (target.startsWith('/')) return false
163
+
164
+ const segments = target.split('/')
165
+ for (const seg of segments) {
166
+ if (seg === '' || seg === '.' || seg === '..') return false
167
+ }
168
+
169
+ const normalizedBase = base.replace(/^\/+|\/+$/g, '')
170
+ return target === normalizedBase || target.startsWith(`${normalizedBase}/`)
171
+ }
@@ -1,8 +1,8 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfigForRequest } from './_storage-config'
3
- import { resolveGitHubTokenForRequest } from './_github-token'
4
2
  import { requireAdmin } from './_auth-guard'
5
3
  import { cachedFetch } from './_github-cache'
4
+ import { resolveGitHubTokenForRequest } from './_github-token'
5
+ import { resolveStorageConfigForRequest } from './_storage-config'
6
6
 
7
7
  /**
8
8
  * GET /api/setzkasten/history/version?path=<file>&sha=<commit-sha>
@@ -31,9 +31,7 @@ export const GET: APIRoute = async ({ request, url, cookies }) => {
31
31
 
32
32
  const cacheKey = `history-version:${owner}/${repo}:${path}:${sha}`
33
33
  const result = await cachedFetch(cacheKey, 5 * 60_000, async () => {
34
- const u = new URL(
35
- `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
36
- )
34
+ const u = new URL(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`)
37
35
  u.searchParams.set('ref', sha)
38
36
  const res = await fetch(u, {
39
37
  headers: {
@@ -42,7 +40,8 @@ export const GET: APIRoute = async ({ request, url, cookies }) => {
42
40
  'X-GitHub-Api-Version': '2022-11-28',
43
41
  },
44
42
  })
45
- if (res.status === 404) return { ok: false as const, status: 404, error: 'File not found at given sha' }
43
+ if (res.status === 404)
44
+ return { ok: false as const, status: 404, error: 'File not found at given sha' }
46
45
  if (!res.ok) return { ok: false as const, status: 502, error: `GitHub returned ${res.status}` }
47
46
  const data = (await res.json()) as { content: string; encoding: string; sha: string }
48
47
  const raw =