@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// graph-loader.js — Milestone 3, sub-project Server, increment 1.
|
|
2
|
+
//
|
|
3
|
+
// Reads and VERIFIES the `.agentic-security/lineage-graph.json` artifact
|
|
4
|
+
// before `explore` is allowed to serve a single byte of it. Reuses
|
|
5
|
+
// `posture/integrity.js`'s `verifyLastScan` DIRECTLY (per the plan and the
|
|
6
|
+
// root CLAUDE.md's own instruction) — this module does not implement any
|
|
7
|
+
// signature comparison of its own. `verifyLastScan` already uses
|
|
8
|
+
// `crypto.timingSafeEqual` internally.
|
|
9
|
+
//
|
|
10
|
+
// Loaded ONCE at server startup (see bin/agentic-security.js's cmdExplore)
|
|
11
|
+
// and held in memory for the life of the process — this is a read-only,
|
|
12
|
+
// single-scan-snapshot server; a change to the graph on disk mid-session is
|
|
13
|
+
// out of scope for this increment (threat-model doc's own "P0 is
|
|
14
|
+
// read-only" framing).
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import { statePath } from '../posture/state-dir.js';
|
|
18
|
+
import { verifyLastScan } from '../posture/integrity.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} scanRoot
|
|
22
|
+
* @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
|
|
23
|
+
*
|
|
24
|
+
* Four, and only four, distinct failure reasons — each with its own clear
|
|
25
|
+
* message so an operator knows exactly what to do next:
|
|
26
|
+
* - 'missing' — no lineage-graph.json at all. Run a scan with
|
|
27
|
+
* AGENTIC_SECURITY_LINEAGE_DEEP=1 first.
|
|
28
|
+
* - 'unsigned' — the graph exists but its .sig sibling does not
|
|
29
|
+
* (verifyLastScan returns null). Refuse to serve an
|
|
30
|
+
* unverifiable graph.
|
|
31
|
+
* - 'tampered' — the graph exists and has a .sig, but the signature does
|
|
32
|
+
* not match the body (verifyLastScan returns false). The
|
|
33
|
+
* file was modified after signing, or signed under a
|
|
34
|
+
* different install key.
|
|
35
|
+
* - 'malformed' — the body passed signature verification but is not
|
|
36
|
+
* valid JSON. Should not happen from a normal scan; the
|
|
37
|
+
* file may be corrupted on disk after signing.
|
|
38
|
+
*/
|
|
39
|
+
export function loadSignedGraph(scanRoot) {
|
|
40
|
+
const graphPath = statePath(scanRoot, 'lineage-graph.json');
|
|
41
|
+
const sigPath = graphPath + '.sig';
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(graphPath)) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: 'missing',
|
|
47
|
+
message: `No lineage graph found at ${graphPath}. Run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first (e.g. \`AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan\`), then re-run \`agentic-security explore\`.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let body;
|
|
52
|
+
try {
|
|
53
|
+
body = fs.readFileSync(graphPath, 'utf8');
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: 'missing',
|
|
58
|
+
message: `Lineage graph found at ${graphPath} but could not be read: ${e && e.message ? e.message : e}.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const verified = verifyLastScan(body, sigPath);
|
|
63
|
+
if (verified === null) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
reason: 'unsigned',
|
|
67
|
+
message: `Lineage graph at ${graphPath} has no signature file (${sigPath} is missing). Refusing to serve an unverifiable graph. Re-run the scan (AGENTIC_SECURITY_LINEAGE_DEEP=1) to regenerate both files together.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (verified === false) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: 'tampered',
|
|
74
|
+
message: `Lineage graph at ${graphPath} FAILED signature verification — its contents do not match ${sigPath}. The file may have been modified after the scan, or signed under a different install key. Refusing to serve a tampered graph. Re-run the scan to regenerate it.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let graph;
|
|
79
|
+
try {
|
|
80
|
+
graph = JSON.parse(body);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: 'malformed',
|
|
85
|
+
message: `Lineage graph at ${graphPath} passed signature verification but is not valid JSON (${e && e.message ? e.message : e}). This should not happen from a normal scan — the file may be corrupted. Re-run the scan to regenerate it.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: true, graph };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load .agentic-security/lineage-graph.json ONLY when it is genuinely
|
|
94
|
+
* fresh for THIS scan — never merely because a file happens to exist on
|
|
95
|
+
* disk. Shared by every caller that signs or narrates a graph:-derived
|
|
96
|
+
* compliance claim (M4 sub-project 6c's final whole-branch review found
|
|
97
|
+
* the identical staleness gap independently reachable from
|
|
98
|
+
* `attest --obligations` AND `compliance --walkthrough`, and required
|
|
99
|
+
* this predicate to live in exactly one place rather than being
|
|
100
|
+
* copy-pasted per caller — a safety check that drifts between two
|
|
101
|
+
* near-identical inline copies is worse than one shared bug).
|
|
102
|
+
*
|
|
103
|
+
* `.agentic-security/lineage-graph.json` is only rewritten when a scan
|
|
104
|
+
* actually finishes building a graph (`if (scan.lineageGraph)` in
|
|
105
|
+
* bin/agentic-security.js's persistence code) — an ordinary non-deep
|
|
106
|
+
* rescan, or a deep scan whose lineage build fails, leaves whatever file
|
|
107
|
+
* was there from an earlier successful deep scan untouched. Loading that
|
|
108
|
+
* stale graph and joining it to the CURRENT scan's other data would let a
|
|
109
|
+
* caller assert a graph-derived fact (e.g. "transit protected") about
|
|
110
|
+
* code that has since changed.
|
|
111
|
+
*
|
|
112
|
+
* `enabled: true` in `scan.scanHealth.lineageAnalysis` does NOT by itself
|
|
113
|
+
* mean the build succeeded — engine.js sets it the moment
|
|
114
|
+
* AGENTIC_SECURITY_LINEAGE_DEEP=1 is read, before the build even starts,
|
|
115
|
+
* and leaves it `true` even when the build later throws (only `failure`
|
|
116
|
+
* gets set in that case). `requested && enabled` alone therefore still
|
|
117
|
+
* accepts a stale graph after a failed rebuild — reproduced live via the
|
|
118
|
+
* scan's own already-shipped fault-injection fixture
|
|
119
|
+
* (test/lineage-fault-injection.test.js) before this `failure === null`
|
|
120
|
+
* check was added.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} scanRoot
|
|
123
|
+
* @param {object} scan - the parsed last-scan.json for the CURRENT scan
|
|
124
|
+
* @returns {{graph:object|null, fresh:boolean, loaded:ReturnType<typeof loadSignedGraph>}}
|
|
125
|
+
* `fresh` is true only when a signed graph loaded successfully AND this
|
|
126
|
+
* scan's own scanHealth confirms lineage analysis was requested,
|
|
127
|
+
* enabled, and did not fail. `graph` is `loaded.graph` when fresh, else
|
|
128
|
+
* `null` — never the stale file, even when one exists on disk.
|
|
129
|
+
* `loaded` is the raw `loadSignedGraph` result, so a caller can still
|
|
130
|
+
* distinguish "no file at all" from "a file exists but isn't fresh" for
|
|
131
|
+
* its own disclosure message.
|
|
132
|
+
*/
|
|
133
|
+
export function loadFreshLineageGraph(scanRoot, scan) {
|
|
134
|
+
const la = scan?.scanHealth?.lineageAnalysis;
|
|
135
|
+
const requested = la?.requested === true;
|
|
136
|
+
const enabled = la?.enabled === true;
|
|
137
|
+
const failure = la?.failure ?? null;
|
|
138
|
+
const loaded = loadSignedGraph(scanRoot);
|
|
139
|
+
const fresh = loaded.ok && requested && enabled && failure === null;
|
|
140
|
+
return { graph: fresh ? loaded.graph : null, fresh, loaded };
|
|
141
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// http-server.js — Milestone 3, sub-project Server, increment 1.
|
|
2
|
+
//
|
|
3
|
+
// The real node:http server for `agentic-security explore`. Binds ONLY to
|
|
4
|
+
// 127.0.0.1 (never 0.0.0.0, never an omitted host arg). Every request is
|
|
5
|
+
// validated (Host header, then session token) before any route handler
|
|
6
|
+
// ever runs. Every response carries CSP + Cache-Control: no-store and
|
|
7
|
+
// NEVER carries Access-Control-Allow-Origin (CORS stays disabled by the
|
|
8
|
+
// absence of that header — see security.js).
|
|
9
|
+
//
|
|
10
|
+
// This is the first node:http usage anywhere in scanner/src/.
|
|
11
|
+
|
|
12
|
+
import * as http from 'node:http';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { isValidHost, constantTimeEqual, CSP_HEADER_VALUE } from './security.js';
|
|
16
|
+
import { handleScan, handleGraph, handleNode, handleEdge, handleFlow, handleQuery } from './routes.js';
|
|
17
|
+
import { resolveStaticAsset, FRONTEND_ROOT, STATIC_CSP_HEADER_VALUE } from './static-assets.js';
|
|
18
|
+
|
|
19
|
+
// Idle-timeout default. No PRD-specified duration exists (confirmed by the
|
|
20
|
+
// scoping doc's own search) — 30 minutes is a reasoned, disclosed default:
|
|
21
|
+
// long enough that a human actually exploring a graph in a browser tab
|
|
22
|
+
// won't get cut off mid-session, short enough that a forgotten terminal
|
|
23
|
+
// doesn't leave a loopback server (and its session token, printed once to
|
|
24
|
+
// stdout/scrollback) listening indefinitely.
|
|
25
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
// Request-size cap, applied uniformly to every request. Milestone 5's own
|
|
28
|
+
// POST /api/v1/query is the first real consumer of a request body — every
|
|
29
|
+
// pre-M5 GET endpoint has no meaningful body of its own, so this mostly
|
|
30
|
+
// protects that one route today, but the cap applies to any future
|
|
31
|
+
// body-bearing route without retrofitting. 64KB is generous for any
|
|
32
|
+
// header/body this server should ever legitimately receive.
|
|
33
|
+
export const MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
|
34
|
+
|
|
35
|
+
// Session token header. A custom header (never a query param) so the
|
|
36
|
+
// token never ends up in server access logs, browser history, or a
|
|
37
|
+
// Referer header the way a query-string token could.
|
|
38
|
+
export const TOKEN_HEADER = 'x-agentic-security-token';
|
|
39
|
+
|
|
40
|
+
function _log(method, urlPath, status, durationMs) {
|
|
41
|
+
// Metadata only — NEVER log request/response bodies, NEVER log the
|
|
42
|
+
// session token itself, even on a failed-auth attempt (T3/§17.4).
|
|
43
|
+
console.error(`[explore] ${method} ${urlPath} ${status} ${durationMs}ms`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _sendJson(res, status, bodyObj) {
|
|
47
|
+
const body = JSON.stringify(bodyObj);
|
|
48
|
+
if (!res.headersSent) {
|
|
49
|
+
res.writeHead(status, {
|
|
50
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
51
|
+
'Content-Security-Policy': CSP_HEADER_VALUE,
|
|
52
|
+
'Cache-Control': 'no-store',
|
|
53
|
+
'Content-Length': Buffer.byteLength(body),
|
|
54
|
+
// Access-Control-Allow-Origin is intentionally NEVER set anywhere in
|
|
55
|
+
// this file. CORS stays disabled by the absence of this header.
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
res.end(body);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ROUTES = [
|
|
62
|
+
{ method: 'GET', pattern: /^\/api\/v1\/scan\/?$/, handler: (graph) => handleScan(graph) },
|
|
63
|
+
{ method: 'GET', pattern: /^\/api\/v1\/graph\/?$/, handler: (graph) => handleGraph(graph) },
|
|
64
|
+
{ method: 'GET', pattern: /^\/api\/v1\/nodes\/([^/]+)\/?$/, handler: (graph, m) => handleNode(graph, decodeURIComponent(m[1])) },
|
|
65
|
+
{ method: 'GET', pattern: /^\/api\/v1\/edges\/([^/]+)\/?$/, handler: (graph, m) => handleEdge(graph, decodeURIComponent(m[1])) },
|
|
66
|
+
{ method: 'GET', pattern: /^\/api\/v1\/flows\/([^/]+)\/?$/, handler: (graph, m) => handleFlow(graph, decodeURIComponent(m[1])) },
|
|
67
|
+
{ method: 'POST', pattern: /^\/api\/v1\/query\/?$/, handler: (graph, m, body) => handleQuery(graph, body?.filter) },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Serves one resolved static asset from disk. `resolved` is a real
|
|
72
|
+
* `{ok:true, relativePath, contentType}` from resolveStaticAsset() —
|
|
73
|
+
* already vetted against the allowlist, so the only failure mode left here
|
|
74
|
+
* is the file genuinely missing/unreadable on disk (allowlist/inventory
|
|
75
|
+
* drift), which degrades to a 404 rather than a 500.
|
|
76
|
+
*
|
|
77
|
+
* `relativePath` is built entirely from static-assets.js's own restricted
|
|
78
|
+
* character class (no backslash, already traversal-checked) before it ever
|
|
79
|
+
* reaches this function, so joining it onto FRONTEND_ROOT with the platform
|
|
80
|
+
* path.join is safe.
|
|
81
|
+
*/
|
|
82
|
+
function _serveStaticAsset(res, resolved) {
|
|
83
|
+
let body;
|
|
84
|
+
try {
|
|
85
|
+
body = fs.readFileSync(path.join(FRONTEND_ROOT, resolved.relativePath));
|
|
86
|
+
} catch {
|
|
87
|
+
_sendJson(res, 404, { error: 'not found' });
|
|
88
|
+
return 404;
|
|
89
|
+
}
|
|
90
|
+
if (!res.headersSent) {
|
|
91
|
+
res.writeHead(200, {
|
|
92
|
+
'Content-Type': resolved.contentType,
|
|
93
|
+
// The STATIC CSP (same-origin-permitting), NOT the JSON API's
|
|
94
|
+
// `default-src 'none'` — this response is HTML/JS/CSS, not JSON.
|
|
95
|
+
'Content-Security-Policy': STATIC_CSP_HEADER_VALUE,
|
|
96
|
+
'Cache-Control': 'no-store',
|
|
97
|
+
'Content-Length': body.length,
|
|
98
|
+
// Access-Control-Allow-Origin is intentionally NEVER set anywhere in
|
|
99
|
+
// this file, on any response, static or JSON alike.
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
res.end(body);
|
|
103
|
+
return 200;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Creates and starts the `explore` HTTP server. Returns a Promise that
|
|
108
|
+
* resolves once the server is actually listening, with `{server, port}`
|
|
109
|
+
* (the REAL bound port, resolved from the OS when `port: 0`/omitted was
|
|
110
|
+
* requested).
|
|
111
|
+
*
|
|
112
|
+
* @param {object} opts
|
|
113
|
+
* @param {object} opts.graph - the verified, in-memory lineage graph
|
|
114
|
+
* @param {number} [opts.port=0] - 0 lets the OS choose a random free port
|
|
115
|
+
* @param {string} opts.sessionToken - required on every request
|
|
116
|
+
* @param {number} [opts.idleTimeoutMs=DEFAULT_IDLE_TIMEOUT_MS]
|
|
117
|
+
* @param {boolean} [opts.keepOpen=false] - suppresses idle-timeout auto-stop
|
|
118
|
+
*/
|
|
119
|
+
export function createExploreServer({
|
|
120
|
+
graph,
|
|
121
|
+
port = 0,
|
|
122
|
+
sessionToken,
|
|
123
|
+
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
124
|
+
keepOpen = false,
|
|
125
|
+
} = {}) {
|
|
126
|
+
if (!graph || typeof graph !== 'object') {
|
|
127
|
+
throw new Error('createExploreServer requires a graph object');
|
|
128
|
+
}
|
|
129
|
+
if (typeof sessionToken !== 'string' || sessionToken.length < 32) {
|
|
130
|
+
throw new Error('createExploreServer requires a real sessionToken (>= 32 chars)');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let idleTimer = null;
|
|
134
|
+
|
|
135
|
+
function _clearIdleTimer() {
|
|
136
|
+
if (idleTimer) {
|
|
137
|
+
clearTimeout(idleTimer);
|
|
138
|
+
idleTimer = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Reset the idle-auto-stop timer. Only called for requests that pass
|
|
143
|
+
// BOTH the Host and session-token checks — an unauthenticated request
|
|
144
|
+
// (a DNS-rebinding probe, a stray scan) must never be able to keep the
|
|
145
|
+
// server alive indefinitely by hammering it; only genuine, authenticated
|
|
146
|
+
// activity extends the session.
|
|
147
|
+
function _resetIdleTimer() {
|
|
148
|
+
if (keepOpen) return;
|
|
149
|
+
_clearIdleTimer();
|
|
150
|
+
idleTimer = setTimeout(() => {
|
|
151
|
+
try { server.closeAllConnections?.(); } catch { /* best-effort */ }
|
|
152
|
+
server.close();
|
|
153
|
+
}, idleTimeoutMs);
|
|
154
|
+
// Don't let this timer alone keep the Node process alive past a clean
|
|
155
|
+
// shutdown path.
|
|
156
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const server = http.createServer((req, res) => {
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
const method = req.method || 'GET';
|
|
162
|
+
let urlPath = '/';
|
|
163
|
+
try {
|
|
164
|
+
urlPath = new URL(req.url, 'http://internal').pathname;
|
|
165
|
+
} catch {
|
|
166
|
+
urlPath = req.url || '/';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const finish = (status) => _log(method, urlPath, status, Date.now() - start);
|
|
170
|
+
|
|
171
|
+
// The actual bound port. Cheap (no syscall — Node caches the bound
|
|
172
|
+
// address on the server object), so it's safe to read per-request
|
|
173
|
+
// rather than caching it in a closure variable that could theoretically
|
|
174
|
+
// race the 'listening' event.
|
|
175
|
+
const boundPort = server.address()?.port;
|
|
176
|
+
|
|
177
|
+
// 1. Host header validation (T2 defense) — before anything else.
|
|
178
|
+
const hostHeader = req.headers.host;
|
|
179
|
+
if (!isValidHost(hostHeader, boundPort)) {
|
|
180
|
+
_sendJson(res, 400, { error: 'invalid Host header' });
|
|
181
|
+
finish(400);
|
|
182
|
+
req.resume(); // drain and discard any body without processing it
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 1.5. Static-asset serving (Wire's own new surface) — the ONE
|
|
187
|
+
// deliberate, disclosed exception to "every request requires a session
|
|
188
|
+
// token" below. The token travels to the browser via a URL FRAGMENT
|
|
189
|
+
// (`#token=...`), which is NEVER sent to the server by the browser in
|
|
190
|
+
// any HTTP request — so the very first page-load request (GET /) is
|
|
191
|
+
// structurally incapable of carrying it. Every subsequent same-origin
|
|
192
|
+
// fetch() the page itself makes against /api/v1/* still requires the
|
|
193
|
+
// token, completely unchanged from S1.
|
|
194
|
+
//
|
|
195
|
+
// Namespaced away from /api/v1/* so there is no ambiguity: any GET
|
|
196
|
+
// request NOT under /api/ is resolved against the static-asset
|
|
197
|
+
// allowlist and NEVER reaches the token check at all, matching or
|
|
198
|
+
// rejecting with a 404 (never a 403 — a 403 would confirm to a prober
|
|
199
|
+
// that a rejected path exists). This still runs strictly AFTER the
|
|
200
|
+
// Host-header check above (T2 applies uniformly, auth or not — DNS
|
|
201
|
+
// rebinding does not care whether the resource behind it needs a
|
|
202
|
+
// token).
|
|
203
|
+
if (method === 'GET' && !urlPath.startsWith('/api/')) {
|
|
204
|
+
const staticResult = resolveStaticAsset(urlPath);
|
|
205
|
+
if (staticResult.ok) {
|
|
206
|
+
const status = _serveStaticAsset(res, staticResult);
|
|
207
|
+
finish(status);
|
|
208
|
+
} else {
|
|
209
|
+
_sendJson(res, 404, { error: 'not found' });
|
|
210
|
+
finish(404);
|
|
211
|
+
}
|
|
212
|
+
req.resume();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 2. Session-token validation (T3 defense) — required on EVERY
|
|
217
|
+
// request, read or write, per the threat-model doc's own §17.4
|
|
218
|
+
// checklist. Compared in constant time.
|
|
219
|
+
const provided = req.headers[TOKEN_HEADER];
|
|
220
|
+
if (typeof provided !== 'string' || !constantTimeEqual(provided, sessionToken)) {
|
|
221
|
+
_sendJson(res, 401, { error: 'missing or invalid session token' });
|
|
222
|
+
finish(401);
|
|
223
|
+
req.resume();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Genuine, authenticated request — extend the idle-auto-stop window.
|
|
228
|
+
_resetIdleTimer();
|
|
229
|
+
|
|
230
|
+
// 3. Request-size cap, applied uniformly (S1's GET endpoints have no
|
|
231
|
+
// meaningful body; Milestone 5's own POST /api/v1/query is the first
|
|
232
|
+
// real consumer). Milestone 5 also starts accumulating the actual body
|
|
233
|
+
// BYTES (not just the size) — no earlier route needed them.
|
|
234
|
+
let bodySize = 0;
|
|
235
|
+
let bodyChunks = [];
|
|
236
|
+
let aborted = false;
|
|
237
|
+
req.on('data', (chunk) => {
|
|
238
|
+
if (aborted) return;
|
|
239
|
+
bodySize += chunk.length;
|
|
240
|
+
if (bodySize > MAX_REQUEST_BODY_BYTES) {
|
|
241
|
+
aborted = true;
|
|
242
|
+
_sendJson(res, 413, { error: 'request body too large' });
|
|
243
|
+
finish(413);
|
|
244
|
+
// Stop reading further body bytes only once the 413 response has
|
|
245
|
+
// actually been flushed to the client — destroying the request
|
|
246
|
+
// stream immediately can race the response write and cut it off
|
|
247
|
+
// before the client sees it.
|
|
248
|
+
res.once('finish', () => { try { req.destroy(); } catch { /* best-effort */ } });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
bodyChunks.push(chunk);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
req.on('end', () => {
|
|
255
|
+
if (aborted) return;
|
|
256
|
+
|
|
257
|
+
// 4. Route.
|
|
258
|
+
let matched = null;
|
|
259
|
+
let match = null;
|
|
260
|
+
for (const route of ROUTES) {
|
|
261
|
+
if (route.method !== method) continue;
|
|
262
|
+
const m = route.pattern.exec(urlPath);
|
|
263
|
+
if (m) { matched = route; match = m; break; }
|
|
264
|
+
}
|
|
265
|
+
if (!matched) {
|
|
266
|
+
_sendJson(res, 404, { error: 'not found' });
|
|
267
|
+
finish(404);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Milestone 5: parse the body ONLY for a matched route, and only as
|
|
272
|
+
// JSON when non-empty — every pre-M5 GET route still ignores this
|
|
273
|
+
// 3rd argument entirely, so an empty/missing body for them is a
|
|
274
|
+
// no-op, not an error. Disclosed, real, low-risk behavior change
|
|
275
|
+
// (final whole-branch review finding): this JSON-parse pass applies
|
|
276
|
+
// to ANY matched route with a non-empty body, not just the new POST
|
|
277
|
+
// /api/v1/query — a GET request that (unusually) carries a
|
|
278
|
+
// non-JSON-parseable body now gets a clean 400 instead of the
|
|
279
|
+
// pre-M5 behavior (body silently drained, request processed
|
|
280
|
+
// normally). No real client sends a body on a GET here
|
|
281
|
+
// (frontend/src/lib/api-client.js's own fetch() calls never do), so
|
|
282
|
+
// this is not expected to affect any real caller — narrower
|
|
283
|
+
// per-method gating was judged unnecessary complexity for a case
|
|
284
|
+
// with no real-world traffic, but is a real, disclosed option if
|
|
285
|
+
// this ever needs revisiting.
|
|
286
|
+
let body;
|
|
287
|
+
if (bodyChunks.length > 0) {
|
|
288
|
+
try {
|
|
289
|
+
body = JSON.parse(Buffer.concat(bodyChunks).toString('utf8'));
|
|
290
|
+
} catch {
|
|
291
|
+
_sendJson(res, 400, { error: 'malformed JSON request body' });
|
|
292
|
+
finish(400);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const result = matched.handler(graph, match, body);
|
|
299
|
+
_sendJson(res, result.status, result.body);
|
|
300
|
+
finish(result.status);
|
|
301
|
+
} catch {
|
|
302
|
+
_sendJson(res, 500, { error: 'internal error' });
|
|
303
|
+
finish(500);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
req.on('error', () => {
|
|
308
|
+
// A client that aborts mid-request — nothing to respond to.
|
|
309
|
+
aborted = true;
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const onError = (err) => reject(err);
|
|
315
|
+
server.once('error', onError);
|
|
316
|
+
// Bind explicitly to 127.0.0.1 — never omit the host argument (that is
|
|
317
|
+
// NOT the same as 127.0.0.1 on every platform) and never 0.0.0.0.
|
|
318
|
+
server.listen(port, '127.0.0.1', () => {
|
|
319
|
+
server.removeListener('error', onError);
|
|
320
|
+
const actualPort = server.address().port;
|
|
321
|
+
_resetIdleTimer(); // start the idle clock even before the first request
|
|
322
|
+
resolve({ server, port: actualPort });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// routes.js — Milestone 3, sub-project Server, increment 1.
|
|
2
|
+
//
|
|
3
|
+
// Five pure GET-endpoint handlers, each `(graph, ...) -> {status, body}`.
|
|
4
|
+
// No req/res access anywhere in this file — that is what makes these
|
|
5
|
+
// handlers unit-testable without an HTTP layer at all. http-server.js is
|
|
6
|
+
// the only module that touches node:http and calls into these.
|
|
7
|
+
//
|
|
8
|
+
// Every response body is wrapped in `wrapResponse`, which adds the exact
|
|
9
|
+
// envelope fields PRD line 1326 names (quoted in the implementation plan):
|
|
10
|
+
// "base graph/snapshot digest, schema/extension versions, scope, coverage,
|
|
11
|
+
// limitations, and contributing canonical IDs."
|
|
12
|
+
|
|
13
|
+
import { _filterGraph, validateFilterShape } from '../lineage/export-json.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shared response envelope. Maps PRD line 1326's required fields onto the
|
|
17
|
+
* graph's own real fields:
|
|
18
|
+
* - digest -> graph.graphId (the base graph/snapshot digest)
|
|
19
|
+
* - schemaVersion -> graph.schemaVersion
|
|
20
|
+
* - extensions -> graph.extensions (schema/extension versions —
|
|
21
|
+
* today always `{}`; see schema.js)
|
|
22
|
+
* - scope -> graph.scope
|
|
23
|
+
* - coverage -> graph.coverage
|
|
24
|
+
* - limitations -> graph.limitations
|
|
25
|
+
* - canonicalIds -> see the design note below
|
|
26
|
+
*
|
|
27
|
+
* "contributing canonical IDs" design decision (disclosed per the plan):
|
|
28
|
+
* for `handleScan`/`handleGraph`, which describe the WHOLE graph rather
|
|
29
|
+
* than one entity, `canonicalIds` is `null` — the response body for
|
|
30
|
+
* `handleGraph` already IS the full nodes/edges/flows arrays, so echoing
|
|
31
|
+
* every id again here would be pure duplication with no informational
|
|
32
|
+
* gain, and for a large graph would materially bloat the response for
|
|
33
|
+
* zero benefit. For `handleNode`/`handleEdge`, `canonicalIds` is the
|
|
34
|
+
* single id the response is about. For `handleFlow`, `canonicalIds` is
|
|
35
|
+
* the flow's own id PLUS the node/edge ids that flow's evidence draws
|
|
36
|
+
* from (source, sink, edgeIds) — a flow is a derived record referencing
|
|
37
|
+
* several underlying entities, and naming all of them here is genuinely
|
|
38
|
+
* useful metadata a client would otherwise have to re-derive from the
|
|
39
|
+
* flow body itself.
|
|
40
|
+
*/
|
|
41
|
+
export function wrapResponse(data, graph, { canonicalIds = null } = {}) {
|
|
42
|
+
return {
|
|
43
|
+
digest: graph?.graphId ?? null,
|
|
44
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
45
|
+
extensions: graph?.extensions ?? {},
|
|
46
|
+
scope: graph?.scope ?? null,
|
|
47
|
+
coverage: graph?.coverage ?? null,
|
|
48
|
+
limitations: graph?.limitations ?? [],
|
|
49
|
+
canonicalIds,
|
|
50
|
+
data,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _findById(list, id) {
|
|
55
|
+
if (!Array.isArray(list)) return null;
|
|
56
|
+
return list.find((item) => item && item.id === id) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Scan/graph metadata — NOT the full node/edge arrays. */
|
|
60
|
+
export function handleScan(graph) {
|
|
61
|
+
const data = {
|
|
62
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
63
|
+
graphId: graph?.graphId ?? null,
|
|
64
|
+
generatedAt: graph?.generatedAt ?? null,
|
|
65
|
+
scope: graph?.scope ?? null,
|
|
66
|
+
scanHealth: graph?.scanHealth ?? null,
|
|
67
|
+
coverage: graph?.coverage ?? null,
|
|
68
|
+
};
|
|
69
|
+
return { status: 200, body: wrapResponse(data, graph, { canonicalIds: null }) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The full graph document, unfiltered. For a scoped/narrowed projection, use `handleQuery` (`POST /api/v1/query`, Milestone 5) below instead. */
|
|
73
|
+
export function handleGraph(graph) {
|
|
74
|
+
return { status: 200, body: wrapResponse(graph, graph, { canonicalIds: null }) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A deterministic typed projection query — Milestone 5's own
|
|
79
|
+
* `POST /api/v1/query`, the S2 endpoint `handleGraph`'s own header
|
|
80
|
+
* comment named and deferred. `filter` is the exact `{nodeIds, edgeIds}`
|
|
81
|
+
* shape `dataflow export --filter`/`exportGraphJSON` already use — reused
|
|
82
|
+
* via `_filterGraph`, never reimplemented. Final whole-branch review
|
|
83
|
+
* finding: `undefined` (filter omitted entirely) returns the WHOLE graph,
|
|
84
|
+
* identical to `handleGraph` — but `{}` (an empty, well-formed filter
|
|
85
|
+
* object) is NOT the same thing, and does NOT mean "no restriction": both
|
|
86
|
+
* `nodeIds`/`edgeIds` default to empty Sets inside `_filterGraph`, so `{}`
|
|
87
|
+
* narrows the graph down to EMPTY node/edge/flow/dataElement arrays. A
|
|
88
|
+
* caller that wants the whole graph must omit `filter` entirely, never
|
|
89
|
+
* pass `{}` meaning "everything." A malformed filter is a 400, never a
|
|
90
|
+
* thrown exception reaching the caller.
|
|
91
|
+
*/
|
|
92
|
+
export function handleQuery(graph, filter) {
|
|
93
|
+
const check = validateFilterShape(filter);
|
|
94
|
+
if (!check.valid) {
|
|
95
|
+
return { status: 400, body: { error: check.error } };
|
|
96
|
+
}
|
|
97
|
+
return { status: 200, body: wrapResponse(_filterGraph(graph, filter), graph, { canonicalIds: null }) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Look up one node by id. 404 with a clear body if not found. */
|
|
101
|
+
export function handleNode(graph, id) {
|
|
102
|
+
const node = _findById(graph?.nodes, id);
|
|
103
|
+
if (!node) {
|
|
104
|
+
return { status: 404, body: wrapResponse({ error: `node not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
105
|
+
}
|
|
106
|
+
return { status: 200, body: wrapResponse(node, graph, { canonicalIds: [id] }) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Look up one edge by id. 404 with a clear body if not found. */
|
|
110
|
+
export function handleEdge(graph, id) {
|
|
111
|
+
const edge = _findById(graph?.edges, id);
|
|
112
|
+
if (!edge) {
|
|
113
|
+
return { status: 404, body: wrapResponse({ error: `edge not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
114
|
+
}
|
|
115
|
+
return { status: 200, body: wrapResponse(edge, graph, { canonicalIds: [id] }) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Look up one flow by id. 404 with a clear body if not found. */
|
|
119
|
+
export function handleFlow(graph, id) {
|
|
120
|
+
const flow = _findById(graph?.flows, id);
|
|
121
|
+
if (!flow) {
|
|
122
|
+
return { status: 404, body: wrapResponse({ error: `flow not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
123
|
+
}
|
|
124
|
+
const contributing = new Set([id]);
|
|
125
|
+
if (flow.source) contributing.add(flow.source);
|
|
126
|
+
if (flow.sink) contributing.add(flow.sink);
|
|
127
|
+
for (const eid of (flow.edgeIds || [])) contributing.add(eid);
|
|
128
|
+
return { status: 200, body: wrapResponse(flow, graph, { canonicalIds: [...contributing] }) };
|
|
129
|
+
}
|