@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.
- package/dist/agent/debrief-retention.d.ts +12 -0
- package/dist/agent/debrief-retention.d.ts.map +1 -0
- package/dist/agent/debrief-retention.js +27 -0
- package/dist/agent/debrief-retention.js.map +1 -0
- package/dist/agent/envoy-client.d.ts +216 -0
- package/dist/agent/envoy-client.d.ts.map +1 -0
- package/dist/agent/envoy-client.js +266 -0
- package/dist/agent/envoy-client.js.map +1 -0
- package/dist/agent/envoy-registry.d.ts +102 -0
- package/dist/agent/envoy-registry.d.ts.map +1 -0
- package/dist/agent/envoy-registry.js +319 -0
- package/dist/agent/envoy-registry.js.map +1 -0
- package/dist/agent/health-checker.d.ts +39 -0
- package/dist/agent/health-checker.d.ts.map +1 -0
- package/dist/agent/health-checker.js +49 -0
- package/dist/agent/health-checker.js.map +1 -0
- package/dist/agent/mcp-client-manager.d.ts +36 -0
- package/dist/agent/mcp-client-manager.d.ts.map +1 -0
- package/dist/agent/mcp-client-manager.js +106 -0
- package/dist/agent/mcp-client-manager.js.map +1 -0
- package/dist/agent/stale-deployment-detector.d.ts +15 -0
- package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
- package/dist/agent/stale-deployment-detector.js +50 -0
- package/dist/agent/stale-deployment-detector.js.map +1 -0
- package/dist/agent/step-runner.d.ts +31 -0
- package/dist/agent/step-runner.d.ts.map +1 -0
- package/dist/agent/step-runner.js +80 -0
- package/dist/agent/step-runner.js.map +1 -0
- package/dist/agent/synth-agent.d.ts +168 -0
- package/dist/agent/synth-agent.d.ts.map +1 -0
- package/dist/agent/synth-agent.js +1195 -0
- package/dist/agent/synth-agent.js.map +1 -0
- package/dist/api/agent.d.ts +36 -0
- package/dist/api/agent.d.ts.map +1 -0
- package/dist/api/agent.js +867 -0
- package/dist/api/agent.js.map +1 -0
- package/dist/api/api-keys.d.ts +4 -0
- package/dist/api/api-keys.d.ts.map +1 -0
- package/dist/api/api-keys.js +118 -0
- package/dist/api/api-keys.js.map +1 -0
- package/dist/api/artifacts.d.ts +5 -0
- package/dist/api/artifacts.d.ts.map +1 -0
- package/dist/api/artifacts.js +142 -0
- package/dist/api/artifacts.js.map +1 -0
- package/dist/api/auth.d.ts +4 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +280 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/deployments.d.ts +11 -0
- package/dist/api/deployments.d.ts.map +1 -0
- package/dist/api/deployments.js +1098 -0
- package/dist/api/deployments.js.map +1 -0
- package/dist/api/environments.d.ts +5 -0
- package/dist/api/environments.d.ts.map +1 -0
- package/dist/api/environments.js +69 -0
- package/dist/api/environments.js.map +1 -0
- package/dist/api/envoy-reports.d.ts +17 -0
- package/dist/api/envoy-reports.d.ts.map +1 -0
- package/dist/api/envoy-reports.js +138 -0
- package/dist/api/envoy-reports.js.map +1 -0
- package/dist/api/envoys.d.ts +5 -0
- package/dist/api/envoys.d.ts.map +1 -0
- package/dist/api/envoys.js +192 -0
- package/dist/api/envoys.js.map +1 -0
- package/dist/api/fleet.d.ts +11 -0
- package/dist/api/fleet.d.ts.map +1 -0
- package/dist/api/fleet.js +394 -0
- package/dist/api/fleet.js.map +1 -0
- package/dist/api/graph.d.ts +8 -0
- package/dist/api/graph.d.ts.map +1 -0
- package/dist/api/graph.js +355 -0
- package/dist/api/graph.js.map +1 -0
- package/dist/api/health.d.ts +20 -0
- package/dist/api/health.d.ts.map +1 -0
- package/dist/api/health.js +248 -0
- package/dist/api/health.js.map +1 -0
- package/dist/api/idp-schemas.d.ts +41 -0
- package/dist/api/idp-schemas.d.ts.map +1 -0
- package/dist/api/idp-schemas.js +17 -0
- package/dist/api/idp-schemas.js.map +1 -0
- package/dist/api/idp.d.ts +6 -0
- package/dist/api/idp.d.ts.map +1 -0
- package/dist/api/idp.js +620 -0
- package/dist/api/idp.js.map +1 -0
- package/dist/api/intake.d.ts +10 -0
- package/dist/api/intake.d.ts.map +1 -0
- package/dist/api/intake.js +418 -0
- package/dist/api/intake.js.map +1 -0
- package/dist/api/partitions.d.ts +5 -0
- package/dist/api/partitions.d.ts.map +1 -0
- package/dist/api/partitions.js +113 -0
- package/dist/api/partitions.js.map +1 -0
- package/dist/api/progress-event-store.d.ts +62 -0
- package/dist/api/progress-event-store.d.ts.map +1 -0
- package/dist/api/progress-event-store.js +118 -0
- package/dist/api/progress-event-store.js.map +1 -0
- package/dist/api/schemas.d.ts +1000 -0
- package/dist/api/schemas.d.ts.map +1 -0
- package/dist/api/schemas.js +328 -0
- package/dist/api/schemas.js.map +1 -0
- package/dist/api/security-boundaries.d.ts +4 -0
- package/dist/api/security-boundaries.d.ts.map +1 -0
- package/dist/api/security-boundaries.js +32 -0
- package/dist/api/security-boundaries.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +99 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/system.d.ts +75 -0
- package/dist/api/system.d.ts.map +1 -0
- package/dist/api/system.js +558 -0
- package/dist/api/system.js.map +1 -0
- package/dist/api/telemetry.d.ts +4 -0
- package/dist/api/telemetry.d.ts.map +1 -0
- package/dist/api/telemetry.js +24 -0
- package/dist/api/telemetry.js.map +1 -0
- package/dist/api/users.d.ts +4 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/api/users.js +173 -0
- package/dist/api/users.js.map +1 -0
- package/dist/archive-unpacker.d.ts +24 -0
- package/dist/archive-unpacker.d.ts.map +1 -0
- package/dist/archive-unpacker.js +239 -0
- package/dist/archive-unpacker.js.map +1 -0
- package/dist/artifact-analyzer.d.ts +59 -0
- package/dist/artifact-analyzer.d.ts.map +1 -0
- package/dist/artifact-analyzer.js +334 -0
- package/dist/artifact-analyzer.js.map +1 -0
- package/dist/auth/idp/index.d.ts +9 -0
- package/dist/auth/idp/index.d.ts.map +1 -0
- package/dist/auth/idp/index.js +5 -0
- package/dist/auth/idp/index.js.map +1 -0
- package/dist/auth/idp/ldap.d.ts +56 -0
- package/dist/auth/idp/ldap.d.ts.map +1 -0
- package/dist/auth/idp/ldap.js +276 -0
- package/dist/auth/idp/ldap.js.map +1 -0
- package/dist/auth/idp/oidc.d.ts +27 -0
- package/dist/auth/idp/oidc.d.ts.map +1 -0
- package/dist/auth/idp/oidc.js +97 -0
- package/dist/auth/idp/oidc.js.map +1 -0
- package/dist/auth/idp/role-mapping.d.ts +9 -0
- package/dist/auth/idp/role-mapping.d.ts.map +1 -0
- package/dist/auth/idp/role-mapping.js +16 -0
- package/dist/auth/idp/role-mapping.js.map +1 -0
- package/dist/auth/idp/saml.d.ts +40 -0
- package/dist/auth/idp/saml.d.ts.map +1 -0
- package/dist/auth/idp/saml.js +117 -0
- package/dist/auth/idp/saml.js.map +1 -0
- package/dist/auth/idp/types.d.ts +23 -0
- package/dist/auth/idp/types.d.ts.map +1 -0
- package/dist/auth/idp/types.js +2 -0
- package/dist/auth/idp/types.js.map +1 -0
- package/dist/fleet/fleet-executor.d.ts +35 -0
- package/dist/fleet/fleet-executor.d.ts.map +1 -0
- package/dist/fleet/fleet-executor.js +228 -0
- package/dist/fleet/fleet-executor.js.map +1 -0
- package/dist/fleet/fleet-store.d.ts +13 -0
- package/dist/fleet/fleet-store.d.ts.map +1 -0
- package/dist/fleet/fleet-store.js +13 -0
- package/dist/fleet/fleet-store.js.map +1 -0
- package/dist/fleet/index.d.ts +5 -0
- package/dist/fleet/index.d.ts.map +1 -0
- package/dist/fleet/index.js +4 -0
- package/dist/fleet/index.js.map +1 -0
- package/dist/fleet/representative-selector.d.ts +15 -0
- package/dist/fleet/representative-selector.d.ts.map +1 -0
- package/dist/fleet/representative-selector.js +71 -0
- package/dist/fleet/representative-selector.js.map +1 -0
- package/dist/graph/graph-executor.d.ts +36 -0
- package/dist/graph/graph-executor.d.ts.map +1 -0
- package/dist/graph/graph-executor.js +348 -0
- package/dist/graph/graph-executor.js.map +1 -0
- package/dist/graph/graph-inference.d.ts +22 -0
- package/dist/graph/graph-inference.d.ts.map +1 -0
- package/dist/graph/graph-inference.js +149 -0
- package/dist/graph/graph-inference.js.map +1 -0
- package/dist/graph/graph-store.d.ts +12 -0
- package/dist/graph/graph-store.d.ts.map +1 -0
- package/dist/graph/graph-store.js +61 -0
- package/dist/graph/graph-store.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +4 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/index.d.ts +6 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +5 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/intake-processor.d.ts +17 -0
- package/dist/intake/intake-processor.d.ts.map +1 -0
- package/dist/intake/intake-processor.js +99 -0
- package/dist/intake/intake-processor.js.map +1 -0
- package/dist/intake/intake-store.d.ts +7 -0
- package/dist/intake/intake-store.d.ts.map +1 -0
- package/dist/intake/intake-store.js +7 -0
- package/dist/intake/intake-store.js.map +1 -0
- package/dist/intake/registry-poller.d.ts +41 -0
- package/dist/intake/registry-poller.d.ts.map +1 -0
- package/dist/intake/registry-poller.js +202 -0
- package/dist/intake/registry-poller.js.map +1 -0
- package/dist/intake/webhook-handlers.d.ts +37 -0
- package/dist/intake/webhook-handlers.d.ts.map +1 -0
- package/dist/intake/webhook-handlers.js +268 -0
- package/dist/intake/webhook-handlers.js.map +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +88 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/middleware/auth.d.ts +29 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +76 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/permissions.d.ts +13 -0
- package/dist/middleware/permissions.d.ts.map +1 -0
- package/dist/middleware/permissions.js +32 -0
- package/dist/middleware/permissions.js.map +1 -0
- package/dist/pattern-store.d.ts +104 -0
- package/dist/pattern-store.d.ts.map +1 -0
- package/dist/pattern-store.js +299 -0
- package/dist/pattern-store.js.map +1 -0
- package/package.json +54 -0
- package/src/agent/debrief-retention.ts +44 -0
- package/src/agent/envoy-client.ts +474 -0
- package/src/agent/envoy-registry.ts +384 -0
- package/src/agent/health-checker.ts +70 -0
- package/src/agent/mcp-client-manager.ts +131 -0
- package/src/agent/stale-deployment-detector.ts +79 -0
- package/src/agent/step-runner.ts +124 -0
- package/src/agent/synth-agent.ts +1567 -0
- package/src/api/agent.ts +1075 -0
- package/src/api/api-keys.ts +129 -0
- package/src/api/artifacts.ts +194 -0
- package/src/api/auth.ts +320 -0
- package/src/api/deployments.ts +1347 -0
- package/src/api/environments.ts +97 -0
- package/src/api/envoy-reports.ts +159 -0
- package/src/api/envoys.ts +237 -0
- package/src/api/fleet.ts +510 -0
- package/src/api/graph.ts +516 -0
- package/src/api/health.ts +311 -0
- package/src/api/idp-schemas.ts +19 -0
- package/src/api/idp.ts +735 -0
- package/src/api/intake.ts +537 -0
- package/src/api/partitions.ts +147 -0
- package/src/api/progress-event-store.ts +153 -0
- package/src/api/schemas.ts +376 -0
- package/src/api/security-boundaries.ts +54 -0
- package/src/api/settings.ts +118 -0
- package/src/api/system.ts +704 -0
- package/src/api/telemetry.ts +32 -0
- package/src/api/users.ts +210 -0
- package/src/archive-unpacker.ts +271 -0
- package/src/artifact-analyzer.ts +438 -0
- package/src/auth/idp/index.ts +8 -0
- package/src/auth/idp/ldap.ts +340 -0
- package/src/auth/idp/oidc.ts +117 -0
- package/src/auth/idp/role-mapping.ts +22 -0
- package/src/auth/idp/saml.ts +148 -0
- package/src/auth/idp/types.ts +22 -0
- package/src/fleet/fleet-executor.ts +309 -0
- package/src/fleet/fleet-store.ts +13 -0
- package/src/fleet/index.ts +4 -0
- package/src/fleet/representative-selector.ts +83 -0
- package/src/graph/graph-executor.ts +446 -0
- package/src/graph/graph-inference.ts +184 -0
- package/src/graph/graph-store.ts +75 -0
- package/src/graph/index.ts +4 -0
- package/src/index.ts +916 -0
- package/src/intake/index.ts +5 -0
- package/src/intake/intake-processor.ts +111 -0
- package/src/intake/intake-store.ts +7 -0
- package/src/intake/registry-poller.ts +230 -0
- package/src/intake/webhook-handlers.ts +328 -0
- package/src/logger.ts +19 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/server.ts +34 -0
- package/src/mcp/tools.ts +117 -0
- package/src/middleware/auth.ts +103 -0
- package/src/middleware/permissions.ts +35 -0
- package/src/pattern-store.ts +409 -0
- package/tests/agent-mode.test.ts +536 -0
- package/tests/api-handlers.test.ts +1245 -0
- package/tests/archive-unpacker.test.ts +179 -0
- package/tests/artifact-analyzer.test.ts +240 -0
- package/tests/auth-middleware.test.ts +189 -0
- package/tests/decision-diary.test.ts +957 -0
- package/tests/diary-reader.test.ts +782 -0
- package/tests/envoy-client.test.ts +342 -0
- package/tests/envoy-reports.test.ts +156 -0
- package/tests/mcp-tools.test.ts +213 -0
- package/tests/orchestration.test.ts +536 -0
- package/tests/partition-deletion.test.ts +143 -0
- package/tests/partition-isolation.test.ts +830 -0
- package/tests/pattern-store.test.ts +371 -0
- package/tests/rbac-enforcement.test.ts +409 -0
- package/tests/ssrf-validation.test.ts +56 -0
- package/tests/stale-deployment.test.ts +85 -0
- package/tests/step-runner.test.ts +308 -0
- package/tests/ui-journey.test.ts +330 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intake API routes — channel management, webhook receiver, API intake, events.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import type { FastifyInstance } from "fastify";
|
|
7
|
+
import type { IArtifactStore } from "@synth-deploy/core";
|
|
8
|
+
import { requirePermission } from "../middleware/permissions.js";
|
|
9
|
+
import { detectArtifactType } from "../artifact-analyzer.js";
|
|
10
|
+
import type { IntakeChannelStore, IntakeEventStore } from "../intake/intake-store.js";
|
|
11
|
+
import type { IntakeProcessor } from "../intake/intake-processor.js";
|
|
12
|
+
import type { RegistryPoller } from "../intake/registry-poller.js";
|
|
13
|
+
import { parseWebhook, verifyWebhookSignature } from "../intake/webhook-handlers.js";
|
|
14
|
+
import type { RegistryConfig } from "@synth-deploy/core";
|
|
15
|
+
|
|
16
|
+
/** Extract a semver-like version from a filename. Returns null if none found. */
|
|
17
|
+
function extractVersionFromFilename(filename: string): string | null {
|
|
18
|
+
const match = filename.match(/[-_v](\d+\.\d+[\.\d-]*)(?:\.\w+)*$/);
|
|
19
|
+
return match ? match[1] : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Strip version suffix and extension(s) from a filename to get the artifact name. */
|
|
23
|
+
function extractNameFromFilename(filename: string): string {
|
|
24
|
+
let name = filename;
|
|
25
|
+
// Strip common multi-part extensions like .tar.gz, .tar.bz2
|
|
26
|
+
name = name.replace(/\.tar\.\w+$/, "");
|
|
27
|
+
// Strip remaining single extension — only if it's a short, lowercase-alpha extension
|
|
28
|
+
// (≤4 chars, all lowercase letters). This preserves qualifier suffixes like
|
|
29
|
+
// Dockerfile.server, Dockerfile.envoy, nginx.conf.template, etc.
|
|
30
|
+
name = name.replace(/\.[a-z]{1,4}$/, "");
|
|
31
|
+
// Strip trailing version suffix: -1.2.3 or _1.2.3 or -v1.2.3
|
|
32
|
+
name = name.replace(/[-_]v?\d+[\d.\-]*$/, "");
|
|
33
|
+
return name || filename;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerIntakeRoutes(
|
|
37
|
+
app: FastifyInstance,
|
|
38
|
+
channelStore: IntakeChannelStore,
|
|
39
|
+
eventStore: IntakeEventStore,
|
|
40
|
+
processor: IntakeProcessor,
|
|
41
|
+
poller: RegistryPoller,
|
|
42
|
+
artifactStore: IArtifactStore,
|
|
43
|
+
): void {
|
|
44
|
+
// -----------------------------------------------------------------------
|
|
45
|
+
// Channel management (require settings.manage permission)
|
|
46
|
+
// -----------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
// List all intake channels
|
|
49
|
+
app.get(
|
|
50
|
+
"/api/intake/channels",
|
|
51
|
+
{ preHandler: [requirePermission("settings.manage")] },
|
|
52
|
+
async () => {
|
|
53
|
+
const channels = channelStore.list().map((ch) => ({
|
|
54
|
+
...ch,
|
|
55
|
+
// Never expose authToken in list responses — only on creation
|
|
56
|
+
authToken: undefined,
|
|
57
|
+
}));
|
|
58
|
+
return { channels };
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Create a new intake channel
|
|
63
|
+
app.post(
|
|
64
|
+
"/api/intake/channels",
|
|
65
|
+
{ preHandler: [requirePermission("settings.manage")] },
|
|
66
|
+
async (request, reply) => {
|
|
67
|
+
const body = request.body as Record<string, unknown>;
|
|
68
|
+
const type = body.type as string;
|
|
69
|
+
const name = body.name as string;
|
|
70
|
+
const config = (body.config as Record<string, unknown>) ?? {};
|
|
71
|
+
const enabled = body.enabled !== false;
|
|
72
|
+
|
|
73
|
+
if (!type || !name) {
|
|
74
|
+
return reply.status(400).send({ error: "type and name are required" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!["webhook", "registry", "api", "manual"].includes(type)) {
|
|
78
|
+
return reply.status(400).send({ error: "type must be one of: webhook, registry, api, manual" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Generate an auth token for webhook channels
|
|
82
|
+
const authToken = type === "webhook" || type === "api"
|
|
83
|
+
? crypto.randomUUID()
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
const channel = channelStore.create({
|
|
87
|
+
type: type as "webhook" | "registry" | "api" | "manual",
|
|
88
|
+
name,
|
|
89
|
+
enabled,
|
|
90
|
+
config,
|
|
91
|
+
authToken,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Start polling if it's a registry channel and enabled
|
|
95
|
+
if (type === "registry" && enabled) {
|
|
96
|
+
poller.startPolling(channel);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return reply.status(201).send({ channel });
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Update an intake channel
|
|
104
|
+
app.put<{ Params: { id: string } }>(
|
|
105
|
+
"/api/intake/channels/:id",
|
|
106
|
+
{ preHandler: [requirePermission("settings.manage")] },
|
|
107
|
+
async (request, reply) => {
|
|
108
|
+
const channel = channelStore.get(request.params.id);
|
|
109
|
+
if (!channel) {
|
|
110
|
+
return reply.status(404).send({ error: "Channel not found" });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body = request.body as Record<string, unknown>;
|
|
114
|
+
const updates: Record<string, unknown> = {};
|
|
115
|
+
if (body.name !== undefined) updates.name = body.name;
|
|
116
|
+
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
|
117
|
+
if (body.config !== undefined) updates.config = body.config;
|
|
118
|
+
|
|
119
|
+
const updated = channelStore.update(request.params.id, updates as Parameters<typeof channelStore.update>[1]);
|
|
120
|
+
|
|
121
|
+
// Handle registry polling state changes
|
|
122
|
+
if (updated.type === "registry") {
|
|
123
|
+
if (updated.enabled) {
|
|
124
|
+
poller.startPolling(updated);
|
|
125
|
+
} else {
|
|
126
|
+
poller.stopPolling(updated.id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { channel: { ...updated, authToken: undefined } };
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Delete an intake channel
|
|
135
|
+
app.delete<{ Params: { id: string } }>(
|
|
136
|
+
"/api/intake/channels/:id",
|
|
137
|
+
{ preHandler: [requirePermission("settings.manage")] },
|
|
138
|
+
async (request, reply) => {
|
|
139
|
+
const channel = channelStore.get(request.params.id);
|
|
140
|
+
if (!channel) {
|
|
141
|
+
return reply.status(404).send({ error: "Channel not found" });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stop polling if applicable
|
|
145
|
+
if (channel.type === "registry") {
|
|
146
|
+
poller.stopPolling(channel.id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
channelStore.delete(request.params.id);
|
|
150
|
+
return reply.status(204).send();
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Test registry connection
|
|
155
|
+
app.post<{ Params: { id: string } }>(
|
|
156
|
+
"/api/intake/channels/:id/test",
|
|
157
|
+
{ preHandler: [requirePermission("settings.manage")] },
|
|
158
|
+
async (request, reply) => {
|
|
159
|
+
const channel = channelStore.get(request.params.id);
|
|
160
|
+
if (!channel) {
|
|
161
|
+
return reply.status(404).send({ error: "Channel not found" });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (channel.type !== "registry") {
|
|
165
|
+
return reply.status(400).send({ error: "Test is only available for registry channels" });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const config = channel.config as unknown as RegistryConfig;
|
|
169
|
+
try {
|
|
170
|
+
const baseUrl = config.url.replace(/\/$/, "");
|
|
171
|
+
const headers: Record<string, string> = {};
|
|
172
|
+
if (config.credentials) {
|
|
173
|
+
const auth = Buffer.from(`${config.credentials.username}:${config.credentials.password}`).toString("base64");
|
|
174
|
+
headers["Authorization"] = `Basic ${auth}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let testUrl: string;
|
|
178
|
+
switch (config.type) {
|
|
179
|
+
case "docker":
|
|
180
|
+
testUrl = `${baseUrl}/v2/`;
|
|
181
|
+
break;
|
|
182
|
+
case "npm":
|
|
183
|
+
testUrl = baseUrl || "https://registry.npmjs.org/";
|
|
184
|
+
break;
|
|
185
|
+
case "nuget":
|
|
186
|
+
testUrl = `${baseUrl || "https://api.nuget.org/v3"}/index.json`;
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
testUrl = baseUrl;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const res = await fetch(testUrl, { headers, signal: AbortSignal.timeout(10_000) });
|
|
193
|
+
if (res.ok || res.status === 401) {
|
|
194
|
+
// 401 means the registry exists but needs auth — still a valid connection
|
|
195
|
+
return { success: true, status: res.status };
|
|
196
|
+
}
|
|
197
|
+
return { success: false, error: `HTTP ${res.status}: ${res.statusText}` };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return { success: false, error: err instanceof Error ? err.message : "Connection failed" };
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// -----------------------------------------------------------------------
|
|
205
|
+
// Webhook receiver — authenticated by channel token in URL, NOT JWT
|
|
206
|
+
// -----------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
app.post<{ Params: { channelId: string } }>(
|
|
209
|
+
"/api/intake/webhook/:channelId",
|
|
210
|
+
async (request, reply) => {
|
|
211
|
+
const channel = channelStore.get(request.params.channelId);
|
|
212
|
+
if (!channel) {
|
|
213
|
+
return reply.status(404).send({ error: "Channel not found" });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!channel.enabled) {
|
|
217
|
+
return reply.status(403).send({ error: "Channel is disabled" });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (channel.type !== "webhook") {
|
|
221
|
+
return reply.status(400).send({ error: "Channel is not a webhook channel" });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate auth token from query parameter or header
|
|
225
|
+
const queryToken = (request.query as Record<string, string>).token;
|
|
226
|
+
const headerToken = request.headers["x-intake-token"] as string | undefined;
|
|
227
|
+
const token = queryToken || headerToken;
|
|
228
|
+
|
|
229
|
+
if (!token || token !== channel.authToken) {
|
|
230
|
+
return reply.status(401).send({ error: "Invalid or missing intake token" });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Verify webhook signature (GitHub X-Hub-Signature-256, GitLab X-Gitlab-Token)
|
|
234
|
+
const webhookSource = (channel.config as Record<string, unknown>).source as string ?? "generic";
|
|
235
|
+
const webhookSecret = (channel.config as Record<string, unknown>).secretToken as string | undefined;
|
|
236
|
+
const rawBody = typeof request.body === "string" ? request.body : JSON.stringify(request.body);
|
|
237
|
+
const sigResult = verifyWebhookSignature(
|
|
238
|
+
webhookSource,
|
|
239
|
+
webhookSecret,
|
|
240
|
+
request.headers as Record<string, string | string[] | undefined>,
|
|
241
|
+
rawBody,
|
|
242
|
+
);
|
|
243
|
+
if (!sigResult.verified) {
|
|
244
|
+
return reply.status(401).send({ error: sigResult.error ?? "Webhook signature verification failed" });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Create intake event
|
|
248
|
+
const event = eventStore.create({
|
|
249
|
+
channelId: channel.id,
|
|
250
|
+
status: "received",
|
|
251
|
+
payload: (request.body as Record<string, unknown>) ?? {},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Parse the webhook payload
|
|
255
|
+
const source = (channel.config as Record<string, unknown>).source as string ?? "generic";
|
|
256
|
+
const parsed = parseWebhook(source, request.body);
|
|
257
|
+
|
|
258
|
+
if (!parsed) {
|
|
259
|
+
eventStore.update(event.id, {
|
|
260
|
+
status: "failed",
|
|
261
|
+
error: "Could not parse webhook payload",
|
|
262
|
+
processedAt: new Date(),
|
|
263
|
+
});
|
|
264
|
+
return reply.status(422).send({ error: "Could not parse webhook payload", eventId: event.id });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Process the payload asynchronously
|
|
268
|
+
eventStore.update(event.id, { status: "processing" });
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const result = await processor.process(parsed, channel.id);
|
|
272
|
+
eventStore.update(event.id, {
|
|
273
|
+
status: "completed",
|
|
274
|
+
artifactId: result.artifactId,
|
|
275
|
+
processedAt: new Date(),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return reply.status(201).send({
|
|
279
|
+
eventId: event.id,
|
|
280
|
+
artifactId: result.artifactId,
|
|
281
|
+
versionId: result.versionId,
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
eventStore.update(event.id, {
|
|
285
|
+
status: "failed",
|
|
286
|
+
error: err instanceof Error ? err.message : "Processing failed",
|
|
287
|
+
processedAt: new Date(),
|
|
288
|
+
});
|
|
289
|
+
return reply.status(500).send({
|
|
290
|
+
error: "Intake processing failed",
|
|
291
|
+
eventId: event.id,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// -----------------------------------------------------------------------
|
|
298
|
+
// API intake — JWT authenticated, direct artifact submission
|
|
299
|
+
// -----------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
app.post(
|
|
302
|
+
"/api/intake/artifacts",
|
|
303
|
+
{ preHandler: [requirePermission("artifact.create")] },
|
|
304
|
+
async (request, reply) => {
|
|
305
|
+
const body = request.body as Record<string, unknown>;
|
|
306
|
+
const artifactName = body.artifactName as string;
|
|
307
|
+
const artifactType = body.artifactType as string;
|
|
308
|
+
const version = body.version as string;
|
|
309
|
+
const source = (body.source as string) ?? "api";
|
|
310
|
+
const downloadUrl = body.downloadUrl as string | undefined;
|
|
311
|
+
const metadata = (body.metadata as Record<string, unknown>) ?? {};
|
|
312
|
+
|
|
313
|
+
if (!artifactName || !version) {
|
|
314
|
+
return reply.status(400).send({ error: "artifactName and version are required" });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const event = eventStore.create({
|
|
318
|
+
channelId: "api-direct",
|
|
319
|
+
status: "processing",
|
|
320
|
+
payload: body,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const result = await processor.process(
|
|
325
|
+
{
|
|
326
|
+
artifactName,
|
|
327
|
+
artifactType: artifactType ?? "unknown",
|
|
328
|
+
version,
|
|
329
|
+
source,
|
|
330
|
+
downloadUrl,
|
|
331
|
+
metadata,
|
|
332
|
+
},
|
|
333
|
+
"api-direct",
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
eventStore.update(event.id, {
|
|
337
|
+
status: "completed",
|
|
338
|
+
artifactId: result.artifactId,
|
|
339
|
+
processedAt: new Date(),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return reply.status(201).send({
|
|
343
|
+
eventId: event.id,
|
|
344
|
+
artifactId: result.artifactId,
|
|
345
|
+
versionId: result.versionId,
|
|
346
|
+
});
|
|
347
|
+
} catch (err) {
|
|
348
|
+
eventStore.update(event.id, {
|
|
349
|
+
status: "failed",
|
|
350
|
+
error: err instanceof Error ? err.message : "Processing failed",
|
|
351
|
+
processedAt: new Date(),
|
|
352
|
+
});
|
|
353
|
+
return reply.status(500).send({
|
|
354
|
+
error: "Intake processing failed",
|
|
355
|
+
eventId: event.id,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
// -----------------------------------------------------------------------
|
|
362
|
+
// Manual upload — form-based artifact submission via UI
|
|
363
|
+
// -----------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
app.post(
|
|
366
|
+
"/api/intake/manual",
|
|
367
|
+
{ preHandler: [requirePermission("artifact.create")] },
|
|
368
|
+
async (request, reply) => {
|
|
369
|
+
const body = request.body as Record<string, unknown>;
|
|
370
|
+
const artifactName = body.artifactName as string;
|
|
371
|
+
const artifactType = body.artifactType as string;
|
|
372
|
+
const version = body.version as string;
|
|
373
|
+
const source = (body.source as string) ?? "manual-upload";
|
|
374
|
+
const metadata = (body.metadata as Record<string, unknown>) ?? {};
|
|
375
|
+
|
|
376
|
+
if (!artifactName || !artifactType || !version) {
|
|
377
|
+
return reply.status(400).send({ error: "artifactName, artifactType, and version are required" });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const event = eventStore.create({
|
|
381
|
+
channelId: "manual",
|
|
382
|
+
status: "processing",
|
|
383
|
+
payload: body,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const result = await processor.process(
|
|
388
|
+
{
|
|
389
|
+
artifactName,
|
|
390
|
+
artifactType,
|
|
391
|
+
version,
|
|
392
|
+
source,
|
|
393
|
+
metadata,
|
|
394
|
+
},
|
|
395
|
+
"manual",
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
eventStore.update(event.id, {
|
|
399
|
+
status: "completed",
|
|
400
|
+
artifactId: result.artifactId,
|
|
401
|
+
processedAt: new Date(),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return reply.status(201).send({
|
|
405
|
+
eventId: event.id,
|
|
406
|
+
artifactId: result.artifactId,
|
|
407
|
+
versionId: result.versionId,
|
|
408
|
+
});
|
|
409
|
+
} catch (err) {
|
|
410
|
+
eventStore.update(event.id, {
|
|
411
|
+
status: "failed",
|
|
412
|
+
error: err instanceof Error ? err.message : "Processing failed",
|
|
413
|
+
processedAt: new Date(),
|
|
414
|
+
});
|
|
415
|
+
return reply.status(500).send({
|
|
416
|
+
error: "Intake processing failed",
|
|
417
|
+
eventId: event.id,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// -----------------------------------------------------------------------
|
|
424
|
+
// File upload — multipart/form-data artifact submission
|
|
425
|
+
// -----------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
app.post(
|
|
428
|
+
"/api/intake/upload",
|
|
429
|
+
{ preHandler: [requirePermission("artifact.create")] },
|
|
430
|
+
async (request, reply) => {
|
|
431
|
+
let fileBuffer: Buffer | null = null;
|
|
432
|
+
let originalFilename = "";
|
|
433
|
+
let existingArtifactId: string | undefined;
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const parts = request.parts();
|
|
437
|
+
for await (const part of parts) {
|
|
438
|
+
if (part.type === "file") {
|
|
439
|
+
const chunks: Buffer[] = [];
|
|
440
|
+
for await (const chunk of part.file) {
|
|
441
|
+
chunks.push(chunk as Buffer);
|
|
442
|
+
}
|
|
443
|
+
fileBuffer = Buffer.concat(chunks);
|
|
444
|
+
originalFilename = part.filename ?? "unknown";
|
|
445
|
+
} else if (part.type === "field" && part.fieldname === "existingArtifactId") {
|
|
446
|
+
existingArtifactId = String(part.value ?? "").trim() || undefined;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return reply.status(400).send({ error: "Failed to parse multipart upload" });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!fileBuffer || !originalFilename) {
|
|
454
|
+
return reply.status(400).send({ error: "File is required" });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// If attaching to an existing artifact, look it up to get the canonical name
|
|
458
|
+
let artifactName: string;
|
|
459
|
+
let artifactType: string;
|
|
460
|
+
if (existingArtifactId) {
|
|
461
|
+
const existing = artifactStore.get(existingArtifactId);
|
|
462
|
+
if (!existing) {
|
|
463
|
+
return reply.status(404).send({ error: "Artifact not found" });
|
|
464
|
+
}
|
|
465
|
+
artifactName = existing.name;
|
|
466
|
+
artifactType = existing.type;
|
|
467
|
+
} else {
|
|
468
|
+
artifactName = extractNameFromFilename(originalFilename);
|
|
469
|
+
artifactType = detectArtifactType({ name: originalFilename, source: "manual-upload", content: fileBuffer });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const version = extractVersionFromFilename(originalFilename) ?? "unknown";
|
|
473
|
+
|
|
474
|
+
const event = eventStore.create({
|
|
475
|
+
channelId: "manual-upload",
|
|
476
|
+
status: "processing",
|
|
477
|
+
payload: { filename: originalFilename, artifactName, version },
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const result = await processor.process(
|
|
482
|
+
{
|
|
483
|
+
artifactName,
|
|
484
|
+
artifactType,
|
|
485
|
+
version,
|
|
486
|
+
source: "manual-upload",
|
|
487
|
+
metadata: { filename: originalFilename },
|
|
488
|
+
content: fileBuffer,
|
|
489
|
+
},
|
|
490
|
+
"manual-upload",
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
eventStore.update(event.id, {
|
|
494
|
+
status: "completed",
|
|
495
|
+
artifactId: result.artifactId,
|
|
496
|
+
processedAt: new Date(),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
return reply.status(201).send({
|
|
500
|
+
eventId: event.id,
|
|
501
|
+
artifactId: result.artifactId,
|
|
502
|
+
versionId: result.versionId,
|
|
503
|
+
});
|
|
504
|
+
} catch (err) {
|
|
505
|
+
eventStore.update(event.id, {
|
|
506
|
+
status: "failed",
|
|
507
|
+
error: err instanceof Error ? err.message : "Processing failed",
|
|
508
|
+
processedAt: new Date(),
|
|
509
|
+
});
|
|
510
|
+
return reply.status(500).send({
|
|
511
|
+
error: "Upload processing failed",
|
|
512
|
+
eventId: event.id,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
// -----------------------------------------------------------------------
|
|
519
|
+
// Events — view recent intake events
|
|
520
|
+
// -----------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
app.get(
|
|
523
|
+
"/api/intake/events",
|
|
524
|
+
{ preHandler: [requirePermission("artifact.view")] },
|
|
525
|
+
async (request) => {
|
|
526
|
+
const query = request.query as Record<string, string>;
|
|
527
|
+
const channelId = query.channelId;
|
|
528
|
+
const limit = parseInt(query.limit ?? "50", 10);
|
|
529
|
+
|
|
530
|
+
const events = channelId
|
|
531
|
+
? eventStore.listByChannel(channelId, limit)
|
|
532
|
+
: eventStore.listRecent(limit);
|
|
533
|
+
|
|
534
|
+
return { events };
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import type { IPartitionStore, ITelemetryStore, DebriefReader, DebriefWriter } from "@synth-deploy/core";
|
|
3
|
+
import { generateOperationHistory } from "@synth-deploy/core";
|
|
4
|
+
import type { DeploymentStore } from "../agent/synth-agent.js";
|
|
5
|
+
import { CreatePartitionSchema, UpdatePartitionSchema, SetVariablesSchema } from "./schemas.js";
|
|
6
|
+
import { requirePermission } from "../middleware/permissions.js";
|
|
7
|
+
|
|
8
|
+
export function registerPartitionRoutes(
|
|
9
|
+
app: FastifyInstance,
|
|
10
|
+
partitions: IPartitionStore,
|
|
11
|
+
deployments: DeploymentStore,
|
|
12
|
+
debrief: DebriefReader & DebriefWriter,
|
|
13
|
+
telemetry: ITelemetryStore,
|
|
14
|
+
): void {
|
|
15
|
+
// List all partitions
|
|
16
|
+
app.get("/api/partitions", { preHandler: [requirePermission("partition.view")] }, async () => {
|
|
17
|
+
return { partitions: partitions.list() };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Create a partition
|
|
21
|
+
app.post("/api/partitions", { preHandler: [requirePermission("partition.create")] }, async (request, reply) => {
|
|
22
|
+
const parsed = CreatePartitionSchema.safeParse(request.body);
|
|
23
|
+
if (!parsed.success) {
|
|
24
|
+
return reply.status(400).send({ error: "Invalid input", details: parsed.error.format() });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const partition = partitions.create(parsed.data.name.trim(), parsed.data.variables ?? {});
|
|
28
|
+
telemetry.record({ actor: (request.user?.email) ?? "anonymous", action: "partition.created", target: { type: "partition", id: partition.id }, details: { name: parsed.data.name } });
|
|
29
|
+
return reply.status(201).send({ partition });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Get partition by ID
|
|
33
|
+
app.get<{ Params: { id: string } }>("/api/partitions/:id", { preHandler: [requirePermission("partition.view")] }, async (request, reply) => {
|
|
34
|
+
const partition = partitions.get(request.params.id);
|
|
35
|
+
if (!partition) {
|
|
36
|
+
return reply.status(404).send({ error: "Partition not found" });
|
|
37
|
+
}
|
|
38
|
+
return { partition };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Update partition (name)
|
|
42
|
+
app.put<{ Params: { id: string } }>("/api/partitions/:id", { preHandler: [requirePermission("partition.update")] }, async (request, reply) => {
|
|
43
|
+
const parsed = UpdatePartitionSchema.safeParse(request.body);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
return reply.status(400).send({ error: "Invalid input", details: parsed.error.format() });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const partition = partitions.update(request.params.id, {
|
|
50
|
+
name: parsed.data.name?.trim(),
|
|
51
|
+
});
|
|
52
|
+
return { partition };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err instanceof Error && err.message.toLowerCase().includes("not found")) {
|
|
55
|
+
return reply.status(404).send({ error: "Partition not found" });
|
|
56
|
+
}
|
|
57
|
+
app.log.error(err, "Failed to update partition");
|
|
58
|
+
return reply.status(500).send({ error: "Internal server error" });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Delete partition
|
|
63
|
+
app.delete<{ Params: { id: string }; Querystring: { cascade?: string } }>(
|
|
64
|
+
"/api/partitions/:id",
|
|
65
|
+
{ preHandler: [requirePermission("partition.delete")] },
|
|
66
|
+
async (request, reply) => {
|
|
67
|
+
const { id } = request.params;
|
|
68
|
+
const partition = partitions.get(id);
|
|
69
|
+
if (!partition) {
|
|
70
|
+
return reply.status(404).send({ error: "Partition not found" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const linkedDeployments = deployments.getByPartition(id);
|
|
74
|
+
const hasLinks = linkedDeployments.length > 0;
|
|
75
|
+
|
|
76
|
+
if (hasLinks && request.query.cascade !== "true") {
|
|
77
|
+
return reply.status(409).send({
|
|
78
|
+
error: "Partition has linked records",
|
|
79
|
+
deployments: linkedDeployments.length,
|
|
80
|
+
hint: "Add ?cascade=true to force-delete with all linked records",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (hasLinks && request.query.cascade === "true") {
|
|
85
|
+
// Log cascade deletion to Decision Diary
|
|
86
|
+
debrief.record({
|
|
87
|
+
partitionId: id,
|
|
88
|
+
deploymentId: null,
|
|
89
|
+
agent: "server",
|
|
90
|
+
decisionType: "system",
|
|
91
|
+
decision: `Cascade-deleted partition "${partition.name}" with ${linkedDeployments.length} deployment(s)`,
|
|
92
|
+
reasoning: "User requested cascade deletion via ?cascade=true query parameter",
|
|
93
|
+
context: {
|
|
94
|
+
partitionId: id,
|
|
95
|
+
partitionName: partition.name,
|
|
96
|
+
deploymentCount: linkedDeployments.length,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
partitions.delete(id);
|
|
102
|
+
return { deleted: true, cascade: hasLinks };
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Update partition variables
|
|
107
|
+
app.put<{ Params: { id: string } }>(
|
|
108
|
+
"/api/partitions/:id/variables",
|
|
109
|
+
{ preHandler: [requirePermission("partition.update")] },
|
|
110
|
+
async (request, reply) => {
|
|
111
|
+
const parsed = SetVariablesSchema.safeParse(request.body);
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
return reply.status(400).send({ error: "Invalid input", details: parsed.error.format() });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const partition = partitions.setVariables(request.params.id, parsed.data.variables);
|
|
118
|
+
telemetry.record({ actor: (request.user?.email) ?? "anonymous", action: "partition.variables.updated", target: { type: "partition", id: request.params.id }, details: { variableCount: Object.keys(parsed.data.variables).length } });
|
|
119
|
+
return { partition };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err instanceof Error && err.message.toLowerCase().includes("not found")) {
|
|
122
|
+
return reply.status(404).send({ error: "Partition not found" });
|
|
123
|
+
}
|
|
124
|
+
app.log.error(err, "Failed to set partition variables");
|
|
125
|
+
return reply.status(500).send({ error: "Internal server error" });
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Get partition deployment history / operation history
|
|
131
|
+
app.get<{ Params: { id: string } }>(
|
|
132
|
+
"/api/partitions/:id/history",
|
|
133
|
+
{ preHandler: [requirePermission("partition.view")] },
|
|
134
|
+
async (request, reply) => {
|
|
135
|
+
const partition = partitions.get(request.params.id);
|
|
136
|
+
if (!partition) {
|
|
137
|
+
return reply.status(404).send({ error: "Partition not found" });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const partitionDeployments = deployments.getByPartition(request.params.id);
|
|
141
|
+
const partitionEntries = debrief.getByPartition(request.params.id);
|
|
142
|
+
const history = generateOperationHistory(partitionEntries, partitionDeployments);
|
|
143
|
+
|
|
144
|
+
return { history };
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
}
|