@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
@@ -1,8 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig, prefixPath } from './_storage-config'
2
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { removeFromPageConfig } from './section-management'
4
4
  import { parseSession, guardPageAccess } from './_auth-guard'
5
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  /**
8
9
  * DELETE /api/setzkasten/sections
@@ -18,8 +19,11 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
18
19
  const session = cookies.get('setzkasten_session')?.value
19
20
  if (!session) return new Response('Unauthorized', { status: 401 })
20
21
 
21
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
22
- if (!githubToken) 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 })
25
+ }
26
+ const githubToken = tokenResult.value
23
27
 
24
28
  try {
25
29
  const body = await request.json() as {
@@ -31,7 +35,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
31
35
  contentPath?: string
32
36
  }
33
37
 
34
- const storage = resolveStorageConfig(body)
38
+ const storage = await resolveStorageConfigForRequest(request, body)
35
39
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
36
40
  const { owner, repo, branch, projectPrefix } = storage
37
41
 
@@ -44,7 +48,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
44
48
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
45
49
  }
46
50
 
47
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
51
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
48
52
  if (denied) return denied
49
53
 
50
54
  const headers = {
@@ -80,6 +84,26 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
80
84
 
81
85
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
82
86
 
87
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
88
+ await recordPageEdit(
89
+ { owner, repo, branch, contentPath, token: tokenResult.value },
90
+ pageKey,
91
+ ).catch(() => {})
92
+
93
+ // Fire content.delete webhooks (fire-and-forget).
94
+ const { fireWebhooks } = await import('./_webhook-dispatcher.js')
95
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
96
+ void fireWebhooks(
97
+ 'content.delete',
98
+ {
99
+ website: { id: owner, repo: `${owner}/${repo}`, branch },
100
+ user: { email: session?.user?.email ?? 'unknown', name: session?.user?.name },
101
+ commit: { sha: commitResult.sha, message: `Delete ${sectionKey} from ${pageKey}` },
102
+ files: [{ path: sectionJsonPath }],
103
+ },
104
+ request,
105
+ )
106
+
83
107
  return Response.json({ success: true, commitSha: commitResult.sha })
84
108
  } catch (error) {
85
109
  console.error('[setzkasten] section-delete error:', error)
@@ -1,8 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig, prefixPath } from './_storage-config'
2
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
4
  import { parseSession, guardPageAccess } from './_auth-guard'
5
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  /**
8
9
  * POST /api/setzkasten/sections/duplicate
@@ -18,8 +19,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
18
19
  const session = cookies.get('setzkasten_session')?.value
19
20
  if (!session) return new Response('Unauthorized', { status: 401 })
20
21
 
21
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
22
- if (!githubToken) 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 })
25
+ }
26
+ const githubToken = tokenResult.value
23
27
 
24
28
  try {
25
29
  const body = await request.json() as {
@@ -31,7 +35,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
31
35
  contentPath?: string
32
36
  }
33
37
 
34
- const storage = resolveStorageConfig(body)
38
+ const storage = await resolveStorageConfigForRequest(request, body)
35
39
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
36
40
  const { owner, repo, branch, projectPrefix } = storage
37
41
 
@@ -44,7 +48,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
44
48
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
45
49
  }
46
50
 
47
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
51
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
48
52
  if (denied) return denied
49
53
 
50
54
  const headers = {
@@ -91,6 +95,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
91
95
 
92
96
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
93
97
 
98
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
99
+ await recordPageEdit(
100
+ { owner, repo, branch, contentPath, token: tokenResult.value },
101
+ pageKey,
102
+ ).catch(() => {})
103
+
94
104
  return Response.json({ success: true, newKey, commitSha: commitResult.sha })
95
105
  } catch (error) {
96
106
  console.error('[setzkasten] section-duplicate error:', error)
@@ -1,6 +1,7 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig } from './_storage-config'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
5
 
5
6
  /**
6
7
  * POST /api/setzkasten/sections/prepare-copy
@@ -13,14 +14,24 @@ import { generateDuplicateKey, duplicateInPageConfig } from './section-managemen
13
14
  * The client uses this to update local state + preview draft immediately.
14
15
  * Only committed to GitHub when the user presses "Live setzen".
15
16
  *
17
+ * Note: this route intentionally does NOT call recordPageEdit. The
18
+ * page-recency spec lists it as a "mutating route", but in practice it
19
+ * only reads and returns — the real GitHub commit happens later in
20
+ * commit-pending, which records the edit. Bumping the timestamp here
21
+ * would mark a page as recently-modified even when the user opens the
22
+ * duplicate dialog and then cancels without committing.
23
+ *
16
24
  * Body: { pageKey, sectionKey, owner?, repo?, branch?, contentPath? }
17
25
  */
