@setzkasten-cms/astro-admin 0.8.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +16 -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__/github-token-for-request.test.ts +112 -0
  5. package/src/api-routes/__tests__/github-token.test.ts +78 -0
  6. package/src/api-routes/__tests__/global-config-theme.test.ts +71 -0
  7. package/src/api-routes/__tests__/init-scan-page-resolve-config.test.ts +61 -0
  8. package/src/api-routes/__tests__/license-tier.test.ts +45 -0
  9. package/src/api-routes/__tests__/migrate-to-multi.test.ts +189 -0
  10. package/src/api-routes/__tests__/pages-meta-store.test.ts +179 -0
  11. package/src/api-routes/__tests__/route-registry.test.ts +120 -0
  12. package/src/api-routes/__tests__/session-cookie.test.ts +67 -0
  13. package/src/api-routes/__tests__/setup-github-app-callback.test.ts +145 -0
  14. package/src/api-routes/__tests__/setup-github-app-repos.test.ts +192 -0
  15. package/src/api-routes/__tests__/setup-github-app.test.ts +107 -0
  16. package/src/api-routes/__tests__/storage-config-for-request.test.ts +78 -0
  17. package/src/api-routes/__tests__/website-resolver-bootstrap-standalone.test.ts +85 -0
  18. package/src/api-routes/__tests__/website-resolver-bootstrap.test.ts +108 -0
  19. package/src/api-routes/__tests__/website-resolver.test.ts +123 -0
  20. package/src/api-routes/__tests__/websites-add.test.ts +305 -0
  21. package/src/api-routes/__tests__/websites-list.test.ts +112 -0
  22. package/src/api-routes/__tests__/websites-remove.test.ts +155 -0
  23. package/src/api-routes/_auth-guard.ts +134 -13
  24. package/src/api-routes/_github-token.ts +64 -0
  25. package/src/api-routes/_license-tier.ts +25 -0
  26. package/src/api-routes/_pages-meta-store.ts +134 -0
  27. package/src/api-routes/_session-cookie.ts +42 -0
  28. package/src/api-routes/_storage-config.ts +64 -4
  29. package/src/api-routes/_vercel-origin.ts +22 -0
  30. package/src/api-routes/_website-resolver.ts +243 -0
  31. package/src/api-routes/_websites-store.ts +120 -0
  32. package/src/api-routes/asset-proxy.ts +6 -4
  33. package/src/api-routes/auth-callback.ts +6 -7
  34. package/src/api-routes/auth-logout.ts +5 -1
  35. package/src/api-routes/auth-setzkasten-login.ts +21 -10
  36. package/src/api-routes/catalog-add.ts +9 -5
  37. package/src/api-routes/catalog-export.ts +8 -4
  38. package/src/api-routes/config.ts +12 -5
  39. package/src/api-routes/editors.ts +79 -10
  40. package/src/api-routes/github-proxy.ts +5 -5
  41. package/src/api-routes/global-config.ts +23 -6
  42. package/src/api-routes/init-add-section.ts +13 -5
  43. package/src/api-routes/init-apply.ts +5 -3
  44. package/src/api-routes/init-migrate.ts +7 -5
  45. package/src/api-routes/init-scan-page.ts +26 -6
  46. package/src/api-routes/init-scan.ts +5 -3
  47. package/src/api-routes/migrate-to-multi.ts +255 -0
  48. package/src/api-routes/pages.ts +118 -4
  49. package/src/api-routes/section-add.ts +15 -5
  50. package/src/api-routes/section-commit-pending.ts +18 -5
  51. package/src/api-routes/section-delete.ts +15 -5
  52. package/src/api-routes/section-duplicate.ts +15 -5
  53. package/src/api-routes/section-prepare-copy.ts +15 -4
  54. package/src/api-routes/section-prepare.ts +9 -5
  55. package/src/api-routes/setup-github-app-bounce.ts +52 -0
  56. package/src/api-routes/setup-github-app-branches.ts +63 -0
  57. package/src/api-routes/setup-github-app-callback.ts +53 -0
  58. package/src/api-routes/setup-github-app-installed.ts +44 -0
  59. package/src/api-routes/setup-github-app-repos.ts +46 -0
  60. package/src/api-routes/setup-github-app.ts +58 -0
  61. package/src/api-routes/updater-register.ts +6 -23
  62. package/src/api-routes/updater-transfer.ts +1 -12
  63. package/src/api-routes/websites-add.ts +113 -0
  64. package/src/api-routes/websites-list.ts +40 -0
  65. package/src/api-routes/websites-remove.ts +74 -0
  66. package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
  67. package/src/init/template-patcher-v2.ts +33 -0
  68. package/LICENSE +0 -37
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { generateKeyPairSync } from 'node:crypto'
6
+ import type { WebsiteEntry } from '@setzkasten-cms/core'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
10
+ const PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
11
+
12
+ const REGISTRY = {
13
+ websites: [
14
+ {
15
+ id: 'keep-me',
16
+ name: 'Keep Me',
17
+ repo: 'acme/keep',
18
+ branch: 'main',
19
+ previewOrigin: 'https://keep.example.com',
20
+ githubApp: { appId: '1', installationId: '111' },
21
+ },
22
+ {
23
+ id: 'remove-me',
24
+ name: 'Remove Me',
25
+ repo: 'acme/remove',
26
+ branch: 'main',
27
+ previewOrigin: 'https://remove.example.com',
28
+ githubApp: { appId: '1', installationId: '222' },
29
+ },
30
+ ],
31
+ }
32
+
33
+ // Admin sessions only — websites-remove is admin-gated.
34
+ const ADMIN_SESSION = JSON.stringify({
35
+ user: {
36
+ id: 'u1',
37
+ email: 'admin@example.com',
38
+ role: 'admin',
39
+ provider: 'github',
40
+ },
41
+ expiresAt: Date.now() + 60 * 60 * 1000,
42
+ })
43
+
44
+ function makeCtx(body: unknown, sessionValue: string | null = ADMIN_SESSION) {
45
+ const request = new Request('https://cms.example.com/api/setzkasten/websites/remove', {
46
+ method: 'POST',
47
+ body: JSON.stringify(body),
48
+ headers: { 'content-type': 'application/json' },
49
+ })
50
+ return {
51
+ request,
52
+ cookies: {
53
+ get: vi.fn((name: string) =>
54
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
55
+ ),
56
+ },
57
+ }
58
+ }
59
+
60
+ beforeEach(() => {
61
+ vi.unstubAllEnvs()
62
+ vi.stubEnv('GITHUB_APP_ID', '1')
63
+ vi.stubEnv('GITHUB_APP_INSTALLATION_ID', '111')
64
+ vi.stubEnv('GITHUB_APP_PRIVATE_KEY', PEM)
65
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = {
66
+ storage: {
67
+ kind: 'standalone',
68
+ configRepo: 'acme/cms-config',
69
+ configBranch: 'main',
70
+ appId: '1',
71
+ installationId: '111',
72
+ },
73
+ }
74
+ })
75
+
76
+ afterEach(() => {
77
+ vi.restoreAllMocks()
78
+ vi.unstubAllEnvs()
79
+ ;(globalThis as Record<string, unknown>).__SETZKASTEN_FULL_CONFIG__ = undefined
80
+ })
81
+
82
+ function mockGithubFetch() {
83
+ const calls: Array<{ url: string; method?: string; body?: unknown }> = []
84
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
85
+ calls.push({ url, method: init?.method, body: init?.body })
86
+ if (url.includes('/access_tokens')) {
87
+ return {
88
+ ok: true,
89
+ json: async () => ({
90
+ token: 'gh_mock',
91
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
92
+ }),
93
+ } as Response
94
+ }
95
+ if (url.includes('/contents/websites.json') && (init?.method ?? 'GET') === 'GET') {
96
+ return {
97
+ ok: true,
98
+ json: async () => ({
99
+ content: Buffer.from(JSON.stringify(REGISTRY)).toString('base64'),
100
+ sha: 'reg-sha',
101
+ }),
102
+ } as Response
103
+ }
104
+ if (url.includes('/contents/websites.json') && init?.method === 'PUT') {
105
+ return {
106
+ ok: true,
107
+ json: async () => ({ content: { sha: 'new-sha' } }),
108
+ } as Response
109
+ }
110
+ throw new Error(`unexpected URL: ${url} method=${init?.method}`)
111
+ })
112
+ vi.stubGlobal('fetch', fetchMock)
113
+ return calls
114
+ }
115
+
116
+ describe('POST /api/setzkasten/websites/remove', () => {
117
+ it('returns 401 without a session', async () => {
118
+ const { POST } = await import('../websites-remove')
119
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ id: 'x' }, null))
120
+
121
+ expect(res.status).toBe(401)
122
+ })
123
+
124
+ it('returns 400 when body has no id', async () => {
125
+ const { POST } = await import('../websites-remove')
126
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({}))
127
+
128
+ expect(res.status).toBe(400)
129
+ })
130
+
131
+ it('returns 404 when the id does not exist', async () => {
132
+ mockGithubFetch()
133
+
134
+ const { POST } = await import('../websites-remove')
135
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ id: 'nope' }))
136
+
137
+ expect(res.status).toBe(404)
138
+ })
139
+
140
+ it('removes the entry and writes the updated registry', async () => {
141
+ const calls = mockGithubFetch()
142
+
143
+ const { POST } = await import('../websites-remove')
144
+ const res = await (POST as (ctx: unknown) => Promise<Response>)(makeCtx({ id: 'remove-me' }))
145
+
146
+ expect(res.status).toBe(200)
147
+
148
+ const putCall = calls.find((c) => c.method === 'PUT')
149
+ expect(putCall).toBeDefined()
150
+ const writtenBody = JSON.parse(String(putCall!.body)) as { content: string; sha?: string }
151
+ expect(writtenBody.sha).toBe('reg-sha')
152
+ const decoded = JSON.parse(Buffer.from(writtenBody.content, 'base64').toString('utf-8'))
153
+ expect(decoded.websites.map((w: WebsiteEntry) => w.id)).toEqual(['keep-me'])
154
+ })
155
+ })
@@ -1,10 +1,9 @@
1
1
  import { canEditPage } from '@setzkasten-cms/auth'
