@setzkasten-cms/astro-admin 0.6.0 → 1.1.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 (79) hide show
  1. package/package.json +23 -6
  2. package/src/admin-page.astro +9 -8
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
  5. package/src/api-routes/__tests__/github-cache.test.ts +100 -0
  6. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  7. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  8. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  9. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  10. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  11. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  12. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  13. package/src/api-routes/__tests__/pages.test.ts +72 -0
  14. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  15. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  16. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  17. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  18. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  19. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  20. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  21. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  22. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  23. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  24. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  25. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  26. package/src/api-routes/_auth-guard.ts +153 -0
  27. package/src/api-routes/_commit-trailers.ts +16 -0
  28. package/src/api-routes/_github-cache.ts +32 -0
  29. package/src/api-routes/_github-token.ts +64 -0
  30. package/src/api-routes/_license-tier.ts +25 -0
  31. package/src/api-routes/_pages-meta-store.ts +134 -0
  32. package/src/api-routes/_session-cookie.ts +42 -0
  33. package/src/api-routes/_storage-config.ts +64 -4
  34. package/src/api-routes/_vercel-origin.ts +22 -0
  35. package/src/api-routes/_website-resolver.ts +243 -0
  36. package/src/api-routes/_websites-store.ts +120 -0
  37. package/src/api-routes/asset-proxy.ts +6 -4
  38. package/src/api-routes/auth-callback.ts +21 -53
  39. package/src/api-routes/auth-login.ts +18 -65
  40. package/src/api-routes/auth-logout.ts +5 -1
  41. package/src/api-routes/auth-setzkasten-login.ts +71 -0
  42. package/src/api-routes/catalog-add.ts +18 -5
  43. package/src/api-routes/catalog-export.ts +8 -4
  44. package/src/api-routes/config.ts +17 -5
  45. package/src/api-routes/editors.ts +205 -0
  46. package/src/api-routes/github-proxy.ts +5 -5
  47. package/src/api-routes/global-config.ts +149 -0
  48. package/src/api-routes/init-add-section.ts +21 -10
  49. package/src/api-routes/init-apply.ts +7 -4
  50. package/src/api-routes/init-migrate.ts +9 -6
  51. package/src/api-routes/init-scan-page.ts +26 -6
  52. package/src/api-routes/init-scan.ts +5 -3
  53. package/src/api-routes/migrate-to-multi.ts +255 -0
  54. package/src/api-routes/pages.ts +138 -6
  55. package/src/api-routes/section-add.ts +23 -5
  56. package/src/api-routes/section-commit-pending.ts +28 -5
  57. package/src/api-routes/section-delete.ts +24 -5
  58. package/src/api-routes/section-duplicate.ts +25 -5
  59. package/src/api-routes/section-prepare-copy.ts +15 -4
  60. package/src/api-routes/section-prepare.ts +12 -4
  61. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  62. package/src/api-routes/setup-github-app-branches.ts +63 -0
  63. package/src/api-routes/setup-github-app-callback.ts +53 -0
  64. package/src/api-routes/setup-github-app-installed.ts +44 -0
  65. package/src/api-routes/setup-github-app-repos.ts +46 -0
  66. package/src/api-routes/setup-github-app.ts +58 -0
  67. package/src/api-routes/updater-check.ts +49 -0
  68. package/src/api-routes/updater-register.ts +90 -0
  69. package/src/api-routes/updater-transfer.ts +51 -0
  70. package/src/api-routes/updater-unbind.ts +59 -0
  71. package/src/api-routes/websites-add.ts +113 -0
  72. package/src/api-routes/websites-list.ts +40 -0
  73. package/src/api-routes/websites-remove.ts +74 -0
  74. package/src/init/__tests__/page-level.test.ts +47 -0
  75. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  76. package/src/init/__tests__/section-pipeline.test.ts +3 -1
  77. package/src/init/astro-section-analyzer-v2.ts +29 -2
  78. package/src/init/template-patcher-v2.ts +100 -0
  79. package/LICENSE +0 -37
