@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
@@ -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,17 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { requireAdmin } from './_auth-guard'
3
+ import { getWebhookStatus } from './_webhook-status-store'
4
+
5
+ /**
6
+ * GET /api/setzkasten/webhooks/status
7
+ *
8
+ * Returns the in-memory status map (lastFiredAt + lastStatus per webhook
9
+ * id). Empty for cold-started instances; the UI handles that gracefully
10
+ * with "noch nie gefeuert".
11
+ */
12
+ export const GET: APIRoute = async ({ cookies }) => {
13
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
14
+ if (denied) return denied
15
+
16
+ return Response.json({ status: getWebhookStatus() })
17
+ }
@@ -0,0 +1,134 @@
1
+ import type { APIRoute } from 'astro'
2
+ import {
3
+ parseWebhooksFile,
4
+ type WebhookConfig,
5
+ type WebhookPayload,
6
+ } from '@setzkasten-cms/core'
7
+ import { resolveStorageConfigForRequest } from './_storage-config'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
9
+ import { parseSession, requireAdmin } from './_auth-guard'
10
+ import { gateFeature } from './_feature-gate'
11
+ import { recordWebhookFire } from './_webhook-status-store'
12
+ import { signPayload } from './_webhook-signing'
13
+
14
+ const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
15
+ const TEST_TIMEOUT_MS = 10_000
16
+
17
+ /**
18
+ * POST /api/setzkasten/webhooks/test
19
+ * Body: { id: string }
20
+ *
21
+ * Fires a synthetic test payload at the configured URL and returns
22
+ * { ok, status, latencyMs }. Admin-only, Pro-gated. Used by the UI
23
+ * "Test-Fire"-Button to verify a webhook config end-to-end.
24
+ */
25
+ export const POST: APIRoute = async ({ request, cookies }) => {
26
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
27
+ if (denied) return denied
28
+
29
+ const gate = gateFeature('webhooks')
30
+ if (gate) return gate
31
+
32
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
33
+ if (!session) return new Response('Unauthorized', { status: 401 })
34
+
35
+ let body: { id?: string }
36
+ try {
37
+ body = (await request.json()) as { id?: string }
38
+ } catch {
39
+ return Response.json({ error: 'Invalid JSON' }, { status: 400 })
40
+ }
41
+ if (!body.id) {
42
+ return Response.json({ error: 'id is required' }, { status: 400 })
43
+ }
44
+
45
+ const tokenResult = await resolveGitHubTokenForRequest(request)
46
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
47
+
48
+ const storage = await resolveStorageConfigForRequest(request)
49
+ if (!storage) {
50
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
51
+ }
52
+ const { owner, repo, branch } = storage
53
+
54
+ const serverConfig = (globalThis as {
55
+ __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
56
+ }).__SETZKASTEN_CONFIG__
57
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
58
+
59
+ // Read webhooks file directly (no cache — we want the freshly-saved value)
60
+ const fileRes = await fetch(
61
+ `https://api.github.com/repos/${owner}/${repo}/contents/${WEBHOOKS_FILE(contentPath)}?ref=${branch}`,
62
+ {
63
+ headers: {
64
+ Authorization: `Bearer ${tokenResult.value}`,
65
+ Accept: 'application/vnd.github+json',
66
+ 'X-GitHub-Api-Version': '2022-11-28',
67
+ },
68
+ },
69
+ )
70
+ if (fileRes.status === 404) {
71
+ return Response.json({ error: 'No webhooks configured' }, { status: 404 })
72
+ }
73
+ if (!fileRes.ok) {
74
+ return Response.json({ error: 'Could not read webhooks file' }, { status: 502 })
75
+ }
76
+ const data = (await fileRes.json()) as { content: string; encoding: string }
77
+ const raw =
78
+ data.encoding === 'base64'
79
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
80
+ : data.content
81
+ const parsed = parseWebhooksFile(raw)
82
+ if (!parsed.ok) {
83
+ return Response.json({ error: 'Webhooks file is malformed' }, { status: 502 })
84
+ }
85
+
86
+ const target: WebhookConfig | undefined = parsed.value.webhooks.find((w) => w.id === body.id)
87
+ if (!target) {
88
+ return Response.json({ error: `Webhook "${body.id}" not found` }, { status: 404 })
89
+ }
90
+
91
+ // Build test payload — flagged as a test so receivers can ignore it
92
+ const payload: WebhookPayload & { test: boolean } = {
93
+ event: 'content.save',
94
+ timestamp: new Date().toISOString(),
95
+ website: { id: owner, repo: `${owner}/${repo}`, branch },
96
+ user: { email: session.user.email, name: session.user.name },
97
+ commit: { sha: '0'.repeat(40), message: 'test webhook fire' },
98
+ files: [],
99
+ test: true,
100
+ }
101
+ const payloadBody = JSON.stringify(payload)
102
+
103
+ const headers: Record<string, string> = {
104
+ 'Content-Type': 'application/json',
105
+ 'X-Setzkasten-Event': 'content.save',
106
+ 'X-Setzkasten-Delivery': crypto.randomUUID(),
107
+ 'X-Setzkasten-Test': 'true',
108
+ }
109
+ if (target.secret) {
110
+ headers['X-Setzkasten-Signature'] = `sha256=${signPayload(payloadBody, target.secret)}`
111
+ }
112
+
113
+ const startedAt = Date.now()
114
+ try {
115
+ const res = await fetch(target.url, {
116
+ method: 'POST',
117
+ headers,
118
+ body: payloadBody,
119
+ signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
120
+ })
121
+ const latencyMs = Date.now() - startedAt
122
+ recordWebhookFire(target.id, res.status)
123
+ return Response.json({ ok: res.ok, status: res.status, latencyMs })
124
+ } catch (err) {
125
+ const latencyMs = Date.now() - startedAt
126
+ recordWebhookFire(target.id, 'error')
127
+ return Response.json({
128
+ ok: false,
129
+ status: 'error',
130
+ latencyMs,
131
+ error: err instanceof Error ? err.message : 'Unknown error',
132
+ })
133
+ }
134
+ }
@@ -0,0 +1,163 @@
1
+ import type { APIRoute } from 'astro'
2
+ import {
3
+ parseWebhooksFile,
4
+ validateWebhookConfig,
5
+ type WebhookConfig,
6
+ } from '@setzkasten-cms/core'
7
+ import { resolveStorageConfigForRequest } from './_storage-config'
8
+ import { resolveGitHubTokenForRequest } from './_github-token'
9
+ import { parseSession, requireAdmin } from './_auth-guard'
10
+ import { cachedFetch, invalidateCache } from './_github-cache'
11
+ import { withTrailers } from './_commit-trailers'
12
+ import { gateFeature } from './_feature-gate'
13
+
14
+ const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // GET /api/setzkasten/webhooks — list (admin only, cached 60s)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const GET: APIRoute = async ({ request, cookies }) => {
21
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
22
+ if (denied) return denied
23
+
24
+ const tokenResult = await resolveGitHubTokenForRequest(request)
25
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
26
+
27
+ const storage = await resolveStorageConfigForRequest(request)
28
+ if (!storage) {
29
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
30
+ }
31
+ const { owner, repo, branch } = storage
32
+ const contentPath = resolveContentPath()
33
+
34
+ const cacheKey = `webhooks:${owner}/${repo}:${branch}`
35
+ const webhooks = await cachedFetch(cacheKey, 60_000, async () => {
36
+ const res = await fetch(
37
+ `https://api.github.com/repos/${owner}/${repo}/contents/${WEBHOOKS_FILE(contentPath)}?ref=${branch}`,
38
+ {
39
+ headers: {
40
+ Authorization: `Bearer ${tokenResult.value}`,
41
+ Accept: 'application/vnd.github+json',
42
+ 'X-GitHub-Api-Version': '2022-11-28',
43
+ },
44
+ },
45
+ )
46
+ if (res.status === 404) return [] as readonly WebhookConfig[]
47
+ if (!res.ok) return null
48
+ const data = (await res.json()) as { content: string; encoding: string }
49
+ const raw =
50
+ data.encoding === 'base64'
51
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
52
+ : data.content
53
+ const parsed = parseWebhooksFile(raw)
54
+ return parsed.ok ? parsed.value.webhooks : null
55
+ })
56
+
57
+ if (webhooks === null) {
58
+ return Response.json({ error: 'Could not read webhooks file' }, { status: 502 })
59
+ }
60
+ return Response.json({ webhooks })
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // PUT /api/setzkasten/webhooks — replace whole list (admin + Pro-gated)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export const PUT: APIRoute = async ({ request, cookies }) => {
68
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
69
+ if (denied) return denied
70
+
71
+ const gate = gateFeature('webhooks')
72
+ if (gate) return gate
73
+
74
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
75
+ if (!session) return new Response('Unauthorized', { status: 401 })
76
+
77
+ let body: { webhooks?: unknown }
78
+ try {
79
+ body = (await request.json()) as { webhooks?: unknown }
80
+ } catch {
81
+ return Response.json({ error: 'Invalid JSON' }, { status: 400 })
82
+ }
83
+ if (!Array.isArray(body.webhooks)) {
84
+ return Response.json({ error: 'webhooks must be an array' }, { status: 400 })
85
+ }
86
+
87
+ const validated: WebhookConfig[] = []
88
+ const seenIds = new Set<string>()
89
+ for (let i = 0; i < body.webhooks.length; i++) {
90
+ const result = validateWebhookConfig(body.webhooks[i])
91
+ if (!result.ok) {
92
+ return Response.json(
93
+ { error: result.error.message, index: i },
94
+ { status: 400 },
95
+ )
96
+ }
97
+ if (seenIds.has(result.value.id)) {
98
+ return Response.json(
99
+ { error: `Duplicate webhook id: ${result.value.id}`, index: i },
100
+ { status: 400 },
101
+ )
102
+ }
103
+ seenIds.add(result.value.id)
104
+ validated.push(result.value)
105
+ }
106
+
107
+ const tokenResult = await resolveGitHubTokenForRequest(request)
108
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
109
+
110
+ const storage = await resolveStorageConfigForRequest(request)
111
+ if (!storage) {
112
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
113
+ }
114
+ const { owner, repo, branch } = storage
115
+ const contentPath = resolveContentPath()
116
+ const filePath = WEBHOOKS_FILE(contentPath)
117
+
118
+ const headers = {
119
+ Authorization: `Bearer ${tokenResult.value}`,
120
+ Accept: 'application/vnd.github+json',
121
+ 'X-GitHub-Api-Version': '2022-11-28',
122
+ 'Content-Type': 'application/json',
123
+ }
124
+
125
+ // Read current SHA for in-place updates
126
+ let currentSha: string | null = null
127
+ const existingRes = await fetch(
128
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
129
+ { headers },
130
+ )
131
+ if (existingRes.ok) {
132
+ const data = (await existingRes.json()) as { sha: string }
133
+ currentSha = data.sha
134
+ }
135
+
136
+ const fileBody = JSON.stringify({ version: 1, webhooks: validated }, null, 2)
137
+ const putBody: Record<string, unknown> = {
138
+ message: withTrailers('chore(webhooks): update webhook configuration', session.user.email),
139
+ content: Buffer.from(fileBody).toString('base64'),
140
+ branch,
141
+ }
142
+ if (currentSha) putBody.sha = currentSha
143
+
144
+ const putRes = await fetch(
145
+ `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
146
+ { method: 'PUT', headers, body: JSON.stringify(putBody) },
147
+ )
148
+
149
+ if (!putRes.ok) {
150
+ const text = await putRes.text()
151
+ return Response.json({ error: `Webhook write failed: ${text}` }, { status: 502 })
152
+ }
153
+
154
+ invalidateCache(`webhooks:${owner}/${repo}:${branch}`)
155
+ return Response.json({ ok: true, webhooks: validated })
156
+ }
157
+
158
+ function resolveContentPath(): string {
159
+ const serverConfig = (globalThis as {
160
+ __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
161
+ }).__SETZKASTEN_CONFIG__
162
+ return serverConfig?.storage?.contentPath ?? 'content'
163
+ }
@@ -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
+ }
@@ -0,0 +1,74 @@
1
+ import { removeWebsiteFromRegistry } from '@setzkasten-cms/core'
2
+ import type { APIRoute } from 'astro'
3
+ import { requireAdmin } from './_auth-guard'
4
+ import { resolveConfigRepoToken } from './_github-token'
5
+ import {
6
+ readWebsitesRegistryFromGitHub,
7
+ resolveConfigRepoTargetFromGlobals,
8
+ writeWebsitesRegistryToGitHub,
9
+ } from './_websites-store'
10
+
11
+ /**
12
+ * POST /api/setzkasten/websites/remove
13
+ *
14
+ * Body: { id: string }
15
+ *
16
+ * Drops the matching entry from websites.json and commits the new file.
17
+ * Admin-only — editors must not be able to detach websites from the
18
+ * registry. Returns 404 when the id is not present, 502 when GitHub I/O
19
+ * fails.
20
+ */
21
+ export const POST: APIRoute = async ({ request, cookies }) => {
22
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
23
+ if (denied) return denied
24
+
25
+ let body: { id?: string } = {}
26
+ try {
27
+ body = (await request.json()) as { id?: string }
28
+ } catch {
29
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
30
+ }
31
+ if (!body.id || typeof body.id !== 'string') {
32
+ return new Response(JSON.stringify({ error: 'Missing "id" in body' }), { status: 400 })
33
+ }
34
+
35
+ const tokenResult = await resolveConfigRepoToken()
36
+ if (!tokenResult.ok) {
37
+ return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
38
+ }
39
+
40
+ const target = resolveConfigRepoTargetFromGlobals(tokenResult.value)
41
+ if (!target) {
42
+ return new Response(
43
+ JSON.stringify({
44
+ error: 'Multi-mode storage not configured (storage.kind must be "multi").',
45
+ }),
46
+ { status: 400 },
47
+ )
48
+ }
49
+
50
+ const current = await readWebsitesRegistryFromGitHub(target)
51
+ if (!current.ok) {
52
+ return new Response(JSON.stringify({ error: current.error.message }), { status: 502 })
53
+ }
54
+
55
+ const next = removeWebsiteFromRegistry(current.value.registry, body.id)
56
+ if (!next.ok) {
57
+ return new Response(JSON.stringify({ error: next.error.message }), { status: 404 })
58
+ }
59
+
60
+ const written = await writeWebsitesRegistryToGitHub(
61
+ target,
62
+ next.value,
63
+ current.value.sha,
64
+ `chore(websites): remove "${body.id}" from registry`,
65
+ )
66
+ if (!written.ok) {
67
+ return new Response(JSON.stringify({ error: written.error.message }), { status: 502 })
68
+ }
69
+
70
+ return new Response(JSON.stringify({ ok: true, websites: next.value.websites }), {
71
+ status: 200,
72
+ headers: { 'Content-Type': 'application/json' },
73
+ })
74
+ }
@@ -25,9 +25,42 @@
25
25
  */
26
26
 
27
27
  import { describe, it, expect } from 'vitest'
28
- import { patchTemplateForFields, stripTemplateFallbacks } from '../../init/template-patcher-v2'
28
+ import { patchTemplateForFields, stripTemplateFallbacks, convertToSetHtml } from '../../init/template-patcher-v2'
29
29
  import type { PatchField } from '../../init/template-patcher-v2'
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // convertToSetHtml: auto-upgrade `<tag>{x?.field}</tag>` → `<tag set:html=…>`
33
+ // when the inline RTE introduces HTML markup. Regression: previously the
34
+ // regex only matched skData?.field / item.field, missing hand-rolled section
35
+ // components (apps/website/...) that destructure `data` from Astro.props.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('convertToSetHtml — variable-name flexibility', () => {
39
+ it('converts data?.field bindings (Astro.props destructure pattern)', () => {
40
+ const source = `<h2 data-sk-field="how.heading">{data?.heading}</h2>`
41
+ expect(convertToSetHtml(source)).toBe(
42
+ `<h2 data-sk-field="how.heading" set:html={data?.heading}></h2>`,
43
+ )
44
+ })
45
+
46
+ it('still converts skData?.field bindings (canonical pattern)', () => {
47
+ const source = `<h2 data-sk-field="how.heading">{skData?.heading}</h2>`
48
+ expect(convertToSetHtml(source)).toBe(
49
+ `<h2 data-sk-field="how.heading" set:html={skData?.heading}></h2>`,
50
+ )
51
+ })
52
+
53
+ it('is idempotent — already-converted templates pass through unchanged', () => {
54
+ const source = `<h2 data-sk-field="how.heading" set:html={data?.heading}></h2>`
55
+ expect(convertToSetHtml(source)).toBe(source)
56
+ })
57
+
58
+ it('leaves data-sk-field elements with non-binding content alone', () => {
59
+ const source = `<h2 data-sk-field="how.heading">Static</h2>`
60
+ expect(convertToSetHtml(source)).toBe(source)
61
+ })
62
+ })
63
+
31
64
  // ---------------------------------------------------------------------------
32
65
  // Edge Case 1: UTF-8 / multi-byte characters
33
66
  //