graphlin 0.1.3 → 0.2.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/docs/decision-service.md +393 -0
- package/docs/extension-authoring.md +553 -0
- package/docs/model-api.md +293 -0
- package/docs/usage.md +465 -0
- package/docs/visualizer-views.md +199 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +74 -9
- package/plugin.json +4 -2
- package/runtime/core/evidence.mjs +43 -9
- package/runtime/core/graph.mjs +11 -6
- package/runtime/core/privacy.mjs +1 -0
- package/runtime/daemon/auth.mjs +7 -3
- package/runtime/daemon/diagnostics.mjs +1 -1
- package/runtime/daemon/extension-api.mjs +203 -0
- package/runtime/daemon/lineage.mjs +70 -0
- package/runtime/daemon/manager.mjs +9 -6
- package/runtime/daemon/model-api.mjs +728 -0
- package/runtime/daemon/model-persistence.mjs +220 -0
- package/runtime/daemon/server.mjs +70 -12
- package/runtime/daemon/settings.mjs +11 -3
- package/runtime/decisions/broker.mjs +349 -0
- package/runtime/decisions/contracts.mjs +179 -0
- package/runtime/decisions/evaluation.mjs +305 -0
- package/runtime/decisions/faults.mjs +32 -0
- package/runtime/decisions/index.mjs +818 -0
- package/runtime/decisions/profiles.mjs +93 -0
- package/runtime/decisions/questions.mjs +268 -0
- package/runtime/discovery/index.mjs +2 -0
- package/runtime/discovery/inventory.mjs +160 -0
- package/runtime/discovery/parser.mjs +40 -0
- package/runtime/discovery/structure.mjs +232 -0
- package/runtime/extensions/contracts.mjs +59 -0
- package/runtime/extensions/frame.mjs +64 -0
- package/runtime/extensions/index.mjs +9 -0
- package/runtime/extensions/manifest.mjs +95 -0
- package/runtime/extensions/packages.mjs +222 -0
- package/runtime/extensions/profiles.mjs +36 -0
- package/runtime/extensions/projection.mjs +130 -0
- package/runtime/extensions/registry.mjs +285 -0
- package/runtime/extensions/scene.mjs +105 -0
- package/runtime/extensions/sdk.d.ts +205 -0
- package/runtime/extensions/sdk.mjs +88 -0
- package/runtime/jev/index.mjs +13 -777
- package/runtime/jev/provider.mjs +101 -0
- package/runtime/jev/questions.mjs +16 -258
- package/runtime/jev/wire.mjs +17 -25
- package/runtime/model/changes.mjs +42 -0
- package/runtime/model/history.mjs +124 -0
- package/runtime/model/index.mjs +2 -0
- package/runtime/model/project-model.mjs +889 -0
- package/runtime/model/records.mjs +239 -0
- package/runtime/pipeline.mjs +127 -48
- package/runtime/platform.mjs +254 -0
- package/runtime/visualizers/blocks.mjs +5 -0
- package/runtime/visualizers/c4.mjs +52 -0
- package/runtime/visualizers/changes.mjs +24 -0
- package/runtime/visualizers/code.mjs +5 -0
- package/runtime/visualizers/index.mjs +23 -0
- package/runtime/visualizers/structure.mjs +120 -0
- package/runtime/visualizers/timeline.mjs +66 -0
- package/runtime/web/app.js +225 -63
- package/runtime/web/extension-frame.js +128 -0
- package/runtime/web/index.html +36 -1
- package/runtime/web/model-client.js +162 -0
- package/runtime/web/platform.js +337 -0
- package/runtime/web/scene.js +111 -0
- package/runtime/web/style.css +49 -0
- package/schemas/graph.schema.json +4 -1
- package/scripts/arguments.mjs +5 -1
- package/scripts/build-packages.mjs +6 -2
- package/scripts/control.mjs +1 -1
- package/scripts/daemon.mjs +2 -1
- package/scripts/extensions.mjs +44 -0
- package/scripts/graphlin.mjs +23 -3
- package/scripts/onboarding.mjs +10 -3
- package/scripts/validate-packages.mjs +54 -8
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { safeLabel, safeText, excluded } from '../core/privacy.mjs';
|
|
3
|
+
|
|
4
|
+
const PREFIX = '/api/model/v1/';
|
|
5
|
+
const PAGE_LIMIT = 200, MAX_BYTES = 512 * 1024, PAYLOAD_BYTES = MAX_BYTES - 2048;
|
|
6
|
+
const BODY_BYTES = 4096, RETAINED_POSITIONS = 128, MAX_STREAMS = 16, MAX_GRANTS = 32;
|
|
7
|
+
const MAX_ENUMERATIONS = 64, ENUMERATION_BYTES = 64 * 1024;
|
|
8
|
+
const RECORD_CACHE_BYTES = 16 * 1024 * 1024, RECORD_CACHE_ENTRIES = 75_000;
|
|
9
|
+
const CURSOR_TTL = 5 * 60_000, MAX_TTL = 3600;
|
|
10
|
+
const COLLECTIONS = ['entities', 'relations', 'interpretations', 'activity', 'sessions', 'checkpoints'];
|
|
11
|
+
const FIELDS = [...COLLECTIONS, 'coverage'];
|
|
12
|
+
const CAPS = { entities: 20_000, relations: 40_000, interpretations: 4096, activity: 10_000, sessions: 256, checkpoints: 256 };
|
|
13
|
+
const COUNT_FIELDS = ['inventoried', 'inspected', 'retained', 'deferred', 'excluded', 'unsupported',
|
|
14
|
+
'unavailable', 'inventoryDeferred', 'oldestSequence', 'observed', 'resolved', 'unresolved',
|
|
15
|
+
'entities', 'relations', 'artifacts', 'imports', 'interpretations', 'scopes', 'files', 'bytes'];
|
|
16
|
+
const secret = () => randomBytes(32).toString('base64url');
|
|
17
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
18
|
+
const bytes = value => Buffer.byteLength(JSON.stringify(value));
|
|
19
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
const natural = value => Number.isSafeInteger(value) && value >= 0;
|
|
21
|
+
const identifier = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(value) &&
|
|
22
|
+
safeText(value, 160) ? value : undefined;
|
|
23
|
+
const text = value => safeLabel(value) && !/[{};`=]/.test(value) ? value : undefined;
|
|
24
|
+
const word = value => typeof value === 'string' && /^[a-z][a-z0-9_.-]{0,79}$/.test(value) ? value : undefined;
|
|
25
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
26
|
+
const API_ERROR = Symbol('model_api_error');
|
|
27
|
+
const fault = (status, code) => Object.assign(new Error(code), { status, code, [API_ERROR]: true });
|
|
28
|
+
const fail = (status, code) => { throw fault(status, code); };
|
|
29
|
+
const integerField = value => natural(value) ? value : undefined;
|
|
30
|
+
const positiveInteger = value => natural(value) && value > 0 ? value : undefined;
|
|
31
|
+
const booleanField = value => typeof value === 'boolean' ? value : undefined;
|
|
32
|
+
const versionField = value => typeof value === 'string' && value.length <= 256 &&
|
|
33
|
+
/^[@A-Za-z0-9][A-Za-z0-9_.@+/-]*$/.test(value) && safeText(value, 256) ? value : undefined;
|
|
34
|
+
const time = value => natural(value) ? value : typeof value === 'string' &&
|
|
35
|
+
/^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(value) && Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
36
|
+
const ids = value => Array.isArray(value) && value.length <= 256 && value.every(identifier)
|
|
37
|
+
? [...new Set(value)] : undefined;
|
|
38
|
+
const path = value => typeof value === 'string' && value.length <= 512 && safeText(value, 512) &&
|
|
39
|
+
!/[\\:%<>\r\n]/.test(value) && !value.split('/').some(part => !part || part === '.' || part === '..') &&
|
|
40
|
+
!excluded(value, {}) ? value : undefined;
|
|
41
|
+
|
|
42
|
+
const schemaEntries = new WeakMap();
|
|
43
|
+
function entries(schema) {
|
|
44
|
+
let result = schemaEntries.get(schema);
|
|
45
|
+
if (!result) { result = Object.entries(schema); schemaEntries.set(schema, result); }
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function pick(value, schema) {
|
|
49
|
+
if (!plain(value)) return undefined;
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const [key, project] of entries(schema)) {
|
|
52
|
+
const input = value[key];
|
|
53
|
+
if (input === undefined) continue;
|
|
54
|
+
const selected = project(input);
|
|
55
|
+
if (selected !== undefined) result[key] = selected;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const REF_SCHEMA = { artifactId: identifier, eventId: identifier, generation: integerField,
|
|
60
|
+
hash: v => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v) ? v : undefined,
|
|
61
|
+
startLine: integerField, endLine: integerField, sourceClass: word,
|
|
62
|
+
extractor: identifier, extractorVersion: versionField, identityVersion: versionField };
|
|
63
|
+
function refs(value) {
|
|
64
|
+
if (!Array.isArray(value) || value.length > 16) return undefined;
|
|
65
|
+
const results = [];
|
|
66
|
+
for (const ref of value) {
|
|
67
|
+
const result = pick(ref, REF_SCHEMA);
|
|
68
|
+
if (!result?.artifactId && !result?.eventId) return undefined;
|
|
69
|
+
for (const key of ['artifactId', 'eventId', 'generation', 'hash', 'startLine', 'endLine', 'sourceClass',
|
|
70
|
+
'extractor', 'extractorVersion', 'identityVersion']) {
|
|
71
|
+
if (ref[key] !== undefined && result[key] === undefined) return undefined;
|
|
72
|
+
}
|
|
73
|
+
results.push(result);
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
const common = {
|
|
78
|
+
id: identifier, kind: word, label: text, basis: word, validity: word, freshness: word,
|
|
79
|
+
classification: word, support: word, completeness: word, sourceRefs: refs,
|
|
80
|
+
sessionId: identifier, knownAtSequence: integerField,
|
|
81
|
+
};
|
|
82
|
+
const SCHEMAS = {
|
|
83
|
+
entities: { ...common, parentId: v => v === null ? null : identifier(v), artifactId: identifier,
|
|
84
|
+
qualifiedName: text, relativePath: path, legacyId: identifier, ownership: word, createdAtSequence: positiveInteger },
|
|
85
|
+
relations: { ...common, source: identifier, target: identifier },
|
|
86
|
+
interpretations: { ...common, namespace: identifier, producer: identifier, profile: identifier,
|
|
87
|
+
version: identifier, entityIds: ids },
|
|
88
|
+
activity: { ...common, sequence: integerField, at: time, timestamp: time, recordedAt: time,
|
|
89
|
+
outcome: word, attribution: word, toolCategory: word, agentId: identifier, toolCallId: identifier,
|
|
90
|
+
entityIds: ids, artifactIds: ids, creation: booleanField },
|
|
91
|
+
sessions: { id: identifier, host: word, status: word, startedAt: time, endedAt: time },
|
|
92
|
+
checkpoints: { id: identifier, projectId: identifier, label: text, sessionId: identifier,
|
|
93
|
+
revision: integerField, sequence: integerField, at: time },
|
|
94
|
+
};
|
|
95
|
+
const SUPPORT_FIELDS = ['sourceRefs', 'entityIds', 'artifactIds'];
|
|
96
|
+
const RECORD_FIELDS = Object.fromEntries(Object.entries(SCHEMAS).map(([kind, schema]) =>
|
|
97
|
+
[kind, [...new Set([...Object.keys(schema), ...SUPPORT_FIELDS])]]));
|
|
98
|
+
function record(value, kind) {
|
|
99
|
+
const result = pick(value, SCHEMAS[kind]);
|
|
100
|
+
if (!result?.id || (kind === 'relations' && (!result.source || !result.target))) return null;
|
|
101
|
+
// Do not truncate a support/member set while claiming that it is complete.
|
|
102
|
+
for (const field of SUPPORT_FIELDS) {
|
|
103
|
+
if (value[field] !== undefined && result[field] === undefined) return null;
|
|
104
|
+
}
|
|
105
|
+
return bytes(result) <= 64 * 1024 ? result : null;
|
|
106
|
+
}
|
|
107
|
+
function sameValue(input, projected, schema) {
|
|
108
|
+
if (input === projected) return true;
|
|
109
|
+
if (Array.isArray(projected)) return Array.isArray(input) && input.length === projected.length &&
|
|
110
|
+
projected.every((value, index) => sameValue(input[index], value, schema));
|
|
111
|
+
// Validation reads explicit schema properties, including inherited/non-enumerable
|
|
112
|
+
// ones. Newly present reference fields must invalidate an earlier projection.
|
|
113
|
+
return !!schema && plain(projected) && plain(input) &&
|
|
114
|
+
entries(schema).every(([key]) => input[key] === projected[key]);
|
|
115
|
+
}
|
|
116
|
+
function enumeration(value) {
|
|
117
|
+
if (!plain(value) || !identifier(value.artifactId) || !identifier(value.scopeId) ||
|
|
118
|
+
!positiveInteger(value.generation) || typeof value.hash !== 'string' || !/^[a-f0-9]{64}$/.test(value.hash) ||
|
|
119
|
+
!identifier(value.extractor) || !versionField(value.version) || !versionField(value.identityVersion) ||
|
|
120
|
+
typeof value.complete !== 'boolean' || !word(value.capability) ||
|
|
121
|
+
!Array.isArray(value.omissions) || value.omissions.length > 32 || !value.omissions.every(word) ||
|
|
122
|
+
!Array.isArray(value.coveredRanges) || value.coveredRanges.length > 128) return null;
|
|
123
|
+
const coveredRanges = [];
|
|
124
|
+
for (const range of value.coveredRanges) {
|
|
125
|
+
if (!plain(range) || !positiveInteger(range.startLine) || !positiveInteger(range.endLine) ||
|
|
126
|
+
range.startLine > range.endLine || range.endLine > 10_000_000) return null;
|
|
127
|
+
coveredRanges.push({ startLine: range.startLine, endLine: range.endLine });
|
|
128
|
+
}
|
|
129
|
+
return { artifactId: value.artifactId, scopeId: value.scopeId, hash: value.hash, generation: value.generation,
|
|
130
|
+
complete: value.complete && value.capability === 'parsed' && coveredRanges.length > 0 && value.omissions.length === 0 &&
|
|
131
|
+
![value.extractor, value.version, value.identityVersion].includes('unknown'),
|
|
132
|
+
extractor: value.extractor, version: value.version, identityVersion: value.identityVersion,
|
|
133
|
+
coveredRanges, omissions: [...value.omissions], capability: value.capability };
|
|
134
|
+
}
|
|
135
|
+
function lineage(value) {
|
|
136
|
+
const result = pick(value, {
|
|
137
|
+
id: identifier,
|
|
138
|
+
status: value => ['git', 'not_git', 'unavailable'].includes(value) ? value : undefined,
|
|
139
|
+
branch: text,
|
|
140
|
+
head: value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value) ? value : undefined,
|
|
141
|
+
});
|
|
142
|
+
return result?.id && result.status ? result : undefined;
|
|
143
|
+
}
|
|
144
|
+
function coverage(value, selectedArtifacts) {
|
|
145
|
+
const schema = Object.fromEntries(COUNT_FIELDS.map(field => [field, integerField]));
|
|
146
|
+
const result = pick(value, { ...schema, complete: booleanField, truncated: booleanField, lineage }) ?? {};
|
|
147
|
+
for (const name of ['deferred', 'relationships', 'limits']) {
|
|
148
|
+
const projected = pick(value?.[name], schema);
|
|
149
|
+
if (projected) result[name] = projected;
|
|
150
|
+
}
|
|
151
|
+
// Coverage detail is inventory, not a licence to export arbitrary nested data.
|
|
152
|
+
result.detailCounts = Object.fromEntries(['scopes', 'files', 'enumerations', 'artifacts']
|
|
153
|
+
.map(name => [name, Array.isArray(value?.[name]) ? value[name].length : 0]));
|
|
154
|
+
result.parsing = pick(value?.parsing, {
|
|
155
|
+
...Object.fromEntries(['queued', 'active', 'deferred', 'parsed', 'failed', 'stale', 'omitted']
|
|
156
|
+
.map(field => [field, integerField])),
|
|
157
|
+
lastError: value => value === null ? null : word(value),
|
|
158
|
+
}) ?? {};
|
|
159
|
+
const inputs = (Array.isArray(value?.enumerations) ? value.enumerations : [])
|
|
160
|
+
.filter(value => !selectedArtifacts || selectedArtifacts.has(value?.artifactId));
|
|
161
|
+
const seen = new Set(), duplicates = new Set();
|
|
162
|
+
for (const value of inputs) {
|
|
163
|
+
if (seen.has(value?.artifactId)) duplicates.add(value.artifactId);
|
|
164
|
+
seen.add(value?.artifactId);
|
|
165
|
+
}
|
|
166
|
+
result.enumerations = [];
|
|
167
|
+
let retainedBytes = 2;
|
|
168
|
+
for (const input of inputs) {
|
|
169
|
+
if (duplicates.has(input?.artifactId)) continue;
|
|
170
|
+
const certificate = enumeration(input);
|
|
171
|
+
if (!certificate) continue;
|
|
172
|
+
const length = bytes(certificate) + 1;
|
|
173
|
+
if (result.enumerations.length >= MAX_ENUMERATIONS || retainedBytes + length > ENUMERATION_BYTES) continue;
|
|
174
|
+
result.enumerations.push(certificate); retainedBytes += length;
|
|
175
|
+
}
|
|
176
|
+
result.enumerations.sort((a, b) => compare(a.artifactId, b.artifactId));
|
|
177
|
+
result.enumerationCoverage = { total: inputs.length, returned: result.enumerations.length,
|
|
178
|
+
omitted: inputs.length - result.enumerations.length, truncated: inputs.length > result.enumerations.length };
|
|
179
|
+
if (result.enumerationCoverage.truncated) result.truncated = true;
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
function containmentOrder(entities) {
|
|
183
|
+
const byId = new Map(entities.map(value => [value.id, value])), children = new Map(), pending = [];
|
|
184
|
+
// The input is ID-sorted: roots and each sibling group are deterministic.
|
|
185
|
+
for (const entity of entities) {
|
|
186
|
+
if (!entity.parentId || !byId.has(entity.parentId)) pending.push(entity);
|
|
187
|
+
else {
|
|
188
|
+
if (!children.has(entity.parentId)) children.set(entity.parentId, []);
|
|
189
|
+
children.get(entity.parentId).push(entity);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const ordered = [];
|
|
193
|
+
for (let offset = 0; offset < pending.length; offset++) {
|
|
194
|
+
const entity = pending[offset];
|
|
195
|
+
ordered.push(entity);
|
|
196
|
+
for (const child of children.get(entity.id) ?? []) pending.push(child);
|
|
197
|
+
}
|
|
198
|
+
if (ordered.length !== entities.length) fail(503, 'invalid_model_containment');
|
|
199
|
+
return ordered;
|
|
200
|
+
}
|
|
201
|
+
function exactKeys(value, allowed) {
|
|
202
|
+
if (!plain(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(400, 'invalid_input');
|
|
203
|
+
}
|
|
204
|
+
function origin(value) {
|
|
205
|
+
if (typeof value !== 'string' || value.length > 256 || value.includes('*')) return null;
|
|
206
|
+
try {
|
|
207
|
+
const url = new URL(value);
|
|
208
|
+
return ['http:', 'https:'].includes(url.protocol) && url.origin === value &&
|
|
209
|
+
!url.username && !url.password ? value : null;
|
|
210
|
+
} catch { return null; }
|
|
211
|
+
}
|
|
212
|
+
function strictJSON(raw) {
|
|
213
|
+
let value;
|
|
214
|
+
try { value = JSON.parse(raw); } catch { fail(400, 'invalid_json'); }
|
|
215
|
+
// JSON.parse accepts duplicate keys. Reject those before validating the shape.
|
|
216
|
+
const stack = [];
|
|
217
|
+
for (const match of raw.matchAll(/"(?:[^"\\]|\\.)*"|[{}\[\],:]|[^\s{}\[\],:]+/g)) {
|
|
218
|
+
const token = match[0], top = stack.at(-1);
|
|
219
|
+
if (token === '{') stack.push({ keys: new Set(), key: true });
|
|
220
|
+
else if (token === '[') stack.push({ key: false });
|
|
221
|
+
else if (token === '}' || token === ']') stack.pop();
|
|
222
|
+
else if (token === ',' && top?.keys) top.key = true;
|
|
223
|
+
else if (token.startsWith('"') && top?.key) {
|
|
224
|
+
const key = JSON.parse(token);
|
|
225
|
+
if (top.keys.has(key)) fail(400, 'duplicate_key');
|
|
226
|
+
top.keys.add(key); top.key = false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!plain(value)) fail(400, 'invalid_input');
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
function bodyJSON(req) {
|
|
233
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(req.headers['content-type'] ?? '') ||
|
|
234
|
+
req.headers['content-encoding']) fail(415, 'invalid_content_type');
|
|
235
|
+
if (req.headers['content-length'] && (!/^\d+$/.test(req.headers['content-length']) ||
|
|
236
|
+
Number(req.headers['content-length']) > BODY_BYTES)) fail(413, 'input_too_large');
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
let chunks = [], size = 0;
|
|
239
|
+
const finish = (error, result) => {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
req.removeListener('data', data); req.removeListener('end', end);
|
|
242
|
+
req.removeListener('error', broken); req.removeListener('aborted', broken);
|
|
243
|
+
chunks = [];
|
|
244
|
+
if (error) { req.resume(); reject(error); } else resolve(result);
|
|
245
|
+
};
|
|
246
|
+
const broken = () => finish(fault(400, 'invalid_input'));
|
|
247
|
+
const data = chunk => {
|
|
248
|
+
size += chunk.length;
|
|
249
|
+
if (size > BODY_BYTES) finish(fault(413, 'input_too_large'));
|
|
250
|
+
else chunks.push(chunk);
|
|
251
|
+
};
|
|
252
|
+
const end = () => {
|
|
253
|
+
try { finish(null, strictJSON(Buffer.concat(chunks).toString('utf8'))); }
|
|
254
|
+
catch (error) { finish(error); }
|
|
255
|
+
};
|
|
256
|
+
const timer = setTimeout(() => finish(fault(408, 'request_timeout')), 2000);
|
|
257
|
+
timer.unref?.();
|
|
258
|
+
req.on('data', data); req.on('end', end); req.on('error', broken); req.on('aborted', broken);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The caller validates loopback and Host BEFORE dispatch and supplies an already
|
|
264
|
+
* authenticated viewer principal. Callbacks must synchronously return snapshots
|
|
265
|
+
* projected through CURRENT disclosure policy, including historical reads.
|
|
266
|
+
*/
|
|
267
|
+
export function createModelAPI({ projectId, getSnapshot, getSessions, createCheckpoint, now = Date.now } = {}) {
|
|
268
|
+
if (!identifier(projectId) || typeof getSnapshot !== 'function' || typeof now !== 'function' ||
|
|
269
|
+
(getSessions !== undefined && typeof getSessions !== 'function') ||
|
|
270
|
+
(createCheckpoint !== undefined && typeof createCheckpoint !== 'function')) {
|
|
271
|
+
throw new TypeError('invalid_model_api_options');
|
|
272
|
+
}
|
|
273
|
+
let epoch = secret().slice(0, 22), sequence = 1, closed = false, flushTask;
|
|
274
|
+
let retained = [1];
|
|
275
|
+
const cursorKey = randomBytes(32), grants = new Map(), clients = new Set();
|
|
276
|
+
const recordCache = new Map();
|
|
277
|
+
let recordCacheBytes = 0;
|
|
278
|
+
function clearRecordCache() { recordCache.clear(); recordCacheBytes = 0; }
|
|
279
|
+
function projectedRecord(value, kind) {
|
|
280
|
+
const key = typeof value?.id === 'string' ? `${kind}:${value.id}` : null;
|
|
281
|
+
const cached = key && recordCache.get(key);
|
|
282
|
+
// Compare every allowed field with CURRENT provider input, including nested
|
|
283
|
+
// support. Revision alone cannot detect policy redaction or changed evidence.
|
|
284
|
+
if (cached && plain(value) && RECORD_FIELDS[kind].every(field =>
|
|
285
|
+
sameValue(value[field], cached.value[field], field === 'sourceRefs' ? REF_SCHEMA : undefined))) return cached.value;
|
|
286
|
+
if (cached) { recordCache.delete(key); recordCacheBytes -= cached.bytes; }
|
|
287
|
+
const result = record(value, kind);
|
|
288
|
+
if (result) {
|
|
289
|
+
const size = bytes(result);
|
|
290
|
+
if (recordCache.size < RECORD_CACHE_ENTRIES && recordCacheBytes + size <= RECORD_CACHE_BYTES) {
|
|
291
|
+
recordCache.set(key, { value: result, bytes: size }); recordCacheBytes += size;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
const viewer = { id: 'viewer', fields: FIELDS, history: true };
|
|
297
|
+
const bounds = () => ({ epoch, oldestSequence: retained[0], latestSequence: sequence });
|
|
298
|
+
const selectionKey = (selection, principal) => digest(JSON.stringify([projectId, selection, principal.id])).slice(0, 24);
|
|
299
|
+
const eventId = (position, key) => `${epoch}:${position}:${key}`;
|
|
300
|
+
function json(res, status, value) {
|
|
301
|
+
const data = JSON.stringify(value);
|
|
302
|
+
if (Buffer.byteLength(data) > MAX_BYTES) fail(503, 'response_too_large');
|
|
303
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(data);
|
|
304
|
+
}
|
|
305
|
+
function endClient(client, reason) {
|
|
306
|
+
clients.delete(client);
|
|
307
|
+
if (client.res.destroyed || client.res.writableEnded) return;
|
|
308
|
+
if (client.res.writableLength) client.res.destroy();
|
|
309
|
+
else {
|
|
310
|
+
if (reason) client.res.write(`event: ${reason}\ndata: {"reason":"${reason}"}\n\n`);
|
|
311
|
+
client.res.end();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function removeGrant(hash, reason = 'revoked') {
|
|
315
|
+
const grant = grants.get(hash);
|
|
316
|
+
if (!grant) return;
|
|
317
|
+
grants.delete(hash); clearTimeout(grant.timer); clearRecordCache();
|
|
318
|
+
for (const client of clients) if (client.principal.id === grant.id) endClient(client, reason);
|
|
319
|
+
}
|
|
320
|
+
function sweep() {
|
|
321
|
+
for (const [hash, grant] of grants) if (grant.expiresAt <= now()) removeGrant(hash, 'expired');
|
|
322
|
+
}
|
|
323
|
+
function principalFor(req, viewerAuthorized) {
|
|
324
|
+
sweep();
|
|
325
|
+
for (const name of ['authorization', 'origin']) {
|
|
326
|
+
if ((req.rawHeaders ?? []).filter((value, index) => index % 2 === 0 && value.toLowerCase() === name).length > 1) {
|
|
327
|
+
fail(400, 'duplicate_header');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const authorization = req.headers.authorization;
|
|
331
|
+
if (authorization !== undefined) {
|
|
332
|
+
if (typeof authorization !== 'string' || !/^Bearer [A-Za-z0-9_-]{43}$/.test(authorization)) fail(401, 'invalid_token');
|
|
333
|
+
const grant = grants.get(digest(authorization.slice(7)));
|
|
334
|
+
if (!grant || grant.projectId !== projectId) fail(401, 'invalid_token');
|
|
335
|
+
const requestOrigin = req.headers.origin;
|
|
336
|
+
if (requestOrigin !== undefined && (!origin(requestOrigin) || !grant.origins.includes(requestOrigin))) {
|
|
337
|
+
fail(403, 'forbidden_origin');
|
|
338
|
+
}
|
|
339
|
+
return grant;
|
|
340
|
+
}
|
|
341
|
+
if (viewerAuthorized !== true) fail(401, 'authentication_required');
|
|
342
|
+
if (req.headers.origin !== undefined && (!origin(req.headers.origin) ||
|
|
343
|
+
new URL(req.headers.origin).host !== req.headers.host)) fail(403, 'forbidden_origin');
|
|
344
|
+
return viewer;
|
|
345
|
+
}
|
|
346
|
+
function hostMutation(req, principal) {
|
|
347
|
+
if (principal !== viewer) fail(403, 'viewer_required');
|
|
348
|
+
if (!origin(req.headers.origin) || new URL(req.headers.origin).host !== req.headers.host ||
|
|
349
|
+
(req.headers['sec-fetch-site'] && req.headers['sec-fetch-site'] !== 'same-origin')) {
|
|
350
|
+
fail(403, 'forbidden_origin');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function cors(res, requestOrigin) {
|
|
354
|
+
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
|
|
355
|
+
res.setHeader('Vary', 'Origin');
|
|
356
|
+
}
|
|
357
|
+
function parameters(url, allowed) {
|
|
358
|
+
const result = {};
|
|
359
|
+
for (const [name, value] of url.searchParams) {
|
|
360
|
+
if (!allowed.includes(name) || Object.hasOwn(result, name) || !value) fail(400, 'invalid_query');
|
|
361
|
+
result[name] = value;
|
|
362
|
+
}
|
|
363
|
+
for (const name of ['session', 'checkpoint', 'scope']) {
|
|
364
|
+
if (result[name] !== undefined && !identifier(result[name])) fail(400, 'invalid_query');
|
|
365
|
+
}
|
|
366
|
+
if (result.limit !== undefined && (!/^[1-9]\d{0,2}$/.test(result.limit) || Number(result.limit) > PAGE_LIMIT)) {
|
|
367
|
+
fail(400, 'invalid_limit');
|
|
368
|
+
}
|
|
369
|
+
if (result.cursor !== undefined && result.cursor.length > 2048) fail(400, 'invalid_cursor');
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
function selectionFor(params, principal) {
|
|
373
|
+
if (!principal.history && (params.session || params.checkpoint)) fail(403, 'history_not_granted');
|
|
374
|
+
return { ...(params.session ? { sessionId: params.session } : {}),
|
|
375
|
+
...(params.checkpoint ? { checkpointId: params.checkpoint } : {}),
|
|
376
|
+
...(params.scope ? { scopeId: params.scope } : {}) };
|
|
377
|
+
}
|
|
378
|
+
function requireField(principal, kind) {
|
|
379
|
+
if (!principal.fields.includes(kind)) fail(403, 'field_not_granted');
|
|
380
|
+
}
|
|
381
|
+
function read(selection, principal) {
|
|
382
|
+
const raw = getSnapshot({ ...selection, persistent: false });
|
|
383
|
+
if (!plain(raw) || raw.then || raw.schemaVersion !== 2 || raw.projectId !== projectId ||
|
|
384
|
+
!natural(raw.revision) || !natural(raw.sequence)) fail(503, 'invalid_model_snapshot');
|
|
385
|
+
const result = { schemaVersion: 2, projectId, revision: raw.revision, sequence: raw.sequence };
|
|
386
|
+
const omitted = {};
|
|
387
|
+
for (const kind of COLLECTIONS) {
|
|
388
|
+
let values = raw[kind];
|
|
389
|
+
if (kind === 'sessions' && values === undefined && getSessions && !selection.checkpointId) values = getSessions();
|
|
390
|
+
if (values === undefined) values = [];
|
|
391
|
+
if (!Array.isArray(values) || values.length > CAPS[kind]) fail(503, 'model_capacity_exceeded');
|
|
392
|
+
const allowed = principal.fields.includes(kind) && (kind !== 'checkpoints' || principal.history);
|
|
393
|
+
result[kind] = allowed ? values.map(value => projectedRecord(value, kind)).filter(Boolean) : [];
|
|
394
|
+
omitted[kind] = allowed ? values.length - result[kind].length : 0;
|
|
395
|
+
const seen = new Set();
|
|
396
|
+
for (const value of result[kind]) {
|
|
397
|
+
if (seen.has(value.id)) fail(503, 'duplicate_model_identity');
|
|
398
|
+
seen.add(value.id);
|
|
399
|
+
}
|
|
400
|
+
result[kind].sort((a, b) => kind === 'activity' || kind === 'checkpoints'
|
|
401
|
+
? (a.sequence ?? 0) - (b.sequence ?? 0) || compare(a.id, b.id) : compare(a.id, b.id));
|
|
402
|
+
}
|
|
403
|
+
result.entities = containmentOrder(result.entities);
|
|
404
|
+
if (selection.sessionId) {
|
|
405
|
+
for (const kind of ['activity', 'sessions', 'checkpoints']) result[kind] = result[kind].filter(value =>
|
|
406
|
+
(kind === 'sessions' ? value.id : value.sessionId) === selection.sessionId);
|
|
407
|
+
}
|
|
408
|
+
if (selection.scopeId) {
|
|
409
|
+
requireField(principal, 'entities');
|
|
410
|
+
const byId = new Map(result.entities.map(entity => [entity.id, entity])), children = new Map();
|
|
411
|
+
if (!byId.has(selection.scopeId)) fail(404, 'scope_not_found');
|
|
412
|
+
for (const entity of result.entities) {
|
|
413
|
+
if (!children.has(entity.parentId)) children.set(entity.parentId, []);
|
|
414
|
+
children.get(entity.parentId).push(entity.id);
|
|
415
|
+
}
|
|
416
|
+
const selected = new Set(), pending = [selection.scopeId];
|
|
417
|
+
while (pending.length) {
|
|
418
|
+
const next = pending.pop();
|
|
419
|
+
if (selected.has(next)) continue;
|
|
420
|
+
selected.add(next);
|
|
421
|
+
for (const child of children.get(next) ?? []) pending.push(child);
|
|
422
|
+
}
|
|
423
|
+
let parent = byId.get(selection.scopeId)?.parentId;
|
|
424
|
+
while (parent && !selected.has(parent)) { selected.add(parent); parent = byId.get(parent)?.parentId; }
|
|
425
|
+
result.entities = result.entities.filter(value => selected.has(value.id));
|
|
426
|
+
result.relations = result.relations.filter(value => selected.has(value.source) && selected.has(value.target));
|
|
427
|
+
result.interpretations = result.interpretations.filter(value =>
|
|
428
|
+
Array.isArray(value.entityIds) && value.entityIds.every(id => selected.has(id)));
|
|
429
|
+
result.activity = result.activity.filter(value => !value.entityIds?.length || value.entityIds.some(id => selected.has(id)));
|
|
430
|
+
}
|
|
431
|
+
result.coverage = principal.fields.includes('coverage') ? coverage(raw.coverage,
|
|
432
|
+
selection.scopeId ? new Set(result.entities.flatMap(value =>
|
|
433
|
+
[value.artifactId, ...(value.sourceRefs ?? []).map(ref => ref.artifactId)].filter(Boolean))) : undefined) : {};
|
|
434
|
+
result.coverage.projection = { omitted, detail: 'summary' };
|
|
435
|
+
return { data: result, fingerprint: digest(JSON.stringify(result)) };
|
|
436
|
+
}
|
|
437
|
+
function cursorFor(context, kind, offset) {
|
|
438
|
+
const body = Buffer.from(JSON.stringify({ e: epoch, p: context.principal.id, k: kind,
|
|
439
|
+
q: context.selection, r: context.data.revision, s: context.data.sequence,
|
|
440
|
+
f: context.fingerprint, o: offset, x: now() + CURSOR_TTL })).toString('base64url');
|
|
441
|
+
return `${body}.${createHmac('sha256', cursorKey).update(body).digest('base64url')}`;
|
|
442
|
+
}
|
|
443
|
+
function offsetFor(cursor, context, kind) {
|
|
444
|
+
if (!cursor) return 0;
|
|
445
|
+
if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/.test(cursor)) fail(400, 'invalid_cursor');
|
|
446
|
+
const [body, signature] = cursor.split('.');
|
|
447
|
+
const expected = createHmac('sha256', cursorKey).update(body).digest();
|
|
448
|
+
if (!timingSafeEqual(expected, Buffer.from(signature, 'base64url'))) fail(400, 'invalid_cursor');
|
|
449
|
+
let value;
|
|
450
|
+
try { value = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); } catch { fail(400, 'invalid_cursor'); }
|
|
451
|
+
if (value.p !== context.principal.id || value.k !== kind ||
|
|
452
|
+
JSON.stringify(value.q) !== JSON.stringify(context.selection) || !natural(value.o)) fail(400, 'invalid_cursor');
|
|
453
|
+
if (value.e !== epoch || value.r !== context.data.revision || value.s !== context.data.sequence ||
|
|
454
|
+
value.f !== context.fingerprint || value.x <= now()) fail(409, 'stale_cursor');
|
|
455
|
+
return value.o;
|
|
456
|
+
}
|
|
457
|
+
function metadata(context) {
|
|
458
|
+
return { schemaVersion: 2, projectId, revision: context.data.revision, sequence: context.data.sequence,
|
|
459
|
+
selection: context.selection, transport: { ...bounds(), sequence: context.position,
|
|
460
|
+
eventId: eventId(context.position, selectionKey(context.selection, context.principal)) } };
|
|
461
|
+
}
|
|
462
|
+
function page(context, kind, values, params, cursorKind = kind) {
|
|
463
|
+
const offset = offsetFor(params.cursor, context, cursorKind), limit = Number(params.limit ?? PAGE_LIMIT);
|
|
464
|
+
if (offset > values.length) fail(400, 'invalid_cursor');
|
|
465
|
+
const items = [];
|
|
466
|
+
const response = () => ({ ...metadata(context), kind, items, page: { total: values.length, offset,
|
|
467
|
+
returned: items.length, complete: offset + items.length === values.length,
|
|
468
|
+
nextCursor: offset + items.length < values.length ? cursorFor(context, cursorKind, offset + items.length) : null } });
|
|
469
|
+
// Count record bytes once. Repeatedly serializing a large candidate page to
|
|
470
|
+
// remove one record at a time needlessly stalls otherwise bounded reads.
|
|
471
|
+
let size = bytes(response()) + 1024;
|
|
472
|
+
for (const value of values.slice(offset, offset + limit)) {
|
|
473
|
+
const length = bytes(value) + 1;
|
|
474
|
+
if (size + length > PAYLOAD_BYTES) break;
|
|
475
|
+
items.push(value); size += length;
|
|
476
|
+
}
|
|
477
|
+
let result = response();
|
|
478
|
+
while (bytes(result) > PAYLOAD_BYTES && items.length) { items.pop(); result = response(); }
|
|
479
|
+
if (values.length > offset && !items.length) fail(503, 'record_too_large');
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
function snapshot(context, limit = PAGE_LIMIT) {
|
|
483
|
+
const projectedCoverage = { ...context.data.coverage };
|
|
484
|
+
const certificates = (projectedCoverage.enumerations ?? []).slice(0, Math.floor(limit / 2));
|
|
485
|
+
if (projectedCoverage.enumerations) {
|
|
486
|
+
projectedCoverage.enumerations = certificates;
|
|
487
|
+
const total = projectedCoverage.enumerationCoverage.total;
|
|
488
|
+
projectedCoverage.enumerationCoverage = { total, returned: certificates.length,
|
|
489
|
+
omitted: total - certificates.length, truncated: total > certificates.length };
|
|
490
|
+
if (projectedCoverage.enumerationCoverage.truncated) projectedCoverage.truncated = true;
|
|
491
|
+
}
|
|
492
|
+
const result = { ...metadata(context), coverage: projectedCoverage, pages: {} };
|
|
493
|
+
const counts = Object.fromEntries(COLLECTIONS.map(kind => [kind, 0]));
|
|
494
|
+
// Reserve space for activity and history even when the entity inventory is large.
|
|
495
|
+
for (let remaining = limit - certificates.length; remaining > 0;) {
|
|
496
|
+
let added = false;
|
|
497
|
+
for (const kind of COLLECTIONS) if (remaining && counts[kind] < context.data[kind].length) {
|
|
498
|
+
counts[kind]++; remaining--; added = true;
|
|
499
|
+
}
|
|
500
|
+
if (!added) break;
|
|
501
|
+
}
|
|
502
|
+
const assemble = () => {
|
|
503
|
+
for (const kind of COLLECTIONS) {
|
|
504
|
+
result[kind] = context.data[kind].slice(0, counts[kind]);
|
|
505
|
+
result.pages[kind] = { total: context.data[kind].length, returned: counts[kind],
|
|
506
|
+
nextCursor: counts[kind] < context.data[kind].length ? cursorFor(context, kind, counts[kind]) : null };
|
|
507
|
+
}
|
|
508
|
+
result.partial = COLLECTIONS.some(kind => counts[kind] < context.data[kind].length);
|
|
509
|
+
};
|
|
510
|
+
assemble();
|
|
511
|
+
const sizes = Object.fromEntries(COLLECTIONS.map(kind => [kind, result[kind].map(value => bytes(value) + 1)]));
|
|
512
|
+
const totals = Object.fromEntries(COLLECTIONS.map(kind => [kind, sizes[kind].reduce((sum, size) => sum + size, 0)]));
|
|
513
|
+
const envelope = bytes({ ...result, ...Object.fromEntries(COLLECTIONS.map(kind => [kind, []])) }) + 1024;
|
|
514
|
+
let total = envelope + Object.values(totals).reduce((sum, size) => sum + size, 0);
|
|
515
|
+
while (total > PAYLOAD_BYTES) {
|
|
516
|
+
const kind = COLLECTIONS.filter(kind => counts[kind]).sort((a, b) => totals[b] - totals[a])[0];
|
|
517
|
+
if (!kind) fail(503, 'response_too_large');
|
|
518
|
+
const removed = sizes[kind][--counts[kind]];
|
|
519
|
+
total -= removed; totals[kind] -= removed;
|
|
520
|
+
}
|
|
521
|
+
assemble();
|
|
522
|
+
if (bytes(result) > PAYLOAD_BYTES) fail(503, 'response_too_large');
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
function capabilities(principal) {
|
|
526
|
+
return { apiVersion: 1, modelSchemaVersion: 2, projectId, fields: principal.fields,
|
|
527
|
+
history: principal.history, checkpointCreation: principal === viewer && !!createCheckpoint,
|
|
528
|
+
grantCreation: principal === viewer, stream: 'scoped-snapshot', resume: 'coalesced-snapshot',
|
|
529
|
+
limits: { pageRecords: PAGE_LIMIT, payloadBytes: MAX_BYTES, retainedPositions: RETAINED_POSITIONS,
|
|
530
|
+
cursorTtlSeconds: CURSOR_TTL / 1000, maxGrantTtlSeconds: MAX_TTL,
|
|
531
|
+
enumerationRecords: MAX_ENUMERATIONS, enumerationBytes: ENUMERATION_BYTES }, ...bounds() };
|
|
532
|
+
}
|
|
533
|
+
function contextFor(selection, principal) {
|
|
534
|
+
const position = sequence; // Capture before callback: reentrant notify is delivered subsequently.
|
|
535
|
+
const result = read(selection, principal);
|
|
536
|
+
if (principal !== viewer && principal.expiresAt <= now()) { sweep(); fail(401, 'invalid_token'); }
|
|
537
|
+
return { ...result, selection, principal, position };
|
|
538
|
+
}
|
|
539
|
+
function writeEvent(client, type, data, id) {
|
|
540
|
+
if (!clients.has(client) || client.res.destroyed || client.res.writableEnded) return;
|
|
541
|
+
if (client.principal !== viewer && client.principal.expiresAt <= now()) { sweep(); return; }
|
|
542
|
+
const frame = `${id ? `id: ${id}\n` : ''}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
543
|
+
if (Buffer.byteLength(frame) + client.res.writableLength > MAX_BYTES) {
|
|
544
|
+
clients.delete(client); client.res.destroy(); return;
|
|
545
|
+
}
|
|
546
|
+
client.res.write(frame);
|
|
547
|
+
}
|
|
548
|
+
function flush() {
|
|
549
|
+
flushTask = undefined;
|
|
550
|
+
if (closed) return;
|
|
551
|
+
sweep();
|
|
552
|
+
const contexts = new Map();
|
|
553
|
+
for (const client of clients) if (client.position < sequence) {
|
|
554
|
+
try {
|
|
555
|
+
const key = selectionKey(client.selection, client.principal);
|
|
556
|
+
let context = contexts.get(key);
|
|
557
|
+
if (!context) { context = contextFor(client.selection, client.principal); contexts.set(key, context); }
|
|
558
|
+
const result = snapshot(context, client.limit);
|
|
559
|
+
writeEvent(client, 'snapshot', result, result.transport.eventId);
|
|
560
|
+
client.position = context.position;
|
|
561
|
+
} catch { endClient(client, 'unavailable'); }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function notify() {
|
|
565
|
+
if (closed) return;
|
|
566
|
+
clearRecordCache();
|
|
567
|
+
if (sequence === Number.MAX_SAFE_INTEGER) {
|
|
568
|
+
epoch = secret().slice(0, 22); sequence = 0; retained = [];
|
|
569
|
+
for (const client of clients) endClient(client, 'reset');
|
|
570
|
+
}
|
|
571
|
+
retained.push(++sequence);
|
|
572
|
+
if (retained.length > RETAINED_POSITIONS) retained.shift();
|
|
573
|
+
if (!flushTask) flushTask = setImmediate(flush);
|
|
574
|
+
}
|
|
575
|
+
function stream(req, res, selection, principal, params) {
|
|
576
|
+
if (clients.size >= MAX_STREAMS) fail(503, 'stream_limit');
|
|
577
|
+
const last = req.headers['last-event-id'];
|
|
578
|
+
if (last !== undefined && (typeof last !== 'string' || last.length > 256 || /[\r\n\0]/.test(last))) {
|
|
579
|
+
fail(400, 'invalid_event_id');
|
|
580
|
+
}
|
|
581
|
+
const key = selectionKey(selection, principal);
|
|
582
|
+
let resume, reset;
|
|
583
|
+
if (last) {
|
|
584
|
+
const parsed = /^([A-Za-z0-9_-]{22}):([1-9]\d{0,15}):([a-f0-9]{24})$/.exec(last);
|
|
585
|
+
const position = parsed ? Number(parsed[2]) : NaN;
|
|
586
|
+
if (!principal.history) reset = 'history_not_granted';
|
|
587
|
+
else if (!parsed || parsed[1] !== epoch || parsed[3] !== key) reset = 'wrong_lineage';
|
|
588
|
+
else if (!retained.includes(position)) reset = 'position_unavailable';
|
|
589
|
+
else resume = { fromSequence: position, coalesced: true };
|
|
590
|
+
}
|
|
591
|
+
// Register before obtaining the initial snapshot, without any await in between.
|
|
592
|
+
const client = { req, res, principal, selection, limit: Number(params.limit ?? PAGE_LIMIT), position: 0 };
|
|
593
|
+
clients.add(client);
|
|
594
|
+
res.on('close', () => clients.delete(client));
|
|
595
|
+
let initial, result;
|
|
596
|
+
try { initial = contextFor(selection, principal); result = snapshot(initial, client.limit); }
|
|
597
|
+
catch (error) { clients.delete(client); throw error; }
|
|
598
|
+
if (resume) result.transport.resume = resume;
|
|
599
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
600
|
+
res.flushHeaders();
|
|
601
|
+
if (reset) writeEvent(client, 'reset', { reason: reset, ...bounds() });
|
|
602
|
+
writeEvent(client, 'snapshot', result, result.transport.eventId);
|
|
603
|
+
client.position = initial.position;
|
|
604
|
+
}
|
|
605
|
+
async function handle(req, res, { viewerAuthorized = false } = {}) {
|
|
606
|
+
if (typeof req.url !== 'string' || !req.url.startsWith(PREFIX)) return false;
|
|
607
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
608
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
609
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
610
|
+
try {
|
|
611
|
+
if (closed) fail(503, 'model_api_closed');
|
|
612
|
+
if (req.url.length > 4096 || /[\s\\#]/.test(req.url) || /%(?![a-f\d]{2})/i.test(req.url) ||
|
|
613
|
+
req.url.split('?')[0].split('/').some(part => part === '.' || part === '..')) fail(400, 'invalid_route');
|
|
614
|
+
const url = new URL(req.url, 'http://model.invalid');
|
|
615
|
+
if (!url.pathname.startsWith(PREFIX) || url.pathname.includes('%') || url.pathname.includes('//')) fail(400, 'invalid_route');
|
|
616
|
+
const route = url.pathname.slice(PREFIX.length);
|
|
617
|
+
const entityRoute = /^entities\/([A-Za-z0-9][A-Za-z0-9_.:-]{0,159})(\/children)?$/.exec(route);
|
|
618
|
+
const readRoutes = ['capabilities', 'bootstrap', 'snapshot', 'events', 'history',
|
|
619
|
+
'entities', 'relations', 'interpretations', 'activity', 'sessions'];
|
|
620
|
+
const readable = readRoutes.includes(route) || !!entityRoute;
|
|
621
|
+
const allowed = route === 'capabilities' ? [] : ['scope', 'session', 'checkpoint',
|
|
622
|
+
...(!(entityRoute && !entityRoute[2]) ? ['limit'] : []),
|
|
623
|
+
...(route === 'history' ? ['kind'] : []),
|
|
624
|
+
...(!['events', 'bootstrap', 'snapshot'].includes(route) && !(entityRoute && !entityRoute[2]) ? ['cursor'] : [])];
|
|
625
|
+
if (req.method === 'OPTIONS') {
|
|
626
|
+
if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] ?? 0) !== 0) fail(400, 'unexpected_body');
|
|
627
|
+
parameters(url, allowed);
|
|
628
|
+
if (!readable || req.headers['access-control-request-method'] !== 'GET' || !origin(req.headers.origin)) fail(403, 'forbidden_origin');
|
|
629
|
+
const requested = (req.headers['access-control-request-headers'] ?? '').toLowerCase().split(',').map(v => v.trim()).filter(Boolean);
|
|
630
|
+
if (requested.some(name => !['authorization', 'last-event-id'].includes(name))) fail(403, 'forbidden_headers');
|
|
631
|
+
sweep();
|
|
632
|
+
if (![...grants.values()].some(grant => grant.origins.includes(req.headers.origin))) fail(403, 'forbidden_origin');
|
|
633
|
+
cors(res, req.headers.origin);
|
|
634
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
|
635
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Last-Event-ID');
|
|
636
|
+
res.writeHead(204); res.end(); return true;
|
|
637
|
+
}
|
|
638
|
+
const principal = principalFor(req, viewerAuthorized);
|
|
639
|
+
if (principal !== viewer && req.method !== 'GET') fail(403, 'viewer_required');
|
|
640
|
+
if (req.method === 'GET' && readable) {
|
|
641
|
+
if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] ?? 0) !== 0) fail(400, 'unexpected_body');
|
|
642
|
+
const params = parameters(url, allowed), selection = selectionFor(params, principal);
|
|
643
|
+
if (principal !== viewer && req.headers.origin) cors(res, req.headers.origin);
|
|
644
|
+
if (route === 'capabilities') { json(res, 200, capabilities(principal)); return true; }
|
|
645
|
+
if (route === 'history' && !principal.history) fail(403, 'history_not_granted');
|
|
646
|
+
if (route === 'events') { stream(req, res, selection, principal, params); return true; }
|
|
647
|
+
const context = contextFor(selection, principal);
|
|
648
|
+
let result;
|
|
649
|
+
if (route === 'snapshot' || route === 'bootstrap') {
|
|
650
|
+
result = snapshot(context, Number(params.limit ?? PAGE_LIMIT));
|
|
651
|
+
if (route === 'bootstrap') result.capabilities = capabilities(principal);
|
|
652
|
+
} else if (entityRoute) {
|
|
653
|
+
requireField(principal, 'entities');
|
|
654
|
+
const id = entityRoute[1], entity = context.data.entities.find(value => value.id === id);
|
|
655
|
+
if (!entity) fail(404, 'entity_not_found');
|
|
656
|
+
result = entityRoute[2] ? page(context, 'entities',
|
|
657
|
+
context.data.entities.filter(value => value.parentId === id), params, `children:${id}`)
|
|
658
|
+
: { ...metadata(context), entity };
|
|
659
|
+
} else {
|
|
660
|
+
const kind = route === 'history' ? params.kind ?? 'checkpoints' : route;
|
|
661
|
+
if (route === 'history' && !['checkpoints', 'activity'].includes(kind)) fail(400, 'invalid_query');
|
|
662
|
+
requireField(principal, kind);
|
|
663
|
+
result = page(context, kind, context.data[kind], params);
|
|
664
|
+
}
|
|
665
|
+
json(res, 200, result); return true;
|
|
666
|
+
}
|
|
667
|
+
if (req.method !== 'POST' || !['grants', 'grants/revoke', 'checkpoints'].includes(route)) fail(404, 'not_found');
|
|
668
|
+
parameters(url, []); hostMutation(req, principal);
|
|
669
|
+
const input = await bodyJSON(req);
|
|
670
|
+
if (closed) fail(503, 'model_api_closed');
|
|
671
|
+
if (route === 'grants') {
|
|
672
|
+
exactKeys(input, ['projectId', 'fields', 'history', 'ttlSeconds', 'origins']);
|
|
673
|
+
if (input.projectId !== projectId) fail(403, 'wrong_project');
|
|
674
|
+
if (!Array.isArray(input.fields) || !input.fields.length || input.fields.length > FIELDS.length ||
|
|
675
|
+
input.fields.some(field => !FIELDS.includes(field)) || new Set(input.fields).size !== input.fields.length ||
|
|
676
|
+
typeof input.history !== 'boolean') fail(400, 'invalid_grant');
|
|
677
|
+
const ttl = input.ttlSeconds ?? 900, origins = input.origins ?? [];
|
|
678
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > MAX_TTL || !Array.isArray(origins) || origins.length > 8 ||
|
|
679
|
+
origins.some(value => !origin(value)) || new Set(origins).size !== origins.length) fail(400, 'invalid_grant');
|
|
680
|
+
sweep();
|
|
681
|
+
if (grants.size >= MAX_GRANTS) fail(429, 'grant_limit');
|
|
682
|
+
const token = secret(), hash = digest(token);
|
|
683
|
+
const grant = { id: `grant-${secret().slice(0, 22)}`, projectId, fields: [...input.fields].sort(),
|
|
684
|
+
history: input.history, origins: [...origins], expiresAt: now() + ttl * 1000 };
|
|
685
|
+
grant.timer = setTimeout(() => removeGrant(hash, 'expired'), ttl * 1000); grant.timer.unref?.();
|
|
686
|
+
grants.set(hash, grant);
|
|
687
|
+
json(res, 201, { grant: { id: grant.id, projectId, fields: grant.fields, history: grant.history,
|
|
688
|
+
origins: grant.origins, expiresAt: grant.expiresAt }, token });
|
|
689
|
+
} else if (route === 'grants/revoke') {
|
|
690
|
+
exactKeys(input, ['grantId']);
|
|
691
|
+
if (!identifier(input.grantId)) fail(400, 'invalid_input');
|
|
692
|
+
for (const [hash, grant] of grants) if (grant.id === input.grantId) removeGrant(hash);
|
|
693
|
+
json(res, 200, { revoked: true });
|
|
694
|
+
} else {
|
|
695
|
+
exactKeys(input, ['label', 'sessionId']);
|
|
696
|
+
if ((input.label !== undefined && !text(input.label)) ||
|
|
697
|
+
(input.sessionId !== undefined && !identifier(input.sessionId))) fail(400, 'invalid_input');
|
|
698
|
+
if (!createCheckpoint) fail(501, 'checkpoints_unavailable');
|
|
699
|
+
const marker = record(createCheckpoint(input), 'checkpoints');
|
|
700
|
+
if (!marker || marker.projectId !== projectId || !natural(marker.revision) || !natural(marker.sequence)) {
|
|
701
|
+
fail(503, 'invalid_checkpoint');
|
|
702
|
+
}
|
|
703
|
+
notify(); json(res, 201, { checkpoint: marker });
|
|
704
|
+
}
|
|
705
|
+
} catch (error) {
|
|
706
|
+
if (res.headersSent) res.destroy();
|
|
707
|
+
else {
|
|
708
|
+
const known = { MODEL_CHECKPOINT_UNAVAILABLE: [404, 'checkpoint_unavailable'],
|
|
709
|
+
MODEL_CHECKPOINT_CAPACITY: [409, 'checkpoint_capacity'] }[error?.code];
|
|
710
|
+
const status = known?.[0] ?? (error?.[API_ERROR] ? error.status : 503);
|
|
711
|
+
if ([408, 413].includes(status)) res.setHeader('Connection', 'close');
|
|
712
|
+
json(res, status, { error: known?.[1] ?? (error?.[API_ERROR] ? error.code : 'model_unavailable') });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return true;
|
|
716
|
+
}
|
|
717
|
+
// Expiry is enforced even on an idle stream and with an injected clock.
|
|
718
|
+
const timer = setInterval(sweep, 250); timer.unref?.();
|
|
719
|
+
function close() {
|
|
720
|
+
if (closed) return;
|
|
721
|
+
closed = true; clearInterval(timer); clearImmediate(flushTask);
|
|
722
|
+
clearRecordCache();
|
|
723
|
+
for (const client of clients) endClient(client, 'closed');
|
|
724
|
+
for (const grant of grants.values()) clearTimeout(grant.timer);
|
|
725
|
+
grants.clear(); retained = []; cursorKey.fill(0);
|
|
726
|
+
}
|
|
727
|
+
return { handle, notify, close };
|
|
728
|
+
}
|