@@ -0,0 +1,149 @@
1
+ import type { APIRoute } from 'astro'
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
+ import { withTrailers } from './_commit-trailers'
7
+
8
+ const GLOBAL_CONFIG_FILE = (contentPath: string) => `${contentPath}/_global_config.json`
9
+
10
+ export interface GlobalConfig {
11
+ firebaseConfig?: {
12
+ apiKey: string
13
+ authDomain: string
14
+ projectId: string
15
+ }
16
+ theme?: {
17
+ primaryColor?: string
18
+ brandName?: string
19
+ logo?: string
20
+ }
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // GET /api/setzkasten/global-config — any authenticated user
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export const GET: APIRoute = async ({ cookies }) => {
28
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
29
+ if (!session) return new Response('Unauthorized', { status: 401 })
30
+
31
+ const cfg = await readGlobalConfig()
32
+ return Response.json(cfg ?? {})
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // PUT /api/setzkasten/global-config — admin only
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export const PUT: APIRoute = async ({ request, cookies }) => {
40
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
41
+ if (!session) return new Response('Unauthorized', { status: 401 })
42
+ if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
43
+
44
+ let patch: Partial<GlobalConfig>
45
+ try {
46
+ patch = (await request.json()) as Partial<GlobalConfig>
47
+ } catch {
48
+ return Response.json({ error: 'Invalid request body' }, { status: 400 })
49
+ }
50
+
51
+ const current = (await readGlobalConfig()) ?? {}
52
+ const next: GlobalConfig = { ...current }
53
+ for (const [k, v] of Object.entries(patch)) {
54
+ if (v === null) delete (next as Record<string, unknown>)[k]
55
+ else (next as Record<string, unknown>)[k] = v
56
+ }
57
+ await writeGlobalConfig(next)
58
+ return Response.json({ ok: true })
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ //
64
+ // Global config is a Setzkasten-instance-level file that lives in the
65
+ // config-repo regardless of which website the request is targeting:
66
+ // - Single-Mode: config-repo == website-repo, so this is fine
67
+ // - Multi-Mode: config-repo holds editors + global config + websites.json
68
+ // We deliberately ignore the request and X-SK-Website here; otherwise
69
+ // global config would ping-pong between per-website locations as users
70
+ // switch active sites.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ async function getStorageParams() {
74
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
75
+ .__SETZKASTEN_CONFIG__
76
+ const storage = resolveStorageConfig()
77
+ if (!storage) return null
78
+ const tokenResult = await resolveConfigRepoToken()
79
+ if (!tokenResult.ok) return null
80
+ return {
81
+ owner: storage.owner,
82
+ repo: storage.repo,
83
+ branch: storage.branch,
84
+ contentPath: serverConfig?.storage?.contentPath ?? 'content',
85
+ token: tokenResult.value,
86
+ }
87
+ }
88
+
89
+ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
90
+ const params = await getStorageParams()
91
+ if (!params) return null
92
+ const { owner, repo, branch, contentPath, token } = params
93
+ const key = `global-config:${owner}/${repo}:${branch}`
94
+ return cachedFetch(key, 5 * 60_000, async () => {
95
+ const res = await fetch(
96
+ `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' } },
98
+ )
99
+ 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
104
+ return JSON.parse(raw) as GlobalConfig
105
+ })
106
+ }
107
+
108
+ export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
109
+ const params = await getStorageParams()
110
+ if (!params) throw new Error('Storage not configured')
111
+ const { owner, repo, branch, contentPath, token } = params
112
+ invalidateCache(`global-config:${owner}/${repo}:${branch}`)
113
+ const filePath = GLOBAL_CONFIG_FILE(contentPath)
114
+ const headers = {
115
+ Authorization: `Bearer ${token}`,
116
+ Accept: 'application/vnd.github+json',
117
+ 'X-GitHub-Api-Version': '2022-11-28',
118
+ 'Content-Type': 'application/json',
119
+ }
120
+
121
+ // Get current SHA if file exists
122
+ let sha: string | undefined
123
+ try {
124
+ const existing = await fetch(
125
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
126
+ { headers },
127
+ )
128
+ if (existing.ok) {
129
+ const data = await existing.json() as { sha: string }
130
+ sha = data.sha
131
+ }
132
+ } catch { /* file doesn't exist yet */ }
133
+
134
+ const body: Record<string, unknown> = {
135
+ message: withTrailers('chore(config): update global config'),
136
+ content: Buffer.from(JSON.stringify(config, null, 2)).toString('base64'),
137
+ branch,
138
+ }
139
+ if (sha) body.sha = sha
140
+
141
+ const res = await fetch(
142
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
143
+ { method: 'PUT', headers, body: JSON.stringify(body) },
144
+ )
145
+ if (!res.ok) {
146
+ const text = await res.text()
147
+ throw new Error(`GitHub write failed: ${text}`)
148
+ }
149
+ }
@@ -1,9 +1,11 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import type { InferredSection } from '@setzkasten-cms/core/init'
3
3
  import { addSectionToConfig } from '@setzkasten-cms/core/init'
4
- import { resolveStorageConfig, prefixPath } from './_storage-config'
4
+ import { prefixPath, resolveStorageConfigForRequest } 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'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
7
9
 
8
10
  /**
9
11
  * POST /api/setzkasten/init/add-section
@@ -20,10 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
20
22
  return new Response('Unauthorized', { status: 401 })
21
23
  }
22
24
 
23
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
- if (!githubToken) {
25
- return new Response('GitHub token not configured', { status: 500 })
25
+ const tokenResult = await resolveGitHubTokenForRequest(request)
26
+ if (!tokenResult.ok) {
27
+ return new Response(tokenResult.error.message, { status: 500 })
26
28
  }
29
+ const githubToken = tokenResult.value
27
30
 
28
31
  try {
29
32
  const body = await request.json() as {
@@ -37,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
37
40
  contentPath?: string
38
41
  }
39
42
 
40
- const storage = resolveStorageConfig(body)
43
+ const storage = await resolveStorageConfigForRequest(request, body)
41
44
  if (!storage) {
42
45
  return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
43
46
  }
@@ -84,7 +87,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
84
87
  // Only add values for fields that don't already exist
85
88
  for (const field of section.fields) {
86
89
  if (!(field.key in sectionData)) {
87
- sectionData[field.key] = field.defaultValue ?? getDefaultValue(field.type)
90
+ let value = field.defaultValue ?? getDefaultValue(field.type)
91
+ if (Array.isArray(value)) value = value.filter((item: unknown) => item != null)
92
+ sectionData[field.key] = value
88
93
  }
89
94
  }
90
95
 
@@ -208,7 +213,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
208
213
  for (const g of repeatedGroups) {
209
214
  const topField = fields.find(f => f.key === g.fieldKey)
210
215
  if (!topField || !Array.isArray(topField.defaultValue)) continue
211
- sectionData[g.fieldKey] = topField.defaultValue
216
+ sectionData[g.fieldKey] = (topField.defaultValue as unknown[]).filter(item => item != null)
212
217
  const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
213
218
  if (jsonIdx !== -1) {
214
219
  filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
@@ -232,7 +237,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
232
237
  for (const g of repeatedGroups) {
233
238
  const topField = fields.find(f => f.key === g.fieldKey)
234
239
  if (!topField || !Array.isArray(topField.defaultValue)) continue
235
- const items = topField.defaultValue as Array<Record<string, unknown>>
240
+ const items = (topField.defaultValue as Array<Record<string, unknown>>).filter(item => item != null)
236
241
 
237
242
  // Update sectionData with the enriched items array
238
243
  sectionData[g.fieldKey] = items
@@ -279,9 +284,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
279
284
  repo,
280
285
  branch,
281
286
  filesToCommit,
282
- existingSectionJson
287
+ withTrailers(existingSectionJson
283
288
  ? `content: update ${section.key} section — add new fields`
284
- : `content: add ${section.key} section to Setzkasten`,
289
+ : `content: add ${section.key} section to Setzkasten`),
285
290
  headers,
286
291
  )
287
292
 
@@ -289,6 +294,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
289
294
  return Response.json({ error: commitResult.error }, { status: 500 })
290
295
  }
291
296
 
297
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
298
+ await recordPageEdit(
299
+ { owner, repo, branch, contentPath, token: tokenResult.value },
300
+ pageKey,
301
+ ).catch(() => {})
302
+
292
303
  return Response.json({
293
304
  success: true,
294
305
  commitSha: commitResult.sha,
@@ -2,6 +2,8 @@ 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'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
5
7
 
6
8
  interface ApplyRequest {
7
9
  owner: string
@@ -35,10 +37,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
35
37
  return new Response('Unauthorized', { status: 401 })
36
38
  }
37
39
 
38
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
39
- if (!githubToken) {
40
- return new Response('GitHub token not configured', { status: 500 })
40
+ const tokenResult = await resolveGitHubTokenForRequest(request)
41
+ if (!tokenResult.ok) {
42
+ return new Response(tokenResult.error.message, { status: 500 })
41
43
  }
44
+ const githubToken = tokenResult.value
42
45
 
43
46
  try {
44
47
  const body = await request.json() as ApplyRequest
@@ -115,7 +118,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
115
118
  repo,
116
119
  branch,
117
120
  filesToCommit,
118
- 'feat: initialize Setzkasten CMS',
121
+ withTrailers('feat: initialize Setzkasten CMS'),
119
122
  githubToken,
120
123
  )
121
124
 
@@ -1,7 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import type { SetzKastenConfig } from '@setzkasten-cms/core'
3
- import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
4
4
  import { patchTemplateForFields } from '../init/template-patcher-v2'
5
+ import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
5
7
 
6
8
  /**
7
9
  * POST /api/setzkasten/init/migrate
@@ -20,10 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
20
22
  return new Response('Unauthorized', { status: 401 })
21
23
  }
22
24
 
23
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
- if (!githubToken) {
25
- return new Response('GitHub token not configured', { status: 500 })
25
+ const tokenResult = await resolveGitHubTokenForRequest(request)
26
+ if (!tokenResult.ok) {
27
+ return new Response(tokenResult.error.message, { status: 500 })
26
28
  }
29
+ const githubToken = tokenResult.value
27
30
 
28
31
  try {
29
32
  const body = await request.json() as {
@@ -34,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
34
37
  componentPath?: string
35
38
  }
36
39
 
37
- const storage = resolveStorageConfig(body)
40
+ const storage = await resolveStorageConfigForRequest(request, body)
38
41
  if (!storage) {
39
42
  return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
40
43
  }
@@ -108,7 +111,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
108
111
  repo,
109
112
  branch,
110
113
  [{ path: componentPath, content: patched }],
111
- `chore: add live-preview bindings to ${sectionKey} section`,
114
+ withTrailers(`chore: add live-preview bindings to ${sectionKey} section`),
112
115
  headers,
113
116
  )
114
117
 
@@ -1,9 +1,28 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import type { SetzKastenConfig } from '@setzkasten-cms/core'
3
- import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
4
4
  import { extractSectionImports, extractLayoutImport } from '../init/astro-detector'
5
5
  import { analyzeAstroSection } from '../init/astro-section-analyzer-v2'
6
6
  import type { RepoFile } from '@setzkasten-cms/core/init'
7
+ import { resolveGitHubTokenForRequest } from './_github-token'
8
+
9
+ // Build-time constant injected by the Vite define plugin — always available in
10
+ // compiled API routes (unlike page-ssr injectScript which only runs for SSR pages).
11
+ declare const __SETZKASTEN_FULL_CONFIG__: SetzKastenConfig | null | undefined
12
+
13
+ /**
14
+ * Resolves the full Setzkasten config.
15
+ * Reads the Vite build-time constant first; falls back to globalThis for
16
+ * local dev / test environments where the define is not applied.
17
+ *
18
+ * Without the build-time fallback, cold-start Vercel function invocations of
19
+ * this API route see managedSections={} and offer every adopted section
20
+ * (including _layout_header / _layout_footer) for re-adoption.
21
+ */
22
+ export function resolveFullConfig(): SetzKastenConfig | undefined {
23
+ const buildConfig = typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null
24
+ return (buildConfig ?? (globalThis as any).__SETZKASTEN_FULL_CONFIG__) as SetzKastenConfig | undefined
25
+ }
7
26
 
8
27
  /**
9
28
  * POST /api/setzkasten/init/scan-page
@@ -21,10 +40,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
21
40
  return new Response('Unauthorized', { status: 401 })
22
41
  }
23
42
 
24
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
25
- if (!githubToken) {
26
- return new Response('GitHub token not configured', { status: 500 })
43
+ const tokenResult = await resolveGitHubTokenForRequest(request)
44
+ if (!tokenResult.ok) {
45
+ return new Response(tokenResult.error.message, { status: 500 })
27
46
  }
47
+ const githubToken = tokenResult.value
28
48
 
29
49
  try {
30
50
  const body = await request.json() as {
@@ -35,7 +55,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
35
55
  projectRoot?: string
36
56
  }
37
57
 
38
- const storage = resolveStorageConfig(body)
58
+ const storage = await resolveStorageConfigForRequest(request, body)
39
59
  if (!storage) {
40
60
  return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
41
61
  }
@@ -47,7 +67,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
47
67
  }
48
68
 
49
69
  // Get current schema to know which sections are already managed + their fields
50
- const config = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as SetzKastenConfig | undefined
70
+ const config = resolveFullConfig()
51
71
  const managedSections = new Map<string, Set<string>>() // key → field keys
52
72
  if (config) {
53
73
  for (const product of Object.values(config.products)) {
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro'
2
2
  import { analyzeProject, type RepoFile } from '@setzkasten-cms/core/init'
3
3
  import { findAstroPages, extractSectionImports } from '../init/astro-detector'
4
4
  import { analyzeAstroSection } from '../init/astro-section-analyzer-v2'
5
+ import { resolveGitHubTokenForRequest } from './_github-token'
5
6
 
6
7
  /**
7
8
  * POST /api/setzkasten/init/scan
@@ -16,10 +17,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
16
17
  return new Response('Unauthorized', { status: 401 })
17
18
  }
18
19
 
19
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
20
- if (!githubToken) {
21
- return new Response('GitHub token not configured', { status: 500 })
20
+ const tokenResult = await resolveGitHubTokenForRequest(request)
21
+ if (!tokenResult.ok) {
22
+ return new Response(tokenResult.error.message, { status: 500 })
22
23
  }
24
+ const githubToken = tokenResult.value
23
25
 
24
26
  try {
25
27
  const body = await request.json() as { owner: string; repo: string; branch?: string }
@@ -0,0 +1,255 @@
1
+ import { type WebsiteEntry, canAddWebsite, isMultiModeAvailable } from '@setzkasten-cms/core'
2
+ import type { APIRoute } from 'astro'
3
+ import { parseSession } from './_auth-guard'
4
+ import { withTrailers } from './_commit-trailers'
5
+ import { resolveConfigRepoToken } from './_github-token'
6
+ import { resolveLicenseTier } from './_license-tier'
7
+ import { resolveStorageConfig } from './_storage-config'
8
+
9
+ /**
10
+ * POST /api/setzkasten/migrate/to-multi
11
+ *
12
+ * Body: { configRepo: 'owner/repo', configInstallationId: string,
13
+ * previewOrigin?: string }
14
+ *
15
+ * Admin-only. Expects the deployer to have already (1) created the
16
+ * config repo and (2) installed the GitHub App on it. The endpoint then:
17
+ *
18
+ * 1. Reads `_editors.json` and `_global_config.json` from the current
19
+ * single-mode website repo.
20
+ * 2. Writes both files into `<config-repo>/content/`.
21
+ * 3. Initialises `<config-repo>/websites.json` with a single entry that
22
+ * snapshots the current single-mode setup (repo, branch, preview
23
+ * origin, App-Installation-ID).
24
+ *
25
+ * After a 200 response the deployer still has to update
26
+ * `setzkasten.config.ts` (kind: 'single' → 'multi') and the ENV
27
+ * variables and redeploy. The wizard surfaces a copy-ready diff for
28
+ * those manual steps.
29
+ */
30
+ export const POST: APIRoute = async ({ request, cookies }) => {
31
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
32
+ if (!session) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
33
+ if (session.user.role !== 'admin')
34
+ return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
35
+
36
+ const tier = resolveLicenseTier()
37
+ if (!isMultiModeAvailable(tier)) {
38
+ return new Response(
39
+ JSON.stringify({
40
+ error: 'Multi-Mode ist nur mit Pro- oder Enterprise-Lizenz verfügbar.',
41
+ tier,
42
+ }),
43
+ { status: 402, headers: { 'Content-Type': 'application/json' } },
44
+ )
45
+ }
46
+
47
+ let body: { configRepo?: string; configInstallationId?: string; previewOrigin?: string } = {}
48
+ try {
49
+ body = (await request.json()) as typeof body
50
+ } catch {
51
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
52
+ }
53
+
54
+ if (!body.configRepo || typeof body.configRepo !== 'string' || !body.configRepo.includes('/')) {
55
+ return new Response(JSON.stringify({ error: 'configRepo (owner/repo) ist erforderlich' }), {
56
+ status: 400,
57
+ })
58
+ }
59
+ if (!body.configInstallationId || typeof body.configInstallationId !== 'string') {
60
+ return new Response(JSON.stringify({ error: 'configInstallationId ist erforderlich' }), {
61
+ status: 400,
62
+ })
63
+ }
64
+
65
+ const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
66
+ | { storage?: { kind?: string } }
67
+ | undefined
68
+ if (
69
+ fullConfig?.storage?.kind &&
70
+ fullConfig.storage.kind !== 'single' &&
71
+ fullConfig.storage.kind !== 'github-app'
72
+ ) {
73
+ return new Response(
74
+ JSON.stringify({
75
+ error: `Migration nur aus dem Single-Mode möglich. Aktueller storage.kind: ${fullConfig.storage.kind ?? 'unbekannt'}`,
76
+ }),
77
+ { status: 400 },
78
+ )
79
+ }
80
+
81
+ // We need the source-tier slot just in case the config repo already has
82
+ // entries — defensively preflight the limit so we never half-migrate.
83
+ const allowed = canAddWebsite(tier, 0)
84
+ if (!allowed.ok) {
85
+ return new Response(JSON.stringify({ error: allowed.reason }), { status: 402 })
86
+ }
87
+
88
+ const sourceStorage = resolveStorageConfig()
89
+ if (!sourceStorage) {
90
+ return new Response(
91
+ JSON.stringify({ error: 'Single-Mode Storage konnte nicht aufgelöst werden' }),
92
+ { status: 500 },
93
+ )
94
+ }
95
+
96
+ const tokenResult = await resolveConfigRepoToken()
97
+ if (!tokenResult.ok) {
98
+ return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
99
+ }
100
+ const token = tokenResult.value
101
+
102
+ const [configOwner, configRepo] = body.configRepo.split('/')
103
+ if (!configOwner || !configRepo) {
104
+ return new Response(JSON.stringify({ error: 'configRepo muss "owner/repo" sein' }), {
105
+ status: 400,
106
+ })
107
+ }
108
+
109
+ const sourceContentPath: string =
110
+ (
111
+ (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
112
+ | { storage?: { contentPath?: string } }
113
+ | undefined
114
+ )?.storage?.contentPath ?? 'content'
115
+
116
+ const headers = {
117
+ Authorization: `Bearer ${token}`,
118
+ Accept: 'application/vnd.github+json',
119
+ 'X-GitHub-Api-Version': '2022-11-28',
120
+ 'Content-Type': 'application/json',
121
+ }
122
+
123
+ const ghBase = (owner: string, repo: string, path: string) =>
124
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`
125
+
126
+ // 1. Read editors + global from website repo (best-effort — both files
127
+ // are optional in single-mode setups).
128
+ const sourceEditors = await readOptional(
129
+ ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_editors.json`),
130
+ `?ref=${sourceStorage.branch}`,
131
+ headers,
132
+ )
133
+ const sourceGlobal = await readOptional(
134
+ ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_global_config.json`),
135
+ `?ref=${sourceStorage.branch}`,
136
+ headers,
137
+ )
138
+
139
+ // 2. Write copies into config repo. (Branch defaults to 'main' since the
140
+ // user just created the repo; we don't expose a branch override here.)
141
+ const configBranch = 'main'
142
+
143
+ const editorsCommit = sourceEditors
144
+ ? await putFile(
145
+ ghBase(configOwner, configRepo, 'content/_editors.json'),
146
+ sourceEditors,
147
+ configBranch,
148
+ 'chore(migrate): copy editors from website repo',
149
+ headers,
150
+ )
151
+ : true
152
+
153
+ const globalCommit = sourceGlobal
154
+ ? await putFile(
155
+ ghBase(configOwner, configRepo, 'content/_global_config.json'),
156
+ sourceGlobal,
157
+ configBranch,
158
+ 'chore(migrate): copy global config from website repo',
159
+ headers,
160
+ )
161
+ : true
162
+
163
+ // 3. Initialise websites.json with the current setup as the first entry.
164
+ const previewOrigin = body.previewOrigin ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:4321'
165
+
166
+ const initialEntry: WebsiteEntry = {
167
+ id: 'main',
168
+ name: sourceStorage.repo,
169
+ repo: `${sourceStorage.owner}/${sourceStorage.repo}`,
170
+ branch: sourceStorage.branch,
171
+ previewOrigin,
172
+ githubApp: {
173
+ appId: process.env.GITHUB_APP_ID ?? '',
174
+ installationId: process.env.GITHUB_APP_INSTALLATION_ID ?? '',
175
+ },
176
+ }
177
+
178
+ const websitesContent = JSON.stringify({ websites: [initialEntry] }, null, 2)
179
+
180
+ const websitesCommit = await putFile(
181
+ ghBase(configOwner, configRepo, 'websites.json'),
182
+ websitesContent,
183
+ configBranch,
184
+ 'feat(migrate): initialise websites.json with current single-mode setup',
185
+ headers,
186
+ )
187
+
188
+ return new Response(
189
+ JSON.stringify({
190
+ ok: true,
191
+ committed: {
192
+ editors: editorsCommit,
193
+ globalConfig: globalCommit,
194
+ websites: websitesCommit,
195
+ },
196
+ // Echo back the values the user still has to set themselves.
197
+ manual: {
198
+ configRepo: body.configRepo,
199
+ configInstallationId: body.configInstallationId,
200
+ configBranch,
201
+ envChanges: {
202
+ add: {
203
+ SETZKASTEN_CONFIG_REPO: body.configRepo,
204
+ SETZKASTEN_CONFIG_BRANCH: configBranch,
205
+ GITHUB_APP_CONFIG_INSTALLATION_ID: body.configInstallationId,
206
+ },
207
+ },
208
+ },
209
+ }),
210
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
211
+ )
212
+ }
213
+
214
+ async function readOptional(
215
+ url: string,
216
+ qs: string,
217
+ headers: Record<string, string>,
218
+ ): Promise<string | null> {
219
+ const res = await fetch(url + qs, { headers })
220
+ if (!res.ok) return null
221
+ const data = (await res.json()) as { content: string; encoding: string }
222
+ return data.encoding === 'base64'
223
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
224
+ : data.content
225
+ }
226
+
227
+ async function putFile(
228
+ url: string,
229
+ content: string,
230
+ branch: string,
231
+ message: string,
232
+ headers: Record<string, string>,
233
+ ): Promise<boolean> {
234
+ // Read existing SHA so we can update an existing file rather than 422.
235
+ let sha: string | undefined
236
+ try {
237
+ const existing = await fetch(`${url}?ref=${branch}`, { headers })
238
+ if (existing.ok) {
239
+ const data = (await existing.json()) as { sha?: string }
240
+ sha = data.sha
241
+ }
242
+ } catch {
243
+ /* file doesn't exist — fine */
244
+ }
245
+
246
+ const body: Record<string, unknown> = {
247
+ message: withTrailers(message),
248
+ content: Buffer.from(content).toString('base64'),
249
+ branch,
250
+ }
251
+ if (sha) body.sha = sha
252
+
253
+ const res = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(body) })
254
+ return res.ok
255
+ }