@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,60 @@
1
+ import { resolveRoleForUser, type UserRole, type AuthProviderKind } from '@setzkasten-cms/core'
2
+ import { resolveStorageConfig } from './_storage-config'
3
+ import { resolveConfigRepoToken } from './_github-token'
4
+ import { readEditorsFileStatus } from './editors'
5
+
6
+ /**
7
+ * Builds the role-resolver callback that auth-adapters call per OAuth
8
+ * callback. Reads the live `_editors.json` (case-insensitive lookup) and
9
+ * combines it with the configured `allowedEmails` env list. Callers pass
10
+ * the resolver into `createGitHubAuth` / `createGoogleAuth` /
11
+ * `verifyFirebaseJwt` so role assignment happens once, in one place.
12
+ *
13
+ * Returns `null` when the user is not allowed at all, otherwise the
14
+ * effective role.
15
+ */
16
+ export function makeRoleResolver(
17
+ provider: AuthProviderKind,
18
+ allowedEmails: readonly string[] | undefined,
19
+ ): (email: string) => Promise<UserRole | null> {
20
+ return async (email: string): Promise<UserRole | null> => {
21
+ const editors = await loadEditorsForResolution()
22
+ const result = resolveRoleForUser(email, provider, editors, allowedEmails)
23
+ return result.ok ? result.resolution.role : null
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Reads `_editors.json` for role-resolution purposes.
29
+ *
30
+ * Returns `undefined` when the file is genuinely absent (treated as "no
31
+ * editors yet" — bootstrap path applies). Throws when the read errors out
32
+ * to fail-closed: an unreachable storage backend must never silently fall
33
+ * through to the bootstrap path and grant admin to everyone in
34
+ * `allowedEmails`.
35
+ */
36
+ async function loadEditorsForResolution() {
37
+ const storage = resolveStorageConfig()
38
+ if (!storage) return undefined
39
+
40
+ const tokenResult = await resolveConfigRepoToken()
41
+ if (!tokenResult.ok) {
42
+ throw new Error(`role-resolver: token unavailable (${tokenResult.error.message})`)
43
+ }
44
+
45
+ const serverConfig = (globalThis as {
46
+ __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
47
+ }).__SETZKASTEN_CONFIG__
48
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
49
+
50
+ const status = await readEditorsFileStatus(
51
+ storage.owner,
52
+ storage.repo,
53
+ storage.branch,
54
+ contentPath,
55
+ tokenResult.value,
56
+ )
57
+ if (status.kind === 'absent') return undefined
58
+ if (status.kind === 'present') return status.editors
59
+ throw new Error(`role-resolver: ${status.message}`)
60
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Centralised builder for the session-cookie attributes. Two routes set the
3
+ * cookie today (auth-callback for GitHub OAuth, auth-setzkasten-login for
4
+ * Firebase) — both must share the exact same domain/secure/path so the
5
+ * cookie can be read across the admin and (in standalone setups) the
6
+ * managed website on a sibling subdomain.
7
+ */
8
+
9
+ export interface SessionCookieOptions {
10
+ readonly httpOnly: true
11
+ readonly secure: boolean
12
+ readonly sameSite: 'lax'
13
+ readonly path: '/'
14
+ readonly maxAge: number
15
+ readonly domain?: string
16
+ }
17
+
18
+ const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7
19
+
20
+ export function sessionCookieOptions(secure: boolean): SessionCookieOptions {
21
+ return {
22
+ httpOnly: true,
23
+ secure,
24
+ sameSite: 'lax',
25
+ path: '/',
26
+ maxAge: SESSION_MAX_AGE_SECONDS,
27
+ ...(resolveCookieDomain() ? { domain: resolveCookieDomain()! } : {}),
28
+ }
29
+ }
30
+
31
+ function resolveCookieDomain(): string | undefined {
32
+ const fullConfig = (globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
33
+ | { auth?: { cookieDomain?: string } }
34
+ | undefined
35
+ const fromConfig = fullConfig?.auth?.cookieDomain
36
+ if (typeof fromConfig === 'string' && fromConfig) return fromConfig
37
+
38
+ const fromEnv = process.env.SETZKASTEN_COOKIE_DOMAIN
39
+ if (typeof fromEnv === 'string' && fromEnv) return fromEnv
40
+
41
+ return undefined
42
+ }
@@ -1,8 +1,23 @@
1
1
  /**
2
- * Resolve storage config from all available sources:
3
- * 1. Request body (explicit override)
4
- * 2. __SETZKASTEN_STORAGE__ (Vite define, embedded at build time — works everywhere)
5
- * 3. globalThis.__SETZKASTEN_CONFIG__ (injectScript, only works for rendered pages)
2
+ * Storage resolution helpers for admin API routes.
3
+ *
4
+ * Two entry points:
5
+ *
6
+ * - {@link resolveStorageConfigForRequest} (default for content routes) —
7
+ * resolves the **active website** for a request. In single mode this
8
+ * is always the integration's website; in multi mode it is the website
9
+ * selected by the X-SK-Website header.
10
+ *
11
+ * - {@link resolveStorageConfig} (build-time only, used by auth-guard
12
+ * for the editors file) — returns the integration's build-time storage
13
+ * target. In single mode this is the website repo; in multi mode it
14
+ * is the config repo (where `_editors.json` lives next to
15
+ * `websites.json`).
16
+ *
17
+ * The lookup chain inside `resolveStorageConfig`:
18
+ * 1. Request body override (explicit `{ owner, repo, branch }` payload)
19
+ * 2. `__SETZKASTEN_STORAGE__` Vite define (embedded at build time)
20
+ * 3. `globalThis.__SETZKASTEN_CONFIG__` (injectScript, SSR-only fallback)
6
21
  */
7
22
 
8
23
  declare const __SETZKASTEN_STORAGE__: {
@@ -23,6 +38,13 @@ export interface StorageConfig {
23
38
  projectPrefix: string
24
39
  }
25
40
 
41
+ /**
42
+ * Returns the integration's build-time storage target. Used by the
43
+ * auth-guard to read the editors file (which lives next to the build-
44
+ * time storage in both single and multi mode) and by tests that want
45
+ * to bypass the per-request resolver. Most content routes should use
46
+ * {@link resolveStorageConfigForRequest} instead.
47
+ */
26
48
  export function resolveStorageConfig(body?: {
27
49
  owner?: string
28
50
  repo?: string
@@ -52,3 +74,54 @@ export function prefixPath(filePath: string, projectPrefix: string): string {
52
74
  if (!projectPrefix) return filePath
53
75
  return `${projectPrefix}/${filePath}`
54
76
  }
77
+
78
+ /**
79
+ * Standalone-aware variant: resolves storage from the active website
80
+ * (X-SK-Website header → WebsitesRegistry) before falling back to the
81
+ * single-repo build-time configuration.
82
+ *
83
+ * Body overrides still win over both — useful for routes that take an
84
+ * explicit `{ owner, repo, branch }` payload.
85
+ */
86
+ export async function resolveStorageConfigForRequest(
87
+ request: Request,
88
+ body?: { owner?: string; repo?: string; branch?: string },
89
+ ): Promise<StorageConfig | null> {
90
+ // The build-time integration knows the monorepo layout (e.g. project
91
+ // prefix `apps/website/`). Carry that through so routes that touch
92
+ // source files — section templates for set:html upgrades, the migrator,
93
+ // etc. — resolve to the correct path. When the request targets a
94
+ // *different* repo than the build, the prefix doesn't apply, so we
95
+ // only inherit it for matching owner/repo.
96
+ const buildConfig = typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null
97
+ const inheritPrefix = (owner: string, repo: string): string => {
98
+ if (!buildConfig?.projectPrefix) return ''
99
+ if (buildConfig.owner === owner && buildConfig.repo === repo) return buildConfig.projectPrefix
100
+ return ''
101
+ }
102
+
103
+ if (body?.owner && body.repo) {
104
+ return {
105
+ owner: body.owner,
106
+ repo: body.repo,
107
+ branch: body.branch ?? 'main',
108
+ projectPrefix: inheritPrefix(body.owner, body.repo),
109
+ }
110
+ }
111
+
112
+ const { resolveCurrentWebsite } = await import('./_website-resolver.js')
113
+ const resolved = await resolveCurrentWebsite(request)
114
+ if (resolved.ok) {
115
+ const [owner, repo] = resolved.value.repo.split('/')
116
+ if (owner && repo) {
117
+ return {
118
+ owner,
119
+ repo,
120
+ branch: resolved.value.branch,
121
+ projectPrefix: inheritPrefix(owner, repo),
122
+ }
123
+ }
124
+ }
125
+
126
+ return resolveStorageConfig(body)
127
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Derives the real public origin on Vercel (and any reverse-proxy setup).
3
+ *
4
+ * Problem: Inside a Vercel serverless function, both `request.url` and
5
+ * Astro's `url` context resolve to the internal function host
6
+ * (e.g. "https://localhost"), not the public hostname.
7
+ *
8
+ * Solution: Read the x-forwarded-host and x-forwarded-proto headers that
9
+ * Vercel's edge layer sets on every inbound request.
10
+ *
11
+ * Falls back gracefully to the `host` header and `https` for local dev
12
+ * or non-Vercel environments.
13
+ */
14
+ export function getPublicOrigin(request: Request): string {
15
+ const host =
16
+ request.headers.get('x-forwarded-host') ??
17
+ request.headers.get('host') ??
18
+ 'localhost'
19
+ const proto =
20
+ request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
21
+ return `${proto}://${host}`
22
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ parseWebhooksFile,
3
+ selectWebhooksForEvent,
4
+ type WebhookConfig,
5
+ type WebhookEvent,
6
+ type WebhookPayload,
7
+ } from '@setzkasten-cms/core'
8
+ import { resolveStorageConfigForRequest } from './_storage-config'
9
+ import { resolveGitHubTokenForRequest } from './_github-token'
10
+ import { cachedFetch } from './_github-cache'
11
+ import { recordWebhookFire } from './_webhook-status-store'
12
+ import { signPayload } from './_webhook-signing'
13
+
14
+ const WEBHOOKS_FILE = (contentPath: string) => `${contentPath}/_webhooks.json`
15
+ const DISPATCH_TIMEOUT_MS = 5_000
16
+
17
+ /**
18
+ * Fire all enabled webhooks subscribed to `event`. Best-effort —
19
+ * each request runs with a 5s timeout; failures are recorded in the
20
+ * in-memory status store but do not throw or block the caller.
21
+ *
22
+ * Caller-side: invoke as `void fireWebhooks(...)` to make the
23
+ * fire-and-forget intent explicit.
24
+ */
25
+ export async function fireWebhooks(
26
+ event: WebhookEvent,
27
+ payload: Omit<WebhookPayload, 'event' | 'timestamp'>,
28
+ request: Request,
29
+ ): Promise<void> {
30
+ try {
31
+ const webhooks = await loadWebhooksForRequest(request)
32
+ if (!webhooks || webhooks.length === 0) return
33
+
34
+ const targets = selectWebhooksForEvent(webhooks, event)
35
+ if (targets.length === 0) return
36
+
37
+ const fullPayload: WebhookPayload = {
38
+ event,
39
+ timestamp: new Date().toISOString(),
40
+ ...payload,
41
+ }
42
+ const body = JSON.stringify(fullPayload)
43
+
44
+ await Promise.all(targets.map((w) => fireOne(w, event, body)))
45
+ } catch (err) {
46
+ // Dispatcher failures must not break the save flow.
47
+ console.error('[setzkasten] webhook dispatch failed:', err)
48
+ }
49
+ }
50
+
51
+ async function fireOne(
52
+ webhook: WebhookConfig,
53
+ event: WebhookEvent,
54
+ body: string,
55
+ ): Promise<void> {
56
+ const headers: Record<string, string> = {
57
+ 'Content-Type': 'application/json',
58
+ 'X-Setzkasten-Event': event,
59
+ 'X-Setzkasten-Delivery': crypto.randomUUID(),
60
+ }
61
+ if (webhook.secret) {
62
+ headers['X-Setzkasten-Signature'] = `sha256=${signPayload(body, webhook.secret)}`
63
+ }
64
+
65
+ try {
66
+ const res = await fetch(webhook.url, {
67
+ method: 'POST',
68
+ headers,
69
+ body,
70
+ signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS),
71
+ })
72
+ recordWebhookFire(webhook.id, res.status)
73
+ } catch (err) {
74
+ recordWebhookFire(webhook.id, 'error')
75
+ console.warn(`[setzkasten] webhook "${webhook.id}" failed:`, err)
76
+ }
77
+ }
78
+
79
+ async function loadWebhooksForRequest(
80
+ request: Request,
81
+ ): Promise<readonly WebhookConfig[] | null> {
82
+ const tokenResult = await resolveGitHubTokenForRequest(request)
83
+ if (!tokenResult.ok) return null
84
+
85
+ const storage = await resolveStorageConfigForRequest(request)
86
+ if (!storage) return null
87
+
88
+ const serverConfig = (globalThis as {
89
+ __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } }
90
+ }).__SETZKASTEN_CONFIG__
91
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
92
+ const { owner, repo, branch } = storage
93
+
94
+ const cacheKey = `webhooks:${owner}/${repo}:${branch}`
95
+ return cachedFetch(cacheKey, 60_000, async () => {
96
+ const res = await fetch(
97
+ `https://api.github.com/repos/${owner}/${repo}/contents/${WEBHOOKS_FILE(contentPath)}?ref=${branch}`,
98
+ {
99
+ headers: {
100
+ Authorization: `Bearer ${tokenResult.value}`,
101
+ Accept: 'application/vnd.github+json',
102
+ 'X-GitHub-Api-Version': '2022-11-28',
103
+ },
104
+ },
105
+ )
106
+ if (res.status === 404) return [] as readonly WebhookConfig[]
107
+ if (!res.ok) return null
108
+ const data = (await res.json()) as { content: string; encoding: string }
109
+ const raw =
110
+ data.encoding === 'base64'
111
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
112
+ : data.content
113
+ const parsed = parseWebhooksFile(raw)
114
+ if (!parsed.ok) {
115
+ console.warn('[setzkasten] _webhooks.json parse error:', parsed.error.message)
116
+ return null
117
+ }
118
+ return parsed.value.webhooks
119
+ })
120
+ }
@@ -0,0 +1,13 @@
1
+ import { createHmac } from 'node:crypto'
2
+
3
+ /**
4
+ * HMAC-SHA256 signature of the webhook payload body, hex-encoded.
5
+ * Receivers verify with the same secret and the **raw** request body —
6
+ * pattern is identical to GitHub webhooks.
7
+ *
8
+ * Lives in astro-admin (not core) because it imports node:crypto.
9
+ * core's "zero external deps" rule keeps it edge/browser-runnable.
10
+ */
11
+ export function signPayload(body: string, secret: string): string {
12
+ return createHmac('sha256', secret).update(body, 'utf8').digest('hex')
13
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * In-memory webhook status — last fired timestamp + last HTTP status per
3
+ * webhook id. Survives only the server process; cold-start losses are
4
+ * acceptable because this is a status display, not the source of truth.
5
+ *
6
+ * Persisting to `_webhooks.json` would create a commit-storm
7
+ * (every save → webhook fire → webhook commit → trigger save → …). The
8
+ * UI just refetches `/webhooks/status` after a test-fire or save.
9
+ */
10
+
11
+ export interface WebhookStatusEntry {
12
+ readonly lastFiredAt: string
13
+ readonly lastStatus: number | 'error'
14
+ }
15
+
16
+ const store = new Map<string, WebhookStatusEntry>()
17
+
18
+ export function recordWebhookFire(id: string, status: number | 'error'): void {
19
+ store.set(id, { lastFiredAt: new Date().toISOString(), lastStatus: status })
20
+ }
21
+
22
+ export function getWebhookStatus(): Record<string, WebhookStatusEntry> {
23
+ const out: Record<string, WebhookStatusEntry> = {}
24
+ for (const [k, v] of store.entries()) out[k] = v
25
+ return out
26
+ }
27
+
28
+ /** Test-only — clears the in-memory map. */
29
+ export function _resetWebhookStatusForTests(): void {
30
+ store.clear()
31
+ }
@@ -0,0 +1,243 @@
1
+ import {
2
+ type Result,
3
+ type WebsiteEntry,
4
+ type WebsitesRegistryProvider,
5
+ err,
6
+ notFoundError,
7
+ ok,
8
+ validationError,
9
+ } from '@setzkasten-cms/core'
10
+ import { GitHubAppClient, GitHubWebsitesRegistry } from '@setzkasten-cms/github-adapter'
11
+
12
+ /**
13
+ * Per-request resolver for the active website.
14
+ *
15
+ * Standalone mode: looks up the entry matching the X-SK-Website request
16
+ * header in the websites registry (config-repo).
17
+ *
18
+ * Single-repo mode (backward compat): returns a synthesized WebsiteEntry
19
+ * built from the integration's build-time storage + GitHub-App ENV vars.
20
+ * The header is ignored.
21
+ */
22
+
23
+ interface MultiState {
24
+ readonly mode: 'multi'
25
+ readonly registry: WebsitesRegistryProvider
26
+ }
27
+
28
+ interface SingleRepoState {
29
+ readonly mode: 'single'
30
+ readonly synthesized: WebsiteEntry
31
+ }
32
+
33
+ type ResolverState = MultiState | SingleRepoState | null
34
+
35
+ let state: ResolverState = null
36
+
37
+ /** Test/admin hook: install or clear the resolver state. */
38
+ export function __resetWebsiteResolverForTests(next: ResolverState): void {
39
+ state = next
40
+ }
41
+
42
+ /**
43
+ * Configure the resolver state explicitly. Currently only the test suite
44
+ * uses this — production wiring runs lazily through
45
+ * {@link bootstrapResolverFromGlobals} on the first request, since the
46
+ * Astro integration doesn't have an obvious "initialize once" hook
47
+ * (`astro:server:start` runs in dev only). The export stays available so
48
+ * a future integration-startup wire-up has a typed entry point.
49
+ */
50
+ export function configureWebsiteResolver(next: ResolverState): void {
51
+ state = next
52
+ }
53
+
54
+ interface FullConfig {
55
+ storage?:
56
+ | {
57
+ // 'single' is canonical; 'github-app' is the legacy alias.
58
+ kind: 'single' | 'github-app' | 'local'
59
+ repo?: string
60
+ appId?: string
61
+ installationId?: string
62
+ }
63
+ | {
64
+ // 'multi' is canonical; 'standalone' is the legacy alias.
65
+ kind: 'multi' | 'standalone'
66
+ configRepo: string
67
+ configBranch?: string
68
+ appId: string
69
+ installationId: string
70
+ }
71
+ }
72
+
73
+ function isMultiKind(kind: unknown): boolean {
74
+ return kind === 'multi' || kind === 'standalone'
75
+ }
76
+
77
+ interface BuildTimeStorage {
78
+ owner: string
79
+ repo: string
80
+ branch: string
81
+ }
82
+
83
+ // Vite define-injected literals. The integration (`packages/astro/src/
84
+ // integration.ts`) sets them via the build-time Vite define plugin, so by
85
+ // the time this code runs in a compiled API route the literals are
86
+ // substituted with their JSON values. globalThis-style reads break on
87
+ // cold-start serverless functions because the integration's
88
+ // page-ssr injectScript only fires for SSR pages, never for API-only
89
+ // invocations.
90
+ declare const __SETZKASTEN_FULL_CONFIG__: FullConfig | null | undefined
91
+ declare const __SETZKASTEN_STORAGE__: BuildTimeStorage | null | undefined
92
+ declare const __SETZKASTEN_WEBSITE_URL__: string | undefined
93
+
94
+ /**
95
+ * Lazy bootstrap from build-time globals. Reads `__SETZKASTEN_FULL_CONFIG__`
96
+ * and `__SETZKASTEN_STORAGE__` (set by the Astro integration via Vite
97
+ * define — literals only, NOT globalThis) to derive single-repo or
98
+ * standalone state. Idempotent — does nothing if the resolver is already
99
+ * configured.
100
+ */
101
+ export function bootstrapResolverFromGlobals(): void {
102
+ if (state !== null) return
103
+
104
+ const fullConfig =
105
+ (typeof __SETZKASTEN_FULL_CONFIG__ !== 'undefined' ? __SETZKASTEN_FULL_CONFIG__ : null) ??
106
+ ((globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ as
107
+ | FullConfig
108
+ | undefined)
109
+ const buildStorage =
110
+ (typeof __SETZKASTEN_STORAGE__ !== 'undefined' ? __SETZKASTEN_STORAGE__ : null) ??
111
+ ((globalThis as Record<string, unknown>).__SETZKASTEN_STORAGE__ as
112
+ | BuildTimeStorage
113
+ | undefined)
114
+
115
+ const storageKind = fullConfig?.storage?.kind
116
+ if (isMultiKind(storageKind)) {
117
+ const standalone = fullConfig?.storage as {
118
+ kind: 'multi' | 'standalone'
119
+ configRepo: string
120
+ configBranch?: string
121
+ appId: string
122
+ installationId: string
123
+ }
124
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
125
+ if (!privateKey) {
126
+ // Without the private key the resolver can't mint installation tokens;
127
+ // every Multi-Mode request would 400 with a confusing "X-SK-Website
128
+ // header missing" message instead of the actual root cause.
129
+ console.error(
130
+ '[setzkasten] Multi-mode resolver bootstrap failed: GITHUB_APP_PRIVATE_KEY is not set in env. ' +
131
+ 'Set it on the standalone-admin deployment so the registry can be loaded.',
132
+ )
133
+ return
134
+ }
135
+ const [owner, repo] = standalone.configRepo.split('/')
136
+ if (!owner || !repo) {
137
+ console.error(
138
+ `[setzkasten] Multi-mode resolver bootstrap failed: storage.configRepo "${standalone.configRepo}" is not in "owner/repo" form.`,
139
+ )
140
+ return
141
+ }
142
+
143
+ const client = new GitHubAppClient(
144
+ { appId: standalone.appId, installationId: standalone.installationId, privateKey },
145
+ { owner, repo, branch: standalone.configBranch ?? 'main' },
146
+ )
147
+ const registry = new GitHubWebsitesRegistry({
148
+ reader: { read: (path) => client.getFileContent(path) },
149
+ path: 'websites.json',
150
+ ttlMs: 60_000,
151
+ })
152
+ state = { mode: 'multi', registry }
153
+ return
154
+ }
155
+
156
+ if (!buildStorage) return
157
+
158
+ const appId = process.env.GITHUB_APP_ID ?? ''
159
+ const installationId = process.env.GITHUB_APP_INSTALLATION_ID ?? ''
160
+ // Source of truth: __SETZKASTEN_WEBSITE_URL__ Vite define (mirrors
161
+ // astro.config.mjs#site, dev-server origin in dev) — same value the
162
+ // updater registers licenses with. As a Vite literal it survives
163
+ // cold-start API-only function invocations (unlike __SETZKASTEN_CONFIG__
164
+ // which is only set via injectScript on page-ssr renders).
165
+ // PUBLIC_SITE_URL stays as an escape hatch for setups without `site:`.
166
+ const websiteUrlLiteral =
167
+ typeof __SETZKASTEN_WEBSITE_URL__ !== 'undefined' ? __SETZKASTEN_WEBSITE_URL__ : ''
168
+ const previewOrigin =
169
+ websiteUrlLiteral ||
170
+ process.env.PUBLIC_SITE_URL ||
171
+ 'http://localhost:4321'
172
+
173
+ state = {
174
+ mode: 'single',
175
+ synthesized: {
176
+ id: 'default',
177
+ name: buildStorage.repo,
178
+ repo: `${buildStorage.owner}/${buildStorage.repo}`,
179
+ branch: buildStorage.branch,
180
+ previewOrigin,
181
+ githubApp: { appId, installationId },
182
+ },
183
+ }
184
+ }
185
+
186
+ const HEADER = 'x-sk-website'
187
+
188
+ export async function resolveCurrentWebsite(request: Request): Promise<Result<WebsiteEntry>> {
189
+ if (state === null) bootstrapResolverFromGlobals()
190
+ if (state === null) {
191
+ return err(
192
+ validationError(
193
+ ['websiteResolver'],
194
+ 'not-configured',
195
+ 'Website resolver not configured — call configureWebsiteResolver() at integration startup.',
196
+ ),
197
+ )
198
+ }
199
+
200
+ if (state.mode === 'single') {
201
+ return ok(state.synthesized)
202
+ }
203
+
204
+ const requested = request.headers.get(HEADER)?.trim() ?? ''
205
+
206
+ if (!requested) {
207
+ const list = await state.registry.list()
208
+ if (!list.ok) return list
209
+
210
+ const sole = list.value.length === 1 ? list.value[0] : undefined
211
+ if (sole) return ok(sole)
212
+
213
+ return err(
214
+ validationError(
215
+ [HEADER],
216
+ 'required',
217
+ 'Standalone mode requires the X-SK-Website request header (registry has multiple entries).',
218
+ ),
219
+ )
220
+ }
221
+
222
+ const found = await state.registry.get(requested)
223
+ if (!found.ok) return found
224
+ if (!found.value) return err(notFoundError(`website:${requested}`))
225
+
226
+ return ok(found.value)
227
+ }
228
+
229
+ /**
230
+ * Lists all websites known to the resolver.
231
+ * Standalone mode: defers to the registry. Single-repo mode: returns the
232
+ * synthesized entry as a one-element list.
233
+ */
234
+ export async function listAllWebsites(): Promise<Result<readonly WebsiteEntry[]>> {
235
+ if (state === null) bootstrapResolverFromGlobals()
236
+ if (state === null) {
237
+ return err(
238
+ validationError(['websiteResolver'], 'not-configured', 'Website resolver not configured.'),
239
+ )
240
+ }
241
+ if (state.mode === 'single') return ok([state.synthesized])
242
+ return state.registry.list()
243
+ }