2
- import type { AuthSession, SetzKastenConfig } from '@setzkasten-cms/core'
2
+ import type { AuthSession, ContentEditorConfig, SetzKastenConfig } from '@setzkasten-cms/core'
3
+ import { resolveStorageConfig } from './_storage-config'
4
+ import { resolveConfigRepoToken } from './_github-token'
5
+ import { readEditorsFileStatus } from './editors'
3
6
 
4
- /**
5
- * Parses the raw session cookie value.
6
- * Returns null if missing or invalid.
7
- */
8
7
  export function parseSession(raw: string | undefined): AuthSession | null {
9
8
  if (!raw) return null
10
9
  try {
@@ -15,18 +14,140 @@ export function parseSession(raw: string | undefined): AuthSession | null {
15
14
  }
16
15
 
17
16
  /**
18
- * Returns a 403 Response if the session user may NOT edit the given page.
19
- * Returns null (= allowed) otherwise.
17
+ * Returns 401 if the request has no valid session, 403 if the user is not
18
+ * an admin, or null (= allowed) otherwise. Used by every admin-only API
19
+ * endpoint so the role check is mechanically applied — never just
20
+ * `if (!session) return 401`, which lets editors hit admin-only routes.
21
+ */
22
+ export function requireAdmin(rawSession: string | undefined): Response | null {
23
+ const session = parseSession(rawSession)
24
+ if (!session) {
25
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
26
+ status: 401,
27
+ headers: { 'Content-Type': 'application/json' },
28
+ })
29
+ }
30
+ if (session.user.role !== 'admin') {
31
+ return new Response(JSON.stringify({ error: 'Forbidden: admin role required' }), {
32
+ status: 403,
33
+ headers: { 'Content-Type': 'application/json' },
34
+ })
35
+ }
36
+ return null
37
+ }
38
+
39
+ /**
40
+ * Returns a 403/503 Response if the session user may NOT edit the given page,
41
+ * or null (= allowed) otherwise.
42
+ *
43
+ * Authorization is two-layered:
20
44
  *
21
- * Admins always pass. Editors are checked against config.auth.editors.
45
+ * 1. Global editors (_editors.json in the config-repo) decide which pages
46
+ * an editor account can touch at all. Admins always pass.
47
+ *
48
+ * 2. Per-website allowedEmails (in WebsiteEntry, multi-mode only) decide
49
+ * which editors may operate on the website the request is targeting.
50
+ * Without this layer, an editor could swap the X-SK-Website header
51
+ * to any registered website id and inherit edit access transitively
52
+ * from layer 1. Single-mode requests skip this layer because the
53
+ * resolver returns no Result.ok for them.
54
+ *
55
+ * Fail-modes for layer 1:
56
+ * - File absent (genuine 404) → undefined → canEditPage allows
57
+ * (semantic: "no restrictions configured")
58
+ * - File present and parsed → list → canEditPage applies it
59
+ * - Fetch failed / parse failed → 503, deny access (was a silent grant
60
+ * before review finding #1).
22
61
  */
