@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
@@ -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.2",
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.2",
274
- "@setzkasten-cms/catalog": "1.4.2",
275
- "@setzkasten-cms/core": "1.4.2",
276
- "@setzkasten-cms/github-adapter": "1.4.2",
277
- "@setzkasten-cms/ui": "1.4.2"
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
 
@@ -106,12 +115,10 @@ function buildPageConfig(
106
115
  return { sections: [{ key: sectionKey, enabled: true }] }
107
116
  }
108
117
 
109
- /** Build the sk-preview clone: strip prerender, fix import depths */
110
- function buildPreviewClone(patchedSource: string): string {
111
- return patchedSource
112
- .replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
113
- .replace(/(from\s+')(\.\.\/)/g, '$1../$2')
114
- .replace(/(from\s+")(\.\.\/)/g, '$1../$2')
118
+ /** Build a thin SSR wrapper that imports the production page as a component */
119
+ function buildPreviewClone(relativePage: string): string {
120
+ const importDepth = '../'.repeat(relativePage.split('/').length)
121
+ return `---\nexport const prerender = false;\nimport Page from '${importDepth}${relativePage}';\n---\n<Page />\n`
115
122
  }
116
123
 
117
124
  // ---------------------------------------------------------------------------
@@ -153,20 +160,32 @@ describe('Content JSON generation', () => {
153
160
  ]
154
161
 
155
162
  it('should use defaultValue when provided', () => {
156
- const data = buildSectionData({}, fields, fields.map(f => f.key))
163
+ const data = buildSectionData(
164
+ {},
165
+ fields,
166
+ fields.map((f) => f.key),
167
+ )
157
168
  expect(data.heading).toBe('Hello')
158
169
  expect(data.count).toBe(42)
159
170
  expect(data.items).toEqual(['a', 'b'])
160
171
  })
161
172
 
162
173
  it('should fall back to getDefaultValue when defaultValue is absent', () => {
163
- const data = buildSectionData({}, fields, fields.map(f => f.key))
174
+ const data = buildSectionData(
175
+ {},
176
+ fields,
177
+ fields.map((f) => f.key),
178
+ )
164
179
  expect(data.active).toBe(false)
165
180
  })
166
181
 
167
182
  it('should not overwrite existing values on re-adoption', () => {
168
183
  const existing = { heading: 'Existing Title', count: 99 }
169
- 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
+ )
170
189
  expect(data.heading).toBe('Existing Title')
171
190
  expect(data.count).toBe(99)
172
191
  })
@@ -225,50 +244,35 @@ describe('Page config — section deduplication', () => {
225
244
  })
226
245
 
227
246
  // ---------------------------------------------------------------------------
228
- // 4. sk-preview clone — prerender removal and import depth fix
247
+ // 4. sk-preview clone — thin wrapper with correct import depth
229
248
  // ---------------------------------------------------------------------------
230
249
 
231
250
  describe('sk-preview clone generation', () => {
232
- const patched = `---
233
- export const prerender = true;
234
- import BaseLayout from '../../layouts/BaseLayout.astro';
235
- import { getSection } from 'setzkasten:content'
236
- const skData = getSection('_page_docs_architecture')
237
- ---
238
-
239
- <BaseLayout>
240
- <section id="section-_page_docs_architecture">
241
- <h1 set:html={skData?.heading ?? 'Architektur'} />
242
- </section>
243
- </BaseLayout>
244
- `
245
-
246
- it('should remove the prerender export', () => {
247
- const clone = buildPreviewClone(patched)
248
- expect(clone).not.toContain('export const prerender')
251
+ it('top-level page: imports with one ../', () => {
252
+ const clone = buildPreviewClone('impressum.astro')
253
+ expect(clone).toBe(
254
+ "---\nexport const prerender = false;\nimport Page from '../impressum.astro';\n---\n<Page />\n",
255
+ )
249
256
  })
250
257
 
251
- it('should add an extra ../ level to relative imports', () => {
252
- const clone = buildPreviewClone(patched)
253
- // Original: '../../layouts/BaseLayout.astro'
254
- // After fix: '../../../layouts/BaseLayout.astro'
255
- expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
258
+ it('nested page: imports with correct depth', () => {
259
+ const clone = buildPreviewClone('docs/index.astro')
260
+ expect(clone).toContain("import Page from '../../docs/index.astro'")
256
261
  })
257
262
 
258
- it('should not touch absolute module imports', () => {
259
- const clone = buildPreviewClone(patched)
260
- expect(clone).toContain("from 'setzkasten:content'")
263
+ it('three-level page: imports with three ../', () => {
264
+ const clone = buildPreviewClone('docs/api/reference.astro')
265
+ expect(clone).toContain("import Page from '../../../docs/api/reference.astro'")
261
266
  })
262
267
 
263
- it('should preserve getSection call', () => {
264
- const clone = buildPreviewClone(patched)
265
- expect(clone).toContain("getSection('_page_docs_architecture')")
268
+ it('always has export const prerender = false', () => {
269
+ const clone = buildPreviewClone('impressum.astro')
270
+ expect(clone).toContain('export const prerender = false')
266
271
  })
267
272
 
268
- it('should handle double-quoted imports too', () => {
269
- const dq = patched.replace("from '../../layouts", 'from "../../layouts').replace("BaseLayout.astro'", 'BaseLayout.astro"')
270
- const clone = buildPreviewClone(dq)
271
- expect(clone).toContain('../../../layouts/BaseLayout.astro')
273
+ it('renders as <Page />', () => {
274
+ const clone = buildPreviewClone('impressum.astro')
275
+ expect(clone).toContain('<Page />')
272
276
  })
273
277
  })
274
278
 
@@ -278,28 +282,36 @@ const skData = getSection('_page_docs_architecture')
278
282
 
279
283
  describe('calculateRelativePath', () => {
280
284
  it('same directory', () => {
281
- expect(calculateRelativePath('src/components', 'src/components/Hero.astro'))
282
- .toBe('./Hero.astro')
285
+ expect(calculateRelativePath('src/components', 'src/components/Hero.astro')).toBe(
286
+ './Hero.astro',
287
+ )
283
288
  })
284
289
 
285
290
  it('one level up', () => {
286
- expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro'))
287
- .toBe('../components/sections/HeroSection.astro')
291
+ expect(calculateRelativePath('src/pages', 'src/components/sections/HeroSection.astro')).toBe(
292
+ '../components/sections/HeroSection.astro',
293
+ )
288
294
  })
289
295
 
290
296
  it('two levels up', () => {
291
- expect(calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'))
292
- .toBe('../../components/sections/HeroSection.astro')
297
+ expect(
298
+ calculateRelativePath('src/pages/docs', 'src/components/sections/HeroSection.astro'),
299
+ ).toBe('../../components/sections/HeroSection.astro')
293
300
  })
294
301
 
295
302
  it('completely different paths', () => {
296
- expect(calculateRelativePath('apps/website/src/pages', 'apps/website/src/components/sections/Footer.astro'))
297
- .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')
298
309
  })
299
310
 
300
311
  it('component in same folder as page', () => {
301
- expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro'))
302
- .toBe('./HeroSection.astro')
312
+ expect(calculateRelativePath('src/pages', 'src/pages/HeroSection.astro')).toBe(
313
+ './HeroSection.astro',
314
+ )
303
315
  })
304
316
  })
305
317
 
@@ -309,16 +321,14 @@ describe('calculateRelativePath', () => {
309
321
  // When the UI sends pagePath='src/pages/docs.astro' but the actual file is
310
322
  // src/pages/docs/index.astro (directory route), the sk-preview clone must
311
323
  // be placed at sk-preview/docs/index.astro (NOT sk-preview/docs.astro).
312
- //
313
- // sk-preview/docs.astro (wrong): same depth as src/pages/sk-preview/
314
- // → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✗
315
- //
316
- // sk-preview/docs/index.astro (correct): one level deeper in sk-preview/docs/
317
- // → buildPreviewClone adds "../" → "../../layouts/" becomes "../../../layouts/" ✓
324
+ // The import depth must match: docs/index.astro → '../../docs/index.astro'.
318
325
  // ---------------------------------------------------------------------------
319
326
 
320
327
  /** Simulate resolvedPagePath logic from init-add-section.ts step 4 */
321
- function resolvePagePath(bodyPagePath: string, fileExistsOnGitHub: (path: string) => boolean): string {
328
+ function resolvePagePath(
329
+ bodyPagePath: string,
330
+ fileExistsOnGitHub: (path: string) => boolean,
331
+ ): string {
322
332
  if (fileExistsOnGitHub(bodyPagePath)) return bodyPagePath
323
333
  if (bodyPagePath.endsWith('.astro')) {
324
334
  const indexPath = bodyPagePath.replace(/\.astro$/, '/index.astro')
@@ -334,7 +344,6 @@ describe('Directory-route clone path (build-failure regression)', () => {
334
344
  })
335
345
 
336
346
  it('directory route: resolvedPagePath falls back to index.astro', () => {
337
- // docs.astro does not exist, docs/index.astro does
338
347
  const resolved = resolvePagePath(
339
348
  'src/pages/docs.astro',
340
349
  (p) => p === 'src/pages/docs/index.astro',
@@ -342,42 +351,27 @@ describe('Directory-route clone path (build-failure regression)', () => {
342
351
  expect(resolved).toBe('src/pages/docs/index.astro')
343
352
  })
344
353
 
345
- it('directory route: clone relativePage uses resolved path, not body path', () => {
346
- const bodyPagePath = 'src/pages/docs.astro'
354
+ it('directory route: relativePage uses resolved path, not body path', () => {
347
355
  const resolved = resolvePagePath(
348
- bodyPagePath,
356
+ 'src/pages/docs.astro',
349
357
  (p) => p === 'src/pages/docs/index.astro',
350
358
  )
351
359
  const relativePage = resolved.replace(/^src\/pages\//, '')
352
- // Must be 'docs/index.astro', NOT 'docs.astro'
353
360
  expect(relativePage).toBe('docs/index.astro')
354
361
  expect(relativePage).not.toBe('docs.astro')
355
362
  })
356
363
 
357
- it('directory route clone gets correct import depth after buildPreviewClone', () => {
358
- // src/pages/docs/index.astro imports '../../layouts/BaseLayout.astro'
359
- // clone at sk-preview/docs/index.astro needs '../../../layouts/BaseLayout.astro'
360
- const source = `---
361
- import BaseLayout from '../../layouts/BaseLayout.astro';
362
- import { getSection } from 'setzkasten:content'
363
- const skData = getSection('_page_docs')
364
- ---
365
- <BaseLayout><slot /></BaseLayout>
366
- `
367
- const clone = buildPreviewClone(source)
368
- expect(clone).toContain("from '../../../layouts/BaseLayout.astro'")
364
+ it('directory route: clone imports from correct depth', () => {
365
+ const clone = buildPreviewClone('docs/index.astro')
366
+ expect(clone).toContain("import Page from '../../docs/index.astro'")
369
367
  })
370
368
 
371
- it('wrong clone path (docs.astro) would have incorrect import depth', () => {
372
- // sk-preview/docs.astro is at same depth as sk-preview/*.astro
373
- // It needs '../../layouts/' but buildPreviewClone would produce '../../../layouts/'
374
- // This test documents the bug: if relativePage were 'docs.astro' instead of
375
- // 'docs/index.astro', the clone ends up at the wrong path and imports break.
376
- const bodyPagePath = 'src/pages/docs.astro'
377
- const wrongRelativePage = bodyPagePath.replace(/^src\/pages\//, '') // 'docs.astro' ← the bug
378
- expect(wrongRelativePage).toBe('docs.astro')
379
- // The CORRECT relative page (after fix):
380
- const correctRelativePage = 'src/pages/docs/index.astro'.replace(/^src\/pages\//, '')
381
- expect(correctRelativePage).toBe('docs/index.astro')
369
+ it('wrong relativePage (docs.astro) produces shallow import depth', () => {
370
+ // Documents why resolved path matters: wrong path → wrong import
371
+ const wrongClone = buildPreviewClone('docs.astro')
372
+ expect(wrongClone).toContain("import Page from '../docs.astro'")
373
+ // vs correct:
374
+ const correctClone = buildPreviewClone('docs/index.astro')
375
+ expect(correctClone).toContain("import Page from '../../docs/index.astro'")
382
376
  })
383
377
  })