Files
zpan/server/services/cf-custom-hostnames.ts
T
3e6d3ee63b feat: v2.4.0 T5 — /api/ihost/config + Cloudflare for SaaS integration (#316)
* 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>
2026-04-21 04:00:17 -04:00

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 })
}