23
- export function guardPageAccess(
62
+ export async function guardPageAccess(
24
63
  session: AuthSession | null,
25
64
  pageKey: string,
26
65
  fullConfig: SetzKastenConfig | undefined,
27
- ): Response | null {
66
+ request?: Request,
67
+ ): Promise<Response | null> {
28
68
  if (!session) return new Response('Unauthorized', { status: 401 })
29
- if (!fullConfig) return null // no config = no restrictions
30
- if (canEditPage(session, pageKey, fullConfig)) return null
31
- return new Response('Forbidden: you do not have access to this page', { status: 403 })
69
+
70
+ // Layer 1: global editors file
71
+ const editorsLookup = await resolveDynamicEditors()
72
+ if (!editorsLookup.ok) {
73
+ return new Response(
74
+ `Forbidden: editor permissions unavailable (${editorsLookup.error})`,
75
+ { status: 503 },
76
+ )
77
+ }
78
+ if (!canEditPage(session, pageKey, editorsLookup.editors)) {
79
+ return new Response('Forbidden: you do not have access to this page', { status: 403 })
80
+ }
81
+
82
+ // Layer 2: per-website allowedEmails (multi-mode only). Requires the
83
+ // request so we can re-route through the website resolver. Routes that
84
+ // don't pass `request` skip this layer — kept optional so the existing
85
+ // call sites compile without a coordinated update.
86
+ if (request) {
87
+ const denied = await guardWebsiteAccess(session, request)
88
+ if (denied) return denied
89
+ }
90
+
91
+ return null
92
+ }
93
+
94
+ /**
95
+ * Per-website authorization. Editors must be listed on
96
+ * `WebsiteEntry.allowedEmails` to operate on the website that the
97
+ * X-SK-Website header resolves to. Admins always pass; single-mode
98
+ * skips this check entirely (no website context).
99
+ *
100
+ * Returns 403 on deny, null on allow. Resolver failures are
101
+ * considered "no per-website restriction" — the global guard above
102
+ * already covers fail-closed for editors-file errors.
103
+ */
104
+ export async function guardWebsiteAccess(
105
+ session: AuthSession,
106
+ request: Request,
107
+ ): Promise<Response | null> {
108
+ if (session.user.role === 'admin') return null
109
+
110
+ const { resolveCurrentWebsite } = await import('./_website-resolver.js')
111
+ const website = await resolveCurrentWebsite(request)
112
+ if (!website.ok) return null
113
+
114
+ const allowed = website.value.allowedEmails
115
+ if (!allowed || allowed.length === 0) return null
116
+
117
+ if (!allowed.includes(session.user.email)) {
118
+ return new Response(
119
+ `Forbidden: not allowed on website "${website.value.id}"`,
120
+ { status: 403 },
121
+ )
122
+ }
123
+ return null
124
+ }
125
+
126
+ type EditorsLookup =
127
+ | { ok: true; editors: readonly ContentEditorConfig[] | undefined }
128
+ | { ok: false; error: string }
129
+
130
+ async function resolveDynamicEditors(): Promise<EditorsLookup> {
131
+ const storage = resolveStorageConfig()
132
+ if (!storage) return { ok: true, editors: undefined }
133
+
134
+ const tokenResult = await resolveConfigRepoToken()
135
+ if (!tokenResult.ok) {
136
+ return { ok: false, error: `token: ${tokenResult.error.message}` }
137
+ }
138
+
139
+ const serverConfig = (globalThis as { __SETZKASTEN_CONFIG__?: { storage?: { contentPath?: string } } })
140
+ .__SETZKASTEN_CONFIG__
141
+ const contentPath = serverConfig?.storage?.contentPath ?? 'content'
142
+
143
+ const status = await readEditorsFileStatus(
144
+ storage.owner,
145
+ storage.repo,
146
+ storage.branch,
147
+ contentPath,
148
+ tokenResult.value,
149
+ )
150
+ if (status.kind === 'absent') return { ok: true, editors: undefined }
151
+ if (status.kind === 'present') return { ok: true, editors: status.editors }
152
+ return { ok: false, error: status.message }
32
153
  }
@@ -0,0 +1,64 @@
1
+ import { err, authError, type Result } from '@setzkasten-cms/core'
2
+ import { GitHubAppClient } from '@setzkasten-cms/github-adapter'
3
+
4
+ /**
5
+ * Token for the **config repo** (in multi mode) or the **website repo**
6
+ * (in single mode). Reads the App credentials straight from ENV — no
7
+ * X-SK-Website routing involved. Used by config-management endpoints
8
+ * (`/websites/add`, `/websites/remove`) and any read of `_global_config.json`
9
+ * / `_editors.json` that lives next to the registry, not next to the
10
+ * editable content.
11
+ *
12
+ * In single mode this is the only relevant token; in multi mode it is
13
+ * the token for the config repo, while per-website operations use
14
+ * {@link resolveGitHubTokenForRequest}.
15
+ */
16
+ export async function resolveConfigRepoToken(): Promise<Result<string>> {
17
+ const appId = process.env.GITHUB_APP_ID
18
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
19
+ const installationId = process.env.GITHUB_APP_INSTALLATION_ID
20
+
21
+ if (appId && privateKey && installationId) {
22
+ const client = new GitHubAppClient(
23
+ { appId, privateKey, installationId },
24
+ { owner: '', repo: '', branch: '' },
25
+ )
26
+ return client.getInstallationToken()
27
+ }
28
+
29
+ return err(
30
+ authError(
31
+ 'GitHub App not configured. Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_INSTALLATION_ID.',
32
+ ),
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Token for the website resolved from the request's `X-SK-Website` header.
38
+ * In multi mode this picks the installation id of the active website;
39
+ * in single mode the resolver synthesises one website from build-time
40
+ * storage and returns the same token as {@link resolveConfigRepoToken}.
41
+ *
42
+ * The PEM private key always comes from `GITHUB_APP_PRIVATE_KEY` — one
43
+ * App, many installations.
44
+ */
45
+ export async function resolveGitHubTokenForRequest(request: Request): Promise<Result<string>> {
46
+ const { resolveCurrentWebsite } = await import('./_website-resolver.js')
47
+ const website = await resolveCurrentWebsite(request)
48
+ if (!website.ok) return website
49
+
50
+ const privateKey = process.env.GITHUB_APP_PRIVATE_KEY
51
+ if (!privateKey) {
52
+ return err(authError('GitHub App not configured. Set GITHUB_APP_PRIVATE_KEY.'))
53
+ }
54
+
55
+ const client = new GitHubAppClient(
56
+ {
57
+ appId: website.value.githubApp.appId,
58
+ installationId: website.value.githubApp.installationId,
59
+ privateKey,
60
+ },
61
+ { owner: '', repo: '', branch: '' },
62
+ )
63
+ return client.getInstallationToken()
64
+ }
@@ -0,0 +1,25 @@
1
+ import type { FeatureTier } from '@setzkasten-cms/core'
2
+
3
+ /**
4
+ * Server-side license-tier lookup based on the configured license key.
5
+ *
6
+ * The prefix is the canonical signal:
7
+ * SK-PRO-... → 'pro'
8
+ * SK-ENT-... → 'enterprise'
9
+ * anything else (or unset) → 'free'
10
+ *
11
+ * The updater backend does the real validation (revocation, expiry); this
12
+ * function is enough to enforce honest-user limits at the API boundary.
13
+ * A bad actor faking a prefix will get 200 here but be flagged on the next
14
+ * dashboard register call — and the limit gate is a deterrent, not a
15
+ * payment system.
16
+ *
17
+ * Reads `SETZKASTEN_LICENSE_KEY` from the env first; future versions may
18
+ * add a config-repo `_global_config.json` fallback.
19
+ */
20
+ export function resolveLicenseTier(): FeatureTier {
21
+ const raw = process.env.SETZKASTEN_LICENSE_KEY?.trim() ?? ''
22
+ if (raw.startsWith('SK-PRO-')) return 'pro'
23
+ if (raw.startsWith('SK-ENT-')) return 'enterprise'
24
+ return 'free'
25
+ }
@@ -0,0 +1,134 @@
1
+ import {
2
+ emptyPagesMeta,
3
+ err,
4
+ networkError,
5
+ ok,
6
+ parsePagesMeta,
7
+ setPageLastModified,
8
+ type PagesMeta,
9
+ type Result,
10
+ } from '@setzkasten-cms/core'
11
+ import { withTrailers } from './_commit-trailers'
12
+
13
+ /**
14
+ * Server-side read/write helpers for `_pages-meta.json`.
15
+ *
16
+ * - `readPagesMeta` returns the parsed registry plus its current SHA, or
17
+ * an empty registry with `sha: null` when the file does not exist.
18
+ * - `recordPageEdit` updates a single page's `lastModified` timestamp and
19
+ * commits back, retrying once on 409 (concurrent edit). Failures
20
+ * propagate as network errors so callers can decide whether to log
21
+ * silently or surface them.
22
+ */
23
+
24
+ export interface PagesMetaTarget {
25
+ readonly owner: string
26
+ readonly repo: string
27
+ readonly branch: string
28
+ readonly contentPath: string
29
+ readonly token: string
30
+ }
31
+
32
+ interface PagesMetaSnapshot {
33
+ readonly meta: PagesMeta
34
+ readonly sha: string | null
35
+ }
36
+
37
+ const RELATIVE_PATH = '_pages-meta.json'
38
+ // Spec: up to 3 attempts, then silent fail. With 5+ mutating routes firing
39
+ // concurrently on a busy dashboard, the third attempt absorbs the burst.
40
+ const MAX_RETRIES = 3
41
+
42
+ function metaFilePath(contentPath: string): string {
43
+ return `${contentPath}/${RELATIVE_PATH}`
44
+ }
45
+
46
+ function ghHeaders(token: string) {
47
+ return {
48
+ Authorization: `Bearer ${token}`,
49
+ Accept: 'application/vnd.github+json',
50
+ 'X-GitHub-Api-Version': '2022-11-28',
51
+ 'Content-Type': 'application/json',
52
+ }
53
+ }
54
+
55
+ function ghContentsUrl(target: PagesMetaTarget): string {
56
+ return `https://api.github.com/repos/${target.owner}/${target.repo}/contents/${metaFilePath(target.contentPath)}`
57
+ }
58
+
59
+ export async function readPagesMeta(target: PagesMetaTarget): Promise<Result<PagesMetaSnapshot>> {
60
+ try {
61
+ const res = await fetch(`${ghContentsUrl(target)}?ref=${target.branch}`, {
62
+ headers: ghHeaders(target.token),
63
+ })
64
+
65
+ if (res.status === 404) {
66
+ return ok({ meta: emptyPagesMeta(), sha: null })
67
+ }
68
+ if (!res.ok) {
69
+ return err(networkError(`GitHub returned ${res.status} reading ${RELATIVE_PATH}`))
70
+ }
71
+
72
+ const data = (await res.json()) as { content: string; sha: string }
73
+ const decoded = Buffer.from(data.content, 'base64').toString('utf-8')
74
+ const parsed = parsePagesMeta(decoded)
75
+ if (!parsed.ok) return parsed
76
+ return ok({ meta: parsed.value, sha: data.sha })
77
+ } catch (cause) {
78
+ const message = cause instanceof Error ? cause.message : 'Unknown error'
79
+ return err(networkError(`Failed to read ${RELATIVE_PATH}: ${message}`, cause))
80
+ }
81
+ }
82
+
83
+ async function writePagesMeta(
84
+ target: PagesMetaTarget,
85
+ next: PagesMeta,
86
+ previousSha: string | null,
87
+ ): Promise<Response> {
88
+ const body: Record<string, unknown> = {
89
+ message: withTrailers('chore(meta): update _pages-meta.json'),
90
+ content: Buffer.from(JSON.stringify(next, null, 2)).toString('base64'),
91
+ branch: target.branch,
92
+ }
93
+ if (previousSha) body.sha = previousSha
94
+
95
+ return fetch(ghContentsUrl(target), {
96
+ method: 'PUT',
97
+ headers: ghHeaders(target.token),
98
+ body: JSON.stringify(body),
99
+ })
100
+ }
101
+
102
+ /**
103
+ * Records that `pageKey` was just edited. Reads the meta, sets the
104
+ * timestamp, writes back. On a 409 conflict (someone else committed
105
+ * meanwhile) the function re-reads and retries up to MAX_RETRIES times.
106
+ */
107
+ export async function recordPageEdit(
108
+ target: PagesMetaTarget,
109
+ pageKey: string,
110
+ timestamp = Date.now(),
111
+ ): Promise<Result<void>> {
112
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
113
+ const current = await readPagesMeta(target)
114
+ if (!current.ok) return current
115
+
116
+ const next = setPageLastModified(current.value.meta, pageKey, timestamp)
117
+
118
+ let response: Response
119
+ try {
120
+ response = await writePagesMeta(target, next, current.value.sha)
121
+ } catch (cause) {
122
+ const message = cause instanceof Error ? cause.message : 'Unknown error'
123
+ return err(networkError(`Failed to write ${RELATIVE_PATH}: ${message}`, cause))
124
+ }
125
+
126
+ if (response.ok) return ok(undefined)
127
+ if (response.status === 409 && attempt < MAX_RETRIES - 1) continue
128
+
129
+ const text = await response.text().catch(() => '')
130
+ return err(networkError(`GitHub PUT failed: ${response.status} ${text}`))
131
+ }
132
+
133
+ return err(networkError(`recordPageEdit: exhausted ${MAX_RETRIES} retries`))
134
+ }
@@ -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,41 @@ 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
+ if (body?.owner && body.repo) {
91
+ return {
92
+ owner: body.owner,
93
+ repo: body.repo,
94
+ branch: body.branch ?? 'main',
95
+ projectPrefix: '',
96
+ }
97
+ }
98
+
99
+ const { resolveCurrentWebsite } = await import('./_website-resolver.js')
100
+ const resolved = await resolveCurrentWebsite(request)
101
+ if (resolved.ok) {
102
+ const [owner, repo] = resolved.value.repo.split('/')
103
+ if (owner && repo) {
104
+ return {
105
+ owner,
106
+ repo,
107
+ branch: resolved.value.branch,
108
+ projectPrefix: '',
109
+ }
110
+ }
111
+ }
112
+
113
+ return resolveStorageConfig(body)
114
+ }
@@ -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
+ }