18
26
  export const POST: APIRoute = async ({ request, cookies }) => {
19
27
  const session = cookies.get('setzkasten_session')?.value
20
28
  if (!session) return new Response('Unauthorized', { status: 401 })
21
29
 
22
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
23
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
30
+ const tokenResult = await resolveGitHubTokenForRequest(request)
31
+ if (!tokenResult.ok) {
32
+ return new Response(tokenResult.error.message, { status: 500 })
33
+ }
34
+ const githubToken = tokenResult.value
24
35
 
25
36
  try {
26
37
  const body = await request.json() as {
@@ -32,7 +43,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
32
43
  contentPath?: string
33
44
  }
34
45
 
35
- const storage = resolveStorageConfig(body)
46
+ const storage = await resolveStorageConfigForRequest(request, body)
36
47
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
37
48
  const { owner, repo, branch } = storage
38
49
 
@@ -1,7 +1,8 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig, prefixPath } from './_storage-config'
2
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { generateAddKey } from './section-management'
4
4
  import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { resolveGitHubTokenForRequest } from './_github-token'
5
6
 
6
7
  /**
7
8
  * POST /api/setzkasten/sections/prepare
@@ -20,8 +21,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
20
21
  const session = cookies.get('setzkasten_session')?.value
21
22
  if (!session) return new Response('Unauthorized', { status: 401 })
22
23
 
23
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
24
+ const tokenResult = await resolveGitHubTokenForRequest(request)
25
+ if (!tokenResult.ok) {
26
+ return new Response(tokenResult.error.message, { status: 500 })
27
+ }
28
+ const githubToken = tokenResult.value
25
29
 
26
30
  try {
27
31
  const body = await request.json() as {
@@ -33,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
33
37
  contentPath?: string
34
38
  }
35
39
 
36
- const storage = resolveStorageConfig(body)
40
+ const storage = await resolveStorageConfigForRequest(request, body)
37
41
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
38
42
  const { owner, repo, branch } = storage
39
43
 
@@ -46,7 +50,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
46
50
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
47
51
  }
48
52
 
49
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
53
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
50
54
  if (denied) return denied
51
55
 
52
56
  // 1. Read current page config from GitHub to determine existing keys
@@ -0,0 +1,52 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { getPublicOrigin } from './_vercel-origin.js'
3
+
4
+ /**
5
+ * Server-side manifest bounce for the GitHub App Manifest flow.
6
+ *
7
+ * GET /api/setzkasten/setup/github-app/bounce?name=my-app
8
+ *
9
+ * Generates the GitHub App manifest JSON using the server-known origin,
10
+ * then returns a minimal HTML page that auto-submits a form to GitHub.
11
+ */
12
+ export const GET: APIRoute = async ({ url, request }) => {
13
+ const name = url.searchParams.get('name')?.trim() || 'Setzkasten CMS'
14
+ const origin = getPublicOrigin(request)
15
+
16
+ const manifest = JSON.stringify({
17
+ name,
18
+ url: origin,
19
+ redirect_url: `${origin}/api/setzkasten/setup/github-app/callback`,
20
+ setup_url: `${origin}/api/setzkasten/setup/github-app/installed`,
21
+ setup_on_update: false,
22
+ public: false,
23
+ default_permissions: { contents: 'write' },
24
+ })
25
+
26
+ const safeManifest = manifest.replace(/&/g, '&').replace(/"/g, '"')
27
+
28
+ const html = `<!DOCTYPE html>
29
+ <html lang="de">
30
+ <head>
31
+ <meta charset="UTF-8">
32
+ <title>Weiterleitung zu GitHub…</title>
33
+ <style>
34
+ body { font-family: sans-serif; display: flex; align-items: center;
35
+ justify-content: center; height: 100vh; margin: 0;
36
+ background: #0d1117; color: #e6edf3; }
37
+ p { opacity: .6; }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <p>Weiterleitung zu GitHub…</p>
42
+ <form id="f" method="POST" action="https://github.com/settings/apps/new">
43
+ <input type="hidden" name="manifest" value="${safeManifest}">
44
+ </form>
45
+ <script>document.getElementById('f').submit()</script>
46
+ </body>
47
+ </html>`
48
+
49
+ return new Response(html, {
50
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
51
+ })
52
+ }
@@ -0,0 +1,63 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { listRepoBranches } from '@setzkasten-cms/github-adapter'
3
+ import { requireAdmin } from './_auth-guard'
4
+
5
+ /**
6
+ * GET /api/setzkasten/setup/github-app/branches?installation=<id>&repo=<owner/repo>
7
+ *
8
+ * Returns the list of branches for one repo, fetched via the installation
9
+ * token of the given installation. Used by the WebsitesView form so the
10
+ * Branch field becomes a dropdown after the user picks a repo.
11
+ *
12
+ * Admin-only — same reasoning as /setup/github-app/repos.
13
+ */
14
+ export const GET: APIRoute = async ({ cookies, url }) => {
15
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
16
+ if (denied) return denied
17
+
18
+ const appId = process.env.GITHUB_APP_ID
19
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
20
+ if (!appId || !privateKey) {
21
+ return new Response(
22
+ JSON.stringify({
23
+ error:
24
+ 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
25
+ }),
26
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
27
+ )
28
+ }
29
+
30
+ const installationId = url.searchParams.get('installation')
31
+ const repoFull = url.searchParams.get('repo')
32
+ if (!installationId || !repoFull) {
33
+ return new Response(
34
+ JSON.stringify({ error: 'Both ?installation and ?repo (owner/name) are required.' }),
35
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
36
+ )
37
+ }
38
+
39
+ const slash = repoFull.indexOf('/')
40
+ if (slash <= 0 || slash === repoFull.length - 1) {
41
+ return new Response(
42
+ JSON.stringify({ error: '?repo must be in "owner/name" format.' }),
43
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
44
+ )
45
+ }
46
+ const owner = repoFull.slice(0, slash)
47
+ const repo = repoFull.slice(slash + 1)
48
+
49
+ const result = await listRepoBranches({ appId, privateKey }, installationId, owner, repo)
50
+ if (!result.ok) {
51
+ const status =
52
+ result.error.type === 'auth' ? 401 : result.error.type === 'not-found' ? 404 : 502
53
+ return new Response(JSON.stringify({ error: result.error.message }), {
54
+ status,
55
+ headers: { 'Content-Type': 'application/json' },
56
+ })
57
+ }
58
+
59
+ return new Response(JSON.stringify({ branches: result.value }), {
60
+ status: 200,
61
+ headers: { 'Content-Type': 'application/json' },
62
+ })
63
+ }
@@ -0,0 +1,71 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { getPublicOrigin } from './_vercel-origin.js'
3
+
4
+ const COOKIE_NAME = 'sk_app_setup'
5
+ const COOKIE_MAX_AGE = 600 // 10 minutes
6
+
7
+ /**
8
+ * Receives the code from GitHub after a GitHub App Manifest creation.
9
+ * Exchanges it for the full app credentials (App ID, private key, slug).
10
+ *
11
+ * GET /api/setzkasten/setup/github-app/callback?code=xxx
12
+ *
13
+ * Note: uses mutable new Response() instead of Response.redirect() —
14
+ * Response.redirect() is immutable and prevents Astro from appending
15
+ * the Set-Cookie header (TypeError: immutable).
16
+ */
17
+ export const GET: APIRoute = async ({ url, request, cookies }) => {
18
+ const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
19
+ | { adminPath?: string }
20
+ | undefined
21
+ const adminPath = config?.adminPath ?? '/admin'
22
+ const adminUrl = new URL(adminPath, getPublicOrigin(request))
23
+ const code = url.searchParams.get('code')
24
+
25
+ if (!code) {
26
+ adminUrl.searchParams.set('github-app-error', 'missing_code')
27
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
28
+ }
29
+
30
+ // GitHub returns a lot more than these five fields (permissions,
31
+ // events, html_url, …) but these are the only ones we persist for
32
+ // the wizard. `client_id` + `client_secret` since v1.2: the GH-App
33
+ // doubles as the OAuth provider for admin login, so the Manifest
34
+ // exchange replaces what was previously a separate OAuth-App that
35
+ // the user had to create on github.com/settings/developers.
36
+ let data: {
37
+ id: number
38
+ slug: string
39
+ pem: string
40
+ client_id: string
41
+ client_secret: string
42
+ } | null = null
43
+ try {
44
+ const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
45
+ method: 'POST',
46
+ headers: { Accept: 'application/vnd.github.v3+json' },
47
+ signal: AbortSignal.timeout(8000),
48
+ })
49
+ if (!response.ok) throw new Error(`GitHub returned ${response.status}`)
50
+ data = (await response.json()) as typeof data
51
+ } catch {
52
+ adminUrl.searchParams.set('github-app-error', 'exchange_failed')
53
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
54
+ }
55
+
56
+ // Set cookie before constructing the redirect response —
57
+ // Response.redirect() is immutable and blocks subsequent header writes.
58
+ cookies.set(
59
+ COOKIE_NAME,
60
+ JSON.stringify({
61
+ appId: String(data!.id),
62
+ slug: data!.slug,
63
+ privateKey: data!.pem,
64
+ clientId: data!.client_id,
65
+ clientSecret: data!.client_secret,
66
+ }),
67
+ { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
68
+ )
69
+
70
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
71
+ }
@@ -0,0 +1,44 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { getPublicOrigin } from './_vercel-origin.js'
3
+
4
+ const COOKIE_NAME = 'sk_app_setup'
5
+ const COOKIE_MAX_AGE = 600
6
+
7
+ /**
8
+ * Receives the installation_id from GitHub after the user installs the App.
9
+ * Merges it into the existing setup cookie and redirects back to the admin.
10
+ *
11
+ * GET /api/setzkasten/setup/github-app/installed?installation_id=xxx
12
+ *
13
+ * Note: uses mutable new Response() instead of Response.redirect() —
14
+ * Response.redirect() is immutable and prevents Astro from appending
15
+ * the Set-Cookie header (TypeError: immutable).
16
+ */
17
+ export const GET: APIRoute = async ({ url, request, cookies }) => {
18
+ const config = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
19
+ | { adminPath?: string }
20
+ | undefined
21
+ const adminPath = config?.adminPath ?? '/admin'
22
+ const adminUrl = new URL(adminPath, getPublicOrigin(request))
23
+ const installationId = url.searchParams.get('installation_id')
24
+ const existing = cookies.get(COOKIE_NAME)?.value
25
+
26
+ if (!installationId || !existing) {
27
+ adminUrl.searchParams.set('github-app-error', 'missing_installation')
28
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
29
+ }
30
+
31
+ try {
32
+ const data = JSON.parse(existing) as Record<string, string>
33
+ cookies.set(
34
+ COOKIE_NAME,
35
+ JSON.stringify({ ...data, installationId }),
36
+ { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
37
+ )
38
+ } catch {
39
+ adminUrl.searchParams.set('github-app-error', 'invalid_session')
40
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
41
+ }
42
+
43
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
44
+ }
@@ -0,0 +1,46 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { listAccessibleRepos } from '@setzkasten-cms/github-adapter'
3
+ import { requireAdmin } from './_auth-guard'
4
+
5
+ /**
6
+ * GET /api/setzkasten/setup/github-app/repos
7
+ *
8
+ * Returns every repository the configured GitHub App can access, flattened
9
+ * across all installations. Each entry includes the installationId so the
10
+ * "Neue Website hinzufügen" form can record which installation owns the
11
+ * repo without a follow-up lookup.
12
+ *
13
+ * Admin-only — editors must not be able to enumerate repos across the
14
+ * organization. The endpoint uses the global App credentials from the env
15
+ * (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY) so the user never has to type them.
16
+ */
17
+ export const GET: APIRoute = async ({ cookies }) => {
18
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
19
+ if (denied) return denied
20
+
21
+ const appId = process.env.GITHUB_APP_ID
22
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
23
+ if (!appId || !privateKey) {
24
+ return new Response(
25
+ JSON.stringify({
26
+ error:
27
+ 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
28
+ }),
29
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
30
+ )
31
+ }
32
+
33
+ const result = await listAccessibleRepos({ appId, privateKey })
34
+ if (!result.ok) {
35
+ const status = result.error.type === 'auth' ? 401 : 502
36
+ return new Response(JSON.stringify({ error: result.error.message }), {
37
+ status,
38
+ headers: { 'Content-Type': 'application/json' },
39
+ })
40
+ }
41
+
42
+ return new Response(JSON.stringify({ repos: result.value }), {
43
+ status: 200,
44
+ headers: { 'Content-Type': 'application/json' },
45
+ })
46
+ }
@@ -0,0 +1,58 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { GitHubAppClient } from '@setzkasten-cms/github-adapter'
3
+
4
+ /**
5
+ * Setup-Wizard: GitHub App Integration
6
+ *
7
+ * GET /api/setzkasten/setup/github-app – Status abfragen
8
+ * POST /api/setzkasten/setup/github-app – Verbindung testen
9
+ *
10
+ * Credentials werden NICHT persistiert – der Nutzer setzt die env vars manuell.
11
+ * Der POST-Endpunkt validiert die Verbindung durch einen echten Token-Request.
12
+ */
13
+
14
+ export const GET: APIRoute = async () => {
15
+ const appId = process.env.GITHUB_APP_ID
16
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
17
+ const installationId = process.env.GITHUB_APP_INSTALLATION_ID
18
+
19
+ const configured = Boolean(appId && privateKey && installationId)
20
+
21
+ return Response.json({ configured, ...(configured ? { appId } : {}) })
22
+ }
23
+
24
+ export const POST: APIRoute = async ({ request }) => {
25
+ let body: unknown
26
+ try {
27
+ body = await request.json()
28
+ } catch {
29
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
30
+ }
31
+
32
+ const { appId, privateKey, installationId } =
33
+ (body as Record<string, unknown>) ?? {}
34
+
35
+ if (!appId || !privateKey || !installationId) {
36
+ return Response.json(
37
+ { error: 'appId, privateKey and installationId are required' },
38
+ { status: 400 },
39
+ )
40
+ }
41
+
42
+ const client = new GitHubAppClient(
43
+ {
44
+ appId: String(appId),
45
+ privateKey: String(privateKey),
46
+ installationId: String(installationId),
47
+ },
48
+ { owner: '', repo: '', branch: '' },
49
+ )
50
+
51
+ const result = await client.getInstallationToken()
52
+
53
+ if (!result.ok) {
54
+ return Response.json({ ok: false, error: result.error.message }, { status: 400 })
55
+ }
56
+
57
+ return Response.json({ ok: true })
58
+ }
@@ -1,5 +1,23 @@
1
1
  import type { APIRoute } from 'astro'
2
2
 
3
+ // Vite-define injected by @setzkasten-cms/astro at build time. Available
4
+ // in API-only Vercel cold-starts where the page-ssr injectScript that
5
+ // writes globalThis.__SETZKASTEN_CONFIG__ does not fire.
6
+ declare const __SETZKASTEN_BUILD_CONFIG__: {
7
+ adminPath?: string
8
+ updaterUrl?: string
9
+ version?: string
10
+ websiteUrl?: string
11
+ hasGitHub?: boolean
12
+ storage?: {
13
+ owner?: string
14
+ repo?: string
15
+ branch?: string
16
+ contentPath?: string
17
+ assetsPath?: string
18
+ }
19
+ } | null | undefined
20
+
3
21
  /**
4
22
  * Registers this Setzkasten instance with the central updater backend.
5
23
  * Called on every Dashboard load. Returns update status and license tier.
@@ -8,11 +26,6 @@ import type { APIRoute } from 'astro'
8
26
  *
9
27
  * Body (optional — for UI activation flow):
10
28
  * { licenseEmail: string, licenseKey: string }
11
- *
12
- * Priority for credentials:
13
- * 1. Config (`setzkasten.config.ts` → license.{email,key}) — always wins if set
14
- * 2. Request body — UI activation flow, one-time
15
- * 3. Firebase instance fallback — stored binding from previous activation
16
29
  */
17
30
  export const POST: APIRoute = async ({ cookies, request }) => {
18
31
  const session = cookies.get('setzkasten_session')?.value
@@ -20,16 +33,23 @@ export const POST: APIRoute = async ({ cookies, request }) => {
20
33
  return new Response('Unauthorized', { status: 401 })
21
34
  }
22
35
 
23
- const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
36
+ // Two layers: the page-ssr injectScript writes __SETZKASTEN_CONFIG__ on
37
+ // globalThis (only fires for SSR-rendered pages). The Vite-define
38
+ // __SETZKASTEN_BUILD_CONFIG__ ships the same shape baked into the bundle
39
+ // and is therefore visible in API-only Vercel cold-starts where the
40
+ // injectScript never runs. We prefer the runtime globalThis value (it
41
+ // can pick up later overrides) but fall back to the build constant.
42
+ type ConfigShape = {
24
43
  updaterUrl?: string
25
44
  version?: string
26
45
  websiteUrl?: string
27
46
  storage?: { owner?: string; repo?: string }
28
- } | undefined
29
-
30
- const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
31
- license?: { email?: string; key?: string; telemetryEnabled?: boolean }
32
- } | undefined
47
+ }
48
+ const buildConfig = (typeof __SETZKASTEN_BUILD_CONFIG__ !== 'undefined'
49
+ ? __SETZKASTEN_BUILD_CONFIG__
50
+ : null) as ConfigShape | null
51
+ const runtimeConfig = (globalThis as any).__SETZKASTEN_CONFIG__ as ConfigShape | undefined
52
+ const config: ConfigShape | null = runtimeConfig ?? buildConfig
33
53
 
34
54
  const currentVersion = config?.version ?? '0.0.0'
35
55
  const updaterUrl = config?.updaterUrl
@@ -49,25 +69,19 @@ export const POST: APIRoute = async ({ cookies, request }) => {
49
69
  const repo = config?.storage?.repo ?? ''
50
70
  const repoUrl = owner && repo ? `${owner}/${repo}` : ''
51
71
  const websiteUrl = config?.websiteUrl ?? ''
52
- const configLicense = fullConfig?.license
53
72
 
54
- // ── Parse optional UI activation payload ──────────────────────────────
55
- let uiEmail: string | undefined
56
- let uiKey: string | undefined
73
+ let licenseEmail: string | undefined
74
+ let licenseKey: string | undefined
57
75
  try {
58
76
  if (request.headers.get('content-type')?.includes('application/json')) {
59
77
  const parsed = await request.json() as { licenseEmail?: string; licenseKey?: string }
60
- uiEmail = parsed.licenseEmail?.trim() || undefined
61
- uiKey = parsed.licenseKey?.trim() || undefined
78
+ licenseEmail = parsed.licenseEmail?.trim() || undefined
79
+ licenseKey = parsed.licenseKey?.trim() || undefined
62
80
  }
63
81
  } catch {
64
- // Empty / malformed body is fine — treat as no UI input
82
+ // Empty / malformed body — treat as no UI input
65
83
  }
66
84
 
67
- // Config wins over UI. UI only applies if config has no license.
68
- const licenseEmail = configLicense?.email ?? uiEmail
69
- const licenseKey = configLicense?.key ?? uiKey
70
-
71
85
  try {
72
86
  const response = await fetch(`${updaterUrl}/api/register`, {
73
87
  method: 'POST',
@@ -78,7 +92,7 @@ export const POST: APIRoute = async ({ cookies, request }) => {
78
92
  currentVersion,
79
93
  licenseEmail,
80
94
  licenseKey,
81
- telemetryEnabled: configLicense?.telemetryEnabled !== false,
95
+ telemetryEnabled: true,
82
96
  managedWebsites: [],
83
97
  }),
84
98
  signal: AbortSignal.timeout(5000),
@@ -90,8 +104,6 @@ export const POST: APIRoute = async ({ cookies, request }) => {
90
104
 
91
105
  const data = await response.json() as { firebaseConfig?: { apiKey: string; authDomain: string; projectId: string }; [key: string]: unknown }
92
106
 
93
- // Pass firebaseConfig through if the Updater returned one (Pro/Enterprise licenses).
94
- // Writing to _global_config.json is done explicitly via the GlobalConfigView activation flow.
95
107
  return Response.json({ ...data, currentVersion, _firebaseConfig: data.firebaseConfig ?? null })
96
108
  } catch {
97
109
  return Response.json({