@otskit/client 0.5.0 → 0.6.1

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.
@@ -0,0 +1,742 @@
1
+ // src/errors.ts
2
+ var OpenTimestampsClientError = class extends Error {
3
+ cause;
4
+ constructor(message, options) {
5
+ super(message);
6
+ this.name = this.constructor.name;
7
+ if (options?.cause !== void 0)
8
+ this.cause = options.cause;
9
+ Error.captureStackTrace?.(this, this.constructor);
10
+ }
11
+ };
12
+ var ValidationError = class extends OpenTimestampsClientError {
13
+ };
14
+ var StampError = class extends OpenTimestampsClientError {
15
+ successfulSubmissions;
16
+ failedSubmissions;
17
+ constructor(message, successful, failed, options) {
18
+ super(message, options);
19
+ this.successfulSubmissions = successful;
20
+ this.failedSubmissions = failed;
21
+ }
22
+ };
23
+ var NetworkError = class extends OpenTimestampsClientError {
24
+ /** HTTP status code when the failure originates from an HTTP response. */
25
+ status;
26
+ constructor(message, options) {
27
+ super(message, options);
28
+ if (options?.status !== void 0) this.status = options.status;
29
+ }
30
+ };
31
+ var CircuitBreakerError = class extends NetworkError {
32
+ constructor(calendar) {
33
+ super(`Circuit breaker open for calendar: ${calendar}`);
34
+ }
35
+ };
36
+ var CommitmentNotFoundError = class extends NetworkError {
37
+ };
38
+ var CalendarResponseTooLargeError = class extends NetworkError {
39
+ };
40
+ var SizeLimitExceededError = class extends NetworkError {
41
+ maxBytes;
42
+ actualBytes;
43
+ constructor(maxBytes, actualBytes, options) {
44
+ super(
45
+ actualBytes === void 0 ? `Response size exceeds limit of ${maxBytes} bytes` : `Response size ${actualBytes} bytes exceeds limit of ${maxBytes} bytes`,
46
+ options
47
+ );
48
+ this.maxBytes = maxBytes;
49
+ if (actualBytes !== void 0) this.actualBytes = actualBytes;
50
+ }
51
+ };
52
+
53
+ // src/network/circuit-breaker.ts
54
+ var CircuitBreaker = class {
55
+ constructor(options, logger) {
56
+ this.options = options;
57
+ this.logger = logger;
58
+ }
59
+ options;
60
+ logger;
61
+ circuits = /* @__PURE__ */ new Map();
62
+ /**
63
+ * Execute a request through the circuit breaker
64
+ */
65
+ async execute(key, fn) {
66
+ if (!this.options.enabled) {
67
+ return fn();
68
+ }
69
+ const circuit = this.getOrCreateCircuit(key);
70
+ if (circuit.state === "OPEN" /* OPEN */) {
71
+ const shouldAttemptRecovery = this.shouldAttemptRecovery(circuit);
72
+ if (shouldAttemptRecovery) {
73
+ this.logger?.info(`Circuit breaker for ${key} entering HALF_OPEN state`);
74
+ circuit.state = "HALF_OPEN" /* HALF_OPEN */;
75
+ circuit.stats.halfOpenAttempts = 0;
76
+ } else {
77
+ throw new CircuitBreakerError(key);
78
+ }
79
+ }
80
+ if (circuit.state === "HALF_OPEN" /* HALF_OPEN */) {
81
+ const maxAttempts = this.options.halfOpenMaxAttempts || 1;
82
+ if (circuit.stats.halfOpenAttempts >= maxAttempts) {
83
+ this.logger?.warn(`Circuit breaker for ${key} reopening after failed HALF_OPEN attempts`);
84
+ circuit.state = "OPEN" /* OPEN */;
85
+ circuit.stats.lastFailureTime = Date.now();
86
+ throw new CircuitBreakerError(key);
87
+ }
88
+ circuit.stats.halfOpenAttempts++;
89
+ }
90
+ try {
91
+ const result = await fn();
92
+ this.onSuccess(key, circuit);
93
+ return result;
94
+ } catch (error) {
95
+ const is4xx = error instanceof Error && error.retryable === false;
96
+ if (!is4xx) {
97
+ this.onFailure(key, circuit);
98
+ }
99
+ throw error;
100
+ }
101
+ }
102
+ getOrCreateCircuit(key) {
103
+ let circuit = this.circuits.get(key);
104
+ if (!circuit) {
105
+ circuit = {
106
+ state: "CLOSED" /* CLOSED */,
107
+ stats: {
108
+ consecutiveFailures: 0,
109
+ halfOpenAttempts: 0
110
+ }
111
+ };
112
+ this.circuits.set(key, circuit);
113
+ }
114
+ return circuit;
115
+ }
116
+ shouldAttemptRecovery(circuit) {
117
+ if (!circuit.stats.lastFailureTime) return false;
118
+ const elapsed = Date.now() - circuit.stats.lastFailureTime;
119
+ return elapsed >= this.options.recoveryTimeoutMs;
120
+ }
121
+ onSuccess(key, circuit) {
122
+ if (circuit.state === "HALF_OPEN" /* HALF_OPEN */) {
123
+ this.logger?.info(`Circuit breaker for ${key} closing after successful HALF_OPEN attempt`);
124
+ circuit.state = "CLOSED" /* CLOSED */;
125
+ }
126
+ circuit.stats.consecutiveFailures = 0;
127
+ circuit.stats.halfOpenAttempts = 0;
128
+ }
129
+ onFailure(key, circuit) {
130
+ circuit.stats.consecutiveFailures++;
131
+ circuit.stats.lastFailureTime = Date.now();
132
+ if (circuit.state === "HALF_OPEN" /* HALF_OPEN */) {
133
+ this.logger?.warn(`Circuit breaker for ${key} reopening after failed HALF_OPEN attempt`);
134
+ circuit.state = "OPEN" /* OPEN */;
135
+ return;
136
+ }
137
+ if (circuit.stats.consecutiveFailures >= this.options.failureThreshold) {
138
+ this.logger?.warn(
139
+ `Circuit breaker for ${key} opening after ${circuit.stats.consecutiveFailures} consecutive failures`
140
+ );
141
+ circuit.state = "OPEN" /* OPEN */;
142
+ }
143
+ }
144
+ /** Get current state for debugging/monitoring */
145
+ getState(key) {
146
+ return this.circuits.get(key)?.state;
147
+ }
148
+ /** Reset a specific circuit */
149
+ reset(key) {
150
+ this.circuits.delete(key);
151
+ }
152
+ /** Reset all circuits */
153
+ resetAll() {
154
+ this.circuits.clear();
155
+ }
156
+ };
157
+
158
+ // src/network/retry.ts
159
+ function calculateDelay(attempt, options) {
160
+ const { strategy, initialDelayMs, maxDelayMs, jitter } = options.backoff;
161
+ let delay;
162
+ switch (strategy) {
163
+ case "exponential":
164
+ delay = initialDelayMs * Math.pow(2, attempt - 1);
165
+ break;
166
+ case "linear":
167
+ delay = initialDelayMs * attempt;
168
+ break;
169
+ case "constant":
170
+ delay = initialDelayMs;
171
+ break;
172
+ }
173
+ if (maxDelayMs && delay > maxDelayMs) {
174
+ delay = maxDelayMs;
175
+ }
176
+ switch (jitter) {
177
+ case "full":
178
+ delay = Math.random() * delay;
179
+ break;
180
+ case "equal":
181
+ delay = delay / 2 + Math.random() * (delay / 2);
182
+ break;
183
+ case "none":
184
+ default:
185
+ break;
186
+ }
187
+ return Math.floor(delay);
188
+ }
189
+ function sleep(ms, signal) {
190
+ return new Promise((resolve, reject) => {
191
+ if (signal?.aborted) {
192
+ reject(new Error("Aborted"));
193
+ return;
194
+ }
195
+ const onAbort = () => {
196
+ clearTimeout(timeout);
197
+ reject(new Error("Aborted"));
198
+ };
199
+ const timeout = setTimeout(() => {
200
+ signal?.removeEventListener("abort", onAbort);
201
+ resolve();
202
+ }, ms);
203
+ signal?.addEventListener("abort", onAbort, { once: true });
204
+ });
205
+ }
206
+ async function withRetry(fn, options, logger, signal) {
207
+ if (!options.enabled) {
208
+ return fn();
209
+ }
210
+ let lastError;
211
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt++) {
212
+ try {
213
+ logger?.debug(`Attempt ${attempt}/${options.maxAttempts}`);
214
+ return await fn();
215
+ } catch (error) {
216
+ lastError = error instanceof Error ? error : new Error(String(error));
217
+ if (signal?.aborted) {
218
+ throw lastError;
219
+ }
220
+ if (error.retryable === false) {
221
+ logger?.debug("Error is not retryable (4xx client error), failing immediately");
222
+ throw lastError;
223
+ }
224
+ if (attempt === options.maxAttempts) {
225
+ logger?.warn(`All ${options.maxAttempts} attempts failed`);
226
+ throw lastError;
227
+ }
228
+ const delay = calculateDelay(attempt, options);
229
+ logger?.debug(`Retry attempt ${attempt} failed, waiting ${delay}ms before next attempt`);
230
+ try {
231
+ await sleep(delay, signal);
232
+ } catch {
233
+ throw lastError;
234
+ }
235
+ }
236
+ }
237
+ throw lastError || new Error("Retry failed");
238
+ }
239
+
240
+ // src/adapters/fetch-adapter.ts
241
+ function getDeclaredContentLength(response) {
242
+ const value = response.headers.get("content-length");
243
+ if (value === null || !/^\d+$/.test(value)) return void 0;
244
+ const n = Number(value);
245
+ return Number.isSafeInteger(n) ? n : void 0;
246
+ }
247
+ async function readStreamLimited(body, maxBytes, status) {
248
+ const reader = body.getReader();
249
+ const buffer = new Uint8Array(maxBytes);
250
+ let received = 0;
251
+ try {
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done) return buffer.subarray(0, received);
255
+ const next = received + value.byteLength;
256
+ if (next > maxBytes) {
257
+ await reader.cancel();
258
+ throw new SizeLimitExceededError(maxBytes, next, { status });
259
+ }
260
+ buffer.set(value, received);
261
+ received = next;
262
+ }
263
+ } finally {
264
+ reader.releaseLock();
265
+ }
266
+ }
267
+ async function readResponseBody(response, maxBytes) {
268
+ const contentLength = getDeclaredContentLength(response);
269
+ if (contentLength !== void 0 && contentLength > maxBytes) {
270
+ throw new SizeLimitExceededError(maxBytes, contentLength, { status: response.status });
271
+ }
272
+ if (response.body === null) {
273
+ const ab = await response.arrayBuffer();
274
+ if (ab.byteLength > maxBytes) {
275
+ throw new SizeLimitExceededError(maxBytes, ab.byteLength, { status: response.status });
276
+ }
277
+ return new Uint8Array(ab);
278
+ }
279
+ return readStreamLimited(response.body, maxBytes, response.status);
280
+ }
281
+ async function executeRequest(request, maxBytes) {
282
+ try {
283
+ const response = await globalThis.fetch(request.url, {
284
+ method: request.method,
285
+ headers: { "Content-Type": "application/octet-stream", ...request.headers },
286
+ ...request.body !== void 0 ? { body: request.body } : {},
287
+ ...request.signal !== void 0 ? { signal: request.signal } : {},
288
+ redirect: "error"
289
+ });
290
+ const data = await readResponseBody(response, maxBytes);
291
+ return { ok: response.ok, status: response.status, statusText: response.statusText, data };
292
+ } catch (error) {
293
+ if (error instanceof SizeLimitExceededError) throw error;
294
+ if (error instanceof NetworkError) throw error;
295
+ if (error instanceof Error) {
296
+ if (error.name === "AbortError") throw new NetworkError("Request aborted", { cause: error });
297
+ if (error.message.includes("timeout"))
298
+ throw new NetworkError("Request timeout", { cause: error });
299
+ throw new NetworkError(`Network request failed: ${error.message}`, { cause: error });
300
+ }
301
+ throw new NetworkError("Unknown network error");
302
+ }
303
+ }
304
+ function createTimeoutController(timeoutMs, parentSignal) {
305
+ const controller = new AbortController();
306
+ const timeout = setTimeout(() => controller.abort(new Error("Timeout")), timeoutMs);
307
+ const onParentAbort = () => {
308
+ clearTimeout(timeout);
309
+ parentSignal?.removeEventListener("abort", onParentAbort);
310
+ controller.abort(parentSignal?.reason);
311
+ };
312
+ controller.signal.addEventListener(
313
+ "abort",
314
+ () => {
315
+ clearTimeout(timeout);
316
+ parentSignal?.removeEventListener("abort", onParentAbort);
317
+ },
318
+ { once: true }
319
+ );
320
+ if (parentSignal) {
321
+ if (parentSignal.aborted) {
322
+ clearTimeout(timeout);
323
+ controller.abort(parentSignal.reason);
324
+ } else {
325
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
326
+ }
327
+ }
328
+ return controller;
329
+ }
330
+
331
+ // src/network/resilience.ts
332
+ var ResilientNetworkLayer = class {
333
+ constructor(options, logger) {
334
+ this.options = options;
335
+ this.logger = logger;
336
+ this.circuitBreaker = new CircuitBreaker(options.circuitBreaker, logger);
337
+ }
338
+ options;
339
+ logger;
340
+ circuitBreaker;
341
+ /**
342
+ * Execute a request with full resilience pipeline
343
+ */
344
+ async request(calendarUrl, request, parentSignal) {
345
+ const startTime = Date.now();
346
+ const totalController = createTimeoutController(
347
+ this.options.totalTimeoutMs,
348
+ parentSignal
349
+ );
350
+ try {
351
+ return await this.circuitBreaker.execute(calendarUrl, async () => {
352
+ return await withRetry(
353
+ async () => {
354
+ const attemptController = createTimeoutController(
355
+ this.options.connectTimeoutMs,
356
+ totalController.signal
357
+ );
358
+ try {
359
+ const response = await executeRequest(
360
+ { ...request, signal: attemptController.signal },
361
+ this.options.maxResponseBytes ?? 1e5
362
+ );
363
+ const elapsed = Date.now() - startTime;
364
+ this.logger?.debug(`Request to ${calendarUrl} succeeded in ${elapsed}ms`);
365
+ if (!response.ok) {
366
+ if (response.status >= 400 && response.status < 500) {
367
+ const error = new NetworkError(
368
+ `HTTP ${response.status}: ${response.statusText}`,
369
+ { status: response.status }
370
+ );
371
+ error.retryable = false;
372
+ throw error;
373
+ }
374
+ throw new NetworkError(
375
+ `HTTP ${response.status}: ${response.statusText}`,
376
+ { status: response.status }
377
+ );
378
+ }
379
+ return response;
380
+ } finally {
381
+ attemptController.abort(new Error("Attempt complete"));
382
+ }
383
+ },
384
+ this.options.retries,
385
+ this.logger,
386
+ totalController.signal
387
+ );
388
+ });
389
+ } catch (error) {
390
+ const elapsed = Date.now() - startTime;
391
+ this.logger?.error(`Request to ${calendarUrl} failed after ${elapsed}ms`, error);
392
+ throw error;
393
+ } finally {
394
+ totalController.abort(new Error("Request complete"));
395
+ }
396
+ }
397
+ /** Get circuit breaker state for a calendar */
398
+ getCircuitState(calendarUrl) {
399
+ return this.circuitBreaker.getState(calendarUrl);
400
+ }
401
+ /** Reset circuit breaker for a calendar */
402
+ resetCircuit(calendarUrl) {
403
+ this.circuitBreaker.reset(calendarUrl);
404
+ }
405
+ /** Reset all circuit breakers */
406
+ resetAllCircuits() {
407
+ this.circuitBreaker.resetAll();
408
+ }
409
+ };
410
+
411
+ // src/core/stamp.ts
412
+ import { DetachedTimestampFile, OpSHA256, OpAppend, makeMerkleTree } from "@otskit/core";
413
+
414
+ // src/network/calendar.ts
415
+ import {
416
+ Timestamp,
417
+ StreamDeserializationContext,
418
+ bytesToHex,
419
+ TRUSTED_CALENDAR_WHITELIST_PATTERNS,
420
+ DEFAULT_AGGREGATOR_URLS
421
+ } from "@otskit/core";
422
+ var MAX_CALENDAR_RESPONSE_SIZE = 1e4;
423
+ function assertCommitment(bytes) {
424
+ if (!(bytes instanceof Uint8Array)) {
425
+ throw new TypeError("commitment must be a Uint8Array");
426
+ }
427
+ if (bytes.length === 0 || bytes.length > 64) {
428
+ throw new RangeError(`commitment length ${bytes.length} is out of range (1..64)`);
429
+ }
430
+ }
431
+ var OTS_HEADERS = {
432
+ Accept: "application/vnd.opentimestamps.v1",
433
+ "Content-Type": "application/x-www-form-urlencoded"
434
+ };
435
+ function joinUrl(base, path) {
436
+ return base.replace(/\/+$/, "") + path;
437
+ }
438
+ var CalendarClient = class {
439
+ constructor(url, networkLayer, logger) {
440
+ this.url = url;
441
+ this.networkLayer = networkLayer;
442
+ this.logger = logger;
443
+ }
444
+ url;
445
+ networkLayer;
446
+ logger;
447
+ /** Submits a digest to the calendar and returns the Timestamp that commits to it. */
448
+ async submit(digest, signal) {
449
+ assertCommitment(digest);
450
+ this.logger?.debug(`Submitting digest to ${this.url}/digest`);
451
+ const response = await this.networkLayer.request(
452
+ this.url,
453
+ { url: joinUrl(this.url, "/digest"), method: "POST", headers: OTS_HEADERS, body: digest },
454
+ signal
455
+ );
456
+ return this.#parseTimestamp(response.data, digest);
457
+ }
458
+ /** Asks the calendar for a more complete Timestamp for `commitment` (upgrade). */
459
+ async getTimestamp(commitment, signal) {
460
+ assertCommitment(commitment);
461
+ const path = `/timestamp/${bytesToHex(commitment)}`;
462
+ this.logger?.debug(`Querying ${this.url}${path}`);
463
+ let response;
464
+ try {
465
+ response = await this.networkLayer.request(
466
+ this.url,
467
+ { url: joinUrl(this.url, path), method: "GET", headers: OTS_HEADERS },
468
+ signal
469
+ );
470
+ } catch (err) {
471
+ if (err instanceof NetworkError && err.status === 404) {
472
+ throw new CommitmentNotFoundError(`calendar ${this.url} has no timestamp for the commitment yet`, {
473
+ cause: err
474
+ });
475
+ }
476
+ throw err;
477
+ }
478
+ return this.#parseTimestamp(response.data, commitment);
479
+ }
480
+ /** Deserializes the calendar response as a Timestamp committed to `commitment`. */
481
+ #parseTimestamp(data, commitment) {
482
+ if (data.length > MAX_CALENDAR_RESPONSE_SIZE) {
483
+ throw new CalendarResponseTooLargeError(
484
+ `calendar response of ${data.length} bytes exceeds limit ${MAX_CALENDAR_RESPONSE_SIZE}`
485
+ );
486
+ }
487
+ const ctx = new StreamDeserializationContext(data);
488
+ const timestamp = Timestamp.deserialize(ctx, commitment);
489
+ ctx.assertEof();
490
+ return timestamp;
491
+ }
492
+ };
493
+ function parseWhitelistPattern(raw) {
494
+ let parsed;
495
+ try {
496
+ parsed = new URL(raw);
497
+ } catch {
498
+ return void 0;
499
+ }
500
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return void 0;
501
+ const hostname = parsed.hostname.toLowerCase();
502
+ const wildcardSuffix = hostname.startsWith("*.") ? hostname.slice(2) : void 0;
503
+ if (hostname.includes("*") && wildcardSuffix === void 0) return void 0;
504
+ if (wildcardSuffix !== void 0 && (wildcardSuffix.length === 0 || wildcardSuffix.includes("*"))) return void 0;
505
+ return {
506
+ protocol: parsed.protocol,
507
+ hostname,
508
+ port: parsed.port,
509
+ pathname: parsed.pathname,
510
+ ...wildcardSuffix !== void 0 ? { wildcardSuffix } : {}
511
+ };
512
+ }
513
+ function hostnameMatchesPattern(hostname, pattern) {
514
+ if (pattern.wildcardSuffix === void 0) return hostname === pattern.hostname;
515
+ if (!hostname.endsWith("." + pattern.wildcardSuffix)) return false;
516
+ const label = hostname.slice(0, -pattern.wildcardSuffix.length - 1);
517
+ return label.length > 0 && !label.includes(".");
518
+ }
519
+ var UrlWhitelist = class {
520
+ #patterns = /* @__PURE__ */ new Map();
521
+ constructor(urls) {
522
+ if (urls) {
523
+ for (const u of urls) this.add(u);
524
+ }
525
+ }
526
+ /**
527
+ * Adds a pattern. If the URL has no scheme, both http:// and https:// variants are added.
528
+ * Throws TypeError if the pattern is not a valid string or is structurally invalid.
529
+ */
530
+ add(url) {
531
+ if (typeof url !== "string") {
532
+ throw new TypeError("UrlWhitelist: URL must be a string");
533
+ }
534
+ if (url.startsWith("http://") || url.startsWith("https://")) {
535
+ const pattern = parseWhitelistPattern(url);
536
+ if (pattern === void 0) {
537
+ throw new TypeError(`UrlWhitelist: invalid or unsupported pattern: "${url}"`);
538
+ }
539
+ this.#patterns.set(url, pattern);
540
+ } else {
541
+ this.add("http://" + url);
542
+ this.add("https://" + url);
543
+ }
544
+ }
545
+ /** Returns true if `url` matches any pattern in the allowlist. */
546
+ contains(url) {
547
+ let parsed;
548
+ try {
549
+ parsed = new URL(url);
550
+ } catch {
551
+ return false;
552
+ }
553
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
554
+ if (parsed.search !== "" || parsed.hash !== "") return false;
555
+ const hostname = parsed.hostname.toLowerCase();
556
+ for (const pattern of this.#patterns.values()) {
557
+ if (parsed.protocol !== pattern.protocol || parsed.port !== pattern.port) continue;
558
+ if (parsed.pathname !== pattern.pathname) continue;
559
+ if (hostnameMatchesPattern(hostname, pattern)) return true;
560
+ }
561
+ return false;
562
+ }
563
+ toString() {
564
+ return `UrlWhitelist([${[...this.#patterns.keys()].join(", ")}])`;
565
+ }
566
+ };
567
+ var DEFAULT_CALENDAR_WHITELIST = new UrlWhitelist([...TRUSTED_CALENDAR_WHITELIST_PATTERNS]);
568
+ var DEFAULT_AGGREGATORS = [...DEFAULT_AGGREGATOR_URLS];
569
+
570
+ // src/utils/hex.ts
571
+ function hexToBytes(hex) {
572
+ const clean = hex.trim().toLowerCase();
573
+ if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) throw new Error("Invalid hex string");
574
+ const out = new Uint8Array(clean.length / 2);
575
+ for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
576
+ return out;
577
+ }
578
+ function bytesToHex2(bytes) {
579
+ let s = "";
580
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
581
+ return s;
582
+ }
583
+
584
+ // src/core/shared.ts
585
+ function validateHash(hash) {
586
+ if (typeof hash === "string") {
587
+ const hex = hash.trim().toLowerCase();
588
+ if (!/^[0-9a-f]{64}$/.test(hex)) {
589
+ throw new ValidationError("Hash must be a 64-character hex string (SHA-256)");
590
+ }
591
+ return hexToBytes(hex);
592
+ }
593
+ if (hash.length !== 32) {
594
+ throw new ValidationError("Hash must be exactly 32 bytes (SHA-256)");
595
+ }
596
+ return Uint8Array.from(hash);
597
+ }
598
+ function secureNonce(n) {
599
+ const bytes = new Uint8Array(n);
600
+ if (!globalThis.crypto?.getRandomValues) {
601
+ throw new Error("secure RNG unavailable: globalThis.crypto.getRandomValues is required");
602
+ }
603
+ globalThis.crypto.getRandomValues(bytes);
604
+ return bytes;
605
+ }
606
+
607
+ // src/core/stamp.ts
608
+ async function orchestrateStamp(hash, calendars, networkLayer, validateCalendarUrl, logger, signal, minimumSuccessfulSubmissions = 2) {
609
+ if (calendars.length === 0) {
610
+ throw new ValidationError("at least one calendar is required to stamp");
611
+ }
612
+ if (!Number.isInteger(minimumSuccessfulSubmissions) || minimumSuccessfulSubmissions < 1) {
613
+ throw new ValidationError("minimumSuccessfulSubmissions must be an integer >= 1");
614
+ }
615
+ if (minimumSuccessfulSubmissions > calendars.length) {
616
+ throw new ValidationError(
617
+ `minimumSuccessfulSubmissions (${minimumSuccessfulSubmissions}) cannot exceed the number of calendars (${calendars.length})`
618
+ );
619
+ }
620
+ await Promise.all(calendars.map((url) => validateCalendarUrl(url)));
621
+ const digest = validateHash(hash);
622
+ logger?.info(`Starting stamp for ${bytesToHex2(digest)}`);
623
+ const detached = DetachedTimestampFile.fromHash(new OpSHA256(), digest);
624
+ const nonceAppended = detached.timestamp.add(new OpAppend(secureNonce(16)));
625
+ const merkleRoot = nonceAppended.add(new OpSHA256());
626
+ const merkleTip = makeMerkleTree([merkleRoot]);
627
+ const results = await Promise.allSettled(
628
+ calendars.map(
629
+ (url) => new CalendarClient(url, networkLayer, logger).submit(merkleTip.getDigest(), signal)
630
+ )
631
+ );
632
+ const successful = [];
633
+ const failed = [];
634
+ results.forEach((r, i) => {
635
+ const calendar = calendars[i];
636
+ if (r.status === "fulfilled") {
637
+ merkleTip.merge(r.value);
638
+ successful.push({ calendar });
639
+ logger?.info(`Submitted to ${calendar}`);
640
+ } else {
641
+ const error = r.reason instanceof Error ? r.reason : new Error(String(r.reason));
642
+ failed.push({ calendar, error });
643
+ logger?.warn(`Failed to submit to ${calendar}: ${error.message}`);
644
+ }
645
+ });
646
+ if (successful.length < minimumSuccessfulSubmissions) {
647
+ throw new StampError(
648
+ `Insufficient successful submissions (${successful.length}/${minimumSuccessfulSubmissions} required)`,
649
+ successful,
650
+ failed
651
+ );
652
+ }
653
+ return detached.serializeToBytes();
654
+ }
655
+
656
+ // src/security/ssrf-web.ts
657
+ async function assertSafeCalendarUrlStructural(url) {
658
+ let parsed;
659
+ try {
660
+ parsed = new URL(url);
661
+ } catch {
662
+ throw new ValidationError(`Calendar URL is not valid: "${url}"`);
663
+ }
664
+ if (parsed.protocol !== "https:") {
665
+ throw new ValidationError(`Calendar URL must use https: "${url}"`);
666
+ }
667
+ if (parsed.username || parsed.password) {
668
+ throw new ValidationError(`Calendar URL must not contain embedded credentials: "${url}"`);
669
+ }
670
+ }
671
+
672
+ // src/types.ts
673
+ import { DEFAULT_CALENDAR_URLS } from "@otskit/core";
674
+ var DEFAULT_CALENDARS = [...DEFAULT_CALENDAR_URLS];
675
+ var DEFAULT_RESILIENCE = {
676
+ totalTimeoutMs: 3e4,
677
+ connectTimeoutMs: 5e3,
678
+ retries: {
679
+ enabled: true,
680
+ maxAttempts: 3,
681
+ backoff: {
682
+ strategy: "exponential",
683
+ initialDelayMs: 200,
684
+ maxDelayMs: 5e3,
685
+ jitter: "full"
686
+ }
687
+ },
688
+ circuitBreaker: {
689
+ enabled: true,
690
+ failureThreshold: 5,
691
+ recoveryTimeoutMs: 15e3,
692
+ halfOpenMaxAttempts: 1
693
+ }
694
+ };
695
+
696
+ // src/utils/hash-web.ts
697
+ async function hashBytes(data) {
698
+ return new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data));
699
+ }
700
+ async function hashBlob(blob) {
701
+ return hashBytes(new Uint8Array(await blob.arrayBuffer()));
702
+ }
703
+
704
+ // src/browser.ts
705
+ var BROWSER_CALENDARS = [
706
+ "https://a.pool.opentimestamps.org",
707
+ "https://b.pool.opentimestamps.org",
708
+ "https://a.pool.eternitywall.com"
709
+ ];
710
+ var OpenTimestampsBrowserClient = class {
711
+ // Immutable allowlist: no arbitrary-URL option is exposed on purpose. An attacker-supplied URL
712
+ // could make a visitor's browser fire requests into their own local network (CORS blocks
713
+ // reading the response, not sending the request).
714
+ calendars = [...BROWSER_CALENDARS];
715
+ minSubs;
716
+ layer;
717
+ logger;
718
+ constructor(options = {}) {
719
+ this.minSubs = options.minimumSuccessfulSubmissions ?? 2;
720
+ if (options.logger !== void 0) this.logger = options.logger;
721
+ this.layer = new ResilientNetworkLayer(DEFAULT_RESILIENCE, this.logger);
722
+ }
723
+ /** hash: 32-byte Uint8Array or 64-char hex. Returns the pending .ots as Uint8Array. */
724
+ stamp(hash) {
725
+ return orchestrateStamp(
726
+ hash,
727
+ this.calendars,
728
+ this.layer,
729
+ assertSafeCalendarUrlStructural,
730
+ this.logger,
731
+ void 0,
732
+ this.minSubs
733
+ );
734
+ }
735
+ };
736
+ export {
737
+ BROWSER_CALENDARS,
738
+ OpenTimestampsBrowserClient,
739
+ bytesToHex2 as bytesToHex,
740
+ hashBlob,
741
+ hashBytes
742
+ };