@otskit/client 0.4.0 → 0.5.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 +1 -1
- package/dist/index.cjs +105 -35
- package/dist/index.d.cts +59 -44
- package/dist/index.d.ts +59 -44
- package/dist/index.js +111 -39
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
### Developer Experience
|
|
34
34
|
- **TypeScript-first** — Strict types throughout; full IntelliSense for every option and error
|
|
35
|
-
- **
|
|
35
|
+
- **Node.js 20+** — Requires Node.js; uses native `crypto`, `dns`, and `net` APIs not available in browsers or edge runtimes
|
|
36
36
|
- **Tree-shakeable** — Dual ESM/CJS build, zero runtime dependencies
|
|
37
37
|
- **`AbortController` support** — Cancel any in-flight operation at any level
|
|
38
38
|
- **Observable** — Drop-in `Logger` interface compatible with `console`, `pino`, `winston`, etc.
|
package/dist/index.cjs
CHANGED
|
@@ -85,7 +85,7 @@ var OpenTimestampsClientError = class extends Error {
|
|
|
85
85
|
constructor(message, options) {
|
|
86
86
|
super(message);
|
|
87
87
|
this.name = this.constructor.name;
|
|
88
|
-
this.cause = options
|
|
88
|
+
if (options?.cause !== void 0) this.cause = options.cause;
|
|
89
89
|
Error.captureStackTrace?.(this, this.constructor);
|
|
90
90
|
}
|
|
91
91
|
};
|
|
@@ -103,11 +103,11 @@ var StampError = class extends OpenTimestampsClientError {
|
|
|
103
103
|
var UpgradeError = class extends OpenTimestampsClientError {
|
|
104
104
|
};
|
|
105
105
|
var NetworkError = class extends OpenTimestampsClientError {
|
|
106
|
-
/** HTTP status code
|
|
106
|
+
/** HTTP status code when the failure originates from an HTTP response. */
|
|
107
107
|
status;
|
|
108
108
|
constructor(message, options) {
|
|
109
109
|
super(message, options);
|
|
110
|
-
this.status = options
|
|
110
|
+
if (options?.status !== void 0) this.status = options.status;
|
|
111
111
|
}
|
|
112
112
|
};
|
|
113
113
|
var CircuitBreakerError = class extends NetworkError {
|
|
@@ -130,7 +130,7 @@ var SizeLimitExceededError = class extends NetworkError {
|
|
|
130
130
|
options
|
|
131
131
|
);
|
|
132
132
|
this.maxBytes = maxBytes;
|
|
133
|
-
this.actualBytes = actualBytes;
|
|
133
|
+
if (actualBytes !== void 0) this.actualBytes = actualBytes;
|
|
134
134
|
}
|
|
135
135
|
};
|
|
136
136
|
|
|
@@ -182,7 +182,10 @@ var CircuitBreaker = class {
|
|
|
182
182
|
this.onSuccess(key, circuit);
|
|
183
183
|
return result;
|
|
184
184
|
} catch (error) {
|
|
185
|
-
|
|
185
|
+
const is4xx = error instanceof Error && error.retryable === false;
|
|
186
|
+
if (!is4xx) {
|
|
187
|
+
this.onFailure(key, circuit);
|
|
188
|
+
}
|
|
186
189
|
throw error;
|
|
187
190
|
}
|
|
188
191
|
}
|
|
@@ -279,11 +282,15 @@ function sleep(ms, signal) {
|
|
|
279
282
|
reject(new Error("Aborted"));
|
|
280
283
|
return;
|
|
281
284
|
}
|
|
282
|
-
const
|
|
283
|
-
signal?.addEventListener("abort", () => {
|
|
285
|
+
const onAbort = () => {
|
|
284
286
|
clearTimeout(timeout);
|
|
285
287
|
reject(new Error("Aborted"));
|
|
286
|
-
}
|
|
288
|
+
};
|
|
289
|
+
const timeout = setTimeout(() => {
|
|
290
|
+
signal?.removeEventListener("abort", onAbort);
|
|
291
|
+
resolve();
|
|
292
|
+
}, ms);
|
|
293
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
287
294
|
});
|
|
288
295
|
}
|
|
289
296
|
async function withRetry(fn, options, logger, signal) {
|
|
@@ -366,8 +373,9 @@ async function executeRequest(request, maxBytes) {
|
|
|
366
373
|
const response = await globalThis.fetch(request.url, {
|
|
367
374
|
method: request.method,
|
|
368
375
|
headers: { "Content-Type": "application/octet-stream", ...request.headers },
|
|
369
|
-
body: request.body,
|
|
370
|
-
signal: request.signal
|
|
376
|
+
...request.body !== void 0 ? { body: request.body } : {},
|
|
377
|
+
...request.signal !== void 0 ? { signal: request.signal } : {},
|
|
378
|
+
redirect: "error"
|
|
371
379
|
});
|
|
372
380
|
const data = await readResponseBody(response, maxBytes);
|
|
373
381
|
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
|
@@ -515,7 +523,7 @@ var CalendarClient = class {
|
|
|
515
523
|
url;
|
|
516
524
|
networkLayer;
|
|
517
525
|
logger;
|
|
518
|
-
/**
|
|
526
|
+
/** Submits a digest to the calendar and returns the Timestamp that commits to it. */
|
|
519
527
|
async submit(digest, signal) {
|
|
520
528
|
assertCommitment(digest);
|
|
521
529
|
this.logger?.debug(`Submitting digest to ${this.url}/digest`);
|
|
@@ -526,7 +534,7 @@ var CalendarClient = class {
|
|
|
526
534
|
);
|
|
527
535
|
return this.#parseTimestamp(response.data, digest);
|
|
528
536
|
}
|
|
529
|
-
/**
|
|
537
|
+
/** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
|
|
530
538
|
async getTimestamp(commitment, signal) {
|
|
531
539
|
assertCommitment(commitment);
|
|
532
540
|
const path = `/timestamp/${(0, import_core2.bytesToHex)(commitment)}`;
|
|
@@ -548,7 +556,7 @@ var CalendarClient = class {
|
|
|
548
556
|
}
|
|
549
557
|
return this.#parseTimestamp(response.data, commitment);
|
|
550
558
|
}
|
|
551
|
-
/**
|
|
559
|
+
/** Deserializes the calendar response as a Timestamp committed to `commitment`. */
|
|
552
560
|
#parseTimestamp(data, commitment) {
|
|
553
561
|
if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
|
|
554
562
|
throw new CalendarResponseTooLargeError(
|
|
@@ -578,7 +586,7 @@ function parseWhitelistPattern(raw) {
|
|
|
578
586
|
hostname,
|
|
579
587
|
port: parsed.port,
|
|
580
588
|
pathname: parsed.pathname,
|
|
581
|
-
wildcardSuffix
|
|
589
|
+
...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
|
|
582
590
|
};
|
|
583
591
|
}
|
|
584
592
|
function hostnameMatchesPattern(hostname, pattern) {
|
|
@@ -594,20 +602,26 @@ var UrlWhitelist = class {
|
|
|
594
602
|
for (const u of urls) this.add(u);
|
|
595
603
|
}
|
|
596
604
|
}
|
|
597
|
-
/**
|
|
605
|
+
/**
|
|
606
|
+
* Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
|
|
607
|
+
* Throws TypeError if the pattern is not a valid string or is structurally invalid.
|
|
608
|
+
*/
|
|
598
609
|
add(url) {
|
|
599
610
|
if (typeof url !== "string") {
|
|
600
611
|
throw new TypeError("UrlWhitelist: URL must be a string");
|
|
601
612
|
}
|
|
602
613
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
603
614
|
const pattern = parseWhitelistPattern(url);
|
|
604
|
-
if (pattern
|
|
615
|
+
if (pattern === void 0) {
|
|
616
|
+
throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
|
|
617
|
+
}
|
|
618
|
+
this.#patterns.set(url, pattern);
|
|
605
619
|
} else {
|
|
606
620
|
this.add("http://" + url);
|
|
607
621
|
this.add("https://" + url);
|
|
608
622
|
}
|
|
609
623
|
}
|
|
610
|
-
/**
|
|
624
|
+
/** Returns true if `url` matches any pattern in the allowlist. */
|
|
611
625
|
contains(url) {
|
|
612
626
|
let parsed;
|
|
613
627
|
try {
|
|
@@ -633,10 +647,17 @@ var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...import_core2.TRUSTED_CALEN
|
|
|
633
647
|
var DEFAULT_AGGREGATORS = [...import_core2.DEFAULT_AGGREGATOR_URLS];
|
|
634
648
|
|
|
635
649
|
// src/network/esplora.ts
|
|
650
|
+
var import_node_crypto = require("crypto");
|
|
636
651
|
var import_core3 = require("@otskit/core");
|
|
637
652
|
var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
|
|
638
653
|
var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
|
|
654
|
+
var RAW_HEADER_SIZE = 80;
|
|
639
655
|
var HEX64_RE = /^[0-9a-f]{64}$/i;
|
|
656
|
+
function sha256dDisplayHex(data) {
|
|
657
|
+
const first = (0, import_node_crypto.createHash)("sha256").update(data).digest();
|
|
658
|
+
const second = (0, import_node_crypto.createHash)("sha256").update(first).digest();
|
|
659
|
+
return Buffer.from(second).reverse().toString("hex");
|
|
660
|
+
}
|
|
640
661
|
var EsploraClient = class {
|
|
641
662
|
#url;
|
|
642
663
|
#networkLayer;
|
|
@@ -656,7 +677,7 @@ var EsploraClient = class {
|
|
|
656
677
|
this.#url = raw.replace(/\/+$/, "");
|
|
657
678
|
this.#logger = options.logger;
|
|
658
679
|
}
|
|
659
|
-
/**
|
|
680
|
+
/** Returns the block hash (64-char hex, lowercase) at the given height. */
|
|
660
681
|
async blockHash(height, signal) {
|
|
661
682
|
if (!Number.isSafeInteger(height) || height < 0) {
|
|
662
683
|
throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
|
|
@@ -673,7 +694,7 @@ var EsploraClient = class {
|
|
|
673
694
|
}
|
|
674
695
|
return text.toLowerCase();
|
|
675
696
|
}
|
|
676
|
-
/**
|
|
697
|
+
/** Returns the block header (merkle root + timestamp) for the given hash. */
|
|
677
698
|
async block(hash, signal) {
|
|
678
699
|
if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
|
|
679
700
|
throw new ValidationError("block hash must be a 64-char hex string");
|
|
@@ -691,7 +712,7 @@ var EsploraClient = class {
|
|
|
691
712
|
} catch (err) {
|
|
692
713
|
throw new EsploraResponseError("esplora returned a non-JSON block response", {
|
|
693
714
|
/* v8 ignore next */
|
|
694
|
-
|
|
715
|
+
...err instanceof Error ? { cause: err } : {}
|
|
695
716
|
});
|
|
696
717
|
}
|
|
697
718
|
if (typeof body !== "object" || body === null) {
|
|
@@ -706,7 +727,36 @@ var EsploraClient = class {
|
|
|
706
727
|
}
|
|
707
728
|
return { merkleroot, time };
|
|
708
729
|
}
|
|
709
|
-
/**
|
|
730
|
+
/**
|
|
731
|
+
* Fetches the raw 80-byte block header for `hash` and self-authenticates it:
|
|
732
|
+
* sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
|
|
733
|
+
* JSON layer — the raw header is cryptographically bound to the block hash we requested.
|
|
734
|
+
*/
|
|
735
|
+
async rawBlockHeader(hash, signal) {
|
|
736
|
+
if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
|
|
737
|
+
throw new ValidationError("block hash must be a 64-char hex string");
|
|
738
|
+
}
|
|
739
|
+
this.#logger?.debug(`Esplora raw header ${hash}`);
|
|
740
|
+
const response = await this.#networkLayer.request(
|
|
741
|
+
this.#url,
|
|
742
|
+
{ url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "application/octet-stream" } },
|
|
743
|
+
signal
|
|
744
|
+
);
|
|
745
|
+
const data = response.data;
|
|
746
|
+
if (data.length !== RAW_HEADER_SIZE) {
|
|
747
|
+
throw new EsploraResponseError(
|
|
748
|
+
`raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const actualHash = sha256dDisplayHex(data);
|
|
752
|
+
if (actualHash !== hash.toLowerCase()) {
|
|
753
|
+
throw new EsploraResponseError(
|
|
754
|
+
`raw block header hash mismatch: expected ${hash.toLowerCase()}, got ${actualHash}`
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
return data;
|
|
758
|
+
}
|
|
759
|
+
/** Decodes the response body as text, enforcing the size limit (fail-closed). */
|
|
710
760
|
#decode(data) {
|
|
711
761
|
if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
|
|
712
762
|
throw new EsploraResponseError(
|
|
@@ -717,7 +767,7 @@ var EsploraClient = class {
|
|
|
717
767
|
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
718
768
|
} catch (cause) {
|
|
719
769
|
throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
|
|
720
|
-
cause
|
|
770
|
+
...cause instanceof Error ? { cause } : {}
|
|
721
771
|
});
|
|
722
772
|
}
|
|
723
773
|
}
|
|
@@ -727,12 +777,12 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
727
777
|
throw new import_core3.VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
|
|
728
778
|
}
|
|
729
779
|
const hash = await explorer.blockHash(attestation.height, signal);
|
|
730
|
-
const
|
|
731
|
-
return (0, import_core3.
|
|
780
|
+
const rawHeader = await explorer.rawBlockHeader(hash, signal);
|
|
781
|
+
return (0, import_core3.verifyAgainstRawHeader)(digest, rawHeader);
|
|
732
782
|
}
|
|
733
783
|
|
|
734
784
|
// src/core/orchestration.ts
|
|
735
|
-
var
|
|
785
|
+
var import_node_crypto2 = require("crypto");
|
|
736
786
|
|
|
737
787
|
// src/security/ssrf.ts
|
|
738
788
|
var import_promises = require("dns/promises");
|
|
@@ -855,7 +905,7 @@ function secureNonce(n) {
|
|
|
855
905
|
}
|
|
856
906
|
function timingSafeEq(a, b) {
|
|
857
907
|
if (a.length !== b.length) return false;
|
|
858
|
-
return (0,
|
|
908
|
+
return (0, import_node_crypto2.timingSafeEqual)(a, b);
|
|
859
909
|
}
|
|
860
910
|
var bytesEqFast = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
|
|
861
911
|
async function orchestrateStamp(hash, calendars, networkLayer, logger, signal, minimumSuccessfulSubmissions = 2, allowPrivateCalendars = false) {
|
|
@@ -912,7 +962,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
912
962
|
} catch (error) {
|
|
913
963
|
throw new ValidationError("Invalid .ots proof format", {
|
|
914
964
|
/* v8 ignore next */
|
|
915
|
-
|
|
965
|
+
...error instanceof Error ? { cause: error } : {}
|
|
916
966
|
});
|
|
917
967
|
}
|
|
918
968
|
if (detached.timestamp.isTimestampComplete()) {
|
|
@@ -949,15 +999,21 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
949
999
|
}
|
|
950
1000
|
return Buffer.from(after);
|
|
951
1001
|
}
|
|
952
|
-
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
|
|
1002
|
+
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
953
1003
|
let detached;
|
|
954
1004
|
try {
|
|
955
1005
|
detached = import_core4.DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
956
1006
|
} catch (cause) {
|
|
957
1007
|
throw new ValidationError("Invalid .ots proof format", {
|
|
958
|
-
cause
|
|
1008
|
+
...cause instanceof Error ? { cause } : {}
|
|
959
1009
|
});
|
|
960
1010
|
}
|
|
1011
|
+
if (detached.fileHashOp instanceof import_core4.OpSHA1 || detached.fileHashOp instanceof import_core4.OpRIPEMD160) {
|
|
1012
|
+
return {
|
|
1013
|
+
status: "invalid",
|
|
1014
|
+
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
961
1017
|
if (originalDataHash !== void 0) {
|
|
962
1018
|
let expected;
|
|
963
1019
|
try {
|
|
@@ -965,7 +1021,7 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
965
1021
|
} catch (err) {
|
|
966
1022
|
throw new ValidationError(
|
|
967
1023
|
err instanceof Error ? err.message : "Invalid hash format",
|
|
968
|
-
{
|
|
1024
|
+
{ ...err instanceof Error ? { cause: err } : {} }
|
|
969
1025
|
);
|
|
970
1026
|
}
|
|
971
1027
|
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
@@ -996,7 +1052,10 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
996
1052
|
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
997
1053
|
};
|
|
998
1054
|
}
|
|
999
|
-
const explorer = new EsploraClient(networkLayer
|
|
1055
|
+
const explorer = new EsploraClient(networkLayer, {
|
|
1056
|
+
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
1057
|
+
...logger !== void 0 ? { logger } : {}
|
|
1058
|
+
});
|
|
1000
1059
|
let lastNetworkError;
|
|
1001
1060
|
let lastCryptoError;
|
|
1002
1061
|
for (const { msg, attestation } of bitcoinAtts) {
|
|
@@ -1038,20 +1097,32 @@ var OpenTimestampsClient = class {
|
|
|
1038
1097
|
globalSignal;
|
|
1039
1098
|
minimumSuccessfulSubmissions;
|
|
1040
1099
|
allowPrivateCalendars;
|
|
1100
|
+
esploraUrl;
|
|
1041
1101
|
/**
|
|
1042
1102
|
* Create a new OpenTimestamps client
|
|
1043
1103
|
*
|
|
1044
1104
|
* @param options Client configuration options
|
|
1045
1105
|
*/
|
|
1046
1106
|
constructor(options = {}) {
|
|
1107
|
+
this.logger = options.logger;
|
|
1047
1108
|
if (!options.calendars || options.calendars.length === 0) {
|
|
1048
1109
|
this.calendars = DEFAULT_CALENDARS;
|
|
1049
1110
|
this.logger?.info("No calendars provided, using defaults");
|
|
1050
1111
|
} else {
|
|
1051
1112
|
this.calendars = options.calendars;
|
|
1052
1113
|
}
|
|
1053
|
-
|
|
1114
|
+
const minSubs = options.minimumSuccessfulSubmissions ?? 2;
|
|
1115
|
+
if (!Number.isInteger(minSubs) || minSubs < 1) {
|
|
1116
|
+
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
1117
|
+
}
|
|
1118
|
+
if (minSubs > this.calendars.length) {
|
|
1119
|
+
throw new ValidationError(
|
|
1120
|
+
`minimumSuccessfulSubmissions (${minSubs}) cannot exceed the number of calendars (${this.calendars.length})`
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
this.minimumSuccessfulSubmissions = minSubs;
|
|
1054
1124
|
this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
|
|
1125
|
+
this.esploraUrl = options.esploraUrl;
|
|
1055
1126
|
const resilienceConfig = {
|
|
1056
1127
|
...DEFAULT_RESILIENCE,
|
|
1057
1128
|
...options.resilience,
|
|
@@ -1068,8 +1139,7 @@ var OpenTimestampsClient = class {
|
|
|
1068
1139
|
...options.resilience?.circuitBreaker
|
|
1069
1140
|
}
|
|
1070
1141
|
};
|
|
1071
|
-
this.
|
|
1072
|
-
this.globalSignal = options.signal;
|
|
1142
|
+
if (options.signal !== void 0) this.globalSignal = options.signal;
|
|
1073
1143
|
const internalOptions = options;
|
|
1074
1144
|
this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
|
|
1075
1145
|
this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
|
|
@@ -1154,7 +1224,7 @@ var OpenTimestampsClient = class {
|
|
|
1154
1224
|
* ```
|
|
1155
1225
|
*/
|
|
1156
1226
|
async verify(proof, originalDataHash) {
|
|
1157
|
-
return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
|
|
1227
|
+
return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal, this.esploraUrl);
|
|
1158
1228
|
}
|
|
1159
1229
|
/**
|
|
1160
1230
|
* Get the current state of the circuit breaker for a calendar
|
package/dist/index.d.cts
CHANGED
|
@@ -4,7 +4,7 @@ export { Attestation, BitcoinAttestation, DetachedTimestampFile, PendingAttestat
|
|
|
4
4
|
/**
|
|
5
5
|
* Type definitions for the OpenTimestamps Client SDK
|
|
6
6
|
*/
|
|
7
|
-
/** Logger interface for observability */
|
|
7
|
+
/** Logger interface for observability. */
|
|
8
8
|
interface Logger {
|
|
9
9
|
debug(message: string, ...args: unknown[]): void;
|
|
10
10
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -39,7 +39,7 @@ interface ResilienceOptions {
|
|
|
39
39
|
connectTimeoutMs: number;
|
|
40
40
|
retries: RetryOptions;
|
|
41
41
|
circuitBreaker: CircuitBreakerOptions;
|
|
42
|
-
/**
|
|
42
|
+
/** Maximum bytes allowed in the response body. Defaults to 100 KB. */
|
|
43
43
|
maxResponseBytes?: number;
|
|
44
44
|
}
|
|
45
45
|
/** Client configuration options */
|
|
@@ -55,12 +55,18 @@ interface ClientOptions {
|
|
|
55
55
|
/** Minimum successful calendar submissions required (default: 2) */
|
|
56
56
|
minimumSuccessfulSubmissions?: number;
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* Base URL for the Esplora block explorer used by verify().
|
|
59
|
+
* Defaults to Blockstream (https://blockstream.info/api).
|
|
60
|
+
* Override to use a self-hosted or alternative explorer.
|
|
61
|
+
*/
|
|
62
|
+
esploraUrl?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Allows calendar URLs that resolve to private/reserved IPs.
|
|
59
65
|
*
|
|
60
|
-
* **
|
|
66
|
+
* **Do not enable in production.** Useful for local testing or corporate networks.
|
|
61
67
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
68
|
+
* Even when `false`, protection is best-effort against DNS rebinding (TOCTOU).
|
|
69
|
+
* For high-security environments, complement with network-level egress filtering.
|
|
64
70
|
*
|
|
65
71
|
* @default false
|
|
66
72
|
*/
|
|
@@ -139,6 +145,7 @@ declare class OpenTimestampsClient {
|
|
|
139
145
|
private globalSignal?;
|
|
140
146
|
private minimumSuccessfulSubmissions;
|
|
141
147
|
private allowPrivateCalendars;
|
|
148
|
+
private esploraUrl;
|
|
142
149
|
/**
|
|
143
150
|
* Create a new OpenTimestamps client
|
|
144
151
|
*
|
|
@@ -265,7 +272,7 @@ declare class UpgradeError extends OpenTimestampsClientError {
|
|
|
265
272
|
}
|
|
266
273
|
/** Network-related error (timeout, all retries failed, etc.) */
|
|
267
274
|
declare class NetworkError extends OpenTimestampsClientError {
|
|
268
|
-
/** HTTP status code
|
|
275
|
+
/** HTTP status code when the failure originates from an HTTP response. */
|
|
269
276
|
readonly status?: number;
|
|
270
277
|
constructor(message: string, options?: {
|
|
271
278
|
cause?: Error;
|
|
@@ -276,16 +283,16 @@ declare class NetworkError extends OpenTimestampsClientError {
|
|
|
276
283
|
declare class CircuitBreakerError extends NetworkError {
|
|
277
284
|
constructor(calendar: string);
|
|
278
285
|
}
|
|
279
|
-
/**
|
|
286
|
+
/** The calendar does not yet know the queried commitment (HTTP 404). */
|
|
280
287
|
declare class CommitmentNotFoundError extends NetworkError {
|
|
281
288
|
}
|
|
282
|
-
/**
|
|
289
|
+
/** The calendar response exceeds the allowed size limit (DoS defense). */
|
|
283
290
|
declare class CalendarResponseTooLargeError extends NetworkError {
|
|
284
291
|
}
|
|
285
|
-
/**
|
|
292
|
+
/** Invalid Esplora response: empty, non-JSON, malformed, or too large (DoS defense). */
|
|
286
293
|
declare class EsploraResponseError extends NetworkError {
|
|
287
294
|
}
|
|
288
|
-
/**
|
|
295
|
+
/** Response exceeds the allowed byte limit (DoS defense). */
|
|
289
296
|
declare class SizeLimitExceededError extends NetworkError {
|
|
290
297
|
readonly maxBytes: number;
|
|
291
298
|
readonly actualBytes?: number;
|
|
@@ -306,8 +313,7 @@ declare enum CircuitState {
|
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
/**
|
|
309
|
-
*
|
|
310
|
-
* Funciona en Node.js 18+, browsers y edge runtimes.
|
|
316
|
+
* Fetch adapter for Node.js 20+.
|
|
311
317
|
*/
|
|
312
318
|
interface FetchRequest {
|
|
313
319
|
url: string;
|
|
@@ -341,30 +347,33 @@ declare class ResilientNetworkLayer {
|
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
/**
|
|
344
|
-
*
|
|
350
|
+
* Remote OpenTimestamps calendar client (real OTS protocol).
|
|
345
351
|
*/
|
|
346
352
|
|
|
347
|
-
/**
|
|
353
|
+
/** Maximum response size from a calendar server (DoS defense). */
|
|
348
354
|
declare const MAX_CALENDAR_RESPONSE_SIZE = 10000;
|
|
349
|
-
/**
|
|
355
|
+
/** Interface to a remote calendar server. */
|
|
350
356
|
declare class CalendarClient {
|
|
351
357
|
#private;
|
|
352
358
|
private readonly url;
|
|
353
359
|
private readonly networkLayer;
|
|
354
360
|
private readonly logger?;
|
|
355
361
|
constructor(url: string, networkLayer: ResilientNetworkLayer, logger?: Logger | undefined);
|
|
356
|
-
/**
|
|
362
|
+
/** Submits a digest to the calendar and returns the Timestamp that commits to it. */
|
|
357
363
|
submit(digest: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
|
|
358
|
-
/**
|
|
364
|
+
/** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
|
|
359
365
|
getTimestamp(commitment: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
|
|
360
366
|
}
|
|
361
|
-
/**
|
|
367
|
+
/** Allowlist of trusted calendar URLs. */
|
|
362
368
|
declare class UrlWhitelist {
|
|
363
369
|
#private;
|
|
364
370
|
constructor(urls?: readonly string[]);
|
|
365
|
-
/**
|
|
371
|
+
/**
|
|
372
|
+
* Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
|
|
373
|
+
* Throws TypeError if the pattern is not a valid string or is structurally invalid.
|
|
374
|
+
*/
|
|
366
375
|
add(url: string): void;
|
|
367
|
-
/**
|
|
376
|
+
/** Returns true if `url` matches any pattern in the allowlist. */
|
|
368
377
|
contains(url: string): boolean;
|
|
369
378
|
toString(): string;
|
|
370
379
|
}
|
|
@@ -379,32 +388,38 @@ declare const DEFAULT_CALENDAR_WHITELIST: UrlWhitelist;
|
|
|
379
388
|
*/
|
|
380
389
|
declare const DEFAULT_AGGREGATORS: readonly string[];
|
|
381
390
|
|
|
382
|
-
/**
|
|
391
|
+
/** Default public Esplora explorer (Bitcoin mainnet). */
|
|
383
392
|
declare const PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
|
|
384
|
-
/**
|
|
393
|
+
/** Maximum size of an Esplora response (DoS defense). A JSON block header is a few hundred bytes. */
|
|
385
394
|
declare const MAX_ESPLORA_RESPONSE_SIZE = 100000;
|
|
386
395
|
interface EsploraClientOptions {
|
|
387
|
-
/** URL
|
|
396
|
+
/** Base URL of the explorer (defaults to Blockstream). Useful for pointing to a Litecoin Esplora. */
|
|
388
397
|
url?: string;
|
|
389
398
|
logger?: Logger;
|
|
390
399
|
}
|
|
391
|
-
/**
|
|
400
|
+
/** Client for a remote Esplora explorer. */
|
|
392
401
|
declare class EsploraClient {
|
|
393
402
|
#private;
|
|
394
403
|
constructor(networkLayer: ResilientNetworkLayer, options?: EsploraClientOptions);
|
|
395
|
-
/**
|
|
404
|
+
/** Returns the block hash (64-char hex, lowercase) at the given height. */
|
|
396
405
|
blockHash(height: number, signal?: AbortSignal): Promise<string>;
|
|
397
|
-
/**
|
|
406
|
+
/** Returns the block header (merkle root + timestamp) for the given hash. */
|
|
398
407
|
block(hash: string, signal?: AbortSignal): Promise<BlockHeader>;
|
|
408
|
+
/**
|
|
409
|
+
* Fetches the raw 80-byte block header for `hash` and self-authenticates it:
|
|
410
|
+
* sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
|
|
411
|
+
* JSON layer — the raw header is cryptographically bound to the block hash we requested.
|
|
412
|
+
*/
|
|
413
|
+
rawBlockHeader(hash: string, signal?: AbortSignal): Promise<Uint8Array>;
|
|
399
414
|
}
|
|
400
415
|
/**
|
|
401
|
-
*
|
|
416
|
+
* Verifies a Bitcoin/Litecoin attestation against the corresponding block header.
|
|
402
417
|
*
|
|
403
|
-
* `digest`
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
418
|
+
* `digest` is the final tree commitment at the attestation point (32 bytes, must equal the
|
|
419
|
+
* block's merkle root). `explorer` must point to the correct chain (Blockstream for Bitcoin;
|
|
420
|
+
* a Litecoin Esplora for Litecoin). Returns the block time (epoch seconds) on success;
|
|
421
|
+
* throws `VerificationError` if the digest does not match or the attestation is not
|
|
422
|
+
* on-chain verifiable (`pending`/`unknown`). Fail-closed.
|
|
408
423
|
*/
|
|
409
424
|
declare function verifyTimestampAttestation(digest: Uint8Array, attestation: Attestation, explorer: EsploraClient, signal?: AbortSignal): Promise<number>;
|
|
410
425
|
|
|
@@ -412,21 +427,21 @@ declare function hashBuffer(data: Buffer | Uint8Array): Buffer;
|
|
|
412
427
|
declare function hashFile(path: string): Promise<Buffer>;
|
|
413
428
|
|
|
414
429
|
/**
|
|
415
|
-
* SSRF protection
|
|
430
|
+
* SSRF protection for user-configurable calendar URLs.
|
|
416
431
|
*
|
|
417
|
-
*
|
|
418
|
-
* - TOCTOU/DNS rebinding:
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* - IPv4-mapped IPv6 (::ffff:x.x.x.x):
|
|
422
|
-
*
|
|
432
|
+
* KNOWN LIMITATIONS (documented intentionally):
|
|
433
|
+
* - TOCTOU / DNS rebinding: DNS validation happens BEFORE the connection.
|
|
434
|
+
* A server with TTL=0 can change its IP between validation and the actual fetch.
|
|
435
|
+
* Real mitigation requires network-level egress filtering.
|
|
436
|
+
* - IPv4-mapped IPv6 (::ffff:x.x.x.x): the ::ffff: prefix is blocked but the
|
|
437
|
+
* IPv4 component validation depends on the format Node.js returns.
|
|
423
438
|
*/
|
|
424
439
|
/**
|
|
425
|
-
*
|
|
426
|
-
*
|
|
440
|
+
* Validates that a calendar URL is safe for outbound HTTP requests.
|
|
441
|
+
* Blocks private/reserved IPs by default.
|
|
427
442
|
*
|
|
428
|
-
* @param allowPrivate
|
|
429
|
-
*
|
|
443
|
+
* @param allowPrivate When true, skips the IP range check.
|
|
444
|
+
* Useful for local testing or internal corporate networks.
|
|
430
445
|
*/
|
|
431
446
|
declare function assertSafeCalendarUrl(url: string, options: {
|
|
432
447
|
allowPrivate: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { Attestation, BitcoinAttestation, DetachedTimestampFile, PendingAttestat
|
|
|
4
4
|
/**
|
|
5
5
|
* Type definitions for the OpenTimestamps Client SDK
|
|
6
6
|
*/
|
|
7
|
-
/** Logger interface for observability */
|
|
7
|
+
/** Logger interface for observability. */
|
|
8
8
|
interface Logger {
|
|
9
9
|
debug(message: string, ...args: unknown[]): void;
|
|
10
10
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -39,7 +39,7 @@ interface ResilienceOptions {
|
|
|
39
39
|
connectTimeoutMs: number;
|
|
40
40
|
retries: RetryOptions;
|
|
41
41
|
circuitBreaker: CircuitBreakerOptions;
|
|
42
|
-
/**
|
|
42
|
+
/** Maximum bytes allowed in the response body. Defaults to 100 KB. */
|
|
43
43
|
maxResponseBytes?: number;
|
|
44
44
|
}
|
|
45
45
|
/** Client configuration options */
|
|
@@ -55,12 +55,18 @@ interface ClientOptions {
|
|
|
55
55
|
/** Minimum successful calendar submissions required (default: 2) */
|
|
56
56
|
minimumSuccessfulSubmissions?: number;
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* Base URL for the Esplora block explorer used by verify().
|
|
59
|
+
* Defaults to Blockstream (https://blockstream.info/api).
|
|
60
|
+
* Override to use a self-hosted or alternative explorer.
|
|
61
|
+
*/
|
|
62
|
+
esploraUrl?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Allows calendar URLs that resolve to private/reserved IPs.
|
|
59
65
|
*
|
|
60
|
-
* **
|
|
66
|
+
* **Do not enable in production.** Useful for local testing or corporate networks.
|
|
61
67
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
68
|
+
* Even when `false`, protection is best-effort against DNS rebinding (TOCTOU).
|
|
69
|
+
* For high-security environments, complement with network-level egress filtering.
|
|
64
70
|
*
|
|
65
71
|
* @default false
|
|
66
72
|
*/
|
|
@@ -139,6 +145,7 @@ declare class OpenTimestampsClient {
|
|
|
139
145
|
private globalSignal?;
|
|
140
146
|
private minimumSuccessfulSubmissions;
|
|
141
147
|
private allowPrivateCalendars;
|
|
148
|
+
private esploraUrl;
|
|
142
149
|
/**
|
|
143
150
|
* Create a new OpenTimestamps client
|
|
144
151
|
*
|
|
@@ -265,7 +272,7 @@ declare class UpgradeError extends OpenTimestampsClientError {
|
|
|
265
272
|
}
|
|
266
273
|
/** Network-related error (timeout, all retries failed, etc.) */
|
|
267
274
|
declare class NetworkError extends OpenTimestampsClientError {
|
|
268
|
-
/** HTTP status code
|
|
275
|
+
/** HTTP status code when the failure originates from an HTTP response. */
|
|
269
276
|
readonly status?: number;
|
|
270
277
|
constructor(message: string, options?: {
|
|
271
278
|
cause?: Error;
|
|
@@ -276,16 +283,16 @@ declare class NetworkError extends OpenTimestampsClientError {
|
|
|
276
283
|
declare class CircuitBreakerError extends NetworkError {
|
|
277
284
|
constructor(calendar: string);
|
|
278
285
|
}
|
|
279
|
-
/**
|
|
286
|
+
/** The calendar does not yet know the queried commitment (HTTP 404). */
|
|
280
287
|
declare class CommitmentNotFoundError extends NetworkError {
|
|
281
288
|
}
|
|
282
|
-
/**
|
|
289
|
+
/** The calendar response exceeds the allowed size limit (DoS defense). */
|
|
283
290
|
declare class CalendarResponseTooLargeError extends NetworkError {
|
|
284
291
|
}
|
|
285
|
-
/**
|
|
292
|
+
/** Invalid Esplora response: empty, non-JSON, malformed, or too large (DoS defense). */
|
|
286
293
|
declare class EsploraResponseError extends NetworkError {
|
|
287
294
|
}
|
|
288
|
-
/**
|
|
295
|
+
/** Response exceeds the allowed byte limit (DoS defense). */
|
|
289
296
|
declare class SizeLimitExceededError extends NetworkError {
|
|
290
297
|
readonly maxBytes: number;
|
|
291
298
|
readonly actualBytes?: number;
|
|
@@ -306,8 +313,7 @@ declare enum CircuitState {
|
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
/**
|
|
309
|
-
*
|
|
310
|
-
* Funciona en Node.js 18+, browsers y edge runtimes.
|
|
316
|
+
* Fetch adapter for Node.js 20+.
|
|
311
317
|
*/
|
|
312
318
|
interface FetchRequest {
|
|
313
319
|
url: string;
|
|
@@ -341,30 +347,33 @@ declare class ResilientNetworkLayer {
|
|
|
341
347
|
}
|
|
342
348
|
|
|
343
349
|
/**
|
|
344
|
-
*
|
|
350
|
+
* Remote OpenTimestamps calendar client (real OTS protocol).
|
|
345
351
|
*/
|
|
346
352
|
|
|
347
|
-
/**
|
|
353
|
+
/** Maximum response size from a calendar server (DoS defense). */
|
|
348
354
|
declare const MAX_CALENDAR_RESPONSE_SIZE = 10000;
|
|
349
|
-
/**
|
|
355
|
+
/** Interface to a remote calendar server. */
|
|
350
356
|
declare class CalendarClient {
|
|
351
357
|
#private;
|
|
352
358
|
private readonly url;
|
|
353
359
|
private readonly networkLayer;
|
|
354
360
|
private readonly logger?;
|
|
355
361
|
constructor(url: string, networkLayer: ResilientNetworkLayer, logger?: Logger | undefined);
|
|
356
|
-
/**
|
|
362
|
+
/** Submits a digest to the calendar and returns the Timestamp that commits to it. */
|
|
357
363
|
submit(digest: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
|
|
358
|
-
/**
|
|
364
|
+
/** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
|
|
359
365
|
getTimestamp(commitment: Uint8Array, signal?: AbortSignal): Promise<Timestamp>;
|
|
360
366
|
}
|
|
361
|
-
/**
|
|
367
|
+
/** Allowlist of trusted calendar URLs. */
|
|
362
368
|
declare class UrlWhitelist {
|
|
363
369
|
#private;
|
|
364
370
|
constructor(urls?: readonly string[]);
|
|
365
|
-
/**
|
|
371
|
+
/**
|
|
372
|
+
* Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
|
|
373
|
+
* Throws TypeError if the pattern is not a valid string or is structurally invalid.
|
|
374
|
+
*/
|
|
366
375
|
add(url: string): void;
|
|
367
|
-
/**
|
|
376
|
+
/** Returns true if `url` matches any pattern in the allowlist. */
|
|
368
377
|
contains(url: string): boolean;
|
|
369
378
|
toString(): string;
|
|
370
379
|
}
|
|
@@ -379,32 +388,38 @@ declare const DEFAULT_CALENDAR_WHITELIST: UrlWhitelist;
|
|
|
379
388
|
*/
|
|
380
389
|
declare const DEFAULT_AGGREGATORS: readonly string[];
|
|
381
390
|
|
|
382
|
-
/**
|
|
391
|
+
/** Default public Esplora explorer (Bitcoin mainnet). */
|
|
383
392
|
declare const PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
|
|
384
|
-
/**
|
|
393
|
+
/** Maximum size of an Esplora response (DoS defense). A JSON block header is a few hundred bytes. */
|
|
385
394
|
declare const MAX_ESPLORA_RESPONSE_SIZE = 100000;
|
|
386
395
|
interface EsploraClientOptions {
|
|
387
|
-
/** URL
|
|
396
|
+
/** Base URL of the explorer (defaults to Blockstream). Useful for pointing to a Litecoin Esplora. */
|
|
388
397
|
url?: string;
|
|
389
398
|
logger?: Logger;
|
|
390
399
|
}
|
|
391
|
-
/**
|
|
400
|
+
/** Client for a remote Esplora explorer. */
|
|
392
401
|
declare class EsploraClient {
|
|
393
402
|
#private;
|
|
394
403
|
constructor(networkLayer: ResilientNetworkLayer, options?: EsploraClientOptions);
|
|
395
|
-
/**
|
|
404
|
+
/** Returns the block hash (64-char hex, lowercase) at the given height. */
|
|
396
405
|
blockHash(height: number, signal?: AbortSignal): Promise<string>;
|
|
397
|
-
/**
|
|
406
|
+
/** Returns the block header (merkle root + timestamp) for the given hash. */
|
|
398
407
|
block(hash: string, signal?: AbortSignal): Promise<BlockHeader>;
|
|
408
|
+
/**
|
|
409
|
+
* Fetches the raw 80-byte block header for `hash` and self-authenticates it:
|
|
410
|
+
* sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
|
|
411
|
+
* JSON layer — the raw header is cryptographically bound to the block hash we requested.
|
|
412
|
+
*/
|
|
413
|
+
rawBlockHeader(hash: string, signal?: AbortSignal): Promise<Uint8Array>;
|
|
399
414
|
}
|
|
400
415
|
/**
|
|
401
|
-
*
|
|
416
|
+
* Verifies a Bitcoin/Litecoin attestation against the corresponding block header.
|
|
402
417
|
*
|
|
403
|
-
* `digest`
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
418
|
+
* `digest` is the final tree commitment at the attestation point (32 bytes, must equal the
|
|
419
|
+
* block's merkle root). `explorer` must point to the correct chain (Blockstream for Bitcoin;
|
|
420
|
+
* a Litecoin Esplora for Litecoin). Returns the block time (epoch seconds) on success;
|
|
421
|
+
* throws `VerificationError` if the digest does not match or the attestation is not
|
|
422
|
+
* on-chain verifiable (`pending`/`unknown`). Fail-closed.
|
|
408
423
|
*/
|
|
409
424
|
declare function verifyTimestampAttestation(digest: Uint8Array, attestation: Attestation, explorer: EsploraClient, signal?: AbortSignal): Promise<number>;
|
|
410
425
|
|
|
@@ -412,21 +427,21 @@ declare function hashBuffer(data: Buffer | Uint8Array): Buffer;
|
|
|
412
427
|
declare function hashFile(path: string): Promise<Buffer>;
|
|
413
428
|
|
|
414
429
|
/**
|
|
415
|
-
* SSRF protection
|
|
430
|
+
* SSRF protection for user-configurable calendar URLs.
|
|
416
431
|
*
|
|
417
|
-
*
|
|
418
|
-
* - TOCTOU/DNS rebinding:
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* - IPv4-mapped IPv6 (::ffff:x.x.x.x):
|
|
422
|
-
*
|
|
432
|
+
* KNOWN LIMITATIONS (documented intentionally):
|
|
433
|
+
* - TOCTOU / DNS rebinding: DNS validation happens BEFORE the connection.
|
|
434
|
+
* A server with TTL=0 can change its IP between validation and the actual fetch.
|
|
435
|
+
* Real mitigation requires network-level egress filtering.
|
|
436
|
+
* - IPv4-mapped IPv6 (::ffff:x.x.x.x): the ::ffff: prefix is blocked but the
|
|
437
|
+
* IPv4 component validation depends on the format Node.js returns.
|
|
423
438
|
*/
|
|
424
439
|
/**
|
|
425
|
-
*
|
|
426
|
-
*
|
|
440
|
+
* Validates that a calendar URL is safe for outbound HTTP requests.
|
|
441
|
+
* Blocks private/reserved IPs by default.
|
|
427
442
|
*
|
|
428
|
-
* @param allowPrivate
|
|
429
|
-
*
|
|
443
|
+
* @param allowPrivate When true, skips the IP range check.
|
|
444
|
+
* Useful for local testing or internal corporate networks.
|
|
430
445
|
*/
|
|
431
446
|
declare function assertSafeCalendarUrl(url: string, options: {
|
|
432
447
|
allowPrivate: boolean;
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ var OpenTimestampsClientError = class extends Error {
|
|
|
29
29
|
constructor(message, options) {
|
|
30
30
|
super(message);
|
|
31
31
|
this.name = this.constructor.name;
|
|
32
|
-
this.cause = options
|
|
32
|
+
if (options?.cause !== void 0) this.cause = options.cause;
|
|
33
33
|
Error.captureStackTrace?.(this, this.constructor);
|
|
34
34
|
}
|
|
35
35
|
};
|
|
@@ -47,11 +47,11 @@ var StampError = class extends OpenTimestampsClientError {
|
|
|
47
47
|
var UpgradeError = class extends OpenTimestampsClientError {
|
|
48
48
|
};
|
|
49
49
|
var NetworkError = class extends OpenTimestampsClientError {
|
|
50
|
-
/** HTTP status code
|
|
50
|
+
/** HTTP status code when the failure originates from an HTTP response. */
|
|
51
51
|
status;
|
|
52
52
|
constructor(message, options) {
|
|
53
53
|
super(message, options);
|
|
54
|
-
this.status = options
|
|
54
|
+
if (options?.status !== void 0) this.status = options.status;
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
var CircuitBreakerError = class extends NetworkError {
|
|
@@ -74,7 +74,7 @@ var SizeLimitExceededError = class extends NetworkError {
|
|
|
74
74
|
options
|
|
75
75
|
);
|
|
76
76
|
this.maxBytes = maxBytes;
|
|
77
|
-
this.actualBytes = actualBytes;
|
|
77
|
+
if (actualBytes !== void 0) this.actualBytes = actualBytes;
|
|
78
78
|
}
|
|
79
79
|
};
|
|
80
80
|
|
|
@@ -126,7 +126,10 @@ var CircuitBreaker = class {
|
|
|
126
126
|
this.onSuccess(key, circuit);
|
|
127
127
|
return result;
|
|
128
128
|
} catch (error) {
|
|
129
|
-
|
|
129
|
+
const is4xx = error instanceof Error && error.retryable === false;
|
|
130
|
+
if (!is4xx) {
|
|
131
|
+
this.onFailure(key, circuit);
|
|
132
|
+
}
|
|
130
133
|
throw error;
|
|
131
134
|
}
|
|
132
135
|
}
|
|
@@ -223,11 +226,15 @@ function sleep(ms, signal) {
|
|
|
223
226
|
reject(new Error("Aborted"));
|
|
224
227
|
return;
|
|
225
228
|
}
|
|
226
|
-
const
|
|
227
|
-
signal?.addEventListener("abort", () => {
|
|
229
|
+
const onAbort = () => {
|
|
228
230
|
clearTimeout(timeout);
|
|
229
231
|
reject(new Error("Aborted"));
|
|
230
|
-
}
|
|
232
|
+
};
|
|
233
|
+
const timeout = setTimeout(() => {
|
|
234
|
+
signal?.removeEventListener("abort", onAbort);
|
|
235
|
+
resolve();
|
|
236
|
+
}, ms);
|
|
237
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
231
238
|
});
|
|
232
239
|
}
|
|
233
240
|
async function withRetry(fn, options, logger, signal) {
|
|
@@ -310,8 +317,9 @@ async function executeRequest(request, maxBytes) {
|
|
|
310
317
|
const response = await globalThis.fetch(request.url, {
|
|
311
318
|
method: request.method,
|
|
312
319
|
headers: { "Content-Type": "application/octet-stream", ...request.headers },
|
|
313
|
-
body: request.body,
|
|
314
|
-
signal: request.signal
|
|
320
|
+
...request.body !== void 0 ? { body: request.body } : {},
|
|
321
|
+
...request.signal !== void 0 ? { signal: request.signal } : {},
|
|
322
|
+
redirect: "error"
|
|
315
323
|
});
|
|
316
324
|
const data = await readResponseBody(response, maxBytes);
|
|
317
325
|
return { ok: response.ok, status: response.status, statusText: response.statusText, data };
|
|
@@ -434,6 +442,8 @@ import {
|
|
|
434
442
|
DetachedTimestampFile,
|
|
435
443
|
OpSHA256,
|
|
436
444
|
OpAppend,
|
|
445
|
+
OpSHA1,
|
|
446
|
+
OpRIPEMD160,
|
|
437
447
|
makeMerkleTree
|
|
438
448
|
} from "@otskit/core";
|
|
439
449
|
|
|
@@ -470,7 +480,7 @@ var CalendarClient = class {
|
|
|
470
480
|
url;
|
|
471
481
|
networkLayer;
|
|
472
482
|
logger;
|
|
473
|
-
/**
|
|
483
|
+
/** Submits a digest to the calendar and returns the Timestamp that commits to it. */
|
|
474
484
|
async submit(digest, signal) {
|
|
475
485
|
assertCommitment(digest);
|
|
476
486
|
this.logger?.debug(`Submitting digest to ${this.url}/digest`);
|
|
@@ -481,7 +491,7 @@ var CalendarClient = class {
|
|
|
481
491
|
);
|
|
482
492
|
return this.#parseTimestamp(response.data, digest);
|
|
483
493
|
}
|
|
484
|
-
/**
|
|
494
|
+
/** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
|
|
485
495
|
async getTimestamp(commitment, signal) {
|
|
486
496
|
assertCommitment(commitment);
|
|
487
497
|
const path = `/timestamp/${bytesToHex(commitment)}`;
|
|
@@ -503,7 +513,7 @@ var CalendarClient = class {
|
|
|
503
513
|
}
|
|
504
514
|
return this.#parseTimestamp(response.data, commitment);
|
|
505
515
|
}
|
|
506
|
-
/**
|
|
516
|
+
/** Deserializes the calendar response as a Timestamp committed to `commitment`. */
|
|
507
517
|
#parseTimestamp(data, commitment) {
|
|
508
518
|
if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
|
|
509
519
|
throw new CalendarResponseTooLargeError(
|
|
@@ -533,7 +543,7 @@ function parseWhitelistPattern(raw) {
|
|
|
533
543
|
hostname,
|
|
534
544
|
port: parsed.port,
|
|
535
545
|
pathname: parsed.pathname,
|
|
536
|
-
wildcardSuffix
|
|
546
|
+
...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
|
|
537
547
|
};
|
|
538
548
|
}
|
|
539
549
|
function hostnameMatchesPattern(hostname, pattern) {
|
|
@@ -549,20 +559,26 @@ var UrlWhitelist = class {
|
|
|
549
559
|
for (const u of urls) this.add(u);
|
|
550
560
|
}
|
|
551
561
|
}
|
|
552
|
-
/**
|
|
562
|
+
/**
|
|
563
|
+
* Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
|
|
564
|
+
* Throws TypeError if the pattern is not a valid string or is structurally invalid.
|
|
565
|
+
*/
|
|
553
566
|
add(url) {
|
|
554
567
|
if (typeof url !== "string") {
|
|
555
568
|
throw new TypeError("UrlWhitelist: URL must be a string");
|
|
556
569
|
}
|
|
557
570
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
558
571
|
const pattern = parseWhitelistPattern(url);
|
|
559
|
-
if (pattern
|
|
572
|
+
if (pattern === void 0) {
|
|
573
|
+
throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
|
|
574
|
+
}
|
|
575
|
+
this.#patterns.set(url, pattern);
|
|
560
576
|
} else {
|
|
561
577
|
this.add("http://" + url);
|
|
562
578
|
this.add("https://" + url);
|
|
563
579
|
}
|
|
564
580
|
}
|
|
565
|
-
/**
|
|
581
|
+
/** Returns true if `url` matches any pattern in the allowlist. */
|
|
566
582
|
contains(url) {
|
|
567
583
|
let parsed;
|
|
568
584
|
try {
|
|
@@ -588,10 +604,17 @@ var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...TRUSTED_CALENDAR_WHITELIST
|
|
|
588
604
|
var DEFAULT_AGGREGATORS = [...DEFAULT_AGGREGATOR_URLS];
|
|
589
605
|
|
|
590
606
|
// src/network/esplora.ts
|
|
591
|
-
import {
|
|
607
|
+
import { createHash } from "crypto";
|
|
608
|
+
import { verifyAgainstRawHeader, VerificationError } from "@otskit/core";
|
|
592
609
|
var PUBLIC_ESPLORA_URL = "https://blockstream.info/api";
|
|
593
610
|
var MAX_ESPLORA_RESPONSE_SIZE = 1e5;
|
|
611
|
+
var RAW_HEADER_SIZE = 80;
|
|
594
612
|
var HEX64_RE = /^[0-9a-f]{64}$/i;
|
|
613
|
+
function sha256dDisplayHex(data) {
|
|
614
|
+
const first = createHash("sha256").update(data).digest();
|
|
615
|
+
const second = createHash("sha256").update(first).digest();
|
|
616
|
+
return Buffer.from(second).reverse().toString("hex");
|
|
617
|
+
}
|
|
595
618
|
var EsploraClient = class {
|
|
596
619
|
#url;
|
|
597
620
|
#networkLayer;
|
|
@@ -611,7 +634,7 @@ var EsploraClient = class {
|
|
|
611
634
|
this.#url = raw.replace(/\/+$/, "");
|
|
612
635
|
this.#logger = options.logger;
|
|
613
636
|
}
|
|
614
|
-
/**
|
|
637
|
+
/** Returns the block hash (64-char hex, lowercase) at the given height. */
|
|
615
638
|
async blockHash(height, signal) {
|
|
616
639
|
if (!Number.isSafeInteger(height) || height < 0) {
|
|
617
640
|
throw new ValidationError(`block height must be a non-negative safe integer; got ${height}`);
|
|
@@ -628,7 +651,7 @@ var EsploraClient = class {
|
|
|
628
651
|
}
|
|
629
652
|
return text.toLowerCase();
|
|
630
653
|
}
|
|
631
|
-
/**
|
|
654
|
+
/** Returns the block header (merkle root + timestamp) for the given hash. */
|
|
632
655
|
async block(hash, signal) {
|
|
633
656
|
if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
|
|
634
657
|
throw new ValidationError("block hash must be a 64-char hex string");
|
|
@@ -646,7 +669,7 @@ var EsploraClient = class {
|
|
|
646
669
|
} catch (err) {
|
|
647
670
|
throw new EsploraResponseError("esplora returned a non-JSON block response", {
|
|
648
671
|
/* v8 ignore next */
|
|
649
|
-
|
|
672
|
+
...err instanceof Error ? { cause: err } : {}
|
|
650
673
|
});
|
|
651
674
|
}
|
|
652
675
|
if (typeof body !== "object" || body === null) {
|
|
@@ -661,7 +684,36 @@ var EsploraClient = class {
|
|
|
661
684
|
}
|
|
662
685
|
return { merkleroot, time };
|
|
663
686
|
}
|
|
664
|
-
/**
|
|
687
|
+
/**
|
|
688
|
+
* Fetches the raw 80-byte block header for `hash` and self-authenticates it:
|
|
689
|
+
* sha256d(rawHeader) reversed must equal `hash`. This removes trust in the explorer's
|
|
690
|
+
* JSON layer — the raw header is cryptographically bound to the block hash we requested.
|
|
691
|
+
*/
|
|
692
|
+
async rawBlockHeader(hash, signal) {
|
|
693
|
+
if (typeof hash !== "string" || !HEX64_RE.test(hash)) {
|
|
694
|
+
throw new ValidationError("block hash must be a 64-char hex string");
|
|
695
|
+
}
|
|
696
|
+
this.#logger?.debug(`Esplora raw header ${hash}`);
|
|
697
|
+
const response = await this.#networkLayer.request(
|
|
698
|
+
this.#url,
|
|
699
|
+
{ url: `${this.#url}/block/${hash}/header`, method: "GET", headers: { Accept: "application/octet-stream" } },
|
|
700
|
+
signal
|
|
701
|
+
);
|
|
702
|
+
const data = response.data;
|
|
703
|
+
if (data.length !== RAW_HEADER_SIZE) {
|
|
704
|
+
throw new EsploraResponseError(
|
|
705
|
+
`raw block header must be ${RAW_HEADER_SIZE} bytes; got ${data.length}`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
const actualHash = sha256dDisplayHex(data);
|
|
709
|
+
if (actualHash !== hash.toLowerCase()) {
|
|
710
|
+
throw new EsploraResponseError(
|
|
711
|
+
`raw block header hash mismatch: expected ${hash.toLowerCase()}, got ${actualHash}`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
return data;
|
|
715
|
+
}
|
|
716
|
+
/** Decodes the response body as text, enforcing the size limit (fail-closed). */
|
|
665
717
|
#decode(data) {
|
|
666
718
|
if (data.length > MAX_ESPLORA_RESPONSE_SIZE) {
|
|
667
719
|
throw new EsploraResponseError(
|
|
@@ -672,7 +724,7 @@ var EsploraClient = class {
|
|
|
672
724
|
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
673
725
|
} catch (cause) {
|
|
674
726
|
throw new EsploraResponseError("esplora response contains invalid UTF-8 bytes", {
|
|
675
|
-
cause
|
|
727
|
+
...cause instanceof Error ? { cause } : {}
|
|
676
728
|
});
|
|
677
729
|
}
|
|
678
730
|
}
|
|
@@ -682,8 +734,8 @@ async function verifyTimestampAttestation(digest, attestation, explorer, signal)
|
|
|
682
734
|
throw new VerificationError(`cannot verify a '${attestation.kind}' attestation against the chain`);
|
|
683
735
|
}
|
|
684
736
|
const hash = await explorer.blockHash(attestation.height, signal);
|
|
685
|
-
const
|
|
686
|
-
return
|
|
737
|
+
const rawHeader = await explorer.rawBlockHeader(hash, signal);
|
|
738
|
+
return verifyAgainstRawHeader(digest, rawHeader);
|
|
687
739
|
}
|
|
688
740
|
|
|
689
741
|
// src/core/orchestration.ts
|
|
@@ -867,7 +919,7 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
867
919
|
} catch (error) {
|
|
868
920
|
throw new ValidationError("Invalid .ots proof format", {
|
|
869
921
|
/* v8 ignore next */
|
|
870
|
-
|
|
922
|
+
...error instanceof Error ? { cause: error } : {}
|
|
871
923
|
});
|
|
872
924
|
}
|
|
873
925
|
if (detached.timestamp.isTimestampComplete()) {
|
|
@@ -904,15 +956,21 @@ async function orchestrateUpgrade(incompleteProof, _calendars, networkLayer, log
|
|
|
904
956
|
}
|
|
905
957
|
return Buffer.from(after);
|
|
906
958
|
}
|
|
907
|
-
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal) {
|
|
959
|
+
async function orchestrateVerify(proof, networkLayer, originalDataHash, logger, signal, esploraUrl) {
|
|
908
960
|
let detached;
|
|
909
961
|
try {
|
|
910
962
|
detached = DetachedTimestampFile.deserialize(new Uint8Array(proof));
|
|
911
963
|
} catch (cause) {
|
|
912
964
|
throw new ValidationError("Invalid .ots proof format", {
|
|
913
|
-
cause
|
|
965
|
+
...cause instanceof Error ? { cause } : {}
|
|
914
966
|
});
|
|
915
967
|
}
|
|
968
|
+
if (detached.fileHashOp instanceof OpSHA1 || detached.fileHashOp instanceof OpRIPEMD160) {
|
|
969
|
+
return {
|
|
970
|
+
status: "invalid",
|
|
971
|
+
reason: `This proof uses ${detached.fileHashOp.tagName} (a weak hash algorithm). Re-stamp the original file with SHA-256 to get a verifiable proof.`
|
|
972
|
+
};
|
|
973
|
+
}
|
|
916
974
|
if (originalDataHash !== void 0) {
|
|
917
975
|
let expected;
|
|
918
976
|
try {
|
|
@@ -920,7 +978,7 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
920
978
|
} catch (err) {
|
|
921
979
|
throw new ValidationError(
|
|
922
980
|
err instanceof Error ? err.message : "Invalid hash format",
|
|
923
|
-
{
|
|
981
|
+
{ ...err instanceof Error ? { cause: err } : {} }
|
|
924
982
|
);
|
|
925
983
|
}
|
|
926
984
|
if (!timingSafeEq(expected, detached.fileDigest())) {
|
|
@@ -951,7 +1009,10 @@ async function orchestrateVerify(proof, networkLayer, originalDataHash, logger,
|
|
|
951
1009
|
reason: hasLitecoin ? "Litecoin-only attestation is not supported by this client" : "No Bitcoin attestation found \u2014 timestamp not yet confirmed"
|
|
952
1010
|
};
|
|
953
1011
|
}
|
|
954
|
-
const explorer = new EsploraClient(networkLayer
|
|
1012
|
+
const explorer = new EsploraClient(networkLayer, {
|
|
1013
|
+
...esploraUrl !== void 0 ? { url: esploraUrl } : {},
|
|
1014
|
+
...logger !== void 0 ? { logger } : {}
|
|
1015
|
+
});
|
|
955
1016
|
let lastNetworkError;
|
|
956
1017
|
let lastCryptoError;
|
|
957
1018
|
for (const { msg, attestation } of bitcoinAtts) {
|
|
@@ -993,20 +1054,32 @@ var OpenTimestampsClient = class {
|
|
|
993
1054
|
globalSignal;
|
|
994
1055
|
minimumSuccessfulSubmissions;
|
|
995
1056
|
allowPrivateCalendars;
|
|
1057
|
+
esploraUrl;
|
|
996
1058
|
/**
|
|
997
1059
|
* Create a new OpenTimestamps client
|
|
998
1060
|
*
|
|
999
1061
|
* @param options Client configuration options
|
|
1000
1062
|
*/
|
|
1001
1063
|
constructor(options = {}) {
|
|
1064
|
+
this.logger = options.logger;
|
|
1002
1065
|
if (!options.calendars || options.calendars.length === 0) {
|
|
1003
1066
|
this.calendars = DEFAULT_CALENDARS;
|
|
1004
1067
|
this.logger?.info("No calendars provided, using defaults");
|
|
1005
1068
|
} else {
|
|
1006
1069
|
this.calendars = options.calendars;
|
|
1007
1070
|
}
|
|
1008
|
-
|
|
1071
|
+
const minSubs = options.minimumSuccessfulSubmissions ?? 2;
|
|
1072
|
+
if (!Number.isInteger(minSubs) || minSubs < 1) {
|
|
1073
|
+
throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
|
|
1074
|
+
}
|
|
1075
|
+
if (minSubs > this.calendars.length) {
|
|
1076
|
+
throw new ValidationError(
|
|
1077
|
+
`minimumSuccessfulSubmissions (${minSubs}) cannot exceed the number of calendars (${this.calendars.length})`
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
this.minimumSuccessfulSubmissions = minSubs;
|
|
1009
1081
|
this.allowPrivateCalendars = options.allowPrivateCalendars ?? false;
|
|
1082
|
+
this.esploraUrl = options.esploraUrl;
|
|
1010
1083
|
const resilienceConfig = {
|
|
1011
1084
|
...DEFAULT_RESILIENCE,
|
|
1012
1085
|
...options.resilience,
|
|
@@ -1023,8 +1096,7 @@ var OpenTimestampsClient = class {
|
|
|
1023
1096
|
...options.resilience?.circuitBreaker
|
|
1024
1097
|
}
|
|
1025
1098
|
};
|
|
1026
|
-
this.
|
|
1027
|
-
this.globalSignal = options.signal;
|
|
1099
|
+
if (options.signal !== void 0) this.globalSignal = options.signal;
|
|
1028
1100
|
const internalOptions = options;
|
|
1029
1101
|
this.networkLayer = internalOptions._networkLayer ?? new ResilientNetworkLayer(resilienceConfig, this.logger);
|
|
1030
1102
|
this.logger?.info(`OpenTimestamps client initialized with ${this.calendars.length} calendars`);
|
|
@@ -1109,7 +1181,7 @@ var OpenTimestampsClient = class {
|
|
|
1109
1181
|
* ```
|
|
1110
1182
|
*/
|
|
1111
1183
|
async verify(proof, originalDataHash) {
|
|
1112
|
-
return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal);
|
|
1184
|
+
return orchestrateVerify(proof, this.networkLayer, originalDataHash, this.logger, this.globalSignal, this.esploraUrl);
|
|
1113
1185
|
}
|
|
1114
1186
|
/**
|
|
1115
1187
|
* Get the current state of the circuit breaker for a calendar
|
|
@@ -1143,17 +1215,17 @@ var OpenTimestampsClient = class {
|
|
|
1143
1215
|
|
|
1144
1216
|
// src/index.ts
|
|
1145
1217
|
import { DetachedTimestampFile as DetachedTimestampFile2, Timestamp as Timestamp2 } from "@otskit/core";
|
|
1146
|
-
import { verifyAgainstBlockheader
|
|
1218
|
+
import { verifyAgainstBlockheader } from "@otskit/core";
|
|
1147
1219
|
|
|
1148
1220
|
// src/utils/hash.ts
|
|
1149
|
-
import { createHash } from "crypto";
|
|
1221
|
+
import { createHash as createHash2 } from "crypto";
|
|
1150
1222
|
import { createReadStream } from "fs";
|
|
1151
1223
|
function hashBuffer(data) {
|
|
1152
|
-
return
|
|
1224
|
+
return createHash2("sha256").update(data).digest();
|
|
1153
1225
|
}
|
|
1154
1226
|
function hashFile(path) {
|
|
1155
1227
|
return new Promise((resolve, reject) => {
|
|
1156
|
-
const hash =
|
|
1228
|
+
const hash = createHash2("sha256");
|
|
1157
1229
|
createReadStream(path).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest())).on("error", reject);
|
|
1158
1230
|
});
|
|
1159
1231
|
}
|
|
@@ -1187,6 +1259,6 @@ export {
|
|
|
1187
1259
|
hashBuffer,
|
|
1188
1260
|
hashFile,
|
|
1189
1261
|
isVerified,
|
|
1190
|
-
|
|
1262
|
+
verifyAgainstBlockheader,
|
|
1191
1263
|
verifyTimestampAttestation
|
|
1192
1264
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@otskit/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Official client SDK for OpenTimestamps calendars with resilience patterns",
|
|
5
5
|
"author": "alexalves87",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"dist"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
27
|
+
"node": ">=20.0.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
31
31
|
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
32
32
|
"test": "vitest run",
|
|
33
|
+
"coverage": "vitest run --coverage",
|
|
33
34
|
"test:watch": "vitest",
|
|
34
35
|
"lint": "eslint .",
|
|
35
36
|
"format": "prettier --write \"src/**/*.ts\"",
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
"prettier": "^3.3.3",
|
|
72
73
|
"semantic-release": "^25.0.3",
|
|
73
74
|
"tsup": "^8.3.5",
|
|
74
|
-
"typescript": "^
|
|
75
|
+
"typescript": "^6.0.3",
|
|
75
76
|
"vitest": "^4.1.8"
|
|
76
77
|
}
|
|
77
78
|
}
|