@setzkasten-cms/astro-admin 1.1.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.
@@ -0,0 +1,144 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { parseSession, requireAdmin } from './_auth-guard'
5
+ import { withTrailers } from './_commit-trailers'
6
+ import { invalidateCache } from './_github-cache'
7
+
8
+ interface RollbackBody {
9
+ path?: string
10
+ sha?: string
11
+ /** Optional ETag-style guard: client sends the SHA they think is HEAD;
12
+ * if HEAD has moved, we 409 to prevent stomping live edits. */
13
+ expectedHeadSha?: string
14
+ }
15
+
16
+ /**
17
+ * POST /api/setzkasten/history/rollback
18
+ *
19
+ * Body: { path, sha, expectedHeadSha? }
20
+ *
21
+ * Restores `path` to the contents from `sha` by writing a new commit
22
+ * (no `git revert` — JSON content is set wholesale). The original SHA
23
+ * stays in history so users can roll forward again.
24
+ *
25
+ * Conflict semantics: the client passes the SHA they currently render in
26
+ * the file picker. If the file's HEAD has moved between page-load and the
27
+ * rollback click, we return 409 with `code: 'head-moved'` so the UI can
28
+ * tell the user to refresh.
29
+ *
30
+ * Admin-only — editors can edit, but rollback is destructive enough to
31
+ * warrant the audit-log control.
32
+ */
33
+ export const POST: APIRoute = async ({ request, cookies }) => {
34
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
35
+ if (denied) return denied
36
+
37
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
38
+ if (!session) return new Response('Unauthorized', { status: 401 })
39
+
40
+ let body: RollbackBody
41
+ try {
42
+ body = (await request.json()) as RollbackBody
43
+ } catch {
44
+ return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
45
+ }
46
+ const { path, sha, expectedHeadSha } = body
47
+ if (!path || !sha) {
48
+ return Response.json({ error: 'path and sha are required' }, { status: 400 })
49
+ }
50
+
51
+ const tokenResult = await resolveGitHubTokenForRequest(request)
52
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
53
+
54
+ const storage = await resolveStorageConfigForRequest(request)
55
+ if (!storage) {
56
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
57
+ }
58
+ const { owner, repo, branch } = storage
59
+
60
+ const headers = {
61
+ Authorization: `Bearer ${tokenResult.value}`,
62
+ Accept: 'application/vnd.github+json',
63
+ 'X-GitHub-Api-Version': '2022-11-28',
64
+ 'Content-Type': 'application/json',
65
+ }
66
+
67
+ // 1. Fetch contents of the file at the target sha
68
+ const versionRes = await fetch(
69
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${sha}`,
70
+ { headers },
71
+ )
72
+ if (versionRes.status === 404) {
73
+ return Response.json(
74
+ { error: 'File did not exist at the requested sha', code: 'version-not-found' },
75
+ { status: 404 },
76
+ )
77
+ }
78
+ if (!versionRes.ok) {
79
+ return Response.json(
80
+ { error: `Failed to read version: ${versionRes.status}` },
81
+ { status: 502 },
82
+ )
83
+ }
84
+ const versionData = (await versionRes.json()) as {
85
+ content: string
86
+ encoding: string
87
+ }
88
+ const targetContent =
89
+ versionData.encoding === 'base64'
90
+ ? Buffer.from(versionData.content, 'base64').toString('utf-8')
91
+ : versionData.content
92
+
93
+ // 2. Fetch current HEAD SHA of the file (for conflict detection + PUT sha param)
94
+ const headRes = await fetch(
95
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
96
+ { headers },
97
+ )
98
+ let currentSha: string | null = null
99
+ if (headRes.ok) {
100
+ const data = (await headRes.json()) as { sha: string }
101
+ currentSha = data.sha
102
+ }
103
+
104
+ if (expectedHeadSha && currentSha && expectedHeadSha !== currentSha) {
105
+ return Response.json(
106
+ {
107
+ error: 'Datei wurde inzwischen geändert. Bitte den Verlauf neu laden.',
108
+ code: 'head-moved',
109
+ },
110
+ { status: 409 },
111
+ )
112
+ }
113
+
114
+ // 3. Write new commit with the historical content
115
+ const shortSha = sha.slice(0, 7)
116
+ const fileName = path.split('/').pop() ?? path
117
+ const message = withTrailers(
118
+ `revert(${fileName}): rollback to ${shortSha}`,
119
+ session.user.email,
120
+ )
121
+
122
+ const putBody: Record<string, unknown> = {
123
+ message,
124
+ content: Buffer.from(targetContent).toString('base64'),
125
+ branch,
126
+ }
127
+ if (currentSha) putBody.sha = currentSha
128
+
129
+ const putRes = await fetch(
130
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
131
+ { method: 'PUT', headers, body: JSON.stringify(putBody) },
132
+ )
133
+
134
+ if (!putRes.ok) {
135
+ const text = await putRes.text()
136
+ return Response.json({ error: `Rollback write failed: ${text}` }, { status: 502 })
137
+ }
138
+
139
+ // Invalidate history cache for this path — fresh commit shows up in lists.
140
+ invalidateCache(`history:${owner}/${repo}:${branch}:${path}:head`)
141
+
142
+ const putData = (await putRes.json()) as { commit: { sha: string } }
143
+ return Response.json({ ok: true, commitSha: putData.commit.sha })
144
+ }
@@ -0,0 +1,57 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { requireAdmin } from './_auth-guard'
5
+ import { cachedFetch } from './_github-cache'
6
+
7
+ /**
8
+ * GET /api/setzkasten/history/version?path=<file>&sha=<commit-sha>
9
+ *
10
+ * Returns the file content at a specific commit (for diff rendering).
11
+ * Cached per (path, sha) for 5 minutes — historical content is immutable.
12
+ */
13
+ export const GET: APIRoute = async ({ request, url, cookies }) => {
14
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
15
+ if (denied) return denied
16
+
17
+ const path = url.searchParams.get('path')
18
+ const sha = url.searchParams.get('sha')
19
+ if (!path || !sha) {
20
+ return Response.json({ error: 'Missing required `path` or `sha`.' }, { status: 400 })
21
+ }
22
+
23
+ const tokenResult = await resolveGitHubTokenForRequest(request)
24
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
25
+
26
+ const storage = await resolveStorageConfigForRequest(request)
27
+ if (!storage) {
28
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
29
+ }
30
+ const { owner, repo } = storage
31
+
32
+ const cacheKey = `history-version:${owner}/${repo}:${path}:${sha}`
33
+ const result = await cachedFetch(cacheKey, 5 * 60_000, async () => {
34
+ const u = new URL(
35
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
36
+ )
37
+ u.searchParams.set('ref', sha)
38
+ const res = await fetch(u, {
39
+ headers: {
40
+ Authorization: `Bearer ${tokenResult.value}`,
41
+ Accept: 'application/vnd.github+json',
42
+ 'X-GitHub-Api-Version': '2022-11-28',
43
+ },
44
+ })
45
+ if (res.status === 404) return { ok: false as const, status: 404, error: 'File not found at given sha' }
46
+ if (!res.ok) return { ok: false as const, status: 502, error: `GitHub returned ${res.status}` }
47
+ const data = (await res.json()) as { content: string; encoding: string; sha: string }
48
+ const raw =
49
+ data.encoding === 'base64'
50
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
51
+ : data.content
52
+ return { ok: true as const, value: { content: raw, sha: data.sha } }
53
+ })
54
+
55
+ if (!result.ok) return Response.json({ error: result.error }, { status: result.status })
56
+ return Response.json(result.value)
57
+ }
@@ -0,0 +1,119 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { resolveStorageConfigForRequest } from './_storage-config'
3
+ import { resolveGitHubTokenForRequest } from './_github-token'
4
+ import { requireAdmin } from './_auth-guard'
5
+ import { parseCoAuthorTrailers, type CommitInfo } from '@setzkasten-cms/core'
6
+ import { cachedFetch } from './_github-cache'
7
+
8
+ /**
9
+ * GET /api/setzkasten/history?path=<contentPath>&before=<sha>
10
+ *
11
+ * Returns up to 5 most recent commits affecting the given file. Pagination
12
+ * via `before=<sha>` returns 10 more older commits — clients call this on
13
+ * "Mehr laden". Admin-only — editors can read content but not the audit
14
+ * trail (and certainly not roll back).
15
+ */
16
+ export const GET: APIRoute = async ({ request, url, cookies }) => {
17
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
18
+ if (denied) return denied
19
+
20
+ const path = url.searchParams.get('path')
21
+ const before = url.searchParams.get('before')
22
+ if (!path) {
23
+ return Response.json({ error: 'Missing required `path` parameter.' }, { status: 400 })
24
+ }
25
+
26
+ const tokenResult = await resolveGitHubTokenForRequest(request)
27
+ if (!tokenResult.ok) return new Response(tokenResult.error.message, { status: 500 })
28
+
29
+ const storage = await resolveStorageConfigForRequest(request)
30
+ if (!storage) {
31
+ return Response.json({ error: 'Could not resolve owner/repo' }, { status: 400 })
32
+ }
33
+ const { owner, repo, branch } = storage
34
+ const perPage = before ? 10 : 5
35
+
36
+ // Cache history per (path, before) for 60s — invalidated by rollback.
37
+ const cacheKey = `history:${owner}/${repo}:${branch}:${path}:${before ?? 'head'}`
38
+ const commits = await cachedFetch(cacheKey, 60_000, () =>
39
+ fetchCommits(owner, repo, branch, path, perPage, before, tokenResult.value),
40
+ )
41
+
42
+ if (!commits.ok) {
43
+ return Response.json({ error: commits.error }, { status: commits.status })
44
+ }
45
+ return Response.json({ commits: commits.value })
46
+ }
47
+
48
+ interface CommitsListSuccess {
49
+ ok: true
50
+ value: readonly CommitInfo[]
51
+ }
52
+ interface CommitsListFailure {
53
+ ok: false
54
+ status: number
55
+ error: string
56
+ }
57
+ type CommitsResult = CommitsListSuccess | CommitsListFailure
58
+
59
+ async function fetchCommits(
60
+ owner: string,
61
+ repo: string,
62
+ branch: string,
63
+ path: string,
64
+ perPage: number,
65
+ before: string | null,
66
+ token: string,
67
+ ): Promise<CommitsResult> {
68
+ // GitHub paginates by `?sha=<commit>` — passing `before` as `sha` gets
69
+ // commits older than (and including) that SHA. We start one before
70
+ // requested SHA so the same commit doesn't appear twice. The simplest
71
+ // way: pass sha=<before>, request perPage+1, and skip the first.
72
+ const sha = before ?? branch
73
+ const u = new URL(`https://api.github.com/repos/${owner}/${repo}/commits`)
74
+ u.searchParams.set('path', path)
75
+ u.searchParams.set('sha', sha)
76
+ u.searchParams.set('per_page', String(before ? perPage + 1 : perPage))
77
+
78
+ const res = await fetch(u, {
79
+ headers: {
80
+ Authorization: `Bearer ${token}`,
81
+ Accept: 'application/vnd.github+json',
82
+ 'X-GitHub-Api-Version': '2022-11-28',
83
+ },
84
+ })
85
+
86
+ if (res.status === 404) return { ok: true, value: [] }
87
+ if (!res.ok) {
88
+ return { ok: false, status: 502, error: `GitHub returned ${res.status}` }
89
+ }
90
+
91
+ const data = (await res.json()) as Array<{
92
+ sha: string
93
+ commit: {
94
+ author: { name: string; email: string; date: string }
95
+ message: string
96
+ }
97
+ author: { avatar_url?: string } | null
98
+ }>
99
+
100
+ // Skip first if we paginated (the `before` SHA itself).
101
+ const start = before ? 1 : 0
102
+ const slice = data.slice(start, start + perPage)
103
+ const commits: CommitInfo[] = slice.map((c) => {
104
+ const [firstLine, ...rest] = c.commit.message.split('\n')
105
+ const body = rest.join('\n')
106
+ return {
107
+ sha: c.sha,
108
+ shortSha: c.sha.slice(0, 7),
109
+ authoredAt: c.commit.author.date,
110
+ authorName: c.commit.author.name,
111
+ authorEmail: c.commit.author.email,
112
+ authorAvatarUrl: c.author?.avatar_url,
113
+ coAuthors: parseCoAuthorTrailers(body),
114
+ message: firstLine ?? '',
115
+ body,
116
+ }
117
+ })
118
+ return { ok: true, value: commits }
119
+ }
@@ -1,10 +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 { resolveStorageConfigForRequest } 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
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'
8
11
 
9
12
  /**
10
13
  * POST /api/setzkasten/sections/commit-pending
@@ -80,6 +83,45 @@ export const POST: APIRoute = async ({ request, cookies }) => {
80
83
  })),
81
84
  ]
82
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
+
83
125
  const parts: string[] = []
84
126
  if (sections.length > 0) {
85
127
  const keys = sections.map(s => s.key).join(', ')
@@ -110,14 +152,25 @@ export const POST: APIRoute = async ({ request, cookies }) => {
110
152
  )
111
153
  }
112
154
 
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(() => {})
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
+ )
121
174
 
122
175
  return Response.json({ success: true, commitSha: commitResult.sha })
123
176
  } catch (error) {
@@ -129,6 +182,52 @@ export const POST: APIRoute = async ({ request, cookies }) => {
129
182
  }
130
183
  }
131
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
+
132
231
  async function batchCommit(
133
232
  owner: string, repo: string, branch: string,
134
233
  files: Array<{ path: string; content: string }>,
@@ -90,6 +90,20 @@ export const DELETE: APIRoute = async ({ request, cookies }) => {
90
90
  pageKey,
91
91
  ).catch(() => {})
92
92
 
93
+ // Fire content.delete webhooks (fire-and-forget).
94
+ const { fireWebhooks } = await import('./_webhook-dispatcher.js')
95
+ const session = parseSession(cookies.get('setzkasten_session')?.value)
96
+ void fireWebhooks(
97
+ 'content.delete',
98
+ {
99
+ website: { id: owner, repo: `${owner}/${repo}`, branch },
100
+ user: { email: session?.user?.email ?? 'unknown', name: session?.user?.name },
101
+ commit: { sha: commitResult.sha, message: `Delete ${sectionKey} from ${pageKey}` },
102
+ files: [{ path: sectionJsonPath }],
103
+ },
104
+ request,
105
+ )
106
+
93
107
  return Response.json({ success: true, commitSha: commitResult.sha })
94
108
  } catch (error) {
95
109
  console.error('[setzkasten] section-delete error:', error)
@@ -27,7 +27,19 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
27
27
  return new Response(null, { status: 302, headers: { Location: adminUrl.toString() } })
28
28
  }
29
29
 
30
- let data: { id: number; slug: string; pem: string } | null = null
30
+ // GitHub returns a lot more than these five fields (permissions,
31
+ // events, html_url, …) but these are the only ones we persist for
32
+ // the wizard. `client_id` + `client_secret` since v1.2: the GH-App
33
+ // doubles as the OAuth provider for admin login, so the Manifest
34
+ // exchange replaces what was previously a separate OAuth-App that
35
+ // the user had to create on github.com/settings/developers.
36
+ let data: {
37
+ id: number
38
+ slug: string
39
+ pem: string
40
+ client_id: string
41
+ client_secret: string
42
+ } | null = null
31
43
  try {
32
44
  const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, {
33
45
  method: 'POST',
@@ -45,7 +57,13 @@ export const GET: APIRoute = async ({ url, request, cookies }) => {
45
57
  // Response.redirect() is immutable and blocks subsequent header writes.
46
58
  cookies.set(
47
59
  COOKIE_NAME,
48
- JSON.stringify({ appId: String(data!.id), slug: data!.slug, privateKey: data!.pem }),
60
+ JSON.stringify({
61
+ appId: String(data!.id),
62
+ slug: data!.slug,
63
+ privateKey: data!.pem,
64
+ clientId: data!.client_id,
65
+ clientSecret: data!.client_secret,
66
+ }),
49
67
  { httpOnly: false, sameSite: 'lax', maxAge: COOKIE_MAX_AGE, path: '/' },
50
68
  )
51
69
 
@@ -1,5 +1,23 @@
1
1
  import type { APIRoute } from 'astro'
2
2
 
3
+ // Vite-define injected by @setzkasten-cms/astro at build time. Available
4
+ // in API-only Vercel cold-starts where the page-ssr injectScript that
5
+ // writes globalThis.__SETZKASTEN_CONFIG__ does not fire.
6
+ declare const __SETZKASTEN_BUILD_CONFIG__: {
7
+ adminPath?: string
8
+ updaterUrl?: string
9
+ version?: string
10
+ websiteUrl?: string
11
+ hasGitHub?: boolean
12
+ storage?: {
13
+ owner?: string
14
+ repo?: string
15
+ branch?: string
16
+ contentPath?: string
17
+ assetsPath?: string
18
+ }
19
+ } | null | undefined
20
+
3
21
  /**
4
22
  * Registers this Setzkasten instance with the central updater backend.
5
23
  * Called on every Dashboard load. Returns update status and license tier.
@@ -15,12 +33,23 @@ export const POST: APIRoute = async ({ cookies, request }) => {
15
33
  return new Response('Unauthorized', { status: 401 })
16
34
  }
17
35
 
18
- const config = (globalThis as any).__SETZKASTEN_CONFIG__ as {
36
+ // Two layers: the page-ssr injectScript writes __SETZKASTEN_CONFIG__ on
37
+ // globalThis (only fires for SSR-rendered pages). The Vite-define
38
+ // __SETZKASTEN_BUILD_CONFIG__ ships the same shape baked into the bundle
39
+ // and is therefore visible in API-only Vercel cold-starts where the
40
+ // injectScript never runs. We prefer the runtime globalThis value (it
41
+ // can pick up later overrides) but fall back to the build constant.
42
+ type ConfigShape = {
19
43
  updaterUrl?: string
20
44
  version?: string
21
45
  websiteUrl?: string
22
46
  storage?: { owner?: string; repo?: string }
23
- } | undefined
47
+ }
48
+ const buildConfig = (typeof __SETZKASTEN_BUILD_CONFIG__ !== 'undefined'
49
+ ? __SETZKASTEN_BUILD_CONFIG__
50
+ : null) as ConfigShape | null
51
+ const runtimeConfig = (globalThis as any).__SETZKASTEN_CONFIG__ as ConfigShape | undefined
52
+ const config: ConfigShape | null = runtimeConfig ?? buildConfig
24
53
 
25
54
  const currentVersion = config?.version ?? '0.0.0'
26
55
  const updaterUrl = config?.updaterUrl
@@ -0,0 +1,17 @@
1
+ import type { APIRoute } from 'astro'
2
+ import { requireAdmin } from './_auth-guard'
3
+ import { getWebhookStatus } from './_webhook-status-store'
4
+
5
+ /**
6
+ * GET /api/setzkasten/webhooks/status
7
+ *
8
+ * Returns the in-memory status map (lastFiredAt + lastStatus per webhook
9
+ * id). Empty for cold-started instances; the UI handles that gracefully
10
+ * with "noch nie gefeuert".
11
+ */
12
+ export const GET: APIRoute = async ({ cookies }) => {
13
+ const denied = requireAdmin(cookies.get('setzkasten_session')?.value)
14
+ if (denied) return denied
15
+
16
+ return Response.json({ status: getWebhookStatus() })
17
+ }