@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
@@ -0,0 +1,243 @@
1
+ import {
2
+ type Result,
3
+ type WebsiteEntry,
4
+ type WebsitesRegistryProvider,
5
+ err,
6
+ notFoundError,
7
+ ok,
8
+ validationError,
9
+ } from '@setzkasten-cms/core'
10
+ import { GitHubAppClient, GitHubWebsitesRegistry } from '@setzkasten-cms/github-adapter'
11
+
12
+ /**
13
+ * Per-request resolver for the active website.
14
+ *
15
+ * Standalone mode: looks up the entry matching the X-SK-Website request
16
+ * header in the websites registry (config-repo).
17
+ *
18
+ * Single-repo mode (backward compat): returns a synthesized WebsiteEntry
19
+ * built from the integration's build-time storage + GitHub-App ENV vars.
20
+ * The header is ignored.
21
+ */
22
+
23
+ interface MultiState {
24
+ readonly mode: 'multi'
25
+ readonly registry: WebsitesRegistryProvider
26
+ }
27
+
28
+ interface SingleRepoState {
29
+ readonly mode: 'single'
30
+ readonly synthesized: WebsiteEntry
31
+ }
32
+
33
+ type ResolverState = MultiState | SingleRepoState | null
34
+
35
+ let state: ResolverState = null
36
+
37
+ /** Test/admin hook: install or clear the resolver state. */
38
+ export function __resetWebsiteResolverForTests(next: ResolverState): void {
39
+ state = next
40
+ }
41
+
42
+ /**
43
+ * Configure the resolver state explicitly. Currently only the test suite
44
+ * uses this — production wiring runs lazily through
45
+ * {@link bootstrapResolverFromGlobals} on the first request, since the
46
+ * Astro integration doesn't have an obvious "initialize once" hook
47
+ * (`astro:server:start` runs in dev only). The export stays available so
48
+ * a future integration-startup wire-up has a typed entry point.
49
+ */
50
+ export function configureWebsiteResolver(next: ResolverState): void {
51
+ state = next
52
+ }
53
+
54
+ interface FullConfig {
55
+ storage?:
56
+ | {
57
+ // 'single' is canonical; 'github-app' is the legacy alias.
58
+ kind: 'single' | 'github-app' | 'local'
59
+ repo?: string
60
+ appId?: string
61
+ installationId?: string
62
+ }
63
+ | {
64
+ // 'multi' is canonical; 'standalone' is the legacy alias.
65
+ kind: 'multi' | 'standalone'
66
+ configRepo: string
67
+ configBranch?: string
68
+ appId: string
69
+ installationId: string
70
+ }
71
+ }
72
+
73
+ function isMultiKind(kind: unknown): boolean {
74
+ return kind === 'multi' || kind === 'standalone'
75
+ }
76
+
77
+ interface BuildTimeStorage {
78
+ owner: string
79
+ repo: string
80
+ branch: string
81
+ }
82
+
83
+ // Vite define-injected literals. The integration (`packages/astro/src/
84
+ // integration.ts`) sets them via the build-time Vite define plugin, so by
85
+ // the time this code runs in a compiled API route the literals are
86
+ // substituted with their JSON values. globalThis-style reads break on
87
+ // cold-start serverless functions because the integration's
88
+ // page-ssr injectScript only fires for SSR pages, never for API-only
89
+ // invocations.
90
+ declare const __SETZKASTEN_FULL_CONFIG__: FullConfig | null | undefined
91
+ declare const __SETZKASTEN_STORAGE__: BuildTimeStorage | null | undefined
92
+ declare const __SETZKASTEN_WEBSITE_URL__: string | undefined
93
+
94
+ /**
95
+ * Lazy bootstrap from build-time globals. Reads `__SETZKASTEN_FULL_CONFIG__`
96
+ * and `__SETZKASTEN_STORAGE__` (set by the Astro integration via Vite
97
+ * define — literals only, NOT globalThis) to derive single-repo or
98
+ * standalone state. Idempotent — does nothing if the resolver is already
99
+ * configured.
100
+ */
101
+ export function bootstrapResolverFromGlobals(): void {
102
+ if (state !== null) return
103
+
104
+ const fullConfig =
105
+ (typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null) ??
106
+ ((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
107
+ | FullConfig
108
+ | undefined)
109
+ const buildStorage =
110
+ (typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null) ??
111
+ ((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as
112
+ | BuildTimeStorage
113
+ | undefined)
114
+
115
+ const storageKind = fullConfig?.storage?.kind
116
+ if (isMultiKind(storageKind)) {
117
+ const standalone = fullConfig?.storage as {
118
+ kind: 'multi' | 'standalone'
119
+ configRepo: string
120
+ configBranch?: string
121
+ appId: string
122
+ installationId: string
123
+ }
124
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
125
+ if (!privateKey) {
126
+ // Without the private key the resolver can't mint installation tokens;
127
+ // every Multi-Mode request would 400 with a confusing "X-SK-Website
128
+ // header missing" message instead of the actual root cause.
129
+ console.error(
130
+ '[setzkasten] Multi-mode resolver bootstrap failed: GITHUB_APP_PRIVATE_KEY is not set in env. ' +
131
+ 'Set it on the standalone-admin deployment so the registry can be loaded.',
132
+ )
133
+ return
134
+ }
135
+ const [owner, repo] = standalone.configRepo.split('/')
136
+ if (!owner || !repo) {
137
+ console.error(
138
+ `[setzkasten] Multi-mode resolver bootstrap failed: storage.configRepo "${standalone.configRepo}" is not in "owner/repo" form.`,
139
+ )
140
+ return
141
+ }
142
+
143
+ const client = new GitHubAppClient(
144
+ { appId: standalone.appId, installationId: standalone.installationId, privateKey },
145
+ { owner, repo, branch: standalone.configBranch ?? 'main' },
146
+ )
147
+ const registry = new GitHubWebsitesRegistry({
148
+ reader: { read: (path) => client.getFileContent(path) },
149
+ path: 'websites.json',
150
+ ttlMs: 60_000,
151
+ })
152
+ state = { mode: 'multi', registry }
153
+ return
154
+ }
155
+
156
+ if (!buildStorage) return
157
+
158
+ const appId = process.env.GITHUB_APP_ID ?? ''
159
+ const installationId = process.env.GITHUB_APP_INSTALLATION_ID ?? ''
160
+ // Source of truth: __SETZKASTEN_WEBSITE_URL__ Vite define (mirrors
161
+ // astro.config.mjs#site, dev-server origin in dev) — same value the
162
+ // updater registers licenses with. As a Vite literal it survives
163
+ // cold-start API-only function invocations (unlike __SETZKASTEN_CONFIG__
164
+ // which is only set via injectScript on page-ssr renders).
165
+ // PUBLIC_SITE_URL stays as an escape hatch for setups without `site:`.
166
+ const websiteUrlLiteral =
167
+ typeof __SETZKASTEN_WEBSITE_URL__ !== 'undefined' ? __SETZKASTEN_WEBSITE_URL__ : ''
168
+ const previewOrigin =
169
+ websiteUrlLiteral ||
170
+ process.env.PUBLIC_SITE_URL ||
171
+ 'http://localhost:4321'
172
+
173
+ state = {
174
+ mode: 'single',
175
+ synthesized: {
176
+ id: 'default',
177
+ name: buildStorage.repo,
178
+ repo: `${buildStorage.owner}/${buildStorage.repo}`,
179
+ branch: buildStorage.branch,
180
+ previewOrigin,
181
+ githubApp: { appId, installationId },
182
+ },
183
+ }
184
+ }
185
+
186
+ const HEADER = 'x-sk-website'
187
+
188
+ export async function resolveCurrentWebsite(request: Request): Promise<Result<WebsiteEntry>> {
189
+ if (state === null) bootstrapResolverFromGlobals()
190
+ if (state === null) {
191
+ return err(
192
+ validationError(
193
+ ['websiteResolver'],
194
+ 'not-configured',
195
+ 'Website resolver not configured — call configureWebsiteResolver() at integration startup.',
196
+ ),
197
+ )
198
+ }
199
+
200
+ if (state.mode === 'single') {
201
+ return ok(state.synthesized)
202
+ }
203
+
204
+ const requested = request.headers.get(HEADER)?.trim() ?? ''
205
+
206
+ if (!requested) {
207
+ const list = await state.registry.list()
208
+ if (!list.ok) return list
209
+
210
+ const sole = list.value.length === 1 ? list.value[0] : undefined
211
+ if (sole) return ok(sole)
212
+
213
+ return err(
214
+ validationError(
215
+ [HEADER],
216
+ 'required',
217
+ 'Standalone mode requires the X-SK-Website request header (registry has multiple entries).',
218
+ ),
219
+ )
220
+ }
221
+
222
+ const found = await state.registry.get(requested)
223
+ if (!found.ok) return found
224
+ if (!found.value) return err(notFoundError(`website:${requested}`))
225
+
226
+ return ok(found.value)
227
+ }
228
+
229
+ /**
230
+ * Lists all websites known to the resolver.
231
+ * Standalone mode: defers to the registry. Single-repo mode: returns the
232
+ * synthesized entry as a one-element list.
233
+ */
234
+ export async function listAllWebsites(): Promise<Result<readonly WebsiteEntry[]>> {
235
+ if (state === null) bootstrapResolverFromGlobals()
236
+ if (state === null) {
237
+ return err(
238
+ validationError(['websiteResolver'], 'not-configured', 'Website resolver not configured.'),
239
+ )
240
+ }
241
+ if (state.mode === 'single') return ok([state.synthesized])
242
+ return state.registry.list()
243
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Read/write the standalone admin's `websites.json` from the config repo.
3
+ * Pure HTTP — no caching here; the GitHub-side cache lives in
4
+ * GitHubWebsitesRegistry, which API routes invalidate explicitly after a
5
+ * successful write.
6
+ */
7
+
8
+ import {
9
+ type Result,
10
+ type WebsitesRegistry,
11
+ err,
12
+ networkError,
13
+ ok,
14
+ parseWebsitesRegistry,
15
+ } from '@setzkasten-cms/core'
16
+ import { withTrailers } from './_commit-trailers'
17
+
18
+ interface ConfigRepoTarget {
19
+ readonly owner: string
20
+ readonly repo: string
21
+ readonly branch: string
22
+ readonly path: string
23
+ readonly token: string
24
+ }
25
+
26
+ interface ReadResult {
27
+ readonly registry: WebsitesRegistry
28
+ readonly sha: string | null
29
+ }
30
+
31
+ const githubHeaders = (token: string) => ({
32
+ Authorization: `Bearer ${token}`,
33
+ Accept: 'application/vnd.github+json',
34
+ 'X-GitHub-Api-Version': '2022-11-28',
35
+ 'Content-Type': 'application/json',
36
+ })
37
+
38
+ export async function readWebsitesRegistryFromGitHub(
39
+ target: ConfigRepoTarget,
40
+ ): Promise<Result<ReadResult>> {
41
+ try {
42
+ const url = `https://api.github.com/repos/${target.owner}/${target.repo}/contents/${target.path}?ref=${target.branch}`
43
+ const response = await fetch(url, { headers: githubHeaders(target.token) })
44
+
45
+ if (response.status === 404) {
46
+ return ok({ registry: { websites: [] }, sha: null })
47
+ }
48
+ if (!response.ok) {
49
+ return err(networkError(`GitHub returned ${response.status} reading ${target.path}`))
50
+ }
51
+
52
+ const data = (await response.json()) as { content: string; sha: string }
53
+ const decoded = Buffer.from(data.content, 'base64').toString('utf-8')
54
+ const parsed = parseWebsitesRegistry(decoded)
55
+ if (!parsed.ok) return parsed
56
+
57
+ return ok({ registry: parsed.value, sha: data.sha })
58
+ } catch (cause) {
59
+ const message = cause instanceof Error ? cause.message : 'Unknown error'
60
+ return err(networkError(`Failed to read ${target.path}: ${message}`, cause))
61
+ }
62
+ }
63
+
64
+ export async function writeWebsitesRegistryToGitHub(
65
+ target: ConfigRepoTarget,
66
+ registry: WebsitesRegistry,
67
+ previousSha: string | null,
68
+ commitMessage: string,
69
+ ): Promise<Result<void>> {
70
+ const body: Record<string, unknown> = {
71
+ message: withTrailers(commitMessage),
72
+ content: Buffer.from(JSON.stringify(registry, null, 2)).toString('base64'),
73
+ branch: target.branch,
74
+ }
75
+ if (previousSha) body.sha = previousSha
76
+
77
+ try {
78
+ const response = await fetch(
79
+ `https://api.github.com/repos/${target.owner}/${target.repo}/contents/${target.path}`,
80
+ { method: 'PUT', headers: githubHeaders(target.token), body: JSON.stringify(body) },
81
+ )
82
+
83
+ if (!response.ok) {
84
+ const text = await response.text()
85
+ return err(networkError(`GitHub PUT failed: ${response.status} ${text}`))
86
+ }
87
+ return ok(undefined)
88
+ } catch (cause) {
89
+ const message = cause instanceof Error ? cause.message : 'Unknown error'
90
+ return err(networkError(`Failed to write ${target.path}: ${message}`, cause))
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Reads the standalone-admin storage config out of the build-time
96
+ * `__SETZKASTEN_FULL_CONFIG__` global. Returns null when the deployment
97
+ * is not in standalone mode (single-repo setups have no websites.json).
98
+ */
99
+ export function resolveConfigRepoTargetFromGlobals(token: string): ConfigRepoTarget | null {
100
+ const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
101
+ | { storage?: { kind: string; configRepo?: string; configBranch?: string } }
102
+ | undefined
103
+
104
+ // Accept the canonical 'multi' as well as the legacy 'standalone' alias.
105
+ // defineConfig() rewrites legacy values, but tests and direct setters of
106
+ // __SETZKASTEN_FULL_CONFIG__ may pass either form.
107
+ const storage = fullConfig?.storage
108
+ if (!storage || (storage.kind !== 'multi' && storage.kind !== 'standalone')) return null
109
+ if (!storage.configRepo) return null
110
+ const [owner, repo] = storage.configRepo.split('/')
111
+ if (!owner || !repo) return null
112
+
113
+ return {
114
+ owner,
115
+ repo,
116
+ branch: storage.configBranch ?? 'main',
117
+ path: 'websites.json',
118
+ token,
119
+ }
120
+ }
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { resolveGitHubTokenForRequest } from './_github-token'
2
3
 
3
4
  /**
4
5
  * Asset proxy – serves images from the private GitHub repo.
@@ -7,7 +8,7 @@ import type { APIRoute } from 'astro'
7
8
  * GET /api/setzkasten/asset/public/images/about/LP_Logo.png
8
9
  * → fetches from GitHub API and returns the raw binary with correct Content-Type.
9
10
  */
10
- export const GET: APIRoute = async ({ params, cookies }) => {
11
+ export const GET: APIRoute = async ({ params, request, cookies }) => {
11
12
  const session = cookies.get('setzkasten_session')?.value
12
13
  if (!session) {
13
14
  return new Response('Unauthorized', { status: 401 })
@@ -18,10 +19,11 @@ export const GET: APIRoute = async ({ params, cookies }) => {
18
19
  return new Response('Missing path', { status: 400 })
19
20
  }
20
21
 
21
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
22
- if (!githubToken) {
23
- return new Response('GitHub token not configured', { status: 500 })
22
+ const tokenResult = await resolveGitHubTokenForRequest(request)
23
+ if (!tokenResult.ok) {
24
+ return new Response(tokenResult.error.message, { status: 500 })
24
25
  }
26
+ const githubToken = tokenResult.value
25
27
 
26
28
  const config = (globalThis as any).__SETZKASTEN_CONFIG__
27
29
  if (!config?.storage) {
@@ -1,5 +1,6 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { createGitHubAuth } from '@setzkasten-cms/auth'
3
+ import { sessionCookieOptions } from './_session-cookie.js'
3
4
 
4
5
  /**
5
6
  * GitHub OAuth callback handler.
@@ -59,13 +60,11 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
59
60
  }
60
61
 
61
62
  const session = sessionResult.value
62
- cookies.set('setzkasten_session', JSON.stringify({ user: session.user, expiresAt: session.expiresAt }), {
63
- httpOnly: true,
64
- secure: import.meta.env.PROD,
65
- sameSite: 'lax',
66
- path: '/',
67
- maxAge: 60 * 60 * 24 * 7,
68
- })
63
+ cookies.set(
64
+ 'setzkasten_session',
65
+ JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
66
+ sessionCookieOptions(import.meta.env.PROD),
67
+ )
69
68
 
70
69
  return redirect(adminPath)
71
70
  } catch {
@@ -1,9 +1,13 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { sessionCookieOptions } from './_session-cookie.js'
2
3
 
3
4
  /**
4
5
  * Logout – clears the session cookie and redirects to home.
6
+ * Mirrors the same `domain` attribute used when the cookie was set so the
7
+ * browser actually deletes it on subdomains in standalone-admin setups.
5
8
  */
6
9
  export const GET: APIRoute = async ({ cookies, redirect }) => {
7
- cookies.delete('setzkasten_session', { path: '/' })
10
+ const opts = sessionCookieOptions(false)
11
+ cookies.delete('setzkasten_session', { path: '/', domain: opts.domain })
8
12
  return redirect('/')
9
13
  }
@@ -3,6 +3,8 @@ import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
3
3
  import { readEditorsFile } from './editors'
4
4
  import { readGlobalConfig } from './global-config'
5
5
  import { resolveStorageConfig } from './_storage-config'
6
+ import { resolveConfigRepoToken } from './_github-token'
7
+ import { sessionCookieOptions } from './_session-cookie.js'
6
8
 
7
9
  /**
8
10
  * POST /api/setzkasten/auth/setzkasten-login
@@ -10,6 +12,12 @@ import { resolveStorageConfig } from './_storage-config'
10
12
  *
11
13
  * Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
12
14
  * Access is gated exclusively by _editors.json (fail-closed).
15
+ *
16
+ * Editors live in the build-time-configured repo regardless of which website
17
+ * the request is targeting — in single-mode that's the website's repo, in
18
+ * multi-mode it's the config-repo. The per-request resolver and X-SK-Website
19
+ * header are intentionally NOT consulted here, because login predates any
20
+ * website selection.
13
21
  */
14
22
  export const POST: APIRoute = async ({ request, cookies }) => {
15
23
  const body = await request.json().catch(() => null)
@@ -24,9 +32,14 @@ export const POST: APIRoute = async ({ request, cookies }) => {
24
32
  return new Response('Storage not configured', { status: 500 })
25
33
  }
26
34
  const { owner, repo, branch } = storage
27
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
35
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
36
+ .__SETZKASTEN_CONFIG__
28
37
  const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
29
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''
38
+
39
+ const tokenResult = await resolveConfigRepoToken()
40
+ if (!tokenResult.ok) {
41
+ return new Response(`GitHub token unavailable: ${tokenResult.error.message}`, { status: 503 })
42
+ }
30
43
 
31
44
  // Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
32
45
  const globalCfg = await readGlobalConfig().catch(() => null)
@@ -35,7 +48,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
35
48
  }
36
49
 
37
50
  // Read editors list — fail-closed: if unreadable, deny all logins
38
- const editors = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
51
+ const editors = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
39
52
  if (editors === null) {
40
53
  return new Response('Editors list unavailable — no access granted', { status: 503 })
41
54
  }
@@ -48,13 +61,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
48
61
  }
49
62
 
50
63
  const session = result.value
51
- cookies.set('setzkasten_session', JSON.stringify({ user: session.user, expiresAt: session.expiresAt }), {
52
- httpOnly: true,
53
- secure: import.meta.env.PROD,
54
- sameSite: 'lax',
55
- path: '/',
56
- maxAge: 60 * 60 * 24 * 7,
57
- })
64
+ cookies.set(
65
+ 'setzkasten_session',
66
+ JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
67
+ sessionCookieOptions(import.meta.env.PROD),
68
+ )
58
69
 
59
70
  return Response.json({ ok: true })
60
71
  }
@@ -1,10 +1,11 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { registry } from '@setzkasten-cms/catalog'
3
- import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
4
4
  import { validateCatalogAddBody, buildCatalogAddCommit } from './catalog-helpers'
5
5
  import { generateAddKey, addToPageConfig } from './section-management'
6
6
  import { parseSession, guardPageAccess } from './_auth-guard'
7
7
  import { withTrailers } from './_commit-trailers'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
8
9
 
9
10
  /**
10
11
  * POST /api/setzkasten/catalog/add
@@ -19,8 +20,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
19
20
  const session = cookies.get('setzkasten_session')?.value
20
21
  if (!session) return new Response('Unauthorized', { status: 401 })
21
22
 
22
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
23
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
23
+ const tokenResult = await resolveGitHubTokenForRequest(request)
24
+ if (!tokenResult.ok) {
25
+ return new Response(tokenResult.error.message, { status: 500 })
26
+ }
27
+ const githubToken = tokenResult.value
24
28
 
25
29
  try {
26
30
  const body = await request.json() as Record<string, unknown>
@@ -34,7 +38,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
34
38
 
35
39
  const { templateName, pageKey } = validated
36
40
 
37
- const storage = resolveStorageConfig(body)
41
+ const storage = await resolveStorageConfigForRequest(request, body)
38
42
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
39
43
  const { owner, repo, branch, projectPrefix } = storage
40
44
 
@@ -42,7 +46,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
42
46
  const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
43
47
  const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
44
48
 
45
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
49
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
46
50
  if (denied) return denied
47
51
 
48
52
  const headers = {
@@ -1,6 +1,7 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { exportTemplate } from '@setzkasten-cms/catalog'
3
- import { resolveStorageConfig, prefixPath } from './_storage-config'
3
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
4
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
5
 
5
6
  /**
6
7
  * POST /api/setzkasten/catalog/export
@@ -14,8 +15,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
14
15
  const session = cookies.get('setzkasten_session')?.value
15
16
  if (!session) return new Response('Unauthorized', { status: 401 })
16
17
 
17
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
18
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
18
+ const tokenResult = await resolveGitHubTokenForRequest(request)
19
+ if (!tokenResult.ok) {
20
+ return new Response(tokenResult.error.message, { status: 500 })
21
+ }
22
+ const githubToken = tokenResult.value
19
23
 
20
24
  try {
21
25
  const body = await request.json() as {
@@ -30,7 +34,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
30
34
  return Response.json({ error: 'sectionKey is required' }, { status: 400 })
31
35
  }
32
36
 
33
- const storage = resolveStorageConfig(body)
37
+ const storage = await resolveStorageConfigForRequest(request, body)
34
38
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
35
39
  const { owner, repo, branch, projectPrefix } = storage
36
40
 
@@ -2,10 +2,11 @@ import type { APIRoute } from 'astro'
2
2
  import { readGlobalConfig } from './global-config'
3
3
 
4
4
  /**
5
- * Returns the full SetzKastenConfig as JSON.
6
- * The config is injected into globalThis by the integration at build time.
7
- *
8
5
  * GET /api/setzkasten/config
6
+ *
7
+ * Returns the full SetzKastenConfig as JSON. Fields from GlobalConfig
8
+ * (stored in _global_config.json) are merged over the static config so
9
+ * admins can change theme and Firebase settings without a code deployment.
9
10
  */
10
11
  export const GET: APIRoute = async () => {
11
12
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
@@ -13,13 +14,19 @@ export const GET: APIRoute = async () => {
13
14
 
14
15
  const globalCfg = await readGlobalConfig().catch(() => null)
15
16
 
17
+ const staticTheme = (config as any).theme ?? {}
18
+ const globalTheme = globalCfg?.theme ?? {}
19
+
16
20
  const result = {
17
- storage: { kind: 'github' },
21
+ // Default fallback when no config is injected at build time. Real
22
+ // values are spread from `config` below.
23
+ storage: { kind: 'local' },
18
24
  auth: { providers: ['github'] },
19
- theme: {},
20
25
  products: {},
21
26
  collections: {},
22
27
  ...config,
28
+ // Global config theme overrides static config theme field by field
29
+ theme: { ...staticTheme, ...globalTheme },
23
30
  // Include storage params so the client can create ProxyContentRepository
24
31
  _storage: ssrConfig?.storage ?? undefined,
25
32
  _hasGitHub: ssrConfig?.hasGitHub ?? false,