@sparkvault/sdk-mobile 6.0.2 → 7.0.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.
Files changed (156) hide show
  1. package/README.md +5 -5
  2. package/dist/account.d.ts +4 -3
  3. package/dist/account.js +16 -11
  4. package/dist/account.js.map +1 -1
  5. package/dist/app-sdk/email-address.d.ts +37 -0
  6. package/dist/app-sdk/email-address.js +91 -0
  7. package/dist/app-sdk/email-address.js.map +1 -0
  8. package/dist/app-sdk/encoding.d.ts +64 -0
  9. package/dist/app-sdk/encoding.js +245 -0
  10. package/dist/app-sdk/encoding.js.map +1 -0
  11. package/dist/app-sdk/entropy-contract.d.ts +33 -0
  12. package/dist/app-sdk/entropy-contract.js +51 -0
  13. package/dist/app-sdk/entropy-contract.js.map +1 -0
  14. package/dist/app-sdk/filename.d.ts +93 -0
  15. package/dist/app-sdk/filename.js +161 -0
  16. package/dist/app-sdk/filename.js.map +1 -0
  17. package/dist/app-sdk/forge-url.d.ts +107 -0
  18. package/dist/app-sdk/forge-url.js +222 -0
  19. package/dist/app-sdk/forge-url.js.map +1 -0
  20. package/dist/app-sdk/ids.d.ts +48 -0
  21. package/dist/app-sdk/ids.js +65 -0
  22. package/dist/app-sdk/ids.js.map +1 -0
  23. package/dist/app-sdk/ingot-access-log.d.ts +29 -0
  24. package/dist/app-sdk/ingot-access-log.js +49 -0
  25. package/dist/app-sdk/ingot-access-log.js.map +1 -0
  26. package/dist/app-sdk/phone.d.ts +37 -0
  27. package/dist/app-sdk/phone.js +134 -0
  28. package/dist/app-sdk/phone.js.map +1 -0
  29. package/dist/app-sdk/platform-domains.d.ts +41 -0
  30. package/dist/app-sdk/platform-domains.js +82 -0
  31. package/dist/app-sdk/platform-domains.js.map +1 -0
  32. package/dist/app-sdk/request-path.d.ts +49 -0
  33. package/dist/app-sdk/request-path.js +81 -0
  34. package/dist/app-sdk/request-path.js.map +1 -0
  35. package/dist/app-sdk/transfer-retry.d.ts +298 -0
  36. package/dist/app-sdk/transfer-retry.js +535 -0
  37. package/dist/app-sdk/transfer-retry.js.map +1 -0
  38. package/dist/app-sdk/transient.d.ts +23 -0
  39. package/dist/app-sdk/transient.js +29 -0
  40. package/dist/app-sdk/transient.js.map +1 -0
  41. package/dist/app-sdk/upload-publication.d.ts +81 -0
  42. package/dist/app-sdk/upload-publication.js +93 -0
  43. package/dist/app-sdk/upload-publication.js.map +1 -0
  44. package/dist/app-sdk/vat-refusal.d.ts +66 -0
  45. package/dist/app-sdk/vat-refusal.js +69 -0
  46. package/dist/app-sdk/vat-refusal.js.map +1 -0
  47. package/dist/billing.d.ts +9 -17
  48. package/dist/billing.js +14 -17
  49. package/dist/billing.js.map +1 -1
  50. package/dist/config.d.ts +33 -1
  51. package/dist/config.js +23 -17
  52. package/dist/config.js.map +1 -1
  53. package/dist/encoding.d.ts +30 -4
  54. package/dist/encoding.js +89 -83
  55. package/dist/encoding.js.map +1 -1
  56. package/dist/entropy.d.ts +25 -4
  57. package/dist/entropy.js +62 -15
  58. package/dist/entropy.js.map +1 -1
  59. package/dist/entropy.test-d.d.ts +40 -0
  60. package/dist/entropy.test-d.js +12 -0
  61. package/dist/entropy.test-d.js.map +1 -0
  62. package/dist/errors.d.ts +105 -21
  63. package/dist/errors.js +74 -28
  64. package/dist/errors.js.map +1 -1
  65. package/dist/folders.js +8 -7
  66. package/dist/folders.js.map +1 -1
  67. package/dist/http.d.ts +57 -15
  68. package/dist/http.js +125 -138
  69. package/dist/http.js.map +1 -1
  70. package/dist/identity-dialog.js +4 -30
  71. package/dist/identity-dialog.js.map +1 -1
  72. package/dist/identity-input.d.ts +39 -0
  73. package/dist/identity-input.js +59 -0
  74. package/dist/identity-input.js.map +1 -0
  75. package/dist/index.d.ts +8 -7
  76. package/dist/index.js +5 -4
  77. package/dist/index.js.map +1 -1
  78. package/dist/ingots.d.ts +77 -32
  79. package/dist/ingots.js +152 -136
  80. package/dist/ingots.js.map +1 -1
  81. package/dist/mutex.d.ts +1 -2
  82. package/dist/mutex.js +0 -6
  83. package/dist/mutex.js.map +1 -1
  84. package/dist/notify.d.ts +7 -2
  85. package/dist/notify.js +12 -15
  86. package/dist/notify.js.map +1 -1
  87. package/dist/notify.test-d.d.ts +3 -2
  88. package/dist/notify.test-d.js +1 -1
  89. package/dist/products/identity.d.ts +5 -0
  90. package/dist/products/identity.js +40 -35
  91. package/dist/products/identity.js.map +1 -1
  92. package/dist/request-path.d.ts +32 -0
  93. package/dist/request-path.js +35 -0
  94. package/dist/request-path.js.map +1 -0
  95. package/dist/sparks.js +16 -15
  96. package/dist/sparks.js.map +1 -1
  97. package/dist/tus.d.ts +198 -16
  98. package/dist/tus.js +816 -327
  99. package/dist/tus.js.map +1 -1
  100. package/dist/types.d.ts +183 -23
  101. package/dist/users.d.ts +12 -5
  102. package/dist/users.js +27 -19
  103. package/dist/users.js.map +1 -1
  104. package/dist/validation.d.ts +20 -5
  105. package/dist/validation.js +29 -27
  106. package/dist/validation.js.map +1 -1
  107. package/dist/vaults.d.ts +34 -83
  108. package/dist/vaults.js +64 -44
  109. package/dist/vaults.js.map +1 -1
  110. package/dist/vaults.test-d.d.ts +22 -0
  111. package/dist/vaults.test-d.js +14 -0
  112. package/dist/vaults.test-d.js.map +1 -0
  113. package/package.json +4 -2
  114. package/src/account.ts +16 -11
  115. package/src/app-sdk/email-address.js +94 -0
  116. package/src/app-sdk/encoding.js +250 -0
  117. package/src/app-sdk/entropy-contract.js +56 -0
  118. package/src/app-sdk/filename.js +174 -0
  119. package/src/app-sdk/forge-url.js +224 -0
  120. package/src/app-sdk/ids.js +71 -0
  121. package/src/app-sdk/ingot-access-log.js +51 -0
  122. package/src/app-sdk/phone.js +139 -0
  123. package/src/app-sdk/platform-domains.js +99 -0
  124. package/src/app-sdk/request-path.js +81 -0
  125. package/src/app-sdk/transfer-retry.js +563 -0
  126. package/src/app-sdk/transient.js +30 -0
  127. package/src/app-sdk/upload-publication.js +102 -0
  128. package/src/app-sdk/vat-refusal.js +74 -0
  129. package/src/billing.ts +16 -25
  130. package/src/config.ts +56 -23
  131. package/src/encoding.ts +91 -103
  132. package/src/entropy.test-d.ts +47 -0
  133. package/src/entropy.ts +86 -20
  134. package/src/errors.ts +135 -38
  135. package/src/folders.ts +8 -7
  136. package/src/http.ts +145 -150
  137. package/src/identity-dialog.tsx +4 -37
  138. package/src/identity-input.ts +70 -0
  139. package/src/index.ts +13 -3
  140. package/src/ingots.ts +225 -180
  141. package/src/mutex.ts +1 -8
  142. package/src/notify.test-d.ts +3 -1
  143. package/src/notify.ts +19 -17
  144. package/src/products/identity.ts +45 -39
  145. package/src/request-path.ts +45 -0
  146. package/src/sparks.ts +16 -13
  147. package/src/tus.ts +1054 -408
  148. package/src/types.ts +181 -23
  149. package/src/users.ts +27 -17
  150. package/src/validation.ts +30 -24
  151. package/src/vaults.test-d.ts +29 -0
  152. package/src/vaults.ts +87 -153
  153. package/dist/platform-domains.generated.d.ts +0 -2
  154. package/dist/platform-domains.generated.js +0 -16
  155. package/dist/platform-domains.generated.js.map +0 -1
  156. package/src/platform-domains.generated.ts +0 -17
package/dist/tus.js CHANGED
@@ -1,32 +1,77 @@
1
- import { base64EncodeUtf8 } from './encoding.js';
1
+ import { FORGE_URL_REFUSAL, readForgeSessionUrl, readForgeTransferUrl, resolveForgeSessionUrl } from './app-sdk/forge-url.js';
2
+ import { SPARKVAULT_OWNED_HOST_PATTERNS } from './app-sdk/platform-domains.js';
3
+ import { HEAD_AND_RECEIPT_TIMEOUT_MS, TRANSFER_VERDICT, UPLOAD_EXPIRES_HEADER, classifyTransferResponse, createTransferBudget, parseUploadExpires, resumeOffsetProblem, retryDelayMs, transferJitterKey, } from './app-sdk/transfer-retry.js';
4
+ import { UPLOAD_REFUSAL_CODES } from './app-sdk/upload-publication.js';
2
5
  import { BackgroundTransferError, gateErrorFromBody, SparkVaultMobileError, SparkVaultValidationError, TusUploadError, } from './errors.js';
3
6
  const TUS_VERSION = '1.0.0';
