tempest-react-sdk 0.81.0 → 0.83.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 (194) hide show
  1. package/README.md +9 -10
  2. package/bin/create-tempest-app.e2e.test.mjs +31 -0
  3. package/bin/create-tempest-app.mjs +11 -1
  4. package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
  5. package/bin/lib/doctor/vitest.mjs +120 -0
  6. package/bin/lib/doctor/vitest.test.mjs +139 -0
  7. package/bin/lib/fs/contain.mjs +7 -0
  8. package/bin/lib/openapi/escape.mjs +68 -4
  9. package/bin/lib/openapi/generate.hostile.test.mjs +228 -0
  10. package/bin/lib/openapi/generate.mjs +119 -15
  11. package/bin/lib/openapi/pattern-risk.mjs +134 -0
  12. package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
  13. package/bin/tempest-gen.test.mjs +100 -0
  14. package/bin/tempest.mjs +24 -8
  15. package/dist/access/use-can.cjs +1 -1
  16. package/dist/access/use-can.cjs.map +1 -1
  17. package/dist/access/use-can.js +25 -28
  18. package/dist/access/use-can.js.map +1 -1
  19. package/dist/auth/create-tempest-auth.cjs +1 -1
  20. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  21. package/dist/auth/create-tempest-auth.js +189 -143
  22. package/dist/auth/create-tempest-auth.js.map +1 -1
  23. package/dist/auth/session-identity.cjs +2 -0
  24. package/dist/auth/session-identity.cjs.map +1 -0
  25. package/dist/auth/session-identity.js +12 -0
  26. package/dist/auth/session-identity.js.map +1 -0
  27. package/dist/br/boleto.cjs.map +1 -1
  28. package/dist/br/boleto.js.map +1 -1
  29. package/dist/components/AuthForm/AuthLinks.cjs +1 -1
  30. package/dist/components/AuthForm/AuthLinks.cjs.map +1 -1
  31. package/dist/components/AuthForm/AuthLinks.js +18 -15
  32. package/dist/components/AuthForm/AuthLinks.js.map +1 -1
  33. package/dist/components/AuthPage/AuthPage.cjs +1 -1
  34. package/dist/components/AuthPage/AuthPage.cjs.map +1 -1
  35. package/dist/components/AuthPage/AuthPage.js +58 -52
  36. package/dist/components/AuthPage/AuthPage.js.map +1 -1
  37. package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
  38. package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
  39. package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
  40. package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  41. package/dist/components/Button/Button.cjs +1 -1
  42. package/dist/components/Button/Button.cjs.map +1 -1
  43. package/dist/components/Button/Button.js +41 -40
  44. package/dist/components/Button/Button.js.map +1 -1
  45. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  46. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  47. package/dist/components/Chat/ChatAttachments.js +38 -38
  48. package/dist/components/Form/Form.cjs +1 -1
  49. package/dist/components/Form/Form.cjs.map +1 -1
  50. package/dist/components/Form/Form.js +30 -32
  51. package/dist/components/Form/Form.js.map +1 -1
  52. package/dist/components/Layout/Layout.cjs +1 -1
  53. package/dist/components/Layout/Layout.cjs.map +1 -1
  54. package/dist/components/Layout/Layout.js +25 -24
  55. package/dist/components/Layout/Layout.js.map +1 -1
  56. package/dist/components/Layout/gap.cjs +2 -0
  57. package/dist/components/Layout/gap.cjs.map +1 -0
  58. package/dist/components/Layout/gap.js +15 -0
  59. package/dist/components/Layout/gap.js.map +1 -0
  60. package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
  61. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  62. package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
  63. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  64. package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
  65. package/dist/components/Sidebar/Sidebar.js.map +1 -1
  66. package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
  67. package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
  68. package/dist/components/Sidebar/SidebarEntry.js +30 -29
  69. package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
  70. package/dist/data/create-data-provider.cjs +1 -1
  71. package/dist/data/create-data-provider.cjs.map +1 -1
  72. package/dist/data/create-data-provider.js +1 -2
  73. package/dist/data/create-data-provider.js.map +1 -1
  74. package/dist/forms/validate-form.cjs +1 -1
  75. package/dist/forms/validate-form.cjs.map +1 -1
  76. package/dist/forms/validate-form.js +6 -1
  77. package/dist/forms/validate-form.js.map +1 -1
  78. package/dist/forms/zod-resolver.cjs +1 -1
  79. package/dist/forms/zod-resolver.cjs.map +1 -1
  80. package/dist/forms/zod-resolver.js +8 -3
  81. package/dist/forms/zod-resolver.js.map +1 -1
  82. package/dist/http/api-client.cjs +1 -1
  83. package/dist/http/api-client.cjs.map +1 -1
  84. package/dist/http/api-client.js +90 -72
  85. package/dist/http/api-client.js.map +1 -1
  86. package/dist/http/credential-scope.cjs +1 -1
  87. package/dist/http/credential-scope.cjs.map +1 -1
  88. package/dist/http/credential-scope.js +33 -9
  89. package/dist/http/credential-scope.js.map +1 -1
  90. package/dist/http/presigned-post.cjs.map +1 -1
  91. package/dist/http/presigned-post.js.map +1 -1
  92. package/dist/http/resumable-upload.cjs +1 -1
  93. package/dist/http/resumable-upload.cjs.map +1 -1
  94. package/dist/http/resumable-upload.js +167 -112
  95. package/dist/http/resumable-upload.js.map +1 -1
  96. package/dist/http/upload-with-progress.cjs +1 -1
  97. package/dist/http/upload-with-progress.cjs.map +1 -1
  98. package/dist/http/upload-with-progress.js +100 -44
  99. package/dist/http/upload-with-progress.js.map +1 -1
  100. package/dist/oauth/GoogleSignIn.cjs +1 -1
  101. package/dist/oauth/GoogleSignIn.cjs.map +1 -1
  102. package/dist/oauth/GoogleSignIn.js +5 -4
  103. package/dist/oauth/GoogleSignIn.js.map +1 -1
  104. package/dist/offline/create-offline-sync.cjs +1 -1
  105. package/dist/offline/create-offline-sync.cjs.map +1 -1
  106. package/dist/offline/create-offline-sync.js +87 -69
  107. package/dist/offline/create-offline-sync.js.map +1 -1
  108. package/dist/query/offline-persistence.cjs +1 -1
  109. package/dist/query/offline-persistence.cjs.map +1 -1
  110. package/dist/query/offline-persistence.js +67 -44
  111. package/dist/query/offline-persistence.js.map +1 -1
  112. package/dist/sse/create-event-stream.cjs +1 -1
  113. package/dist/sse/create-event-stream.cjs.map +1 -1
  114. package/dist/sse/create-event-stream.js +48 -41
  115. package/dist/sse/create-event-stream.js.map +1 -1
  116. package/dist/sse/ticket-event-stream.cjs +1 -1
  117. package/dist/sse/ticket-event-stream.cjs.map +1 -1
  118. package/dist/sse/ticket-event-stream.js +48 -19
  119. package/dist/sse/ticket-event-stream.js.map +1 -1
  120. package/dist/sse/use-event-stream.cjs +1 -1
  121. package/dist/sse/use-event-stream.cjs.map +1 -1
  122. package/dist/sse/use-event-stream.js +19 -14
  123. package/dist/sse/use-event-stream.js.map +1 -1
  124. package/dist/sw/background-sync.cjs +1 -1
  125. package/dist/sw/background-sync.cjs.map +1 -1
  126. package/dist/sw/background-sync.js +10 -8
  127. package/dist/sw/background-sync.js.map +1 -1
  128. package/dist/sw/cache.cjs +1 -1
  129. package/dist/sw/cache.cjs.map +1 -1
  130. package/dist/sw/cache.js +111 -55
  131. package/dist/sw/cache.js.map +1 -1
  132. package/dist/sw/create-push-handler.cjs +1 -1
  133. package/dist/sw/create-push-handler.cjs.map +1 -1
  134. package/dist/sw/create-push-handler.js +20 -10
  135. package/dist/sw/create-push-handler.js.map +1 -1
  136. package/dist/sw.d.ts +65 -14
  137. package/dist/tabular/compact.cjs +1 -1
  138. package/dist/tabular/compact.cjs.map +1 -1
  139. package/dist/tabular/compact.js +35 -16
  140. package/dist/tabular/compact.js.map +1 -1
  141. package/dist/tabular/manifest.cjs +1 -1
  142. package/dist/tabular/manifest.cjs.map +1 -1
  143. package/dist/tabular/manifest.js +33 -15
  144. package/dist/tabular/manifest.js.map +1 -1
  145. package/dist/tabular.d.ts +11 -1
  146. package/dist/tempest-react-sdk.cjs +1 -1
  147. package/dist/tempest-react-sdk.d.ts +543 -70
  148. package/dist/tempest-react-sdk.js +224 -224
  149. package/dist/theme/initial-theme.cjs +4 -4
  150. package/dist/theme/initial-theme.cjs.map +1 -1
  151. package/dist/theme/initial-theme.js +8 -5
  152. package/dist/theme/initial-theme.js.map +1 -1
  153. package/dist/utils/compressed-storage.cjs +1 -1
  154. package/dist/utils/compressed-storage.cjs.map +1 -1
  155. package/dist/utils/compressed-storage.js +13 -7
  156. package/dist/utils/compressed-storage.js.map +1 -1
  157. package/dist/utils/objects.cjs +1 -1
  158. package/dist/utils/objects.cjs.map +1 -1
  159. package/dist/utils/objects.js +13 -7
  160. package/dist/utils/objects.js.map +1 -1
  161. package/dist/utils/safe-url.cjs +1 -1
  162. package/dist/utils/safe-url.cjs.map +1 -1
  163. package/dist/utils/safe-url.js +4 -1
  164. package/dist/utils/safe-url.js.map +1 -1
  165. package/dist/vite/base-url.cjs +1 -1
  166. package/dist/vite/base-url.cjs.map +1 -1
  167. package/dist/vite/base-url.js +5 -1
  168. package/dist/vite/base-url.js.map +1 -1
  169. package/dist/vite/tempest-pwa-dev-sw.cjs +1 -1
  170. package/dist/vite/tempest-pwa-dev-sw.cjs.map +1 -1
  171. package/dist/vite/tempest-pwa-dev-sw.js +96 -31
  172. package/dist/vite/tempest-pwa-dev-sw.js.map +1 -1
  173. package/dist/vite/tempest-pwa-manifest.cjs +2 -2
  174. package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
  175. package/dist/vite/tempest-pwa-manifest.js +32 -16
  176. package/dist/vite/tempest-pwa-manifest.js.map +1 -1
  177. package/dist/vite.cjs +1 -1
  178. package/dist/vite.d.ts +63 -12
  179. package/dist/vite.js +4 -4
  180. package/dist/webrtc/peer-mesh.cjs +1 -1
  181. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  182. package/dist/webrtc/peer-mesh.js +7 -1
  183. package/dist/webrtc/peer-mesh.js.map +1 -1
  184. package/dist/ws/create-web-socket.cjs +1 -1
  185. package/dist/ws/create-web-socket.cjs.map +1 -1
  186. package/dist/ws/create-web-socket.js +77 -61
  187. package/dist/ws/create-web-socket.js.map +1 -1
  188. package/dist/ws/use-web-socket.cjs +1 -1
  189. package/dist/ws/use-web-socket.cjs.map +1 -1
  190. package/dist/ws/use-web-socket.js +4 -3
  191. package/dist/ws/use-web-socket.js.map +1 -1
  192. package/package.json +1 -1
  193. package/template-pwa/src/pwa.tsx +42 -0
  194. package/template-pwa/src/sw.ts +5 -4
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.js","names":["retryWithBackoff","IDEMPOTENT_METHODS","isRetriableFailure","method","error","TempestApiError","isRetriableStatus","resolveRetry","config","DEFAULT_TIMEOUT","DEFAULT_UPLOAD_TIMEOUT","isFormData","body","elapsedMs","startedAt","pathForLog","path","parseError","response","buildApiError","sentRequestId","createApiClient","fetcher","authHeaders","skipAuth","token","isTrustedCredentialTarget","url","reportSuppressedCredential","rawRequest","rest","options","isForm","configured","limit","timeout","buildApiUrl","callerHeaders","headers","finalHeaders","requestId","csrfHeaders","timed","withTimeout","signal","init","cause","send","log","target","entry","line","notifyUnauthorized","context","SessionSupersededError","authContext","attempt","randomId","sent","skipAuthRetry","replayed","decode","run","perRequest","retryOptions","retry","request","decodeByContentType","upload","formData"],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { csrfHeaders } from \"./csrf\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport {\n buildApiError,\n SessionSupersededError,\n TempestApiError,\n isRetriableStatus,\n} from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestAuthContext, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\n/**\n * The request path as the log line shows it: no query, no fragment, no userinfo.\n *\n * The logger promises never to write the query string, and `params` was never\n * the only way one gets in: a call site can write `\"/files?access_token=…\"`\n * inline, and an absolute path — the signed upload endpoint or CDN URL that\n * `buildApiUrl` passes through untouched — carries its signature in the query\n * and may carry `user:password@` in the authority. Everything from the first\n * `?` or `#` is dropped and the userinfo of an absolute (or scheme-relative)\n * URL is removed; the host and the path stay, because they are what makes the\n * line useful. Pure string work, no `URL` round trip, so a path that would not\n * parse is still logged instead of throwing inside the logging branch.\n *\n * @param path - The path exactly as the call site passed it.\n * @returns The path without query, fragment or credentials.\n */\nfunction pathForLog(path: string): string {\n return path.replace(/[?#][^]*/, \"\").replace(/^([^/]*\\/\\/)[^/]*@/, \"$1\");\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **CSRF** is off unless you set `csrf`. With it, every unsafe request (anything\n * but `GET`/`HEAD`/`OPTIONS`/`TRACE`) echoes the `csrf_token` cookie in an\n * `X-CSRF-Token` header — the double-submit contract of `tempest-fastapi-sdk`'s\n * `CSRFMiddleware` — scoped to the same origins as the bearer token and sent on\n * `skipAuth` requests too. See {@link ApiClientConfig.csrf}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The `Authorization` header for a request, or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client.\n *\n * @param url - The absolute URL the request is going to.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The header, or an empty object.\n */\n function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n if (skipAuth) return {};\n const token = config.getToken?.();\n if (!token) return {};\n if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n reportSuppressedCredential(url, config.baseURL);\n return {};\n }\n return { Authorization: `Bearer ${token}` };\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, params, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n const callerHeaders = headers as Record<string, string> | undefined;\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(url, skipAuth),\n ...csrfHeaders({\n method: rest.method ?? \"GET\",\n url,\n reference: config.baseURL,\n csrf: config.csrf,\n trustedOrigins: config.trustedOrigins,\n headers: { ...config.headers, ...callerHeaders },\n }),\n ...callerHeaders,\n };\n\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n try {\n return await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n const target = `${method} ${pathForLog(path)}`;\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${target} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${target} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n /**\n * Call `onUnauthorized`, keeping the request's own error unless the hook\n * says the attempt belongs to an ended session.\n *\n * @param response - The `401` that ended the attempt.\n * @param requestId - Correlation id for the log.\n * @param context - The credential and session the attempt left with.\n * @throws {SessionSupersededError} When `onUnauthorized` threw it.\n */\n async function notifyUnauthorized(\n response: Response,\n requestId: string,\n context: RequestAuthContext,\n ): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response, context);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n /**\n * The credential and session an attempt leaves with, read just before it is\n * sent.\n *\n * @param skipAuth - Whether the attempt goes out without credentials.\n * @returns The context handed to `refresh` and `onUnauthorized`.\n */\n function authContext(skipAuth: boolean): RequestAuthContext {\n return {\n token: skipAuth ? null : (config.getToken?.() ?? null),\n epoch: config.getSessionEpoch?.() ?? null,\n };\n }\n\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n const sent = authContext(skipAuth);\n let response = await send(path, init, requestId, method, skipAuth);\n\n if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n let replayed: RequestAuthContext;\n try {\n await config.refresh(sent);\n replayed = authContext(skipAuth);\n response = await send(path, init, requestId, method, skipAuth);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n await notifyUnauthorized(response, requestId, sent);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId, replayed);\n }\n } else {\n await notifyUnauthorized(response, requestId, sent);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n url: (path: string, params?: RequestOptions[\"params\"]) =>\n buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"api-client.js","names":["retryWithBackoff","IDEMPOTENT_METHODS","isRetriableFailure","method","error","TempestApiError","REDIRECT_REFUSED","isRetriableStatus","resolveRetry","config","isStoppedRedirect","response","DEFAULT_TIMEOUT","DEFAULT_UPLOAD_TIMEOUT","isFormData","body","elapsedMs","startedAt","pathForLog","path","parseError","buildApiError","sentRequestId","createApiClient","fetcher","scopedHeaders","token","skipAuth","inScope","reportSuppressedCredential","url","names","rawRequest","rest","options","isForm","configured","limit","timeout","callerHeaders","headers","finalHeaders","requestId","csrfHeaders","holdRedirects","timed","withTimeout","signal","init","cause","send","log","target","entry","line","notifyUnauthorized","context","SessionSupersededError","authContext","attempt","randomId","buildApiUrl","isTrustedCredentialTarget","sent","skipAuthRetry","replayed","decode","run","perRequest","retryOptions","retry","request","decodeByContentType","upload","formData"],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { csrfHeaders } from \"./csrf\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport {\n buildApiError,\n SessionSupersededError,\n TempestApiError,\n isRetriableStatus,\n} from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestAuthContext, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n if (error.code === REDIRECT_REFUSED) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/**\n * The `code` of the {@link TempestApiError} thrown when a request carrying the\n * client's static `headers` is answered with a redirect.\n *\n * Kept out of the built-in retry policy: the status is `0` in a browser, which\n * would otherwise read as a network failure, and replaying gets the same\n * redirect back.\n */\nconst REDIRECT_REFUSED = \"REDIRECT_REFUSED\";\n\n/**\n * Whether `response` is a redirect that `redirect: \"manual\"` stopped.\n *\n * Two shapes, measured: a browser hands back an `opaqueredirect` with status\n * `0` and no readable headers (Chrome 150, #554); a server runtime such as\n * Node's `fetch` hands back the real `3xx` with its `Location`. `304` is a\n * cache answer, not a redirect.\n *\n * @param response - What the fetcher resolved with.\n * @returns Whether the request was redirected and not followed.\n */\nfunction isStoppedRedirect(response: Response): boolean {\n if (response.type === \"opaqueredirect\") return true;\n return response.status >= 300 && response.status < 400 && response.status !== 304;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\n/**\n * The request path as the log line shows it: no query, no fragment, no userinfo.\n *\n * The logger promises never to write the query string, and `params` was never\n * the only way one gets in: a call site can write `\"/files?access_token=…\"`\n * inline, and an absolute path — the signed upload endpoint or CDN URL that\n * `buildApiUrl` passes through untouched — carries its signature in the query\n * and may carry `user:password@` in the authority. Everything from the first\n * `?` or `#` is dropped and the userinfo of an absolute (or scheme-relative)\n * URL is removed; the host and the path stay, because they are what makes the\n * line useful. Pure string work, no `URL` round trip, so a path that would not\n * parse is still logged instead of throwing inside the logging branch.\n *\n * @param path - The path exactly as the call site passed it.\n * @returns The path without query, fragment or credentials.\n */\nfunction pathForLog(path: string): string {\n return path.replace(/[?#][^]*/, \"\").replace(/^([^/]*\\/\\/)[^/]*@/, \"$1\");\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}. Since 0.82.0 the static\n * {@link ApiClientConfig.headers} follow the same scope, and a `401` from\n * outside it is thrown without `refresh` or `onUnauthorized` — it answered a\n * request that never carried this session's credential.\n *\n * **Redirects.** The scope is checked against the URL the request starts at;\n * where a redirect then goes is the transport's call. For `Authorization` that\n * is safe: browsers strip it on a cross-origin redirect, measured in Chrome 150\n * for `fetch` and XHR on `301`/`302`/`303`/`307`/`308` (#554). They strip\n * nothing else, so a request carrying the static {@link ApiClientConfig.headers}\n * goes out with `redirect: \"manual\"`, and a redirect answering it is thrown as a\n * {@link TempestApiError} with `code: \"REDIRECT_REFUSED\"` instead of being\n * followed. It cannot be followed by hand: a browser's manual redirect is an\n * opaque response whose `Location` is unreadable. A `redirect` set on the call\n * always wins.\n *\n * **CSRF** is off unless you set `csrf`. With it, every unsafe request (anything\n * but `GET`/`HEAD`/`OPTIONS`/`TRACE`) echoes the `csrf_token` cookie in an\n * `X-CSRF-Token` header — the double-submit contract of `tempest-fastapi-sdk`'s\n * `CSRFMiddleware` — scoped to the same origins as the bearer token and sent on\n * `skipAuth` requests too. See {@link ApiClientConfig.csrf}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The headers that belong to the client's credential scope — the static\n * `config.headers` and the `Authorization` header — or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client. Static headers ride\n * with the token since 0.82.0: an `X-Api-Key` set once on the client is as\n * much a credential as the bearer (#530).\n *\n * @param url - The absolute URL the request is going to.\n * @param inScope - Whether `url` is inside the credential scope.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The headers, or an empty object.\n */\n function scopedHeaders(\n url: string,\n inScope: boolean,\n skipAuth: boolean,\n ): Record<string, string> {\n const token = skipAuth ? null : config.getToken?.();\n if (!inScope) {\n if (token) reportSuppressedCredential(url, config.baseURL);\n const names = Object.keys(config.headers ?? {});\n if (names.length > 0) reportSuppressedCredential(url, config.baseURL, names.join(\", \"));\n return {};\n }\n return { ...config.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) };\n }\n\n async function rawRequest(\n url: string,\n inScope: boolean,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const callerHeaders = headers as Record<string, string> | undefined;\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...scopedHeaders(url, inScope, skipAuth),\n ...csrfHeaders({\n method: rest.method ?? \"GET\",\n url,\n reference: config.baseURL,\n csrf: config.csrf,\n trustedOrigins: config.trustedOrigins,\n headers: { ...config.headers, ...callerHeaders },\n }),\n ...callerHeaders,\n };\n\n const holdRedirects =\n inScope && rest.redirect === undefined && Object.keys(config.headers ?? {}).length > 0;\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n ...(holdRedirects ? { redirect: \"manual\" as const } : {}),\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n let response: Response;\n try {\n response = await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n if (holdRedirects && isStoppedRedirect(response)) {\n throw new TempestApiError({\n status: response.status,\n code: REDIRECT_REFUSED,\n requestId,\n detail: \"Redirect não seguido: a requisição levava os headers fixos do cliente.\",\n });\n }\n return response;\n }\n\n async function send(\n path: string,\n url: string,\n inScope: boolean,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(url, inScope, options, requestId, skipAuth);\n\n const target = `${method} ${pathForLog(path)}`;\n const startedAt = performance.now();\n try {\n const response = await rawRequest(url, inScope, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${target} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${target} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n /**\n * Call `onUnauthorized`, keeping the request's own error unless the hook\n * says the attempt belongs to an ended session.\n *\n * @param response - The `401` that ended the attempt.\n * @param requestId - Correlation id for the log.\n * @param context - The credential and session the attempt left with.\n * @throws {SessionSupersededError} When `onUnauthorized` threw it.\n */\n async function notifyUnauthorized(\n response: Response,\n requestId: string,\n context: RequestAuthContext,\n ): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response, context);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n /**\n * The credential and session an attempt leaves with, read just before it is\n * sent.\n *\n * @param skipAuth - Whether the attempt goes out without credentials.\n * @returns The context handed to `refresh` and `onUnauthorized`.\n */\n function authContext(skipAuth: boolean): RequestAuthContext {\n return {\n token: skipAuth ? null : (config.getToken?.() ?? null),\n epoch: config.getSessionEpoch?.() ?? null,\n };\n }\n\n /**\n * One request, with the `401` refresh-and-replay around it.\n *\n * The refresh path runs only for a target inside the credential scope. A\n * `401` from any other origin answers a request that never carried this\n * session's credential, so it says nothing about the session; until 0.82.0\n * it still drove `refresh()` and `onUnauthorized()`, so a foreign host could\n * spend a refresh and end the user's session (#530).\n *\n * @param path - The path as the call site wrote it.\n * @param options - Request options, policy fields included.\n * @param decode - How to turn the response into a value.\n * @returns The decoded value.\n */\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, params, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n const inScope = isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins);\n const sent = authContext(skipAuth);\n let response = await send(path, url, inScope, init, requestId, method, skipAuth);\n\n if (response.status === 401 && inScope && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n let replayed: RequestAuthContext;\n try {\n await config.refresh(sent);\n replayed = authContext(skipAuth);\n response = await send(path, url, inScope, init, requestId, method, skipAuth);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n await notifyUnauthorized(response, requestId, sent);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId, replayed);\n }\n } else {\n await notifyUnauthorized(response, requestId, sent);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n url: (path: string, params?: RequestOptions[\"params\"]) =>\n buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }),\n };\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- const e=require("../utils/dev-mode.cjs");var t=new Set;function n(e){try{return new URL(e,globalThis.location?.href).origin}catch{return null}}function r(e,t,r=[]){let i=n(e);return i===null||i===n(t)||r.some(e=>n(e)===i)}function i(r,i,a=`Authorization`){if(!e.isDevBuild())return;let o=n(r);if(o===null)return;let s=`${a} ${o}`;t.has(s)||(t.add(s),console.warn(`[tempest-react-sdk] ${a} was not sent to ${o}: the credential is scoped to ${n(i)??i}. Add the origin to trustedOrigins if it should receive the token.`))}exports.isTrustedCredentialTarget=r,exports.reportSuppressedCredential=i;
1
+ const e=require("../utils/dev-mode.cjs");var t=new Set,n=`http://relative.invalid`;function r(e){try{return new URL(e,globalThis.location?.href).origin}catch{return null}}function i(e){let t=globalThis.document?.baseURI??globalThis.location?.href;if(t!==void 0)try{return new URL(e,t).origin}catch{return`invalid`}try{return new URL(e).origin}catch{try{return new URL(e,n),`relative`}catch{return`invalid`}}}function a(e){try{return new URL(e,globalThis.document?.baseURI??globalThis.location?.href).href}catch{return null}}function o(e,t,n=[]){let a=i(e);return a===`relative`?!0:a===`invalid`||a===`null`?!1:a===r(t)||n.some(e=>r(e)===a)}function s(n,i,a=`Authorization`){if(!e.isDevBuild())return;let o=r(n);if(o===null)return;let s=`${a} ${o}`;t.has(s)||(t.add(s),console.warn(`[tempest-react-sdk] ${a} was not sent to ${o}: the credential is scoped to ${r(i)??i}. Add the origin to trustedOrigins if it should receive the token.`))}exports.isTrustedCredentialTarget=o,exports.reportSuppressedCredential=s,exports.resolveRequestUrl=a;
2
2
  //# sourceMappingURL=credential-scope.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"credential-scope.cjs","names":["isDevBuild","require_dev_mode","reported","originOf","url","isTrustedCredentialTarget","targetOrigin","target","reference","trustedOrigins","origin","reportSuppressedCredential","key","header"],"sources":["../../src/http/credential-scope.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Origins already reported by {@link reportSuppressedCredential}, so a chunked\n * upload warns once instead of once per chunk.\n */\nconst reported = new Set<string>();\n\n/**\n * The origin a URL resolves to, or `null` when it has none to compare.\n *\n * Relative values resolve against the document, because that is what they mean\n * everywhere else in the SDK: an `endpoint` of `\"/api/uploads\"` and a `baseURL`\n * of `\"/api\"` are the shape you get behind a dev-server or a reverse proxy, and\n * both name the page's own origin. Comparing a resolved absolute target against\n * an unresolved relative reference is how the first draft of this function\n * withheld the credential from every client configured that way.\n *\n * `null` is reserved for the case with genuinely nothing to compare: a relative\n * URL outside a browsing context, where there is no document to resolve against.\n *\n * @param url - Absolute or relative URL.\n * @returns The origin, or `null` when it cannot be resolved.\n */\nfunction originOf(url: string): string | null {\n try {\n return new URL(url, globalThis.location?.href).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Whether a request to `target` may carry the caller's credential.\n *\n * The SDK writes `Authorization: Bearer` in three places, and until 0.66.0 none\n * of them looked at where the request was going. That is only safe while every\n * target URL is written by the app: a resumable upload takes its URL from the\n * server's `Location` header, and `createApiClient` lets an absolute path\n * override `baseURL` entirely, so a URL arriving over the network could decide\n * which origin received the token — and the file bytes with it.\n *\n * Handing an upload off to object storage on another host is ordinary `tus`\n * deployment, not an attack, which is exactly why the credential must be scoped\n * rather than the request blocked: the upload still goes through, it just goes\n * without the API's bearer token.\n *\n * Both sides are resolved against the document first, so a relative `baseURL`\n * or `endpoint` — the shape behind a dev-server or reverse proxy — compares\n * equal to the absolute URL the request actually goes to. Outside a browsing\n * context a relative target resolves to nothing and is allowed: there is no\n * origin for it to cross.\n *\n * @param target - The URL the request is about to go to.\n * @param reference - The origin the credential belongs to, normally the\n * client's `baseURL` or the upload `endpoint`.\n * @param trustedOrigins - Extra origins the app declared, for the legitimate\n * second host: a CDN, a signed upload endpoint.\n * @returns Whether the credential may be attached.\n *\n * @example\n * isTrustedCredentialTarget(\"https://api.acme.com/me\", \"https://api.acme.com\");\n * // true\n * isTrustedCredentialTarget(\"https://cdn.other/u/1\", \"https://api.acme.com\");\n * // false\n */\nexport function isTrustedCredentialTarget(\n target: string,\n reference: string,\n trustedOrigins: readonly string[] = [],\n): boolean {\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return true;\n if (targetOrigin === originOf(reference)) return true;\n return trustedOrigins.some((origin) => originOf(origin) === targetOrigin);\n}\n\n/**\n * Say once, in a development build, that a credential was withheld.\n *\n * Without it the symptom is a `401` (or, for the CSRF header, a `403`) from a\n * host the developer never typed, and nothing in the app's code mentions the\n * origin that produced it. Keyed by credential and target origin, so a\n * hundred-chunk upload reports one line per header it withheld.\n *\n * Silent in production for the same reason {@link parseResponse} is: the origin\n * an app talks to is not something to print into a user's console or ship to an\n * error tracker.\n *\n * @param target - The URL whose request went out unauthenticated.\n * @param reference - The origin the credential was scoped to.\n * @param header - The header that was withheld. Default `\"Authorization\"`.\n */\nexport function reportSuppressedCredential(\n target: string,\n reference: string,\n header = \"Authorization\",\n): void {\n if (!isDevBuild()) return;\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return;\n const key = `${header} ${targetOrigin}`;\n if (reported.has(key)) return;\n reported.add(key);\n console.warn(\n `[tempest-react-sdk] ${header} was not sent to ${targetOrigin}: the credential is scoped to ${originOf(reference) ?? reference}. Add the origin to trustedOrigins if it should receive the token.`,\n );\n}\n\n/**\n * Forget which origins were already reported.\n *\n * Exported for tests, which assert the once-per-origin behaviour and would\n * otherwise leak state between cases.\n */\nexport function resetSuppressedCredentialReports(): void {\n reported.clear();\n}\n"],"mappings":"A,yCAMA,IAAME,EAAW,IAAI,IAkBrB,SAASC,EAAS,EAA4B,CAC1C,GAAI,CACA,OAAO,IAAI,IAAIC,EAAK,WAAW,UAAU,IAAI,CAAC,CAAC,MACnD,MAAQ,CACJ,OAAO,IACX,CACJ,CAoCA,SAAgBC,EACZ,EACA,EACA,EAAoC,CAAC,EAC9B,CACP,IAAMC,EAAeH,EAASI,CAAM,EAGpC,OAFID,IAAiB,MACjBA,IAAiBH,EAASK,CAAS,GAChCC,EAAe,KAAM,GAAWN,EAASO,CAAM,IAAMJ,CAAY,CAC5E,CAkBA,SAAgBK,EACZ,EACA,EACA,EAAS,gBACL,CACJ,GAAI,CAACX,EAAAA,WAAW,EAAG,OACnB,IAAMM,EAAeH,EAASI,CAAM,EACpC,GAAID,IAAiB,KAAM,OAC3B,IAAMM,EAAM,GAAGC,EAAO,GAAGP,IACrBJ,EAAS,IAAIU,CAAG,IACpBV,EAAS,IAAIU,CAAG,EAChB,QAAQ,KACJ,uBAAuBC,EAAO,mBAAmBP,EAAa,gCAAgCH,EAASK,CAAS,GAAKA,EAAU,mEACnI,EACJ,C"}
