@classytic/arc-next 0.5.0 → 0.7.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.
package/dist/client.js CHANGED
@@ -1,49 +1,59 @@
1
1
  //#region src/client.ts
2
2
  /**
3
- * Top-level error codes arc's `errorHandlerPlugin` emits on the response root
4
- * (`json.code`). Mirrors `statusCodeToCode()` + the special `VALIDATION_ERROR`
5
- * / `DUPLICATE_KEY` / `INTERNAL_ERROR` branches.
6
- *
7
- * Open via intersection with `(string & {})` so unknown codes from custom
8
- * `errorMappers` / `errorMap` still satisfy the type.
9
- */
10
- /**
11
- * Top-level HTTP-status error codes arc's `errorHandlerPlugin` emits on
12
- * `json.code`. Exported as a `const` array first so callers get both
13
- * compile-time exhaustiveness AND runtime iteration (e.g. building a
14
- * client-side i18n lookup, or whitelisting which codes get retried).
15
- */
16
- const KNOWN_TOP_LEVEL_CODES = [
17
- "BAD_REQUEST",
18
- "UNAUTHORIZED",
19
- "FORBIDDEN",
20
- "NOT_FOUND",
21
- "METHOD_NOT_ALLOWED",
22
- "CONFLICT",
23
- "UNPROCESSABLE_ENTITY",
24
- "RATE_LIMITED",
25
- "INTERNAL_ERROR",
26
- "BAD_GATEWAY",
27
- "SERVICE_UNAVAILABLE",
28
- "GATEWAY_TIMEOUT",
29
- "VALIDATION_ERROR",
30
- "DUPLICATE_KEY",
31
- "DOMAIN_ERROR"
32
- ];
33
- /**
34
- * Nested business-logic codes arc's mixins / preset routes / org guards emit
35
- * on `json.details.code`. These are distinct from the HTTP-status code above —
36
- * `error.code` (top-level) vs `error.detailsCode` (nested). When a route
37
- * returns 403 with `details.code: 'ORG_CONTEXT_REQUIRED'`, hosts read
38
- * `detailsCode` to disambiguate "missing org context" from "permission denied".
3
+ * Canonical error codes arc and repo-core emit on `json.code`. Single
4
+ * top-level slot — arc 2.13's `createError` lifts business codes from
5
+ * `details` to top-level so `repo-core`'s `toErrorContract` round-trips
6
+ * them on the wire. There is no separate `detailsCode` slot; everything
7
+ * lives at `error.code`.
8
+ *
9
+ * Three families compose this list:
10
+ * 1. **repo-core canonical** (`validation_error`, `not_found`, ...) — RFC 7807
11
+ * / Stripe-shaped lowercase + snake_case. Cross-package universals.
12
+ * 2. **arc hierarchical** (`arc.forbidden`, `arc.validation_error`,
13
+ * `arc.org.access_denied`, ...) what arc's `errorHandlerPlugin`
14
+ * emits for HTTP-status throws + arc-classified errors.
15
+ * 3. **arc business** (`ORG_CONTEXT_REQUIRED`, `ALL_FIELDS_STRIPPED`,
16
+ * `OWNERSHIP_DENIED`, ...) emitted by mixins / org guards via
17
+ * `createError(status, msg, { code })`. The UPPER_SNAKE form is
18
+ * intentional: these are reason codes, not HTTP-status codes.
19
+ *
20
+ * `(string & {})` keeps the type open so domain packages and custom
21
+ * `errorMappers` codes still satisfy it.
39
22
  */
