@setzkasten-cms/astro-admin 0.8.0 → 1.3.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 (85) hide show
  1. package/package.json +22 -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__/feature-gate.test.ts +60 -0
  5. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  6. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  7. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  8. package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
  9. package/src/api-routes/__tests__/history.test.ts +168 -0
  10. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  11. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  12. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  13. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -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 +152 -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__/webhook-signing.test.ts +39 -0
  21. package/src/api-routes/__tests__/webhooks.test.ts +219 -0
  22. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  23. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  24. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  25. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  26. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  27. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  28. package/src/api-routes/_auth-guard.ts +134 -13
  29. package/src/api-routes/_feature-gate.ts +39 -0
  30. package/src/api-routes/_github-token.ts +64 -0
  31. package/src/api-routes/_license-tier.ts +25 -0
  32. package/src/api-routes/_pages-meta-store.ts +134 -0
  33. package/src/api-routes/_role-resolver.ts +60 -0
  34. package/src/api-routes/_session-cookie.ts +42 -0
  35. package/src/api-routes/_storage-config.ts +77 -4
  36. package/src/api-routes/_vercel-origin.ts +22 -0
  37. package/src/api-routes/_webhook-dispatcher.ts +120 -0
  38. package/src/api-routes/_webhook-signing.ts +13 -0
  39. package/src/api-routes/_webhook-status-store.ts +31 -0
  40. package/src/api-routes/_website-resolver.ts +243 -0
  41. package/src/api-routes/_websites-store.ts +120 -0
  42. package/src/api-routes/asset-proxy.ts +6 -4
  43. package/src/api-routes/auth-callback.ts +8 -7
  44. package/src/api-routes/auth-logout.ts +5 -1
  45. package/src/api-routes/auth-setzkasten-login.ts +37 -11
  46. package/src/api-routes/catalog-add.ts +9 -5
  47. package/src/api-routes/catalog-export.ts +8 -4
  48. package/src/api-routes/config.ts +12 -5
  49. package/src/api-routes/editors.ts +94 -10
  50. package/src/api-routes/github-proxy.ts +5 -5
  51. package/src/api-routes/global-config.ts +23 -6
  52. package/src/api-routes/history-rollback.ts +144 -0
  53. package/src/api-routes/history-version.ts +57 -0
  54. package/src/api-routes/history.ts +119 -0
  55. package/src/api-routes/init-add-section.ts +13 -5
  56. package/src/api-routes/init-apply.ts +5 -3
  57. package/src/api-routes/init-migrate.ts +7 -5
  58. package/src/api-routes/init-scan-page.ts +26 -6
  59. package/src/api-routes/init-scan.ts +5 -3
  60. package/src/api-routes/migrate-to-multi.ts +255 -0
  61. package/src/api-routes/pages.ts +118 -4
  62. package/src/api-routes/section-add.ts +15 -5
  63. package/src/api-routes/section-commit-pending.ts +117 -5
  64. package/src/api-routes/section-delete.ts +29 -5
  65. package/src/api-routes/section-duplicate.ts +15 -5
  66. package/src/api-routes/section-prepare-copy.ts +15 -4
  67. package/src/api-routes/section-prepare.ts +9 -5
  68. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  69. package/src/api-routes/setup-github-app-branches.ts +63 -0
  70. package/src/api-routes/setup-github-app-callback.ts +71 -0
  71. package/src/api-routes/setup-github-app-installed.ts +44 -0
  72. package/src/api-routes/setup-github-app-repos.ts +46 -0
  73. package/src/api-routes/setup-github-app.ts +58 -0
  74. package/src/api-routes/updater-register.ts +37 -25
  75. package/src/api-routes/updater-transfer.ts +1 -12
  76. package/src/api-routes/webhooks-status.ts +17 -0
  77. package/src/api-routes/webhooks-test.ts +134 -0
  78. package/src/api-routes/webhooks.ts +163 -0
  79. package/src/api-routes/websites-add.ts +113 -0
  80. package/src/api-routes/websites-list.ts +40 -0
  81. package/src/api-routes/websites-remove.ts +74 -0
  82. package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
  83. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  84. package/src/init/template-patcher-v2.ts +42 -4
  85. package/LICENSE +0 -37
