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.
Files changed (337) hide show
  1. package/.claude/settings.local.json +111 -0
  2. package/.iec.yaml +5 -0
  3. package/CLAUDE.md +174 -0
  4. package/Dockerfile +34 -0
  5. package/README.md +84 -0
  6. package/catalog-info.yaml +11 -0
  7. package/dist/config/env.d.ts +219 -0
  8. package/dist/config/env.d.ts.map +1 -0
  9. package/dist/config/env.js +89 -0
  10. package/dist/config/env.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +148 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/middleware/auth.d.ts +43 -0
  16. package/dist/middleware/auth.d.ts.map +1 -0
  17. package/dist/middleware/auth.js +217 -0
  18. package/dist/middleware/auth.js.map +1 -0
  19. package/dist/middleware/org-access.d.ts +28 -0
  20. package/dist/middleware/org-access.d.ts.map +1 -0
  21. package/dist/middleware/org-access.js +102 -0
  22. package/dist/middleware/org-access.js.map +1 -0
  23. package/dist/models/types.d.ts +254 -0
  24. package/dist/models/types.d.ts.map +1 -0
  25. package/dist/models/types.js +2 -0
  26. package/dist/models/types.js.map +1 -0
  27. package/dist/routes/ai.d.ts +2 -0
  28. package/dist/routes/ai.d.ts.map +1 -0
  29. package/dist/routes/ai.js +77 -0
  30. package/dist/routes/ai.js.map +1 -0
  31. package/dist/routes/audit.d.ts +2 -0
  32. package/dist/routes/audit.d.ts.map +1 -0
  33. package/dist/routes/audit.js +102 -0
  34. package/dist/routes/audit.js.map +1 -0
  35. package/dist/routes/builds.d.ts +2 -0
  36. package/dist/routes/builds.d.ts.map +1 -0
  37. package/dist/routes/builds.js +262 -0
  38. package/dist/routes/builds.js.map +1 -0
  39. package/dist/routes/cluster.d.ts +2 -0
  40. package/dist/routes/cluster.d.ts.map +1 -0
  41. package/dist/routes/cluster.js +181 -0
  42. package/dist/routes/cluster.js.map +1 -0
  43. package/dist/routes/config.d.ts +2 -0
  44. package/dist/routes/config.d.ts.map +1 -0
  45. package/dist/routes/config.js +291 -0
  46. package/dist/routes/config.js.map +1 -0
  47. package/dist/routes/databases.d.ts +2 -0
  48. package/dist/routes/databases.d.ts.map +1 -0
  49. package/dist/routes/databases.js +161 -0
  50. package/dist/routes/databases.js.map +1 -0
  51. package/dist/routes/db-whitelist.d.ts +2 -0
  52. package/dist/routes/db-whitelist.d.ts.map +1 -0
  53. package/dist/routes/db-whitelist.js +148 -0
  54. package/dist/routes/db-whitelist.js.map +1 -0
  55. package/dist/routes/domains.d.ts +2 -0
  56. package/dist/routes/domains.d.ts.map +1 -0
  57. package/dist/routes/domains.js +449 -0
  58. package/dist/routes/domains.js.map +1 -0
  59. package/dist/routes/oauth.d.ts +2 -0
  60. package/dist/routes/oauth.d.ts.map +1 -0
  61. package/dist/routes/oauth.js +180 -0
  62. package/dist/routes/oauth.js.map +1 -0
  63. package/dist/routes/observability.d.ts +2 -0
  64. package/dist/routes/observability.d.ts.map +1 -0
  65. package/dist/routes/observability.js +167 -0
  66. package/dist/routes/observability.js.map +1 -0
  67. package/dist/routes/orgs.d.ts +2 -0
  68. package/dist/routes/orgs.d.ts.map +1 -0
  69. package/dist/routes/orgs.js +270 -0
  70. package/dist/routes/orgs.js.map +1 -0
  71. package/dist/routes/platform.d.ts +2 -0
  72. package/dist/routes/platform.d.ts.map +1 -0
  73. package/dist/routes/platform.js +107 -0
  74. package/dist/routes/platform.js.map +1 -0
  75. package/dist/routes/push.d.ts +2 -0
  76. package/dist/routes/push.d.ts.map +1 -0
  77. package/dist/routes/push.js +233 -0
  78. package/dist/routes/push.js.map +1 -0
  79. package/dist/routes/rotation.d.ts +3 -0
  80. package/dist/routes/rotation.d.ts.map +1 -0
  81. package/dist/routes/rotation.js +154 -0
  82. package/dist/routes/rotation.js.map +1 -0
  83. package/dist/routes/services.d.ts +2 -0
  84. package/dist/routes/services.d.ts.map +1 -0
  85. package/dist/routes/services.js +246 -0
  86. package/dist/routes/services.js.map +1 -0
  87. package/dist/routes/storage.d.ts +2 -0
  88. package/dist/routes/storage.d.ts.map +1 -0
  89. package/dist/routes/storage.js +118 -0
  90. package/dist/routes/storage.js.map +1 -0
  91. package/dist/routes/users.d.ts +2 -0
  92. package/dist/routes/users.d.ts.map +1 -0
  93. package/dist/routes/users.js +183 -0
  94. package/dist/routes/users.js.map +1 -0
  95. package/dist/routes/versions.d.ts +2 -0
  96. package/dist/routes/versions.d.ts.map +1 -0
  97. package/dist/routes/versions.js +195 -0
  98. package/dist/routes/versions.js.map +1 -0
  99. package/dist/routes/webhooks.d.ts +2 -0
  100. package/dist/routes/webhooks.d.ts.map +1 -0
  101. package/dist/routes/webhooks.js +334 -0
  102. package/dist/routes/webhooks.js.map +1 -0
  103. package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts +2 -0
  104. package/dist/services/__tests__/deploy-pipeline.integration.test.d.ts.map +1 -0
  105. package/dist/services/__tests__/deploy-pipeline.integration.test.js +482 -0
  106. package/dist/services/__tests__/deploy-pipeline.integration.test.js.map +1 -0
  107. package/dist/services/bio-client.d.ts +68 -0
  108. package/dist/services/bio-client.d.ts.map +1 -0
  109. package/dist/services/bio-client.js +110 -0
  110. package/dist/services/bio-client.js.map +1 -0
  111. package/dist/services/build-queue.d.ts +7 -0
  112. package/dist/services/build-queue.d.ts.map +1 -0
  113. package/dist/services/build-queue.js +114 -0
  114. package/dist/services/build-queue.js.map +1 -0
  115. package/dist/services/builder.d.ts +7 -0
  116. package/dist/services/builder.d.ts.map +1 -0
  117. package/dist/services/builder.js +1384 -0
  118. package/dist/services/builder.js.map +1 -0
  119. package/dist/services/catalog.d.ts +177 -0
  120. package/dist/services/catalog.d.ts.map +1 -0
  121. package/dist/services/catalog.js +805 -0
  122. package/dist/services/catalog.js.map +1 -0
  123. package/dist/services/catalog.test.d.ts +2 -0
  124. package/dist/services/catalog.test.d.ts.map +1 -0
  125. package/dist/services/catalog.test.js +467 -0
  126. package/dist/services/catalog.test.js.map +1 -0
  127. package/dist/services/cloudflare.d.ts +43 -0
  128. package/dist/services/cloudflare.d.ts.map +1 -0
  129. package/dist/services/cloudflare.js +182 -0
  130. package/dist/services/cloudflare.js.map +1 -0
  131. package/dist/services/config-validator.d.ts +28 -0
  132. package/dist/services/config-validator.d.ts.map +1 -0
  133. package/dist/services/config-validator.js +68 -0
  134. package/dist/services/config-validator.js.map +1 -0
  135. package/dist/services/config-validator.test.d.ts +2 -0
  136. package/dist/services/config-validator.test.d.ts.map +1 -0
  137. package/dist/services/config-validator.test.js +151 -0
  138. package/dist/services/config-validator.test.js.map +1 -0
  139. package/dist/services/crypto.d.ts +19 -0
  140. package/dist/services/crypto.d.ts.map +1 -0
  141. package/dist/services/crypto.js +63 -0
  142. package/dist/services/crypto.js.map +1 -0
  143. package/dist/services/database.d.ts +26 -0
  144. package/dist/services/database.d.ts.map +1 -0
  145. package/dist/services/database.js +100 -0
  146. package/dist/services/database.js.map +1 -0
  147. package/dist/services/db-credential-manager.d.ts +73 -0
  148. package/dist/services/db-credential-manager.d.ts.map +1 -0
  149. package/dist/services/db-credential-manager.js +342 -0
  150. package/dist/services/db-credential-manager.js.map +1 -0
  151. package/dist/services/db-provisioner.d.ts +57 -0
  152. package/dist/services/db-provisioner.d.ts.map +1 -0
  153. package/dist/services/db-provisioner.js +400 -0
  154. package/dist/services/db-provisioner.js.map +1 -0
  155. package/dist/services/db-provisioner.test.d.ts +2 -0
  156. package/dist/services/db-provisioner.test.d.ts.map +1 -0
  157. package/dist/services/db-provisioner.test.js +141 -0
  158. package/dist/services/db-provisioner.test.js.map +1 -0
  159. package/dist/services/db-whitelist.d.ts +58 -0
  160. package/dist/services/db-whitelist.d.ts.map +1 -0
  161. package/dist/services/db-whitelist.js +379 -0
  162. package/dist/services/db-whitelist.js.map +1 -0
  163. package/dist/services/dependency-resolver.d.ts +58 -0
  164. package/dist/services/dependency-resolver.d.ts.map +1 -0
  165. package/dist/services/dependency-resolver.js +180 -0
  166. package/dist/services/dependency-resolver.js.map +1 -0
  167. package/dist/services/dependency-resolver.test.d.ts +2 -0
  168. package/dist/services/dependency-resolver.test.d.ts.map +1 -0
  169. package/dist/services/dependency-resolver.test.js +195 -0
  170. package/dist/services/dependency-resolver.test.js.map +1 -0
  171. package/dist/services/deploy-gate.d.ts +19 -0
  172. package/dist/services/deploy-gate.d.ts.map +1 -0
  173. package/dist/services/deploy-gate.js +56 -0
  174. package/dist/services/deploy-gate.js.map +1 -0
  175. package/dist/services/deploy-gate.test.d.ts +2 -0
  176. package/dist/services/deploy-gate.test.d.ts.map +1 -0
  177. package/dist/services/deploy-gate.test.js +199 -0
  178. package/dist/services/deploy-gate.test.js.map +1 -0
  179. package/dist/services/dockerfile-generator.d.ts +31 -0
  180. package/dist/services/dockerfile-generator.d.ts.map +1 -0
  181. package/dist/services/dockerfile-generator.js +544 -0
  182. package/dist/services/dockerfile-generator.js.map +1 -0
  183. package/dist/services/dockerfile-generator.test.d.ts +2 -0
  184. package/dist/services/dockerfile-generator.test.d.ts.map +1 -0
  185. package/dist/services/dockerfile-generator.test.js +144 -0
  186. package/dist/services/dockerfile-generator.test.js.map +1 -0
  187. package/dist/services/forgejo.d.ts +58 -0
  188. package/dist/services/forgejo.d.ts.map +1 -0
  189. package/dist/services/forgejo.js +131 -0
  190. package/dist/services/forgejo.js.map +1 -0
  191. package/dist/services/koko.d.ts +153 -0
  192. package/dist/services/koko.d.ts.map +1 -0
  193. package/dist/services/koko.js +260 -0
  194. package/dist/services/koko.js.map +1 -0
  195. package/dist/services/kubernetes.d.ts +16 -0
  196. package/dist/services/kubernetes.d.ts.map +1 -0
  197. package/dist/services/kubernetes.js +102 -0
  198. package/dist/services/kubernetes.js.map +1 -0
  199. package/dist/services/oauth-provisioner.d.ts +30 -0
  200. package/dist/services/oauth-provisioner.d.ts.map +1 -0
  201. package/dist/services/oauth-provisioner.js +182 -0
  202. package/dist/services/oauth-provisioner.js.map +1 -0
  203. package/dist/services/oauth-provisioner.test.d.ts +2 -0
  204. package/dist/services/oauth-provisioner.test.d.ts.map +1 -0
  205. package/dist/services/oauth-provisioner.test.js +349 -0
  206. package/dist/services/oauth-provisioner.test.js.map +1 -0
  207. package/dist/services/pod-diagnostics.d.ts +11 -0
  208. package/dist/services/pod-diagnostics.d.ts.map +1 -0
  209. package/dist/services/pod-diagnostics.js +201 -0
  210. package/dist/services/pod-diagnostics.js.map +1 -0
  211. package/dist/services/rotation-scheduler.d.ts +2 -0
  212. package/dist/services/rotation-scheduler.d.ts.map +1 -0
  213. package/dist/services/rotation-scheduler.js +215 -0
  214. package/dist/services/rotation-scheduler.js.map +1 -0
  215. package/dist/services/storage-credential-manager.d.ts +43 -0
  216. package/dist/services/storage-credential-manager.d.ts.map +1 -0
  217. package/dist/services/storage-credential-manager.js +159 -0
  218. package/dist/services/storage-credential-manager.js.map +1 -0
  219. package/dist/services/storage-provisioner.d.ts +32 -0
  220. package/dist/services/storage-provisioner.d.ts.map +1 -0
  221. package/dist/services/storage-provisioner.js +136 -0
  222. package/dist/services/storage-provisioner.js.map +1 -0
  223. package/dist/services/storage.d.ts +65 -0
  224. package/dist/services/storage.d.ts.map +1 -0
  225. package/dist/services/storage.js +204 -0
  226. package/dist/services/storage.js.map +1 -0
  227. package/dist/services/troubleshooter.d.ts +22 -0
  228. package/dist/services/troubleshooter.d.ts.map +1 -0
  229. package/dist/services/troubleshooter.js +168 -0
  230. package/dist/services/troubleshooter.js.map +1 -0
  231. package/dist/services/vault-client.d.ts +114 -0
  232. package/dist/services/vault-client.d.ts.map +1 -0
  233. package/dist/services/vault-client.js +411 -0
  234. package/dist/services/vault-client.js.map +1 -0
  235. package/dist/utils/logger.d.ts +2 -0
  236. package/dist/utils/logger.d.ts.map +1 -0
  237. package/dist/utils/logger.js +6 -0
  238. package/dist/utils/logger.js.map +1 -0
  239. package/dist/utils/response.d.ts +13 -0
  240. package/dist/utils/response.d.ts.map +1 -0
  241. package/dist/utils/response.js +12 -0
  242. package/dist/utils/response.js.map +1 -0
  243. package/docs/registry-migration.md +301 -0
  244. package/docs/registry-quickstart.md +169 -0
  245. package/ecosystem.config.cjs +14 -0
  246. package/findings.md +168 -0
  247. package/helm/default-service/Chart.yaml +6 -0
  248. package/helm/default-service/templates/deployment.yaml +97 -0
  249. package/helm/default-service/templates/ingress.yaml +43 -0
  250. package/helm/default-service/templates/service.yaml +17 -0
  251. package/helm/default-service/values.yaml +82 -0
  252. package/helm/services/iec-builder/Chart.yaml +6 -0
  253. package/helm/services/iec-builder/templates/_helpers.tpl +61 -0
  254. package/helm/services/iec-builder/templates/deployment.yaml +73 -0
  255. package/helm/services/iec-builder/templates/service.yaml +15 -0
  256. package/helm/services/iec-builder/templates/serviceaccount.yaml +12 -0
  257. package/helm/services/iec-builder/values.yaml +56 -0
  258. package/helm/vault-values.yaml +127 -0
  259. package/package.json +45 -0
  260. package/progress.md +156 -0
  261. package/scripts/.vault-init-keys.json +23 -0
  262. package/scripts/backfill-ownership.ts +113 -0
  263. package/scripts/finalize-mongo-auth.sh +212 -0
  264. package/scripts/setup-ipset.sh +107 -0
  265. package/scripts/setup-mongo-auth.sh +163 -0
  266. package/scripts/setup-neo4j-auth.sh +62 -0
  267. package/scripts/setup-redis-auth.sh +55 -0
  268. package/scripts/setup-registry-secret.sh +71 -0
  269. package/scripts/setup-vault.sh +308 -0
  270. package/src/config/env.ts +117 -0
  271. package/src/index.ts +153 -0
  272. package/src/middleware/auth.ts +294 -0
  273. package/src/middleware/org-access.ts +126 -0
  274. package/src/models/types.ts +288 -0
  275. package/src/routes/ai.ts +115 -0
  276. package/src/routes/audit.ts +121 -0
  277. package/src/routes/builds.ts +320 -0
  278. package/src/routes/cluster.ts +235 -0
  279. package/src/routes/config.ts +369 -0
  280. package/src/routes/databases.ts +201 -0
  281. package/src/routes/db-whitelist.ts +204 -0
  282. package/src/routes/domains.ts +547 -0
  283. package/src/routes/oauth.ts +195 -0
  284. package/src/routes/observability.ts +205 -0
  285. package/src/routes/orgs.ts +330 -0
  286. package/src/routes/platform.ts +134 -0
  287. package/src/routes/rotation.ts +191 -0
  288. package/src/routes/services.ts +290 -0
  289. package/src/routes/storage.ts +153 -0
  290. package/src/routes/users.ts +235 -0
  291. package/src/routes/webhooks.ts +384 -0
  292. package/src/services/__tests__/catalog-storage.test.ts +186 -0
  293. package/src/services/__tests__/deploy-pipeline.integration.test.ts +624 -0
  294. package/src/services/__tests__/pod-diagnostics.test.ts +332 -0
  295. package/src/services/__tests__/storage-credential-manager.test.ts +129 -0
  296. package/src/services/__tests__/storage-provisioner.test.ts +166 -0
  297. package/src/services/__tests__/troubleshooter.test.ts +329 -0
  298. package/src/services/bio-client.ts +189 -0
  299. package/src/services/build-queue.ts +137 -0
  300. package/src/services/builder.ts +1800 -0
  301. package/src/services/catalog.test.ts +1389 -0
  302. package/src/services/catalog.ts +1187 -0
  303. package/src/services/cloudflare.ts +259 -0
  304. package/src/services/config-validator.test.ts +190 -0
  305. package/src/services/config-validator.ts +108 -0
  306. package/src/services/crypto.ts +78 -0
  307. package/src/services/database.ts +122 -0
  308. package/src/services/db-credential-manager.test.ts +101 -0
  309. package/src/services/db-credential-manager.ts +447 -0
  310. package/src/services/db-provisioner.test.ts +602 -0
  311. package/src/services/db-provisioner.ts +589 -0
  312. package/src/services/db-whitelist.test.ts +671 -0
  313. package/src/services/db-whitelist.ts +496 -0
  314. package/src/services/dependency-resolver.test.ts +677 -0
  315. package/src/services/dependency-resolver.ts +319 -0
  316. package/src/services/deploy-gate.test.ts +247 -0
  317. package/src/services/deploy-gate.ts +75 -0
  318. package/src/services/dockerfile-generator.test.ts +401 -0
  319. package/src/services/dockerfile-generator.ts +606 -0
  320. package/src/services/forgejo.ts +212 -0
  321. package/src/services/koko.ts +492 -0
  322. package/src/services/kubernetes.ts +141 -0
  323. package/src/services/oauth-provisioner.test.ts +477 -0
  324. package/src/services/oauth-provisioner.ts +286 -0
  325. package/src/services/pod-diagnostics.ts +261 -0
  326. package/src/services/rotation-scheduler.ts +293 -0
  327. package/src/services/storage-credential-manager.ts +223 -0
  328. package/src/services/storage-provisioner.ts +216 -0
  329. package/src/services/storage.ts +274 -0
  330. package/src/services/troubleshooter.ts +208 -0
  331. package/src/services/vault-client.test.ts +272 -0
  332. package/src/services/vault-client.ts +587 -0
  333. package/src/utils/logger.ts +6 -0
  334. package/src/utils/response.ts +23 -0
  335. package/task_plan.md +171 -0
  336. package/tsconfig.json +20 -0
  337. 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
+ })