@portaidentity/server 1.7.3 → 1.9.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 (494) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/applications/index.d.ts +2 -2
  3. package/dist/applications/index.d.ts.map +1 -1
  4. package/dist/applications/index.js +2 -2
  5. package/dist/applications/index.js.map +1 -1
  6. package/dist/applications/repository.d.ts +49 -4
  7. package/dist/applications/repository.d.ts.map +1 -1
  8. package/dist/applications/repository.js +148 -10
  9. package/dist/applications/repository.js.map +1 -1
  10. package/dist/applications/service.d.ts +35 -17
  11. package/dist/applications/service.d.ts.map +1 -1
  12. package/dist/applications/service.js +144 -35
  13. package/dist/applications/service.js.map +1 -1
  14. package/dist/applications/types.d.ts +1 -1
  15. package/dist/applications/types.d.ts.map +1 -1
  16. package/dist/applications/validators.d.ts +14 -0
  17. package/dist/applications/validators.d.ts.map +1 -0
  18. package/dist/applications/validators.js +14 -0
  19. package/dist/applications/validators.js.map +1 -0
  20. package/dist/auth/effective-branding.d.ts +35 -0
  21. package/dist/auth/effective-branding.d.ts.map +1 -0
  22. package/dist/auth/effective-branding.js +81 -0
  23. package/dist/auth/effective-branding.js.map +1 -0
  24. package/dist/auth/email-service.d.ts +3 -8
  25. package/dist/auth/email-service.d.ts.map +1 -1
  26. package/dist/auth/email-service.js +22 -30
  27. package/dist/auth/email-service.js.map +1 -1
  28. package/dist/auth/i18n.d.ts +5 -0
  29. package/dist/auth/i18n.d.ts.map +1 -1
  30. package/dist/auth/i18n.js +6 -5
  31. package/dist/auth/i18n.js.map +1 -1
  32. package/dist/auth/rate-limiter.d.ts +6 -10
  33. package/dist/auth/rate-limiter.d.ts.map +1 -1
  34. package/dist/auth/rate-limiter.js +12 -16
  35. package/dist/auth/rate-limiter.js.map +1 -1
  36. package/dist/auth/recovery-job-processor.d.ts.map +1 -1
  37. package/dist/auth/recovery-job-processor.js +1 -1
  38. package/dist/auth/recovery-job-processor.js.map +1 -1
  39. package/dist/auth/template-engine.d.ts +3 -1
  40. package/dist/auth/template-engine.d.ts.map +1 -1
  41. package/dist/auth/template-engine.js +1 -1
  42. package/dist/auth/template-engine.js.map +1 -1
  43. package/dist/auth/token-repository.d.ts +9 -3
  44. package/dist/auth/token-repository.d.ts.map +1 -1
  45. package/dist/auth/token-repository.js +17 -8
  46. package/dist/auth/token-repository.js.map +1 -1
  47. package/dist/cli/commands/init.d.ts +1 -1
  48. package/dist/cli/commands/init.d.ts.map +1 -1
  49. package/dist/cli/commands/init.js +12 -12
  50. package/dist/cli/commands/init.js.map +1 -1
  51. package/dist/cli/error-handler.d.ts +3 -2
  52. package/dist/cli/error-handler.d.ts.map +1 -1
  53. package/dist/cli/error-handler.js +9 -2
  54. package/dist/cli/error-handler.js.map +1 -1
  55. package/dist/cli/index.d.ts +1 -1
  56. package/dist/cli/index.d.ts.map +1 -1
  57. package/dist/cli/index.js +1 -1
  58. package/dist/cli/index.js.map +1 -1
  59. package/dist/clients/index.d.ts +1 -1
  60. package/dist/clients/index.js +1 -1
  61. package/dist/clients/repository.d.ts +24 -0
  62. package/dist/clients/repository.d.ts.map +1 -1
  63. package/dist/clients/repository.js +39 -0
  64. package/dist/clients/repository.js.map +1 -1
  65. package/dist/clients/secret-repository.d.ts +35 -8
  66. package/dist/clients/secret-repository.d.ts.map +1 -1
  67. package/dist/clients/secret-repository.js +69 -16
  68. package/dist/clients/secret-repository.js.map +1 -1
  69. package/dist/clients/secret-service.d.ts +5 -9
  70. package/dist/clients/secret-service.d.ts.map +1 -1
  71. package/dist/clients/secret-service.js +13 -22
  72. package/dist/clients/secret-service.js.map +1 -1
  73. package/dist/clients/service.d.ts +10 -13
  74. package/dist/clients/service.d.ts.map +1 -1
  75. package/dist/clients/service.js +121 -42
  76. package/dist/clients/service.js.map +1 -1
  77. package/dist/clients/types.d.ts +14 -1
  78. package/dist/clients/types.d.ts.map +1 -1
  79. package/dist/clients/types.js +1 -0
  80. package/dist/clients/types.js.map +1 -1
  81. package/dist/clients/validators.d.ts +83 -1
  82. package/dist/clients/validators.d.ts.map +1 -1
  83. package/dist/clients/validators.js +110 -0
  84. package/dist/clients/validators.js.map +1 -1
  85. package/dist/config/index.d.ts +1 -0
  86. package/dist/config/index.d.ts.map +1 -1
  87. package/dist/config/index.js +1 -0
  88. package/dist/config/index.js.map +1 -1
  89. package/dist/config/schema.d.ts +1 -0
  90. package/dist/config/schema.d.ts.map +1 -1
  91. package/dist/config/schema.js +23 -1
  92. package/dist/config/schema.js.map +1 -1
  93. package/dist/custom-claims/repository.d.ts +14 -9
  94. package/dist/custom-claims/repository.d.ts.map +1 -1
  95. package/dist/custom-claims/repository.js +38 -11
  96. package/dist/custom-claims/repository.js.map +1 -1
  97. package/dist/custom-claims/service.d.ts +11 -6
  98. package/dist/custom-claims/service.d.ts.map +1 -1
  99. package/dist/custom-claims/service.js +43 -20
  100. package/dist/custom-claims/service.js.map +1 -1
  101. package/dist/index.js +8 -2
  102. package/dist/index.js.map +1 -1
  103. package/dist/lib/admin-permissions.d.ts +12 -7
  104. package/dist/lib/admin-permissions.d.ts.map +1 -1
  105. package/dist/lib/admin-permissions.js +26 -16
  106. package/dist/lib/admin-permissions.js.map +1 -1
  107. package/dist/lib/audit-log.d.ts +2 -2
  108. package/dist/lib/audit-log.d.ts.map +1 -1
  109. package/dist/lib/audit-log.js +1 -1
  110. package/dist/lib/audit-log.js.map +1 -1
  111. package/dist/lib/authority-revocation.d.ts +18 -0
  112. package/dist/lib/authority-revocation.d.ts.map +1 -0
  113. package/dist/lib/authority-revocation.js +37 -0
  114. package/dist/lib/authority-revocation.js.map +1 -0
  115. package/dist/lib/branding-assets.d.ts +35 -5
  116. package/dist/lib/branding-assets.d.ts.map +1 -1
  117. package/dist/lib/branding-assets.js +33 -26
  118. package/dist/lib/branding-assets.js.map +1 -1
  119. package/dist/lib/bulk-operations.d.ts +1 -1
  120. package/dist/lib/bulk-operations.d.ts.map +1 -1
  121. package/dist/lib/bulk-operations.js +1 -13
  122. package/dist/lib/bulk-operations.js.map +1 -1
  123. package/dist/lib/cursor.d.ts +2 -0
  124. package/dist/lib/cursor.d.ts.map +1 -1
  125. package/dist/lib/cursor.js +2 -7
  126. package/dist/lib/cursor.js.map +1 -1
  127. package/dist/lib/database.d.ts.map +1 -1
  128. package/dist/lib/database.js +5 -0
  129. package/dist/lib/database.js.map +1 -1
  130. package/dist/lib/deletion-cleanup.d.ts +43 -0
  131. package/dist/lib/deletion-cleanup.d.ts.map +1 -0
  132. package/dist/lib/deletion-cleanup.js +207 -0
  133. package/dist/lib/deletion-cleanup.js.map +1 -0
  134. package/dist/lib/image-validator.d.ts +3 -1
  135. package/dist/lib/image-validator.d.ts.map +1 -1
  136. package/dist/lib/image-validator.js +32 -19
  137. package/dist/lib/image-validator.js.map +1 -1
  138. package/dist/lib/log-format.d.ts +20 -0
  139. package/dist/lib/log-format.d.ts.map +1 -0
  140. package/dist/lib/log-format.js +22 -0
  141. package/dist/lib/log-format.js.map +1 -0
  142. package/dist/lib/logger.d.ts +3 -1
  143. package/dist/lib/logger.d.ts.map +1 -1
  144. package/dist/lib/logger.js +21 -4
  145. package/dist/lib/logger.js.map +1 -1
  146. package/dist/lib/redis.d.ts.map +1 -1
  147. package/dist/lib/redis.js +3 -0
  148. package/dist/lib/redis.js.map +1 -1
  149. package/dist/lib/session-tracking.d.ts +73 -11
  150. package/dist/lib/session-tracking.d.ts.map +1 -1
  151. package/dist/lib/session-tracking.js +76 -32
  152. package/dist/lib/session-tracking.js.map +1 -1
  153. package/dist/lib/signing-key-crypto.d.ts.map +1 -1
  154. package/dist/lib/signing-key-crypto.js +4 -5
  155. package/dist/lib/signing-key-crypto.js.map +1 -1
  156. package/dist/lib/signing-keys.d.ts +3 -4
  157. package/dist/lib/signing-keys.d.ts.map +1 -1
  158. package/dist/lib/signing-keys.js +66 -41
  159. package/dist/lib/signing-keys.js.map +1 -1
  160. package/dist/lib/super-admin-protection.d.ts +5 -5
  161. package/dist/lib/super-admin-protection.d.ts.map +1 -1
  162. package/dist/lib/super-admin-protection.js +6 -9
  163. package/dist/lib/super-admin-protection.js.map +1 -1
  164. package/dist/lib/system-config-catalog.d.ts +268 -0
  165. package/dist/lib/system-config-catalog.d.ts.map +1 -0
  166. package/dist/lib/system-config-catalog.js +90 -0
  167. package/dist/lib/system-config-catalog.js.map +1 -0
  168. package/dist/lib/system-config.d.ts +30 -33
  169. package/dist/lib/system-config.d.ts.map +1 -1
  170. package/dist/lib/system-config.js +78 -93
  171. package/dist/lib/system-config.js.map +1 -1
  172. package/dist/lib/with-timeout.d.ts +32 -0
  173. package/dist/lib/with-timeout.d.ts.map +1 -0
  174. package/dist/lib/with-timeout.js +45 -0
  175. package/dist/lib/with-timeout.js.map +1 -0
  176. package/dist/middleware/admin-auth.d.ts +6 -5
  177. package/dist/middleware/admin-auth.d.ts.map +1 -1
  178. package/dist/middleware/admin-auth.js +30 -20
  179. package/dist/middleware/admin-auth.js.map +1 -1
  180. package/dist/middleware/admin-cors.d.ts +3 -3
  181. package/dist/middleware/admin-cors.js +3 -3
  182. package/dist/middleware/admin-mutation-audit.d.ts +3 -2
  183. package/dist/middleware/admin-mutation-audit.d.ts.map +1 -1
  184. package/dist/middleware/admin-mutation-audit.js +12 -5
  185. package/dist/middleware/admin-mutation-audit.js.map +1 -1
  186. package/dist/middleware/admin-rate-limiter.d.ts +7 -8
  187. package/dist/middleware/admin-rate-limiter.d.ts.map +1 -1
  188. package/dist/middleware/admin-rate-limiter.js +7 -8
  189. package/dist/middleware/admin-rate-limiter.js.map +1 -1
  190. package/dist/middleware/client-secret-hash.d.ts +10 -29
  191. package/dist/middleware/client-secret-hash.d.ts.map +1 -1
  192. package/dist/middleware/client-secret-hash.js +161 -59
  193. package/dist/middleware/client-secret-hash.js.map +1 -1
  194. package/dist/middleware/health.d.ts +17 -0
  195. package/dist/middleware/health.d.ts.map +1 -1
  196. package/dist/middleware/health.js +25 -21
  197. package/dist/middleware/health.js.map +1 -1
  198. package/dist/middleware/ready.d.ts.map +1 -1
  199. package/dist/middleware/ready.js +1 -12
  200. package/dist/middleware/ready.js.map +1 -1
  201. package/dist/middleware/require-existing-organization.d.ts +18 -0
  202. package/dist/middleware/require-existing-organization.d.ts.map +1 -0
  203. package/dist/middleware/require-existing-organization.js +38 -0
  204. package/dist/middleware/require-existing-organization.js.map +1 -0
  205. package/dist/middleware/require-permission.d.ts.map +1 -1
  206. package/dist/middleware/require-permission.js +35 -1
  207. package/dist/middleware/require-permission.js.map +1 -1
  208. package/dist/middleware/require-user-organization.d.ts.map +1 -1
  209. package/dist/middleware/require-user-organization.js +4 -2
  210. package/dist/middleware/require-user-organization.js.map +1 -1
  211. package/dist/middleware/security-headers.d.ts +18 -0
  212. package/dist/middleware/security-headers.d.ts.map +1 -1
  213. package/dist/middleware/security-headers.js +32 -1
  214. package/dist/middleware/security-headers.js.map +1 -1
  215. package/dist/middleware/tenant-resolver.d.ts +1 -2
  216. package/dist/middleware/tenant-resolver.d.ts.map +1 -1
  217. package/dist/middleware/tenant-resolver.js +1 -5
  218. package/dist/middleware/tenant-resolver.js.map +1 -1
  219. package/dist/middleware/token-rate-limiter.d.ts +37 -16
  220. package/dist/middleware/token-rate-limiter.d.ts.map +1 -1
  221. package/dist/middleware/token-rate-limiter.js +126 -54
  222. package/dist/middleware/token-rate-limiter.js.map +1 -1
  223. package/dist/oidc/account-finder.d.ts +5 -5
  224. package/dist/oidc/account-finder.d.ts.map +1 -1
  225. package/dist/oidc/account-finder.js +31 -23
  226. package/dist/oidc/account-finder.js.map +1 -1
  227. package/dist/oidc/adapter-factory.d.ts.map +1 -1
  228. package/dist/oidc/adapter-factory.js +83 -3
  229. package/dist/oidc/adapter-factory.js.map +1 -1
  230. package/dist/oidc/configuration.d.ts +3 -8
  231. package/dist/oidc/configuration.d.ts.map +1 -1
  232. package/dist/oidc/configuration.js +67 -50
  233. package/dist/oidc/configuration.js.map +1 -1
  234. package/dist/oidc/provider.d.ts.map +1 -1
  235. package/dist/oidc/provider.js +7 -0
  236. package/dist/oidc/provider.js.map +1 -1
  237. package/dist/oidc/redis-adapter.d.ts +2 -3
  238. package/dist/oidc/redis-adapter.d.ts.map +1 -1
  239. package/dist/oidc/redis-adapter.js +54 -46
  240. package/dist/oidc/redis-adapter.js.map +1 -1
  241. package/dist/organizations/branding-url.d.ts +17 -0
  242. package/dist/organizations/branding-url.d.ts.map +1 -0
  243. package/dist/organizations/branding-url.js +38 -0
  244. package/dist/organizations/branding-url.js.map +1 -0
  245. package/dist/organizations/index.d.ts +2 -2
  246. package/dist/organizations/index.d.ts.map +1 -1
  247. package/dist/organizations/index.js +1 -1
  248. package/dist/organizations/index.js.map +1 -1
  249. package/dist/organizations/repository.d.ts +22 -32
  250. package/dist/organizations/repository.d.ts.map +1 -1
  251. package/dist/organizations/repository.js +64 -45
  252. package/dist/organizations/repository.js.map +1 -1
  253. package/dist/organizations/service.d.ts +14 -46
  254. package/dist/organizations/service.d.ts.map +1 -1
  255. package/dist/organizations/service.js +60 -112
  256. package/dist/organizations/service.js.map +1 -1
  257. package/dist/organizations/types.d.ts +1 -15
  258. package/dist/organizations/types.d.ts.map +1 -1
  259. package/dist/organizations/validators.d.ts +41 -0
  260. package/dist/organizations/validators.d.ts.map +1 -0
  261. package/dist/organizations/validators.js +48 -0
  262. package/dist/organizations/validators.js.map +1 -0
  263. package/dist/portability/apply.d.ts +15 -0
  264. package/dist/portability/apply.d.ts.map +1 -0
  265. package/dist/portability/apply.js +274 -0
  266. package/dist/portability/apply.js.map +1 -0
  267. package/dist/portability/authorization.d.ts +30 -0
  268. package/dist/portability/authorization.d.ts.map +1 -0
  269. package/dist/portability/authorization.js +130 -0
  270. package/dist/portability/authorization.js.map +1 -0
  271. package/dist/portability/cleanup.d.ts +29 -0
  272. package/dist/portability/cleanup.d.ts.map +1 -0
  273. package/dist/portability/cleanup.js +167 -0
  274. package/dist/portability/cleanup.js.map +1 -0
  275. package/dist/portability/export.d.ts +18 -0
  276. package/dist/portability/export.d.ts.map +1 -0
  277. package/dist/portability/export.js +125 -0
  278. package/dist/portability/export.js.map +1 -0
  279. package/dist/portability/import-repository.d.ts +204 -0
  280. package/dist/portability/import-repository.d.ts.map +1 -0
  281. package/dist/portability/import-repository.js +348 -0
  282. package/dist/portability/import-repository.js.map +1 -0
  283. package/dist/portability/import-user-client-writers.d.ts +40 -0
  284. package/dist/portability/import-user-client-writers.d.ts.map +1 -0
  285. package/dist/portability/import-user-client-writers.js +171 -0
  286. package/dist/portability/import-user-client-writers.js.map +1 -0
  287. package/dist/portability/index.d.ts +8 -0
  288. package/dist/portability/index.d.ts.map +1 -0
  289. package/dist/portability/index.js +8 -0
  290. package/dist/portability/index.js.map +1 -0
  291. package/dist/portability/plan-support.d.ts +137 -0
  292. package/dist/portability/plan-support.d.ts.map +1 -0
  293. package/dist/portability/plan-support.js +337 -0
  294. package/dist/portability/plan-support.js.map +1 -0
  295. package/dist/portability/plan.d.ts +34 -0
  296. package/dist/portability/plan.d.ts.map +1 -0
  297. package/dist/portability/plan.js +450 -0
  298. package/dist/portability/plan.js.map +1 -0
  299. package/dist/portability/repository.d.ts +95 -0
  300. package/dist/portability/repository.d.ts.map +1 -0
  301. package/dist/portability/repository.js +390 -0
  302. package/dist/portability/repository.js.map +1 -0
  303. package/dist/portability/schema.d.ts +591 -0
  304. package/dist/portability/schema.d.ts.map +1 -0
  305. package/dist/portability/schema.js +459 -0
  306. package/dist/portability/schema.js.map +1 -0
  307. package/dist/portability/types.d.ts +382 -0
  308. package/dist/portability/types.d.ts.map +1 -0
  309. package/dist/portability/types.js +19 -0
  310. package/dist/portability/types.js.map +1 -0
  311. package/dist/rbac/errors.d.ts +4 -0
  312. package/dist/rbac/errors.d.ts.map +1 -1
  313. package/dist/rbac/errors.js +7 -0
  314. package/dist/rbac/errors.js.map +1 -1
  315. package/dist/rbac/index.d.ts +3 -3
  316. package/dist/rbac/index.d.ts.map +1 -1
  317. package/dist/rbac/index.js +3 -3
  318. package/dist/rbac/index.js.map +1 -1
  319. package/dist/rbac/mapping-repository.d.ts +84 -9
  320. package/dist/rbac/mapping-repository.d.ts.map +1 -1
  321. package/dist/rbac/mapping-repository.js +222 -55
  322. package/dist/rbac/mapping-repository.js.map +1 -1
  323. package/dist/rbac/permission-repository.d.ts +36 -19
  324. package/dist/rbac/permission-repository.d.ts.map +1 -1
  325. package/dist/rbac/permission-repository.js +93 -35
  326. package/dist/rbac/permission-repository.js.map +1 -1
  327. package/dist/rbac/permission-service.d.ts +18 -19
  328. package/dist/rbac/permission-service.d.ts.map +1 -1
  329. package/dist/rbac/permission-service.js +100 -57
  330. package/dist/rbac/permission-service.js.map +1 -1
  331. package/dist/rbac/role-repository.d.ts +29 -24
  332. package/dist/rbac/role-repository.d.ts.map +1 -1
  333. package/dist/rbac/role-repository.js +66 -43
  334. package/dist/rbac/role-repository.js.map +1 -1
  335. package/dist/rbac/role-service.d.ts +35 -32
  336. package/dist/rbac/role-service.d.ts.map +1 -1
  337. package/dist/rbac/role-service.js +210 -96
  338. package/dist/rbac/role-service.js.map +1 -1
  339. package/dist/rbac/slugs.d.ts +16 -27
  340. package/dist/rbac/slugs.d.ts.map +1 -1
  341. package/dist/rbac/slugs.js +36 -59
  342. package/dist/rbac/slugs.js.map +1 -1
  343. package/dist/rbac/types.d.ts +2 -4
  344. package/dist/rbac/types.d.ts.map +1 -1
  345. package/dist/rbac/types.js.map +1 -1
  346. package/dist/rbac/user-role-service.d.ts +23 -24
  347. package/dist/rbac/user-role-service.d.ts.map +1 -1
  348. package/dist/rbac/user-role-service.js +117 -61
  349. package/dist/rbac/user-role-service.js.map +1 -1
  350. package/dist/routes/applications.d.ts +4 -2
  351. package/dist/routes/applications.d.ts.map +1 -1
  352. package/dist/routes/applications.js +57 -19
  353. package/dist/routes/applications.js.map +1 -1
  354. package/dist/routes/audit.js +1 -1
  355. package/dist/routes/audit.js.map +1 -1
  356. package/dist/routes/branding.d.ts +3 -3
  357. package/dist/routes/branding.d.ts.map +1 -1
  358. package/dist/routes/branding.js +44 -16
  359. package/dist/routes/branding.js.map +1 -1
  360. package/dist/routes/bulk.d.ts +0 -5
  361. package/dist/routes/bulk.d.ts.map +1 -1
  362. package/dist/routes/bulk.js +3 -5
  363. package/dist/routes/bulk.js.map +1 -1
  364. package/dist/routes/clients.d.ts +4 -4
  365. package/dist/routes/clients.d.ts.map +1 -1
  366. package/dist/routes/clients.js +113 -52
  367. package/dist/routes/clients.js.map +1 -1
  368. package/dist/routes/config.d.ts +17 -12
  369. package/dist/routes/config.d.ts.map +1 -1
  370. package/dist/routes/config.js +165 -64
  371. package/dist/routes/config.js.map +1 -1
  372. package/dist/routes/custom-claims.d.ts.map +1 -1
  373. package/dist/routes/custom-claims.js +8 -2
  374. package/dist/routes/custom-claims.js.map +1 -1
  375. package/dist/routes/exports.d.ts +2 -1
  376. package/dist/routes/exports.d.ts.map +1 -1
  377. package/dist/routes/exports.js +57 -1
  378. package/dist/routes/exports.js.map +1 -1
  379. package/dist/routes/imports.d.ts +2 -2
  380. package/dist/routes/imports.d.ts.map +1 -1
  381. package/dist/routes/imports.js +95 -40
  382. package/dist/routes/imports.js.map +1 -1
  383. package/dist/routes/interactions.d.ts.map +1 -1
  384. package/dist/routes/interactions.js +113 -87
  385. package/dist/routes/interactions.js.map +1 -1
  386. package/dist/routes/invitation.d.ts.map +1 -1
  387. package/dist/routes/invitation.js +28 -27
  388. package/dist/routes/invitation.js.map +1 -1
  389. package/dist/routes/keys.d.ts.map +1 -1
  390. package/dist/routes/keys.js +26 -13
  391. package/dist/routes/keys.js.map +1 -1
  392. package/dist/routes/magic-link.d.ts.map +1 -1
  393. package/dist/routes/magic-link.js +5 -17
  394. package/dist/routes/magic-link.js.map +1 -1
  395. package/dist/routes/organizations.d.ts +1 -2
  396. package/dist/routes/organizations.d.ts.map +1 -1
  397. package/dist/routes/organizations.js +43 -104
  398. package/dist/routes/organizations.js.map +1 -1
  399. package/dist/routes/password-reset.d.ts.map +1 -1
  400. package/dist/routes/password-reset.js +13 -35
  401. package/dist/routes/password-reset.js.map +1 -1
  402. package/dist/routes/permissions.d.ts +1 -1
  403. package/dist/routes/permissions.d.ts.map +1 -1
  404. package/dist/routes/permissions.js +58 -17
  405. package/dist/routes/permissions.js.map +1 -1
  406. package/dist/routes/public-branding.d.ts +16 -0
  407. package/dist/routes/public-branding.d.ts.map +1 -0
  408. package/dist/routes/public-branding.js +52 -0
  409. package/dist/routes/public-branding.js.map +1 -0
  410. package/dist/routes/roles.d.ts +1 -1
  411. package/dist/routes/roles.d.ts.map +1 -1
  412. package/dist/routes/roles.js +63 -21
  413. package/dist/routes/roles.js.map +1 -1
  414. package/dist/routes/sessions.d.ts.map +1 -1
  415. package/dist/routes/sessions.js +23 -12
  416. package/dist/routes/sessions.js.map +1 -1
  417. package/dist/routes/two-factor.d.ts.map +1 -1
  418. package/dist/routes/two-factor.js +91 -38
  419. package/dist/routes/two-factor.js.map +1 -1
  420. package/dist/routes/user-roles.d.ts.map +1 -1
  421. package/dist/routes/user-roles.js +56 -9
  422. package/dist/routes/user-roles.js.map +1 -1
  423. package/dist/routes/users.d.ts +4 -8
  424. package/dist/routes/users.d.ts.map +1 -1
  425. package/dist/routes/users.js +108 -265
  426. package/dist/routes/users.js.map +1 -1
  427. package/dist/server.d.ts.map +1 -1
  428. package/dist/server.js +53 -37
  429. package/dist/server.js.map +1 -1
  430. package/dist/two-factor/errors.d.ts +9 -0
  431. package/dist/two-factor/errors.d.ts.map +1 -1
  432. package/dist/two-factor/errors.js +12 -0
  433. package/dist/two-factor/errors.js.map +1 -1
  434. package/dist/two-factor/index.d.ts +5 -4
  435. package/dist/two-factor/index.d.ts.map +1 -1
  436. package/dist/two-factor/index.js +4 -4
  437. package/dist/two-factor/index.js.map +1 -1
  438. package/dist/two-factor/repository.d.ts +17 -6
  439. package/dist/two-factor/repository.d.ts.map +1 -1
  440. package/dist/two-factor/repository.js +37 -9
  441. package/dist/two-factor/repository.js.map +1 -1
  442. package/dist/two-factor/service.d.ts.map +1 -1
  443. package/dist/two-factor/service.js +26 -19
  444. package/dist/two-factor/service.js.map +1 -1
  445. package/dist/two-factor/totp.d.ts +13 -2
  446. package/dist/two-factor/totp.d.ts.map +1 -1
  447. package/dist/two-factor/totp.js +20 -7
  448. package/dist/two-factor/totp.js.map +1 -1
  449. package/dist/two-factor/types.d.ts +3 -0
  450. package/dist/two-factor/types.d.ts.map +1 -1
  451. package/dist/two-factor/types.js +5 -0
  452. package/dist/two-factor/types.js.map +1 -1
  453. package/dist/users/gdpr.d.ts +2 -40
  454. package/dist/users/gdpr.d.ts.map +1 -1
  455. package/dist/users/gdpr.js +3 -110
  456. package/dist/users/gdpr.js.map +1 -1
  457. package/dist/users/index.d.ts +5 -4
  458. package/dist/users/index.d.ts.map +1 -1
  459. package/dist/users/index.js +5 -4
  460. package/dist/users/index.js.map +1 -1
  461. package/dist/users/repository.d.ts +49 -3
  462. package/dist/users/repository.d.ts.map +1 -1
  463. package/dist/users/repository.js +169 -18
  464. package/dist/users/repository.js.map +1 -1
  465. package/dist/users/service.d.ts +19 -51
  466. package/dist/users/service.d.ts.map +1 -1
  467. package/dist/users/service.js +72 -122
  468. package/dist/users/service.js.map +1 -1
  469. package/dist/users/types.d.ts +4 -5
  470. package/dist/users/types.d.ts.map +1 -1
  471. package/dist/users/types.js.map +1 -1
  472. package/dist/users/validators.d.ts +28 -0
  473. package/dist/users/validators.d.ts.map +1 -0
  474. package/dist/users/validators.js +25 -0
  475. package/dist/users/validators.js.map +1 -0
  476. package/locales/default/en/errors.json +2 -1
  477. package/migrations/024_application_client_correction.sql +39 -0
  478. package/migrations/025_record_deletion_lifecycle.sql +26 -0
  479. package/migrations/026_simplify_user_lifecycle.sql +33 -0
  480. package/migrations/027_branding_asset_size_limits.sql +19 -0
  481. package/migrations/028_totp_replay_protection.sql +17 -0
  482. package/migrations/029_public_session_identifiers.sql +16 -0
  483. package/migrations/030_global_configuration_catalog.sql +39 -0
  484. package/migrations/031_monotonic_updated_at.sql +28 -0
  485. package/migrations/032_client_require_consent.sql +15 -0
  486. package/package.json +2 -2
  487. package/dist/lib/data-import-plan.d.ts +0 -50
  488. package/dist/lib/data-import-plan.d.ts.map +0 -1
  489. package/dist/lib/data-import-plan.js +0 -235
  490. package/dist/lib/data-import-plan.js.map +0 -1
  491. package/dist/lib/data-import.d.ts +0 -276
  492. package/dist/lib/data-import.d.ts.map +0 -1
  493. package/dist/lib/data-import.js +0 -1694
  494. package/dist/lib/data-import.js.map +0 -1