4
- export function parseForgeUrl(forgeUrl) {
5
- let url;
6
- try {
7
- url = new URL(forgeUrl);
8
- }
9
- catch {
10
- throw new SparkVaultValidationError('Invalid Forge URL format');
11
- }
12
- if (url.protocol !== 'https:') {
13
- throw new SparkVaultValidationError('Forge URL must use HTTPS protocol');
14
- }
15
- const istk = url.searchParams.get('istk');
16
- if (!istk) {
17
- throw new SparkVaultValidationError('Forge URL missing ISTK parameter');
7
+ /** What a refused Forge URL was refused for, in the words this SDK's errors use. */
8
+ const FORGE_URL_REFUSAL_MESSAGES = {
9
+ [FORGE_URL_REFUSAL.INVALID]: 'Invalid Forge URL format',
10
+ [FORGE_URL_REFUSAL.NOT_HTTPS]: 'Forge URL must use HTTPS protocol',
11
+ [FORGE_URL_REFUSAL.UNSAFE_PARTS]: 'Forge URL must not carry credentials or a fragment',
12
+ [FORGE_URL_REFUSAL.HOST]: 'Forge URL host is not an allowed transfer host',
13
+ [FORGE_URL_REFUSAL.OPERATION]: 'Forge URL is not an upload endpoint',
14
+ [FORGE_URL_REFUSAL.ISTK]: 'Forge URL must carry exactly one base64url ISTK parameter',
15
+ };
16
+ /**
17
+ * Read a Core-issued Forge upload URL with the platform's transfer URL rule
18
+ * (app-sdk/forge-url.js): https, no credentials or fragment, the encrypt
19
+ * endpoint on an allowed host, and one base64url ISTK.
20
+ *
21
+ * @param allowedHostPatterns - Hosts trusted with the upload; the platform's
22
+ * own hosts by default (a configured SDK passes `allowedDownloadHostPatterns`).
23
+ * @throws SparkVaultValidationError when the URL is refused.
24
+ */
25
+ export function parseForgeUrl(forgeUrl, allowedHostPatterns = SPARKVAULT_OWNED_HOST_PATTERNS) {
26
+ const read = readForgeTransferUrl(forgeUrl, {
27
+ operation: 'encrypt',
28
+ isAllowedHost: allowsHost(allowedHostPatterns),
29
+ });
30
+ if (!read.ok) {
31
+ throw new SparkVaultValidationError(FORGE_URL_REFUSAL_MESSAGES[read.reason] ?? 'Invalid Forge URL', {
32
+ reason: read.reason,
33
+ });
18
34
  }
19
- const baseUrl = `${url.protocol}//${url.host}`;
20
35
  return {
21
- baseUrl,
22
- tusEndpoint: `${baseUrl}${url.pathname}`,
23
- istk,
36
+ baseUrl: read.origin,
37
+ tusEndpoint: read.endpoint,
38
+ istk: read.istk,
24
39
  };
25
40
  }
26
41
  /**
27
- * Parse a Forge error response body (JSON text) into the shared ApiErrorBody
28
- * shape so gate mapping can read its `error.code` / `error.details.resource`.
29
- * Returns an empty body on non-JSON text, which simply matches no gate.
42
+ * Read a kept tus session URL (the one `onSessionCreated` handed out) with the
43
+ * platform's session URL rule (app-sdk/forge-url.js) before the session is
44
+ * sent anything. Every request to a session carries the ISTK, and a PATCH the
45
+ * file's bytes, so a kept session is held to the rule a created one was: a
46
+ * session directly under the upload endpoint on an allowed host. The one
47
+ * reader for every path that sends a kept session a request: resume, probe
48
+ * and terminate.
49
+ *
50
+ * @throws SparkVaultValidationError when the URL is refused.
51
+ */
52
+ function readKeptSessionUrl(uploadUrl, allowedHostPatterns) {
53
+ const session = readForgeSessionUrl(uploadUrl, {
54
+ operation: 'encrypt',
55
+ isAllowedHost: allowsHost(allowedHostPatterns),
56
+ });
57
+ if (!session.ok) {
58
+ throw new SparkVaultValidationError('uploadUrl is not an upload session on an allowed transfer host', {
59
+ reason: session.reason,
60
+ });
61
+ }
62
+ return session.url;
63
+ }
64
+ /** The forge-url rule's host check over a configured list of host patterns. */
65
+ export function allowsHost(allowedHostPatterns) {
66
+ return hostname => allowedHostPatterns.some(pattern => pattern.test(hostname));
67
+ }
68
+ /**
69
+ * Parse a Forge error response body (JSON text), whole, into the shared
70
+ * ApiErrorBody shape: the one reader of a failed answer's body, so gate
71
+ * mapping reads its `error.code` / `error.details.resource` and the transfer
72
+ * policy the refusal Core decided (`error.details.coreCode`) from the same
73
+ * parse. Returns an empty body on non-JSON text, which matches no gate and
74
+ * names no refusal.
30
75
  */
31
76
  function parseErrorBody(text) {
32
77
  if (!text)
@@ -45,9 +90,13 @@ function getErrorName(err) {
45
90
  function getErrorMessage(err) {
46
91
  return err instanceof Error ? err.message : String(err);
47
92
  }
93
+ /** What every path the caller's abort stops reports: the one cancellation the outer catch terminates or keeps by. */
94
+ function cancelledError() {
95
+ return new TusUploadError('Upload cancelled', { phase: 'cancelled' });
96
+ }
48
97
  function ensureNotAborted(signal) {
49
98
  if (signal?.aborted) {
50
- throw new TusUploadError('Upload cancelled', { phase: 'cancelled' });
99
+ throw cancelledError();
51
100
  }
52
101
  }
53
102
  /**
@@ -61,28 +110,231 @@ function parseIntegerHeader(raw, minimum) {
61
110
  const value = Number(raw);
62
111
  return Number.isSafeInteger(value) && value >= minimum ? value : null;
63
112
  }
64
- /**
65
- * Forge's answers that mean the tus session no longer exists: it expired,
66
- * or a DELETE terminated it. One list, so every request that can meet a
67
- * dead session (HEAD, the finalization PATCH, the OS engine's PATCHes,
68
- * DELETE) and the app's own session bookkeeping agree on what "gone" is.
69
- */
70
- const SESSION_GONE_STATUSES = new Set([404, 410]);
71
113
  /**
72
114
  * True when a Forge status says the tus session is gone (expired or
73
115
  * terminated): the persisted session is worthless, and a caller starts a
74
- * fresh upload instead of retrying it.
116
+ * fresh upload instead of retrying it. It is the transfer policy's GONE
117
+ * verdict, so every request that can meet a dead session (HEAD, a PATCH, the
118
+ * OS engine's PATCHes, DELETE) and the app's own session bookkeeping agree on
119
+ * what "gone" is.
75
120
  */
76
121
  export function isSessionGone(status) {
77
- return SESSION_GONE_STATUSES.has(status);
122
+ return Number.isSafeInteger(status) && status > 0 && classifyTransferResponse({ status }).verdict === TRANSFER_VERDICT.GONE;
78
123
  }
79
- function sessionLostError(status, context, phase, cause = null) {
124
+ /**
125
+ * Forge no longer has the session: `coreCode` names the code Core ended it
126
+ * with when the answer named one (`NOT_FOUND`, `UPLOAD_PROTOCOL_RETIRED`, a
127
+ * 412's), none of them a refusal a new upload cannot remedy.
128
+ */
129
+ function sessionLostError(status, context, phase, cause = null, coreCode = null) {
80
130
  return new TusUploadError(`TUS session no longer exists on the server (${status})`, {
81
131
  cause,
82
132
  httpStatus: status,
83
133
  ...context,
84
134
  phase,
85
135
  sessionLost: true,
136
+ coreCode,
137
+ });
138
+ }
139
+ /**
140
+ * How far past the expiry Forge named a session still counts as live: the
141
+ * allowance for a device clock running ahead of Forge's. `Upload-Expires` is
142
+ * read off Forge's clock and compared with the device's; a phone on automatic
143
+ * time is within seconds of Forge, one set by hand is typically minutes fast.
144
+ * The two mistakes cost very differently: a live session judged expired is
145
+ * dropped and its file sent again from byte 0, an expired one judged live
146
+ * costs a single HEAD (Forge answers 410, `sessionLost`). So the margin errs
147
+ * long, and stays small against the 72 h idle window a session lives from its
148
+ * last commit (`@sparkvault/app-sdk/upload-expiry`): a session the clock says
149
+ * lapsed hours ago is still dropped without a request.
150
+ */
151
+ export const SESSION_EXPIRY_CLOCK_MARGIN_MS = 5 * 60 * 1000;
152
+ /**
153
+ * True when the expiry Forge named for a session (`TusSessionInfo.expiresAt`,
154
+ * kept current through `onSessionExpiry`) is past by more than the clock
155
+ * margin: Forge refuses every request to a session past its expiry, so a
156
+ * caller drops it without asking. Within the margin the session is live for
157
+ * all the device clock can tell, and Forge's HEAD decides. An unknown expiry
158
+ * (null) never has passed: only Forge's HEAD can say.
159
+ */
160
+ export function isSessionExpired(expiresAt, now = Date.now()) {
161
+ return typeof expiresAt === 'number' && now > expiresAt + SESSION_EXPIRY_CLOCK_MARGIN_MS;
162
+ }
163
+ /** An answer that arrived: its status, its headers and the body of one that is not a 2xx. */
164
+ function answerOf(status, header, body) {
165
+ return { status, header, body, envelope: parseErrorBody(body), unanswered: null };
166
+ }
167
+ function noAnswer(error) {
168
+ return { status: 0, header: () => null, body: '', envelope: {}, unanswered: error };
169
+ }
170
+ /**
171
+ * The transfer policy's reading of an answer to `request`: what to do next,
172
+ * its `Retry-After`, its `Upload-Offset`, and the refusal Core decided when
173
+ * the answer is one.
174
+ */
175
+ function readOutcome(answer, request) {
176
+ return classifyTransferResponse({
177
+ status: answer.status,
178
+ headers: answer.header,
179
+ details: answer.envelope.error?.details ?? null,
180
+ receipt: request.receipt,
181
+ });
182
+ }
183
+ const CREATE_REQUEST = { label: 'TUS create', phase: 'create', gonePhase: null, receipt: false };
184
+ const RESUME_HEAD_REQUEST = { label: 'TUS HEAD', phase: 'create', gonePhase: 'create', receipt: false };
185
+ const OFFSET_HEAD_REQUEST = { label: 'TUS HEAD', phase: 'upload', gonePhase: 'create', receipt: false };
186
+ const CHUNK_REQUEST = { label: 'Chunk upload', phase: 'upload', gonePhase: 'upload', receipt: false };
187
+ const RECEIPT_REQUEST = { label: 'TUS finalization', phase: 'upload', gonePhase: 'create', receipt: true };
188
+ /** What a caller hears once Forge kept deferring the publication until the upload's idle limit. */
189
+ const RECEIVED_PUBLISHING_MESSAGE = 'Upload received: Forge holds every byte and is still saving the file; it finishes without this device';
190
+ /**
191
+ * What a caller reads for a final refusal whose answer carried no sentence of
192
+ * Core's (a HEAD's, which has no body, naming Core's code in
193
+ * `X-Upload-Refusal` alone): this SDK's own words for each code.
194
+ */
195
+ const REFUSAL_WORDING = new Map([
196
+ [UPLOAD_REFUSAL_CODES.UPLOAD_POOL_FULL, 'This upload could not be saved: the owner\'s storage cannot take the file'],
197
+ [UPLOAD_REFUSAL_CODES.UPLOAD_SUPERSEDED, 'This upload was not saved: a newer upload of this file replaced it'],
198
+ ]);
199
+ /**
200
+ * Core refused the upload for good, in its own words (the answer's body,
201
+ * `details.coreCode` naming Core's code) or, for an answer with no body (a
202
+ * HEAD's, naming the code in `X-Upload-Refusal`), in this SDK's: the
203
+ * account's storage pool cannot take it, or a later upload replaced it. Not
204
+ * `sessionLost`: a new upload of the same file is no remedy.
205
+ */
206
+ function refusedError(status, envelope, coreCode, context, phase, cause = null) {
207
+ const coreMessage = envelope.error?.message;
208
+ const message = typeof coreMessage === 'string' && coreMessage
209
+ ? coreMessage
210
+ : REFUSAL_WORDING.get(coreCode) ?? `Forge did not save this upload: Core refused it for good (${coreCode})`;
211
+ return new TusUploadError(message, { cause, httpStatus: status, code: 'upload_refused', coreCode, ...context, phase });
212
+ }
213
+ /**
214
+ * The failure a request's answer amounts to: the no-answer error as it was
215
+ * classified, a gone session as `sessionLost`, the refusal Core decided in
216
+ * Core's words (`upload_refused`), the receipt's deferral as the
217
+ * received-publishing outcome a caller resumes later (`retryLater`; thrown
218
+ * only once the idle limit ends the asking), a 402 as the typed gate error
219
+ * the JSON HTTP path throws (so callers run their subscribe / add-on UX off
220
+ * one set of types), and every other status as a `TusUploadError` carrying
221
+ * it and its `Retry-After`.
222
+ */
223
+ function failureOf(answer, outcome, request, context) {
224
+ return { error: errorOf(answer, outcome, request, context), outcome, unanswered: answer.unanswered !== null };
225
+ }
226
+ function errorOf(answer, outcome, request, context) {
227
+ if (answer.unanswered)
228
+ return answer.unanswered;
229
+ if (outcome.verdict === TRANSFER_VERDICT.REFUSED && outcome.coreCode !== null) {
230
+ return refusedError(answer.status, answer.envelope, outcome.coreCode, context, request.phase);
231
+ }
232
+ if (outcome.verdict === TRANSFER_VERDICT.GONE && request.gonePhase !== null) {
233
+ return sessionLostError(answer.status, context, request.gonePhase, null, outcome.coreCode);
234
+ }
235
+ if (outcome.verdict === TRANSFER_VERDICT.DEFERRED) {
236
+ return new TusUploadError(RECEIVED_PUBLISHING_MESSAGE, {
237
+ httpStatus: answer.status,
238
+ code: 'upload_received_publishing',
239
+ retryAfterMs: outcome.retryAfterMs,
240
+ ...context,
241
+ phase: 'finalize',
242
+ retryLater: true,
243
+ });
244
+ }
245
+ const gateError = gateErrorFromBody(answer.status, answer.envelope);
246
+ if (gateError)
247
+ return gateError;
248
+ return new TusUploadError(`${request.label} failed: ${answer.status} - ${answer.body.substring(0, 200)}`, {
249
+ httpStatus: answer.status,
250
+ retryAfterMs: outcome.retryAfterMs,
251
+ ...context,
252
+ phase: request.phase,
253
+ });
254
+ }
255
+ /**
256
+ * The session's expiry as its latest answer named it, reported to the
257
+ * caller (`onSessionExpiry`) each time it changes. An answer that names none
258
+ * leaves it unknown: the request may have moved it unread (a commit whose
259
+ * answer was lost, a deferred finalization), and a caller that dropped a
260
+ * session on an expiry Forge has since moved on would re-send a whole file.
261
+ * A request cut short by the caller's abort keeps the last named value: it
262
+ * can only have slid the expiry by that one request's duration.
263
+ */
264
+ class SessionExpiry {
265
+ constructor(known, report) {
266
+ this.known = known;
267
+ this.report = report;
268
+ }
269
+ /** Forge answered (or did not): the expiry is what the answer names. */
270
+ read(answer) {
271
+ this.become(parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)));
272
+ }
273
+ /**
274
+ * A request the OS carries on without JS is about to leave: it may commit
275
+ * chunks, each sliding the expiry, whose answers this upload never reads,
276
+ * possibly while the app is not running.
277
+ */
278
+ forget() {
279
+ this.become(null);
280
+ }
281
+ become(expiresAt) {
282
+ if (expiresAt === this.known)
283
+ return;
284
+ this.known = expiresAt;
285
+ this.report?.(expiresAt);
286
+ }
287
+ }
288
+ /** A backoff the caller's abort cuts short: resolves after `ms`, rejects as cancelled the moment the signal fires. */
289
+ function abortableDelay(ms, signal) {
290
+ return new Promise((resolve, reject) => {
291
+ if (signal?.aborted) {
292
+ reject(cancelledError());
293
+ return;
294
+ }
295
+ const onAbort = () => {
296
+ clearTimeout(timer);
297
+ reject(cancelledError());
298
+ };
299
+ const timer = setTimeout(() => {
300
+ signal?.removeEventListener('abort', onAbort);
301
+ resolve();
302
+ }, ms);
303
+ signal?.addEventListener('abort', onAbort, { once: true });
304
+ });
305
+ }
306
+ /**
307
+ * Resolve once the device is back online; reject as cancelled the moment the
308
+ * caller aborts, the wait's only other end.
309
+ */
310
+ function waitUntilOnline(connectivity, signal) {
311
+ return new Promise((resolve, reject) => {
312
+ let settled = false;
313
+ let unsubscribe = null;
314
+ const settle = (outcome) => {
315
+ if (settled)
316
+ return;
317
+ settled = true;
318
+ unsubscribe?.();
319
+ signal?.removeEventListener('abort', onAbort);
320
+ outcome();
321
+ };
322
+ const onAbort = () => settle(() => reject(cancelledError()));
323
+ if (signal?.aborted) {
324
+ onAbort();
325
+ return;
326
+ }
327
+ signal?.addEventListener('abort', onAbort, { once: true });
328
+ unsubscribe = connectivity.onOnline(() => settle(resolve));
329
+ if (settled) {
330
+ // The listener fired inside the subscribe, before its unsubscribe existed.
331
+ unsubscribe();
332
+ }
333
+ else if (connectivity.isOnline()) {
334
+ // The network came back between the caller's check and the subscribe,
335
+ // so its event would never arrive.
336
+ settle(resolve);
337
+ }
86
338
  });
87
339
  }
88
340
  /**
@@ -116,6 +368,28 @@ async function tusRequest(config, url, init, label, context = {}) {
116
368
  throw new TusUploadError(`${label} failed: ${getErrorMessage(err)}`, { cause, ...context, phase: 'network' });
117
369
  }
118
370
  }
371
+ /**
372
+ * Send a tus request through `tusRequest` and resolve with its answer: a
373
+ * response of any status, or no answer (a network failure or a timeout, as
374
+ * `tusRequest` classified it). The caller's abort and an SDK error still
375
+ * throw, because no retry follows either.
376
+ */
377
+ async function fetchAnswer(config, url, init, label, context) {
378
+ let response;
379
+ try {
380
+ response = await tusRequest(config, url, init, label, context);
381
+ }
382
+ catch (err) {
383
+ if (err instanceof TusUploadError && (err.phase === 'network' || err.phase === 'timeout')) {
384
+ return noAnswer(err);
385
+ }
386
+ throw err;
387
+ }
388
+ if (response.status === 0) {
389
+ return noAnswer(new TusUploadError(`${label} got no answer`, { ...context, phase: 'network' }));
390
+ }
391
+ return answerOf(response.status, name => response.headers.get(name), response.ok ? '' : await response.text().catch(() => ''));
392
+ }
119
393
  /**
120
394
  * Case-insensitive header lookup for adapter results. Native HTTP stacks
121
395
  * hand headers back in whatever case they please (iOS lower-cases them),
@@ -131,167 +405,82 @@ function getHeaderIgnoreCase(headers, name) {
131
405
  }
132
406
  return null;
133
407
  }
134
- function resolveSessionSource(options) {
408
+ function resolveSessionSource(options, allowedHostPatterns) {
135
409
  if (options.resume) {
136
- const istk = options.resume.istk ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl).istk : null);
410
+ const uploadUrl = readKeptSessionUrl(options.resume.uploadUrl, allowedHostPatterns);
411
+ const istk = options.resume.istk
412
+ ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl, allowedHostPatterns).istk : null);
137
413
  if (!istk) {
138
414
  throw new SparkVaultValidationError('resume.istk or forgeUrl is required to resume a TUS upload session');
139
415
  }
140
- return { mode: 'resume', uploadUrl: options.resume.uploadUrl, istk };
416
+ const expiresAt = options.resume.expiresAt ?? null;
417
+ if (expiresAt !== null && !Number.isFinite(expiresAt)) {
418
+ throw new SparkVaultValidationError('resume.expiresAt is an epoch-ms expiry or null', { received: expiresAt });
419
+ }
420
+ return { mode: 'resume', uploadUrl, istk, expiresAt };
141
421
  }
142
422
  if (!options.forgeUrl) {
143
423
  throw new SparkVaultValidationError('forgeUrl is required to create a TUS upload session');
144
424
  }
145
- return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl) };
146
- }
147
- async function createTusUpload(config, parsed, fileSize, filename, contentType, signal) {
148
- ensureNotAborted(signal);
149
- const metadata = `filename ${base64EncodeUtf8(filename)},filetype ${base64EncodeUtf8(contentType || 'application/octet-stream')}`;
150
- const response = await tusRequest(config, parsed.tusEndpoint, {
151
- method: 'POST',
152
- headers: {
153
- 'Tus-Resumable': TUS_VERSION,
154
- 'Upload-Length': String(fileSize),
155
- 'Upload-Metadata': metadata,
156
- 'X-ISTK': parsed.istk,
157
- },
158
- signal,
159
- timeoutMs: config.tusPostTimeoutMs,
160
- }, 'TUS POST request', { filename, fileSize });
161
- if (!response.ok) {
162
- const errorText = await response.text().catch(() => '');
163
- // A 402 here is a billable gate hit mid-create (e.g. storage/bandwidth pool
164
- // exhausted). Route it through the same typed-gate mapping as the JSON HTTP
165
- // path so callers handle subscribe / add-on UX off one set of error types.
166
- const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
167
- if (gateError) {
168
- throw gateError;
169
- }
170
- throw new TusUploadError(`TUS create failed: ${response.status} - ${errorText}`, {
171
- httpStatus: response.status,
172
- filename,
173
- fileSize,
174
- phase: 'create',
175
- });
176
- }
177
- const location = response.headers.get('Location');
178
- if (!location) {
179
- throw new TusUploadError('TUS create response missing Location header', {
180
- filename,
181
- fileSize,
425
+ return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl, allowedHostPatterns) };
426
+ }
427
+ /**
428
+ * The session a create's 2xx opened: where it lives, its chunk geometry and
429
+ * its expiry. Every chunk carries the file's bytes, so the session must live
430
+ * under the endpoint it was created at.
431
+ */
432
+ function readCreatedSession(answer, endpoint, context) {
433
+ const uploadUrl = resolveForgeSessionUrl(answer.header('Location'), endpoint);
434
+ if (!uploadUrl) {
435
+ throw new TusUploadError('TUS create response Location header is missing or outside the upload endpoint', {
436
+ ...context,
182
437
  phase: 'create',
183
438
  });
184
439
  }
185
- const uploadUrl = location.startsWith('http') ? location : `${parsed.baseUrl}${location}`;
186
440
  // Forge fixes the session's chunk geometry and rejects any non-final chunk
187
441
  // that is not exactly the negotiated size, so the chunk size is the server's
188
442
  // to dictate via `X-Chunk-Size` (mirrors sdk-js). A client-side guess could
189
443
  // only ever be rejected, so a missing header is a contract violation.
190
- const chunkSizeHeader = response.headers.get('X-Chunk-Size');
444
+ const chunkSizeHeader = answer.header('X-Chunk-Size');
191
445
  const chunkSize = parseIntegerHeader(chunkSizeHeader, 1);
192
446
  if (chunkSize === null) {
193
447
  throw new TusUploadError(`TUS create response missing or invalid X-Chunk-Size header (received ${JSON.stringify(chunkSizeHeader)})`, {
194
- filename,
195
- fileSize,
448
+ ...context,
196
449
  phase: 'create',
197
450
  });
198
451
  }
199
- return { uploadUrl, chunkSize };
452
+ return { uploadUrl, chunkSize, expiresAt: parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)) };
200
453
  }
201
454
  /**
202
- * Ask Forge where a session stands (tus HEAD). This is the only way to learn
203
- * what a background URLSession delivered after the process was killed, and
204
- * what a failed attempt left behind — so resume never re-sends bytes the
205
- * server holds. A status `isSessionGone` recognises means the session
206
- * expired or was terminated: the error carries `sessionLost` so the caller
207
- * drops its persisted session and creates a fresh one rather than retrying
208
- * a dead URL. Any other failure (a 5xx, an auth rejection, a timeout)
209
- * leaves `sessionLost` false: the
210
- * session may well be intact, and treating it as dead would re-upload a
211
- * whole file over a blip.
455
+ * Where a HEAD's 2xx says the session stands: offset, length and chunk
456
+ * geometry, all required (a 2xx without them is not Forge, a captive
457
+ * portal's page being the classic case), and the expiry when named. An
458
+ * offset past the length can only be a corrupt session.
212
459
  */
213
- async function headTusSession(config, uploadUrl, istk, signal, context = {}) {
214
- ensureNotAborted(signal);
215
- const response = await tusRequest(config, uploadUrl, {
216
- method: 'HEAD',
217
- headers: {
218
- 'Tus-Resumable': TUS_VERSION,
219
- 'X-ISTK': istk,
220
- },
221
- signal,
222
- timeoutMs: config.tusPostTimeoutMs,
223
- }, 'TUS HEAD request', context);
224
- if (isSessionGone(response.status)) {
225
- throw sessionLostError(response.status, context, 'create');
226
- }
227
- if (!response.ok) {
228
- const errorText = await response.text().catch(() => '');
229
- const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
230
- if (gateError) {
231
- throw gateError;
232
- }
233
- throw new TusUploadError(`TUS HEAD failed: ${response.status} - ${errorText}`, {
234
- httpStatus: response.status,
235
- ...context,
236
- phase: 'create',
237
- });
238
- }
239
- const offset = parseIntegerHeader(response.headers.get('Upload-Offset'), 0);
240
- const length = parseIntegerHeader(response.headers.get('Upload-Length'), 0);
241
- const chunkSize = parseIntegerHeader(response.headers.get('X-Chunk-Size'), 1);
460
+ function readSessionProbe(answer, phase, context) {
461
+ const offset = parseIntegerHeader(answer.header('Upload-Offset'), 0);
462
+ const length = parseIntegerHeader(answer.header('Upload-Length'), 0);
463
+ const chunkSize = parseIntegerHeader(answer.header('X-Chunk-Size'), 1);
242
464
  if (offset === null || length === null || chunkSize === null || offset > length) {
243
- throw new TusUploadError('TUS HEAD response missing or invalid Upload-Offset / Upload-Length / X-Chunk-Size headers', { ...context, phase: 'create' });
465
+ throw new TusUploadError('TUS HEAD response missing or invalid Upload-Offset / Upload-Length / X-Chunk-Size headers', { ...context, phase });
244
466
  }
245
- return { offset, length, chunkSize };
467
+ return { offset, length, chunkSize, expiresAt: parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)) };
246
468
  }
