@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/src/tus.ts CHANGED
@@ -1,5 +1,19 @@
1
- import type { ResolvedMobileConfig } from './config.js';
2
- import { base64EncodeUtf8 } from './encoding.js';
1
+ import type { MobileConnectivity, ResolvedMobileConfig } from './config.js';
2
+ import { FORGE_URL_REFUSAL, readForgeSessionUrl, readForgeTransferUrl, resolveForgeSessionUrl } from './app-sdk/forge-url.js';
3
+ import { SPARKVAULT_OWNED_HOST_PATTERNS } from './app-sdk/platform-domains.js';
4
+ import {
5
+ HEAD_AND_RECEIPT_TIMEOUT_MS,
6
+ TRANSFER_VERDICT,
7
+ UPLOAD_EXPIRES_HEADER,
8
+ classifyTransferResponse,
9
+ createTransferBudget,
10
+ parseUploadExpires,
11
+ resumeOffsetProblem,
12
+ retryDelayMs,
13
+ transferJitterKey,
14
+ } from './app-sdk/transfer-retry.js';
15
+ import type { TransferBudget, TransferOutcome } from './app-sdk/transfer-retry.js';
16
+ import { UPLOAD_REFUSAL_CODES } from './app-sdk/upload-publication.js';
3
17
  import {
4
18
  BackgroundTransferError,
5
19
  gateErrorFromBody,
@@ -34,15 +48,23 @@ export interface ParsedForgeUrl {
34
48
  *
35
49
  * A resume that cannot use the session throws a `TusUploadError` whose
36
50
  * `sessionLost` is true exactly when the session is worthless for this file
37
- * and the caller should drop it and start fresh: Forge no longer has it
51
+ * and the caller should drop it and start fresh: the expiry the caller holds
52
+ * for it has passed (refused before any request), Forge no longer has it
38
53
  * (404/410), or its `Upload-Length` is not this file's size (it belongs to a
39
- * different file). A transient HEAD failure (5xx, timeout) is NOT
40
- * `sessionLost`; the session is intact and the same resume can be retried.
54
+ * different file). A HEAD that keeps failing transiently (5xx, timeouts) past
55
+ * the upload's retry budget is NOT `sessionLost`; the session is intact and
56
+ * the same resume can be retried.
41
57
  */
42
58
  export interface TusResumeSession {
43
59
  uploadUrl: string;
44
60
  /** The session's ISTK. Falls back to the one in `forgeUrl` when omitted. */
45
61
  istk?: string;
62
+ /**
63
+ * The session's expiry as the caller holds it (`expiresAt` from
64
+ * `onSessionCreated`, kept current through `onSessionExpiry`). Null or
65
+ * omitted is unknown, and Forge's HEAD decides.
66
+ */
67
+ expiresAt?: number | null;
46
68
  }
47
69
 
48
70
  /** Where a tus session stands server-side, as reported by HEAD. */
@@ -50,13 +72,15 @@ export interface TusSessionProbe {
50
72
  offset: number;
51
73
  length: number;
52
74
  chunkSize: number;
75
+ /** When the session lapses (epoch ms, Forge's `Upload-Expires`), or null when the HEAD named none. */
76
+ expiresAt: number | null;
53
77
  }
54
78
 
