@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,32 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { ITelemetryStore, TelemetryAction } from "@synth-deploy/core";
3
+ import { TelemetryQuerySchema } from "./schemas.js";
4
+ import { requirePermission } from "../middleware/permissions.js";
5
+
6
+ export function registerTelemetryRoutes(
7
+ app: FastifyInstance,
8
+ telemetryStore: ITelemetryStore,
9
+ ): void {
10
+ app.get("/api/telemetry", { preHandler: [requirePermission("settings.manage")] }, async (request) => {
11
+ const parsed = TelemetryQuerySchema.safeParse(request.query);
12
+ const filters = parsed.success ? parsed.data : {};
13
+
14
+ const events = telemetryStore.query({
15
+ actor: filters.actor,
16
+ action: filters.action as TelemetryAction | undefined,
17
+ from: filters.from ? new Date(filters.from) : undefined,
18
+ to: filters.to ? new Date(filters.to) : undefined,
19
+ limit: filters.limit ?? 50,
20
+ offset: filters.offset ?? 0,
21
+ });
22
+
23
+ const total = telemetryStore.count({
24
+ actor: filters.actor,
25
+ action: filters.action as TelemetryAction | undefined,
26
+ from: filters.from ? new Date(filters.from) : undefined,
27
+ to: filters.to ? new Date(filters.to) : undefined,
28
+ });
29
+
30
+ return { events, total };
31
+ });
32
+ }
@@ -0,0 +1,210 @@
1
+ import crypto from "node:crypto";
2
+ import type { FastifyInstance } from "fastify";
3
+ import bcrypt from "bcryptjs";
4
+ import type { IUserStore, IRoleStore, IUserRoleStore, UserId, RoleId, UserPublic, Permission } from "@synth-deploy/core";
5
+ import { requirePermission } from "../middleware/permissions.js";
6
+ import {
7
+ CreateUserSchema,
8
+ UpdateUserSchema,
9
+ AssignRolesSchema,
10
+ CreateRoleSchema,
11
+ UpdateRoleSchema,
12
+ } from "./schemas.js";
13
+
14
+ function toPublicUser(user: { id: UserId; email: string; name: string; authSource?: string; externalId?: string; createdAt: Date; updatedAt: Date }): UserPublic {
15
+ return {
16
+ id: user.id,
17
+ email: user.email,
18
+ name: user.name,
19
+ authSource: (user.authSource as UserPublic["authSource"]) ?? "local",
20
+ createdAt: user.createdAt,
21
+ updatedAt: user.updatedAt,
22
+ };
23
+ }
24
+
25
+ export function registerUserRoutes(
26
+ app: FastifyInstance,
27
+ userStore: IUserStore,
28
+ roleStore: IRoleStore,
29
+ userRoleStore: IUserRoleStore,
30
+ ): void {
31
+
32
+ // --- GET /api/users ---
33
+ app.get("/api/users", { preHandler: [requirePermission("users.manage")] }, async () => {
34
+ const users = userStore.list();
35
+ return { users: users.map(toPublicUser) };
36
+ });
37
+
38
+ // --- POST /api/users ---
39
+ app.post("/api/users", { preHandler: [requirePermission("users.manage")] }, async (request, reply) => {
40
+ const parsed = CreateUserSchema.safeParse(request.body);
41
+ if (!parsed.success) {
42
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
43
+ }
44
+
45
+ const { email, name, password } = parsed.data;
46
+ const existing = userStore.getByEmail(email);
47
+ if (existing) {
48
+ return reply.status(409).send({ error: "Email already in use" });
49
+ }
50
+
51
+ const passwordHash = await bcrypt.hash(password, 10);
52
+ const userId = crypto.randomUUID() as UserId;
53
+ const now = new Date();
54
+
55
+ const user = userStore.create({
56
+ id: userId,
57
+ email,
58
+ name,
59
+ passwordHash,
60
+ createdAt: now,
61
+ updatedAt: now,
62
+ });
63
+
64
+ return reply.status(201).send({ user: toPublicUser(user) });
65
+ });
66
+
67
+ // --- PUT /api/users/:id ---
68
+ app.put<{ Params: { id: string } }>("/api/users/:id", { preHandler: [requirePermission("users.manage")] }, async (request, reply) => {
69
+ const parsed = UpdateUserSchema.safeParse(request.body);
70
+ if (!parsed.success) {
71
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
72
+ }
73
+
74
+ const userId = request.params.id as UserId;
75
+ const existing = userStore.getById(userId);
76
+ if (!existing) {
77
+ return reply.status(404).send({ error: "User not found" });
78
+ }
79
+
80
+ const updates: Record<string, unknown> = { updatedAt: new Date() };
81
+ if (parsed.data.email !== undefined) updates.email = parsed.data.email;
82
+ if (parsed.data.name !== undefined) updates.name = parsed.data.name;
83
+ if (parsed.data.password !== undefined) {
84
+ updates.passwordHash = await bcrypt.hash(parsed.data.password, 10);
85
+ }
86
+
87
+ const user = userStore.update(userId, updates as Parameters<typeof userStore.update>[1]);
88
+ return { user: toPublicUser(user) };
89
+ });
90
+
91
+ // --- DELETE /api/users/:id ---
92
+ app.delete<{ Params: { id: string } }>("/api/users/:id", { preHandler: [requirePermission("users.manage")] }, async (request, reply) => {
93
+ const userId = request.params.id as UserId;
94
+ const existing = userStore.getById(userId);
95
+ if (!existing) {
96
+ return reply.status(404).send({ error: "User not found" });
97
+ }
98
+ userStore.delete(userId);
99
+ return reply.status(204).send();
100
+ });
101
+
102
+ // --- GET /api/users/:id/roles ---
103
+ app.get<{ Params: { id: string } }>("/api/users/:id/roles", { preHandler: [requirePermission("users.manage")] }, async (request, reply) => {
104
+ const userId = request.params.id as UserId;
105
+ const existing = userStore.getById(userId);
106
+ if (!existing) {
107
+ return reply.status(404).send({ error: "User not found" });
108
+ }
109
+ const roles = userRoleStore.getUserRoles(userId);
110
+ return { roles };
111
+ });
112
+
113
+ // --- PUT /api/users/:id/roles ---
114
+ app.put<{ Params: { id: string } }>("/api/users/:id/roles", { preHandler: [requirePermission("roles.manage")] }, async (request, reply) => {
115
+ const parsed = AssignRolesSchema.safeParse(request.body);
116
+ if (!parsed.success) {
117
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
118
+ }
119
+
120
+ const userId = request.params.id as UserId;
121
+ const existing = userStore.getById(userId);
122
+ if (!existing) {
123
+ return reply.status(404).send({ error: "User not found" });
124
+ }
125
+
126
+ const assignedBy = request.user!.id;
127
+ const roleIds = parsed.data.roleIds as RoleId[];
128
+
129
+ // Validate all role IDs exist
130
+ for (const roleId of roleIds) {
131
+ const role = roleStore.getById(roleId);
132
+ if (!role) {
133
+ return reply.status(400).send({ error: `Role not found: ${roleId}` });
134
+ }
135
+ }
136
+
137
+ userRoleStore.setRoles(userId, roleIds, assignedBy);
138
+ const roles = userRoleStore.getUserRoles(userId);
139
+ return { roles };
140
+ });
141
+
142
+ // --- GET /api/roles ---
143
+ app.get("/api/roles", { preHandler: [requirePermission("users.manage")] }, async () => {
144
+ const roles = roleStore.list();
145
+ return { roles };
146
+ });
147
+
148
+ // --- POST /api/roles ---
149
+ app.post("/api/roles", { preHandler: [requirePermission("roles.manage")] }, async (request, reply) => {
150
+ const parsed = CreateRoleSchema.safeParse(request.body);
151
+ if (!parsed.success) {
152
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
153
+ }
154
+
155
+ const { name, permissions } = parsed.data;
156
+ const existing = roleStore.getByName(name);
157
+ if (existing) {
158
+ return reply.status(409).send({ error: "Role name already in use" });
159
+ }
160
+
161
+ const roleId = crypto.randomUUID() as RoleId;
162
+ const role = roleStore.create({
163
+ id: roleId,
164
+ name,
165
+ permissions: permissions as Permission[],
166
+ isBuiltIn: false,
167
+ createdAt: new Date(),
168
+ });
169
+
170
+ return reply.status(201).send({ role });
171
+ });
172
+
173
+ // --- PUT /api/roles/:id ---
174
+ app.put<{ Params: { id: string } }>("/api/roles/:id", { preHandler: [requirePermission("roles.manage")] }, async (request, reply) => {
175
+ const parsed = UpdateRoleSchema.safeParse(request.body);
176
+ if (!parsed.success) {
177
+ return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
178
+ }
179
+
180
+ const roleId = request.params.id as RoleId;
181
+ const existing = roleStore.getById(roleId);
182
+ if (!existing) {
183
+ return reply.status(404).send({ error: "Role not found" });
184
+ }
185
+ if (existing.isBuiltIn) {
186
+ return reply.status(403).send({ error: "Cannot modify built-in roles" });
187
+ }
188
+
189
+ const updates: Record<string, unknown> = {};
190
+ if (parsed.data.name !== undefined) updates.name = parsed.data.name;
191
+ if (parsed.data.permissions !== undefined) updates.permissions = parsed.data.permissions as Permission[];
192
+
193
+ const role = roleStore.update(roleId, updates as Parameters<typeof roleStore.update>[1]);
194
+ return { role };
195
+ });
196
+
197
+ // --- DELETE /api/roles/:id ---
198
+ app.delete<{ Params: { id: string } }>("/api/roles/:id", { preHandler: [requirePermission("roles.manage")] }, async (request, reply) => {
199
+ const roleId = request.params.id as RoleId;
200
+ const existing = roleStore.getById(roleId);
201
+ if (!existing) {
202
+ return reply.status(404).send({ error: "Role not found" });
203
+ }
204
+ if (existing.isBuiltIn) {
205
+ return reply.status(403).send({ error: "Cannot delete built-in roles" });
206
+ }
207
+ roleStore.delete(roleId);
208
+ return reply.status(204).send();
209
+ });
210
+ }
@@ -0,0 +1,271 @@
1
+ import AdmZip from "adm-zip";
2
+ import tarStream from "tar-stream";
3
+ import zlib from "node:zlib";
4
+ import { Readable } from "node:stream";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface ExtractedFile {
11
+ path: string;
12
+ content: string;
13
+ }
14
+
15
+ export interface UnpackResult {
16
+ files: ExtractedFile[];
17
+ skipped: number;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Text file detection
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const TEXT_EXTENSIONS = new Set([
25
+ // Docs / context
26
+ ".md", ".txt", ".rst", ".adoc",
27
+ // Config / manifests
28
+ ".yaml", ".yml", ".json", ".toml", ".ini", ".conf", ".config",
29
+ ".properties", ".env", ".envrc",
30
+ // Infrastructure / IaC
31
+ ".tf", ".tfvars", ".hcl", ".bicep",
32
+ // Build / packaging
33
+ ".xml", ".gradle", ".gradle.kts", ".nuspec", ".pom",
34
+ ".csproj", ".fsproj", ".vbproj", ".sln",
35
+ ".gemspec", ".podspec", ".lock",
36
+ // Scripts
37
+ ".sh", ".bash", ".zsh", ".fish",
38
+ ".ps1", ".psm1", ".psd1", ".cmd", ".bat",
39
+ // Source (for deploy scripts, Lambdas, etc.)
40
+ ".py", ".rb", ".js", ".ts", ".go", ".rs", ".java", ".cs",
41
+ // Web / templates
42
+ ".html", ".htm", ".jinja", ".j2", ".tpl",
43
+ ]);
44
+
45
+ const TEXT_FILENAMES = new Set([
46
+ "dockerfile", "makefile", "procfile", "brewfile", "gemfile",
47
+ "rakefile", "vagrantfile", "jenkinsfile", "capfile", "guardfile",
48
+ "podfile", "appfile", "fastfile",
49
+ "readme", "license", "changelog", "authors", "contributors", "notice",
50
+ ]);
51
+
52
+ const SKIP_PATH_SEGMENTS = new Set([
53
+ "node_modules", ".git", "__pycache__", ".idea", ".vscode",
54
+ "vendor", "dist", "build", "target", "bin", "obj",
55
+ ]);
56
+
57
+ const SKIP_EXTENSIONS = new Set([
58
+ ".pyc", ".class", ".o", ".a", ".so", ".dll", ".exe",
59
+ ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg", ".webp",
60
+ ".woff", ".woff2", ".ttf", ".eot",
61
+ ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
62
+ ".jar", ".war", ".ear", ".whl", ".nupkg",
63
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx",
64
+ ]);
65
+
66
+ const MAX_FILE_BYTES = 50 * 1024; // 50KB per file
67
+ const MAX_TOTAL_BYTES = 300 * 1024; // 300KB total
68
+ const MAX_FILES = 50;
69
+
70
+ function shouldExtract(filePath: string): boolean {
71
+ const lower = filePath.toLowerCase();
72
+ const segments = lower.split("/");
73
+ const filename = segments[segments.length - 1];
74
+
75
+ // Skip directories and hidden files at root
76
+ if (!filename || filename.startsWith(".")) return false;
77
+
78
+ // Skip paths with unwanted segments
79
+ for (const seg of segments.slice(0, -1)) {
80
+ if (SKIP_PATH_SEGMENTS.has(seg)) return false;
81
+ }
82
+
83
+ const dotIdx = filename.lastIndexOf(".");
84
+ const ext = dotIdx !== -1 ? filename.slice(dotIdx) : "";
85
+
86
+ if (SKIP_EXTENSIONS.has(ext)) return false;
87
+ if (TEXT_EXTENSIONS.has(ext)) return true;
88
+ if (TEXT_FILENAMES.has(filename)) return true;
89
+
90
+ return false;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // ZIP unpacker (zip, nupkg, jar, war, ear, whl, vsix, apk)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function unpackZip(buffer: Buffer): UnpackResult {
98
+ const files: ExtractedFile[] = [];
99
+ let skipped = 0;
100
+ let totalBytes = 0;
101
+
102
+ let zip: AdmZip;
103
+ try {
104
+ zip = new AdmZip(buffer);
105
+ } catch {
106
+ return { files: [], skipped: 0 };
107
+ }
108
+
109
+ for (const entry of zip.getEntries()) {
110
+ if (entry.isDirectory) continue;
111
+ if (files.length >= MAX_FILES) { skipped++; continue; }
112
+ if (!shouldExtract(entry.entryName)) { skipped++; continue; }
113
+ if (entry.header.size > MAX_FILE_BYTES) { skipped++; continue; }
114
+ if (totalBytes + entry.header.size > MAX_TOTAL_BYTES) { skipped++; continue; }
115
+
116
+ try {
117
+ const content = entry.getData().toString("utf-8");
118
+ // Reject if it looks binary (high proportion of null bytes / non-printable)
119
+ if (looksLikeBinary(content)) { skipped++; continue; }
120
+ files.push({ path: entry.entryName, content });
121
+ totalBytes += entry.header.size;
122
+ } catch {
123
+ skipped++;
124
+ }
125
+ }
126
+
127
+ return { files, skipped };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // TAR unpacker (tar, tar.gz / tgz)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function unpackTar(buffer: Buffer, gunzip: boolean): Promise<UnpackResult> {
135
+ return new Promise((resolve) => {
136
+ const files: ExtractedFile[] = [];
137
+ let skipped = 0;
138
+ let totalBytes = 0;
139
+
140
+ const extract = tarStream.extract();
141
+
142
+ extract.on("entry", (header, stream, next) => {
143
+ if (
144
+ header.type !== "file" ||
145
+ files.length >= MAX_FILES ||
146
+ !shouldExtract(header.name) ||
147
+ (header.size ?? 0) > MAX_FILE_BYTES ||
148
+ totalBytes + (header.size ?? 0) > MAX_TOTAL_BYTES
149
+ ) {
150
+ if (header.type === "file") skipped++;
151
+ stream.resume();
152
+ next();
153
+ return;
154
+ }
155
+
156
+ const chunks: Buffer[] = [];
157
+ let bytes = 0;
158
+
159
+ stream.on("data", (chunk: Buffer) => {
160
+ bytes += chunk.length;
161
+ if (bytes <= MAX_FILE_BYTES) chunks.push(chunk);
162
+ });
163
+
164
+ stream.on("end", () => {
165
+ try {
166
+ const content = Buffer.concat(chunks).toString("utf-8");
167
+ if (!looksLikeBinary(content)) {
168
+ files.push({ path: header.name, content });
169
+ totalBytes += bytes;
170
+ } else {
171
+ skipped++;
172
+ }
173
+ } catch {
174
+ skipped++;
175
+ }
176
+ next();
177
+ });
178
+
179
+ stream.on("error", () => { skipped++; next(); });
180
+ });
181
+
182
+ extract.on("finish", () => resolve({ files, skipped }));
183
+ extract.on("error", () => resolve({ files, skipped }));
184
+
185
+ const readable = Readable.from(buffer);
186
+ if (gunzip) {
187
+ readable.pipe(zlib.createGunzip()).pipe(extract);
188
+ } else {
189
+ readable.pipe(extract);
190
+ }
191
+ });
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Binary detection heuristic
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function looksLikeBinary(text: string): boolean {
199
+ // Sample the first 1KB — if >10% non-printable chars, treat as binary
200
+ const sample = text.slice(0, 1024);
201
+ let nonPrintable = 0;
202
+ for (let i = 0; i < sample.length; i++) {
203
+ const code = sample.charCodeAt(i);
204
+ if (code < 9 || (code > 13 && code < 32) || code === 127) nonPrintable++;
205
+ }
206
+ return nonPrintable / sample.length > 0.1;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Public API
211
+ // ---------------------------------------------------------------------------
212
+
213
+ export type ArchiveFormat = "zip" | "tar" | "tar-gz";
214
+
215
+ /**
216
+ * Map artifact type string to archive format, if applicable.
217
+ * Returns null for non-archive types.
218
+ */
219
+ export function archiveFormat(artifactType: string, artifactName: string): ArchiveFormat | null {
220
+ switch (artifactType) {
221
+ case "zip":
222
+ case "nupkg":
223
+ case "java-archive":
224
+ case "python-package":
225
+ return "zip";
226
+ case "tarball": {
227
+ const name = artifactName.toLowerCase();
228
+ if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) return "tar-gz";
229
+ return "tar";
230
+ }
231
+ default:
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Unpack an archive buffer and extract readable text files.
238
+ * Returns an empty result for unrecognized or corrupt archives.
239
+ */
240
+ export async function unpackArchive(
241
+ buffer: Buffer,
242
+ format: ArchiveFormat,
243
+ ): Promise<UnpackResult> {
244
+ switch (format) {
245
+ case "zip":
246
+ return unpackZip(buffer);
247
+ case "tar":
248
+ return unpackTar(buffer, false);
249
+ case "tar-gz":
250
+ return unpackTar(buffer, true);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Format extracted files as a text block suitable for inclusion in an LLM prompt.
256
+ */
257
+ export function formatExtractedFiles(result: UnpackResult): string {
258
+ if (result.files.length === 0) {
259
+ return "(no readable text files found in archive)";
260
+ }
261
+
262
+ const sections = result.files.map(
263
+ (f) => `=== ${f.path} ===\n${f.content.trimEnd()}`,
264
+ );
265
+
266
+ const footer = result.skipped > 0
267
+ ? `\n(${result.skipped} binary or oversized file${result.skipped === 1 ? "" : "s"} skipped)`
268
+ : "";
269
+
270
+ return sections.join("\n\n") + footer;
271
+ }