@leadping/consent 1.0.3 → 1.0.5

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.
@@ -5,7 +5,17 @@ export declare class ConsentCapture {
5
5
  private static active?;
6
6
  private readonly browser;
7
7
  private readonly buffer;
8
- private readonly transport;
8
+ private readonly deadline;
9
+ private transport?;
10
+ /** Resolves when the service session is available and validated. Recording starts before this. */
11
+ readonly ready: Promise<void>;
12
+ /** Allocated as soon as the session is ready; this does not assert that submission is saved. */
13
+ get sessionId(): string | undefined;
14
+ /** The server-selected duration limit, available after ready resolves. */
15
+ get maxReplaySeconds(): number | undefined;
16
+ private session?;
17
+ private readonly observedDisclosure;
18
+ private disposed;
9
19
  private readonly pending;
10
20
  private head;
11
21
  private queuedBytes;
@@ -1,6 +1,8 @@
1
1
  import { record } from 'rrweb';
2
2
  import { EventBuffer } from './event-buffer.js';
3
3
  import { CaptureTransport } from './transport.js';
4
+ import { ReplayDeadline } from './replay-deadline.js';
5
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
4
6
  /** One form recording per document, matching rrweb's document-level recorder. */
5
7
  export class ConsentCapture {
6
8
  options;
@@ -18,7 +20,17 @@ export class ConsentCapture {
18
20
  maxTouchPoints: navigator.maxTouchPoints,
19
21
  };
20
22
  buffer = new EventBuffer();
23
+ deadline = new ReplayDeadline();
21
24
  transport;
25
+ /** Resolves when the service session is available and validated. Recording starts before this. */
26
+ ready;
27
+ /** Allocated as soon as the session is ready; this does not assert that submission is saved. */
28
+ get sessionId() { return this.session?.sessionId; }
29
+ /** The server-selected duration limit, available after ready resolves. */
30
+ get maxReplaySeconds() { return this.session?.maxReplaySeconds; }
31
+ session;
32
+ observedDisclosure;
33
+ disposed = false;
22
34
  pending = [];
23
35
  head = 0;
24
36
  queuedBytes = 0;
@@ -36,15 +48,24 @@ export class ConsentCapture {
36
48
  const url = new URL(options.apiUrl);
37
49
  if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
38
50
  throw new Error('Capture API requires an HTTPS URL without credentials.');
39
- if (!/^[0-9a-f-]{36}$/i.test(options.session.sessionId) || options.session.form.origin !== location.origin ||
40
- !options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) ||
41
- options.checkbox.type !== 'checkbox' || options.checkbox.checked || options.checkbox.defaultChecked ||
42
- options.disclosure.textContent !== options.session.form.disclosure)
43
- throw new Error('Form does not match the approved disclosure and unchecked consent control.');
44
- const remaining = Date.parse(options.session.expiresAt) - Date.now();
45
- if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 60 * 60 * 1000)
46
- throw new Error('Invalid or expired capture session.');
47
- this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + options.session.sessionId, options.session.uploadToken);
51
+ if (!options.form.contains(options.checkbox) || !options.form.contains(options.disclosure) ||
52
+ options.checkbox.type !== 'checkbox' || options.checkbox.checked || options.checkbox.defaultChecked)
53
+ throw new Error('Form requires an unchecked consent control and disclosure.');
54
+ this.observedDisclosure = options.disclosure.textContent ?? '';
55
+ this.ready = Promise.resolve().then(() => typeof options.session === 'function' ? options.session() : options.session)
56
+ .then(session => {
57
+ if (this.disposed || this.failure)
58
+ throw this.failure ?? new Error('Recorder is disposed.');
59
+ if (!/^[0-9a-f-]{36}$/i.test(session.sessionId) || session.form.origin !== location.origin ||
60
+ session.form.disclosure !== this.observedDisclosure)
61
+ throw new Error('Form does not match the approved disclosure.');
62
+ this.deadline.configure(session.expiresAt, session.maxReplaySeconds);
63
+ this.session = session;
64
+ this.transport = new CaptureTransport(url.href.replace(/\/$/, '') + '/consent/sessions/' + session.sessionId, session.uploadToken);
65
+ if (!this.stopped)
66
+ this.expiry = setTimeout(() => this.fail(new ReplayLimitExceededError()), this.deadline.remainingMilliseconds);
67
+ });
68
+ void this.ready.then(this.backgroundFlush).catch(error => this.fail(error));
48
69
  ConsentCapture.active = this;
