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,117 @@
1
+ import { z } from 'zod'
2
+ import { config } from 'dotenv'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { dirname, resolve } from 'node:path'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+ config({ path: resolve(__dirname, '../../.env'), override: true })
8
+
9
+ const envSchema = z.object({
10
+ NODE_ENV: z.enum(['development', 'staging', 'production', 'test']).default('development'),
11
+ PORT: z.string().transform(Number).default('3002'),
12
+ LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
13
+
14
+ // Database
15
+ MONGODB_URI: z.string().default('mongodb://localhost:27017/builder'),
16
+ BIO_MONGODB_URI: z.string().default('mongodb://localhost:27017/bio'),
17
+ REDIS_URL: z.string().default('redis://localhost:6379'),
18
+
19
+ // Database provisioning hosts (used by db-provisioner for tenant DBs)
20
+ DB_MONGODB_ADMIN_URI: z.string().default(''), // Admin connection for per-service credential management
21
+ DB_MONGODB_HOST: z.string().default('localhost'),
22
+ DB_MONGODB_PUBLIC_HOST: z.string().default(''), // Public IP for external DB connections (whitelist feature)
23
+ DB_MONGODB_PORT: z.string().transform(Number).default('27017'),
24
+ DB_REDIS_ADMIN_URL: z.string().default(''), // Admin connection for per-service Redis ACL management
25
+ DB_REDIS_HOST: z.string().default('localhost'),
26
+ DB_REDIS_PORT: z.string().transform(Number).default('6379'),
27
+ DB_NEO4J_ADMIN_URI: z.string().default(''), // Admin connection for per-service Neo4j auth management
28
+ DB_NEO4J_ADMIN_USER: z.string().default(''),
29
+ DB_NEO4J_ADMIN_PASSWORD: z.string().default(''),
30
+ DB_NEO4J_HOST: z.string().default('localhost'),
31
+ DB_NEO4J_PORT: z.string().transform(Number).default('7687'),
32
+
33
+ // MinIO object storage (Vault-managed — admin creds live in Vault KV only)
34
+ MINIO_HOST: z.string().default('64.23.181.20'),
35
+ MINIO_PORT: z.string().transform(Number).default('9000'),
36
+
37
+ // Koko integration
38
+ KOKO_URL: z.string().default('https://koko.tawa.insureco.io'),
39
+
40
+ // Bio-ID OAuth provider
41
+ BIO_ID_URL: z.string().default('https://bio.tawa.insureco.io'),
42
+ BIO_INTERNAL_KEY: z.string().default(''),
43
+
44
+ // Build configuration
45
+ WORKSPACE_DIR: z.string().default('/tmp/iec-builds'),
46
+ DOCKER_REGISTRY: z.string().default('registry.insureco.io/insureco'),
47
+ DOCKER_REGISTRY_USER: z.string().default(''), // Username for Docker registry auth
48
+ DOCKER_REGISTRY_TOKEN: z.string().default(''), // Token/password for Docker registry auth
49
+
50
+ // Tarball upload limits
51
+ MAX_UPLOAD_SIZE_MB: z.string().transform(Number).default('500'),
52
+
53
+ // Framework detection and Dockerfile generation
54
+ ENABLE_AUTO_DOCKERFILE: z.string().default('true'),
55
+
56
+ // Catalog version enforcement (reject deploys below minimum catalog schema version)
57
+ ENFORCE_MINIMUM_CATALOG_VERSION: z.string().default('false'),
58
+
59
+ // Kubernetes
60
+ KUBECONFIG: z.string().default(''),
61
+ K8S_NAMESPACE: z.string().default('iec-platform'),
62
+ ENABLE_K8S_DEPLOY: z.string().default('false'),
63
+
64
+ // Cloudflare DNS
65
+ CLOUDFLARE_API_TOKEN: z.string().default(''),
66
+ CLOUDFLARE_ZONE_ID: z.string().default('0945f68a0f28faa9d93721b572586737'),
67
+ INGRESS_TARGET: z.string().default(''),
68
+ ENABLE_DNS_MANAGEMENT: z.string().default('false'),
69
+
70
+ // Forgejo (git.insureco.io) access for cloning private repos
71
+ FORGEJO_URL: z.string().default('https://git.insureco.io'),
72
+ FORGEJO_TOKEN: z.string().default(''),
73
+ FORGEJO_ADMIN_USER: z.string().default(''),
74
+ FORGEJO_ADMIN_PASSWORD: z.string().default(''),
75
+
76
+ // GitHub access for cloning private repos (PAT with repo scope)
77
+ GITHUB_TOKEN: z.string().default(''),
78
+
79
+ // Webhook secrets (for verifying GitHub/GitLab signatures)
80
+ GITHUB_WEBHOOK_SECRET: z.string().default(''),
81
+ GITLAB_WEBHOOK_SECRET: z.string().default(''),
82
+
83
+ // Wallet (deploy gate)
84
+ WALLET_URL: z.string().default(''),
85
+ INTERNAL_SERVICE_KEY: z.string().default(''),
86
+
87
+ // Build worker concurrency (max parallel builds)
88
+ BUILD_CONCURRENCY: z.string().transform(Number).default('3'),
89
+
90
+ // HashiCorp Vault (Phase 2: dynamic credentials)
91
+ VAULT_ADDR: z.string().default(''), // Vault API address (e.g., http://vault.vault.svc.cluster.local:8200)
92
+ VAULT_TOKEN: z.string().default(''), // Builder's Vault token for role management
93
+ VAULT_ENABLED: z.string().default('false'), // Feature flag: 'true' to use Vault dynamic credentials
94
+
95
+ // Credential rotation
96
+ ROTATION_TTL_DAYS: z.string().transform(Number).default('30'),
97
+ RELAY_DIRECT_URL: z.string().default(''), // InsureRelay URL for rotation notifications
98
+ RELAY_INTERNAL_KEY: z.string().default(''), // Auth key for relay service
99
+
100
+ // AI proxy
101
+ ANTHROPIC_API_KEY: z.string().default(''),
102
+ })
103
+
104
+ export type Env = z.infer<typeof envSchema>
105
+
106
+ export function loadEnv(): Env {
107
+ const result = envSchema.safeParse(process.env)
108
+
109
+ if (!result.success) {
110
+ const formatted = result.error.format()
111
+ throw new Error(`Invalid environment configuration: ${JSON.stringify(formatted, null, 2)}`)
112
+ }
113
+
114
+ return result.data
115
+ }
116
+
117
+ export const env = loadEnv()
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ import express from 'express'
2
+ import { pinoHttp } from 'pino-http'
3
+ import { env } from './config/env.js'
4
+ import { logger } from './utils/logger.js'
5
+ import { apiError } from './utils/response.js'
6
+ import { authenticate } from './middleware/auth.js'
7
+ import { connectDatabase, connectRedis, checkConnections, closeConnections } from './services/database.js'
8
+ import { recoverStaleBuilds } from './services/builder.js'
9
+ import { initBuildQueue, initBuildWorker, closeBuildQueue } from './services/build-queue.js'
10
+ import { webhooksRouter } from './routes/webhooks.js'
11
+ import { buildsRouter } from './routes/builds.js'
12
+ import { servicesRouter } from './routes/services.js'
13
+ import { oauthRouter } from './routes/oauth.js'
14
+ import { configRouter } from './routes/config.js'
15
+ import { aiRouter } from './routes/ai.js'
16
+ import { usersRouter } from './routes/users.js'
17
+ import { domainsRouter } from './routes/domains.js'
18
+ import { databasesRouter } from './routes/databases.js'
19
+ import { clusterRouter } from './routes/cluster.js'
20
+ import { orgsRouter } from './routes/orgs.js'
21
+ import { dbWhitelistRouter } from './routes/db-whitelist.js'
22
+ import { storageRouter } from './routes/storage.js'
23
+ import { rotationRouter, rotationDashboardRouter } from './routes/rotation.js'
24
+ import { observabilityRouter } from './routes/observability.js'
25
+ import { auditRouter } from './routes/audit.js'
26
+ import { platformRouter } from './routes/platform.js'
27
+ import { reconcileOnStartup } from './services/db-whitelist.js'
28
+ import { startRotationScheduler } from './services/rotation-scheduler.js'
29
+
30
+ const app = express()
31
+
32
+ // Middleware
33
+ app.use(express.json({ limit: `${env.MAX_UPLOAD_SIZE_MB}mb` }))
34
+ app.use(pinoHttp({ logger }))
35
+
36
+ // Health check
37
+ app.get('/health', (_req, res) => {
38
+ res.json({ status: 'ok', service: 'iec-builder' })
39
+ })
40
+
41
+ // Readiness check
42
+ app.get('/ready', async (_req, res) => {
43
+ const connections = await checkConnections()
44
+ if (connections.mongodb && connections.redis) {
45
+ res.json({ status: 'ready', connections })
46
+ } else {
47
+ res.status(503).json({ status: 'not ready', connections })
48
+ }
49
+ })
50
+
51
+ // Routes
52
+ app.use('/webhooks', webhooksRouter)
53
+ app.use('/builds', authenticate, buildsRouter)
54
+ app.use('/services', authenticate, servicesRouter)
55
+ app.use('/oauth', authenticate, oauthRouter)
56
+ app.use('/services', authenticate, configRouter) // Config sub-routes: /services/:name/config
57
+ app.use('/ai', authenticate, aiRouter)
58
+ app.use('/users', authenticate, usersRouter)
59
+ app.use('/domains', authenticate, domainsRouter)
60
+ app.use('/services', authenticate, databasesRouter) // Database credential sub-routes: /services/:name/databases
61
+ app.use('/cluster', authenticate, clusterRouter)
62
+ app.use('/orgs', authenticate, orgsRouter)
63
+ app.use('/services', authenticate, dbWhitelistRouter) // DB whitelist sub-routes: /services/:name/databases/whitelist
64
+ app.use('/databases', authenticate, dbWhitelistRouter) // Global whitelist: /databases/whitelist
65
+ app.use('/services', authenticate, storageRouter) // Storage sub-routes: /services/:name/storage
66
+ app.use('/services', authenticate, rotationRouter) // Rotation sub-routes: /services/:name/rotation
67
+ app.use('/rotation', authenticate, rotationDashboardRouter) // Platform-wide: /rotation/upcoming
68
+ app.use('/services', authenticate, observabilityRouter) // Observability sub-routes: /services/:name/pods, /pods/logs, /troubleshoot
69
+ app.use('/audit', authenticate, auditRouter) // Audit log: /audit/events
70
+ app.use('/platform', authenticate, platformRouter) // Platform admin: /platform/dashboard
71
+
72
+ // Error handler
73
+ app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
74
+ logger.error({ err }, 'Unhandled error')
75
+ res.status(500).json(apiError('INTERNAL_ERROR', 'Internal server error'))
76
+ })
77
+
78
+ // Start server
79
+ async function start(): Promise<void> {
80
+ try {
81
+ logger.info('Connecting to MongoDB...')
82
+ await connectDatabase()
83
+ logger.info('MongoDB connected ✓')
84
+ logger.info('GridFS initialized for tarball storage ✓')
85
+
86
+ logger.info('Connecting to Redis...')
87
+ await connectRedis()
88
+ logger.info('Redis connected ✓')
89
+
90
+ // Recover builds left in active states from previous crash/restart
91
+ const recovered = await recoverStaleBuilds(0)
92
+ if (recovered > 0) {
93
+ logger.info({ recovered }, 'Recovered stale builds on startup')
94
+ }
95
+
96
+ // Initialize BullMQ build queue and worker
97
+ await initBuildQueue()
98
+ initBuildWorker()
99
+
100
+ // Graceful shutdown: drain active builds before exiting
101
+ const shutdown = async () => {
102
+ logger.info('Shutdown signal received — draining builds...')
103
+ await closeBuildQueue()
104
+ await closeConnections()
105
+ process.exit(0)
106
+ }
107
+ process.once('SIGTERM', shutdown)
108
+ process.once('SIGINT', shutdown)
109
+
110
+ // Reconcile ipset whitelist entries with DB records
111
+ reconcileOnStartup().catch(err => {
112
+ logger.error({ err }, 'Whitelist reconciliation failed')
113
+ })
114
+
115
+ // Start credential rotation scheduler
116
+ startRotationScheduler().catch(err => {
117
+ logger.error({ err }, 'Rotation scheduler crashed')
118
+ })
119
+
120
+ // Validate critical env vars at startup
121
+ const criticalWarnings: string[] = []
122
+ if (!process.env.CONFIG_ENCRYPTION_KEY) criticalWarnings.push('CONFIG_ENCRYPTION_KEY not set — managed secrets will fail')
123
+ if (!env.BIO_INTERNAL_KEY) criticalWarnings.push('BIO_INTERNAL_KEY not set — OAuth provisioning will fail')
124
+ if (!env.CLOUDFLARE_API_TOKEN) criticalWarnings.push('CLOUDFLARE_API_TOKEN not set — DNS management will fail')
125
+ if (!env.KUBECONFIG) criticalWarnings.push('KUBECONFIG not set — K8s deployments may fail')
126
+ if (!env.DB_MONGODB_ADMIN_URI) criticalWarnings.push('DB_MONGODB_ADMIN_URI not set — MongoDB credentials will not be provisioned')
127
+ if (!env.DB_REDIS_ADMIN_URL) criticalWarnings.push('DB_REDIS_ADMIN_URL not set — Redis credentials will not be provisioned')
128
+ if (!env.DB_NEO4J_ADMIN_URI) criticalWarnings.push('DB_NEO4J_ADMIN_URI not set — Neo4j credentials will not be provisioned')
129
+ if (env.VAULT_ENABLED !== 'true') criticalWarnings.push('VAULT_ENABLED not set — storage provisioning (MinIO) will not be available')
130
+ for (const warning of criticalWarnings) {
131
+ logger.warn(`STARTUP: ${warning}`)
132
+ }
133
+
134
+ app.listen(env.PORT, () => {
135
+ logger.info(`iec-builder running on port ${env.PORT}`)
136
+ logger.info(`Environment: ${env.NODE_ENV}`)
137
+ logger.info(`Workspace: ${env.WORKSPACE_DIR}`)
138
+ logger.info(`Registry: ${env.DOCKER_REGISTRY}`)
139
+ logger.info(`K8s deploy: ${env.ENABLE_K8S_DEPLOY}`)
140
+ logger.info(`DNS management: ${env.ENABLE_DNS_MANAGEMENT}`)
141
+ logger.info(`Auto-Dockerfile: ${env.ENABLE_AUTO_DOCKERFILE}`)
142
+ logger.info(`Max upload size: ${env.MAX_UPLOAD_SIZE_MB}MB`)
143
+ if (criticalWarnings.length > 0) {
144
+ logger.warn(`${criticalWarnings.length} critical warning(s) — check logs above`)
145
+ }
146
+ })
147
+ } catch (error) {
148
+ logger.error({ error }, 'Failed to start server')
149
+ process.exit(1)
150
+ }
151
+ }
152
+
153
+ start()
@@ -0,0 +1,294 @@
1
+ import { Request, Response, NextFunction } from 'express'
2
+ import { env } from '../config/env.js'
3
+ import { logger } from '../utils/logger.js'
4
+ import { apiError } from '../utils/response.js'
5
+
6
+ export interface AuthenticatedUser {
7
+ id: string
8
+ email: string
9
+ name?: string
10
+ roles: string[]
11
+ org?: string // Org slug (for service ownership, wallet derivation)
12
+ orgId?: string // Canonical ORG-xxx identifier (for Bio-ID admin calls)
13
+ }
14
+
15
+ export type OrgRole = 'viewer' | 'member' | 'admin'
16
+
17
+ export const ORG_ROLE_HIERARCHY: Record<OrgRole, number> = {
18
+ viewer: 1,
19
+ member: 2,
20
+ admin: 3,
21
+ }
22
+
23
+ /**
24
+ * Derive the effective org role from a user's roles array.
25
+ * Explicit org:admin > admin > org:member > member > default viewer
26
+ */
27
+ export function deriveOrgRole(roles: string[]): OrgRole {
28
+ if (roles.includes('org:admin') || roles.includes('admin')) return 'admin'
29
+ if (roles.includes('org:member') || roles.includes('member')) return 'member'
30
+ if (roles.includes('org:viewer') || roles.includes('viewer')) return 'viewer'
31
+ return 'viewer'
32
+ }
33
+
34
+ export interface AuthenticatedRequest extends Request {
35
+ user?: AuthenticatedUser
36
+ }
37
+
38
+ /**
39
+ * Validate Bearer token from Authorization header
40
+ * Tries Koko CLI token verification first, then bio-id JWT introspection
41
+ */
42
+ export async function validateToken(
43
+ req: AuthenticatedRequest,
44
+ res: Response,
45
+ next: NextFunction
46
+ ): Promise<void> {
47
+ const authHeader = req.headers.authorization
48
+
49
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
50
+ res.status(401).json(apiError('UNAUTHORIZED', 'Missing or invalid authorization header'))
51
+ return
52
+ }
53
+
54
+ const token = authHeader.substring(7)
55
+
56
+ try {
57
+ // Try Koko CLI token verification first (iec CLI uses these)
58
+ const kokoUrl = env.KOKO_URL || 'https://koko.tawa.insureco.io'
59
+ const kokoResponse = await fetch(`${kokoUrl}/api/auth/verify`, {
60
+ headers: {
61
+ Authorization: `Bearer ${token}`,
62
+ },
63
+ })
64
+
65
+ if (kokoResponse.ok) {
66
+ const kokoResult = await kokoResponse.json() as {
67
+ success: boolean
68
+ data?: { id: string; email: string; name: string; org?: string; orgId?: string; roles?: string[] }
69
+ }
70
+
71
+ if (kokoResult.success && kokoResult.data) {
72
+ req.user = {
73
+ id: kokoResult.data.id,
74
+ email: kokoResult.data.email,
75
+ name: kokoResult.data.name,
76
+ roles: kokoResult.data.roles || ['admin'],
77
+ org: kokoResult.data.org,
78
+ orgId: kokoResult.data.orgId,
79
+ }
80
+ next()
81
+ return
82
+ }
83
+ }
84
+
85
+ // Fall back to bio-id JWT introspection
86
+ const bioIdUrl = env.BIO_ID_URL || 'https://bio.tawa.insureco.io'
87
+ const response = await fetch(`${bioIdUrl}/api/auth/introspect`, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ },
92
+ body: JSON.stringify({ token }),
93
+ })
94
+
95
+ if (!response.ok) {
96
+ logger.warn({ status: response.status }, 'Token validation failed (both Koko and bio-id)')
97
+ res.status(401).json(apiError('UNAUTHORIZED', 'Invalid or expired token'))
98
+ return
99
+ }
100
+
101
+ const result = await response.json() as {
102
+ active: boolean
103
+ user?: AuthenticatedUser
104
+ org_id?: string // ORG-xxx (canonical)
105
+ org_slug?: string // URL-friendly slug
106
+ organization_name?: string
107
+ }
108
+
109
+ if (!result.active || !result.user) {
110
+ res.status(401).json(apiError('UNAUTHORIZED', 'Token is not active'))
111
+ return
112
+ }
113
+
114
+ // Populate org (slug) and orgId (ORG-xxx) from introspection response.
115
+ const org = result.user.org
116
+ || result.org_slug
117
+ const orgId = result.org_id
118
+
119
+ req.user = { ...result.user, org, orgId }
120
+ next()
121
+ } catch (error) {
122
+ logger.error({ error }, 'Token validation error')
123
+ res.status(500).json(apiError('AUTH_ERROR', 'Failed to validate token'))
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Simple API key authentication for CLI tools
129
+ * API keys are scoped to services and stored in the service record
130
+ */
131
+ export async function validateApiKey(
132
+ req: AuthenticatedRequest,
133
+ res: Response,
134
+ next: NextFunction
135
+ ): Promise<void> {
136
+ const apiKey = req.headers['x-api-key'] as string
137
+
138
+ if (!apiKey) {
139
+ res.status(401).json(apiError('UNAUTHORIZED', 'Missing API key'))
140
+ return
141
+ }
142
+
143
+ try {
144
+ // Validate API key format: iec_<service-id>_<random-bytes>
145
+ if (!apiKey.startsWith('iec_')) {
146
+ res.status(401).json(apiError('UNAUTHORIZED', 'Invalid API key format'))
147
+ return
148
+ }
149
+
150
+ // Extract service ID from API key
151
+ const parts = apiKey.split('_')
152
+ if (parts.length < 3) {
153
+ res.status(401).json(apiError('UNAUTHORIZED', 'Invalid API key format'))
154
+ return
155
+ }
156
+
157
+ const serviceId = parts[1]
158
+
159
+ // Validate with koko service registry
160
+ const kokoUrl = env.KOKO_URL || 'https://koko.tawa.insureco.io'
161
+ const response = await fetch(`${kokoUrl}/api/services/${serviceId}/validate-key`, {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ },
166
+ body: JSON.stringify({ apiKey }),
167
+ })
168
+
169
+ if (!response.ok) {
170
+ logger.warn({ serviceId, status: response.status }, 'API key validation failed')
171
+ res.status(401).json(apiError('UNAUTHORIZED', 'Invalid API key'))
172
+ return
173
+ }
174
+
175
+ const result = await response.json() as { valid: boolean; serviceId: string; serviceName: string }
176
+
177
+ if (!result.valid) {
178
+ res.status(401).json(apiError('UNAUTHORIZED', 'API key is not valid'))
179
+ return
180
+ }
181
+
182
+ // Set user info from API key validation
183
+ const user: AuthenticatedUser = {
184
+ id: `service:${result.serviceId}`,
185
+ email: `${result.serviceName}@services.iec`,
186
+ name: result.serviceName,
187
+ roles: ['service'],
188
+ }
189
+ req.user = user
190
+
191
+ // Store service info for downstream use
192
+ ;(req as Request & { serviceId: string }).serviceId = result.serviceId
193
+
194
+ next()
195
+ } catch (error) {
196
+ logger.error({ error }, 'API key validation error')
197
+ res.status(500).json(apiError('AUTH_ERROR', 'Failed to validate API key'))
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Combined auth middleware - accepts internal key, Bearer token, or API key.
203
+ * Internal key is used for trusted platform services (tawa-web, etc).
204
+ */
205
+ export async function authenticate(
206
+ req: AuthenticatedRequest,
207
+ res: Response,
208
+ next: NextFunction
209
+ ): Promise<void> {
210
+ // Platform service-to-service auth via shared internal key
211
+ const internalKey = req.headers['x-internal-key'] as string
212
+ if (internalKey && env.INTERNAL_SERVICE_KEY && internalKey === env.INTERNAL_SERVICE_KEY) {
213
+ req.user = {
214
+ id: 'service:platform',
215
+ email: 'platform@internal.iec',
216
+ name: 'Platform Service',
217
+ roles: ['admin', 'service'],
218
+ }
219
+ next()
220
+ return
221
+ }
222
+
223
+ const authHeader = req.headers.authorization
224
+ const apiKey = req.headers['x-api-key']
225
+
226
+ if (authHeader && authHeader.startsWith('Bearer ')) {
227
+ return validateToken(req, res, next)
228
+ }
229
+
230
+ if (apiKey) {
231
+ return validateApiKey(req, res, next)
232
+ }
233
+
234
+ res.status(401).json(apiError('UNAUTHORIZED', 'Authentication required'))
235
+ }
236
+
237
+ /**
238
+ * Require specific roles
239
+ */
240
+ export function requireRoles(...roles: string[]) {
241
+ return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
242
+ if (!req.user) {
243
+ res.status(401).json(apiError('UNAUTHORIZED', 'Authentication required'))
244
+ return
245
+ }
246
+
247
+ const hasRole = roles.some(role => req.user!.roles.includes(role))
248
+ if (!hasRole) {
249
+ res.status(403).json(apiError('FORBIDDEN', `Required roles: ${roles.join(', ')}`))
250
+ return
251
+ }
252
+
253
+ next()
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Validate webhook signature from GitHub
259
+ */
260
+ export function validateGitHubWebhook(
261
+ req: Request,
262
+ res: Response,
263
+ next: NextFunction
264
+ ): void {
265
+ const signature = req.headers['x-hub-signature-256'] as string
266
+ const secret = env.GITHUB_WEBHOOK_SECRET
267
+
268
+ if (!secret) {
269
+ logger.warn('GitHub webhook secret not configured')
270
+ next()
271
+ return
272
+ }
273
+
274
+ if (!signature) {
275
+ res.status(401).json(apiError('UNAUTHORIZED', 'Missing webhook signature'))
276
+ return
277
+ }
278
+
279
+ const payload = JSON.stringify(req.body)
280
+ const expectedSignature = `sha256=${createHmacSignature(payload, secret)}`
281
+
282
+ if (signature !== expectedSignature) {
283
+ logger.warn('Invalid GitHub webhook signature')
284
+ res.status(401).json(apiError('UNAUTHORIZED', 'Invalid webhook signature'))
285
+ return
286
+ }
287
+
288
+ next()
289
+ }
290
+
291
+ function createHmacSignature(payload: string, secret: string): string {
292
+ const { createHmac } = require('crypto')
293
+ return createHmac('sha256', secret).update(payload).digest('hex')
294
+ }
@@ -0,0 +1,126 @@
1
+ import { Response, NextFunction } from 'express'
2
+ import { getServicesCollection } from '../services/database.js'
3
+ import { apiError } from '../utils/response.js'
4
+ import { logger } from '../utils/logger.js'
5
+ import type { AuthenticatedRequest, OrgRole } from './auth.js'
6
+ import { deriveOrgRole, ORG_ROLE_HIERARCHY } from './auth.js'
7
+ import type { Service } from '../models/types.js'
8
+
9
+ export interface ServiceScopedRequest extends AuthenticatedRequest {
10
+ service?: Service
11
+ }
12
+
13
+ /**
14
+ * Extract a service identifier from the request.
15
+ * Checks route params first, then request body.
16
+ */
17
+ function extractServiceIdentifier(req: ServiceScopedRequest): string | undefined {
18
+ return (
19
+ req.params.serviceId ||
20
+ req.params.serviceName ||
21
+ req.body?.serviceId ||
22
+ req.body?.serviceName
23
+ )
24
+ }
25
+
26
+ /**
27
+ * Middleware: Verify the authenticated user belongs to the same org as the service.
28
+ *
29
+ * - Loads the service from DB and attaches it to `req.service`
30
+ * - Super admins and platform services bypass the check
31
+ * - Legacy services without `org` allow access (will be backfilled)
32
+ */
33
+ export function requireOrgAccess() {
34
+ return async (req: ServiceScopedRequest, res: Response, next: NextFunction): Promise<void> => {
35
+ if (!req.user) {
36
+ res.status(401).json(apiError('UNAUTHORIZED', 'Authentication required'))
37
+ return
38
+ }
39
+
40
+ // Platform service and super_admin bypass
41
+ if (req.user.roles.includes('super_admin') || req.user.roles.includes('service')) {
42
+ next()
43
+ return
44
+ }
45
+
46
+ const serviceId = extractServiceIdentifier(req)
47
+ if (!serviceId) {
48
+ // Let the route handler deal with missing params
49
+ next()
50
+ return
51
+ }
52
+
53
+ try {
54
+ const services = getServicesCollection()
55
+ const service = await services.findOne({
56
+ $or: [{ id: serviceId }, { name: serviceId }],
57
+ })
58
+
59
+ if (!service) {
60
+ res.status(404).json(apiError('NOT_FOUND', 'Service not found'))
61
+ return
62
+ }
63
+
64
+ // Legacy services without org: allow access, log for visibility
65
+ if (!service.org) {
66
+ logger.debug({ serviceName: service.name }, 'Service has no org set — allowing access (legacy)')
67
+ req.service = service
68
+ next()
69
+ return
70
+ }
71
+
72
+ // Org match check
73
+ if (req.user.org !== service.org) {
74
+ res.status(403).json(apiError('FORBIDDEN', 'Service belongs to a different organization'))
75
+ return
76
+ }
77
+
78
+ req.service = service
79
+ next()
80
+ } catch (error) {
81
+ logger.error({ error }, 'Error in requireOrgAccess middleware')
82
+ next(error)
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Middleware: Require a minimum org role level.
89
+ *
90
+ * Role hierarchy: viewer (1) < member (2) < admin (3)
91
+ * Roles are derived from the user's JWT/token claims:
92
+ * - org:admin or admin → admin
93
+ * - org:member or member → member
94
+ * - org:viewer or viewer → viewer
95
+ * - default → viewer
96
+ *
97
+ * Super admins and platform services always pass.
98
+ */
99
+ export function requireOrgRole(minimumRole: OrgRole) {
100
+ const minimumLevel = ORG_ROLE_HIERARCHY[minimumRole]
101
+
102
+ return (req: ServiceScopedRequest, res: Response, next: NextFunction): void => {
103
+ if (!req.user) {
104
+ res.status(401).json(apiError('UNAUTHORIZED', 'Authentication required'))
105
+ return
106
+ }
107
+
108
+ // Super admin and platform service bypass
109
+ if (req.user.roles.includes('super_admin') || req.user.roles.includes('service')) {
110
+ next()
111
+ return
112
+ }
113
+
114
+ const effectiveRole = deriveOrgRole(req.user.roles)
115
+ const effectiveLevel = ORG_ROLE_HIERARCHY[effectiveRole]
116
+
117
+ if (effectiveLevel < minimumLevel) {
118
+ res.status(403).json(
119
+ apiError('FORBIDDEN', `Requires org role: ${minimumRole} (you have: ${effectiveRole})`)
120
+ )
121
+ return
122
+ }
123
+
124
+ next()
125
+ }
126
+ }