mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
* feat: add /api/ihost/config endpoint with Cloudflare for SaaS integration - Add CfCustomHostnamesClient service (thin CF API wrapper; no-op when CF env vars absent) - Add /api/ihost/config route (GET/PUT/DELETE) following email-config pattern - GET lazily refreshes domain verification from CF; PUT upserts config, registers/deregisters CF hostnames; DELETE best-effort CF cleanup + row removal - PUT rejects enabled=false (must use DELETE); validates customDomain hostname format; validates refererAllowlist entries as URL origins; catches unique constraint → 409 - Add putIhostConfigSchema and IhostConfigResponse to shared schemas/types - Mount route in app.ts under /api/ihost/config - Add image_hosting_configs and image_hostings tables to test setup SQL - Add 22 integration tests covering all acceptance criteria - Update v2.4.md roadmap with config API notes; add docs/ihost-custom-domain-node.md Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * fix(ihost-config): restrict PUT/DELETE to owner role, add CF client unit tests, fix CodeQL URL check - Change requireTeamRole('editor') → requireTeamRole('owner') on PUT and DELETE (spec requires owner/admin only) - Add explicit editor-role 403 tests for PUT and DELETE - Add server/services/cf-custom-hostnames.test.ts: 16 unit tests covering register/getStatus/delete success, 409/4xx/network errors, no-op behavior, createCfClient factory - Add integration tests: GET domainStatus=verified, domainStatus=none, refererAllowlist JSON parsing, CF lazy verification active/pending paths, dnsInstructions CNAME vs manual, APP_HOST rejection, CF register on PUT, CF delete+register on domain change, CF 409 from register, clear refererAllowlist, DELETE best-effort CF cleanup (success + fail-graceful) - Replace .includes('cloudflare.com') with new URL(url).host === 'api.cloudflare.com' to fix CodeQL CWE-20 incomplete URL substring sanitization - Make createTestApp accept optional envOverrides to enable CF-configured integration tests Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * test(ihost-config): add coverage for uncovered error paths to reach 95% Add 4 targeted integration tests that cover the previously-uncovered branches in server/routes/ihost-config.ts: - PUT INSERT: CF register() throws non-CfConflict error → propagates - PUT UPDATE: CF delete() fails (best-effort console.warn) → request succeeds - PUT UPDATE: CF register() throws non-CfConflict error → propagates - PUT UPDATE: DB unique constraint on UPDATE → 409 (org2 steals org1 domain) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
interface CfConfig {
|
|
apiToken: string
|
|
zoneId: string
|
|
cnameTarget: string
|
|
}
|
|
|
|
interface CfHostnameStatus {
|
|
status: 'pending' | 'active' | 'moved' | 'deleted' | 'blocked'
|
|
ssl_status: string
|
|
}
|
|
|
|
// CfCustomHostnamesClient is a thin wrapper around the Cloudflare Custom
|
|
// Hostnames API (CF for SaaS). When env vars are absent (Node self-hosted),
|
|
// register/delete are no-ops and getStatus always returns 'pending' so
|
|
// domains never auto-verify without crashing the server.
|
|
export class CfCustomHostnamesClient {
|
|
private readonly cfg: CfConfig | null
|
|
|
|
constructor(cfg: CfConfig | null) {
|
|
this.cfg = cfg
|
|
}
|
|
|
|
async register(hostname: string): Promise<{ id: string }> {
|
|
if (!this.cfg) return { id: '' }
|
|
|
|
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.cfg.zoneId}/custom_hostnames`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${this.cfg.apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
hostname,
|
|
ssl: { method: 'http', type: 'dv', settings: { min_tls_version: '1.2' } },
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text()
|
|
if (res.status === 409) throw new CfConflictError(`Domain already registered at Cloudflare: ${text}`)
|
|
throw new Error(`CF registerHostname failed (${res.status}): ${text}`)
|
|
}
|
|
|
|
const data = (await res.json()) as { result: { id: string } }
|
|
return { id: data.result.id }
|
|
}
|
|
|
|
async getStatus(id: string): Promise<CfHostnameStatus> {
|
|
if (!this.cfg || !id) return { status: 'pending', ssl_status: '' }
|
|
|
|
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.cfg.zoneId}/custom_hostnames/${id}`, {
|
|
headers: { Authorization: `Bearer ${this.cfg.apiToken}` },
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text()
|
|
throw new Error(`CF getHostnameStatus failed (${res.status}): ${text}`)
|
|
}
|
|
|
|
const data = (await res.json()) as { result: { status: string; ssl: { status: string } } }
|
|
return {
|
|
status: data.result.status as CfHostnameStatus['status'],
|
|
ssl_status: data.result.ssl?.status ?? '',
|
|
}
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
if (!this.cfg || !id) return
|
|
|
|
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.cfg.zoneId}/custom_hostnames/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${this.cfg.apiToken}` },
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text()
|
|
throw new Error(`CF deleteHostname failed (${res.status}): ${text}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export class CfConflictError extends Error {}
|
|
|
|
export function createCfClient(getEnv: (key: string) => string | undefined): CfCustomHostnamesClient {
|
|
const apiToken = getEnv('CF_API_TOKEN')
|
|
const zoneId = getEnv('CF_ZONE_ID')
|
|
const cnameTarget = getEnv('CF_CNAME_TARGET')
|
|
|
|
if (!apiToken || !zoneId || !cnameTarget) {
|
|
return new CfCustomHostnamesClient(null)
|
|
}
|
|
|
|
return new CfCustomHostnamesClient({ apiToken, zoneId, cnameTarget })
|
|
}
|