@setzkasten-cms/astro-admin 0.6.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 (79) hide show
  1. package/package.json +23 -6
  2. package/src/admin-page.astro +9 -8
  3. package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
  4. package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
  5. package/src/api-routes/__tests__/github-cache.test.ts +100 -0
  6. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  7. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  8. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  9. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  10. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  11. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  12. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  13. package/src/api-routes/__tests__/pages.test.ts +72 -0
  14. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  15. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  16. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  17. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  18. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  19. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  20. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  21. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  22. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  23. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  24. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  25. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  26. package/src/api-routes/_auth-guard.ts +153 -0
  27. package/src/api-routes/_commit-trailers.ts +16 -0
  28. package/src/api-routes/_github-cache.ts +32 -0
  29. package/src/api-routes/_github-token.ts +64 -0
  30. package/src/api-routes/_license-tier.ts +25 -0
  31. package/src/api-routes/_pages-meta-store.ts +134 -0
  32. package/src/api-routes/_session-cookie.ts +42 -0
  33. package/src/api-routes/_storage-config.ts +64 -4
  34. package/src/api-routes/_vercel-origin.ts +22 -0
  35. package/src/api-routes/_website-resolver.ts +243 -0
  36. package/src/api-routes/_websites-store.ts +120 -0
  37. package/src/api-routes/asset-proxy.ts +6 -4
  38. package/src/api-routes/auth-callback.ts +21 -53
  39. package/src/api-routes/auth-login.ts +18 -65
  40. package/src/api-routes/auth-logout.ts +5 -1
  41. package/src/api-routes/auth-setzkasten-login.ts +71 -0
  42. package/src/api-routes/catalog-add.ts +18 -5
  43. package/src/api-routes/catalog-export.ts +8 -4
  44. package/src/api-routes/config.ts +17 -5
  45. package/src/api-routes/editors.ts +205 -0
  46. package/src/api-routes/github-proxy.ts +5 -5
  47. package/src/api-routes/global-config.ts +149 -0
  48. package/src/api-routes/init-add-section.ts +21 -10
  49. package/src/api-routes/init-apply.ts +7 -4
  50. package/src/api-routes/init-migrate.ts +9 -6
  51. package/src/api-routes/init-scan-page.ts +26 -6
  52. package/src/api-routes/init-scan.ts +5 -3
  53. package/src/api-routes/migrate-to-multi.ts +255 -0
  54. package/src/api-routes/pages.ts +138 -6
  55. package/src/api-routes/section-add.ts +23 -5
  56. package/src/api-routes/section-commit-pending.ts +28 -5
  57. package/src/api-routes/section-delete.ts +24 -5
  58. package/src/api-routes/section-duplicate.ts +25 -5
  59. package/src/api-routes/section-prepare-copy.ts +15 -4
  60. package/src/api-routes/section-prepare.ts +12 -4
  61. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  62. package/src/api-routes/setup-github-app-branches.ts +63 -0
  63. package/src/api-routes/setup-github-app-callback.ts +53 -0
  64. package/src/api-routes/setup-github-app-installed.ts +44 -0
  65. package/src/api-routes/setup-github-app-repos.ts +46 -0
  66. package/src/api-routes/setup-github-app.ts +58 -0
  67. package/src/api-routes/updater-check.ts +49 -0
  68. package/src/api-routes/updater-register.ts +90 -0
  69. package/src/api-routes/updater-transfer.ts +51 -0
  70. package/src/api-routes/updater-unbind.ts +59 -0
  71. package/src/api-routes/websites-add.ts +113 -0
  72. package/src/api-routes/websites-list.ts +40 -0
  73. package/src/api-routes/websites-remove.ts +74 -0
  74. package/src/init/__tests__/page-level.test.ts +47 -0
  75. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  76. package/src/init/__tests__/section-pipeline.test.ts +3 -1
  77. package/src/init/astro-section-analyzer-v2.ts +29 -2
  78. package/src/init/template-patcher-v2.ts +100 -0
  79. package/LICENSE +0 -37
