@gentleduck/upload 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -1,52 +1,74 @@
1
+ import { UploadEngineError } from '../../errors'
1
2
  import { isRecord } from '../../utils/guards'
2
3
 
3
- export function validateIntent(intent: unknown, strategy: string): Error | null {
4
+ function isFinitePositive(v: unknown): v is number {
5
+ return typeof v === 'number' && Number.isFinite(v) && v > 0
6
+ }
7
+
8
+ /**
9
+ * Validates the structure and properties of an upload intent returned from the backend.
10
+ *
11
+ * Ensures all required fields are present and valid for the specified strategy.
12
+ *
13
+ * @param intent - The intent payload to validate.
14
+ * @param strategy - The strategy discriminant matching the intent.
15
+ * @returns An `UploadEngineError` if invalid, or `null` if the intent is well-formed.
16
+ */
17
+ export function validateIntent(intent: unknown, strategy: string): UploadEngineError | null {
4
18
  if (!isRecord(intent)) {
5
- return new Error('Invalid intent: must be an object')
19
+ return new UploadEngineError('validation_failed', { message: 'Invalid intent: must be an object' })
6
20
  }
7
21
 
8
- // Check required fields
9
- if (typeof intent.strategy !== 'string') {
10
- return new Error('Invalid intent: missing or invalid strategy field')
22
+ if (typeof intent['strategy'] !== 'string') {
23
+ return new UploadEngineError('validation_failed', { message: 'Invalid intent: missing or invalid strategy field' })
11
24
  }
12
25
 
13
- if (intent.strategy !== strategy) {
14
- return new Error(`Invalid intent: strategy mismatch (expected ${strategy}, got ${intent.strategy})`)
26
+ if (intent['strategy'] !== strategy) {
27
+ return new UploadEngineError('validation_failed', {
28
+ message: `Invalid intent: strategy mismatch (expected ${strategy}, got ${intent['strategy']})`,
29
+ })
15
30
  }
16
31
 
17
- if (typeof intent.fileId !== 'string' || !intent.fileId) {
18
- return new Error('Invalid intent: missing or invalid fileId')
32
+ if (typeof intent['fileId'] !== 'string' || !intent['fileId']) {
33
+ return new UploadEngineError('validation_failed', { message: 'Invalid intent: missing or invalid fileId' })
19
34
  }
20
35
 
21
- // Strategy-specific validation
22
36
  if (strategy === 'post') {
23
- // POST strategy requires url and fields
24
- if (typeof intent.url !== 'string' || !intent.url) {
25
- return new Error('Invalid post intent: missing or invalid url')
37
+ if (typeof intent['url'] !== 'string' || !intent['url']) {
38
+ return new UploadEngineError('validation_failed', { message: 'Invalid post intent: missing or invalid url' })
26
39
  }
27
40
  try {
28
- const url = new URL(intent.url)
41
+ const url = new URL(intent['url'] as string)
29
42
  if (!['http:', 'https:'].includes(url.protocol)) {
30
- return new Error('Invalid post intent: url must use http or https protocol')
43
+ return new UploadEngineError('validation_failed', {
44
+ message: 'Invalid post intent: url must use http or https protocol',
45
+ })
31
46
  }
32
47
  } catch {
33
- return new Error('Invalid post intent: url is not a valid URL')
48
+ return new UploadEngineError('validation_failed', { message: 'Invalid post intent: url is not a valid URL' })
34
49
  }
35
- if (!intent.fields || typeof intent.fields !== 'object') {
36
- return new Error('Invalid post intent: missing or invalid fields')
50
+ if (!intent['fields'] || typeof intent['fields'] !== 'object') {
51
+ return new UploadEngineError('validation_failed', { message: 'Invalid post intent: missing or invalid fields' })
37
52
  }
38
53
  } else if (strategy === 'multipart') {
39
- // Multipart strategy requires uploadId and partSize
40
- if (typeof intent.uploadId !== 'string' || !intent.uploadId) {
41
- return new Error('Invalid multipart intent: missing or invalid uploadId')
54
+ if (typeof intent['uploadId'] !== 'string' || !intent['uploadId']) {
55
+ return new UploadEngineError('validation_failed', {
56
+ message: 'Invalid multipart intent: missing or invalid uploadId',
57
+ })
42
58
  }
43
- if (typeof intent.partSize !== 'number' || intent.partSize <= 0) {
44
- return new Error('Invalid multipart intent: missing or invalid partSize')
59
+ // NaN-bypass defense: typeof NaN === 'number' and NaN > 0 is false, so a
60
+ // plain `partSize <= 0` check would let NaN through. isFinitePositive rejects it.
61
+ if (!isFinitePositive(intent['partSize'])) {
62
+ return new UploadEngineError('validation_failed', {
63
+ message: 'Invalid multipart intent: missing or invalid partSize',
64
+ })
45
65
  }
46
- // Parts array is optional (can be fetched later)
47
- if ('parts' in intent && intent.parts !== undefined) {
48
- if (!Array.isArray(intent.parts)) {
49
- return new Error('Invalid multipart intent: parts must be an array if provided')
66
+ // Parts array is optional (can be fetched later).
67
+ if ('parts' in intent && intent['parts'] !== undefined) {
68
+ if (!Array.isArray(intent['parts'])) {
69
+ return new UploadEngineError('validation_failed', {
70
+ message: 'Invalid multipart intent: parts must be an array if provided',
71
+ })
50
72
  }
51
73
  }
52
74
  }
@@ -1,16 +1,25 @@
1
- import type { UploadConfig } from '../../client'
2
- import type { RejectReason } from '../../contracts'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { Engine } from '../engine.types'
3
3
  import { validateFile } from './file'
4
4
 
5
+ /**
6
+ * Validates an intent object from the backend.
7
+ * Ensures required fields are present and URLs are valid.
8
+ *
9
+ * @param intent - Intent object to validate
10
+ * @param strategy - Expected strategy type (from the intent itself)
11
+ *
12
+ * @returns {Error | null} Error if validation fails, null if valid
13
+ */
5
14
  export function validateFileList<P extends string>(
6
15
  files: File[],
7
16
  purpose: P,
8
- config: UploadConfig<P>,
17
+ config: Engine.Config<P>,
9
18
  existingCount: number = 0,
10
- ): { valid: File[]; rejected: Array<{ file: File; reason: RejectReason }> } {
19
+ ): { valid: File[]; rejected: Array<{ file: File; reason: Contracts.Validation.Rejection }> } {
11
20
  const rules = config.validation?.[purpose]
12
21
  const valid: File[] = []
13
- const rejected: Array<{ file: File; reason: RejectReason }> = []
22
+ const rejected: Array<{ file: File; reason: Contracts.Validation.Rejection }> = []
14
23
 
15
24
  // Calculate remaining slots based on maxFiles limit
16
25
  const maxFiles = rules?.maxFiles
@@ -38,13 +47,3 @@ export function validateFileList<P extends string>(
38
47
 
39
48
  return { valid, rejected }
40
49
  }
41
-
42
- /**
43
- * Validates an intent object from the backend.
44
- * Ensures required fields are present and URLs are valid.
45
- *
46
- * @param intent - Intent object to validate
47
- * @param strategy - Expected strategy type (from the intent itself)
48
- *
49
- * @returns {Error | null} Error if validation fails, null if valid
50
- */
@@ -0,0 +1,39 @@
1
+ import type { Contracts } from '../../contracts'
2
+ import { __mimeWarnings, mimeMatches, sniffMime } from '../../utils/mime-sniff'
3
+
4
+ /**
5
+ * Cross-check the claimed `file.type` against the file's magic bytes. Reads the
6
+ * first 16 bytes only (the longest signature {@link sniffMime} handles is 12).
7
+ *
8
+ * @returns A `mime_mismatch` reject reason when `strict` is `true` and the
9
+ * sniff disagrees. Returns `null` (allow) when the sniff is inconclusive, the
10
+ * claim matches, or `strict` is `false` — in the non-strict case a one-time
11
+ * warning is logged per mismatched pair. Empty files resolve to `null`.
12
+ */
13
+ export async function validateMimeSignature(
14
+ file: File,
15
+ strict: boolean,
16
+ ): Promise<Contracts.Validation.Rejection | null> {
17
+ if (file.size === 0) return null
18
+ // 16 bytes covers every recognised signature (longest is 12).
19
+ const head = await file.slice(0, 16).arrayBuffer()
20
+ const sniffed = sniffMime(new Uint8Array(head))
21
+ if (sniffed === null) return null
22
+ const claimed = file.type || ''
23
+ if (mimeMatches(claimed, sniffed)) return null
24
+
25
+ if (strict) {
26
+ return { code: 'mime_mismatch', claimed: claimed || '<empty>', sniffed }
27
+ }
28
+
29
+ const key = `${claimed || '<empty>'}::${sniffed}`
30
+ if (!__mimeWarnings.has(key)) {
31
+ __mimeWarnings.add(key)
32
+ // Filename is omitted from the warning: it is untrusted input.
33
+ console.warn(
34
+ `[duck-upload] MIME mismatch: claimed "${claimed || '<empty>'}" but bytes look like "${sniffed}". ` +
35
+ 'Set `strictMimeMatch: true` to reject these files.',
36
+ )
37
+ }
38
+ return null
39
+ }
@@ -0,0 +1,271 @@
1
+ import type { Contracts } from './contracts'
2
+
3
+ /**
4
+ * Structured extra context properties for upload errors.
5
+ * @author wildduck2 <https://github.com/wildduck2>
6
+ */
7
+ export type UploadErrorContext = Record<string, unknown>
8
+
9
+ const STATIC_MESSAGES: Record<string, string> = {
10
+ intent_failed: 'upload intent request failed',
11
+ upload_failed: 'upload transfer failed',
12
+ complete_failed: 'upload finalize failed',
13
+ validation_failed: 'file validation failed',
14
+ strategy_missing: 'upload strategy not found',
15
+ aborted: 'upload aborted',
16
+ network: 'network error occurred',
17
+ http: 'http request failed',
18
+ timeout: 'request timed out',
19
+ auth: 'authentication failed',
20
+ rate_limit: 'rate limit exceeded',
21
+ server: 'server error occurred',
22
+ unknown: 'unknown upload error',
23
+ }
24
+
25
+ /**
26
+ * Base typed error thrown by the upload engine.
27
+ *
28
+ * Always carries a stable error classification `code` and an immutable, static
29
+ * `message`. Attic strings (like raw input details) live under `context` or subclass-specific fields.
30
+ * @author wildduck2 <https://github.com/wildduck2>
31
+ */
32
+ export class UploadEngineError extends Error {
33
+ /** Stable machine-readable error code. */
34
+ readonly code: string
35
+ /** Structured context details. */
36
+ readonly context?: UploadErrorContext
37
+ /** Original error, if any, mapped per ES2022. */
38
+ override readonly cause?: unknown
39
+ /** Flag specifying whether the error recovery system should schedule retries. */
40
+ readonly retryable: boolean
41
+
42
+ constructor(
43
+ code: string,
44
+ init?: {
45
+ message?: string | undefined
46
+ context?: UploadErrorContext | undefined
47
+ cause?: unknown
48
+ retryable?: boolean | undefined
49
+ },
50
+ ) {
51
+ const msg = init?.message ?? STATIC_MESSAGES[code] ?? `upload error (${code})`
52
+ super(msg)
53
+ this.name = 'UploadEngineError'
54
+ this.code = code
55
+ this.retryable = init?.retryable ?? false
56
+ if (init?.context) this.context = init.context
57
+ if (init?.cause !== undefined) this.cause = init.cause
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Error thrown when file validation rules fail.
63
+ * @author wildduck2 <https://github.com/wildduck2>
64
+ */
65
+ export class UploadValidationError extends UploadEngineError {
66
+ /** Structured rejection reason details. */
67
+ readonly reason: Contracts.Validation.Rejection
68
+
69
+ constructor(
70
+ reason: Contracts.Validation.Rejection,
71
+ message?: string,
72
+ init?: { cause?: unknown; context?: UploadErrorContext },
73
+ ) {
74
+ super('validation_failed', {
75
+ message: message ?? `File validation failed: ${reason.code}`,
76
+ retryable: false,
77
+ cause: init?.cause,
78
+ context: init?.context,
79
+ })
80
+ this.name = 'UploadValidationError'
81
+ this.reason = reason
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Error thrown when an upload strategy identifier is not registered.
87
+ * @author wildduck2 <https://github.com/wildduck2>
88
+ */
89
+ export class UploadStrategyMissingError extends UploadEngineError {
90
+ /** Unregistered strategy identifier. */
91
+ readonly strategy: string
92
+
93
+ constructor(strategy: string, message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
94
+ super('strategy_missing', {
95
+ message: message ?? `Upload strategy "${strategy}" is not registered`,
96
+ retryable: false,
97
+ cause: init?.cause,
98
+ context: init?.context,
99
+ })
100
+ this.name = 'UploadStrategyMissingError'
101
+ this.strategy = strategy
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Error thrown when an upload is aborted (paused or canceled).
107
+ * @author wildduck2 <https://github.com/wildduck2>
108
+ */
109
+ export class UploadAbortError extends UploadEngineError {
110
+ /** Reason context for the abort. */
111
+ readonly reason: 'pause' | 'cancel' | 'unknown'
112
+
113
+ constructor(
114
+ reason: 'pause' | 'cancel' | 'unknown',
115
+ message?: string,
116
+ init?: { cause?: unknown; context?: UploadErrorContext },
117
+ ) {
118
+ super('aborted', {
119
+ message: message ?? 'Upload aborted',
120
+ retryable: false,
121
+ cause: init?.cause,
122
+ context: init?.context,
123
+ })
124
+ this.name = 'UploadAbortError'
125
+ this.reason = reason
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Error thrown on transport-level connection or fetch failures.
131
+ * @author wildduck2 <https://github.com/wildduck2>
132
+ */
133
+ export class UploadNetworkError extends UploadEngineError {
134
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
135
+ super('network', {
136
+ message: message ?? 'Network error occurred',
137
+ retryable: true,
138
+ cause: init?.cause,
139
+ context: init?.context,
140
+ })
141
+ this.name = 'UploadNetworkError'
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Error thrown when a non-2xx HTTP status response is returned.
147
+ * @author wildduck2 <https://github.com/wildduck2>
148
+ */
149
+ export class UploadHttpError extends UploadEngineError {
150
+ /** HTTP response status code. */
151
+ readonly status: number
152
+ /** HTTP response status text message. */
153
+ readonly statusText?: string
154
+
155
+ constructor(
156
+ status: number,
157
+ message?: string,
158
+ init?: {
159
+ statusText?: string | undefined
160
+ cause?: unknown
161
+ context?: UploadErrorContext | undefined
162
+ retryable?: boolean | undefined
163
+ },
164
+ ) {
165
+ const isRetryable = init?.retryable ?? (status >= 500 || status === 429)
166
+ super('http', {
167
+ message: message ?? `HTTP request failed with status ${status}`,
168
+ retryable: isRetryable,
169
+ cause: init?.cause,
170
+ context: init?.context,
171
+ })
172
+ this.name = 'UploadHttpError'
173
+ this.status = status
174
+ if (init?.statusText) this.statusText = init.statusText
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Error thrown when a timeout occurs during network operations.
180
+ * @author wildduck2 <https://github.com/wildduck2>
181
+ */
182
+ export class UploadTimeoutError extends UploadEngineError {
183
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
184
+ super('timeout', {
185
+ message: message ?? 'Request timed out',
186
+ retryable: true,
187
+ cause: init?.cause,
188
+ context: init?.context,
189
+ })
190
+ this.name = 'UploadTimeoutError'
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Error thrown on authentication / authorization failures.
196
+ * @author wildduck2 <https://github.com/wildduck2>
197
+ */
198
+ export class UploadAuthError extends UploadEngineError {
199
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
200
+ super('auth', {
201
+ message: message ?? 'Authentication failed',
202
+ retryable: false,
203
+ cause: init?.cause,
204
+ context: init?.context,
205
+ })
206
+ this.name = 'UploadAuthError'
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Error thrown when the endpoint indicates rate limits are exceeded.
212
+ * @author wildduck2 <https://github.com/wildduck2>
213
+ */
214
+ export class UploadRateLimitError extends UploadEngineError {
215
+ /** Optional delay value recommended by the server before retry. */
216
+ readonly retryAfterMs?: number
217
+
218
+ constructor(message?: string, init?: { retryAfterMs?: number; cause?: unknown; context?: UploadErrorContext }) {
219
+ super('rate_limit', {
220
+ message: message ?? 'Rate limit exceeded',
221
+ retryable: true,
222
+ cause: init?.cause,
223
+ context: init?.context,
224
+ })
225
+ this.name = 'UploadRateLimitError'
226
+ if (init?.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Error thrown when custom backend/server validation errors are reported.
232
+ * @author wildduck2 <https://github.com/wildduck2>
233
+ */
234
+ export class UploadServerError extends UploadEngineError {
235
+ /** Server-defined diagnostic code. */
236
+ readonly serverCode?: string
237
+
238
+ constructor(message?: string, init?: { serverCode?: string; cause?: unknown; context?: UploadErrorContext }) {
239
+ super('server', {
240
+ message: message ?? 'Server error occurred',
241
+ retryable: true,
242
+ cause: init?.cause,
243
+ context: init?.context,
244
+ })
245
+ this.name = 'UploadServerError'
246
+ if (init?.serverCode) this.serverCode = init.serverCode
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Fallback error class for unclassified or legacy throws.
252
+ * @author wildduck2 <https://github.com/wildduck2>
253
+ */
254
+ export class UploadUnknownError extends UploadEngineError {
255
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
256
+ super('unknown', {
257
+ message: message ?? 'Unknown upload error',
258
+ retryable: false,
259
+ cause: init?.cause,
260
+ context: init?.context,
261
+ })
262
+ this.name = 'UploadUnknownError'
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Runtime error stored on items and passed through engine events.
268
+ * Plain contract shapes and {@link UploadEngineError} subclasses are both valid.
269
+ * @author wildduck2 <https://github.com/wildduck2>
270
+ */
271
+ export type UploadError = Contracts.Errors.Error | UploadEngineError
package/src/core/index.ts CHANGED
@@ -1,5 +1,28 @@
1
- export * from './client'
2
- export * from './contracts'
3
- export * from './engine'
4
- export * from './persistence'
5
- export * from './utils'
1
+ export { resolveUploadConfig } from './client'
2
+ export type { Contracts, Transport } from './contracts'
3
+ export { createFetchTransport, createXHRTransport } from './contracts'
4
+ export type { Store } from './engine'
5
+ export { createUploadStore, Engine } from './engine'
6
+ export type { UploadError, UploadErrorContext } from './errors'
7
+ export {
8
+ UploadAbortError,
9
+ UploadAuthError,
10
+ UploadEngineError,
11
+ UploadHttpError,
12
+ UploadNetworkError,
13
+ UploadRateLimitError,
14
+ UploadServerError,
15
+ UploadStrategyMissingError,
16
+ UploadTimeoutError,
17
+ UploadUnknownError,
18
+ UploadValidationError,
19
+ } from './errors'
20
+ export type { UploadPersistence } from './persistence'
21
+ export {
22
+ createMemoryAdapter,
23
+ deserializeSnapshot,
24
+ IndexedDBAdapter,
25
+ LocalStorageAdapter,
26
+ MemoryAdapter,
27
+ serializeSnapshot,
28
+ } from './persistence'
@@ -0,0 +1,100 @@
1
+ /**
2
+ * NaN-bypass defense for the persistence deserializer. A persisted
3
+ * `size: NaN`, `lastModified: NaN`, `pct: NaN`, or negative byte count
4
+ * would land in the deserialized state and break downstream math
5
+ * (`uploadedBytes / totalBytes` → NaN, comparisons silently false).
6
+ * Every numeric field must pass `Number.isFinite` + non-negative.
7
+ */
8
+
9
+ import { describe, expect, test } from 'vitest'
10
+ import { deserializeSnapshot } from '../persistence'
11
+
12
+ function makeOpts(strategy = 'post') {
13
+ return {
14
+ isPurpose: (v: string): v is string => typeof v === 'string',
15
+ isIntent: (v: unknown): v is { strategy: string; fileId: string } =>
16
+ typeof v === 'object' && v !== null && 'strategy' in v,
17
+ hasStrategy: (v: string) => v === strategy,
18
+ }
19
+ }
20
+
21
+ const okItem = {
22
+ id: 'u1',
23
+ purpose: 'avatar',
24
+ status: 'paused',
25
+ file: { name: 'a.txt', size: 100, type: 'text/plain', lastModified: 1700000000 },
26
+ intent: { strategy: 'post', fileId: 'f' },
27
+ cursor: { strategy: 'post' },
28
+ progress: { uploadedBytes: 50, totalBytes: 100, pct: 50 },
29
+ }
30
+
31
+ describe('deserializeSnapshot - NaN-bypass defense', () => {
32
+ test('NaN version rejects snapshot', () => {
33
+ const raw = { version: Number.NaN, createdAt: 1, items: { u1: okItem } }
34
+ expect(deserializeSnapshot(raw, makeOpts())).toBeNull()
35
+ })
36
+
37
+ test('Infinity createdAt rejects snapshot', () => {
38
+ const raw = { version: 1, createdAt: Number.POSITIVE_INFINITY, items: { u1: okItem } }
39
+ expect(deserializeSnapshot(raw, makeOpts())).toBeNull()
40
+ })
41
+
42
+ test('NaN file.size drops the item (other items still load)', () => {
43
+ const raw = {
44
+ version: 1,
45
+ createdAt: 1,
46
+ items: {
47
+ u1: { ...okItem, file: { ...okItem.file, size: Number.NaN } },
48
+ u2: { ...okItem, id: 'u2' },
49
+ },
50
+ }
51
+ const state = deserializeSnapshot(raw, makeOpts())
52
+ expect(state).not.toBeNull()
53
+ expect(state?.items.has('u1')).toBe(false)
54
+ expect(state?.items.has('u2')).toBe(true)
55
+ })
56
+
57
+ test('negative size drops the item', () => {
58
+ const raw = {
59
+ version: 1,
60
+ createdAt: 1,
61
+ items: { u1: { ...okItem, file: { ...okItem.file, size: -1 } } },
62
+ }
63
+ const state = deserializeSnapshot(raw, makeOpts())
64
+ expect(state?.items.has('u1')).toBe(false)
65
+ })
66
+
67
+ test('NaN lastModified drops the item', () => {
68
+ const raw = {
69
+ version: 1,
70
+ createdAt: 1,
71
+ items: { u1: { ...okItem, file: { ...okItem.file, lastModified: Number.NaN } } },
72
+ }
73
+ const state = deserializeSnapshot(raw, makeOpts())
74
+ expect(state?.items.has('u1')).toBe(false)
75
+ })
76
+
77
+ test('NaN progress.uploadedBytes drops progress (item still loads with zero)', () => {
78
+ const raw = {
79
+ version: 1,
80
+ createdAt: 1,
81
+ items: {
82
+ u1: { ...okItem, progress: { ...okItem.progress, uploadedBytes: Number.NaN } },
83
+ },
84
+ }
85
+ const state = deserializeSnapshot(raw, makeOpts())
86
+ expect(state?.items.has('u1')).toBe(true)
87
+ const item = state?.items.get('u1') as { progress?: { uploadedBytes: number } } | undefined
88
+ expect(item?.progress?.uploadedBytes).toBe(0)
89
+ })
90
+
91
+ test('empty MIME type is allowed (browsers may return empty for unknown)', () => {
92
+ const raw = {
93
+ version: 1,
94
+ createdAt: 1,
95
+ items: { u1: { ...okItem, file: { ...okItem.file, type: '' } } },
96
+ }
97
+ const state = deserializeSnapshot(raw, makeOpts())
98
+ expect(state?.items.has('u1')).toBe(true)
99
+ })
100
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Defense against prototype-pollution via persisted snapshots. Round-trip
3
+ * tests live in `src/__test__/persistence.test.ts`; this file is scoped to
4
+ * security regressions.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
+ import { isRecord } from '../../utils/guards'
8
+ import { LocalStorageAdapter } from '../adapters.local'
9
+
10
+ type LocalStorageStub = {
11
+ store: Record<string, string>
12
+ getItem: (key: string) => string | null
13
+ setItem: (key: string, value: string) => void
14
+ removeItem: (key: string) => void
15
+ clear: () => void
16
+ }
17
+
18
+ function installLocalStorageStub(): LocalStorageStub {
19
+ const stub: LocalStorageStub = {
20
+ store: {},
21
+ getItem(key) {
22
+ return Object.prototype.hasOwnProperty.call(this.store, key) ? (this.store[key] ?? null) : null
23
+ },
24
+ setItem(key, value) {
25
+ this.store[key] = String(value)
26
+ },
27
+ removeItem(key) {
28
+ delete this.store[key]
29
+ },
30
+ clear() {
31
+ this.store = {}
32
+ },
33
+ }
34
+ ;(globalThis as unknown as { localStorage: LocalStorageStub }).localStorage = stub
35
+ return stub
36
+ }
37
+
38
+ describe('LocalStorageAdapter prototype-pollution defense', () => {
39
+ let stub: LocalStorageStub
40
+
41
+ beforeEach(() => {
42
+ stub = installLocalStorageStub()
43
+ })
44
+
45
+ afterEach(() => {
46
+ // Defensive cleanup in case a test forgot to.
47
+ delete (Object.prototype as Record<string, unknown>)['polluted']
48
+ delete (Object.prototype as Record<string, unknown>)['rce']
49
+ })
50
+
51
+ test('a {"__proto__":{"polluted":true}} payload does NOT pollute Object.prototype after load', async () => {
52
+ stub.store['snap'] = '{"__proto__":{"polluted":true}}'
53
+ const loaded = await LocalStorageAdapter.load('snap')
54
+ expect(loaded).not.toBeNull()
55
+ expect(({} as Record<string, unknown>)['polluted']).toBeUndefined()
56
+ expect(Object.prototype.hasOwnProperty.call(loaded as object, '__proto__')).toBe(false)
57
+ })
58
+
59
+ test('a {"constructor":{"prototype":{"polluted":true}}} payload does NOT pollute', async () => {
60
+ stub.store['snap'] = '{"constructor":{"prototype":{"rce":true}}}'
61
+ const loaded = await LocalStorageAdapter.load('snap')
62
+ expect(loaded).not.toBeNull()
63
+ expect(({} as Record<string, unknown>)['rce']).toBeUndefined()
64
+ expect(Object.prototype.hasOwnProperty.call(loaded as object, 'constructor')).toBe(false)
65
+ })
66
+
67
+ test('array-shaped persisted value is rejected by isRecord (no spread into state)', async () => {
68
+ stub.store['snap'] = '[1,2,3]'
69
+ const loaded = await LocalStorageAdapter.load('snap')
70
+ expect(Array.isArray(loaded)).toBe(true)
71
+ expect(isRecord(loaded)).toBe(false)
72
+ })
73
+
74
+ test('happy path: a plain-object snapshot hydrates fine', async () => {
75
+ stub.store['snap'] = JSON.stringify({ version: 1, createdAt: 100, items: { x: { id: 'x', v: 1 } } })
76
+ const loaded = (await LocalStorageAdapter.load('snap')) as Record<string, unknown>
77
+ expect(loaded).toEqual({ version: 1, createdAt: 100, items: { x: { id: 'x', v: 1 } } })
78
+ expect(isRecord(loaded)).toBe(true)
79
+ })
80
+
81
+ test('malformed JSON returns null', async () => {
82
+ stub.store['snap'] = '{not-json'
83
+ expect(await LocalStorageAdapter.load('snap')).toBeNull()
84
+ })
85
+ })