@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.
@@ -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
- const result = await verifyFirebaseJwt(idToken, allowedEmails)
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 = {