@setzkasten-cms/astro-admin 1.1.0 → 1.4.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 (29) hide show
  1. package/package.json +13 -6
  2. package/src/api-routes/__tests__/feature-gate.test.ts +60 -0
  3. package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
  4. package/src/api-routes/__tests__/history.test.ts +168 -0
  5. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +7 -0
  6. package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
  7. package/src/api-routes/__tests__/webhooks.test.ts +219 -0
  8. package/src/api-routes/_feature-gate.ts +39 -0
  9. package/src/api-routes/_role-resolver.ts +60 -0
  10. package/src/api-routes/_storage-config.ts +15 -2
  11. package/src/api-routes/_webhook-dispatcher.ts +120 -0
  12. package/src/api-routes/_webhook-signing.ts +13 -0
  13. package/src/api-routes/_webhook-status-store.ts +31 -0
  14. package/src/api-routes/auth-callback.ts +2 -0
  15. package/src/api-routes/auth-setzkasten-login.ts +16 -1
  16. package/src/api-routes/editors.ts +15 -0
  17. package/src/api-routes/history-rollback.ts +144 -0
  18. package/src/api-routes/history-version.ts +57 -0
  19. package/src/api-routes/history.ts +119 -0
  20. package/src/api-routes/icons-local.ts +169 -0
  21. package/src/api-routes/section-commit-pending.ts +108 -9
  22. package/src/api-routes/section-delete.ts +14 -0
  23. package/src/api-routes/setup-github-app-callback.ts +20 -2
  24. package/src/api-routes/updater-register.ts +31 -2
  25. package/src/api-routes/webhooks-status.ts +17 -0
  26. package/src/api-routes/webhooks-test.ts +134 -0
  27. package/src/api-routes/webhooks.ts +163 -0
  28. package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
  29. package/src/init/template-patcher-v2.ts +9 -4
@@ -27,7 +27,19 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
27
27
  return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
28
28
  }
29
29
 
30
- let data: { id: number; slug: string; pem: string } | null = null
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
31
43
  try {
32
44
  const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
33
45
  method: 'POST',
@@ -45,7 +57,13 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
45
57
  // Response.redirect() is immutable and blocks subsequent header writes.
46
58
  cookies.set(
47
59
  COOKIE_NAME,
48
- JSON.stringify({ appId: String(data!.id), slug: data!.slug, privateKey: data!.pem }),
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
+ }),
49
67
  { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
50
68
  )
51
69
 
@@ -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.
@@ -15,12 +33,23 @@ export const POST: APIRoute = async ({ cookies, request }) => {
15
33
  return new Response('Unauthorized', { status: 401 })
16
34
  }
17
35
 
18
- 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 = {
19
43
  updaterUrl?: string
20
44
  version?: string
21
45
  websiteUrl?: string
22
46
  storage?: { owner?: string; repo?: string }
23
- } | 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
24
53
 
25
54
  const currentVersion = config?.version ?? '0.0.0'
26
55
  const updaterUrl = config?.updaterUrl
@@ -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
+ }
@@ -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
  //
@@ -351,7 +351,7 @@ export async function patchTemplateForFields(
351
351
  * Matches: <tag ...data-sk-field...>{skData?.field}</tag>
352
352
  * Result: <tag ...data-sk-field... set:html={skData?.field}></tag>
353
353
  */
354
- function convertToSetHtml(source: string): string {
354
+ export function convertToSetHtml(source: string): string {
355
355
  const marker = 'data-sk-field'
356
356
  let result = source
357
357
  let searchFrom = 0
@@ -381,9 +381,14 @@ function convertToSetHtml(source: string): string {
381
381
  const innerMatch = afterTag.match(/^(\s*)\{([^{}]+)\}(\s*)<\/(\w+)>/)
382
382
  if (!innerMatch) continue
383
383
 
384
- // Only convert CMS bindings (skData?.field or item.field)
385
- const expr = innerMatch[2]!
386
- if (!/^(?:skData\?\.\w+|item\.\w+)$/.test(expr)) continue
384
+ // Only convert CMS bindings: a simple property access on a section-data
385
+ // variable. Templates emitted by this patcher use `skData?.field`, but
386
+ // hand-rolled section components in the wild commonly use `data?.field`
387
+ // (the convention in apps/website). Accept any `<name>(?.|.)<field>`
388
+ // pattern — the surrounding `data-sk-field` already proves this is a CMS
389
+ // binding, so we don't need to whitelist variable names.
390
+ const expr = innerMatch[2]!.trim()
391
+ if (!/^\w+\??\.\w+$/.test(expr)) continue
387
392
 
388
393
  const fullInnerLength = innerMatch[0]!.length
389
394
  const closeTag = `</${innerMatch[4]}>`