55
79
  export interface TusUploadFromUriOptions {
56
80
  fileUri: string;
57
81
  fileSize: number;
82
+ /** Names the file in error context and debug logs; Forge never receives it. */
58
83
  filename: string;
59
- contentType: string;
60
84
  /**
61
85
  * Forge tus endpoint plus ISTK (`forge_url` from the ingot create).
62
86
  * Required to create a session; on resume it only supplies the ISTK when
@@ -71,6 +95,17 @@ export interface TusUploadFromUriOptions {
71
95
  * relaunch. Not invoked on resume. A throw here fails the upload.
72
96
  */
73
97
  onSessionCreated?: (session: TusSessionInfo) => void;
98
+ /**
99
+ * Invoked whenever the session's expiry, as its latest answer named it,
100
+ * changes: Forge slides it forward with every committed chunk and names it
101
+ * on its answers (`Upload-Expires`). It is null (unknown) after an answer
102
+ * that named none, and from the hand-off of a request the OS carries on
103
+ * without JS (the transfer engine's range, the single-chunk background
104
+ * PATCH): either may have moved it unread. A caller that persists the
105
+ * session keeps its `expiresAt` current with this, so a session it judges
106
+ * expired (`isSessionExpired`) really is. A throw here fails the upload.
107
+ */
108
+ onSessionExpiry?: (expiresAt: number | null) => void;
74
109
  /**
75
110
  * `background` rides the best suspension-proof engine the app provides:
76
111
  * the OS transfer engine (`backgroundTransfer`) for the whole remaining
@@ -101,35 +136,81 @@ export interface TusUploadFromUriOptions {
101
136
 
102
137
  const TUS_VERSION = '1.0.0';
103
138
 
104
- export function parseForgeUrl(forgeUrl: string): ParsedForgeUrl {
105
- let url: URL;
106
- try {
107
- url = new URL(forgeUrl);
108
- } catch {
109
- throw new SparkVaultValidationError('Invalid Forge URL format');
110
- }
139
+ /** What a refused Forge URL was refused for, in the words this SDK's errors use. */
140
+ const FORGE_URL_REFUSAL_MESSAGES: Readonly<Record<string, string>> = {
141
+ [FORGE_URL_REFUSAL.INVALID]: 'Invalid Forge URL format',
142
+ [FORGE_URL_REFUSAL.NOT_HTTPS]: 'Forge URL must use HTTPS protocol',
143
+ [FORGE_URL_REFUSAL.UNSAFE_PARTS]: 'Forge URL must not carry credentials or a fragment',
144
+ [FORGE_URL_REFUSAL.HOST]: 'Forge URL host is not an allowed transfer host',
145
+ [FORGE_URL_REFUSAL.OPERATION]: 'Forge URL is not an upload endpoint',
146
+ [FORGE_URL_REFUSAL.ISTK]: 'Forge URL must carry exactly one base64url ISTK parameter',
147
+ };
111
148
 
112
- if (url.protocol !== 'https:') {
113
- throw new SparkVaultValidationError('Forge URL must use HTTPS protocol');
149
+ /**
150
+ * Read a Core-issued Forge upload URL with the platform's transfer URL rule
151
+ * (app-sdk/forge-url.js): https, no credentials or fragment, the encrypt
152
+ * endpoint on an allowed host, and one base64url ISTK.
153
+ *
154
+ * @param allowedHostPatterns - Hosts trusted with the upload; the platform's
155
+ * own hosts by default (a configured SDK passes `allowedDownloadHostPatterns`).
156
+ * @throws SparkVaultValidationError when the URL is refused.
157
+ */
158
+ export function parseForgeUrl(
159
+ forgeUrl: string,
160
+ allowedHostPatterns: readonly RegExp[] = SPARKVAULT_OWNED_HOST_PATTERNS
161
+ ): ParsedForgeUrl {
162
+ const read = readForgeTransferUrl(forgeUrl, {
163
+ operation: 'encrypt',
164
+ isAllowedHost: allowsHost(allowedHostPatterns),
165
+ });
166
+ if (!read.ok) {
167
+ throw new SparkVaultValidationError(FORGE_URL_REFUSAL_MESSAGES[read.reason] ?? 'Invalid Forge URL', {
168
+ reason: read.reason,
169
+ });
114
170
  }
171
+ return {
172
+ baseUrl: read.origin,
173
+ tusEndpoint: read.endpoint,
174
+ istk: read.istk,
175
+ };
176
+ }
115
177
 
116
- const istk = url.searchParams.get('istk');
117
- if (!istk) {
118
- throw new SparkVaultValidationError('Forge URL missing ISTK parameter');
178
+ /**
179
+ * Read a kept tus session URL (the one `onSessionCreated` handed out) with the
180
+ * platform's session URL rule (app-sdk/forge-url.js) before the session is
181
+ * sent anything. Every request to a session carries the ISTK, and a PATCH the
182
+ * file's bytes, so a kept session is held to the rule a created one was: a
183
+ * session directly under the upload endpoint on an allowed host. The one
184
+ * reader for every path that sends a kept session a request: resume, probe
185
+ * and terminate.
186
+ *
187
+ * @throws SparkVaultValidationError when the URL is refused.
188
+ */
189
+ function readKeptSessionUrl(uploadUrl: string, allowedHostPatterns: readonly RegExp[]): string {
190
+ const session = readForgeSessionUrl(uploadUrl, {
191
+ operation: 'encrypt',
192
+ isAllowedHost: allowsHost(allowedHostPatterns),
193
+ });
194
+ if (!session.ok) {
195
+ throw new SparkVaultValidationError('uploadUrl is not an upload session on an allowed transfer host', {
196
+ reason: session.reason,
197
+ });
119
198
  }
199
+ return session.url;
200
+ }
120
201
 
121
- const baseUrl = `${url.protocol}//${url.host}`;
122
- return {
123
- baseUrl,
124
- tusEndpoint: `${baseUrl}${url.pathname}`,
125
- istk,
126
- };
202
+ /** The forge-url rule's host check over a configured list of host patterns. */
203
+ export function allowsHost(allowedHostPatterns: readonly RegExp[]): (hostname: string) => boolean {
204
+ return hostname => allowedHostPatterns.some(pattern => pattern.test(hostname));
127
205
  }
128
206
 
129
207
  /**
130
- * Parse a Forge error response body (JSON text) into the shared ApiErrorBody
131
- * shape so gate mapping can read its `error.code` / `error.details.resource`.
132
- * Returns an empty body on non-JSON text, which simply matches no gate.
208
+ * Parse a Forge error response body (JSON text), whole, into the shared
209
+ * ApiErrorBody shape: the one reader of a failed answer's body, so gate
210
+ * mapping reads its `error.code` / `error.details.resource` and the transfer
211
+ * policy the refusal Core decided (`error.details.coreCode`) from the same
212
+ * parse. Returns an empty body on non-JSON text, which matches no gate and
213
+ * names no refusal.
133
214
  */
134
215
  function parseErrorBody(text: string): ApiErrorBody {
135
216
  if (!text) return {};
@@ -149,9 +230,14 @@ function getErrorMessage(err: unknown): string {
149
230
  return err instanceof Error ? err.message : String(err);
150
231
  }
151
232
 
233
+ /** What every path the caller's abort stops reports: the one cancellation the outer catch terminates or keeps by. */
234
+ function cancelledError(): TusUploadError {
235
+ return new TusUploadError('Upload cancelled', { phase: 'cancelled' });
236
+ }
237
+
152
238
  function ensureNotAborted(signal?: AbortSignal): void {
153
239
  if (signal?.aborted) {
154
- throw new TusUploadError('Upload cancelled', { phase: 'cancelled' });
240
+ throw cancelledError();
155
241
  }
156
242
  }
157
243
 
@@ -189,28 +275,29 @@ interface TusErrorContext {
189
275
  fileSize?: number;
190
276
  }
191
277
 
192
- /**
193
- * Forge's answers that mean the tus session no longer exists: it expired,
194
- * or a DELETE terminated it. One list, so every request that can meet a
195
- * dead session (HEAD, the finalization PATCH, the OS engine's PATCHes,
196
- * DELETE) and the app's own session bookkeeping agree on what "gone" is.
197
- */
198
- const SESSION_GONE_STATUSES: ReadonlySet<number> = new Set([404, 410]);
199
-
200
278
  /**
201
279
  * True when a Forge status says the tus session is gone (expired or
202
280
  * terminated): the persisted session is worthless, and a caller starts a
203
- * fresh upload instead of retrying it.
281
+ * fresh upload instead of retrying it. It is the transfer policy's GONE
282
+ * verdict, so every request that can meet a dead session (HEAD, a PATCH, the
283
+ * OS engine's PATCHes, DELETE) and the app's own session bookkeeping agree on
284
+ * what "gone" is.
204
285
  */
205
286
  export function isSessionGone(status: number): boolean {
206
- return SESSION_GONE_STATUSES.has(status);
287
+ return Number.isSafeInteger(status) && status > 0 && classifyTransferResponse({ status }).verdict === TRANSFER_VERDICT.GONE;
207
288
  }
208
289
 
290
+ /**
291
+ * Forge no longer has the session: `coreCode` names the code Core ended it
292
+ * with when the answer named one (`NOT_FOUND`, `UPLOAD_PROTOCOL_RETIRED`, a
293
+ * 412's), none of them a refusal a new upload cannot remedy.
294
+ */
209
295
  function sessionLostError(
210
296
  status: number,
211
297
  context: TusErrorContext,
212
298
  phase: TusUploadError['phase'],
213
- cause: Error | null = null
299
+ cause: Error | null = null,
300
+ coreCode: string | null = null
214
301
  ): TusUploadError {
215
302
  return new TusUploadError(`TUS session no longer exists on the server (${status})`, {
216
303
  cause,
@@ -218,6 +305,324 @@ function sessionLostError(
218
305
  ...context,
219
306
  phase,
220
307
  sessionLost: true,
308
+ coreCode,
309
+ });
310
+ }
311
+
312
+ /**
313
+ * How far past the expiry Forge named a session still counts as live: the
314
+ * allowance for a device clock running ahead of Forge's. `Upload-Expires` is
315
+ * read off Forge's clock and compared with the device's; a phone on automatic
316
+ * time is within seconds of Forge, one set by hand is typically minutes fast.
317
+ * The two mistakes cost very differently: a live session judged expired is
318
+ * dropped and its file sent again from byte 0, an expired one judged live
319
+ * costs a single HEAD (Forge answers 410, `sessionLost`). So the margin errs
320
+ * long, and stays small against the 72 h idle window a session lives from its
321
+ * last commit (`@sparkvault/app-sdk/upload-expiry`): a session the clock says
322
+ * lapsed hours ago is still dropped without a request.
323
+ */
324
+ export const SESSION_EXPIRY_CLOCK_MARGIN_MS = 5 * 60 * 1000;
325
+
326
+ /**
327
+ * True when the expiry Forge named for a session (`TusSessionInfo.expiresAt`,
328
+ * kept current through `onSessionExpiry`) is past by more than the clock
329
+ * margin: Forge refuses every request to a session past its expiry, so a
330
+ * caller drops it without asking. Within the margin the session is live for
331
+ * all the device clock can tell, and Forge's HEAD decides. An unknown expiry
332
+ * (null) never has passed: only Forge's HEAD can say.
333
+ */
334
+ export function isSessionExpired(expiresAt: number | null | undefined, now: number = Date.now()): boolean {
335
+ return typeof expiresAt === 'number' && now > expiresAt + SESSION_EXPIRY_CLOCK_MARGIN_MS;
336
+ }
337
+
338
+ /**
339
+ * One answer to a transfer request, or its absence: what the transfer policy
340
+ * reads (the status and the headers) and what an error built from it says
341
+ * (the body). Every request an upload sends resolves to one, so one place
342
+ * decides what the upload does next.
343
+ */
344
+ interface TransferAnswer {
345
+ /** The HTTP status; 0 when no answer arrived (a dropped connection, a timeout, a stalled chunk). */
346
+ readonly status: number;
347
+ /** A response header by name, case-insensitively; always null for no answer. */
348
+ readonly header: (name: string) => string | null;
349
+ /** The body of an answer that is not a 2xx (Forge's error JSON); empty otherwise. */
350
+ readonly body: string;
351
+ /** That body's error envelope, parsed once and whole (`parseErrorBody`); empty for a 2xx or no answer. */
352
+ readonly envelope: ApiErrorBody;
353
+ /** Why no answer arrived (status 0): the error the caller sees when no retry follows. */
354
+ readonly unanswered: TusUploadError | null;
355
+ }
356
+
357
+ /** An answer that arrived: its status, its headers and the body of one that is not a 2xx. */
358
+ function answerOf(status: number, header: (name: string) => string | null, body: string): TransferAnswer {
359
+ return { status, header, body, envelope: parseErrorBody(body), unanswered: null };
360
+ }
361
+
362
+ function noAnswer(error: TusUploadError): TransferAnswer {
363
+ return { status: 0, header: () => null, body: '', envelope: {}, unanswered: error };
364
+ }
365
+
366
+ /**
367
+ * The transfer policy's reading of an answer to `request`: what to do next,
368
+ * its `Retry-After`, its `Upload-Offset`, and the refusal Core decided when
369
+ * the answer is one.
370
+ */
371
+ function readOutcome(answer: TransferAnswer, request: TusRequestKind): TransferOutcome {
372
+ return classifyTransferResponse({
373
+ status: answer.status,
374
+ headers: answer.header,
375
+ details: answer.envelope.error?.details ?? null,
376
+ receipt: request.receipt,
377
+ });
378
+ }
379
+
380
+ /**
381
+ * A failed request of an upload: the error the caller sees when no retry
382
+ * follows, and what the transfer policy made of the answer behind it.
383
+ */
384
+ interface TransferFailure {
385
+ readonly error: SparkVaultMobileError;
386
+ readonly outcome: TransferOutcome;
387
+ /** No answer arrived, so the device may be offline (an answer of any status proves it is not). */
388
+ readonly unanswered: boolean;
389
+ }
390
+
391
+ /** How a request names itself in its errors, and whether it is the receipt. */
392
+ interface TusRequestKind {
393
+ /** The request in the message: `TUS HEAD failed: 503 - …`. */
394
+ readonly label: string;
395
+ readonly phase: TusUploadError['phase'];
396
+ /** The phase a gone session (404/410) is reported in; null for the create, which no session stands behind yet. */
397
+ readonly gonePhase: TusUploadError['phase'] | null;
398
+ /** The PATCH at the final offset, whose 503 is a deferral (the transfer policy's DEFERRED). */
399
+ readonly receipt: boolean;
400
+ }
401
+
402
+ const CREATE_REQUEST: TusRequestKind = { label: 'TUS create', phase: 'create', gonePhase: null, receipt: false };
403
+ const RESUME_HEAD_REQUEST: TusRequestKind = { label: 'TUS HEAD', phase: 'create', gonePhase: 'create', receipt: false };
404
+ const OFFSET_HEAD_REQUEST: TusRequestKind = { label: 'TUS HEAD', phase: 'upload', gonePhase: 'create', receipt: false };
405
+ const CHUNK_REQUEST: TusRequestKind = { label: 'Chunk upload', phase: 'upload', gonePhase: 'upload', receipt: false };
406
+ const RECEIPT_REQUEST: TusRequestKind = { label: 'TUS finalization', phase: 'upload', gonePhase: 'create', receipt: true };
407
+
408
+ /** What a caller hears once Forge kept deferring the publication until the upload's idle limit. */
409
+ const RECEIVED_PUBLISHING_MESSAGE =
410
+ 'Upload received: Forge holds every byte and is still saving the file; it finishes without this device';
411
+
412
+ /**
413
+ * What a caller reads for a final refusal whose answer carried no sentence of
414
+ * Core's (a HEAD's, which has no body, naming Core's code in
415
+ * `X-Upload-Refusal` alone): this SDK's own words for each code.
416
+ */
417
+ const REFUSAL_WORDING: ReadonlyMap<string, string> = new Map([
418
+ [UPLOAD_REFUSAL_CODES.UPLOAD_POOL_FULL, 'This upload could not be saved: the owner\'s storage cannot take the file'],
419
+ [UPLOAD_REFUSAL_CODES.UPLOAD_SUPERSEDED, 'This upload was not saved: a newer upload of this file replaced it'],
420
+ ]);
421
+
422
+ /**
423
+ * Core refused the upload for good, in its own words (the answer's body,
424
+ * `details.coreCode` naming Core's code) or, for an answer with no body (a
425
+ * HEAD's, naming the code in `X-Upload-Refusal`), in this SDK's: the
426
+ * account's storage pool cannot take it, or a later upload replaced it. Not
427
+ * `sessionLost`: a new upload of the same file is no remedy.
428
+ */
429
+ function refusedError(
430
+ status: number,
431
+ envelope: ApiErrorBody,
432
+ coreCode: string,
433
+ context: TusErrorContext,
434
+ phase: TusUploadError['phase'],
435
+ cause: Error | null = null
436
+ ): TusUploadError {
437
+ const coreMessage = envelope.error?.message;
438
+ const message = typeof coreMessage === 'string' && coreMessage
439
+ ? coreMessage
440
+ : REFUSAL_WORDING.get(coreCode) ?? `Forge did not save this upload: Core refused it for good (${coreCode})`;
441
+ return new TusUploadError(message, { cause, httpStatus: status, code: 'upload_refused', coreCode, ...context, phase });
442
+ }
443
+
444
+ /**
445
+ * The failure a request's answer amounts to: the no-answer error as it was
446
+ * classified, a gone session as `sessionLost`, the refusal Core decided in
447
+ * Core's words (`upload_refused`), the receipt's deferral as the
448
+ * received-publishing outcome a caller resumes later (`retryLater`; thrown
449
+ * only once the idle limit ends the asking), a 402 as the typed gate error
450
+ * the JSON HTTP path throws (so callers run their subscribe / add-on UX off
451
+ * one set of types), and every other status as a `TusUploadError` carrying
452
+ * it and its `Retry-After`.
453
+ */
454
+ function failureOf(
455
+ answer: TransferAnswer,
456
+ outcome: TransferOutcome,
457
+ request: TusRequestKind,
458
+ context: TusErrorContext
459
+ ): TransferFailure {
460
+ return { error: errorOf(answer, outcome, request, context), outcome, unanswered: answer.unanswered !== null };
461
+ }
462
+
463
+ function errorOf(
464
+ answer: TransferAnswer,
465
+ outcome: TransferOutcome,
466
+ request: TusRequestKind,
467
+ context: TusErrorContext
468
+ ): SparkVaultMobileError {
469
+ if (answer.unanswered) return answer.unanswered;
470
+ if (outcome.verdict === TRANSFER_VERDICT.REFUSED && outcome.coreCode !== null) {
471
+ return refusedError(answer.status, answer.envelope, outcome.coreCode, context, request.phase);
472
+ }
473
+ if (outcome.verdict === TRANSFER_VERDICT.GONE && request.gonePhase !== null) {
474
+ return sessionLostError(answer.status, context, request.gonePhase, null, outcome.coreCode);
475
+ }
476
+ if (outcome.verdict === TRANSFER_VERDICT.DEFERRED) {
477
+ return new TusUploadError(RECEIVED_PUBLISHING_MESSAGE, {
478
+ httpStatus: answer.status,
479
+ code: 'upload_received_publishing',
480
+ retryAfterMs: outcome.retryAfterMs,
481
+ ...context,
482
+ phase: 'finalize',
483
+ retryLater: true,
484
+ });
485
+ }
486
+ const gateError = gateErrorFromBody(answer.status, answer.envelope);
487
+ if (gateError) return gateError;
488
+ return new TusUploadError(`${request.label} failed: ${answer.status} - ${answer.body.substring(0, 200)}`, {
489
+ httpStatus: answer.status,
490
+ retryAfterMs: outcome.retryAfterMs,
491
+ ...context,
492
+ phase: request.phase,
493
+ });
494
+ }
495
+
496
+ /**
497
+ * The session's expiry as its latest answer named it, reported to the
498
+ * caller (`onSessionExpiry`) each time it changes. An answer that names none
499
+ * leaves it unknown: the request may have moved it unread (a commit whose
500
+ * answer was lost, a deferred finalization), and a caller that dropped a
501
+ * session on an expiry Forge has since moved on would re-send a whole file.
502
+ * A request cut short by the caller's abort keeps the last named value: it
503
+ * can only have slid the expiry by that one request's duration.
504
+ */
505
+ class SessionExpiry {
506
+ private known: number | null;
507
+ private readonly report?: (expiresAt: number | null) => void;
508
+
509
+ constructor(known: number | null, report?: (expiresAt: number | null) => void) {
510
+ this.known = known;
511
+ this.report = report;
512
+ }
513
+
514
+ /** Forge answered (or did not): the expiry is what the answer names. */
515
+ read(answer: TransferAnswer): void {
516
+ this.become(parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)));
517
+ }
518
+
519
+ /**
520
+ * A request the OS carries on without JS is about to leave: it may commit
521
+ * chunks, each sliding the expiry, whose answers this upload never reads,
522
+ * possibly while the app is not running.
523
+ */
524
+ forget(): void {
525
+ this.become(null);
526
+ }
527
+
528
+ private become(expiresAt: number | null): void {
529
+ if (expiresAt === this.known) return;
530
+ this.known = expiresAt;
531
+ this.report?.(expiresAt);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * What every request to one session carries: where it lives, its token, the
537
+ * file it is for, the expiry its answers keep current (none for a one-off
538
+ * probe), and the caller's abort.
539
+ */
540
+ interface SessionLink {
541
+ readonly uploadUrl: string;
542
+ readonly istk: string;
543
+ readonly context: TusErrorContext;
544
+ readonly expiry: SessionExpiry | null;
545
+ readonly signal?: AbortSignal;
546
+ }
547
+
548
+ /**
549
+ * An upload in flight on its session: the file's geometry and the one retry
550
+ * budget every request of it spends (`@sparkvault/app-sdk/transfer-retry`):
551
+ * failures in a row and time without progress, both starting over whenever
552
+ * the server confirms progress, so a slow transfer that keeps landing bytes
553
+ * is never cut off while a dead one ends in bounded time.
554
+ */
555
+ interface TusTransfer extends SessionLink {
556
+ readonly fileSize: number;
557
+ readonly chunkSize: number;
558
+ readonly budget: TransferBudget;
559
+ readonly expiry: SessionExpiry;
560
+ }
561
+
562
+ /** Where an upload stands: the server's offset, and whether the server confirmed the ingot published. */
563
+ interface TransferPosition {
564
+ readonly offset: number;
565
+ readonly published: boolean;
566
+ }
567
+
568
+ /** The file a chunk is read from, and how its chunks travel. */
569
+ interface ChunkSource {
570
+ readonly reader: MobileFileReader;
571
+ readonly uri: string;
572
+ readonly transport?: UploadTransport;
573
+ /** Bytes of the chunk at `offset` that have left the device so far. */
574
+ readonly onChunkProgress: (offset: number, chunkBytesUploaded: number) => void;
575
+ }
576
+
577
+ /** A backoff the caller's abort cuts short: resolves after `ms`, rejects as cancelled the moment the signal fires. */
578
+ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
579
+ return new Promise((resolve, reject) => {
580
+ if (signal?.aborted) {
581
+ reject(cancelledError());
582
+ return;
583
+ }
584
+ const onAbort = () => {
585
+ clearTimeout(timer);
586
+ reject(cancelledError());
587
+ };
588
+ const timer = setTimeout(() => {
589
+ signal?.removeEventListener('abort', onAbort);
590
+ resolve();
591
+ }, ms);
592
+ signal?.addEventListener('abort', onAbort, { once: true });
593
+ });
594
+ }
595
+
596
+ /**
597
+ * Resolve once the device is back online; reject as cancelled the moment the
598
+ * caller aborts, the wait's only other end.
599
+ */
600
+ function waitUntilOnline(connectivity: MobileConnectivity, signal?: AbortSignal): Promise<void> {
601
+ return new Promise((resolve, reject) => {
602
+ let settled = false;
603
+ let unsubscribe: (() => void) | null = null;
604
+ const settle = (outcome: () => void) => {
605
+ if (settled) return;
606
+ settled = true;
607
+ unsubscribe?.();
608
+ signal?.removeEventListener('abort', onAbort);
609
+ outcome();
610
+ };
611
+ const onAbort = () => settle(() => reject(cancelledError()));
612
+ if (signal?.aborted) {
613
+ onAbort();
614
+ return;
615
+ }
616
+ signal?.addEventListener('abort', onAbort, { once: true });
617
+ unsubscribe = connectivity.onOnline(() => settle(resolve));
618
+ if (settled) {
619
+ // The listener fired inside the subscribe, before its unsubscribe existed.
620
+ unsubscribe();
621
+ } else if (connectivity.isOnline()) {
622
+ // The network came back between the caller's check and the subscribe,
623
+ // so its event would never arrive.
624
+ settle(resolve);
625
+ }
221
626
  });
222
627
  }
223
628
 
@@ -264,6 +669,38 @@ async function tusRequest(
264
669
  }
265
670
  }
266
671
 
672
+ /**
673
+ * Send a tus request through `tusRequest` and resolve with its answer: a
674
+ * response of any status, or no answer (a network failure or a timeout, as
675
+ * `tusRequest` classified it). The caller's abort and an SDK error still
676
+ * throw, because no retry follows either.
677
+ */
678
+ async function fetchAnswer(
679
+ config: ResolvedMobileConfig,
680
+ url: string,
681
+ init: TusRequestInit,
682
+ label: string,
683
+ context: TusErrorContext
684
+ ): Promise<TransferAnswer> {
685
+ let response: Response;
686
+ try {
687
+ response = await tusRequest(config, url, init, label, context);
688
+ } catch (err) {
689
+ if (err instanceof TusUploadError && (err.phase === 'network' || err.phase === 'timeout')) {
690
+ return noAnswer(err);
691
+ }
692
+ throw err;
693
+ }
694
+ if (response.status === 0) {
695
+ return noAnswer(new TusUploadError(`${label} got no answer`, { ...context, phase: 'network' }));
696
+ }
697
+ return answerOf(
698
+ response.status,
699
+ name => response.headers.get(name),
700
+ response.ok ? '' : await response.text().catch(() => '')
701
+ );
702
+ }
703
+
267
704
  /**
268
705
  * Case-insensitive header lookup for adapter results. Native HTTP stacks
269
706
  * hand headers back in whatever case they please (iOS lower-cases them),
@@ -285,228 +722,101 @@ function getHeaderIgnoreCase(headers: Record<string, string> | undefined, name:
285
722
  */
286
723
  type TusSessionSource =
287
724
  | { mode: 'create'; parsed: ParsedForgeUrl }
288
- | { mode: 'resume'; uploadUrl: string; istk: string };
725
+ | { mode: 'resume'; uploadUrl: string; istk: string; expiresAt: number | null };
289
726
 
290
- function resolveSessionSource(options: TusUploadFromUriOptions): TusSessionSource {
727
+ function resolveSessionSource(options: TusUploadFromUriOptions, allowedHostPatterns: readonly RegExp[]): TusSessionSource {
291
728
  if (options.resume) {
292
- const istk = options.resume.istk ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl).istk : null);
729
+ const uploadUrl = readKeptSessionUrl(options.resume.uploadUrl, allowedHostPatterns);
730
+ const istk = options.resume.istk
731
+ ?? (options.forgeUrl ? parseForgeUrl(options.forgeUrl, allowedHostPatterns).istk : null);
293
732
  if (!istk) {
294
733
  throw new SparkVaultValidationError('resume.istk or forgeUrl is required to resume a TUS upload session');
295
734
  }
296
- return { mode: 'resume', uploadUrl: options.resume.uploadUrl, istk };
735
+ const expiresAt = options.resume.expiresAt ?? null;
736
+ if (expiresAt !== null && !Number.isFinite(expiresAt)) {
737
+ throw new SparkVaultValidationError('resume.expiresAt is an epoch-ms expiry or null', { received: expiresAt });
738
+ }
739
+ return { mode: 'resume', uploadUrl, istk, expiresAt };
297
740
  }
298
741
  if (!options.forgeUrl) {
299
742
  throw new SparkVaultValidationError('forgeUrl is required to create a TUS upload session');
300
743
  }
301
- return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl) };
744
+ return { mode: 'create', parsed: parseForgeUrl(options.forgeUrl, allowedHostPatterns) };
302
745
  }