49
70
  try {
50
71
  this.stopRecording = record({
@@ -69,11 +90,10 @@ export class ConsentCapture {
69
90
  options.checkbox.addEventListener('change', this.consentChanged);
70
91
  document.addEventListener('visibilitychange', this.visibilityChanged);
71
92
  this.interval = setInterval(this.backgroundFlush, 3000);
72
- this.expiry = setTimeout(() => this.fail(new Error('Capture session expired.')), remaining);
73
- record.addCustomEvent('leadping.disclosure', { formId: options.session.form.id });
93
+ record.addCustomEvent('leadping.disclosure', { disclosure: this.observedDisclosure });
74
94
  }
75
95
  catch (error) {
76
- this.stop();
96
+ this.dispose();
77
97
  throw error;
78
98
  }
79
99
  }
@@ -86,6 +106,9 @@ export class ConsentCapture {
86
106
  if (this.failure)
87
107
  throw this.failure;
88
108
  this.seal();
109
+ await this.ready;
110
+ if (this.failure || this.disposed)
111
+ throw this.failure ?? new Error('Recorder is disposed.');
89
112
  while (this.head < this.pending.length || this.sending) {
90
113
  this.sending ??= this.send();
91
114
  await this.sending;
@@ -93,22 +116,32 @@ export class ConsentCapture {
93
116
  if (this.failure)
94
117
  throw this.failure;
95
118
  }
96
- dispose() { this.stop(); this.transport.dispose(); }
119
+ dispose() { this.disposed = true; this.stop(); this.transport?.dispose(); }
97
120
  async finishCore(recipient) {
98
121
  if (this.stopped || this.failure)
99
122
  throw this.failure ?? new Error('Recorder is stopped.');
123
+ try {
124
+ this.deadline.assertWithinLimit();
125
+ }
126
+ catch (error) {
127
+ this.fail(error);
128
+ throw error;
129
+ }
100
130
  const observedDisclosure = this.options.disclosure.textContent ?? '';
101
- if (observedDisclosure !== this.options.session.form.disclosure)
131
+ if (observedDisclosure !== this.observedDisclosure)
102
132
  throw new Error('The disclosure changed during capture. Start a new approved form session.');
103
133
  const accepted = this.options.checkbox.checked;
104
134
  record.addCustomEvent('leadping.submit', { accepted });
105
135
  this.stop();
136
+ this.options.onProgress?.('uploading');
106
137
  await this.flush();
107
- await this.transport.put('/submission', JSON.stringify({
138
+ this.deadline.assertWithinLimit();
139
+ this.options.onProgress?.('submitting');
140
+ await this.transport.put(this.options.issueCertificate ? '/submission/certificate' : '/submission', JSON.stringify({
108
141
  lastBatchNumber: this.buffer.lastBatchNumber, lastEventNumber: this.buffer.lastEventNumber,
109
142
  accepted, observedDisclosure, recipient, browser: this.browser,
110
143
  }));
111
- return { sessionId: this.options.session.sessionId, uploadToken: this.options.session.uploadToken };
144
+ return { sessionId: this.session.sessionId, uploadToken: this.session.uploadToken };
112
145
  }
113
146
  emit = (event) => {
114
147
  if (this.stopped || this.failure)
@@ -129,7 +162,7 @@ export class ConsentCapture {
129
162
  const batch = this.buffer.seal();
130
163
  if (!batch)
131
164
  return;
132
- // UTF-16 string budget including queued/retrying batches. One in-flight compressed copy is additional.
165
+ // UTF-16 string budget including queued/retrying batches. Up to three compressed copies are additional.
133
166
  if (this.queuedBytes + batch.body.length * 2 > 2 * 1024 * 1024)
134
167
  throw new Error('Recording upload backlog exceeded 2 MiB.');
135
168
  this.pending.push(batch);
@@ -139,19 +172,25 @@ export class ConsentCapture {
139
172
  // Defer so this.sending is assigned before this function can finish.
140
173
  await Promise.resolve();
141
174
  try {
142
- while (this.head < this.pending.length) {
143
- const batch = this.pending[this.head];
144
- await this.transport.put('/batches/' + batch.number, batch.body);
145
- this.queuedBytes -= batch.body.length * 2;
146
- this.pending[this.head] = undefined;
147
- this.head++;
148
- if (this.head === this.pending.length) {
149
- this.pending.length = 0;
150
- this.head = 0;
175
+ // Batches are immutable and individually numbered. Completion order need not match
176
+ // recording order; submission still waits for every durable acknowledgment.
177
+ await Promise.all(Array.from({ length: 3 }, async () => {
178
+ while (!this.failure && !this.disposed && this.head < this.pending.length) {
179
+ const index = this.head++;
180
+ const batch = this.pending[index];
181
+ await this.transport.put('/batches/' + batch.number, batch.body);
182
+ this.queuedBytes -= batch.body.length * 2;
183
+ this.pending[index] = undefined;
151
184
  }
185
+ }));
186
+ // A concurrent flush may append after the last worker exits but before this continuation.
187
+ if (this.head === this.pending.length) {
188
+ this.pending.length = 0;
189
+ this.head = 0;
152
190
  }
153
191
  }
154
192
  catch (error) {
193
+ this.transport?.dispose();
155
194
  this.fail(error instanceof Error ? error : new Error('Capture upload failed.'));
156
195
  throw this.failure;
157
196
  }
@@ -159,7 +198,12 @@ export class ConsentCapture {
159
198
  this.sending = undefined;
160
199
  }
161
200
  }
162
- consentChanged = () => record.addCustomEvent('leadping.consent', { accepted: this.options.checkbox.checked });
201
+ consentChanged = () => {
202
+ record.addCustomEvent('leadping.consent', { accepted: this.options.checkbox.checked });
203
+ // Start draining while the visitor moves from consenting to submitting.
204
+ if (this.options.checkbox.checked)
205
+ this.backgroundFlush();
206
+ };
163
207
  visibilityChanged = () => { if (document.visibilityState === 'hidden')
164
208
  this.backgroundFlush(); };
165
209
  backgroundFlush = () => { void this.flush().catch(error => this.fail(error)); };
@@ -1,3 +1,4 @@
1
1
  export { ConsentCapture } from './capture.js';
2
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
3
  /** Attach the Leadping-hosted capture and submission flow to a form. */
3
4
  export declare function attach(script: HTMLScriptElement): Promise<void>;
package/dist/esm/embed.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { CaptureRejectedError } from './capture-rejected-error.js';
1
2
  import { ConsentCapture } from './capture.js';
3
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
4
  export { ConsentCapture } from './capture.js';
5
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
3
6
  /** Attach the Leadping-hosted capture and submission flow to a form. */
4
7
  export async function attach(script) {
5
8
  const domainId = script.dataset.domainId;
@@ -17,11 +20,10 @@ export async function attach(script) {
17
20
  status.setAttribute('role', 'status');
18
21
  form.append(status);
19
22
  let capture;
20
- let ready = false;
21
23
  let submitted = false;
22
24
  const fail = (error) => {
23
- ready = false;
24
- status.textContent = 'Unable to complete consent capture. Please reload to start again.';
25
+ status.textContent = error instanceof ReplayLimitExceededError ? error.message
26
+ : 'Unable to complete consent capture. Please reload to start again.';
25
27
  form.dispatchEvent(new CustomEvent('leadping:error', { detail: error }));
26
28
  };
27
29
  const origin = new URL(script.dataset.apiUrl ?? 'https://consent.leadping.ai');
@@ -32,26 +34,41 @@ export async function attach(script) {
32
34
  if (portal.protocol !== 'https:' || portal.username || portal.password || portal.search || portal.hash || portal.pathname !== '/')
33
35
  throw new Error('Leadping requires an HTTPS portal origin.');
34
36
  const endpoint = `${origin.origin}/api/consent`;
37
+ const progress = (stage) => {
38
+ form.dispatchEvent(new CustomEvent('leadping:progress', { detail: { stage } }));
39
+ };
35
40
  const post = async (path, body) => {
36
- const response = await fetch(endpoint + path, {
37
- method: 'POST', headers: { 'Content-Type': 'application/json' },
38
- credentials: 'omit', redirect: 'error', body: JSON.stringify(body),
39
- signal: AbortSignal.timeout(30000),
40
- });
41
- if (!response.ok)
42
- throw new Error(`Leadping request failed (${response.status}).`);
43
- return response;
41
+ const attempts = path.endsWith('/sessions') ? 5 : 1;
42
+ for (let attempt = 0; attempt < attempts; attempt++) {
43
+ try {
44
+ const response = await fetch(endpoint + path, {
45
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
46
+ credentials: 'omit', redirect: 'error', body: JSON.stringify(body),
47
+ signal: AbortSignal.timeout(attempts > 1 ? 15000 : 30000),
48
+ });
49
+ if (response.ok)
50
+ return response;
51
+ if (response.status < 500 && response.status !== 408 && response.status !== 429)
52
+ throw new CaptureRejectedError(`Leadping request failed (${response.status}).`);
53
+ throw new Error(`Leadping request failed (${response.status}).`);
54
+ }
55
+ catch (error) {
56
+ if (error instanceof CaptureRejectedError || attempt === attempts - 1)
57
+ throw error;
58
+ }
59
+ await new Promise(resolve => setTimeout(resolve, 500 * 2 ** attempt));
60
+ }
61
+ throw new Error('Leadping session could not be started.');
44
62
  };
45
63
  form.addEventListener('submit', async (event) => {
46
64
  event.preventDefault();
47
- if (!ready || submitted || !capture || !form.reportValidity())
65
+ if (submitted || !capture || !form.reportValidity())
48
66
  return;
49
67
  if (!(checkbox instanceof HTMLInputElement) || !checkbox.checked) {
50
68
  status.textContent = 'Please check the consent box before submitting.';
51
69
  return;
52
70
  }
53
71
  submitted = true;
54
- ready = false;
55
72
  status.textContent = 'Submitting…';
56
73
  const values = new FormData(form);
57
74
  const recipient = {};
@@ -60,15 +77,11 @@ export async function attach(script) {
60
77
  if (typeof value === 'string' && value)
61
78
  recipient[key] = value;
62
79
  }
63
- let issuingCertificate = false;
64
80
  try {
65
81
  const consentCapture = await capture.finish(recipient);
66
- issuingCertificate = true;
67
- const certificate = await (await post('/certificates', consentCapture)).json();
68
- if (!/^[0-9a-f-]{36}$/i.test(certificate.certificateId))
69
- throw new Error('Invalid certificate response.');
82
+ const certificate = { certificateId: consentCapture.sessionId };
70
83
  const certificateUrl = `${portal.origin}/${certificate.certificateId}`;
71
- status.textContent = `Certificate received: ${certificate.certificateId}. `;
84
+ status.textContent = `Submission saved. Certificate: ${certificate.certificateId}. `;
72
85
  const link = document.createElement('a');
73
86
  link.href = certificateUrl;
74
87
  link.textContent = 'View certificate and replay';
@@ -79,8 +92,8 @@ export async function attach(script) {
79
92
  }
80
93
  catch (error) {
81
94
  fail(error);
82
- if (issuingCertificate)
83
- status.textContent = 'Certificate issuance could not be confirmed. Please check before starting another recording.';
95
+ if (!(error instanceof ReplayLimitExceededError))
96
+ status.textContent = 'Submission could not be confirmed. Keep the certificate ID and check before starting another recording.';
84
97
  }
85
98
  finally {
86
99
  capture.dispose();
@@ -90,13 +103,29 @@ export async function attach(script) {
90
103
  try {
91
104
  if (!(disclosure instanceof HTMLElement) || !(checkbox instanceof HTMLInputElement))
92
105
  throw new Error('Add data-leadping-disclosure and data-leadping-consent inside the form.');
93
- const session = await (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl: location.href, disclosure: disclosure.textContent ?? '' })).json();
94
- checkbox.checked = false;
95
- capture = new ConsentCapture({ apiUrl: origin.origin + '/api', session, form, disclosure, checkbox, onError: fail });
96
- window.addEventListener('pagehide', () => { ready = false; capture?.dispose(); }, { once: true });
97
- ready = true;
98
- status.textContent = '';
99
- form.dispatchEvent(new CustomEvent('leadping:ready'));
106
+ const pageUrl = location.href;
107
+ const observedDisclosure = disclosure.textContent ?? '';
108
+ capture = new ConsentCapture({ apiUrl: origin.origin + '/api',
109
+ session: async () => (await post(`/domains/${encodeURIComponent(domainId)}/sessions`, { pageUrl, disclosure: observedDisclosure })).json(),
110
+ form, disclosure, checkbox, issueCertificate: true, onError: fail, onProgress: stage => {
111
+ status.textContent = stage === 'uploading' ? 'Saving recording…' : 'Saving submission and requesting certificate…';
112
+ progress(stage);
113
+ } });
114
+ window.addEventListener('pagehide', () => { capture?.dispose(); }, { once: true });
115
+ await capture.ready;
116
+ const certificateId = capture.sessionId;
117
+ const certificateUrl = `${portal.origin}/${certificateId}`;
118
+ for (const [name, value] of Object.entries({ leadping_certificate_id: certificateId, leadping_certificate_url: certificateUrl })) {
119
+ const input = document.createElement('input');
120
+ input.type = 'hidden';
121
+ input.name = name;
122
+ input.value = value;
123
+ form.append(input);
124
+ }
125
+ if (!submitted)
126
+ status.textContent = '';
127
+ form.dispatchEvent(new CustomEvent('leadping:ready', { detail: { certificateId, certificateUrl,
128
+ maxReplaySeconds: capture.maxReplaySeconds } }));
100
129
  }
101
130
  catch (error) {
102
131
  capture?.dispose();
@@ -42,7 +42,7 @@ export class EventBuffer {
42
42
  seal() {
43
43
  if (this.events.length === 0)
44
44
  return;
45
- if (this.nextBatch >= 256)
45
+ if (this.nextBatch >= 1024)
46
46
  throw new Error('Recording exceeded the session batch limit.');
47
47
  const first = this.nextEvent - this.events.length;
48
48
  const body = `{"firstEventNumber":${first},"events":[${this.events.join(',')}]}`;
@@ -1,2 +1,3 @@
1
1
  export { ConsentCapture } from './capture.js';
2
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
3
  export type { CaptureOptions, CaptureReference, CaptureSession, ConsentForm, Recipient } from './types.js';
package/dist/esm/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { ConsentCapture } from './capture.js';
2
+ export { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
@@ -0,0 +1,10 @@
1
+ /** Checks elapsed time even when a suspended tab has not run its expiry timer. */
2
+ export declare class ReplayDeadline {
3
+ private readonly startedAt;
4
+ private readonly startedTick;
5
+ private expiresAt;
6
+ private maximumMs;
7
+ configure(expiresAt: string, maxReplaySeconds: number): void;
8
+ get remainingMilliseconds(): number;
9
+ assertWithinLimit(): void;
10
+ }
@@ -0,0 +1,23 @@
1
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
2
+ /** Checks elapsed time even when a suspended tab has not run its expiry timer. */
3
+ export class ReplayDeadline {
4
+ startedAt = Date.now();
5
+ startedTick = performance.now();
6
+ expiresAt = Infinity;
7
+ maximumMs = 1800_000;
8
+ configure(expiresAt, maxReplaySeconds) {
9
+ const deadline = Date.parse(expiresAt);
10
+ if (!Number.isFinite(deadline) || !Number.isInteger(maxReplaySeconds) || maxReplaySeconds < 1 || maxReplaySeconds > 1800)
11
+ throw new Error('Invalid recording time limit.');
12
+ this.expiresAt = deadline;
13
+ this.maximumMs = maxReplaySeconds * 1000;
14
+ this.assertWithinLimit();
15
+ }
16
+ get remainingMilliseconds() {
17
+ return Math.min(this.expiresAt - Date.now(), this.maximumMs - (Date.now() - this.startedAt), this.maximumMs - (performance.now() - this.startedTick));
18
+ }
19
+ assertWithinLimit() {
20
+ if (this.remainingMilliseconds <= 0)
21
+ throw new ReplayLimitExceededError();
22
+ }
23
+ }
@@ -0,0 +1,5 @@
1
+ /** The recording cannot support this submission because its allotted time has elapsed. */
2
+ export declare class ReplayLimitExceededError extends Error {
3
+ readonly code = "replay_limit_exceeded";
4
+ constructor();
5
+ }
@@ -0,0 +1,8 @@
1
+ /** The recording cannot support this submission because its allotted time has elapsed. */
2
+ export class ReplayLimitExceededError extends Error {
3
+ code = 'replay_limit_exceeded';
4
+ constructor() {
5
+ super("The recording time limit was reached. This submission wasn't captured. Start a new recording and submit again.");
6
+ this.name = 'ReplayLimitExceededError';
7
+ }
8
+ }
@@ -1,4 +1,4 @@
1
- /** Serial, bounded retry transport. A resolved request means durable server acknowledgment. */
1
+ /** Bounded retry transport. A resolved request means durable server acknowledgment. */
2
2
  export declare class CaptureTransport {
3
3
  private readonly baseUrl;
4
4
  private readonly token;
@@ -1,5 +1,6 @@
1
1
  import { CaptureRejectedError } from './capture-rejected-error.js';
2
- /** Serial, bounded retry transport. A resolved request means durable server acknowledgment. */
2
+ import { ReplayLimitExceededError } from './replay-limit-exceeded-error.js';
3
+ /** Bounded retry transport. A resolved request means durable server acknowledgment. */
3
4
  export class CaptureTransport {
4
5
  baseUrl;
5
6
  token;
@@ -26,11 +27,13 @@ export class CaptureTransport {
26
27
  });
27
28
  if (response.ok)
28
29
  return;
30
+ if (response.status === 410)
31
+ throw new ReplayLimitExceededError();
29
32
  if (response.status < 500 && response.status !== 408 && response.status !== 429)
30
33
  throw new CaptureRejectedError(`Capture rejected (${response.status}).`);
31
34
  }
32
35
  catch (error) {
33
- if (error instanceof CaptureRejectedError || this.abort.signal.aborted)
36
+ if (error instanceof CaptureRejectedError || error instanceof ReplayLimitExceededError || this.abort.signal.aborted)
34
37
  throw error;
35
38
  }
36
39
  if (attempt === 4)
@@ -12,6 +12,8 @@ export interface CaptureSession {
12
12
  sessionId: string;
13
13
  uploadToken: string;
14
14
  expiresAt: string;
15
+ /** Maximum elapsed recording time including idle time, supplied by the service. */
16
+ maxReplaySeconds: number;
15
17
  form: ConsentForm;
16
18
  }
17
19
  export interface Recipient {
@@ -27,12 +29,17 @@ export interface CaptureReference {
27
29
  export interface CaptureOptions {
28
30
  /** Absolute HTTPS API origin; no credentials or query string. */
29
31
  apiUrl: string;
30
- session: CaptureSession;
32
+ /** A session or a factory that obtains one. Capture starts immediately while the factory is pending. */
33
+ session: CaptureSession | (() => Promise<CaptureSession>);
31
34
  form: HTMLFormElement;
32
35
  disclosure: HTMLElement;
33
36
  checkbox: HTMLInputElement;
37
+ /** For public domain sessions, durably request a certificate with the final submission. */
38
+ issueCertificate?: boolean;
34
39
  /** Called on terminal capture failures; never includes captured values. */
35
40
  onError?: (error: Error) => void;
41
+ /** Submission stages; no captured values or upload credentials are included. */
42
+ onProgress?: (stage: 'uploading' | 'submitting') => void;
36
43
  }
37
44
  export interface SealedBatch {
38
45
  number: number;