1
+ {"version":3,"file":"credential-scope.cjs","names":["isDevBuild","require_dev_mode","reported","DETACHED_BASE","originOf","url","targetOriginOf","base","target","resolveRequestUrl","isTrustedCredentialTarget","targetOrigin","reference","trustedOrigins","origin","reportSuppressedCredential","key","header"],"sources":["../../src/http/credential-scope.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Origins already reported by {@link reportSuppressedCredential}, so a chunked\n * upload warns once instead of once per chunk.\n */\nconst reported = new Set<string>();\n\n/**\n * A base no real request resolves against, used only to tell a relative\n * reference from one that does not parse when there is no document at all.\n */\nconst DETACHED_BASE = \"http://relative.invalid\";\n\n/**\n * The origin a credential reference names, or `null` when it has none.\n *\n * A reference — `baseURL`, `endpoint`, `credentialOrigin`, a trusted origin —\n * is the trust anchor, so a relative one resolves against the page's own\n * address (`location.href`), never against a `<base href>`: an injected\n * `<base>` must not be able to move the anchor onto its own host.\n *\n * Relative references are the shape you get behind a dev-server or a reverse\n * proxy, and they name the page's own origin. Comparing a resolved absolute\n * target against an unresolved relative reference is how the first draft of\n * this function withheld the credential from every client configured that way.\n *\n * @param url - Absolute or relative URL.\n * @returns The origin, or `null` when it cannot be resolved.\n */\nfunction originOf(url: string): string | null {\n try {\n return new URL(url, globalThis.location?.href).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Where a request to `target` actually goes, as the transport resolves it.\n *\n * `XMLHttpRequest.open()` and `fetch()` resolve a relative URL against the\n * document's base URL (`document.baseURI`), which a `<base href>` changes and\n * `location.href` does not. Until 0.82.0 the target was resolved against the\n * page's address, so under an injected `<base>` a relative `url` and a relative\n * `credentialOrigin` compared equal while the request went to the `<base>` host\n * with the token (#530). A worker has no document; its base is its location.\n *\n * Three answers, because the caller treats them differently:\n *\n * - an origin — compare it;\n * - `\"relative\"` — a relative target with nothing to resolve it against\n * (outside any browsing context): there is no origin for it to cross;\n * - `\"invalid\"` — a target that does not parse. Refused: the check used to\n * fail open here, so a custom `fetcher` that accepts such a string received\n * the credential (#530).\n *\n * An opaque origin (`data:`, `file:`) comes back as the string `\"null\"`, which\n * the caller also refuses: two opaque origins are never the same origin, yet\n * `\"null\" === \"null\"`.\n *\n * @param target - The URL the request is about to go to.\n * @returns The origin, `\"relative\"` or `\"invalid\"`.\n */\nfunction targetOriginOf(target: string): string {\n const base = globalThis.document?.baseURI ?? globalThis.location?.href;\n if (base !== undefined) {\n try {\n return new URL(target, base).origin;\n } catch {\n return \"invalid\";\n }\n }\n try {\n return new URL(target).origin;\n } catch {\n try {\n new URL(target, DETACHED_BASE);\n return \"relative\";\n } catch {\n return \"invalid\";\n }\n }\n}\n\n/**\n * Resolve a URL the way the transport will, against the document base URL.\n *\n * Exported for the resumable upload, which has to resolve a relative tus\n * `Location` against the creation endpoint as the request actually went out.\n *\n * @param url - Absolute or relative URL.\n * @returns The absolute URL, or `null` when it does not resolve.\n */\nexport function resolveRequestUrl(url: string): string | null {\n try {\n return new URL(url, globalThis.document?.baseURI ?? globalThis.location?.href).href;\n } catch {\n return null;\n }\n}\n\n/**\n * Whether a request to `target` may carry the caller's credential.\n *\n * The SDK writes `Authorization: Bearer` in three places, and until 0.66.0 none\n * of them looked at where the request was going. That is only safe while every\n * target URL is written by the app: a resumable upload takes its URL from the\n * server's `Location` header, and `createApiClient` lets an absolute path\n * override `baseURL` entirely, so a URL arriving over the network could decide\n * which origin received the token — and the file bytes with it.\n *\n * Handing an upload off to object storage on another host is ordinary `tus`\n * deployment, not an attack, which is exactly why the credential must be scoped\n * rather than the request blocked: the upload still goes through, it just goes\n * without the API's bearer token.\n *\n * Both sides are resolved first, so a relative `baseURL` or `endpoint` — the\n * shape behind a dev-server or reverse proxy — compares equal to the absolute\n * URL the request actually goes to. The target resolves against the document\n * base URL, where the transport sends it; the reference against the page's\n * own address, which a `<base href>` cannot move. Outside a browsing context a\n * relative target is allowed — there is no origin for it to cross — and a\n * target that does not parse is refused rather than trusted.\n *\n * @param target - The URL the request is about to go to.\n * @param reference - The origin the credential belongs to, normally the\n * client's `baseURL` or the upload `endpoint`.\n * @param trustedOrigins - Extra origins the app declared, for the legitimate\n * second host: a CDN, a signed upload endpoint.\n * @returns Whether the credential may be attached.\n *\n * @example\n * isTrustedCredentialTarget(\"https://api.acme.com/me\", \"https://api.acme.com\");\n * // true\n * isTrustedCredentialTarget(\"https://cdn.other/u/1\", \"https://api.acme.com\");\n * // false\n */\nexport function isTrustedCredentialTarget(\n target: string,\n reference: string,\n trustedOrigins: readonly string[] = [],\n): boolean {\n const targetOrigin = targetOriginOf(target);\n if (targetOrigin === \"relative\") return true;\n if (targetOrigin === \"invalid\" || targetOrigin === \"null\") return false;\n if (targetOrigin === originOf(reference)) return true;\n return trustedOrigins.some((origin) => originOf(origin) === targetOrigin);\n}\n\n/**\n * Say once, in a development build, that a credential was withheld.\n *\n * Without it the symptom is a `401` (or, for the CSRF header, a `403`) from a\n * host the developer never typed, and nothing in the app's code mentions the\n * origin that produced it. Keyed by credential and target origin, so a\n * hundred-chunk upload reports one line per header it withheld.\n *\n * Silent in production for the same reason {@link parseResponse} is: the origin\n * an app talks to is not something to print into a user's console or ship to an\n * error tracker.\n *\n * @param target - The URL whose request went out unauthenticated.\n * @param reference - The origin the credential was scoped to.\n * @param header - The header that was withheld. Default `\"Authorization\"`.\n */\nexport function reportSuppressedCredential(\n target: string,\n reference: string,\n header = \"Authorization\",\n): void {\n if (!isDevBuild()) return;\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return;\n const key = `${header} ${targetOrigin}`;\n if (reported.has(key)) return;\n reported.add(key);\n console.warn(\n `[tempest-react-sdk] ${header} was not sent to ${targetOrigin}: the credential is scoped to ${originOf(reference) ?? reference}. Add the origin to trustedOrigins if it should receive the token.`,\n );\n}\n\n/**\n * Forget which origins were already reported.\n *\n * Exported for tests, which assert the once-per-origin behaviour and would\n * otherwise leak state between cases.\n */\nexport function resetSuppressedCredentialReports(): void {\n reported.clear();\n}\n"],"mappings":"A,yCAMA,IAAME,EAAW,IAAI,IAMfC,EAAgB,0BAkBtB,SAASC,EAAS,EAA4B,CAC1C,GAAI,CACA,OAAO,IAAI,IAAIC,EAAK,WAAW,UAAU,IAAI,CAAC,CAAC,MACnD,MAAQ,CACJ,OAAO,IACX,CACJ,CA4BA,SAASC,EAAe,EAAwB,CAC5C,IAAMC,EAAO,WAAW,UAAU,SAAW,WAAW,UAAU,KAClE,GAAIA,IAAS,IAAA,GACT,GAAI,CACA,OAAO,IAAI,IAAIC,EAAQD,CAAI,CAAC,CAAC,MACjC,MAAQ,CACJ,MAAO,SACX,CAEJ,GAAI,CACA,OAAO,IAAI,IAAIC,CAAM,CAAC,CAAC,MAC3B,MAAQ,CACJ,GAAI,CAEA,OADA,IAAI,IAAIA,EAAQL,CAAa,EACtB,UACX,MAAQ,CACJ,MAAO,SACX,CACJ,CACJ,CAWA,SAAgBM,EAAkB,EAA4B,CAC1D,GAAI,CACA,OAAO,IAAI,IAAIJ,EAAK,WAAW,UAAU,SAAW,WAAW,UAAU,IAAI,CAAC,CAAC,IACnF,MAAQ,CACJ,OAAO,IACX,CACJ,CAsCA,SAAgBK,EACZ,EACA,EACA,EAAoC,CAAC,EAC9B,CACP,IAAMC,EAAeL,EAAeE,CAAM,EAI1C,OAHIG,IAAiB,WAAmB,GACpCA,IAAiB,WAAaA,IAAiB,OAAe,GAC9DA,IAAiBP,EAASQ,CAAS,GAChCC,EAAe,KAAM,GAAWT,EAASU,CAAM,IAAMH,CAAY,CAC5E,CAkBA,SAAgBI,EACZ,EACA,EACA,EAAS,gBACL,CACJ,GAAI,CAACf,EAAAA,WAAW,EAAG,OACnB,IAAMW,EAAeP,EAASI,CAAM,EACpC,GAAIG,IAAiB,KAAM,OAC3B,IAAMK,EAAM,GAAGC,EAAO,GAAGN,IACrBT,EAAS,IAAIc,CAAG,IACpBd,EAAS,IAAIc,CAAG,EAChB,QAAQ,KACJ,uBAAuBC,EAAO,mBAAmBN,EAAa,gCAAgCP,EAASQ,CAAS,GAAKA,EAAU,mEACnI,EACJ,C"}
@@ -1,25 +1,49 @@
1
1
  import { isDevBuild as e } from "../utils/dev-mode.js";
