@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.
@@ -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
- return record.version === 1 ? parseLegacyRecord(record) : parseVersionTwoRecord(record);
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: 2,
177
- clientRequestId: record.clientRequestId,
178
- requestHash: record.requestHash,
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
- operationId: record.operationId,
182
- operationToken: record.operationToken,
183
- state: record.state,
184
- createdAt: record.createdAt,
185
- updatedAt: record.createdAt,
186
- expiresAt: record.expiresAt,
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
- async beginIntent(clientRequestId, requestHash, sourceKind = "local", sourceLocatorHash = null) {
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, requestHash, existing);
462
+ return this.#requireMatchingRequest(clientRequestId, canonicalIdentityJson, sourceLocator, existing);
271
463
  }
272
464
  const createdAt = this.#now().toISOString();
273
465
  const created = {
274
- version: 2,
466
+ version: 3,
275
467
  clientRequestId,
276
- requestHash,
277
- sourceLocatorHash,
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.#recordToPublic(clientRequestId, created);
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, requestHash, raced);
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 loadLatestByRequestHash(requestHash) {
572
+ async loadLatestByRequestIdentityHmac(requestIdentityHmac) {
347
573
  return (await this.#listRecords())
348
- .filter((record) => record.requestHash === requestHash)
574
+ .filter((record) => record.requestIdentityHmac === requestIdentityHmac)
349
575
  .at(-1) ?? null;
350
576
  }
351
- async loadLatestBySourceLocatorHash(sourceLocatorHash) {
577
+ async loadLatestBySourceLocatorHmac(sourceLocatorHmac) {
352
578
  return (await this.#listRecords())
353
- .filter((record) => record.sourceLocatorHash === sourceLocatorHash)
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
- const intent = await this.beginIntent(clientRequestId, requestHash);
361
- if (intent.state !== "CREATED")
362
- return intent;
363
- try {
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
- catch (error) {
367
- if (!(error instanceof OmniBridgeError) || error.code !== "JOURNAL_STATE_CONFLICT")
368
- throw error;
369
- const raced = await this.loadByRequestId(clientRequestId);
370
- if (raced === null || raced.requestHash !== requestHash)
371
- throw error;
372
- return raced;
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: 2,
701
+ version: 3,
429
702
  clientRequestId: record.clientRequestId,
430
- requestHash: record.requestHash,
431
- sourceLocatorHash: record.sourceLocatorHash,
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
- const persisted = migrateRecord(stored);
458
- const operationToken = persisted.operationToken === null
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, persisted.operationToken);
776
+ : await this.#decryptToken(clientRequestId, migrated.operationToken);
461
777
  return {
462
- clientRequestId: persisted.clientRequestId,
463
- requestHash: persisted.requestHash,
464
- sourceLocatorHash: persisted.sourceLocatorHash,
465
- sourceKind: persisted.sourceKind,
466
- operationId: persisted.operationId,
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: stored.version === 1 ? stored.uploadUrl : null,
469
- state: persisted.state,
470
- createdAt: persisted.createdAt,
471
- updatedAt: persisted.updatedAt,
472
- expiresAt: persisted.expiresAt,
473
- stage: persisted.stage,
474
- progressPercent: persisted.progressPercent,
475
- progress: persisted.progress,
476
- fileUploaded: persisted.fileUploaded,
477
- parserStarted: persisted.parserStarted,
478
- billed: persisted.billed,
479
- contentReleased: persisted.contentReleased,
480
- processingCopy: persisted.processingCopy,
481
- temporaryData: persisted.temporaryData,
482
- deliveryResult: persisted.deliveryResult,
483
- resultId: persisted.resultId,
484
- resultExpiresAt: persisted.resultExpiresAt,
485
- errorCode: persisted.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
- async #requireMatchingRequest(clientRequestId, requestHash, persisted) {
489
- if (persisted.requestHash !== requestHash) {
490
- throw bridgeError("IDEMPOTENCY_COLLISION", "This local operation request identifier is already bound to different metadata.", false);
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
- return this.#recordToPublic(clientRequestId, persisted);
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?.version === 2)
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);