@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,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,7 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { createGitHubAuth } from '@setzkasten-cms/auth'
3
+ import { sessionCookieOptions } from './_session-cookie.js'
4
+ import { makeRoleResolver } from './_role-resolver'
3
5
 
4
6
  /**
5
7
  * GitHub OAuth callback handler.
@@ -49,6 +51,7 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
49
51
  clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
50
52
  redirectUri,
51
53
  allowedEmails,
54
+ resolveRole: makeRoleResolver('github', allowedEmails),
52
55
  })
53
56
 
54
57
  try {
@@ -59,13 +62,11 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
59
62
  }
60
63
 
61
64
  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
- })
65
+ cookies.set(
66
+ 'setzkasten_session',
67
+ JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
68
+ sessionCookieOptions(import.meta.env.PROD),
69
+ )
69
70
 
70
71
  return redirect(adminPath)
71
72
  } 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,10 @@ 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'
8
+ import { makeRoleResolver } from './_role-resolver'
9
+ import { gateFeature } from './_feature-gate'
6
10
 
7
11
  /**
8
12
  * POST /api/setzkasten/auth/setzkasten-login
@@ -10,8 +14,20 @@ import { resolveStorageConfig } from './_storage-config'
10
14
  *
11
15
  * Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
12
16
  * Access is gated exclusively by _editors.json (fail-closed).
17
+ *
18
+ * Editors live in the build-time-configured repo regardless of which website
19
+ * the request is targeting — in single-mode that's the website's repo, in
20
+ * multi-mode it's the config-repo. The per-request resolver and X-SK-Website
21
+ * header are intentionally NOT consulted here, because login predates any
22
+ * website selection.
13
23
  */
14
24
  export const POST: APIRoute = async ({ request, cookies }) => {
25
+ // Feature-gate: Setzkasten-Login is the Firebase-based path that lets
26
+ // editors who don't have a GitHub account log in via Google. That entire
27
+ // flow is gated behind Pro/Enterprise.
28
+ const gate = gateFeature('google-auth')
29
+ if (gate) return gate
30
+
15
31
  const body = await request.json().catch(() => null)
16
32
  const idToken = body?.idToken as string | undefined
17
33
 
@@ -24,9 +40,14 @@ export const POST: APIRoute = async ({ request, cookies }) => {
24
40
  return new Response('Storage not configured', { status: 500 })
25
41
  }
26
42
  const { owner, repo, branch } = storage
27
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
43
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
44
+ .__SETZKASTEN_CONFIG__
28
45
  const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
29
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''
46
+
47
+ const tokenResult = await resolveConfigRepoToken()
48
+ if (!tokenResult.ok) {
49
+ return new Response(`GitHub token unavailable: ${tokenResult.error.message}`, { status: 503 })
50
+ }
30
51
 
31
52
  // Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
32
53
  const globalCfg = await readGlobalConfig().catch(() => null)
@@ -35,26 +56,31 @@ export const POST: APIRoute = async ({ request, cookies }) => {
35
56
  }
36
57
 
37
58
  // Read editors list — fail-closed: if unreadable, deny all logins
38
- const editors = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
59
+ const editors = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
39
60
  if (editors === null) {
40
61
  return new Response('Editors list unavailable — no access granted', { status: 503 })
41
62
  }
42
63
 
43
64
  const allowedEmails = editors.map((e) => e.email)
44
- const result = await verifyFirebaseJwt(idToken, allowedEmails)
65
+ // Setzkasten-Login uses Firebase as the identity backend but logically
66
+ // grants the same role-resolution semantics as Google OAuth — both give
67
+ // an editors-file-listed user the role from that file.
68
+ const result = await verifyFirebaseJwt(
69
+ idToken,
70
+ allowedEmails,
71
+ makeRoleResolver('google', allowedEmails),
72
+ )
45
73
 
46
74
  if (!result.ok) {
47
75
  return new Response(result.error.message, { status: 403 })
48
76
  }
49
77
 
50
78
  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
- })
79
+ cookies.set(
80
+ 'setzkasten_session',
81
+ JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
82
+ sessionCookieOptions(import.meta.env.PROD),
83
+ )
58
84
 
59
85
  return Response.json({ ok: true })
60
86
  }
@@ -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,
@@ -1,12 +1,25 @@
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'
6
+ import { validateEditorsUpdate } from '@setzkasten-cms/core'
5
7
  import { cachedFetch, invalidateCache } from './_github-cache'
6
8
  import { withTrailers } from './_commit-trailers'
9
+ import { gateFeature } from './_feature-gate'
7
10
 
8
11
  const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
9
12
 
13
+ // In Multi-Mode editors are global across all websites and live in the
14
+ // config-repo; in Single-Mode the config-repo IS the website-repo. Either
15
+ // way the answer is "the build-time-configured storage" — never the
16
+ // per-website storage that the X-SK-Website header would route to.
17
+ function configRepoStorage(): { owner: string; repo: string; branch: string } | null {
18
+ const storage = resolveStorageConfig()
19
+ if (!storage) return null
20
+ return { owner: storage.owner, repo: storage.repo, branch: storage.branch }
21
+ }
22
+
10
23
  // ---------------------------------------------------------------------------
