@setzkasten-cms/astro-admin 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/dist/api-routes/_auth-guard.d.ts +27 -3
  2. package/dist/api-routes/_auth-guard.js +5 -2
  3. package/dist/api-routes/_dev-session-secret.d.ts +8 -0
  4. package/dist/api-routes/_dev-session-secret.js +8 -0
  5. package/dist/api-routes/_github-token.js +1 -1
  6. package/dist/api-routes/_role-resolver.js +6 -3
  7. package/dist/api-routes/_session-secret.d.ts +19 -0
  8. package/dist/api-routes/_session-secret.js +7 -0
  9. package/dist/api-routes/_session-signing.d.ts +45 -0
  10. package/dist/api-routes/_session-signing.js +8 -0
  11. package/dist/api-routes/_webhook-dispatcher.js +4 -4
  12. package/dist/api-routes/asset-proxy.js +1 -1
  13. package/dist/api-routes/auth-callback.js +12 -5
  14. package/dist/api-routes/auth-logout.d.ts +4 -4
  15. package/dist/api-routes/auth-logout.js +8 -2
  16. package/dist/api-routes/auth-session.d.ts +6 -0
  17. package/dist/api-routes/auth-session.js +19 -19
  18. package/dist/api-routes/auth-setzkasten-login.js +14 -7
  19. package/dist/api-routes/catalog-add.js +59 -17
  20. package/dist/api-routes/catalog-export.js +14 -4
  21. package/dist/api-routes/config.d.ts +10 -3
  22. package/dist/api-routes/config.js +26 -4
  23. package/dist/api-routes/deploy-hook.js +8 -8
  24. package/dist/api-routes/editors.d.ts +1 -1
  25. package/dist/api-routes/editors.js +5 -2
  26. package/dist/api-routes/github-proxy.js +30 -8
  27. package/dist/api-routes/global-config.js +6 -3
  28. package/dist/api-routes/history-rollback.js +31 -14
  29. package/dist/api-routes/history-version.js +8 -6
  30. package/dist/api-routes/history.js +5 -2
  31. package/dist/api-routes/icons-local.js +1 -1
  32. package/dist/api-routes/init-add-section.js +150 -48
  33. package/dist/api-routes/init-apply.js +56 -42
  34. package/dist/api-routes/init-migrate.js +43 -36
  35. package/dist/api-routes/init-scan-page.d.ts +1 -1
  36. package/dist/api-routes/init-scan-page.js +59 -13
  37. package/dist/api-routes/init-scan.js +22 -7
  38. package/dist/api-routes/migrate-to-multi.js +5 -2
  39. package/dist/api-routes/pages.js +15 -4
  40. package/dist/api-routes/section-add.js +68 -16
  41. package/dist/api-routes/section-commit-pending.js +70 -22
  42. package/dist/api-routes/section-delete.js +49 -14
  43. package/dist/api-routes/section-duplicate.js +65 -16
  44. package/dist/api-routes/section-prepare-copy.js +15 -2
  45. package/dist/api-routes/section-prepare.js +25 -4
  46. package/dist/api-routes/setup-github-app-bounce.js +15 -1
  47. package/dist/api-routes/setup-github-app-branches.js +9 -6
  48. package/dist/api-routes/setup-github-app-callback.js +24 -1
  49. package/dist/api-routes/setup-github-app-credentials.d.ts +27 -0
  50. package/dist/api-routes/setup-github-app-credentials.js +43 -0
  51. package/dist/api-routes/setup-github-app-installed.js +22 -1
  52. package/dist/api-routes/setup-github-app-repos.js +5 -2
  53. package/dist/api-routes/setup-github-app.d.ts +4 -0
  54. package/dist/api-routes/setup-github-app.js +19 -2
  55. package/dist/api-routes/updater-register.js +7 -1
  56. package/dist/api-routes/webhooks-status.js +5 -2
  57. package/dist/api-routes/webhooks-test.js +9 -8
  58. package/dist/api-routes/webhooks.js +12 -14
  59. package/dist/api-routes/websites-add.js +5 -2
  60. package/dist/api-routes/websites-remove.js +5 -2
  61. package/dist/{chunk-ZQDGGWJP.js → chunk-5KMGSFCZ.js} +2 -2
  62. package/dist/{chunk-RHJONMLK.js → chunk-CDXCYYQR.js} +222 -5
  63. package/dist/{chunk-NKDATSPA.js → chunk-DP6RTINQ.js} +1 -1
  64. package/dist/chunk-KENFINT4.js +76 -0
  65. package/dist/chunk-ONP6BRZO.js +47 -0
  66. package/dist/{chunk-INIWFKQ3.js → chunk-Q5HV47DW.js} +33 -19
  67. package/dist/chunk-QVCW6EF3.js +26 -0
  68. package/dist/{chunk-K22A4ZBS.js → chunk-UHI6323G.js} +293 -174
  69. package/dist/{chunk-AM4DZXXM.js → chunk-UJAFZEX2.js} +76 -9
  70. package/package.json +12 -6
  71. package/src/api-routes/__tests__/_session-signing.test.ts +114 -0
  72. package/src/api-routes/__tests__/_session-test-helper.ts +27 -0
  73. package/src/api-routes/__tests__/add-section-helpers.test.ts +91 -97
  74. package/src/api-routes/__tests__/auth-guard.test.ts +46 -7
  75. package/src/api-routes/__tests__/catalog-api.test.ts +14 -6
  76. package/src/api-routes/__tests__/commit-trailers.test.ts +5 -5
  77. package/src/api-routes/__tests__/deferred-operations.test.ts +9 -12
  78. package/src/api-routes/__tests__/deploy-hook.test.ts +3 -8
  79. package/src/api-routes/__tests__/feature-gate.test.ts +1 -1
  80. package/src/api-routes/__tests__/github-cache.test.ts +1 -1
  81. package/src/api-routes/__tests__/github-token.test.ts +1 -1
  82. package/src/api-routes/__tests__/global-config-theme.test.ts +4 -4
  83. package/src/api-routes/__tests__/history-rollback.test.ts +6 -3
  84. package/src/api-routes/__tests__/history.test.ts +9 -6
  85. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +11 -3
  86. package/src/api-routes/__tests__/migrate-to-multi.test.ts +5 -1
  87. package/src/api-routes/__tests__/pages-meta-store.test.ts +10 -5
  88. package/src/api-routes/__tests__/pages.test.ts +7 -2
  89. package/src/api-routes/__tests__/patch-page-file.test.ts +71 -19
  90. package/src/api-routes/__tests__/route-registry.test.ts +11 -18
  91. package/src/api-routes/__tests__/scan-page-helpers.test.ts +13 -10
  92. package/src/api-routes/__tests__/section-management.test.ts +28 -28
  93. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +58 -16
  94. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +4 -5
  95. package/src/api-routes/__tests__/setup-github-app.test.ts +27 -7
  96. package/src/api-routes/__tests__/storage-config-for-request.test.ts +83 -0
  97. package/src/api-routes/__tests__/updater-register.test.ts +230 -0
  98. package/src/api-routes/__tests__/webhook-signing.test.ts +1 -1
  99. package/src/api-routes/__tests__/webhooks.test.ts +19 -7
  100. package/src/api-routes/__tests__/websites-add.test.ts +2 -1
  101. package/src/api-routes/__tests__/websites-remove.test.ts +2 -1
  102. package/src/api-routes/_auth-guard.ts +47 -15
  103. package/src/api-routes/_commit-trailers.ts +3 -2
  104. package/src/api-routes/_dev-session-secret.ts +79 -0
  105. package/src/api-routes/_github-token.ts +1 -1
  106. package/src/api-routes/_pages-meta-store.ts +2 -2
  107. package/src/api-routes/_role-resolver.ts +7 -5
  108. package/src/api-routes/_session-secret.ts +46 -0
  109. package/src/api-routes/_session-signing.ts +135 -0
  110. package/src/api-routes/_vercel-origin.ts +2 -6
  111. package/src/api-routes/_webhook-dispatcher.ts +12 -16
  112. package/src/api-routes/_website-resolver.ts +3 -10
  113. package/src/api-routes/auth-callback.ts +9 -5
  114. package/src/api-routes/auth-login.ts +5 -3
  115. package/src/api-routes/auth-logout.ts +18 -1
  116. package/src/api-routes/auth-session.ts +13 -21
  117. package/src/api-routes/auth-setzkasten-login.ts +12 -9
  118. package/src/api-routes/catalog-add.ts +89 -31
  119. package/src/api-routes/catalog-export.ts +30 -10
  120. package/src/api-routes/config.ts +39 -6
  121. package/src/api-routes/deploy-hook.ts +13 -11
  122. package/src/api-routes/editors.ts +33 -22
  123. package/src/api-routes/github-proxy.ts +25 -11
  124. package/src/api-routes/global-config.ts +103 -18
  125. package/src/api-routes/history-rollback.ts +41 -14
  126. package/src/api-routes/history-version.ts +5 -6
  127. package/src/api-routes/history.ts +3 -3
  128. package/src/api-routes/icons-local.ts +2 -2
  129. package/src/api-routes/init-add-section.ts +218 -88
  130. package/src/api-routes/init-apply.ts +71 -56
  131. package/src/api-routes/init-migrate.ts +54 -48
  132. package/src/api-routes/init-scan-page.ts +77 -30
  133. package/src/api-routes/init-scan.ts +19 -11
  134. package/src/api-routes/pages.ts +16 -11
  135. package/src/api-routes/section-add.ts +98 -27
  136. package/src/api-routes/section-commit-pending.ts +87 -34
  137. package/src/api-routes/section-delete.ts +76 -27
  138. package/src/api-routes/section-duplicate.ts +95 -28
  139. package/src/api-routes/section-management.ts +3 -7
  140. package/src/api-routes/section-prepare-copy.ts +29 -8
  141. package/src/api-routes/section-prepare.ts +38 -10
  142. package/src/api-routes/setup-github-app-bounce.ts +7 -1
  143. package/src/api-routes/setup-github-app-branches.ts +6 -7
  144. package/src/api-routes/setup-github-app-callback.ts +18 -1
  145. package/src/api-routes/setup-github-app-credentials.ts +55 -0
  146. package/src/api-routes/setup-github-app-installed.ts +12 -1
  147. package/src/api-routes/setup-github-app-repos.ts +2 -3
  148. package/src/api-routes/setup-github-app.ts +14 -5
  149. package/src/api-routes/updater-check.ts +6 -4
  150. package/src/api-routes/updater-register.ts +34 -20
  151. package/src/api-routes/updater-transfer.ts +8 -6
  152. package/src/api-routes/updater-unbind.ts +14 -10
  153. package/src/api-routes/webhooks-test.ts +9 -11
  154. package/src/api-routes/webhooks.ts +15 -19
  155. package/src/init/__tests__/page-level.test.ts +279 -105
  156. package/src/init/__tests__/page-list-coverage.test.ts +70 -70
  157. package/src/init/__tests__/patcher-child-component.test.ts +126 -0
  158. package/src/init/__tests__/patcher-edge-cases.test.ts +47 -23
  159. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +16 -6
  160. package/src/init/__tests__/patcher-page-mode.test.ts +30 -20
  161. package/src/init/__tests__/section-pipeline.test.ts +102 -16
  162. package/src/init/astro-config-patcher.ts +4 -18
  163. package/src/init/astro-detector.ts +2 -7
  164. package/src/init/astro-section-analyzer-v2.ts +475 -193
  165. package/src/init/field-label-enricher.ts +6 -6
  166. package/src/init/template-patcher-v2.ts +490 -56
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Signed session cookies.
3
+ *
4
+ * Pre-C1 the session cookie was `JSON.stringify(session)` — readable and
5
+ * forgeable. An unauthenticated attacker could craft any role for any
6
+ * email and walk in. This module replaces that with HMAC-SHA256 over a
7
+ * canonical JSON payload, and refuses cookies whose signature doesn't
8
+ * match a known secret.
9
+ *
10
+ * Wire format:
11
+ *
12
+ * <base64url(JSON.stringify(payload))>.<base64url(HMAC-SHA256(payload))>
13
+ *
14
+ * Two segments separated by a literal `.`. Both segments are base64url
15
+ * (no padding). Anything that doesn't decode to that shape is rejected.
16
+ *
17
+ * The secret is supplied per-call so this module stays pure and keeps
18
+ * the secret-resolution policy (env var, cookie domain, fail-closed in
19
+ * prod) at the integration layer.
20
+ */
21
+
22
+ import { createHmac, timingSafeEqual } from 'node:crypto'
23
+ import type { AuthSession } from '@setzkasten-cms/core'
24
+
25
+ const SEPARATOR = '.'
26
+
27
+ function base64urlEncode(input: string | Buffer): string {
28
+ const buf = typeof input === 'string' ? Buffer.from(input, 'utf-8') : input
29
+ return buf.toString('base64url')
30
+ }
31
+
32
+ function base64urlDecode(input: string): Buffer | null {
33
+ if (!/^[A-Za-z0-9_-]+$/.test(input)) return null
34
+ try {
35
+ return Buffer.from(input, 'base64url')
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function hmac(payload: string, secret: string): Buffer {
42
+ return createHmac('sha256', secret).update(payload).digest()
43
+ }
44
+
45
+ /**
46
+ * Produces the cookie value for a verified session. Throws on missing
47
+ * secret — the caller (always server-side) must have one resolved.
48
+ */
49
+ export function signSession(session: AuthSession, secret: string): string {
50
+ if (typeof secret !== 'string' || secret.trim().length === 0) {
51
+ throw new Error('signSession: missing secret')
52
+ }
53
+ const json = JSON.stringify(session)
54
+ const payloadB64 = base64urlEncode(json)
55
+ const sigB64 = hmac(payloadB64, secret).toString('base64url')
56
+ return `${payloadB64}${SEPARATOR}${sigB64}`
57
+ }
58
+
59
+ export type VerifyResult =
60
+ | { readonly ok: true; readonly value: AuthSession }
61
+ | { readonly ok: false; readonly reason: VerifyFailureReason }
62
+
63
+ export type VerifyFailureReason =
64
+ | 'malformed'
65
+ | 'bad-signature'
66
+ | 'invalid-payload'
67
+ | 'expired'
68
+ | 'missing-expiry'
69
+
70
+ /**
71
+ * Returns the parsed session iff the signature matches AND the payload
72
+ * has a numeric `expiresAt` in the future. Rejects everything else.
73
+ *
74
+ * @param now Override timestamp for tests. Defaults to `Date.now()`.
75
+ */
76
+ export function verifySessionCookie(
77
+ raw: string,
78
+ secret: string,
79
+ now: number = Date.now(),
80
+ ): VerifyResult {
81
+ if (typeof raw !== 'string' || raw.length === 0) {
82
+ return { ok: false, reason: 'malformed' }
83
+ }
84
+ if (typeof secret !== 'string' || secret.trim().length === 0) {
85
+ return { ok: false, reason: 'malformed' }
86
+ }
87
+
88
+ const segments = raw.split(SEPARATOR)
89
+ if (segments.length !== 2) return { ok: false, reason: 'malformed' }
90
+ const [payloadB64, sigB64] = segments
91
+ if (!payloadB64 || !sigB64) return { ok: false, reason: 'malformed' }
92
+
93
+ const expected = hmac(payloadB64, secret)
94
+ const actual = base64urlDecode(sigB64)
95
+ if (!actual) return { ok: false, reason: 'malformed' }
96
+
97
+ // Reject obvious length mismatches before timing-safe-compare so we
98
+ // don't have to special-case Buffer.alloc; but always do the actual
99
+ // comparison constant-time when lengths agree.
100
+ if (actual.length !== expected.length) return { ok: false, reason: 'bad-signature' }
101
+ if (!timingSafeEqual(actual, expected)) return { ok: false, reason: 'bad-signature' }
102
+
103
+ const payloadBuf = base64urlDecode(payloadB64)
104
+ if (!payloadBuf) return { ok: false, reason: 'malformed' }
105
+
106
+ let parsed: unknown
107
+ try {
108
+ parsed = JSON.parse(payloadBuf.toString('utf-8'))
109
+ } catch {
110
+ return { ok: false, reason: 'invalid-payload' }
111
+ }
112
+
113
+ if (!isAuthSessionShape(parsed)) {
114
+ return { ok: false, reason: 'invalid-payload' }
115
+ }
116
+
117
+ if (typeof parsed.expiresAt !== 'number' || !Number.isFinite(parsed.expiresAt)) {
118
+ return { ok: false, reason: 'missing-expiry' }
119
+ }
120
+ if (parsed.expiresAt <= now) {
121
+ return { ok: false, reason: 'expired' }
122
+ }
123
+
124
+ return { ok: true, value: parsed }
125
+ }
126
+
127
+ function isAuthSessionShape(value: unknown): value is AuthSession {
128
+ if (!value || typeof value !== 'object') return false
129
+ const v = value as Record<string, unknown>
130
+ if (!v.user || typeof v.user !== 'object') return false
131
+ const u = v.user as Record<string, unknown>
132
+ if (typeof u.email !== 'string' || u.email.length === 0) return false
133
+ if (u.role !== 'admin' && u.role !== 'editor') return false
134
+ return true
135
+ }
@@ -12,11 +12,7 @@
12
12
  * or non-Vercel environments.
13
13
  */
14
14
  export function getPublicOrigin(request: Request): string {
15
- const host =
16
- request.headers.get('x-forwarded-host') ??
17
- request.headers.get('host') ??
18
- 'localhost'
19
- const proto =
20
- request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
15
+ const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? 'localhost'
16
+ const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
21
17
  return `${proto}://${host}`
22
18
  }
@@ -1,15 +1,15 @@
1
1
  import {
2
- parseWebhooksFile,
3
- selectWebhooksForEvent,
4
2
  type WebhookConfig,
5
3
  type WebhookEvent,
6
4
  type WebhookPayload,
5
+ parseWebhooksFile,
6
+ selectWebhooksForEvent,
7
7
  } from '@setzkasten-cms/core'
8
- import { resolveStorageConfigForRequest } from './_storage-config'
9
- import { resolveGitHubTokenForRequest } from './_github-token'
10
8
  import { cachedFetch } from './_github-cache'
11
- import { recordWebhookFire } from './_webhook-status-store'
9
+ import { resolveGitHubTokenForRequest } from './_github-token'
10
+ import { resolveStorageConfigForRequest } from './_storage-config'
12
11
  import { signPayload } from './_webhook-signing'
12
+ import { recordWebhookFire } from './_webhook-status-store'
13
13
 
14
14
  const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
15
15
  const DISPATCH_TIMEOUT_MS = 5_000
@@ -48,11 +48,7 @@ export async function fireWebhooks(
48
48
  }
49
49
  }
50
50
 
51
- async function fireOne(
52
- webhook: WebhookConfig,
53
- event: WebhookEvent,
54
- body: string,
55
- ): Promise<void> {
51
+ async function fireOne(webhook: WebhookConfig, event: WebhookEvent, body: string): Promise<void> {
56
52
  const headers: Record<string, string> = {
57
53
  'Content-Type': 'application/json',
58
54
  'X-Setzkasten-Event': event,
@@ -76,18 +72,18 @@ async function fireOne(
76
72
  }
77
73
  }
78
74
 
79
- async function loadWebhooksForRequest(
80
- request: Request,
81
- ): Promise<readonly WebhookConfig[] | null> {
75
+ async function loadWebhooksForRequest(request: Request): Promise<readonly WebhookConfig[] | null> {
82
76
  const tokenResult = await resolveGitHubTokenForRequest(request)
83
77
  if (!tokenResult.ok) return null
84
78
 
85
79
  const storage = await resolveStorageConfigForRequest(request)
86
80
  if (!storage) return null
87
81
 
88
- const serverConfig = (globalThis as {
89
- __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
90
- }).__SETZKASTEN_CONFIG__
82
+ const serverConfig = (
83
+ globalThis as {
84
+ __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
85
+ }
86
+ ).__SETZKASTEN_CONFIG__
91
87
  const contentPath = serverConfig?.storage?.contentPath ?? 'content'
92
88
  const { owner, repo, branch } = storage
93
89
 
@@ -103,14 +103,10 @@ export function bootstrapResolverFromGlobals(): void {
103
103
 
104
104
  const fullConfig =
105
105
  (typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null) ??
106
- ((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
107
- | FullConfig
108
- | undefined)
106
+ ((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as FullConfig | undefined)
109
107
  const buildStorage =
110
108
  (typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null) ??
111
- ((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as
112
- | BuildTimeStorage
113
- | undefined)
109
+ ((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as BuildTimeStorage | undefined)
114
110
 
115
111
  const storageKind = fullConfig?.storage?.kind
116
112
  if (isMultiKind(storageKind)) {
@@ -165,10 +161,7 @@ export function bootstrapResolverFromGlobals(): void {
165
161
  // PUBLIC_SITE_URL stays as an escape hatch for setups without `site:`.
166
162
  const websiteUrlLiteral =
167
163
  typeof __SETZKASTEN_WEBSITE_URL__ !== 'undefined' ? __SETZKASTEN_WEBSITE_URL__ : ''
168
- const previewOrigin =
169
- websiteUrlLiteral ||
170
- process.env.PUBLIC_SITE_URL ||
171
- 'http://localhost:4321'
164
+ const previewOrigin = websiteUrlLiteral || process.env.PUBLIC_SITE_URL || 'http://localhost:4321'
172
165
 
173
166
  state = {
174
167
  mode: 'single',
@@ -1,7 +1,9 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { createGitHubAuth } from '@setzkasten-cms/auth'
3
- import { sessionCookieOptions } from './_session-cookie.js'
2
+ import type { APIRoute } from 'astro'
4
3
  import { makeRoleResolver } from './_role-resolver'
4
+ import { sessionCookieOptions } from './_session-cookie.js'
5
+ import { resolveSessionSecret } from './_session-secret.js'
6
+ import { signSession } from './_session-signing'
5
7
 
6
8
  /**
7
9
  * GitHub OAuth callback handler.
@@ -38,10 +40,12 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
38
40
  const adminPath = config?.adminPath ?? '/admin'
39
41
 
40
42
  const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
41
- const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
43
+ const protocol =
44
+ request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
42
45
  const redirectUri = `${protocol}://${host}/api/setzkasten/auth/callback`
43
46
 
44
- const allowedEmailsRaw = import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
47
+ const allowedEmailsRaw =
48
+ import.meta.env.SETZKASTEN_ALLOWED_EMAILS ?? process.env.SETZKASTEN_ALLOWED_EMAILS ?? ''
45
49
  const allowedEmails = allowedEmailsRaw
46
50
  ? allowedEmailsRaw.split(',').map((e: string) => e.trim())
47
51
  : undefined
@@ -64,7 +68,7 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
64
68
  const session = sessionResult.value
65
69
  cookies.set(
66
70
  'setzkasten_session',
67
- JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
71
+ signSession({ user: session.user, expiresAt: session.expiresAt }, resolveSessionSecret()),
68
72
  sessionCookieOptions(import.meta.env.PROD),
69
73
  )
70
74
 
@@ -1,5 +1,5 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { createGitHubAuth } from '@setzkasten-cms/auth'
2
+ import type { APIRoute } from 'astro'
3
3
 
4
4
  /**
5
5
  * Login initiation – redirects the user to GitHub OAuth.
@@ -14,12 +14,14 @@ export const GET: APIRoute = async ({ request, url, cookies, redirect }) => {
14
14
 
15
15
  // On Vercel, url.origin may resolve to localhost. Use the Host header instead.
16
16
  const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host
17
- const protocol = request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
17
+ const protocol =
18
+ request.headers.get('x-forwarded-proto') ?? (url.protocol === 'https:' ? 'https' : 'http')
18
19
  const origin = `${protocol}://${host}`
19
20
  const redirectUri = `${origin}/api/setzkasten/auth/callback`
20
21
 
21
22
  const ghClientId = import.meta.env.GITHUB_CLIENT_ID ?? process.env.GITHUB_CLIENT_ID ?? ''
22
- const ghClientSecret = import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
23
+ const ghClientSecret =
24
+ import.meta.env.GITHUB_CLIENT_SECRET ?? process.env.GITHUB_CLIENT_SECRET ?? ''
23
25
 
24
26
  if (!ghClientId || !ghClientSecret) {
25
27
  return new Response('GitHub OAuth not configured', { status: 500 })
@@ -5,9 +5,26 @@ import { sessionCookieOptions } from './_session-cookie.js'
5
5
  * Logout – clears the session cookie and redirects to home.
6
6
  * Mirrors the same `domain` attribute used when the cookie was set so the
7
7
  * browser actually deletes it on subdomains in standalone-admin setups.
8
+ *
9
+ * POST-only post-M4: a third-party site embedding `<img src=
10
+ * "https://your-cms/api/setzkasten/logout">` could log out admins
11
+ * mid-session under the GET form. The SPA's logout button does a POST
12
+ * (or a fetch) so this is purely a CSRF-grade tightening.
8
13
  */
9
- export const GET: APIRoute = async ({ cookies, redirect }) => {
14
+ async function handleLogout({ cookies, redirect }: Parameters<APIRoute>[0]) {
10
15
  const opts = sessionCookieOptions(false)
11
16
  cookies.delete('setzkasten_session', { path: '/', domain: opts.domain })
12
17
  return redirect('/')
13
18
  }
19
+
20
+ export const POST: APIRoute = handleLogout
21
+
22
+ /**
23
+ * Backwards-compat: a few existing UI links still hit GET. We keep the
24
+ * handler but log a deprecation warning so the SPA can be migrated.
25
+ */
26
+ export const GET: APIRoute = async (ctx) => {
27
+ // eslint-disable-next-line no-console
28
+ console.warn('[setzkasten] GET /api/setzkasten/logout is deprecated — use POST')
29
+ return handleLogout(ctx)
30
+ }
@@ -1,36 +1,28 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { parseSession } from './_auth-guard'
2
3
 
3
4
  /**
4
5
  * Session check – returns current user info or 401.
5
6
  * Used by the admin SPA to check if the user is logged in.
7
+ *
8
+ * Goes through `parseSession` so signature verification + expiry are
9
+ * enforced in exactly one place. Pre-C1 this route did its own
10
+ * `JSON.parse` — the only one that ever checked `expiresAt`, while
11
+ * `_auth-guard.parseSession` ignored it. Now both run through the
12
+ * same verifier.
6
13
  */
7
14
  export const GET: APIRoute = async ({ cookies }) => {
8
- const session = cookies.get('setzkasten_session')?.value
15
+ const raw = cookies.get('setzkasten_session')?.value
16
+ const session = parseSession(raw)
9
17
  if (!session) {
18
+ if (raw) cookies.delete('setzkasten_session', { path: '/' })
10
19
  return new Response(JSON.stringify({ authenticated: false }), {
11
20
  status: 401,
12
21
  headers: { 'Content-Type': 'application/json' },
13
22
  })
14
23
  }
15
24
 
16
- try {
17
- const parsed = JSON.parse(session) as { user: unknown; expiresAt: number }
18
-
19
- if (parsed.expiresAt < Date.now()) {
20
- cookies.delete('setzkasten_session', { path: '/' })
21
- return new Response(JSON.stringify({ authenticated: false, reason: 'expired' }), {
22
- status: 401,
23
- headers: { 'Content-Type': 'application/json' },
24
- })
25
- }
26
-
27
- return new Response(JSON.stringify({ authenticated: true, user: parsed.user }), {
28
- headers: { 'Content-Type': 'application/json' },
29
- })
30
- } catch {
31
- return new Response(JSON.stringify({ authenticated: false }), {
32
- status: 401,
33
- headers: { 'Content-Type': 'application/json' },
34
- })
35
- }
25
+ return new Response(JSON.stringify({ authenticated: true, user: session.user }), {
26
+ headers: { 'Content-Type': 'application/json' },
27
+ })
36
28
  }
@@ -1,12 +1,14 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { verifyFirebaseJwt } from '@setzkasten-cms/auth'
3
- import { readEditorsFile } from './editors'
4
- import { readGlobalConfig } from './global-config'
5
- import { resolveStorageConfig } from './_storage-config'
2
+ import type { APIRoute } from 'astro'
3
+ import { gateFeature } from './_feature-gate'
6
4
  import { resolveConfigRepoToken } from './_github-token'
7
- import { sessionCookieOptions } from './_session-cookie.js'
8
5
  import { makeRoleResolver } from './_role-resolver'
9
- import { gateFeature } from './_feature-gate'
6
+ import { sessionCookieOptions } from './_session-cookie.js'
7
+ import { resolveSessionSecret } from './_session-secret.js'
8
+ import { signSession } from './_session-signing'
9
+ import { resolveStorageConfig } from './_storage-config'
10
+ import { readEditorsFile } from './editors'
11
+ import { readGlobalConfig } from './global-config'
10
12
 
11
13
  /**
12
14
  * POST /api/setzkasten/auth/setzkasten-login
@@ -40,8 +42,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
40
42
  return new Response('Storage not configured', { status: 500 })
41
43
  }
42
44
  const { owner, repo, branch } = storage
43
- const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
44
- .__SETZKASTEN_CONFIG__
45
+ const serverConfig = (
46
+ globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } }
47
+ ).__SETZKASTEN_CONFIG__
45
48
  const contentPath: string = serverConfig?.storage?.contentPath ?? 'content'
46
49
 
47
50
  const tokenResult = await resolveConfigRepoToken()
@@ -78,7 +81,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
78
81
  const session = result.value
79
82
  cookies.set(
80
83
  'setzkasten_session',
81
- JSON.stringify({ user: session.user, expiresAt: session.expiresAt }),
84
+ signSession({ user: session.user, expiresAt: session.expiresAt }, resolveSessionSecret()),
82
85
  sessionCookieOptions(import.meta.env.PROD),
83
86
  )
84
87
 
@@ -1,11 +1,11 @@
1
- import type { APIRoute } from 'astro'
2
1
  import { registry } from '@setzkasten-cms/catalog'
3
- import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
4
- import { validateCatalogAddBody, buildCatalogAddCommit } from './catalog-helpers'
5
- import { generateAddKey, addToPageConfig } from './section-management'
6
- import { parseSession, guardPageAccess } from './_auth-guard'
2
+ import type { APIRoute } from 'astro'
3
+ import { guardPageAccess, parseSession } from './_auth-guard'
7
4
  import { withTrailers } from './_commit-trailers'
8
5
  import { resolveGitHubTokenForRequest } from './_github-token'
6
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
7
+ import { buildCatalogAddCommit, validateCatalogAddBody } from './catalog-helpers'
8
+ import { addToPageConfig, generateAddKey } from './section-management'
9
9
 
10
10
  /**
11
11
  * POST /api/setzkasten/catalog/add
@@ -27,13 +27,16 @@ export const POST: APIRoute = async ({ request, cookies }) => {
27
27
  const githubToken = tokenResult.value
28
28
 
29
29
  try {
30
- const body = await request.json() as Record<string, unknown>
30
+ const body = (await request.json()) as Record<string, unknown>
31
31
 
32
32
  let validated: ReturnType<typeof validateCatalogAddBody>
33
33
  try {
34
34
  validated = validateCatalogAddBody(body)
35
35
  } catch (e) {
36
- return Response.json({ error: e instanceof Error ? e.message : 'Invalid request' }, { status: 400 })
36
+ return Response.json(
37
+ { error: e instanceof Error ? e.message : 'Invalid request' },
38
+ { status: 400 },
39
+ )
37
40
  }
38
41
 
39
42
  const { templateName, pageKey } = validated
@@ -44,9 +47,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
44
47
 
45
48
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
46
49
  const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
47
- const contentPath = (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
50
+ const contentPath =
51
+ (body.contentPath as string) || serverConfig?.storage?.contentPath || 'content'
48
52
 
49
- const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
53
+ const denied = await guardPageAccess(
54
+ parseSession(cookies.get('setzkasten_session')?.value),
55
+ pageKey,
56
+ fullConfig,
57
+ request,
58
+ )
50
59
  if (denied) return denied
51
60
 
52
61
  const headers = {
@@ -69,7 +78,10 @@ export const POST: APIRoute = async ({ request, cookies }) => {
69
78
  // 2. Determine section key
70
79
  const sectionKey = validated.sectionKey ?? generateAddKey(existingKeys, templateName)
71
80
  if (existingKeys.includes(sectionKey)) {
72
- return Response.json({ error: `Key "${sectionKey}" already exists on this page` }, { status: 409 })
81
+ return Response.json(
82
+ { error: `Key "${sectionKey}" already exists on this page` },
83
+ { status: 409 },
84
+ )
73
85
  }
74
86
 
75
87
  // 3. Get template + default content
@@ -87,7 +99,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
87
99
  const updatedConfig = addToPageConfig(pageConfig, sectionKey, templateName)
88
100
 
89
101
  const commitResult = await batchCommit(
90
- owner, repo, branch,
102
+ owner,
103
+ repo,
104
+ branch,
91
105
  [
92
106
  { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
93
107
  { path: sectionJsonPath, content: JSON.stringify(template.defaultContent, null, 2) },
@@ -111,50 +125,94 @@ export const POST: APIRoute = async ({ request, cookies }) => {
111
125
  }
112
126
  }
113
127
 
114
- async function fetchFileContent(owner: string, repo: string, branch: string, path: string, token: string): Promise<string | null> {
128
+ async function fetchFileContent(
129
+ owner: string,
130
+ repo: string,
131
+ branch: string,
132
+ path: string,
133
+ token: string,
134
+ ): Promise<string | null> {
115
135
  try {
116
136
  const res = await fetch(
117
137
  `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
118
- { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } },
138
+ {
139
+ headers: {
140
+ Authorization: `Bearer ${token}`,
141
+ Accept: 'application/vnd.github+json',
142
+ 'X-GitHub-Api-Version': '2022-11-28',
143
+ },
144
+ },
119
145
  )
120
146
  if (!res.ok) return null
121
- const data = await res.json() as { content: string; encoding: string }
122
- return data.encoding === 'base64' ? Buffer.from(data.content, 'base64').toString('utf-8') : data.content
123
- } catch { return null }
147
+ const data = (await res.json()) as { content: string; encoding: string }
148
+ return data.encoding === 'base64'
149
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
150
+ : data.content
151
+ } catch {
152
+ return null
153
+ }
124
154
  }
125
155
 
126
156
  async function batchCommit(
127
- owner: string, repo: string, branch: string,
157
+ owner: string,
158
+ repo: string,
159
+ branch: string,
128
160
  files: Array<{ path: string; content: string }>,
129
161
  message: string,
130
162
  headers: Record<string, string>,
131
163
  ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
132
164
  try {
133
- const refRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { headers })
165
+ const refRes = await fetch(
166
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
167
+ { headers },
168
+ )
134
169
  if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
135
- const { object: { sha: headSha } } = await refRes.json() as { object: { sha: string } }
170
+ const {
171
+ object: { sha: headSha },
172
+ } = (await refRes.json()) as { object: { sha: string } }
136
173
 
137
- const commitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`, { headers })
174
+ const commitRes = await fetch(
175
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
176
+ { headers },
177
+ )
138
178
  if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
139
- const { tree: { sha: baseSha } } = await commitRes.json() as { tree: { sha: string } }
179
+ const {
180
+ tree: { sha: baseSha },
181
+ } = (await commitRes.json()) as { tree: { sha: string } }
140
182
 
141
183
  const treeRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
142
- method: 'POST', headers,
143
- body: JSON.stringify({ base_tree: baseSha, tree: files.map(f => ({ path: f.path, mode: '100644', type: 'blob', content: f.content })) }),
184
+ method: 'POST',
185
+ headers,
186
+ body: JSON.stringify({
187
+ base_tree: baseSha,
188
+ tree: files.map((f) => ({
189
+ path: f.path,
190
+ mode: '100644',
191
+ type: 'blob',
192
+ content: f.content,
193
+ })),
194
+ }),
144
195
  })
145
196
  if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
146
- const { sha: treeSha } = await treeRes.json() as { sha: string }
197
+ const { sha: treeSha } = (await treeRes.json()) as { sha: string }
147
198
 
148
199
  const newCommitRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
149
- method: 'POST', headers,
200
+ method: 'POST',
201
+ headers,
150
202
  body: JSON.stringify({ tree: treeSha, parents: [headSha], message }),
151
203
  })
152
- if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
153
- const { sha: newSha } = await newCommitRes.json() as { sha: string }
154
-
155
- const updateRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, {
156
- method: 'PATCH', headers, body: JSON.stringify({ sha: newSha }),
157
- })
204
+ if (!newCommitRes.ok)
205
+ return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
206
+ const { sha: newSha } = (await newCommitRes.json()) as { sha: string }
207
+
208
+ const updateRes = await fetch(
209
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
210
+ {
211
+ method: 'PATCH',
212
+ headers,
213
+ body: JSON.stringify({ sha: newSha }),
214
+ },
215
+ )
158
216
  if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
159
217
 
160
218
  return { ok: true, sha: newSha }