@@ -1,1694 +0,0 @@
1
- /**
2
- * Data import service.
3
- *
4
- * Imports configuration data from a JSON manifest with three modes:
5
- * - merge: skip existing (match by slug), create new
6
- * - overwrite: update existing, create new
7
- * - dry-run: show what would change without applying
8
- *
9
- * All changes are applied in a single PostgreSQL transaction for atomicity.
10
- * Entities are processed in 12 phases in dependency order:
11
- * 1. Organizations (+branding, 2FA), 2. Applications, 3. Clients + secrets,
12
- * 4. Roles, 5. Permissions, 6. Claim definitions, 7. Role-permission mappings,
13
- * 8. Application modules, 9. Users, 10. User-role assignments,
14
- * 11. User claim values, 12. System config overrides.
15
- *
16
- * Authentication material is never accepted. New users rely on the normal account-recovery
17
- * boundary to establish credentials after provisioning.
18
- *
19
- * @module data-import
20
- * @see 07-import-export-invitation.md
21
- */
22
- import { createHash, randomUUID } from 'crypto';
23
- import { z } from 'zod';
24
- import { getPool } from './database.js';
25
- import { logger } from './logger.js';
26
- import { buildImportManifestPlan, isProhibitedImportConfigKey, requireResolvedImportDependencies, } from './data-import-plan.js';
27
- import { generateClientId, generateSecret, hashSecret, sha256Secret } from '../clients/crypto.js';
28
- import { LOGIN_METHODS } from '../clients/types.js';
29
- import { validateRedirectUris } from '../clients/validators.js';
30
- import { validateSlug as validateOrganizationSlug } from '../organizations/slugs.js';
31
- import { validateSlug as validateApplicationSlug } from '../applications/slugs.js';
32
- import { validatePermissionSlug, validateRoleSlug } from '../rbac/slugs.js';
33
- import { validateClaimName, validateClaimValue } from '../custom-claims/validators.js';
34
- import { writeAuditLogInTransaction } from './audit-log.js';
35
- /**
36
- * Sanitized import failure which retains no manifest values or dependency diagnostics.
37
- */
38
- export class ImportOperationError extends Error {
39
- /** Stable public error discriminator. */
40
- code;
41
- /** Server-created identifier used to correlate private operational diagnostics. */
42
- correlationId;
43
- /** Recommended public HTTP status. */
44
- status;
45
- /**
46
- * Create a minimal import failure.
47
- *
48
- * @param code - Stable public failure category.
49
- * @param status - Recommended HTTP status for the route boundary.
50
- * @param correlationId - Optional existing server correlation identifier.
51
- */
52
- constructor(code, status, correlationId) {
53
- super('Import request could not be completed');
54
- this.name = 'ImportOperationError';
55
- this.code = code;
56
- this.status = status;
57
- this.correlationId = correlationId ?? randomUUID();
58
- }
59
- }
60
- // ============================================================================
61
- // Manifest schema — validates the import payload structure
62
- // ============================================================================
63
- /** Current manifest version */
64
- const MANIFEST_VERSION = '1.0';
65
- const organizationSchema = z
66
- .object({
67
- name: z.string().min(1).max(255),
68
- slug: z.string().refine((slug) => validateOrganizationSlug(slug).isValid),
69
- default_login_methods: z.array(z.enum(LOGIN_METHODS)).min(1).optional(),
70
- default_locale: z.string().max(10).optional().nullable(),
71
- // Organization policy and presentation fields.
72
- two_factor_policy: z
73
- .enum(['optional', 'required_email', 'required_totp', 'required_any'])
74
- .optional(),
75
- branding_primary_color: z.string().max(20).optional(),
76
- branding_company_name: z.string().max(255).optional(),
77
- branding_custom_css: z.string().max(10000).optional(),
78
- branding_logo_url: z.string().max(2048).optional(),
79
- branding_favicon_url: z.string().max(2048).optional(),
80
- })
81
- .strict();
82
- const applicationSchema = z
83
- .object({
84
- name: z.string().min(1).max(255),
85
- slug: z.string().refine((slug) => validateApplicationSlug(slug).isValid),
86
- organization_slug: z.string().refine((slug) => validateOrganizationSlug(slug).isValid),
87
- description: z.string().max(1000).optional().nullable(),
88
- })
89
- .strict();
90
- const clientSchema = z
91
- .object({
92
- client_name: z.string().min(1).max(255),
93
- application_slug: z.string().refine((slug) => validateApplicationSlug(slug).isValid),
94
- organization_slug: z.string().refine((slug) => validateOrganizationSlug(slug).isValid),
95
- client_type: z.enum(['confidential', 'public']),
96
- application_type: z.enum(['web', 'native', 'spa']).optional(),
97
- grant_types: z
98
- .array(z.enum(['authorization_code', 'refresh_token', 'client_credentials']))
99
- .min(1)
100
- .optional(),
101
- redirect_uris: z.array(z.string()).min(1).max(10).optional(),
102
- response_types: z.array(z.literal('code')).min(1).max(1).optional(),
103
- scope: z.string().optional(),
104
- login_methods: z.array(z.enum(LOGIN_METHODS)).min(1).optional().nullable(),
105
- token_endpoint_auth_method: z
106
- .enum(['client_secret_basic', 'client_secret_post', 'none'])
107
- .optional(),
108
- // Additional OIDC client fields matching the database schema.
109
- post_logout_redirect_uris: z.array(z.string().url()).max(10).optional(),
110
- allowed_origins: z.array(z.string().url()).max(10).optional(),
111
- require_pkce: z.boolean().optional(),
112
- // Secret metadata is flattened from the nested provisioning document.
113
- secret_label: z.string().max(255).optional().nullable(),
114
- secret_expires_at: z.string().datetime().optional().nullable(),
115
- })
116
- .strict()
117
- .superRefine((value, context) => {
118
- if (value.redirect_uris !== undefined &&
119
- !validateRedirectUris(value.redirect_uris, false).isValid) {
120
- context.addIssue({
121
- code: 'custom',
122
- message: 'Invalid redirect URI',
123
- path: ['redirect_uris'],
124
- });
125
- }
126
- if (value.post_logout_redirect_uris?.some((uri) => !validateRedirectUris([uri], false).isValid)) {
127
- context.addIssue({
128
- code: 'custom',
129
- message: 'Invalid post-logout redirect URI',
130
- path: ['post_logout_redirect_uris'],
131
- });
132
- }
133
- if (value.allowed_origins?.some((origin) => {
134
- const parsed = new URL(origin);
135
- return (parsed.pathname !== '/' ||
136
- parsed.search !== '' ||
137
- parsed.hash !== '' ||
138
- origin.includes('*'));
139
- })) {
140
- context.addIssue({
141
- code: 'custom',
142
- message: 'Invalid allowed origin',
143
- path: ['allowed_origins'],
144
- });
145
- }
146
- const authMethod = value.token_endpoint_auth_method ??
147
- (value.client_type === 'public' ? 'none' : 'client_secret_post');
148
- if (value.client_type === 'public' && (authMethod !== 'none' || value.require_pkce === false)) {
149
- context.addIssue({
150
- code: 'custom',
151
- message: 'Public clients require PKCE and no client secret authentication',
152
- });
153
- }
154
- if (value.client_type === 'public' && value.grant_types?.includes('client_credentials')) {
155
- context.addIssue({ code: 'custom', message: 'Public clients cannot use client credentials' });
156
- }
157
- if (value.client_type === 'confidential' && authMethod === 'none') {
158
- context.addIssue({ code: 'custom', message: 'Confidential clients require authentication' });
159
- }
160
- });
161
- const roleSchema = z
162
- .object({
163
- name: z.string().min(1).max(255),
164
- slug: z.string().refine(validateRoleSlug),
165
- application_slug: z.string().min(1),
166
- organization_slug: z.string().min(1),
167
- description: z.string().max(1000).optional().nullable(),
168
- })
169
- .strict();
170
- const permissionSchema = z
171
- .object({
172
- name: z.string().min(1).max(255),
173
- slug: z.string().refine(validatePermissionSlug),
174
- application_slug: z.string().min(1),
175
- organization_slug: z.string().min(1),
176
- description: z.string().max(1000).optional().nullable(),
177
- })
178
- .strict();
179
- const claimDefinitionSchema = z
180
- .object({
181
- name: z.string().min(1).max(255),
182
- slug: z.string().refine((slug) => validateClaimName(slug).valid),
183
- application_slug: z.string().min(1),
184
- organization_slug: z.string().min(1),
185
- claim_type: z.enum(['string', 'number', 'boolean', 'json']),
186
- description: z.string().max(1000).optional().nullable(),
187
- })
188
- .strict();
189
- /**
190
- * Schema for role-permission mappings.
191
- * Links roles to permissions after both are created during import.
192
- * Uses slug-based references resolved against the appMap.
193
- */
194
- const rolePermissionMappingSchema = z
195
- .object({
196
- role_slug: z.string().refine(validateRoleSlug),
197
- permission_slugs: z.array(z.string().refine(validatePermissionSlug)).min(1),
198
- application_slug: z.string().refine((slug) => validateApplicationSlug(slug).isValid),
199
- organization_slug: z.string().refine((slug) => validateOrganizationSlug(slug).isValid),
200
- })
201
- .strict();
202
- /** User input in the flat manifest format. */
203
- const userSchema = z
204
- .object({
205
- email: z.string().min(1).email(),
206
- organization_slug: z.string().min(1),
207
- given_name: z.string().max(255).optional(),
208
- family_name: z.string().max(255).optional(),
209
- locale: z.string().max(10).optional(),
210
- status: z.enum(['active', 'inactive']).optional(),
211
- email_verified: z.boolean().optional(),
212
- })
213
- .strict();
214
- /** Application-module input. */
215
- const applicationModuleSchema = z
216
- .object({
217
- name: z.string().min(1).max(255),
218
- slug: z.string().min(1).max(63),
219
- application_slug: z.string().min(1),
220
- organization_slug: z.string().min(1),
221
- description: z.string().max(1000).optional(),
222
- status: z.enum(['active', 'inactive']).optional(),
223
- })
224
- .strict();
225
- /** User-role assignment input. */
226
- const userRoleAssignmentSchema = z
227
- .object({
228
- email: z.string().min(1).email(),
229
- organization_slug: z.string().min(1),
230
- application_slug: z.string().min(1),
231
- role_slug: z.string().min(1),
232
- })
233
- .strict();
234
- /** User claim-value input. */
235
- const userClaimValueSchema = z
236
- .object({
237
- email: z.string().min(1).email(),
238
- organization_slug: z.string().min(1),
239
- application_slug: z.string().min(1),
240
- claim_slug: z.string().min(1),
241
- value: z.unknown(),
242
- })
243
- .strict();
244
- /** Add one issue for every repeated tenant-qualified natural key. */
245
- function requireUniqueNaturalKeys(items, collection, keyFor, context) {
246
- const firstIndexes = new Map();
247
- for (const [index, item] of items.entries()) {
248
- const key = keyFor(item);
249
- if (firstIndexes.has(key)) {
250
- context.addIssue({
251
- code: 'custom',
252
- message: `Duplicate natural key in ${collection}`,
253
- path: [collection, index],
254
- });
255
- continue;
256
- }
257
- firstIndexes.set(key, index);
258
- }
259
- }
260
- /** Join natural-key components without allowing ambiguous concatenation. */
261
- function naturalKey(...parts) {
262
- return parts.join('\u001f');
263
- }
264
- /** The full import manifest schema */
265
- export const importManifestSchema = z
266
- .object({
267
- version: z.literal(MANIFEST_VERSION),
268
- exportedAt: z.string().optional(),
269
- organizations: z.array(organizationSchema).optional().default([]),
270
- applications: z.array(applicationSchema).optional().default([]),
271
- clients: z.array(clientSchema).optional().default([]),
272
- roles: z.array(roleSchema).optional().default([]),
273
- permissions: z.array(permissionSchema).optional().default([]),
274
- claim_definitions: z.array(claimDefinitionSchema).optional().default([]),
275
- // Role-permission mappings are applied after roles and permissions.
276
- role_permission_mappings: z.array(rolePermissionMappingSchema).optional().default([]),
277
- // System configuration overrides can update only existing keys.
278
- config: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
279
- // User and application relationship collections.
280
- users: z.array(userSchema).optional().default([]),
281
- application_modules: z.array(applicationModuleSchema).optional().default([]),
282
- user_role_assignments: z.array(userRoleAssignmentSchema).optional().default([]),
283
- user_claim_values: z.array(userClaimValueSchema).optional().default([]),
284
- })
285
- .strict()
286
- .superRefine((manifest, context) => {
287
- requireUniqueNaturalKeys(manifest.organizations, 'organizations', (item) => item.slug, context);
288
- requireUniqueNaturalKeys(manifest.applications, 'applications', (item) => item.slug, context);
289
- requireUniqueNaturalKeys(manifest.clients, 'clients', (item) => naturalKey(item.organization_slug, item.application_slug, item.client_name), context);
290
- requireUniqueNaturalKeys(manifest.roles, 'roles', (item) => naturalKey(item.application_slug, item.slug), context);
291
- requireUniqueNaturalKeys(manifest.permissions, 'permissions', (item) => naturalKey(item.application_slug, item.slug), context);
292
- requireUniqueNaturalKeys(manifest.claim_definitions, 'claim_definitions', (item) => naturalKey(item.application_slug, item.slug), context);
293
- requireUniqueNaturalKeys(manifest.role_permission_mappings, 'role_permission_mappings', (item) => naturalKey(item.application_slug, item.role_slug), context);
294
- requireUniqueNaturalKeys(manifest.application_modules, 'application_modules', (item) => naturalKey(item.application_slug, item.slug), context);
295
- requireUniqueNaturalKeys(manifest.users, 'users', (item) => naturalKey(item.organization_slug, item.email.toLowerCase()), context);
296
- requireUniqueNaturalKeys(manifest.user_role_assignments, 'user_role_assignments', (item) => naturalKey(item.organization_slug, item.application_slug, item.email.toLowerCase(), item.role_slug), context);
297
- requireUniqueNaturalKeys(manifest.user_claim_values, 'user_claim_values', (item) => naturalKey(item.organization_slug, item.application_slug, item.email.toLowerCase(), item.claim_slug), context);
298
- for (const configKey of Object.keys(manifest.config ?? {})) {
299
- if (isProhibitedImportConfigKey(configKey)) {
300
- context.addIssue({
301
- code: 'custom',
302
- message: 'Import configuration cannot contain authentication material',
303
- path: ['config', configKey],
304
- });
305
- }
306
- }
307
- });
308
- // ============================================================================
309
- // Import service
310
- // ============================================================================
311
- /**
312
- * Import configuration from a JSON manifest.
313
- *
314
- * Processes entities in dependency order within a single transaction:
315
- * organizations → applications → clients → roles → permissions → claims.
316
- *
317
- * @param manifest - The validated import manifest
318
- * @param mode - Import mode: merge, overwrite, or dry-run
319
- * @param actorId - ID of the admin performing the import (for audit log)
320
- * @returns Import result with created/updated/skipped/error counts
321
- * @throws Error if manifest version is incompatible
322
- */
323
- export async function importData(manifestInput, mode, actorId, organizationId) {
324
- // Public routes parse first, while direct service callers receive the same closed/defaulted
325
- // contract here so optional collections can never bypass prevalidation.
326
- const manifest = importManifestSchema.parse(manifestInput);
327
- // Validate manifest version
328
- if (manifest.version !== MANIFEST_VERSION) {
329
- throw new Error(`Unsupported manifest version: ${manifest.version}. Expected: ${MANIFEST_VERSION}`);
330
- }
331
- // Build the complete mutation-free dependency plan before obtaining a transaction client.
332
- // Database-backed planning resolves its external dependencies in the execution task.
333
- const manifestPlan = buildImportManifestPlan(manifest);
334
- const result = {
335
- mode,
336
- created: [],
337
- updated: [],
338
- skipped: [],
339
- errors: [],
340
- credentials: [],
341
- };
342
- const pool = getPool();
343
- const client = await pool.connect();
344
- const acquiredClientLocks = [];
345
- try {
346
- const clientLockKeys = manifest.clients
347
- .map((item) => naturalKey(item.organization_slug, item.application_slug, item.client_name))
348
- .sort();
349
- for (const lockKey of clientLockKeys) {
350
- await client.query('SELECT pg_advisory_lock(hashtextextended($1, 0))', [lockKey]);
351
- acquiredClientLocks.push(lockKey);
352
- }
353
- await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
354
- try {
355
- await requireImportScope(client, manifest, organizationId);
356
- await requireResolvedImportDependencies(client, manifestPlan);
357
- }
358
- catch {
359
- throw new ImportOperationError('import_plan_rejected', 409);
360
- }
361
- // Slug-to-ID maps built during import for resolving foreign key references.
362
- // Populated as entities are created/matched, used by dependent entities.
363
- const orgMap = new Map(); // org slug → org ID
364
- const appMap = new Map(); // "orgSlug:appSlug" → app ID
365
- const roleMap = new Map();
366
- const permissionMap = new Map();
367
- const claimMap = new Map();
368
- // A dry-run cannot persist newly declared parents, but dependent validation still needs stable
369
- // transaction-local identities. UUID-shaped deterministic placeholders allow the normal
370
- // parameterized lookup paths to run without ever matching durable rows.
371
- if (mode === 'dry-run') {
372
- for (const organization of manifest.organizations) {
373
- orgMap.set(organization.slug, plannedEntityId('organization', organization.slug));
374
- }
375
- for (const application of manifest.applications) {
376
- appMap.set(`${application.organization_slug}:${application.slug}`, plannedEntityId('application', `${application.organization_slug}:${application.slug}`));
377
- }
378
- for (const role of manifest.roles) {
379
- roleMap.set(naturalKey(role.organization_slug, role.application_slug, role.slug), plannedEntityId('role', naturalKey(role.organization_slug, role.application_slug, role.slug)));
380
- }
381
- for (const permission of manifest.permissions) {
382
- permissionMap.set(naturalKey(permission.organization_slug, permission.application_slug, permission.slug), plannedEntityId('permission', naturalKey(permission.organization_slug, permission.application_slug, permission.slug)));
383
- }
384
- for (const claim of manifest.claim_definitions) {
385
- const claimKey = naturalKey(claim.organization_slug, claim.application_slug, claim.slug);
386
- claimMap.set(claimKey, {
387
- id: plannedEntityId('claim_definition', claimKey),
388
- claimType: claim.claim_type,
389
- });
390
- }
391
- }
392
- // Organizations have no manifest dependencies.
393
- for (const org of manifest.organizations ?? []) {
394
- await processOrganization(client, org, mode, result, orgMap);
395
- }
396
- // Applications depend on organizations.
397
- for (const app of manifest.applications ?? []) {
398
- await processApplication(client, app, mode, result, appMap);
399
- }
400
- // Clients depend on organizations and applications.
401
- for (const clientDef of manifest.clients ?? []) {
402
- await processClient(client, clientDef, mode, result, orgMap, appMap);
403
- }
404
- // Roles depend on applications.
405
- for (const role of manifest.roles ?? []) {
406
- await processRole(client, role, mode, result, orgMap, appMap, roleMap);
407
- }
408
- // Permissions depend on applications.
409
- for (const perm of manifest.permissions ?? []) {
410
- await processPermission(client, perm, mode, result, orgMap, appMap, permissionMap);
411
- }
412
- // Claim definitions depend on applications.
413
- for (const claim of manifest.claim_definitions ?? []) {
414
- await processClaimDefinition(client, claim, mode, result, orgMap, appMap, claimMap);
415
- }
416
- // Role-permission mappings depend on roles and permissions.
417
- for (const mapping of manifest.role_permission_mappings ?? []) {
418
- await processRolePermissionMapping(client, mapping, mode, result, orgMap, appMap, roleMap, permissionMap);
419
- }
420
- // Application modules depend on applications.
421
- for (const mod of manifest.application_modules ?? []) {
422
- await processApplicationModule(client, mod, mode, result, orgMap, appMap);
423
- }
424
- // Users depend on organizations.
425
- const userMap = new Map(); // "orgSlug:email" → user ID
426
- if (mode === 'dry-run') {
427
- for (const user of manifest.users) {
428
- userMap.set(`${user.organization_slug}:${user.email}`, plannedEntityId('user', `${user.organization_slug}:${user.email.toLowerCase()}`));
429
- }
430
- }
431
- for (const user of manifest.users ?? []) {
432
- await processUser(client, user, mode, result, orgMap, userMap);
433
- }
434
- // User-role assignments depend on users and roles.
435
- for (const assignment of manifest.user_role_assignments ?? []) {
436
- await processUserRoleAssignment(client, assignment, mode, result, orgMap, appMap, userMap, roleMap);
437
- }
438
- // User claim values depend on users and claim definitions.
439
- for (const claimValue of manifest.user_claim_values ?? []) {
440
- await processUserClaimValue(client, claimValue, mode, result, orgMap, appMap, userMap, claimMap);
441
- }
442
- // System configuration overrides run after entity planning.
443
- for (const [key, value] of Object.entries(manifest.config ?? {})) {
444
- await processConfigOverride(client, key, value, mode, result);
445
- }
446
- if (result.errors.length > 0) {
447
- throw new ImportOperationError('import_plan_rejected', 409);
448
- }
449
- // Dry-run rolls back the snapshot transaction even though every processor also avoids writes.
450
- // The rollback is a defense in depth boundary which prevents a later processor regression from
451
- // turning a preview into a mutation.
452
- if (mode === 'dry-run') {
453
- await client.query('ROLLBACK');
454
- }
455
- else {
456
- // Write audit log entry for the import
457
- if (actorId) {
458
- await writeAuditLogInTransaction(client, {
459
- actorId,
460
- eventType: 'admin.import',
461
- eventCategory: 'admin',
462
- metadata: {
463
- mode,
464
- manifestVersion: manifest.version,
465
- manifestDigest: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'),
466
- created: result.created.length,
467
- updated: result.updated.length,
468
- skipped: result.skipped.length,
469
- },
470
- });
471
- }
472
- await client.query('COMMIT');
473
- }
474
- const { errors: _errors, ...successfulResult } = result;
475
- return successfulResult;
476
- }
477
- catch (err) {
478
- try {
479
- await client.query('ROLLBACK');
480
- }
481
- catch {
482
- // The original failure remains authoritative. Connection cleanup happens in finally.
483
- }
484
- const failure = err instanceof ImportOperationError
485
- ? err
486
- : new ImportOperationError('import_execution_failed', 503);
487
- logger.error({ code: failure.code, correlationId: failure.correlationId, mode }, 'Import transaction failed');
488
- throw failure;
489
- }
490
- finally {
491
- await releaseImportClient(client, acquiredClientLocks);
492
- }
493
- }
494
- /**
495
- * Release session locks without revising the transaction's authoritative outcome.
496
- *
497
- * An unlock failure destroys the pooled connection, which makes PostgreSQL release every
498
- * session-scoped lock. Cleanup is deliberately non-throwing because commit may already have made
499
- * a one-time credential result durable and authoritative.
500
- */
501
- async function releaseImportClient(client, acquiredLocks) {
502
- try {
503
- for (const lockKey of [...acquiredLocks].reverse()) {
504
- await client.query('SELECT pg_advisory_unlock(hashtextextended($1, 0))', [lockKey]);
505
- }
506
- }
507
- catch (error) {
508
- try {
509
- client.release(error instanceof Error ? error : new Error('Import lock cleanup failed'));
510
- }
511
- catch {
512
- // Pool cleanup must never replace the committed result or the original transaction failure.
513
- }
514
- return;
515
- }
516
- try {
517
- client.release();
518
- }
519
- catch {
520
- // Pool cleanup must never replace the committed result or the original transaction failure.
521
- }
522
- }
523
- /**
524
- * Require every tenant-qualified manifest entry to resolve to one requested organization.
525
- * Global imports omit the boundary and retain the established super-administrator behavior.
526
- */
527
- async function requireImportScope(client, manifest, organizationId) {
528
- if (organizationId === undefined)
529
- return;
530
- const organization = await client.query('SELECT slug FROM organizations WHERE id = $1', [organizationId]);
531
- const expectedSlug = organization.rows[0]?.slug;
532
- if (!expectedSlug || Object.keys(manifest.config ?? {}).length > 0) {
533
- throw new ImportOperationError('import_plan_rejected', 409);
534
- }
535
- const referencedSlugs = [
536
- ...manifest.organizations.map((item) => item.slug),
537
- ...manifest.applications.map((item) => item.organization_slug),
538
- ...manifest.clients.map((item) => item.organization_slug),
539
- ...manifest.roles.map((item) => item.organization_slug),
540
- ...manifest.permissions.map((item) => item.organization_slug),
541
- ...manifest.claim_definitions.map((item) => item.organization_slug),
542
- ...manifest.role_permission_mappings.map((item) => item.organization_slug),
543
- ...manifest.users.map((item) => item.organization_slug),
544
- ...manifest.application_modules.map((item) => item.organization_slug),
545
- ...manifest.user_role_assignments.map((item) => item.organization_slug),
546
- ...manifest.user_claim_values.map((item) => item.organization_slug),
547
- ];
548
- if (referencedSlugs.some((slug) => slug !== expectedSlug)) {
549
- throw new ImportOperationError('import_plan_rejected', 409);
550
- }
551
- const declaredApplications = new Set(manifest.applications
552
- .filter((application) => application.organization_slug === expectedSlug)
553
- .map((application) => application.slug));
554
- for (const applicationSlug of declaredApplications) {
555
- const existing = await client.query(`SELECT 1 FROM applications a
556
- JOIN clients c ON c.application_id = a.id
557
- WHERE a.slug = $1 AND c.organization_id = $2
558
- LIMIT 1`, [applicationSlug, organizationId]);
559
- const globallyExisting = await client.query('SELECT 1 FROM applications WHERE slug = $1', [
560
- applicationSlug,
561
- ]);
562
- if (globallyExisting.rowCount === 1 && existing.rowCount !== 1) {
563
- throw new ImportOperationError('import_plan_rejected', 409);
564
- }
565
- }
566
- const referencedApplications = new Set([
567
- ...manifest.clients.map((item) => item.application_slug),
568
- ...manifest.roles.map((item) => item.application_slug),
569
- ...manifest.permissions.map((item) => item.application_slug),
570
- ...manifest.claim_definitions.map((item) => item.application_slug),
571
- ...manifest.role_permission_mappings.map((item) => item.application_slug),
572
- ...manifest.application_modules.map((item) => item.application_slug),
573
- ...manifest.user_role_assignments.map((item) => item.application_slug),
574
- ...manifest.user_claim_values.map((item) => item.application_slug),
575
- ]);
576
- for (const applicationSlug of referencedApplications) {
577
- if (declaredApplications.has(applicationSlug))
578
- continue;
579
- const relationship = await client.query(`SELECT 1 FROM applications a
580
- JOIN clients c ON c.application_id = a.id
581
- WHERE a.slug = $1 AND c.organization_id = $2
582
- LIMIT 1`, [applicationSlug, organizationId]);
583
- if (relationship.rowCount !== 1) {
584
- throw new ImportOperationError('import_plan_rejected', 409);
585
- }
586
- }
587
- }
588
- // ============================================================================
589
- // Entity processors
590
- // ============================================================================
591
- /**
592
- * Process a single organization from the manifest.
593
- * Looks up by slug; creates, updates, or skips based on mode.
594
- */
595
- async function processOrganization(client, org, mode, result, orgMap) {
596
- try {
597
- const { rows: existing } = await client.query(`SELECT id, name, default_locale, default_login_methods, two_factor_policy,
598
- branding_primary_color, branding_company_name,
599
- branding_custom_css, branding_logo_url, branding_favicon_url
600
- FROM organizations WHERE slug = $1`, [org.slug]);
601
- if (existing.length > 0) {
602
- orgMap.set(org.slug, existing[0].id);
603
- if (mode !== 'merge' &&
604
- ((org.default_login_methods !== undefined &&
605
- JSON.stringify(existing[0].default_login_methods) !==
606
- JSON.stringify(org.default_login_methods)) ||
607
- (org.two_factor_policy !== undefined &&
608
- existing[0].two_factor_policy !== org.two_factor_policy))) {
609
- throw new ImportOperationError('import_plan_rejected', 409);
610
- }
611
- const changedFields = [
612
- existing[0].name !== org.name ? 'name' : null,
613
- existing[0].default_locale !== (org.default_locale ?? null) ? 'default_locale' : null,
614
- existing[0].branding_primary_color !== (org.branding_primary_color ?? null)
615
- ? 'branding_primary_color'
616
- : null,
617
- existing[0].branding_company_name !== (org.branding_company_name ?? null)
618
- ? 'branding_company_name'
619
- : null,
620
- existing[0].branding_custom_css !== (org.branding_custom_css ?? null)
621
- ? 'branding_custom_css'
622
- : null,
623
- existing[0].branding_logo_url !== (org.branding_logo_url ?? null)
624
- ? 'branding_logo_url'
625
- : null,
626
- existing[0].branding_favicon_url !== (org.branding_favicon_url ?? null)
627
- ? 'branding_favicon_url'
628
- : null,
629
- ].filter((field) => field !== null);
630
- if (mode === 'merge' || changedFields.length === 0) {
631
- result.skipped.push({
632
- type: 'organization',
633
- slug: org.slug,
634
- reason: mode === 'merge' ? 'Already exists (merge mode)' : 'No mutable changes',
635
- });
636
- return;
637
- }
638
- if (mode === 'dry-run') {
639
- result.updated.push({
640
- type: 'organization',
641
- slug: org.slug,
642
- name: org.name,
643
- changes: changedFields,
644
- });
645
- return;
646
- }
647
- await client.query(`UPDATE organizations
648
- SET name = $1, default_locale = $2, branding_primary_color = $3,
649
- branding_company_name = $4, branding_custom_css = $5, branding_logo_url = $6,
650
- branding_favicon_url = $7, updated_at = NOW()
651
- WHERE slug = $8`, [
652
- org.name,
653
- org.default_locale ?? null,
654
- org.branding_primary_color ?? null,
655
- org.branding_company_name ?? null,
656
- org.branding_custom_css ?? null,
657
- org.branding_logo_url ?? null,
658
- org.branding_favicon_url ?? null,
659
- org.slug,
660
- ]);
661
- result.updated.push({
662
- type: 'organization',
663
- slug: org.slug,
664
- name: org.name,
665
- changes: changedFields,
666
- });
667
- }
668
- else {
669
- // Create new organization
670
- if (mode === 'dry-run') {
671
- result.created.push({ type: 'organization', slug: org.slug, name: org.name });
672
- return;
673
- }
674
- // Build INSERT dynamically: omit default_login_methods when not specified
675
- // so the DB NOT NULL DEFAULT '{password,magic_link}' applies
676
- const columns = ['name', 'slug', 'default_locale', 'status'];
677
- const values = [
678
- org.name,
679
- org.slug,
680
- org.default_locale ?? null,
681
- 'active',
682
- ];
683
- if (org.default_login_methods) {
684
- columns.splice(3, 0, 'default_login_methods');
685
- values.splice(3, 0, org.default_login_methods);
686
- }
687
- // Creation may apply the admitted branding and two-factor policy fields.
688
- if (org.two_factor_policy) {
689
- columns.push('two_factor_policy');
690
- values.push(org.two_factor_policy);
691
- }
692
- if (org.branding_primary_color) {
693
- columns.push('branding_primary_color');
694
- values.push(org.branding_primary_color);
695
- }
696
- if (org.branding_company_name) {
697
- columns.push('branding_company_name');
698
- values.push(org.branding_company_name);
699
- }
700
- if (org.branding_custom_css) {
701
- columns.push('branding_custom_css');
702
- values.push(org.branding_custom_css);
703
- }
704
- if (org.branding_logo_url) {
705
- columns.push('branding_logo_url');
706
- values.push(org.branding_logo_url);
707
- }
708
- if (org.branding_favicon_url) {
709
- columns.push('branding_favicon_url');
710
- values.push(org.branding_favicon_url);
711
- }
712
- const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
713
- const { rows } = await client.query(`INSERT INTO organizations (${columns.join(', ')}) VALUES (${placeholders}) RETURNING id`, values);
714
- orgMap.set(org.slug, rows[0].id);
715
- result.created.push({ type: 'organization', slug: org.slug, name: org.name });
716
- }
717
- }
718
- catch (err) {
719
- throw normalizeImportFailure(err);
720
- }
721
- }
722
- /**
723
- * Process a single application from the manifest.
724
- * Applications are global (no org FK); matched by slug.
725
- */
726
- async function processApplication(client, app, mode, result, appMap) {
727
- try {
728
- const { rows: existing } = await client.query('SELECT id, name, description FROM applications WHERE slug = $1', [app.slug]);
729
- const mapKey = `${app.organization_slug}:${app.slug}`;
730
- if (existing.length > 0) {
731
- appMap.set(mapKey, existing[0].id);
732
- const changedFields = [
733
- existing[0].name !== app.name ? 'name' : null,
734
- existing[0].description !== (app.description ?? null) ? 'description' : null,
735
- ].filter((field) => field !== null);
736
- if (mode === 'merge' || changedFields.length === 0) {
737
- result.skipped.push({
738
- type: 'application',
739
- slug: app.slug,
740
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
741
- });
742
- return;
743
- }
744
- if (mode === 'dry-run') {
745
- result.updated.push({
746
- type: 'application',
747
- slug: app.slug,
748
- name: app.name,
749
- changes: changedFields,
750
- });
751
- return;
752
- }
753
- await client.query(`UPDATE applications SET name = $1, description = $2, updated_at = NOW()
754
- WHERE id = $3`, [app.name, app.description ?? null, existing[0].id]);
755
- result.updated.push({
756
- type: 'application',
757
- slug: app.slug,
758
- name: app.name,
759
- changes: changedFields,
760
- });
761
- }
762
- else {
763
- if (mode === 'dry-run') {
764
- result.created.push({ type: 'application', slug: app.slug, name: app.name });
765
- return;
766
- }
767
- const { rows } = await client.query(`INSERT INTO applications (name, slug, description, status)
768
- VALUES ($1, $2, $3, 'active') RETURNING id`, [app.name, app.slug, app.description ?? null]);
769
- appMap.set(mapKey, rows[0].id);
770
- result.created.push({ type: 'application', slug: app.slug, name: app.name });
771
- }
772
- }
773
- catch (err) {
774
- throw normalizeImportFailure(err);
775
- }
776
- }
777
- /**
778
- * Process a single client from the manifest.
779
- * Clients are matched by client_name within the application scope.
780
- */
781
- async function processClient(client, clientDef, mode, result, orgMap, appMap) {
782
- try {
783
- const orgId = await resolveOrgId(client, clientDef.organization_slug, orgMap);
784
- if (!orgId) {
785
- result.errors.push({
786
- type: 'client',
787
- slug: clientDef.client_name,
788
- error: `Parent organization '${clientDef.organization_slug}' not found`,
789
- });
790
- return;
791
- }
792
- const appId = await resolveAppId(client, clientDef.organization_slug, clientDef.application_slug, orgMap, appMap);
793
- if (!appId) {
794
- result.errors.push({
795
- type: 'client',
796
- slug: clientDef.client_name,
797
- error: `Parent application '${clientDef.application_slug}' not found`,
798
- });
799
- return;
800
- }
801
- const { rows: existing } = await client.query(`SELECT id, client_id, client_type, application_type, grant_types, redirect_uris,
802
- response_types, scope, login_methods, token_endpoint_auth_method,
803
- post_logout_redirect_uris, allowed_origins, require_pkce
804
- FROM clients
805
- WHERE client_name = $1 AND application_id = $2 AND organization_id = $3`, [clientDef.client_name, appId, orgId]);
806
- if (existing.length > 1) {
807
- throw new ImportOperationError('import_plan_rejected', 409);
808
- }
809
- if (existing.length > 0) {
810
- const expectedAuthMethod = clientDef.token_endpoint_auth_method ??
811
- (clientDef.client_type === 'public' ? 'none' : 'client_secret_post');
812
- const immutableMatches = existing[0].client_type === clientDef.client_type &&
813
- existing[0].application_type === (clientDef.application_type ?? 'web') &&
814
- JSON.stringify(existing[0].grant_types) ===
815
- JSON.stringify(clientDef.grant_types ?? ['authorization_code']) &&
816
- JSON.stringify(existing[0].redirect_uris) ===
817
- JSON.stringify(clientDef.redirect_uris ?? []) &&
818
- JSON.stringify(existing[0].response_types) ===
819
- JSON.stringify(clientDef.response_types ?? ['code']) &&
820
- existing[0].scope === (clientDef.scope ?? 'openid') &&
821
- JSON.stringify(existing[0].login_methods) ===
822
- JSON.stringify(clientDef.login_methods ?? null) &&
823
- existing[0].token_endpoint_auth_method === expectedAuthMethod &&
824
- JSON.stringify(existing[0].post_logout_redirect_uris) ===
825
- JSON.stringify(clientDef.post_logout_redirect_uris ?? []) &&
826
- JSON.stringify(existing[0].allowed_origins) ===
827
- JSON.stringify(clientDef.allowed_origins ?? []) &&
828
- existing[0].require_pkce === (clientDef.require_pkce ?? true);
829
- if (mode !== 'merge' && !immutableMatches) {
830
- throw new ImportOperationError('import_plan_rejected', 409);
831
- }
832
- result.skipped.push({
833
- type: 'client',
834
- slug: clientDef.client_name,
835
- reason: 'Already exists; immutable client authority preserved',
836
- });
837
- }
838
- else {
839
- if (mode === 'dry-run') {
840
- result.created.push({
841
- type: 'client',
842
- slug: clientDef.client_name,
843
- name: clientDef.client_name,
844
- credentialWillBeGenerated: clientDef.client_type === 'confidential',
845
- });
846
- result.credentials.push({
847
- clientName: clientDef.client_name,
848
- clientType: clientDef.client_type,
849
- credentialWillBeGenerated: clientDef.client_type === 'confidential',
850
- });
851
- return;
852
- }
853
- // Generate base64url client_id using the standard crypto utility
854
- const clientId = generateClientId();
855
- // Derive token_endpoint_auth_method from client_type if not explicitly set
856
- const authMethod = clientDef.token_endpoint_auth_method ??
857
- (clientDef.client_type === 'public' ? 'none' : 'client_secret_post');
858
- const { rows: insertedRows } = await client.query(`INSERT INTO clients (client_id, client_name, organization_id, application_id, client_type,
859
- application_type, grant_types, redirect_uris, response_types, scope, login_methods,
860
- token_endpoint_auth_method, post_logout_redirect_uris, allowed_origins, require_pkce, status)
861
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'active') RETURNING id`, [
862
- clientId,
863
- clientDef.client_name,
864
- orgId,
865
- appId,
866
- clientDef.client_type,
867
- clientDef.application_type ?? 'web',
868
- clientDef.grant_types ?? ['authorization_code'],
869
- clientDef.redirect_uris ?? [],
870
- clientDef.response_types ?? ['code'],
871
- clientDef.scope ?? 'openid',
872
- clientDef.login_methods ?? null,
873
- authMethod,
874
- clientDef.post_logout_redirect_uris ?? [],
875
- clientDef.allowed_origins ?? [],
876
- clientDef.require_pkce ?? true,
877
- ]);
878
- const clientDbId = insertedRows[0].id;
879
- // Generate secret for confidential clients within the same transaction
880
- let secretPlaintext;
881
- let secretId;
882
- if (clientDef.client_type === 'confidential') {
883
- const plaintext = generateSecret();
884
- const secretHash = await hashSecret(plaintext);
885
- const secretSha256 = sha256Secret(plaintext);
886
- const secretResult = await client.query(`INSERT INTO client_secrets (client_id, secret_hash, secret_sha256, label, expires_at)
887
- VALUES ($1, $2, $3, $4, $5) RETURNING id`, [
888
- clientDbId,
889
- secretHash,
890
- secretSha256,
891
- clientDef.secret_label ?? null,
892
- clientDef.secret_expires_at ?? null,
893
- ]);
894
- secretPlaintext = plaintext;
895
- secretId = secretResult.rows[0].id;
896
- }
897
- result.created.push({
898
- type: 'client',
899
- slug: clientDef.client_name,
900
- name: clientDef.client_name,
901
- });
902
- // Report credentials — secret only present for newly created confidential clients
903
- result.credentials.push({
904
- clientName: clientDef.client_name,
905
- clientId,
906
- clientType: clientDef.client_type,
907
- ...(secretPlaintext ? { secretPlaintext } : {}),
908
- ...(secretId ? { secretId } : {}),
909
- ...(clientDef.secret_label ? { secretLabel: clientDef.secret_label } : {}),
910
- ...(clientDef.secret_expires_at ? { secretExpiresAt: clientDef.secret_expires_at } : {}),
911
- });
912
- }
913
- }
914
- catch (err) {
915
- throw normalizeImportFailure(err);
916
- }
917
- }
918
- /**
919
- * Process a single role from the manifest.
920
- */
921
- async function processRole(client, role, mode, result, orgMap, appMap, roleMap) {
922
- try {
923
- const appId = await resolveAppId(client, role.organization_slug, role.application_slug, orgMap, appMap);
924
- if (!appId) {
925
- result.errors.push({
926
- type: 'role',
927
- slug: role.slug,
928
- error: `Parent application '${role.application_slug}' not found`,
929
- });
930
- return;
931
- }
932
- const { rows: existing } = await client.query('SELECT id, name, description FROM roles WHERE slug = $1 AND application_id = $2', [role.slug, appId]);
933
- const roleKey = naturalKey(role.organization_slug, role.application_slug, role.slug);
934
- if (existing.length > 0) {
935
- roleMap.set(roleKey, existing[0].id);
936
- const changedFields = [
937
- existing[0].name !== role.name ? 'name' : null,
938
- existing[0].description !== (role.description ?? null) ? 'description' : null,
939
- ].filter((field) => field !== null);
940
- if (mode === 'merge' || changedFields.length === 0) {
941
- result.skipped.push({
942
- type: 'role',
943
- slug: role.slug,
944
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
945
- });
946
- return;
947
- }
948
- if (mode === 'dry-run') {
949
- result.updated.push({
950
- type: 'role',
951
- slug: role.slug,
952
- name: role.name,
953
- changes: changedFields,
954
- });
955
- return;
956
- }
957
- await client.query(`UPDATE roles SET name = $1, description = $2, updated_at = NOW() WHERE id = $3`, [role.name, role.description ?? null, existing[0].id]);
958
- result.updated.push({
959
- type: 'role',
960
- slug: role.slug,
961
- name: role.name,
962
- changes: changedFields,
963
- });
964
- }
965
- else {
966
- if (mode === 'dry-run') {
967
- result.created.push({ type: 'role', slug: role.slug, name: role.name });
968
- return;
969
- }
970
- const { rows } = await client.query(`INSERT INTO roles (name, slug, application_id, description)
971
- VALUES ($1, $2, $3, $4) RETURNING id`, [role.name, role.slug, appId, role.description ?? null]);
972
- roleMap.set(roleKey, rows[0].id);
973
- result.created.push({ type: 'role', slug: role.slug, name: role.name });
974
- }
975
- }
976
- catch (err) {
977
- throw normalizeImportFailure(err);
978
- }
979
- }
980
- /**
981
- * Process a single permission from the manifest.
982
- */
983
- async function processPermission(client, perm, mode, result, orgMap, appMap, permissionMap) {
984
- try {
985
- const appId = await resolveAppId(client, perm.organization_slug, perm.application_slug, orgMap, appMap);
986
- if (!appId) {
987
- result.errors.push({
988
- type: 'permission',
989
- slug: perm.slug,
990
- error: `Parent application '${perm.application_slug}' not found`,
991
- });
992
- return;
993
- }
994
- const { rows: existing } = await client.query('SELECT id, name, description FROM permissions WHERE slug = $1 AND application_id = $2', [perm.slug, appId]);
995
- const permissionKey = naturalKey(perm.organization_slug, perm.application_slug, perm.slug);
996
- if (existing.length > 0) {
997
- permissionMap.set(permissionKey, existing[0].id);
998
- const changedFields = [
999
- existing[0].name !== perm.name ? 'name' : null,
1000
- existing[0].description !== (perm.description ?? null) ? 'description' : null,
1001
- ].filter((field) => field !== null);
1002
- if (mode === 'merge' || changedFields.length === 0) {
1003
- result.skipped.push({
1004
- type: 'permission',
1005
- slug: perm.slug,
1006
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
1007
- });
1008
- return;
1009
- }
1010
- if (mode === 'dry-run') {
1011
- result.updated.push({
1012
- type: 'permission',
1013
- slug: perm.slug,
1014
- name: perm.name,
1015
- changes: changedFields,
1016
- });
1017
- return;
1018
- }
1019
- await client.query(`UPDATE permissions SET name = $1, description = $2 WHERE id = $3`, [
1020
- perm.name,
1021
- perm.description ?? null,
1022
- existing[0].id,
1023
- ]);
1024
- result.updated.push({
1025
- type: 'permission',
1026
- slug: perm.slug,
1027
- name: perm.name,
1028
- changes: changedFields,
1029
- });
1030
- }
1031
- else {
1032
- if (mode === 'dry-run') {
1033
- result.created.push({ type: 'permission', slug: perm.slug, name: perm.name });
1034
- return;
1035
- }
1036
- const { rows } = await client.query(`INSERT INTO permissions (name, slug, application_id, description)
1037
- VALUES ($1, $2, $3, $4) RETURNING id`, [perm.name, perm.slug, appId, perm.description ?? null]);
1038
- permissionMap.set(permissionKey, rows[0].id);
1039
- result.created.push({ type: 'permission', slug: perm.slug, name: perm.name });
1040
- }
1041
- }
1042
- catch (err) {
1043
- throw normalizeImportFailure(err);
1044
- }
1045
- }
1046
- /**
1047
- * Process a single claim definition from the manifest.
1048
- */
1049
- async function processClaimDefinition(client, claim, mode, result, orgMap, appMap, claimMap) {
1050
- try {
1051
- const appId = await resolveAppId(client, claim.organization_slug, claim.application_slug, orgMap, appMap);
1052
- if (!appId) {
1053
- result.errors.push({
1054
- type: 'claim_definition',
1055
- slug: claim.slug,
1056
- error: `Parent application '${claim.application_slug}' not found`,
1057
- });
1058
- return;
1059
- }
1060
- // Table is custom_claim_definitions with claim_name column (unique per app)
1061
- // The manifest slug maps to claim_name in the DB
1062
- const { rows: existing } = await client.query(`SELECT id, description, claim_type FROM custom_claim_definitions
1063
- WHERE claim_name = $1 AND application_id = $2`, [claim.slug, appId]);
1064
- const claimKey = naturalKey(claim.organization_slug, claim.application_slug, claim.slug);
1065
- if (existing.length > 0) {
1066
- claimMap.set(claimKey, { id: existing[0].id, claimType: existing[0].claim_type });
1067
- if (mode !== 'merge' && existing[0].claim_type !== claim.claim_type) {
1068
- throw new ImportOperationError('import_plan_rejected', 409);
1069
- }
1070
- const changedFields = existing[0].description === (claim.description ?? null) ? [] : ['description'];
1071
- if (mode === 'merge' || changedFields.length === 0) {
1072
- result.skipped.push({
1073
- type: 'claim_definition',
1074
- slug: claim.slug,
1075
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
1076
- });
1077
- return;
1078
- }
1079
- if (mode === 'dry-run') {
1080
- result.updated.push({
1081
- type: 'claim_definition',
1082
- slug: claim.slug,
1083
- name: claim.name,
1084
- changes: changedFields,
1085
- });
1086
- return;
1087
- }
1088
- await client.query(`UPDATE custom_claim_definitions SET description = $1, updated_at = NOW() WHERE id = $2`, [claim.description ?? null, existing[0].id]);
1089
- result.updated.push({
1090
- type: 'claim_definition',
1091
- slug: claim.slug,
1092
- name: claim.name,
1093
- changes: changedFields,
1094
- });
1095
- }
1096
- else {
1097
- if (mode === 'dry-run') {
1098
- result.created.push({ type: 'claim_definition', slug: claim.slug, name: claim.name });
1099
- return;
1100
- }
1101
- const { rows } = await client.query(`INSERT INTO custom_claim_definitions (claim_name, application_id, claim_type, description)
1102
- VALUES ($1, $2, $3, $4) RETURNING id`, [claim.slug, appId, claim.claim_type, claim.description ?? null]);
1103
- claimMap.set(claimKey, { id: rows[0].id, claimType: claim.claim_type });
1104
- result.created.push({ type: 'claim_definition', slug: claim.slug, name: claim.name });
1105
- }
1106
- }
1107
- catch (err) {
1108
- throw normalizeImportFailure(err);
1109
- }
1110
- }
1111
- // ============================================================================
1112
- // Relationship and system-configuration processors
1113
- // ============================================================================
1114
- /**
1115
- * Process a single role-permission mapping from the manifest.
1116
- *
1117
- * Resolves the role and each permission by slug within the application,
1118
- * then inserts the mapping rows. Uses ON CONFLICT DO NOTHING for
1119
- * idempotent merge-mode behavior.
1120
- *
1121
- * @param client - Database client within the transaction
1122
- * @param mapping - The role-permission mapping definition
1123
- * @param mode - Import mode (merge, overwrite, dry-run)
1124
- * @param result - Accumulator for import results
1125
- * @param _orgMap - Organization slug→ID map (unused but kept for signature consistency)
1126
- * @param appMap - Application "orgSlug:appSlug"→ID map
1127
- */
1128
- async function processRolePermissionMapping(client, mapping, mode, result, _orgMap, appMap, roleMap, permissionMap) {
1129
- try {
1130
- // Resolve application ID from the composite key
1131
- const appKey = `${mapping.organization_slug}:${mapping.application_slug}`;
1132
- const appId = await resolveAppId(client, mapping.organization_slug, mapping.application_slug, _orgMap, appMap);
1133
- if (!appId) {
1134
- result.errors.push({
1135
- type: 'role_permission_mapping',
1136
- slug: mapping.role_slug,
1137
- error: `Application not found: ${appKey}`,
1138
- });
1139
- return;
1140
- }
1141
- // Find role by slug within the application
1142
- const roleKey = naturalKey(mapping.organization_slug, mapping.application_slug, mapping.role_slug);
1143
- let roleId = roleMap.get(roleKey);
1144
- if (!roleId) {
1145
- const roleResult = await client.query('SELECT id FROM roles WHERE slug = $1 AND application_id = $2', [mapping.role_slug, appId]);
1146
- roleId = roleResult.rows[0]?.id;
1147
- if (roleId)
1148
- roleMap.set(roleKey, roleId);
1149
- }
1150
- if (!roleId) {
1151
- result.errors.push({
1152
- type: 'role_permission_mapping',
1153
- slug: mapping.role_slug,
1154
- error: `Role not found: ${mapping.role_slug}`,
1155
- });
1156
- return;
1157
- }
1158
- // Process each permission in the mapping
1159
- for (const permSlug of mapping.permission_slugs) {
1160
- const permissionKey = naturalKey(mapping.organization_slug, mapping.application_slug, permSlug);
1161
- let permId = permissionMap.get(permissionKey);
1162
- if (!permId) {
1163
- const permResult = await client.query('SELECT id FROM permissions WHERE slug = $1 AND application_id = $2', [permSlug, appId]);
1164
- permId = permResult.rows[0]?.id;
1165
- if (permId)
1166
- permissionMap.set(permissionKey, permId);
1167
- }
1168
- if (!permId) {
1169
- result.errors.push({
1170
- type: 'role_permission_mapping',
1171
- slug: `${mapping.role_slug}→${permSlug}`,
1172
- error: `Permission not found: ${permSlug}`,
1173
- });
1174
- continue;
1175
- }
1176
- if (mode === 'dry-run' &&
1177
- (roleId === plannedEntityId('role', roleKey) ||
1178
- permId === plannedEntityId('permission', permissionKey))) {
1179
- result.created.push({
1180
- type: 'role_permission_mapping',
1181
- slug: `${mapping.role_slug}→${permSlug}`,
1182
- name: `${mapping.role_slug} → ${permSlug}`,
1183
- });
1184
- continue;
1185
- }
1186
- const existingMapping = await client.query('SELECT 1 FROM role_permissions WHERE role_id = $1 AND permission_id = $2', [roleId, permId]);
1187
- if (existingMapping.rowCount !== 0) {
1188
- result.skipped.push({
1189
- type: 'role_permission_mapping',
1190
- slug: `${mapping.role_slug}→${permSlug}`,
1191
- reason: 'Relationship already exists',
1192
- });
1193
- continue;
1194
- }
1195
- if (mode === 'dry-run') {
1196
- result.created.push({
1197
- type: 'role_permission_mapping',
1198
- slug: `${mapping.role_slug}→${permSlug}`,
1199
- name: `${mapping.role_slug} → ${permSlug}`,
1200
- });
1201
- continue;
1202
- }
1203
- // Insert mapping with ON CONFLICT DO NOTHING for idempotent merge behavior
1204
- await client.query(`INSERT INTO role_permissions (role_id, permission_id)
1205
- VALUES ($1, $2)
1206
- ON CONFLICT (role_id, permission_id) DO NOTHING`, [roleId, permId]);
1207
- result.created.push({
1208
- type: 'role_permission_mapping',
1209
- slug: `${mapping.role_slug}→${permSlug}`,
1210
- name: `${mapping.role_slug} → ${permSlug}`,
1211
- });
1212
- }
1213
- }
1214
- catch (err) {
1215
- throw normalizeImportFailure(err);
1216
- }
1217
- }
1218
- /**
1219
- * Process a single system config override from the manifest.
1220
- *
1221
- * Only updates existing config keys — does not create new ones.
1222
- * This prevents typos from silently creating invalid config entries.
1223
- * Only keys seeded by migration 011 (or added by future migrations) can be set.
1224
- *
1225
- * @param client - Database client within the transaction
1226
- * @param key - Config key to update
1227
- * @param value - New value (string, number, or boolean — stored as JSONB)
1228
- * @param mode - Import mode (merge, overwrite, dry-run)
1229
- * @param result - Accumulator for import results
1230
- */
1231
- async function processConfigOverride(client, key, value, mode, result) {
1232
- try {
1233
- // Check if config key exists — we only update, never create
1234
- const existing = await client.query('SELECT id, value, value_type, is_sensitive FROM system_config WHERE key = $1', [key]);
1235
- if (existing.rowCount === 0) {
1236
- result.errors.push({
1237
- type: 'config',
1238
- slug: key,
1239
- error: `Config key not found: ${key} (only existing keys can be updated)`,
1240
- });
1241
- return;
1242
- }
1243
- if (existing.rows[0].is_sensitive === true ||
1244
- !configValueMatchesType(value, existing.rows[0].value_type)) {
1245
- throw new ImportOperationError('import_plan_rejected', 409);
1246
- }
1247
- const valueChanged = JSON.stringify(existing.rows[0].value) !== JSON.stringify(value);
1248
- if (mode === 'merge' || !valueChanged) {
1249
- result.skipped.push({
1250
- type: 'config',
1251
- slug: key,
1252
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
1253
- });
1254
- return;
1255
- }
1256
- if (mode === 'dry-run') {
1257
- result.updated.push({
1258
- type: 'config',
1259
- slug: key,
1260
- name: key,
1261
- changes: ['value'],
1262
- });
1263
- return;
1264
- }
1265
- await client.query(`UPDATE system_config SET value = $1::jsonb, updated_at = NOW() WHERE key = $2`, [JSON.stringify(value), key]);
1266
- result.updated.push({
1267
- type: 'config',
1268
- slug: key,
1269
- name: key,
1270
- changes: ['value'],
1271
- });
1272
- }
1273
- catch (err) {
1274
- throw normalizeImportFailure(err);
1275
- }
1276
- }
1277
- /** Validate a manifest value against the configuration row's persisted public type. */
1278
- function configValueMatchesType(value, valueType) {
1279
- switch (valueType) {
1280
- case 'string':
1281
- case 'duration':
1282
- return typeof value === 'string';
1283
- case 'number':
1284
- return typeof value === 'number' && Number.isFinite(value);
1285
- case 'boolean':
1286
- return typeof value === 'boolean';
1287
- case 'json':
1288
- return true;
1289
- default:
1290
- return false;
1291
- }
1292
- }
1293
- // ============================================================================
1294
- // User, module, and assignment processors
1295
- // ============================================================================
1296
- /**
1297
- * Process a single user from the manifest.
1298
- * Users are matched by email and organization. Authentication secrets are never imported.
1299
- */
1300
- async function processUser(client, user, mode, result, orgMap, userMap) {
1301
- try {
1302
- const orgId = await resolveOrgId(client, user.organization_slug, orgMap);
1303
- if (!orgId) {
1304
- result.errors.push({
1305
- type: 'user',
1306
- slug: user.email,
1307
- error: `Parent organization '${user.organization_slug}' not found`,
1308
- });
1309
- return;
1310
- }
1311
- const { rows: existing } = await client.query(`SELECT id, given_name, family_name, locale, status, email_verified FROM users
1312
- WHERE email = $1 AND organization_id = $2`, [user.email, orgId]);
1313
- const userMapKey = `${user.organization_slug}:${user.email}`;
1314
- if (existing.length > 0) {
1315
- userMap.set(userMapKey, existing[0].id);
1316
- if (mode !== 'merge' &&
1317
- ((user.status !== undefined && existing[0].status !== user.status) ||
1318
- (user.email_verified !== undefined && existing[0].email_verified !== user.email_verified))) {
1319
- throw new ImportOperationError('import_plan_rejected', 409);
1320
- }
1321
- const changedFields = [
1322
- existing[0].given_name !== (user.given_name ?? null) ? 'given_name' : null,
1323
- existing[0].family_name !== (user.family_name ?? null) ? 'family_name' : null,
1324
- existing[0].locale !== (user.locale ?? null) ? 'locale' : null,
1325
- ].filter((field) => field !== null);
1326
- if (mode === 'merge' || changedFields.length === 0) {
1327
- result.skipped.push({
1328
- type: 'user',
1329
- slug: user.email,
1330
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
1331
- });
1332
- return;
1333
- }
1334
- if (mode === 'dry-run') {
1335
- result.updated.push({
1336
- type: 'user',
1337
- slug: user.email,
1338
- name: user.email,
1339
- changes: changedFields,
1340
- });
1341
- return;
1342
- }
1343
- await client.query(`UPDATE users SET given_name = $1, family_name = $2, locale = $3,
1344
- updated_at = NOW() WHERE id = $4`, [user.given_name ?? null, user.family_name ?? null, user.locale ?? null, existing[0].id]);
1345
- result.updated.push({
1346
- type: 'user',
1347
- slug: user.email,
1348
- name: user.email,
1349
- changes: changedFields,
1350
- });
1351
- }
1352
- else {
1353
- if (mode === 'dry-run') {
1354
- result.created.push({ type: 'user', slug: user.email, name: user.email });
1355
- return;
1356
- }
1357
- const { rows } = await client.query(`INSERT INTO users (email, organization_id, given_name, family_name, locale,
1358
- email_verified, status)
1359
- VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, [
1360
- user.email,
1361
- orgId,
1362
- user.given_name ?? null,
1363
- user.family_name ?? null,
1364
- user.locale ?? null,
1365
- user.email_verified ?? false,
1366
- user.status ?? 'active',
1367
- ]);
1368
- userMap.set(userMapKey, rows[0].id);
1369
- result.created.push({ type: 'user', slug: user.email, name: user.email });
1370
- }
1371
- }
1372
- catch (err) {
1373
- throw normalizeImportFailure(err);
1374
- }
1375
- }
1376
- /**
1377
- * Process a single application module from the manifest.
1378
- * Matched by slug + application.
1379
- */
1380
- async function processApplicationModule(client, mod, mode, result, orgMap, appMap) {
1381
- try {
1382
- const appId = await resolveAppId(client, mod.organization_slug, mod.application_slug, orgMap, appMap);
1383
- if (!appId) {
1384
- result.errors.push({
1385
- type: 'application_module',
1386
- slug: mod.slug,
1387
- error: `Parent application '${mod.application_slug}' not found`,
1388
- });
1389
- return;
1390
- }
1391
- const { rows: existing } = await client.query(`SELECT id, name, description, status FROM application_modules
1392
- WHERE slug = $1 AND application_id = $2`, [mod.slug, appId]);
1393
- if (existing.length > 0) {
1394
- if (mode !== 'merge' && mod.status !== undefined && existing[0].status !== mod.status) {
1395
- throw new ImportOperationError('import_plan_rejected', 409);
1396
- }
1397
- const changedFields = [
1398
- existing[0].name !== mod.name ? 'name' : null,
1399
- existing[0].description !== (mod.description ?? null) ? 'description' : null,
1400
- ].filter((field) => field !== null);
1401
- if (mode === 'merge' || changedFields.length === 0) {
1402
- result.skipped.push({
1403
- type: 'application_module',
1404
- slug: mod.slug,
1405
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
1406
- });
1407
- return;
1408
- }
1409
- if (mode === 'dry-run') {
1410
- result.updated.push({
1411
- type: 'application_module',
1412
- slug: mod.slug,
1413
- name: mod.name,
1414
- changes: changedFields,
1415
- });
1416
- return;
1417
- }
1418
- await client.query(`UPDATE application_modules SET name = $1, description = $2, updated_at = NOW()
1419
- WHERE id = $3`, [mod.name, mod.description ?? null, existing[0].id]);
1420
- result.updated.push({
1421
- type: 'application_module',
1422
- slug: mod.slug,
1423
- name: mod.name,
1424
- changes: changedFields,
1425
- });
1426
- }
1427
- else {
1428
- if (mode === 'dry-run') {
1429
- result.created.push({ type: 'application_module', slug: mod.slug, name: mod.name });
1430
- return;
1431
- }
1432
- await client.query(`INSERT INTO application_modules (name, slug, application_id, description, status)
1433
- VALUES ($1, $2, $3, $4, $5)`, [mod.name, mod.slug, appId, mod.description ?? null, mod.status ?? 'active']);
1434
- result.created.push({ type: 'application_module', slug: mod.slug, name: mod.name });
1435
- }
1436
- }
1437
- catch (err) {
1438
- throw normalizeImportFailure(err);
1439
- }
1440
- }
1441
- /**
1442
- * Process a user-role assignment. Resolves user + role by slug/email.
1443
- * Uses ON CONFLICT DO NOTHING for idempotent behavior.
1444
- */
1445
- async function processUserRoleAssignment(client, assignment, mode, result, orgMap, appMap, userMap, roleMap) {
1446
- try {
1447
- const userMapKey = `${assignment.organization_slug}:${assignment.email}`;
1448
- let userId = userMap.get(userMapKey);
1449
- if (!userId) {
1450
- const orgId = await resolveOrgId(client, assignment.organization_slug, orgMap);
1451
- if (orgId) {
1452
- const { rows } = await client.query('SELECT id FROM users WHERE email = $1 AND organization_id = $2', [assignment.email, orgId]);
1453
- userId = rows[0]?.id;
1454
- if (userId)
1455
- userMap.set(userMapKey, userId);
1456
- }
1457
- }
1458
- if (!userId) {
1459
- result.errors.push({
1460
- type: 'user_role_assignment',
1461
- slug: `${assignment.email}→${assignment.role_slug}`,
1462
- error: `User not found: ${assignment.email} in org ${assignment.organization_slug}`,
1463
- });
1464
- return;
1465
- }
1466
- const appId = await resolveAppId(client, assignment.organization_slug, assignment.application_slug, orgMap, appMap);
1467
- if (!appId) {
1468
- result.errors.push({
1469
- type: 'user_role_assignment',
1470
- slug: `${assignment.email}→${assignment.role_slug}`,
1471
- error: `Application not found: ${assignment.application_slug}`,
1472
- });
1473
- return;
1474
- }
1475
- const roleKey = naturalKey(assignment.organization_slug, assignment.application_slug, assignment.role_slug);
1476
- let roleId = roleMap.get(roleKey);
1477
- if (!roleId) {
1478
- const { rows } = await client.query('SELECT id FROM roles WHERE slug = $1 AND application_id = $2', [assignment.role_slug, appId]);
1479
- roleId = rows[0]?.id;
1480
- if (roleId)
1481
- roleMap.set(roleKey, roleId);
1482
- }
1483
- if (!roleId) {
1484
- result.errors.push({
1485
- type: 'user_role_assignment',
1486
- slug: `${assignment.email}→${assignment.role_slug}`,
1487
- error: `Role not found: ${assignment.role_slug}`,
1488
- });
1489
- return;
1490
- }
1491
- if (mode === 'dry-run' &&
1492
- (userId ===
1493
- plannedEntityId('user', `${assignment.organization_slug}:${assignment.email.toLowerCase()}`) ||
1494
- roleId === plannedEntityId('role', roleKey))) {
1495
- result.created.push({
1496
- type: 'user_role_assignment',
1497
- slug: `${assignment.email}→${assignment.role_slug}`,
1498
- name: `${assignment.email} → ${assignment.role_slug}`,
1499
- });
1500
- return;
1501
- }
1502
- const existingAssignment = await client.query('SELECT 1 FROM user_roles WHERE user_id = $1 AND role_id = $2', [userId, roleId]);
1503
- if (existingAssignment.rowCount !== 0) {
1504
- result.skipped.push({
1505
- type: 'user_role_assignment',
1506
- slug: `${assignment.email}→${assignment.role_slug}`,
1507
- reason: 'Relationship already exists',
1508
- });
1509
- return;
1510
- }
1511
- if (mode === 'dry-run') {
1512
- result.created.push({
1513
- type: 'user_role_assignment',
1514
- slug: `${assignment.email}→${assignment.role_slug}`,
1515
- name: `${assignment.email} → ${assignment.role_slug}`,
1516
- });
1517
- return;
1518
- }
1519
- await client.query(`INSERT INTO user_roles (user_id, role_id, assigned_by)
1520
- VALUES ($1, $2, $3)
1521
- ON CONFLICT (user_id, role_id) DO NOTHING`, [userId, roleId, null]);
1522
- result.created.push({
1523
- type: 'user_role_assignment',
1524
- slug: `${assignment.email}→${assignment.role_slug}`,
1525
- name: `${assignment.email} → ${assignment.role_slug}`,
1526
- });
1527
- }
1528
- catch (err) {
1529
- throw normalizeImportFailure(err);
1530
- }
1531
- }
1532
- /**
1533
- * Process a user claim value. Resolves user + claim definition by slug.
1534
- * Uses ON CONFLICT DO UPDATE for upsert behavior.
1535
- */
1536
- async function processUserClaimValue(client, claimValue, mode, result, orgMap, appMap, userMap, claimMap) {
1537
- try {
1538
- const userMapKey = `${claimValue.organization_slug}:${claimValue.email}`;
1539
- let userId = userMap.get(userMapKey);
1540
- if (!userId) {
1541
- const orgId = await resolveOrgId(client, claimValue.organization_slug, orgMap);
1542
- if (orgId) {
1543
- const { rows } = await client.query('SELECT id FROM users WHERE email = $1 AND organization_id = $2', [claimValue.email, orgId]);
1544
- userId = rows[0]?.id;
1545
- if (userId)
1546
- userMap.set(userMapKey, userId);
1547
- }
1548
- }
1549
- if (!userId) {
1550
- result.errors.push({
1551
- type: 'user_claim_value',
1552
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1553
- error: `User not found: ${claimValue.email} in org ${claimValue.organization_slug}`,
1554
- });
1555
- return;
1556
- }
1557
- const appId = await resolveAppId(client, claimValue.organization_slug, claimValue.application_slug, orgMap, appMap);
1558
- if (!appId) {
1559
- result.errors.push({
1560
- type: 'user_claim_value',
1561
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1562
- error: `Application not found: ${claimValue.application_slug}`,
1563
- });
1564
- return;
1565
- }
1566
- const claimKey = naturalKey(claimValue.organization_slug, claimValue.application_slug, claimValue.claim_slug);
1567
- let claimIdentity = claimMap.get(claimKey);
1568
- if (!claimIdentity) {
1569
- const { rows } = await client.query('SELECT id, claim_type FROM custom_claim_definitions WHERE claim_name = $1 AND application_id = $2', [claimValue.claim_slug, appId]);
1570
- const row = rows[0];
1571
- if (row) {
1572
- claimIdentity = { id: row.id, claimType: row.claim_type };
1573
- claimMap.set(claimKey, claimIdentity);
1574
- }
1575
- }
1576
- if (!claimIdentity) {
1577
- result.errors.push({
1578
- type: 'user_claim_value',
1579
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1580
- error: `Claim definition not found: ${claimValue.claim_slug}`,
1581
- });
1582
- return;
1583
- }
1584
- if (!validateClaimValue(claimIdentity.claimType, claimValue.value).valid) {
1585
- throw new ImportOperationError('import_plan_rejected', 409);
1586
- }
1587
- if (mode === 'dry-run' &&
1588
- (userId ===
1589
- plannedEntityId('user', `${claimValue.organization_slug}:${claimValue.email.toLowerCase()}`) ||
1590
- claimIdentity.id === plannedEntityId('claim_definition', claimKey))) {
1591
- result.created.push({
1592
- type: 'user_claim_value',
1593
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1594
- name: `${claimValue.email} → ${claimValue.claim_slug}`,
1595
- });
1596
- return;
1597
- }
1598
- const existingValue = await client.query('SELECT value FROM custom_claim_values WHERE user_id = $1 AND claim_id = $2', [userId, claimIdentity.id]);
1599
- if (existingValue.rowCount !== 0) {
1600
- const valueChanged = JSON.stringify(existingValue.rows[0].value) !== JSON.stringify(claimValue.value);
1601
- if (mode === 'merge' || !valueChanged) {
1602
- result.skipped.push({
1603
- type: 'user_claim_value',
1604
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1605
- reason: mode === 'merge' ? 'Already exists' : 'No mutable changes',
1606
- });
1607
- return;
1608
- }
1609
- if (mode === 'dry-run') {
1610
- result.updated.push({
1611
- type: 'user_claim_value',
1612
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1613
- name: `${claimValue.email} → ${claimValue.claim_slug}`,
1614
- changes: ['value'],
1615
- });
1616
- return;
1617
- }
1618
- await client.query('UPDATE custom_claim_values SET value = $1 WHERE user_id = $2 AND claim_id = $3', [JSON.stringify(claimValue.value), userId, claimIdentity.id]);
1619
- result.updated.push({
1620
- type: 'user_claim_value',
1621
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1622
- name: `${claimValue.email} → ${claimValue.claim_slug}`,
1623
- changes: ['value'],
1624
- });
1625
- return;
1626
- }
1627
- if (mode === 'dry-run') {
1628
- result.created.push({
1629
- type: 'user_claim_value',
1630
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1631
- name: `${claimValue.email} → ${claimValue.claim_slug}`,
1632
- });
1633
- return;
1634
- }
1635
- await client.query('INSERT INTO custom_claim_values (user_id, claim_id, value) VALUES ($1, $2, $3)', [userId, claimIdentity.id, JSON.stringify(claimValue.value)]);
1636
- result.created.push({
1637
- type: 'user_claim_value',
1638
- slug: `${claimValue.email}:${claimValue.claim_slug}`,
1639
- name: `${claimValue.email} → ${claimValue.claim_slug}`,
1640
- });
1641
- }
1642
- catch (err) {
1643
- throw normalizeImportFailure(err);
1644
- }
1645
- }
1646
- // ============================================================================
1647
- // Helpers
1648
- // ============================================================================
1649
- /** Preserve closed import failures and collapse dependency diagnostics into one public category. */
1650
- function normalizeImportFailure(error) {
1651
- return error instanceof ImportOperationError
1652
- ? error
1653
- : new ImportOperationError('import_execution_failed', 503);
1654
- }
1655
- /** Build a deterministic UUID-shaped identity that can never escape a rolled-back preview. */
1656
- function plannedEntityId(kind, naturalKeyValue) {
1657
- const digest = createHash('sha256')
1658
- .update(`porta-import-preview\0${kind}\0${naturalKeyValue}`)
1659
- .digest('hex');
1660
- return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-4${digest.slice(13, 16)}-8${digest.slice(17, 20)}-${digest.slice(20, 32)}`;
1661
- }
1662
- /**
1663
- * Resolve an organization slug to its ID.
1664
- * First checks the in-memory map (for entities created during this import),
1665
- * then falls back to a database lookup (for pre-existing entities).
1666
- */
1667
- async function resolveOrgId(client, slug, orgMap) {
1668
- if (orgMap.has(slug))
1669
- return orgMap.get(slug);
1670
- const { rows } = await client.query('SELECT id FROM organizations WHERE slug = $1', [slug]);
1671
- if (rows.length > 0) {
1672
- orgMap.set(slug, rows[0].id);
1673
- return rows[0].id;
1674
- }
1675
- return null;
1676
- }
1677
- /**
1678
- * Resolve an application by org slug + app slug to its ID.
1679
- */
1680
- async function resolveAppId(client, orgSlug, appSlug, orgMap, appMap) {
1681
- const mapKey = `${orgSlug}:${appSlug}`;
1682
- if (appMap.has(mapKey))
1683
- return appMap.get(mapKey);
1684
- const orgId = await resolveOrgId(client, orgSlug, orgMap);
1685
- if (!orgId)
1686
- return null;
1687
- const { rows } = await client.query('SELECT id FROM applications WHERE slug = $1', [appSlug]);
1688
- if (rows.length > 0) {
1689
- appMap.set(mapKey, rows[0].id);
1690
- return rows[0].id;
1691
- }
1692
- return null;
1693
- }
1694
- //# sourceMappingURL=data-import.js.map