@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.
- package/dist/api-routes/_auth-guard.d.ts +27 -3
- package/dist/api-routes/_auth-guard.js +5 -2
- package/dist/api-routes/_dev-session-secret.d.ts +8 -0
- package/dist/api-routes/_dev-session-secret.js +8 -0
- package/dist/api-routes/_github-token.js +1 -1
- package/dist/api-routes/_role-resolver.js +6 -3
- package/dist/api-routes/_session-secret.d.ts +19 -0
- package/dist/api-routes/_session-secret.js +7 -0
- package/dist/api-routes/_session-signing.d.ts +45 -0
- package/dist/api-routes/_session-signing.js +8 -0
- package/dist/api-routes/_webhook-dispatcher.js +4 -4
- package/dist/api-routes/asset-proxy.js +1 -1
- package/dist/api-routes/auth-callback.js +12 -5
- package/dist/api-routes/auth-logout.d.ts +4 -4
- package/dist/api-routes/auth-logout.js +8 -2
- package/dist/api-routes/auth-session.d.ts +6 -0
- package/dist/api-routes/auth-session.js +19 -19
- package/dist/api-routes/auth-setzkasten-login.js +14 -7
- package/dist/api-routes/catalog-add.js +59 -17
- package/dist/api-routes/catalog-export.js +14 -4
- package/dist/api-routes/config.d.ts +10 -3
- package/dist/api-routes/config.js +26 -4
- package/dist/api-routes/deploy-hook.js +8 -8
- package/dist/api-routes/editors.d.ts +1 -1
- package/dist/api-routes/editors.js +5 -2
- package/dist/api-routes/github-proxy.js +30 -8
- package/dist/api-routes/global-config.js +6 -3
- package/dist/api-routes/history-rollback.js +31 -14
- package/dist/api-routes/history-version.js +8 -6
- package/dist/api-routes/history.js +5 -2
- package/dist/api-routes/icons-local.js +1 -1
- package/dist/api-routes/init-add-section.js +150 -48
- package/dist/api-routes/init-apply.js +56 -42
- package/dist/api-routes/init-migrate.js +43 -36
- package/dist/api-routes/init-scan-page.d.ts +1 -1
- package/dist/api-routes/init-scan-page.js +59 -13
- package/dist/api-routes/init-scan.js +22 -7
- package/dist/api-routes/migrate-to-multi.js +5 -2
- package/dist/api-routes/pages.js +15 -4
- package/dist/api-routes/section-add.js +68 -16
- package/dist/api-routes/section-commit-pending.js +70 -22
- package/dist/api-routes/section-delete.js +49 -14
- package/dist/api-routes/section-duplicate.js +65 -16
- package/dist/api-routes/section-prepare-copy.js +15 -2
- package/dist/api-routes/section-prepare.js +25 -4
- package/dist/api-routes/setup-github-app-bounce.js +15 -1
- package/dist/api-routes/setup-github-app-branches.js +9 -6
- package/dist/api-routes/setup-github-app-callback.js +24 -1
- package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
- package/dist/api-routes/setup-github-app-credentials.js +43 -0
- package/dist/api-routes/setup-github-app-installed.js +22 -1
- package/dist/api-routes/setup-github-app-repos.js +5 -2
- package/dist/api-routes/setup-github-app.d.ts +4 -0
- package/dist/api-routes/setup-github-app.js +19 -2
- package/dist/api-routes/updater-register.js +7 -1
- package/dist/api-routes/webhooks-status.js +5 -2
- package/dist/api-routes/webhooks-test.js +9 -8
- package/dist/api-routes/webhooks.js +12 -14
- package/dist/api-routes/websites-add.js +5 -2
- package/dist/api-routes/websites-remove.js +5 -2
- package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
- package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
- package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
- package/dist/chunk-KENFINT4.js +76 -0
- package/dist/chunk-ONP6BRZO.js +47 -0
- package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
- package/dist/chunk-QVCW6EF3.js +26 -0
- package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
- package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
- package/package.json +12 -6
- package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
- package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
- package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
- package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
- package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
- package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
- package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
- package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
- package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
- package/src/api-routes/__tests__/github-cache.test.ts +1 -1
- package/src/api-routes/__tests__/github-token.test.ts +1 -1
- package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
- package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
- package/src/api-routes/__tests__/history.test.ts +9 -6
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
- package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
- package/src/api-routes/__tests__/pages.test.ts +7 -2
- package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
- package/src/api-routes/__tests__/route-registry.test.ts +11 -18
- package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
- package/src/api-routes/__tests__/section-management.test.ts +28 -28
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
- package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
- package/src/api-routes/__tests__/updater-register.test.ts +230 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
- package/src/api-routes/__tests__/webhooks.test.ts +19 -7
- package/src/api-routes/__tests__/websites-add.test.ts +2 -1
- package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
- package/src/api-routes/_auth-guard.ts +47 -15
- package/src/api-routes/_commit-trailers.ts +3 -2
- package/src/api-routes/_dev-session-secret.ts +79 -0
- package/src/api-routes/_github-token.ts +1 -1
- package/src/api-routes/_pages-meta-store.ts +2 -2
- package/src/api-routes/_role-resolver.ts +7 -5
- package/src/api-routes/_session-secret.ts +46 -0
- package/src/api-routes/_session-signing.ts +135 -0
- package/src/api-routes/_vercel-origin.ts +2 -6
- package/src/api-routes/_webhook-dispatcher.ts +12 -16
- package/src/api-routes/_website-resolver.ts +3 -10
- package/src/api-routes/auth-callback.ts +9 -5
- package/src/api-routes/auth-login.ts +5 -3
- package/src/api-routes/auth-logout.ts +18 -1
- package/src/api-routes/auth-session.ts +13 -21
- package/src/api-routes/auth-setzkasten-login.ts +12 -9
- package/src/api-routes/catalog-add.ts +89 -31
- package/src/api-routes/catalog-export.ts +30 -10
- package/src/api-routes/config.ts +39 -6
- package/src/api-routes/deploy-hook.ts +13 -11
- package/src/api-routes/editors.ts +33 -22
- package/src/api-routes/github-proxy.ts +25 -11
- package/src/api-routes/global-config.ts +103 -18
- package/src/api-routes/history-rollback.ts +41 -14
- package/src/api-routes/history-version.ts +5 -6
- package/src/api-routes/history.ts +3 -3
- package/src/api-routes/icons-local.ts +2 -2
- package/src/api-routes/init-add-section.ts +218 -88
- package/src/api-routes/init-apply.ts +71 -56
- package/src/api-routes/init-migrate.ts +54 -48
- package/src/api-routes/init-scan-page.ts +77 -30
- package/src/api-routes/init-scan.ts +19 -11
- package/src/api-routes/pages.ts +16 -11
- package/src/api-routes/section-add.ts +98 -27
- package/src/api-routes/section-commit-pending.ts +87 -34
- package/src/api-routes/section-delete.ts +76 -27
- package/src/api-routes/section-duplicate.ts +95 -28
- package/src/api-routes/section-management.ts +3 -7
- package/src/api-routes/section-prepare-copy.ts +29 -8
- package/src/api-routes/section-prepare.ts +38 -10
- package/src/api-routes/setup-github-app-bounce.ts +7 -1
- package/src/api-routes/setup-github-app-branches.ts +6 -7
- package/src/api-routes/setup-github-app-callback.ts +18 -1
- package/src/api-routes/setup-github-app-credentials.ts +55 -0
- package/src/api-routes/setup-github-app-installed.ts +12 -1
- package/src/api-routes/setup-github-app-repos.ts +2 -3
- package/src/api-routes/setup-github-app.ts +14 -5
- package/src/api-routes/updater-check.ts +6 -4
- package/src/api-routes/updater-register.ts +34 -20
- package/src/api-routes/updater-transfer.ts +8 -6
- package/src/api-routes/updater-unbind.ts +14 -10
- package/src/api-routes/webhooks-test.ts +9 -11
- package/src/api-routes/webhooks.ts +15 -19
- package/src/init/__tests__/page-level.test.ts +279 -105
- package/src/init/__tests__/page-list-coverage.test.ts +70 -70
- package/src/init/__tests__/patcher-child-component.test.ts +126 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
- package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
- package/src/init/__tests__/section-pipeline.test.ts +102 -16
- package/src/init/astro-config-patcher.ts +4 -18
- package/src/init/astro-detector.ts +2 -7
- package/src/init/astro-section-analyzer-v2.ts +475 -193
- package/src/init/field-label-enricher.ts +6 -6
- 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
|
+
})
|
|
@@ -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 =
|
|
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 =
|
|
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(
|
|
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: [
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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)
|
|
116
|
-
|
|
117
|
-
if (!allowed.includes(session.user.email)) {
|
|
143
|
+
if (!allowed || allowed.length === 0) {
|
|
118
144
|
return new Response(
|
|
119
|
-
`Forbidden:
|
|
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 = (
|
|
140
|
-
|
|
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
|
|
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,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 {
|
|
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 = (
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|