40
- const KNOWN_DETAILS_CODES = [
23
+ const KNOWN_ARC_ERROR_CODES = [
24
+ "validation_error",
25
+ "not_found",
26
+ "conflict",
27
+ "unauthorized",
28
+ "forbidden",
29
+ "rate_limited",
30
+ "idempotency_conflict",
31
+ "precondition_failed",
32
+ "internal_error",
33
+ "service_unavailable",
34
+ "timeout",
35
+ "arc.bad_request",
36
+ "arc.unauthorized",
37
+ "arc.forbidden",
38
+ "arc.not_found",
39
+ "arc.conflict",
40
+ "arc.unprocessable_entity",
41
+ "arc.rate_limited",
42
+ "arc.internal_error",
43
+ "arc.bad_gateway",
44
+ "arc.service_unavailable",
45
+ "arc.gateway_timeout",
46
+ "arc.validation_error",
47
+ "arc.invalid_id",
48
+ "arc.org.selection_required",
49
+ "arc.org.access_denied",
41
50
  "ORG_CONTEXT_REQUIRED",
42
51
  "ORG_ROLE_REQUIRED",
43
52
  "OWNERSHIP_DENIED",
44
53
  "MIXED_UPDATE_SHAPE",
45
54
  "ALL_FIELDS_STRIPPED",
46
- "BEFORE_RESTORE_HOOK_ERROR"
55
+ "BEFORE_RESTORE_HOOK_ERROR",
56
+ "duplicate_key"
47
57
  ];
48
58
  /**
49
59
  * Rich API error with status code, response payload, and request metadata.
@@ -75,51 +85,59 @@ var ArcApiError = class extends Error {
75
85
  this.method = options.method;
76
86
  }
77
87
  /**
78
- * Top-level error code from arc's response envelope (`json.code`).
88
+ * Canonical error code from arc's wire envelope (`json.code`).
79
89
  *
80
- * One of {@link ArcTopLevelErrorCode} covers HTTP-status codes (`FORBIDDEN`,
81
- * `CONFLICT`...) plus the dedicated `VALIDATION_ERROR` / `DUPLICATE_KEY`
82
- * branches arc emits. Returns `null` if the response carries no envelope.
90
+ * Arc 2.13 + `repo-core` 0.4 emit one canonical {@link ErrorContract}
91
+ * shape — `{ code, message, status, details? }` with the business
92
+ * code at top-level. Hosts switch on `error.code` directly:
83
93
  *
84
94
  * @example
85
- * if (error.code === 'DUPLICATE_KEY') showRetryAsAdmin();
95
+ * if (error.code === 'ORG_CONTEXT_REQUIRED') promptOrgSelector();
96
+ * if (error.code === 'arc.not_found') router.replace('/404');
97
+ * if (error.code === 'duplicate_key') showRetryAsAdmin();
86
98
  */
87
99
  get code() {
88
100
  const j = this.json;
89
101
  return j && typeof j.code === "string" ? j.code : null;
90
102
  }
91
103
  /**
92
- * Nested business-logic code from arc's `json.details.code` slot.
93
- *
94
- * Arc's preset mixins (bulk, softDelete) and org guards emit specific reason
95
- * codes here distinct from the HTTP-status `code` above. The most common
96
- * is `ORG_CONTEXT_REQUIRED` (bulk operations + orgGuard reject when the
97
- * caller's `request.scope.organizationId` is missing).
98
- *
99
- * @example
100
- * if (error.detailsCode === 'ORG_CONTEXT_REQUIRED') {
101
- * alert('Configure auth before bulk operations: configureAuth({ getOrgId })');
102
- * }
104
+ * Canonical structured details populated for validation failures
105
+ * (one entry per offending field) and duplicate-key conflicts (one entry
106
+ * per offending field). Shape matches `repo-core`'s {@link ErrorDetail}:
107
+ * `{ path?, code, message, meta? }`. Returns `null` for non-arc backends
108
+ * or responses without details.
103
109
  */
104
- get detailsCode() {
105
- const c = this.json?.details?.code;
106
- return typeof c === "string" ? c : null;
110
+ get details() {
111
+ const j = this.json;
112
+ return Array.isArray(j?.details) ? j.details : null;
107
113
  }
108
114
  /**
109
115
  * Extract field-level validation errors as `{ field: message }` map.
110
116
  *
111
- * Reads three response shapes used by Arc:
112
- * 1. `{ errors: { email: 'invalid' } }` record form (legacy / app-level handlers).
113
- * 2. `{ details: { errors: [{ field, message }] } }` — Fastify AJV + Arc errorHandler emit this.
114
- * 3. `{ details: { errors: [{ instancePath, message, params }] } }` — raw AJV passthrough.
115
- *
116
- * Returns null when none of the shapes match.
117
+ * Reads the canonical `ErrorContract.details: ErrorDetail[]` shape first
118
+ * (what arc 2.13 + repo-core emit), then falls back to legacy shapes for
119
+ * non-arc backends:
120
+ * 1. `details: [{ path, code, message }]` — canonical (arc / repo-core).
121
+ * 2. `errors: { email: 'invalid' }` — record form (legacy app handlers).
122
+ * 3. `details: { errors: [{ field|instancePath, message }] }` — pre-2.13 AJV.
123
+ * 4. `errors: [...]` at the top level — third-party frameworks.
117
124
  */
118
125
  get fieldErrors() {
119
126
  const j = this.json;
120
127
  if (!j) return null;
128
+ if (Array.isArray(j.details) && j.details.length > 0) {
129
+ const map = {};
130
+ for (const d of j.details) {
131
+ if (!d || typeof d !== "object") continue;
132
+ const key = typeof d.path === "string" ? d.path : "";
133
+ const msg = typeof d.message === "string" ? d.message : "Invalid value";
134
+ if (key && !(key in map)) map[key] = msg;
135
+ }
136
+ if (Object.keys(map).length > 0) return map;
137
+ }
121
138
  if (j.errors && !Array.isArray(j.errors) && typeof j.errors === "object") return j.errors;
122
- const errorList = Array.isArray(j.errors) ? j.errors : Array.isArray(j.details?.errors) ? j.details.errors : null;
139
+ const detailsObj = j.details;
140
+ const errorList = Array.isArray(j.errors) ? j.errors : Array.isArray(detailsObj?.errors) ? detailsObj.errors : null;
123
141
  if (!errorList) return null;
124
142
  const map = {};
125
143
  for (const item of errorList) {
@@ -169,26 +187,26 @@ function isAbortError(error) {
169
187
  return false;
170
188
  }
171
189
  /**
172
- * Generic check: is this an `ArcApiError` carrying a specific top-level OR
173
- * nested `details.code`? Matches whichever slot the code lives in — saves
174
- * call sites from having to know whether arc emitted it at the root or under
175
- * `details`.
190
+ * Generic check: is this an `ArcApiError` carrying a specific `code`?
191
+ * Single-slot — arc 2.13 + repo-core 0.4 emit one canonical `code` at
192
+ * top-level. Pass either the canonical lowercase form (`'arc.not_found'`,
193
+ * `'validation_error'`) or arc's UPPER_SNAKE business form
194
+ * (`'ORG_CONTEXT_REQUIRED'`).
176
195
  *
177
196
  * @example
178
- * if (isArcErrorCode(error, 'DUPLICATE_KEY')) showRetryUI();
197
+ * if (isArcErrorCode(error, 'duplicate_key')) showRetryUI();
179
198
  * if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
180
199
  */
181
200
  function isArcErrorCode(error, code) {
182
- if (!isArcApiError(error)) return false;
183
- return error.code === code || error.detailsCode === code;
201
+ return isArcApiError(error) && error.code === code;
184
202
  }
185
203
  /**
186
204
  * Specific predicate for arc's bulk-preset + orgGuard safety code.
187
205
  *
188
206
  * Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
189
207
  * where `request.scope.organizationId` is missing — the wire signal is
190
- * `403 { details: { code: 'ORG_CONTEXT_REQUIRED' } }`. Hosts hitting this
191
- * need to call `configureAuth({ getOrgId })` before retrying.
208
+ * `403 { code: 'ORG_CONTEXT_REQUIRED', message, status: 403 }`. Hosts hitting
209
+ * this need to call `configureAuth({ getOrgId })` before retrying.
192
210
  *
193
211
  * @example
194
212
  * try { await api.bulkCreate({ data: [...] }); }
@@ -202,20 +220,27 @@ function isOrgContextRequiredError(error) {
202
220
  return isArcErrorCode(error, "ORG_CONTEXT_REQUIRED");
203
221
  }
204
222
  /**
205
- * Specific predicate for arc's `VALIDATION_ERROR` (Fastify AJV + Mongoose
223
+ * Specific predicate for validation failures (Fastify AJV + Mongoose
206
224
  * ValidationError). When true, `error.fieldErrors` is populated with the
207
- * `{ field: message }` map.
225
+ * `{ field: message }` map. Matches arc's `arc.validation_error` and the
226
+ * canonical `validation_error` from repo-core.
208
227
  */
209
228
  function isValidationError(error) {
210
- return isArcErrorCode(error, "VALIDATION_ERROR");
229
+ if (!isArcApiError(error)) return false;
230
+ return error.code === "arc.validation_error" || error.code === "validation_error";
211
231
  }
212
232
  /**
213
- * Specific predicate for arc's `DUPLICATE_KEY` (unique-constraint violation).
214
- * Arc's errorHandler classifies these uniformly across MongoDB E11000,
215
- * Postgres 23505, and Prisma P2002.
233
+ * Specific predicate for unique-constraint violations. Arc's errorHandler
234
+ * classifies these uniformly across MongoDB E11000, Postgres 23505,
235
+ * Prisma P2002 `arc.conflict` (with `details[].code === 'duplicate_key'`).
216
236
  */
217
237
  function isDuplicateKeyError(error) {
218
- return isArcErrorCode(error, "DUPLICATE_KEY");
238
+ if (!isArcApiError(error)) return false;
239
+ if (error.code === "arc.conflict" || error.code === "duplicate_key" || error.code === "conflict") {
240
+ if (error.code === "arc.conflict") return error.details?.some((d) => d.code === "duplicate_key") ?? false;
241
+ return true;
242
+ }
243
+ return false;
219
244
  }
220
245
  let clientConfig = null;
221
246
  /**
@@ -250,6 +275,23 @@ function getBaseUrl() {
250
275
  function isAutoIdempotency() {
251
276
  return clientConfig?.autoIdempotency ?? false;
252
277
  }
278
+ /**
279
+ * Whether the globally-configured client carries enough auth to satisfy a
280
+ * protected endpoint without a per-request token. True when any of
281
+ * `internalApiKey`, `defaultHeaders`, or `authMode: 'cookie'` is configured.
282
+ *
283
+ * Read by `createCrudHooks` to decide whether queries should be enabled when
284
+ * `getToken()` returns null — without this, an app that authenticates via a
285
+ * global `internalApiKey` or static headers would see every query stuck in
286
+ * a permanently-disabled state, looking like a clean empty success.
287
+ */
288
+ function hasGlobalStaticAuth() {
289
+ if (!clientConfig) return false;
290
+ if (clientConfig.authMode === "cookie") return true;
291
+ if (clientConfig.internalApiKey) return true;
292
+ if (clientConfig.defaultHeaders && Object.keys(clientConfig.defaultHeaders).length > 0) return true;
293
+ return false;
294
+ }
253
295
  let authConfig = null;
254
296
  let hasWarnedAsyncToken = false;
255
297
  /**
@@ -283,7 +325,7 @@ function readToken(getToken) {
283
325
  if (result && typeof result.then === "function") {
284
326
  if (!hasWarnedAsyncToken) {
285
327
  hasWarnedAsyncToken = true;
286
- console.warn("[arc-next] configureAuth({ getToken }) returned a Promise. Tokens MUST resolve synchronously — async returns are dropped and requests will be unauthenticated. Cache your token (localStorage, memory, signal) and have getToken() return the cached value.");
328
+ console.error(/* @__PURE__ */ new Error("[arc-next] configureAuth({ getToken }) returned a Promise. Tokens MUST resolve synchronously — async returns are dropped and every authenticated query will be silently disabled (no GET fires, isLoading:false, item:null). Fix: cache the token outside getToken (localStorage, memory, signal, useState) and have getToken() return the cached value. See README → 'Authentication'."));
287
329
  }
288
330
  return null;
289
331
  }
@@ -303,6 +345,138 @@ function _resetAuthWarnings() {
303
345
  hasWarnedAsyncToken = false;
304
346
  }
305
347
  /**
348
+ * Shared in-flight refresh. Multiple concurrent 401s collapse onto one
349
+ * `onAuthError` invocation — the dedup happens here. Cleared in `.finally`
350
+ * so the NEXT 401 (after settlement) triggers a fresh recovery cycle.
351
+ */
352
+ let pendingAuthRecovery = null;
353
+ /** @internal — exposed for tests; clears the dedup so they don't bleed. */
354
+ function _resetAuthRecovery() {
355
+ pendingAuthRecovery = null;
356
+ }
357
+ /**
358
+ * @internal
359
+ * Cross-transport access to the configured auth-recovery handler.
360
+ * Upload (XHR) and WebSocket / SSE plumbing share the same dedup as the
361
+ * fetch path — they read the handler here and call {@link _runAuthRecovery}
362
+ * when they detect a transport-specific auth failure (XHR 401, WS close
363
+ * code 1008/4401, SSE pre-flight probe 401).
364
+ */
365
+ function _getAuthErrorHandler() {
366
+ return {
367
+ handler: authConfig?.onAuthError,
368
+ retryOn403: authConfig?.retryOn403 ?? false,
369
+ maxAuthRetries: Math.max(0, authConfig?.maxAuthRetries ?? 1)
370
+ };
371
+ }
372
+ /**
373
+ * @internal
374
+ * Drive the shared recovery cycle from a non-fetch transport. Same dedup
375
+ * as the fetch path — concurrent callers (XHR upload + WebSocket reconnect
376
+ * + SSE probe firing at once) collapse to one refresh.
377
+ */
378
+ function _runAuthRecovery(handler, ctx) {
379
+ return runAuthRecovery(handler, ctx);
380
+ }
381
+ /**
382
+ * @internal
383
+ * Resolve the next-attempt token. Mirrors the priority in `executeRequest`'s
384
+ * auth loop — `setToken` override beats re-reading `getToken()`. Exported so
385
+ * transports outside the fetch path apply the same precedence.
386
+ */
387
+ function _resolveRefreshedToken(overrideToken) {
388
+ if (overrideToken !== void 0) return overrideToken;
389
+ return readToken(authConfig?.getToken);
390
+ }
391
+ /**
392
+ * @internal
393
+ * True for any error a transport should run through `onAuthError`. Matches
394
+ * the fetch path's predicate so XHR / WS / SSE failures classify the same
395
+ * way (401, or 403 when `retryOn403`).
396
+ */
397
+ function _isAuthRecoverable(error, retryOn403) {
398
+ return isAuthRecoverable(error, retryOn403);
399
+ }
400
+ /** True for status codes that should trigger {@link AuthConfig.onAuthError}. */
401
+ function isAuthRecoverable(error, retryOn403) {
402
+ if (!isArcApiError(error)) return false;
403
+ if (error.status === 401) return true;
404
+ if (retryOn403 && error.status === 403) return true;
405
+ return false;
406
+ }
407
+ /**
408
+ * Run the recovery handler with concurrent-request dedup. Every concurrent
409
+ * 401 awaits the same promise and gets the same decision + override token.
410
+ * Cleared on settlement so a later (post-settlement) 401 starts a new cycle.
411
+ */
412
+ function runAuthRecovery(handler, ctx) {
413
+ if (pendingAuthRecovery) return pendingAuthRecovery;
414
+ pendingAuthRecovery = (async () => {
415
+ let overrideToken = void 0;
416
+ const setToken = (token) => {
417
+ overrideToken = token;
418
+ };
419
+ return {
420
+ decision: await handler({
421
+ ...ctx,
422
+ setToken
423
+ }),
424
+ overrideToken
425
+ };
426
+ })().finally(() => {
427
+ pendingAuthRecovery = null;
428
+ });
429
+ return pendingAuthRecovery;
430
+ }
431
+ /**
432
+ * Build an {@link AuthErrorHandler} from any `refresh()` function that
433
+ * returns the new token (or `null` if the session is truly expired).
434
+ *
435
+ * Catches refresh errors and surfaces them as `'skip'` by default so the
436
+ * original 401 reaches the consumer instead of a misleading "refresh failed"
437
+ * trace — consumers expect to handle "session expired" once, not twice. Pass
438
+ * `onRefreshError: 'throw'` to opt in to propagation.
439
+ *
440
+ * @example Better Auth (or any session-based lib)
441
+ * ```ts
442
+ * import { configureAuth, createAuthRefreshHandler } from '@classytic/arc-next/client';
443
+ * import { authClient } from '@/lib/auth-client';
444
+ *
445
+ * configureAuth({
446
+ * getToken: () => authClient.getSession().data?.session.token ?? null,
447
+ * onAuthError: createAuthRefreshHandler({
448
+ * refresh: async () => {
449
+ * const { data } = await authClient.getSession({ disableCookieCache: true });
450
+ * return data?.session.token ?? null;
451
+ * },
452
+ * }),
453
+ * });
454
+ * ```
455
+ *
456
+ * @example Custom OAuth refresh
457
+ * ```ts
458
+ * configureAuth({
459
+ * getToken: () => tokenStore.getAccessToken(),
460
+ * onAuthError: createAuthRefreshHandler({
461
+ * refresh: () => oauthClient.refresh(tokenStore.getRefreshToken()),
462
+ * }),
463
+ * });
464
+ * ```
465
+ */
466
+ function createAuthRefreshHandler(opts) {
467
+ return async ({ setToken }) => {
468
+ try {
469
+ const token = await opts.refresh();
470
+ if (token == null) return "skip";
471
+ setToken(token);
472
+ return "retry";
473
+ } catch (err) {
474
+ if (opts.onRefreshError === "throw") throw err;
475
+ return "skip";
476
+ }
477
+ };
478
+ }
479
+ /**
306
480
  * Build an auth-aware URL using the global client + auth singletons.
307
481
  *
308
482
  * Single source of truth for {@link import('./sse.js').buildSseUrl} (HTTP) and
@@ -445,7 +619,14 @@ function computeBackoff(retry, attempt) {
445
619
  if (strategy === "linear") return 300 * (attempt + 1);
446
620
  return Math.min(300 * Math.pow(2, attempt), 1e4);
447
621
  }
448
- async function executeRequest(config, method, endpoint, options = {}) {
622
+ /**
623
+ * Inner request loop — handles 5xx + network-failure backoff per
624
+ * {@link ClientConfig.retry}. Knows nothing about 401 recovery; that's the
625
+ * outer {@link executeRequest} wrapper. Split so the two retry families don't
626
+ * tangle: the backoff loop has its own attempt counter and predicate, the
627
+ * auth loop has its own cap and dedup.
628
+ */
629
+ async function executeWithBackoff(config, method, endpoint, options = {}) {
449
630
  const totalAttempts = Math.max(1, config.retry?.attempts ?? 1);
450
631
  const shouldRetry = (() => {
451
632
  const r = config.retry?.retryOn;
@@ -464,6 +645,47 @@ async function executeRequest(config, method, endpoint, options = {}) {
464
645
  }
465
646
  throw lastError;
466
647
  }
648
+ /**
649
+ * Top-level request entry. Wraps the 5xx-backoff loop with the 401-recovery
650
+ * loop so the two retry families compose cleanly:
651
+ *
652
+ * 401 (auth) ─→ onAuthError ─→ retry with fresh token ─→ may 5xx ─→ backoff
653
+ *
654
+ * `maxAuthRetries` (default 1) caps the auth loop independently of the
655
+ * backoff loop's `attempts`. AbortSignal propagates through both loops.
656
+ *
657
+ * The auth loop only fires when {@link AuthConfig.onAuthError} is configured —
658
+ * apps that haven't wired a refresh handler see the original behavior (401
659
+ * surfaces immediately, no extra round-trip).
660
+ */
661
+ async function executeRequest(config, method, endpoint, options = {}) {
662
+ const handler = authConfig?.onAuthError;
663
+ if (!handler) return executeWithBackoff(config, method, endpoint, options);
664
+ const retryOn403 = authConfig?.retryOn403 ?? false;
665
+ const maxAuthRetries = Math.max(0, authConfig?.maxAuthRetries ?? 1);
666
+ let currentOptions = options;
667
+ for (let authAttempt = 0; authAttempt <= maxAuthRetries; authAttempt++) try {
668
+ return await executeWithBackoff(config, method, endpoint, currentOptions);
669
+ } catch (error) {
670
+ if (authAttempt >= maxAuthRetries || !isAuthRecoverable(error, retryOn403)) throw error;
671
+ if (currentOptions.signal?.aborted) throw error;
672
+ const { decision, overrideToken } = await runAuthRecovery(handler, {
673
+ error,
674
+ request: {
675
+ method,
676
+ endpoint
677
+ },
678
+ attempt: authAttempt + 1
679
+ });
680
+ if (decision !== "retry") throw error;
681
+ const nextToken = overrideToken !== void 0 ? overrideToken : readToken(authConfig?.getToken);
682
+ currentOptions = {
683
+ ...currentOptions,
684
+ token: nextToken
685
+ };
686
+ }
687
+ throw new Error("arc-next: auth retry loop terminated without resolution");
688
+ }
467
689
  /** Sleep that resolves early if the signal aborts. */
468
690
  function sleepAbortable(ms, signal) {
469
691
  return new Promise((resolve, reject) => {
@@ -492,21 +714,23 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
492
714
  ...config.defaultHeaders ?? {}
493
715
  };
494
716
  if (config.internalApiKey) headers["x-internal-api-key"] = config.internalApiKey;
495
- if (token) if (config.authMode === "header") {
496
- const headerName = authConfig?.headerName ?? "x-api-key";
497
- headers[headerName] = token;
498
- } else headers["Authorization"] = `Bearer ${token}`;
717
+ if (token) {
718
+ if (config.authMode === "header") {
719
+ const headerName = authConfig?.headerName ?? "x-api-key";
720
+ headers[headerName] = token;
721
+ } else if (config.authMode !== "cookie") headers["Authorization"] = `Bearer ${token}`;
722
+ }
499
723
  if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
500
724
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
501
725
  if (elevated ?? config.elevated ?? false) headers["x-arc-scope"] = "platform";
502
- if (body !== void 0 && body !== null && !(body instanceof FormData)) headers["Content-Type"] = "application/json";
726
+ if (body !== void 0 && body !== null && !isNonJsonBody(body)) headers["Content-Type"] = "application/json";
503
727
  if (headerOptions) headers = {
504
728
  ...headers,
505
729
  ...headerOptions
506
730
  };
507
731
  const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
508
732
  let serializedBody = void 0;
509
- if (body !== void 0 && body !== null) serializedBody = body instanceof FormData ? body : JSON.stringify(body);
733
+ if (body !== void 0 && body !== null) serializedBody = isNonJsonBody(body) ? body : JSON.stringify(body);
510
734
  if (config.beforeRequest) {
511
735
  const ctx = await config.beforeRequest({
512
736
  method,
@@ -609,8 +833,8 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
609
833
  * Handles JSON, binary (PDF, images), CSV, and text responses.
610
834
  *
611
835
  * @example
612
- * const { success, data } = await handleApiRequest<ApiResponse<User>>('GET', '/users/me');
613
- * const response = await handleApiRequest<PaginatedResponse<Product>>('GET', '/products?page=1');
836
+ * const user = await handleApiRequest<User>('GET', '/users/me');
837
+ * const response = await handleApiRequest<PaginatedResult<Product>>('GET', '/products?page=1');
614
838
  */
615
839
  async function handleApiRequest(method, endpoint, options = {}) {
616
840
  if (!clientConfig) throw new Error("arc-next: Client not configured. Call configureClient({ baseUrl }) before making API requests.");
@@ -655,10 +879,195 @@ function createQueryString(params = {}) {
655
879
  if (value.length > 1) searchParams.append(`${key}[in]`, value.join(","));
656
880
  else if (value.length === 1) searchParams.append(key, String(value[0]));
657
881
  } else if (value === null) searchParams.append(key, "null");
882
+ else if (typeof value === "object") for (const [op, opValue] of Object.entries(value)) {
883
+ if (opValue === void 0 || opValue === "") continue;
884
+ const bracketKey = `${key}[${op}]`;
885
+ if (Array.isArray(opValue)) {
886
+ if (opValue.length > 0) searchParams.append(bracketKey, opValue.join(","));
887
+ } else if (opValue === null) searchParams.append(bracketKey, "null");
888
+ else searchParams.append(bracketKey, String(opValue));
889
+ }
658
890
  else searchParams.append(key, String(value));
659
891
  });
660
892
  return searchParams.toString();
661
893
  }
894
+ /**
895
+ * Body shapes that carry their own Content-Type and must NOT be re-serialized
896
+ * by arc-next:
897
+ *
898
+ * - `FormData` — multipart with a runtime-computed boundary.
899
+ * - `Blob` / `File` — carries `.type`.
900
+ * - `URLSearchParams` — `application/x-www-form-urlencoded`.
901
+ * - `ArrayBuffer` / typed arrays — raw bytes; caller controls Content-Type.
902
+ * - `ReadableStream` — caller controls Content-Type.
903
+ * - `string` — caller controls Content-Type (could be plain text, XML, etc.).
904
+ *
905
+ * Plain objects and arrays fall through and get `JSON.stringify`d with
906
+ * `Content-Type: application/json`.
907
+ */
908
+ function isNonJsonBody(body) {
909
+ if (body == null) return false;
910
+ if (typeof body === "string") return true;
911
+ if (typeof FormData !== "undefined" && body instanceof FormData) return true;
912
+ if (typeof Blob !== "undefined" && body instanceof Blob) return true;
913
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true;
914
+ if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) return true;
915
+ if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) return true;
916
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return true;
917
+ return false;
918
+ }
919
+ /**
920
+ * Returns the auth headers arc-next would inject on a fetch right now —
921
+ * `Authorization` (or the configured custom `headerName` for `authMode:
922
+ * 'header'`), `x-organization-id`, and `x-internal-api-key`. Use for the
923
+ * rare case where you need full `Response` control via plain `fetch` but
924
+ * still want arc-next's auth wiring.
925
+ *
926
+ * @example
927
+ * const res = await fetch(url, {
928
+ * headers: { ...arcAuthHeaders(), 'X-Custom': '1' },
929
+ * credentials: getAuthMode() === 'cookie' ? 'include' : 'same-origin',
930
+ * });
931
+ */
932
+ function arcAuthHeaders() {
933
+ const { token, organizationId } = getAuthContext();
934
+ const authMode = getAuthMode();
935
+ const headers = {};
936
+ if (token) {
937
+ if (authMode === "header") headers[authConfig?.headerName ?? "x-api-key"] = token;
938
+ else if (authMode !== "cookie") headers.Authorization = `Bearer ${token}`;
939
+ }
940
+ if (organizationId) headers["x-organization-id"] = organizationId;
941
+ if (clientConfig?.internalApiKey) headers["x-internal-api-key"] = clientConfig.internalApiKey;
942
+ return headers;
943
+ }
944
+ /**
945
+ * Headers that arc-next owns and a caller must not override via
946
+ * {@link ArcFetchOptions.headers}. Passing one of these in `options.headers`
947
+ * is silently dropped — see the `arcFetch` JSDoc for the rationale.
948
+ *
949
+ * Lower-cased on lookup so case-insensitive HTTP header semantics are
950
+ * honored (`Authorization` vs `authorization` both protected).
951
+ */
952
+ const ARC_FETCH_PROTECTED_HEADERS = new Set([
953
+ "authorization",
954
+ "x-organization-id",
955
+ "x-internal-api-key"
956
+ ]);
957
+ let defaultArcFetchClient = null;
958
+ function getDefaultArcFetchClient() {
959
+ if (!defaultArcFetchClient) defaultArcFetchClient = createAuthAwareClient();
960
+ return defaultArcFetchClient;
961
+ }
962
+ /** @internal — tests reset the default client between cases. */
963
+ function _resetArcFetchClient() {
964
+ defaultArcFetchClient = null;
965
+ }
966
+ /**
967
+ * Strip protected auth headers from a caller-supplied map. Case-insensitive
968
+ * (HTTP header names are case-insensitive). The custom-auth `headerName`
969
+ * (when `authMode: 'header'` is configured) is computed at call time so
970
+ * apps that reconfigure auth modes don't lose protection on the renamed
971
+ * header.
972
+ */
973
+ function sanitizeUserHeaders(headers) {
974
+ if (!headers) return {};
975
+ const customHeader = authConfig?.headerName?.toLowerCase();
976
+ const out = {};
977
+ for (const [name, value] of Object.entries(headers)) {
978
+ const lower = name.toLowerCase();
979
+ if (ARC_FETCH_PROTECTED_HEADERS.has(lower)) continue;
980
+ if (customHeader && lower === customHeader) continue;
981
+ out[name] = value;
982
+ }
983
+ return out;
984
+ }
985
+ /**
986
+ * Authenticated, tenant-scoped fetch to an arc endpoint — one line for the
987
+ * non-hook contexts where `useQuery` / `useMutation` aren't available
988
+ * (event handlers, service workers, server actions, custom MDX submits,
989
+ * background polls).
990
+ *
991
+ * Auto-injects on every call:
992
+ * - `Authorization: Bearer <token>` (from `configureAuth().getToken`), or
993
+ * the custom header for `authMode: 'header'`
994
+ * - `x-organization-id` (from `configureAuth().getOrgId`)
995
+ * - `Content-Type: application/json` (only for plain object/array bodies)
996
+ * - `x-internal-api-key`, `Accept-Version`, `Idempotency-Key`,
997
+ * `x-arc-scope` when configured
998
+ *
999
+ * Composes with everything else `configureClient` + `configureAuth` do:
1000
+ * - `retry` (5xx backoff)
1001
+ * - `onAuthError` (401 → refresh → retry, with concurrent dedup)
1002
+ * - `beforeRequest` / `afterResponse` interceptors
1003
+ * - `cookie` / `bearer` / `header` auth modes
1004
+ *
1005
+ * Response handling:
1006
+ * - 2xx → parsed body (JSON for `application/json`, Blob for binary,
1007
+ * text for `text/*`).
1008
+ * - non-2xx → throws `ArcApiError` with parsed body, status, endpoint,
1009
+ * method. Use `isArcApiError(err)` + `err.code` to discriminate.
1010
+ *
1011
+ * For full `Response` control (rare), use plain `fetch` with
1012
+ * {@link arcAuthHeaders} instead.
1013
+ *
1014
+ * @example
1015
+ * import { arc } from '@classytic/arc-next/client';
1016
+ *
1017
+ * // Before — 15 lines of header dance + error parse + JSON
1018
+ * // After:
1019
+ * const result = await arc.post<{ ok: true }>('/api/statements', statements);
1020
+ */
1021
+ function arcFetch(path, options = {}) {
1022
+ const { method = "GET", body, headers, signal, elevated, idempotencyKey, revalidate, tags, cache, client } = options;
1023
+ const transport = client ?? getDefaultArcFetchClient();
1024
+ const apiOptions = {
1025
+ body,
1026
+ headerOptions: sanitizeUserHeaders(headers),
1027
+ ...signal ? { signal } : {},
1028
+ ...elevated !== void 0 ? { elevated } : {},
1029
+ ...idempotencyKey ? { idempotencyKey } : {},
1030
+ ...revalidate !== void 0 ? { revalidate } : {},
1031
+ ...tags ? { tags } : {},
1032
+ ...cache ? { cache } : {}
1033
+ };
1034
+ return transport.request(method, path, apiOptions);
1035
+ }
1036
+ /**
1037
+ * Method-specific shorthands for the 90% case. Each mirrors `arcFetch` with
1038
+ * the HTTP verb pre-filled; mutating verbs accept `body` as the second arg
1039
+ * so the call reads as a sentence:
1040
+ *
1041
+ * `arc.post('/path', payload)` instead of
1042
+ * `arcFetch('/path', { method: 'POST', body: payload })`.
1043
+ *
1044
+ * Identical composition with `onAuthError`, retry, and interceptors.
1045
+ */
1046
+ const arc = {
1047
+ get: (path, opts = {}) => arcFetch(path, {
1048
+ ...opts,
1049
+ method: "GET"
1050
+ }),
1051
+ post: (path, body, opts = {}) => arcFetch(path, {
1052
+ ...opts,
1053
+ method: "POST",
1054
+ body
1055
+ }),
1056
+ put: (path, body, opts = {}) => arcFetch(path, {
1057
+ ...opts,
1058
+ method: "PUT",
1059
+ body
1060
+ }),
1061
+ patch: (path, body, opts = {}) => arcFetch(path, {
1062
+ ...opts,
1063
+ method: "PATCH",
1064
+ body
1065
+ }),
1066
+ delete: (path, opts = {}) => arcFetch(path, {
1067
+ ...opts,
1068
+ method: "DELETE"
1069
+ })
1070
+ };
662
1071
 
663
1072
  //#endregion
664
- export { ArcApiError, KNOWN_DETAILS_CODES, KNOWN_TOP_LEVEL_CODES, _resetAuthWarnings, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
1073
+ export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };