@datasynx/agentic-ai-cartography 2.2.0 → 2.3.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/api-bin.js +24 -0
- package/dist/api-bin.js.map +1 -0
- package/dist/{chunk-BNDCY2RI.js → chunk-7QEBFMN4.js} +47 -2441
- package/dist/chunk-7QEBFMN4.js.map +1 -0
- package/dist/chunk-7VZH5PFV.js +1134 -0
- package/dist/chunk-7VZH5PFV.js.map +1 -0
- package/dist/chunk-B2AKONVW.js +2465 -0
- package/dist/chunk-B2AKONVW.js.map +1 -0
- package/dist/cli.js +34 -9
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1282 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +283 -2
- package/dist/index.d.ts +283 -2
- package/dist/index.js +1218 -65
- package/dist/index.js.map +1 -1
- package/dist/mcp-bin.js +2 -1
- package/dist/mcp-bin.js.map +1 -1
- package/package.json +8 -5
- package/scripts/gen-api-schemas.ts +29 -0
- package/scripts/sync-version.mjs +51 -0
- package/server.json +2 -2
- package/dist/chunk-BNDCY2RI.js.map +0 -1
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CartographyDB,
|
|
4
|
+
DEFAULT_TENANT,
|
|
5
|
+
assertSafeBind,
|
|
6
|
+
checkBearer,
|
|
7
|
+
defaultAllowedHosts,
|
|
8
|
+
normalizeTenant
|
|
9
|
+
} from "./chunk-7QEBFMN4.js";
|
|
10
|
+
import {
|
|
11
|
+
ANOMALY_KINDS,
|
|
12
|
+
ANOMALY_SEVERITIES,
|
|
13
|
+
COST_PERIODS,
|
|
14
|
+
defaultConfig
|
|
15
|
+
} from "./chunk-WCR47QA2.js";
|
|
16
|
+
|
|
17
|
+
// src/api/start.ts
|
|
18
|
+
import { readFileSync } from "fs";
|
|
19
|
+
import { dirname, resolve } from "path";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
|
|
22
|
+
// src/store/query.ts
|
|
23
|
+
var NotFoundError = class extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "NotFoundError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var MAX_NODE_LIMIT = 1e3;
|
|
30
|
+
var MAX_DEPTH = 64;
|
|
31
|
+
function clamp(value, min, max) {
|
|
32
|
+
return Math.floor(Math.max(min, Math.min(value, max)));
|
|
33
|
+
}
|
|
34
|
+
var SqliteQueryBackend = class {
|
|
35
|
+
constructor(db, defaultSession = "latest") {
|
|
36
|
+
this.db = db;
|
|
37
|
+
this.defaultSession = defaultSession;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the session id for a request, scoped to `ctx.tenant`. An explicit id must
|
|
41
|
+
* belong to the tenant or it resolves to undefined (cross-tenant isolation); else the
|
|
42
|
+
* newest `discover` session for the tenant. Mirrors `resolveSession` in the MCP server.
|
|
43
|
+
*/
|
|
44
|
+
resolveSession(ctx, sessionId) {
|
|
45
|
+
const requested = sessionId ?? (this.defaultSession === "latest" ? void 0 : this.defaultSession);
|
|
46
|
+
if (requested) {
|
|
47
|
+
const s = this.db.getSession(requested);
|
|
48
|
+
if (s && s.tenant === ctx.tenant) return s.id;
|
|
49
|
+
throw new NotFoundError(`session not found`);
|
|
50
|
+
}
|
|
51
|
+
const latest = this.db.getLatestSession("discover", ctx.tenant) ?? this.db.getLatestSession(void 0, ctx.tenant);
|
|
52
|
+
if (!latest) throw new NotFoundError(`no session available`);
|
|
53
|
+
return latest.id;
|
|
54
|
+
}
|
|
55
|
+
summary(ctx, sessionId) {
|
|
56
|
+
return this.db.getGraphSummary(this.resolveSession(ctx, sessionId));
|
|
57
|
+
}
|
|
58
|
+
nodes(ctx, q, sessionId) {
|
|
59
|
+
const sid = this.resolveSession(ctx, sessionId);
|
|
60
|
+
const limit = clamp(q.limit ?? 100, 1, MAX_NODE_LIMIT);
|
|
61
|
+
const offset = Math.floor(Math.max(0, q.offset ?? 0));
|
|
62
|
+
const total = this.db.getNodeCount(sid);
|
|
63
|
+
if (q.search) {
|
|
64
|
+
const nodes2 = this.db.searchNodes(sid, q.search, { ...q.types ? { types: q.types } : {}, limit });
|
|
65
|
+
return { nodes: nodes2, total: nodes2.length, limit, offset: 0 };
|
|
66
|
+
}
|
|
67
|
+
const nodes = this.db.getNodes(sid, { limit, offset });
|
|
68
|
+
return { nodes, total, limit, offset };
|
|
69
|
+
}
|
|
70
|
+
node(ctx, id, sessionId) {
|
|
71
|
+
return this.db.getNode(this.resolveSession(ctx, sessionId), id);
|
|
72
|
+
}
|
|
73
|
+
dependencies(ctx, id, q, sessionId) {
|
|
74
|
+
const sid = this.resolveSession(ctx, sessionId);
|
|
75
|
+
return this.db.getDependencies(sid, id, {
|
|
76
|
+
direction: q.direction ?? "downstream",
|
|
77
|
+
maxDepth: clamp(q.maxDepth ?? 8, 1, MAX_DEPTH)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
diff(ctx, base, current) {
|
|
81
|
+
for (const id of [base, current]) {
|
|
82
|
+
const s = this.db.getSession(id);
|
|
83
|
+
if (!s || s.tenant !== ctx.tenant) throw new NotFoundError(`session not found`);
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return this.db.diffSessions(base, current);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new NotFoundError(err instanceof Error ? err.message : "diff failed");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
sessions(ctx) {
|
|
92
|
+
return this.db.getSessions(ctx.tenant);
|
|
93
|
+
}
|
|
94
|
+
health(ctx) {
|
|
95
|
+
return { store: "sqlite", sessions: this.db.getSessions(ctx.tenant).length };
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
function createSqliteQueryBackend(db, defaultSession = "latest") {
|
|
99
|
+
return new SqliteQueryBackend(db, defaultSession);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/api/server.ts
|
|
103
|
+
import http from "http";
|
|
104
|
+
|
|
105
|
+
// src/api/tenant.ts
|
|
106
|
+
var TENANT_HEADER = "x-cartograph-tenant";
|
|
107
|
+
var InvalidTenantError = class extends Error {
|
|
108
|
+
constructor() {
|
|
109
|
+
super("invalid tenant");
|
|
110
|
+
this.name = "InvalidTenantError";
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
function resolveTenant(req, url, opts = {}) {
|
|
114
|
+
const headerName = (opts.header ?? TENANT_HEADER).toLowerCase();
|
|
115
|
+
const raw = headerValue(req, headerName) ?? url.searchParams.get("tenant") ?? void 0;
|
|
116
|
+
if (raw === void 0 || raw === "") {
|
|
117
|
+
return { tenant: opts.defaultTenant ?? DEFAULT_TENANT };
|
|
118
|
+
}
|
|
119
|
+
if (raw.trim().length > 128) {
|
|
120
|
+
throw new InvalidTenantError();
|
|
121
|
+
}
|
|
122
|
+
const normalized = normalizeTenant(raw);
|
|
123
|
+
if (normalized === DEFAULT_TENANT && raw.trim() !== DEFAULT_TENANT) {
|
|
124
|
+
throw new InvalidTenantError();
|
|
125
|
+
}
|
|
126
|
+
return { tenant: normalized };
|
|
127
|
+
}
|
|
128
|
+
function headerValue(req, name) {
|
|
129
|
+
const v = req.headers[name];
|
|
130
|
+
if (Array.isArray(v)) return v[0];
|
|
131
|
+
return v;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/api/schemas.ts
|
|
135
|
+
import { z } from "zod";
|
|
136
|
+
var DIRECTIONS = ["downstream", "upstream", "both"];
|
|
137
|
+
var CostSchema = z.object({
|
|
138
|
+
amount: z.number(),
|
|
139
|
+
currency: z.string(),
|
|
140
|
+
period: z.enum(COST_PERIODS),
|
|
141
|
+
source: z.string().optional()
|
|
142
|
+
});
|
|
143
|
+
var NodeSchema = z.object({
|
|
144
|
+
id: z.string(),
|
|
145
|
+
type: z.string(),
|
|
146
|
+
name: z.string(),
|
|
147
|
+
confidence: z.number(),
|
|
148
|
+
domain: z.string().optional(),
|
|
149
|
+
subDomain: z.string().optional(),
|
|
150
|
+
qualityScore: z.number().optional(),
|
|
151
|
+
owner: z.string().optional(),
|
|
152
|
+
cost: CostSchema.optional(),
|
|
153
|
+
tags: z.array(z.string())
|
|
154
|
+
});
|
|
155
|
+
var EdgeSchema = z.object({
|
|
156
|
+
sourceId: z.string(),
|
|
157
|
+
targetId: z.string(),
|
|
158
|
+
relationship: z.string(),
|
|
159
|
+
confidence: z.number(),
|
|
160
|
+
evidence: z.string()
|
|
161
|
+
});
|
|
162
|
+
var AnomalySchema = z.object({
|
|
163
|
+
nodeId: z.string(),
|
|
164
|
+
kind: z.enum(ANOMALY_KINDS),
|
|
165
|
+
severity: z.enum(ANOMALY_SEVERITIES),
|
|
166
|
+
reason: z.string()
|
|
167
|
+
});
|
|
168
|
+
var TopConnectedSchema = z.object({
|
|
169
|
+
id: z.string(),
|
|
170
|
+
name: z.string(),
|
|
171
|
+
type: z.string(),
|
|
172
|
+
degree: z.number().int()
|
|
173
|
+
});
|
|
174
|
+
var CostByDomainSchema = z.object({
|
|
175
|
+
domain: z.string(),
|
|
176
|
+
currency: z.string(),
|
|
177
|
+
period: z.string(),
|
|
178
|
+
total: z.number(),
|
|
179
|
+
nodes: z.number().int()
|
|
180
|
+
});
|
|
181
|
+
var CostByOwnerSchema = z.object({
|
|
182
|
+
owner: z.string(),
|
|
183
|
+
currency: z.string(),
|
|
184
|
+
period: z.string(),
|
|
185
|
+
total: z.number(),
|
|
186
|
+
nodes: z.number().int()
|
|
187
|
+
});
|
|
188
|
+
var SummaryResponse = z.object({
|
|
189
|
+
sessionId: z.string(),
|
|
190
|
+
totals: z.object({ nodes: z.number().int(), edges: z.number().int() }),
|
|
191
|
+
nodesByType: z.record(z.string(), z.number().int()),
|
|
192
|
+
nodesByDomain: z.record(z.string(), z.number().int()),
|
|
193
|
+
edgesByRelationship: z.record(z.string(), z.number().int()),
|
|
194
|
+
topConnected: z.array(TopConnectedSchema),
|
|
195
|
+
anomalies: z.array(AnomalySchema),
|
|
196
|
+
contributors: z.number().int(),
|
|
197
|
+
costByDomain: z.array(CostByDomainSchema),
|
|
198
|
+
costByOwner: z.array(CostByOwnerSchema),
|
|
199
|
+
costCoverage: z.object({ withCost: z.number().int(), total: z.number().int() })
|
|
200
|
+
});
|
|
201
|
+
var NodesResponse = z.object({
|
|
202
|
+
nodes: z.array(NodeSchema),
|
|
203
|
+
total: z.number().int(),
|
|
204
|
+
limit: z.number().int(),
|
|
205
|
+
offset: z.number().int()
|
|
206
|
+
});
|
|
207
|
+
var DependencyNodeSchema = NodeSchema.extend({ depth: z.number().int() });
|
|
208
|
+
var DependenciesResponse = z.object({
|
|
209
|
+
root: NodeSchema.optional(),
|
|
210
|
+
direction: z.enum(DIRECTIONS),
|
|
211
|
+
maxDepth: z.number().int(),
|
|
212
|
+
nodes: z.array(DependencyNodeSchema),
|
|
213
|
+
edges: z.array(EdgeSchema)
|
|
214
|
+
});
|
|
215
|
+
var SessionEndpointSchema = z.object({
|
|
216
|
+
sessionId: z.string(),
|
|
217
|
+
startedAt: z.string(),
|
|
218
|
+
nodeCount: z.number().int(),
|
|
219
|
+
edgeCount: z.number().int()
|
|
220
|
+
});
|
|
221
|
+
var NodeChangeSchema = z.object({
|
|
222
|
+
id: z.string(),
|
|
223
|
+
changedFields: z.array(z.string()),
|
|
224
|
+
confidenceDelta: z.number()
|
|
225
|
+
});
|
|
226
|
+
var DiffResponse = z.object({
|
|
227
|
+
base: SessionEndpointSchema,
|
|
228
|
+
current: SessionEndpointSchema,
|
|
229
|
+
summary: z.object({
|
|
230
|
+
nodesAdded: z.number().int(),
|
|
231
|
+
nodesRemoved: z.number().int(),
|
|
232
|
+
nodesChanged: z.number().int(),
|
|
233
|
+
edgesAdded: z.number().int(),
|
|
234
|
+
edgesRemoved: z.number().int()
|
|
235
|
+
}),
|
|
236
|
+
nodes: z.object({
|
|
237
|
+
added: z.array(NodeSchema),
|
|
238
|
+
removed: z.array(NodeSchema),
|
|
239
|
+
changed: z.array(NodeChangeSchema),
|
|
240
|
+
unchanged: z.number().int()
|
|
241
|
+
}),
|
|
242
|
+
edges: z.object({
|
|
243
|
+
added: z.array(EdgeSchema),
|
|
244
|
+
removed: z.array(EdgeSchema),
|
|
245
|
+
unchanged: z.number().int()
|
|
246
|
+
}),
|
|
247
|
+
anomalies: z.object({ added: z.array(AnomalySchema) })
|
|
248
|
+
});
|
|
249
|
+
var SessionSchema = z.object({
|
|
250
|
+
id: z.string(),
|
|
251
|
+
mode: z.literal("discover"),
|
|
252
|
+
startedAt: z.string(),
|
|
253
|
+
completedAt: z.string().optional(),
|
|
254
|
+
name: z.string().optional(),
|
|
255
|
+
tenant: z.string(),
|
|
256
|
+
lastScannedAt: z.string().optional()
|
|
257
|
+
});
|
|
258
|
+
var SessionsResponse = z.object({ sessions: z.array(SessionSchema) });
|
|
259
|
+
var HealthResponse = z.object({
|
|
260
|
+
status: z.literal("ok"),
|
|
261
|
+
version: z.string(),
|
|
262
|
+
store: z.literal("sqlite"),
|
|
263
|
+
sessions: z.number().int()
|
|
264
|
+
});
|
|
265
|
+
var ErrorResponse = z.object({
|
|
266
|
+
error: z.string(),
|
|
267
|
+
code: z.string().optional()
|
|
268
|
+
});
|
|
269
|
+
var API_SCHEMAS = {
|
|
270
|
+
Node: NodeSchema,
|
|
271
|
+
Edge: EdgeSchema,
|
|
272
|
+
Anomaly: AnomalySchema,
|
|
273
|
+
Summary: SummaryResponse,
|
|
274
|
+
Nodes: NodesResponse,
|
|
275
|
+
Dependencies: DependenciesResponse,
|
|
276
|
+
Diff: DiffResponse,
|
|
277
|
+
Session: SessionSchema,
|
|
278
|
+
Sessions: SessionsResponse,
|
|
279
|
+
Health: HealthResponse,
|
|
280
|
+
Error: ErrorResponse
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// src/api/rest.ts
|
|
284
|
+
function toApiNode(n) {
|
|
285
|
+
const out = { id: n.id, type: n.type, name: n.name, confidence: n.confidence, tags: n.tags };
|
|
286
|
+
if (n.domain !== void 0) out["domain"] = n.domain;
|
|
287
|
+
if (n.subDomain !== void 0) out["subDomain"] = n.subDomain;
|
|
288
|
+
if (n.qualityScore !== void 0) out["qualityScore"] = n.qualityScore;
|
|
289
|
+
if (n.owner !== void 0) out["owner"] = n.owner;
|
|
290
|
+
if (n.cost !== void 0) out["cost"] = n.cost;
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
function toApiEdge(e) {
|
|
294
|
+
return { sourceId: e.sourceId, targetId: e.targetId, relationship: e.relationship, confidence: e.confidence, evidence: e.evidence };
|
|
295
|
+
}
|
|
296
|
+
function toApiSession(s) {
|
|
297
|
+
const out = { id: s.id, mode: s.mode, startedAt: s.startedAt, tenant: s.tenant };
|
|
298
|
+
if (s.completedAt !== void 0) out["completedAt"] = s.completedAt;
|
|
299
|
+
if (s.name !== void 0) out["name"] = s.name;
|
|
300
|
+
if (s.lastScannedAt !== void 0) out["lastScannedAt"] = s.lastScannedAt;
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function toApiAnomaly(a) {
|
|
304
|
+
return { nodeId: a.nodeId, kind: a.kind, severity: a.severity, reason: a.reason };
|
|
305
|
+
}
|
|
306
|
+
function projectDependencies(r) {
|
|
307
|
+
return {
|
|
308
|
+
...r.root ? { root: toApiNode(r.root) } : {},
|
|
309
|
+
direction: r.direction,
|
|
310
|
+
maxDepth: r.maxDepth,
|
|
311
|
+
nodes: r.nodes.map((n) => ({ ...toApiNode(n), depth: n.depth })),
|
|
312
|
+
edges: r.edges.map(toApiEdge)
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function projectDiff(diff) {
|
|
316
|
+
return {
|
|
317
|
+
base: { sessionId: diff.base.sessionId, startedAt: diff.base.startedAt, nodeCount: diff.base.nodeCount, edgeCount: diff.base.edgeCount },
|
|
318
|
+
current: { sessionId: diff.current.sessionId, startedAt: diff.current.startedAt, nodeCount: diff.current.nodeCount, edgeCount: diff.current.edgeCount },
|
|
319
|
+
summary: diff.summary,
|
|
320
|
+
nodes: {
|
|
321
|
+
added: diff.nodes.added.map(toApiNode),
|
|
322
|
+
removed: diff.nodes.removed.map(toApiNode),
|
|
323
|
+
changed: diff.nodes.changed.map((c) => ({ id: c.id, changedFields: c.changedFields, confidenceDelta: c.confidenceDelta })),
|
|
324
|
+
unchanged: diff.nodes.unchanged
|
|
325
|
+
},
|
|
326
|
+
edges: {
|
|
327
|
+
added: diff.edges.added.map(toApiEdge),
|
|
328
|
+
removed: diff.edges.removed.map(toApiEdge),
|
|
329
|
+
unchanged: diff.edges.unchanged
|
|
330
|
+
},
|
|
331
|
+
anomalies: { added: diff.anomalies.added.map(toApiAnomaly) }
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function ok(body) {
|
|
335
|
+
return { status: 200, body };
|
|
336
|
+
}
|
|
337
|
+
function badRequest(error) {
|
|
338
|
+
return { status: 400, body: { error } };
|
|
339
|
+
}
|
|
340
|
+
function notFound(error = "not found") {
|
|
341
|
+
return { status: 404, body: { error } };
|
|
342
|
+
}
|
|
343
|
+
function guard(fn) {
|
|
344
|
+
try {
|
|
345
|
+
return fn();
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (err instanceof NotFoundError) return notFound(err.message);
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function validateOut(schema, body) {
|
|
352
|
+
if (process.env["NODE_ENV"] !== "production") {
|
|
353
|
+
const r = schema.safeParse(body);
|
|
354
|
+
if (!r.success) throw new Error(`API response failed its own schema contract: ${r.error.message}`);
|
|
355
|
+
}
|
|
356
|
+
return body;
|
|
357
|
+
}
|
|
358
|
+
function intParam(url, name) {
|
|
359
|
+
const raw = url.searchParams.get(name);
|
|
360
|
+
if (raw === null || raw.trim() === "") return void 0;
|
|
361
|
+
const n = Number(raw);
|
|
362
|
+
return Number.isInteger(n) ? n : void 0;
|
|
363
|
+
}
|
|
364
|
+
function sessionParam(url) {
|
|
365
|
+
return url.searchParams.get("session") ?? void 0;
|
|
366
|
+
}
|
|
367
|
+
function handleSummary(ctx, url, d) {
|
|
368
|
+
return guard(() => ok(validateOut(SummaryResponse, d.backend.summary(ctx, sessionParam(url)))));
|
|
369
|
+
}
|
|
370
|
+
function handleNodes(ctx, url, d) {
|
|
371
|
+
return guard(() => {
|
|
372
|
+
const search = url.searchParams.get("search") ?? void 0;
|
|
373
|
+
const typesRaw = url.searchParams.get("types");
|
|
374
|
+
const types = typesRaw ? typesRaw.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
375
|
+
const limit = intParam(url, "limit");
|
|
376
|
+
const offset = intParam(url, "offset");
|
|
377
|
+
const r = d.backend.nodes(
|
|
378
|
+
ctx,
|
|
379
|
+
{ ...search ? { search } : {}, ...types ? { types } : {}, ...limit !== void 0 ? { limit } : {}, ...offset !== void 0 ? { offset } : {} },
|
|
380
|
+
sessionParam(url)
|
|
381
|
+
);
|
|
382
|
+
return ok(validateOut(NodesResponse, { nodes: r.nodes.map(toApiNode), total: r.total, limit: r.limit, offset: r.offset }));
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
function handleDependencies(ctx, id, url, d) {
|
|
386
|
+
const directionRaw = url.searchParams.get("direction");
|
|
387
|
+
if (directionRaw !== null && !DIRECTIONS.includes(directionRaw)) {
|
|
388
|
+
return badRequest(`direction must be one of ${DIRECTIONS.join(", ")}`);
|
|
389
|
+
}
|
|
390
|
+
return guard(() => {
|
|
391
|
+
const direction = directionRaw ?? void 0;
|
|
392
|
+
const maxDepth = intParam(url, "maxDepth");
|
|
393
|
+
const r = d.backend.dependencies(
|
|
394
|
+
ctx,
|
|
395
|
+
id,
|
|
396
|
+
{ ...direction ? { direction } : {}, ...maxDepth !== void 0 ? { maxDepth } : {} },
|
|
397
|
+
sessionParam(url)
|
|
398
|
+
);
|
|
399
|
+
return ok(validateOut(DependenciesResponse, projectDependencies(r)));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function handleDiff(ctx, url, d) {
|
|
403
|
+
const base = url.searchParams.get("base");
|
|
404
|
+
const current = url.searchParams.get("current");
|
|
405
|
+
if (!base || !current) return badRequest("both `base` and `current` query params are required");
|
|
406
|
+
return guard(() => {
|
|
407
|
+
const diff = d.backend.diff(ctx, base, current);
|
|
408
|
+
return ok(validateOut(DiffResponse, projectDiff(diff)));
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
function handleSessions(ctx, d) {
|
|
412
|
+
return guard(() => ok(validateOut(SessionsResponse, { sessions: d.backend.sessions(ctx).map(toApiSession) })));
|
|
413
|
+
}
|
|
414
|
+
function handleHealth(ctx, d) {
|
|
415
|
+
const h = d.backend.health(ctx);
|
|
416
|
+
return ok(validateOut(HealthResponse, { status: "ok", version: d.version, store: h.store, sessions: h.sessions }));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/api/openapi.ts
|
|
420
|
+
function defOf(schema) {
|
|
421
|
+
return schema.def ?? {};
|
|
422
|
+
}
|
|
423
|
+
function unwrapOptional(schema) {
|
|
424
|
+
const def = defOf(schema);
|
|
425
|
+
if ((def.type === "optional" || def.type === "nullable") && def.innerType) {
|
|
426
|
+
return { inner: def.innerType, optional: true };
|
|
427
|
+
}
|
|
428
|
+
return { inner: schema, optional: false };
|
|
429
|
+
}
|
|
430
|
+
function zodToJsonSchema(schema) {
|
|
431
|
+
const def = defOf(schema);
|
|
432
|
+
switch (def.type) {
|
|
433
|
+
case "string":
|
|
434
|
+
return { type: "string" };
|
|
435
|
+
case "number": {
|
|
436
|
+
const isInt = (def.checks ?? []).some((c) => c._zod?.def?.check === "number_format");
|
|
437
|
+
return { type: isInt ? "integer" : "number" };
|
|
438
|
+
}
|
|
439
|
+
case "boolean":
|
|
440
|
+
return { type: "boolean" };
|
|
441
|
+
case "literal": {
|
|
442
|
+
const values = def.values ?? [];
|
|
443
|
+
return values.length === 1 ? { const: values[0] } : { enum: values };
|
|
444
|
+
}
|
|
445
|
+
case "enum":
|
|
446
|
+
return { type: "string", enum: Object.values(def.entries ?? {}) };
|
|
447
|
+
case "array":
|
|
448
|
+
return { type: "array", items: def.element ? zodToJsonSchema(def.element) : {} };
|
|
449
|
+
case "record":
|
|
450
|
+
return { type: "object", additionalProperties: def.valueType ? zodToJsonSchema(def.valueType) : true };
|
|
451
|
+
case "optional":
|
|
452
|
+
case "nullable":
|
|
453
|
+
return def.innerType ? zodToJsonSchema(def.innerType) : {};
|
|
454
|
+
case "object": {
|
|
455
|
+
const shape = def.shape ?? {};
|
|
456
|
+
const properties = {};
|
|
457
|
+
const required = [];
|
|
458
|
+
for (const key of Object.keys(shape)) {
|
|
459
|
+
const { inner, optional } = unwrapOptional(shape[key]);
|
|
460
|
+
properties[key] = zodToJsonSchema(inner);
|
|
461
|
+
if (!optional) required.push(key);
|
|
462
|
+
}
|
|
463
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
464
|
+
}
|
|
465
|
+
default:
|
|
466
|
+
throw new Error(`zodToJsonSchema: unsupported zod construct "${def.type ?? "unknown"}". Extend src/api/openapi.ts.`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
var TENANT_PARAM = {
|
|
470
|
+
name: "tenant",
|
|
471
|
+
in: "query",
|
|
472
|
+
required: false,
|
|
473
|
+
description: 'Tenant/org scope (also accepted via the X-Cartograph-Tenant header). Defaults to "local".',
|
|
474
|
+
schema: { type: "string" }
|
|
475
|
+
};
|
|
476
|
+
var SESSION_PARAM = {
|
|
477
|
+
name: "session",
|
|
478
|
+
in: "query",
|
|
479
|
+
required: false,
|
|
480
|
+
description: "Session id to query, or omit for the latest discovery session.",
|
|
481
|
+
schema: { type: "string" }
|
|
482
|
+
};
|
|
483
|
+
function errorResponses() {
|
|
484
|
+
const err = { description: "Error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } };
|
|
485
|
+
return { "400": { ...err, description: "Bad request" }, "401": { ...err, description: "Unauthorized" }, "404": { ...err, description: "Not found" } };
|
|
486
|
+
}
|
|
487
|
+
function ok2(ref, description) {
|
|
488
|
+
return { description, content: { "application/json": { schema: { $ref: `#/components/schemas/${ref}` } } } };
|
|
489
|
+
}
|
|
490
|
+
function buildOpenApiDocument(opts) {
|
|
491
|
+
const schemas = {};
|
|
492
|
+
for (const [name, schema] of Object.entries(API_SCHEMAS)) {
|
|
493
|
+
schemas[name] = zodToJsonSchema(schema);
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
openapi: "3.1.0",
|
|
497
|
+
info: {
|
|
498
|
+
title: "Cartograph API",
|
|
499
|
+
version: opts.version,
|
|
500
|
+
description: "Read-only REST API over the discovered infrastructure/agentic-AI topology. Every endpoint is tenant-scoped and bearer-authenticated."
|
|
501
|
+
},
|
|
502
|
+
servers: [{ url: "/" }],
|
|
503
|
+
security: [{ bearerAuth: [] }],
|
|
504
|
+
components: {
|
|
505
|
+
securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } },
|
|
506
|
+
schemas
|
|
507
|
+
},
|
|
508
|
+
paths: {
|
|
509
|
+
"/v1/health": {
|
|
510
|
+
get: {
|
|
511
|
+
summary: "Liveness + store/coverage probe",
|
|
512
|
+
security: [],
|
|
513
|
+
responses: { "200": ok2("Health", "Service health") }
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
"/v1/openapi.json": {
|
|
517
|
+
get: {
|
|
518
|
+
summary: "This OpenAPI document",
|
|
519
|
+
security: [],
|
|
520
|
+
responses: { "200": { description: "OpenAPI 3.1 document", content: { "application/json": { schema: { type: "object" } } } } }
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
"/v1/summary": {
|
|
524
|
+
get: {
|
|
525
|
+
summary: "Low-token topology aggregate for the resolved session",
|
|
526
|
+
parameters: [SESSION_PARAM, TENANT_PARAM],
|
|
527
|
+
responses: { "200": ok2("Summary", "Topology summary"), ...errorResponses() }
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
"/v1/nodes": {
|
|
531
|
+
get: {
|
|
532
|
+
summary: "List/search/paginate nodes",
|
|
533
|
+
parameters: [
|
|
534
|
+
{ name: "search", in: "query", required: false, description: "Lexical/semantic search anchor.", schema: { type: "string" } },
|
|
535
|
+
{ name: "types", in: "query", required: false, description: "Comma-separated node-type filter.", schema: { type: "string" } },
|
|
536
|
+
{ name: "limit", in: "query", required: false, description: "Page size (default 100, max 1000).", schema: { type: "integer" } },
|
|
537
|
+
{ name: "offset", in: "query", required: false, description: "Page offset (ignored for search).", schema: { type: "integer" } },
|
|
538
|
+
SESSION_PARAM,
|
|
539
|
+
TENANT_PARAM
|
|
540
|
+
],
|
|
541
|
+
responses: { "200": ok2("Nodes", "A page of nodes"), ...errorResponses() }
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
"/v1/nodes/{id}/dependencies": {
|
|
545
|
+
get: {
|
|
546
|
+
summary: "Dependency traversal from a node",
|
|
547
|
+
parameters: [
|
|
548
|
+
{ name: "id", in: "path", required: true, description: 'Node id ("{type}:{id}").', schema: { type: "string" } },
|
|
549
|
+
{ name: "direction", in: "query", required: false, description: "downstream | upstream | both (default downstream).", schema: { type: "string", enum: ["downstream", "upstream", "both"] } },
|
|
550
|
+
{ name: "maxDepth", in: "query", required: false, description: "Traversal depth (default 8, max 64).", schema: { type: "integer" } },
|
|
551
|
+
SESSION_PARAM,
|
|
552
|
+
TENANT_PARAM
|
|
553
|
+
],
|
|
554
|
+
responses: { "200": ok2("Dependencies", "Traversal result"), ...errorResponses() }
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
"/v1/diff": {
|
|
558
|
+
get: {
|
|
559
|
+
summary: "Compare two sessions (drift)",
|
|
560
|
+
parameters: [
|
|
561
|
+
{ name: "base", in: "query", required: true, description: "Base session id.", schema: { type: "string" } },
|
|
562
|
+
{ name: "current", in: "query", required: true, description: "Current session id.", schema: { type: "string" } },
|
|
563
|
+
TENANT_PARAM
|
|
564
|
+
],
|
|
565
|
+
responses: { "200": ok2("Diff", "Topology delta"), ...errorResponses() }
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
"/v1/sessions": {
|
|
569
|
+
get: {
|
|
570
|
+
summary: "List discovery sessions for the tenant",
|
|
571
|
+
parameters: [TENANT_PARAM],
|
|
572
|
+
responses: { "200": ok2("Sessions", "Sessions"), ...errorResponses() }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/api/graphql.ts
|
|
580
|
+
var SDL = `# Cartograph read-only GraphQL API (4.2). Mirrors the REST surface.
|
|
581
|
+
schema { query: Query }
|
|
582
|
+
|
|
583
|
+
type Query {
|
|
584
|
+
summary(session: String): Summary
|
|
585
|
+
nodes(search: String, types: [String!], limit: Int, offset: Int, session: String): NodeConnection
|
|
586
|
+
node(id: String!, session: String): Node
|
|
587
|
+
dependencies(id: String!, direction: Direction, maxDepth: Int, session: String): Dependencies
|
|
588
|
+
diff(base: String!, current: String!): Diff
|
|
589
|
+
sessions: [Session!]!
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
enum Direction { downstream upstream both }
|
|
593
|
+
|
|
594
|
+
type Totals { nodes: Int! edges: Int! }
|
|
595
|
+
type Count { key: String! value: Int! }
|
|
596
|
+
type TopConnected { id: String! name: String! type: String! degree: Int! }
|
|
597
|
+
type Anomaly { nodeId: String! kind: String! severity: String! reason: String! }
|
|
598
|
+
type Cost { amount: Float! currency: String! period: String! source: String }
|
|
599
|
+
type CostRollup { key: String! currency: String! period: String! total: Float! nodes: Int! }
|
|
600
|
+
type CostCoverage { withCost: Int! total: Int! }
|
|
601
|
+
|
|
602
|
+
type Node {
|
|
603
|
+
id: String! type: String! name: String! confidence: Float!
|
|
604
|
+
domain: String subDomain: String qualityScore: Float owner: String cost: Cost tags: [String!]!
|
|
605
|
+
}
|
|
606
|
+
type DependencyNode {
|
|
607
|
+
id: String! type: String! name: String! confidence: Float!
|
|
608
|
+
domain: String subDomain: String qualityScore: Float owner: String cost: Cost tags: [String!]! depth: Int!
|
|
609
|
+
}
|
|
610
|
+
type Edge { sourceId: String! targetId: String! relationship: String! confidence: Float! evidence: String! }
|
|
611
|
+
|
|
612
|
+
type Summary {
|
|
613
|
+
sessionId: String!
|
|
614
|
+
totals: Totals!
|
|
615
|
+
topConnected: [TopConnected!]!
|
|
616
|
+
anomalies: [Anomaly!]!
|
|
617
|
+
contributors: Int!
|
|
618
|
+
costByDomain: [CostRollup!]!
|
|
619
|
+
costByOwner: [CostRollup!]!
|
|
620
|
+
costCoverage: CostCoverage!
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
type NodeConnection { nodes: [Node!]! total: Int! limit: Int! offset: Int! }
|
|
624
|
+
type Dependencies { root: Node direction: Direction! maxDepth: Int! nodes: [DependencyNode!]! edges: [Edge!]! }
|
|
625
|
+
|
|
626
|
+
type SessionEndpoint { sessionId: String! startedAt: String! nodeCount: Int! edgeCount: Int! }
|
|
627
|
+
type DiffSummary { nodesAdded: Int! nodesRemoved: Int! nodesChanged: Int! edgesAdded: Int! edgesRemoved: Int! }
|
|
628
|
+
type NodeChange { id: String! changedFields: [String!]! confidenceDelta: Float! }
|
|
629
|
+
type DiffNodes { added: [Node!]! removed: [Node!]! changed: [NodeChange!]! unchanged: Int! }
|
|
630
|
+
type DiffEdges { added: [Edge!]! removed: [Edge!]! unchanged: Int! }
|
|
631
|
+
type DiffAnomalies { added: [Anomaly!]! }
|
|
632
|
+
type Diff {
|
|
633
|
+
base: SessionEndpoint! current: SessionEndpoint! summary: DiffSummary!
|
|
634
|
+
nodes: DiffNodes! edges: DiffEdges! anomalies: DiffAnomalies!
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
type Session { id: String! mode: String! startedAt: String! completedAt: String name: String tenant: String! lastScannedAt: String }
|
|
638
|
+
`;
|
|
639
|
+
var resolvers = {
|
|
640
|
+
summary: (ctx, args, backend) => backend.summary(ctx, str(args["session"])),
|
|
641
|
+
nodes: (ctx, args, backend) => {
|
|
642
|
+
const r = backend.nodes(
|
|
643
|
+
ctx,
|
|
644
|
+
{
|
|
645
|
+
...str(args["search"]) ? { search: str(args["search"]) } : {},
|
|
646
|
+
...Array.isArray(args["types"]) ? { types: args["types"].map(String) } : {},
|
|
647
|
+
...num(args["limit"]) !== void 0 ? { limit: num(args["limit"]) } : {},
|
|
648
|
+
...num(args["offset"]) !== void 0 ? { offset: num(args["offset"]) } : {}
|
|
649
|
+
},
|
|
650
|
+
str(args["session"])
|
|
651
|
+
);
|
|
652
|
+
return { nodes: r.nodes.map(toApiNode), total: r.total, limit: r.limit, offset: r.offset };
|
|
653
|
+
},
|
|
654
|
+
node: (ctx, args, backend) => {
|
|
655
|
+
const n = backend.node(ctx, String(args["id"]), str(args["session"]));
|
|
656
|
+
return n ? toApiNode(n) : null;
|
|
657
|
+
},
|
|
658
|
+
dependencies: (ctx, args, backend) => {
|
|
659
|
+
const r = backend.dependencies(
|
|
660
|
+
ctx,
|
|
661
|
+
String(args["id"]),
|
|
662
|
+
{
|
|
663
|
+
...str(args["direction"]) ? { direction: str(args["direction"]) } : {},
|
|
664
|
+
...num(args["maxDepth"]) !== void 0 ? { maxDepth: num(args["maxDepth"]) } : {}
|
|
665
|
+
},
|
|
666
|
+
str(args["session"])
|
|
667
|
+
);
|
|
668
|
+
return projectDependencies(r);
|
|
669
|
+
},
|
|
670
|
+
diff: (ctx, args, backend) => projectDiff(backend.diff(ctx, String(args["base"]), String(args["current"]))),
|
|
671
|
+
sessions: (ctx, _args, backend) => backend.sessions(ctx).map(toApiSession)
|
|
672
|
+
};
|
|
673
|
+
function str(v) {
|
|
674
|
+
return typeof v === "string" ? v : void 0;
|
|
675
|
+
}
|
|
676
|
+
function num(v) {
|
|
677
|
+
return typeof v === "number" && Number.isInteger(v) ? v : void 0;
|
|
678
|
+
}
|
|
679
|
+
var NAME_RE = /[_A-Za-z][_0-9A-Za-z]*/y;
|
|
680
|
+
function tokenize(src) {
|
|
681
|
+
const tokens = [];
|
|
682
|
+
let i = 0;
|
|
683
|
+
while (i < src.length) {
|
|
684
|
+
const c = src[i];
|
|
685
|
+
if (/\s|,/.test(c)) {
|
|
686
|
+
i++;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (c === "#") {
|
|
690
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if ("{}()[]:!$".includes(c)) {
|
|
694
|
+
tokens.push(c);
|
|
695
|
+
i++;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (c === '"') {
|
|
699
|
+
let j = i + 1;
|
|
700
|
+
let s = "";
|
|
701
|
+
while (j < src.length && src[j] !== '"') {
|
|
702
|
+
s += src[j];
|
|
703
|
+
j++;
|
|
704
|
+
}
|
|
705
|
+
tokens.push(JSON.stringify(s));
|
|
706
|
+
i = j + 1;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
NAME_RE.lastIndex = i;
|
|
710
|
+
const m = NAME_RE.exec(src);
|
|
711
|
+
if (m && m.index === i) {
|
|
712
|
+
tokens.push(m[0]);
|
|
713
|
+
i = NAME_RE.lastIndex;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const numMatch = /-?\d+(\.\d+)?/y;
|
|
717
|
+
numMatch.lastIndex = i;
|
|
718
|
+
const nm = numMatch.exec(src);
|
|
719
|
+
if (nm && nm.index === i) {
|
|
720
|
+
tokens.push(nm[0]);
|
|
721
|
+
i = numMatch.lastIndex;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
throw new Error(`unexpected character '${c}'`);
|
|
725
|
+
}
|
|
726
|
+
return tokens;
|
|
727
|
+
}
|
|
728
|
+
var MAX_SELECTION_DEPTH = 32;
|
|
729
|
+
var Parser = class {
|
|
730
|
+
constructor(tokens, variables) {
|
|
731
|
+
this.tokens = tokens;
|
|
732
|
+
this.variables = variables;
|
|
733
|
+
}
|
|
734
|
+
pos = 0;
|
|
735
|
+
depth = 0;
|
|
736
|
+
peek() {
|
|
737
|
+
return this.tokens[this.pos];
|
|
738
|
+
}
|
|
739
|
+
next() {
|
|
740
|
+
return this.tokens[this.pos++];
|
|
741
|
+
}
|
|
742
|
+
expect(tok) {
|
|
743
|
+
if (this.tokens[this.pos] !== tok) throw new Error(`expected '${tok}', got '${this.tokens[this.pos] ?? "<eof>"}'`);
|
|
744
|
+
this.pos++;
|
|
745
|
+
}
|
|
746
|
+
parseDocument() {
|
|
747
|
+
if (this.peek() === "mutation" || this.peek() === "subscription") {
|
|
748
|
+
throw new Error("only query operations are supported (read-only API)");
|
|
749
|
+
}
|
|
750
|
+
if (this.peek() === "query") {
|
|
751
|
+
this.next();
|
|
752
|
+
if (this.peek() && this.peek() !== "{" && this.peek() !== "(") this.next();
|
|
753
|
+
if (this.peek() === "(") this.skipBalanced("(", ")");
|
|
754
|
+
}
|
|
755
|
+
this.expect("{");
|
|
756
|
+
const selections = this.parseSelectionSet();
|
|
757
|
+
return selections;
|
|
758
|
+
}
|
|
759
|
+
skipBalanced(open, close) {
|
|
760
|
+
this.expect(open);
|
|
761
|
+
let depth = 1;
|
|
762
|
+
while (depth > 0) {
|
|
763
|
+
const t = this.next();
|
|
764
|
+
if (t === void 0) throw new Error("unbalanced");
|
|
765
|
+
if (t === open) depth++;
|
|
766
|
+
else if (t === close) depth--;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
parseSelectionSet() {
|
|
770
|
+
if (++this.depth > MAX_SELECTION_DEPTH) throw new Error(`selection set nested deeper than ${MAX_SELECTION_DEPTH}`);
|
|
771
|
+
const out = [];
|
|
772
|
+
while (this.peek() !== "}") {
|
|
773
|
+
if (this.peek() === void 0) throw new Error("unexpected end of selection set");
|
|
774
|
+
out.push(this.parseSelection());
|
|
775
|
+
}
|
|
776
|
+
this.expect("}");
|
|
777
|
+
this.depth--;
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
parseSelection() {
|
|
781
|
+
let name = this.next();
|
|
782
|
+
const alias = name;
|
|
783
|
+
if (this.peek() === ":") {
|
|
784
|
+
this.next();
|
|
785
|
+
name = this.next();
|
|
786
|
+
}
|
|
787
|
+
const args = {};
|
|
788
|
+
if (this.peek() === "(") {
|
|
789
|
+
this.next();
|
|
790
|
+
while (this.peek() !== ")") {
|
|
791
|
+
const argName = this.next();
|
|
792
|
+
this.expect(":");
|
|
793
|
+
args[argName] = this.parseValue();
|
|
794
|
+
}
|
|
795
|
+
this.expect(")");
|
|
796
|
+
}
|
|
797
|
+
let selections = [];
|
|
798
|
+
if (this.peek() === "{") {
|
|
799
|
+
this.next();
|
|
800
|
+
selections = this.parseSelectionSet();
|
|
801
|
+
}
|
|
802
|
+
return { name, alias, args, selections };
|
|
803
|
+
}
|
|
804
|
+
parseValue() {
|
|
805
|
+
const t = this.next();
|
|
806
|
+
if (t === "$") {
|
|
807
|
+
const v = this.next();
|
|
808
|
+
return this.variables[v];
|
|
809
|
+
}
|
|
810
|
+
if (t === "[") {
|
|
811
|
+
const arr = [];
|
|
812
|
+
while (this.peek() !== "]") arr.push(this.parseValue());
|
|
813
|
+
this.expect("]");
|
|
814
|
+
return arr;
|
|
815
|
+
}
|
|
816
|
+
if (t.startsWith('"')) return JSON.parse(t);
|
|
817
|
+
if (t === "true") return true;
|
|
818
|
+
if (t === "false") return false;
|
|
819
|
+
if (t === "null") return null;
|
|
820
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
821
|
+
return t;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
function project(value, selections) {
|
|
825
|
+
if (value === null || value === void 0) return null;
|
|
826
|
+
if (selections.length === 0) return value;
|
|
827
|
+
if (Array.isArray(value)) return value.map((v) => project(v, selections));
|
|
828
|
+
if (typeof value !== "object") return value;
|
|
829
|
+
const obj = value;
|
|
830
|
+
const out = {};
|
|
831
|
+
for (const sel of selections) {
|
|
832
|
+
if (sel.name === "__typename") {
|
|
833
|
+
out[sel.alias] = void 0;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
out[sel.alias] = project(obj[sel.name], sel.selections);
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
function introspectionSchema() {
|
|
841
|
+
const names = [...SDL.matchAll(/^(?:type|enum)\s+([_A-Za-z][_0-9A-Za-z]*)/gm)].map((m) => m[1]);
|
|
842
|
+
const types = names.map((name) => ({ name, kind: /^[A-Z]/.test(name) ? "OBJECT" : "SCALAR" }));
|
|
843
|
+
return {
|
|
844
|
+
__schema: {
|
|
845
|
+
queryType: { name: "Query" },
|
|
846
|
+
mutationType: null,
|
|
847
|
+
subscriptionType: null,
|
|
848
|
+
types,
|
|
849
|
+
directives: []
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
async function executeGraphql(ctx, body, deps) {
|
|
854
|
+
const req = body ?? {};
|
|
855
|
+
if (typeof req.query !== "string" || req.query.trim() === "") {
|
|
856
|
+
return { errors: [{ message: "missing query" }] };
|
|
857
|
+
}
|
|
858
|
+
const variables = typeof req.variables === "object" && req.variables !== null ? req.variables : {};
|
|
859
|
+
let selections;
|
|
860
|
+
try {
|
|
861
|
+
selections = new Parser(tokenize(req.query), variables).parseDocument();
|
|
862
|
+
} catch (err) {
|
|
863
|
+
return { errors: [{ message: `syntax error: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
864
|
+
}
|
|
865
|
+
const data = {};
|
|
866
|
+
const errors = [];
|
|
867
|
+
for (const sel of selections) {
|
|
868
|
+
try {
|
|
869
|
+
if (sel.name === "__schema") {
|
|
870
|
+
data[sel.alias] = project(introspectionSchema()["__schema"], sel.selections);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (sel.name === "__typename") {
|
|
874
|
+
data[sel.alias] = "Query";
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const resolver = resolvers[sel.name];
|
|
878
|
+
if (!resolver) {
|
|
879
|
+
errors.push({ message: `Cannot query field "${sel.name}" on type "Query"` });
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const resolved = resolver(ctx, sel.args, deps.backend);
|
|
883
|
+
data[sel.alias] = project(resolved, sel.selections);
|
|
884
|
+
} catch (err) {
|
|
885
|
+
errors.push({ message: err instanceof Error ? err.message : String(err) });
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return errors.length > 0 ? { data, errors } : { data };
|
|
889
|
+
}
|
|
890
|
+
function handleGraphqlGet() {
|
|
891
|
+
return { status: 200, body: SDL };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/api/server.ts
|
|
895
|
+
var DEPENDENCIES_RE = /^\/v1\/nodes\/(.+)\/dependencies$/;
|
|
896
|
+
var MAX_GRAPHQL_BYTES = 1024 * 1024;
|
|
897
|
+
function send(res, status, body, headers = {}) {
|
|
898
|
+
res.writeHead(status, { "content-type": "application/json", ...headers }).end(JSON.stringify(body));
|
|
899
|
+
}
|
|
900
|
+
async function readBody(req, cap) {
|
|
901
|
+
const chunks = [];
|
|
902
|
+
let total = 0;
|
|
903
|
+
let overflow = false;
|
|
904
|
+
for await (const chunk of req) {
|
|
905
|
+
if (overflow) continue;
|
|
906
|
+
const buf = chunk;
|
|
907
|
+
total += buf.length;
|
|
908
|
+
if (total > cap) {
|
|
909
|
+
overflow = true;
|
|
910
|
+
chunks.length = 0;
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
chunks.push(buf);
|
|
914
|
+
}
|
|
915
|
+
if (overflow) return { overflow: true, value: void 0 };
|
|
916
|
+
if (chunks.length === 0) return { overflow: false, value: void 0 };
|
|
917
|
+
try {
|
|
918
|
+
return { overflow: false, value: JSON.parse(Buffer.concat(chunks).toString("utf8")) };
|
|
919
|
+
} catch {
|
|
920
|
+
return { overflow: false, value: void 0 };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function runApi(opts) {
|
|
924
|
+
const host = opts.host ?? "127.0.0.1";
|
|
925
|
+
const requestedPort = opts.port ?? 3737;
|
|
926
|
+
const token = opts.token;
|
|
927
|
+
const graphqlEnabled = opts.graphql !== false;
|
|
928
|
+
const defaultTenant = opts.tenant?.defaultTenant ?? DEFAULT_TENANT;
|
|
929
|
+
const log = opts.log ?? (() => {
|
|
930
|
+
});
|
|
931
|
+
const restDeps = { backend: opts.backend, version: opts.version };
|
|
932
|
+
const openApiDoc = buildOpenApiDocument({ version: opts.version });
|
|
933
|
+
const allowedOrigins = opts.allowedOrigins ?? [];
|
|
934
|
+
assertSafeBind({ host, port: requestedPort, ...opts.allowedHosts ? { allowedHosts: opts.allowedHosts } : {}, ...token ? { token } : {} });
|
|
935
|
+
let allowedHosts = opts.allowedHosts ?? [];
|
|
936
|
+
const corsHeaders = (req) => {
|
|
937
|
+
const origin = req.headers["origin"];
|
|
938
|
+
if (typeof origin === "string" && allowedOrigins.includes(origin)) {
|
|
939
|
+
return {
|
|
940
|
+
"access-control-allow-origin": origin,
|
|
941
|
+
"vary": "Origin",
|
|
942
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
943
|
+
"access-control-allow-headers": "authorization, content-type, x-cartograph-tenant"
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
return {};
|
|
947
|
+
};
|
|
948
|
+
const server = http.createServer((req, res) => {
|
|
949
|
+
const started = Date.now();
|
|
950
|
+
let tenantLabel = "-";
|
|
951
|
+
const finish = (status) => {
|
|
952
|
+
log(`[cartography-api] ${req.method ?? "-"} ${req.url ?? "-"} ${status} ${Date.now() - started}ms tenant=${tenantLabel}`);
|
|
953
|
+
};
|
|
954
|
+
void (async () => {
|
|
955
|
+
try {
|
|
956
|
+
const url = new URL(req.url ?? "/", `http://${req.headers["host"] ?? host}`);
|
|
957
|
+
const path = url.pathname;
|
|
958
|
+
const cors = corsHeaders(req);
|
|
959
|
+
if (req.method === "OPTIONS") {
|
|
960
|
+
res.writeHead(204, cors).end();
|
|
961
|
+
finish(204);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const hostHeader = (req.headers["host"] ?? "").toLowerCase();
|
|
965
|
+
if (!allowedHosts.some((h) => h.toLowerCase() === hostHeader)) {
|
|
966
|
+
send(res, 403, { error: "host not allowed" }, cors);
|
|
967
|
+
finish(403);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (path === "/v1/openapi.json" && req.method === "GET") {
|
|
971
|
+
send(res, 200, openApiDoc, cors);
|
|
972
|
+
finish(200);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (path === "/v1/health") {
|
|
976
|
+
if (req.method !== "GET") {
|
|
977
|
+
send(res, 405, { error: "method not allowed" }, { allow: "GET", ...cors });
|
|
978
|
+
finish(405);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
tenantLabel = defaultTenant;
|
|
982
|
+
const r = handleHealth({ tenant: defaultTenant }, restDeps);
|
|
983
|
+
send(res, r.status, r.body, cors);
|
|
984
|
+
finish(r.status);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (!checkBearer(req.headers["authorization"], token)) {
|
|
988
|
+
send(res, 401, { error: "unauthorized" }, { "www-authenticate": "Bearer", ...cors });
|
|
989
|
+
finish(401);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
let ctx;
|
|
993
|
+
try {
|
|
994
|
+
ctx = resolveTenant(req, url, opts.tenant ?? {});
|
|
995
|
+
tenantLabel = ctx.tenant;
|
|
996
|
+
} catch (err) {
|
|
997
|
+
if (err instanceof InvalidTenantError) {
|
|
998
|
+
send(res, 400, { error: "invalid tenant" }, cors);
|
|
999
|
+
finish(400);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
throw err;
|
|
1003
|
+
}
|
|
1004
|
+
if (graphqlEnabled && path === "/graphql") {
|
|
1005
|
+
if (req.method === "GET") {
|
|
1006
|
+
const g = handleGraphqlGet();
|
|
1007
|
+
res.writeHead(g.status, { "content-type": "text/plain; charset=utf-8", ...cors }).end(g.body);
|
|
1008
|
+
finish(g.status);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (req.method === "POST") {
|
|
1012
|
+
const { overflow, value } = await readBody(req, MAX_GRAPHQL_BYTES);
|
|
1013
|
+
if (overflow) {
|
|
1014
|
+
send(res, 413, { error: "payload too large" }, cors);
|
|
1015
|
+
finish(413);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const result = await executeGraphql(ctx, value, { backend: opts.backend });
|
|
1019
|
+
send(res, 200, result, cors);
|
|
1020
|
+
finish(200);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
send(res, 405, { error: "method not allowed" }, { allow: "GET, POST", ...cors });
|
|
1024
|
+
finish(405);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (path.startsWith("/v1/")) {
|
|
1028
|
+
if (req.method !== "GET") {
|
|
1029
|
+
send(res, 405, { error: "method not allowed" }, { allow: "GET", ...cors });
|
|
1030
|
+
finish(405);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const result = dispatchRest(ctx, path, url, restDeps);
|
|
1034
|
+
if (result) {
|
|
1035
|
+
send(res, result.status, result.body, cors);
|
|
1036
|
+
finish(result.status);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
send(res, 404, { error: "not found" }, cors);
|
|
1041
|
+
finish(404);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
process.stderr.write(`[cartography-api] request failed: ${err instanceof Error ? err.message : String(err)}
|
|
1044
|
+
`);
|
|
1045
|
+
if (!res.headersSent) send(res, 500, { error: "internal error" });
|
|
1046
|
+
finish(500);
|
|
1047
|
+
}
|
|
1048
|
+
})();
|
|
1049
|
+
});
|
|
1050
|
+
await new Promise((resolve2) => server.listen(requestedPort, host, resolve2));
|
|
1051
|
+
const actualPort = server.address().port;
|
|
1052
|
+
if (allowedHosts.length === 0) allowedHosts = defaultAllowedHosts(host, actualPort);
|
|
1053
|
+
return server;
|
|
1054
|
+
}
|
|
1055
|
+
function dispatchRest(ctx, path, url, deps) {
|
|
1056
|
+
switch (path) {
|
|
1057
|
+
case "/v1/summary":
|
|
1058
|
+
return handleSummary(ctx, url, deps);
|
|
1059
|
+
case "/v1/nodes":
|
|
1060
|
+
return handleNodes(ctx, url, deps);
|
|
1061
|
+
case "/v1/diff":
|
|
1062
|
+
return handleDiff(ctx, url, deps);
|
|
1063
|
+
case "/v1/sessions":
|
|
1064
|
+
return handleSessions(ctx, deps);
|
|
1065
|
+
default: {
|
|
1066
|
+
const m = DEPENDENCIES_RE.exec(path);
|
|
1067
|
+
if (m) return handleDependencies(ctx, decodeURIComponent(m[1]), url, deps);
|
|
1068
|
+
return void 0;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// src/api/start.ts
|
|
1074
|
+
function readVersion() {
|
|
1075
|
+
try {
|
|
1076
|
+
const dir = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));
|
|
1077
|
+
return JSON.parse(readFileSync(resolve(dir, "..", "package.json"), "utf-8")).version ?? "0.0.0";
|
|
1078
|
+
} catch {
|
|
1079
|
+
return "0.0.0";
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function parseApiArgs(argv) {
|
|
1083
|
+
const opts = {};
|
|
1084
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1085
|
+
const a = argv[i];
|
|
1086
|
+
if (a === "--http") continue;
|
|
1087
|
+
else if (a === "--no-graphql") opts.graphql = false;
|
|
1088
|
+
else if (a === "--port") opts.port = Number(argv[++i]);
|
|
1089
|
+
else if (a === "--host") opts.host = argv[++i];
|
|
1090
|
+
else if (a === "--allowed-hosts") opts.allowedHosts = splitList(argv[++i]);
|
|
1091
|
+
else if (a === "--allowed-origins") opts.allowedOrigins = splitList(argv[++i]);
|
|
1092
|
+
else if (a === "--token") opts.token = argv[++i];
|
|
1093
|
+
else if (a === "--db") opts.dbPath = argv[++i];
|
|
1094
|
+
else if (a === "--session") opts.session = argv[++i];
|
|
1095
|
+
else if (a === "--tenant" || a === "--org") opts.tenant = argv[++i];
|
|
1096
|
+
else if (a === "--help" || a === "-h") opts.help = true;
|
|
1097
|
+
}
|
|
1098
|
+
return opts;
|
|
1099
|
+
}
|
|
1100
|
+
function splitList(raw) {
|
|
1101
|
+
return (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1102
|
+
}
|
|
1103
|
+
async function startApi(opts = {}) {
|
|
1104
|
+
const log = opts.log ?? ((m) => process.stderr.write(m + "\n"));
|
|
1105
|
+
const db = new CartographyDB(opts.dbPath ?? defaultConfig().dbPath);
|
|
1106
|
+
const backend = createSqliteQueryBackend(db, opts.session ?? "latest");
|
|
1107
|
+
const token = opts.token ?? process.env["CARTOGRAPHY_HTTP_TOKEN"];
|
|
1108
|
+
const host = opts.host ?? "127.0.0.1";
|
|
1109
|
+
const port = opts.port ?? 3737;
|
|
1110
|
+
const version = readVersion();
|
|
1111
|
+
const server = await runApi({
|
|
1112
|
+
host,
|
|
1113
|
+
port,
|
|
1114
|
+
backend,
|
|
1115
|
+
version,
|
|
1116
|
+
...opts.allowedHosts ? { allowedHosts: opts.allowedHosts } : {},
|
|
1117
|
+
...opts.allowedOrigins ? { allowedOrigins: opts.allowedOrigins } : {},
|
|
1118
|
+
...token ? { token } : {},
|
|
1119
|
+
...opts.graphql === false ? { graphql: false } : {},
|
|
1120
|
+
...opts.tenant ? { tenant: { defaultTenant: normalizeTenant(opts.tenant) } } : {},
|
|
1121
|
+
log
|
|
1122
|
+
});
|
|
1123
|
+
const graphqlNote = opts.graphql === false ? " [REST only]" : " + /graphql";
|
|
1124
|
+
log(
|
|
1125
|
+
`Cartograph API (REST${graphqlNote}) on http://${host}:${port}/v1${token ? " (auth: bearer token required)" : ""} (tenant: ${normalizeTenant(opts.tenant)})`
|
|
1126
|
+
);
|
|
1127
|
+
return server;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export {
|
|
1131
|
+
parseApiArgs,
|
|
1132
|
+
startApi
|
|
1133
|
+
};
|
|
1134
|
+
//# sourceMappingURL=chunk-7VZH5PFV.js.map
|