@setzkasten-cms/astro-admin 0.8.0 → 1.1.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 (68) hide show
  1. package/package.json +16 -6
  2. package/src/admin-page.astro +1 -1
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
@@ -0,0 +1,255 @@
1
+ import { type WebsiteEntry, canAddWebsite, isMultiModeAvailable } from '@setzkasten-cms/core'
2
+ import type { APIRoute } from 'astro'
3
+ import { parseSession } from './_auth-guard'
4
+ import { withTrailers } from './_commit-trailers'
5
+ import { resolveConfigRepoToken } from './_github-token'
6
+ import { resolveLicenseTier } from './_license-tier'
7
+ import { resolveStorageConfig } from './_storage-config'
8
+
9
+ /**
10
+ * POST /api/setzkasten/migrate/to-multi
11
+ *
12
+ * Body: { configRepo: 'owner/repo', configInstallationId: string,
13
+ * previewOrigin?: string }
14
+ *
15
+ * Admin-only. Expects the deployer to have already (1) created the
16
+ * config repo and (2) installed the GitHub App on it. The endpoint then:
17
+ *
18
+ * 1. Reads `_editors.json` and `_global_config.json` from the current
19
+ * single-mode website repo.
20
+ * 2. Writes both files into `<config-repo>/content/`.
21
+ * 3. Initialises `<config-repo>/websites.json` with a single entry that
22
+ * snapshots the current single-mode setup (repo, branch, preview
23
+ * origin, App-Installation-ID).
24
+ *
25
+ * After a 200 response the deployer still has to update
26
+ * `setzkasten.config.ts` (kind: 'single' → 'multi') and the ENV
27
+ * variables and redeploy. The wizard surfaces a copy-ready diff for
28
+ * those manual steps.
29
+ */
30
+ export const POST: APIRoute = async ({ request, cookies }) => {
31
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
32
+ if (!session) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
33
+ if (session.user.role !== 'admin')
34
+ return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
35
+
36
+ const tier = resolveLicenseTier()
37
+ if (!isMultiModeAvailable(tier)) {
38
+ return new Response(
39
+ JSON.stringify({
40
+ error: 'Multi-Mode ist nur mit Pro- oder Enterprise-Lizenz verfügbar.',
41
+ tier,
42
+ }),
43
+ { status: 402, headers: { 'Content-Type': 'application/json' } },
44
+ )
45
+ }
46
+
47
+ let body: { configRepo?: string; configInstallationId?: string; previewOrigin?: string } = {}
48
+ try {
49
+ body = (await request.json()) as typeof body
50
+ } catch {
51
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400 })
52
+ }
53
+
54
+ if (!body.configRepo || typeof body.configRepo !== 'string' || !body.configRepo.includes('/')) {
55
+ return new Response(JSON.stringify({ error: 'configRepo (owner/repo) ist erforderlich' }), {
56
+ status: 400,
57
+ })
58
+ }
59
+ if (!body.configInstallationId || typeof body.configInstallationId !== 'string') {
60
+ return new Response(JSON.stringify({ error: 'configInstallationId ist erforderlich' }), {
61
+ status: 400,
62
+ })
63
+ }
64
+
65
+ const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
66
+ | { storage?: { kind?: string } }
67
+ | undefined
68
+ if (
69
+ fullConfig?.storage?.kind &&
70
+ fullConfig.storage.kind !== 'single' &&
71
+ fullConfig.storage.kind !== 'github-app'
72
+ ) {
73
+ return new Response(
74
+ JSON.stringify({
75
+ error: `Migration nur aus dem Single-Mode möglich. Aktueller storage.kind: ${fullConfig.storage.kind ?? 'unbekannt'}`,
76
+ }),
77
+ { status: 400 },
78
+ )
79
+ }
80
+
81
+ // We need the source-tier slot just in case the config repo already has
82
+ // entries — defensively preflight the limit so we never half-migrate.
83
+ const allowed = canAddWebsite(tier, 0)
84
+ if (!allowed.ok) {
85
+ return new Response(JSON.stringify({ error: allowed.reason }), { status: 402 })
86
+ }
87
+
88
+ const sourceStorage = resolveStorageConfig()
89
+ if (!sourceStorage) {
90
+ return new Response(
91
+ JSON.stringify({ error: 'Single-Mode Storage konnte nicht aufgelöst werden' }),
92
+ { status: 500 },
93
+ )
94
+ }
95
+
96
+ const tokenResult = await resolveConfigRepoToken()
97
+ if (!tokenResult.ok) {
98
+ return new Response(JSON.stringify({ error: tokenResult.error.message }), { status: 500 })
99
+ }
100
+ const token = tokenResult.value
101
+
102
+ const [configOwner, configRepo] = body.configRepo.split('/')
103
+ if (!configOwner || !configRepo) {
104
+ return new Response(JSON.stringify({ error: 'configRepo muss "owner/repo" sein' }), {
105
+ status: 400,
106
+ })
107
+ }
108
+
109
+ const sourceContentPath: string =
110
+ (
111
+ (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
112
+ | { storage?: { contentPath?: string } }
113
+ | undefined
114
+ )?.storage?.contentPath ?? 'content'
115
+
116
+ const headers = {
117
+ Authorization: `Bearer ${token}`,
118
+ Accept: 'application/vnd.github+json',
119
+ 'X-GitHub-Api-Version': '2022-11-28',
120
+ 'Content-Type': 'application/json',
121
+ }
122
+
123
+ const ghBase = (owner: string, repo: string, path: string) =>
124
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`
125
+
126
+ // 1. Read editors + global from website repo (best-effort — both files
127
+ // are optional in single-mode setups).
128
+ const sourceEditors = await readOptional(
129
+ ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_editors.json`),
130
+ `?ref=${sourceStorage.branch}`,
131
+ headers,
132
+ )
133
+ const sourceGlobal = await readOptional(
134
+ ghBase(sourceStorage.owner, sourceStorage.repo, `${sourceContentPath}/_global_config.json`),
135
+ `?ref=${sourceStorage.branch}`,
136
+ headers,
137
+ )
138
+
139
+ // 2. Write copies into config repo. (Branch defaults to 'main' since the
140
+ // user just created the repo; we don't expose a branch override here.)
141
+ const configBranch = 'main'
142
+
143
+ const editorsCommit = sourceEditors
144
+ ? await putFile(
145
+ ghBase(configOwner, configRepo, 'content/_editors.json'),
146
+ sourceEditors,
147
+ configBranch,
148
+ 'chore(migrate): copy editors from website repo',
149
+ headers,
150
+ )
151
+ : true
152
+
153
+ const globalCommit = sourceGlobal
154
+ ? await putFile(
155
+ ghBase(configOwner, configRepo, 'content/_global_config.json'),
156
+ sourceGlobal,
157
+ configBranch,
158
+ 'chore(migrate): copy global config from website repo',
159
+ headers,
160
+ )
161
+ : true
162
+
163
+ // 3. Initialise websites.json with the current setup as the first entry.
164
+ const previewOrigin = body.previewOrigin ?? process.env.PUBLIC_SITE_URL ?? 'http://localhost:4321'
165
+
166
+ const initialEntry: WebsiteEntry = {
167
+ id: 'main',
168
+ name: sourceStorage.repo,
169
+ repo: `${sourceStorage.owner}/${sourceStorage.repo}`,
170
+ branch: sourceStorage.branch,
171
+ previewOrigin,
172
+ githubApp: {
173
+ appId: process.env.GITHUB_APP_ID ?? '',
174
+ installationId: process.env.GITHUB_APP_INSTALLATION_ID ?? '',
175
+ },
176
+ }
177
+
178
+ const websitesContent = JSON.stringify({ websites: [initialEntry] }, null, 2)
179
+
180
+ const websitesCommit = await putFile(
181
+ ghBase(configOwner, configRepo, 'websites.json'),
182
+ websitesContent,
183
+ configBranch,
184
+ 'feat(migrate): initialise websites.json with current single-mode setup',
185
+ headers,
186
+ )
187
+
188
+ return new Response(
189
+ JSON.stringify({
190
+ ok: true,
191
+ committed: {
192
+ editors: editorsCommit,
193
+ globalConfig: globalCommit,
194
+ websites: websitesCommit,
195
+ },
196
+ // Echo back the values the user still has to set themselves.
197
+ manual: {
198
+ configRepo: body.configRepo,
199
+ configInstallationId: body.configInstallationId,
200
+ configBranch,
201
+ envChanges: {
202
+ add: {
203
+ SETZKASTEN_CONFIG_REPO: body.configRepo,
204
+ SETZKASTEN_CONFIG_BRANCH: configBranch,
205
+ GITHUB_APP_CONFIG_INSTALLATION_ID: body.configInstallationId,
206
+ },
207
+ },
208
+ },
209
+ }),
210
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
211
+ )
212
+ }
213
+
214
+ async function readOptional(
215
+ url: string,
216
+ qs: string,
217
+ headers: Record<string, string>,
218
+ ): Promise<string | null> {
219
+ const res = await fetch(url + qs, { headers })
220
+ if (!res.ok) return null
221
+ const data = (await res.json()) as { content: string; encoding: string }
222
+ return data.encoding === 'base64'
223
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
224
+ : data.content
225
+ }
226
+
227
+ async function putFile(
228
+ url: string,
229
+ content: string,
230
+ branch: string,
231
+ message: string,
232
+ headers: Record<string, string>,
233
+ ): Promise<boolean> {
234
+ // Read existing SHA so we can update an existing file rather than 422.
235
+ let sha: string | undefined
236
+ try {
237
+ const existing = await fetch(`${url}?ref=${branch}`, { headers })
238
+ if (existing.ok) {
239
+ const data = (await existing.json()) as { sha?: string }
240
+ sha = data.sha
241
+ }
242
+ } catch {
243
+ /* file doesn't exist — fine */
244
+ }
245
+
246
+ const body: Record<string, unknown> = {
247
+ message: withTrailers(message),
248
+ content: Buffer.from(content).toString('base64'),
249
+ branch,
250
+ }
251
+ if (sha) body.sha = sha
252
+
253
+ const res = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(body) })
254
+ return res.ok
255
+ }
@@ -1,10 +1,17 @@
1
1
  import type { APIRoute } from 'astro'
