@cueai/omni-reader-mcp 1.1.2 → 1.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/README.md +139 -80
- package/dist/artifact-store.d.ts +58 -0
- package/dist/artifact-store.js +449 -5
- package/dist/capabilities.d.ts +92 -0
- package/dist/capabilities.js +123 -0
- package/dist/constants.d.ts +34 -1
- package/dist/constants.js +39 -1
- package/dist/cube-client.d.ts +3 -0
- package/dist/cube-client.js +141 -9
- package/dist/cursor.d.ts +12 -0
- package/dist/cursor.js +89 -9
- package/dist/operation-journal.d.ts +19 -5
- package/dist/operation-journal.js +435 -73
- package/dist/operation-manager.d.ts +7 -2
- package/dist/operation-manager.js +215 -16
- package/dist/protocol.d.ts +16 -3
- package/dist/protocol.js +25 -1
- package/dist/remote-client.d.ts +4 -2
- package/dist/remote-client.js +155 -13
- package/dist/result-bundle.d.ts +21 -0
- package/dist/result-bundle.js +320 -0
- package/dist/result-contract.d.ts +398 -4
- package/dist/result-contract.js +175 -19
- package/dist/server.d.ts +17 -0
- package/dist/server.js +45 -1
- package/dist/tools.d.ts +7 -3
- package/dist/tools.js +77 -1
- package/package.json +1 -1
|
@@ -1,8 +1,48 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
2
2
|
import { chmod, link, mkdir, open, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { OmniBridgeError } from "./errors.js";
|
|
6
|
+
import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION, normalizeRepresentation, } from "./protocol.js";
|
|
7
|
+
// D2-D item 4-6: v3 records persist keyed identities instead of unkeyed
|
|
8
|
+
// request/source hashes. requestIdentityHmac and sourceLocatorHmac are
|
|
9
|
+
// lowercase full hex of HMAC-SHA-256 over the journal secret with the exact
|
|
10
|
+
// domain prefixes below; they are never returned in tool output and never
|
|
11
|
+
// stored in any legacy unkeyed form. Legacy v1/v2 records are migrated in
|
|
12
|
+
// memory only (see migrateLegacyRecord) and only normalize to text/none/none.
|
|
13
|
+
const REQUEST_IDENTITY_DOMAIN = "omni-request-identity-v1";
|
|
14
|
+
const SOURCE_LOCATOR_DOMAIN = "omni-source-locator-v1";
|
|
15
|
+
// D2-D item 4: the persisted v3 key set is exact. Records with extra fields
|
|
16
|
+
// (for example legacy unkeyed requestHash) or missing fields are rejected.
|
|
17
|
+
const PERSISTED_V3_KEYS = [
|
|
18
|
+
"version",
|
|
19
|
+
"clientRequestId",
|
|
20
|
+
"requestIdentityHmac",
|
|
21
|
+
"sourceLocatorHmac",
|
|
22
|
+
"sourceKind",
|
|
23
|
+
"detail",
|
|
24
|
+
"groundingSchemaVersion",
|
|
25
|
+
"bundleProtocolVersion",
|
|
26
|
+
"operationId",
|
|
27
|
+
"operationToken",
|
|
28
|
+
"state",
|
|
29
|
+
"createdAt",
|
|
30
|
+
"updatedAt",
|
|
31
|
+
"expiresAt",
|
|
32
|
+
"stage",
|
|
33
|
+
"progressPercent",
|
|
34
|
+
"progress",
|
|
35
|
+
"fileUploaded",
|
|
36
|
+
"parserStarted",
|
|
37
|
+
"billed",
|
|
38
|
+
"contentReleased",
|
|
39
|
+
"processingCopy",
|
|
40
|
+
"temporaryData",
|
|
41
|
+
"deliveryResult",
|
|
42
|
+
"resultId",
|
|
43
|
+
"resultExpiresAt",
|
|
44
|
+
"errorCode",
|
|
45
|
+
];
|
|
6
46
|
const JOURNAL_STATES = new Set([
|
|
7
47
|
"CREATED",
|
|
8
48
|
"GRANT_PENDING",
|
|
@@ -43,6 +83,11 @@ const DELIVERY_STATES = new Set([
|
|
|
43
83
|
]);
|
|
44
84
|
const RECORD_FILE_PATTERN = /^[0-9a-f]{64}(?:\.issued)?\.json$/u;
|
|
45
85
|
const LOCK_STALE_MS = 30_000;
|
|
86
|
+
const HEX_64_PATTERN = /^[0-9a-f]{64}$/u;
|
|
87
|
+
const TEXT_REPRESENTATION = normalizeRepresentation();
|
|
88
|
+
// Terminal source-free recovery failure for legacy records whose request
|
|
89
|
+
// identity cannot be reconstructed from safe canonical source facts.
|
|
90
|
+
export const LEGACY_RECOVERY_FAILURE_CODE = "SOURCE_FACTS_NOT_RECOVERABLE";
|
|
46
91
|
function bridgeError(code, message, retryable) {
|
|
47
92
|
return new OmniBridgeError({
|
|
48
93
|
code,
|
|
@@ -162,28 +207,157 @@ function parseVersionTwoRecord(value) {
|
|
|
162
207
|
progressPercent: value.progressPercent ?? 0,
|
|
163
208
|
};
|
|
164
209
|
}
|
|
210
|
+
function parseVersionThreeRecord(value) {
|
|
211
|
+
const keys = Object.keys(value);
|
|
212
|
+
if (keys.length !== PERSISTED_V3_KEYS.length ||
|
|
213
|
+
PERSISTED_V3_KEYS.some((key) => !(key in value))) {
|
|
214
|
+
throw new Error("version-3 record fields are invalid");
|
|
215
|
+
}
|
|
216
|
+
if (value.version !== 3 ||
|
|
217
|
+
typeof value.clientRequestId !== "string" ||
|
|
218
|
+
typeof value.requestIdentityHmac !== "string" ||
|
|
219
|
+
!HEX_64_PATTERN.test(value.requestIdentityHmac) ||
|
|
220
|
+
!(value.sourceLocatorHmac === null ||
|
|
221
|
+
(typeof value.sourceLocatorHmac === "string" && HEX_64_PATTERN.test(value.sourceLocatorHmac))) ||
|
|
222
|
+
(value.sourceKind !== "local" && value.sourceKind !== "url") ||
|
|
223
|
+
(value.detail !== "text" && value.detail !== "grounded" && value.detail !== "layout") ||
|
|
224
|
+
(value.groundingSchemaVersion !== "none" &&
|
|
225
|
+
value.groundingSchemaVersion !== GROUNDING_SCHEMA_VERSION) ||
|
|
226
|
+
(value.bundleProtocolVersion !== "none" &&
|
|
227
|
+
value.bundleProtocolVersion !== RESULT_BUNDLE_PROTOCOL_VERSION) ||
|
|
228
|
+
!isStringOrNull(value.operationId) ||
|
|
229
|
+
!(value.operationToken === null || isEncryptedToken(value.operationToken)) ||
|
|
230
|
+
typeof value.state !== "string" ||
|
|
231
|
+
!JOURNAL_STATES.has(value.state) ||
|
|
232
|
+
typeof value.createdAt !== "string" ||
|
|
233
|
+
typeof value.updatedAt !== "string" ||
|
|
234
|
+
!isStringOrNull(value.expiresAt) ||
|
|
235
|
+
!isStringOrNull(value.stage) ||
|
|
236
|
+
!(typeof value.progressPercent === "number" &&
|
|
237
|
+
Number.isFinite(value.progressPercent) &&
|
|
238
|
+
value.progressPercent >= 0 &&
|
|
239
|
+
value.progressPercent <= 100) ||
|
|
240
|
+
!(value.progress === null || isProgress(value.progress)) ||
|
|
241
|
+
typeof value.fileUploaded !== "boolean" ||
|
|
242
|
+
typeof value.parserStarted !== "boolean" ||
|
|
243
|
+
typeof value.billed !== "boolean" ||
|
|
244
|
+
typeof value.contentReleased !== "boolean" ||
|
|
245
|
+
typeof value.processingCopy !== "string" ||
|
|
246
|
+
!CLEANUP_STATES.has(value.processingCopy) ||
|
|
247
|
+
typeof value.temporaryData !== "string" ||
|
|
248
|
+
!CLEANUP_STATES.has(value.temporaryData) ||
|
|
249
|
+
typeof value.deliveryResult !== "string" ||
|
|
250
|
+
!DELIVERY_STATES.has(value.deliveryResult) ||
|
|
251
|
+
!isStringOrNull(value.resultId) ||
|
|
252
|
+
!isStringOrNull(value.resultExpiresAt) ||
|
|
253
|
+
!isStringOrNull(value.errorCode)) {
|
|
254
|
+
throw new Error("version-3 record fields are invalid");
|
|
255
|
+
}
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
165
258
|
function parsePersistedRecord(value) {
|
|
166
259
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
167
260
|
throw new Error("record is not an object");
|
|
168
261
|
}
|
|
169
262
|
const record = value;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
function migrateRecord(record) {
|
|
263
|
+
if (record.version === 1)
|
|
264
|
+
return parseLegacyRecord(record);
|
|
173
265
|
if (record.version === 2)
|
|
174
|
-
return record;
|
|
266
|
+
return parseVersionTwoRecord(record);
|
|
267
|
+
if (record.version === 3)
|
|
268
|
+
return parseVersionThreeRecord(record);
|
|
269
|
+
throw new Error("record version is invalid");
|
|
270
|
+
}
|
|
271
|
+
function migrateInMemory(stored) {
|
|
272
|
+
if (stored.version === 3) {
|
|
273
|
+
return {
|
|
274
|
+
version: 3,
|
|
275
|
+
clientRequestId: stored.clientRequestId,
|
|
276
|
+
requestIdentityHmac: stored.requestIdentityHmac,
|
|
277
|
+
sourceLocatorHmac: stored.sourceLocatorHmac,
|
|
278
|
+
requestHash: null,
|
|
279
|
+
sourceLocatorHash: null,
|
|
280
|
+
uploadUrl: null,
|
|
281
|
+
sourceKind: stored.sourceKind,
|
|
282
|
+
detail: stored.detail,
|
|
283
|
+
groundingSchemaVersion: stored.groundingSchemaVersion,
|
|
284
|
+
bundleProtocolVersion: stored.bundleProtocolVersion,
|
|
285
|
+
operationId: stored.operationId,
|
|
286
|
+
operationToken: stored.operationToken,
|
|
287
|
+
state: stored.state,
|
|
288
|
+
createdAt: stored.createdAt,
|
|
289
|
+
updatedAt: stored.updatedAt,
|
|
290
|
+
expiresAt: stored.expiresAt,
|
|
291
|
+
stage: stored.stage,
|
|
292
|
+
progressPercent: stored.progressPercent,
|
|
293
|
+
progress: stored.progress,
|
|
294
|
+
fileUploaded: stored.fileUploaded,
|
|
295
|
+
parserStarted: stored.parserStarted,
|
|
296
|
+
billed: stored.billed,
|
|
297
|
+
contentReleased: stored.contentReleased,
|
|
298
|
+
processingCopy: stored.processingCopy,
|
|
299
|
+
temporaryData: stored.temporaryData,
|
|
300
|
+
deliveryResult: stored.deliveryResult,
|
|
301
|
+
resultId: stored.resultId,
|
|
302
|
+
resultExpiresAt: stored.resultExpiresAt,
|
|
303
|
+
errorCode: stored.errorCode,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (stored.version === 2) {
|
|
307
|
+
// Legacy v2 text records normalize only to text/none/none; keyed handles
|
|
308
|
+
// are recomputed only when safe canonical source facts reconstruct the
|
|
309
|
+
// stored unkeyed requestHash (see #reconstructLegacy).
|
|
310
|
+
return {
|
|
311
|
+
version: 3,
|
|
312
|
+
clientRequestId: stored.clientRequestId,
|
|
313
|
+
requestIdentityHmac: null,
|
|
314
|
+
sourceLocatorHmac: null,
|
|
315
|
+
requestHash: stored.requestHash,
|
|
316
|
+
sourceLocatorHash: stored.sourceLocatorHash,
|
|
317
|
+
uploadUrl: null,
|
|
318
|
+
sourceKind: stored.sourceKind,
|
|
319
|
+
detail: "text",
|
|
320
|
+
groundingSchemaVersion: "none",
|
|
321
|
+
bundleProtocolVersion: "none",
|
|
322
|
+
operationId: stored.operationId,
|
|
323
|
+
operationToken: stored.operationToken,
|
|
324
|
+
state: stored.state,
|
|
325
|
+
createdAt: stored.createdAt,
|
|
326
|
+
updatedAt: stored.updatedAt,
|
|
327
|
+
expiresAt: stored.expiresAt,
|
|
328
|
+
stage: stored.stage,
|
|
329
|
+
progressPercent: stored.progressPercent,
|
|
330
|
+
progress: stored.progress,
|
|
331
|
+
fileUploaded: stored.fileUploaded,
|
|
332
|
+
parserStarted: stored.parserStarted,
|
|
333
|
+
billed: stored.billed,
|
|
334
|
+
contentReleased: stored.contentReleased,
|
|
335
|
+
processingCopy: stored.processingCopy,
|
|
336
|
+
temporaryData: stored.temporaryData,
|
|
337
|
+
deliveryResult: stored.deliveryResult,
|
|
338
|
+
resultId: stored.resultId,
|
|
339
|
+
resultExpiresAt: stored.resultExpiresAt,
|
|
340
|
+
errorCode: stored.errorCode,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
175
343
|
return {
|
|
176
|
-
version:
|
|
177
|
-
clientRequestId:
|
|
178
|
-
|
|
344
|
+
version: 3,
|
|
345
|
+
clientRequestId: stored.clientRequestId,
|
|
346
|
+
requestIdentityHmac: null,
|
|
347
|
+
sourceLocatorHmac: null,
|
|
348
|
+
requestHash: stored.requestHash,
|
|
179
349
|
sourceLocatorHash: null,
|
|
350
|
+
uploadUrl: stored.uploadUrl,
|
|
180
351
|
sourceKind: "local",
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
352
|
+
detail: "text",
|
|
353
|
+
groundingSchemaVersion: "none",
|
|
354
|
+
bundleProtocolVersion: "none",
|
|
355
|
+
operationId: stored.operationId,
|
|
356
|
+
operationToken: stored.operationToken,
|
|
357
|
+
state: stored.state,
|
|
358
|
+
createdAt: stored.createdAt,
|
|
359
|
+
updatedAt: stored.createdAt,
|
|
360
|
+
expiresAt: stored.expiresAt,
|
|
187
361
|
stage: null,
|
|
188
362
|
progressPercent: 0,
|
|
189
363
|
progress: null,
|
|
@@ -255,6 +429,11 @@ function assertStableTransition(current, updated) {
|
|
|
255
429
|
}
|
|
256
430
|
}
|
|
257
431
|
}
|
|
432
|
+
function hmacEqual(left, right) {
|
|
433
|
+
if (left.length !== right.length)
|
|
434
|
+
return false;
|
|
435
|
+
return timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"));
|
|
436
|
+
}
|
|
258
437
|
export class OperationJournal {
|
|
259
438
|
#rootDirectory;
|
|
260
439
|
#now;
|
|
@@ -263,19 +442,40 @@ export class OperationJournal {
|
|
|
263
442
|
this.#rootDirectory = options.rootDirectory ?? defaultRootDirectory();
|
|
264
443
|
this.#now = options.now ?? (() => new Date());
|
|
265
444
|
}
|
|
266
|
-
|
|
445
|
+
// Public keyed-identity derivation. HMAC-SHA-256 over the journal secret
|
|
446
|
+
// with the exact domain prefix; lowercase full hex. The value is a keyed
|
|
447
|
+
// handle and is never returned in tool output or stored unkeyed.
|
|
448
|
+
async requestIdentityHmac(canonicalIdentityJson) {
|
|
449
|
+
return this.#hmac(REQUEST_IDENTITY_DOMAIN, canonicalIdentityJson);
|
|
450
|
+
}
|
|
451
|
+
async sourceLocatorHmac(sourceLocator) {
|
|
452
|
+
return this.#hmac(SOURCE_LOCATOR_DOMAIN, sourceLocator);
|
|
453
|
+
}
|
|
454
|
+
// v3 intent creation. canonicalIdentityJson is the exact canonical
|
|
455
|
+
// serialization of {source_kind, source_facts} (which includes the
|
|
456
|
+
// normalized representation tuple); the journal derives the keyed identity
|
|
457
|
+
// from it and persists only the HMAC, never the payload or any unkeyed hash.
|
|
458
|
+
async beginIntent(clientRequestId, canonicalIdentityJson, sourceKind = "local", sourceLocator = null, representation = TEXT_REPRESENTATION) {
|
|
267
459
|
validateClientRequestId(clientRequestId);
|
|
268
460
|
const existing = await this.#readRecord(clientRequestId);
|
|
269
461
|
if (existing !== null) {
|
|
270
|
-
return this.#requireMatchingRequest(clientRequestId,
|
|
462
|
+
return this.#requireMatchingRequest(clientRequestId, canonicalIdentityJson, sourceLocator, existing);
|
|
271
463
|
}
|
|
272
464
|
const createdAt = this.#now().toISOString();
|
|
273
465
|
const created = {
|
|
274
|
-
version:
|
|
466
|
+
version: 3,
|
|
275
467
|
clientRequestId,
|
|
276
|
-
|
|
277
|
-
|
|
468
|
+
requestIdentityHmac: await this.#hmac(REQUEST_IDENTITY_DOMAIN, canonicalIdentityJson),
|
|
469
|
+
sourceLocatorHmac: sourceLocator === null
|
|
470
|
+
? null
|
|
471
|
+
: await this.#hmac(SOURCE_LOCATOR_DOMAIN, sourceLocator),
|
|
472
|
+
requestHash: null,
|
|
473
|
+
sourceLocatorHash: null,
|
|
474
|
+
uploadUrl: null,
|
|
278
475
|
sourceKind,
|
|
476
|
+
detail: representation.detail,
|
|
477
|
+
groundingSchemaVersion: representation.groundingSchemaVersion,
|
|
478
|
+
bundleProtocolVersion: representation.bundleProtocolVersion,
|
|
279
479
|
operationId: null,
|
|
280
480
|
operationToken: null,
|
|
281
481
|
state: "CREATED",
|
|
@@ -297,13 +497,39 @@ export class OperationJournal {
|
|
|
297
497
|
errorCode: null,
|
|
298
498
|
};
|
|
299
499
|
if (await this.#createRecord(created, this.#recordPath(clientRequestId))) {
|
|
300
|
-
return this.#
|
|
500
|
+
return this.#recordFromInMemory(clientRequestId, created);
|
|
301
501
|
}
|
|
302
502
|
const raced = await this.#readRecord(clientRequestId);
|
|
303
503
|
if (raced === null) {
|
|
304
504
|
throw bridgeError("JOURNAL_WRITE_FAILED", "The local operation journal could not be saved.", true);
|
|
305
505
|
}
|
|
306
|
-
return this.#requireMatchingRequest(clientRequestId,
|
|
506
|
+
return this.#requireMatchingRequest(clientRequestId, canonicalIdentityJson, sourceLocator, raced);
|
|
507
|
+
}
|
|
508
|
+
// Migrate a legacy v1/v2 record using the caller's safe canonical source
|
|
509
|
+
// facts. When the facts reconstruct the stored unkeyed requestHash, the
|
|
510
|
+
// record migrates to v3 (durably) with domain-separated HMACs and keeps its
|
|
511
|
+
// state; otherwise it becomes a terminal source-free recovery failure and
|
|
512
|
+
// never copies the unkeyed hashes. Returns null when no record exists.
|
|
513
|
+
async migrateLegacyRecord(clientRequestId, canonicalIdentityJson, sourceLocator = null) {
|
|
514
|
+
validateClientRequestId(clientRequestId);
|
|
515
|
+
const stored = await this.#readRecord(clientRequestId);
|
|
516
|
+
if (stored === null)
|
|
517
|
+
return null;
|
|
518
|
+
if (stored.version === 3)
|
|
519
|
+
return this.#recordToPublic(clientRequestId, stored);
|
|
520
|
+
const expected = `sha256:${createHash("sha256").update(canonicalIdentityJson, "utf8").digest("hex")}`;
|
|
521
|
+
if (stored.requestHash === expected) {
|
|
522
|
+
const migrated = await this.#reconstructLegacy(stored, canonicalIdentityJson, sourceLocator);
|
|
523
|
+
await this.#replaceRecord(await this.#persistedFromInMemory(migrated), this.#recordPath(clientRequestId));
|
|
524
|
+
return this.#recordFromInMemory(clientRequestId, migrated);
|
|
525
|
+
}
|
|
526
|
+
const failed = migrateInMemory(stored);
|
|
527
|
+
failed.state = "FAILED";
|
|
528
|
+
failed.errorCode = LEGACY_RECOVERY_FAILURE_CODE;
|
|
529
|
+
failed.operationToken = null;
|
|
530
|
+
failed.stage = "failed";
|
|
531
|
+
failed.uploadUrl = null;
|
|
532
|
+
return this.#recordFromInMemory(clientRequestId, failed);
|
|
307
533
|
}
|
|
308
534
|
async transition(clientRequestId, expectedState, nextState, patch) {
|
|
309
535
|
validateClientRequestId(clientRequestId);
|
|
@@ -343,34 +569,81 @@ export class OperationJournal {
|
|
|
343
569
|
return null;
|
|
344
570
|
return (await this.#listRecords()).find((record) => record.operationId === operationId) ?? null;
|
|
345
571
|
}
|
|
346
|
-
async
|
|
572
|
+
async loadLatestByRequestIdentityHmac(requestIdentityHmac) {
|
|
347
573
|
return (await this.#listRecords())
|
|
348
|
-
.filter((record) => record.
|
|
574
|
+
.filter((record) => record.requestIdentityHmac === requestIdentityHmac)
|
|
349
575
|
.at(-1) ?? null;
|
|
350
576
|
}
|
|
351
|
-
async
|
|
577
|
+
async loadLatestBySourceLocatorHmac(sourceLocatorHmac) {
|
|
352
578
|
return (await this.#listRecords())
|
|
353
|
-
.filter((record) => record.
|
|
579
|
+
.filter((record) => record.sourceLocatorHmac === sourceLocatorHmac)
|
|
354
580
|
.at(-1) ?? null;
|
|
355
581
|
}
|
|
356
582
|
async listRecoverable() {
|
|
357
|
-
return (await this.#listRecords()).filter((record) => !TERMINAL_STATES.has(record.state));
|
|
583
|
+
return (await this.#listRecords()).filter((record) => !TERMINAL_STATES.has(record.state) && record.requestIdentityHmac !== null);
|
|
358
584
|
}
|
|
359
585
|
async begin(clientRequestId, requestHash) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return await this.transition(clientRequestId, "CREATED", "GRANT_PENDING", {});
|
|
586
|
+
validateClientRequestId(clientRequestId);
|
|
587
|
+
const existing = await this.#readRecord(clientRequestId);
|
|
588
|
+
if (existing !== null) {
|
|
589
|
+
return this.#requireMatchingCubeRequest(clientRequestId, requestHash, existing);
|
|
365
590
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
591
|
+
// Legacy Cube path: the grant request hash is the safe canonical identity
|
|
592
|
+
// the journal receives, so the keyed handle is derived over that value.
|
|
593
|
+
const createdAt = this.#now().toISOString();
|
|
594
|
+
const created = {
|
|
595
|
+
version: 3,
|
|
596
|
+
clientRequestId,
|
|
597
|
+
requestIdentityHmac: await this.#hmac(REQUEST_IDENTITY_DOMAIN, requestHash),
|
|
598
|
+
sourceLocatorHmac: null,
|
|
599
|
+
requestHash: null,
|
|
600
|
+
sourceLocatorHash: null,
|
|
601
|
+
uploadUrl: null,
|
|
602
|
+
sourceKind: "local",
|
|
603
|
+
detail: "text",
|
|
604
|
+
groundingSchemaVersion: "none",
|
|
605
|
+
bundleProtocolVersion: "none",
|
|
606
|
+
operationId: null,
|
|
607
|
+
operationToken: null,
|
|
608
|
+
state: "CREATED",
|
|
609
|
+
createdAt,
|
|
610
|
+
updatedAt: createdAt,
|
|
611
|
+
expiresAt: null,
|
|
612
|
+
stage: null,
|
|
613
|
+
progressPercent: 0,
|
|
614
|
+
progress: null,
|
|
615
|
+
fileUploaded: false,
|
|
616
|
+
parserStarted: false,
|
|
617
|
+
billed: false,
|
|
618
|
+
contentReleased: false,
|
|
619
|
+
processingCopy: "not_created",
|
|
620
|
+
temporaryData: "not_created",
|
|
621
|
+
deliveryResult: "not_created",
|
|
622
|
+
resultId: null,
|
|
623
|
+
resultExpiresAt: null,
|
|
624
|
+
errorCode: null,
|
|
625
|
+
};
|
|
626
|
+
if (await this.#createRecord(created, this.#recordPath(clientRequestId))) {
|
|
627
|
+
const intent = await this.#recordFromInMemory(clientRequestId, created);
|
|
628
|
+
if (intent.state !== "CREATED")
|
|
629
|
+
return intent;
|
|
630
|
+
try {
|
|
631
|
+
return await this.transition(clientRequestId, "CREATED", "GRANT_PENDING", {});
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
if (!(error instanceof OmniBridgeError) || error.code !== "JOURNAL_STATE_CONFLICT")
|
|
635
|
+
throw error;
|
|
636
|
+
const raced = await this.#readRecord(clientRequestId);
|
|
637
|
+
if (raced === null)
|
|
638
|
+
throw error;
|
|
639
|
+
return this.#requireMatchingCubeRequest(clientRequestId, requestHash, raced);
|
|
640
|
+
}
|
|
373
641
|
}
|
|
642
|
+
const raced = await this.#readRecord(clientRequestId);
|
|
643
|
+
if (raced === null) {
|
|
644
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local operation journal could not be saved.", true);
|
|
645
|
+
}
|
|
646
|
+
return this.#requireMatchingCubeRequest(clientRequestId, requestHash, raced);
|
|
374
647
|
}
|
|
375
648
|
async markGrantIssued(clientRequestId, fields) {
|
|
376
649
|
const current = await this.loadByRequestId(clientRequestId);
|
|
@@ -425,11 +698,19 @@ export class OperationJournal {
|
|
|
425
698
|
}
|
|
426
699
|
async #persistedRecord(record) {
|
|
427
700
|
return {
|
|
428
|
-
version:
|
|
701
|
+
version: 3,
|
|
429
702
|
clientRequestId: record.clientRequestId,
|
|
430
|
-
|
|
431
|
-
|
|
703
|
+
// A legacy record persisted before reconstruction (for example a
|
|
704
|
+
// source-free recovery failure) gets the deterministic keyed handle of
|
|
705
|
+
// the empty identity; it can never match a real submission.
|
|
706
|
+
requestIdentityHmac: record.requestIdentityHmac === null
|
|
707
|
+
? await this.#hmac(REQUEST_IDENTITY_DOMAIN, "")
|
|
708
|
+
: record.requestIdentityHmac,
|
|
709
|
+
sourceLocatorHmac: record.sourceLocatorHmac,
|
|
432
710
|
sourceKind: record.sourceKind,
|
|
711
|
+
detail: record.detail,
|
|
712
|
+
groundingSchemaVersion: record.groundingSchemaVersion,
|
|
713
|
+
bundleProtocolVersion: record.bundleProtocolVersion,
|
|
433
714
|
operationId: record.operationId,
|
|
434
715
|
operationToken: record.operationToken === null
|
|
435
716
|
? null
|
|
@@ -453,43 +734,116 @@ export class OperationJournal {
|
|
|
453
734
|
errorCode: record.errorCode,
|
|
454
735
|
};
|
|
455
736
|
}
|
|
737
|
+
async #persistedFromInMemory(record) {
|
|
738
|
+
return {
|
|
739
|
+
version: 3,
|
|
740
|
+
clientRequestId: record.clientRequestId,
|
|
741
|
+
requestIdentityHmac: record.requestIdentityHmac === null
|
|
742
|
+
? await this.#hmac(REQUEST_IDENTITY_DOMAIN, "")
|
|
743
|
+
: record.requestIdentityHmac,
|
|
744
|
+
sourceLocatorHmac: record.sourceLocatorHmac,
|
|
745
|
+
sourceKind: record.sourceKind,
|
|
746
|
+
detail: record.detail,
|
|
747
|
+
groundingSchemaVersion: record.groundingSchemaVersion,
|
|
748
|
+
bundleProtocolVersion: record.bundleProtocolVersion,
|
|
749
|
+
operationId: record.operationId,
|
|
750
|
+
operationToken: record.operationToken,
|
|
751
|
+
state: record.state,
|
|
752
|
+
createdAt: record.createdAt,
|
|
753
|
+
updatedAt: record.updatedAt,
|
|
754
|
+
expiresAt: record.expiresAt,
|
|
755
|
+
stage: record.stage,
|
|
756
|
+
progressPercent: record.progressPercent,
|
|
757
|
+
progress: record.progress,
|
|
758
|
+
fileUploaded: record.fileUploaded,
|
|
759
|
+
parserStarted: record.parserStarted,
|
|
760
|
+
billed: record.billed,
|
|
761
|
+
contentReleased: record.contentReleased,
|
|
762
|
+
processingCopy: record.processingCopy,
|
|
763
|
+
temporaryData: record.temporaryData,
|
|
764
|
+
deliveryResult: record.deliveryResult,
|
|
765
|
+
resultId: record.resultId,
|
|
766
|
+
resultExpiresAt: record.resultExpiresAt,
|
|
767
|
+
errorCode: record.errorCode,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
456
770
|
async #recordToPublic(clientRequestId, stored) {
|
|
457
|
-
|
|
458
|
-
|
|
771
|
+
return this.#recordFromInMemory(clientRequestId, migrateInMemory(stored));
|
|
772
|
+
}
|
|
773
|
+
async #recordFromInMemory(clientRequestId, migrated) {
|
|
774
|
+
const operationToken = migrated.operationToken === null
|
|
459
775
|
? null
|
|
460
|
-
: await this.#decryptToken(clientRequestId,
|
|
776
|
+
: await this.#decryptToken(clientRequestId, migrated.operationToken);
|
|
461
777
|
return {
|
|
462
|
-
clientRequestId:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
778
|
+
clientRequestId: migrated.clientRequestId,
|
|
779
|
+
requestIdentityHmac: migrated.requestIdentityHmac,
|
|
780
|
+
sourceLocatorHmac: migrated.sourceLocatorHmac,
|
|
781
|
+
requestHash: migrated.requestHash,
|
|
782
|
+
sourceLocatorHash: migrated.sourceLocatorHash,
|
|
783
|
+
sourceKind: migrated.sourceKind,
|
|
784
|
+
detail: migrated.detail,
|
|
785
|
+
groundingSchemaVersion: migrated.groundingSchemaVersion,
|
|
786
|
+
bundleProtocolVersion: migrated.bundleProtocolVersion,
|
|
787
|
+
operationId: migrated.operationId,
|
|
467
788
|
operationToken,
|
|
468
|
-
uploadUrl:
|
|
469
|
-
state:
|
|
470
|
-
createdAt:
|
|
471
|
-
updatedAt:
|
|
472
|
-
expiresAt:
|
|
473
|
-
stage:
|
|
474
|
-
progressPercent:
|
|
475
|
-
progress:
|
|
476
|
-
fileUploaded:
|
|
477
|
-
parserStarted:
|
|
478
|
-
billed:
|
|
479
|
-
contentReleased:
|
|
480
|
-
processingCopy:
|
|
481
|
-
temporaryData:
|
|
482
|
-
deliveryResult:
|
|
483
|
-
resultId:
|
|
484
|
-
resultExpiresAt:
|
|
485
|
-
errorCode:
|
|
789
|
+
uploadUrl: migrated.uploadUrl,
|
|
790
|
+
state: migrated.state,
|
|
791
|
+
createdAt: migrated.createdAt,
|
|
792
|
+
updatedAt: migrated.updatedAt,
|
|
793
|
+
expiresAt: migrated.expiresAt,
|
|
794
|
+
stage: migrated.stage,
|
|
795
|
+
progressPercent: migrated.progressPercent,
|
|
796
|
+
progress: migrated.progress,
|
|
797
|
+
fileUploaded: migrated.fileUploaded,
|
|
798
|
+
parserStarted: migrated.parserStarted,
|
|
799
|
+
billed: migrated.billed,
|
|
800
|
+
contentReleased: migrated.contentReleased,
|
|
801
|
+
processingCopy: migrated.processingCopy,
|
|
802
|
+
temporaryData: migrated.temporaryData,
|
|
803
|
+
deliveryResult: migrated.deliveryResult,
|
|
804
|
+
resultId: migrated.resultId,
|
|
805
|
+
resultExpiresAt: migrated.resultExpiresAt,
|
|
806
|
+
errorCode: migrated.errorCode,
|
|
486
807
|
};
|
|
487
808
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
809
|
+
// Recompute domain-separated HMACs from safe canonical source facts and drop
|
|
810
|
+
// the legacy unkeyed hashes from the in-memory record.
|
|
811
|
+
async #reconstructLegacy(stored, hmacPayload, sourceLocator) {
|
|
812
|
+
const migrated = migrateInMemory(stored);
|
|
813
|
+
migrated.requestIdentityHmac = await this.#hmac(REQUEST_IDENTITY_DOMAIN, hmacPayload);
|
|
814
|
+
migrated.sourceLocatorHmac = sourceLocator === null
|
|
815
|
+
? null
|
|
816
|
+
: await this.#hmac(SOURCE_LOCATOR_DOMAIN, sourceLocator);
|
|
817
|
+
migrated.requestHash = null;
|
|
818
|
+
migrated.sourceLocatorHash = null;
|
|
819
|
+
return migrated;
|
|
820
|
+
}
|
|
821
|
+
async #requireMatchingRequest(clientRequestId, canonicalIdentityJson, sourceLocator, persisted) {
|
|
822
|
+
if (persisted.version === 3) {
|
|
823
|
+
const expected = await this.#hmacExisting(REQUEST_IDENTITY_DOMAIN, canonicalIdentityJson);
|
|
824
|
+
if (!hmacEqual(persisted.requestIdentityHmac, expected)) {
|
|
825
|
+
throw bridgeError("IDEMPOTENCY_COLLISION", "This local operation request identifier is already bound to different metadata.", false);
|
|
826
|
+
}
|
|
827
|
+
return this.#recordToPublic(clientRequestId, persisted);
|
|
491
828
|
}
|
|
492
|
-
|
|
829
|
+
if (persisted.requestHash ===
|
|
830
|
+
`sha256:${createHash("sha256").update(canonicalIdentityJson, "utf8").digest("hex")}`) {
|
|
831
|
+
return this.#recordFromInMemory(clientRequestId, await this.#reconstructLegacy(persisted, canonicalIdentityJson, sourceLocator));
|
|
832
|
+
}
|
|
833
|
+
throw bridgeError("IDEMPOTENCY_COLLISION", "This local operation request identifier is already bound to different metadata.", false);
|
|
834
|
+
}
|
|
835
|
+
async #requireMatchingCubeRequest(clientRequestId, requestHash, persisted) {
|
|
836
|
+
if (persisted.version === 3) {
|
|
837
|
+
const expected = await this.#hmacExisting(REQUEST_IDENTITY_DOMAIN, requestHash);
|
|
838
|
+
if (!hmacEqual(persisted.requestIdentityHmac, expected)) {
|
|
839
|
+
throw bridgeError("IDEMPOTENCY_COLLISION", "This local operation request identifier is already bound to different metadata.", false);
|
|
840
|
+
}
|
|
841
|
+
return this.#recordToPublic(clientRequestId, persisted);
|
|
842
|
+
}
|
|
843
|
+
if (persisted.requestHash === requestHash) {
|
|
844
|
+
return this.#recordFromInMemory(clientRequestId, await this.#reconstructLegacy(persisted, requestHash, null));
|
|
845
|
+
}
|
|
846
|
+
throw bridgeError("IDEMPOTENCY_COLLISION", "This local operation request identifier is already bound to different metadata.", false);
|
|
493
847
|
}
|
|
494
848
|
#requireMatchingGrant(current, fields) {
|
|
495
849
|
if (current.operationId !== fields.operationId ||
|
|
@@ -515,7 +869,7 @@ export class OperationJournal {
|
|
|
515
869
|
}
|
|
516
870
|
async #readRecord(clientRequestId) {
|
|
517
871
|
const current = await this.#readRecordFile(clientRequestId, this.#recordPath(clientRequestId));
|
|
518
|
-
if (current
|
|
872
|
+
if (current !== null && current.version >= 2)
|
|
519
873
|
return current;
|
|
520
874
|
const legacyIssued = await this.#readRecordFile(clientRequestId, this.#legacyIssuedRecordPath(clientRequestId));
|
|
521
875
|
return legacyIssued ?? current;
|
|
@@ -548,7 +902,7 @@ export class OperationJournal {
|
|
|
548
902
|
let handle;
|
|
549
903
|
try {
|
|
550
904
|
handle = await open(temporaryPath, "wx", 0o600);
|
|
551
|
-
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
905
|
+
await handle.writeFile(`${JSON.stringify(await this.#persistedFromInMemory(record))}\n`, "utf8");
|
|
552
906
|
await handle.sync();
|
|
553
907
|
await handle.close();
|
|
554
908
|
handle = undefined;
|
|
@@ -687,6 +1041,14 @@ export class OperationJournal {
|
|
|
687
1041
|
await unlink(temporaryPath).catch(() => undefined);
|
|
688
1042
|
}
|
|
689
1043
|
}
|
|
1044
|
+
async #hmac(domain, payload) {
|
|
1045
|
+
const key = await this.#journalKey();
|
|
1046
|
+
return createHmac("sha256", key).update(domain, "utf8").update(payload, "utf8").digest("hex");
|
|
1047
|
+
}
|
|
1048
|
+
async #hmacExisting(domain, payload) {
|
|
1049
|
+
const key = await this.#existingJournalKey();
|
|
1050
|
+
return createHmac("sha256", key).update(domain, "utf8").update(payload, "utf8").digest("hex");
|
|
1051
|
+
}
|
|
690
1052
|
async #encryptToken(clientRequestId, operationToken) {
|
|
691
1053
|
const key = await this.#journalKey();
|
|
692
1054
|
const nonce = randomBytes(12);
|