@setzkasten-cms/astro-admin 1.4.2 → 1.5.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 (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +150 -48
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +218 -88
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +126 -0
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +102 -16
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +490 -56
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { type Result, type WebsiteEntry, err, ok, validationError } from '@setzkasten-cms/core'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { __resetWebsiteResolverForTests } from '../_website-resolver'
8
+
9
+ const ENTRY_A: WebsiteEntry = {
10
+ id: 'site-a',
11
+ name: 'Site A',
12
+ repo: 'acme/site-a',
13
+ branch: 'main',
14
+ previewOrigin: 'https://a.example.com',
15
+ githubApp: { appId: '1', installationId: '101' },
16
+ }
17
+
18
+ const ENTRY_B: WebsiteEntry = {
19
+ id: 'site-b',
20
+ name: 'Site B',
21
+ repo: 'acme/site-b',
22
+ branch: 'develop',
23
+ previewOrigin: 'https://b.example.com',
24
+ githubApp: { appId: '1', installationId: '202' },
25
+ }
26
+
27
+ const SESSION = JSON.stringify({
28
+ user: {
29
+ id: 'u1',
30
+ email: 'admin@example.com',
31
+ role: 'admin',
32
+ provider: 'github',
33
+ },
34
+ expiresAt: Date.now() + 60 * 60 * 1000,
35
+ })
36
+
37
+ function makeCtx(body: unknown = undefined, sessionValue: string | null = SESSION) {
38
+ const init: RequestInit = { method: 'POST' }
39
+ if (body !== undefined) {
40
+ init.body = JSON.stringify(body)
41
+ init.headers = { 'content-type': 'application/json' }
42
+ }
43
+ const request = new Request('https://cms.example.com/api/setzkasten/updater/register', init)
44
+ return {
45
+ request,
46
+ cookies: {
47
+ get: vi.fn((name: string) =>
48
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
49
+ ),
50
+ },
51
+ }
52
+ }
53
+
54
+ interface MockRegistry {
55
+ list: ReturnType<typeof vi.fn>
56
+ get: ReturnType<typeof vi.fn>
57
+ }
58
+
59
+ function makeRegistry(entries: readonly WebsiteEntry[]): MockRegistry {
60
+ return {
61
+ list: vi.fn(async (): Promise<Result<readonly WebsiteEntry[]>> => ok(entries)),
62
+ get: vi.fn(async (id: string): Promise<Result<WebsiteEntry | null>> => {
63
+ return ok(entries.find((e) => e.id === id) ?? null)
64
+ }),
65
+ }
66
+ }
67
+
68
+ function setBuildConfig(updaterUrl: string) {
69
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ = {
70
+ updaterUrl,
71
+ version: '1.2.3',
72
+ websiteUrl: 'https://cms.example.com',
73
+ storage: { owner: 'acme', repo: 'cms-config' },
74
+ }
75
+ }
76
+
77
+ beforeEach(() => {
78
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ = undefined
79
+ __resetWebsiteResolverForTests(null)
80
+ })
81
+
82
+ afterEach(() => {
83
+ vi.restoreAllMocks()
84
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ = undefined
85
+ __resetWebsiteResolverForTests(null)
86
+ })
87
+
88
+ describe('POST /api/setzkasten/updater/register – managedWebsites payload', () => {
89
+ it('sends the full list of website ids from the multi-mode registry', async () => {
90
+ setBuildConfig('https://updater.example.com')
91
+ __resetWebsiteResolverForTests({
92
+ mode: 'multi',
93
+ registry: makeRegistry([ENTRY_A, ENTRY_B]),
94
+ })
95
+
96
+ let captured: { managedWebsites?: string[] } | null = null
97
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
98
+ captured = JSON.parse(String(init?.body)) as { managedWebsites?: string[] }
99
+ return {
100
+ ok: true,
101
+ json: async () => ({ instanceId: 'abc', latestVersion: '1.2.3' }),
102
+ } as Response
103
+ })
104
+ vi.stubGlobal('fetch', fetchMock)
105
+
106
+ const { POST } = await import('../updater-register')
107
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx())
108
+
109
+ expect(res.status).toBe(200)
110
+ expect(captured).not.toBeNull()
111
+ expect(captured!.managedWebsites).toEqual(['site-a', 'site-b'])
112
+ })
113
+
114
+ it('sends the synthesized id in single-repo mode', async () => {
115
+ setBuildConfig('https://updater.example.com')
116
+ __resetWebsiteResolverForTests({
117
+ mode: 'single',
118
+ synthesized: {
119
+ id: 'default',
120
+ name: 'cms',
121
+ repo: 'acme/cms',
122
+ branch: 'main',
123
+ previewOrigin: 'https://cms.example.com',
124
+ githubApp: { appId: '1', installationId: '1' },
125
+ },
126
+ })
127
+
128
+ let captured: { managedWebsites?: string[] } | null = null
129
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
130
+ captured = JSON.parse(String(init?.body)) as { managedWebsites?: string[] }
131
+ return {
132
+ ok: true,
133
+ json: async () => ({ instanceId: 'abc', latestVersion: '1.2.3' }),
134
+ } as Response
135
+ })
136
+ vi.stubGlobal('fetch', fetchMock)
137
+
138
+ const { POST } = await import('../updater-register')
139
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx())
140
+
141
+ expect(res.status).toBe(200)
142
+ expect(captured!.managedWebsites).toEqual(['default'])
143
+ })
144
+
145
+ it('falls back to an empty array when the resolver is not configured', async () => {
146
+ setBuildConfig('https://updater.example.com')
147
+ // resolver intentionally left null — listAllWebsites() will return a validation error
148
+ __resetWebsiteResolverForTests(null)
149
+ // Block bootstrap from succeeding via globals.
150
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
151
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ = undefined
152
+
153
+ let captured: { managedWebsites?: string[] } | null = null
154
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
155
+ captured = JSON.parse(String(init?.body)) as { managedWebsites?: string[] }
156
+ return {
157
+ ok: true,
158
+ json: async () => ({ instanceId: 'abc', latestVersion: '1.2.3' }),
159
+ } as Response
160
+ })
161
+ vi.stubGlobal('fetch', fetchMock)
162
+
163
+ const { POST } = await import('../updater-register')
164
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx())
165
+
166
+ expect(res.status).toBe(200)
167
+ expect(captured!.managedWebsites).toEqual([])
168
+ })
169
+
170
+ it('reflects newly added websites on the next register call', async () => {
171
+ setBuildConfig('https://updater.example.com')
172
+ const initial = [ENTRY_A]
173
+ const after = [ENTRY_A, ENTRY_B]
174
+
175
+ const captured: Array<{ managedWebsites?: string[] }> = []
176
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
177
+ captured.push(JSON.parse(String(init?.body)) as { managedWebsites?: string[] })
178
+ return {
179
+ ok: true,
180
+ json: async () => ({ instanceId: 'abc', latestVersion: '1.2.3' }),
181
+ } as Response
182
+ })
183
+ vi.stubGlobal('fetch', fetchMock)
184
+
185
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry(initial) })
186
+ const { POST } = await import('../updater-register')
187
+ await (POST as (ctx: unknown) => Promise<Response>)(makeCtx())
188
+
189
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry(after) })
190
+ await (POST as (ctx: unknown) => Promise<Response>)(makeCtx())
191
+
192
+ expect(captured[0]!.managedWebsites).toEqual(['site-a'])
193
+ expect(captured[1]!.managedWebsites).toEqual(['site-a', 'site-b'])
194
+ })
195
+
196
+ it('returns 401 without a session', async () => {
197
+ setBuildConfig('https://updater.example.com')
198
+ const { POST } = await import('../updater-register')
199
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx(undefined, null))
200
+
201
+ expect(res.status).toBe(401)
202
+ })
203
+
204
+ it('survives a registry list error: heartbeat still fires with an empty array', async () => {
205
+ setBuildConfig('https://updater.example.com')
206
+ __resetWebsiteResolverForTests({
207
+ mode: 'multi',
208
+ registry: {
209
+ list: vi.fn(async () => err(validationError(['x'], 'boom', 'registry unreachable'))),
210
+ get: vi.fn(),
211
+ } as unknown as MockRegistry,
212
+ })
213
+
214
+ let captured: { managedWebsites?: string[] } | null = null
215
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
216
+ captured = JSON.parse(String(init?.body)) as { managedWebsites?: string[] }
217
+ return {
218
+ ok: true,
219
+ json: async () => ({ instanceId: 'abc', latestVersion: '1.2.3' }),
220
+ } as Response
221
+ })
222
+ vi.stubGlobal('fetch', fetchMock)
223
+
224
+ const { POST } = await import('../updater-register')
225
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx())
226
+
227
+ expect(res.status).toBe(200)
228
+ expect(captured!.managedWebsites).toEqual([])
229
+ })
230
+ })
@@ -2,7 +2,7 @@
2
2
  * @vitest-environment node
