@setzkasten-cms/astro-admin 0.6.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +23 -6
  2. package/src/admin-page.astro +9 -8
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
  5. package/src/api-routes/__tests__/github-cache.test.ts +100 -0
  6. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  7. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  8. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  9. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  10. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  11. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  12. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  13. package/src/api-routes/__tests__/pages.test.ts +72 -0
  14. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  15. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  16. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  17. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  18. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  19. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  20. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  21. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  22. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  23. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  24. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  25. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  26. package/src/api-routes/_auth-guard.ts +153 -0
  27. package/src/api-routes/_commit-trailers.ts +16 -0
  28. package/src/api-routes/_github-cache.ts +32 -0
  29. package/src/api-routes/_github-token.ts +64 -0
  30. package/src/api-routes/_license-tier.ts +25 -0
  31. package/src/api-routes/_pages-meta-store.ts +134 -0
  32. package/src/api-routes/_session-cookie.ts +42 -0
  33. package/src/api-routes/_storage-config.ts +64 -4
  34. package/src/api-routes/_vercel-origin.ts +22 -0
  35. package/src/api-routes/_website-resolver.ts +243 -0
  36. package/src/api-routes/_websites-store.ts +120 -0
  37. package/src/api-routes/asset-proxy.ts +6 -4
  38. package/src/api-routes/auth-callback.ts +21 -53
  39. package/src/api-routes/auth-login.ts +18 -65
  40. package/src/api-routes/auth-logout.ts +5 -1
  41. package/src/api-routes/auth-setzkasten-login.ts +71 -0
  42. package/src/api-routes/catalog-add.ts +18 -5
  43. package/src/api-routes/catalog-export.ts +8 -4
  44. package/src/api-routes/config.ts +17 -5
  45. package/src/api-routes/editors.ts +205 -0
  46. package/src/api-routes/github-proxy.ts +5 -5
  47. package/src/api-routes/global-config.ts +149 -0
  48. package/src/api-routes/init-add-section.ts +21 -10
  49. package/src/api-routes/init-apply.ts +7 -4
  50. package/src/api-routes/init-migrate.ts +9 -6
  51. package/src/api-routes/init-scan-page.ts +26 -6
  52. package/src/api-routes/init-scan.ts +5 -3
  53. package/src/api-routes/migrate-to-multi.ts +255 -0
  54. package/src/api-routes/pages.ts +138 -6
  55. package/src/api-routes/section-add.ts +23 -5
  56. package/src/api-routes/section-commit-pending.ts +28 -5
  57. package/src/api-routes/section-delete.ts +24 -5
  58. package/src/api-routes/section-duplicate.ts +25 -5
  59. package/src/api-routes/section-prepare-copy.ts +15 -4
  60. package/src/api-routes/section-prepare.ts +12 -4
  61. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  62. package/src/api-routes/setup-github-app-branches.ts +63 -0
  63. package/src/api-routes/setup-github-app-callback.ts +53 -0
  64. package/src/api-routes/setup-github-app-installed.ts +44 -0
  65. package/src/api-routes/setup-github-app-repos.ts +46 -0
  66. package/src/api-routes/setup-github-app.ts +58 -0
  67. package/src/api-routes/updater-check.ts +49 -0
  68. package/src/api-routes/updater-register.ts +90 -0
  69. package/src/api-routes/updater-transfer.ts +51 -0
  70. package/src/api-routes/updater-unbind.ts +59 -0
  71. package/src/api-routes/websites-add.ts +113 -0
  72. package/src/api-routes/websites-list.ts +40 -0
  73. package/src/api-routes/websites-remove.ts +74 -0
  74. package/src/init/__tests__/page-level.test.ts +47 -0
  75. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  76. package/src/init/__tests__/section-pipeline.test.ts +3 -1
  77. package/src/init/astro-section-analyzer-v2.ts +29 -2
  78. package/src/init/template-patcher-v2.ts +100 -0
  79. package/LICENSE +0 -37
@@ -1,87 +1,40 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { createGitHubAuth } from '@setzkasten-cms/auth'
3
- import { createGoogleAuth } from '@setzkasten-cms/auth'
4
3
 
5
4
  /**
6
- * Login initiation – redirects the user to the chosen OAuth provider.
5
+ * Login initiation – redirects the user to GitHub OAuth.
6
+ * Google uses GIS (POST /api/setzkasten/auth/google) instead of this redirect flow.
7
7
  *
8
- * GET /api/setzkasten/auth/login?provider=github|google
8
+ * GET /api/setzkasten/auth/login?provider=github
9
9
  */
10
10
  export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
