@unson/brainbase-mcp 0.6.0 → 0.8.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/contracts/foundation/graph-history.sql +634 -0
- package/contracts/judgment-value-proof/schema.json +43 -1
- package/dist/canonical-task-contract.d.ts +9 -0
- package/dist/canonical-task-contract.js +39 -0
- package/dist/canonical-task-principal.d.ts +16 -0
- package/dist/canonical-task-principal.js +36 -0
- package/dist/canonical-task-service.d.ts +251 -0
- package/dist/canonical-task-service.js +820 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/company-os-evaluation.d.ts +193 -0
- package/dist/company-os-evaluation.js +1150 -0
- package/dist/company-os-impact-review.d.ts +248 -0
- package/dist/company-os-impact-review.js +663 -0
- package/dist/company-os-learning-adoption.d.ts +288 -0
- package/dist/company-os-learning-adoption.js +944 -0
- package/dist/company-os-objectives.d.ts +63 -0
- package/dist/company-os-objectives.js +234 -0
- package/dist/company-os-receipt-adapter.d.ts +109 -0
- package/dist/company-os-receipt-adapter.js +636 -0
- package/dist/constraint-exception-store.d.ts +23 -0
- package/dist/constraint-exception-store.js +108 -0
- package/dist/constraint-resolution.d.ts +175 -0
- package/dist/constraint-resolution.js +922 -0
- package/dist/decision-adapter.d.ts +145 -0
- package/dist/decision-adapter.js +586 -0
- package/dist/durable-wait-http.d.ts +51 -0
- package/dist/durable-wait-http.js +497 -0
- package/dist/durable-waits.d.ts +234 -0
- package/dist/durable-waits.js +944 -0
- package/dist/execution-authority-http.d.ts +28 -0
- package/dist/execution-authority-http.js +359 -0
- package/dist/execution-authority.d.ts +201 -0
- package/dist/execution-authority.js +634 -0
- package/dist/foundation-authenticated-tools.d.ts +56 -0
- package/dist/foundation-authenticated-tools.js +347 -0
- package/dist/foundation-catalog.d.ts +26 -0
- package/dist/foundation-catalog.js +206 -0
- package/dist/foundation-constraint-store.d.ts +50 -0
- package/dist/foundation-constraint-store.js +215 -0
- package/dist/foundation-graph-http.d.ts +43 -0
- package/dist/foundation-graph-http.js +243 -0
- package/dist/foundation-graph-write.d.ts +90 -0
- package/dist/foundation-graph-write.js +447 -0
- package/dist/foundation-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-public-provider.d.ts +140 -0
- package/dist/foundation-public-provider.js +192 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -0
- package/dist/graph-corrections.d.ts +157 -0
- package/dist/graph-corrections.js +620 -0
- package/dist/graph-foundation-reader.d.ts +60 -0
- package/dist/graph-foundation-reader.js +494 -0
- package/dist/graph-retrieval.d.ts +6 -1
- package/dist/graph-revision-reader.d.ts +80 -0
- package/dist/graph-revision-reader.js +246 -0
- package/dist/graph-web-http.d.ts +26 -0
- package/dist/graph-web-http.js +215 -0
- package/dist/graph-web.d.ts +364 -0
- package/dist/graph-web.js +639 -0
- package/dist/judgment-dag-composition.d.ts +224 -0
- package/dist/judgment-dag-composition.js +731 -0
- package/dist/judgment-dag.d.ts +2 -0
- package/dist/judgment-dag.js +1 -0
- package/dist/judgment-problem-snapshot.d.ts +213 -0
- package/dist/judgment-problem-snapshot.js +1073 -0
- package/dist/judgment-value-proof-review.d.ts +166 -0
- package/dist/judgment-value-proof-review.js +437 -0
- package/dist/judgment-value-proof.d.ts +28 -1
- package/dist/judgment-value-proof.js +53 -8
- package/dist/judgment-view-http.d.ts +22 -0
- package/dist/judgment-view-http.js +128 -0
- package/dist/judgment-view.d.ts +222 -0
- package/dist/judgment-view.js +627 -0
- package/dist/knowledge-adapter.d.ts +112 -0
- package/dist/knowledge-adapter.js +691 -0
- package/dist/knowledge-continuation.d.ts +127 -0
- package/dist/knowledge-continuation.js +446 -0
- package/dist/knowledge-lookup.d.ts +178 -0
- package/dist/knowledge-lookup.js +758 -0
- package/dist/local-web-fetch-bridge.d.ts +5 -0
- package/dist/local-web-fetch-bridge.js +37 -0
- package/dist/local-web-host.d.ts +109 -0
- package/dist/local-web-host.js +688 -0
- package/dist/local-web-security.d.ts +25 -0
- package/dist/local-web-security.js +95 -0
- package/dist/ontology-foundation.d.ts +209 -0
- package/dist/ontology-foundation.js +1000 -0
- package/dist/ontology.d.ts +13 -0
- package/dist/ontology.js +14 -0
- package/dist/organization-connection.d.ts +164 -0
- package/dist/organization-connection.js +428 -0
- package/dist/organization-graph.d.ts +15 -0
- package/dist/organization-graph.js +24 -4
- package/dist/personal-knowledge.d.ts +132 -0
- package/dist/personal-knowledge.js +779 -0
- package/dist/personal-memory-handover.d.ts +108 -0
- package/dist/personal-memory-handover.js +369 -0
- package/dist/philosophy-revision-reader.d.ts +85 -0
- package/dist/philosophy-revision-reader.js +241 -0
- package/dist/portable-graph.d.ts +24 -0
- package/dist/portable-graph.js +77 -0
- package/dist/problem-candidates.d.ts +149 -0
- package/dist/problem-candidates.js +898 -0
- package/dist/problem-selection.d.ts +238 -0
- package/dist/problem-selection.js +995 -0
- package/dist/resource-reservation-http.d.ts +31 -0
- package/dist/resource-reservation-http.js +371 -0
- package/dist/resource-reservations.d.ts +221 -0
- package/dist/resource-reservations.js +577 -0
- package/dist/server.d.ts +104 -2
- package/dist/server.js +186 -4
- package/dist/ssot.d.ts +6 -1
- package/dist/ssot.js +63 -5
- package/dist/streamable-http.d.ts +42 -0
- package/dist/streamable-http.js +288 -0
- package/dist/types.d.ts +20 -0
- package/dist/value-proof-review-http.d.ts +25 -0
- package/dist/value-proof-review-http.js +185 -0
- package/dist/world-model.d.ts +199 -0
- package/dist/world-model.js +955 -0
- package/package.json +230 -3
- package/ui/brainbase-tokens.css +56 -0
- package/ui/graph-own-share.js +367 -0
- package/ui/graph-projects-view.css +23 -0
- package/ui/graph-projects-view.js +730 -0
- package/ui/graph-registry-view.css +101 -0
- package/ui/graph-registry-view.js +804 -0
- package/ui/graph-view-shared.css +522 -0
- package/ui/graph-view-shared.js +1227 -0
- package/ui/judgment-view.css +312 -0
- package/ui/judgment-view.js +508 -0
- package/ui/local-web-shell.css +396 -0
- package/ui/local-web-shell.js +378 -0
- package/ui/objective-editor-http-port.js +154 -0
- package/ui/objective-editor.css +286 -0
- package/ui/objective-editor.js +1586 -0
- package/ui/outcome-knowledge.css +50 -14
- package/ui/outcome-knowledge.js +15 -1
- package/ui/outcome-mana.css +91 -14
- package/ui/outcome-mana.js +958 -61
- package/ui/value-proof-review.css +504 -0
- package/ui/value-proof-review.js +1104 -0
- package/ui/workspace-kit.css +485 -0
- package/ui/workspace-kit.js +229 -0
- package/ui/world-model-view.css +188 -0
- package/ui/world-model-view.js +486 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalEdgeId, isActiveAt } from './canonical-graph.js';
|
|
3
|
+
import { canonicalPortableJson } from './portable-graph.js';
|
|
4
|
+
/** Immutable Graph entity/edge reads are versioned independently of Graph file releases. */
|
|
5
|
+
export const GRAPH_REVISION_READ_CONTRACT_VERSION = 'graph-revision-read.v1';
|
|
6
|
+
const GRAPH_KINDS = ['entity', 'edge'];
|
|
7
|
+
const REVISION_PATTERN = /^[1-9]\d*$/u;
|
|
8
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
9
|
+
const SCOPE_TYPES = ['personal', 'project', 'organization'];
|
|
10
|
+
const ACL_VISIBILITIES = ['private', 'project', 'organization', 'public'];
|
|
11
|
+
const CORE_RELATIONS = [
|
|
12
|
+
'member_of',
|
|
13
|
+
'participates_in',
|
|
14
|
+
'accountable_for',
|
|
15
|
+
'owned_by',
|
|
16
|
+
'governs',
|
|
17
|
+
'supersedes'
|
|
18
|
+
];
|
|
19
|
+
const ENTITY_TYPES = new Set(['person', 'org', 'project', 'decision']);
|
|
20
|
+
/**
|
|
21
|
+
* Computes the stable digest used by a GraphRevisionReference. Current ACL
|
|
22
|
+
* is intentionally excluded so an ACL change does not create a false content
|
|
23
|
+
* revision; the adapter still checks the current ACL before every read.
|
|
24
|
+
*/
|
|
25
|
+
export function graphRevisionDigest(input) {
|
|
26
|
+
assertGraphRevisionIdentity(input);
|
|
27
|
+
assertGraphRevisionPayload(input.kind, input.id, input.payload);
|
|
28
|
+
const content = {
|
|
29
|
+
kind: input.kind,
|
|
30
|
+
id: input.id,
|
|
31
|
+
revision: input.revision,
|
|
32
|
+
payload: input.payload
|
|
33
|
+
};
|
|
34
|
+
return `sha256:${createHash('sha256').update(canonicalPortableJson(content), 'utf8').digest('hex')}`;
|
|
35
|
+
}
|
|
36
|
+
/** Structural check for callers that need to validate a reference before I/O. */
|
|
37
|
+
export function isGraphRevisionReference(value) {
|
|
38
|
+
if (!isPlainRecord(value) || !GRAPH_KINDS.includes(value.kind))
|
|
39
|
+
return false;
|
|
40
|
+
if (!isNonEmptyString(value.id) || !isPositiveRevision(value.revision) || !isDigest(value.digest))
|
|
41
|
+
return false;
|
|
42
|
+
return isGraphRevisionScope(value.scope);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Adapt Graph entity/edge reads to `JudgmentProblemReferenceProvider.resolveOther`.
|
|
46
|
+
* Unknown reference kinds remain unresolved so another provider can be composed
|
|
47
|
+
* explicitly by the caller; this resolver never treats an unhandled kind as
|
|
48
|
+
* resolved.
|
|
49
|
+
*/
|
|
50
|
+
export function createJudgmentProblemGraphReferenceResolver(options) {
|
|
51
|
+
if (!options || !options.reader || typeof options.reader.read !== 'function') {
|
|
52
|
+
throw new TypeError('A GraphRevisionReader is required');
|
|
53
|
+
}
|
|
54
|
+
return async (input) => {
|
|
55
|
+
const kind = input.reference.kind;
|
|
56
|
+
if (!GRAPH_KINDS.includes(kind)) {
|
|
57
|
+
return { status: 'unresolved', message: `Graph revision resolver does not handle ${input.reference.kind}` };
|
|
58
|
+
}
|
|
59
|
+
if (!isNonEmptyString(input.context.principal)) {
|
|
60
|
+
return { status: 'unauthorized', message: 'A trusted principal is required for Graph revision reads' };
|
|
61
|
+
}
|
|
62
|
+
const reference = {
|
|
63
|
+
kind,
|
|
64
|
+
id: input.reference.id,
|
|
65
|
+
revision: input.reference.revision,
|
|
66
|
+
digest: input.reference.digest,
|
|
67
|
+
scope: cloneScope(input.reference.scope)
|
|
68
|
+
};
|
|
69
|
+
if (!isGraphRevisionReference(reference)) {
|
|
70
|
+
return { status: 'unresolved', message: 'Graph revision reference is invalid' };
|
|
71
|
+
}
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = await options.reader.read({
|
|
75
|
+
reference,
|
|
76
|
+
phase: input.phase,
|
|
77
|
+
// The scope is a requested boundary. The host reader must bind it to
|
|
78
|
+
// its trusted tenant/project context before returning a record.
|
|
79
|
+
context: { principal: input.context.principal, scope: cloneScope(reference.scope) }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { status: 'unresolved', message: 'Graph revision reader failed closed' };
|
|
84
|
+
}
|
|
85
|
+
if (!isPlainRecord(result) || typeof result.status !== 'string') {
|
|
86
|
+
return { status: 'unresolved', message: 'Graph revision reader returned an invalid result' };
|
|
87
|
+
}
|
|
88
|
+
if (result.status === 'missing') {
|
|
89
|
+
return { status: 'missing', message: result.message ?? 'Graph revision was not found' };
|
|
90
|
+
}
|
|
91
|
+
if (result.status === 'unauthorized') {
|
|
92
|
+
return { status: 'unauthorized', message: result.message ?? 'Graph revision is not readable' };
|
|
93
|
+
}
|
|
94
|
+
if (result.status === 'corrupt') {
|
|
95
|
+
return { status: 'unresolved', message: result.message ?? 'Graph revision failed integrity validation' };
|
|
96
|
+
}
|
|
97
|
+
if (result.status !== 'resolved' || !isPlainRecord(result.record)) {
|
|
98
|
+
return { status: 'unresolved', message: 'Graph revision reader returned an invalid resolved record' };
|
|
99
|
+
}
|
|
100
|
+
const record = result.record;
|
|
101
|
+
const integrity = validateResolvedRecord(record, reference, input.context.principal);
|
|
102
|
+
if (integrity.status !== 'resolved')
|
|
103
|
+
return integrity;
|
|
104
|
+
return { status: 'resolved', digest: reference.digest };
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function validateResolvedRecord(record, reference, principal) {
|
|
108
|
+
if (!GRAPH_KINDS.includes(record.kind) || record.kind !== reference.kind ||
|
|
109
|
+
record.id !== reference.id || record.revision !== reference.revision) {
|
|
110
|
+
return { status: 'unresolved', message: 'Graph revision identity does not match the requested reference' };
|
|
111
|
+
}
|
|
112
|
+
if (!isDigest(record.digest) || record.digest !== reference.digest) {
|
|
113
|
+
return { status: 'unresolved', message: 'Graph revision digest does not match the requested reference' };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
assertGraphRevisionPayload(record.kind, record.id, record.payload);
|
|
117
|
+
if (graphRevisionDigest(record) !== record.digest) {
|
|
118
|
+
return { status: 'unresolved', message: 'Graph revision payload digest is corrupt' };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { status: 'unresolved', message: 'Graph revision payload is corrupt' };
|
|
123
|
+
}
|
|
124
|
+
if (!isGraphRevisionAcl(record.currentAcl) || !isGraphRevisionScope(record.currentScope)) {
|
|
125
|
+
return { status: 'unresolved', message: 'Graph revision authorization metadata is corrupt' };
|
|
126
|
+
}
|
|
127
|
+
if (!sameScope(record.currentScope, reference.scope)) {
|
|
128
|
+
return { status: 'unauthorized', message: 'Graph revision is outside the requested tenant/project scope' };
|
|
129
|
+
}
|
|
130
|
+
if (!canReadGraphRevision(record.currentAcl, principal)) {
|
|
131
|
+
return { status: 'unauthorized', message: 'Graph revision is not readable by the current principal' };
|
|
132
|
+
}
|
|
133
|
+
return { status: 'resolved', digest: reference.digest };
|
|
134
|
+
}
|
|
135
|
+
function assertGraphRevisionIdentity(value) {
|
|
136
|
+
if (!GRAPH_KINDS.includes(value.kind) || !isNonEmptyString(value.id) || !isPositiveRevision(value.revision)) {
|
|
137
|
+
throw new TypeError('Graph revision identity requires kind, id, and a positive revision');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function assertGraphRevisionPayload(kind, id, payload) {
|
|
141
|
+
if (!isPlainRecord(payload) || payload.id !== id)
|
|
142
|
+
throw new TypeError('Graph revision payload id does not match its reference');
|
|
143
|
+
if (kind === 'entity') {
|
|
144
|
+
if (!ENTITY_TYPES.has(payload.type) || !isNonEmptyString(payload.name)) {
|
|
145
|
+
throw new TypeError('Graph entity revision payload is invalid');
|
|
146
|
+
}
|
|
147
|
+
assertOptionalStringArray(payload.aliases);
|
|
148
|
+
assertOptionalString(payload.summary);
|
|
149
|
+
assertOptionalStringArray(payload.tags);
|
|
150
|
+
assertOptionalRecord(payload.metadata);
|
|
151
|
+
assertOptionalValidity(payload.validFrom, payload.validTo);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!isNonEmptyString(payload.fromId) || !isNonEmptyString(payload.toId) ||
|
|
155
|
+
!CORE_RELATIONS.includes(payload.relation) ||
|
|
156
|
+
canonicalEdgeId({
|
|
157
|
+
fromId: payload.fromId,
|
|
158
|
+
relation: payload.relation,
|
|
159
|
+
toId: payload.toId
|
|
160
|
+
}) !== id) {
|
|
161
|
+
throw new TypeError('Graph edge revision payload is invalid');
|
|
162
|
+
}
|
|
163
|
+
assertOptionalString(payload.role);
|
|
164
|
+
assertOptionalString(payload.context);
|
|
165
|
+
assertOptionalValidity(payload.validFrom, payload.validTo);
|
|
166
|
+
if (payload.provenance !== undefined) {
|
|
167
|
+
if (!isPlainRecord(payload.provenance) ||
|
|
168
|
+
!['user_approved', 'migration', 'import', 'onboarding'].includes(String(payload.provenance.sourceKind))) {
|
|
169
|
+
throw new TypeError('Graph edge provenance is invalid');
|
|
170
|
+
}
|
|
171
|
+
assertOptionalString(payload.provenance.sourceId);
|
|
172
|
+
assertOptionalString(payload.provenance.evidenceHash);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function isGraphRevisionAcl(value) {
|
|
176
|
+
if (!isPlainRecord(value) || !isNonEmptyString(value.ownerId) ||
|
|
177
|
+
!ACL_VISIBILITIES.includes(value.visibility) ||
|
|
178
|
+
!isStringArray(value.readerIds) || !isStringArray(value.writerIds))
|
|
179
|
+
return false;
|
|
180
|
+
return new Set(value.readerIds).size === value.readerIds.length && new Set(value.writerIds).size === value.writerIds.length;
|
|
181
|
+
}
|
|
182
|
+
function canReadGraphRevision(acl, principal) {
|
|
183
|
+
return acl.visibility === 'public' || acl.ownerId === principal ||
|
|
184
|
+
acl.readerIds.includes(principal) || acl.writerIds.includes(principal);
|
|
185
|
+
}
|
|
186
|
+
function isGraphRevisionScope(value) {
|
|
187
|
+
return isPlainRecord(value) && SCOPE_TYPES.includes(value.type) && isNonEmptyString(value.id);
|
|
188
|
+
}
|
|
189
|
+
function sameScope(left, right) {
|
|
190
|
+
return left.type === right.type && left.id === right.id;
|
|
191
|
+
}
|
|
192
|
+
function cloneScope(scope) {
|
|
193
|
+
return { type: scope.type, id: scope.id };
|
|
194
|
+
}
|
|
195
|
+
function isDigest(value) {
|
|
196
|
+
return typeof value === 'string' && DIGEST_PATTERN.test(value);
|
|
197
|
+
}
|
|
198
|
+
function isPositiveRevision(value) {
|
|
199
|
+
return typeof value === 'string' && REVISION_PATTERN.test(value);
|
|
200
|
+
}
|
|
201
|
+
function isNonEmptyString(value) {
|
|
202
|
+
return typeof value === 'string' && value.trim().length > 0 && !value.includes('\0');
|
|
203
|
+
}
|
|
204
|
+
function isPlainRecord(value) {
|
|
205
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
206
|
+
return false;
|
|
207
|
+
const prototype = Object.getPrototypeOf(value);
|
|
208
|
+
return prototype === Object.prototype || prototype === null;
|
|
209
|
+
}
|
|
210
|
+
function isStringArray(value) {
|
|
211
|
+
return Array.isArray(value) && value.every((item) => isNonEmptyString(item));
|
|
212
|
+
}
|
|
213
|
+
function assertOptionalString(value) {
|
|
214
|
+
if (value !== undefined && typeof value !== 'string')
|
|
215
|
+
throw new TypeError('Optional Graph revision text must be a string');
|
|
216
|
+
}
|
|
217
|
+
function assertOptionalStringArray(value) {
|
|
218
|
+
if (value !== undefined && !isStringArray(value))
|
|
219
|
+
throw new TypeError('Optional Graph revision string array is invalid');
|
|
220
|
+
}
|
|
221
|
+
function assertOptionalRecord(value) {
|
|
222
|
+
if (value !== undefined && !isPlainRecord(value))
|
|
223
|
+
throw new TypeError('Optional Graph revision metadata must be a record');
|
|
224
|
+
}
|
|
225
|
+
function assertOptionalValidity(from, to) {
|
|
226
|
+
if (from !== undefined && !isRfc3339(from))
|
|
227
|
+
throw new TypeError('Graph revision validFrom is invalid');
|
|
228
|
+
if (to !== undefined && !isRfc3339(to))
|
|
229
|
+
throw new TypeError('Graph revision validTo is invalid');
|
|
230
|
+
if (typeof from === 'string' && typeof to === 'string' && Date.parse(from) > Date.parse(to)) {
|
|
231
|
+
throw new TypeError('Graph revision validity interval is invalid');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function isRfc3339(value) {
|
|
235
|
+
if (typeof value !== 'string')
|
|
236
|
+
return false;
|
|
237
|
+
try {
|
|
238
|
+
// Reuse the canonical Graph calendar validation so historical revision
|
|
239
|
+
// payloads and current Graph reads reject the same malformed timestamps.
|
|
240
|
+
isActiveAt({ validFrom: value }, value);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export declare const GRAPH_WEB_HTTP_VERSION: "graph-web-http.v1";
|
|
3
|
+
export interface GraphWebHttpOptions {
|
|
4
|
+
/** Personal OS data directory chosen by the host. Never taken from the request. */
|
|
5
|
+
readonly dataDir: string;
|
|
6
|
+
readonly basePath?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Called before a write request body is read. The host checks its per-launch
|
|
9
|
+
* token and same origin here and throws to refuse the write. Without it every
|
|
10
|
+
* write is refused.
|
|
11
|
+
*/
|
|
12
|
+
readonly assertWriteAllowed?: (request: IncomingMessage) => void | Promise<void>;
|
|
13
|
+
readonly now?: () => Date;
|
|
14
|
+
}
|
|
15
|
+
export type GraphWebHttpHandler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Node-style handler for the local Graph read and correction API:
|
|
18
|
+
*
|
|
19
|
+
* - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`
|
|
20
|
+
* - `GET {base}/search?q=&type=&as_of=&limit=`, `GET {base}/entities/:id`, `GET {base}/ontology`
|
|
21
|
+
* - `POST {base}/corrections`
|
|
22
|
+
*
|
|
23
|
+
* It returns `false` for any other path so the host can compose other handlers
|
|
24
|
+
* under the same base path. It never starts a server.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createGraphWebHttpHandler(options: GraphWebHttpOptions): GraphWebHttpHandler;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { applyGraphCorrection, GraphCorrectionError } from './graph-corrections.js';
|
|
2
|
+
import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
|
|
3
|
+
export const GRAPH_WEB_HTTP_VERSION = 'graph-web-http.v1';
|
|
4
|
+
const DEFAULT_BASE_PATH = '/api/graph';
|
|
5
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
6
|
+
class HttpError extends Error {
|
|
7
|
+
statusCode;
|
|
8
|
+
code;
|
|
9
|
+
extra;
|
|
10
|
+
headers;
|
|
11
|
+
constructor(statusCode, code, message, extra = {}, headers = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.statusCode = statusCode;
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.extra = extra;
|
|
16
|
+
this.headers = headers;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Node-style handler for the local Graph read and correction API:
|
|
21
|
+
*
|
|
22
|
+
* - `GET {base}/status`, `GET {base}/projects`, `GET {base}/projects/:id`
|
|
23
|
+
* - `GET {base}/search?q=&type=&as_of=&limit=`, `GET {base}/entities/:id`, `GET {base}/ontology`
|
|
24
|
+
* - `POST {base}/corrections`
|
|
25
|
+
*
|
|
26
|
+
* It returns `false` for any other path so the host can compose other handlers
|
|
27
|
+
* under the same base path. It never starts a server.
|
|
28
|
+
*/
|
|
29
|
+
export function createGraphWebHttpHandler(options) {
|
|
30
|
+
if (typeof options.dataDir !== 'string' || options.dataDir.trim() === '') {
|
|
31
|
+
throw new TypeError('dataDir is required');
|
|
32
|
+
}
|
|
33
|
+
const basePath = (options.basePath ?? DEFAULT_BASE_PATH).replace(/\/+$/u, '');
|
|
34
|
+
const now = options.now ?? (() => new Date());
|
|
35
|
+
const dataDir = options.dataDir;
|
|
36
|
+
return async (request, response) => {
|
|
37
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
38
|
+
let route;
|
|
39
|
+
try {
|
|
40
|
+
route = matchRoute(url.pathname, basePath);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof HttpError) {
|
|
44
|
+
writeError(response, error);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (!route)
|
|
50
|
+
return false;
|
|
51
|
+
try {
|
|
52
|
+
if (route.name === 'corrections') {
|
|
53
|
+
if (request.method !== 'POST')
|
|
54
|
+
throw methodNotAllowed('POST');
|
|
55
|
+
await assertWriteAllowed(options.assertWriteAllowed, request);
|
|
56
|
+
const body = await readJsonBody(request);
|
|
57
|
+
const result = await applyGraphCorrection(dataDir, body, { now: now() });
|
|
58
|
+
writeJson(response, 201, result);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (request.method !== 'GET')
|
|
62
|
+
throw methodNotAllowed('GET');
|
|
63
|
+
const asOf = queryValue(url, 'as_of');
|
|
64
|
+
const readOptions = { ...(asOf !== undefined ? { asOf } : {}), now: now() };
|
|
65
|
+
switch (route.name) {
|
|
66
|
+
case 'status':
|
|
67
|
+
writeJson(response, 200, await readGraphWebStatus(dataDir, readOptions));
|
|
68
|
+
break;
|
|
69
|
+
case 'projects':
|
|
70
|
+
writeJson(response, 200, await listGraphProjects(dataDir, readOptions));
|
|
71
|
+
break;
|
|
72
|
+
case 'project':
|
|
73
|
+
writeJson(response, 200, await readGraphProject(dataDir, route.id, readOptions));
|
|
74
|
+
break;
|
|
75
|
+
case 'search':
|
|
76
|
+
writeJson(response, 200, await searchGraphEntities(dataDir, {
|
|
77
|
+
q: queryValue(url, 'q'),
|
|
78
|
+
type: queryValue(url, 'type'),
|
|
79
|
+
asOf,
|
|
80
|
+
limit: parseLimit(queryValue(url, 'limit')),
|
|
81
|
+
now: readOptions.now
|
|
82
|
+
}));
|
|
83
|
+
break;
|
|
84
|
+
case 'entity':
|
|
85
|
+
writeJson(response, 200, await readGraphEntity(dataDir, route.id, readOptions));
|
|
86
|
+
break;
|
|
87
|
+
case 'ontology':
|
|
88
|
+
writeJson(response, 200, await readGraphOntology(dataDir, readOptions));
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
writeError(response, toHttpError(error));
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function matchRoute(pathname, basePath) {
|
|
99
|
+
if (pathname !== basePath && !pathname.startsWith(`${basePath}/`))
|
|
100
|
+
return undefined;
|
|
101
|
+
const rest = pathname.slice(basePath.length);
|
|
102
|
+
if (rest === '/status')
|
|
103
|
+
return { name: 'status' };
|
|
104
|
+
if (rest === '/projects')
|
|
105
|
+
return { name: 'projects' };
|
|
106
|
+
if (rest === '/search')
|
|
107
|
+
return { name: 'search' };
|
|
108
|
+
if (rest === '/ontology')
|
|
109
|
+
return { name: 'ontology' };
|
|
110
|
+
if (rest === '/corrections')
|
|
111
|
+
return { name: 'corrections' };
|
|
112
|
+
const match = /^\/(projects|entities)\/([^/]+)$/u.exec(rest);
|
|
113
|
+
if (!match)
|
|
114
|
+
return undefined;
|
|
115
|
+
let id;
|
|
116
|
+
try {
|
|
117
|
+
id = decodeURIComponent(match[2]);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new HttpError(400, 'invalid_id', 'The id in the path is not valid percent-encoding');
|
|
121
|
+
}
|
|
122
|
+
if (id.trim() === '' || id.length > 500)
|
|
123
|
+
throw new HttpError(400, 'invalid_id', 'The id in the path is not valid');
|
|
124
|
+
return { name: match[1] === 'projects' ? 'project' : 'entity', id };
|
|
125
|
+
}
|
|
126
|
+
async function assertWriteAllowed(guard, request) {
|
|
127
|
+
if (!guard) {
|
|
128
|
+
throw new HttpError(403, 'write_protection_missing', 'Writes are disabled because the host did not provide write protection');
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await guard(request);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const candidate = error;
|
|
135
|
+
const statusCode = typeof candidate.statusCode === 'number' && candidate.statusCode >= 400 && candidate.statusCode < 500
|
|
136
|
+
? candidate.statusCode
|
|
137
|
+
: 403;
|
|
138
|
+
const code = typeof candidate.code === 'string' && candidate.code ? candidate.code : 'write_not_allowed';
|
|
139
|
+
const message = typeof candidate.message === 'string' && candidate.message ? candidate.message : 'Write is not allowed';
|
|
140
|
+
throw new HttpError(statusCode, code, message);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function readJsonBody(request) {
|
|
144
|
+
const contentType = String(request.headers['content-type'] ?? '');
|
|
145
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
146
|
+
throw new HttpError(415, 'unsupported_media_type', 'Content-Type must be application/json');
|
|
147
|
+
}
|
|
148
|
+
const declared = Number(request.headers['content-length']);
|
|
149
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
150
|
+
throw new HttpError(413, 'payload_too_large', 'Request body is too large');
|
|
151
|
+
}
|
|
152
|
+
const chunks = [];
|
|
153
|
+
let size = 0;
|
|
154
|
+
for await (const chunk of request) {
|
|
155
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
156
|
+
size += buffer.length;
|
|
157
|
+
if (size > MAX_BODY_BYTES)
|
|
158
|
+
throw new HttpError(413, 'payload_too_large', 'Request body is too large');
|
|
159
|
+
chunks.push(buffer);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new HttpError(400, 'invalid_json', 'Request body must be JSON');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function toHttpError(error) {
|
|
169
|
+
if (error instanceof HttpError)
|
|
170
|
+
return error;
|
|
171
|
+
if (error instanceof GraphCorrectionError) {
|
|
172
|
+
if (error.kind === 'conflict') {
|
|
173
|
+
return new HttpError(409, error.code, error.message, error.current ? { current: error.current } : {});
|
|
174
|
+
}
|
|
175
|
+
const status = error.kind === 'invalid' ? 400
|
|
176
|
+
: error.kind === 'not_found' ? 404
|
|
177
|
+
: error.kind === 'migration_required' ? 409
|
|
178
|
+
: 500;
|
|
179
|
+
return new HttpError(status, error.code, error.message);
|
|
180
|
+
}
|
|
181
|
+
if (error instanceof GraphWebError) {
|
|
182
|
+
const status = error.kind === 'invalid' ? 400 : error.kind === 'not_found' ? 404 : 503;
|
|
183
|
+
return new HttpError(status, error.code, error.message);
|
|
184
|
+
}
|
|
185
|
+
// Canonical files that cannot be read or written are reported, never hidden.
|
|
186
|
+
return new HttpError(500, 'graph_operation_failed', error instanceof Error ? error.message : 'Unexpected error');
|
|
187
|
+
}
|
|
188
|
+
function methodNotAllowed(allow) {
|
|
189
|
+
return new HttpError(405, 'method_not_allowed', `Use ${allow}`, {}, { Allow: allow });
|
|
190
|
+
}
|
|
191
|
+
function queryValue(url, key) {
|
|
192
|
+
const value = url.searchParams.get(key);
|
|
193
|
+
return value === null ? undefined : value;
|
|
194
|
+
}
|
|
195
|
+
function parseLimit(value) {
|
|
196
|
+
if (value === undefined || value === '')
|
|
197
|
+
return undefined;
|
|
198
|
+
if (!/^\d+$/u.test(value))
|
|
199
|
+
throw new HttpError(400, 'invalid_query', 'limit must be a positive integer');
|
|
200
|
+
return Number(value);
|
|
201
|
+
}
|
|
202
|
+
function writeJson(response, statusCode, body, headers = {}) {
|
|
203
|
+
if (response.headersSent)
|
|
204
|
+
return;
|
|
205
|
+
response.statusCode = statusCode;
|
|
206
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
207
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
208
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
209
|
+
for (const [name, value] of Object.entries(headers))
|
|
210
|
+
response.setHeader(name, value);
|
|
211
|
+
response.end(JSON.stringify(body));
|
|
212
|
+
}
|
|
213
|
+
function writeError(response, error) {
|
|
214
|
+
writeJson(response, error.statusCode, { error: { code: error.code, message: error.message, ...error.extra } }, error.headers);
|
|
215
|
+
}
|