@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.
- package/package.json +22 -6
- package/src/admin-page.astro +1 -1
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/feature-gate.test.ts +60 -0
- package/src/api-routes/__tests__/github-token-for-request.test.ts +112 -0
- package/src/api-routes/__tests__/github-token.test.ts +78 -0
- package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
- package/src/api-routes/__tests__/history-rollback.test.ts +196 -0
- package/src/api-routes/__tests__/history.test.ts +168 -0
- package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
- package/src/api-routes/__tests__/license-tier.test.ts +45 -0
- package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
- package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
- package/src/api-routes/__tests__/route-registry.test.ts +120 -0
- package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
- package/src/api-routes/__tests__/setup-github-app-callback.test.ts +152 -0
- package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
- package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
- package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
- package/src/api-routes/__tests__/webhook-signing.test.ts +39 -0
- package/src/api-routes/__tests__/webhooks.test.ts +219 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
- package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
- package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
- package/src/api-routes/__tests__/websites-add.test.ts +305 -0
- package/src/api-routes/__tests__/websites-list.test.ts +112 -0
- package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
- package/src/api-routes/_auth-guard.ts +134 -13
- package/src/api-routes/_feature-gate.ts +39 -0
- package/src/api-routes/_github-token.ts +64 -0
- package/src/api-routes/_license-tier.ts +25 -0
- package/src/api-routes/_pages-meta-store.ts +134 -0
- package/src/api-routes/_role-resolver.ts +60 -0
- package/src/api-routes/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +77 -4
- package/src/api-routes/_vercel-origin.ts +22 -0
- package/src/api-routes/_webhook-dispatcher.ts +120 -0
- package/src/api-routes/_webhook-signing.ts +13 -0
- package/src/api-routes/_webhook-status-store.ts +31 -0
- package/src/api-routes/_website-resolver.ts +243 -0
- package/src/api-routes/_websites-store.ts +120 -0
- package/src/api-routes/asset-proxy.ts +6 -4
- package/src/api-routes/auth-callback.ts +8 -7
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +37 -11
- package/src/api-routes/catalog-add.ts +9 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +12 -5
- package/src/api-routes/editors.ts +94 -10
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +23 -6
- package/src/api-routes/history-rollback.ts +144 -0
- package/src/api-routes/history-version.ts +57 -0
- package/src/api-routes/history.ts +119 -0
- package/src/api-routes/init-add-section.ts +13 -5
- package/src/api-routes/init-apply.ts +5 -3
- package/src/api-routes/init-migrate.ts +7 -5
- package/src/api-routes/init-scan-page.ts +26 -6
- package/src/api-routes/init-scan.ts +5 -3
- package/src/api-routes/migrate-to-multi.ts +255 -0
- package/src/api-routes/pages.ts +118 -4
- package/src/api-routes/section-add.ts +15 -5
- package/src/api-routes/section-commit-pending.ts +117 -5
- package/src/api-routes/section-delete.ts +29 -5
- package/src/api-routes/section-duplicate.ts +15 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +9 -5
- package/src/api-routes/setup-github-app-bounce.ts +52 -0
- package/src/api-routes/setup-github-app-branches.ts +63 -0
- package/src/api-routes/setup-github-app-callback.ts +71 -0
- package/src/api-routes/setup-github-app-installed.ts +44 -0
- package/src/api-routes/setup-github-app-repos.ts +46 -0
- package/src/api-routes/setup-github-app.ts +58 -0
- package/src/api-routes/updater-register.ts +37 -25
- package/src/api-routes/updater-transfer.ts +1 -12
- package/src/api-routes/webhooks-status.ts +17 -0
- package/src/api-routes/webhooks-test.ts +134 -0
- package/src/api-routes/webhooks.ts +163 -0
- package/src/api-routes/websites-add.ts +113 -0
- package/src/api-routes/websites-list.ts +40 -0
- package/src/api-routes/websites-remove.ts +74 -0
- package/src/init/__tests__/patcher-edge-cases.test.ts +34 -1
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/template-patcher-v2.ts +42 -4
- 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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
+
}
|