@setzkasten-cms/astro-admin 1.1.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.
- package/package.json +12 -6
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -0
- package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
- package/src/api-routes/__tests__/history.test.ts +168 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +7 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
- package/src/api-routes/__tests__/webhooks.test.ts +219 -0
- package/src/api-routes/_feature-gate.ts +39 -0
- package/src/api-routes/_role-resolver.ts +60 -0
- package/src/api-routes/_storage-config.ts +15 -2
- package/src/api-routes/_webhook-dispatcher.ts +120 -0
- package/src/api-routes/_webhook-signing.ts +13 -0
- package/src/api-routes/_webhook-status-store.ts +31 -0
- package/src/api-routes/auth-callback.ts +2 -0
- package/src/api-routes/auth-setzkasten-login.ts +16 -1
- package/src/api-routes/editors.ts +15 -0
- package/src/api-routes/history-rollback.ts +144 -0
- package/src/api-routes/history-version.ts +57 -0
- package/src/api-routes/history.ts +119 -0
- package/src/api-routes/section-commit-pending.ts +108 -9
- package/src/api-routes/section-delete.ts +14 -0
- package/src/api-routes/setup-github-app-callback.ts +20 -2
- package/src/api-routes/updater-register.ts +31 -2
- package/src/api-routes/webhooks-status.ts +17 -0
- package/src/api-routes/webhooks-test.ts +134 -0
- package/src/api-routes/webhooks.ts +163 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
- package/src/init/template-patcher-v2.ts +9 -4
|
@@ -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
|
|
385
|
-
|
|
386
|
-
|
|
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]}>`
|