@@ -1,17 +1,149 @@
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'
6
+
7
+ interface PageInfo {
8
+ path: string
9
+ pageKey: string
10
+ label: string
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
15
+ }
16
+
17
+ // Build-time constant injected by the Vite define plugin — always available in
18
+ // compiled API routes (unlike page-ssr injectScript which only runs for SSR pages).
19
+ declare const __SETZKASTEN_PAGES__: PageInfo[] | undefined
2
20
 
3
21
  /**
4
- * Returns the list of pages detected at build time.
5
- * The pages are scanned from src/pages/ by the integration hook
6
- * and injected into globalThis.__SETZKASTEN_PAGES__.
22
+ * Returns the list of pages scanned at build time.
23
+ * Reads the Vite build-time constant first; falls back to globalThis for
24
+ * local dev / test environments where the define is not applied.
7
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}).
29
+ */
30
+ export function resolvePages(): PageInfo[] {
31
+ const buildPages = typeof __SETZKASTEN_PAGES__ !== 'undefined' ? __SETZKASTEN_PAGES__ : null
32
+ return buildPages ?? (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ as PageInfo[] ?? []
33
+ }
34
+
35
+ /**
8
36
  * GET /api/setzkasten/pages
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.
9
49
  */
10
- export const GET: APIRoute = async () => {
11
- const pages = (globalThis as Record<string, unknown>).__SETZKASTEN_PAGES__ ?? []
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()
55
+
56
+ const enriched = await enrichWithLastModified(pages, request).catch(() => pages)
12
57
 
13
- return new Response(JSON.stringify({ pages }), {
58
+ return new Response(JSON.stringify({ pages: enriched }), {
14
59
  status: 200,
15
60
  headers: { 'Content-Type': 'application/json' },
16
61
  })
17
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,6 +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
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
7
 
5
8
  /**
6
9
  * POST /api/setzkasten/sections/add
@@ -19,8 +22,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
19
22
  const session = cookies.get('setzkasten_session')?.value
20
23
  if (!session) return new Response('Unauthorized', { status: 401 })
21
24
 
22
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
23
- 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
24
30
 
25
31
  try {
26
32
  const body = await request.json() as {
@@ -34,7 +40,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
34
40
  contentPath?: string
35
41
  }
36
42
 
37
- const storage = resolveStorageConfig(body)
43
+ const storage = await resolveStorageConfigForRequest(request, body)
38
44
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
39
45
  const { owner, repo, branch, projectPrefix } = storage
40
46
 
@@ -47,6 +53,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
47
53
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
48
54
  }
49
55
 
56
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
57
+ if (denied) return denied
58
+
50
59
  const headers = {
51
60
  Authorization: `Bearer ${githubToken}`,
52
61
  Accept: 'application/vnd.github+json',
@@ -97,12 +106,21 @@ export const POST: APIRoute = async ({ request, cookies }) => {
97
106
  { path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) },
98
107
  { path: sectionJsonPath, content: JSON.stringify(defaultContent, null, 2) },
99
108
  ],
100
- `content: add ${sectionType} section "${newKey}" to ${pageKey}`,
109
+ withTrailers(
110
+ `content: add ${sectionType} section "${newKey}" to ${pageKey}`,
111
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
112
+ ),
101
113
  headers,
102
114
  )
103
115
 
104
116
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
105
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
+
106
124
  return Response.json({ success: true, newKey, commitSha: commitResult.sha })
107
125
  } catch (error) {
108
126
  console.error('[setzkasten] section-add error:', error)
@@ -1,7 +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
+ import { parseSession, guardPageAccess } from './_auth-guard'
6
+ import { withTrailers } from './_commit-trailers'
7
+ import { resolveGitHubTokenForRequest } from './_github-token'
5
8
 
6
9
  /**
7
10
  * POST /api/setzkasten/sections/commit-pending
@@ -20,8 +23,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
20
23
  const session = cookies.get('setzkasten_session')?.value
21
24
  if (!session) return new Response('Unauthorized', { status: 401 })
22
25
 
23
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
- 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
25
31
 
26
32
  try {
27
33
  const body = await request.json() as {
@@ -35,11 +41,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
35
41
  contentPath?: string
36
42
  }
37
43
 
38
- const storage = resolveStorageConfig(body)
44
+ const storage = await resolveStorageConfigForRequest(request, body)
39
45
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
40
46
  const { owner, repo, branch } = storage
41
47
 
42
48
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
49
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
43
50
  const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
44
51
  const { pageKey, pageConfig, sections, edits = [] } = body
45
52
 
@@ -47,6 +54,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
47
54
  return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
48
55
  }
49
56
 
57
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
58
+ if (denied) return denied
59
+
50
60
  const headers = {
51
61
  Authorization: `Bearer ${githubToken}`,
52
62
  Accept: 'application/vnd.github+json',
@@ -79,7 +89,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
79
89
  const keys = edits.map(s => s.key).join(', ')
80
90
  parts.push(`update ${edits.length} section${edits.length > 1 ? 's' : ''} (${keys})`)
81
91
  }
82
- const commitMessage = `content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`
92
+ const editorEmail = parseSession(cookies.get('setzkasten_session')?.value)?.user?.email
93
+ const commitMessage = withTrailers(
94
+ `content: ${parts.length > 0 ? parts.join(', ') : 'update page config'} on ${pageKey}`,
95
+ editorEmail,
96
+ )
83
97
  const commitResult = await batchCommit(owner, repo, branch, files, commitMessage, headers)
84
98
 
85
99
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
@@ -96,6 +110,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
96
110
  )
97
111
  }
98
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
+
99
122
  return Response.json({ success: true, commitSha: commitResult.sha })
100
123
  } catch (error) {
101
124
  console.error('[setzkasten] section-commit-pending error:', error)
@@ -1,6 +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
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
7
 
5
8
  /**
6
9
  * DELETE /api/setzkasten/sections
@@ -16,8 +19,11 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
16
19
  const session = cookies.get('setzkasten_session')?.value
17
20
  if (!session) return new Response('Unauthorized', { status: 401 })
18
21
 
19
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
20
- 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
21
27
 
22
28
  try {
23
29
  const body = await request.json() as {
@@ -29,11 +35,12 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
29
35
  contentPath?: string
30
36
  }
31
37
 
32
- const storage = resolveStorageConfig(body)
38
+ const storage = await resolveStorageConfigForRequest(request, body)
33
39
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
34
40
  const { owner, repo, branch, projectPrefix } = storage
35
41
 
36
42
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
43
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
37
44
  const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
38
45
  const { pageKey, sectionKey } = body
39
46
 
@@ -41,6 +48,9 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
41
48
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
42
49
  }
43
50
 
51
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
52
+ if (denied) return denied
53
+
44
54
  const headers = {
45
55
  Authorization: `Bearer ${githubToken}`,
46
56
  Accept: 'application/vnd.github+json',
@@ -65,12 +75,21 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
65
75
  owner, repo, branch,
66
76
  [{ path: pageConfigPath, content: JSON.stringify(updatedConfig, null, 2) }],
67
77
  [sectionJsonPath],
68
- `content: remove ${sectionKey} section from ${pageKey}`,
78
+ withTrailers(
79
+ `content: remove ${sectionKey} section from ${pageKey}`,
80
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
81
+ ),
69
82
  headers,
70
83
  )
71
84
 
72
85
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
73
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
+
74
93
  return Response.json({ success: true, commitSha: commitResult.sha })
75
94
  } catch (error) {
76
95
  console.error('[setzkasten] section-delete error:', error)
@@ -1,6 +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
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
6
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
7
 
5
8
  /**
6
9
  * POST /api/setzkasten/sections/duplicate
@@ -16,8 +19,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
16
19
  const session = cookies.get('setzkasten_session')?.value
17
20
  if (!session) return new Response('Unauthorized', { status: 401 })
18
21
 
19
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
20
- 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
21
27
 
22
28
  try {
23
29
  const body = await request.json() as {
@@ -29,11 +35,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
29
35
  contentPath?: string
30
36
  }
31
37
 
32
- const storage = resolveStorageConfig(body)
38
+ const storage = await resolveStorageConfigForRequest(request, body)
33
39
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
34
40
  const { owner, repo, branch, projectPrefix } = storage
35
41
 
36
42
  const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
43
+ const fullConfig = (globalThis as any).__SETZKASTEN_FULL_CONFIG__
37
44
  const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
38
45
  const { pageKey, sectionKey } = body
39
46
 
@@ -41,6 +48,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
41
48
  return Response.json({ error: 'pageKey and sectionKey are required' }, { status: 400 })
42
49
  }
43
50
 
51
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
52
+ if (denied) return denied
53
+
44
54
  const headers = {
45
55
  Authorization: `Bearer ${githubToken}`,
46
56
  Accept: 'application/vnd.github+json',
@@ -77,10 +87,20 @@ export const POST: APIRoute = async ({ request, cookies }) => {
77
87
  }
78
88
 
79
89
  const commitResult = await batchCommit(owner, repo, branch, filesToCommit,
80
- `content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`, headers)
90
+ withTrailers(
91
+ `content: duplicate ${sectionKey} → ${newKey} on ${pageKey}`,
92
+ parseSession(cookies.get('setzkasten_session')?.value)?.user?.email,
93
+ ),
94
+ headers)
81
95
 
82
96
  if (!commitResult.ok) return Response.json({ error: commitResult.error }, { status: 500 })
83
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
+
84
104
  return Response.json({ success: true, newKey, commitSha: commitResult.sha })
85
105
  } catch (error) {
86
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
 
@@ -1,6 +1,8 @@
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 } from './section-management'
4
+ import { parseSession, guardPageAccess } from './_auth-guard'
5
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
6
 
5
7
  /**
6
8
  * POST /api/setzkasten/sections/prepare
@@ -19,8 +21,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
19
21
  const session = cookies.get('setzkasten_session')?.value
20
22
  if (!session) return new Response('Unauthorized', { status: 401 })
21
23
 
22
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
23
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
24
+ const tokenResult = await resolveGitHubTokenForRequest(request)
25
+ if (!tokenResult.ok) {
26
+ return new Response(tokenResult.error.message, { status: 500 })
27
+ }
28
+ const githubToken = tokenResult.value
24
29
 
25
30
  try {
26
31
  const body = await request.json() as {
@@ -32,7 +37,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
32
37
  contentPath?: string
33
38
  }
34
39
 
35
- const storage = resolveStorageConfig(body)
40
+ const storage = await resolveStorageConfigForRequest(request, body)
36
41
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
37
42
  const { owner, repo, branch } = storage
38
43
 
@@ -45,6 +50,9 @@ export const POST: APIRoute = async ({ request, cookies }) => {
45
50
  return Response.json({ error: 'pageKey and sectionType are required' }, { status: 400 })
46
51
  }
47
52
 
53
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
54
+ if (denied) return denied
55
+
48
56
  // 1. Read current page config from GitHub to determine existing keys
49
57
  const configKey = '_' + pageKey.replace(/\//g, '_')
50
58
  const pageConfigPath = `${contentPath}/pages/${configKey}.json`
@@ -0,0 +1,52 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { getPublicOrigin } from './_vercel-origin.js'
3
+
4
+ /**
5
+ * Server-side manifest bounce for the GitHub App Manifest flow.
6
+ *
7
+ * GET /api/setzkasten/setup/github-app/bounce?name=my-app
8
+ *
9
+ * Generates the GitHub App manifest JSON using the server-known origin,
10
+ * then returns a minimal HTML page that auto-submits a form to GitHub.
11
+ */
12
+ export const GET: APIRoute = async ({ url, request }) => {
13
+ const name = url.searchParams.get('name')?.trim() || 'Setzkasten CMS'
14
+ const origin = getPublicOrigin(request)
15
+
16
+ const manifest = JSON.stringify({
17
+ name,
18
+ url: origin,
19
+ redirect_url: `${origin}/api/setzkasten/setup/github-app/callback`,
20
+ setup_url: `${origin}/api/setzkasten/setup/github-app/installed`,
21
+ setup_on_update: false,
22
+ public: false,
23
+ default_permissions: { contents: 'write' },
24
+ })
25
+
26
+ const safeManifest = manifest.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
27
+
28
+ const html = `<!DOCTYPE html>
29
+ <html lang="de">
30
+ <head>
31
+ <meta charset="UTF-8">
32
+ <title>Weiterleitung zu GitHub…</title>
33
+ <style>
34
+ body { font-family: sans-serif; display: flex; align-items: center;
35
+ justify-content: center; height: 100vh; margin: 0;
36
+ background: #0d1117; color: #e6edf3; }
37
+ p { opacity: .6; }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <p>Weiterleitung zu GitHub…</p>
42
+ <form id="f" method="POST" action="https://github.com/settings/apps/new">
43
+ <input type="hidden" name="manifest" value="${safeManifest}">
44
+ </form>
45
+ <script>document.getElementById('f').submit()</script>
46
+ </body>
47
+ </html>`
48
+
49
+ return new Response(html, {
50
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
51
+ })
52
+ }
@@ -0,0 +1,63 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { listRepoBranches } from '@setzkasten-cms/github-adapter'
3
+ import { requireAdmin } from './_auth-guard'
4
+
5
+ /**
6
+ * GET /api/setzkasten/setup/github-app/branches?installation=<id>&repo=<owner/repo>
7
+ *
8
+ * Returns the list of branches for one repo, fetched via the installation
9
+ * token of the given installation. Used by the WebsitesView form so the
10
+ * Branch field becomes a dropdown after the user picks a repo.
11
+ *
12
+ * Admin-only — same reasoning as /setup/github-app/repos.
13
+ */
14
+ export const GET: APIRoute = async ({ cookies, url }) => {
15
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
16
+ if (denied) return denied
17
+
18
+ const appId = process.env.GITHUB_APP_ID
19
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
20
+ if (!appId || !privateKey) {
21
+ return new Response(
22
+ JSON.stringify({
23
+ error:
24
+ 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY.',
25
+ }),
26
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
27
+ )
28
+ }
29
+
30
+ const installationId = url.searchParams.get('installation')
31
+ const repoFull = url.searchParams.get('repo')
32
+ if (!installationId || !repoFull) {
33
+ return new Response(
34
+ JSON.stringify({ error: 'Both ?installation and ?repo (owner/name) are required.' }),
35
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
36
+ )
37
+ }
38
+
39
+ const slash = repoFull.indexOf('/')
40
+ if (slash <= 0 || slash === repoFull.length - 1) {
41
+ return new Response(
42
+ JSON.stringify({ error: '?repo must be in "owner/name" format.' }),
43
+ { status: 400, headers: { 'Content-Type': 'application/json' } },
44
+ )
45
+ }
46
+ const owner = repoFull.slice(0, slash)
47
+ const repo = repoFull.slice(slash + 1)
48
+
49
+ const result = await listRepoBranches({ appId, privateKey }, installationId, owner, repo)
50
+ if (!result.ok) {
51
+ const status =
52
+ result.error.type === 'auth' ? 401 : result.error.type === 'not-found' ? 404 : 502
53
+ return new Response(JSON.stringify({ error: result.error.message }), {
54
+ status,
55
+ headers: { 'Content-Type': 'application/json' },
56
+ })
57
+ }
58
+
59
+ return new Response(JSON.stringify({ branches: result.value }), {
60
+ status: 200,
61
+ headers: { 'Content-Type': 'application/json' },
62
+ })
63
+ }