@synth-deploy/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/dist/agent/debrief-retention.d.ts +12 -0
  2. package/dist/agent/debrief-retention.d.ts.map +1 -0
  3. package/dist/agent/debrief-retention.js +27 -0
  4. package/dist/agent/debrief-retention.js.map +1 -0
  5. package/dist/agent/envoy-client.d.ts +216 -0
  6. package/dist/agent/envoy-client.d.ts.map +1 -0
  7. package/dist/agent/envoy-client.js +266 -0
  8. package/dist/agent/envoy-client.js.map +1 -0
  9. package/dist/agent/envoy-registry.d.ts +102 -0
  10. package/dist/agent/envoy-registry.d.ts.map +1 -0
  11. package/dist/agent/envoy-registry.js +319 -0
  12. package/dist/agent/envoy-registry.js.map +1 -0
  13. package/dist/agent/health-checker.d.ts +39 -0
  14. package/dist/agent/health-checker.d.ts.map +1 -0
  15. package/dist/agent/health-checker.js +49 -0
  16. package/dist/agent/health-checker.js.map +1 -0
  17. package/dist/agent/mcp-client-manager.d.ts +36 -0
  18. package/dist/agent/mcp-client-manager.d.ts.map +1 -0
  19. package/dist/agent/mcp-client-manager.js +106 -0
  20. package/dist/agent/mcp-client-manager.js.map +1 -0
  21. package/dist/agent/stale-deployment-detector.d.ts +15 -0
  22. package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
  23. package/dist/agent/stale-deployment-detector.js +50 -0
  24. package/dist/agent/stale-deployment-detector.js.map +1 -0
  25. package/dist/agent/step-runner.d.ts +31 -0
  26. package/dist/agent/step-runner.d.ts.map +1 -0
  27. package/dist/agent/step-runner.js +80 -0
  28. package/dist/agent/step-runner.js.map +1 -0
  29. package/dist/agent/synth-agent.d.ts +168 -0
  30. package/dist/agent/synth-agent.d.ts.map +1 -0
  31. package/dist/agent/synth-agent.js +1195 -0
  32. package/dist/agent/synth-agent.js.map +1 -0
  33. package/dist/api/agent.d.ts +36 -0
  34. package/dist/api/agent.d.ts.map +1 -0
  35. package/dist/api/agent.js +867 -0
  36. package/dist/api/agent.js.map +1 -0
  37. package/dist/api/api-keys.d.ts +4 -0
  38. package/dist/api/api-keys.d.ts.map +1 -0
  39. package/dist/api/api-keys.js +118 -0
  40. package/dist/api/api-keys.js.map +1 -0
  41. package/dist/api/artifacts.d.ts +5 -0
  42. package/dist/api/artifacts.d.ts.map +1 -0
  43. package/dist/api/artifacts.js +142 -0
  44. package/dist/api/artifacts.js.map +1 -0
  45. package/dist/api/auth.d.ts +4 -0
  46. package/dist/api/auth.d.ts.map +1 -0
  47. package/dist/api/auth.js +280 -0
  48. package/dist/api/auth.js.map +1 -0
  49. package/dist/api/deployments.d.ts +11 -0
  50. package/dist/api/deployments.d.ts.map +1 -0
  51. package/dist/api/deployments.js +1098 -0
  52. package/dist/api/deployments.js.map +1 -0
  53. package/dist/api/environments.d.ts +5 -0
  54. package/dist/api/environments.d.ts.map +1 -0
  55. package/dist/api/environments.js +69 -0
  56. package/dist/api/environments.js.map +1 -0
  57. package/dist/api/envoy-reports.d.ts +17 -0
  58. package/dist/api/envoy-reports.d.ts.map +1 -0
  59. package/dist/api/envoy-reports.js +138 -0
  60. package/dist/api/envoy-reports.js.map +1 -0
  61. package/dist/api/envoys.d.ts +5 -0
  62. package/dist/api/envoys.d.ts.map +1 -0
  63. package/dist/api/envoys.js +192 -0
  64. package/dist/api/envoys.js.map +1 -0
  65. package/dist/api/fleet.d.ts +11 -0
  66. package/dist/api/fleet.d.ts.map +1 -0
  67. package/dist/api/fleet.js +394 -0
  68. package/dist/api/fleet.js.map +1 -0
  69. package/dist/api/graph.d.ts +8 -0
  70. package/dist/api/graph.d.ts.map +1 -0
  71. package/dist/api/graph.js +355 -0
  72. package/dist/api/graph.js.map +1 -0
  73. package/dist/api/health.d.ts +20 -0
  74. package/dist/api/health.d.ts.map +1 -0
  75. package/dist/api/health.js +248 -0
  76. package/dist/api/health.js.map +1 -0
  77. package/dist/api/idp-schemas.d.ts +41 -0
  78. package/dist/api/idp-schemas.d.ts.map +1 -0
  79. package/dist/api/idp-schemas.js +17 -0
  80. package/dist/api/idp-schemas.js.map +1 -0
  81. package/dist/api/idp.d.ts +6 -0
  82. package/dist/api/idp.d.ts.map +1 -0
  83. package/dist/api/idp.js +620 -0
  84. package/dist/api/idp.js.map +1 -0
  85. package/dist/api/intake.d.ts +10 -0
  86. package/dist/api/intake.d.ts.map +1 -0
  87. package/dist/api/intake.js +418 -0
  88. package/dist/api/intake.js.map +1 -0
  89. package/dist/api/partitions.d.ts +5 -0
  90. package/dist/api/partitions.d.ts.map +1 -0
  91. package/dist/api/partitions.js +113 -0
  92. package/dist/api/partitions.js.map +1 -0
  93. package/dist/api/progress-event-store.d.ts +62 -0
  94. package/dist/api/progress-event-store.d.ts.map +1 -0
  95. package/dist/api/progress-event-store.js +118 -0
  96. package/dist/api/progress-event-store.js.map +1 -0
  97. package/dist/api/schemas.d.ts +1000 -0
  98. package/dist/api/schemas.d.ts.map +1 -0
  99. package/dist/api/schemas.js +328 -0
  100. package/dist/api/schemas.js.map +1 -0
  101. package/dist/api/security-boundaries.d.ts +4 -0
  102. package/dist/api/security-boundaries.d.ts.map +1 -0
  103. package/dist/api/security-boundaries.js +32 -0
  104. package/dist/api/security-boundaries.js.map +1 -0
  105. package/dist/api/settings.d.ts +4 -0
  106. package/dist/api/settings.d.ts.map +1 -0
  107. package/dist/api/settings.js +99 -0
  108. package/dist/api/settings.js.map +1 -0
  109. package/dist/api/system.d.ts +75 -0
  110. package/dist/api/system.d.ts.map +1 -0
  111. package/dist/api/system.js +558 -0
  112. package/dist/api/system.js.map +1 -0
  113. package/dist/api/telemetry.d.ts +4 -0
  114. package/dist/api/telemetry.d.ts.map +1 -0
  115. package/dist/api/telemetry.js +24 -0
  116. package/dist/api/telemetry.js.map +1 -0
  117. package/dist/api/users.d.ts +4 -0
  118. package/dist/api/users.d.ts.map +1 -0
  119. package/dist/api/users.js +173 -0
  120. package/dist/api/users.js.map +1 -0
  121. package/dist/archive-unpacker.d.ts +24 -0
  122. package/dist/archive-unpacker.d.ts.map +1 -0
  123. package/dist/archive-unpacker.js +239 -0
  124. package/dist/archive-unpacker.js.map +1 -0
  125. package/dist/artifact-analyzer.d.ts +59 -0
  126. package/dist/artifact-analyzer.d.ts.map +1 -0
  127. package/dist/artifact-analyzer.js +334 -0
  128. package/dist/artifact-analyzer.js.map +1 -0
  129. package/dist/auth/idp/index.d.ts +9 -0
  130. package/dist/auth/idp/index.d.ts.map +1 -0
  131. package/dist/auth/idp/index.js +5 -0
  132. package/dist/auth/idp/index.js.map +1 -0
  133. package/dist/auth/idp/ldap.d.ts +56 -0
  134. package/dist/auth/idp/ldap.d.ts.map +1 -0
  135. package/dist/auth/idp/ldap.js +276 -0
  136. package/dist/auth/idp/ldap.js.map +1 -0
  137. package/dist/auth/idp/oidc.d.ts +27 -0
  138. package/dist/auth/idp/oidc.d.ts.map +1 -0
  139. package/dist/auth/idp/oidc.js +97 -0
  140. package/dist/auth/idp/oidc.js.map +1 -0
  141. package/dist/auth/idp/role-mapping.d.ts +9 -0
  142. package/dist/auth/idp/role-mapping.d.ts.map +1 -0
  143. package/dist/auth/idp/role-mapping.js +16 -0
  144. package/dist/auth/idp/role-mapping.js.map +1 -0
  145. package/dist/auth/idp/saml.d.ts +40 -0
  146. package/dist/auth/idp/saml.d.ts.map +1 -0
  147. package/dist/auth/idp/saml.js +117 -0
  148. package/dist/auth/idp/saml.js.map +1 -0
  149. package/dist/auth/idp/types.d.ts +23 -0
  150. package/dist/auth/idp/types.d.ts.map +1 -0
  151. package/dist/auth/idp/types.js +2 -0
  152. package/dist/auth/idp/types.js.map +1 -0
  153. package/dist/fleet/fleet-executor.d.ts +35 -0
  154. package/dist/fleet/fleet-executor.d.ts.map +1 -0
  155. package/dist/fleet/fleet-executor.js +228 -0
  156. package/dist/fleet/fleet-executor.js.map +1 -0
  157. package/dist/fleet/fleet-store.d.ts +13 -0
  158. package/dist/fleet/fleet-store.d.ts.map +1 -0
  159. package/dist/fleet/fleet-store.js +13 -0
  160. package/dist/fleet/fleet-store.js.map +1 -0
  161. package/dist/fleet/index.d.ts +5 -0
  162. package/dist/fleet/index.d.ts.map +1 -0
  163. package/dist/fleet/index.js +4 -0
  164. package/dist/fleet/index.js.map +1 -0
  165. package/dist/fleet/representative-selector.d.ts +15 -0
  166. package/dist/fleet/representative-selector.d.ts.map +1 -0
  167. package/dist/fleet/representative-selector.js +71 -0
  168. package/dist/fleet/representative-selector.js.map +1 -0
  169. package/dist/graph/graph-executor.d.ts +36 -0
  170. package/dist/graph/graph-executor.d.ts.map +1 -0
  171. package/dist/graph/graph-executor.js +348 -0
  172. package/dist/graph/graph-executor.js.map +1 -0
  173. package/dist/graph/graph-inference.d.ts +22 -0
  174. package/dist/graph/graph-inference.d.ts.map +1 -0
  175. package/dist/graph/graph-inference.js +149 -0
  176. package/dist/graph/graph-inference.js.map +1 -0
  177. package/dist/graph/graph-store.d.ts +12 -0
  178. package/dist/graph/graph-store.d.ts.map +1 -0
  179. package/dist/graph/graph-store.js +61 -0
  180. package/dist/graph/graph-store.js.map +1 -0
  181. package/dist/graph/index.d.ts +5 -0
  182. package/dist/graph/index.d.ts.map +1 -0
  183. package/dist/graph/index.js +4 -0
  184. package/dist/graph/index.js.map +1 -0
  185. package/dist/index.d.ts +2 -0
  186. package/dist/index.d.ts.map +1 -0
  187. package/dist/index.js +837 -0
  188. package/dist/index.js.map +1 -0
  189. package/dist/intake/index.d.ts +6 -0
  190. package/dist/intake/index.d.ts.map +1 -0
  191. package/dist/intake/index.js +5 -0
  192. package/dist/intake/index.js.map +1 -0
  193. package/dist/intake/intake-processor.d.ts +17 -0
  194. package/dist/intake/intake-processor.d.ts.map +1 -0
  195. package/dist/intake/intake-processor.js +99 -0
  196. package/dist/intake/intake-processor.js.map +1 -0
  197. package/dist/intake/intake-store.d.ts +7 -0
  198. package/dist/intake/intake-store.d.ts.map +1 -0
  199. package/dist/intake/intake-store.js +7 -0
  200. package/dist/intake/intake-store.js.map +1 -0
  201. package/dist/intake/registry-poller.d.ts +41 -0
  202. package/dist/intake/registry-poller.d.ts.map +1 -0
  203. package/dist/intake/registry-poller.js +202 -0
  204. package/dist/intake/registry-poller.js.map +1 -0
  205. package/dist/intake/webhook-handlers.d.ts +37 -0
  206. package/dist/intake/webhook-handlers.d.ts.map +1 -0
  207. package/dist/intake/webhook-handlers.js +268 -0
  208. package/dist/intake/webhook-handlers.js.map +1 -0
  209. package/dist/logger.d.ts +5 -0
  210. package/dist/logger.d.ts.map +1 -0
  211. package/dist/logger.js +15 -0
  212. package/dist/logger.js.map +1 -0
  213. package/dist/mcp/resources.d.ts +9 -0
  214. package/dist/mcp/resources.d.ts.map +1 -0
  215. package/dist/mcp/resources.js +72 -0
  216. package/dist/mcp/resources.js.map +1 -0
  217. package/dist/mcp/server.d.ts +15 -0
  218. package/dist/mcp/server.d.ts.map +1 -0
  219. package/dist/mcp/server.js +20 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/mcp/tools.d.ts +9 -0
  222. package/dist/mcp/tools.d.ts.map +1 -0
  223. package/dist/mcp/tools.js +88 -0
  224. package/dist/mcp/tools.js.map +1 -0
  225. package/dist/middleware/auth.d.ts +29 -0
  226. package/dist/middleware/auth.d.ts.map +1 -0
  227. package/dist/middleware/auth.js +76 -0
  228. package/dist/middleware/auth.js.map +1 -0
  229. package/dist/middleware/permissions.d.ts +13 -0
  230. package/dist/middleware/permissions.d.ts.map +1 -0
  231. package/dist/middleware/permissions.js +32 -0
  232. package/dist/middleware/permissions.js.map +1 -0
  233. package/dist/pattern-store.d.ts +104 -0
  234. package/dist/pattern-store.d.ts.map +1 -0
  235. package/dist/pattern-store.js +299 -0
  236. package/dist/pattern-store.js.map +1 -0
  237. package/package.json +54 -0
  238. package/src/agent/debrief-retention.ts +44 -0
  239. package/src/agent/envoy-client.ts +474 -0
  240. package/src/agent/envoy-registry.ts +384 -0
  241. package/src/agent/health-checker.ts +70 -0
  242. package/src/agent/mcp-client-manager.ts +131 -0
  243. package/src/agent/stale-deployment-detector.ts +79 -0
  244. package/src/agent/step-runner.ts +124 -0
  245. package/src/agent/synth-agent.ts +1567 -0
  246. package/src/api/agent.ts +1075 -0
  247. package/src/api/api-keys.ts +129 -0
  248. package/src/api/artifacts.ts +194 -0
  249. package/src/api/auth.ts +320 -0
  250. package/src/api/deployments.ts +1347 -0
  251. package/src/api/environments.ts +97 -0
  252. package/src/api/envoy-reports.ts +159 -0
  253. package/src/api/envoys.ts +237 -0
  254. package/src/api/fleet.ts +510 -0
  255. package/src/api/graph.ts +516 -0
  256. package/src/api/health.ts +311 -0
  257. package/src/api/idp-schemas.ts +19 -0
  258. package/src/api/idp.ts +735 -0
  259. package/src/api/intake.ts +537 -0
  260. package/src/api/partitions.ts +147 -0
  261. package/src/api/progress-event-store.ts +153 -0
  262. package/src/api/schemas.ts +376 -0
  263. package/src/api/security-boundaries.ts +54 -0
  264. package/src/api/settings.ts +118 -0
  265. package/src/api/system.ts +704 -0
  266. package/src/api/telemetry.ts +32 -0
  267. package/src/api/users.ts +210 -0
  268. package/src/archive-unpacker.ts +271 -0
  269. package/src/artifact-analyzer.ts +438 -0
  270. package/src/auth/idp/index.ts +8 -0
  271. package/src/auth/idp/ldap.ts +340 -0
  272. package/src/auth/idp/oidc.ts +117 -0
  273. package/src/auth/idp/role-mapping.ts +22 -0
  274. package/src/auth/idp/saml.ts +148 -0
  275. package/src/auth/idp/types.ts +22 -0
  276. package/src/fleet/fleet-executor.ts +309 -0
  277. package/src/fleet/fleet-store.ts +13 -0
  278. package/src/fleet/index.ts +4 -0
  279. package/src/fleet/representative-selector.ts +83 -0
  280. package/src/graph/graph-executor.ts +446 -0
  281. package/src/graph/graph-inference.ts +184 -0
  282. package/src/graph/graph-store.ts +75 -0
  283. package/src/graph/index.ts +4 -0
  284. package/src/index.ts +916 -0
  285. package/src/intake/index.ts +5 -0
  286. package/src/intake/intake-processor.ts +111 -0
  287. package/src/intake/intake-store.ts +7 -0
  288. package/src/intake/registry-poller.ts +230 -0
  289. package/src/intake/webhook-handlers.ts +328 -0
  290. package/src/logger.ts +19 -0
  291. package/src/mcp/resources.ts +98 -0
  292. package/src/mcp/server.ts +34 -0
  293. package/src/mcp/tools.ts +117 -0
  294. package/src/middleware/auth.ts +103 -0
  295. package/src/middleware/permissions.ts +35 -0
  296. package/src/pattern-store.ts +409 -0
  297. package/tests/agent-mode.test.ts +536 -0
  298. package/tests/api-handlers.test.ts +1245 -0
  299. package/tests/archive-unpacker.test.ts +179 -0
  300. package/tests/artifact-analyzer.test.ts +240 -0
  301. package/tests/auth-middleware.test.ts +189 -0
  302. package/tests/decision-diary.test.ts +957 -0
  303. package/tests/diary-reader.test.ts +782 -0
  304. package/tests/envoy-client.test.ts +342 -0
  305. package/tests/envoy-reports.test.ts +156 -0
  306. package/tests/mcp-tools.test.ts +213 -0
  307. package/tests/orchestration.test.ts +536 -0
  308. package/tests/partition-deletion.test.ts +143 -0
  309. package/tests/partition-isolation.test.ts +830 -0
  310. package/tests/pattern-store.test.ts +371 -0
  311. package/tests/rbac-enforcement.test.ts +409 -0
  312. package/tests/ssrf-validation.test.ts +56 -0
  313. package/tests/stale-deployment.test.ts +85 -0
  314. package/tests/step-runner.test.ts +308 -0
  315. package/tests/ui-journey.test.ts +330 -0
  316. package/tsconfig.json +11 -0
  317. package/vitest.config.ts +27 -0