11
- const provider = url.searchParams.get('provider') ?? 'github'
12
-
13
11
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
14
- | { adminPath: string; hasGitHub: boolean; hasGoogle: boolean }
12
+ | { adminPath: string }
15
13
  | undefined
16
14
 
17
- const adminPath = config?.adminPath ?? '/admin'
18
-
19
15
  // On Vercel, url.origin may resolve to localhost. Use the Host header instead.
20
16
  const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
21
17
  const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
22
18
  const origin = `${protocol}://${host}`
23
19
  const redirectUri = `${origin}/api/setzkasten/auth/callback`
24
20
 
25
- let loginUrl: string
26
-
27
- if (provider === 'google') {
28
- const googleClientId = import.meta.env.GOOGLE_CLIENT_ID ?? process.env.GOOGLE_CLIENT_ID ?? ''
29
- const googleClientSecret = import.meta.env.GOOGLE_CLIENT_SECRET ?? process.env.GOOGLE_CLIENT_SECRET ?? ''
30
-
31
- if (!googleClientId || !googleClientSecret) {
32
- return new Response('Google OAuth not configured', { status: 500 })
33
- }
34
-
35
- const auth = createGoogleAuth({
36
- clientId: googleClientId,
37
- clientSecret: googleClientSecret,
38
- redirectUri,
39
- })
21
+ const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
22
+ const ghClientSecret = import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
40
23
 
41
- loginUrl = auth.getLoginUrl('google')
42
-
43
- // Store the PKCE code verifier in a cookie for the callback
44
- // Arctic generates it internally – we extract it from the URL
45
- const urlObj = new URL(loginUrl)
46
- const state = urlObj.searchParams.get('state')
47
- if (state) {
48
- cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'google' }), {
49
- httpOnly: true,
50
- secure: true,
51
- sameSite: 'lax',
52
- path: '/',
53
- maxAge: 600, // 10 min
54
- })
55
- }
56
- } else {
57
- // Default: GitHub
58
- const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
59
- const ghClientSecret = import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
60
-
61
- if (!ghClientId || !ghClientSecret) {
62
- return new Response('GitHub OAuth not configured', { status: 500 })
63
- }
64
-
65
- const auth = createGitHubAuth({
66
- clientId: ghClientId,
67
- clientSecret: ghClientSecret,
68
- redirectUri,
69
- })
24
+ if (!ghClientId || !ghClientSecret) {
25
+ return new Response('GitHub OAuth not configured', { status: 500 })
26
+ }
70
27
 
71
- loginUrl = auth.getLoginUrl('github')
28
+ const auth = createGitHubAuth({ clientId: ghClientId, clientSecret: ghClientSecret, redirectUri })
29
+ const { url: loginUrl, state } = auth.getLoginUrl()
72
30
 
73
- const urlObj = new URL(loginUrl)
74
- const state = urlObj.searchParams.get('state')
75
- if (state) {
76
- cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'github' }), {
77
- httpOnly: true,
78
- secure: true,
79
- sameSite: 'lax',
80
- path: '/',
81
- maxAge: 600,
82
- })
83
- }
84
- }
31
+ cookies.set('setzkasten_oauth_state', JSON.stringify({ state, provider: 'github' }), {
32
+ httpOnly: true,
33
+ secure: true,
34
+ sameSite: 'lax',
35
+ path: '/',
36
+ maxAge: 600,
37
+ })
85
38
 
86
39
  return redirect(loginUrl)
87
40
  }
@@ -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
  }