303
746
 
304
- async function createTusUpload(
305
- config: ResolvedMobileConfig,
306
- parsed: ParsedForgeUrl,
307
- fileSize: number,
308
- filename: string,
309
- contentType: string,
310
- signal?: AbortSignal
311
- ): Promise<{ uploadUrl: string; chunkSize: number }> {
312
- ensureNotAborted(signal);
313
-
314
- const metadata = `filename ${base64EncodeUtf8(filename)},filetype ${base64EncodeUtf8(contentType || 'application/octet-stream')}`;
315
- const response = await tusRequest(
316
- config,
317
- parsed.tusEndpoint,
318
- {
319
- method: 'POST',
320
- headers: {
321
- 'Tus-Resumable': TUS_VERSION,
322
- 'Upload-Length': String(fileSize),
323
- 'Upload-Metadata': metadata,
324
- 'X-ISTK': parsed.istk,
325
- },
326
- signal,
327
- timeoutMs: config.tusPostTimeoutMs,
328
- },
329
- 'TUS POST request',
330
- { filename, fileSize }
331
- );
332
-
333
- if (!response.ok) {
334
- const errorText = await response.text().catch(() => '');
335
-
336
- // A 402 here is a billable gate hit mid-create (e.g. storage/bandwidth pool
337
- // exhausted). Route it through the same typed-gate mapping as the JSON HTTP
338
- // path so callers handle subscribe / add-on UX off one set of error types.
339
- const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
340
- if (gateError) {
341
- throw gateError;
342
- }
343
-
344
- throw new TusUploadError(`TUS create failed: ${response.status} - ${errorText}`, {
345
- httpStatus: response.status,
346
- filename,
347
- fileSize,
348
- phase: 'create',
349
- });
350
- }
351
-
352
- const location = response.headers.get('Location');
353
- if (!location) {
354
- throw new TusUploadError('TUS create response missing Location header', {
355
- filename,
356
- fileSize,
747
+ /**
748
+ * The session a create's 2xx opened: where it lives, its chunk geometry and
749
+ * its expiry. Every chunk carries the file's bytes, so the session must live
750
+ * under the endpoint it was created at.
751
+ */
752
+ function readCreatedSession(
753
+ answer: TransferAnswer,
754
+ endpoint: string,
755
+ context: TusErrorContext
756
+ ): { uploadUrl: string; chunkSize: number; expiresAt: number | null } {
757
+ const uploadUrl = resolveForgeSessionUrl(answer.header('Location'), endpoint);
758
+ if (!uploadUrl) {
759
+ throw new TusUploadError('TUS create response Location header is missing or outside the upload endpoint', {
760
+ ...context,
357
761
  phase: 'create',
358
762
  });
359
763
  }
360
764
 
361
- const uploadUrl = location.startsWith('http') ? location : `${parsed.baseUrl}${location}`;
362
-
363
765
  // Forge fixes the session's chunk geometry and rejects any non-final chunk
364
766
  // that is not exactly the negotiated size, so the chunk size is the server's
365
767
  // to dictate via `X-Chunk-Size` (mirrors sdk-js). A client-side guess could
366
768
  // only ever be rejected, so a missing header is a contract violation.
367
- const chunkSizeHeader = response.headers.get('X-Chunk-Size');
769
+ const chunkSizeHeader = answer.header('X-Chunk-Size');
368
770
  const chunkSize = parseIntegerHeader(chunkSizeHeader, 1);
369
771
  if (chunkSize === null) {
370
772
  throw new TusUploadError(`TUS create response missing or invalid X-Chunk-Size header (received ${JSON.stringify(chunkSizeHeader)})`, {
371
- filename,
372
- fileSize,
773
+ ...context,
373
774
  phase: 'create',
374
775
  });
375
776
  }
376
777
 
377
- return { uploadUrl, chunkSize };
778
+ return { uploadUrl, chunkSize, expiresAt: parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)) };
378
779
  }
379
780
 
380
781
  /**
381
- * Ask Forge where a session stands (tus HEAD). This is the only way to learn
382
- * what a background URLSession delivered after the process was killed, and
383
- * what a failed attempt left behind — so resume never re-sends bytes the
384
- * server holds. A status `isSessionGone` recognises means the session
385
- * expired or was terminated: the error carries `sessionLost` so the caller
386
- * drops its persisted session and creates a fresh one rather than retrying
387
- * a dead URL. Any other failure (a 5xx, an auth rejection, a timeout)
388
- * leaves `sessionLost` false: the
389
- * session may well be intact, and treating it as dead would re-upload a
390
- * whole file over a blip.
782
+ * Where a HEAD's 2xx says the session stands: offset, length and chunk
783
+ * geometry, all required (a 2xx without them is not Forge, a captive
784
+ * portal's page being the classic case), and the expiry when named. An
785
+ * offset past the length can only be a corrupt session.
391
786
  */
392
- async function headTusSession(
393
- config: ResolvedMobileConfig,
394
- uploadUrl: string,
395
- istk: string,
396
- signal?: AbortSignal,
397
- context: TusErrorContext = {}
398
- ): Promise<TusSessionProbe> {
399
- ensureNotAborted(signal);
400
-
401
- const response = await tusRequest(
402
- config,
403
- uploadUrl,
404
- {
405
- method: 'HEAD',
406
- headers: {
407
- 'Tus-Resumable': TUS_VERSION,
408
- 'X-ISTK': istk,
409
- },
410
- signal,
411
- timeoutMs: config.tusPostTimeoutMs,
412
- },
413
- 'TUS HEAD request',
414
- context
415
- );
416
-
417
- if (isSessionGone(response.status)) {
418
- throw sessionLostError(response.status, context, 'create');
419
- }
420
-
421
- if (!response.ok) {
422
- const errorText = await response.text().catch(() => '');
423
- const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
424
- if (gateError) {
425
- throw gateError;
426
- }
427
- throw new TusUploadError(`TUS HEAD failed: ${response.status} - ${errorText}`, {
428
- httpStatus: response.status,
429
- ...context,
430
- phase: 'create',
431
- });
432
- }
433
-
434
- const offset = parseIntegerHeader(response.headers.get('Upload-Offset'), 0);
435
- const length = parseIntegerHeader(response.headers.get('Upload-Length'), 0);
436
- const chunkSize = parseIntegerHeader(response.headers.get('X-Chunk-Size'), 1);
787
+ function readSessionProbe(answer: TransferAnswer, phase: TusUploadError['phase'], context: TusErrorContext): TusSessionProbe {
788
+ const offset = parseIntegerHeader(answer.header('Upload-Offset'), 0);
789
+ const length = parseIntegerHeader(answer.header('Upload-Length'), 0);
790
+ const chunkSize = parseIntegerHeader(answer.header('X-Chunk-Size'), 1);
437
791
  if (offset === null || length === null || chunkSize === null || offset > length) {
438
792
  throw new TusUploadError(
439
793
  'TUS HEAD response missing or invalid Upload-Offset / Upload-Length / X-Chunk-Size headers',
440
- { ...context, phase: 'create' }
794
+ { ...context, phase }
441
795
  );
442
796
  }
443
-
444
- return { offset, length, chunkSize };
797
+ return { offset, length, chunkSize, expiresAt: parseUploadExpires(answer.header(UPLOAD_EXPIRES_HEADER)) };
445
798
  }
446
799
 
447
800
  /**
448
- * Confirm finalization of a session Forge already holds every byte of: a
449
- * zero-length PATCH at `Upload-Offset: fileSize`. Forge treats any PATCH at
450
- * or past the declared size as "finalize (again) and acknowledge" - it
451
- * re-runs a finalization that failed transiently after the last chunk
452
- * (Core/DynamoDB/billing), and answers 204 for one that already completed.
453
- * Without this a resume that finds the transfer complete would skip straight
454
- * to the status poll and could report a never-activated ingot as done.
455
- *
456
- * 409 means another finalizer holds the lease right now: retry later, the
457
- * session is intact. A status `isSessionGone` recognises means the session
458
- * is gone (`sessionLost`).
801
+ * A session offset the server named (a HEAD's, or a 409's `Upload-Offset`),
802
+ * checked before anything is sent from it: Forge commits whole chunks, so a
803
+ * session stands at a chunk boundary or at the total, never past it, and an
804
+ * answer that says otherwise is not one to resume from.
459
805
  */
460
- async function confirmTusFinalization(
461
- config: ResolvedMobileConfig,
462
- uploadUrl: string,
463
- istk: string,
464
- fileSize: number,
465
- signal?: AbortSignal,
466
- context: TusErrorContext = {}
467
- ): Promise<void> {
468
- ensureNotAborted(signal);
469
-
470
- const response = await tusRequest(
471
- config,
472
- uploadUrl,
473
- {
474
- method: 'PATCH',
475
- headers: {
476
- 'Tus-Resumable': TUS_VERSION,
477
- 'Upload-Offset': String(fileSize),
478
- 'Content-Type': 'application/offset+octet-stream',
479
- 'X-ISTK': istk,
480
- },
481
- signal,
482
- timeoutMs: config.tusChunkTimeoutMs,
483
- },
484
- 'TUS finalization request',
485
- context
486
- );
487
-
488
- if (isSessionGone(response.status)) {
489
- throw sessionLostError(response.status, context, 'create');
490
- }
491
- if (response.status === 409) {
492
- throw new TusUploadError('TUS finalization is already in progress on the server; retry shortly', {
493
- httpStatus: 409,
494
- ...context,
495
- phase: 'upload',
496
- });
497
- }
498
- if (!response.ok) {
499
- const errorText = await response.text().catch(() => '');
500
- const gateError = gateErrorFromBody(response.status, parseErrorBody(errorText));
501
- if (gateError) {
502
- throw gateError;
503
- }
504
- throw new TusUploadError(`TUS finalization failed: ${response.status} - ${errorText.substring(0, 200)}`, {
505
- httpStatus: response.status,
806
+ function checkedServerOffset(
807
+ offset: number | null,
808
+ geometry: { fileSize: number; chunkSize: number },
809
+ phase: TusUploadError['phase'],
810
+ context: TusErrorContext
811
+ ): number {
812
+ const problem = resumeOffsetProblem(offset, { totalSize: geometry.fileSize, chunkSize: geometry.chunkSize });
813
+ if (offset === null || problem !== null) {
814
+ throw new TusUploadError(`Forge named an upload offset this upload cannot continue from (${offset}: ${problem})`, {
506
815
  ...context,
507
- phase: 'upload',
816
+ phase,
508
817
  });
509
818
  }
819
+ return offset;
510
820
  }
511
821
 
512
822
  /**
@@ -517,26 +827,29 @@ async function confirmTusFinalization(
517
827
  const CHUNK_ABSOLUTE_CAP_MS = 60 * 60_000;
518
828
 
519
829
  /**
520
- * PATCH one chunk. `stallTimeoutMs` is a STALL watchdog, not a deadline: it
521
- * fires only when no upload progress (and no response) arrives for that long.
522
- * Forge fixes non-final chunks at 50 MB and the client cannot shrink them, so
523
- * an absolute per-request timeout would kill every legitimately slow uplink
524
- * (a 50 MB chunk needs ~3.5 Mbps sustained to beat 120 s) and restart the
525
- * file from byte 0. The final PATCH also waits for Forge to finalize after the
526
- * last byte is sent; a progress-based watchdog gives that the same patience.
830
+ * PATCH one chunk over XHR and resolve with Forge's answer, or with no answer
831
+ * when the connection drops, the request times out or the chunk stalls. Only
832
+ * the caller's abort rejects. `stallTimeoutMs` is a STALL watchdog, not a
833
+ * deadline: it fires only when no upload progress (and no response) arrives
834
+ * for that long. Forge fixes non-final chunks at 50 MB and the client cannot
835
+ * shrink them, so an absolute per-request timeout would kill every
836
+ * legitimately slow uplink (a 50 MB chunk needs ~3.5 Mbps sustained to beat
837
+ * 120 s). The final PATCH also waits for Forge to finalize after the last
838
+ * byte is sent; a progress-based watchdog gives that the same patience.
839
+ * Bytes leaving are a sign of life: they restart the transfer's idle clock
840
+ * (never its failure count, which only the server's confirmation resets).
527
841
  */
528
- function uploadChunk(
529
- uploadUrl: string,
530
- istk: string,
842
+ function sendChunkOverXhr(
843
+ transfer: TusTransfer,
531
844
  chunk: TusChunk,
532
845
  offset: number,
533
846
  stallTimeoutMs: number,
534
- signal?: AbortSignal,
535
847
  onChunkProgress?: (chunkBytesUploaded: number) => void
536
- ): Promise<number> {
848
+ ): Promise<TransferAnswer> {
537
849
  if (typeof XMLHttpRequest === 'undefined') {
538
850
  throw new SparkVaultValidationError('XMLHttpRequest is required for mobile TUS uploads');
539
851
  }
852
+ const { uploadUrl, istk, signal, context } = transfer;
540
853
 
541
854
  return new Promise((resolve, reject) => {
542
855
  const xhr = new XMLHttpRequest();
@@ -559,7 +872,7 @@ function uploadChunk(
559
872
 
560
873
  const abort = () => {
561
874
  xhr.abort();
562
- finish(() => reject(new TusUploadError('Upload cancelled', { phase: 'cancelled' })));
875
+ finish(() => reject(cancelledError()));
563
876
  };
564
877
 
565
878
  if (signal?.aborted) {
@@ -568,10 +881,13 @@ function uploadChunk(
568
881
  }
569
882
  signal?.addEventListener('abort', abort, { once: true });
570
883
 
884
+ const unanswered = (message: string, phase: 'network' | 'timeout') =>
885
+ finish(() => resolve(noAnswer(new TusUploadError(message, { ...context, phase }))));
886
+
571
887
  // Settle BEFORE aborting: the abort handler below would otherwise report
572
888
  // the stall as a user cancellation.
573
889
  const stall = (message: string) => {
574
- finish(() => reject(new TusUploadError(message, { phase: 'timeout' })));
890
+ unanswered(message, 'timeout');
575
891
  xhr.abort();
576
892
  };
577
893
  const armWatchdog = () => {
@@ -584,38 +900,24 @@ function uploadChunk(
584
900
 
585
901
  xhr.upload.onprogress = event => {
586
902
  armWatchdog();
903
+ transfer.budget.alive();
587
904
  if (event.lengthComputable) {
588
905
  onChunkProgress?.(event.loaded);
589
906
  }
590
907
  };
591
908
 
592
909
  xhr.onload = () => {
593
- if (xhr.status >= 200 && xhr.status < 300) {
594
- // Forge answers every PATCH with Upload-Offset, so a 2xx without a
595
- // readable one is not Forge (a captive portal's 200 page is the
596
- // classic case): report no advance and let the stall guard fail the
597
- // attempt, rather than crediting bytes that may never have arrived.
598
- // parseInt would also turn a garbage header into NaN, which slips
599
- // through that guard (every comparison with NaN is false) and ends
600
- // the upload loop as a success.
601
- const newOffset = parseIntegerHeader(xhr.getResponseHeader('Upload-Offset'), 0) ?? offset;
602
- finish(() => resolve(newOffset));
603
- } else {
604
- const gateError = gateErrorFromBody(xhr.status, parseErrorBody(xhr.responseText));
605
- if (gateError) {
606
- finish(() => reject(gateError));
607
- return;
608
- }
609
- finish(() => reject(new TusUploadError(`Chunk upload failed: ${xhr.status} - ${xhr.responseText.substring(0, 200)}`, {
610
- httpStatus: xhr.status,
611
- phase: 'upload',
612
- })));
910
+ if (xhr.status === 0) {
911
+ unanswered('Chunk upload got no answer', 'network');
912
+ return;
613
913
  }
914
+ const answered = xhr.status >= 200 && xhr.status < 300;
915
+ finish(() => resolve(answerOf(xhr.status, name => xhr.getResponseHeader(name), answered ? '' : xhr.responseText)));
614
916
  };
615
917
 
616
- xhr.onerror = () => finish(() => reject(new TusUploadError('Chunk upload network error', { phase: 'network' })));
617
- xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { phase: 'cancelled' })));
618
- xhr.ontimeout = () => finish(() => reject(new TusUploadError('Chunk upload timed out', { phase: 'timeout' })));
918
+ xhr.onerror = () => unanswered('Chunk upload network error', 'network');
919
+ xhr.onabort = () => finish(() => reject(new TusUploadError('Chunk upload aborted', { ...context, phase: 'cancelled' })));
920
+ xhr.ontimeout = () => unanswered('Chunk upload timed out', 'timeout');
619
921
 
620
922
  xhr.open('PATCH', uploadUrl);
621
923
  xhr.setRequestHeader('Tus-Resumable', TUS_VERSION);
@@ -637,64 +939,60 @@ function uploadChunk(
637
939
 
638
940
  /**
639
941
  * PATCH a whole file as the session's only chunk on the app's background
640
- * URLSession. The OS owns the transfer, so it finishes even if iOS suspends
641
- * the app; the trade is no progress events and no abort. The result is mapped
642
- * exactly like the XHR path so callers see one error taxonomy.
942
+ * URLSession and resolve with Forge's answer, or with no answer when the
943
+ * adapter got none. The OS owns the transfer, so it finishes even if iOS
944
+ * suspends the app; the trade is no progress events and no abort. Its answer
945
+ * is read exactly like the XHR path's, so callers see one error taxonomy.
643
946
  */
644
- async function uploadWholeFileInBackground(
947
+ async function sendWholeFileInBackground(
645
948
  uploader: MobileBackgroundUploader,
646
- uploadUrl: string,
647
- istk: string,
949
+ transfer: TusTransfer,
648
950
  fileUri: string,
649
951
  offset: number
650
- ): Promise<number> {
952
+ ): Promise<TransferAnswer> {
651
953
  let result: Awaited<ReturnType<MobileBackgroundUploader['uploadFile']>>;
652
954
  try {
653
- result = await uploader.uploadFile(uploadUrl, fileUri, {
955
+ result = await uploader.uploadFile(transfer.uploadUrl, fileUri, {
654
956
  method: 'PATCH',
655
957
  headers: {
656
958
  'Tus-Resumable': TUS_VERSION,
657
959
  'Upload-Offset': String(offset),
658
960
  'Content-Type': 'application/offset+octet-stream',
659
- 'X-ISTK': istk,
961
+ 'X-ISTK': transfer.istk,
660
962
  },
661
963
  });
662
964
  } catch (err) {
663
965
  // The adapter only throws when no HTTP response came back (offline, DNS,
664
966
  // the OS cancelled the task); an HTTP failure is a status below.
665
- throw new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
967
+ return noAnswer(new TusUploadError(`Background chunk upload failed: ${getErrorMessage(err)}`, {
666
968
  cause: err instanceof Error ? err : null,
969
+ ...transfer.context,
667
970
  phase: 'network',
668
- });
971
+ }));
669
972
  }
670
973
 
671
- const body = typeof result.body === 'string' ? result.body : '';
672
- if (result.status >= 200 && result.status < 300) {
673
- // Forge always answers a PATCH with a readable Upload-Offset. A 2xx
674
- // without one is not Forge (a captive portal's 200 page is the classic
675
- // case), so report no advance: the stall guard fails the attempt and the
676
- // next one asks Forge (HEAD) instead of marking bytes uploaded that may
677
- // never have arrived. Same strict parse as the XHR path (uploadChunk).
678
- return parseIntegerHeader(getHeaderIgnoreCase(result.headers, 'Upload-Offset'), 0) ?? offset;
679
- }
680
-
681
- const gateError = gateErrorFromBody(result.status, parseErrorBody(body));
682
- if (gateError) {
683
- throw gateError;
974
+ if (!Number.isSafeInteger(result.status) || result.status <= 0) {
975
+ return noAnswer(new TusUploadError(`Background chunk upload got no answer (status ${String(result.status)})`, {
976
+ ...transfer.context,
977
+ phase: 'network',
978
+ }));
684
979
  }
685
- throw new TusUploadError(`Chunk upload failed: ${result.status} - ${body.substring(0, 200)}`, {
686
- httpStatus: result.status,
687
- phase: 'upload',
688
- });
980
+ const answered = result.status >= 200 && result.status < 300;
981
+ return answerOf(
982
+ result.status,
983
+ name => getHeaderIgnoreCase(result.headers, name),
984
+ !answered && typeof result.body === 'string' ? result.body : ''
985
+ );
689
986
  }
690
987
 
691
988
  /**
692
989
  * Translate what the OS transfer adapter reports into the SDK's one error
693
990
  * taxonomy, so a caller sees the same `TusUploadError` phases and typed gate
694
991
  * errors whichever engine carried the bytes. Forge's status and body ride
695
- * through untouched: a 402 becomes the gate error the XHR path throws, a
696
- * status `isSessionGone` recognises marks the session lost exactly as HEAD
697
- * would, and any other status keeps its number for the caller's own policy
992
+ * through untouched: the refusal Core decided is `upload_refused` in Core's
993
+ * words as on the XHR path, a 402 becomes the gate error the XHR path
994
+ * throws, a status `isSessionGone` recognises marks the session lost exactly
995
+ * as HEAD would, and any other status keeps its number for the caller's own policy
698
996
  * (a 409 during finalize is a retry-later, not a lost session). The
699
997
  * remaining kinds are transport
700
998
  * outcomes: the caller's own abort is `cancelled`; an OS-ended task
@@ -720,9 +1018,18 @@ export function mapBackgroundTransferError(
720
1018
  const status = err.httpStatus;
721
1019
  const body = err.body ?? '';
722
1020
  if (status !== undefined) {
723
- const gateError = gateErrorFromBody(status, parseErrorBody(body));
1021
+ const envelope = parseErrorBody(body);
1022
+ const outcome = Number.isSafeInteger(status) && status > 0
1023
+ ? classifyTransferResponse({ status, details: envelope.error?.details ?? null })
1024
+ : null;
1025
+ if (outcome?.verdict === TRANSFER_VERDICT.REFUSED && outcome.coreCode !== null) {
1026
+ return refusedError(status, envelope, outcome.coreCode, context, 'upload', err);
1027
+ }
1028
+ const gateError = gateErrorFromBody(status, envelope);
724
1029
  if (gateError) return gateError;
725
- if (isSessionGone(status)) return sessionLostError(status, context, 'upload', err);
1030
+ if (outcome?.verdict === TRANSFER_VERDICT.GONE) {
1031
+ return sessionLostError(status, context, 'upload', err, outcome.coreCode);
1032
+ }
726
1033
  }
727
1034
  return new TusUploadError(
728
1035
  `Background chunk upload failed: ${status ?? 'unknown status'} - ${body.substring(0, 200)}`,
@@ -786,72 +1093,98 @@ export class MobileTusUploader {
786
1093
  this.config = config;
787
1094
  }
788
1095
 
1096
+ /**
1097
+ * Upload a file over tus: create the session (or resume a kept one), send
1098
+ * the bytes Forge does not hold, and resolve once Forge has confirmed the
1099
+ * ingot published.
1100
+ *
1101
+ * Every answer is read through the platform's transfer policy
1102
+ * (`@sparkvault/app-sdk/transfer-retry`), and one budget per call bounds
1103
+ * every retry, the create's included: a request that got no answer, 408,
1104
+ * 425, 429, a 5xx or a lease-held 409 waits its backoff (never shorter than
1105
+ * `Retry-After`), then asks Forge where the session stands (HEAD) and sends
1106
+ * from there; a 409 carrying `Upload-Offset` continues from that offset at
1107
+ * once; a receipt Forge defers (503 with `Retry-After`) is asked again,
1108
+ * each time after a longer backoff, without spending a failure until the
1109
+ * idle limit ends the upload as `upload_received_publishing`
1110
+ * (`retryLater`); a gone session is `sessionLost` (`coreCode` naming the
1111
+ * code Core ended it with, when the answer named one); a final refusal of
1112
+ * Core's (`UPLOAD_POOL_FULL`, `UPLOAD_SUPERSEDED`) is `upload_refused` in
1113
+ * Core's words (`coreCode`); any other 4xx ends the upload with the
1114
+ * 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
1115
+ * no answer spends nothing: the upload waits for the network. The OS
1116
+ * transfer engine's range is the exception: the engine chains its own
1117
+ * requests, and its failure ends the call for the caller's own retry.
1118
+ */
789
1119
  async uploadFromUri(options: TusUploadFromUriOptions): Promise<void> {
790
1120
  const fileReader = this.config.fileReader;
791
1121
  if (!fileReader) {
792
1122
  throw new SparkVaultValidationError('fileReader adapter is required for URI uploads');
793
1123
  }
794
1124
 
795
- const source = resolveSessionSource(options);
1125
+ const source = resolveSessionSource(options, this.config.allowedDownloadHostPatterns);
796
1126
  const istk = source.mode === 'create' ? source.parsed.istk : source.istk;
797
1127
  const debug = options.debug;
798
1128
  const cleanUri = options.fileUri.split('#')[0];
799
- let bytesUploaded = 0;
1129
+ const signal = options.abortSignal;
1130
+ const fileSize = options.fileSize;
1131
+ const context: TusErrorContext = { filename: options.filename, fileSize };
1132
+ const budget = createTransferBudget();
800
1133
  // Known up front on resume so a cancel during the HEAD still terminates
801
1134
  // the (existing) session.
802
1135
  let uploadUrl: string | null = source.mode === 'resume' ? source.uploadUrl : null;
1136
+ // The last offset the caller heard, so the end of the upload reports 100%
1137
+ // exactly once however the last byte was confirmed.
1138
+ let reported: number | null = null;
1139
+ const report = (uploaded: number) => {
1140
+ reported = uploaded;
1141
+ options.onProgress?.(uploaded, fileSize);
1142
+ };
803
1143
 
804
1144
  try {
805
- let chunkSize: number;
806
- let sessionUrl: string;
1145
+ let transfer: TusTransfer;
1146
+ let position: TransferPosition;
807
1147
  if (source.mode === 'resume') {
808
- sessionUrl = source.uploadUrl;
809
1148
  debug?.log(`Resuming TUS upload for ${options.filename}`);
810
- const probe = await headTusSession(this.config, source.uploadUrl, istk, options.abortSignal, {
811
- filename: options.filename,
812
- fileSize: options.fileSize,
813
- });
814
- // A session is bound to one file: a different length means the
815
- // persisted session belongs to another file (or this file changed),
816
- // and PATCHing into it would corrupt the ingot. That makes the
817
- // session worthless for this file, which is exactly what
818
- // `sessionLost` means: the caller drops it and starts fresh, the same
819
- // as for a 404/410, instead of retrying a resume that can never work.
820
- if (probe.length !== options.fileSize) {
821
- throw new TusUploadError(
822
- `TUS session Upload-Length (${probe.length}) does not match the file size (${options.fileSize}); the session belongs to a different file`,
823
- { filename: options.filename, fileSize: options.fileSize, phase: 'create', sessionLost: true }
824
- );
825
- }
826
- chunkSize = probe.chunkSize;
827
- bytesUploaded = probe.offset;
828
- debug?.log(`TUS session for ${options.filename} is at offset ${bytesUploaded}/${options.fileSize}`);
829
- if (bytesUploaded >= options.fileSize) {
830
- // Every byte is there, but the finalization the last chunk should
831
- // have triggered may not have happened (that is often why the
832
- // previous attempt failed): ask Forge to finalize and acknowledge.
833
- await confirmTusFinalization(this.config, sessionUrl, istk, options.fileSize, options.abortSignal, {
834
- filename: options.filename,
835
- fileSize: options.fileSize,
1149
+ // A session past the expiry Forge named for it is gone: Forge refuses
1150
+ // every request to it, so asking would only spend one to learn that.
1151
+ const heldExpiry = source.expiresAt;
1152
+ if (heldExpiry !== null && isSessionExpired(heldExpiry)) {
1153
+ throw new TusUploadError(`TUS session expired at ${new Date(heldExpiry).toISOString()}`, {
1154
+ ...context,
1155
+ phase: 'create',
1156
+ sessionLost: true,
836
1157
  });
837
- options.onProgress?.(bytesUploaded, options.fileSize);
838
1158
  }
1159
+ const expiry = new SessionExpiry(source.expiresAt, options.onSessionExpiry);
1160
+ const link: SessionLink = { uploadUrl: source.uploadUrl, istk, context, expiry, signal };
1161
+ const probe = await this.readResumePoint(link, budget, fileSize);
1162
+ transfer = { ...link, expiry, fileSize, chunkSize: probe.chunkSize, budget };
1163
+ position = { offset: checkedServerOffset(probe.offset, transfer, 'create', context), published: false };
1164
+ debug?.log(`TUS session for ${options.filename} is at offset ${position.offset}/${fileSize}`);
839
1165
  } else {
840
1166
  debug?.log(`Starting TUS upload for ${options.filename}`);
841
- const created = await createTusUpload(
842
- this.config,
843
- source.parsed,
844
- options.fileSize,
845
- options.filename,
846
- options.contentType,
847
- options.abortSignal
848
- );
849
- sessionUrl = created.uploadUrl;
850
- uploadUrl = sessionUrl;
851
- chunkSize = created.chunkSize;
1167
+ const created = await this.createSession(source.parsed, fileSize, budget, context, signal);
1168
+ uploadUrl = created.uploadUrl;
1169
+ transfer = {
1170
+ uploadUrl: created.uploadUrl,
1171
+ istk,
1172
+ context,
1173
+ expiry: new SessionExpiry(created.expiresAt, options.onSessionExpiry),
1174
+ signal,
1175
+ fileSize,
1176
+ chunkSize: created.chunkSize,
1177
+ budget,
1178
+ };
1179
+ position = { offset: 0, published: false };
852
1180
  // Before the first PATCH: once bytes are in flight the session is
853
1181
  // worth resuming, so the caller must already hold it.
854
- options.onSessionCreated?.({ uploadUrl: sessionUrl, istk, chunkSize });
1182
+ options.onSessionCreated?.({
1183
+ uploadUrl: created.uploadUrl,
1184
+ istk,
1185
+ chunkSize: created.chunkSize,
1186
+ expiresAt: created.expiresAt,
1187
+ });
855
1188
  }
856
1189
 
857
1190
  // The OS transfer engine, when the app provides one, takes the whole
@@ -863,83 +1196,59 @@ export class MobileTusUploader {
863
1196
  // that resumes the same session re-joins the job the OS is still
864
1197
  // running instead of PATCHing over it.
865
1198
  const backgroundTransfer = options.transport === 'background' ? this.config.backgroundTransfer : undefined;
866
- if (backgroundTransfer && bytesUploaded < options.fileSize) {
867
- ensureNotAborted(options.abortSignal);
1199
+ if (backgroundTransfer && position.offset < fileSize) {
1200
+ ensureNotAborted(signal);
1201
+ transfer.expiry.forget();
868
1202
  const policy = options.transferPolicy ?? {};
869
1203
  const finalOffset = await transferRemainderInBackground(
870
1204
  backgroundTransfer,
871
1205
  {
872
- uploadUrl: sessionUrl,
1206
+ uploadUrl: transfer.uploadUrl,
873
1207
  istk,
874
1208
  fileUri: cleanUri,
875
- fileSize: options.fileSize,
876
- chunkSize,
877
- offset: bytesUploaded,
1209
+ fileSize,
1210
+ chunkSize: transfer.chunkSize,
1211
+ offset: position.offset,
878
1212
  allowsCellularAccess: policy.allowsCellularAccess ?? true,
879
1213
  allowsConstrainedNetworkAccess: policy.allowsConstrainedNetworkAccess ?? true,
880
1214
  },
881
- { filename: options.filename, abortSignal: options.abortSignal, onProgress: options.onProgress }
1215
+ { filename: options.filename, abortSignal: signal, onProgress: offset => report(offset) }
882
1216
  );
883
1217
  // The engine only resolves at the end of the file; anything else is
884
1218
  // an engine that gave up quietly, and an offset past the file is as
885
1219
  // untrustworthy as one short of it. Same treatment as a non-advancing
886
1220
  // PATCH: fail, and let the next attempt ask Forge (HEAD) where the
887
1221
  // session really stands.
888
- if (finalOffset !== options.fileSize) {
1222
+ if (finalOffset !== fileSize) {
889
1223
  throw new TusUploadError(
890
- `Upload stalled: background transfer ended at offset ${finalOffset} of ${options.fileSize}`,
891
- { phase: 'stalled', filename: options.filename, fileSize: options.fileSize }
1224
+ `Upload stalled: background transfer ended at offset ${finalOffset} of ${fileSize}`,
1225
+ { ...context, phase: 'stalled' }
892
1226
  );
893
1227
  }
894
- // An abort that lands after the OS delivered the last byte changes
895
- // nothing: the transfer is complete, and the loop below (with its
896
- // abort check) has nothing left to do. Same rule as the single-chunk
897
- // adapter: a finished upload is never reported as cancelled.
898
- bytesUploaded = finalOffset;
899
- options.onProgress?.(bytesUploaded, options.fileSize);
1228
+ // The engine's last PATCH was answered 2xx at the final offset, which
1229
+ // Forge answers only once the ingot is published. An abort that lands
1230
+ // after the OS delivered the last byte changes nothing: the transfer
1231
+ // is complete, and the loop below (with its abort check) has nothing
1232
+ // left to do. Same rule as the single-chunk adapter: a finished
1233
+ // upload is never reported as cancelled.
1234
+ position = { offset: fileSize, published: true };
900
1235
  }
901
1236
 
902
- while (bytesUploaded < options.fileSize) {
903
- ensureNotAborted(options.abortSignal);
904
- const chunkStart = bytesUploaded;
905
- const chunkLength = Math.min(chunkSize, options.fileSize - chunkStart);
906
-
907
- let newOffset: number;
908
- const backgroundUploader = options.transport === 'background' ? this.config.backgroundUploader : undefined;
909
- if (backgroundUploader && this.fitsOneBackgroundRequest(chunkStart, options.fileSize, chunkSize)) {
910
- // The OS owns this request, so an abort that fires while it is in
911
- // flight cannot stop it. It only ever carries a whole single-chunk
912
- // file, so a 2xx here means the transfer is complete: the loop
913
- // ends and the upload finishes normally (the caller verifies the
914
- // ingot), because a finished upload must never be reported as
915
- // cancelled, and a DELETE now would drop bytes Forge has already
916
- // finalized. Only a response that leaves bytes missing reaches
917
- // the `ensureNotAborted` at the top of the next iteration.
918
- newOffset = await uploadWholeFileInBackground(backgroundUploader, sessionUrl, istk, cleanUri, chunkStart);
919
- } else {
920
- const chunk = await this.readChunk(fileReader, cleanUri, chunkStart, chunkLength);
921
- newOffset = await uploadChunk(
922
- sessionUrl,
923
- istk,
924
- chunk,
925
- chunkStart,
926
- this.config.tusChunkTimeoutMs,
927
- options.abortSignal,
928
- chunkUploaded => options.onProgress?.(chunkStart + chunkUploaded, options.fileSize)
929
- );
930
- }
931
-
932
- if (newOffset <= chunkStart || newOffset > options.fileSize) {
933
- throw new TusUploadError('Upload stalled: server offset did not advance', {
934
- phase: 'stalled',
935
- filename: options.filename,
936
- fileSize: options.fileSize,
937
- });
938
- }
939
-
940
- bytesUploaded = newOffset;
941
- options.onProgress?.(bytesUploaded, options.fileSize);
1237
+ const chunks: ChunkSource = {
1238
+ reader: fileReader,
1239
+ uri: cleanUri,
1240
+ transport: options.transport,
1241
+ onChunkProgress: (offset, chunkBytesUploaded) => report(offset + chunkBytesUploaded),
1242
+ };
1243
+ while (!position.published) {
1244
+ ensureNotAborted(signal);
1245
+ const before = position.offset;
1246
+ position = position.offset < fileSize
1247
+ ? await this.sendChunkAt(transfer, position.offset, chunks)
1248
+ : await this.confirmReceipt(transfer);
1249
+ if (position.offset > before) report(position.offset);
942
1250
  }
1251
+ if (reported !== fileSize) report(fileSize);
943
1252
 
944
1253
  debug?.success(`TUS upload completed for ${options.filename}`);
945
1254
  } catch (err) {
@@ -968,10 +1277,11 @@ export class MobileTusUploader {
968
1277
  // the caller intact so the subscribe / add-on UX fires; never bury them
969
1278
  // in a generic TusUploadError.
970
1279
  if (err instanceof SparkVaultMobileError) throw err;
971
- // Only a fileReader read or an onSessionCreated hook can throw anything
972
- // else here: every request classifies its own failure (tusRequest,
973
- // uploadChunk, the adapters). Those are the caller's errors, wrapped
974
- // with the file context and nothing inferred about them.
1280
+ // Only a fileReader read or a caller's hook (onSessionCreated,
1281
+ // onSessionExpiry, onProgress) can throw anything else here: every
1282
+ // request classifies its own failure (tusRequest, the chunk senders,
1283
+ // the adapters). Those are the caller's errors, wrapped with the file
1284
+ // context and nothing inferred about them.
975
1285
  throw TusUploadError.fromError(error, {
976
1286
  filename: options.filename,
977
1287
  fileSize: options.fileSize,
@@ -979,23 +1289,354 @@ export class MobileTusUploader {
979
1289
  }
980
1290
  }
981
1291
 
1292
+ /**
1293
+ * Open the upload's session. A create the transfer policy calls RETRY (no
1294
+ * answer, 408, 425, 429, a 5xx) is sent again under the upload's budget,
1295
+ * its backoff never shorter than `Retry-After`: Forge refuses such a create
1296
+ * before it claims the token, or hands the claim back on a transient
1297
+ * key-service failure (503), so the same ISTK opens the session on a later
1298
+ * attempt. Every other answer is final and never a lost session, a 401
1299
+ * included: the token was already used, by an earlier create whose answer
1300
+ * was lost, and no Location survives to continue that session from. No
1301
+ * session URL exists yet to spread the retries by, and the ISTK never feeds
1302
+ * one, so they spread by a key of the endpoint (`transferJitterKey`).
1303
+ */
1304
+ private async createSession(
1305
+ parsed: ParsedForgeUrl,
1306
+ fileSize: number,
1307
+ budget: TransferBudget,
1308
+ context: TusErrorContext,
1309
+ signal?: AbortSignal
1310
+ ): Promise<{ uploadUrl: string; chunkSize: number; expiresAt: number | null }> {
1311
+ const jitterKey = transferJitterKey(parsed.tusEndpoint);
1312
+ for (;;) {
1313
+ ensureNotAborted(signal);
1314
+ // Forge's create contract is these three headers: the ingot's name and
1315
+ // type were set by Core when the ingot was created, and Forge publishes
1316
+ // Core's.
1317
+ const answer = await fetchAnswer(
1318
+ this.config,
1319
+ parsed.tusEndpoint,
1320
+ {
1321
+ method: 'POST',
1322
+ headers: {
1323
+ 'Tus-Resumable': TUS_VERSION,
1324
+ 'Upload-Length': String(fileSize),
1325
+ 'X-ISTK': parsed.istk,
1326
+ },
1327
+ signal,
1328
+ timeoutMs: this.config.tusPostTimeoutMs,
1329
+ },
1330
+ 'TUS POST request',
1331
+ context
1332
+ );
1333
+ const outcome = readOutcome(answer, CREATE_REQUEST);
1334
+ if (outcome.verdict === TRANSFER_VERDICT.OK) {
1335
+ const created = readCreatedSession(answer, parsed.tusEndpoint, context);
1336
+ // The session exists: the server confirmed progress.
1337
+ budget.progressed();
1338
+ return created;
1339
+ }
1340
+ const failure = failureOf(answer, outcome, CREATE_REQUEST, context);
1341
+ if (outcome.verdict !== TRANSFER_VERDICT.RETRY) throw failure.error;
1342
+ await this.waitToRetry(budget, failure, jitterKey, signal);
1343
+ }
1344
+ }
1345
+
1346
+ /**
1347
+ * Where a kept session stands, asked of Forge (HEAD) under the upload's
1348
+ * budget, so resume never re-sends bytes the server holds: it is the only
1349
+ * way to learn what a background transfer delivered after the process was
1350
+ * killed, and what a failed attempt left behind. A HEAD that gets no
1351
+ * answer or a transient one is asked again after its backoff; a gone
1352
+ * session is `sessionLost`, so the caller drops it and creates a fresh one
1353
+ * rather than retrying a dead URL. A session is bound to one file: a length that
1354
+ * is not this file's means the persisted session belongs to another file
1355
+ * (or this file changed), and PATCHing into it would corrupt the ingot.
1356
+ * That makes it as worthless as a gone one: `sessionLost` too.
1357
+ */
1358
+ private async readResumePoint(link: SessionLink, budget: TransferBudget, fileSize: number): Promise<TusSessionProbe> {
1359
+ for (;;) {
1360
+ const head = await this.headSession(link, RESUME_HEAD_REQUEST);
1361
+ if ('failure' in head) {
1362
+ await this.waitToRetry(budget, head.failure, link.uploadUrl, link.signal);
1363
+ continue;
1364
+ }
1365
+ if (head.probe.length !== fileSize) {
1366
+ throw new TusUploadError(
1367
+ `TUS session Upload-Length (${head.probe.length}) does not match the file size (${fileSize}); the session belongs to a different file`,
1368
+ { ...link.context, phase: 'create', sessionLost: true }
1369
+ );
1370
+ }
1371
+ return head.probe;
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * Ask Forge once where the session stands (tus HEAD): the probe on a 2xx,
1377
+ * or the failure its answer amounts to. Forge re-drives a received
1378
+ * upload's publication on its HEAD and answers the full offset whatever the
1379
+ * re-drive came to, so an offset at the total proves receipt only: the
1380
+ * receipt PATCH (`confirmReceipt`) is what says published.
1381
+ */
1382
+ private async headSession(
1383
+ link: SessionLink,
1384
+ request: TusRequestKind
1385
+ ): Promise<{ probe: TusSessionProbe } | { failure: TransferFailure }> {
1386
+ ensureNotAborted(link.signal);
1387
+ const answer = await fetchAnswer(
1388
+ this.config,
1389
+ link.uploadUrl,
1390
+ {
1391
+ method: 'HEAD',
1392
+ headers: {
1393
+ 'Tus-Resumable': TUS_VERSION,
1394
+ 'X-ISTK': link.istk,
1395
+ },
1396
+ signal: link.signal,
1397
+ timeoutMs: HEAD_AND_RECEIPT_TIMEOUT_MS,
1398
+ },
1399
+ 'TUS HEAD request',
1400
+ link.context
1401
+ );
1402
+ link.expiry?.read(answer);
1403
+ const outcome = readOutcome(answer, request);
1404
+ if (outcome.verdict === TRANSFER_VERDICT.OK) {
1405
+ return { probe: readSessionProbe(answer, request.phase, link.context) };
1406
+ }
1407
+ return { failure: failureOf(answer, outcome, request, link.context) };
1408
+ }
1409
+
1410
+ /**
1411
+ * Send the chunk that starts at `offset`. Answers where the upload stands
1412
+ * next: past the chunk on its 2xx, which for the last chunk is also the
1413
+ * publication receipt (Forge answers it only once the ingot is published),
1414
+ * or wherever the server says after a failure.
1415
+ */
1416
+ private async sendChunkAt(transfer: TusTransfer, offset: number, chunks: ChunkSource): Promise<TransferPosition> {
1417
+ const answer = await this.sendChunk(transfer, offset, chunks);
1418
+ transfer.expiry.read(answer);
1419
+ const outcome = readOutcome(answer, CHUNK_REQUEST);
1420
+ if (outcome.verdict !== TRANSFER_VERDICT.OK) {
1421
+ return this.positionAfterFailure(transfer, failureOf(answer, outcome, CHUNK_REQUEST, transfer.context), offset);
1422
+ }
1423
+ // Forge answers every PATCH with Upload-Offset, so a 2xx without a
1424
+ // readable one is not Forge (a captive portal's 200 page is the classic
1425
+ // case): it reports no advance, and the stall guard fails the attempt
1426
+ // rather than crediting bytes that may never have arrived. The parse is
1427
+ // strict because parseInt would turn a garbage header into NaN, which
1428
+ // slips through the guard (every comparison with NaN is false) and would
1429
+ // end the upload as a success; an offset past the file is as
1430
+ // untrustworthy as one short of it.
1431
+ const confirmed = parseIntegerHeader(answer.header('Upload-Offset'), 0) ?? offset;
1432
+ if (confirmed <= offset || confirmed > transfer.fileSize) {
1433
+ throw new TusUploadError('Upload stalled: server offset did not advance', {
1434
+ ...transfer.context,
1435
+ phase: 'stalled',
1436
+ });
1437
+ }
1438
+ transfer.budget.progressed();
1439
+ return { offset: confirmed, published: confirmed === transfer.fileSize };
1440
+ }
1441
+
1442
+ /** PATCH the chunk at `offset` on the transport the upload rides, resolving with Forge's answer. */
1443
+ private async sendChunk(transfer: TusTransfer, offset: number, chunks: ChunkSource): Promise<TransferAnswer> {
1444
+ const backgroundUploader = chunks.transport === 'background' ? this.config.backgroundUploader : undefined;
1445
+ if (backgroundUploader && this.fitsOneBackgroundRequest(offset, transfer.fileSize, transfer.chunkSize)) {
1446
+ // The OS owns this request, so an abort that fires while it is in
1447
+ // flight cannot stop it, and it may finish while the app is away. It
1448
+ // only ever carries a whole single-chunk file, so a 2xx here means the
1449
+ // transfer is complete: the loop ends and the upload finishes normally
1450
+ // (the caller verifies the ingot), because a finished upload must never
1451
+ // be reported as cancelled, and a DELETE now would drop bytes Forge has
1452
+ // already finalized. Only an answer that leaves bytes missing reaches
1453
+ // the abort check before the next request.
1454
+ transfer.expiry.forget();
1455
+ return sendWholeFileInBackground(backgroundUploader, transfer, chunks.uri, offset);
1456
+ }
1457
+ const chunkLength = Math.min(transfer.chunkSize, transfer.fileSize - offset);
1458
+ const chunk = await this.readChunk(chunks.reader, chunks.uri, offset, chunkLength);
1459
+ return sendChunkOverXhr(
1460
+ transfer,
1461
+ chunk,
1462
+ offset,
1463
+ this.config.tusChunkTimeoutMs,
1464
+ chunkBytesUploaded => chunks.onChunkProgress(offset, chunkBytesUploaded)
1465
+ );
1466
+ }
1467
+
1468
+ /**
1469
+ * Where the upload stands after a failed chunk PATCH, in the server's
1470
+ * words: a 409's own `Upload-Offset` at once, otherwise the session's HEAD
1471
+ * after the backoff, the HEAD itself retried the same way out of the same
1472
+ * budget. A PATCH whose answer was lost may have landed, and one sent at a
1473
+ * stale offset is refused, so no chunk is ever sent again at an offset the
1474
+ * server did not just name.
1475
+ */
1476
+ private async positionAfterFailure(
1477
+ transfer: TusTransfer,
1478
+ failure: TransferFailure,
1479
+ sentOffset: number
1480
+ ): Promise<TransferPosition> {
1481
+ let current = failure;
1482
+ for (;;) {
1483
+ if (current.outcome.verdict === TRANSFER_VERDICT.RESYNC) {
1484
+ return this.resyncedPosition(transfer, current, sentOffset);
1485
+ }
1486
+ await this.waitToRetry(transfer.budget, current, transfer.uploadUrl, transfer.signal);
1487
+ const head = await this.headSession(transfer, OFFSET_HEAD_REQUEST);
1488
+ if ('failure' in head) {
1489
+ current = head.failure;
1490
+ continue;
1491
+ }
1492
+ if (head.probe.length !== transfer.fileSize) {
1493
+ throw new TusUploadError(
1494
+ `TUS session Upload-Length (${head.probe.length}) does not match the file size (${transfer.fileSize}); the session belongs to a different file`,
1495
+ { ...transfer.context, phase: 'upload', sessionLost: true }
1496
+ );
1497
+ }
1498
+ const offset = checkedServerOffset(head.probe.offset, transfer, 'upload', transfer.context);
1499
+ // Bytes reached the server despite the failure, so the transfer is
1500
+ // alive: the budget starts over rather than being spent down across
1501
+ // the many chunks of a large file.
1502
+ if (offset > sentOffset) transfer.budget.progressed();
1503
+ return { offset, published: false };
1504
+ }
1505
+ }
1506
+
1507
+ /**
1508
+ * Continue from the offset a 409 named, at once and without a HEAD: the
1509
+ * server just said where the session stands. Past what this upload sent,
1510
+ * the server holds more than it knew, which is progress; anywhere else the
1511
+ * answer contradicts the request, so it spends the budget like any failure
1512
+ * and repeating it ends the upload.
1513
+ */
1514
+ private resyncedPosition(transfer: TusTransfer, failure: TransferFailure, sentOffset: number): TransferPosition {
1515
+ const offset = checkedServerOffset(failure.outcome.offset, transfer, 'upload', transfer.context);
1516
+ if (offset > sentOffset) {
1517
+ transfer.budget.progressed();
1518
+ } else if (!transfer.budget.failed(TRANSFER_VERDICT.RESYNC)) {
1519
+ throw failure.error;
1520
+ }
1521
+ return { offset, published: false };
1522
+ }
1523
+
1524
+ /**
1525
+ * Every byte is on the server but publication is unconfirmed: the final
1526
+ * chunk's answer was lost or deferred, or a HEAD's offset proved receipt
1527
+ * only. A zero-length PATCH at the final offset re-drives publication if it
1528
+ * did not finish (a transient Core or billing failure after the last chunk)
1529
+ * and answers 204 once the ingot is published, which is the receipt every
1530
+ * upload resolves on. While another request finalizes it answers 409 with
1531
+ * `Retry-After`, asked again as a failure; while the publication is
1532
+ * deferred, 503 with `Retry-After`, asked again, each time after a longer
1533
+ * backoff, without spending a failure until the idle limit ends the upload
1534
+ * as `upload_received_publishing` (`retryLater`); a 503 naming no
1535
+ * `Retry-After` is no deferral of Forge's, asked again as a failure. A 409
1536
+ * naming an earlier offset means bytes are missing, and the upload
1537
+ * continues from there. Forge answers it only after its own call to Core,
1538
+ * so it waits `HEAD_AND_RECEIPT_TIMEOUT_MS`, as a HEAD does.
1539
+ */
1540
+ private async confirmReceipt(transfer: TusTransfer): Promise<TransferPosition> {
1541
+ ensureNotAborted(transfer.signal);
1542
+ const answer = await fetchAnswer(
1543
+ this.config,
1544
+ transfer.uploadUrl,
1545
+ {
1546
+ method: 'PATCH',
1547
+ headers: {
1548
+ 'Tus-Resumable': TUS_VERSION,
1549
+ 'Upload-Offset': String(transfer.fileSize),
1550
+ 'Content-Type': 'application/offset+octet-stream',
1551
+ 'X-ISTK': transfer.istk,
1552
+ },
1553
+ signal: transfer.signal,
1554
+ timeoutMs: HEAD_AND_RECEIPT_TIMEOUT_MS,
1555
+ },
1556
+ 'TUS finalization request',
1557
+ transfer.context
1558
+ );
1559
+ transfer.expiry.read(answer);
1560
+ const outcome = readOutcome(answer, RECEIPT_REQUEST);
1561
+ if (outcome.verdict === TRANSFER_VERDICT.OK) {
1562
+ return { offset: transfer.fileSize, published: true };
1563
+ }
1564
+ const failure = failureOf(answer, outcome, RECEIPT_REQUEST, transfer.context);
1565
+ if (outcome.verdict === TRANSFER_VERDICT.RESYNC) {
1566
+ return this.resyncedPosition(transfer, failure, transfer.fileSize);
1567
+ }
1568
+ await this.waitToRetry(transfer.budget, failure, transfer.uploadUrl, transfer.signal);
1569
+ return { offset: transfer.fileSize, published: false };
1570
+ }
1571
+
1572
+ /**
1573
+ * Wait out the backoff of a failed request, spending a retry of the
1574
+ * upload's budget, or of a deferred receipt, spending none for as long as
1575
+ * the idle limit allows; never shorter than the answer's `Retry-After`.
1576
+ * Throws the failure no retry follows (a gone session, the server's
1577
+ * verdict), and the last one, or the deferral, once the budget is spent. A
1578
+ * request that got no answer while the device is offline spends nothing:
1579
+ * the upload waits for the network (or the caller's abort), the idle clock
1580
+ * starts over when it is back, and the next request asks again at once.
1581
+ */
1582
+ private async waitToRetry(
1583
+ budget: TransferBudget,
1584
+ failure: TransferFailure,
1585
+ jitterKey: string,
1586
+ signal?: AbortSignal
1587
+ ): Promise<void> {
1588
+ const { verdict, retryAfterMs } = failure.outcome;
1589
+ if (verdict === TRANSFER_VERDICT.DEFERRED) {
1590
+ if (!budget.deferred(retryAfterMs)) throw failure.error;
1591
+ await abortableDelay(retryDelayMs({ attempt: budget.attempt, retryAfterMs, jitterKey }), signal);
1592
+ return;
1593
+ }
1594
+ if (verdict !== TRANSFER_VERDICT.RETRY) {
1595
+ throw failure.error;
1596
+ }
1597
+ const connectivity = this.config.connectivity;
1598
+ if (failure.unanswered && connectivity && !connectivity.isOnline()) {
1599
+ await waitUntilOnline(connectivity, signal);
1600
+ budget.alive();
1601
+ return;
1602
+ }
1603
+ if (!budget.failed(verdict, retryAfterMs)) {
1604
+ throw failure.error;
1605
+ }
1606
+ const delayMs = retryDelayMs({
1607
+ attempt: budget.attempt,
1608
+ retryAfterMs,
1609
+ jitterKey,
1610
+ });
1611
+ await abortableDelay(delayMs, signal);
1612
+ }
1613
+
982
1614
  /**
983
1615
  * Where a persisted session stands, or `null` when Forge no longer has it
984
- * (404/410 — expired or terminated). Any other failure throws with
985
- * `sessionLost` false (a 5xx or timeout says nothing about the session).
986
- * The probe reports the session's own length; whether that length is the
987
- * caller's file is for the caller (or `uploadFromUri`'s resume, which
988
- * throws `sessionLost` on a mismatch) to judge. Lets a caller reconcile
989
- * sessions on relaunch (a completed one just needs its ingot verified; a
990
- * lost one needs a fresh create) before spending an upload slot on it.
1616
+ * (404/410 — expired or terminated), from one HEAD and no retry. Any other
1617
+ * failure throws with `sessionLost` false (a 5xx or a timeout says nothing
1618
+ * against the session), carrying the answer's `retryAfterMs`; a final
1619
+ * refusal Core decided on the HEAD's re-drive (`UPLOAD_POOL_FULL` or
1620
+ * `UPLOAD_SUPERSEDED` in `X-Upload-Refusal`) throws as `upload_refused`,
1621
+ * which no fresh create remedies, and any other code Core ended the session
1622
+ * with reads as gone (null). An offset at
1623
+ * the session's length proves every byte received, not published: a
1624
+ * resume of it sends the receipt. The probe reports the session's own length;
1625
+ * whether that length is the caller's file is for the caller (or
1626
+ * `uploadFromUri`'s resume, which throws `sessionLost` on a mismatch) to
1627
+ * judge. Lets a caller reconcile sessions on relaunch (a completed one just
1628
+ * needs its ingot verified; a lost one needs a fresh create) before
1629
+ * spending an upload slot on it.
1630
+ *
1631
+ * @throws SparkVaultValidationError, before any request, when `uploadUrl`
1632
+ * is not an upload session on an allowed transfer host.
991
1633
  */
992
1634
  async probeSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
993
- try {
994
- return await headTusSession(this.config, session.uploadUrl, session.istk);
995
- } catch (err) {
996
- if (err instanceof TusUploadError && err.sessionLost) return null;
997
- throw err;
998
- }
1635
+ const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
1636
+ const head = await this.headSession({ uploadUrl, istk: session.istk, context: {}, expiry: null }, RESUME_HEAD_REQUEST);
1637
+ if ('probe' in head) return head.probe;
1638
+ if (head.failure.outcome.verdict === TRANSFER_VERDICT.GONE) return null;
1639
+ throw head.failure.error;
999
1640
  }
1000
1641
 
1001
1642
  /**
@@ -1021,17 +1662,22 @@ export class MobileTusUploader {
1021
1662
  * Forge sent) so the caller can retry later. Not bound to any
1022
1663
  * abort signal: a terminate is the last thing a caller does with a
1023
1664
  * session, never something to cancel.
1665
+ *
1666
+ * @throws SparkVaultValidationError, before any request, when `uploadUrl`
1667
+ * is not an upload session on an allowed transfer host.
1024
1668
  */
1025
1669
  async terminateSession(session: { uploadUrl: string; istk: string }): Promise<void> {
1670
+ const uploadUrl = readKeptSessionUrl(session.uploadUrl, this.config.allowedDownloadHostPatterns);
1671
+
1026
1672
  // A terminate is the one place the SDK knows the OS job must die with
1027
1673
  // the session: a DELETE under a job the engine is still chaining would
1028
1674
  // leave it PATCHing a gone session until it fails on its own.
1029
1675
  const backgroundTransfer = this.config.backgroundTransfer;
1030
1676
  if (backgroundTransfer) {
1031
- await this.cancelBackgroundTransfer(backgroundTransfer, session.uploadUrl);
1677
+ await this.cancelBackgroundTransfer(backgroundTransfer, uploadUrl);
1032
1678
  }
1033
1679
 
1034
- const response = await this.requestTerminate(session.uploadUrl, session.istk);
1680
+ const response = await this.requestTerminate(uploadUrl, session.istk);
1035
1681
 
1036
1682
  if (response.ok || isSessionGone(response.status)) return;
1037
1683