@setzkasten-cms/astro-admin 0.6.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.
- package/package.json +23 -6
- package/src/admin-page.astro +9 -8
- package/src/api-routes/__tests__/auth-guard.test.ts +134 -0
- package/src/api-routes/__tests__/commit-trailers.test.ts +69 -0
- package/src/api-routes/__tests__/github-cache.test.ts +100 -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__/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__/pages.test.ts +72 -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 +145 -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__/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 +153 -0
- package/src/api-routes/_commit-trailers.ts +16 -0
- package/src/api-routes/_github-cache.ts +32 -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/_session-cookie.ts +42 -0
- package/src/api-routes/_storage-config.ts +64 -4
- package/src/api-routes/_vercel-origin.ts +22 -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 +21 -53
- package/src/api-routes/auth-login.ts +18 -65
- package/src/api-routes/auth-logout.ts +5 -1
- package/src/api-routes/auth-setzkasten-login.ts +71 -0
- package/src/api-routes/catalog-add.ts +18 -5
- package/src/api-routes/catalog-export.ts +8 -4
- package/src/api-routes/config.ts +17 -5
- package/src/api-routes/editors.ts +205 -0
- package/src/api-routes/github-proxy.ts +5 -5
- package/src/api-routes/global-config.ts +149 -0
- package/src/api-routes/init-add-section.ts +21 -10
- package/src/api-routes/init-apply.ts +7 -4
- package/src/api-routes/init-migrate.ts +9 -6
- 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 +138 -6
- package/src/api-routes/section-add.ts +23 -5
- package/src/api-routes/section-commit-pending.ts +28 -5
- package/src/api-routes/section-delete.ts +24 -5
- package/src/api-routes/section-duplicate.ts +25 -5
- package/src/api-routes/section-prepare-copy.ts +15 -4
- package/src/api-routes/section-prepare.ts +12 -4
- 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 +53 -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-check.ts +49 -0
- package/src/api-routes/updater-register.ts +90 -0
- package/src/api-routes/updater-transfer.ts +51 -0
- package/src/api-routes/updater-unbind.ts +59 -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__/page-level.test.ts +47 -0
- package/src/init/__tests__/patcher-mixed-content-wrapper.test.ts +90 -0
- package/src/init/__tests__/section-pipeline.test.ts +3 -1
- package/src/init/astro-section-analyzer-v2.ts +29 -2
- package/src/init/template-patcher-v2.ts +100 -0
- 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
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { canEditPage } from '@setzkasten-cms/auth'
|
|
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'
|
|
6
|
+
|
|
7
|
+
export function parseSession(raw: string | undefined): AuthSession | null {
|
|
8
|
+
if (!raw) return null
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(raw) as AuthSession
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
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:
|
|
44
|
+
*
|
|
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).
|
|
61
|
+
*/
|
|
62
|
+
export async function guardPageAccess(
|
|
63
|
+
session: AuthSession | null,
|
|
64
|
+
pageKey: string,
|
|
65
|
+
fullConfig: SetzKastenConfig | undefined,
|
|
66
|
+
request?: Request,
|
|
67
|
+
): Promise<Response | null> {
|
|
68
|
+
if (!session) return new Response('Unauthorized', { status: 401 })
|
|
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 }
|
|
153
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const SETZKASTEN_CO_AUTHOR = 'Co-authored-by: Setzkasten <setzkasten@setzkasten.dev>'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Appends git commit trailers to a commit message.
|
|
5
|
+
* Always includes the Setzkasten co-author; optionally includes the editor.
|
|
6
|
+
*/
|
|
7
|
+
export function withTrailers(message: string, editorEmail?: string | null): string {
|
|
8
|
+
const trailers = [SETZKASTEN_CO_AUTHOR]
|
|
9
|
+
if (editorEmail) {
|
|
10
|
+
const name = editorEmail.split('@')[0]!
|
|
11
|
+
.replace(/[._-]+/g, ' ')
|
|
12
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
13
|
+
trailers.push(`Co-authored-by: ${name} <${editorEmail}>`)
|
|
14
|
+
}
|
|
15
|
+
return `${message}\n\n${trailers.join('\n')}`
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface CacheEntry<T> {
|
|
2
|
+
value: T
|
|
3
|
+
fetchedAt: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const cache = new Map<string, CacheEntry<unknown>>()
|
|
7
|
+
|
|
8
|
+
export async function cachedFetch<T>(
|
|
9
|
+
key: string,
|
|
10
|
+
ttlMs: number,
|
|
11
|
+
fetcher: () => Promise<T>,
|
|
12
|
+
): Promise<T> {
|
|
13
|
+
const entry = cache.get(key) as CacheEntry<T> | undefined
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
|
|
16
|
+
if (entry !== undefined && now - entry.fetchedAt < ttlMs) {
|
|
17
|
+
return entry.value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const value = await fetcher()
|
|
22
|
+
cache.set(key, { value, fetchedAt: now })
|
|
23
|
+
return value
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (entry !== undefined) return entry.value
|
|
26
|
+
throw err
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function invalidateCache(key: string): void {
|
|
31
|
+
cache.delete(key)
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|