247
469
  /**
248
- * Confirm finalization of a session Forge already holds every byte of: a
249
- * zero-length PATCH at `Upload-Offset: fileSize`. Forge treats any PATCH at
250
- * or past the declared size as "finalize (again) and acknowledge" - it
251
- * re-runs a finalization that failed transiently after the last chunk
252
- * (Core/DynamoDB/billing), and answers 204 for one that already completed.
253
- * Without this a resume that finds the transfer complete would skip straight
254
- * to the status poll and could report a never-activated ingot as done.
255
- *
256
- * 409 means another finalizer holds the lease right now: retry later, the
257
- * session is intact. A status `isSessionGone` recognises means the session
258
- * is gone (`sessionLost`).
470
+ * A session offset the server named (a HEAD's, or a 409's `Upload-Offset`),
471
+ * checked before anything is sent from it: Forge commits whole chunks, so a
472
+ * session stands at a chunk boundary or at the total, never past it, and an
473
+ * answer that says otherwise is not one to resume from.
259
474
  */
260
- async function confirmTusFinalization(config, uploadUrl, istk, fileSize, signal, context = {}) {
261
- ensureNotAborted(signal);
262
- const response = await tusRequest(config, uploadUrl, {
263
- method: 'PATCH',
264
- headers: {
265
- 'Tus-Resumable': TUS_VERSION,
266
- 'Upload-Offset': String(fileSize),
267
- 'Content-Type': 'application/offset+octet-stream',
268
- 'X-ISTK': istk,
269
- },
270
- signal,
271
- timeoutMs: config.tusChunkTimeoutMs,
272
- }, 'TUS finalization request', context);
273
- if (isSessionGone(response.status)) {
274
- throw sessionLostError(response.status, context, 'create');
275
- }
276
- if (response.status === 409) {
277
- throw new TusUploadError('TUS finalization is already in progress on the server; retry shortly', {
278
- httpStatus: 409,
279
- ...context,
280
- phase: 'upload',
281
- });
282
- }
283
- if (!response.ok) {
284
- const errorText = await response.text().catch(() => '');
285
- const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
286
- if (gateError) {
287
- throw gateError;
288
- }
289
- throw new TusUploadError(`TUS finalization failed: ${response.status} - ${errorText.substring(0, 200)}`, {
290
- httpStatus: response.status,
475
+ function checkedServerOffset(offset, geometry, phase, context) {
476
+ const problem = resumeOffsetProblem(offset, { totalSize: geometry.fileSize, chunkSize: geometry.chunkSize });
477
+ if (offset === null || problem !== null) {
478
+ throw new TusUploadError(`Forge named an upload offset this upload cannot continue from (${offset}: ${problem})`, {
291
479
  ...context,
292
- phase: 'upload',
480
+ phase,
293
481
  });
294
482
  }
483
+ return offset;
295
484
  }
296
485
  /**
297
486
  * Hard ceiling on a single chunk PATCH regardless of progress. The stall
@@ -300,18 +489,23 @@ async function confirmTusFinalization(config, uploadUrl, istk, fileSize, signal,
300
489
  */
301
490
  const CHUNK_ABSOLUTE_CAP_MS = 60 * 60000;
302
491
  /**
303
- * PATCH one chunk. `stallTimeoutMs` is a STALL watchdog, not a deadline: it
304
- * fires only when no upload progress (and no response) arrives for that long.
305
- * Forge fixes non-final chunks at 50 MB and the client cannot shrink them, so
306
- * an absolute per-request timeout would kill every legitimately slow uplink
307
- * (a 50 MB chunk needs ~3.5 Mbps sustained to beat 120 s) and restart the
308
- * file from byte 0. The final PATCH also waits for Forge to finalize after the
309
- * last byte is sent; a progress-based watchdog gives that the same patience.
492
+ * PATCH one chunk over XHR and resolve with Forge's answer, or with no answer
493
+ * when the connection drops, the request times out or the chunk stalls. Only
494
+ * the caller's abort rejects. `stallTimeoutMs` is a STALL watchdog, not a
495
+ * deadline: it fires only when no upload progress (and no response) arrives
496
+ * for that long. Forge fixes non-final chunks at 50 MB and the client cannot
497
+ * shrink them, so an absolute per-request timeout would kill every
498
+ * legitimately slow uplink (a 50 MB chunk needs ~3.5 Mbps sustained to beat
499
+ * 120 s). The final PATCH also waits for Forge to finalize after the last
500
+ * byte is sent; a progress-based watchdog gives that the same patience.
501
+ * Bytes leaving are a sign of life: they restart the transfer's idle clock
502
+ * (never its failure count, which only the server's confirmation resets).
310
503
  */
311
- function uploadChunk(uploadUrl, istk, chunk, offset, stallTimeoutMs, signal, onChunkProgress) {
504
+ function sendChunkOverXhr(transfer, chunk, offset, stallTimeoutMs, onChunkProgress) {
312
505
  if (typeof XMLHttpRequest === 'undefined') {
313
506
  throw new SparkVaultValidationError('XMLHttpRequest is required for mobile TUS uploads');
314
507
  }
508
+ const { uploadUrl, istk, signal, context } = transfer;
315
509
  return new Promise((resolve, reject) => {
316
510
  const xhr = new XMLHttpRequest();
317
511
  let settled = false;
@@ -333,17 +527,18 @@ function uploadChunk(uploadUrl, istk, chunk, offset, stallTimeoutMs, signal, onC
333
527
  };
334
528
  const abort = () => {
335
529
  xhr.abort();
336
- finish(() => reject(new TusUploadError('Upload cancelled', { phase: 'cancelled' })));
530
+ finish(() => reject(cancelledError()));
337
531
  };
338
532
  if (signal?.aborted) {
339
533
  abort();
340
534
  return;
341
535
  }
342
536
  signal?.addEventListener('abort', abort, { once: true });
537
+ const unanswered = (message, phase) => finish(() => resolve(noAnswer(new TusUploadError(message, { ...context, phase }))));
343
538
  // Settle BEFORE aborting: the abort handler below would otherwise report
344
539
  // the stall as a user cancellation.
345
540
  const stall = (message) => {
346
- finish(() => reject(new TusUploadError(message, { phase: 'timeout' })));
541
+ unanswered(message, 'timeout');
347
542
  xhr.abort();
348
543
  };
349
544
  const armWatchdog = () => {
@@ -353,37 +548,22 @@ function uploadChunk(uploadUrl, istk, chunk, offset, stallTimeoutMs, signal, onC
353
548
  };
354
549
  xhr.upload.onprogress = event => {
355
550
  armWatchdog();
551
+ transfer.budget.alive();
356
552
  if (event.lengthComputable) {
357
553
  onChunkProgress?.(event.loaded);
358
554
  }
359
555
  };
360
556
  xhr.onload = () => {
361
- if (xhr.status >= 200 && xhr.status < 300) {
362
- // Forge answers every PATCH with Upload-Offset, so a 2xx without a
363
- // readable one is not Forge (a captive portal's 200 page is the
364
- // classic case): report no advance and let the stall guard fail the
365
- // attempt, rather than crediting bytes that may never have arrived.
366
- // parseInt would also turn a garbage header into NaN, which slips
367
- // through that guard (every comparison with NaN is false) and ends
368
- // the upload loop as a success.
369
- const newOffset = parseIntegerHeader(xhr.getResponseHeader('Upload-Offset'), 0) ?? offset;
370
- finish(() => resolve(newOffset));
371
- }
372
- else {
373
- const gateError = gateErrorFromBody(xhr.status, parseErrorBody(xhr.responseText));
374
- if (gateError) {
375
- finish(() => reject(gateError));
376
- return;
377
- }
378
- finish(() => reject(new TusUploadError(`Chunk upload failed: ${xhr.status} - ${xhr.responseText.substring(0, 200)}`, {
379
- httpStatus: xhr.status,
380
- phase: 'upload',
381
- })));
557
+ if (xhr.status === 0) {
558
+ unanswered('Chunk upload got no answer', 'network');
559
+ return;
382
560
  }
561
+ const answered = xhr.status >= 200 && xhr.status < 300;
562
+ finish(() => resolve(answerOf(xhr.status, name => xhr.getResponseHeader(name), answered ? '' : xhr.responseText)));
383
563
  };
384
- xhr.onerror = () => finish(() => reject(new TusUploadError('Chunk upload network error', { phase: 'network' })));
385
- xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { phase: 'cancelled' })));
386
- xhr.ontimeout = () => finish(() => reject(new TusUploadError('Chunk upload timed out', { phase: 'timeout' })));
564
+ xhr.onerror = () => unanswered('Chunk upload network error', 'network');
565
+ xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { ...context, phase: 'cancelled' })));
566
+ xhr.ontimeout = () => unanswered('Chunk upload timed out', 'timeout');
387
567
  xhr.open('PATCH', uploadUrl);
388
568
  xhr.setRequestHeader('Tus-Resumable', TUS_VERSION);
389
569
  xhr.setRequestHeader('Upload-Offset', String(offset));
@@ -400,56 +580,50 @@ function uploadChunk(uploadUrl, istk, chunk, offset, stallTimeoutMs, signal, onC
400
580
  }
401
581
  /**
402
582
  * PATCH a whole file as the session's only chunk on the app's background
403
- * URLSession. The OS owns the transfer, so it finishes even if iOS suspends
404
- * the app; the trade is no progress events and no abort. The result is mapped
405
- * exactly like the XHR path so callers see one error taxonomy.
583
+ * URLSession and resolve with Forge's answer, or with no answer when the
584
+ * adapter got none. The OS owns the transfer, so it finishes even if iOS
585
+ * suspends the app; the trade is no progress events and no abort. Its answer
586
+ * is read exactly like the XHR path's, so callers see one error taxonomy.
406
587
  */
407
- async function uploadWholeFileInBackground(uploader, uploadUrl, istk, fileUri, offset) {
588
+ async function sendWholeFileInBackground(uploader, transfer, fileUri, offset) {
408
589
  let result;
409
590
  try {
410
- result = await uploader.uploadFile(uploadUrl, fileUri, {
591
+ result = await uploader.uploadFile(transfer.uploadUrl, fileUri, {
411
592
  method: 'PATCH',
412
593
  headers: {
413
594
  'Tus-Resumable': TUS_VERSION,
414
595
  'Upload-Offset': String(offset),
415
596
  'Content-Type': 'application/offset+octet-stream',
416
- 'X-ISTK': istk,
597
+ 'X-ISTK': transfer.istk,
417
598
  },
418
599
  });
419
600
  }
420
601
  catch (err) {
421
602
  // The adapter only throws when no HTTP response came back (offline, DNS,
422
603
  // the OS cancelled the task); an HTTP failure is a status below.
423
- throw new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
604
+ return noAnswer(new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
424
605
  cause: err instanceof Error ? err : null,
606
+ ...transfer.context,
425
607
  phase: 'network',
426
- });
608
+ }));
427
609
  }
428
- const body = typeof result.body === 'string' ? result.body : '';
429
- if (result.status >= 200 && result.status < 300) {
430
- // Forge always answers a PATCH with a readable Upload-Offset. A 2xx
431
- // without one is not Forge (a captive portal's 200 page is the classic
432
- // case), so report no advance: the stall guard fails the attempt and the
433
- // next one asks Forge (HEAD) instead of marking bytes uploaded that may
434
- // never have arrived. Same strict parse as the XHR path (uploadChunk).
435
- return parseIntegerHeader(getHeaderIgnoreCase(result.headers, 'Upload-Offset'), 0) ?? offset;
436
- }
437
- const gateError = gateErrorFromBody(result.status, parseErrorBody(body));
438
- if (gateError) {
439
- throw gateError;
440
- }
441
- throw new TusUploadError(`Chunk upload failed: ${result.status} - ${body.substring(0, 200)}`, {
442
- httpStatus: result.status,
443
- phase: 'upload',
444
- });
610
+ if (!Number.isSafeInteger(result.status) || result.status <= 0) {
611
+ return noAnswer(new TusUploadError(`Background chunk upload got no answer (status ${String(result.status)})`, {
612
+ ...transfer.context,
613
+ phase: 'network',
614
+ }));
615
+ }
616
+ const answered = result.status >= 200 && result.status < 300;
617
+ return answerOf(result.status, name => getHeaderIgnoreCase(result.headers, name), !answered && typeof result.body === 'string' ? result.body : '');
445
618
  }
446
619
  /**
447
620
  * Translate what the OS transfer adapter reports into the SDK's one error
448
621
  * taxonomy, so a caller sees the same `TusUploadError` phases and typed gate
449
622
  * errors whichever engine carried the bytes. Forge's status and body ride
450
- * through untouched: a 402 becomes the gate error the XHR path throws, a
451
- * status `isSessionGone` recognises marks the session lost exactly as HEAD
452
- * would, and any other status keeps its number for the caller's own policy
623
+ * through untouched: the refusal Core decided is `upload_refused` in Core's
624
+ * words as on the XHR path, a 402 becomes the gate error the XHR path
625
+ * throws, a status `isSessionGone` recognises marks the session lost exactly
626
+ * as HEAD would, and any other status keeps its number for the caller's own policy
453
627
  * (a 409 during finalize is a retry-later, not a lost session). The
454
628
  * remaining kinds are transport
455
629
  * outcomes: the caller's own abort is `cancelled`; an OS-ended task
@@ -471,11 +645,19 @@ export function mapBackgroundTransferError(err, context = {}) {
471
645
  const status = err.httpStatus;
472
646
  const body = err.body ?? '';
473
647
  if (status !== undefined) {
474
- const gateError = gateErrorFromBody(status, parseErrorBody(body));
648
+ const envelope = parseErrorBody(body);
649
+ const outcome = Number.isSafeInteger(status) && status > 0
650
+ ? classifyTransferResponse({ status, details: envelope.error?.details ?? null })
651
+ : null;
652
+ if (outcome?.verdict === TRANSFER_VERDICT.REFUSED && outcome.coreCode !== null) {
653
+ return refusedError(status, envelope, outcome.coreCode, context, 'upload', err);
654
+ }
655
+ const gateError = gateErrorFromBody(status, envelope);
475
656
  if (gateError)
476
657
  return gateError;
477
- if (isSessionGone(status))
478
- return sessionLostError(status, context, 'upload', err);
658
+ if (outcome?.verdict === TRANSFER_VERDICT.GONE) {
659
+ return sessionLostError(status, context, 'upload', err, outcome.coreCode);
660
+ }
479
661
  }
480
662
  return new TusUploadError(`Background chunk upload failed: ${status ?? 'unknown status'} - ${body.substring(0, 200)}`, { cause: err, httpStatus: status ?? null, ...context, phase: 'upload' });
481
663
  }
@@ -528,61 +710,97 @@ export class MobileTusUploader {
528
710
  constructor(config) {
529
711
  this.config = config;
530
712
  }
713
+ /**
714
+ * Upload a file over tus: create the session (or resume a kept one), send
715
+ * the bytes Forge does not hold, and resolve once Forge has confirmed the
716
+ * ingot published.
717
+ *
718
+ * Every answer is read through the platform's transfer policy
719
+ * (`@sparkvault/app-sdk/transfer-retry`), and one budget per call bounds
720
+ * every retry, the create's included: a request that got no answer, 408,
721
+ * 425, 429, a 5xx or a lease-held 409 waits its backoff (never shorter than
722
+ * `Retry-After`), then asks Forge where the session stands (HEAD) and sends
723
+ * from there; a 409 carrying `Upload-Offset` continues from that offset at
724
+ * once; a receipt Forge defers (503 with `Retry-After`) is asked again,
725
+ * each time after a longer backoff, without spending a failure until the
726
+ * idle limit ends the upload as `upload_received_publishing`
727
+ * (`retryLater`); a gone session is `sessionLost` (`coreCode` naming the
728
+ * code Core ended it with, when the answer named one); a final refusal of
729
+ * Core's (`UPLOAD_POOL_FULL`, `UPLOAD_SUPERSEDED`) is `upload_refused` in
730
+ * Core's words (`coreCode`); any other 4xx ends the upload with the
731
+ * server's answer. No chunk is ever sent at an offset the server did not just name. While the device is offline (`isOnline`), a request that got
732
+ * no answer spends nothing: the upload waits for the network. The OS
733
+ * transfer engine's range is the exception: the engine chains its own
734
+ * requests, and its failure ends the call for the caller's own retry.
735
+ */
531
736
  async uploadFromUri(options) {
532
737
  const fileReader = this.config.fileReader;
533
738
  if (!fileReader) {
534
739
  throw new SparkVaultValidationError('fileReader adapter is required for URI uploads');
535
740
  }
536
- const source = resolveSessionSource(options);
741
+ const source = resolveSessionSource(options, this.config.allowedDownloadHostPatterns);
537
742
  const istk = source.mode === 'create' ? source.parsed.istk : source.istk;
538
743
  const debug = options.debug;
539
744
  const cleanUri = options.fileUri.split('#')[0];
540
- let bytesUploaded = 0;
745
+ const signal = options.abortSignal;
746
+ const fileSize = options.fileSize;
747
+ const context = { filename: options.filename, fileSize };
748
+ const budget = createTransferBudget();
541
749
  // Known up front on resume so a cancel during the HEAD still terminates
542
750
  // the (existing) session.
543
751
  let uploadUrl = source.mode === 'resume' ? source.uploadUrl : null;
752
+ // The last offset the caller heard, so the end of the upload reports 100%
753
+ // exactly once however the last byte was confirmed.
754
+ let reported = null;
755
+ const report = (uploaded) => {
756
+ reported = uploaded;
757
+ options.onProgress?.(uploaded, fileSize);
758
+ };
544
759
  try {
545
- let chunkSize;
546
- let sessionUrl;
760
+ let transfer;
761
+ let position;
547
762
  if (source.mode === 'resume') {
548
- sessionUrl = source.uploadUrl;
549
763
  debug?.log(`Resuming TUS upload for ${options.filename}`);
550
- const probe = await headTusSession(this.config, source.uploadUrl, istk, options.abortSignal, {
551
- filename: options.filename,
552
- fileSize: options.fileSize,
553
- });
554
- // A session is bound to one file: a different length means the
555
- // persisted session belongs to another file (or this file changed),
556
- // and PATCHing into it would corrupt the ingot. That makes the
557
- // session worthless for this file, which is exactly what
558
- // `sessionLost` means: the caller drops it and starts fresh, the same
559
- // as for a 404/410, instead of retrying a resume that can never work.
560
- if (probe.length !== options.fileSize) {
561
- throw new TusUploadError(`TUS session Upload-Length (${probe.length}) does not match the file size (${options.fileSize}); the session belongs to a different file`, { filename: options.filename, fileSize: options.fileSize, phase: 'create', sessionLost: true });
562
- }
563
- chunkSize = probe.chunkSize;
564
- bytesUploaded = probe.offset;
565
- debug?.log(`TUS session for ${options.filename} is at offset ${bytesUploaded}/${options.fileSize}`);
566
- if (bytesUploaded >= options.fileSize) {
567
- // Every byte is there, but the finalization the last chunk should
568
- // have triggered may not have happened (that is often why the
569
- // previous attempt failed): ask Forge to finalize and acknowledge.
570
- await confirmTusFinalization(this.config, sessionUrl, istk, options.fileSize, options.abortSignal, {
571
- filename: options.filename,
572
- fileSize: options.fileSize,
764
+ // A session past the expiry Forge named for it is gone: Forge refuses
765
+ // every request to it, so asking would only spend one to learn that.
766
+ const heldExpiry = source.expiresAt;
767
+ if (heldExpiry !== null && isSessionExpired(heldExpiry)) {
768
+ throw new TusUploadError(`TUS session expired at ${new Date(heldExpiry).toISOString()}`, {
769
+ ...context,
770
+ phase: 'create',
771
+ sessionLost: true,
573
772
  });
574
- options.onProgress?.(bytesUploaded, options.fileSize);
575
773
  }
774
+ const expiry = new SessionExpiry(source.expiresAt, options.onSessionExpiry);
775
+ const link = { uploadUrl: source.uploadUrl, istk, context, expiry, signal };
776
+ const probe = await this.readResumePoint(link, budget, fileSize);
777
+ transfer = { ...link, expiry, fileSize, chunkSize: probe.chunkSize, budget };
778
+ position = { offset: checkedServerOffset(probe.offset, transfer, 'create', context), published: false };
779
+ debug?.log(`TUS session for ${options.filename} is at offset ${position.offset}/${fileSize}`);
576
780
  }
577
781
  else {
578
782
  debug?.log(`Starting TUS upload for ${options.filename}`);
579
- const created = await createTusUpload(this.config, source.parsed, options.fileSize, options.filename, options.contentType, options.abortSignal);
580
- sessionUrl = created.uploadUrl;
581
- uploadUrl = sessionUrl;
582
- chunkSize = created.chunkSize;
783
+ const created = await this.createSession(source.parsed, fileSize, budget, context, signal);
784
+ uploadUrl = created.uploadUrl;
785
+ transfer = {
786
+ uploadUrl: created.uploadUrl,
787
+ istk,
788
+ context,
789
+ expiry: new SessionExpiry(created.expiresAt, options.onSessionExpiry),
790
+ signal,
791
+ fileSize,
792
+ chunkSize: created.chunkSize,
793
+ budget,
794
+ };
795
+ position = { offset: 0, published: false };
583
796
  // Before the first PATCH: once bytes are in flight the session is
584
797
  // worth resuming, so the caller must already hold it.
585
- options.onSessionCreated?.({ uploadUrl: sessionUrl, istk, chunkSize });
798
+ options.onSessionCreated?.({
799
+ uploadUrl: created.uploadUrl,
800
+ istk,
801
+ chunkSize: created.chunkSize,
802
+ expiresAt: created.expiresAt,
803
+ });
586
804
  }
587
805
  // The OS transfer engine, when the app provides one, takes the whole
588
806
  // remaining range in a single hand-off: it chains the chunks itself and
@@ -593,65 +811,53 @@ export class MobileTusUploader {
593
811
  // that resumes the same session re-joins the job the OS is still
594
812
  // running instead of PATCHing over it.
595
813
  const backgroundTransfer = options.transport === 'background' ? this.config.backgroundTransfer : undefined;
596
- if (backgroundTransfer && bytesUploaded < options.fileSize) {
597
- ensureNotAborted(options.abortSignal);
814
+ if (backgroundTransfer && position.offset < fileSize) {
815
+ ensureNotAborted(signal);
816
+ transfer.expiry.forget();
598
817
  const policy = options.transferPolicy ?? {};
599
818
  const finalOffset = await transferRemainderInBackground(backgroundTransfer, {
600
- uploadUrl: sessionUrl,
819
+ uploadUrl: transfer.uploadUrl,
601
820
  istk,
602
821
  fileUri: cleanUri,
603
- fileSize: options.fileSize,
604
- chunkSize,
605
- offset: bytesUploaded,
822
+ fileSize,
823
+ chunkSize: transfer.chunkSize,
824
+ offset: position.offset,
606
825
  allowsCellularAccess: policy.allowsCellularAccess ?? true,
607
826
  allowsConstrainedNetworkAccess: policy.allowsConstrainedNetworkAccess ?? true,
608
- }, { filename: options.filename, abortSignal: options.abortSignal, onProgress: options.onProgress });
827
+ }, { filename: options.filename, abortSignal: signal, onProgress: offset => report(offset) });
609
828
  // The engine only resolves at the end of the file; anything else is
610
829
  // an engine that gave up quietly, and an offset past the file is as
611
830
  // untrustworthy as one short of it. Same treatment as a non-advancing
612
831
  // PATCH: fail, and let the next attempt ask Forge (HEAD) where the
613
832
  // session really stands.
614
- if (finalOffset !== options.fileSize) {
615
- throw new TusUploadError(`Upload stalled: background transfer ended at offset ${finalOffset} of ${options.fileSize}`, { phase: 'stalled', filename: options.filename, fileSize: options.fileSize });
833
+ if (finalOffset !== fileSize) {
834
+ throw new TusUploadError(`Upload stalled: background transfer ended at offset ${finalOffset} of ${fileSize}`, { ...context, phase: 'stalled' });
616
835
  }
617
- // An abort that lands after the OS delivered the last byte changes
618
- // nothing: the transfer is complete, and the loop below (with its
619
- // abort check) has nothing left to do. Same rule as the single-chunk
620
- // adapter: a finished upload is never reported as cancelled.
621
- bytesUploaded = finalOffset;
622
- options.onProgress?.(bytesUploaded, options.fileSize);
836
+ // The engine's last PATCH was answered 2xx at the final offset, which
837
+ // Forge answers only once the ingot is published. An abort that lands
838
+ // after the OS delivered the last byte changes nothing: the transfer
839
+ // is complete, and the loop below (with its abort check) has nothing
840
+ // left to do. Same rule as the single-chunk adapter: a finished
841
+ // upload is never reported as cancelled.
842
+ position = { offset: fileSize, published: true };
623
843
  }
624
- while (bytesUploaded < options.fileSize) {
625
- ensureNotAborted(options.abortSignal);
626
- const chunkStart = bytesUploaded;
627
- const chunkLength = Math.min(chunkSize, options.fileSize - chunkStart);
628
- let newOffset;
629
- const backgroundUploader = options.transport === 'background' ? this.config.backgroundUploader : undefined;
630
- if (backgroundUploader && this.fitsOneBackgroundRequest(chunkStart, options.fileSize, chunkSize)) {
631
- // The OS owns this request, so an abort that fires while it is in
632
- // flight cannot stop it. It only ever carries a whole single-chunk
633
- // file, so a 2xx here means the transfer is complete: the loop
634
- // ends and the upload finishes normally (the caller verifies the
635
- // ingot), because a finished upload must never be reported as
636
- // cancelled, and a DELETE now would drop bytes Forge has already
637
- // finalized. Only a response that leaves bytes missing reaches
638
- // the `ensureNotAborted` at the top of the next iteration.
639
- newOffset = await uploadWholeFileInBackground(backgroundUploader, sessionUrl, istk, cleanUri, chunkStart);
640
- }
641
- else {
642
- const chunk = await this.readChunk(fileReader, cleanUri, chunkStart, chunkLength);
643
- newOffset = await uploadChunk(sessionUrl, istk, chunk, chunkStart, this.config.tusChunkTimeoutMs, options.abortSignal, chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize));
644
- }
645
- if (newOffset <= chunkStart || newOffset > options.fileSize) {
646
- throw new TusUploadError('Upload stalled: server offset did not advance', {
647
- phase: 'stalled',
648
- filename: options.filename,
649
- fileSize: options.fileSize,
650
- });
651
- }
652
- bytesUploaded = newOffset;
653
- options.onProgress?.(bytesUploaded, options.fileSize);
844
+ const chunks = {
845
+ reader: fileReader,
846
+ uri: cleanUri,
847
+ transport: options.transport,
848
+ onChunkProgress: (offset, chunkBytesUploaded) => report(offset + chunkBytesUploaded),
849
+ };
850
+ while (!position.published) {
851
+ ensureNotAborted(signal);
852
+ const before = position.offset;
853
+ position = position.offset < fileSize
854
+ ? await this.sendChunkAt(transfer, position.offset, chunks)
855
+ : await this.confirmReceipt(transfer);
856
+ if (position.offset > before)
857
+ report(position.offset);
654
858
  }
859
+ if (reported !== fileSize)
860
+ report(fileSize);
655
861
  debug?.success(`TUS upload completed for ${options.filename}`);
656
862
  }
657
863
  catch (err) {
@@ -680,10 +886,11 @@ export class MobileTusUploader {
680
886
  // in a generic TusUploadError.
681
887
  if (err instanceof SparkVaultMobileError)
682
888
  throw err;
683
- // Only a fileReader read or an onSessionCreated hook can throw anything
684
- // else here: every request classifies its own failure (tusRequest,
685
- // uploadChunk, the adapters). Those are the caller's errors, wrapped
686
- // with the file context and nothing inferred about them.
889
+ // Only a fileReader read or a caller's hook (onSessionCreated,
890
+ // onSessionExpiry, onProgress) can throw anything else here: every
891
+ // request classifies its own failure (tusRequest, the chunk senders,
892
+ // the adapters). Those are the caller's errors, wrapped with the file
893
+ // context and nothing inferred about them.
687
894
  throw TusUploadError.fromError(error, {
688
895
  filename: options.filename,
689
896
  fileSize: options.fileSize,
@@ -691,25 +898,303 @@ export class MobileTusUploader {
691
898
  }
692
899
  }
693
900
  /**
694
- * Where a persisted session stands, or `null` when Forge no longer has it
695
- * (404/410 — expired or terminated). Any other failure throws with
696
- * `sessionLost` false (a 5xx or timeout says nothing about the session).
697
- * The probe reports the session's own length; whether that length is the
698
- * caller's file is for the caller (or `uploadFromUri`'s resume, which
699
- * throws `sessionLost` on a mismatch) to judge. Lets a caller reconcile
700
- * sessions on relaunch (a completed one just needs its ingot verified; a
701
- * lost one needs a fresh create) before spending an upload slot on it.
901
+ * Open the upload's session. A create the transfer policy calls RETRY (no
902
+ * answer, 408, 425, 429, a 5xx) is sent again under the upload's budget,
903
+ * its backoff never shorter than `Retry-After`: Forge refuses such a create
904
+ * before it claims the token, or hands the claim back on a transient
905
+ * key-service failure (503), so the same ISTK opens the session on a later
906
+ * attempt. Every other answer is final and never a lost session, a 401
907
+ * included: the token was already used, by an earlier create whose answer
908
+ * was lost, and no Location survives to continue that session from. No
909
+ * session URL exists yet to spread the retries by, and the ISTK never feeds
910
+ * one, so they spread by a key of the endpoint (`transferJitterKey`).
702
911
  */
703
- async probeSession(session) {
704
- try {
705
- return await headTusSession(this.config, session.uploadUrl, session.istk);
912
+ async createSession(parsed, fileSize, budget, context, signal) {
913
+ const jitterKey = transferJitterKey(parsed.tusEndpoint);
914
+ for (;;) {
915
+ ensureNotAborted(signal);
916
+ // Forge's create contract is these three headers: the ingot's name and
917
+ // type were set by Core when the ingot was created, and Forge publishes
918
+ // Core's.
919
+ const answer = await fetchAnswer(this.config, parsed.tusEndpoint, {
920
+ method: 'POST',
921
+ headers: {
922
+ 'Tus-Resumable': TUS_VERSION,
923
+ 'Upload-Length': String(fileSize),
924
+ 'X-ISTK': parsed.istk,
925
+ },
926
+ signal,
927
+ timeoutMs: this.config.tusPostTimeoutMs,
928
+ }, 'TUS POST request', context);
929
+ const outcome = readOutcome(answer, CREATE_REQUEST);
930
+ if (outcome.verdict === TRANSFER_VERDICT.OK) {
931
+ const created = readCreatedSession(answer, parsed.tusEndpoint, context);
932
+ // The session exists: the server confirmed progress.
933
+ budget.progressed();
934
+ return created;
935
+ }
936
+ const failure = failureOf(answer, outcome, CREATE_REQUEST, context);
937
+ if (outcome.verdict !== TRANSFER_VERDICT.RETRY)
938
+ throw failure.error;
939
+ await this.waitToRetry(budget, failure, jitterKey, signal);
706
940
  }
707
- catch (err) {
708
- if (err instanceof TusUploadError && err.sessionLost)
709
- return null;
710
- throw err;
941
+ }
942
+ /**
943
+ * Where a kept session stands, asked of Forge (HEAD) under the upload's
944
+ * budget, so resume never re-sends bytes the server holds: it is the only
945
+ * way to learn what a background transfer delivered after the process was
946
+ * killed, and what a failed attempt left behind. A HEAD that gets no
947
+ * answer or a transient one is asked again after its backoff; a gone
948
+ * session is `sessionLost`, so the caller drops it and creates a fresh one
949
+ * rather than retrying a dead URL. A session is bound to one file: a length that
950
+ * is not this file's means the persisted session belongs to another file
951
+ * (or this file changed), and PATCHing into it would corrupt the ingot.
952
+ * That makes it as worthless as a gone one: `sessionLost` too.
953
+ */
954
+ async readResumePoint(link, budget, fileSize) {
955
+ for (;;) {
956
+ const head = await this.headSession(link, RESUME_HEAD_REQUEST);
957
+ if ('failure' in head) {
958
+ await this.waitToRetry(budget, head.failure, link.uploadUrl, link.signal);
959
+ continue;
960
+ }
961
+ if (head.probe.length !== fileSize) {
962
+ throw new TusUploadError(`TUS session Upload-Length (${head.probe.length}) does not match the file size (${fileSize}); the session belongs to a different file`, { ...link.context, phase: 'create', sessionLost: true });
963
+ }
964
+ return head.probe;
711
965
  }
712
966
  }
967
+ /**
968
+ * Ask Forge once where the session stands (tus HEAD): the probe on a 2xx,
969
+ * or the failure its answer amounts to. Forge re-drives a received
970
+ * upload's publication on its HEAD and answers the full offset whatever the
971
+ * re-drive came to, so an offset at the total proves receipt only: the
972
+ * receipt PATCH (`confirmReceipt`) is what says published.
973
+ */
974
+ async headSession(link, request) {
975
+ ensureNotAborted(link.signal);
976
+ const answer = await fetchAnswer(this.config, link.uploadUrl, {
977
+ method: 'HEAD',
978
+ headers: {
979
+ 'Tus-Resumable': TUS_VERSION,
980
+ 'X-ISTK': link.istk,
981
+ },
982
+ signal: link.signal,
983
+ timeoutMs: HEAD_AND_RECEIPT_TIMEOUT_MS,
984
+ }, 'TUS HEAD request', link.context);
985
+ link.expiry?.read(answer);
986
+ const outcome = readOutcome(answer, request);
987
+ if (outcome.verdict === TRANSFER_VERDICT.OK) {
988
+ return { probe: readSessionProbe(answer, request.phase, link.context) };
989
+ }
990
+ return { failure: failureOf(answer, outcome, request, link.context) };
991
+ }
992
+ /**
993
+ * Send the chunk that starts at `offset`. Answers where the upload stands
994
+ * next: past the chunk on its 2xx, which for the last chunk is also the
995
+ * publication receipt (Forge answers it only once the ingot is published),
996
+ * or wherever the server says after a failure.
997
+ */
998
+ async sendChunkAt(transfer, offset, chunks) {
999
+ const answer = await this.sendChunk(transfer, offset, chunks);
1000
+ transfer.expiry.read(answer);
1001
+ const outcome = readOutcome(answer, CHUNK_REQUEST);
1002
+ if (outcome.verdict !== TRANSFER_VERDICT.OK) {
1003
+ return this.positionAfterFailure(transfer, failureOf(answer, outcome, CHUNK_REQUEST, transfer.context), offset);
1004
+ }
1005
+ // Forge answers every PATCH with Upload-Offset, so a 2xx without a
1006
+ // readable one is not Forge (a captive portal's 200 page is the classic
1007
+ // case): it reports no advance, and the stall guard fails the attempt
1008
+ // rather than crediting bytes that may never have arrived. The parse is
1009
+ // strict because parseInt would turn a garbage header into NaN, which
1010
+ // slips through the guard (every comparison with NaN is false) and would
1011
+ // end the upload as a success; an offset past the file is as
1012
+ // untrustworthy as one short of it.
1013
+ const confirmed = parseIntegerHeader(answer.header('Upload-Offset'), 0) ?? offset;
1014
+ if (confirmed <= offset || confirmed > transfer.fileSize) {
1015
+ throw new TusUploadError('Upload stalled: server offset did not advance', {
1016
+ ...transfer.context,
1017
+ phase: 'stalled',
1018
+ });
1019
+ }
1020
+ transfer.budget.progressed();
1021
+ return { offset: confirmed, published: confirmed === transfer.fileSize };
1022
+ }
1023
+ /** PATCH the chunk at `offset` on the transport the upload rides, resolving with Forge's answer. */
1024
+ async sendChunk(transfer, offset, chunks) {
1025
+ const backgroundUploader = chunks.transport === 'background' ? this.config.backgroundUploader : undefined;
1026
+ if (backgroundUploader && this.fitsOneBackgroundRequest(offset, transfer.fileSize, transfer.chunkSize)) {
1027
+ // The OS owns this request, so an abort that fires while it is in
1028
+ // flight cannot stop it, and it may finish while the app is away. It
1029
+ // only ever carries a whole single-chunk file, so a 2xx here means the
1030
+ // transfer is complete: the loop ends and the upload finishes normally
1031
+ // (the caller verifies the ingot), because a finished upload must never
1032
+ // be reported as cancelled, and a DELETE now would drop bytes Forge has
1033
+ // already finalized. Only an answer that leaves bytes missing reaches
1034
+ // the abort check before the next request.
1035
+ transfer.expiry.forget();
1036
+ return sendWholeFileInBackground(backgroundUploader, transfer, chunks.uri, offset);
1037
+ }
1038
+ const chunkLength = Math.min(transfer.chunkSize, transfer.fileSize - offset);
1039
+ const chunk = await this.readChunk(chunks.reader, chunks.uri, offset, chunkLength);
1040
+ return sendChunkOverXhr(transfer, chunk, offset, this.config.tusChunkTimeoutMs, chunkBytesUploaded => chunks.onChunkProgress(offset, chunkBytesUploaded));
1041
+ }
1042
+ /**
1043
+ * Where the upload stands after a failed chunk PATCH, in the server's
1044
+ * words: a 409's own `Upload-Offset` at once, otherwise the session's HEAD
1045
+ * after the backoff, the HEAD itself retried the same way out of the same
1046
+ * budget. A PATCH whose answer was lost may have landed, and one sent at a
1047
+ * stale offset is refused, so no chunk is ever sent again at an offset the
1048
+ * server did not just name.
1049
+ */
1050
+ async positionAfterFailure(transfer, failure, sentOffset) {
1051
+ let current = failure;
1052
+ for (;;) {
1053
+ if (current.outcome.verdict === TRANSFER_VERDICT.RESYNC) {
1054
+ return this.resyncedPosition(transfer, current, sentOffset);
1055
+ }
1056
+ await this.waitToRetry(transfer.budget, current, transfer.uploadUrl, transfer.signal);
1057
+ const head = await this.headSession(transfer, OFFSET_HEAD_REQUEST);
1058
+ if ('failure' in head) {
1059
+ current = head.failure;
1060
+ continue;
1061
+ }
1062
+ if (head.probe.length !== transfer.fileSize) {
1063
+ throw new TusUploadError(`TUS session Upload-Length (${head.probe.length}) does not match the file size (${transfer.fileSize}); the session belongs to a different file`, { ...transfer.context, phase: 'upload', sessionLost: true });
1064
+ }
1065
+ const offset = checkedServerOffset(head.probe.offset, transfer, 'upload', transfer.context);
1066
+ // Bytes reached the server despite the failure, so the transfer is
1067
+ // alive: the budget starts over rather than being spent down across
1068
+ // the many chunks of a large file.
1069
+ if (offset > sentOffset)
1070
+ transfer.budget.progressed();
1071
+ return { offset, published: false };
1072
+ }
1073
+ }
1074
+ /**
1075
+ * Continue from the offset a 409 named, at once and without a HEAD: the
1076
+ * server just said where the session stands. Past what this upload sent,
1077
+ * the server holds more than it knew, which is progress; anywhere else the
1078
+ * answer contradicts the request, so it spends the budget like any failure
1079
+ * and repeating it ends the upload.
1080
+ */
1081
+ resyncedPosition(transfer, failure, sentOffset) {
1082
+ const offset = checkedServerOffset(failure.outcome.offset, transfer, 'upload', transfer.context);
1083
+ if (offset > sentOffset) {
1084
+ transfer.budget.progressed();
1085
+ }
1086
+ else if (!transfer.budget.failed(TRANSFER_VERDICT.RESYNC)) {
1087
+ throw failure.error;
1088
+ }
1089
+ return { offset, published: false };
1090
+ }
1091
+ /**
1092
+ * Every byte is on the server but publication is unconfirmed: the final
1093
+ * chunk's answer was lost or deferred, or a HEAD's offset proved receipt
1094
+ * only. A zero-length PATCH at the final offset re-drives publication if it
1095
+ * did not finish (a transient Core or billing failure after the last chunk)
1096
+ * and answers 204 once the ingot is published, which is the receipt every
1097
+ * upload resolves on. While another request finalizes it answers 409 with
1098
+ * `Retry-After`, asked again as a failure; while the publication is
1099
+ * deferred, 503 with `Retry-After`, asked again, each time after a longer
1100
+ * backoff, without spending a failure until the idle limit ends the upload
1101
+ * as `upload_received_publishing` (`retryLater`); a 503 naming no
1102
+ * `Retry-After` is no deferral of Forge's, asked again as a failure. A 409
1103
+ * naming an earlier offset means bytes are missing, and the upload
1104
+ * continues from there. Forge answers it only after its own call to Core,
1105
+ * so it waits `HEAD_AND_RECEIPT_TIMEOUT_MS`, as a HEAD does.
1106
+ */
1107
+ async confirmReceipt(transfer) {
1108
+ ensureNotAborted(transfer.signal);
1109
+ const answer = await fetchAnswer(this.config, transfer.uploadUrl, {
1110
+ method: 'PATCH',
1111
+ headers: {
1112
+ 'Tus-Resumable': TUS_VERSION,
1113
+ 'Upload-Offset': String(transfer.fileSize),
1114
+ 'Content-Type': 'application/offset+octet-stream',
1115
+ 'X-ISTK': transfer.istk,
1116
+ },
1117
+ signal: transfer.signal,
1118
+ timeoutMs: HEAD_AND_RECEIPT_TIMEOUT_MS,
1119
+ }, 'TUS finalization request', transfer.context);
1120
+ transfer.expiry.read(answer);
1121
+ const outcome = readOutcome(answer, RECEIPT_REQUEST);
1122
+ if (outcome.verdict === TRANSFER_VERDICT.OK) {
1123
+ return { offset: transfer.fileSize, published: true };
1124
+ }
1125
+ const failure = failureOf(answer, outcome, RECEIPT_REQUEST, transfer.context);
1126
+ if (outcome.verdict === TRANSFER_VERDICT.RESYNC) {
1127
+ return this.resyncedPosition(transfer, failure, transfer.fileSize);
1128
+ }
1129
+ await this.waitToRetry(transfer.budget, failure, transfer.uploadUrl, transfer.signal);
1130
+ return { offset: transfer.fileSize, published: false };
1131
+ }
1132
+ /**
1133
+ * Wait out the backoff of a failed request, spending a retry of the
1134
+ * upload's budget, or of a deferred receipt, spending none for as long as
1135
+ * the idle limit allows; never shorter than the answer's `Retry-After`.
1136
+ * Throws the failure no retry follows (a gone session, the server's
1137
+ * verdict), and the last one, or the deferral, once the budget is spent. A
1138
+ * request that got no answer while the device is offline spends nothing:
1139
+ * the upload waits for the network (or the caller's abort), the idle clock
1140
+ * starts over when it is back, and the next request asks again at once.
1141
+ */
1142
+ async waitToRetry(budget, failure, jitterKey, signal) {
1143
+ const { verdict, retryAfterMs } = failure.outcome;
1144
+ if (verdict === TRANSFER_VERDICT.DEFERRED) {
1145
+ if (!budget.deferred(retryAfterMs))
1146
+ throw failure.error;
1147
+ await abortableDelay(retryDelayMs({ attempt: budget.attempt, retryAfterMs, jitterKey }), signal);
1148
+ return;
1149
+ }
1150
+ if (verdict !== TRANSFER_VERDICT.RETRY) {
1151
+ throw failure.error;
1152
+ }
1153
+ const connectivity = this.config.connectivity;
1154
+ if (failure.unanswered && connectivity && !connectivity.isOnline()) {
1155
+ await waitUntilOnline(connectivity, signal);
1156
+ budget.alive();
1157
+ return;
1158
+ }
1159
+ if (!budget.failed(verdict, retryAfterMs)) {
1160
+ throw failure.error;
1161
+ }
1162
+ const delayMs = retryDelayMs({
1163
+ attempt: budget.attempt,
1164
+ retryAfterMs,
1165
+ jitterKey,
1166
+ });
1167
+ await abortableDelay(delayMs, signal);
1168
+ }
1169
+ /**
1170
+ * Where a persisted session stands, or `null` when Forge no longer has it
1171
+ * (404/410 — expired or terminated), from one HEAD and no retry. Any other
1172
+ * failure throws with `sessionLost` false (a 5xx or a timeout says nothing
1173
+ * against the session), carrying the answer's `retryAfterMs`; a final
1174
+ * refusal Core decided on the HEAD's re-drive (`UPLOAD_POOL_FULL` or
1175
+ * `UPLOAD_SUPERSEDED` in `X-Upload-Refusal`) throws as `upload_refused`,
1176
+ * which no fresh create remedies, and any other code Core ended the session
1177
+ * with reads as gone (null). An offset at
1178
+ * the session's length proves every byte received, not published: a
1179
+ * resume of it sends the receipt. The probe reports the session's own length;
1180
+ * whether that length is the caller's file is for the caller (or
1181
+ * `uploadFromUri`'s resume, which throws `sessionLost` on a mismatch) to
1182
+ * judge. Lets a caller reconcile sessions on relaunch (a completed one just
1183
+ * needs its ingot verified; a lost one needs a fresh create) before
1184
+ * spending an upload slot on it.
1185
+ *
1186
+ * @throws SparkVaultValidationError, before any request, when `uploadUrl`
1187
+ * is not an upload session on an allowed transfer host.
1188
+ */
1189
+ async probeSession(session) {
1190
+ const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
1191
+ const head = await this.headSession({ uploadUrl, istk: session.istk, context: {}, expiry: null }, RESUME_HEAD_REQUEST);
1192
+ if ('probe' in head)
1193
+ return head.probe;
1194
+ if (head.failure.outcome.verdict === TRANSFER_VERDICT.GONE)
1195
+ return null;
1196
+ throw head.failure.error;
1197
+ }
713
1198
  /**
714
1199
  * Whether the remaining transfer is ONE PATCH the background adapter can
715
1200
  * send: nothing uploaded yet and the whole file inside a single chunk. The
@@ -732,16 +1217,20 @@ export class MobileTusUploader {
732
1217
  * Forge sent) so the caller can retry later. Not bound to any
733
1218
  * abort signal: a terminate is the last thing a caller does with a
734
1219
  * session, never something to cancel.
1220
+ *
1221
+ * @throws SparkVaultValidationError, before any request, when `uploadUrl`
1222
+ * is not an upload session on an allowed transfer host.
735
1223
  */
736
1224
  async terminateSession(session) {
1225
+ const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
737
1226
  // A terminate is the one place the SDK knows the OS job must die with
738
1227
  // the session: a DELETE under a job the engine is still chaining would
739
1228
  // leave it PATCHing a gone session until it fails on its own.
740
1229
  const backgroundTransfer = this.config.backgroundTransfer;
741
1230
  if (backgroundTransfer) {
742
- await this.cancelBackgroundTransfer(backgroundTransfer, session.uploadUrl);
1231
+ await this.cancelBackgroundTransfer(backgroundTransfer, uploadUrl);
743
1232
  }
744
- const response = await this.requestTerminate(session.uploadUrl, session.istk);
1233
+ const response = await this.requestTerminate(uploadUrl, session.istk);
745
1234
  if (response.ok || isSessionGone(response.status))
746
1235
  return;
747
1236
  const errorText = await response.text().catch(() => '');