2
2
  //#region src/http/credential-scope.ts
3
- var t = /* @__PURE__ */ new Set();
4
- function n(e) {
3
+ var t = /* @__PURE__ */ new Set(), n = "http://relative.invalid";
4
+ function r(e) {
5
5
  try {
6
6
  return new URL(e, globalThis.location?.href).origin;
7
7
  } catch {
8
8
  return null;
9
9
  }
10
10
  }
11
- function r(e, t, r = []) {
12
- let i = n(e);
13
- return i === null || i === n(t) || r.some((e) => n(e) === i);
11
+ function i(e) {
12
+ let t = globalThis.document?.baseURI ?? globalThis.location?.href;
13
+ if (t !== void 0) try {
14
+ return new URL(e, t).origin;
15
+ } catch {
16
+ return "invalid";
17
+ }
18
+ try {
19
+ return new URL(e).origin;
20
+ } catch {
21
+ try {
22
+ return new URL(e, n), "relative";
23
+ } catch {
24
+ return "invalid";
25
+ }
26
+ }
27
+ }
28
+ function a(e) {
29
+ try {
30
+ return new URL(e, globalThis.document?.baseURI ?? globalThis.location?.href).href;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ function o(e, t, n = []) {
36
+ let a = i(e);
37
+ return a === "relative" ? !0 : a === "invalid" || a === "null" ? !1 : a === r(t) || n.some((e) => r(e) === a);
14
38
  }
15
- function i(r, i, a = "Authorization") {
39
+ function s(n, i, a = "Authorization") {
16
40
  if (!e()) return;
17
- let o = n(r);
41
+ let o = r(n);
18
42
  if (o === null) return;
19
43
  let s = `${a} ${o}`;
20
- t.has(s) || (t.add(s), console.warn(`[tempest-react-sdk] ${a} was not sent to ${o}: the credential is scoped to ${n(i) ?? i}. Add the origin to trustedOrigins if it should receive the token.`));
44
+ t.has(s) || (t.add(s), console.warn(`[tempest-react-sdk] ${a} was not sent to ${o}: the credential is scoped to ${r(i) ?? i}. Add the origin to trustedOrigins if it should receive the token.`));
21
45
  }
22
46
  //#endregion
23
- export { r as isTrustedCredentialTarget, i as reportSuppressedCredential };
47
+ export { o as isTrustedCredentialTarget, s as reportSuppressedCredential, a as resolveRequestUrl };
24
48
 
25
49
  //# sourceMappingURL=credential-scope.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"credential-scope.js","names":["reported","originOf","url","isTrustedCredentialTarget","targetOrigin","target","reference","trustedOrigins","origin","reportSuppressedCredential","isDevBuild","key","header"],"sources":["../../src/http/credential-scope.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Origins already reported by {@link reportSuppressedCredential}, so a chunked\n * upload warns once instead of once per chunk.\n */\nconst reported = new Set<string>();\n\n/**\n * The origin a URL resolves to, or `null` when it has none to compare.\n *\n * Relative values resolve against the document, because that is what they mean\n * everywhere else in the SDK: an `endpoint` of `\"/api/uploads\"` and a `baseURL`\n * of `\"/api\"` are the shape you get behind a dev-server or a reverse proxy, and\n * both name the page's own origin. Comparing a resolved absolute target against\n * an unresolved relative reference is how the first draft of this function\n * withheld the credential from every client configured that way.\n *\n * `null` is reserved for the case with genuinely nothing to compare: a relative\n * URL outside a browsing context, where there is no document to resolve against.\n *\n * @param url - Absolute or relative URL.\n * @returns The origin, or `null` when it cannot be resolved.\n */\nfunction originOf(url: string): string | null {\n try {\n return new URL(url, globalThis.location?.href).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Whether a request to `target` may carry the caller's credential.\n *\n * The SDK writes `Authorization: Bearer` in three places, and until 0.66.0 none\n * of them looked at where the request was going. That is only safe while every\n * target URL is written by the app: a resumable upload takes its URL from the\n * server's `Location` header, and `createApiClient` lets an absolute path\n * override `baseURL` entirely, so a URL arriving over the network could decide\n * which origin received the token — and the file bytes with it.\n *\n * Handing an upload off to object storage on another host is ordinary `tus`\n * deployment, not an attack, which is exactly why the credential must be scoped\n * rather than the request blocked: the upload still goes through, it just goes\n * without the API's bearer token.\n *\n * Both sides are resolved against the document first, so a relative `baseURL`\n * or `endpoint` — the shape behind a dev-server or reverse proxy — compares\n * equal to the absolute URL the request actually goes to. Outside a browsing\n * context a relative target resolves to nothing and is allowed: there is no\n * origin for it to cross.\n *\n * @param target - The URL the request is about to go to.\n * @param reference - The origin the credential belongs to, normally the\n * client's `baseURL` or the upload `endpoint`.\n * @param trustedOrigins - Extra origins the app declared, for the legitimate\n * second host: a CDN, a signed upload endpoint.\n * @returns Whether the credential may be attached.\n *\n * @example\n * isTrustedCredentialTarget(\"https://api.acme.com/me\", \"https://api.acme.com\");\n * // true\n * isTrustedCredentialTarget(\"https://cdn.other/u/1\", \"https://api.acme.com\");\n * // false\n */\nexport function isTrustedCredentialTarget(\n target: string,\n reference: string,\n trustedOrigins: readonly string[] = [],\n): boolean {\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return true;\n if (targetOrigin === originOf(reference)) return true;\n return trustedOrigins.some((origin) => originOf(origin) === targetOrigin);\n}\n\n/**\n * Say once, in a development build, that a credential was withheld.\n *\n * Without it the symptom is a `401` (or, for the CSRF header, a `403`) from a\n * host the developer never typed, and nothing in the app's code mentions the\n * origin that produced it. Keyed by credential and target origin, so a\n * hundred-chunk upload reports one line per header it withheld.\n *\n * Silent in production for the same reason {@link parseResponse} is: the origin\n * an app talks to is not something to print into a user's console or ship to an\n * error tracker.\n *\n * @param target - The URL whose request went out unauthenticated.\n * @param reference - The origin the credential was scoped to.\n * @param header - The header that was withheld. Default `\"Authorization\"`.\n */\nexport function reportSuppressedCredential(\n target: string,\n reference: string,\n header = \"Authorization\",\n): void {\n if (!isDevBuild()) return;\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return;\n const key = `${header} ${targetOrigin}`;\n if (reported.has(key)) return;\n reported.add(key);\n console.warn(\n `[tempest-react-sdk] ${header} was not sent to ${targetOrigin}: the credential is scoped to ${originOf(reference) ?? reference}. Add the origin to trustedOrigins if it should receive the token.`,\n );\n}\n\n/**\n * Forget which origins were already reported.\n *\n * Exported for tests, which assert the once-per-origin behaviour and would\n * otherwise leak state between cases.\n */\nexport function resetSuppressedCredentialReports(): void {\n reported.clear();\n}\n"],"mappings":";;AAMA,IAAMA,oBAAW,IAAI,IAAY;AAkBjC,SAASC,EAAS,GAA4B;CAC1C,IAAI;EACA,OAAO,IAAI,IAAIC,GAAK,WAAW,UAAU,IAAI,CAAC,CAAC;CACnD,QAAQ;EACJ,OAAO;CACX;AACJ;AAoCA,SAAgBC,EACZ,GACA,GACA,IAAoC,CAAC,GAC9B;CACP,IAAMC,IAAeH,EAASI,CAAM;CAGpC,OAFID,MAAiB,QACjBA,MAAiBH,EAASK,CAAS,KAChCC,EAAe,MAAM,MAAWN,EAASO,CAAM,MAAMJ,CAAY;AAC5E;AAkBA,SAAgBK,EACZ,GACA,GACA,IAAS,iBACL;CACJ,IAAI,CAACC,EAAW,GAAG;CACnB,IAAMN,IAAeH,EAASI,CAAM;CACpC,IAAID,MAAiB,MAAM;CAC3B,IAAMO,IAAM,GAAGC,EAAO,GAAGR;CACzB,AAAIJ,EAAS,IAAIW,CAAG,MACpBX,EAAS,IAAIW,CAAG,GAChB,QAAQ,KACJ,uBAAuBC,EAAO,mBAAmBR,EAAa,gCAAgCH,EAASK,CAAS,KAAKA,EAAU,mEACnI;AACJ"}
1
+ {"version":3,"file":"credential-scope.js","names":["reported","DETACHED_BASE","originOf","url","targetOriginOf","base","target","resolveRequestUrl","isTrustedCredentialTarget","targetOrigin","reference","trustedOrigins","origin","reportSuppressedCredential","isDevBuild","key","header"],"sources":["../../src/http/credential-scope.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Origins already reported by {@link reportSuppressedCredential}, so a chunked\n * upload warns once instead of once per chunk.\n */\nconst reported = new Set<string>();\n\n/**\n * A base no real request resolves against, used only to tell a relative\n * reference from one that does not parse when there is no document at all.\n */\nconst DETACHED_BASE = \"http://relative.invalid\";\n\n/**\n * The origin a credential reference names, or `null` when it has none.\n *\n * A reference — `baseURL`, `endpoint`, `credentialOrigin`, a trusted origin —\n * is the trust anchor, so a relative one resolves against the page's own\n * address (`location.href`), never against a `<base href>`: an injected\n * `<base>` must not be able to move the anchor onto its own host.\n *\n * Relative references are the shape you get behind a dev-server or a reverse\n * proxy, and they name the page's own origin. Comparing a resolved absolute\n * target against an unresolved relative reference is how the first draft of\n * this function withheld the credential from every client configured that way.\n *\n * @param url - Absolute or relative URL.\n * @returns The origin, or `null` when it cannot be resolved.\n */\nfunction originOf(url: string): string | null {\n try {\n return new URL(url, globalThis.location?.href).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Where a request to `target` actually goes, as the transport resolves it.\n *\n * `XMLHttpRequest.open()` and `fetch()` resolve a relative URL against the\n * document's base URL (`document.baseURI`), which a `<base href>` changes and\n * `location.href` does not. Until 0.82.0 the target was resolved against the\n * page's address, so under an injected `<base>` a relative `url` and a relative\n * `credentialOrigin` compared equal while the request went to the `<base>` host\n * with the token (#530). A worker has no document; its base is its location.\n *\n * Three answers, because the caller treats them differently:\n *\n * - an origin — compare it;\n * - `\"relative\"` — a relative target with nothing to resolve it against\n * (outside any browsing context): there is no origin for it to cross;\n * - `\"invalid\"` — a target that does not parse. Refused: the check used to\n * fail open here, so a custom `fetcher` that accepts such a string received\n * the credential (#530).\n *\n * An opaque origin (`data:`, `file:`) comes back as the string `\"null\"`, which\n * the caller also refuses: two opaque origins are never the same origin, yet\n * `\"null\" === \"null\"`.\n *\n * @param target - The URL the request is about to go to.\n * @returns The origin, `\"relative\"` or `\"invalid\"`.\n */\nfunction targetOriginOf(target: string): string {\n const base = globalThis.document?.baseURI ?? globalThis.location?.href;\n if (base !== undefined) {\n try {\n return new URL(target, base).origin;\n } catch {\n return \"invalid\";\n }\n }\n try {\n return new URL(target).origin;\n } catch {\n try {\n new URL(target, DETACHED_BASE);\n return \"relative\";\n } catch {\n return \"invalid\";\n }\n }\n}\n\n/**\n * Resolve a URL the way the transport will, against the document base URL.\n *\n * Exported for the resumable upload, which has to resolve a relative tus\n * `Location` against the creation endpoint as the request actually went out.\n *\n * @param url - Absolute or relative URL.\n * @returns The absolute URL, or `null` when it does not resolve.\n */\nexport function resolveRequestUrl(url: string): string | null {\n try {\n return new URL(url, globalThis.document?.baseURI ?? globalThis.location?.href).href;\n } catch {\n return null;\n }\n}\n\n/**\n * Whether a request to `target` may carry the caller's credential.\n *\n * The SDK writes `Authorization: Bearer` in three places, and until 0.66.0 none\n * of them looked at where the request was going. That is only safe while every\n * target URL is written by the app: a resumable upload takes its URL from the\n * server's `Location` header, and `createApiClient` lets an absolute path\n * override `baseURL` entirely, so a URL arriving over the network could decide\n * which origin received the token — and the file bytes with it.\n *\n * Handing an upload off to object storage on another host is ordinary `tus`\n * deployment, not an attack, which is exactly why the credential must be scoped\n * rather than the request blocked: the upload still goes through, it just goes\n * without the API's bearer token.\n *\n * Both sides are resolved first, so a relative `baseURL` or `endpoint` — the\n * shape behind a dev-server or reverse proxy — compares equal to the absolute\n * URL the request actually goes to. The target resolves against the document\n * base URL, where the transport sends it; the reference against the page's\n * own address, which a `<base href>` cannot move. Outside a browsing context a\n * relative target is allowed — there is no origin for it to cross — and a\n * target that does not parse is refused rather than trusted.\n *\n * @param target - The URL the request is about to go to.\n * @param reference - The origin the credential belongs to, normally the\n * client's `baseURL` or the upload `endpoint`.\n * @param trustedOrigins - Extra origins the app declared, for the legitimate\n * second host: a CDN, a signed upload endpoint.\n * @returns Whether the credential may be attached.\n *\n * @example\n * isTrustedCredentialTarget(\"https://api.acme.com/me\", \"https://api.acme.com\");\n * // true\n * isTrustedCredentialTarget(\"https://cdn.other/u/1\", \"https://api.acme.com\");\n * // false\n */\nexport function isTrustedCredentialTarget(\n target: string,\n reference: string,\n trustedOrigins: readonly string[] = [],\n): boolean {\n const targetOrigin = targetOriginOf(target);\n if (targetOrigin === \"relative\") return true;\n if (targetOrigin === \"invalid\" || targetOrigin === \"null\") return false;\n if (targetOrigin === originOf(reference)) return true;\n return trustedOrigins.some((origin) => originOf(origin) === targetOrigin);\n}\n\n/**\n * Say once, in a development build, that a credential was withheld.\n *\n * Without it the symptom is a `401` (or, for the CSRF header, a `403`) from a\n * host the developer never typed, and nothing in the app's code mentions the\n * origin that produced it. Keyed by credential and target origin, so a\n * hundred-chunk upload reports one line per header it withheld.\n *\n * Silent in production for the same reason {@link parseResponse} is: the origin\n * an app talks to is not something to print into a user's console or ship to an\n * error tracker.\n *\n * @param target - The URL whose request went out unauthenticated.\n * @param reference - The origin the credential was scoped to.\n * @param header - The header that was withheld. Default `\"Authorization\"`.\n */\nexport function reportSuppressedCredential(\n target: string,\n reference: string,\n header = \"Authorization\",\n): void {\n if (!isDevBuild()) return;\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return;\n const key = `${header} ${targetOrigin}`;\n if (reported.has(key)) return;\n reported.add(key);\n console.warn(\n `[tempest-react-sdk] ${header} was not sent to ${targetOrigin}: the credential is scoped to ${originOf(reference) ?? reference}. Add the origin to trustedOrigins if it should receive the token.`,\n );\n}\n\n/**\n * Forget which origins were already reported.\n *\n * Exported for tests, which assert the once-per-origin behaviour and would\n * otherwise leak state between cases.\n */\nexport function resetSuppressedCredentialReports(): void {\n reported.clear();\n}\n"],"mappings":";;AAMA,IAAMA,oBAAW,IAAI,IAAY,GAM3BC,IAAgB;AAkBtB,SAASC,EAAS,GAA4B;CAC1C,IAAI;EACA,OAAO,IAAI,IAAIC,GAAK,WAAW,UAAU,IAAI,CAAC,CAAC;CACnD,QAAQ;EACJ,OAAO;CACX;AACJ;AA4BA,SAASC,EAAe,GAAwB;CAC5C,IAAMC,IAAO,WAAW,UAAU,WAAW,WAAW,UAAU;CAClE,IAAIA,MAAS,KAAA,GACT,IAAI;EACA,OAAO,IAAI,IAAIC,GAAQD,CAAI,CAAC,CAAC;CACjC,QAAQ;EACJ,OAAO;CACX;CAEJ,IAAI;EACA,OAAO,IAAI,IAAIC,CAAM,CAAC,CAAC;CAC3B,QAAQ;EACJ,IAAI;GAEA,OADA,IAAI,IAAIA,GAAQL,CAAa,GACtB;EACX,QAAQ;GACJ,OAAO;EACX;CACJ;AACJ;AAWA,SAAgBM,EAAkB,GAA4B;CAC1D,IAAI;EACA,OAAO,IAAI,IAAIJ,GAAK,WAAW,UAAU,WAAW,WAAW,UAAU,IAAI,CAAC,CAAC;CACnF,QAAQ;EACJ,OAAO;CACX;AACJ;AAsCA,SAAgBK,EACZ,GACA,GACA,IAAoC,CAAC,GAC9B;CACP,IAAMC,IAAeL,EAAeE,CAAM;CAI1C,OAHIG,MAAiB,aAAmB,KACpCA,MAAiB,aAAaA,MAAiB,SAAe,KAC9DA,MAAiBP,EAASQ,CAAS,KAChCC,EAAe,MAAM,MAAWT,EAASU,CAAM,MAAMH,CAAY;AAC5E;AAkBA,SAAgBI,EACZ,GACA,GACA,IAAS,iBACL;CACJ,IAAI,CAACC,EAAW,GAAG;CACnB,IAAML,IAAeP,EAASI,CAAM;CACpC,IAAIG,MAAiB,MAAM;CAC3B,IAAMM,IAAM,GAAGC,EAAO,GAAGP;CACzB,AAAIT,EAAS,IAAIe,CAAG,MACpBf,EAAS,IAAIe,CAAG,GAChB,QAAQ,KACJ,uBAAuBC,EAAO,mBAAmBP,EAAa,gCAAgCP,EAASQ,CAAS,KAAKA,EAAU,mEACnI;AACJ"}
@@ -1 +1 @@
1
- {"version":3,"file":"presigned-post.cjs","names":["TempestApiError","uploadWithProgress","require_errors","require_upload_with_progress","S3_ERRORS","EXPIRED_POLICY","xmlText","tag","xml","normalizeError","error","s3Code","message","mapped","uploadPresignedPost","options","file","post","form","name","value","fileName"],"sources":["../../src/http/presigned-post.ts"],"sourcesContent":["import { TempestApiError } from \"./errors\";\nimport { uploadWithProgress } from \"./upload-with-progress\";\nimport type { UploadProgressEvent } from \"./upload-with-progress\";\n\n/**\n * A pre-signed `POST` form, as `tempest-fastapi-sdk` >= 0.331.0 hands it out\n * (`presigned_post_policy` / `SubjectObjectStorage.presign_post`).\n *\n * Snake case on purpose: this is the wire shape, so it parses straight from the\n * response of the app's own route.\n */\nexport interface PresignedPost {\n /** Where the form is posted: the bucket on S3/MinIO, or the API itself on the local backend. */\n url: string;\n /** Form fields to send before the file, in this order — `key`, `policy`, signature and the rest. */\n fields: Record<string, string>;\n /** Largest file the policy accepts, in bytes. */\n max_bytes: number;\n}\n\nexport interface UploadPresignedPostOptions {\n /** Called on every `progress` event of the upload. */\n onProgress?: (event: UploadProgressEvent) => void;\n /** Abort the upload; rejects with an `AbortError` `DOMException`. */\n signal?: AbortSignal;\n /** File name sent with the `file` part. Defaults to `file.name` for a `File`. */\n fileName?: string;\n}\n\n/**\n * S3 error codes mapped to the codes the local storage backend of\n * `tempest-fastapi-sdk` answers with, so one `code` reaches the UI whichever\n * backend the service is configured with.\n *\n * Statuses follow the local backend too: S3 answers `EntityTooLarge` with `400`,\n * the local backend `FILE_TOO_LARGE` with `413`. Measured against MinIO\n * `RELEASE.2025-04-22T22-12-26Z` from Chrome, cross-origin: the XML error body is\n * readable (MinIO sends CORS headers on errors), an expired policy and a broken\n * condition are both `AccessDenied` and differ only in the message\n * (`Policy expired` / `Policy Condition failed`).\n */\nconst S3_ERRORS: Readonly<Record<string, { status: number; code: string; detail: string }>> = {\n EntityTooLarge: {\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: \"Arquivo maior que o permitido.\",\n },\n EntityTooSmall: {\n status: 400,\n code: \"FILE_TOO_SMALL\",\n detail: \"Arquivo menor que o permitido.\",\n },\n AccessDenied: {\n status: 403,\n code: \"SIGNED_URL_INVALID\",\n detail: \"O link de envio não aceita este arquivo.\",\n },\n};\n\nconst EXPIRED_POLICY = /policy expired/i;\n\n/**\n * Text of the first `<tag>` element in an S3 XML error body.\n *\n * @param xml - The response body.\n * @param tag - Element name.\n * @returns The element text, or `undefined` when absent.\n */\nfunction xmlText(xml: string, tag: string): string | undefined {\n return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1];\n}\n\n/**\n * Rewrite an S3 XML error into the local backend's code and status.\n *\n * A JSON envelope from the local backend already carries `code` and passes\n * through untouched, as does any failure without an XML body (network, abort).\n *\n * @param error - What the upload rejected with.\n * @returns The error to surface.\n */\nfunction normalizeError(error: unknown): unknown {\n if (!(error instanceof TempestApiError) || typeof error.body !== \"string\") return error;\n const s3Code = xmlText(error.body, \"Code\");\n if (!s3Code) return error;\n const message = xmlText(error.body, \"Message\") ?? \"\";\n const mapped =\n s3Code === \"AccessDenied\" && EXPIRED_POLICY.test(message)\n ? {\n status: 403,\n code: \"SIGNED_URL_EXPIRED\",\n detail: \"O link de envio expirou.\",\n }\n : (S3_ERRORS[s3Code] ?? { status: error.status, code: s3Code, detail: message });\n return new TempestApiError({\n status: mapped.status,\n code: mapped.code,\n detail: mapped.detail || error.detail,\n requestId: xmlText(error.body, \"RequestId\") ?? error.requestId,\n body: error.body,\n });\n}\n\n/**\n * Upload a file straight to storage through a pre-signed `POST` form, with the\n * size ceiling the policy enforces.\n *\n * The form is built the way S3 requires: every `post.fields` entry in order,\n * the file last in the `file` part — S3 ignores whatever follows the file.\n *\n * The request carries **no** credential and no custom header: no\n * `Authorization`, no CSRF token, no `X-Request-ID`, no cookies. `post.url` is\n * usually the storage host, outside the API's origin, and the signature in\n * `fields` is the whole authorization. Without custom headers the request is a\n * CORS \"simple request\", so it does not depend on the bucket's CORS rules\n * allowing a preflight.\n *\n * A file larger than `post.max_bytes` is refused before anything is sent, with\n * the same `413 FILE_TOO_LARGE` the server would answer. Server errors are\n * normalized into one `TempestApiError` whichever backend answered: the S3 XML\n * (`EntityTooLarge` → `FILE_TOO_LARGE`, `EntityTooSmall` → `FILE_TOO_SMALL`,\n * `AccessDenied` → `SIGNED_URL_INVALID`, or `SIGNED_URL_EXPIRED` when the policy\n * expired) or the local backend's JSON envelope, which already uses those codes.\n *\n * @example\n * const post = await api.post<PresignedPost>(\"/avatars/upload-form\");\n * await uploadPresignedPost(post, file, { onProgress: (p) => setProgress(p.fraction) });\n *\n * @param post - The form the API handed out.\n * @param file - The file or blob to upload.\n * @param options - Progress callback, abort signal and file name.\n * @returns Resolves when storage accepted the file (`204` on S3).\n * @throws TempestApiError On a refused upload, with the normalized `code`.\n */\nexport async function uploadPresignedPost(\n post: PresignedPost,\n file: Blob,\n options: UploadPresignedPostOptions = {},\n): Promise<void> {\n const { onProgress, signal, fileName } = options;\n if (file.size > post.max_bytes) {\n throw new TempestApiError({\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: `Arquivo de ${file.size} bytes excede o limite de ${post.max_bytes} bytes.`,\n });\n }\n\n const form = new FormData();\n for (const [name, value] of Object.entries(post.fields)) form.append(name, value);\n if (fileName !== undefined) form.append(\"file\", file, fileName);\n else form.append(\"file\", file);\n\n try {\n await uploadWithProgress({\n url: post.url,\n body: form,\n method: \"POST\",\n requestId: () => \"\",\n withCredentials: false,\n onProgress,\n signal,\n parser: () => undefined,\n });\n } catch (error) {\n throw normalizeError(error);\n }\n}\n"],"mappings":"A,wEAyCA,IAAMI,EAAwF,CAC1F,eAAgB,CACZ,OAAQ,IACR,KAAM,iBACN,OAAQ,gCACZ,EACA,eAAgB,CACZ,OAAQ,IACR,KAAM,iBACN,OAAQ,gCACZ,EACA,aAAc,CACV,OAAQ,IACR,KAAM,qBACN,OAAQ,0CACZ,CACJ,EAEMC,EAAiB,kBASvB,SAASC,EAAQ,EAAa,EAAiC,CAC3D,OAAW,OAAO,IAAIC,EAAI,YAAYA,EAAI,EAAE,CAAC,CAAC,KAAKC,CAAG,CAAC,GAAG,EAC9D,CAWA,SAASC,EAAe,EAAyB,CAC7C,GAAI,EAAEC,aAAiBV,EAAAA,kBAAoB,OAAOU,EAAM,MAAS,SAAU,OAAOA,EAClF,IAAMC,EAASL,EAAQI,EAAM,KAAM,MAAM,EACzC,GAAI,CAACC,EAAQ,OAAOD,EACpB,IAAME,EAAUN,EAAQI,EAAM,KAAM,SAAS,GAAK,GAC5CG,EACFF,IAAW,gBAAkBN,EAAe,KAAKO,CAAO,EAClD,CACI,OAAQ,IACR,KAAM,qBACN,OAAQ,0BACZ,EACCR,EAAUO,IAAW,CAAE,OAAQD,EAAM,OAAQ,KAAMC,EAAQ,OAAQC,CAAQ,EACtF,OAAO,IAAIZ,EAAAA,gBAAgB,CACvB,OAAQa,EAAO,OACf,KAAMA,EAAO,KACb,OAAQA,EAAO,QAAUH,EAAM,OAC/B,UAAWJ,EAAQI,EAAM,KAAM,WAAW,GAAKA,EAAM,UACrD,KAAMA,EAAM,IAChB,CAAC,CACL,CAiCA,eAAsBI,EAClB,EACA,EACA,EAAsC,CAAC,EAC1B,CACb,GAAM,CAAE,aAAY,SAAQ,YAAaC,EACzC,GAAIC,EAAK,KAAOC,EAAK,UACjB,MAAM,IAAIjB,EAAAA,gBAAgB,CACtB,OAAQ,IACR,KAAM,iBACN,OAAQ,cAAcgB,EAAK,KAAK,4BAA4BC,EAAK,UAAU,QAC/E,CAAC,EAGL,IAAMC,EAAO,IAAI,SACjB,IAAK,GAAM,CAACC,EAAMC,KAAU,OAAO,QAAQH,EAAK,MAAM,EAAGC,EAAK,OAAOC,EAAMC,CAAK,EAC5EC,IAAa,IAAA,GACZH,EAAK,OAAO,OAAQF,CAAI,EADDE,EAAK,OAAO,OAAQF,EAAMK,CAAQ,EAG9D,GAAI,CACA,MAAMpB,EAAAA,mBAAmB,CACrB,IAAKgB,EAAK,IACV,KAAMC,EACN,OAAQ,OACR,cAAiB,GACjB,gBAAiB,GACjB,aACA,SACA,WAAc,IAAA,EAClB,CAAC,CACL,OAASR,EAAO,CACZ,MAAMD,EAAeC,CAAK,CAC9B,CACJ,C"}
1
+ {"version":3,"file":"presigned-post.cjs","names":["TempestApiError","uploadWithProgress","require_errors","require_upload_with_progress","S3_ERRORS","EXPIRED_POLICY","xmlText","tag","xml","normalizeError","error","s3Code","message","mapped","uploadPresignedPost","options","file","post","form","name","value","fileName"],"sources":["../../src/http/presigned-post.ts"],"sourcesContent":["import { TempestApiError } from \"./errors\";\nimport { uploadWithProgress } from \"./upload-with-progress\";\nimport type { UploadProgressEvent } from \"./upload-with-progress\";\n\n/**\n * A pre-signed `POST` form, as `tempest-fastapi-sdk` >= 0.331.0 hands it out\n * (`presigned_post_policy` / `SubjectObjectStorage.presign_post`).\n *\n * Snake case on purpose: this is the wire shape, so it parses straight from the\n * response of the app's own route.\n */\nexport interface PresignedPost {\n /** Where the form is posted: the bucket on S3/MinIO, or the API itself on the local backend. */\n url: string;\n /** Form fields to send before the file, in this order — `key`, `policy`, signature and the rest. */\n fields: Record<string, string>;\n /** Largest file the policy accepts, in bytes. */\n max_bytes: number;\n}\n\nexport interface UploadPresignedPostOptions {\n /** Called on every `progress` event of the upload. */\n onProgress?: (event: UploadProgressEvent) => void;\n /** Abort the upload; rejects with an `AbortError` `DOMException`. */\n signal?: AbortSignal;\n /** File name sent with the `file` part. Defaults to `file.name` for a `File`. */\n fileName?: string;\n}\n\n/**\n * S3 error codes mapped to the codes the local storage backend of\n * `tempest-fastapi-sdk` answers with, so one `code` reaches the UI whichever\n * backend the service is configured with.\n *\n * Statuses follow the local backend too: S3 answers `EntityTooLarge` with `400`,\n * the local backend `FILE_TOO_LARGE` with `413`. Measured against MinIO\n * `RELEASE.2025-04-22T22-12-26Z` from Chrome, cross-origin: the XML error body is\n * readable (MinIO sends CORS headers on errors), an expired policy and a broken\n * condition are both `AccessDenied` and differ only in the message\n * (`Policy expired` / `Policy Condition failed`).\n */\nconst S3_ERRORS: Readonly<Record<string, { status: number; code: string; detail: string }>> = {\n EntityTooLarge: {\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: \"Arquivo maior que o permitido.\",\n },\n EntityTooSmall: {\n status: 400,\n code: \"FILE_TOO_SMALL\",\n detail: \"Arquivo menor que o permitido.\",\n },\n AccessDenied: {\n status: 403,\n code: \"SIGNED_URL_INVALID\",\n detail: \"O link de envio não aceita este arquivo.\",\n },\n};\n\nconst EXPIRED_POLICY = /policy expired/i;\n\n/**\n * Text of the first `<tag>` element in an S3 XML error body.\n *\n * @param xml - The response body.\n * @param tag - Element name.\n * @returns The element text, or `undefined` when absent.\n */\nfunction xmlText(xml: string, tag: string): string | undefined {\n return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1];\n}\n\n/**\n * Rewrite an S3 XML error into the local backend's code and status.\n *\n * A JSON envelope from the local backend already carries `code` and passes\n * through untouched, as does any failure without an XML body (network, abort).\n *\n * @param error - What the upload rejected with.\n * @returns The error to surface.\n */\nfunction normalizeError(error: unknown): unknown {\n if (!(error instanceof TempestApiError) || typeof error.body !== \"string\") return error;\n const s3Code = xmlText(error.body, \"Code\");\n if (!s3Code) return error;\n const message = xmlText(error.body, \"Message\") ?? \"\";\n const mapped =\n s3Code === \"AccessDenied\" && EXPIRED_POLICY.test(message)\n ? {\n status: 403,\n code: \"SIGNED_URL_EXPIRED\",\n detail: \"O link de envio expirou.\",\n }\n : (S3_ERRORS[s3Code] ?? { status: error.status, code: s3Code, detail: message });\n return new TempestApiError({\n status: mapped.status,\n code: mapped.code,\n detail: mapped.detail || error.detail,\n requestId: xmlText(error.body, \"RequestId\") ?? error.requestId,\n body: error.body,\n });\n}\n\n/**\n * Upload a file straight to storage through a pre-signed `POST` form, with the\n * size ceiling the policy enforces.\n *\n * The form is built the way S3 requires: every `post.fields` entry in order,\n * the file last in the `file` part — S3 ignores whatever follows the file.\n *\n * The request carries **no** credential and no custom header: no\n * `Authorization`, no CSRF token, no `X-Request-ID`, no cookies. `post.url` is\n * usually the storage host, outside the API's origin, and the signature in\n * `fields` is the whole authorization. Without custom headers the request is a\n * CORS \"simple request\", so it does not depend on the bucket's CORS rules\n * allowing a preflight.\n *\n * A file larger than `post.max_bytes` is refused before anything is sent, with\n * the same `413 FILE_TOO_LARGE` the server would answer. A numeric string\n * (`\"5242880\"`) compares as a number; a missing or non-numeric `max_bytes` —\n * a bare boto3 `generate_presigned_post` has none — skips this precheck, and\n * the signed policy's `content-length-range` stays the control that refuses\n * the bytes. The precheck saves a wasted upload; it never was the limit.\n * Server errors are\n * normalized into one `TempestApiError` whichever backend answered: the S3 XML\n * (`EntityTooLarge` → `FILE_TOO_LARGE`, `EntityTooSmall` → `FILE_TOO_SMALL`,\n * `AccessDenied` → `SIGNED_URL_INVALID`, or `SIGNED_URL_EXPIRED` when the policy\n * expired) or the local backend's JSON envelope, which already uses those codes.\n *\n * @example\n * const post = await api.post<PresignedPost>(\"/avatars/upload-form\");\n * await uploadPresignedPost(post, file, { onProgress: (p) => setProgress(p.fraction) });\n *\n * @param post - The form the API handed out.\n * @param file - The file or blob to upload.\n * @param options - Progress callback, abort signal and file name.\n * @returns Resolves when storage accepted the file (`204` on S3).\n * @throws TempestApiError On a refused upload, with the normalized `code`.\n */\nexport async function uploadPresignedPost(\n post: PresignedPost,\n file: Blob,\n options: UploadPresignedPostOptions = {},\n): Promise<void> {\n const { onProgress, signal, fileName } = options;\n if (file.size > post.max_bytes) {\n throw new TempestApiError({\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: `Arquivo de ${file.size} bytes excede o limite de ${post.max_bytes} bytes.`,\n });\n }\n\n const form = new FormData();\n for (const [name, value] of Object.entries(post.fields)) form.append(name, value);\n if (fileName !== undefined) form.append(\"file\", file, fileName);\n else form.append(\"file\", file);\n\n try {\n await uploadWithProgress({\n url: post.url,\n body: form,\n method: \"POST\",\n requestId: () => \"\",\n withCredentials: false,\n onProgress,\n signal,\n parser: () => undefined,\n });\n } catch (error) {\n throw normalizeError(error);\n }\n}\n"],"mappings":"A,wEAyCA,IAAMI,EAAwF,CAC1F,eAAgB,CACZ,OAAQ,IACR,KAAM,iBACN,OAAQ,gCACZ,EACA,eAAgB,CACZ,OAAQ,IACR,KAAM,iBACN,OAAQ,gCACZ,EACA,aAAc,CACV,OAAQ,IACR,KAAM,qBACN,OAAQ,0CACZ,CACJ,EAEMC,EAAiB,kBASvB,SAASC,EAAQ,EAAa,EAAiC,CAC3D,OAAW,OAAO,IAAIC,EAAI,YAAYA,EAAI,EAAE,CAAC,CAAC,KAAKC,CAAG,CAAC,GAAG,EAC9D,CAWA,SAASC,EAAe,EAAyB,CAC7C,GAAI,EAAEC,aAAiBV,EAAAA,kBAAoB,OAAOU,EAAM,MAAS,SAAU,OAAOA,EAClF,IAAMC,EAASL,EAAQI,EAAM,KAAM,MAAM,EACzC,GAAI,CAACC,EAAQ,OAAOD,EACpB,IAAME,EAAUN,EAAQI,EAAM,KAAM,SAAS,GAAK,GAC5CG,EACFF,IAAW,gBAAkBN,EAAe,KAAKO,CAAO,EAClD,CACI,OAAQ,IACR,KAAM,qBACN,OAAQ,0BACZ,EACCR,EAAUO,IAAW,CAAE,OAAQD,EAAM,OAAQ,KAAMC,EAAQ,OAAQC,CAAQ,EACtF,OAAO,IAAIZ,EAAAA,gBAAgB,CACvB,OAAQa,EAAO,OACf,KAAMA,EAAO,KACb,OAAQA,EAAO,QAAUH,EAAM,OAC/B,UAAWJ,EAAQI,EAAM,KAAM,WAAW,GAAKA,EAAM,UACrD,KAAMA,EAAM,IAChB,CAAC,CACL,CAsCA,eAAsBI,EAClB,EACA,EACA,EAAsC,CAAC,EAC1B,CACb,GAAM,CAAE,aAAY,SAAQ,YAAaC,EACzC,GAAIC,EAAK,KAAOC,EAAK,UACjB,MAAM,IAAIjB,EAAAA,gBAAgB,CACtB,OAAQ,IACR,KAAM,iBACN,OAAQ,cAAcgB,EAAK,KAAK,4BAA4BC,EAAK,UAAU,QAC/E,CAAC,EAGL,IAAMC,EAAO,IAAI,SACjB,IAAK,GAAM,CAACC,EAAMC,KAAU,OAAO,QAAQH,EAAK,MAAM,EAAGC,EAAK,OAAOC,EAAMC,CAAK,EAC5EC,IAAa,IAAA,GACZH,EAAK,OAAO,OAAQF,CAAI,EADDE,EAAK,OAAO,OAAQF,EAAMK,CAAQ,EAG9D,GAAI,CACA,MAAMpB,EAAAA,mBAAmB,CACrB,IAAKgB,EAAK,IACV,KAAMC,EACN,OAAQ,OACR,cAAiB,GACjB,gBAAiB,GACjB,aACA,SACA,WAAc,IAAA,EAClB,CAAC,CACL,OAASR,EAAO,CACZ,MAAMD,EAAeC,CAAK,CAC9B,CACJ,C"}
@@ -1 +1 @@
1
- {"version":3,"file":"presigned-post.js","names":["S3_ERRORS","EXPIRED_POLICY","xmlText","tag","xml","normalizeError","error","TempestApiError","s3Code","message","mapped","uploadPresignedPost","options","file","post","form","name","value","fileName","uploadWithProgress"],"sources":["../../src/http/presigned-post.ts"],"sourcesContent":["import { TempestApiError } from \"./errors\";\nimport { uploadWithProgress } from \"./upload-with-progress\";\nimport type { UploadProgressEvent } from \"./upload-with-progress\";\n\n/**\n * A pre-signed `POST` form, as `tempest-fastapi-sdk` >= 0.331.0 hands it out\n * (`presigned_post_policy` / `SubjectObjectStorage.presign_post`).\n *\n * Snake case on purpose: this is the wire shape, so it parses straight from the\n * response of the app's own route.\n */\nexport interface PresignedPost {\n /** Where the form is posted: the bucket on S3/MinIO, or the API itself on the local backend. */\n url: string;\n /** Form fields to send before the file, in this order — `key`, `policy`, signature and the rest. */\n fields: Record<string, string>;\n /** Largest file the policy accepts, in bytes. */\n max_bytes: number;\n}\n\nexport interface UploadPresignedPostOptions {\n /** Called on every `progress` event of the upload. */\n onProgress?: (event: UploadProgressEvent) => void;\n /** Abort the upload; rejects with an `AbortError` `DOMException`. */\n signal?: AbortSignal;\n /** File name sent with the `file` part. Defaults to `file.name` for a `File`. */\n fileName?: string;\n}\n\n/**\n * S3 error codes mapped to the codes the local storage backend of\n * `tempest-fastapi-sdk` answers with, so one `code` reaches the UI whichever\n * backend the service is configured with.\n *\n * Statuses follow the local backend too: S3 answers `EntityTooLarge` with `400`,\n * the local backend `FILE_TOO_LARGE` with `413`. Measured against MinIO\n * `RELEASE.2025-04-22T22-12-26Z` from Chrome, cross-origin: the XML error body is\n * readable (MinIO sends CORS headers on errors), an expired policy and a broken\n * condition are both `AccessDenied` and differ only in the message\n * (`Policy expired` / `Policy Condition failed`).\n */\nconst S3_ERRORS: Readonly<Record<string, { status: number; code: string; detail: string }>> = {\n EntityTooLarge: {\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: \"Arquivo maior que o permitido.\",\n },\n EntityTooSmall: {\n status: 400,\n code: \"FILE_TOO_SMALL\",\n detail: \"Arquivo menor que o permitido.\",\n },\n AccessDenied: {\n status: 403,\n code: \"SIGNED_URL_INVALID\",\n detail: \"O link de envio não aceita este arquivo.\",\n },\n};\n\nconst EXPIRED_POLICY = /policy expired/i;\n\n/**\n * Text of the first `<tag>` element in an S3 XML error body.\n *\n * @param xml - The response body.\n * @param tag - Element name.\n * @returns The element text, or `undefined` when absent.\n */\nfunction xmlText(xml: string, tag: string): string | undefined {\n return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1];\n}\n\n/**\n * Rewrite an S3 XML error into the local backend's code and status.\n *\n * A JSON envelope from the local backend already carries `code` and passes\n * through untouched, as does any failure without an XML body (network, abort).\n *\n * @param error - What the upload rejected with.\n * @returns The error to surface.\n */\nfunction normalizeError(error: unknown): unknown {\n if (!(error instanceof TempestApiError) || typeof error.body !== \"string\") return error;\n const s3Code = xmlText(error.body, \"Code\");\n if (!s3Code) return error;\n const message = xmlText(error.body, \"Message\") ?? \"\";\n const mapped =\n s3Code === \"AccessDenied\" && EXPIRED_POLICY.test(message)\n ? {\n status: 403,\n code: \"SIGNED_URL_EXPIRED\",\n detail: \"O link de envio expirou.\",\n }\n : (S3_ERRORS[s3Code] ?? { status: error.status, code: s3Code, detail: message });\n return new TempestApiError({\n status: mapped.status,\n code: mapped.code,\n detail: mapped.detail || error.detail,\n requestId: xmlText(error.body, \"RequestId\") ?? error.requestId,\n body: error.body,\n });\n}\n\n/**\n * Upload a file straight to storage through a pre-signed `POST` form, with the\n * size ceiling the policy enforces.\n *\n * The form is built the way S3 requires: every `post.fields` entry in order,\n * the file last in the `file` part — S3 ignores whatever follows the file.\n *\n * The request carries **no** credential and no custom header: no\n * `Authorization`, no CSRF token, no `X-Request-ID`, no cookies. `post.url` is\n * usually the storage host, outside the API's origin, and the signature in\n * `fields` is the whole authorization. Without custom headers the request is a\n * CORS \"simple request\", so it does not depend on the bucket's CORS rules\n * allowing a preflight.\n *\n * A file larger than `post.max_bytes` is refused before anything is sent, with\n * the same `413 FILE_TOO_LARGE` the server would answer. Server errors are\n * normalized into one `TempestApiError` whichever backend answered: the S3 XML\n * (`EntityTooLarge` → `FILE_TOO_LARGE`, `EntityTooSmall` → `FILE_TOO_SMALL`,\n * `AccessDenied` → `SIGNED_URL_INVALID`, or `SIGNED_URL_EXPIRED` when the policy\n * expired) or the local backend's JSON envelope, which already uses those codes.\n *\n * @example\n * const post = await api.post<PresignedPost>(\"/avatars/upload-form\");\n * await uploadPresignedPost(post, file, { onProgress: (p) => setProgress(p.fraction) });\n *\n * @param post - The form the API handed out.\n * @param file - The file or blob to upload.\n * @param options - Progress callback, abort signal and file name.\n * @returns Resolves when storage accepted the file (`204` on S3).\n * @throws TempestApiError On a refused upload, with the normalized `code`.\n */\nexport async function uploadPresignedPost(\n post: PresignedPost,\n file: Blob,\n options: UploadPresignedPostOptions = {},\n): Promise<void> {\n const { onProgress, signal, fileName } = options;\n if (file.size > post.max_bytes) {\n throw new TempestApiError({\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: `Arquivo de ${file.size} bytes excede o limite de ${post.max_bytes} bytes.`,\n });\n }\n\n const form = new FormData();\n for (const [name, value] of Object.entries(post.fields)) form.append(name, value);\n if (fileName !== undefined) form.append(\"file\", file, fileName);\n else form.append(\"file\", file);\n\n try {\n await uploadWithProgress({\n url: post.url,\n body: form,\n method: \"POST\",\n requestId: () => \"\",\n withCredentials: false,\n onProgress,\n signal,\n parser: () => undefined,\n });\n } catch (error) {\n throw normalizeError(error);\n }\n}\n"],"mappings":";;;AAyCA,IAAMA,IAAwF;CAC1F,gBAAgB;EACZ,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;CACA,gBAAgB;EACZ,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;CACA,cAAc;EACV,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;AACJ,GAEMC,IAAiB;AASvB,SAASC,EAAQ,GAAa,GAAiC;CAC3D,OAAW,OAAO,IAAIC,EAAI,YAAYA,EAAI,EAAE,CAAC,CAAC,KAAKC,CAAG,CAAC,GAAG;AAC9D;AAWA,SAASC,EAAe,GAAyB;CAC7C,IAAI,EAAEC,aAAiBC,MAAoB,OAAOD,EAAM,QAAS,UAAU,OAAOA;CAClF,IAAME,IAASN,EAAQI,EAAM,MAAM,MAAM;CACzC,IAAI,CAACE,GAAQ,OAAOF;CACpB,IAAMG,IAAUP,EAAQI,EAAM,MAAM,SAAS,KAAK,IAC5CI,IACFF,MAAW,kBAAkBP,EAAe,KAAKQ,CAAO,IAClD;EACI,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ,IACCT,EAAUQ,MAAW;EAAE,QAAQF,EAAM;EAAQ,MAAME;EAAQ,QAAQC;CAAQ;CACtF,OAAO,IAAIF,EAAgB;EACvB,QAAQG,EAAO;EACf,MAAMA,EAAO;EACb,QAAQA,EAAO,UAAUJ,EAAM;EAC/B,WAAWJ,EAAQI,EAAM,MAAM,WAAW,KAAKA,EAAM;EACrD,MAAMA,EAAM;CAChB,CAAC;AACL;AAiCA,eAAsBK,EAClB,GACA,GACA,IAAsC,CAAC,GAC1B;CACb,IAAM,EAAE,eAAY,WAAQ,gBAAaC;CACzC,IAAIC,EAAK,OAAOC,EAAK,WACjB,MAAM,IAAIP,EAAgB;EACtB,QAAQ;EACR,MAAM;EACN,QAAQ,cAAcM,EAAK,KAAK,4BAA4BC,EAAK,UAAU;CAC/E,CAAC;CAGL,IAAMC,IAAO,IAAI,SAAS;CAC1B,KAAK,IAAM,CAACC,GAAMC,MAAU,OAAO,QAAQH,EAAK,MAAM,GAAG,EAAK,OAAOE,GAAMC,CAAK;CAChF,AAAIC,MAAa,KAAA,IACZH,EAAK,OAAO,QAAQF,CAAI,IADDE,EAAK,OAAO,QAAQF,GAAMK,CAAQ;CAG9D,IAAI;EACA,MAAMC,EAAmB;GACrB,KAAKL,EAAK;GACV,MAAMC;GACN,QAAQ;GACR,iBAAiB;GACjB,iBAAiB;GACjB;GACA;GACA,cAAc,KAAA;EAClB,CAAC;CACL,SAAST,GAAO;EACZ,MAAMD,EAAeC,CAAK;CAC9B;AACJ"}
1
+ {"version":3,"file":"presigned-post.js","names":["S3_ERRORS","EXPIRED_POLICY","xmlText","tag","xml","normalizeError","error","TempestApiError","s3Code","message","mapped","uploadPresignedPost","options","file","post","form","name","value","fileName","uploadWithProgress"],"sources":["../../src/http/presigned-post.ts"],"sourcesContent":["import { TempestApiError } from \"./errors\";\nimport { uploadWithProgress } from \"./upload-with-progress\";\nimport type { UploadProgressEvent } from \"./upload-with-progress\";\n\n/**\n * A pre-signed `POST` form, as `tempest-fastapi-sdk` >= 0.331.0 hands it out\n * (`presigned_post_policy` / `SubjectObjectStorage.presign_post`).\n *\n * Snake case on purpose: this is the wire shape, so it parses straight from the\n * response of the app's own route.\n */\nexport interface PresignedPost {\n /** Where the form is posted: the bucket on S3/MinIO, or the API itself on the local backend. */\n url: string;\n /** Form fields to send before the file, in this order — `key`, `policy`, signature and the rest. */\n fields: Record<string, string>;\n /** Largest file the policy accepts, in bytes. */\n max_bytes: number;\n}\n\nexport interface UploadPresignedPostOptions {\n /** Called on every `progress` event of the upload. */\n onProgress?: (event: UploadProgressEvent) => void;\n /** Abort the upload; rejects with an `AbortError` `DOMException`. */\n signal?: AbortSignal;\n /** File name sent with the `file` part. Defaults to `file.name` for a `File`. */\n fileName?: string;\n}\n\n/**\n * S3 error codes mapped to the codes the local storage backend of\n * `tempest-fastapi-sdk` answers with, so one `code` reaches the UI whichever\n * backend the service is configured with.\n *\n * Statuses follow the local backend too: S3 answers `EntityTooLarge` with `400`,\n * the local backend `FILE_TOO_LARGE` with `413`. Measured against MinIO\n * `RELEASE.2025-04-22T22-12-26Z` from Chrome, cross-origin: the XML error body is\n * readable (MinIO sends CORS headers on errors), an expired policy and a broken\n * condition are both `AccessDenied` and differ only in the message\n * (`Policy expired` / `Policy Condition failed`).\n */\nconst S3_ERRORS: Readonly<Record<string, { status: number; code: string; detail: string }>> = {\n EntityTooLarge: {\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: \"Arquivo maior que o permitido.\",\n },\n EntityTooSmall: {\n status: 400,\n code: \"FILE_TOO_SMALL\",\n detail: \"Arquivo menor que o permitido.\",\n },\n AccessDenied: {\n status: 403,\n code: \"SIGNED_URL_INVALID\",\n detail: \"O link de envio não aceita este arquivo.\",\n },\n};\n\nconst EXPIRED_POLICY = /policy expired/i;\n\n/**\n * Text of the first `<tag>` element in an S3 XML error body.\n *\n * @param xml - The response body.\n * @param tag - Element name.\n * @returns The element text, or `undefined` when absent.\n */\nfunction xmlText(xml: string, tag: string): string | undefined {\n return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1];\n}\n\n/**\n * Rewrite an S3 XML error into the local backend's code and status.\n *\n * A JSON envelope from the local backend already carries `code` and passes\n * through untouched, as does any failure without an XML body (network, abort).\n *\n * @param error - What the upload rejected with.\n * @returns The error to surface.\n */\nfunction normalizeError(error: unknown): unknown {\n if (!(error instanceof TempestApiError) || typeof error.body !== \"string\") return error;\n const s3Code = xmlText(error.body, \"Code\");\n if (!s3Code) return error;\n const message = xmlText(error.body, \"Message\") ?? \"\";\n const mapped =\n s3Code === \"AccessDenied\" && EXPIRED_POLICY.test(message)\n ? {\n status: 403,\n code: \"SIGNED_URL_EXPIRED\",\n detail: \"O link de envio expirou.\",\n }\n : (S3_ERRORS[s3Code] ?? { status: error.status, code: s3Code, detail: message });\n return new TempestApiError({\n status: mapped.status,\n code: mapped.code,\n detail: mapped.detail || error.detail,\n requestId: xmlText(error.body, \"RequestId\") ?? error.requestId,\n body: error.body,\n });\n}\n\n/**\n * Upload a file straight to storage through a pre-signed `POST` form, with the\n * size ceiling the policy enforces.\n *\n * The form is built the way S3 requires: every `post.fields` entry in order,\n * the file last in the `file` part — S3 ignores whatever follows the file.\n *\n * The request carries **no** credential and no custom header: no\n * `Authorization`, no CSRF token, no `X-Request-ID`, no cookies. `post.url` is\n * usually the storage host, outside the API's origin, and the signature in\n * `fields` is the whole authorization. Without custom headers the request is a\n * CORS \"simple request\", so it does not depend on the bucket's CORS rules\n * allowing a preflight.\n *\n * A file larger than `post.max_bytes` is refused before anything is sent, with\n * the same `413 FILE_TOO_LARGE` the server would answer. A numeric string\n * (`\"5242880\"`) compares as a number; a missing or non-numeric `max_bytes` —\n * a bare boto3 `generate_presigned_post` has none — skips this precheck, and\n * the signed policy's `content-length-range` stays the control that refuses\n * the bytes. The precheck saves a wasted upload; it never was the limit.\n * Server errors are\n * normalized into one `TempestApiError` whichever backend answered: the S3 XML\n * (`EntityTooLarge` → `FILE_TOO_LARGE`, `EntityTooSmall` → `FILE_TOO_SMALL`,\n * `AccessDenied` → `SIGNED_URL_INVALID`, or `SIGNED_URL_EXPIRED` when the policy\n * expired) or the local backend's JSON envelope, which already uses those codes.\n *\n * @example\n * const post = await api.post<PresignedPost>(\"/avatars/upload-form\");\n * await uploadPresignedPost(post, file, { onProgress: (p) => setProgress(p.fraction) });\n *\n * @param post - The form the API handed out.\n * @param file - The file or blob to upload.\n * @param options - Progress callback, abort signal and file name.\n * @returns Resolves when storage accepted the file (`204` on S3).\n * @throws TempestApiError On a refused upload, with the normalized `code`.\n */\nexport async function uploadPresignedPost(\n post: PresignedPost,\n file: Blob,\n options: UploadPresignedPostOptions = {},\n): Promise<void> {\n const { onProgress, signal, fileName } = options;\n if (file.size > post.max_bytes) {\n throw new TempestApiError({\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: `Arquivo de ${file.size} bytes excede o limite de ${post.max_bytes} bytes.`,\n });\n }\n\n const form = new FormData();\n for (const [name, value] of Object.entries(post.fields)) form.append(name, value);\n if (fileName !== undefined) form.append(\"file\", file, fileName);\n else form.append(\"file\", file);\n\n try {\n await uploadWithProgress({\n url: post.url,\n body: form,\n method: \"POST\",\n requestId: () => \"\",\n withCredentials: false,\n onProgress,\n signal,\n parser: () => undefined,\n });\n } catch (error) {\n throw normalizeError(error);\n }\n}\n"],"mappings":";;;AAyCA,IAAMA,IAAwF;CAC1F,gBAAgB;EACZ,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;CACA,gBAAgB;EACZ,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;CACA,cAAc;EACV,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;AACJ,GAEMC,IAAiB;AASvB,SAASC,EAAQ,GAAa,GAAiC;CAC3D,OAAW,OAAO,IAAIC,EAAI,YAAYA,EAAI,EAAE,CAAC,CAAC,KAAKC,CAAG,CAAC,GAAG;AAC9D;AAWA,SAASC,EAAe,GAAyB;CAC7C,IAAI,EAAEC,aAAiBC,MAAoB,OAAOD,EAAM,QAAS,UAAU,OAAOA;CAClF,IAAME,IAASN,EAAQI,EAAM,MAAM,MAAM;CACzC,IAAI,CAACE,GAAQ,OAAOF;CACpB,IAAMG,IAAUP,EAAQI,EAAM,MAAM,SAAS,KAAK,IAC5CI,IACFF,MAAW,kBAAkBP,EAAe,KAAKQ,CAAO,IAClD;EACI,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ,IACCT,EAAUQ,MAAW;EAAE,QAAQF,EAAM;EAAQ,MAAME;EAAQ,QAAQC;CAAQ;CACtF,OAAO,IAAIF,EAAgB;EACvB,QAAQG,EAAO;EACf,MAAMA,EAAO;EACb,QAAQA,EAAO,UAAUJ,EAAM;EAC/B,WAAWJ,EAAQI,EAAM,MAAM,WAAW,KAAKA,EAAM;EACrD,MAAMA,EAAM;CAChB,CAAC;AACL;AAsCA,eAAsBK,EAClB,GACA,GACA,IAAsC,CAAC,GAC1B;CACb,IAAM,EAAE,eAAY,WAAQ,gBAAaC;CACzC,IAAIC,EAAK,OAAOC,EAAK,WACjB,MAAM,IAAIP,EAAgB;EACtB,QAAQ;EACR,MAAM;EACN,QAAQ,cAAcM,EAAK,KAAK,4BAA4BC,EAAK,UAAU;CAC/E,CAAC;CAGL,IAAMC,IAAO,IAAI,SAAS;CAC1B,KAAK,IAAM,CAACC,GAAMC,MAAU,OAAO,QAAQH,EAAK,MAAM,GAAG,EAAK,OAAOE,GAAMC,CAAK;CAChF,AAAIC,MAAa,KAAA,IACZH,EAAK,OAAO,QAAQF,CAAI,IADDE,EAAK,OAAO,QAAQF,GAAMK,CAAQ;CAG9D,IAAI;EACA,MAAMC,EAAmB;GACrB,KAAKL,EAAK;GACV,MAAMC;GACN,QAAQ;GACR,iBAAiB;GACjB,iBAAiB;GACjB;GACA;GACA,cAAc,KAAA;EAClB,CAAC;CACL,SAAST,GAAO;EACZ,MAAMD,EAAeC,CAAK;CAC9B;AACJ"}
@@ -1,2 +1,2 @@
1
- const e=require("./errors.cjs"),t=require("../utils/base64.cjs"),n=require("./credential-scope.cjs"),r=require("./csrf.cjs"),i=require("./retry.cjs"),a=require("../utils/owner-fingerprint.cjs"),o=require("./idempotency.cjs");var s=`1.0.0`,c=5242880,l=class extends Error{reason;constructor(e){super(e===`principal`?`O usuário mudou durante o upload resumível.`:`Os registros de upload resumível foram limpos durante o upload.`),this.name=`ResumableUploadSupersededError`,this.reason=e}},u=new Map,d=new WeakMap;function f(e){if(!e)return!1;try{let t=JSON.parse(e);return typeof t==`object`&&!!t&&typeof t.url==`string`&&typeof t.offset==`number`&&typeof t.size==`number`&&typeof t.idempotencyKey==`string`}catch{return!1}}function p(e){return t.bytesToBase64(new TextEncoder().encode(e))}function m(e){if(!e)return null;let t=Object.entries(e).map(([e,t])=>`${e} ${p(t)}`);return t.length>0?t.join(`,`):null}function h(e,t){let n=t,r=typeof n.name==`string`?n.name:`blob`,i=typeof n.lastModified==`number`?n.lastModified:0;return`${e}|${r}|${t.size}|${t.type}|${i}`}function g(e=`tempest-upload:`){function t(){try{return typeof localStorage>`u`?null:localStorage}catch{return null}}let n={get(n){let r=t()?.getItem(e+n);if(!r)return null;try{return JSON.parse(r)}catch{return null}},set(n,r){t()?.setItem(e+n,JSON.stringify(r))},delete(n){t()?.removeItem(e+n)},clear(){u.set(e,(u.get(e)??0)+1);let n=t();if(!n)return;let r=[];for(let t=0;t<n.length;t+=1){let i=n.key(t);i?.startsWith(e)&&f(n.getItem(i))&&r.push(i)}for(let e of r)n.removeItem(e)}};return d.set(n,()=>u.get(e)??0),n}function _(t){return new Promise((n,r)=>{let i=new XMLHttpRequest;i.open(t.method,t.url),i.withCredentials=t.withCredentials;for(let[e,n]of Object.entries(t.headers))i.setRequestHeader(e,n);if(t.onProgress){let e=t.onProgress;i.upload.onprogress=t=>e(t.loaded)}i.onload=()=>n({status:i.status,text:i.responseText,header:e=>i.getResponseHeader(e)}),i.onerror=()=>r(new e.TempestApiError({status:0,detail:`Falha de rede no upload resumível.`})),i.onabort=()=>r(new DOMException(`Aborted`,`AbortError`)),t.register(i),i.send(t.body)})}function v(e){let t=e.header(`Upload-Offset`);if(t===null)return null;let n=Number(t);return Number.isFinite(n)&&n>=0?n:null}function y(e){if(!e)return null;try{return JSON.parse(e)}catch{return e}}var b=new Set([409,412]);function x(t){return!e.isApiError(t)||b.has(t.status)?!0:e.isRetriableStatus(t.status)}function S(t,n){let r=y(t.text),i=e.buildApiError(t.status,r,{get:t.header}),a=typeof r==`object`&&!!r&&(`detail`in r||`message`in r);return new e.TempestApiError({...i,detail:a?i.detail:n})}function C(e){let t=typeof window>`u`?void 0:window.location.href;try{return new URL(e,t).href}catch{return e}}function w(t){let{endpoint:u,file:f,chunkSize:p=c,metadata:y,headers:b={},getToken:w,getPrincipal:T,trustedOrigins:E,withCredentials:D=!1,csrf:O,key:k=h(u,f),storage:A=g(),retry:j,onProgress:M,onStateChange:N}=t,P=`idle`,F=0,I=null,L=null,R,z=!1,B=A?d.get(A):void 0,V=B?.()??0;function H(){if(T&&a.ownerFingerprint(`p`,T())!==R)throw new l(`principal`);if(B&&B()!==V)throw new l(`cleared`)}let U=null,W=null,G=0;function K(e){P!==e&&(P=e,N?.(e))}function q(e){M?.({loaded:e,total:f.size,fraction:f.size===0?1:e/f.size,resumedFrom:G})}function J(e,t){let i={...b,"Tus-Resumable":s},a=w?.();return a&&!(`Authorization`in i)&&(n.isTrustedCredentialTarget(e,u,E)?i.Authorization=`Bearer ${a}`:n.reportSuppressedCredential(e,u)),Object.assign(i,r.csrfHeaders({method:t,url:e,reference:u,csrf:O,trustedOrigins:E,headers:b})),i}function Y(e){W=e}async function X(){A&&I&&L&&(H(),await A.set(k,{url:I,offset:F,size:f.size,idempotencyKey:L,updatedAt:Date.now(),...R===void 0?{}:{owner:R}}))}async function Z(t){let n=await _({method:`HEAD`,url:t,headers:J(t,`HEAD`),withCredentials:D,register:Y});if(n.status===404||n.status===410)throw new e.TempestApiError({status:n.status,detail:`O upload expirou no servidor. Comece de novo.`});let r=v(n);if(r===null)throw S(n,`HEAD sem Upload-Offset.`);return r}async function ee(){let e=T?a.ownerFingerprint(`p`,T()):void 0;z&&e!==R&&(L=null),R=e,z=!0,V=B?.()??0;let t=A?await A.get(k):null;if(t&&R!==void 0&&t.owner!==R&&(await A?.delete(k),t=null),t&&t.size===f.size&&(L=t.idempotencyKey,t.url))try{return F=await Z(t.url),I=t.url,t.url}catch{F=0}K(`creating`),L??=o.generateIdempotencyKey(),I=null,F=0,A&&await A.set(k,{url:``,offset:0,size:f.size,idempotencyKey:L,updatedAt:Date.now(),...R===void 0?{}:{owner:R}});let n={...J(u,`POST`),"Upload-Length":String(f.size),"Idempotency-Key":L},r=m(y);r&&(n[`Upload-Metadata`]=r);let i=await _({method:`POST`,url:u,headers:n,withCredentials:D,register:Y});if(i.status!==201)throw S(i,`Criação do upload recusada.`);let s=i.header(`Location`);if(!s)throw S(i,`Criação do upload sem cabeçalho Location.`);return I=C(s),await X(),I}async function te(e,t){if(H(),t.needed&&(F=await Z(e),t.needed=!1,q(F),await X(),F>=f.size))return;let n=Math.min(F+p,f.size),r=F,i=await _({method:`PATCH`,url:e,headers:{...J(e,`PATCH`),"Content-Type":`application/offset+octet-stream`,"Upload-Offset":String(r)},body:f.slice(r,n),withCredentials:D,onProgress:e=>q(Math.min(r+e,f.size)),register:Y});if(i.status===409||i.status===412)throw t.needed=!0,S(i,`Offset divergente — o servidor já tinha esses bytes.`);if(i.status!==204&&i.status!==200)throw S(i,`Chunk recusado pelo servidor.`);F=v(i)??n,q(F),await X()}async function ne(){U=null;let e=await ee();G=F,K(`uploading`),q(F);let t={needed:!1};for(;F<f.size&&!U;)await i.retry(()=>te(e,t),{retries:5,...j,shouldRetry:(e,n)=>{if(U||e instanceof l||e instanceof DOMException&&e.name===`AbortError`)return!1;let r=j?.shouldRetry?.(e,n)??x(e);return r&&(t.needed=!0),r}});return U===`pause`?(K(`paused`),null):U===`abort`?(K(`aborted`),null):(K(`done`),A&&await A.delete(k),{url:e,size:f.size})}async function Q(){try{return await ne()}catch(e){if(U!==null||e instanceof DOMException&&e.name===`AbortError`)return K(U===`abort`?`aborted`:`paused`),null;throw K(`error`),e}finally{W=null}}function $(e){U=e,W?.abort(),W=null}return{start:Q,resume:Q,pause:()=>$(`pause`),abort:async({discard:e=!1}={})=>{$(`abort`),K(`aborted`),e&&(I&&await _({method:`DELETE`,url:I,headers:J(I,`DELETE`),withCredentials:D,register:()=>void 0}).catch(()=>void 0),A&&await A.delete(k))},get state(){return P},get offset(){return F},get url(){return I},key:k}}exports.DEFAULT_CHUNK_SIZE=c,exports.ResumableUploadSupersededError=l,exports.TUS_VERSION=s,exports.createLocalUploadStorage=g,exports.createResumableUpload=w,exports.uploadFingerprint=h;
1
+ const e=require("./errors.cjs"),t=require("../utils/base64.cjs"),n=require("./credential-scope.cjs"),r=require("./csrf.cjs"),i=require("./retry.cjs"),a=require("../auth/session-identity.cjs"),o=require("../utils/owner-fingerprint.cjs"),ee=require("./idempotency.cjs");var s=`1.0.0`,c=5242880,l=class extends Error{reason;constructor(e){super(e===`principal`?`O usuário mudou durante o upload resumível.`:`Os registros de upload resumível foram limpos durante o upload.`),this.name=`ResumableUploadSupersededError`,this.reason=e}},u=new Map,d=new WeakMap;function f(e){if(!e)return!1;try{let t=JSON.parse(e);return typeof t==`object`&&!!t&&typeof t.url==`string`&&typeof t.offset==`number`&&typeof t.size==`number`&&typeof t.idempotencyKey==`string`}catch{return!1}}function p(e){return t.bytesToBase64(new TextEncoder().encode(e))}function te(e){if(!e)return null;let t=Object.entries(e).map(([e,t])=>`${e} ${p(t)}`);return t.length>0?t.join(`,`):null}function m(e,t){let n=t,r=typeof n.name==`string`?n.name:`blob`,i=typeof n.lastModified==`number`?n.lastModified:0;return`${e}|${r}|${t.size}|${t.type}|${i}`}function h(e=`tempest-upload:`){function t(){try{return typeof localStorage>`u`?null:localStorage}catch{return null}}let n={get(n){let r=t()?.getItem(e+n);if(!r)return null;try{return JSON.parse(r)}catch{return null}},set(n,r){t()?.setItem(e+n,JSON.stringify(r))},delete(n){t()?.removeItem(e+n)},clear(){u.set(e,(u.get(e)??0)+1);let n=t();if(!n)return;let r=[];for(let t=0;t<n.length;t+=1){let i=n.key(t);i?.startsWith(e)&&f(n.getItem(i))&&r.push(i)}for(let e of r)n.removeItem(e)}};return d.set(n,()=>u.get(e)??0),n}var g=`REDIRECT_REFUSED`,_=`UPLOAD_OFFSET_STALLED`;function v(e){return e.holdRedirects?y(e):b(e)}async function y(t){let n=new AbortController;t.register(n);let r;try{r=await fetch(t.url,{method:t.method,headers:t.headers,body:t.body,credentials:t.withCredentials?`include`:`same-origin`,redirect:`manual`,signal:n.signal})}catch{throw n.signal.aborted?new DOMException(`Aborted`,`AbortError`):new e.TempestApiError({status:0,detail:`Falha de rede no upload resumível.`})}if(r.type===`opaqueredirect`||r.status>=300&&r.status<400&&r.status!==304)throw new e.TempestApiError({status:r.status,code:g,detail:`Redirect não seguido: a requisição do upload levava os headers fixos.`});let i=t.method===`HEAD`?``:await r.text();return{status:r.status,text:i,header:e=>r.headers.get(e)}}function b(t){return new Promise((n,r)=>{let i=new XMLHttpRequest;i.open(t.method,t.url),i.withCredentials=t.withCredentials;for(let[e,n]of Object.entries(t.headers))i.setRequestHeader(e,n);if(t.onProgress){let e=t.onProgress;i.upload.onprogress=t=>e(t.loaded)}i.onload=()=>n({status:i.status,text:i.responseText,header:e=>i.getResponseHeader(e)}),i.onerror=()=>r(new e.TempestApiError({status:0,detail:`Falha de rede no upload resumível.`})),i.onabort=()=>r(new DOMException(`Aborted`,`AbortError`)),t.register(i),i.send(t.body)})}function x(e){let t=e.header(`Upload-Offset`);if(t===null)return null;let n=Number(t);return Number.isFinite(n)&&n>=0?n:null}function S(e){if(!e)return null;try{return JSON.parse(e)}catch{return e}}var C=new Set([409,412]);function ne(t){return e.isApiError(t)?t.code===g?!1:t.code===_||C.has(t.status)?!0:e.isRetriableStatus(t.status):!0}function w(t,n){let r=S(t.text),i=e.buildApiError(t.status,r,{get:t.header}),a=typeof r==`object`&&!!r&&(`detail`in r||`message`in r);return new e.TempestApiError({...i,detail:a?i.detail:n})}function T(e,t){try{return new URL(e,n.resolveRequestUrl(t)??void 0).href}catch{return e}}function E(t){let{endpoint:u,file:f,chunkSize:p=c,metadata:g,headers:y={},getToken:b,getPrincipal:S,trustedOrigins:C,withCredentials:E=!1,csrf:re,key:D=m(u,f),storage:O=h(),retry:k,onProgress:ie,onStateChange:ae}=t,A=S??a.sessionIdentityOf(b)?.current,j=`idle`,M=0,N=null,P=null,F,I=!1,L=!1,R=O?d.get(O):void 0,z=R?.()??0;function B(){if(A)return o.ownerFingerprint(`p`,A());if(b)return o.ownerFingerprint(`t`,b())}function V(){if(A&&o.ownerFingerprint(`p`,A())!==F)throw new l(`principal`);if(R&&R()!==z)throw new l(`cleared`)}let H=null,U=null,W=0;function G(e){j!==e&&(j=e,ae?.(e))}function K(e){ie?.({loaded:e,total:f.size,fraction:f.size===0?1:e/f.size,resumedFrom:W})}function q(e,t){let i=n.isTrustedCredentialTarget(e,u,C),a={...i?y:{},"Tus-Resumable":s},o=b?.();return o&&!(`Authorization`in y)&&(i?a.Authorization=`Bearer ${o}`:n.reportSuppressedCredential(e,u)),Object.assign(a,r.csrfHeaders({method:t,url:e,reference:u,csrf:re,trustedOrigins:C,headers:y})),a}function J(e){U=e}function Y(e){return Object.keys(y).length>0&&n.isTrustedCredentialTarget(e,u,C)}async function X(){O&&N&&P&&(V(),F=B(),await O.set(D,{url:N,offset:M,size:f.size,idempotencyKey:P,updatedAt:Date.now(),...F===void 0?{}:{owner:F}}))}async function Z(t){let n=await v({method:`HEAD`,url:t,headers:q(t,`HEAD`),withCredentials:E,register:J,holdRedirects:Y(t)});if(n.status===404||n.status===410)throw new e.TempestApiError({status:n.status,detail:`O upload expirou no servidor. Comece de novo.`});let r=x(n);if(r===null)throw w(n,`HEAD sem Upload-Offset.`);return r}async function oe(){L&&(L=!1,I=!1,N=null,P=null);let e=B();I&&A&&e!==F&&(P=null);let t=I&&!A?N:null;F=e,I=!0,z=R?.()??0;let n=O?await O.get(D):null,r=n!==null&&t!==null&&n.url===t;if(n&&F!==void 0&&n.owner!==F&&!r&&(await O?.delete(D),n=null),n&&n.size===f.size&&(P=n.idempotencyKey,n.url))try{return M=await Z(n.url),N=n.url,n.url}catch{M=0}G(`creating`),P??=ee.generateIdempotencyKey(),N=null,M=0,O&&await O.set(D,{url:``,offset:0,size:f.size,idempotencyKey:P,updatedAt:Date.now(),...F===void 0?{}:{owner:F}});let i={...q(u,`POST`),"Upload-Length":String(f.size),"Idempotency-Key":P},a=te(g);a&&(i[`Upload-Metadata`]=a);let o=await v({method:`POST`,url:u,headers:i,withCredentials:E,register:J,holdRedirects:Y(u)});if(o.status!==201)throw w(o,`Criação do upload recusada.`);let s=o.header(`Location`);if(!s)throw w(o,`Criação do upload sem cabeçalho Location.`);return N=T(s,u),await X(),N}async function se(t,n){if(V(),n.needed&&(M=await Z(t),n.needed=!1,K(M),await X(),M>=f.size))return;let r=Math.min(M+p,f.size),i=M,a=await v({method:`PATCH`,url:t,headers:{...q(t,`PATCH`),"Content-Type":`application/offset+octet-stream`,"Upload-Offset":String(i)},body:f.slice(i,r),withCredentials:E,onProgress:e=>K(Math.min(i+e,f.size)),register:J,holdRedirects:Y(t)});if(a.status===409||a.status===412)throw n.needed=!0,w(a,`Offset divergente — o servidor já tinha esses bytes.`);if(a.status!==204&&a.status!==200)throw w(a,`Chunk recusado pelo servidor.`);let o=x(a);if(o!==null&&o<=i)throw new e.TempestApiError({status:a.status,code:_,detail:`O servidor confirmou o chunk sem avançar o Upload-Offset (${i} → ${o}).`});M=o??r,K(M),await X()}async function ce(){H=null;let e=await oe();W=M,G(`uploading`),K(M);let t={needed:!1};for(;M<f.size&&!H;)await i.retry(()=>se(e,t),{retries:5,...k,shouldRetry:(e,n)=>{if(H||e instanceof l||e instanceof DOMException&&e.name===`AbortError`)return!1;let r=k?.shouldRetry?.(e,n)??ne(e);return r&&(t.needed=!0),r}});return H===`pause`?(G(`paused`),null):H===`abort`?(G(`aborted`),null):(G(`done`),O&&await O.delete(D),{url:e,size:f.size})}async function Q(){try{return await ce()}catch(e){if(H!==null||e instanceof DOMException&&e.name===`AbortError`)return G(H===`abort`?`aborted`:`paused`),null;throw G(`error`),e}finally{U=null}}function $(e){H=e,U?.abort(),U=null}return{start:Q,resume:Q,pause:()=>$(`pause`),abort:async({discard:e=!1}={})=>{$(`abort`),L=!0,G(`aborted`),e&&(N&&await v({method:`DELETE`,url:N,headers:q(N,`DELETE`),withCredentials:E,register:()=>void 0,holdRedirects:Y(N)}).catch(()=>void 0),O&&await O.delete(D))},get state(){return j},get offset(){return M},get url(){return N},key:D}}exports.DEFAULT_CHUNK_SIZE=c,exports.ResumableUploadSupersededError=l,exports.TUS_VERSION=s,exports.createLocalUploadStorage=h,exports.createResumableUpload=E,exports.uploadFingerprint=m;
2
2
  //# sourceMappingURL=resumable-upload.cjs.map