@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,577 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { initializePersonalOs, mutatePersonalOsWithSidecar, readPersonalOsSidecar } from './ssot.js';
|
|
3
|
+
const ledgerVersion = 1;
|
|
4
|
+
const defaultSidecarPath = 'resource-reservations/ledger.json';
|
|
5
|
+
const snapshotIdPattern = /^sha256:[0-9a-f]{64}$/u;
|
|
6
|
+
/**
|
|
7
|
+
* Adapt the real immutable ProblemSnapshot store to the reservation port.
|
|
8
|
+
*
|
|
9
|
+
* Story05 owns loading, integrity checks, and current read ACL checks. This
|
|
10
|
+
* adapter only binds the caller's principal, loads the current snapshot, and
|
|
11
|
+
* verifies the fixed problem/revision and scope reference. The loader must
|
|
12
|
+
* connect Story05's canonical reference provider. The tenant boundary and
|
|
13
|
+
* execution authorization remain the reservation/auth ports.
|
|
14
|
+
*/
|
|
15
|
+
export function createResourceReservationProblemSnapshotPort(options) {
|
|
16
|
+
if (!options || typeof options !== 'object' || typeof options.root !== 'string' || options.root.length === 0) {
|
|
17
|
+
throw new TypeError('problem snapshot root must be a non-empty path');
|
|
18
|
+
}
|
|
19
|
+
if (typeof options.load !== 'function') {
|
|
20
|
+
throw new TypeError('problem snapshot loader is required');
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
async verify({ principal, scopeId, reference }) {
|
|
24
|
+
try {
|
|
25
|
+
const snapshot = await options.load({
|
|
26
|
+
root: options.root,
|
|
27
|
+
snapshot_id: reference.problem_snapshot_id,
|
|
28
|
+
access: { principal },
|
|
29
|
+
reference_resolution: 'current',
|
|
30
|
+
});
|
|
31
|
+
return snapshot.snapshot_version === 'judgment-problem-snapshot.v1'
|
|
32
|
+
&& snapshot.problem_id === reference.problem_id
|
|
33
|
+
&& snapshot.revision === reference.revision
|
|
34
|
+
&& snapshot.owner_scope.id === scopeId
|
|
35
|
+
&& snapshot.execution_permission === 'none';
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export class ResourceReservationError extends Error {
|
|
44
|
+
code;
|
|
45
|
+
constructor(code, message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'ResourceReservationError';
|
|
48
|
+
this.code = code;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class ResourceReservationService {
|
|
52
|
+
dataDir;
|
|
53
|
+
authorization;
|
|
54
|
+
problemSnapshot;
|
|
55
|
+
capacity;
|
|
56
|
+
clock;
|
|
57
|
+
sidecarPath;
|
|
58
|
+
constructor(options) {
|
|
59
|
+
assertText(options.dataDir, 'dataDir');
|
|
60
|
+
this.dataDir = options.dataDir;
|
|
61
|
+
this.authorization = options.authorization;
|
|
62
|
+
this.problemSnapshot = options.problemSnapshot;
|
|
63
|
+
this.capacity = options.capacity;
|
|
64
|
+
this.clock = options.clock ?? (() => new Date());
|
|
65
|
+
this.sidecarPath = options.sidecarPath ?? defaultSidecarPath;
|
|
66
|
+
assertSidecarPath(this.sidecarPath);
|
|
67
|
+
}
|
|
68
|
+
async initialize() {
|
|
69
|
+
await initializePersonalOs(this.dataDir);
|
|
70
|
+
}
|
|
71
|
+
async estimate(input) {
|
|
72
|
+
const request = normalizeRequest(input);
|
|
73
|
+
await this.verifyProblem(request);
|
|
74
|
+
await this.authorize({ action: 'estimate', request });
|
|
75
|
+
await this.initialize();
|
|
76
|
+
const ledger = parseLedger(await readPersonalOsSidecar(this.dataDir, this.sidecarPath));
|
|
77
|
+
const capacity = await this.readCapacity(request);
|
|
78
|
+
const committedAmount = committedAmountFor(ledger, request);
|
|
79
|
+
return {
|
|
80
|
+
tenantId: request.tenantId,
|
|
81
|
+
scopeId: request.scopeId,
|
|
82
|
+
resourceId: request.resourceId,
|
|
83
|
+
period: request.period,
|
|
84
|
+
unit: request.unit,
|
|
85
|
+
requestedAmount: request.amount,
|
|
86
|
+
capacity,
|
|
87
|
+
committedAmount,
|
|
88
|
+
availableAmount: Math.max(0, capacity - committedAmount),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
async approve(input) {
|
|
92
|
+
const request = normalizeRequest(input);
|
|
93
|
+
return this.mutate('approve', request.operationId, request, async (ledger) => {
|
|
94
|
+
await this.verifyProblem(request);
|
|
95
|
+
const decision = await this.authorize({ action: 'approve', request });
|
|
96
|
+
const approval = approvalFromDecision(decision, this.now());
|
|
97
|
+
const now = this.now().toISOString();
|
|
98
|
+
const reservation = {
|
|
99
|
+
reservationId: `reservation-${randomUUID()}`,
|
|
100
|
+
state: 'approved',
|
|
101
|
+
...reservationFields(request),
|
|
102
|
+
approval,
|
|
103
|
+
externalAction: { status: 'not_started' },
|
|
104
|
+
createdAt: now,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
history: [{
|
|
107
|
+
action: 'approve',
|
|
108
|
+
operationId: request.operationId,
|
|
109
|
+
principal: request.principal,
|
|
110
|
+
occurredAt: now,
|
|
111
|
+
toState: 'approved',
|
|
112
|
+
amount: request.amount,
|
|
113
|
+
externalActionStatus: 'not_started',
|
|
114
|
+
}],
|
|
115
|
+
};
|
|
116
|
+
ledger.reservations.push(reservation);
|
|
117
|
+
return { reservation: clone(reservation) };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async reserve(input) {
|
|
121
|
+
const request = normalizeRequest(input);
|
|
122
|
+
const approvalId = input.approvalId;
|
|
123
|
+
if (approvalId !== undefined)
|
|
124
|
+
assertText(approvalId, 'approvalId');
|
|
125
|
+
if (approvalId === undefined) {
|
|
126
|
+
throw new ResourceReservationError('approval_required', 'reserve requires a prior approvalId');
|
|
127
|
+
}
|
|
128
|
+
const payload = { ...request, approvalId };
|
|
129
|
+
return this.mutate('reserve', request.operationId, payload, async (ledger) => {
|
|
130
|
+
await this.verifyProblem(request);
|
|
131
|
+
const reservation = findReservationByApprovalId(ledger, approvalId, request);
|
|
132
|
+
assertRequestMatchesReservation(request, reservation);
|
|
133
|
+
assertApprovalUsable(reservation, this.now());
|
|
134
|
+
if (reservation.state !== 'approved') {
|
|
135
|
+
throw new ResourceReservationError('reservation_state_conflict', `Reservation ${reservation.reservationId} is ${reservation.state}`);
|
|
136
|
+
}
|
|
137
|
+
const approval = reservation.approval;
|
|
138
|
+
await this.authorize({ action: 'reserve', request, approvalId: approval.approvalId, reservation });
|
|
139
|
+
const capacity = await this.readCapacity(request);
|
|
140
|
+
const committedAmount = committedAmountFor(ledger, request, reservation.reservationId);
|
|
141
|
+
if (committedAmount + request.amount > capacity) {
|
|
142
|
+
throw new ResourceReservationError('capacity_exceeded', `Capacity ${capacity} is insufficient for ${request.amount}`);
|
|
143
|
+
}
|
|
144
|
+
const now = this.now().toISOString();
|
|
145
|
+
const previousState = reservation.state;
|
|
146
|
+
reservation.state = 'reserved';
|
|
147
|
+
reservation.updatedAt = now;
|
|
148
|
+
reservation.history.push({
|
|
149
|
+
action: 'reserve',
|
|
150
|
+
operationId: request.operationId,
|
|
151
|
+
principal: request.principal,
|
|
152
|
+
occurredAt: now,
|
|
153
|
+
fromState: previousState,
|
|
154
|
+
toState: 'reserved',
|
|
155
|
+
amount: reservation.amount,
|
|
156
|
+
externalActionStatus: reservation.externalAction.status,
|
|
157
|
+
});
|
|
158
|
+
return { reservation: clone(reservation) };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async release(input) {
|
|
162
|
+
return this.transition('release', input, 'released');
|
|
163
|
+
}
|
|
164
|
+
async cancel(input) {
|
|
165
|
+
return this.transition('cancel', input, 'cancelled');
|
|
166
|
+
}
|
|
167
|
+
async consume(input) {
|
|
168
|
+
return this.transition('consume', input, 'consumed');
|
|
169
|
+
}
|
|
170
|
+
async startExternalAction(input) {
|
|
171
|
+
const normalized = normalizeMutationInput(input);
|
|
172
|
+
return this.mutate('start_external', normalized.operationId, normalized, async (ledger) => {
|
|
173
|
+
const reservation = findReservationById(ledger, normalized.reservationId, normalized);
|
|
174
|
+
await this.authorize({ action: 'start_external', input: normalized, reservation });
|
|
175
|
+
if (reservation.state !== 'reserved') {
|
|
176
|
+
throw new ResourceReservationError('reservation_state_conflict', `Reservation ${reservation.reservationId} is ${reservation.state}`);
|
|
177
|
+
}
|
|
178
|
+
if (reservation.externalAction.status !== 'not_started') {
|
|
179
|
+
throw new ResourceReservationError('reservation_state_conflict', `External action is already ${reservation.externalAction.status}`);
|
|
180
|
+
}
|
|
181
|
+
const now = this.now().toISOString();
|
|
182
|
+
reservation.externalAction = { status: 'started', startedAt: now };
|
|
183
|
+
reservation.updatedAt = now;
|
|
184
|
+
reservation.history.push({
|
|
185
|
+
action: 'start_external',
|
|
186
|
+
operationId: normalized.operationId,
|
|
187
|
+
principal: normalized.principal,
|
|
188
|
+
occurredAt: now,
|
|
189
|
+
fromState: reservation.state,
|
|
190
|
+
toState: reservation.state,
|
|
191
|
+
amount: reservation.amount,
|
|
192
|
+
externalActionStatus: 'started',
|
|
193
|
+
});
|
|
194
|
+
return { reservation: clone(reservation) };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async readLedger(input) {
|
|
198
|
+
const normalized = normalizeScopeInput(input);
|
|
199
|
+
await this.authorize({ action: 'read', input: normalized });
|
|
200
|
+
await this.initialize();
|
|
201
|
+
const ledger = parseLedger(await readPersonalOsSidecar(this.dataDir, this.sidecarPath));
|
|
202
|
+
return {
|
|
203
|
+
version: ledgerVersion,
|
|
204
|
+
reservations: ledger.reservations
|
|
205
|
+
.filter((reservation) => reservation.tenantId === normalized.tenantId
|
|
206
|
+
&& reservation.scopeId === normalized.scopeId
|
|
207
|
+
&& reservation.principal === normalized.principal)
|
|
208
|
+
.map((reservation) => clone(reservation)),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async transition(action, input, targetState) {
|
|
212
|
+
const normalized = normalizeMutationInput(input);
|
|
213
|
+
return this.mutate(action, normalized.operationId, normalized, async (ledger) => {
|
|
214
|
+
const reservation = findReservationById(ledger, normalized.reservationId, normalized);
|
|
215
|
+
await this.authorize({ action, input: normalized, reservation });
|
|
216
|
+
if (reservation.externalAction.status === 'started' || reservation.externalAction.status === 'unknown') {
|
|
217
|
+
if (action === 'release' || action === 'cancel') {
|
|
218
|
+
throw new ResourceReservationError('external_action_reconciliation_required', `Reservation ${reservation.reservationId} has an external action in ${reservation.externalAction.status} state`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const allowedStates = action === 'consume' ? ['reserved'] : ['approved', 'reserved'];
|
|
222
|
+
if (!allowedStates.includes(reservation.state)) {
|
|
223
|
+
throw new ResourceReservationError('reservation_state_conflict', `Reservation ${reservation.reservationId} is ${reservation.state}`);
|
|
224
|
+
}
|
|
225
|
+
const now = this.now().toISOString();
|
|
226
|
+
const previousState = reservation.state;
|
|
227
|
+
reservation.state = targetState;
|
|
228
|
+
reservation.updatedAt = now;
|
|
229
|
+
if (action === 'consume')
|
|
230
|
+
reservation.externalAction = { status: 'completed', startedAt: reservation.externalAction.startedAt };
|
|
231
|
+
reservation.history.push({
|
|
232
|
+
action,
|
|
233
|
+
operationId: normalized.operationId,
|
|
234
|
+
principal: normalized.principal,
|
|
235
|
+
occurredAt: now,
|
|
236
|
+
fromState: previousState,
|
|
237
|
+
toState: targetState,
|
|
238
|
+
amount: reservation.amount,
|
|
239
|
+
externalActionStatus: reservation.externalAction.status,
|
|
240
|
+
});
|
|
241
|
+
return { reservation: clone(reservation) };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async mutate(action, operationId, payload, handler) {
|
|
245
|
+
await this.initialize();
|
|
246
|
+
const payloadDigest = digest({ action, payload });
|
|
247
|
+
return mutatePersonalOsWithSidecar(this.dataDir, this.sidecarPath, async (current, sidecarContent) => {
|
|
248
|
+
const ledger = parseLedger(sidecarContent);
|
|
249
|
+
const tenantId = extractTenantId(payload);
|
|
250
|
+
const existing = ledger.operations.find((operation) => (operation.tenantId === tenantId && operation.operationId === operationId));
|
|
251
|
+
if (existing) {
|
|
252
|
+
// Keep the scope boundary before comparing the payload so a caller in
|
|
253
|
+
// another principal or scope cannot learn whether an operation exists.
|
|
254
|
+
assertReplayScope(payload, existing.result.reservation);
|
|
255
|
+
if (existing.action !== action || existing.payloadDigest !== payloadDigest) {
|
|
256
|
+
throw new ResourceReservationError('operation_conflict', `Operation ${operationId} was already used with a different payload`);
|
|
257
|
+
}
|
|
258
|
+
// Replaying an idempotent operation still reads its stored result. The
|
|
259
|
+
// caller must pass the current authorization check before that result
|
|
260
|
+
// is returned, even though no new write is performed.
|
|
261
|
+
await this.reauthorizeReplay(action, existing);
|
|
262
|
+
return { next: current, sidecarContent: serializeLedger(ledger), result: clone(existing.result) };
|
|
263
|
+
}
|
|
264
|
+
const result = await handler(ledger);
|
|
265
|
+
ledger.operations.push({
|
|
266
|
+
operationId,
|
|
267
|
+
tenantId,
|
|
268
|
+
action,
|
|
269
|
+
payloadDigest,
|
|
270
|
+
result: clone(result),
|
|
271
|
+
recordedAt: this.now().toISOString(),
|
|
272
|
+
});
|
|
273
|
+
return { next: current, sidecarContent: serializeLedger(ledger), result };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async reauthorizeReplay(action, existing) {
|
|
277
|
+
const storedReservation = existing.result.reservation;
|
|
278
|
+
if (action === 'approve') {
|
|
279
|
+
const request = requestFromReservation(storedReservation, existing.operationId);
|
|
280
|
+
await this.authorize({ action, request, reservation: storedReservation });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (action === 'reserve') {
|
|
284
|
+
const request = requestFromReservation(storedReservation, existing.operationId);
|
|
285
|
+
await this.authorize({
|
|
286
|
+
action,
|
|
287
|
+
request,
|
|
288
|
+
approvalId: storedReservation.approval.approvalId,
|
|
289
|
+
reservation: storedReservation,
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const input = {
|
|
294
|
+
operationId: existing.operationId,
|
|
295
|
+
tenantId: storedReservation.tenantId,
|
|
296
|
+
principal: storedReservation.principal,
|
|
297
|
+
scopeId: storedReservation.scopeId,
|
|
298
|
+
reservationId: storedReservation.reservationId,
|
|
299
|
+
};
|
|
300
|
+
await this.authorize({ action, input, reservation: storedReservation });
|
|
301
|
+
}
|
|
302
|
+
async authorize(input) {
|
|
303
|
+
const request = input.request;
|
|
304
|
+
const scopeInput = input.input;
|
|
305
|
+
const mutationInput = scopeInput !== undefined && 'reservationId' in scopeInput ? scopeInput : undefined;
|
|
306
|
+
const decision = await this.authorization.authorize({
|
|
307
|
+
action: input.action,
|
|
308
|
+
operationId: request?.operationId ?? mutationInput?.operationId ?? `read-${randomUUID()}`,
|
|
309
|
+
tenantId: request?.tenantId ?? scopeInput?.tenantId ?? input.reservation?.tenantId ?? '',
|
|
310
|
+
principal: request?.principal ?? scopeInput?.principal ?? input.reservation?.principal ?? '',
|
|
311
|
+
scopeId: request?.scopeId ?? scopeInput?.scopeId ?? input.reservation?.scopeId ?? '',
|
|
312
|
+
resourceId: request?.resourceId ?? input.reservation?.resourceId,
|
|
313
|
+
runId: request?.runId ?? input.reservation?.runId,
|
|
314
|
+
problem: request?.problem ?? input.reservation?.problem,
|
|
315
|
+
approvalId: input.approvalId,
|
|
316
|
+
reservationId: mutationInput?.reservationId ?? input.reservation?.reservationId,
|
|
317
|
+
reservation: input.reservation,
|
|
318
|
+
});
|
|
319
|
+
if (decision.status === 'expired')
|
|
320
|
+
throw new ResourceReservationError('approval_expired', 'Authorization approval is expired');
|
|
321
|
+
if (decision.status === 'revoked')
|
|
322
|
+
throw new ResourceReservationError('approval_revoked', 'Authorization approval is revoked');
|
|
323
|
+
if (decision.status !== 'approved')
|
|
324
|
+
throw new ResourceReservationError('authorization_denied', 'Authorization denied the resource operation');
|
|
325
|
+
if (decision.expiresAt !== undefined && decision.expiresAt !== null) {
|
|
326
|
+
assertTimestamp(decision.expiresAt, 'authorization.expiresAt');
|
|
327
|
+
if (Date.parse(decision.expiresAt) <= this.now().getTime()) {
|
|
328
|
+
throw new ResourceReservationError('approval_expired', 'Authorization approval is expired');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return decision;
|
|
332
|
+
}
|
|
333
|
+
async verifyProblem(request) {
|
|
334
|
+
try {
|
|
335
|
+
const verified = await this.problemSnapshot.verify({
|
|
336
|
+
tenantId: request.tenantId,
|
|
337
|
+
principal: request.principal,
|
|
338
|
+
scopeId: request.scopeId,
|
|
339
|
+
reference: request.problem,
|
|
340
|
+
});
|
|
341
|
+
if (verified === false)
|
|
342
|
+
throw new Error('snapshot verifier rejected reference');
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
throw new ResourceReservationError('problem_snapshot_invalid', 'Problem snapshot reference could not be verified');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async readCapacity(request) {
|
|
349
|
+
const capacity = await this.capacity.read({
|
|
350
|
+
tenantId: request.tenantId,
|
|
351
|
+
principal: request.principal,
|
|
352
|
+
scopeId: request.scopeId,
|
|
353
|
+
resourceId: request.resourceId,
|
|
354
|
+
period: request.period,
|
|
355
|
+
unit: request.unit,
|
|
356
|
+
});
|
|
357
|
+
if (capacity === null || !Number.isFinite(capacity) || capacity < 0) {
|
|
358
|
+
throw new ResourceReservationError('capacity_unknown', 'Current resource capacity is unknown');
|
|
359
|
+
}
|
|
360
|
+
return capacity;
|
|
361
|
+
}
|
|
362
|
+
now() {
|
|
363
|
+
const now = this.clock();
|
|
364
|
+
if (!(now instanceof Date) || !Number.isFinite(now.getTime()))
|
|
365
|
+
throw new ResourceReservationError('validation_error', 'Clock returned an invalid date');
|
|
366
|
+
return new Date(now);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function normalizeRequest(input) {
|
|
370
|
+
assertText(input.operationId, 'operationId');
|
|
371
|
+
assertText(input.tenantId, 'tenantId');
|
|
372
|
+
assertText(input.principal, 'principal');
|
|
373
|
+
assertText(input.scopeId, 'scopeId');
|
|
374
|
+
assertText(input.resourceId, 'resourceId');
|
|
375
|
+
assertText(input.unit, 'unit');
|
|
376
|
+
assertText(input.runId, 'runId');
|
|
377
|
+
if (!Number.isFinite(input.amount) || input.amount <= 0)
|
|
378
|
+
throw new ResourceReservationError('validation_error', 'amount must be a positive finite number');
|
|
379
|
+
const period = normalizePeriod(input.period);
|
|
380
|
+
const problem = normalizeProblemReference(input.problem);
|
|
381
|
+
return {
|
|
382
|
+
operationId: input.operationId,
|
|
383
|
+
tenantId: input.tenantId,
|
|
384
|
+
principal: input.principal,
|
|
385
|
+
scopeId: input.scopeId,
|
|
386
|
+
resourceId: input.resourceId,
|
|
387
|
+
period,
|
|
388
|
+
amount: input.amount,
|
|
389
|
+
unit: input.unit,
|
|
390
|
+
runId: input.runId,
|
|
391
|
+
problem,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function normalizeMutationInput(input) {
|
|
395
|
+
assertText(input.operationId, 'operationId');
|
|
396
|
+
assertText(input.tenantId, 'tenantId');
|
|
397
|
+
assertText(input.principal, 'principal');
|
|
398
|
+
assertText(input.scopeId, 'scopeId');
|
|
399
|
+
assertText(input.reservationId, 'reservationId');
|
|
400
|
+
return { ...input };
|
|
401
|
+
}
|
|
402
|
+
function normalizeScopeInput(input) {
|
|
403
|
+
assertText(input.tenantId, 'tenantId');
|
|
404
|
+
assertText(input.principal, 'principal');
|
|
405
|
+
assertText(input.scopeId, 'scopeId');
|
|
406
|
+
return { ...input };
|
|
407
|
+
}
|
|
408
|
+
function normalizePeriod(period) {
|
|
409
|
+
assertText(period?.startsAt, 'period.startsAt');
|
|
410
|
+
assertText(period?.endsAt, 'period.endsAt');
|
|
411
|
+
assertTimestamp(period.startsAt, 'period.startsAt');
|
|
412
|
+
assertTimestamp(period.endsAt, 'period.endsAt');
|
|
413
|
+
if (Date.parse(period.endsAt) <= Date.parse(period.startsAt)) {
|
|
414
|
+
throw new ResourceReservationError('validation_error', 'period.endsAt must be after period.startsAt');
|
|
415
|
+
}
|
|
416
|
+
return { startsAt: period.startsAt, endsAt: period.endsAt };
|
|
417
|
+
}
|
|
418
|
+
function normalizeProblemReference(reference) {
|
|
419
|
+
assertText(reference?.problem_snapshot_id, 'problem.problem_snapshot_id');
|
|
420
|
+
assertText(reference?.problem_id, 'problem.problem_id');
|
|
421
|
+
assertText(reference?.revision, 'problem.revision');
|
|
422
|
+
if (!snapshotIdPattern.test(reference.problem_snapshot_id)) {
|
|
423
|
+
throw new ResourceReservationError('validation_error', 'problem.problem_snapshot_id must be a sha256 snapshot ID');
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
problem_snapshot_id: reference.problem_snapshot_id,
|
|
427
|
+
problem_id: reference.problem_id,
|
|
428
|
+
revision: reference.revision,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function reservationFields(request) {
|
|
432
|
+
const { operationId: _operationId, ...fields } = request;
|
|
433
|
+
return fields;
|
|
434
|
+
}
|
|
435
|
+
function requestFromReservation(reservation, operationId) {
|
|
436
|
+
return {
|
|
437
|
+
operationId,
|
|
438
|
+
tenantId: reservation.tenantId,
|
|
439
|
+
principal: reservation.principal,
|
|
440
|
+
scopeId: reservation.scopeId,
|
|
441
|
+
resourceId: reservation.resourceId,
|
|
442
|
+
period: { ...reservation.period },
|
|
443
|
+
amount: reservation.amount,
|
|
444
|
+
unit: reservation.unit,
|
|
445
|
+
runId: reservation.runId,
|
|
446
|
+
problem: { ...reservation.problem },
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function assertText(value, label) {
|
|
450
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 512 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
451
|
+
throw new ResourceReservationError('validation_error', `${label} must be a bounded text value`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function assertTimestamp(value, label) {
|
|
455
|
+
if (!Number.isFinite(Date.parse(value)))
|
|
456
|
+
throw new ResourceReservationError('validation_error', `${label} must be an ISO timestamp`);
|
|
457
|
+
}
|
|
458
|
+
function assertSidecarPath(path) {
|
|
459
|
+
if (!path || path.startsWith('/') || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
460
|
+
throw new ResourceReservationError('validation_error', 'sidecarPath must remain relative to the SSOT root');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function approvalFromDecision(decision, now) {
|
|
464
|
+
const approvalId = decision.approvalId ?? `approval-${randomUUID()}`;
|
|
465
|
+
assertText(approvalId, 'authorization.approvalId');
|
|
466
|
+
const expiresAt = decision.expiresAt ?? undefined;
|
|
467
|
+
if (expiresAt !== undefined)
|
|
468
|
+
assertTimestamp(expiresAt, 'authorization.expiresAt');
|
|
469
|
+
const evidence = decision.evidence ?? {};
|
|
470
|
+
for (const [key, value] of Object.entries(evidence)) {
|
|
471
|
+
assertText(key, 'authorization.evidence key');
|
|
472
|
+
assertText(value, 'authorization.evidence value');
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
approvalId,
|
|
476
|
+
approvedAt: now.toISOString(),
|
|
477
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
478
|
+
evidence: { ...evidence },
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function assertApprovalUsable(reservation, now = new Date()) {
|
|
482
|
+
if (reservation.approval.expiresAt !== undefined && Date.parse(reservation.approval.expiresAt) <= now.getTime()) {
|
|
483
|
+
throw new ResourceReservationError('approval_expired', `Approval ${reservation.approval.approvalId} is expired`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function assertRequestMatchesReservation(request, reservation) {
|
|
487
|
+
if (request.tenantId !== reservation.tenantId || request.scopeId !== reservation.scopeId) {
|
|
488
|
+
throw new ResourceReservationError('scope_mismatch', `Reservation ${reservation.reservationId} is outside the requested scope`);
|
|
489
|
+
}
|
|
490
|
+
const same = request.resourceId === reservation.resourceId
|
|
491
|
+
&& request.unit === reservation.unit
|
|
492
|
+
&& request.runId === reservation.runId
|
|
493
|
+
&& request.amount === reservation.amount
|
|
494
|
+
&& request.period.startsAt === reservation.period.startsAt
|
|
495
|
+
&& request.period.endsAt === reservation.period.endsAt
|
|
496
|
+
&& digest(request.problem) === digest(reservation.problem);
|
|
497
|
+
if (!same)
|
|
498
|
+
throw new ResourceReservationError('approval_mismatch', `Reservation ${reservation.reservationId} does not match the requested payload`);
|
|
499
|
+
}
|
|
500
|
+
function assertReplayScope(payload, reservation) {
|
|
501
|
+
if (!isRecord(payload)
|
|
502
|
+
|| payload.tenantId !== reservation.tenantId
|
|
503
|
+
|| payload.principal !== reservation.principal
|
|
504
|
+
|| payload.scopeId !== reservation.scopeId) {
|
|
505
|
+
throw new ResourceReservationError('scope_mismatch', `Stored operation ${reservation.reservationId} is outside the requested scope`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function findReservationById(ledger, reservationId, scope) {
|
|
509
|
+
const reservation = ledger.reservations.find((item) => item.reservationId === reservationId);
|
|
510
|
+
if (!reservation)
|
|
511
|
+
throw new ResourceReservationError('reservation_not_found', `Reservation ${reservationId} was not found`);
|
|
512
|
+
if (reservation.tenantId !== scope.tenantId || reservation.principal !== scope.principal || reservation.scopeId !== scope.scopeId) {
|
|
513
|
+
throw new ResourceReservationError('scope_mismatch', `Reservation ${reservationId} is outside the requested scope`);
|
|
514
|
+
}
|
|
515
|
+
return reservation;
|
|
516
|
+
}
|
|
517
|
+
function findReservationByApprovalId(ledger, approvalId, scope) {
|
|
518
|
+
const reservation = ledger.reservations.find((item) => item.approval.approvalId === approvalId);
|
|
519
|
+
if (!reservation)
|
|
520
|
+
throw new ResourceReservationError('approval_required', `Approval ${approvalId} was not found`);
|
|
521
|
+
if (reservation.tenantId !== scope.tenantId || reservation.principal !== scope.principal || reservation.scopeId !== scope.scopeId) {
|
|
522
|
+
throw new ResourceReservationError('scope_mismatch', `Approval ${approvalId} is outside the requested scope`);
|
|
523
|
+
}
|
|
524
|
+
return reservation;
|
|
525
|
+
}
|
|
526
|
+
function committedAmountFor(ledger, request, excludingReservationId) {
|
|
527
|
+
return ledger.reservations
|
|
528
|
+
.filter((reservation) => reservation.reservationId !== excludingReservationId)
|
|
529
|
+
.filter((reservation) => reservation.state === 'reserved')
|
|
530
|
+
.filter((reservation) => reservation.tenantId === request.tenantId && reservation.scopeId === request.scopeId)
|
|
531
|
+
.filter((reservation) => reservation.resourceId === request.resourceId && reservation.unit === request.unit)
|
|
532
|
+
.filter((reservation) => overlaps(reservation.period, request.period))
|
|
533
|
+
.reduce((sum, reservation) => sum + reservation.amount, 0);
|
|
534
|
+
}
|
|
535
|
+
function overlaps(left, right) {
|
|
536
|
+
return Date.parse(left.startsAt) < Date.parse(right.endsAt) && Date.parse(right.startsAt) < Date.parse(left.endsAt);
|
|
537
|
+
}
|
|
538
|
+
function parseLedger(content) {
|
|
539
|
+
if (!content || content.trim() === '')
|
|
540
|
+
return { version: ledgerVersion, reservations: [], operations: [] };
|
|
541
|
+
let value;
|
|
542
|
+
try {
|
|
543
|
+
value = JSON.parse(content);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
throw new ResourceReservationError('ledger_invalid', 'Resource reservation ledger is not valid JSON');
|
|
547
|
+
}
|
|
548
|
+
if (!isRecord(value) || value.version !== ledgerVersion || !Array.isArray(value.reservations) || !Array.isArray(value.operations)) {
|
|
549
|
+
throw new ResourceReservationError('ledger_invalid', 'Resource reservation ledger has an unsupported shape');
|
|
550
|
+
}
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
function serializeLedger(ledger) {
|
|
554
|
+
return `${JSON.stringify(ledger, null, 2)}\n`;
|
|
555
|
+
}
|
|
556
|
+
function extractTenantId(payload) {
|
|
557
|
+
if (!isRecord(payload) || typeof payload.tenantId !== 'string') {
|
|
558
|
+
throw new ResourceReservationError('validation_error', 'operation payload must include tenantId');
|
|
559
|
+
}
|
|
560
|
+
return payload.tenantId;
|
|
561
|
+
}
|
|
562
|
+
function digest(value) {
|
|
563
|
+
return createHash('sha256').update(JSON.stringify(sortJson(value))).digest('hex');
|
|
564
|
+
}
|
|
565
|
+
function sortJson(value) {
|
|
566
|
+
if (Array.isArray(value))
|
|
567
|
+
return value.map((item) => sortJson(item));
|
|
568
|
+
if (!isRecord(value))
|
|
569
|
+
return value;
|
|
570
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sortJson(item)]));
|
|
571
|
+
}
|
|
572
|
+
function isRecord(value) {
|
|
573
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
574
|
+
}
|
|
575
|
+
function clone(value) {
|
|
576
|
+
return structuredClone(value);
|
|
577
|
+
}
|