@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,9 +1,10 @@
1
+ import { isSafeKey } from '@setzkasten-cms/core'
1
2
  import type { APIRoute } from 'astro'
2
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
- import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
- import { parseSession, guardPageAccess } from './_auth-guard'
3
+ import { guardPageAccess, parseSession } from './_auth-guard'
5
4
  import { withTrailers } from './_commit-trailers'
6
5
  import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { resolveStorageConfigForRequest } from './_storage-config'
7
+ import { duplicateInPageConfig, generateDuplicateKey } from './section-management'
7
8
 
8
9
  /**
9
10
  * POST /api/setzkasten/sections/duplicate
@@ -26,7 +27,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
26
27
  const githubToken = tokenResult.value
27
28
 
28
29
  try {
29
- const body = await request.json() as {
30
+ const body = (await request.json()) as {
30
31
  pageKey: string
31
32
  sectionKey: string
32
33
  owner?: string
@@ -47,8 +48,19 @@ export const POST: APIRoute = async ({ request, cookies }) => {
47
48
  if (!pageKey || !sectionKey) {
48
49
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
49
50
  }
51
+ if (!isSafeKey(pageKey)) {
52
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
53
+ }
54
+ if (!isSafeKey(sectionKey)) {
55
+ return Response.json({ error: 'invalid sectionKey' }, { status: 400 })
56
+ }
50
57
 
51
- const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
58
+ const denied = await guardPageAccess(
59
+ parseSession(cookies.get('setzkasten_session')?.value),
60
+ pageKey,
61
+ fullConfig,
62
+ request,
63
+ )
52
64
  if (denied) return denied
53
65
 
54
66
  const headers = {
@@ -73,7 +85,13 @@ export const POST: APIRoute = async ({ request, cookies }) => {
73
85
 
74
86
  // 3. Read original section content
75
87
  const originalJsonPath = `${contentPath}/_sections/${sectionKey}.json`
76
- const originalContent = await fetchFileContent(owner, repo, branch, originalJsonPath, githubToken)
88
+ const originalContent = await fetchFileContent(
89
+ owner,
90
+ repo,
91
+ branch,
92
+ originalJsonPath,
93
+ githubToken,
94
+ )
77
95
 
78
96
  // 4. Build commit
79
97
  const updatedConfig = duplicateInPageConfig(pageConfig, sectionKey, newKey)
@@ -86,12 +104,17 @@ export const POST: APIRoute = async ({ request, cookies }) => {
86
104
  filesToCommit.push({ path: copyPath, content: originalContent })
87
105
  }
88
106
 
89
- const commitResult = await batchCommit(owner, repo, branch, filesToCommit,
107
+ const commitResult = await batchCommit(
108
+ owner,
109
+ repo,
110
+ branch,
111
+ filesToCommit,
90
112
  withTrailers(
91
113
  `content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`,
92
114
  parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
93
115
  ),
94
- headers)
116
+ headers,
117
+ )
95
118
 
96
119
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
97
120
 
@@ -111,50 +134,94 @@ export const POST: APIRoute = async ({ request, cookies }) => {
111
134
  }
112
135
  }
113
136
 
114
- async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
137
+ async function fetchFileContent(
138
+ owner: string,
139
+ repo: string,
140
+ branch: string,
141
+ path: string,
142
+ token: string,
143
+ ): Promise<string | null> {
115
144
  try {
116
145
  const res = await fetch(
117
146
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
118
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
147
+ {
148
+ headers: {
149
+ Authorization: `Bearer ${token}`,
150
+ Accept: 'application/vnd.github+json',
151
+ 'X-GitHub-Api-Version': '2022-11-28',
152
+ },
153
+ },
119
154
  )
120
155
  if (!res.ok) return null
121
- const data = await res.json() as { content: string; encoding: string }
122
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
123
- } catch { return null }
156
+ const data = (await res.json()) as { content: string; encoding: string }
157
+ return data.encoding === 'base64'
158
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
159
+ : data.content
160
+ } catch {
161
+ return null
162
+ }
124
163
  }
125
164
 
126
165
  async function batchCommit(
127
- owner: string, repo: string, branch: string,
166
+ owner: string,
167
+ repo: string,
168
+ branch: string,
128
169
  files: Array<{ path: string; content: string }>,
129
170
  message: string,
130
171
  headers: Record<string, string>,
131
172
  ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
132
173
  try {
133
- const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
174
+ const refRes = await fetch(
175
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
176
+ { headers },
177
+ )
134
178
  if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
135
- const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
179
+ const {
180
+ object: { sha: headSha },
181
+ } = (await refRes.json()) as { object: { sha: string } }
136
182
 
137
- const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
183
+ const commitRes = await fetch(
184
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
185
+ { headers },
186
+ )
138
187
  if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
139
- const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
188
+ const {
189
+ tree: { sha: baseSha },
190
+ } = (await commitRes.json()) as { tree: { sha: string } }
140
191
 
141
192
  const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
142
- method: 'POST', headers,
143
- body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
193
+ method: 'POST',
194
+ headers,
195
+ body: JSON.stringify({
196
+ base_tree: baseSha,
197
+ tree: files.map((f) => ({
198
+ path: f.path,
199
+ mode: '100644',
200
+ type: 'blob',
201
+ content: f.content,
202
+ })),
203
+ }),
144
204
  })
145
205
  if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
146
- const { sha: treeSha } = await treeRes.json() as { sha: string }
206
+ const { sha: treeSha } = (await treeRes.json()) as { sha: string }
147
207
 
148
208
  const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
149
- method: 'POST', headers,
209
+ method: 'POST',
210
+ headers,
150
211
  body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
151
212
  })
152
- if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
153
- const { sha: newSha } = await newCommitRes.json() as { sha: string }
154
-
155
- const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
156
- method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
157
- })
213
+ if (!newCommitRes.ok)
214
+ return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
215
+ const { sha: newSha } = (await newCommitRes.json()) as { sha: string }
216
+
217
+ const updateRes = await fetch(
218
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
219
+ {
220
+ method: 'PATCH',
221
+ headers,
222
+ body: JSON.stringify({ sha: newSha }),
223
+ },
224
+ )
158
225
  if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
159
226
 
160
227
  return { ok: true, sha: newSha }
@@ -21,7 +21,7 @@ interface PageConfig {
21
21
  */
