@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,112 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { type Result, type WebsiteEntry, ok } from '@setzkasten-cms/core'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { __resetWebsiteResolverForTests } from '../_website-resolver'
8
+
9
+ const ENTRY_A: WebsiteEntry = {
10
+ id: 'site-a',
11
+ name: 'Site A',
12
+ repo: 'acme/site-a',
13
+ branch: 'main',
14
+ previewOrigin: 'https://a.example.com',
15
+ githubApp: { appId: '1', installationId: '101' },
16
+ }
17
+
18
+ const ENTRY_B: WebsiteEntry = {
19
+ id: 'site-b',
20
+ name: 'Site B',
21
+ repo: 'acme/site-b',
22
+ branch: 'develop',
23
+ previewOrigin: 'https://b.example.com',
24
+ githubApp: { appId: '1', installationId: '202' },
25
+ allowedEmails: ['secret@example.com'],
26
+ }
27
+
28
+ function makeRegistry(entries: readonly WebsiteEntry[]) {
29
+ return {
30
+ list: vi.fn(async (): Promise<Result<readonly WebsiteEntry[]>> => ok(entries)),
31
+ get: vi.fn(async (id: string): Promise<Result<WebsiteEntry | null>> => {
32
+ return ok(entries.find((e) => e.id === id) ?? null)
33
+ }),
34
+ }
35
+ }
36
+
37
+ function makeCtx(sessionValue?: string) {
38
+ return {
39
+ request: new Request('https://cms.example.com/api/setzkasten/websites'),
40
+ cookies: {
41
+ get: vi.fn((name: string) =>
42
+ name === 'setzkasten_session' && sessionValue ? { value: sessionValue } : undefined,
43
+ ),
44
+ },
45
+ }
46
+ }
47
+
48
+ import { GET } from '../websites-list'
49
+
50
+ describe('GET /api/setzkasten/websites', () => {
51
+ afterEach(() => __resetWebsiteResolverForTests(null))
52
+
53
+ it('returns 401 when no session cookie is present', async () => {
54
+ __resetWebsiteResolverForTests({ mode: 'multi', registry: makeRegistry([ENTRY_A]) })
55
+
56
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx())
57
+
58
+ expect(res.status).toBe(401)
59
+ })
60
+
61
+ it('returns the list of websites when authenticated', async () => {
62
+ __resetWebsiteResolverForTests({
63
+ mode: 'multi',
64
+ registry: makeRegistry([ENTRY_A, ENTRY_B]),
65
+ })
66
+
67
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
68
+ const body = await res.json()
69
+
70
+ expect(res.status).toBe(200)
71
+ expect(body.websites).toHaveLength(2)
72
+ expect(body.websites[0].id).toBe('site-a')
73
+ expect(body.websites[1].id).toBe('site-b')
74
+ })
75
+
76
+ it('does not expose githubApp installation ids in the response', async () => {
77
+ __resetWebsiteResolverForTests({
78
+ mode: 'multi',
79
+ registry: makeRegistry([ENTRY_B]),
80
+ })
81
+
82
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
83
+ const body = await res.json()
84
+
85
+ expect(body.websites[0]).not.toHaveProperty('githubApp')
86
+ expect(JSON.stringify(body)).not.toContain('202')
87
+ })
88
+
89
+ it('does not expose allowedEmails in the response', async () => {
90
+ __resetWebsiteResolverForTests({
91
+ mode: 'multi',
92
+ registry: makeRegistry([ENTRY_B]),
93
+ })
94
+
95
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
96
+ const body = await res.json()
97
+
98
+ expect(body.websites[0]).not.toHaveProperty('allowedEmails')
99
+ expect(JSON.stringify(body)).not.toContain('secret@example.com')
100
+ })
101
+
102
+ it('returns the synthesized entry in single-repo mode', async () => {
103
+ __resetWebsiteResolverForTests({ mode: 'single', synthesized: ENTRY_A })
104
+
105
+ const res = await (GET as (ctx: unknown) => Promise<Response>)(makeCtx('valid'))
106
+ const body = await res.json()
107
+
108
+ expect(res.status).toBe(200)
109
+ expect(body.websites).toHaveLength(1)
110
+ expect(body.websites[0].id).toBe(ENTRY_A.id)
111
+ })
112
+ })
@@ -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,39 @@
1
+ import { checkFeature } from '@setzkasten-cms/core'
2
+ import { resolveLicenseTier } from './_license-tier'
3
+
4
+ /**
5
+ * Soft-fail feature-gate guard for API routes. Returns `null` when the
6
+ * feature is available at the current license tier; otherwise returns a
7
+ * 403 Response with a machine-readable JSON body.
8
+ *
9
+ * Usage:
10
+ *
11
+ * const gate = gateFeature('editors')
12
+ * if (gate) return gate
13
+ * // ... normal route logic
14
+ *
15
+ * The body shape:
16
+ *
17
+ * { error: string, code: 'feature-locked', feature: string, requiredTier: string }
18
+ *
19
+ * Routes use 403 (not 402) because UI clients robustly handle 403 Forbidden
20
+ * but often surface 402 Payment Required as a generic error. The `code`
21
+ * field lets the UI hook (`useFeatureGate`) distinguish locked features
22
+ * from real authorization failures.
23
+ */
24
+ export function gateFeature(feature: string): Response | null {
25
+ const tier = resolveLicenseTier()
26
+ const result = checkFeature(feature, tier)
27
+ if (result.ok) return null
28
+
29
+ return new Response(
30
+ JSON.stringify({
31
+ error: result.reason,
32
+ code: 'feature-locked',
33
+ feature: result.feature,
34
+ requiredTier: result.requiredTier,
35
+ currentTier: tier,
36
+ }),
37
+ { status: 403, headers: { 'Content-Type': 'application/json' } },
38
+ )
39
+ }
@@ -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
+ }