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,286 @@
1
+ import { getBioClient } from './bio-client.js'
2
+ import { getServicesCollection } from './database.js'
3
+ import { encrypt, decrypt } from './crypto.js'
4
+ import { logger } from '../utils/logger.js'
5
+ import type { CustomDomainRecord, OAuthCredentialRecord } from '../models/types.js'
6
+ import type { AuthMode } from './catalog.js'
7
+
8
+ export interface OAuthCredentials {
9
+ readonly clientId: string
10
+ readonly clientSecret: string
11
+ }
12
+
13
+ interface BioClientData {
14
+ clientId: string
15
+ clientSecret?: string
16
+ redirectUris?: string[]
17
+ }
18
+
19
+ /**
20
+ * Build the OAuth client ID from service name and environment.
21
+ */
22
+ export function buildOAuthClientId(serviceName: string, environment: string): string {
23
+ return `${serviceName}-${environment}`
24
+ }
25
+
26
+ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/
27
+
28
+ /**
29
+ * Build the set of redirect URIs for a service based on naming convention.
30
+ * Includes the platform hostname plus any verified custom domains for the environment.
31
+ */
32
+ export function buildRedirectUris(
33
+ serviceName: string,
34
+ environment: string,
35
+ customDomains?: ReadonlyArray<CustomDomainRecord>
36
+ ): ReadonlyArray<string> {
37
+ const hostname = environment === 'prod'
38
+ ? `${serviceName}.tawa.insureco.io`
39
+ : `${serviceName}.${environment}.tawa.insureco.io`
40
+
41
+ const platformUri = `https://${hostname}/api/auth/callback`
42
+
43
+ const customUris = (customDomains ?? [])
44
+ .filter(d => d.dnsVerified && d.environment === environment && DOMAIN_RE.test(d.domain))
45
+ .map(d => `https://${d.domain}/api/auth/callback`)
46
+
47
+ return [platformUri, ...customUris]
48
+ }
49
+
50
+ /**
51
+ * Read stored OAuth credentials from the service's MongoDB document.
52
+ */
53
+ function readStoredCredentials(
54
+ storedCreds: ReadonlyArray<OAuthCredentialRecord> | undefined,
55
+ clientId: string,
56
+ environment: string
57
+ ): OAuthCredentials | null {
58
+ const record = (storedCreds ?? []).find(
59
+ c => c.clientId === clientId && c.environment === environment
60
+ )
61
+ if (!record) return null
62
+
63
+ try {
64
+ const clientSecret = decrypt(record.clientSecretEncrypted)
65
+ return { clientId: record.clientId, clientSecret }
66
+ } catch (error) {
67
+ logger.warn({ clientId, error }, 'Failed to decrypt stored OAuth credentials')
68
+ return null
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Persist OAuth credentials (encrypted) to the service's MongoDB document.
74
+ */
75
+ async function storeCredentials(
76
+ serviceId: string,
77
+ credentials: OAuthCredentials,
78
+ environment: string,
79
+ existingCreds: ReadonlyArray<OAuthCredentialRecord> | undefined
80
+ ): Promise<void> {
81
+ const record: OAuthCredentialRecord = {
82
+ clientId: credentials.clientId,
83
+ clientSecretEncrypted: encrypt(credentials.clientSecret),
84
+ environment: environment as OAuthCredentialRecord['environment'],
85
+ provisionedAt: new Date().toISOString(),
86
+ }
87
+
88
+ // Replace existing record for this environment, or append
89
+ const filtered = (existingCreds ?? []).filter(
90
+ c => !(c.clientId === credentials.clientId && c.environment === environment)
91
+ )
92
+
93
+ await getServicesCollection().updateOne(
94
+ { id: serviceId },
95
+ { $set: { oauthCredentials: [...filtered, record], updatedAt: new Date().toISOString() } }
96
+ )
97
+
98
+ logger.info({ clientId: credentials.clientId, environment }, 'OAuth credentials stored in service record')
99
+ }
100
+
101
+ /**
102
+ * Try to create a new OAuth client in Bio-id.
103
+ * Returns the credentials if successful, null otherwise.
104
+ */
105
+ async function createNewOAuthClient(
106
+ clientId: string,
107
+ serviceName: string,
108
+ environment: string,
109
+ redirectUris: ReadonlyArray<string>,
110
+ createdBy?: string,
111
+ grants?: ReadonlyArray<string>,
112
+ scopes?: ReadonlyArray<string>,
113
+ orgSlug?: string
114
+ ): Promise<OAuthCredentials | null> {
115
+ const bioClient = getBioClient()
116
+
117
+ const createResult = await bioClient.createClient({
118
+ clientId,
119
+ name: `${serviceName} (${environment})`,
120
+ redirectUris: [...redirectUris],
121
+ allowedGrantTypes: grants ? [...grants] : ['authorization_code', 'refresh_token'],
122
+ ...(scopes ? { allowedScopes: [...scopes] } : {}),
123
+ ...(createdBy ? { createdBy } : {}),
124
+ ...(orgSlug ? { orgSlug } : {}),
125
+ })
126
+
127
+ if (!createResult.success) {
128
+ logger.error(
129
+ { clientId, error: createResult.error },
130
+ 'Failed to create OAuth client in Bio-id'
131
+ )
132
+ return null
133
+ }
134
+
135
+ const data = createResult.data as BioClientData
136
+ if (!data.clientSecret) {
137
+ logger.error({ clientId }, 'Bio-ID did not return clientSecret on create')
138
+ return null
139
+ }
140
+ return {
141
+ clientId: data.clientId,
142
+ clientSecret: data.clientSecret,
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Update an existing OAuth client's redirect URIs in Bio-ID.
148
+ * Does NOT attempt to retrieve or regenerate the secret — the caller
149
+ * must already have the secret from the stored credentials.
150
+ */
151
+ async function syncRedirectUris(
152
+ clientId: string,
153
+ redirectUris: ReadonlyArray<string>,
154
+ createdBy?: string,
155
+ grants?: ReadonlyArray<string>,
156
+ scopes?: ReadonlyArray<string>,
157
+ orgSlug?: string
158
+ ): Promise<void> {
159
+ const bioClient = getBioClient()
160
+
161
+ const updateResult = await bioClient.updateClient(clientId, {
162
+ redirectUris: [...redirectUris],
163
+ ...(grants ? { allowedGrantTypes: [...grants] } : {}),
164
+ ...(scopes ? { allowedScopes: [...scopes] } : {}),
165
+ ...(createdBy ? { createdBy } : {}),
166
+ ...(orgSlug ? { orgSlug } : {}),
167
+ })
168
+
169
+ if (!updateResult.success) {
170
+ logger.warn(
171
+ { clientId, error: updateResult.error },
172
+ 'Failed to update OAuth client redirect URIs'
173
+ )
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Provision an OAuth client in Bio-id for a deployed service.
179
+ *
180
+ * Priority order for the client secret:
181
+ * 1. Stored credentials in MongoDB (encrypted) — normal path for subsequent deploys
182
+ * 2. Create new client in Bio-ID — first deploy
183
+ * 3. Regenerate secret — client exists in Bio-ID but no stored credentials (migration)
184
+ *
185
+ * Credentials are persisted (encrypted) in the service document for future deploys.
186
+ * Redirect URIs are synced on every deploy to pick up custom domain changes.
187
+ *
188
+ * Returns null if Bio-id is unavailable or provisioning fails.
189
+ */
190
+ export async function provisionOAuthClient(
191
+ serviceName: string,
192
+ environment: string,
193
+ namespace: string,
194
+ customDomains?: ReadonlyArray<CustomDomainRecord>,
195
+ createdBy?: string,
196
+ authMode?: AuthMode,
197
+ serviceId?: string,
198
+ storedOAuthCreds?: ReadonlyArray<OAuthCredentialRecord>,
199
+ hasExternalDeps?: boolean,
200
+ dependencyScopes?: ReadonlyArray<string>,
201
+ orgSlug?: string
202
+ ): Promise<OAuthCredentials | null> {
203
+ const clientId = buildOAuthClientId(serviceName, environment)
204
+
205
+ const grants = authMode === 'service-only'
206
+ ? ['client_credentials'] as const
207
+ : hasExternalDeps
208
+ ? ['authorization_code', 'refresh_token', 'client_credentials'] as const
209
+ : ['authorization_code', 'refresh_token'] as const
210
+
211
+ const redirectUris = authMode === 'service-only'
212
+ ? []
213
+ : buildRedirectUris(serviceName, environment, customDomains)
214
+
215
+ // Merge SSO scopes with dependency scopes (e.g. relay:send)
216
+ const ssoScopes = ['openid', 'profile', 'email']
217
+ const allScopes = Array.from(new Set([...ssoScopes, ...(dependencyScopes || [])]))
218
+
219
+ logger.info(
220
+ { clientId, serviceName, environment, authMode, redirectUris, grants, allowedScopes: allScopes },
221
+ 'Provisioning OAuth client'
222
+ )
223
+
224
+ try {
225
+ // 1. Check stored credentials first (fastest, no network call to Bio-ID)
226
+ const stored = readStoredCredentials(storedOAuthCreds, clientId, environment)
227
+ if (stored) {
228
+ logger.info({ clientId }, 'Using stored OAuth credentials, syncing redirect URIs')
229
+ await syncRedirectUris(clientId, redirectUris, createdBy, [...grants], allScopes, orgSlug)
230
+ return stored
231
+ }
232
+
233
+ // 2. No stored credentials — check Bio-ID
234
+ const bioClient = getBioClient()
235
+ const existingResult = await bioClient.getClient(clientId)
236
+
237
+ let credentials: OAuthCredentials | null
238
+
239
+ if (existingResult.success && existingResult.data) {
240
+ // Client exists in Bio-ID but we have no stored secret.
241
+ // Must regenerate to get a secret we can store.
242
+ logger.warn({ clientId }, 'OAuth client exists in Bio-ID but no stored credentials, regenerating secret')
243
+ await syncRedirectUris(clientId, redirectUris, createdBy, [...grants], allScopes, orgSlug)
244
+ const regenResult = await bioClient.regenerateSecret(clientId)
245
+ const regenSecret = (regenResult.data as BioClientData | undefined)?.clientSecret
246
+
247
+ if (!regenSecret) {
248
+ logger.error({ clientId, error: regenResult.error }, 'Failed to regenerate OAuth client secret')
249
+ return null
250
+ }
251
+ credentials = { clientId, clientSecret: regenSecret }
252
+ } else if (
253
+ existingResult.error?.code === 'NOT_FOUND' ||
254
+ existingResult.error?.code === 'RESOURCE_NOT_FOUND'
255
+ ) {
256
+ // Client does not exist — create new
257
+ logger.info({ clientId }, 'OAuth client not found, creating new')
258
+ credentials = await createNewOAuthClient(clientId, serviceName, environment, redirectUris, createdBy, [...grants], allScopes, orgSlug)
259
+ } else {
260
+ // Bio-id returned an unexpected error (likely unavailable)
261
+ logger.error(
262
+ { clientId, error: existingResult.error },
263
+ 'Bio-id returned unexpected error, skipping OAuth provisioning'
264
+ )
265
+ return null
266
+ }
267
+
268
+ if (!credentials) {
269
+ return null
270
+ }
271
+
272
+ // 3. Store credentials for future deploys
273
+ if (serviceId) {
274
+ await storeCredentials(serviceId, credentials, environment, storedOAuthCreds)
275
+ }
276
+
277
+ return credentials
278
+ } catch (error) {
279
+ const message = error instanceof Error ? error.message : 'Unknown error'
280
+ logger.error(
281
+ { clientId, error: message },
282
+ 'OAuth provisioning failed'
283
+ )
284
+ return null
285
+ }
286
+ }
@@ -0,0 +1,261 @@
1
+ import { spawn } from 'child_process'
2
+ import { logger } from '../utils/logger.js'
3
+ import type { PodDiagnostic, ContainerStatusInfo, PodEventInfo, DeployDiagnostics } from '../models/types.js'
4
+
5
+ const KUBECTL_TIMEOUT = 15_000
6
+
7
+ // Strict K8s name validation to prevent command injection
8
+ const K8S_NAME_REGEX = /^[a-z0-9][a-z0-9._-]{0,252}$/
9
+
10
+ function validateK8sName(value: string, label: string): string {
11
+ if (!K8S_NAME_REGEX.test(value)) {
12
+ throw new Error(`Invalid ${label}: "${value}"`)
13
+ }
14
+ return value
15
+ }
16
+
17
+ async function kubectlArgs(args: ReadonlyArray<string>): Promise<string> {
18
+ return new Promise((resolve) => {
19
+ const proc = spawn('kubectl', args as string[])
20
+ let out = ''
21
+ let errOut = ''
22
+ proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
23
+ proc.stderr.on('data', (chunk: Buffer) => { errOut += chunk.toString() })
24
+ proc.on('close', (code) => {
25
+ if (code !== 0 && errOut) {
26
+ logger.warn({ args: args.slice(0, 6), code }, 'kubectl command failed (diagnostic)')
27
+ }
28
+ resolve(out.trim())
29
+ })
30
+ proc.on('error', (err) => {
31
+ logger.warn({ args: args.slice(0, 6), err }, 'kubectl spawn failed (diagnostic)')
32
+ resolve('')
33
+ })
34
+ const timer = setTimeout(() => {
35
+ proc.kill('SIGTERM')
36
+ resolve('')
37
+ }, KUBECTL_TIMEOUT)
38
+ proc.on('close', () => clearTimeout(timer))
39
+ })
40
+ }
41
+
42
+ interface K8sContainerStatus {
43
+ name: string
44
+ ready: boolean
45
+ restartCount: number
46
+ state: Record<string, { reason?: string; message?: string; exitCode?: number; startedAt?: string }>
47
+ lastState?: Record<string, { reason?: string; message?: string; exitCode?: number }>
48
+ }
49
+
50
+ interface K8sPodItem {
51
+ metadata: { name: string; namespace: string }
52
+ status: {
53
+ phase: string
54
+ reason?: string
55
+ containerStatuses?: K8sContainerStatus[]
56
+ initContainerStatuses?: K8sContainerStatus[]
57
+ }
58
+ }
59
+
60
+ interface K8sEvent {
61
+ type: string
62
+ reason: string
63
+ message: string
64
+ lastTimestamp?: string
65
+ eventTime?: string
66
+ metadata?: { creationTimestamp?: string }
67
+ involvedObject?: { name: string }
68
+ }
69
+
70
+ function parseContainerStatus(cs: K8sContainerStatus): ContainerStatusInfo {
71
+ const stateKey = Object.keys(cs.state)[0] ?? 'unknown'
72
+ const stateDetail = cs.state[stateKey] ?? {}
73
+ const lastStateKey = cs.lastState ? Object.keys(cs.lastState)[0] : undefined
74
+ const lastDetail = lastStateKey && cs.lastState ? cs.lastState[lastStateKey] : undefined
75
+
76
+ return {
77
+ name: cs.name,
78
+ ready: cs.ready,
79
+ state: stateKey,
80
+ reason: stateDetail.reason ?? lastDetail?.reason,
81
+ message: stateDetail.message ?? lastDetail?.message,
82
+ exitCode: stateDetail.exitCode ?? lastDetail?.exitCode,
83
+ }
84
+ }
85
+
86
+ function computeRestartCount(containerStatuses: K8sContainerStatus[] | undefined): number {
87
+ if (!containerStatuses) return 0
88
+ return containerStatuses.reduce((sum, cs) => sum + (cs.restartCount || 0), 0)
89
+ }
90
+
91
+ export async function getPodEvents(namespace: string, podName: string): Promise<ReadonlyArray<PodEventInfo>> {
92
+ const ns = validateK8sName(namespace, 'namespace')
93
+ const pod = validateK8sName(podName, 'podName')
94
+ const raw = await kubectlArgs([
95
+ 'get', 'events', '-n', ns,
96
+ '--field-selector', `involvedObject.name=${pod}`,
97
+ '--sort-by=.lastTimestamp', '-o', 'json',
98
+ ])
99
+ if (!raw) return []
100
+
101
+ try {
102
+ const parsed = JSON.parse(raw)
103
+ const items: K8sEvent[] = parsed.items ?? []
104
+ return items.slice(-20).map((e) => ({
105
+ type: e.type ?? 'Normal',
106
+ reason: e.reason ?? 'Unknown',
107
+ message: e.message ?? '',
108
+ age: e.lastTimestamp ?? e.eventTime ?? e.metadata?.creationTimestamp ?? '',
109
+ }))
110
+ } catch {
111
+ return []
112
+ }
113
+ }
114
+
115
+ export async function getContainerLogs(
116
+ namespace: string,
117
+ podName: string,
118
+ options?: { previous?: boolean; tail?: number; container?: string }
119
+ ): Promise<string> {
120
+ const ns = validateK8sName(namespace, 'namespace')
121
+ const pod = validateK8sName(podName, 'podName')
122
+ const tail = options?.tail ?? 50
123
+ const args = ['logs', '-n', ns, pod, `--tail=${tail}`]
124
+ if (options?.previous) args.push('--previous')
125
+ if (options?.container) {
126
+ args.push('-c', validateK8sName(options.container, 'container'))
127
+ }
128
+
129
+ return kubectlArgs(args)
130
+ }
131
+
132
+ export async function getPods(namespace: string, serviceName?: string): Promise<ReadonlyArray<PodDiagnostic>> {
133
+ const ns = validateK8sName(namespace, 'namespace')
134
+ const baseArgs = ['get', 'pods', '-n', ns, '-o', 'json']
135
+ const labelArgs = serviceName
136
+ ? [...baseArgs, '-l', `app=${validateK8sName(serviceName, 'serviceName')}`]
137
+ : baseArgs
138
+ const raw = await kubectlArgs(labelArgs)
139
+ if (!raw) {
140
+ // Try alternative label selector for custom charts
141
+ if (serviceName) {
142
+ const altRaw = await kubectlArgs([
143
+ ...baseArgs, '-l', `app.kubernetes.io/name=${validateK8sName(serviceName, 'serviceName')}`,
144
+ ])
145
+ if (altRaw) return parsePodList(altRaw, ns)
146
+ }
147
+ return []
148
+ }
149
+
150
+ return parsePodList(raw, ns)
151
+ }
152
+
153
+ async function parsePodList(raw: string, namespace: string): Promise<ReadonlyArray<PodDiagnostic>> {
154
+ try {
155
+ const parsed = JSON.parse(raw)
156
+ const items: K8sPodItem[] = parsed.items ?? []
157
+
158
+ const results = await Promise.all(
159
+ items.map(async (pod) => {
160
+ const containerStatuses = (pod.status.containerStatuses ?? []).map(parseContainerStatus)
161
+ const initContainerStatuses = (pod.status.initContainerStatuses ?? []).map(parseContainerStatus)
162
+ const restartCount = computeRestartCount(pod.status.containerStatuses)
163
+ const events = await getPodEvents(namespace, pod.metadata.name)
164
+
165
+ const isUnhealthy = pod.status.phase !== 'Running' && pod.status.phase !== 'Succeeded'
166
+ const hasCrashes = restartCount > 0
167
+ const logs = isUnhealthy || hasCrashes
168
+ ? await getContainerLogs(namespace, pod.metadata.name, { tail: 50 })
169
+ : undefined
170
+
171
+ const previousLogs = hasCrashes
172
+ ? await getContainerLogs(namespace, pod.metadata.name, { tail: 30, previous: true })
173
+ : undefined
174
+
175
+ const combinedLogs = [previousLogs ? `--- Previous container ---\n${previousLogs}` : '', logs]
176
+ .filter(Boolean)
177
+ .join('\n--- Current container ---\n')
178
+
179
+ const diagnostic: PodDiagnostic = {
180
+ name: pod.metadata.name,
181
+ phase: pod.status.phase,
182
+ reason: pod.status.reason,
183
+ restartCount,
184
+ containerStatuses,
185
+ ...(initContainerStatuses.length > 0 ? { initContainerStatuses } : {}),
186
+ events,
187
+ ...(combinedLogs ? { logs: combinedLogs } : {}),
188
+ }
189
+ return diagnostic
190
+ })
191
+ )
192
+
193
+ return results
194
+ } catch {
195
+ return []
196
+ }
197
+ }
198
+
199
+ export function summarizeDiagnostics(pods: ReadonlyArray<PodDiagnostic>): string {
200
+ if (pods.length === 0) return 'No pods found in namespace'
201
+
202
+ const issues: string[] = []
203
+
204
+ for (const pod of pods) {
205
+ const allStatuses = [...pod.containerStatuses, ...(pod.initContainerStatuses ?? [])]
206
+
207
+ for (const cs of allStatuses) {
208
+ if (cs.reason === 'CrashLoopBackOff') {
209
+ issues.push(`CrashLoopBackOff: ${cs.name} in ${pod.name} (${pod.restartCount} restarts)`)
210
+ }
211
+ if (cs.reason === 'ImagePullBackOff' || cs.reason === 'ErrImagePull') {
212
+ issues.push(`ImagePullBackOff: ${cs.name} in ${pod.name}`)
213
+ }
214
+ if (cs.reason === 'OOMKilled') {
215
+ issues.push(`OOMKilled: ${cs.name} in ${pod.name}`)
216
+ }
217
+ if (cs.reason === 'CreateContainerConfigError') {
218
+ issues.push(`CreateContainerConfigError: ${cs.name} in ${pod.name}`)
219
+ }
220
+ }
221
+
222
+ const vaultInit = (pod.initContainerStatuses ?? []).find(
223
+ (cs) => cs.name.includes('vault') && !cs.ready
224
+ )
225
+ if (vaultInit) {
226
+ issues.push(`Vault init failed: ${vaultInit.name} in ${pod.name}`)
227
+ }
228
+
229
+ if (pod.phase === 'Pending') {
230
+ const schedEvent = pod.events.find((e) => e.reason === 'FailedScheduling')
231
+ if (schedEvent) {
232
+ issues.push(`FailedScheduling: ${pod.name} — ${schedEvent.message.slice(0, 100)}`)
233
+ }
234
+ }
235
+ }
236
+
237
+ if (issues.length === 0) {
238
+ const allRunning = pods.every((p) => p.phase === 'Running' || p.phase === 'Succeeded')
239
+ const totalRestarts = pods.reduce((sum, p) => sum + p.restartCount, 0)
240
+ if (allRunning && totalRestarts === 0) return 'All pods healthy'
241
+ if (allRunning) return `All pods running (${totalRestarts} total restarts)`
242
+ return `${pods.length} pod(s) — mixed status`
243
+ }
244
+
245
+ return issues.join('; ')
246
+ }
247
+
248
+ export async function captureDeployDiagnostics(
249
+ serviceName: string,
250
+ namespace: string
251
+ ): Promise<DeployDiagnostics> {
252
+ const pods = await getPods(namespace, serviceName)
253
+ const summary = summarizeDiagnostics(pods)
254
+
255
+ return {
256
+ capturedAt: new Date().toISOString(),
257
+ namespace,
258
+ pods,
259
+ summary,
260
+ }
261
+ }