@@ -0,0 +1,71 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
3
+ import { readEditorsFile } from './editors'
4
+ import { readGlobalConfig } from './global-config'
5
+ import { resolveStorageConfig } from './_storage-config'
6
+ import { resolveConfigRepoToken } from './_github-token'
7
+ import { sessionCookieOptions } from './_session-cookie.js'
8
+
9
+ /**
10
+ * POST /api/setzkasten/auth/setzkasten-login
11
+ * Body: { idToken: string } — Firebase ID token from signInWithPopup
12
+ *
13
+ * Verifies the Firebase JWT against Firebase's public JWKS (no secret needed).
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.
21
+ */
22
+ export const POST: APIRoute = async ({ request, cookies }) => {
23
+ const body = await request.json().catch(() => null)
24
+ const idToken = body?.idToken as string | undefined
25
+
26
+ if (!idToken) {
27
+ return new Response('Missing idToken', { status: 400 })
28
+ }
29
+
30
+ const storage = resolveStorageConfig()
31
+ if (!storage) {
32
+ return new Response('Storage not configured', { status: 500 })
33
+ }
34
+ const { owner, repo, branch } = storage
35
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
36
+ .__SETZKASTEN_CONFIG__
37
+ const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
38
+
39
+ const tokenResult = await resolveConfigRepoToken()
40
+ if (!tokenResult.ok) {
41
+ return new Response(`GitHub token unavailable: ${tokenResult.error.message}`, { status: 503 })
42
+ }
43
+
44
+ // Verify that SetzKastenLogin is configured (firebaseConfig must exist in global config)
45
+ const globalCfg = await readGlobalConfig().catch(() => null)
46
+ if (!globalCfg?.firebaseConfig) {
47
+ return new Response('SetzKastenLogin not configured', { status: 500 })
48
+ }
49
+
50
+ // Read editors list — fail-closed: if unreadable, deny all logins
51
+ const editors = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
52
+ if (editors === null) {
53
+ return new Response('Editors list unavailable — no access granted', { status: 503 })
54
+ }
55
+
56
+ const allowedEmails = editors.map((e) => e.email)
57
+ const result = await verifyFirebaseJwt(idToken, allowedEmails)
58
+
59
+ if (!result.ok) {
60
+ return new Response(result.error.message, { status: 403 })
61
+ }
62
+
63
+ const session = result.value
64
+ cookies.set(
65
+ 'setzkasten_session',
66
+ JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
67
+ sessionCookieOptions(import.meta.env.PROD),
68
+ )
69
+
70
+ return Response.json({ ok: true })
71
+ }
@@ -1,8 +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
+ import { parseSession, guardPageAccess } from './_auth-guard'
7
+ import { withTrailers } from './_commit-trailers'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
9
 
7
10
  /**
8
11
  * POST /api/setzkasten/catalog/add
@@ -17,8 +20,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
17
20
  const session = cookies.get('setzkasten_session')?.value
18
21
  if (!session) return new Response('Unauthorized', { status: 401 })
19
22
 
20
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
21
- 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
22
28
 
23
29
  try {
24
30
  const body = await request.json() as Record<string, unknown>
@@ -32,13 +38,17 @@ export const POST: APIRoute = async ({ request, cookies }) => {
32
38
 
33
39
  const { templateName, pageKey } = validated
34
40
 
35
- const storage = resolveStorageConfig(body)
41
+ const storage = await resolveStorageConfigForRequest(request, body)
36
42
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
37
43
  const { owner, repo, branch, projectPrefix } = storage
38
44
 
39
45
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
46
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
40
47
  const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
41
48
 
49
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
50
+ if (denied) return denied
51
+
42
52
  const headers = {
43
53
  Authorization: `Bearer ${githubToken}`,
44
54
  Accept: 'application/vnd.github+json',
@@ -82,7 +92,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
82
92
  { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
83
93
  { path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
84
94
  ],
85
- `content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
95
+ withTrailers(
96
+ `content: add catalog template "${templateName}" as "${sectionKey}" to ${pageKey}`,
97
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
98
+ ),
86
99
  headers,
87
100
  )
88
101
 
@@ -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
 
@@ -1,26 +1,38 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { readGlobalConfig } from './global-config'
2
3
 
3
4
  /**
4
- * Returns the full SetzKastenConfig as JSON.
5
- * The config is injected into globalThis by the integration at build time.
6
- *
7
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.
8
10
  */
