@setzkasten-cms/astro-admin 0.8.0 → 1.3.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 (85) hide show
  1. package/package.json +22 -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__/feature-gate.test.ts +60 -0
  5. package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
  6. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  7. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  8. package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
  9. package/src/api-routes/__tests__/history.test.ts +168 -0
  10. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  11. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  12. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  13. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -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 +152 -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__/webhook-signing.test.ts +39 -0
  21. package/src/api-routes/__tests__/webhooks.test.ts +219 -0
  22. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  23. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  24. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  25. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  26. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  27. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  28. package/src/api-routes/_auth-guard.ts +134 -13
  29. package/src/api-routes/_feature-gate.ts +39 -0
  30. package/src/api-routes/_github-token.ts +64 -0
  31. package/src/api-routes/_license-tier.ts +25 -0
  32. package/src/api-routes/_pages-meta-store.ts +134 -0
  33. package/src/api-routes/_role-resolver.ts +60 -0
  34. package/src/api-routes/_session-cookie.ts +42 -0
  35. package/src/api-routes/_storage-config.ts +77 -4
  36. package/src/api-routes/_vercel-origin.ts +22 -0
  37. package/src/api-routes/_webhook-dispatcher.ts +120 -0
  38. package/src/api-routes/_webhook-signing.ts +13 -0
  39. package/src/api-routes/_webhook-status-store.ts +31 -0
  40. package/src/api-routes/_website-resolver.ts +243 -0
  41. package/src/api-routes/_websites-store.ts +120 -0
  42. package/src/api-routes/asset-proxy.ts +6 -4
  43. package/src/api-routes/auth-callback.ts +8 -7
  44. package/src/api-routes/auth-logout.ts +5 -1
  45. package/src/api-routes/auth-setzkasten-login.ts +37 -11
  46. package/src/api-routes/catalog-add.ts +9 -5
  47. package/src/api-routes/catalog-export.ts +8 -4
  48. package/src/api-routes/config.ts +12 -5
  49. package/src/api-routes/editors.ts +94 -10
  50. package/src/api-routes/github-proxy.ts +5 -5
  51. package/src/api-routes/global-config.ts +23 -6
  52. package/src/api-routes/history-rollback.ts +144 -0
  53. package/src/api-routes/history-version.ts +57 -0
  54. package/src/api-routes/history.ts +119 -0
  55. package/src/api-routes/init-add-section.ts +13 -5
  56. package/src/api-routes/init-apply.ts +5 -3
  57. package/src/api-routes/init-migrate.ts +7 -5
  58. package/src/api-routes/init-scan-page.ts +26 -6
  59. package/src/api-routes/init-scan.ts +5 -3
  60. package/src/api-routes/migrate-to-multi.ts +255 -0
  61. package/src/api-routes/pages.ts +118 -4
  62. package/src/api-routes/section-add.ts +15 -5
  63. package/src/api-routes/section-commit-pending.ts +117 -5
  64. package/src/api-routes/section-delete.ts +29 -5
  65. package/src/api-routes/section-duplicate.ts +15 -5
  66. package/src/api-routes/section-prepare-copy.ts +15 -4
  67. package/src/api-routes/section-prepare.ts +9 -5
  68. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  69. package/src/api-routes/setup-github-app-branches.ts +63 -0
  70. package/src/api-routes/setup-github-app-callback.ts +71 -0
  71. package/src/api-routes/setup-github-app-installed.ts +44 -0
  72. package/src/api-routes/setup-github-app-repos.ts +46 -0
  73. package/src/api-routes/setup-github-app.ts +58 -0
  74. package/src/api-routes/updater-register.ts +37 -25
  75. package/src/api-routes/updater-transfer.ts +1 -12
  76. package/src/api-routes/webhooks-status.ts +17 -0
  77. package/src/api-routes/webhooks-test.ts +134 -0
  78. package/src/api-routes/webhooks.ts +163 -0
  79. package/src/api-routes/websites-add.ts +113 -0
  80. package/src/api-routes/websites-list.ts +40 -0
  81. package/src/api-routes/websites-remove.ts +74 -0
  82. package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
  83. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  84. package/src/init/template-patcher-v2.ts +42 -4
  85. 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,13 @@
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, prefixPath } from './_storage-config'
5
5
  import { parseSession, guardPageAccess } from './_auth-guard'
6
6
  import { withTrailers } from './_commit-trailers'
7
+ import { resolveGitHubTokenForRequest } from './_github-token'
8
+ import { convertToSetHtml } from '../init/template-patcher-v2'
9
+ import { readPagesMeta } from './_pages-meta-store'
10
+ import { setPageLastModified } from '@setzkasten-cms/core'
7
11
 
8
12
  /**
9
13
  * POST /api/setzkasten/sections/commit-pending
@@ -22,8 +26,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
22
26
  const session = cookies.get('setzkasten_session')?.value
23
27
  if (!session) return new Response('Unauthorized', { status: 401 })
24
28
 
25
- const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
26
- if (!githubToken) return new Response('GitHub token not configured', { status: 500 })
29
+ const tokenResult = await resolveGitHubTokenForRequest(request)
30
+ if (!tokenResult.ok) {
31
+ return new Response(tokenResult.error.message, { status: 500 })
32
+ }
33
+ const githubToken = tokenResult.value
27
34
 
28
35
  try {
29
36
  const body = await request.json() as {
@@ -37,7 +44,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
37
44
  contentPath?: string
38
45
  }
39
46
 
40
- const storage = resolveStorageConfig(body)
47
+ const storage = await resolveStorageConfigForRequest(request, body)
41
48
  if (!storage) return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
42
49
  const { owner, repo, branch } = storage
43
50
 
@@ -50,7 +57,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
50
57
  return Response.json({ error: 'pageKey, pageConfig, and sections are required' }, { status: 400 })
51
58
  }
52
59
 
53
- const denied = guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig)
60
+ const denied = await guardPageAccess(parseSession(cookies.get('setzkasten_session')?.value), pageKey, fullConfig, request)
54
61
  if (denied) return denied
55
62
 
56
63
  const headers = {
@@ -76,6 +83,45 @@ export const POST: APIRoute = async ({ request, cookies }) => {
76
83
  })),
77
84
  ]
78
85
 
86
+ // Auto-upgrade plain-text fields to set:html when the user introduces
87
+ // formatting via the inline RTE. Without this, Astro's `{value}` escapes
88
+ // tags and the published page shows literal `<strong>…</strong>`. We
89
+ // detect HTML in any committed string value, fetch the section template,
90
+ // run convertToSetHtml (idempotent — no-op if already converted), and
91
+ // include the patched template in the same batch commit.
92
+ const sectionsWithHtml = [...sections, ...edits]
93
+ .filter(s => containsHtmlValue(s.content))
94
+ .map(s => s.key)
95
+ const projectPrefix = (storage as { projectPrefix?: string }).projectPrefix
96
+ for (const sectionKey of sectionsWithHtml) {
97
+ const componentPath = prefixPath(
98
+ `src/components/sections/${pascalCase(sectionKey)}Section.astro`,
99
+ projectPrefix ?? '',
100
+ )
101
+ if (files.some(f => f.path === componentPath)) continue
102
+ const original = await fetchFileContent(owner, repo, branch, componentPath, githubToken)
103
+ if (!original) continue
104
+ const patched = convertToSetHtml(original)
105
+ if (patched !== original) {
106
+ files.push({ path: componentPath, content: patched })
107
+ }
108
+ }
109
+
110
+ // Fold the recency-meta update into this same batch commit. Previously
111
+ // we issued a follow-up PUT via recordPageEdit, which produced a second
112
+ // commit ("chore(meta): update _pages-meta.json") and a second deploy
113
+ // for every save — visible noise in history and wasted CI minutes.
114
+ const metaContentPath: string = serverConfig?.storage?.contentPath ?? 'content'
115
+ const metaTarget = { owner, repo, branch, contentPath: metaContentPath, token: githubToken }
116
+ const metaSnapshot = await readPagesMeta(metaTarget)
117
+ if (metaSnapshot.ok) {
118
+ const nextMeta = setPageLastModified(metaSnapshot.value.meta, pageKey, Date.now())
119
+ files.push({
120
+ path: `${metaContentPath}/_pages-meta.json`,
121
+ content: JSON.stringify(nextMeta, null, 2),
122
+ })
123
+ }
124
+
79
125
  const parts: string[] = []
80
126
  if (sections.length > 0) {
81
127
  const keys = sections.map(s => s.key).join(', ')
@@ -106,6 +152,26 @@ export const POST: APIRoute = async ({ request, cookies }) => {
106
152
  )
107
153
  }
108
154
 
155
+ // Fire content.save webhooks. Best-effort, fire-and-forget — does
156
+ // not block the response.
157
+ const { fireWebhooks } = await import('./_webhook-dispatcher.js')
158
+ const parsedSession = parseSession(cookies.get('setzkasten_session')?.value)
159
+ void fireWebhooks(
160
+ 'content.save',
161
+ {
162
+ website: { id: owner, repo: `${owner}/${repo}`, branch },
163
+ user: {
164
+ email: parsedSession?.user?.email ?? 'unknown',
165
+ name: parsedSession?.user?.name,
166
+ },
167
+ commit: { sha: commitResult.sha, message: `Commit on ${pageKey}` },
168
+ files: sections.map((s: { key: string }) => ({
169
+ path: `${metaContentPath}/_sections/${s.key}.json`,
170
+ })),
171
+ },
172
+ request,
173
+ )
174
+
109
175
  return Response.json({ success: true, commitSha: commitResult.sha })
110
176
  } catch (error) {
111
177
  console.error('[setzkasten] section-commit-pending error:', error)
@@ -116,6 +182,52 @@ export const POST: APIRoute = async ({ request, cookies }) => {
116
182
  }
117
183
  }
118
184
 
185
+ /** Recursively scan a section content tree for any string value containing
186
+ * inline HTML markup (a `<` followed by an ASCII letter or `/`). Used to
187
+ * decide whether the section template needs upgrading to set:html. */
188
+ function containsHtmlValue(value: unknown): boolean {
189
+ if (typeof value === 'string') return /<\/?[a-z]/i.test(value)
190
+ if (Array.isArray(value)) return value.some(containsHtmlValue)
191
+ if (value && typeof value === 'object') return Object.values(value).some(containsHtmlValue)
192
+ return false
193
+ }
194
+
195
+ function pascalCase(input: string): string {
196
+ return input
197
+ .split(/[-_\s]+/)
198
+ .filter(Boolean)
199
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
200
+ .join('')
201
+ }
202
+
203
+ async function fetchFileContent(
204
+ owner: string,
205
+ repo: string,
206
+ branch: string,
207
+ path: string,
208
+ token: string,
209
+ ): Promise<string | null> {
210
+ try {
211
+ const res = await fetch(
212
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
213
+ {
214
+ headers: {
215
+ Authorization: `Bearer ${token}`,
216
+ Accept: 'application/vnd.github+json',
217
+ 'X-GitHub-Api-Version': '2022-11-28',
218
+ },
219
+ },
220
+ )
221
+ if (!res.ok) return null
222
+ const data = await res.json() as { content: string; encoding: string }
223
+ return data.encoding === 'base64'
224
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
225
+ : data.content
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+
119
231
  async function batchCommit(
120
232
  owner: string, repo: string, branch: string,
121
233
  files: Array<{ path: string; content: string }>,