@setzkasten-cms/astro-admin 0.8.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 (68) hide show
  1. package/package.json +16 -6
  2. package/src/admin-page.astro +1 -1
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
@@ -1,12 +1,23 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { resolveStorageConfig } from './_storage-config'
3
3
  import { parseSession } from './_auth-guard'
4
+ import { resolveConfigRepoToken } from './_github-token'
4
5
  import type { ContentEditorConfig } from '@setzkasten-cms/core'
5
6
  import { cachedFetch, invalidateCache } from './_github-cache'
6
7
  import { withTrailers } from './_commit-trailers'
7
8
 
8
9
  const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
9
10
 
11
+ // In Multi-Mode editors are global across all websites and live in the
12
+ // config-repo; in Single-Mode the config-repo IS the website-repo. Either
13
+ // way the answer is "the build-time-configured storage" — never the
14
+ // per-website storage that the X-SK-Website header would route to.
15
+ function configRepoStorage(): { owner: string; repo: string; branch: string } | null {
16
+ const storage = resolveStorageConfig()
17
+ if (!storage) return null
18
+ return { owner: storage.owner, repo: storage.repo, branch: storage.branch }
19
+ }
20
+
10
21
  // ---------------------------------------------------------------------------
11
22
  // GET /api/setzkasten/editors
12
23
  // Returns the current editors list from _editors.json.
@@ -17,17 +28,18 @@ export const GET: APIRoute = async ({ cookies }) => {
17
28
  const session = parseSession(cookies.get('setzkasten_session')?.value)
18
29
  if (!session) return new Response('Unauthorized', { status: 401 })
19
30
 
20
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
21
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
31
+ const tokenResult = await resolveConfigRepoToken()
32
+ if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
22
33
 
23
- const storage = resolveStorageConfig()
34
+ const storage = configRepoStorage()
24
35
  if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
25
36
 
26
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
37
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
38
+ .__SETZKASTEN_CONFIG__
27
39
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
28
40
  const { owner, repo, branch } = storage
29
41
 
30
- const raw = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
42
+ const raw = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
31
43
  return Response.json(raw ?? [])
32
44
  }
33
45
 
@@ -42,13 +54,14 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
42
54
  if (!session) return new Response('Unauthorized', { status: 401 })
43
55
  if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
44
56
 
45
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
46
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
57
+ const tokenResult = await resolveConfigRepoToken()
58
+ if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
47
59
 
48
- const storage = resolveStorageConfig()
60
+ const storage = configRepoStorage()
49
61
  if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
50
62
 
51
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
63
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
64
+ .__SETZKASTEN_CONFIG__
52
65
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
53
66
  const { owner, repo, branch } = storage
54
67
 
@@ -63,7 +76,7 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
63
76
  const filePath = EDITORS_FILE(contentPath)
64
77
  const fileContent = JSON.stringify(editors, null, 2)
65
78
  const headers = {
66
- Authorization: `Bearer ${githubToken}`,
79
+ Authorization: `Bearer ${tokenResult.value}`,
67
80
  Accept: 'application/vnd.github+json',
68
81
  'X-GitHub-Api-Version': '2022-11-28',
69
82
  'Content-Type': 'application/json',
@@ -134,3 +147,59 @@ export async function readEditorsFile(
134
147
  return JSON.parse(raw) as ContentEditorConfig[]
135
148
  })
136
149
  }
