@setzkasten-cms/astro-admin 0.8.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +16 -6
  2. package/src/admin-page.astro +1 -1
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
@@ -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,53 @@
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
+ let data: { id: number; slug: string; pem: string } | null = null
31
+ try {
32
+ const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
33
+ method: 'POST',
34
+ headers: { Accept: 'application/vnd.github.v3+json' },
35
+ signal: AbortSignal.timeout(8000),
36
+ })
37
+ if (!response.ok) throw new Error(`GitHub returned ${response.status}`)
38
+ data = (await response.json()) as typeof data
39
+ } catch {
40
+ adminUrl.searchParams.set('github-app-error', 'exchange_failed')
41
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
42
+ }
43
+
44
+ // Set cookie before constructing the redirect response —
45
+ // Response.redirect() is immutable and blocks subsequent header writes.
46
+ cookies.set(
47
+ COOKIE_NAME,
48
+ JSON.stringify({ appId: String(data!.id), slug: data!.slug, privateKey: data!.pem }),
49
+ { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
50
+ )
51
+
52
+ return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
53
+ }
@@ -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
+ }
@@ -8,11 +8,6 @@ import type { APIRoute } from 'astro'
8
8
  *
9
9
  * Body (optional — for UI activation flow):
10
10
  * { 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
11
  */
17
12
  export const POST: APIRoute = async ({ cookies, request }) => {
18
13
  const session = cookies.get('setzkasten_session')?.value
@@ -27,10 +22,6 @@ export const POST: APIRoute = async ({ cookies, request }) => {
27
22
  storage?: { owner?: string; repo?: string }
28
23
  } | undefined
29
24
 
30
- const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
31
- license?: { email?: string; key?: string; telemetryEnabled?: boolean }
32
- } | undefined
33
-
34
25
  const currentVersion = config?.version ?? '0.0.0'
35
26
  const updaterUrl = config?.updaterUrl
36
27
  if (!updaterUrl) {
@@ -49,25 +40,19 @@ export const POST: APIRoute = async ({ cookies, request }) => {
49
40
  const repo = config?.storage?.repo ?? ''
50
41
  const repoUrl = owner && repo ? `${owner}/${repo}` : ''
51
42
  const websiteUrl = config?.websiteUrl ?? ''
52
- const configLicense = fullConfig?.license
53
43
 
54
- // ── Parse optional UI activation payload ──────────────────────────────
55
- let uiEmail: string | undefined
56
- let uiKey: string | undefined
44
+ let licenseEmail: string | undefined
45
+ let licenseKey: string | undefined
57
46
  try {
58
47
  if (request.headers.get('content-type')?.includes('application/json')) {
59
48
  const parsed = await request.json() as { licenseEmail?: string; licenseKey?: string }
60
- uiEmail = parsed.licenseEmail?.trim() || undefined
61
- uiKey = parsed.licenseKey?.trim() || undefined
49
+ licenseEmail = parsed.licenseEmail?.trim() || undefined
50
+ licenseKey = parsed.licenseKey?.trim() || undefined
62
51
  }
63
52
  } catch {
64
- // Empty / malformed body is fine — treat as no UI input
53
+ // Empty / malformed body — treat as no UI input
65
54
  }
66
55
 
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
56
  try {
72
57
  const response = await fetch(`${updaterUrl}/api/register`, {
73
58
  method: 'POST',
@@ -78,7 +63,7 @@ export const POST: APIRoute = async ({ cookies, request }) => {
78
63
  currentVersion,
79
64
  licenseEmail,
80
65
  licenseKey,
81
- telemetryEnabled: configLicense?.telemetryEnabled !== false,
66
+ telemetryEnabled: true,
82
67
  managedWebsites: [],
83
68
  }),
84
69
  signal: AbortSignal.timeout(5000),
@@ -90,8 +75,6 @@ export const POST: APIRoute = async ({ cookies, request }) => {
90
75
 
91
76
  const data = await response.json() as { firebaseConfig?: { apiKey: string; authDomain: string; projectId: string }; [key: string]: unknown }
92
77
 
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
78
  return Response.json({ ...data, currentVersion, _firebaseConfig: data.firebaseConfig ?? null })
96
79
  } catch {
97
80
  return Response.json({
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
 
4
4
  /**
5
5
  * Transfer a license to the current Setzkasten instance.
6
+ * The backend identifies the license by instanceId (computed from repoUrl + websiteUrl).
6
7
  *
7
8
  * POST /api/setzkasten/updater/transfer
8
9
  */
@@ -18,26 +19,16 @@ export const POST: APIRoute = async ({ cookies }) => {
18
19
  storage?: { owner?: string; repo?: string }
19
20
  } | undefined
20
21
 
21
- const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__ as {
22
- license?: { email?: string; key?: string }
23
- } | undefined
24
-
25
22
  const updaterUrl = config?.updaterUrl
26
23
  if (!updaterUrl) {
27
24
  return Response.json({ error: 'Updater not configured' }, { status: 400 })
28
25
  }
29
26
 
30
- const license = fullConfig?.license
31
- if (!license?.key || !license?.email) {
32
- return Response.json({ error: 'No license configured' }, { status: 400 })
33
- }
34
-
35
27
  const owner = config?.storage?.owner ?? ''
36
28
  const repo = config?.storage?.repo ?? ''
37
29
  const repoUrl = owner && repo ? `${owner}/${repo}` : ''
38
30
  const websiteUrl = config?.websiteUrl ?? ''
39
31
 
40
- // Compute deterministic instanceId (same as backend register)
41
32
  const raw = (repoUrl || 'unknown') + '|' + (websiteUrl || 'unknown')
42
33
  const instanceId = createHash('sha256').update(raw).digest('hex').slice(0, 32)
43
34
 
@@ -46,8 +37,6 @@ export const POST: APIRoute = async ({ cookies }) => {
46
37
  method: 'POST',
47
38
  headers: { 'Content-Type': 'application/json' },
48
39
  body: JSON.stringify({
49
- licenseKey: license.key,
50
- licenseEmail: license.email,
51
40
  toInstanceId: instanceId,
52
41
  toWebsiteUrl: websiteUrl,
53
42
  }),
@@ -0,0 +1,113 @@
1
+ import {
2
+ type WebsiteEntry,
3
+ addWebsiteToRegistry,
4
+ canAddWebsite,
5
+ parseWebsitesRegistry,
6
+ } from '@setzkasten-cms/core'
7
+ import type { APIRoute } from 'astro'
8
+ import { requireAdmin } from './_auth-guard'
9
+ import { resolveConfigRepoToken } from './_github-token'
10
+ import { resolveLicenseTier } from './_license-tier'
11
+ import {
12
+ readWebsitesRegistryFromGitHub,
13
+ resolveConfigRepoTargetFromGlobals,
14
+ writeWebsitesRegistryToGitHub,
15
+ } from './_websites-store'
16
+
17
+ /**
18
+ * POST /api/setzkasten/websites/add
19
+ *
20
+ * Body: { entry: WebsiteEntry }
21
+ *
22
+ * Reads the current websites.json from the config-repo, adds the entry
23
+ * (validated via parseWebsitesRegistry to share one truth on what makes
24
+ * a valid WebsiteEntry), and commits the new file back via the GitHub
25
+ * Contents API.
26
+ */
27
+ export const POST: APIRoute = async ({ request, cookies }) => {
28
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
29
+ if (denied) return denied
30
+
31
+ let parsed: { entry?: WebsiteEntry } = {}
32
+ try {
33
+ parsed = (await request.json()) as { entry?: WebsiteEntry }
34
+ } catch {
35
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
36
+ }
37
+ if (!parsed.entry || typeof parsed.entry !== 'object') {
38
+ return new Response(JSON.stringify({ error: 'Missing "entry" in body' }), { status: 400 })
39
+ }
40
+
41
+ // The form leaves githubApp.appId blank when the user wants to fall back
42
+ // to GITHUB_APP_ID from the env. Inject it before validation so the
43
+ // registry sees a complete entry. The App-ID itself is not sensitive
44
+ // (it's public on every App's settings page on github.com), so writing
45
+ // it into websites.json is fine — but doing it server-side keeps the
46
+ // client form simpler.
47
+ const entry = parsed.entry as unknown as {
48
+ githubApp?: { appId?: string; installationId?: string }
49
+ }
50
+ if (entry.githubApp && !entry.githubApp.appId) {
51
+ const envAppId = process.env.GITHUB_APP_ID
52
+ if (envAppId) entry.githubApp.appId = envAppId
53
+ }
54
+
55
+ // Reuse parseWebsitesRegistry's per-entry validation by stuffing the new
56
+ // entry into a one-element registry — same rules everywhere.
57
+ const validation = parseWebsitesRegistry(JSON.stringify({ websites: [parsed.entry] }))
58
+ if (!validation.ok) {
59
+ return new Response(JSON.stringify({ error: validation.error.message }), { status: 400 })
60
+ }
61
+
62
+ const tokenResult = await resolveConfigRepoToken()
63
+ if (!tokenResult.ok) {
64
+ return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
65
+ }
66
+
67
+ const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
68
+ if (!target) {
69
+ return new Response(
70
+ JSON.stringify({
71
+ error: 'Multi-mode storage not configured (storage.kind must be "multi").',
72
+ }),
73
+ { status: 400 },
74
+ )
75
+ }
76
+
77
+ const current = await readWebsitesRegistryFromGitHub(target)
78
+ if (!current.ok) {
79
+ return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
80
+ }
81
+
82
+ // License gate: enforce the per-tier website limit before mutating GitHub.
83
+ // 402 Payment Required is the most accurate code here — the registry would
84
+ // accept the entry, only the license disagrees.
85
+ const tier = resolveLicenseTier()
86
+ const allowed = canAddWebsite(tier, current.value.registry.websites.length)
87
+ if (!allowed.ok) {
88
+ return new Response(
89
+ JSON.stringify({ error: allowed.reason, tier: allowed.tier, limit: allowed.limit }),
90
+ { status: 402, headers: { 'Content-Type': 'application/json' } },
91
+ )
92
+ }
93
+
94
+ const merged = addWebsiteToRegistry(current.value.registry, parsed.entry)
95
+ if (!merged.ok) {
96
+ return new Response(JSON.stringify({ error: merged.error.message }), { status: 409 })
97
+ }
98
+
99
+ const written = await writeWebsitesRegistryToGitHub(
100
+ target,
101
+ merged.value,
102
+ current.value.sha,
103
+ `feat(websites): add "${parsed.entry.id}" to registry`,
104
+ )
105
+ if (!written.ok) {
106
+ return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
107
+ }
108
+
109
+ return new Response(JSON.stringify({ ok: true, websites: merged.value.websites }), {
110
+ status: 200,
111
+ headers: { 'Content-Type': 'application/json' },
112
+ })
113
+ }
@@ -0,0 +1,40 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { listAllWebsites } from './_website-resolver.js'
3
+
4
+ /**
5
+ * GET /api/setzkasten/websites
6
+ *
7
+ * Returns the list of managed websites visible to the admin SPA.
8
+ * Strips private fields (`githubApp` installation refs, `allowedEmails`)
9
+ * — the client only needs ids/names/repos for the switcher.
10
+ */
11
+ export const GET: APIRoute = async ({ cookies }) => {
12
+ const session = cookies.get('setzkasten_session')?.value
13
+ if (!session) {
14
+ return new Response(JSON.stringify({ authenticated: false }), {
15
+ status: 401,
16
+ headers: { 'Content-Type': 'application/json' },
17
+ })
18
+ }
19
+
20
+ const result = await listAllWebsites()
21
+ if (!result.ok) {
22
+ return new Response(JSON.stringify({ error: result.error.message }), {
23
+ status: 500,
24
+ headers: { 'Content-Type': 'application/json' },
25
+ })
26
+ }
27
+
28
+ const websites = result.value.map((entry) => ({
29
+ id: entry.id,
30
+ name: entry.name,
31
+ repo: entry.repo,
32
+ branch: entry.branch,
33
+ previewOrigin: entry.previewOrigin,
34
+ }))
35
+
36
+ return new Response(JSON.stringify({ websites }), {
37
+ status: 200,
38
+ headers: { 'Content-Type': 'application/json' },
39
+ })
40
+ }