2
+ import { resolveGitHubTokenForRequest } from './_github-token'
3
+ import { readPagesMeta, type PagesMetaTarget } from './_pages-meta-store'
4
+ import { resolveStorageConfigForRequest } from './_storage-config'
5
+ import { cachedFetch } from './_github-cache'
2
6
 
3
7
  interface PageInfo {
4
8
  path: string
5
9
  pageKey: string
6
10
  label: string
7
11
  hasConfig: boolean
12
+ /** Unix-ms timestamp of the page's last Setzkasten-driven commit, when
13
+ * `_pages-meta.json` knows about the page. */
14
+ lastModified?: number
8
15
  }
9
16
 
10
17
  // Build-time constant injected by the Vite define plugin — always available in
@@ -15,6 +22,10 @@ declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
15
22
  * Returns the list of pages scanned at build time.
16
23
  * Reads the Vite build-time constant first; falls back to globalThis for
17
24
  * local dev / test environments where the define is not applied.
25
+ *
26
+ * Only valid in single-mode where the admin and the website share the same
27
+ * Astro project. Multi-mode has to fetch the page list per website at
28
+ * runtime (see {@link fetchPagesFromGitHub}).
18
29
  */
19
30
  export function resolvePages(): PageInfo[] {
20
31
  const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
@@ -23,13 +34,116 @@ export function resolvePages(): PageInfo[] {
23
34
 
24
35
  /**
25
36
  * GET /api/setzkasten/pages
26
- * Returns the list of pages detected at build time.
37
+ *
38
+ * Single-mode: returns the build-time scan from the admin's own Astro
39
+ * project, enriched with `_pages-meta.json` timestamps from the
40
+ * (single) repo.
41
+ *
42
+ * Multi-mode: the X-SK-Website header selects one of the registered
43
+ * websites. The admin doesn't have build-time access to that website's
44
+ * `src/pages/` directory, so we fetch it via the GitHub Contents API
45
+ * (cached for 5 min), then enrich with the per-website
46
+ * `_pages-meta.json`. Without this branch, every website in Multi-Mode
47
+ * would see the admin's own page list — typically a single "index"
48
+ * stub — instead of its real pages.
27
49
  */
28
- export const GET: APIRoute = async () => {
29
- const pages = resolvePages()
50
+ export const GET: APIRoute = async ({ request }) => {
51
+ const isMulti = request.headers.get('x-sk-website') !== null
52
+ const pages = isMulti
53
+ ? await fetchPagesFromGitHub(request).catch(() => [])
54
+ : resolvePages()
30
55
 
31
- return new Response(JSON.stringify({ pages }), {
56
+ const enriched = await enrichWithLastModified(pages, request).catch(() => pages)
57
+
58
+ return new Response(JSON.stringify({ pages: enriched }), {
32
59
  status: 200,
33
60
  headers: { 'Content-Type': 'application/json' },
34
61
  })
35
62
  }
63
+
64
+ interface ContentsApiEntry {
65
+ type: 'file' | 'dir' | 'symlink' | 'submodule'
66
+ name: string
67
+ path: string
68
+ }
69
+
70
+ /**
71
+ * Lists the website's `src/pages/` directory via the GitHub Contents API
72
+ * and turns it into PageInfo entries. Only top-level `.astro` files
73
+ * (excluding `_layout.astro` and other underscore-prefixed privates and
74
+ * dynamic `[slug].astro` routes) become editable pages.
75
+ *
76
+ * Cached for 5 minutes per (owner, repo, branch) — the page list is a
77
+ * structural change that rarely happens during a normal editing session.
78
+ */
79
+ async function fetchPagesFromGitHub(request: Request): Promise<PageInfo[]> {
80
+ const storage = await resolveStorageConfigForRequest(request)
81
+ if (!storage) return []
82
+
83
+ const tokenResult = await resolveGitHubTokenForRequest(request)
84
+ if (!tokenResult.ok) return []
85
+
86
+ const { owner, repo, branch } = storage
87
+ const cacheKey = `pages-list:${owner}/${repo}:${branch}`
88
+ return cachedFetch(cacheKey, 5 * 60_000, async () => {
89
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/src/pages?ref=${branch}`
90
+ const res = await fetch(url, {
91
+ headers: {
92
+ Authorization: `Bearer ${tokenResult.value}`,
93
+ Accept: 'application/vnd.github+json',
94
+ 'X-GitHub-Api-Version': '2022-11-28',
95
+ },
96
+ })
97
+ if (!res.ok) return []
98
+ const entries = (await res.json()) as ContentsApiEntry[]
99
+ if (!Array.isArray(entries)) return []
100
+
101
+ const pages: PageInfo[] = []
102
+ for (const entry of entries) {
103
+ if (entry.type !== 'file') continue
104
+ if (!entry.name.endsWith('.astro')) continue
105
+ // Skip privates (_layout.astro etc.) and dynamic routes ([slug].astro).
106
+ if (entry.name.startsWith('_') || entry.name.startsWith('[')) continue
107
+ const pageKey = entry.name.slice(0, -'.astro'.length)
108
+ pages.push({
109
+ path: entry.path,
110
+ pageKey,
111
+ label: pageKey === 'index' ? 'Startseite' : pageKey,
112
+ hasConfig: true,
113
+ })
114
+ }
115
+ return pages
116
+ })
117
+ }
118
+
119
+ async function enrichWithLastModified(
120
+ pages: PageInfo[],
121
+ request: Request,
122
+ ): Promise<PageInfo[]> {
123
+ if (pages.length === 0) return pages
124
+
125
+ const storage = await resolveStorageConfigForRequest(request)
126
+ if (!storage) return pages
127
+
128
+ const tokenResult = await resolveGitHubTokenForRequest(request)
129
+ if (!tokenResult.ok) return pages
130
+
131
+ const serverConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_CONFIG__ as
132
+ | { storage?: { contentPath?: string } }
133
+ | undefined
134
+ const target: PagesMetaTarget = {
135
+ owner: storage.owner,
136
+ repo: storage.repo,
137
+ branch: storage.branch,
138
+ contentPath: serverConfig?.storage?.contentPath ?? 'content',
139
+ token: tokenResult.value,
140
+ }
141
+
142
+ const meta = await readPagesMeta(target)
143
+ if (!meta.ok) return pages
144
+
145
+ return pages.map((p) => {
146
+ const ts = meta.value.meta.pages[p.pageKey]?.lastModified
147
+ return ts !== undefined ? { ...p, lastModified: ts } : p
148
+ })
149
+ }
@@ -1,8 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig, prefixPath } from './_storage-config'
2
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { generateAddKey, addToPageConfig } from './section-management'
4
4
  import { parseSession, guardPageAccess } from './_auth-guard'
5
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  /**
8
9
  * POST /api/setzkasten/sections/add
@@ -21,8 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
21
22
  const session = cookies.get('setzkasten_session')?.value
22
23
  if (!session) return new Response('Unauthorized', { status: 401 })
23
24
 
24
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
25
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
25
+ const tokenResult = await resolveGitHubTokenForRequest(request)
26
+ if (!tokenResult.ok) {
27
+ return new Response(tokenResult.error.message, { status: 500 })
28
+ }
29
+ const githubToken = tokenResult.value
26
30
 
27
31
  try {
28
32
  const body = await request.json() as {
@@ -36,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
36
40
  contentPath?: string
37
41
  }
38
42
 
39
- const storage = resolveStorageConfig(body)
43
+ const storage = await resolveStorageConfigForRequest(request, body)
40
44
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
41
45
  const { owner, repo, branch, projectPrefix } = storage
42
46
 
@@ -49,7 +53,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
49
53
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
50
54
  }
51
55
 
52
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
56
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
53
57
  if (denied) return denied
54
58
 
55
59
  const headers = {
@@ -111,6 +115,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
111
115
 
112
116
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
113
117
 
118
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
119
+ await recordPageEdit(
120
+ { owner, repo, branch, contentPath, token: tokenResult.value },
121
+ pageKey,
122
+ ).catch(() => {})
123
+
114
124
  return Response.json({ success: true, newKey, commitSha: commitResult.sha })
115
125
  } catch (error) {
116
126
  console.error('[setzkasten] section-add error:', error)
@@ -1,9 +1,10 @@
1
1
  import type { APIRoute } from 'astro'
2
2
  import { writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
- import { resolveStorageConfig } from './_storage-config'
4
+ import { resolveStorageConfigForRequest } from './_storage-config'
5
5
  import { parseSession, guardPageAccess } from './_auth-guard'
6
6
  import { withTrailers } from './_commit-trailers'
7
+ import { resolveGitHubTokenForRequest } from './_github-token'
7
8
 
8
9
  /**
9
10
  * POST /api/setzkasten/sections/commit-pending
@@ -22,8 +23,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
22
23
  const session = cookies.get('setzkasten_session')?.value
23
24
  if (!session) return new Response('Unauthorized', { status: 401 })
24
25
 
25
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
26
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
26
+ const tokenResult = await resolveGitHubTokenForRequest(request)
27
+ if (!tokenResult.ok) {
28
+ return new Response(tokenResult.error.message, { status: 500 })
29
+ }
30
+ const githubToken = tokenResult.value
27
31
 
28
32
  try {
29
33
  const body = await request.json() as {
@@ -37,7 +41,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
37
41
  contentPath?: string
38
42
  }
39
43
 
40
- const storage = resolveStorageConfig(body)
44
+ const storage = await resolveStorageConfigForRequest(request, body)
41
45
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
42
46
  const { owner, repo, branch } = storage
43
47
 
@@ -50,7 +54,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
50
54
  return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
51
55
  }
52
56
 
53
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
57
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
54
58
  if (denied) return denied
55
59
 
56
60
  const headers = {
@@ -106,6 +110,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
106
110
  )
107
111
  }
108
112
 
113
+ // Best-effort recency tracking. Metadata write must not derail the
114
+ // primary save — surface failures via the trailing return only.
115
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
116
+ const metaContentPath: string = serverConfig?.storage?.contentPath ?? 'content'
117
+ await recordPageEdit(
118
+ { owner, repo, branch, contentPath: metaContentPath, token: tokenResult.value },
119
+ pageKey,
120
+ ).catch(() => {})
121
+
109
122
  return Response.json({ success: true, commitSha: commitResult.sha })
110
123
  } catch (error) {
111
124
  console.error('[setzkasten] section-commit-pending error:', error)
@@ -1,8 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig, prefixPath } from './_storage-config'
2
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { removeFromPageConfig } from './section-management'
4
4
  import { parseSession, guardPageAccess } from './_auth-guard'
5
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  /**
8
9
  * DELETE /api/setzkasten/sections
@@ -18,8 +19,11 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
18
19
  const session = cookies.get('setzkasten_session')?.value
19
20
  if (!session) return new Response('Unauthorized', { status: 401 })
20
21
 
21
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
22
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
22
+ const tokenResult = await resolveGitHubTokenForRequest(request)
23
+ if (!tokenResult.ok) {
24
+ return new Response(tokenResult.error.message, { status: 500 })
25
+ }
26
+ const githubToken = tokenResult.value
23
27
 
24
28
  try {
25
29
  const body = await request.json() as {
@@ -31,7 +35,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
31
35
  contentPath?: string
32
36
  }
33
37
 
34
- const storage = resolveStorageConfig(body)
38
+ const storage = await resolveStorageConfigForRequest(request, body)
35
39
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
36
40
  const { owner, repo, branch, projectPrefix } = storage
37
41
 
@@ -44,7 +48,7 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
44
48
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
45
49
  }
46
50
 
47
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
51
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
48
52
  if (denied) return denied
49
53
 
50
54
  const headers = {
@@ -80,6 +84,12 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
80
84
 
81
85
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
82
86
 
87
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
88
+ await recordPageEdit(
89
+ { owner, repo, branch, contentPath, token: tokenResult.value },
90
+ pageKey,
91
+ ).catch(() => {})
92
+
83
93
  return Response.json({ success: true, commitSha: commitResult.sha })
84
94
  } catch (error) {
85
95
  console.error('[setzkasten] section-delete error:', error)
@@ -1,8 +1,9 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig, prefixPath } from './_storage-config'
2
+ import { prefixPath, resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
4
  import { parseSession, guardPageAccess } from './_auth-guard'
5
5
  import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
6
7
 
7
8
  /**
8
9
  * POST /api/setzkasten/sections/duplicate
@@ -18,8 +19,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
18
19
  const session = cookies.get('setzkasten_session')?.value
19
20
  if (!session) return new Response('Unauthorized', { status: 401 })
20
21
 
21
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
22
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
22
+ const tokenResult = await resolveGitHubTokenForRequest(request)
23
+ if (!tokenResult.ok) {
24
+ return new Response(tokenResult.error.message, { status: 500 })
25
+ }
26
+ const githubToken = tokenResult.value
23
27
 
24
28
  try {
25
29
  const body = await request.json() as {
@@ -31,7 +35,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
31
35
  contentPath?: string
32
36
  }
33
37
 
34
- const storage = resolveStorageConfig(body)
38
+ const storage = await resolveStorageConfigForRequest(request, body)
35
39
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
36
40
  const { owner, repo, branch, projectPrefix } = storage
37
41
 
@@ -44,7 +48,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
44
48
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
45
49
  }
46
50
 
47
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
51
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
48
52
  if (denied) return denied
49
53
 
50
54
  const headers = {
@@ -91,6 +95,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
91
95
 
92
96
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
93
97
 
98
+ const { recordPageEdit } = await import('./_pages-meta-store.js')
99
+ await recordPageEdit(
100
+ { owner, repo, branch, contentPath, token: tokenResult.value },
101
+ pageKey,
102
+ ).catch(() => {})
103
+
94
104
  return Response.json({ success: true, newKey, commitSha: commitResult.sha })
95
105
  } catch (error) {
96
106
  console.error('[setzkasten] section-duplicate error:', error)
@@ -1,6 +1,7 @@
1
1
  import type { APIRoute } from 'astro'
2
- import { resolveStorageConfig } from './_storage-config'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
3
  import { generateDuplicateKey, duplicateInPageConfig } from './section-management'
4
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
5
 
5
6
  /**
6
7
  * POST /api/setzkasten/sections/prepare-copy
@@ -13,14 +14,24 @@ import { generateDuplicateKey, duplicateInPageConfig } from './section-managemen
13
14
  * The client uses this to update local state + preview draft immediately.
14
15
  * Only committed to GitHub when the user presses "Live setzen".
15
16
  *
17
+ * Note: this route intentionally does NOT call recordPageEdit. The
18
+ * page-recency spec lists it as a "mutating route", but in practice it
19
+ * only reads and returns — the real GitHub commit happens later in
20
+ * commit-pending, which records the edit. Bumping the timestamp here
21
+ * would mark a page as recently-modified even when the user opens the
22
+ * duplicate dialog and then cancels without committing.
23
+ *
16
24
  * Body: { pageKey, sectionKey, owner?, repo?, branch?, contentPath? }
17
25
  */
18
26
  export const POST: APIRoute = async ({ request, cookies }) => {
19
27
  const session = cookies.get('setzkasten_session')?.value
20
28
  if (!session) return new Response('Unauthorized', { status: 401 })
21
29
 
22
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
23
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
30
+ const tokenResult = await resolveGitHubTokenForRequest(request)
31
+ if (!tokenResult.ok) {
32
+ return new Response(tokenResult.error.message, { status: 500 })
33
+ }
34
+ const githubToken = tokenResult.value
24
35
 
25
36
  try {
26
37
  const body = await request.json() as {
@@ -32,7 +43,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
32
43
  contentPath?: string
33
44
  }
34
45
 
35
- const storage = resolveStorageConfig(body)
46
+ const storage = await resolveStorageConfigForRequest(request, body)
36
47
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
37
48
  const { owner, repo, branch } = storage
38
49