11
24
  // GET /api/setzkasten/editors
12
25
  // Returns the current editors list from _editors.json.
@@ -17,17 +30,18 @@ export const GET: APIRoute = async ({ cookies }) => {
17
30
  const session = parseSession(cookies.get('setzkasten_session')?.value)
18
31
  if (!session) return new Response('Unauthorized', { status: 401 })
19
32
 
20
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
21
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
33
+ const tokenResult = await resolveConfigRepoToken()
34
+ if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
22
35
 
23
- const storage = resolveStorageConfig()
36
+ const storage = configRepoStorage()
24
37
  if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
25
38
 
26
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
39
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
40
+ .__SETZKASTEN_CONFIG__
27
41
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
28
42
  const { owner, repo, branch } = storage
29
43
 
30
- const raw = await readEditorsFile(owner, repo, branch, contentPath, githubToken)
44
+ const raw = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
31
45
  return Response.json(raw ?? [])
32
46
  }
33
47
 
@@ -42,13 +56,19 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
42
56
  if (!session) return new Response('Unauthorized', { status: 401 })
43
57
  if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
44
58
 
45
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
46
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
59
+ // Feature-gate: editor management is a Pro feature. Free-tier admins
60
+ // can read the editors list (GET) but not write it.
61
+ const gate = gateFeature('editors')
62
+ if (gate) return gate
47
63
 
48
- const storage = resolveStorageConfig()
64
+ const tokenResult = await resolveConfigRepoToken()
65
+ if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
66
+
67
+ const storage = configRepoStorage()
49
68
  if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
50
69
 
51
- const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
70
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
71
+ .__SETZKASTEN_CONFIG__
52
72
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
53
73
  const { owner, repo, branch } = storage
54
74
 
@@ -60,10 +80,18 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
60
80
  return Response.json({ error: 'Invalid request body' }, { status: 400 })
61
81
  }
62
82
 
83
+ const validation = validateEditorsUpdate(editors, session.user.email)
84
+ if (!validation.ok) {
85
+ return Response.json(
86
+ { error: validation.message, code: validation.code },
87
+ { status: 400 },
88
+ )
89
+ }
90
+
63
91
  const filePath = EDITORS_FILE(contentPath)
64
92
  const fileContent = JSON.stringify(editors, null, 2)
65
93
  const headers = {
66
- Authorization: `Bearer ${githubToken}`,
94
+ Authorization: `Bearer ${tokenResult.value}`,
67
95
  Accept: 'application/vnd.github+json',
68
96
  'X-GitHub-Api-Version': '2022-11-28',
69
97
  'Content-Type': 'application/json',
@@ -134,3 +162,59 @@ export async function readEditorsFile(
134
162
  return JSON.parse(raw) as ContentEditorConfig[]
135
163
  })
136
164
  }
165
+
166
+ /**
167
+ * Discriminated result for the editors-file fetch. Lets callers decide the
168
+ * fail-mode policy: the auth-guard wants to ALLOW when the file is genuinely
169
+ * absent (no restrictions configured) but DENY when the fetch errors out.
170
+ * The basic readEditorsFile() above returns null for both cases, which is
171
+ * unsafe for authorization checks.
172
+ *
173
+ * Caller is responsible for caching — this function never reads from or
174
+ * writes to the shared cache, because caching an "error" state would
175
+ * silently extend privilege-escalation windows.
176
+ */
177
+ export type EditorsStatus =
178
+ | { kind: 'absent' }
179
+ | { kind: 'present'; editors: ContentEditorConfig[] }
180
+ | { kind: 'error'; message: string }
181
+
182
+ export async function readEditorsFileStatus(
183
+ owner: string,
184
+ repo: string,
185
+ branch: string,
186
+ contentPath: string,
187
+ token: string,
188
+ ): Promise<EditorsStatus> {
189
+ let res: Response
190
+ try {
191
+ res = await fetch(
192
+ `https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
193
+ {
194
+ headers: {
195
+ Authorization: `Bearer ${token}`,
196
+ Accept: 'application/vnd.github+json',
197
+ 'X-GitHub-Api-Version': '2022-11-28',
198
+ },
199
+ },
200
+ )
201
+ } catch (err) {
202
+ return { kind: 'error', message: err instanceof Error ? err.message : 'network error' }
203
+ }
204
+
205
+ if (res.status === 404) return { kind: 'absent' }
206
+ if (!res.ok) {
207
+ return { kind: 'error', message: `GitHub returned ${res.status}` }
208
+ }
209
+
210
+ try {
211
+ const data = (await res.json()) as { content: string; encoding: string }
212
+ const raw =
213
+ data.encoding === 'base64'
214
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
215
+ : data.content
216
+ return { kind: 'present', editors: JSON.parse(raw) as ContentEditorConfig[] }
217
+ } catch (err) {
218
+ return { kind: 'error', message: err instanceof Error ? err.message : 'parse error' }
219
+ }
220
+ }
@@ -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}`)