150
+
151
+ /**
152
+ * Discriminated result for the editors-file fetch. Lets callers decide the
153
+ * fail-mode policy: the auth-guard wants to ALLOW when the file is genuinely
154
+ * absent (no restrictions configured) but DENY when the fetch errors out.
155
+ * The basic readEditorsFile() above returns null for both cases, which is
156
+ * unsafe for authorization checks.
157
+ *
158
+ * Caller is responsible for caching — this function never reads from or
159
+ * writes to the shared cache, because caching an "error" state would
160
+ * silently extend privilege-escalation windows.
161
+ */
162
+ export type EditorsStatus =
163
+ | { kind: 'absent' }
164
+ | { kind: 'present'; editors: ContentEditorConfig[] }
165
+ | { kind: 'error'; message: string }
166
+
167
+ export async function readEditorsFileStatus(
168
+ owner: string,
169
+ repo: string,
170
+ branch: string,
171
+ contentPath: string,
172
+ token: string,
173
+ ): Promise<EditorsStatus> {
174
+ let res: Response
175
+ try {
176
+ res = await fetch(
177
+ `https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
178
+ {
179
+ headers: {
180
+ Authorization: `Bearer ${token}`,
181
+ Accept: 'application/vnd.github+json',
182
+ 'X-GitHub-Api-Version': '2022-11-28',
183
+ },
184
+ },
185
+ )
186
+ } catch (err) {
187
+ return { kind: 'error', message: err instanceof Error ? err.message : 'network error' }
188
+ }
189
+
190
+ if (res.status === 404) return { kind: 'absent' }
191
+ if (!res.ok) {
192
+ return { kind: 'error', message: `GitHub returned ${res.status}` }
193
+ }
194
+
195
+ try {
196
+ const data = (await res.json()) as { content: string; encoding: string }
197
+ const raw =
198
+ data.encoding === 'base64'
199
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
200
+ : data.content
201
+ return { kind: 'present', editors: JSON.parse(raw) as ContentEditorConfig[] }
202
+ } catch (err) {
203
+ return { kind: 'error', message: err instanceof Error ? err.message : 'parse error' }
204
+ }
205
+ }
@@ -1,6 +1,7 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
5
 
5
6
  /**
6
7
  * Server-side proxy for GitHub API calls.
@@ -21,12 +22,11 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => {
21
22
  return new Response('Missing path', { status: 400 })
22
23
  }
23
24
 
24
- // GitHub App token from environment (never sent to client)
25
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
26
-
27
- if (!githubToken) {
28
- 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 })
29
28
  }
29
+ const githubToken = tokenResult.value
30
30
 
31
31
  const githubUrl = `https://api.github.com/${githubPath}`
32
32
 
@@ -1,6 +1,7 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { parseSession } from './_auth-guard'
3
3
  import { resolveStorageConfig } from './_storage-config'
4
+ import { resolveConfigRepoToken } from './_github-token'
4
5
  import { cachedFetch, invalidateCache } from './_github-cache'
5
6
  import { withTrailers } from './_commit-trailers'
6
7
 
@@ -12,6 +13,11 @@ export interface GlobalConfig {
12
13
  authDomain: string
13
14
  projectId: string
14
15
  }
16
+ theme?: {
17
+ primaryColor?: string
18
+ brandName?: string
19
+ logo?: string
20
+ }
15
21
  }
16
22
 
17
23
  // ---------------------------------------------------------------------------
@@ -42,7 +48,7 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
42
48
  return Response.json({ error: 'Invalid request body' }, { status: 400 })
43
49
  }
44
50
 
45
- const current = await readGlobalConfig() ?? {}
51
+ const current = (await readGlobalConfig()) ?? {}
46
52
  const next: GlobalConfig = { ...current }
47
53
  for (const [k, v] of Object.entries(patch)) {
48
54
  if (v === null) delete (next as Record<string, unknown>)[k]
@@ -54,23 +60,34 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
54
60
 
55
61
  // ---------------------------------------------------------------------------
56
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.
57
71
  // ---------------------------------------------------------------------------
58
72
 
59
- function getStorageParams() {
60
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
73
+ async function getStorageParams() {
74
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
75
+ .__SETZKASTEN_CONFIG__
61
76
  const storage = resolveStorageConfig()
62
77
  if (!storage) return null
78
+ const tokenResult = await resolveConfigRepoToken()
79
+ if (!tokenResult.ok) return null
63
80
  return {
64
81
  owner: storage.owner,
65
82
  repo: storage.repo,
66
83
  branch: storage.branch,
67
84
  contentPath: serverConfig?.storage?.contentPath ?? 'content',
68
- token: (import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? '') as string,
85
+ token: tokenResult.value,
69
86
  }
70
87
  }
71
88
 
72
89
  export async function readGlobalConfig(): Promise<GlobalConfig | null> {
73
- const params = getStorageParams()
90
+ const params = await getStorageParams()
74
91
  if (!params) return null
75
92
  const { owner, repo, branch, contentPath, token } = params
76
93
  const key = `global-config:${owner}/${repo}:${branch}`
@@ -89,7 +106,7 @@ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
89
106
  }
90
107
 
91
108
  export async function writeGlobalConfig(config: GlobalConfig): Promise<void> {
92
- const params = getStorageParams()
109
+ const params = await getStorageParams()
93
110
  if (!params) throw new Error('Storage not configured')
94
111
  const { owner, repo, branch, contentPath, token } = params
95
112
  invalidateCache(`global-config:${owner}/${repo}:${branch}`)
@@ -1,10 +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
7
  import { withTrailers } from './_commit-trailers'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
8
9
 
9
10
  /**
10
11
  * POST /api/setzkasten/init/add-section
@@ -21,10 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
21
22
  return new Response('Unauthorized', { status: 401 })
22
23
  }
23
24
 
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 })
25
+ const tokenResult = await resolveGitHubTokenForRequest(request)
26
+ if (!tokenResult.ok) {
27
+ return new Response(tokenResult.error.message, { status: 500 })
27
28
  }
29
+ const githubToken = tokenResult.value
28
30
 
29
31
  try {
30
32
  const body = await request.json() as {
@@ -38,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
38
40
  contentPath?: string
39
41
  }
40
42
 
41
- const storage = resolveStorageConfig(body)
43
+ const storage = await resolveStorageConfigForRequest(request, body)
42
44
  if (!storage) {
43
45
  return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
44
46
  }
@@ -292,6 +294,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
292
294
  return Response.json({ error: commitResult.error }, { status: 500 })
293
295
  }
294
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
+
295
303
  return Response.json({
296
304
  success: true,
297
305
  commitSha: commitResult.sha,
@@ -3,6 +3,7 @@ import { generateConfigFile, type InferredSection, type ConfigGeneratorInput } f
3
3
  import { patchAstroConfig } from '../init/astro-config-patcher'
4
4
  import { patchTemplateForFields } from '../init/template-patcher-v2'
5
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  interface ApplyRequest {
8
9
  owner: string
@@ -36,10 +37,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
36
37
  return new Response('Unauthorized', { status: 401 })
37
38
  }
38
39
 
39
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
40
- if (!githubToken) {
41
- 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 })
42
43
  }
44
+ const githubToken = tokenResult.value
43
45
 
44
46
  try {
45
47
  const body = await request.json() as ApplyRequest
@@ -1,8 +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
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  /**
8
9
  * POST /api/setzkasten/init/migrate
@@ -21,10 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
21
22
  return new Response('Unauthorized', { status: 401 })
22
23
  }
23
24
 
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 })
25
+ const tokenResult = await resolveGitHubTokenForRequest(request)
26
+ if (!tokenResult.ok) {
27
+ return new Response(tokenResult.error.message, { status: 500 })
27
28
  }
29
+ const githubToken = tokenResult.value
28
30
 
29
31
  try {
30
32
  const body = await request.json() as {
@@ -35,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
35
37
  componentPath?: string
36
38
  }
37
39
 
38
- const storage = resolveStorageConfig(body)
40
+ const storage = await resolveStorageConfigForRequest(request, body)
39
41
  if (!storage) {
40
42
  return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
41
43
  }
@@ -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 }