@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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
9
|
+
const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
10
|
+
|
|
11
|
+
const ADMIN_SESSION = JSON.stringify({
|
|
12
|
+
user: { id: 'u1', email: 'admin@example.com', role: 'admin', provider: 'github' },
|
|
13
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const EDITOR_SESSION = JSON.stringify({
|
|
17
|
+
user: { id: 'u2', email: 'editor@example.com', role: 'editor', provider: 'google' },
|
|
18
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function makeCtx(method: 'GET' | 'PUT', body?: unknown, sessionValue: string | null = ADMIN_SESSION) {
|
|
22
|
+
const url = new URL('https://cms.example.com/api/setzkasten/webhooks')
|
|
23
|
+
const init: RequestInit = { method }
|
|
24
|
+
if (body !== undefined) {
|
|
25
|
+
init.body = JSON.stringify(body)
|
|
26
|
+
init.headers = { 'content-type': 'application/json' }
|
|
27
|
+
}
|
|
28
|
+
const request = new Request(url, init)
|
|
29
|
+
return {
|
|
30
|
+
request,
|
|
31
|
+
url,
|
|
32
|
+
cookies: {
|
|
33
|
+
get: vi.fn((name: string) =>
|
|
34
|
+
name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.unstubAllEnvs()
|
|
42
|
+
vi.stubEnv('GITHUB_APP_ID', '1')
|
|
43
|
+
vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
|
|
44
|
+
vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
|
|
45
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', 'SK-PRO-AAAAAAAA-BBBBBBBB-CCCCCCCC')
|
|
46
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = {
|
|
47
|
+
kind: 'github-app',
|
|
48
|
+
repo: 'acme/site',
|
|
49
|
+
branch: 'main',
|
|
50
|
+
appId: '1',
|
|
51
|
+
installationId: '111',
|
|
52
|
+
}
|
|
53
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
|
|
54
|
+
storage: {
|
|
55
|
+
kind: 'github-app',
|
|
56
|
+
repo: 'acme/site',
|
|
57
|
+
branch: 'main',
|
|
58
|
+
appId: '1',
|
|
59
|
+
installationId: '111',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.restoreAllMocks()
|
|
66
|
+
vi.unstubAllEnvs()
|
|
67
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
|
|
68
|
+
;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const SAMPLE_WEBHOOKS_FILE = {
|
|
72
|
+
version: 1,
|
|
73
|
+
webhooks: [
|
|
74
|
+
{
|
|
75
|
+
id: 'algolia',
|
|
76
|
+
name: 'Algolia',
|
|
77
|
+
url: 'https://hooks.example.com/algolia',
|
|
78
|
+
events: ['content.save'],
|
|
79
|
+
enabled: true,
|
|
80
|
+
createdAt: '2026-05-08T12:00:00Z',
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('GET /api/setzkasten/webhooks', () => {
|
|
86
|
+
it('returns 401 without a session', async () => {
|
|
87
|
+
const { GET } = await import('../webhooks')
|
|
88
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('GET', undefined, null))
|
|
89
|
+
expect(res.status).toBe(401)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns 403 for editor session', async () => {
|
|
93
|
+
const { GET } = await import('../webhooks')
|
|
94
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(
|
|
95
|
+
makeCtx('GET', undefined, EDITOR_SESSION),
|
|
96
|
+
)
|
|
97
|
+
expect(res.status).toBe(403)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('returns parsed webhooks list for admin', async () => {
|
|
101
|
+
vi.stubGlobal(
|
|
102
|
+
'fetch',
|
|
103
|
+
vi.fn(async (url: string | URL) => {
|
|
104
|
+
const u = url instanceof URL ? url : new URL(url)
|
|
105
|
+
if (u.pathname.endsWith('/access_tokens')) {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({
|
|
109
|
+
token: 'gh_mock',
|
|
110
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
111
|
+
}),
|
|
112
|
+
} as Response
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
status: 200,
|
|
117
|
+
json: async () => ({
|
|
118
|
+
content: Buffer.from(JSON.stringify(SAMPLE_WEBHOOKS_FILE)).toString('base64'),
|
|
119
|
+
encoding: 'base64',
|
|
120
|
+
}),
|
|
121
|
+
} as Response
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const { GET } = await import('../webhooks')
|
|
126
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('GET'))
|
|
127
|
+
expect(res.status).toBe(200)
|
|
128
|
+
const body = await res.json()
|
|
129
|
+
// Cache may have stale data from earlier tests — accept non-empty list with our id
|
|
130
|
+
expect(Array.isArray(body.webhooks)).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('returns empty list when webhooks file is absent (404)', async () => {
|
|
134
|
+
vi.stubGlobal(
|
|
135
|
+
'fetch',
|
|
136
|
+
vi.fn(async (url: string | URL) => {
|
|
137
|
+
const u = url instanceof URL ? url : new URL(url)
|
|
138
|
+
if (u.pathname.endsWith('/access_tokens')) {
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
json: async () => ({
|
|
142
|
+
token: 'gh_mock',
|
|
143
|
+
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
144
|
+
}),
|
|
145
|
+
} as Response
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, status: 404, json: async () => ({}) } as Response
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const { GET } = await import('../webhooks')
|
|
152
|
+
const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('GET'))
|
|
153
|
+
expect(res.status).toBe(200)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('PUT /api/setzkasten/webhooks', () => {
|
|
158
|
+
it('returns 401 without session', async () => {
|
|
159
|
+
const { PUT } = await import('../webhooks')
|
|
160
|
+
const res = await (PUT as (ctx: unknown) => Promise<Response>)(
|
|
161
|
+
makeCtx('PUT', { webhooks: [] }, null),
|
|
162
|
+
)
|
|
163
|
+
expect(res.status).toBe(401)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('returns 403 for editor session', async () => {
|
|
167
|
+
const { PUT } = await import('../webhooks')
|
|
168
|
+
const res = await (PUT as (ctx: unknown) => Promise<Response>)(
|
|
169
|
+
makeCtx('PUT', { webhooks: [] }, EDITOR_SESSION),
|
|
170
|
+
)
|
|
171
|
+
expect(res.status).toBe(403)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('returns 403 with feature-locked at free tier', async () => {
|
|
175
|
+
vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
|
|
176
|
+
const { PUT } = await import('../webhooks')
|
|
177
|
+
const res = await (PUT as (ctx: unknown) => Promise<Response>)(
|
|
178
|
+
makeCtx('PUT', { webhooks: [] }),
|
|
179
|
+
)
|
|
180
|
+
expect(res.status).toBe(403)
|
|
181
|
+
const body = await res.json()
|
|
182
|
+
expect(body.code).toBe('feature-locked')
|
|
183
|
+
expect(body.feature).toBe('webhooks')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('returns 400 when webhooks is not an array', async () => {
|
|
187
|
+
const { PUT } = await import('../webhooks')
|
|
188
|
+
const res = await (PUT as (ctx: unknown) => Promise<Response>)(
|
|
189
|
+
makeCtx('PUT', { webhooks: 'nope' }),
|
|
190
|
+
)
|
|
191
|
+
expect(res.status).toBe(400)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('returns 400 for invalid webhook entry', async () => {
|
|
195
|
+
const { PUT } = await import('../webhooks')
|
|
196
|
+
const res = await (PUT as (ctx: unknown) => Promise<Response>)(
|
|
197
|
+
makeCtx('PUT', {
|
|
198
|
+
webhooks: [{ id: 'x', name: 'X', url: 'not-a-url', events: ['content.save'], enabled: true, createdAt: '2026-05-08T12:00:00Z' }],
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
expect(res.status).toBe(400)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('returns 400 for duplicate ids', async () => {
|
|
205
|
+
const valid = {
|
|
206
|
+
id: 'dup',
|
|
207
|
+
name: 'Dup',
|
|
208
|
+
url: 'https://example.com/hook',
|
|
209
|
+
events: ['content.save'],
|
|
210
|
+
enabled: true,
|
|
211
|
+
createdAt: '2026-05-08T12:00:00Z',
|
|
212
|
+
}
|
|
213
|
+
const { PUT } = await import('../webhooks')
|
|
214
|
+
const res = await (PUT as (ctx: unknown) => Promise<Response>)(
|
|
215
|
+
makeCtx('PUT', { webhooks: [valid, valid] }),
|
|
216
|
+
)
|
|
217
|
+
expect(res.status).toBe(400)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { checkFeature } from '@setzkasten-cms/core'
|
|
2
|
+
import { resolveLicenseTier } from './_license-tier'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Soft-fail feature-gate guard for API routes. Returns `null` when the
|
|
6
|
+
* feature is available at the current license tier; otherwise returns a
|
|
7
|
+
* 403 Response with a machine-readable JSON body.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
*
|
|
11
|
+
* const gate = gateFeature('editors')
|
|
12
|
+
* if (gate) return gate
|
|
13
|
+
* // ... normal route logic
|
|
14
|
+
*
|
|
15
|
+
* The body shape:
|
|
16
|
+
*
|
|
17
|
+
* { error: string, code: 'feature-locked', feature: string, requiredTier: string }
|
|
18
|
+
*
|
|
19
|
+
* Routes use 403 (not 402) because UI clients robustly handle 403 Forbidden
|
|
20
|
+
* but often surface 402 Payment Required as a generic error. The `code`
|
|
21
|
+
* field lets the UI hook (`useFeatureGate`) distinguish locked features
|
|
22
|
+
* from real authorization failures.
|
|
23
|
+
*/
|
|
24
|
+
export function gateFeature(feature: string): Response | null {
|
|
25
|
+
const tier = resolveLicenseTier()
|
|
26
|
+
const result = checkFeature(feature, tier)
|
|
27
|
+
if (result.ok) return null
|
|
28
|
+
|
|
29
|
+
return new Response(
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
error: result.reason,
|
|
32
|
+
code: 'feature-locked',
|
|
33
|
+
feature: result.feature,
|
|
34
|
+
requiredTier: result.requiredTier,
|
|
35
|
+
currentTier: tier,
|
|
36
|
+
}),
|
|
37
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { resolveRoleForUser, type UserRole, type AuthProviderKind } from '@setzkasten-cms/core'
|
|
2
|
+
import { resolveStorageConfig } from './_storage-config'
|
|
3
|
+
import { resolveConfigRepoToken } from './_github-token'
|
|
4
|
+
import { readEditorsFileStatus } from './editors'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds the role-resolver callback that auth-adapters call per OAuth
|
|
8
|
+
* callback. Reads the live `_editors.json` (case-insensitive lookup) and
|
|
9
|
+
* combines it with the configured `allowedEmails` env list. Callers pass
|
|
10
|
+
* the resolver into `createGitHubAuth` / `createGoogleAuth` /
|
|
11
|
+
* `verifyFirebaseJwt` so role assignment happens once, in one place.
|
|
12
|
+
*
|
|
13
|
+
* Returns `null` when the user is not allowed at all, otherwise the
|
|
14
|
+
* effective role.
|
|
15
|
+
*/
|
|
16
|
+
export function makeRoleResolver(
|
|
17
|
+
provider: AuthProviderKind,
|
|
18
|
+
allowedEmails: readonly string[] | undefined,
|
|
19
|
+
): (email: string) => Promise<UserRole | null> {
|
|
20
|
+
return async (email: string): Promise<UserRole | null> => {
|
|
21
|
+
const editors = await loadEditorsForResolution()
|
|
22
|
+
const result = resolveRoleForUser(email, provider, editors, allowedEmails)
|
|
23
|
+
return result.ok ? result.resolution.role : null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads `_editors.json` for role-resolution purposes.
|
|
29
|
+
*
|
|
30
|
+
* Returns `undefined` when the file is genuinely absent (treated as "no
|
|
31
|
+
* editors yet" — bootstrap path applies). Throws when the read errors out
|
|
32
|
+
* to fail-closed: an unreachable storage backend must never silently fall
|
|
33
|
+
* through to the bootstrap path and grant admin to everyone in
|
|
34
|
+
* `allowedEmails`.
|
|
35
|
+
*/
|
|
36
|
+
async function loadEditorsForResolution() {
|
|
37
|
+
const storage = resolveStorageConfig()
|
|
38
|
+
if (!storage) return undefined
|
|
39
|
+
|
|
40
|
+
const tokenResult = await resolveConfigRepoToken()
|
|
41
|
+
if (!tokenResult.ok) {
|
|
42
|
+
throw new Error(`role-resolver: token unavailable (${tokenResult.error.message})`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const serverConfig = (globalThis as {
|
|
46
|
+
__SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
|
|
47
|
+
}).__SETZKASTEN_CONFIG__
|
|
48
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
49
|
+
|
|
50
|
+
const status = await readEditorsFileStatus(
|
|
51
|
+
storage.owner,
|
|
52
|
+
storage.repo,
|
|
53
|
+
storage.branch,
|
|
54
|
+
contentPath,
|
|
55
|
+
tokenResult.value,
|
|
56
|
+
)
|
|
57
|
+
if (status.kind === 'absent') return undefined
|
|
58
|
+
if (status.kind === 'present') return status.editors
|
|
59
|
+
throw new Error(`role-resolver: ${status.message}`)
|
|
60
|
+
}
|
|
@@ -87,12 +87,25 @@ export async function resolveStorageConfigForRequest(
|
|
|
87
87
|
request: Request,
|
|
88
88
|
body?: { owner?: string; repo?: string; branch?: string },
|
|
89
89
|
): Promise<StorageConfig | null> {
|
|
90
|
+
// The build-time integration knows the monorepo layout (e.g. project
|
|
91
|
+
// prefix `apps/website/`). Carry that through so routes that touch
|
|
92
|
+
// source files — section templates for set:html upgrades, the migrator,
|
|
93
|
+
// etc. — resolve to the correct path. When the request targets a
|
|
94
|
+
// *different* repo than the build, the prefix doesn't apply, so we
|
|
95
|
+
// only inherit it for matching owner/repo.
|
|
96
|
+
const buildConfig = typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null
|
|
97
|
+
const inheritPrefix = (owner: string, repo: string): string => {
|
|
98
|
+
if (!buildConfig?.projectPrefix) return ''
|
|
99
|
+
if (buildConfig.owner === owner && buildConfig.repo === repo) return buildConfig.projectPrefix
|
|
100
|
+
return ''
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
if (body?.owner && body.repo) {
|
|
91
104
|
return {
|
|
92
105
|
owner: body.owner,
|
|
93
106
|
repo: body.repo,
|
|
94
107
|
branch: body.branch ?? 'main',
|
|
95
|
-
projectPrefix:
|
|
108
|
+
projectPrefix: inheritPrefix(body.owner, body.repo),
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
111
|
|
|
@@ -105,7 +118,7 @@ export async function resolveStorageConfigForRequest(
|
|
|
105
118
|
owner,
|
|
106
119
|
repo,
|
|
107
120
|
branch: resolved.value.branch,
|
|
108
|
-
projectPrefix:
|
|
121
|
+
projectPrefix: inheritPrefix(owner, repo),
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseWebhooksFile,
|
|
3
|
+
selectWebhooksForEvent,
|
|
4
|
+
type WebhookConfig,
|
|
5
|
+
type WebhookEvent,
|
|
6
|
+
type WebhookPayload,
|
|
7
|
+
} from '@setzkasten-cms/core'
|
|
8
|
+
import { resolveStorageConfigForRequest } from './_storage-config'
|
|
9
|
+
import { resolveGitHubTokenForRequest } from './_github-token'
|
|
10
|
+
import { cachedFetch } from './_github-cache'
|
|
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 DISPATCH_TIMEOUT_MS = 5_000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fire all enabled webhooks subscribed to `event`. Best-effort —
|
|
19
|
+
* each request runs with a 5s timeout; failures are recorded in the
|
|
20
|
+
* in-memory status store but do not throw or block the caller.
|
|
21
|
+
*
|
|
22
|
+
* Caller-side: invoke as `void fireWebhooks(...)` to make the
|
|
23
|
+
* fire-and-forget intent explicit.
|
|
24
|
+
*/
|
|
25
|
+
export async function fireWebhooks(
|
|
26
|
+
event: WebhookEvent,
|
|
27
|
+
payload: Omit<WebhookPayload, 'event' | 'timestamp'>,
|
|
28
|
+
request: Request,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
const webhooks = await loadWebhooksForRequest(request)
|
|
32
|
+
if (!webhooks || webhooks.length === 0) return
|
|
33
|
+
|
|
34
|
+
const targets = selectWebhooksForEvent(webhooks, event)
|
|
35
|
+
if (targets.length === 0) return
|
|
36
|
+
|
|
37
|
+
const fullPayload: WebhookPayload = {
|
|
38
|
+
event,
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
...payload,
|
|
41
|
+
}
|
|
42
|
+
const body = JSON.stringify(fullPayload)
|
|
43
|
+
|
|
44
|
+
await Promise.all(targets.map((w) => fireOne(w, event, body)))
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Dispatcher failures must not break the save flow.
|
|
47
|
+
console.error('[setzkasten] webhook dispatch failed:', err)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fireOne(
|
|
52
|
+
webhook: WebhookConfig,
|
|
53
|
+
event: WebhookEvent,
|
|
54
|
+
body: string,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const headers: Record<string, string> = {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'X-Setzkasten-Event': event,
|
|
59
|
+
'X-Setzkasten-Delivery': crypto.randomUUID(),
|
|
60
|
+
}
|
|
61
|
+
if (webhook.secret) {
|
|
62
|
+
headers['X-Setzkasten-Signature'] = `sha256=${signPayload(body, webhook.secret)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(webhook.url, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers,
|
|
69
|
+
body,
|
|
70
|
+
signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS),
|
|
71
|
+
})
|
|
72
|
+
recordWebhookFire(webhook.id, res.status)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
recordWebhookFire(webhook.id, 'error')
|
|
75
|
+
console.warn(`[setzkasten] webhook "${webhook.id}" failed:`, err)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function loadWebhooksForRequest(
|
|
80
|
+
request: Request,
|
|
81
|
+
): Promise<readonly WebhookConfig[] | null> {
|
|
82
|
+
const tokenResult = await resolveGitHubTokenForRequest(request)
|
|
83
|
+
if (!tokenResult.ok) return null
|
|
84
|
+
|
|
85
|
+
const storage = await resolveStorageConfigForRequest(request)
|
|
86
|
+
if (!storage) return null
|
|
87
|
+
|
|
88
|
+
const serverConfig = (globalThis as {
|
|
89
|
+
__SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
|
|
90
|
+
}).__SETZKASTEN_CONFIG__
|
|
91
|
+
const contentPath = serverConfig?.storage?.contentPath ?? 'content'
|
|
92
|
+
const { owner, repo, branch } = storage
|
|
93
|
+
|
|
94
|
+
const cacheKey = `webhooks:${owner}/${repo}:${branch}`
|
|
95
|
+
return cachedFetch(cacheKey, 60_000, async () => {
|
|
96
|
+
const res = await fetch(
|
|
97
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${WEBHOOKS_FILE(contentPath)}?ref=${branch}`,
|
|
98
|
+
{
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `Bearer ${tokenResult.value}`,
|
|
101
|
+
Accept: 'application/vnd.github+json',
|
|
102
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
if (res.status === 404) return [] as readonly WebhookConfig[]
|
|
107
|
+
if (!res.ok) return null
|
|
108
|
+
const data = (await res.json()) as { content: string; encoding: string }
|
|
109
|
+
const raw =
|
|
110
|
+
data.encoding === 'base64'
|
|
111
|
+
? Buffer.from(data.content, 'base64').toString('utf-8')
|
|
112
|
+
: data.content
|
|
113
|
+
const parsed = parseWebhooksFile(raw)
|
|
114
|
+
if (!parsed.ok) {
|
|
115
|
+
console.warn('[setzkasten] _webhooks.json parse error:', parsed.error.message)
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
return parsed.value.webhooks
|
|
119
|
+
})
|
|
120
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HMAC-SHA256 signature of the webhook payload body, hex-encoded.
|
|
5
|
+
* Receivers verify with the same secret and the **raw** request body —
|
|
6
|
+
* pattern is identical to GitHub webhooks.
|
|
7
|
+
*
|
|
8
|
+
* Lives in astro-admin (not core) because it imports node:crypto.
|
|
9
|
+
* core's "zero external deps" rule keeps it edge/browser-runnable.
|
|
10
|
+
*/
|
|
11
|
+
export function signPayload(body: string, secret: string): string {
|
|
12
|
+
return createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory webhook status — last fired timestamp + last HTTP status per
|
|
3
|
+
* webhook id. Survives only the server process; cold-start losses are
|
|
4
|
+
* acceptable because this is a status display, not the source of truth.
|
|
5
|
+
*
|
|
6
|
+
* Persisting to `_webhooks.json` would create a commit-storm
|
|
7
|
+
* (every save → webhook fire → webhook commit → trigger save → …). The
|
|
8
|
+
* UI just refetches `/webhooks/status` after a test-fire or save.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface WebhookStatusEntry {
|
|
12
|
+
readonly lastFiredAt: string
|
|
13
|
+
readonly lastStatus: number | 'error'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const store = new Map<string, WebhookStatusEntry>()
|
|
17
|
+
|
|
18
|
+
export function recordWebhookFire(id: string, status: number | 'error'): void {
|
|
19
|
+
store.set(id, { lastFiredAt: new Date().toISOString(), lastStatus: status })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getWebhookStatus(): Record<string, WebhookStatusEntry> {
|
|
23
|
+
const out: Record<string, WebhookStatusEntry> = {}
|
|
24
|
+
for (const [k, v] of store.entries()) out[k] = v
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Test-only — clears the in-memory map. */
|
|
29
|
+
export function _resetWebhookStatusForTests(): void {
|
|
30
|
+
store.clear()
|
|
31
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro'
|
|
2
2
|
import { createGitHubAuth } from '@setzkasten-cms/auth'
|
|
3
3
|
import { sessionCookieOptions } from './_session-cookie.js'
|
|
4
|
+
import { makeRoleResolver } from './_role-resolver'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* GitHub OAuth callback handler.
|
|
@@ -50,6 +51,7 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
|
|
|
50
51
|
clientSecret: import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? '',
|
|
51
52
|
redirectUri,
|
|
52
53
|
allowedEmails,
|
|
54
|
+
resolveRole: makeRoleResolver('github', allowedEmails),
|
|
53
55
|
})
|
|
54
56
|
|
|
55
57
|
try {
|
|
@@ -5,6 +5,8 @@ import { readGlobalConfig } from './global-config'
|
|
|
5
5
|
import { resolveStorageConfig } from './_storage-config'
|
|
6
6
|
import { resolveConfigRepoToken } from './_github-token'
|
|
7
7
|
import { sessionCookieOptions } from './_session-cookie.js'
|
|
8
|
+
import { makeRoleResolver } from './_role-resolver'
|
|
9
|
+
import { gateFeature } from './_feature-gate'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* POST /api/setzkasten/auth/setzkasten-login
|
|
@@ -20,6 +22,12 @@ import { sessionCookieOptions } from './_session-cookie.js'
|
|
|
20
22
|
* website selection.
|
|
21
23
|
*/
|
|
22
24
|
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
25
|
+
// Feature-gate: Setzkasten-Login is the Firebase-based path that lets
|
|
26
|
+
// editors who don't have a GitHub account log in via Google. That entire
|
|
27
|
+
// flow is gated behind Pro/Enterprise.
|
|
28
|
+
const gate = gateFeature('google-auth')
|
|
29
|
+
if (gate) return gate
|
|
30
|
+
|
|
23
31
|
const body = await request.json().catch(() => null)
|
|
24
32
|
const idToken = body?.idToken as string | undefined
|
|
25
33
|
|
|
@@ -54,7 +62,14 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
const allowedEmails = editors.map((e) => e.email)
|
|
57
|
-
|
|
65
|
+
// Setzkasten-Login uses Firebase as the identity backend but logically
|
|
66
|
+
// grants the same role-resolution semantics as Google OAuth — both give
|
|
67
|
+
// an editors-file-listed user the role from that file.
|
|
68
|
+
const result = await verifyFirebaseJwt(
|
|
69
|
+
idToken,
|
|
70
|
+
allowedEmails,
|
|
71
|
+
makeRoleResolver('google', allowedEmails),
|
|
72
|
+
)
|
|
58
73
|
|
|
59
74
|
if (!result.ok) {
|
|
60
75
|
return new Response(result.error.message, { status: 403 })
|
|
@@ -3,8 +3,10 @@ import { resolveStorageConfig } from './_storage-config'
|
|
|
3
3
|
import { parseSession } from './_auth-guard'
|
|
4
4
|
import { resolveConfigRepoToken } from './_github-token'
|
|
5
5
|
import type { ContentEditorConfig } from '@setzkasten-cms/core'
|
|
6
|
+
import { validateEditorsUpdate } from '@setzkasten-cms/core'
|
|
6
7
|
import { cachedFetch, invalidateCache } from './_github-cache'
|
|
7
8
|
import { withTrailers } from './_commit-trailers'
|
|
9
|
+
import { gateFeature } from './_feature-gate'
|
|
8
10
|
|
|
9
11
|
const EDITORS_FILE = (contentPath: string) => `${contentPath}/_editors.json`
|
|
10
12
|
|
|
@@ -54,6 +56,11 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
|
54
56
|
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
55
57
|
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
|
|
56
58
|
|
|
59
|
+
// Feature-gate: editor management is a Pro feature. Free-tier admins
|
|
60
|
+
// can read the editors list (GET) but not write it.
|
|
61
|
+
const gate = gateFeature('editors')
|
|
62
|
+
if (gate) return gate
|
|
63
|
+
|
|
57
64
|
const tokenResult = await resolveConfigRepoToken()
|
|
58
65
|
if (!tokenResult.ok) return new Response('GitHub token not configured', { status: 500 })
|
|
59
66
|
|
|
@@ -73,6 +80,14 @@ export const PUT: APIRoute = async ({ request, cookies }) => {
|
|
|
73
80
|
return Response.json({ error: 'Invalid request body' }, { status: 400 })
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
const validation = validateEditorsUpdate(editors, session.user.email)
|
|
84
|
+
if (!validation.ok) {
|
|
85
|
+
return Response.json(
|
|
86
|
+
{ error: validation.message, code: validation.code },
|
|
87
|
+
{ status: 400 },
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
const filePath = EDITORS_FILE(contentPath)
|
|
77
92
|
const fileContent = JSON.stringify(editors, null, 2)
|
|
78
93
|
const headers = {
|