@@ -0,0 +1,144 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { parseSession, requireAdmin } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
6
+ import { invalidateCache } from './_github-cache'
7
+
8
+ interface RollbackBody {
9
+ path?: string
10
+ sha?: string
11
+ /** Optional ETag-style guard: client sends the SHA they think is HEAD;
12
+ * if HEAD has moved, we 409 to prevent stomping live edits. */
13
+ expectedHeadSha?: string
14
+ }
15
+
16
+ /**
17
+ * POST /api/setzkasten/history/rollback
18
+ *
19
+ * Body: { path, sha, expectedHeadSha? }
20
+ *
21
+ * Restores `path` to the contents from `sha` by writing a new commit
22
+ * (no `git revert` — JSON content is set wholesale). The original SHA
23
+ * stays in history so users can roll forward again.
24
+ *
25
+ * Conflict semantics: the client passes the SHA they currently render in
26
+ * the file picker. If the file's HEAD has moved between page-load and the
27
+ * rollback click, we return 409 with `code: 'head-moved'` so the UI can
28
+ * tell the user to refresh.
29
+ *
30
+ * Admin-only — editors can edit, but rollback is destructive enough to
31
+ * warrant the audit-log control.
32
+ */
33
+ export const POST: APIRoute = async ({ request, cookies }) => {
34
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
35
+ if (denied) return denied
36
+
37
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
38
+ if (!session) return new Response('Unauthorized', { status: 401 })
39
+
40
+ let body: RollbackBody
41
+ try {
42
+ body = (await request.json()) as RollbackBody
43
+ } catch {
44
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
45
+ }
46
+ const { path, sha, expectedHeadSha } = body
47
+ if (!path || !sha) {
48
+ return Response.json({ error: 'path and sha are required' }, { status: 400 })
49
+ }
50
+
51
+ const tokenResult = await resolveGitHubTokenForRequest(request)
52
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
53
+
54
+ const storage = await resolveStorageConfigForRequest(request)
55
+ if (!storage) {
56
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
57
+ }
58
+ const { owner, repo, branch } = storage
59
+
60
+ const headers = {
61
+ Authorization: `Bearer ${tokenResult.value}`,
62
+ Accept: 'application/vnd.github+json',
63
+ 'X-GitHub-Api-Version': '2022-11-28',
64
+ 'Content-Type': 'application/json',
65
+ }
66
+
67
+ // 1. Fetch contents of the file at the target sha
68
+ const versionRes = await fetch(
69
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${sha}`,
70
+ { headers },
71
+ )
72
+ if (versionRes.status === 404) {
73
+ return Response.json(
74
+ { error: 'File did not exist at the requested sha', code: 'version-not-found' },
75
+ { status: 404 },
76
+ )
77
+ }
78
+ if (!versionRes.ok) {
79
+ return Response.json(
80
+ { error: `Failed to read version: ${versionRes.status}` },
81
+ { status: 502 },
82
+ )
83
+ }
84
+ const versionData = (await versionRes.json()) as {
85
+ content: string
86
+ encoding: string
87
+ }
88
+ const targetContent =
89
+ versionData.encoding === 'base64'
90
+ ? Buffer.from(versionData.content, 'base64').toString('utf-8')
91
+ : versionData.content
92
+
93
+ // 2. Fetch current HEAD SHA of the file (for conflict detection + PUT sha param)
94
+ const headRes = await fetch(
95
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
96
+ { headers },
97
+ )
98
+ let currentSha: string | null = null
99
+ if (headRes.ok) {
100
+ const data = (await headRes.json()) as { sha: string }
101
+ currentSha = data.sha
102
+ }
103
+
104
+ if (expectedHeadSha && currentSha && expectedHeadSha !== currentSha) {
105
+ return Response.json(
106
+ {
107
+ error: 'Datei wurde inzwischen geändert. Bitte den Verlauf neu laden.',
108
+ code: 'head-moved',
109
+ },
110
+ { status: 409 },
111
+ )
112
+ }
113
+
114
+ // 3. Write new commit with the historical content
115
+ const shortSha = sha.slice(0, 7)
116
+ const fileName = path.split('/').pop() ?? path
117
+ const message = withTrailers(
118
+ `revert(${fileName}): rollback to ${shortSha}`,
119
+ session.user.email,
120
+ )
121
+
122
+ const putBody: Record<string, unknown> = {
123
+ message,
124
+ content: Buffer.from(targetContent).toString('base64'),
125
+ branch,
126
+ }
127
+ if (currentSha) putBody.sha = currentSha
128
+
129
+ const putRes = await fetch(
130
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
131
+ { method: 'PUT', headers, body: JSON.stringify(putBody) },
132
+ )
133
+
134
+ if (!putRes.ok) {
135
+ const text = await putRes.text()
136
+ return Response.json({ error: `Rollback write failed: ${text}` }, { status: 502 })
137
+ }
138
+
139
+ // Invalidate history cache for this path — fresh commit shows up in lists.
140
+ invalidateCache(`history:${owner}/${repo}:${branch}:${path}:head`)
141
+
142
+ const putData = (await putRes.json()) as { commit: { sha: string } }
143
+ return Response.json({ ok: true, commitSha: putData.commit.sha })
144
+ }
@@ -0,0 +1,57 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { requireAdmin } from './_auth-guard'
5
+ import { cachedFetch } from './_github-cache'
6
+
7
+ /**
8
+ * GET /api/setzkasten/history/version?path=<file>&sha=<commit-sha>
9
+ *
10
+ * Returns the file content at a specific commit (for diff rendering).
11
+ * Cached per (path, sha) for 5 minutes — historical content is immutable.
12
+ */
13
+ export const GET: APIRoute = async ({ request, url, cookies }) => {
14
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
15
+ if (denied) return denied
16
+
17
+ const path = url.searchParams.get('path')
18
+ const sha = url.searchParams.get('sha')
19
+ if (!path || !sha) {
20
+ return Response.json({ error: 'Missing required `path` or `sha`.' }, { status: 400 })
21
+ }
22
+
23
+ const tokenResult = await resolveGitHubTokenForRequest(request)
24
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
25
+
26
+ const storage = await resolveStorageConfigForRequest(request)
27
+ if (!storage) {
28
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
29
+ }
30
+ const { owner, repo } = storage
31
+
32
+ const cacheKey = `history-version:${owner}/${repo}:${path}:${sha}`
33
+ const result = await cachedFetch(cacheKey, 5 * 60_000, async () => {
34
+ const u = new URL(
35
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
36
+ )
37
+ u.searchParams.set('ref', sha)
38
+ const res = await fetch(u, {
39
+ headers: {
40
+ Authorization: `Bearer ${tokenResult.value}`,
41
+ Accept: 'application/vnd.github+json',
42
+ 'X-GitHub-Api-Version': '2022-11-28',
43
+ },
44
+ })
45
+ if (res.status === 404) return { ok: false as const, status: 404, error: 'File not found at given sha' }
46
+ if (!res.ok) return { ok: false as const, status: 502, error: `GitHub returned ${res.status}` }
47
+ const data = (await res.json()) as { content: string; encoding: string; sha: string }
48
+ const raw =
49
+ data.encoding === 'base64'
50
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
51
+ : data.content
52
+ return { ok: true as const, value: { content: raw, sha: data.sha } }
53
+ })
54
+
55
+ if (!result.ok) return Response.json({ error: result.error }, { status: result.status })
56
+ return Response.json(result.value)
57
+ }
@@ -0,0 +1,119 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { requireAdmin } from './_auth-guard'
5
+ import { parseCoAuthorTrailers, type CommitInfo } from '@setzkasten-cms/core'
6
+ import { cachedFetch } from './_github-cache'
7
+
8
+ /**
9
+ * GET /api/setzkasten/history?path=<contentPath>&before=<sha>
10
+ *
11
+ * Returns up to 5 most recent commits affecting the given file. Pagination
12
+ * via `before=<sha>` returns 10 more older commits — clients call this on
13
+ * "Mehr laden". Admin-only — editors can read content but not the audit
14
+ * trail (and certainly not roll back).
15
+ */
16
+ export const GET: APIRoute = async ({ request, url, cookies }) => {
17
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
18
+ if (denied) return denied
19
+
20
+ const path = url.searchParams.get('path')
21
+ const before = url.searchParams.get('before')
22
+ if (!path) {
23
+ return Response.json({ error: 'Missing required `path` parameter.' }, { status: 400 })
24
+ }
25
+
26
+ const tokenResult = await resolveGitHubTokenForRequest(request)
27
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
28
+
29
+ const storage = await resolveStorageConfigForRequest(request)
30
+ if (!storage) {
31
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
32
+ }
33
+ const { owner, repo, branch } = storage
34
+ const perPage = before ? 10 : 5
35
+
36
+ // Cache history per (path, before) for 60s — invalidated by rollback.
37
+ const cacheKey = `history:${owner}/${repo}:${branch}:${path}:${before ?? 'head'}`
38
+ const commits = await cachedFetch(cacheKey, 60_000, () =>
39
+ fetchCommits(owner, repo, branch, path, perPage, before, tokenResult.value),
40
+ )
41
+
42
+ if (!commits.ok) {
43
+ return Response.json({ error: commits.error }, { status: commits.status })
44
+ }
45
+ return Response.json({ commits: commits.value })
46
+ }
47
+
48
+ interface CommitsListSuccess {
49
+ ok: true
50
+ value: readonly CommitInfo[]
51
+ }
52
+ interface CommitsListFailure {
53
+ ok: false
54
+ status: number
55
+ error: string
56
+ }
57
+ type CommitsResult = CommitsListSuccess | CommitsListFailure
58
+
59
+ async function fetchCommits(
60
+ owner: string,
61
+ repo: string,
62
+ branch: string,
63
+ path: string,
64
+ perPage: number,
65
+ before: string | null,
66
+ token: string,
67
+ ): Promise<CommitsResult> {
68
+ // GitHub paginates by `?sha=<commit>` — passing `before` as `sha` gets
69
+ // commits older than (and including) that SHA. We start one before
70
+ // requested SHA so the same commit doesn't appear twice. The simplest
71
+ // way: pass sha=<before>, request perPage+1, and skip the first.
72
+ const sha = before ?? branch
73
+ const u = new URL(`https://api.github.com/repos/${owner}/${repo}/commits`)
74
+ u.searchParams.set('path', path)
75
+ u.searchParams.set('sha', sha)
76
+ u.searchParams.set('per_page', String(before ? perPage + 1 : perPage))
77
+
78
+ const res = await fetch(u, {
79
+ headers: {
80
+ Authorization: `Bearer ${token}`,
81
+ Accept: 'application/vnd.github+json',
82
+ 'X-GitHub-Api-Version': '2022-11-28',
83
+ },
84
+ })
85
+
86
+ if (res.status === 404) return { ok: true, value: [] }
87
+ if (!res.ok) {
88
+ return { ok: false, status: 502, error: `GitHub returned ${res.status}` }
89
+ }
90
+
91
+ const data = (await res.json()) as Array<{
92
+ sha: string
93
+ commit: {
94
+ author: { name: string; email: string; date: string }
95
+ message: string
96
+ }
97
+ author: { avatar_url?: string } | null
98
+ }>
99
+
100
+ // Skip first if we paginated (the `before` SHA itself).
101
+ const start = before ? 1 : 0
102
+ const slice = data.slice(start, start + perPage)
103
+ const commits: CommitInfo[] = slice.map((c) => {
104
+ const [firstLine, ...rest] = c.commit.message.split('\n')
105
+ const body = rest.join('\n')
106
+ return {
107
+ sha: c.sha,
108
+ shortSha: c.sha.slice(0, 7),
109
+ authoredAt: c.commit.author.date,
110
+ authorName: c.commit.author.name,
111
+ authorEmail: c.commit.author.email,
112
+ authorAvatarUrl: c.author?.avatar_url,
113
+ coAuthors: parseCoAuthorTrailers(body),
114
+ message: firstLine ?? '',
115
+ body,
116
+ }
117
+ })
118
+ return { ok: true, value: commits }
119
+ }
@@ -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 }