9
11
  export const GET: APIRoute = async () => {
10
12
  const config = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ ?? {}
11
13
  const ssrConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as Record<string, unknown> | undefined
12
14
 
15
+ const globalCfg = await readGlobalConfig().catch(() => null)
16
+
17
+ const staticTheme = (config as any).theme ?? {}
18
+ const globalTheme = globalCfg?.theme ?? {}
19
+
13
20
  const result = {
14
- 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' },
15
24
  auth: { providers: ['github'] },
16
- theme: {},
17
25
  products: {},
18
26
  collections: {},
19
27
  ...config,
28
+ // Global config theme overrides static config theme field by field
29
+ theme: { ...staticTheme, ...globalTheme },
20
30
  // Include storage params so the client can create ProxyContentRepository
21
31
  _storage: ssrConfig?.storage ?? undefined,
22
32
  _hasGitHub: ssrConfig?.hasGitHub ?? false,
23
33
  _hasGoogle: ssrConfig?.hasGoogle ?? false,
34
+ // SetzKastenLogin Firebase config (present only when license is valid)
35
+ _firebaseConfig: globalCfg?.firebaseConfig ?? null,
24
36
  }
25
37
 
26
38
  return new Response(JSON.stringify(result), {
@@ -0,0 +1,205 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfig } from './_storage-config'
3
+ import { parseSession } from './_auth-guard'
4
+ import { resolveConfigRepoToken } from './_github-token'
5
+ import type { ContentEditorConfig } from '@setzkasten-cms/core'
6
+ import { cachedFetch, invalidateCache } from './_github-cache'
7
+ import { withTrailers } from './_commit-trailers'
8
+
9
+ const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
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
+
21
+ // ---------------------------------------------------------------------------
22
+ // GET /api/setzkasten/editors
23
+ // Returns the current editors list from _editors.json.
24
+ // Any authenticated user may read this (needed for the page-filter in the UI).
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export const GET: APIRoute = async ({ cookies }) => {
28
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
29
+ if (!session) return new Response('Unauthorized', { status: 401 })
30
+
31
+ const tokenResult = await resolveConfigRepoToken()
32
+ if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
33
+
34
+ const storage = configRepoStorage()
35
+ if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
36
+
37
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
38
+ .__SETZKASTEN_CONFIG__
39
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
40
+ const { owner, repo, branch } = storage
41
+
42
+ const raw = await readEditorsFile(owner, repo, branch, contentPath, tokenResult.value)
43
+ return Response.json(raw ?? [])
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // PUT /api/setzkasten/editors
48
+ // Replaces the editors list. Admin-only.
49
+ // Body: ContentEditorConfig[]
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export const PUT: APIRoute = async ({ request, cookies }) => {
53
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
54
+ if (!session) return new Response('Unauthorized', { status: 401 })
55
+ if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
56
+
57
+ const tokenResult = await resolveConfigRepoToken()
58
+ if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
59
+
60
+ const storage = configRepoStorage()
61
+ if (!storage) return Response.json({ error: 'Could not resolve storage config' }, { status: 400 })
62
+
63
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
64
+ .__SETZKASTEN_CONFIG__
65
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
66
+ const { owner, repo, branch } = storage
67
+
68
+ let editors: ContentEditorConfig[]
69
+ try {
70
+ editors = (await request.json()) as ContentEditorConfig[]
71
+ if (!Array.isArray(editors)) throw new Error('Expected array')
72
+ } catch {
73
+ return Response.json({ error: 'Invalid request body' }, { status: 400 })
74
+ }
75
+
76
+ const filePath = EDITORS_FILE(contentPath)
77
+ const fileContent = JSON.stringify(editors, null, 2)
78
+ const headers = {
79
+ Authorization: `Bearer ${tokenResult.value}`,
80
+ Accept: 'application/vnd.github+json',
81
+ 'X-GitHub-Api-Version': '2022-11-28',
82
+ 'Content-Type': 'application/json',
83
+ }
84
+
85
+ // Get current SHA if the file already exists (needed for updates)
86
+ const existing = await fetchFileSha(owner, repo, branch, filePath, headers)
87
+
88
+ const body: Record<string, unknown> = {
89
+ message: withTrailers('chore(editors): update content editor permissions'),
90
+ content: Buffer.from(fileContent).toString('base64'),
91
+ branch,
92
+ }
93
+ if (existing) body.sha = existing
94
+
95
+ const res = await fetch(
96
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
97
+ { method: 'PUT', headers, body: JSON.stringify(body) },
98
+ )
99
+
100
+ if (!res.ok) {
101
+ const text = await res.text()
102
+ return Response.json({ error: `GitHub write failed: ${text}` }, { status: 502 })
103
+ }
104
+
105
+ invalidateCache(`editors:${owner}/${repo}:${branch}`)
106
+ return Response.json({ ok: true })
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Helpers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ async function fetchFileSha(
114
+ owner: string,
115
+ repo: string,
116
+ branch: string,
117
+ path: string,
118
+ headers: Record<string, string>,
119
+ ): Promise<string | null> {
120
+ try {
121
+ const res = await fetch(
122
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
123
+ { headers },
124
+ )
125
+ if (!res.ok) return null
126
+ const data = await res.json() as { sha: string }
127
+ return data.sha ?? null
128
+ } catch { return null }
129
+ }
130
+
131
+ export async function readEditorsFile(
132
+ owner: string,
133
+ repo: string,
134
+ branch: string,
135
+ contentPath: string,
136
+ token: string,
137
+ ): Promise<ContentEditorConfig[] | null> {
138
+ const key = `editors:${owner}/${repo}:${branch}`
139
+ return cachedFetch(key, 2 * 60_000, async () => {
140
+ const res = await fetch(
141
+ `https://api.github.com/repos/${owner}/${repo}/contents/${EDITORS_FILE(contentPath)}?ref=${branch}`,
142
+ { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
143
+ )
144
+ if (!res.ok) return null
145
+ const data = await res.json() as { content: string; encoding: string }
146
+ const raw = data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
147
+ return JSON.parse(raw) as ContentEditorConfig[]
148
+ })
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