iec-builder 0.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/.claude/settings.local.json +111 -0
- package/.iec.yaml +5 -0
- package/CLAUDE.md +174 -0
- package/Dockerfile +34 -0
- package/README.md +84 -0
- package/catalog-info.yaml +11 -0
- package/dist/config/env.d.ts +219 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +89 -0
- package/dist/config/env.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +43 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +217 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/org-access.d.ts +28 -0
- package/dist/middleware/org-access.d.ts.map +1 -0
- package/dist/middleware/org-access.js +102 -0
- package/dist/middleware/org-access.js.map +1 -0
- package/dist/models/types.d.ts +254 -0
- package/dist/models/types.d.ts.map +1 -0
- package/dist/models/types.js +2 -0
- package/dist/models/types.js.map +1 -0
- package/dist/routes/ai.d.ts +2 -0
- package/dist/routes/ai.d.ts.map +1 -0
- package/dist/routes/ai.js +77 -0
- package/dist/routes/ai.js.map +1 -0
- package/dist/routes/audit.d.ts +2 -0
- package/dist/routes/audit.d.ts.map +1 -0
- package/dist/routes/audit.js +102 -0
- package/dist/routes/audit.js.map +1 -0
- package/dist/routes/builds.d.ts +2 -0
- package/dist/routes/builds.d.ts.map +1 -0
- package/dist/routes/builds.js +262 -0
- package/dist/routes/builds.js.map +1 -0
- package/dist/routes/cluster.d.ts +2 -0
- package/dist/routes/cluster.d.ts.map +1 -0
- package/dist/routes/cluster.js +181 -0
- package/dist/routes/cluster.js.map +1 -0
- package/dist/routes/config.d.ts +2 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +291 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/databases.d.ts +2 -0
- package/dist/routes/databases.d.ts.map +1 -0
- package/dist/routes/databases.js +161 -0
- package/dist/routes/databases.js.map +1 -0
- package/dist/routes/db-whitelist.d.ts +2 -0
- package/dist/routes/db-whitelist.d.ts.map +1 -0
- package/dist/routes/db-whitelist.js +148 -0
- package/dist/routes/db-whitelist.js.map +1 -0
- package/dist/routes/domains.d.ts +2 -0
- package/dist/routes/domains.d.ts.map +1 -0
- package/dist/routes/domains.js +449 -0
- package/dist/routes/domains.js.map +1 -0
- package/dist/routes/oauth.d.ts +2 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +180 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/observability.d.ts +2 -0
- package/dist/routes/observability.d.ts.map +1 -0
- package/dist/routes/observability.js +167 -0
- package/dist/routes/observability.js.map +1 -0
- package/dist/routes/orgs.d.ts +2 -0
- package/dist/routes/orgs.d.ts.map +1 -0
- package/dist/routes/orgs.js +270 -0
- package/dist/routes/orgs.js.map +1 -0
- package/dist/routes/platform.d.ts +2 -0
- package/dist/routes/platform.d.ts.map +1 -0
- package/dist/routes/platform.js +107 -0
- package/dist/routes/platform.js.map +1 -0
- package/dist/routes/push.d.ts +2 -0
- package/dist/routes/push.d.ts.map +1 -0
- package/dist/routes/push.js +233 -0
- package/dist/routes/push.js.map +1 -0
- package/dist/routes/rotation.d.ts +3 -0
- package/dist/routes/rotation.d.ts.map +1 -0
- package/dist/routes/rotation.js +154 -0
- package/dist/routes/rotation.js.map +1 -0
- package/dist/routes/services.d.ts +2 -0
- package/dist/routes/services.d.ts.map +1 -0
- package/dist/routes/services.js +246 -0
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/storage.d.ts +2 -0
- package/dist/routes/storage.d.ts.map +1 -0
- package/dist/routes/storage.js +118 -0
- package/dist/routes/storage.js.map +1 -0
- package/dist/routes/users.d.ts +2 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +183 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/routes/versions.d.ts +2 -0
- package/dist/routes/versions.d.ts.map +1 -0
- package/dist/routes/versions.js +195 -0
- package/dist/routes/versions.js.map +1 -0
- package/dist/routes/webhooks.d.ts +2 -0
- package/dist/routes/webhooks.d.ts.map +1 -0
- package/dist/routes/webhooks.js +334 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts +2 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts.map +1 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.js +482 -0
- package/dist/services/__tests__/deploy-pipeline.integration.test.js.map +1 -0
- package/dist/services/bio-client.d.ts +68 -0
- package/dist/services/bio-client.d.ts.map +1 -0
- package/dist/services/bio-client.js +110 -0
- package/dist/services/bio-client.js.map +1 -0
- package/dist/services/build-queue.d.ts +7 -0
- package/dist/services/build-queue.d.ts.map +1 -0
- package/dist/services/build-queue.js +114 -0
- package/dist/services/build-queue.js.map +1 -0
- package/dist/services/builder.d.ts +7 -0
- package/dist/services/builder.d.ts.map +1 -0
- package/dist/services/builder.js +1384 -0
- package/dist/services/builder.js.map +1 -0
- package/dist/services/catalog.d.ts +177 -0
- package/dist/services/catalog.d.ts.map +1 -0
- package/dist/services/catalog.js +805 -0
- package/dist/services/catalog.js.map +1 -0
- package/dist/services/catalog.test.d.ts +2 -0
- package/dist/services/catalog.test.d.ts.map +1 -0
- package/dist/services/catalog.test.js +467 -0
- package/dist/services/catalog.test.js.map +1 -0
- package/dist/services/cloudflare.d.ts +43 -0
- package/dist/services/cloudflare.d.ts.map +1 -0
- package/dist/services/cloudflare.js +182 -0
- package/dist/services/cloudflare.js.map +1 -0
- package/dist/services/config-validator.d.ts +28 -0
- package/dist/services/config-validator.d.ts.map +1 -0
- package/dist/services/config-validator.js +68 -0
- package/dist/services/config-validator.js.map +1 -0
- package/dist/services/config-validator.test.d.ts +2 -0
- package/dist/services/config-validator.test.d.ts.map +1 -0
- package/dist/services/config-validator.test.js +151 -0
- package/dist/services/config-validator.test.js.map +1 -0
- package/dist/services/crypto.d.ts +19 -0
- package/dist/services/crypto.d.ts.map +1 -0
- package/dist/services/crypto.js +63 -0
- package/dist/services/crypto.js.map +1 -0
- package/dist/services/database.d.ts +26 -0
- package/dist/services/database.d.ts.map +1 -0
- package/dist/services/database.js +100 -0
- package/dist/services/database.js.map +1 -0
- package/dist/services/db-credential-manager.d.ts +73 -0
- package/dist/services/db-credential-manager.d.ts.map +1 -0
- package/dist/services/db-credential-manager.js +342 -0
- package/dist/services/db-credential-manager.js.map +1 -0
- package/dist/services/db-provisioner.d.ts +57 -0
- package/dist/services/db-provisioner.d.ts.map +1 -0
- package/dist/services/db-provisioner.js +400 -0
- package/dist/services/db-provisioner.js.map +1 -0
- package/dist/services/db-provisioner.test.d.ts +2 -0
- package/dist/services/db-provisioner.test.d.ts.map +1 -0
- package/dist/services/db-provisioner.test.js +141 -0
- package/dist/services/db-provisioner.test.js.map +1 -0
- package/dist/services/db-whitelist.d.ts +58 -0
- package/dist/services/db-whitelist.d.ts.map +1 -0
- package/dist/services/db-whitelist.js +379 -0
- package/dist/services/db-whitelist.js.map +1 -0
- package/dist/services/dependency-resolver.d.ts +58 -0
- package/dist/services/dependency-resolver.d.ts.map +1 -0
- package/dist/services/dependency-resolver.js +180 -0
- package/dist/services/dependency-resolver.js.map +1 -0
- package/dist/services/dependency-resolver.test.d.ts +2 -0
- package/dist/services/dependency-resolver.test.d.ts.map +1 -0
- package/dist/services/dependency-resolver.test.js +195 -0
- package/dist/services/dependency-resolver.test.js.map +1 -0
- package/dist/services/deploy-gate.d.ts +19 -0
- package/dist/services/deploy-gate.d.ts.map +1 -0
- package/dist/services/deploy-gate.js +56 -0
- package/dist/services/deploy-gate.js.map +1 -0
- package/dist/services/deploy-gate.test.d.ts +2 -0
- package/dist/services/deploy-gate.test.d.ts.map +1 -0
- package/dist/services/deploy-gate.test.js +199 -0
- package/dist/services/deploy-gate.test.js.map +1 -0
- package/dist/services/dockerfile-generator.d.ts +31 -0
- package/dist/services/dockerfile-generator.d.ts.map +1 -0
- package/dist/services/dockerfile-generator.js +544 -0
- package/dist/services/dockerfile-generator.js.map +1 -0
- package/dist/services/dockerfile-generator.test.d.ts +2 -0
- package/dist/services/dockerfile-generator.test.d.ts.map +1 -0
- package/dist/services/dockerfile-generator.test.js +144 -0
- package/dist/services/dockerfile-generator.test.js.map +1 -0
- package/dist/services/forgejo.d.ts +58 -0
- package/dist/services/forgejo.d.ts.map +1 -0
- package/dist/services/forgejo.js +131 -0
- package/dist/services/forgejo.js.map +1 -0
- package/dist/services/koko.d.ts +153 -0
- package/dist/services/koko.d.ts.map +1 -0
- package/dist/services/koko.js +260 -0
- package/dist/services/koko.js.map +1 -0
- package/dist/services/kubernetes.d.ts +16 -0
- package/dist/services/kubernetes.d.ts.map +1 -0
- package/dist/services/kubernetes.js +102 -0
- package/dist/services/kubernetes.js.map +1 -0
- package/dist/services/oauth-provisioner.d.ts +30 -0
- package/dist/services/oauth-provisioner.d.ts.map +1 -0
- package/dist/services/oauth-provisioner.js +182 -0
- package/dist/services/oauth-provisioner.js.map +1 -0
- package/dist/services/oauth-provisioner.test.d.ts +2 -0
- package/dist/services/oauth-provisioner.test.d.ts.map +1 -0
- package/dist/services/oauth-provisioner.test.js +349 -0
- package/dist/services/oauth-provisioner.test.js.map +1 -0
- package/dist/services/pod-diagnostics.d.ts +11 -0
- package/dist/services/pod-diagnostics.d.ts.map +1 -0
- package/dist/services/pod-diagnostics.js +201 -0
- package/dist/services/pod-diagnostics.js.map +1 -0
- package/dist/services/rotation-scheduler.d.ts +2 -0
- package/dist/services/rotation-scheduler.d.ts.map +1 -0
- package/dist/services/rotation-scheduler.js +215 -0
- package/dist/services/rotation-scheduler.js.map +1 -0
- package/dist/services/storage-credential-manager.d.ts +43 -0
- package/dist/services/storage-credential-manager.d.ts.map +1 -0
- package/dist/services/storage-credential-manager.js +159 -0
- package/dist/services/storage-credential-manager.js.map +1 -0
- package/dist/services/storage-provisioner.d.ts +32 -0
- package/dist/services/storage-provisioner.d.ts.map +1 -0
- package/dist/services/storage-provisioner.js +136 -0
- package/dist/services/storage-provisioner.js.map +1 -0
- package/dist/services/storage.d.ts +65 -0
- package/dist/services/storage.d.ts.map +1 -0
- package/dist/services/storage.js +204 -0
- package/dist/services/storage.js.map +1 -0
- package/dist/services/troubleshooter.d.ts +22 -0
- package/dist/services/troubleshooter.d.ts.map +1 -0
- package/dist/services/troubleshooter.js +168 -0
- package/dist/services/troubleshooter.js.map +1 -0
- package/dist/services/vault-client.d.ts +114 -0
- package/dist/services/vault-client.d.ts.map +1 -0
- package/dist/services/vault-client.js +411 -0
- package/dist/services/vault-client.js.map +1 -0
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +6 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/response.d.ts +13 -0
- package/dist/utils/response.d.ts.map +1 -0
- package/dist/utils/response.js +12 -0
- package/dist/utils/response.js.map +1 -0
- package/docs/registry-migration.md +301 -0
- package/docs/registry-quickstart.md +169 -0
- package/ecosystem.config.cjs +14 -0
- package/findings.md +168 -0
- package/helm/default-service/Chart.yaml +6 -0
- package/helm/default-service/templates/deployment.yaml +97 -0
- package/helm/default-service/templates/ingress.yaml +43 -0
- package/helm/default-service/templates/service.yaml +17 -0
- package/helm/default-service/values.yaml +82 -0
- package/helm/services/iec-builder/Chart.yaml +6 -0
- package/helm/services/iec-builder/templates/_helpers.tpl +61 -0
- package/helm/services/iec-builder/templates/deployment.yaml +73 -0
- package/helm/services/iec-builder/templates/service.yaml +15 -0
- package/helm/services/iec-builder/templates/serviceaccount.yaml +12 -0
- package/helm/services/iec-builder/values.yaml +56 -0
- package/helm/vault-values.yaml +127 -0
- package/package.json +45 -0
- package/progress.md +156 -0
- package/scripts/.vault-init-keys.json +23 -0
- package/scripts/backfill-ownership.ts +113 -0
- package/scripts/finalize-mongo-auth.sh +212 -0
- package/scripts/setup-ipset.sh +107 -0
- package/scripts/setup-mongo-auth.sh +163 -0
- package/scripts/setup-neo4j-auth.sh +62 -0
- package/scripts/setup-redis-auth.sh +55 -0
- package/scripts/setup-registry-secret.sh +71 -0
- package/scripts/setup-vault.sh +308 -0
- package/src/config/env.ts +117 -0
- package/src/index.ts +153 -0
- package/src/middleware/auth.ts +294 -0
- package/src/middleware/org-access.ts +126 -0
- package/src/models/types.ts +288 -0
- package/src/routes/ai.ts +115 -0
- package/src/routes/audit.ts +121 -0
- package/src/routes/builds.ts +320 -0
- package/src/routes/cluster.ts +235 -0
- package/src/routes/config.ts +369 -0
- package/src/routes/databases.ts +201 -0
- package/src/routes/db-whitelist.ts +204 -0
- package/src/routes/domains.ts +547 -0
- package/src/routes/oauth.ts +195 -0
- package/src/routes/observability.ts +205 -0
- package/src/routes/orgs.ts +330 -0
- package/src/routes/platform.ts +134 -0
- package/src/routes/rotation.ts +191 -0
- package/src/routes/services.ts +290 -0
- package/src/routes/storage.ts +153 -0
- package/src/routes/users.ts +235 -0
- package/src/routes/webhooks.ts +384 -0
- package/src/services/__tests__/catalog-storage.test.ts +186 -0
- package/src/services/__tests__/deploy-pipeline.integration.test.ts +624 -0
- package/src/services/__tests__/pod-diagnostics.test.ts +332 -0
- package/src/services/__tests__/storage-credential-manager.test.ts +129 -0
- package/src/services/__tests__/storage-provisioner.test.ts +166 -0
- package/src/services/__tests__/troubleshooter.test.ts +329 -0
- package/src/services/bio-client.ts +189 -0
- package/src/services/build-queue.ts +137 -0
- package/src/services/builder.ts +1800 -0
- package/src/services/catalog.test.ts +1389 -0
- package/src/services/catalog.ts +1187 -0
- package/src/services/cloudflare.ts +259 -0
- package/src/services/config-validator.test.ts +190 -0
- package/src/services/config-validator.ts +108 -0
- package/src/services/crypto.ts +78 -0
- package/src/services/database.ts +122 -0
- package/src/services/db-credential-manager.test.ts +101 -0
- package/src/services/db-credential-manager.ts +447 -0
- package/src/services/db-provisioner.test.ts +602 -0
- package/src/services/db-provisioner.ts +589 -0
- package/src/services/db-whitelist.test.ts +671 -0
- package/src/services/db-whitelist.ts +496 -0
- package/src/services/dependency-resolver.test.ts +677 -0
- package/src/services/dependency-resolver.ts +319 -0
- package/src/services/deploy-gate.test.ts +247 -0
- package/src/services/deploy-gate.ts +75 -0
- package/src/services/dockerfile-generator.test.ts +401 -0
- package/src/services/dockerfile-generator.ts +606 -0
- package/src/services/forgejo.ts +212 -0
- package/src/services/koko.ts +492 -0
- package/src/services/kubernetes.ts +141 -0
- package/src/services/oauth-provisioner.test.ts +477 -0
- package/src/services/oauth-provisioner.ts +286 -0
- package/src/services/pod-diagnostics.ts +261 -0
- package/src/services/rotation-scheduler.ts +293 -0
- package/src/services/storage-credential-manager.ts +223 -0
- package/src/services/storage-provisioner.ts +216 -0
- package/src/services/storage.ts +274 -0
- package/src/services/troubleshooter.ts +208 -0
- package/src/services/vault-client.test.ts +272 -0
- package/src/services/vault-client.ts +587 -0
- package/src/utils/logger.ts +6 -0
- package/src/utils/response.ts +23 -0
- package/task_plan.md +171 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { Router, type Request, type Response, type NextFunction } from 'express'
|
|
2
|
+
import { promises as dns } from 'node:dns'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { apiResponse, apiError } from '../utils/response.js'
|
|
5
|
+
import { getServicesCollection } from '../services/database.js'
|
|
6
|
+
import { createOrUpdateDnsRecord, deleteDnsRecord, getZoneIdForDomain, buildDnsHostname } from '../services/cloudflare.js'
|
|
7
|
+
import {
|
|
8
|
+
registerOrUpdateDomainInKoko,
|
|
9
|
+
findDomainInKoko,
|
|
10
|
+
updateDomainInKoko,
|
|
11
|
+
deleteDomainFromKoko,
|
|
12
|
+
} from '../services/koko.js'
|
|
13
|
+
import { env } from '../config/env.js'
|
|
14
|
+
import { logger } from '../utils/logger.js'
|
|
15
|
+
import { patchIngressAddHost, patchIngressRemoveHost } from '../services/kubernetes.js'
|
|
16
|
+
import type { AuthenticatedRequest } from '../middleware/auth.js'
|
|
17
|
+
import { deriveOrgRole, ORG_ROLE_HIERARCHY, type OrgRole } from '../middleware/auth.js'
|
|
18
|
+
import type { CustomDomainRecord, Service } from '../models/types.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inline org access check for domain routes.
|
|
22
|
+
* Returns an error message if access is denied, or null if allowed.
|
|
23
|
+
*/
|
|
24
|
+
function checkDomainOrgAccess(
|
|
25
|
+
service: Service,
|
|
26
|
+
user: { roles: string[]; org?: string },
|
|
27
|
+
minimumRole: OrgRole = 'viewer'
|
|
28
|
+
): string | null {
|
|
29
|
+
// Super admin and platform service bypass
|
|
30
|
+
if (user.roles.includes('super_admin') || user.roles.includes('service')) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Role check
|
|
35
|
+
const effectiveRole = deriveOrgRole(user.roles)
|
|
36
|
+
const effectiveLevel = ORG_ROLE_HIERARCHY[effectiveRole]
|
|
37
|
+
if (effectiveLevel < ORG_ROLE_HIERARCHY[minimumRole]) {
|
|
38
|
+
return `Requires org role: ${minimumRole} (you have: ${effectiveRole})`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Legacy services without org: allow
|
|
42
|
+
if (!service.org) return null
|
|
43
|
+
|
|
44
|
+
// Org match check
|
|
45
|
+
if (user.org && service.org !== user.org) {
|
|
46
|
+
return 'Service belongs to a different organization'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const domainsRouter = Router()
|
|
53
|
+
|
|
54
|
+
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
|
|
55
|
+
|
|
56
|
+
// Validate :domain route parameter format
|
|
57
|
+
domainsRouter.param('domain', (_req, res, next, domain) => {
|
|
58
|
+
if (!DOMAIN_REGEX.test(domain)) {
|
|
59
|
+
res.status(400).json(apiError('VALIDATION_ERROR', 'Invalid domain format'))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
next()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const AddDomainSchema = z.object({
|
|
66
|
+
domain: z.string().min(1).max(253).regex(DOMAIN_REGEX, 'Invalid domain format'),
|
|
67
|
+
serviceId: z.string().min(1),
|
|
68
|
+
environment: z.enum(['prod', 'sandbox', 'uat', 'dev']).default('prod'),
|
|
69
|
+
dnsProvider: z.enum(['cloudflare', 'external']),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const VerifyDomainSchema = z.object({
|
|
73
|
+
domain: z.string().min(1),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find a service by name or id, and return it along with the collection.
|
|
78
|
+
*/
|
|
79
|
+
async function findService(serviceId: string): Promise<{ service: Service | null; services: ReturnType<typeof getServicesCollection> }> {
|
|
80
|
+
const services = getServicesCollection()
|
|
81
|
+
const service = await services.findOne({
|
|
82
|
+
$or: [{ id: serviceId }, { name: serviceId }],
|
|
83
|
+
}) as Service | null
|
|
84
|
+
return { service, services }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if CNAME resolves to the expected target.
|
|
89
|
+
*/
|
|
90
|
+
async function checkCnameResolution(domain: string, expectedTarget: string): Promise<{
|
|
91
|
+
verified: boolean
|
|
92
|
+
resolvedTarget?: string
|
|
93
|
+
}> {
|
|
94
|
+
try {
|
|
95
|
+
const records = await dns.resolveCname(domain)
|
|
96
|
+
const matches = records.some(r => r === expectedTarget)
|
|
97
|
+
return { verified: matches, resolvedTarget: records[0] }
|
|
98
|
+
} catch {
|
|
99
|
+
try {
|
|
100
|
+
const addresses = await dns.resolve4(domain)
|
|
101
|
+
return { verified: false, resolvedTarget: addresses[0] ? `A: ${addresses[0]}` : undefined }
|
|
102
|
+
} catch {
|
|
103
|
+
return { verified: false }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* POST /domains/add
|
|
110
|
+
* Add a custom domain to a service.
|
|
111
|
+
*/
|
|
112
|
+
domainsRouter.post('/add', async (req: Request, res: Response, next: NextFunction) => {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = AddDomainSchema.safeParse(req.body)
|
|
115
|
+
if (!parsed.success) {
|
|
116
|
+
res.status(400).json(apiError('VALIDATION_ERROR', 'Invalid request body', parsed.error.flatten()))
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { domain, serviceId, environment, dnsProvider } = parsed.data
|
|
121
|
+
const authReq = req as AuthenticatedRequest
|
|
122
|
+
const { service, services } = await findService(serviceId)
|
|
123
|
+
|
|
124
|
+
if (!service) {
|
|
125
|
+
res.status(404).json(apiError('NOT_FOUND', `Service '${serviceId}' not found`))
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Org access check (admin required for adding domains)
|
|
130
|
+
if (authReq.user) {
|
|
131
|
+
const orgError = checkDomainOrgAccess(service, authReq.user, 'admin')
|
|
132
|
+
if (orgError) {
|
|
133
|
+
res.status(403).json(apiError('FORBIDDEN', orgError))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check if domain is claimed by a different service
|
|
139
|
+
const claimedByOther = await services.findOne({
|
|
140
|
+
'customDomains.domain': domain,
|
|
141
|
+
id: { $ne: service.id },
|
|
142
|
+
name: { $ne: service.name },
|
|
143
|
+
}) as Service | null
|
|
144
|
+
if (claimedByOther) {
|
|
145
|
+
res.status(409).json(apiError('DOMAIN_TAKEN', `Domain '${domain}' is already registered to service '${claimedByOther.name}'`))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if domain already exists on this service (re-sync / refresh)
|
|
150
|
+
const existingDomain = (service.customDomains || []).find(d => d.domain === domain)
|
|
151
|
+
const isResync = !!existingDomain
|
|
152
|
+
|
|
153
|
+
// For custom domains, prefer CNAME to service's platform hostname.
|
|
154
|
+
// Only fall back to A record if INGRESS_TARGET is an IP and no platform hostname exists.
|
|
155
|
+
const platformHostname = buildDnsHostname(service.name, environment)
|
|
156
|
+
const ingressTarget = env.INGRESS_TARGET
|
|
157
|
+
const isIpTarget = /^(\d{1,3}\.){3}\d{1,3}$/.test(ingressTarget)
|
|
158
|
+
|
|
159
|
+
// Custom domains should CNAME to the platform hostname (e.g. moonshot.tawa.insureco.io)
|
|
160
|
+
// so if the ingress IP changes, only the platform DNS record needs updating.
|
|
161
|
+
const expectedTarget = isIpTarget ? platformHostname : (ingressTarget || platformHostname)
|
|
162
|
+
const addedBy = authReq.user?.email || 'unknown'
|
|
163
|
+
|
|
164
|
+
const domainRecord: CustomDomainRecord = {
|
|
165
|
+
domain,
|
|
166
|
+
environment: isResync ? existingDomain.environment : environment,
|
|
167
|
+
dnsProvider,
|
|
168
|
+
dnsVerified: false,
|
|
169
|
+
expectedTarget,
|
|
170
|
+
addedAt: isResync ? existingDomain.addedAt : new Date().toISOString(),
|
|
171
|
+
addedBy: isResync ? existingDomain.addedBy : addedBy,
|
|
172
|
+
kokoDomainId: isResync ? existingDomain.kokoDomainId : undefined,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (dnsProvider === 'cloudflare') {
|
|
176
|
+
// Auto-configure DNS via Cloudflare (createOrUpdate is already idempotent)
|
|
177
|
+
try {
|
|
178
|
+
const zoneId = await getZoneIdForDomain(domain)
|
|
179
|
+
logger.info({ domain, zoneId, expectedTarget, isResync }, 'Resolved Cloudflare zone for custom domain')
|
|
180
|
+
|
|
181
|
+
const dnsRecord = await createOrUpdateDnsRecord({
|
|
182
|
+
type: 'CNAME',
|
|
183
|
+
name: domain,
|
|
184
|
+
content: expectedTarget,
|
|
185
|
+
proxied: true,
|
|
186
|
+
}, zoneId)
|
|
187
|
+
|
|
188
|
+
domainRecord.cloudflareRecordId = dnsRecord.id
|
|
189
|
+
domainRecord.cloudflareZoneId = zoneId
|
|
190
|
+
domainRecord.dnsVerified = true
|
|
191
|
+
domainRecord.dnsVerifiedAt = new Date().toISOString()
|
|
192
|
+
|
|
193
|
+
logger.info({ domain, recordId: dnsRecord.id, zoneId, isResync }, 'Cloudflare DNS record configured')
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
196
|
+
logger.error({ domain, error: msg }, 'Failed to create Cloudflare DNS record')
|
|
197
|
+
res.status(500).json(apiError('DNS_ERROR', `Failed to create DNS record: ${msg}`))
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Register/update in Koko
|
|
202
|
+
const kokoDomain = await registerOrUpdateDomainInKoko(
|
|
203
|
+
domain,
|
|
204
|
+
service.id,
|
|
205
|
+
environment,
|
|
206
|
+
'custom',
|
|
207
|
+
domainRecord.cloudflareZoneId,
|
|
208
|
+
domainRecord.cloudflareRecordId
|
|
209
|
+
)
|
|
210
|
+
if (kokoDomain) {
|
|
211
|
+
domainRecord.kokoDomainId = kokoDomain.id
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
// External DNS — register in Koko as unverified
|
|
215
|
+
const kokoDomain = await registerOrUpdateDomainInKoko(
|
|
216
|
+
domain,
|
|
217
|
+
service.id,
|
|
218
|
+
environment,
|
|
219
|
+
'custom'
|
|
220
|
+
)
|
|
221
|
+
if (kokoDomain) {
|
|
222
|
+
domainRecord.kokoDomainId = kokoDomain.id
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Store on service document
|
|
227
|
+
if (isResync) {
|
|
228
|
+
// Replace the existing domain record with refreshed data
|
|
229
|
+
await services.updateOne(
|
|
230
|
+
{ $or: [{ id: serviceId }, { name: serviceId }], 'customDomains.domain': domain },
|
|
231
|
+
{ $set: { 'customDomains.$': domainRecord } as Record<string, unknown> }
|
|
232
|
+
)
|
|
233
|
+
} else {
|
|
234
|
+
// Push new domain record (rollback DNS/Koko on failure)
|
|
235
|
+
try {
|
|
236
|
+
await services.updateOne(
|
|
237
|
+
{ $or: [{ id: serviceId }, { name: serviceId }] },
|
|
238
|
+
{ $push: { customDomains: domainRecord } as Record<string, unknown> }
|
|
239
|
+
)
|
|
240
|
+
} catch (dbError) {
|
|
241
|
+
if (domainRecord.cloudflareRecordId) {
|
|
242
|
+
await deleteDnsRecord(domainRecord.cloudflareRecordId).catch(() => {})
|
|
243
|
+
}
|
|
244
|
+
if (domainRecord.kokoDomainId) {
|
|
245
|
+
await deleteDomainFromKoko(domainRecord.kokoDomainId).catch(() => {})
|
|
246
|
+
}
|
|
247
|
+
throw dbError
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
logger.info({ domain, serviceId: service.id, dnsProvider, environment, isResync }, 'Custom domain configured')
|
|
252
|
+
|
|
253
|
+
// Patch live ingress so domain works immediately (no redeploy needed)
|
|
254
|
+
const namespace = service.namespace || `${service.name}-${environment}`
|
|
255
|
+
let ingressPatched = false
|
|
256
|
+
if (domainRecord.dnsVerified) {
|
|
257
|
+
const patchResult = await patchIngressAddHost(service.name, namespace, domain)
|
|
258
|
+
ingressPatched = patchResult.patched
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const message = dnsProvider === 'external'
|
|
262
|
+
? `Add a CNAME record pointing ${domain} to ${expectedTarget}, then run "tawa domain verify ${domain}".`
|
|
263
|
+
: ingressPatched
|
|
264
|
+
? `Domain is live at ${domain}`
|
|
265
|
+
: `DNS configured. Run "tawa deploy --${environment}" to apply ingress changes.`
|
|
266
|
+
|
|
267
|
+
res.status(isResync ? 200 : 201).json(apiResponse({
|
|
268
|
+
domain,
|
|
269
|
+
serviceId: service.id,
|
|
270
|
+
environment,
|
|
271
|
+
dnsProvider,
|
|
272
|
+
dnsVerified: domainRecord.dnsVerified,
|
|
273
|
+
expectedTarget,
|
|
274
|
+
ingressPatched,
|
|
275
|
+
resynced: isResync,
|
|
276
|
+
message,
|
|
277
|
+
}))
|
|
278
|
+
} catch (err) {
|
|
279
|
+
next(err)
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* POST /domains/verify
|
|
285
|
+
* Verify DNS propagation for an external domain.
|
|
286
|
+
*/
|
|
287
|
+
domainsRouter.post('/verify', async (req: Request, res: Response, next: NextFunction) => {
|
|
288
|
+
try {
|
|
289
|
+
const parsed = VerifyDomainSchema.safeParse(req.body)
|
|
290
|
+
if (!parsed.success) {
|
|
291
|
+
res.status(400).json(apiError('VALIDATION_ERROR', 'Invalid request body', parsed.error.flatten()))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { domain } = parsed.data
|
|
296
|
+
const services = getServicesCollection()
|
|
297
|
+
|
|
298
|
+
const service = await services.findOne({
|
|
299
|
+
'customDomains.domain': domain,
|
|
300
|
+
}) as Service | null
|
|
301
|
+
|
|
302
|
+
if (!service) {
|
|
303
|
+
res.status(404).json(apiError('NOT_FOUND', `Domain '${domain}' not found on any service`))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Org access check (member required for verifying domains)
|
|
308
|
+
const verifyAuthReq = req as AuthenticatedRequest
|
|
309
|
+
if (verifyAuthReq.user) {
|
|
310
|
+
const orgError = checkDomainOrgAccess(service, verifyAuthReq.user, 'member')
|
|
311
|
+
if (orgError) {
|
|
312
|
+
res.status(403).json(apiError('FORBIDDEN', orgError))
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const domainEntry = (service.customDomains || []).find(d => d.domain === domain)
|
|
318
|
+
if (!domainEntry) {
|
|
319
|
+
res.status(404).json(apiError('NOT_FOUND', `Domain '${domain}' not found`))
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { verified, resolvedTarget } = await checkCnameResolution(domain, domainEntry.expectedTarget)
|
|
324
|
+
|
|
325
|
+
if (verified) {
|
|
326
|
+
// Update the domain record
|
|
327
|
+
await services.updateOne(
|
|
328
|
+
{ 'customDomains.domain': domain },
|
|
329
|
+
{
|
|
330
|
+
$set: {
|
|
331
|
+
'customDomains.$.dnsVerified': true,
|
|
332
|
+
'customDomains.$.dnsVerifiedAt': new Date().toISOString(),
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
// Update Koko
|
|
338
|
+
if (domainEntry.kokoDomainId) {
|
|
339
|
+
await updateDomainInKoko(domainEntry.kokoDomainId, { dnsVerified: true })
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
logger.info({ domain, resolvedTarget }, 'Domain DNS verified')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
res.json(apiResponse({
|
|
346
|
+
domain,
|
|
347
|
+
verified,
|
|
348
|
+
resolvedTarget,
|
|
349
|
+
expectedTarget: domainEntry.expectedTarget,
|
|
350
|
+
message: verified
|
|
351
|
+
? `DNS verified. Run "tawa deploy" to apply ingress changes.`
|
|
352
|
+
: `DNS not yet propagated. Expected CNAME to ${domainEntry.expectedTarget}.`,
|
|
353
|
+
}))
|
|
354
|
+
} catch (err) {
|
|
355
|
+
next(err)
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* GET /domains
|
|
361
|
+
* List custom domains, optionally filtered by serviceId.
|
|
362
|
+
*/
|
|
363
|
+
domainsRouter.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|
364
|
+
try {
|
|
365
|
+
const listAuthReq = req as AuthenticatedRequest
|
|
366
|
+
const { serviceId } = req.query
|
|
367
|
+
const services = getServicesCollection()
|
|
368
|
+
|
|
369
|
+
if (serviceId && typeof serviceId === 'string') {
|
|
370
|
+
const service = await services.findOne({
|
|
371
|
+
$or: [{ id: serviceId }, { name: serviceId }],
|
|
372
|
+
}) as Service | null
|
|
373
|
+
|
|
374
|
+
if (!service) {
|
|
375
|
+
res.status(404).json(apiError('NOT_FOUND', `Service '${serviceId}' not found`))
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Org access check
|
|
380
|
+
if (listAuthReq.user) {
|
|
381
|
+
const orgError = checkDomainOrgAccess(service, listAuthReq.user)
|
|
382
|
+
if (orgError) {
|
|
383
|
+
res.status(403).json(apiError('FORBIDDEN', orgError))
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
res.json(apiResponse(service.customDomains || []))
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Return all custom domains across services (filtered by org)
|
|
393
|
+
const listQuery: Record<string, unknown> = {
|
|
394
|
+
customDomains: { $exists: true, $ne: [] },
|
|
395
|
+
}
|
|
396
|
+
const isSuperAdmin = listAuthReq.user?.roles.includes('super_admin') ?? false
|
|
397
|
+
const isServiceRole = listAuthReq.user?.roles.includes('service') ?? false
|
|
398
|
+
if (!isSuperAdmin && !isServiceRole && listAuthReq.user?.org) {
|
|
399
|
+
listQuery.org = listAuthReq.user.org
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const allServices = await services.find(listQuery).toArray() as Service[]
|
|
403
|
+
|
|
404
|
+
const domains = allServices.flatMap(s =>
|
|
405
|
+
(s.customDomains || []).map(d => ({
|
|
406
|
+
...d,
|
|
407
|
+
serviceId: s.id,
|
|
408
|
+
serviceName: s.name,
|
|
409
|
+
}))
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
res.json(apiResponse(domains))
|
|
413
|
+
} catch (err) {
|
|
414
|
+
next(err)
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* GET /domains/:domain/status
|
|
420
|
+
* Get domain status with a live DNS check.
|
|
421
|
+
*/
|
|
422
|
+
domainsRouter.get('/:domain/status', async (req: Request, res: Response, next: NextFunction) => {
|
|
423
|
+
try {
|
|
424
|
+
const statusAuthReq = req as AuthenticatedRequest
|
|
425
|
+
const { domain } = req.params
|
|
426
|
+
const services = getServicesCollection()
|
|
427
|
+
|
|
428
|
+
const service = await services.findOne({
|
|
429
|
+
'customDomains.domain': domain,
|
|
430
|
+
}) as Service | null
|
|
431
|
+
|
|
432
|
+
if (!service) {
|
|
433
|
+
res.status(404).json(apiError('NOT_FOUND', `Domain '${domain}' not found`))
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Org access check
|
|
438
|
+
if (statusAuthReq.user) {
|
|
439
|
+
const orgError = checkDomainOrgAccess(service, statusAuthReq.user)
|
|
440
|
+
if (orgError) {
|
|
441
|
+
res.status(403).json(apiError('FORBIDDEN', orgError))
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const domainEntry = (service.customDomains || []).find(d => d.domain === domain)
|
|
447
|
+
if (!domainEntry) {
|
|
448
|
+
res.status(404).json(apiError('NOT_FOUND', `Domain '${domain}' not found`))
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Live DNS check
|
|
453
|
+
const { verified: dnsLive, resolvedTarget } = await checkCnameResolution(
|
|
454
|
+
domain,
|
|
455
|
+
domainEntry.expectedTarget
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
res.json(apiResponse({
|
|
459
|
+
domain: domainEntry.domain,
|
|
460
|
+
serviceId: service.id,
|
|
461
|
+
serviceName: service.name,
|
|
462
|
+
environment: domainEntry.environment,
|
|
463
|
+
dnsProvider: domainEntry.dnsProvider,
|
|
464
|
+
dnsVerified: domainEntry.dnsVerified,
|
|
465
|
+
dnsLive,
|
|
466
|
+
resolvedTarget,
|
|
467
|
+
expectedTarget: domainEntry.expectedTarget,
|
|
468
|
+
addedAt: domainEntry.addedAt,
|
|
469
|
+
addedBy: domainEntry.addedBy,
|
|
470
|
+
}))
|
|
471
|
+
} catch (err) {
|
|
472
|
+
next(err)
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* DELETE /domains/:domain
|
|
478
|
+
* Remove a custom domain from a service.
|
|
479
|
+
*/
|
|
480
|
+
domainsRouter.delete('/:domain', async (req: Request, res: Response, next: NextFunction) => {
|
|
481
|
+
try {
|
|
482
|
+
const deleteAuthReq = req as AuthenticatedRequest
|
|
483
|
+
const { domain } = req.params
|
|
484
|
+
const services = getServicesCollection()
|
|
485
|
+
|
|
486
|
+
const service = await services.findOne({
|
|
487
|
+
'customDomains.domain': domain,
|
|
488
|
+
}) as Service | null
|
|
489
|
+
|
|
490
|
+
if (!service) {
|
|
491
|
+
res.status(404).json(apiError('NOT_FOUND', `Domain '${domain}' not found`))
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Org access check (admin required for deleting domains)
|
|
496
|
+
if (deleteAuthReq.user) {
|
|
497
|
+
const orgError = checkDomainOrgAccess(service, deleteAuthReq.user, 'admin')
|
|
498
|
+
if (orgError) {
|
|
499
|
+
res.status(403).json(apiError('FORBIDDEN', orgError))
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const domainEntry = (service.customDomains || []).find(d => d.domain === domain)
|
|
505
|
+
if (!domainEntry) {
|
|
506
|
+
res.status(404).json(apiError('NOT_FOUND', `Domain '${domain}' not found`))
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Clean up Cloudflare DNS record
|
|
511
|
+
if (domainEntry.dnsProvider === 'cloudflare' && domainEntry.cloudflareRecordId) {
|
|
512
|
+
try {
|
|
513
|
+
const zoneId = domainEntry.cloudflareZoneId || env.CLOUDFLARE_ZONE_ID
|
|
514
|
+
await deleteDnsRecord(domainEntry.cloudflareRecordId, zoneId)
|
|
515
|
+
logger.info({ domain, recordId: domainEntry.cloudflareRecordId, zoneId }, 'Cloudflare DNS record deleted')
|
|
516
|
+
} catch (error) {
|
|
517
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
518
|
+
logger.warn({ domain, error: msg }, 'Failed to delete Cloudflare DNS record')
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Clean up Koko domain
|
|
523
|
+
if (domainEntry.kokoDomainId) {
|
|
524
|
+
await deleteDomainFromKoko(domainEntry.kokoDomainId)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Remove from service document
|
|
528
|
+
await services.updateOne(
|
|
529
|
+
{ $or: [{ id: service.id }, { name: service.name }] },
|
|
530
|
+
{ $pull: { customDomains: { domain } } as Record<string, unknown> }
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
// Remove from live ingress
|
|
534
|
+
const namespace = service.namespace || `${service.name}-${domainEntry.environment}`
|
|
535
|
+
const { patched: ingressPatched } = await patchIngressRemoveHost(service.name, namespace, domain)
|
|
536
|
+
|
|
537
|
+
logger.info({ domain, serviceId: service.id, ingressPatched }, 'Custom domain removed')
|
|
538
|
+
|
|
539
|
+
res.json(apiResponse({
|
|
540
|
+
message: ingressPatched
|
|
541
|
+
? `Domain '${domain}' removed and ingress updated.`
|
|
542
|
+
: `Domain '${domain}' removed. Run "tawa deploy" to update ingress.`,
|
|
543
|
+
}))
|
|
544
|
+
} catch (err) {
|
|
545
|
+
next(err)
|
|
546
|
+
}
|
|
547
|
+
})
|