@setzkasten-cms/astro-admin 1.4.6 → 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 +113 -47
  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-Q3N336KR.js → chunk-CDXCYYQR.js} +29 -24
  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-TD76R3A6.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 +59 -25
  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 +174 -79
  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 +12 -3
  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 +53 -19
  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 +218 -97
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  parseSession
3
- } from "./chunk-INIWFKQ3.js";
3
+ } from "./chunk-Q5HV47DW.js";
4
4
  import {
5
5
  resolveStorageConfig
6
6
  } from "./chunk-6UIKVKED.js";
@@ -10,7 +10,7 @@ import {
10
10
  } from "./chunk-45ARVNT3.js";
11
11
  import {
12
12
  resolveConfigRepoToken
13
- } from "./chunk-NKDATSPA.js";
13
+ } from "./chunk-DP6RTINQ.js";
14
14
  import {
15
15
  withTrailers
16
16
  } from "./chunk-KH22FJO5.js";
@@ -27,12 +27,17 @@ var PUT = async ({ request, cookies }) => {
27
27
  const session = parseSession(cookies.get("setzkasten_session")?.value);
28
28
  if (!session) return new Response("Unauthorized", { status: 401 });
29
29
  if (session.user.role !== "admin") return new Response("Forbidden", { status: 403 });
30
- let patch;
30
+ let raw;
31
31
  try {
32
- patch = await request.json();
32
+ raw = await request.json();
33
33
  } catch {
34
34
  return Response.json({ error: "Invalid request body" }, { status: 400 });
35
35
  }
36
+ const validated = validateGlobalConfigPatch(raw);
37
+ if (!validated.ok) {
38
+ return Response.json({ error: validated.error }, { status: 400 });
39
+ }
40
+ const patch = validated.value;
36
41
  const current = await readGlobalConfig() ?? {};
37
42
  const next = { ...current };
38
43
  for (const [k, v] of Object.entries(patch)) {
@@ -42,6 +47,61 @@ var PUT = async ({ request, cookies }) => {
42
47
  await writeGlobalConfig(next);
43
48
  return Response.json({ ok: true });
44
49
  };
50
+ function validateGlobalConfigPatch(input) {
51
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
52
+ return { ok: false, error: "Body must be a JSON object" };
53
+ }
54
+ const o = input;
55
+ const out = {};
56
+ if ("firebaseConfig" in o) {
57
+ if (o.firebaseConfig === null) {
58
+ ;
59
+ out.firebaseConfig = null;
60
+ } else {
61
+ if (!o.firebaseConfig || typeof o.firebaseConfig !== "object") {
62
+ return { ok: false, error: "firebaseConfig must be an object or null" };
63
+ }
64
+ const fc = o.firebaseConfig;
65
+ for (const k of ["apiKey", "authDomain", "projectId"]) {
66
+ if (typeof fc[k] !== "string" || !fc[k]) {
67
+ return { ok: false, error: `firebaseConfig.${k} must be a non-empty string` };
68
+ }
69
+ }
70
+ out.firebaseConfig = {
71
+ apiKey: fc.apiKey,
72
+ authDomain: fc.authDomain,
73
+ projectId: fc.projectId
74
+ };
75
+ }
76
+ }
77
+ if ("theme" in o) {
78
+ if (o.theme === null) {
79
+ ;
80
+ out.theme = null;
81
+ } else {
82
+ if (!o.theme || typeof o.theme !== "object") {
83
+ return { ok: false, error: "theme must be an object or null" };
84
+ }
85
+ const t = o.theme;
86
+ const theme = {};
87
+ for (const k of ["primaryColor", "brandName", "logo"]) {
88
+ if (k in t) {
89
+ if (typeof t[k] !== "string") {
90
+ return { ok: false, error: `theme.${k} must be a string` };
91
+ }
92
+ theme[k] = t[k];
93
+ }
94
+ }
95
+ out.theme = theme;
96
+ }
97
+ }
98
+ for (const k of Object.keys(o)) {
99
+ if (k !== "firebaseConfig" && k !== "theme") {
100
+ return { ok: false, error: `unknown top-level field: ${k}` };
101
+ }
102
+ }
103
+ return { ok: true, value: out };
104
+ }
45
105
  async function getStorageParams() {
46
106
  const serverConfig = globalThis.__SETZKASTEN_CONFIG__;
47
107
  const storage = resolveStorageConfig();
@@ -64,7 +124,13 @@ async function readGlobalConfig() {
64
124
  return cachedFetch(key, 5 * 6e4, async () => {
65
125
  const res = await fetch(
66
126
  `https://api.github.com/repos/${owner}/${repo}/contents/${GLOBAL_CONFIG_FILE(contentPath)}?ref=${branch}`,
67
- { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } }
127
+ {
128
+ headers: {
129
+ Authorization: `Bearer ${token}`,
130
+ Accept: "application/vnd.github+json",
131
+ "X-GitHub-Api-Version": "2022-11-28"
132
+ }
133
+ }
68
134
  );
69
135
  if (!res.ok) return null;
70
136
  const data = await res.json();
@@ -102,10 +168,11 @@ async function writeGlobalConfig(config) {
102
168
  branch
103
169
  };
104
170
  if (sha) body.sha = sha;
105
- const res = await fetch(
106
- `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`,
107
- { method: "PUT", headers, body: JSON.stringify(body) }
108
- );
171
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, {
172
+ method: "PUT",
173
+ headers,
174
+ body: JSON.stringify(body)
175
+ });
109
176
  if (!res.ok) {
110
177
  const text = await res.text();
111
178
  throw new Error(`GitHub write failed: ${text}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setzkasten-cms/astro-admin",
3
- "version": "1.4.6",
3
+ "version": "1.5.0",
4
4
  "description": "Setzkasten Admin-UI, Init-Wizard und Adoptions-Pipeline für Astro",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -226,6 +226,11 @@
226
226
  "import": "./dist/api-routes/setup-github-app-bounce.js",
227
227
  "default": "./dist/api-routes/setup-github-app-bounce.js"
228
228
  },
229
+ "./setup-github-app-credentials": {
230
+ "types": "./dist/api-routes/setup-github-app-credentials.d.ts",
231
+ "import": "./dist/api-routes/setup-github-app-credentials.js",
232
+ "default": "./dist/api-routes/setup-github-app-credentials.js"
233
+ },
229
234
  "./setup-github-app-repos": {
230
235
  "types": "./dist/api-routes/setup-github-app-repos.d.ts",
231
236
  "import": "./dist/api-routes/setup-github-app-repos.js",
@@ -270,11 +275,11 @@
270
275
  },
271
276
  "dependencies": {
272
277
  "@astrojs/compiler": "^3.0.0",
273
- "@setzkasten-cms/auth": "1.4.6",
274
- "@setzkasten-cms/catalog": "1.4.6",
275
- "@setzkasten-cms/core": "1.4.6",
276
- "@setzkasten-cms/github-adapter": "1.4.6",
277
- "@setzkasten-cms/ui": "1.4.6"
278
+ "@setzkasten-cms/auth": "1.5.0",
279
+ "@setzkasten-cms/core": "1.5.0",
280
+ "@setzkasten-cms/catalog": "1.5.0",
281
+ "@setzkasten-cms/github-adapter": "1.5.0",
282
+ "@setzkasten-cms/ui": "1.5.0"
278
283
  },
279
284
  "peerDependencies": {
280
285
  "astro": "^5.0.0",
@@ -285,6 +290,7 @@
285
290
  "build": "tsup",
286
291
  "typecheck": "tsc --noEmit",
287
292
  "test": "vitest run",
293
+ "coverage": "vitest run --coverage",
288
294
  "test:watch": "vitest"
289
295
  }
290
296
  }
@@ -0,0 +1,114 @@
1
+ import type { AuthSession } from '@setzkasten-cms/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { signSession, verifySessionCookie } from '../_session-signing'
4
+
5
+ const SECRET = 'test-secret-do-not-use-in-production-32bytes-min'
6
+
7
+ const VALID_SESSION: AuthSession = {
8
+ user: {
9
+ id: 'u-1',
10
+ email: 'a@example.com',
11
+ name: 'A',
12
+ provider: 'github',
13
+ role: 'admin',
14
+ },
15
+ expiresAt: Date.now() + 60_000,
16
+ }
17
+
18
+ describe('signSession + verifySessionCookie', () => {
19
+ it('round-trips a valid payload', () => {
20
+ const cookie = signSession(VALID_SESSION, SECRET)
21
+ const result = verifySessionCookie(cookie, SECRET)
22
+ expect(result.ok).toBe(true)
23
+ if (result.ok) {
24
+ expect(result.value.user.email).toBe('a@example.com')
25
+ expect(result.value.user.role).toBe('admin')
26
+ }
27
+ })
28
+
29
+ it('rejects a tampered payload (role escalation attempt)', () => {
30
+ const cookie = signSession(
31
+ { ...VALID_SESSION, user: { ...VALID_SESSION.user, role: 'editor' } },
32
+ SECRET,
33
+ )
34
+ const [, sig] = cookie.split('.')
35
+ // Re-encode the payload with role: 'admin' but keep the original signature.
36
+ const forgedPayload = Buffer.from(
37
+ JSON.stringify({ ...VALID_SESSION, user: { ...VALID_SESSION.user, role: 'admin' } }),
38
+ ).toString('base64url')
39
+ const forged = `${forgedPayload}.${sig}`
40
+
41
+ const result = verifySessionCookie(forged, SECRET)
42
+ expect(result.ok).toBe(false)
43
+ })
44
+
45
+ it('rejects a tampered signature', () => {
46
+ const cookie = signSession(VALID_SESSION, SECRET)
47
+ const [payload] = cookie.split('.')
48
+ const result = verifySessionCookie(`${payload}.AAAAAAAAAAAA`, SECRET)
49
+ expect(result.ok).toBe(false)
50
+ })
51
+
52
+ it('rejects an expired session', () => {
53
+ const expired: AuthSession = { ...VALID_SESSION, expiresAt: Date.now() - 1 }
54
+ const cookie = signSession(expired, SECRET)
55
+ const result = verifySessionCookie(cookie, SECRET)
56
+ expect(result.ok).toBe(false)
57
+ })
58
+
59
+ it('rejects a session with expiresAt in the distant future (max-age cap)', () => {
60
+ // An attacker who got an old cookie can't push the expiry forever.
61
+ // Honest: we don't enforce a server-side max age (besides cookie
62
+ // maxAge), but the signing layer must at minimum require a numeric
63
+ // expiresAt — guard against missing/bogus types.
64
+ const noExpiry = { ...VALID_SESSION, expiresAt: undefined as unknown as number }
65
+ const cookie = signSession(noExpiry, SECRET)
66
+ const result = verifySessionCookie(cookie, SECRET)
67
+ expect(result.ok).toBe(false)
68
+ })
69
+
70
+ it('rejects a cookie signed with a different secret', () => {
71
+ const cookie = signSession(VALID_SESSION, SECRET)
72
+ const result = verifySessionCookie(cookie, 'different-secret-still-32-bytes-or-longer')
73
+ expect(result.ok).toBe(false)
74
+ })
75
+
76
+ it('rejects an unsigned plain JSON cookie (legacy format)', () => {
77
+ // Pre-C1 cookies were `JSON.stringify(session)`. Those must not pass
78
+ // verification — users get logged out and re-auth, which is correct.
79
+ const legacy = JSON.stringify(VALID_SESSION)
80
+ const result = verifySessionCookie(legacy, SECRET)
81
+ expect(result.ok).toBe(false)
82
+ })
83
+
84
+ it('rejects malformed cookies', () => {
85
+ expect(verifySessionCookie('', SECRET).ok).toBe(false)
86
+ expect(verifySessionCookie('only-one-segment', SECRET).ok).toBe(false)
87
+ expect(verifySessionCookie('a.b.c', SECRET).ok).toBe(false)
88
+ expect(verifySessionCookie('!.!', SECRET).ok).toBe(false)
89
+ })
90
+
91
+ it('uses constant-time comparison (no early exit on length mismatch)', () => {
92
+ // We can't directly assert timing, but we can assert that the API
93
+ // doesn't throw on signature length mismatch — a naïve `===` on
94
+ // buffers would. This guards against accidental refactor regressions.
95
+ const cookie = signSession(VALID_SESSION, SECRET)
96
+ const [payload] = cookie.split('.')
97
+ expect(verifySessionCookie(`${payload}.x`, SECRET).ok).toBe(false)
98
+ expect(verifySessionCookie(`${payload}.${'A'.repeat(200)}`, SECRET).ok).toBe(false)
99
+ })
100
+
101
+ it('refuses to sign without a secret', () => {
102
+ expect(() => signSession(VALID_SESSION, '')).toThrow()
103
+ expect(() => signSession(VALID_SESSION, ' ')).toThrow()
104
+ })
105
+
106
+ it('respects a custom `now` value for testing (deterministic expiry)', () => {
107
+ const cookie = signSession(VALID_SESSION, SECRET)
108
+ // 1 ms before expiry: ok. 1 ms after: rejected.
109
+ const ok = verifySessionCookie(cookie, SECRET, VALID_SESSION.expiresAt - 1)
110
+ const expired = verifySessionCookie(cookie, SECRET, VALID_SESSION.expiresAt + 1)
111
+ expect(ok.ok).toBe(true)
112
+ expect(expired.ok).toBe(false)
113
+ })
114
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Test-only helper to mint signed session cookies. Pre-C1 tests used
3
+ * `JSON.stringify` and expected `parseSession` to accept it — that's
4
+ * exactly the forgery surface the production code now rejects.
5
+ *
6
+ * On import, this module pins `SETZKASTEN_SESSION_SECRET` so that the
7
+ * production resolver returns the same secret the helper signs with.
8
+ * No filesystem touch (avoids creating .setzkasten/dev-secret during
9
+ * test runs).
10
+ */
11
+ import type { AuthSession } from '@setzkasten-cms/core'
12
+ import { __setDevSessionSecretForTests } from '../_dev-session-secret'
13
+ import { signSession } from '../_session-signing'
14
+
15
+ const TEST_SECRET = 'sk-test-fixed-secret-32-bytes-min-vitest-deterministic-runs-v1'
16
+
17
+ // Env var is the first lookup in the resolver — set it once on module
18
+ // load so all callers, including ones that don't import this helper
19
+ // directly but transitively touch parseSession, see the same value.
20
+ process.env.SETZKASTEN_SESSION_SECRET = TEST_SECRET
21
+ // Also seed the dev cache so the file-based fallback never triggers
22
+ // even if the env var is unset in some test that resets globals.
23
+ __setDevSessionSecretForTests(TEST_SECRET)
24
+
25
+ export function makeTestSessionCookie(session: AuthSession): string {
26
+ return signSession(session, TEST_SECRET)
27
+ }
@@ -14,7 +14,7 @@
14
14
  * 6. calculateRelativePath: correct relative paths for various depths
15
15
  */
16
16
 
17
- import { describe, it, expect } from 'vitest'
17
+ import { describe, expect, it } from 'vitest'
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // Helpers under test — re-implemented inline so we can test them without
@@ -26,15 +26,24 @@ import { describe, it, expect } from 'vitest'
26
26
 
27
27
  function getDefaultValue(fieldType: string): unknown {
28
28
  switch (fieldType) {
29
- case 'text': return ''
30
- case 'number': return 0
31
- case 'boolean': return false
32
- case 'image': return { path: '', alt: '' }
33
- case 'array': return []
34
- case 'color': return '#000000'
35
- case 'date': return ''
36
- case 'icon': return ''
37
- default: return ''
29
+ case 'text':
30
+ return ''
31
+ case 'number':
32
+ return 0
33
+ case 'boolean':
34
+ return false
35
+ case 'image':
36
+ return { path: '', alt: '' }
37
+ case 'array':
38
+ return []
39
+ case 'color':
40
+ return '#000000'
41
+ case 'date':
42
+ return ''
43
+ case 'icon':
44
+ return ''
45
+ default:
46
+ return ''
38
47
  }
39
48
  }
40
49
 
@@ -151,20 +160,32 @@ describe('Content JSON generation', () => {
151
160
  ]
152
161
 
153
162
  it('should use defaultValue when provided', () => {
154
- const data = buildSectionData({}, fields, fields.map(f => f.key))
163
+ const data = buildSectionData(
164
+ {},
165
+ fields,
166
+ fields.map((f) => f.key),
167
+ )
155
168
  expect(data.heading).toBe('Hello')
156
169
  expect(data.count).toBe(42)
157
170
  expect(data.items).toEqual(['a', 'b'])
158
171
  })
159
172
 
160
173
  it('should fall back to getDefaultValue when defaultValue is absent', () => {
161
- const data = buildSectionData({}, fields, fields.map(f => f.key))
174
+ const data = buildSectionData(
175
+ {},
176
+ fields,
177
+ fields.map((f) => f.key),
178
+ )
162
179
  expect(data.active).toBe(false)
163
180
  })
164
181
 
165
182
  it('should not overwrite existing values on re-adoption', () => {
166
183
  const existing = { heading: 'Existing Title', count: 99 }
167
- const data = buildSectionData(existing, fields, fields.map(f => f.key))
184
+ const data = buildSectionData(
185
+ existing,
186
+ fields,
187
+ fields.map((f) => f.key),
188
+ )
168
189
  expect(data.heading).toBe('Existing Title')
169
190
  expect(data.count).toBe(99)
170
191
  })
@@ -229,7 +250,9 @@ describe('Page config — section deduplication', () => {
229
250
  describe('sk-preview clone generation', () => {
230
251
  it('top-level page: imports with one ../', () => {
231
252
  const clone = buildPreviewClone('impressum.astro')
232
- expect(clone).toBe("---\nexport const prerender = false;\nimport Page from '../impressum.astro';\n---\n<Page />\n")
253
+ expect(clone).toBe(
254
+ "---\nexport const prerender = false;\nimport Page from '../impressum.astro';\n---\n<Page />\n",
255
+ )
233
256
  })
234
257
 
235
258
  it('nested page: imports with correct depth', () => {
@@ -259,28 +282,36 @@ describe('sk-preview clone generation', () => {
259
282
 
260
283
  describe('calculateRelativePath', () => {
261
284
  it('same directory', () => {
262
- expect(calculateRelativePath('src/components', 'src/components/Hero.astro'))
263
- .toBe('./Hero.astro')
285
+ expect(calculateRelativePath('src/components', 'src/components/Hero.astro')).toBe(
286
+ './Hero.astro',
287
+ )
264
288
  })
265
289
 
266
290
  it('one level up', () => {
267
- expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro'))
268
- .toBe('../components/sections/HeroSection.astro')
291
+ expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro')).toBe(
292
+ '../components/sections/HeroSection.astro',
293
+ )
269
294
  })
270
295
 
271
296
  it('two levels up', () => {
272
- expect(calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'))
273
- .toBe('../../components/sections/HeroSection.astro')
297
+ expect(
298
+ calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'),
299
+ ).toBe('../../components/sections/HeroSection.astro')
274
300
  })
275
301
 
276
302
  it('completely different paths', () => {
277
- expect(calculateRelativePath('apps/website/src/pages', 'apps/website/src/components/sections/Footer.astro'))
278
- .toBe('../components/sections/Footer.astro')
303
+ expect(
304
+ calculateRelativePath(
305
+ 'apps/website/src/pages',
306
+ 'apps/website/src/components/sections/Footer.astro',
307
+ ),
308
+ ).toBe('../components/sections/Footer.astro')
279
309
  })
280
310
 
281
311
  it('component in same folder as page', () => {
282
- expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro'))
283
- .toBe('./HeroSection.astro')
312
+ expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro')).toBe(
313
+ './HeroSection.astro',
314
+ )
284
315
  })
285
316
  })
286
317
 
@@ -294,7 +325,10 @@ describe('calculateRelativePath', () => {
294
325
  // ---------------------------------------------------------------------------
295
326
 
296
327
  /** Simulate resolvedPagePath logic from init-add-section.ts step 4 */
297
- function resolvePagePath(bodyPagePath: string, fileExistsOnGitHub: (path: string) => boolean): string {
328
+ function resolvePagePath(
329
+ bodyPagePath: string,
330
+ fileExistsOnGitHub: (path: string) => boolean,
331
+ ): string {
298
332
  if (fileExistsOnGitHub(bodyPagePath)) return bodyPagePath
299
333
  if (bodyPagePath.endsWith('.astro')) {
300
334
  const indexPath = bodyPagePath.replace(/\.astro$/, '/index.astro')
@@ -1,17 +1,24 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { parseSession, guardPageAccess } from '../_auth-guard'
3
1
  import type { AuthSession, SetzKastenConfig } from '@setzkasten-cms/core'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { guardPageAccess, parseSession } from '../_auth-guard'
4
+ import { makeTestSessionCookie } from './_session-test-helper'
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Helpers
7
8
  // ---------------------------------------------------------------------------
8
9
 
9
10
  function adminSession(): AuthSession {
10
- return { user: { id: '1', email: 'admin@example.com', provider: 'github', role: 'admin' }, expiresAt: Date.now() + 86400_000 }
11
+ return {
12
+ user: { id: '1', email: 'admin@example.com', provider: 'github', role: 'admin' },
13
+ expiresAt: Date.now() + 86400_000,
14
+ }
11
15
  }
12
16
 
13
17
  function editorSession(email = 'editor@example.com'): AuthSession {
14
- return { user: { id: '2', email, provider: 'google', role: 'editor' }, expiresAt: Date.now() + 86400_000 }
18
+ return {
19
+ user: { id: '2', email, provider: 'google', role: 'editor' },
20
+ expiresAt: Date.now() + 86400_000,
21
+ }
15
22
  }
16
23
 
17
24
  const baseConfig: SetzKastenConfig = {
@@ -33,9 +40,37 @@ describe('parseSession', () => {
33
40
  expect(parseSession('not-json')).toBeNull()
34
41
  })
35
42
 
36
- it('parses a valid session cookie', () => {
43
+ it('rejects an unsigned plain-JSON cookie (legacy / forged format)', () => {
44
+ // Pre-C1 the cookie was JSON.stringify(session) — anyone could forge
45
+ // any role. parseSession must refuse the old format outright.
37
46
  const session = adminSession()
38
- expect(parseSession(JSON.stringify(session))).toEqual(session)
47
+ expect(parseSession(JSON.stringify(session))).toBeNull()
48
+ })
49
+
50
+ it('accepts a properly signed session cookie', () => {
51
+ const session = adminSession()
52
+ expect(parseSession(makeTestSessionCookie(session))).toEqual(session)
53
+ })
54
+
55
+ it('rejects an expired signed session', () => {
56
+ const expired: AuthSession = {
57
+ user: { id: '1', email: 'admin@example.com', provider: 'github', role: 'admin' },
58
+ expiresAt: Date.now() - 1,
59
+ }
60
+ expect(parseSession(makeTestSessionCookie(expired))).toBeNull()
61
+ })
62
+
63
+ it('rejects a tampered payload (role escalation forgery)', () => {
64
+ const editor: AuthSession = {
65
+ user: { id: '2', email: 'e@example.com', provider: 'google', role: 'editor' },
66
+ expiresAt: Date.now() + 60_000,
67
+ }
68
+ const cookie = makeTestSessionCookie(editor)
69
+ const [, sig] = cookie.split('.')
70
+ const forgedPayload = Buffer.from(
71
+ JSON.stringify({ ...editor, user: { ...editor.user, role: 'admin' } }),
72
+ ).toString('base64url')
73
+ expect(parseSession(`${forgedPayload}.${sig}`)).toBeNull()
39
74
  })
40
75
  })
41
76
 
@@ -103,7 +138,11 @@ describe('guardPageAccess – dynamic editors', () => {
103
138
  kind: 'present',
104
139
  editors: [{ email: 'editor@example.com' }],
105
140
  })
106
- const res = await guardPageAccess(editorSession('editor@example.com'), 'secret-page', baseConfig)
141
+ const res = await guardPageAccess(
142
+ editorSession('editor@example.com'),
143
+ 'secret-page',
144
+ baseConfig,
145
+ )
107
146
  expect(res).toBeNull()
108
147
  })
109
148
 
@@ -10,11 +10,11 @@
10
10
  * 3. buildCatalogAddCommit — builds file paths for a catalog add commit
11
11
  */
12
12
 
13
- import { describe, it, expect } from 'vitest'
13
+ import { describe, expect, it } from 'vitest'
14
14
  import {
15
+ buildCatalogAddCommit,
15
16
  buildCatalogResponse,
16
17
  validateCatalogAddBody,
17
- buildCatalogAddCommit,
18
18
  } from '../catalog-helpers'
19
19
 
20
20
  // ---------------------------------------------------------------------------
@@ -31,7 +31,7 @@ describe('buildCatalogResponse', () => {
31
31
  })
32
32
 
33
33
  it('includes hero, features, cta', () => {
34
- const names = buildCatalogResponse().map(t => t.name)
34
+ const names = buildCatalogResponse().map((t) => t.name)
35
35
  expect(names).toContain('hero')
36
36
  expect(names).toContain('features')
37
37
  expect(names).toContain('cta')
@@ -62,7 +62,9 @@ describe('validateCatalogAddBody', () => {
62
62
  })
63
63
 
64
64
  it('accepts body with sectionKey override', () => {
65
- expect(() => validateCatalogAddBody({ templateName: 'hero', pageKey: 'index', sectionKey: 'hero--top' })).not.toThrow()
65
+ expect(() =>
66
+ validateCatalogAddBody({ templateName: 'hero', pageKey: 'index', sectionKey: 'hero--top' }),
67
+ ).not.toThrow()
66
68
  })
67
69
 
68
70
  it('throws when templateName is missing', () => {
@@ -74,7 +76,9 @@ describe('validateCatalogAddBody', () => {
74
76
  })
75
77
 
76
78
  it('throws when templateName is not in registry', () => {
77
- expect(() => validateCatalogAddBody({ templateName: 'nonexistent-xyz', pageKey: 'index' })).toThrow()
79
+ expect(() =>
80
+ validateCatalogAddBody({ templateName: 'nonexistent-xyz', pageKey: 'index' }),
81
+ ).toThrow()
78
82
  })
79
83
  })
80
84
 
@@ -109,7 +113,11 @@ describe('buildCatalogAddCommit', () => {
109
113
  })
110
114
 
111
115
  it('pageConfigPath matches the pageKey', () => {
112
- const result = buildCatalogAddCommit({ ...opts, pageKey: 'about', pageConfigPath: 'content/pages/_about.json' })
116
+ const result = buildCatalogAddCommit({
117
+ ...opts,
118
+ pageKey: 'about',
119
+ pageConfigPath: 'content/pages/_about.json',
120
+ })
113
121
  expect(result.pageConfigPath).toBe('content/pages/_about.json')
114
122
  })
115
123
  })
@@ -3,8 +3,8 @@
3
3
  * @vitest-environment node
4
4
  */
5
5
 
6
- import { describe, it, expect } from 'vitest'
7
- import { withTrailers, SETZKASTEN_CO_AUTHOR } from '../_commit-trailers'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { SETZKASTEN_CO_AUTHOR, withTrailers } from '../_commit-trailers'
8
8
 
9
9
  describe('SETZKASTEN_CO_AUTHOR', () => {
10
10
  it('is a valid Co-authored-by trailer', () => {
@@ -28,20 +28,20 @@ describe('withTrailers', () => {
28
28
 
29
29
  it('does not add editor trailer when editorEmail is not provided', () => {
30
30
  const result = withTrailers('chore: something')
31
- const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
31
+ const lines = result.split('\n').filter((l) => l.startsWith('Co-authored-by:'))
32
32
  expect(lines).toHaveLength(1)
33
33
  expect(lines[0]).toBe(SETZKASTEN_CO_AUTHOR)
34
34
  })
35
35
 
36
36
  it('does not add editor trailer when editorEmail is null', () => {
37
37
  const result = withTrailers('chore: something', null)
38
- const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
38
+ const lines = result.split('\n').filter((l) => l.startsWith('Co-authored-by:'))
39
39
  expect(lines).toHaveLength(1)
40
40
  })
41
41
 
42
42
  it('adds editor Co-authored-by when editorEmail is provided', () => {
43
43
  const result = withTrailers('content: update', 'jane.doe@example.com')
44
- const lines = result.split('\n').filter(l => l.startsWith('Co-authored-by:'))
44
+ const lines = result.split('\n').filter((l) => l.startsWith('Co-authored-by:'))
45
45
  expect(lines).toHaveLength(2)
46
46
  expect(lines[1]).toContain('jane.doe@example.com')
47
47
  })