@@ -0,0 +1,620 @@
1
+ import crypto from "node:crypto";
2
+ import bcrypt from "bcryptjs";
3
+ import { requirePermission, requireEdition } from "../middleware/permissions.js";
4
+ import { generateTokens } from "../middleware/auth.js";
5
+ import { OidcAdapter } from "../auth/idp/oidc.js";
6
+ import { SamlAdapter } from "../auth/idp/saml.js";
7
+ import { LdapAdapter } from "../auth/idp/ldap.js";
8
+ import { applyRoleMappings } from "../auth/idp/role-mapping.js";
9
+ import { CreateIdpProviderSchema, UpdateIdpProviderSchema, CreateRoleMappingSchema, } from "./idp-schemas.js";
10
+ /** Mask a client secret for API responses — never return the full value. */
11
+ function maskSecret(secret) {
12
+ if (secret.length <= 8)
13
+ return "****";
14
+ return secret.slice(0, 4) + "****" + secret.slice(-4);
15
+ }
16
+ /** Return a provider with secrets masked. */
17
+ function toPublicProvider(provider) {
18
+ const config = { ...provider.config };
19
+ if (typeof config.clientSecret === "string") {
20
+ config.clientSecret = maskSecret(config.clientSecret);
21
+ }
22
+ if (typeof config.bindCredential === "string") {
23
+ config.bindCredential = maskSecret(config.bindCredential);
24
+ }
25
+ return { ...provider, config };
26
+ }
27
+ /**
28
+ * OIDC state tokens — in-memory, short-lived.
29
+ * Maps state -> { providerId, createdAt }
30
+ */
31
+ const pendingStates = new Map();
32
+ const STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes
33
+ // Periodic cleanup of expired states
34
+ setInterval(() => {
35
+ const now = Date.now();
36
+ for (const [key, val] of pendingStates) {
37
+ if (now - val.createdAt > STATE_TTL_MS) {
38
+ pendingStates.delete(key);
39
+ }
40
+ }
41
+ }, 60_000);
42
+ export function registerIdpRoutes(app, idpProviderStore, roleMappingStore, userStore, roleStore, userRoleStore, sessionStore, jwtSecret, options) {
43
+ const hasDedicatedEncryptionKey = options?.hasDedicatedEncryptionKey ?? false;
44
+ const oidcAdapter = new OidcAdapter();
45
+ const samlAdapter = new SamlAdapter();
46
+ const ldapAdapter = new LdapAdapter();
47
+ // ─── IdP Provider CRUD (admin only) ───────────────────────────────
48
+ // GET /api/idp/providers — list configured IdPs
49
+ app.get("/api/idp/providers", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async () => {
50
+ const providers = idpProviderStore.list();
51
+ return { providers: providers.map(toPublicProvider) };
52
+ });
53
+ // POST /api/idp/providers — create new IdP
54
+ app.post("/api/idp/providers", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
55
+ if (!hasDedicatedEncryptionKey) {
56
+ return reply.status(503).send({
57
+ error: "Encryption key not configured. Set SYNTH_ENCRYPTION_KEY environment variable before configuring identity providers.",
58
+ });
59
+ }
60
+ const parsed = CreateIdpProviderSchema.safeParse(request.body);
61
+ if (!parsed.success) {
62
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
63
+ }
64
+ const { type, name, enabled, config } = parsed.data;
65
+ const now = new Date();
66
+ const provider = {
67
+ id: crypto.randomUUID(),
68
+ type: type,
69
+ name,
70
+ enabled: enabled ?? true,
71
+ config: config,
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ };
75
+ idpProviderStore.create(provider);
76
+ return reply.status(201).send({ provider: toPublicProvider(provider) });
77
+ });
78
+ // PUT /api/idp/providers/:id — update IdP config
79
+ app.put("/api/idp/providers/:id", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
80
+ if (!hasDedicatedEncryptionKey) {
81
+ return reply.status(503).send({
82
+ error: "Encryption key not configured. Set SYNTH_ENCRYPTION_KEY environment variable before configuring identity providers.",
83
+ });
84
+ }
85
+ const parsed = UpdateIdpProviderSchema.safeParse(request.body);
86
+ if (!parsed.success) {
87
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
88
+ }
89
+ const existing = idpProviderStore.getById(request.params.id);
90
+ if (!existing) {
91
+ return reply.status(404).send({ error: "IdP provider not found" });
92
+ }
93
+ const updates = { updatedAt: new Date() };
94
+ if (parsed.data.name !== undefined)
95
+ updates.name = parsed.data.name;
96
+ if (parsed.data.enabled !== undefined)
97
+ updates.enabled = parsed.data.enabled;
98
+ if (parsed.data.config !== undefined) {
99
+ // If clientSecret or bindCredential is masked (contains ****), preserve the existing one
100
+ const newConfig = parsed.data.config;
101
+ if (typeof newConfig.clientSecret === "string" && newConfig.clientSecret.includes("****")) {
102
+ newConfig.clientSecret = existing.config.clientSecret;
103
+ }
104
+ if (typeof newConfig.bindCredential === "string" && newConfig.bindCredential.includes("****")) {
105
+ newConfig.bindCredential = existing.config.bindCredential;
106
+ }
107
+ updates.config = newConfig;
108
+ }
109
+ const provider = idpProviderStore.update(request.params.id, updates);
110
+ return { provider: toPublicProvider(provider) };
111
+ });
112
+ // DELETE /api/idp/providers/:id — remove IdP
113
+ app.delete("/api/idp/providers/:id", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
114
+ const existing = idpProviderStore.getById(request.params.id);
115
+ if (!existing) {
116
+ return reply.status(404).send({ error: "IdP provider not found" });
117
+ }
118
+ idpProviderStore.delete(request.params.id);
119
+ return reply.status(204).send();
120
+ });
121
+ // POST /api/idp/providers/:id/test — test connection
122
+ app.post("/api/idp/providers/:id/test", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
123
+ const provider = idpProviderStore.getById(request.params.id);
124
+ if (!provider) {
125
+ return reply.status(404).send({ error: "IdP provider not found" });
126
+ }
127
+ if (provider.type === "oidc") {
128
+ const result = await oidcAdapter.validateConfig(provider.config);
129
+ return { success: result.valid, error: result.error };
130
+ }
131
+ if (provider.type === "saml") {
132
+ const result = await samlAdapter.validateConfig(provider.config);
133
+ return { success: result.valid, error: result.error };
134
+ }
135
+ if (provider.type === "ldap") {
136
+ const result = await ldapAdapter.testConnection(provider.config);
137
+ return { success: result.success, error: result.error };
138
+ }
139
+ return reply.status(400).send({ error: `Test not supported for provider type: ${provider.type}` });
140
+ });
141
+ // ─── Role Mapping CRUD ────────────────────────────────────────────
142
+ // GET /api/idp/providers/:id/mappings — list role mapping rules
143
+ app.get("/api/idp/providers/:id/mappings", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
144
+ const provider = idpProviderStore.getById(request.params.id);
145
+ if (!provider) {
146
+ return reply.status(404).send({ error: "IdP provider not found" });
147
+ }
148
+ const mappings = roleMappingStore.listByProvider(request.params.id);
149
+ return { mappings };
150
+ });
151
+ // POST /api/idp/providers/:id/mappings — add role mapping rule
152
+ app.post("/api/idp/providers/:id/mappings", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
153
+ const provider = idpProviderStore.getById(request.params.id);
154
+ if (!provider) {
155
+ return reply.status(404).send({ error: "IdP provider not found" });
156
+ }
157
+ const parsed = CreateRoleMappingSchema.safeParse(request.body);
158
+ if (!parsed.success) {
159
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
160
+ }
161
+ const rule = roleMappingStore.create({
162
+ id: crypto.randomUUID(),
163
+ providerId: request.params.id,
164
+ idpGroup: parsed.data.idpGroup,
165
+ synthRole: parsed.data.synthRole,
166
+ });
167
+ return reply.status(201).send({ mapping: rule });
168
+ });
169
+ // DELETE /api/idp/mappings/:id — remove mapping rule
170
+ app.delete("/api/idp/mappings/:id", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
171
+ const existing = roleMappingStore.getById(request.params.id);
172
+ if (!existing) {
173
+ return reply.status(404).send({ error: "Role mapping not found" });
174
+ }
175
+ roleMappingStore.delete(request.params.id);
176
+ return reply.status(204).send();
177
+ });
178
+ // ─── OIDC Auth Routes (exempt from auth middleware) ───────────────
179
+ // GET /api/auth/oidc/:providerId/authorize — redirect to IdP
180
+ app.get("/api/auth/oidc/:providerId/authorize", async (request, reply) => {
181
+ const provider = idpProviderStore.getById(request.params.providerId);
182
+ if (!provider || !provider.enabled) {
183
+ return reply.status(404).send({ error: "Identity provider not found or disabled" });
184
+ }
185
+ if (provider.type !== "oidc") {
186
+ return reply.status(400).send({ error: "Provider is not an OIDC provider" });
187
+ }
188
+ const config = provider.config;
189
+ const state = crypto.randomUUID();
190
+ pendingStates.set(state, { providerId: provider.id, createdAt: Date.now() });
191
+ // Build redirect URI based on the current request
192
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
193
+ const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
194
+ const redirectUri = `${proto}://${host}/api/auth/callback/oidc/${provider.id}`;
195
+ const authUrl = await oidcAdapter.getAuthorizationUrl(config, redirectUri, state);
196
+ return reply.redirect(authUrl);
197
+ });
198
+ // GET /api/auth/callback/oidc/:providerId — OIDC callback
199
+ app.get("/api/auth/callback/oidc/:providerId", async (request, reply) => {
200
+ const { code, state, error: oidcError } = request.query;
201
+ if (oidcError) {
202
+ return reply.status(400).send({ error: `OIDC error: ${oidcError}` });
203
+ }
204
+ if (!code || !state) {
205
+ return reply.status(400).send({ error: "Missing code or state parameter" });
206
+ }
207
+ // Validate state
208
+ const pendingState = pendingStates.get(state);
209
+ if (!pendingState || pendingState.providerId !== request.params.providerId) {
210
+ return reply.status(400).send({ error: "Invalid or expired state parameter" });
211
+ }
212
+ pendingStates.delete(state);
213
+ // Check if state has expired
214
+ if (Date.now() - pendingState.createdAt > STATE_TTL_MS) {
215
+ return reply.status(400).send({ error: "State parameter expired" });
216
+ }
217
+ const provider = idpProviderStore.getById(request.params.providerId);
218
+ if (!provider || !provider.enabled) {
219
+ return reply.status(404).send({ error: "Identity provider not found or disabled" });
220
+ }
221
+ const config = provider.config;
222
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
223
+ const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
224
+ const redirectUri = `${proto}://${host}/api/auth/callback/oidc/${provider.id}`;
225
+ // Exchange code for user info
226
+ let idpUser;
227
+ try {
228
+ idpUser = await oidcAdapter.authenticate({ code, redirectUri, config });
229
+ }
230
+ catch (err) {
231
+ const message = err instanceof Error ? err.message : "Unknown OIDC error";
232
+ return reply.status(500).send({ error: `OIDC authentication failed: ${message}` });
233
+ }
234
+ if (!idpUser.email) {
235
+ return reply.status(400).send({ error: "OIDC provider did not return an email address" });
236
+ }
237
+ // Provision or update user
238
+ let user = userStore.getByExternalId(idpUser.externalId, "oidc");
239
+ if (!user) {
240
+ // Check if a local user exists with this email
241
+ user = userStore.getByEmail(idpUser.email);
242
+ if (user && user.authSource === "local") {
243
+ // Link existing local user to OIDC
244
+ user = userStore.update(user.id, {
245
+ externalId: idpUser.externalId,
246
+ authSource: "oidc",
247
+ name: idpUser.displayName || user.name,
248
+ updatedAt: new Date(),
249
+ });
250
+ }
251
+ else if (!user) {
252
+ // Create new user
253
+ const userId = crypto.randomUUID();
254
+ const now = new Date();
255
+ user = userStore.create({
256
+ id: userId,
257
+ email: idpUser.email,
258
+ name: idpUser.displayName || idpUser.email,
259
+ passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random password — user authenticates via OIDC
260
+ authSource: "oidc",
261
+ externalId: idpUser.externalId,
262
+ createdAt: now,
263
+ updatedAt: now,
264
+ });
265
+ }
266
+ }
267
+ else {
268
+ // Update existing OIDC user
269
+ user = userStore.update(user.id, {
270
+ email: idpUser.email,
271
+ name: idpUser.displayName || user.name,
272
+ updatedAt: new Date(),
273
+ });
274
+ }
275
+ // Apply role mappings
276
+ const mappingRules = roleMappingStore.listByProvider(provider.id);
277
+ const mappedRoleNames = applyRoleMappings(idpUser, mappingRules);
278
+ if (mappedRoleNames.length > 0) {
279
+ const roleIds = [];
280
+ for (const roleName of mappedRoleNames) {
281
+ const role = roleStore.getByName(roleName);
282
+ if (role)
283
+ roleIds.push(role.id);
284
+ }
285
+ if (roleIds.length > 0) {
286
+ userRoleStore.setRoles(user.id, roleIds, user.id);
287
+ }
288
+ }
289
+ // Create session and generate tokens
290
+ const tokens = await generateTokens(user.id, jwtSecret);
291
+ sessionStore.create({
292
+ id: crypto.randomUUID(),
293
+ userId: user.id,
294
+ token: tokens.token,
295
+ refreshToken: tokens.refreshToken,
296
+ expiresAt: tokens.expiresAt,
297
+ createdAt: new Date(),
298
+ userAgent: request.headers["user-agent"] ?? undefined,
299
+ ipAddress: request.ip ?? undefined,
300
+ });
301
+ // Redirect to UI with token (the UI picks this up and stores it)
302
+ const uiRedirect = `/?oidc_token=${encodeURIComponent(tokens.token)}&oidc_refresh=${encodeURIComponent(tokens.refreshToken)}`;
303
+ return reply.redirect(uiRedirect);
304
+ });
305
+ // ─── SAML Auth Routes (exempt from auth middleware) ─────────────────
306
+ // GET /api/auth/saml/:providerId/authorize — generate AuthnRequest, redirect to IdP
307
+ app.get("/api/auth/saml/:providerId/authorize", async (request, reply) => {
308
+ const provider = idpProviderStore.getById(request.params.providerId);
309
+ if (!provider || !provider.enabled) {
310
+ return reply.status(404).send({ error: "Identity provider not found or disabled" });
311
+ }
312
+ if (provider.type !== "saml") {
313
+ return reply.status(400).send({ error: "Provider is not a SAML provider" });
314
+ }
315
+ const config = provider.config;
316
+ // Build the ACS callback URL based on the current request
317
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
318
+ const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
319
+ const callbackUrl = `${proto}://${host}/api/auth/callback/saml/${provider.id}`;
320
+ // Ensure config has the correct callbackUrl for this request
321
+ const effectiveConfig = {
322
+ ...config,
323
+ callbackUrl,
324
+ groupsAttribute: config.groupsAttribute || "memberOf",
325
+ signatureAlgorithm: config.signatureAlgorithm || "sha256",
326
+ };
327
+ // Generate state for relay
328
+ const state = crypto.randomUUID();
329
+ pendingStates.set(state, { providerId: provider.id, createdAt: Date.now() });
330
+ try {
331
+ const authUrl = await samlAdapter.getAuthorizationUrl(effectiveConfig, state);
332
+ return reply.redirect(authUrl);
333
+ }
334
+ catch (err) {
335
+ const message = err instanceof Error ? err.message : "Unknown SAML error";
336
+ return reply.status(500).send({ error: `Failed to generate SAML AuthnRequest: ${message}` });
337
+ }
338
+ });
339
+ // POST /api/auth/callback/saml/:providerId — handle SAML Response (ACS endpoint)
340
+ app.post("/api/auth/callback/saml/:providerId", async (request, reply) => {
341
+ const body = request.body;
342
+ const samlResponse = body?.SAMLResponse;
343
+ const relayState = body?.RelayState;
344
+ if (!samlResponse) {
345
+ return reply.status(400).send({ error: "Missing SAMLResponse in request body" });
346
+ }
347
+ // Validate relay state if present
348
+ if (relayState) {
349
+ const pendingState = pendingStates.get(relayState);
350
+ if (!pendingState || pendingState.providerId !== request.params.providerId) {
351
+ return reply.status(400).send({ error: "Invalid or expired relay state" });
352
+ }
353
+ pendingStates.delete(relayState);
354
+ if (Date.now() - pendingState.createdAt > STATE_TTL_MS) {
355
+ return reply.status(400).send({ error: "Relay state expired" });
356
+ }
357
+ }
358
+ const provider = idpProviderStore.getById(request.params.providerId);
359
+ if (!provider || !provider.enabled) {
360
+ return reply.status(404).send({ error: "Identity provider not found or disabled" });
361
+ }
362
+ const config = provider.config;
363
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
364
+ const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
365
+ const callbackUrl = `${proto}://${host}/api/auth/callback/saml/${provider.id}`;
366
+ const effectiveConfig = {
367
+ ...config,
368
+ callbackUrl,
369
+ groupsAttribute: config.groupsAttribute || "memberOf",
370
+ signatureAlgorithm: config.signatureAlgorithm || "sha256",
371
+ };
372
+ // Validate SAML Response and extract user
373
+ let idpUser;
374
+ try {
375
+ idpUser = await samlAdapter.authenticate({ samlResponse, config: effectiveConfig });
376
+ }
377
+ catch (err) {
378
+ const message = err instanceof Error ? err.message : "Unknown SAML error";
379
+ return reply.status(500).send({ error: `SAML authentication failed: ${message}` });
380
+ }
381
+ if (!idpUser.email) {
382
+ return reply.status(400).send({ error: "SAML provider did not return an email address" });
383
+ }
384
+ // Provision or update user (same pattern as OIDC)
385
+ let user = userStore.getByExternalId(idpUser.externalId, "saml");
386
+ if (!user) {
387
+ user = userStore.getByEmail(idpUser.email);
388
+ if (user && user.authSource === "local") {
389
+ // Link existing local user to SAML
390
+ user = userStore.update(user.id, {
391
+ externalId: idpUser.externalId,
392
+ authSource: "saml",
393
+ name: idpUser.displayName || user.name,
394
+ updatedAt: new Date(),
395
+ });
396
+ }
397
+ else if (!user) {
398
+ // Create new user
399
+ const userId = crypto.randomUUID();
400
+ const now = new Date();
401
+ user = userStore.create({
402
+ id: userId,
403
+ email: idpUser.email,
404
+ name: idpUser.displayName || idpUser.email,
405
+ passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random password — user authenticates via SAML
406
+ authSource: "saml",
407
+ externalId: idpUser.externalId,
408
+ createdAt: now,
409
+ updatedAt: now,
410
+ });
411
+ }
412
+ }
413
+ else {
414
+ // Update existing SAML user
415
+ user = userStore.update(user.id, {
416
+ email: idpUser.email,
417
+ name: idpUser.displayName || user.name,
418
+ updatedAt: new Date(),
419
+ });
420
+ }
421
+ // Apply role mappings
422
+ const mappingRules = roleMappingStore.listByProvider(provider.id);
423
+ const mappedRoleNames = applyRoleMappings(idpUser, mappingRules);
424
+ if (mappedRoleNames.length > 0) {
425
+ const roleIds = [];
426
+ for (const roleName of mappedRoleNames) {
427
+ const role = roleStore.getByName(roleName);
428
+ if (role)
429
+ roleIds.push(role.id);
430
+ }
431
+ if (roleIds.length > 0) {
432
+ userRoleStore.setRoles(user.id, roleIds, user.id);
433
+ }
434
+ }
435
+ // Create session and generate tokens
436
+ const tokens = await generateTokens(user.id, jwtSecret);
437
+ sessionStore.create({
438
+ id: crypto.randomUUID(),
439
+ userId: user.id,
440
+ token: tokens.token,
441
+ refreshToken: tokens.refreshToken,
442
+ expiresAt: tokens.expiresAt,
443
+ createdAt: new Date(),
444
+ userAgent: request.headers["user-agent"] ?? undefined,
445
+ ipAddress: request.ip ?? undefined,
446
+ });
447
+ // Redirect to UI with token
448
+ const uiRedirect = `/?saml_token=${encodeURIComponent(tokens.token)}&saml_refresh=${encodeURIComponent(tokens.refreshToken)}`;
449
+ return reply.redirect(uiRedirect);
450
+ });
451
+ // GET /api/auth/saml/:providerId/metadata — return SP metadata XML
452
+ app.get("/api/auth/saml/:providerId/metadata", async (request, reply) => {
453
+ const provider = idpProviderStore.getById(request.params.providerId);
454
+ if (!provider) {
455
+ return reply.status(404).send({ error: "Identity provider not found" });
456
+ }
457
+ if (provider.type !== "saml") {
458
+ return reply.status(400).send({ error: "Provider is not a SAML provider" });
459
+ }
460
+ const config = provider.config;
461
+ const proto = request.headers["x-forwarded-proto"] ?? "http";
462
+ const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
463
+ const callbackUrl = `${proto}://${host}/api/auth/callback/saml/${provider.id}`;
464
+ const effectiveConfig = {
465
+ ...config,
466
+ callbackUrl,
467
+ groupsAttribute: config.groupsAttribute || "memberOf",
468
+ signatureAlgorithm: config.signatureAlgorithm || "sha256",
469
+ };
470
+ try {
471
+ const metadata = samlAdapter.generateMetadata(effectiveConfig);
472
+ reply.type("application/xml");
473
+ return reply.send(metadata);
474
+ }
475
+ catch (err) {
476
+ const message = err instanceof Error ? err.message : "Unknown error";
477
+ return reply.status(500).send({ error: `Failed to generate SP metadata: ${message}` });
478
+ }
479
+ });
480
+ // ─── LDAP Auth Routes ────────────────────────────────────────────────
481
+ // POST /api/auth/ldap/:providerId/login — username/password login via LDAP
482
+ app.post("/api/auth/ldap/:providerId/login", async (request, reply) => {
483
+ const provider = idpProviderStore.getById(request.params.providerId);
484
+ if (!provider || !provider.enabled) {
485
+ return reply.status(404).send({ error: "Identity provider not found or disabled" });
486
+ }
487
+ if (provider.type !== "ldap") {
488
+ return reply.status(400).send({ error: "Provider is not an LDAP provider" });
489
+ }
490
+ const body = request.body;
491
+ const username = body?.username;
492
+ const password = body?.password;
493
+ if (!username || !password) {
494
+ return reply.status(400).send({ error: "Username and password are required" });
495
+ }
496
+ const config = provider.config;
497
+ // Authenticate against LDAP
498
+ let idpUser;
499
+ try {
500
+ idpUser = await ldapAdapter.authenticate({ username, password, config });
501
+ }
502
+ catch (err) {
503
+ const message = err instanceof Error ? err.message : "Unknown LDAP error";
504
+ return reply.status(401).send({ error: `LDAP authentication failed: ${message}` });
505
+ }
506
+ if (!idpUser.email) {
507
+ return reply.status(400).send({ error: "LDAP directory did not return an email address for this user" });
508
+ }
509
+ // Provision or update user (same pattern as OIDC/SAML)
510
+ let user = userStore.getByExternalId(idpUser.externalId, "ldap");
511
+ if (!user) {
512
+ // Check if a local user exists with this email
513
+ user = userStore.getByEmail(idpUser.email);
514
+ if (user && user.authSource === "local") {
515
+ // Link existing local user to LDAP
516
+ user = userStore.update(user.id, {
517
+ externalId: idpUser.externalId,
518
+ authSource: "ldap",
519
+ name: idpUser.displayName || user.name,
520
+ updatedAt: new Date(),
521
+ });
522
+ }
523
+ else if (!user) {
524
+ // Create new user
525
+ const userId = crypto.randomUUID();
526
+ const now = new Date();
527
+ user = userStore.create({
528
+ id: userId,
529
+ email: idpUser.email,
530
+ name: idpUser.displayName || idpUser.email,
531
+ passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random password — user authenticates via LDAP
532
+ authSource: "ldap",
533
+ externalId: idpUser.externalId,
534
+ createdAt: now,
535
+ updatedAt: now,
536
+ });
537
+ }
538
+ }
539
+ else {
540
+ // Update existing LDAP user
541
+ user = userStore.update(user.id, {
542
+ email: idpUser.email,
543
+ name: idpUser.displayName || user.name,
544
+ updatedAt: new Date(),
545
+ });
546
+ }
547
+ // Apply role mappings
548
+ const mappingRules = roleMappingStore.listByProvider(provider.id);
549
+ const mappedRoleNames = applyRoleMappings(idpUser, mappingRules);
550
+ if (mappedRoleNames.length > 0) {
551
+ const roleIds = [];
552
+ for (const roleName of mappedRoleNames) {
553
+ const role = roleStore.getByName(roleName);
554
+ if (role)
555
+ roleIds.push(role.id);
556
+ }
557
+ if (roleIds.length > 0) {
558
+ userRoleStore.setRoles(user.id, roleIds, user.id);
559
+ }
560
+ }
561
+ // Create session and generate tokens
562
+ const tokens = await generateTokens(user.id, jwtSecret);
563
+ const now = new Date();
564
+ sessionStore.create({
565
+ id: crypto.randomUUID(),
566
+ userId: user.id,
567
+ token: tokens.token,
568
+ refreshToken: tokens.refreshToken,
569
+ expiresAt: tokens.expiresAt,
570
+ createdAt: now,
571
+ userAgent: request.headers["user-agent"] ?? undefined,
572
+ ipAddress: request.ip ?? undefined,
573
+ });
574
+ const permissions = userRoleStore.getUserPermissions(user.id);
575
+ return {
576
+ user: {
577
+ id: user.id,
578
+ email: user.email,
579
+ name: user.name,
580
+ authSource: user.authSource ?? "ldap",
581
+ createdAt: user.createdAt,
582
+ updatedAt: user.updatedAt,
583
+ },
584
+ token: tokens.token,
585
+ refreshToken: tokens.refreshToken,
586
+ permissions,
587
+ };
588
+ });
589
+ // POST /api/idp/providers/:id/test-ldap-user — test user search (admin only)
590
+ app.post("/api/idp/providers/:id/test-ldap-user", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
591
+ const provider = idpProviderStore.getById(request.params.id);
592
+ if (!provider) {
593
+ return reply.status(404).send({ error: "IdP provider not found" });
594
+ }
595
+ if (provider.type !== "ldap") {
596
+ return reply.status(400).send({ error: "Provider is not an LDAP provider" });
597
+ }
598
+ const body = request.body;
599
+ const username = body?.username;
600
+ if (!username) {
601
+ return reply.status(400).send({ error: "username is required in request body" });
602
+ }
603
+ const config = provider.config;
604
+ const result = await ldapAdapter.testUser(config, username);
605
+ return result;
606
+ });
607
+ // ─── Public: list enabled providers (for login page) ──────────────
608
+ // GET /api/auth/providers — list enabled IdPs (public, for login page)
609
+ app.get("/api/auth/providers", async () => {
610
+ const providers = idpProviderStore.list()
611
+ .filter((p) => p.enabled)
612
+ .map((p) => ({
613
+ id: p.id,
614
+ type: p.type,
615
+ name: p.name,
616
+ }));
617
+ return { providers };
618
+ });
619
+ }
620
+ //# sourceMappingURL=idp.js.map