22
22
  export function removeFromPageConfig(config: PageConfig, sectionKey: string): PageConfig {
23
23
  const sections = config.sections
24
- .filter(s => s.key !== sectionKey)
24
+ .filter((s) => s.key !== sectionKey)
25
25
  .map((s, i) => ({ ...s, order: i }))
26
26
  return { ...config, sections }
27
27
  }
@@ -53,11 +53,7 @@ export function generateAddKey(existingKeys: string[], type: string): string {
53
53
  * Appends a new section entry at the end of the page config.
54
54
  * Sets `type` only when key differs from type (multi-instance case).
55
55
  */
56
- export function addToPageConfig(
57
- config: PageConfig,
58
- key: string,
59
- type: string,
60
- ): PageConfig {
56
+ export function addToPageConfig(config: PageConfig, key: string, type: string): PageConfig {
61
57
  const entry: SectionEntry = {
62
58
  key,
63
59
  enabled: true,
@@ -76,7 +72,7 @@ export function duplicateInPageConfig(
76
72
  originalKey: string,
77
73
  newKey: string,
78
74
  ): PageConfig {
79
- const original = config.sections.find(s => s.key === originalKey)
75
+ const original = config.sections.find((s) => s.key === originalKey)
80
76
  if (!original) return config
81
77
 
82
78
  // Always set type explicitly: copy key differs from type (e.g. 'testPricing--copy'),
@@ -1,7 +1,8 @@
1
+ import { isSafeKey } from '@setzkasten-cms/core'
1
2
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfigForRequest } from './_storage-config'
3
- import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
3
  import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { resolveStorageConfigForRequest } from './_storage-config'
5
+ import { duplicateInPageConfig, generateDuplicateKey } from './section-management'
5
6
 
6
7
  /**
7
8
  * POST /api/setzkasten/sections/prepare-copy
@@ -34,7 +35,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
34
35
  const githubToken = tokenResult.value
35
36
 
36
37
  try {
37
- const body = await request.json() as {
38
+ const body = (await request.json()) as {
38
39
  pageKey: string
39
40
  sectionKey: string
40
41
  owner?: string
@@ -54,6 +55,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
54
55
  if (!pageKey || !sectionKey) {
55
56
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
56
57
  }
58
+ if (!isSafeKey(pageKey)) {
59
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
60
+ }
61
+ if (!isSafeKey(sectionKey)) {
62
+ return Response.json({ error: 'invalid sectionKey' }, { status: 400 })
63
+ }
57
64
 
58
65
  // 1. Read current page config
59
66
  const configKey = '_' + pageKey.replace(/\//g, '_')
@@ -90,15 +97,29 @@ export const POST: APIRoute = async ({ request, cookies }) => {
90
97
  }
91
98
 
92
99
  async function fetchFileContent(
93
- owner: string, repo: string, branch: string, path: string, token: string,
100
+ owner: string,
101
+ repo: string,
102
+ branch: string,
103
+ path: string,
104
+ token: string,
94
105
  ): Promise<string | null> {
95
106
  try {
96
107
  const res = await fetch(
97
108
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
98
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
109
+ {
110
+ headers: {
111
+ Authorization: `Bearer ${token}`,
112
+ Accept: 'application/vnd.github+json',
113
+ 'X-GitHub-Api-Version': '2022-11-28',
114
+ },
115
+ },
99
116
  )
100
117
  if (!res.ok) return null
101
- const data = await res.json() as { content: string; encoding: string }
102
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
103
- } catch { return null }
118
+ const data = (await res.json()) as { content: string; encoding: string }
119
+ return data.encoding === 'base64'
120
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
121
+ : data.content
122
+ } catch {
123
+ return null
124
+ }
104
125
  }
@@ -1,8 +1,9 @@
1
+ import { isSafeKey } from '@setzkasten-cms/core'
1
2
  import type { APIRoute } from 'astro'
2
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
- import { generateAddKey } from './section-management'
4
- import { parseSession, guardPageAccess } from './_auth-guard'
3
+ import { guardPageAccess, parseSession } from './_auth-guard'
5
4
  import { resolveGitHubTokenForRequest } from './_github-token'
5
+ import { resolveStorageConfigForRequest } from './_storage-config'
6
+ import { generateAddKey } from './section-management'
6
7
 
7
8
  /**
8
9
  * POST /api/setzkasten/sections/prepare
@@ -28,7 +29,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
28
29
  const githubToken = tokenResult.value
29
30
 
30
31
  try {
31
- const body = await request.json() as {
32
+ const body = (await request.json()) as {
32
33
  pageKey: string
33
34
  sectionType: string
34
35
  owner?: string
@@ -49,8 +50,19 @@ export const POST: APIRoute = async ({ request, cookies }) => {
49
50
  if (!pageKey || !sectionType) {
50
51
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
51
52
  }
53
+ if (!isSafeKey(pageKey)) {
54
+ return Response.json({ error: 'invalid pageKey' }, { status: 400 })
55
+ }
56
+ if (!isSafeKey(sectionType)) {
57
+ return Response.json({ error: 'invalid sectionType' }, { status: 400 })
58
+ }
52
59
 
53
- const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
60
+ const denied = await guardPageAccess(
61
+ parseSession(cookies.get('setzkasten_session')?.value),
62
+ pageKey,
63
+ fullConfig,
64
+ request,
65
+ )
54
66
  if (denied) return denied
55
67
 
56
68
  // 1. Read current page config from GitHub to determine existing keys
@@ -116,14 +128,30 @@ function buildDefaultContent(fields: Record<string, any>): Record<string, unknow
116
128
  return result
117
129
  }
118
130
 
119
- async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
131
+ async function fetchFileContent(
132
+ owner: string,
133
+ repo: string,
134
+ branch: string,
135
+ path: string,
136
+ token: string,
137
+ ): Promise<string | null> {
120
138
  try {
121
139
  const res = await fetch(
122
140
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
123
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
141
+ {
142
+ headers: {
143
+ Authorization: `Bearer ${token}`,
144
+ Accept: 'application/vnd.github+json',
145
+ 'X-GitHub-Api-Version': '2022-11-28',
146
+ },
147
+ },
124
148
  )
125
149
  if (!res.ok) return null
126
- const data = await res.json() as { content: string; encoding: string }
127
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
128
- } catch { return null }
150
+ const data = (await res.json()) as { content: string; encoding: string }
151
+ return data.encoding === 'base64'
152
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
153
+ : data.content
154
+ } catch {
155
+ return null
156
+ }
129
157
  }
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { requireAdmin } from './_auth-guard'
2
3
  import { getPublicOrigin } from './_vercel-origin.js'
3
4
 
4
5
  /**
@@ -9,7 +10,12 @@ import { getPublicOrigin } from './_vercel-origin.js'
9
10
  * Generates the GitHub App manifest JSON using the server-known origin,
10
11
  * then returns a minimal HTML page that auto-submits a form to GitHub.
11
12
  */
12
- export const GET: APIRoute = async ({ url, request }) => {
13
+ export const GET: APIRoute = async ({ url, request, cookies }) => {
14
+ // Admin-only — pre-fix any unauthenticated visitor could trigger the
15
+ // manifest redirect and probe the deployment's origin / setup state.
16
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
17
+ if (denied) return denied
18
+
13
19
  const name = url.searchParams.get('name')?.trim() || 'Setzkasten CMS'
14
20
  const origin = getPublicOrigin(request)
15
21
 
@@ -1,5 +1,5 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { listRepoBranches } from '@setzkasten-cms/github-adapter'
2
+ import type { APIRoute } from 'astro'
3
3
  import { requireAdmin } from './_auth-guard'
4
4
 
5
5
  /**
@@ -20,8 +20,7 @@ export const GET: APIRoute = async ({ cookies, url }) => {
20
20
  if (!appId || !privateKey) {
21
21
  return new Response(
22
22
  JSON.stringify({
23
- error:
24
- 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
23
+ error: 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
25
24
  }),
26
25
  { status: 400, headers: { 'Content-Type': 'application/json' } },
27
26
  )
@@ -38,10 +37,10 @@ export const GET: APIRoute = async ({ cookies, url }) => {
38
37
 
39
38
  const slash = repoFull.indexOf('/')
40
39
  if (slash <= 0 || slash === repoFull.length - 1) {
41
- return new Response(
42
- JSON.stringify({ error: '?repo must be in "owner/name" format.' }),
43
- { status: 400, headers: { 'Content-Type': 'application/json' } },
44
- )
40
+ return new Response(JSON.stringify({ error: '?repo must be in "owner/name" format.' }), {
41
+ status: 400,
42
+ headers: { 'Content-Type': 'application/json' },
43
+ })
45
44
  }
46
45
  const owner = repoFull.slice(0, slash)
47
46
  const repo = repoFull.slice(slash + 1)
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { requireAdmin } from './_auth-guard'
2
3
  import { getPublicOrigin } from './_vercel-origin.js'
3
4
 
4
5
  const COOKIE_NAME = 'sk_app_setup'
@@ -15,6 +16,13 @@ const COOKIE_MAX_AGE = 600 // 10 minutes
15
16
  * the Set-Cookie header (TypeError: immutable).
16
17
  */
17
18
  export const GET: APIRoute = async ({ url, request, cookies }) => {
19
+ // GitHub redirects the admin's browser back here after the Manifest
20
+ // flow — the original session cookie travels with the top-level
21
+ // navigation. Without this gate, anyone could replay an older `?code`
22
+ // and force a re-write of the setup cookie. Admin-only.
23
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
24
+ if (denied) return denied
25
+
18
26
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
19
27
  | { adminPath?: string }
20
28
  | undefined
@@ -64,7 +72,16 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
64
72
  clientId: data!.client_id,
65
73
  clientSecret: data!.client_secret,
66
74
  }),
67
- { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
75
+ // httpOnly:true pre-fix this was readable to any JS on origin (XSS,
76
+ // extensions). The SPA now reads via /api/setzkasten/setup/github-app/
77
+ // credentials (server reads the cookie, returns values).
78
+ {
79
+ httpOnly: true,
80
+ secure: import.meta.env.PROD,
81
+ sameSite: 'lax',
82
+ maxAge: COOKIE_MAX_AGE,
83
+ path: '/',
84
+ },
68
85
  )
69
86
 
70
87
  return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
@@ -0,0 +1,55 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { requireAdmin } from './_auth-guard'
3
+
4
+ const COOKIE_NAME = 'sk_app_setup'
5
+
6
+ /**
7
+ * GET /api/setzkasten/setup/github-app/credentials
8
+ *
9
+ * Returns the freshly-minted GitHub App credentials so the admin SPA can
10
+ * display them (env-var copy step of the wizard). Pre-C6 the SPA read
11
+ * these directly from `document.cookie`, which required the cookie to
12
+ * be `httpOnly: false` — exposing the App private key to any JS on the
13
+ * origin (XSS, extensions, embedded widgets). Now the cookie is
14
+ * httpOnly and only this admin-gated endpoint can read it.
15
+ *
16
+ * The cookie itself stays for the wizard's full lifetime (10 min); this
17
+ * endpoint just makes the contents available without exposing them via
18
+ * the document.
19
+ */
20
+ export const GET: APIRoute = async ({ cookies }) => {
21
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
22
+ if (denied) return denied
23
+
24
+ const raw = cookies.get(COOKIE_NAME)?.value
25
+ if (!raw) {
26
+ return Response.json({ available: false }, { status: 404 })
27
+ }
28
+
29
+ let parsed: unknown
30
+ try {
31
+ parsed = JSON.parse(raw)
32
+ } catch {
33
+ return Response.json({ available: false, error: 'malformed' }, { status: 500 })
34
+ }
35
+
36
+ if (!parsed || typeof parsed !== 'object') {
37
+ return Response.json({ available: false }, { status: 404 })
38
+ }
39
+
40
+ return Response.json({ available: true, credentials: parsed })
41
+ }
42
+
43
+ /**
44
+ * DELETE /api/setzkasten/setup/github-app/credentials
45
+ *
46
+ * Clears the setup cookie once the admin has copied the env vars and
47
+ * confirmed the deploy. Removes the credentials from the wire as soon
48
+ * as they're no longer needed.
49
+ */
50
+ export const DELETE: APIRoute = async ({ cookies }) => {
51
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
52
+ if (denied) return denied
53
+ cookies.delete(COOKIE_NAME, { path: '/' })
54
+ return Response.json({ ok: true })
55
+ }
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { requireAdmin } from './_auth-guard'
2
3
  import { getPublicOrigin } from './_vercel-origin.js'
3
4
 
4
5
  const COOKIE_NAME = 'sk_app_setup'
@@ -15,6 +16,9 @@ const COOKIE_MAX_AGE = 600
15
16
  * the Set-Cookie header (TypeError: immutable).
16
17
  */
17
18
  export const GET: APIRoute = async ({ url, request, cookies }) => {
19
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
20
+ if (denied) return denied
21
+
18
22
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
19
23
  | { adminPath?: string }
20
24
  | undefined
@@ -33,7 +37,14 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
33
37
  cookies.set(
34
38
  COOKIE_NAME,
35
39
  JSON.stringify({ ...data, installationId }),
36
- { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
40
+ // C6: same httpOnly hardening as the callback route.
41
+ {
42
+ httpOnly: true,
43
+ secure: import.meta.env.PROD,
44
+ sameSite: 'lax',
45
+ maxAge: COOKIE_MAX_AGE,
46
+ path: '/',
47
+ },
37
48
  )
38
49
  } catch {
39
50
  adminUrl.searchParams.set('github-app-error', 'invalid_session')
@@ -1,5 +1,5 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { listAccessibleRepos } from '@setzkasten-cms/github-adapter'
2
+ import type { APIRoute } from 'astro'
3
3
  import { requireAdmin } from './_auth-guard'
4
4
 
5
5
  /**
@@ -23,8 +23,7 @@ export const GET: APIRoute = async ({ cookies }) => {
23
23
  if (!appId || !privateKey) {
24
24
  return new Response(
25
25
  JSON.stringify({
26
- error:
27
- 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
26
+ error: 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
28
27
  }),
29
28
  { status: 400, headers: { 'Content-Type': 'application/json' } },
30
29
  )
@@ -1,5 +1,6 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { GitHubAppClient } from '@setzkasten-cms/github-adapter'
2
+ import type { APIRoute } from 'astro'
3
+ import { requireAdmin } from './_auth-guard'
3
4
 
4
5
  /**
5
6
  * Setup-Wizard: GitHub App Integration
@@ -9,9 +10,16 @@ import { GitHubAppClient } from '@setzkasten-cms/github-adapter'
9
10
  *
10
11
  * Credentials werden NICHT persistiert – der Nutzer setzt die env vars manuell.
11
12
  * Der POST-Endpunkt validiert die Verbindung durch einen echten Token-Request.
13
+ *
14
+ * Both methods are admin-only. Pre-fix the GET let any unauthenticated
15
+ * visitor probe whether a deployment was configured (reconnaissance for
16
+ * targeting half-set-up instances).
12
17
  */
13
18
 
14
- export const GET: APIRoute = async () => {
19
+ export const GET: APIRoute = async ({ cookies }) => {
20
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
21
+ if (denied) return denied
22
+
15
23
  const appId = process.env.GITHUB_APP_ID
16
24
  const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
17
25
  const installationId = process.env.GITHUB_APP_INSTALLATION_ID
@@ -21,7 +29,9 @@ export const GET: APIRoute = async () => {
21
29
  return Response.json({ configured, ...(configured ? { appId } : {}) })
22
30
  }
23
31
 
24
- export const POST: APIRoute = async ({ request }) => {
32
+ export const POST: APIRoute = async ({ request, cookies }) => {
33
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
34
+ if (denied) return denied
25
35
  let body: unknown
26
36
  try {
27
37
  body = await request.json()
@@ -29,8 +39,7 @@ export const POST: APIRoute = async ({ request }) => {
29
39
  return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
30
40
  }
31
41
 
32
- const { appId, privateKey, installationId } =
33
- (body as Record<string, unknown>) ?? {}
42
+ const { appId, privateKey, installationId } = (body as Record<string, unknown>) ?? {}
34
43
 
35
44
  if (!appId || !privateKey || !installationId) {
36
45
  return Response.json(
@@ -11,10 +11,12 @@ export const GET: APIRoute = async ({ cookies, url }) => {
11
11
  return new Response('Unauthorized', { status: 401 })
12
12
  }
13
13
 
14
- const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
15
- updaterUrl?: string
16
- version?: string
17
- } | undefined
14
+ const config = (globalThis as any).__SETZKASTEN_CONFIG__ as
15
+ | {
16
+ updaterUrl?: string
17
+ version?: string
18
+ }
19
+ | undefined
18
20
 
19
21
  const updaterUrl = config?.updaterUrl
20
22
  if (!updaterUrl) {