3
3
  */
4
4
 
5
- import { describe, it, expect } from 'vitest'
5
+ import { describe, expect, it } from 'vitest'
6
6
  import { signPayload } from '../_webhook-signing'
7
7
 
8
8
  describe('signPayload', () => {
@@ -4,21 +4,26 @@
4
4
 
5
5
  import { generateKeyPairSync } from 'node:crypto'
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { makeTestSessionCookie } from './_session-test-helper'
7
8
 
8
9
  const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
9
10
  const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
10
11
 
11
- const ADMIN_SESSION = JSON.stringify({
12
+ const ADMIN_SESSION = makeTestSessionCookie({
12
13
  user: { id: 'u1', email: 'admin@example.com', role: 'admin', provider: 'github' },
13
14
  expiresAt: Date.now() + 60 * 60 * 1000,
14
15
  })
15
16
 
16
- const EDITOR_SESSION = JSON.stringify({
17
+ const EDITOR_SESSION = makeTestSessionCookie({
17
18
  user: { id: 'u2', email: 'editor@example.com', role: 'editor', provider: 'google' },
18
19
  expiresAt: Date.now() + 60 * 60 * 1000,
19
20
  })
20
21
 
21
- function makeCtx(method: 'GET' | 'PUT', body?: unknown, sessionValue: string | null = ADMIN_SESSION) {
22
+ function makeCtx(
23
+ method: 'GET' | 'PUT',
24
+ body?: unknown,
25
+ sessionValue: string | null = ADMIN_SESSION,
26
+ ) {
22
27
  const url = new URL('https://cms.example.com/api/setzkasten/webhooks')
23
28
  const init: RequestInit = { method }
24
29
  if (body !== undefined) {
@@ -174,9 +179,7 @@ describe('PUT /api/setzkasten/webhooks', () => {
174
179
  it('returns 403 with feature-locked at free tier', async () => {
175
180
  vi.stubEnv('SETZKASTEN_LICENSE_KEY', '')
176
181
  const { PUT } = await import('../webhooks')
177
- const res = await (PUT as (ctx: unknown) => Promise<Response>)(
178
- makeCtx('PUT', { webhooks: [] }),
179
- )
182
+ const res = await (PUT as (ctx: unknown) => Promise<Response>)(makeCtx('PUT', { webhooks: [] }))
180
183
  expect(res.status).toBe(403)
181
184
  const body = await res.json()
182
185
  expect(body.code).toBe('feature-locked')
@@ -195,7 +198,16 @@ describe('PUT /api/setzkasten/webhooks', () => {
195
198
  const { PUT } = await import('../webhooks')
196
199
  const res = await (PUT as (ctx: unknown) => Promise<Response>)(
197
200
  makeCtx('PUT', {
198
- webhooks: [{ id: 'x', name: 'X', url: 'not-a-url', events: ['content.save'], enabled: true, createdAt: '2026-05-08T12:00:00Z' }],
201
+ webhooks: [
202
+ {
203
+ id: 'x',
204
+ name: 'X',
205
+ url: 'not-a-url',
206
+ events: ['content.save'],
207
+ enabled: true,
208
+ createdAt: '2026-05-08T12:00:00Z',
209
+ },
210
+ ],
199
211
  }),
200
212
  )
201
213
  expect(res.status).toBe(400)
@@ -5,6 +5,7 @@
5
5
  import { generateKeyPairSync } from 'node:crypto'
6
6
  import type { WebsiteEntry } from '@setzkasten-cms/core'
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import { makeTestSessionCookie } from './_session-test-helper'
8
9
 
9
10
  const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
10
11
  const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
@@ -33,7 +34,7 @@ const EXISTING_REGISTRY = {
33
34
 
34
35
  // Admin sessions only — websites-add is admin-gated. Tests that exercise
35
36
  // the unauthorized branch pass `null` to drop the cookie entirely.
36
- const ADMIN_SESSION = JSON.stringify({
37
+ const ADMIN_SESSION = makeTestSessionCookie({
37
38
  user: {
38
39
  id: 'u1',
39
40
  email: 'admin@example.com',
@@ -5,6 +5,7 @@
5
5
  import { generateKeyPairSync } from 'node:crypto'
6
6
  import type { WebsiteEntry } from '@setzkasten-cms/core'
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import { makeTestSessionCookie } from './_session-test-helper'
8
9
 
9
10
  const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
10
11
  const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
@@ -31,7 +32,7 @@ const REGISTRY = {
31
32
  }
32
33
 
33
34
  // Admin sessions only — websites-remove is admin-gated.
34
- const ADMIN_SESSION = JSON.stringify({
35
+ const ADMIN_SESSION = makeTestSessionCookie({
35
36
  user: {
36
37
  id: 'u1',
37
38
  email: 'admin@example.com',
@@ -1,14 +1,28 @@
1
1
  import { canEditPage } from '@setzkasten-cms/auth'
2
2
  import type { AuthSession, ContentEditorConfig, SetzKastenConfig } from '@setzkasten-cms/core'
3
- import { resolveStorageConfig } from './_storage-config'
4
3
  import { resolveConfigRepoToken } from './_github-token'
4
+ import { resolveSessionSecret } from './_session-secret'
5
+ import { verifySessionCookie } from './_session-signing'
6
+ import { resolveStorageConfig } from './_storage-config'
5
7
  import { readEditorsFileStatus } from './editors'
6
8
 
9
+ /**
10
+ * Returns the verified session iff the cookie's HMAC signature matches
11
+ * the server secret AND `expiresAt` is in the future. Pre-C1 this used
12
+ * to do `JSON.parse` on whatever the client sent — any unauthenticated
13
+ * attacker could forge an admin session in 30 seconds.
14
+ *
15
+ * Single source of truth: every guard (`requireAdmin`, `guardPageAccess`)
16
+ * runs through this. No code path reads the cookie directly anymore.
17
+ */
7
18
  export function parseSession(raw: string | undefined): AuthSession | null {
8
19
  if (!raw) return null
9
20
  try {
10
- return JSON.parse(raw) as AuthSession
21
+ const result = verifySessionCookie(raw, resolveSessionSecret())
22
+ return result.ok ? result.value : null
11
23
  } catch {
24
+ // resolveSessionSecret throws in production when misconfigured —
25
+ // fail closed so a missing secret can't be exploited as "no auth".
12
26
  return null
13
27
  }
14
28
  }
@@ -70,10 +84,9 @@ export async function guardPageAccess(
70
84
  // Layer 1: global editors file
71
85
  const editorsLookup = await resolveDynamicEditors()
72
86
  if (!editorsLookup.ok) {
73
- return new Response(
74
- `Forbidden: editor permissions unavailable (${editorsLookup.error})`,
75
- { status: 503 },
76
- )
87
+ return new Response(`Forbidden: editor permissions unavailable (${editorsLookup.error})`, {
88
+ status: 503,
89
+ })
77
90
  }
78
91
  if (!canEditPage(session, pageKey, editorsLookup.editors)) {
79
92
  return new Response('Forbidden: you do not have access to this page', { status: 403 })
@@ -97,9 +110,24 @@ export async function guardPageAccess(
97
110
  * X-SK-Website header resolves to. Admins always pass; single-mode
98
111
  * skips this check entirely (no website context).
99
112
  *
100
- * Returns 403 on deny, null on allow. Resolver failures are
101
- * considered "no per-website restriction" the global guard above
102
- * already covers fail-closed for editors-file errors.
113
+ * Default-deny semantics (post-C5): in multi-mode, an editor with no
114
+ * explicit `allowedEmails` entry on the target website is denied.
115
+ *
116
+ * Pre-fix: an editor on website A could send `X-SK-Website: B` and
117
+ * inherit access transitively from the global editors file, because
118
+ * "no allowedEmails on B" was treated as "no restriction". That made
119
+ * the global file the only effective gate — an editor for project A
120
+ * could mutate project B by spoofing one HTTP header.
121
+ *
122
+ * Now: empty / missing `allowedEmails` means "no editors allowed on
123
+ * this website" — only admins pass. Set `allowedEmails: [editor@…]`
124
+ * explicitly to grant access. Existing single-website-per-editor
125
+ * setups need to add the email to the website entry; the migration
126
+ * note in `docs/migration-single-to-multi.md` documents this.
127
+ *
128
+ * Returns 403 on deny, null on allow. Resolver failures (no website
129
+ * context) are still treated as "no check applies" so single-mode
130
+ * routes work unchanged.
103
131
  */
104
132
  export async function guardWebsiteAccess(
105
133
  session: AuthSession,
@@ -112,14 +140,17 @@ export async function guardWebsiteAccess(
112
140
  if (!website.ok) return null
113
141
 
114
142
  const allowed = website.value.allowedEmails
115
- if (!allowed || allowed.length === 0) return null
116
-
117
- if (!allowed.includes(session.user.email)) {
143
+ if (!allowed || allowed.length === 0) {
118
144
  return new Response(
119
- `Forbidden: not allowed on website "${website.value.id}"`,
145
+ `Forbidden: website "${website.value.id}" has no editor allow-list — ` +
146
+ `add the editor's email to allowedEmails or grant admin role`,
120
147
  { status: 403 },
121
148
  )
122
149
  }
150
+
151
+ if (!allowed.includes(session.user.email)) {
152
+ return new Response(`Forbidden: not allowed on website "${website.value.id}"`, { status: 403 })
153
+ }
123
154
  return null
124
155
  }
125
156
 
@@ -136,8 +167,9 @@ async function resolveDynamicEditors(): Promise<EditorsLookup> {
136
167
  return { ok: false, error: `token: ${tokenResult.error.message}` }
137
168
  }
138
169
 
139
- const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
140
- .__SETZKASTEN_CONFIG__
170
+ const serverConfig = (
171
+ globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } }
172
+ ).__SETZKASTEN_CONFIG__
141
173
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
142
174
 
143
175
  const status = await readEditorsFileStatus(
@@ -7,9 +7,10 @@ export const SETZKASTEN_CO_AUTHOR = 'Co-authored-by: Setzkasten <setzkasten@setz
7
7
  export function withTrailers(message: string, editorEmail?: string | null): string {
8
8
  const trailers = [SETZKASTEN_CO_AUTHOR]
9
9
  if (editorEmail) {
10
- const name = editorEmail.split('@')[0]!
10
+ const name = editorEmail
11
+ .split('@')[0]!
11
12
  .replace(/[._-]+/g, ' ')
12
- .replace(/\b\w/g, c => c.toUpperCase())
13
+ .replace(/\b\w/g, (c) => c.toUpperCase())
13
14
  trailers.push(`Co-authored-by: ${name} <${editorEmail}>`)
14
15
  }
15
16
  return `${message}\n\n${trailers.join('\n')}`
@@ -0,0 +1,79 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Ensures a per-machine dev session secret exists and returns its value.
7
+ *
8
+ * Pre-fix: a hardcoded fallback string was used when no env var was set
9
+ * — anyone with the source could mint admin cookies against any
10
+ * deployment that forgot to set NODE_ENV=production. The string was
11
+ * literally checked into the repo.
12
+ *
13
+ * Now: on first start without `SETZKASTEN_SESSION_SECRET`, a 32-byte
14
+ * random secret is generated and persisted to `.setzkasten/dev-secret`
15
+ * (relative to cwd, gitignored). Subsequent starts read the same file.
16
+ *
17
+ * - Per-machine, never shared across developers.
18
+ * - File mode 0600 so other users on the box can't read it.
19
+ * - Gitignored — the secret never leaves the developer's machine.
20
+ * - Rotation is `rm -rf .setzkasten` followed by re-login.
21
+ *
22
+ * Production never reaches this code path: the resolver fails closed
23
+ * with a clear error when the env var is missing under NODE_ENV=production.
24
+ */
25
+
26
+ const DEV_SECRET_DIR = '.setzkasten'
27
+ const DEV_SECRET_FILE = 'dev-secret'
28
+
29
+ let cachedSecret: string | null = null
30
+
31
+ export function ensureDevSessionSecret(): string {
32
+ if (cachedSecret) return cachedSecret
33
+
34
+ const dir = join(process.cwd(), DEV_SECRET_DIR)
35
+ const file = join(dir, DEV_SECRET_FILE)
36
+
37
+ if (existsSync(file)) {
38
+ const content = readFileSync(file, 'utf-8').trim()
39
+ if (content.length >= 32) {
40
+ cachedSecret = content
41
+ return content
42
+ }
43
+ // Truncated / corrupted — regenerate below.
44
+ }
45
+
46
+ mkdirSync(dir, { recursive: true })
47
+ const fresh = randomBytes(32).toString('hex')
48
+
49
+ try {
50
+ writeFileSync(file, `${fresh}\n`, { flag: 'wx', mode: 0o600 })
51
+ } catch (cause) {
52
+ // Race with another worker that wrote first; re-read.
53
+ if ((cause as NodeJS.ErrnoException)?.code === 'EEXIST') {
54
+ const content = readFileSync(file, 'utf-8').trim()
55
+ if (content.length >= 32) {
56
+ cachedSecret = content
57
+ return content
58
+ }
59
+ }
60
+ throw cause
61
+ }
62
+
63
+ // eslint-disable-next-line no-console
64
+ console.warn(
65
+ `[setzkasten] Generated dev session secret at ${file} (mode 0600). ` +
66
+ 'Set SETZKASTEN_SESSION_SECRET in production. Delete the file to rotate.',
67
+ )
68
+
69
+ cachedSecret = fresh
70
+ return fresh
71
+ }
72
+
73
+ /**
74
+ * Test-only escape hatch: lets a test helper set the secret without
75
+ * touching the filesystem. Production code never calls this.
76
+ */
77
+ export function __setDevSessionSecretForTests(secret: string | null): void {
78
+ cachedSecret = secret
79
+ }
@@ -1,4 +1,4 @@
1
- import { err, authError, type Result } from '@setzkasten-cms/core'
1
+ import { type Result, authError, err } from '@setzkasten-cms/core'
2
2
  import { GitHubAppClient } from '@setzkasten-cms/github-adapter'
3
3
 
4
4
  /**
@@ -1,12 +1,12 @@
1
1
  import {
2
+ type PagesMeta,
3
+ type Result,
2
4
  emptyPagesMeta,
3
5
  err,
4
6
  networkError,
5
7
  ok,
6
8
  parsePagesMeta,
7
9
  setPageLastModified,
8
- type PagesMeta,
9
- type Result,
10
10
  } from '@setzkasten-cms/core'
11
11
  import { withTrailers } from './_commit-trailers'
12
12
 
@@ -1,6 +1,6 @@
1
- import { resolveRoleForUser, type UserRole, type AuthProviderKind } from '@setzkasten-cms/core'
2
- import { resolveStorageConfig } from './_storage-config'
1
+ import { type AuthProviderKind, type UserRole, resolveRoleForUser } from '@setzkasten-cms/core'
3
2
  import { resolveConfigRepoToken } from './_github-token'
3
+ import { resolveStorageConfig } from './_storage-config'
4
4
  import { readEditorsFileStatus } from './editors'
5
5
 
6
6
  /**
@@ -42,9 +42,11 @@ async function loadEditorsForResolution() {
42
42
  throw new Error(`role-resolver: token unavailable (${tokenResult.error.message})`)
43
43
  }
44
44
 
45
- const serverConfig = (globalThis as {
46
- __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
47
- }).__SETZKASTEN_CONFIG__
45
+ const serverConfig = (
46
+ globalThis as {
47
+ __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
48
+ }
49
+ ).__SETZKASTEN_CONFIG__
48
50
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
49
51
 
50
52
  const status = await readEditorsFileStatus(
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Resolves the HMAC secret used to sign session cookies.
3
+ *
4
+ * Priority:
5
+ * 1. `SETZKASTEN_SESSION_SECRET` env var (production requirement)
6
+ * 2. `__SETZKASTEN_BUILD_CONFIG__.sessionSecret` (build-time injection)
7
+ * 3. **Dev-only:** persistent per-machine random secret in
8
+ * `.setzkasten/dev-secret` (gitignored). Generated on first run,
9
+ * reused thereafter. Never the same string across machines, never
10
+ * checked into the repo.
11
+ *
12
+ * Pre-C1 there was no signing at all. Pre-this-fix the dev fallback was
13
+ * a hardcoded string in source — anyone reading the code could mint
14
+ * admin cookies against any deployment that accidentally ran without
15
+ * NODE_ENV=production. The fallback is now random per-machine.
16
+ */
17
+
18
+ import { ensureDevSessionSecret } from './_dev-session-secret'
19
+
20
+ const MIN_SECRET_LENGTH = 32
21
+
22
+ export function resolveSessionSecret(): string {
23
+ const fromEnv = process.env.SETZKASTEN_SESSION_SECRET
24
+ if (typeof fromEnv === 'string' && fromEnv.trim().length >= MIN_SECRET_LENGTH) {
25
+ return fromEnv.trim()
26
+ }
27
+
28
+ const fromBuild = (globalThis as Record<string, unknown>).__SETZKASTEN_BUILD_CONFIG__ as
29
+ | { sessionSecret?: string }
30
+ | undefined
31
+ if (
32
+ typeof fromBuild?.sessionSecret === 'string' &&
33
+ fromBuild.sessionSecret.trim().length >= MIN_SECRET_LENGTH
34
+ ) {
35
+ return fromBuild.sessionSecret.trim()
36
+ }
37
+
38
+ if (process.env.NODE_ENV === 'production') {
39
+ throw new Error(
40
+ 'SETZKASTEN_SESSION_SECRET is missing or too short (need ≥32 chars). ' +
41
+ 'Generate one with `openssl rand -hex 32` and set it as an env var.',
42
+ )
43
+ }
44
+
45
+ return ensureDevSessionSecret()
46
+ }