@shipstatic/types 0.9.5 → 0.9.7

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/README.md CHANGED
@@ -35,6 +35,7 @@ import { ShipError, ErrorType, isShipError } from '@shipstatic/types';
35
35
 
36
36
  throw ShipError.validation('File too large');
37
37
  throw ShipError.notFound('Deployment', id);
38
+ throw ShipError.forbidden('Account terminated');
38
39
  throw ShipError.authentication();
39
40
  throw ShipError.business('Plan limit reached');
40
41
 
@@ -43,7 +44,8 @@ if (isShipError(error)) {
43
44
  }
44
45
 
45
46
  if (error.isClientError()) { /* Business | Config | File | Validation */ }
46
- if (error.isAuthError()) { /* handle auth */ }
47
+ if (error.isAuthError()) { /* handle auth */ }
48
+ if (error.type === ErrorType.Validation) { /* specific-type checks */ }
47
49
  ```
48
50
 
49
51
  **HTTP client integration.** Both producer and consumer sides of the wire have first-class helpers, so every HTTP client across the platform reconstructs the same `ShipError` shape:
@@ -54,6 +56,8 @@ return c.json(error.toResponse(), error.status ?? 500);
54
56
 
55
57
  // Consumer side — two symmetric helpers cover both HTTP error modes:
56
58
 
59
+ // Both helpers take an optional operationName for context-aware fallback messages.
60
+
57
61
  // 1. Server returned a non-OK response
58
62
  if (!response.ok) {
59
63
  throw await ShipError.fromHttpResponse(response, 'Get account');
package/dist/index.d.ts CHANGED
@@ -305,25 +305,27 @@ export interface AccountOverrides {
305
305
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
306
306
  */
307
307
  export declare const ErrorType: {
308
- /** Validation failed (400) */
308
+ /** Validation failed (400). Input shape is wrong. */
309
309
  readonly Validation: "validation_failed";
310
- /** Resource not found (404) */
310
+ /** Resource not found (404). */
311
311
  readonly NotFound: "not_found";
312
- /** Rate limit exceeded (429) */
312
+ /** Authenticated but not allowed (403). User lacks permission for this action. */
313
+ readonly Forbidden: "forbidden";
314
+ /** Rate limit exceeded (429). */
313
315
  readonly RateLimit: "rate_limit_exceeded";
314
- /** Authentication required (401) */
316
+ /** Authentication required or failed (401). Missing/invalid credentials. */
315
317
  readonly Authentication: "authentication_failed";
316
- /** Business logic error (400) */
318
+ /** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
317
319
  readonly Business: "business_logic_error";
318
- /** API server error (500) */
320
+ /** API server error (500). Generic server-side fault. */
319
321
  readonly Api: "internal_server_error";
320
322
  /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
321
323
  readonly Network: "network_error";
322
324
  /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
323
325
  readonly Cancelled: "operation_cancelled";
324
- /** File operation error */
326
+ /** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
325
327
  readonly File: "file_error";
326
- /** Configuration error */
328
+ /** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
327
329
  readonly Config: "config_error";
328
330
  };
329
331
  export type ErrorType = typeof ErrorType[keyof typeof ErrorType];
@@ -337,8 +339,8 @@ export interface ErrorResponse {
337
339
  message: string;
338
340
  /** HTTP status code (API contexts) */
339
341
  status?: number;
340
- /** Optional additional error details */
341
- details?: any;
342
+ /** Optional additional error details. Untyped by design — narrow at the read site. */
343
+ details?: unknown;
342
344
  }
343
345
  /**
344
346
  * Simple unified error class for both API and SDK
@@ -346,27 +348,33 @@ export interface ErrorResponse {
346
348
  export declare class ShipError extends Error {
347
349
  readonly type: ErrorType;
348
350
  readonly status?: number | undefined;
349
- readonly details?: any | undefined;
350
- constructor(type: ErrorType, message: string, status?: number | undefined, details?: any | undefined);
351
+ readonly details?: unknown | undefined;
352
+ constructor(type: ErrorType, message: string, status?: number | undefined, details?: unknown | undefined);
351
353
  /** Convert to wire format */
352
354
  toResponse(): ErrorResponse;
353
355
  /**
354
356
  * Construct a `ShipError` from an HTTP error response.
355
357
  *
356
358
  * Best-effort body parse for `{ message, error?, details? }`. Message
357
- * resolution: `body.message` → `body.error` → `fallbackMessage`
358
- * `Request failed with status N`.
359
+ * resolution: `body.message` → `body.error` → `"<operationName> failed with
360
+ * status <N>"`.
359
361
  *
360
- * Type resolution: trusts `body.error` when it's a known `ErrorType`
361
- * (preserves the wire's intent — server's `ShipError.validation(...)`
362
- * round-trips back to `ErrorType.Validation` on the client). Falls back to
363
- * status-derived (401 → Authentication, 429 → RateLimit, else → Api) for
364
- * non-API responses (CDN errors, intermediaries) or malformed bodies.
362
+ * Type resolution: trusts `body.error` when it's a known server-producible
363
+ * `ErrorType` (preserves the wire's intent — server's
364
+ * `ShipError.validation(...)` round-trips back to `ErrorType.Validation`
365
+ * on the client). Falls back to status-derived (401 → Authentication,
366
+ * 403 Forbidden, 429 → RateLimit, else → Api) for non-API responses
367
+ * (CDN errors, intermediaries) or malformed bodies. Client-only types
368
+ * (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
369
+ * trusted set — a misbehaving server claiming one of those is ignored.
370
+ *
371
+ * `operationName` (e.g. `"Get account"`) is used to compose the fallback
372
+ * message. Defaults to `"Request"`. Same convention as `fromFetchError`.
365
373
  *
366
374
  * Async because it reads the response body. Returns rather than throws so
367
375
  * callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.
368
376
  */
369
- static fromHttpResponse(response: Response, fallbackMessage?: string): Promise<ShipError>;
377
+ static fromHttpResponse(response: Response, operationName?: string): Promise<ShipError>;
370
378
  /**
371
379
  * Construct a `ShipError` from an error caught around a `fetch()` call.
372
380
  *
@@ -386,23 +394,33 @@ export declare class ShipError extends Error {
386
394
  * `"Request"` when omitted.
387
395
  */
388
396
  static fromFetchError(cause: unknown, operationName?: string): ShipError;
389
- static validation(message: string, details?: any): ShipError;
397
+ static validation(message: string, details?: unknown): ShipError;
390
398
  static notFound(resource: string, id?: string): ShipError;
399
+ static forbidden(message: string, details?: unknown): ShipError;
391
400
  static rateLimit(message?: string): ShipError;
392
- static authentication(message?: string, details?: any): ShipError;
401
+ /**
402
+ * Construct an Authentication (401) error.
403
+ *
404
+ * **Telemetry pattern — `details: { internal: '<tag>' }`.** When the
405
+ * server creates an auth error with an `internal` key in `details`
406
+ * (e.g. `{ internal: 'jwt_missing_subject' }`), `toResponse()` strips the
407
+ * entire `details` object before serialization. This keeps the wire
408
+ * response a clean "Authentication failed" while preserving granular
409
+ * server-side telemetry (which strategy/check failed) for logs and tests.
410
+ *
411
+ * Use this pattern in API auth code; do not put client-visible info under
412
+ * `internal`. Other `details` keys round-trip normally.
413
+ */
414
+ static authentication(message?: string, details?: unknown): ShipError;
393
415
  static business(message: string, status?: number): ShipError;
394
- static network(message: string, cause?: Error): ShipError;
416
+ static network(message: string, details?: unknown): ShipError;
395
417
  static cancelled(message: string): ShipError;
396
- static file(message: string, filePath?: string): ShipError;
397
- static config(message: string, details?: any): ShipError;
418
+ static file(message: string, details?: unknown): ShipError;
419
+ static config(message: string, details?: unknown): ShipError;
398
420
  static api(message: string, status?: number): ShipError;
399
- get filePath(): string | undefined;
400
421
  isClientError(): boolean;
401
422
  isNetworkError(): boolean;
402
423
  isAuthError(): boolean;
403
- isValidationError(): boolean;
404
- isFileError(): boolean;
405
- isConfigError(): boolean;
406
424
  isType(errorType: ErrorType): boolean;
407
425
  }
408
426
  /**
package/dist/index.js CHANGED
@@ -59,48 +59,59 @@ export const AccountPlan = {
59
59
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
60
60
  */
61
61
  export const ErrorType = {
62
- /** Validation failed (400) */
62
+ /** Validation failed (400). Input shape is wrong. */
63
63
  Validation: 'validation_failed',
64
- /** Resource not found (404) */
64
+ /** Resource not found (404). */
65
65
  NotFound: 'not_found',
66
- /** Rate limit exceeded (429) */
66
+ /** Authenticated but not allowed (403). User lacks permission for this action. */
67
+ Forbidden: 'forbidden',
68
+ /** Rate limit exceeded (429). */
67
69
  RateLimit: 'rate_limit_exceeded',
68
- /** Authentication required (401) */
70
+ /** Authentication required or failed (401). Missing/invalid credentials. */
69
71
  Authentication: 'authentication_failed',
70
- /** Business logic error (400) */
72
+ /** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
71
73
  Business: 'business_logic_error',
72
- /** API server error (500) */
74
+ /** API server error (500). Generic server-side fault. */
73
75
  Api: 'internal_server_error',
74
76
  /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
75
77
  Network: 'network_error',
76
78
  /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
77
79
  Cancelled: 'operation_cancelled',
78
- /** File operation error */
80
+ /** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
79
81
  File: 'file_error',
80
- /** Configuration error */
82
+ /** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
81
83
  Config: 'config_error',
82
84
  };
85
+ /**
86
+ * Error types that originate exclusively on the client (HTTP clients, SDK
87
+ * file processing, local config parsing). These never appear on the wire
88
+ * from the server, so `fromHttpResponse` will not trust them even if a
89
+ * misbehaving server claims one in `body.error`.
90
+ */
91
+ const CLIENT_ONLY_ERROR_TYPES = new Set([
92
+ ErrorType.Network,
93
+ ErrorType.Cancelled,
94
+ ErrorType.File,
95
+ ErrorType.Config,
96
+ ]);
83
97
  /**
84
98
  * Categorizes error types for the `isClientError` / `isNetworkError` /
85
99
  * `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`
86
100
  * union so `.has(error.type)` accepts any value from the union.
87
101
  */
88
102
  const ERROR_CATEGORIES = {
89
- client: new Set([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Validation]),
103
+ client: new Set([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Forbidden, ErrorType.Validation]),
90
104
  network: new Set([ErrorType.Network]),
91
105
  auth: new Set([ErrorType.Authentication]),
92
106
  };
93
107
  /**
94
- * Lookup set of error types that legitimately appear on the wire i.e.
95
- * server-thrown types. Used by `ShipError.fromHttpResponse` to validate the
96
- * body's `error` field before trusting it as the `ShipError.type`.
97
- *
98
- * Excludes `Network` and `Cancelled`, which are client-side-only by design:
99
- * they originate on the client (fetch failure, abort) and should never be
100
- * reconstructed from a server response, even if a misbehaving server were
101
- * to send them. A defensive omission, not a theoretical concern.
108
+ * Error types the server can legitimately produce on the wire. Used by
109
+ * `ShipError.fromHttpResponse` to validate the body's `error` field before
110
+ * trusting it as `ShipError.type`. Derived by exclusion from
111
+ * `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to
112
+ * `ErrorType` is automatically picked up.
102
113
  */
103
- const SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter(t => t !== ErrorType.Network && t !== ErrorType.Cancelled));
114
+ const SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter(t => !CLIENT_ONLY_ERROR_TYPES.has(t)));
104
115
  /**
105
116
  * Simple unified error class for both API and SDK
106
117
  */
@@ -117,8 +128,11 @@ export class ShipError extends Error {
117
128
  }
118
129
  /** Convert to wire format */
119
130
  toResponse() {
120
- // For security, exclude internal details from authentication errors in API responses
121
- const details = this.type === ErrorType.Authentication && this.details?.internal
131
+ // Strip authentication details when they carry an `internal` telemetry
132
+ // tag (see `ShipError.authentication` JSDoc) these are server-side
133
+ // diagnostics like 'jwt_missing_subject' that must not leak to clients.
134
+ const authDetails = this.details;
135
+ const details = this.type === ErrorType.Authentication && authDetails?.internal
122
136
  ? undefined
123
137
  : this.details;
124
138
  return {
@@ -132,19 +146,25 @@ export class ShipError extends Error {
132
146
  * Construct a `ShipError` from an HTTP error response.
133
147
  *
134
148
  * Best-effort body parse for `{ message, error?, details? }`. Message
135
- * resolution: `body.message` → `body.error` → `fallbackMessage`
136
- * `Request failed with status N`.
149
+ * resolution: `body.message` → `body.error` → `"<operationName> failed with
150
+ * status <N>"`.
137
151
  *
138
- * Type resolution: trusts `body.error` when it's a known `ErrorType`
139
- * (preserves the wire's intent — server's `ShipError.validation(...)`
140
- * round-trips back to `ErrorType.Validation` on the client). Falls back to
141
- * status-derived (401 → Authentication, 429 → RateLimit, else → Api) for
142
- * non-API responses (CDN errors, intermediaries) or malformed bodies.
152
+ * Type resolution: trusts `body.error` when it's a known server-producible
153
+ * `ErrorType` (preserves the wire's intent — server's
154
+ * `ShipError.validation(...)` round-trips back to `ErrorType.Validation`
155
+ * on the client). Falls back to status-derived (401 → Authentication,
156
+ * 403 Forbidden, 429 → RateLimit, else → Api) for non-API responses
157
+ * (CDN errors, intermediaries) or malformed bodies. Client-only types
158
+ * (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
159
+ * trusted set — a misbehaving server claiming one of those is ignored.
160
+ *
161
+ * `operationName` (e.g. `"Get account"`) is used to compose the fallback
162
+ * message. Defaults to `"Request"`. Same convention as `fromFetchError`.
143
163
  *
144
164
  * Async because it reads the response body. Returns rather than throws so
145
165
  * callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.
146
166
  */
147
- static async fromHttpResponse(response, fallbackMessage) {
167
+ static async fromHttpResponse(response, operationName) {
148
168
  let message;
149
169
  let details;
150
170
  let bodyType;
@@ -171,12 +191,13 @@ export class ShipError extends Error {
171
191
  }
172
192
  }
173
193
  catch {
174
- // Body unreadable; fall through to fallback.
194
+ // Body unreadable; fall through to operationName-derived message.
175
195
  }
176
- message = message || fallbackMessage || `Request failed with status ${response.status}`;
196
+ message = message || `${operationName || 'Request'} failed with status ${response.status}`;
177
197
  const type = bodyType ?? (response.status === 401 ? ErrorType.Authentication :
178
- response.status === 429 ? ErrorType.RateLimit :
179
- ErrorType.Api);
198
+ response.status === 403 ? ErrorType.Forbidden :
199
+ response.status === 429 ? ErrorType.RateLimit :
200
+ ErrorType.Api);
180
201
  return new ShipError(type, message, response.status, details);
181
202
  }
182
203
  /**
@@ -206,13 +227,16 @@ export class ShipError extends Error {
206
227
  return ShipError.cancelled(`${op} was cancelled`);
207
228
  }
208
229
  if (cause instanceof TypeError && cause.message.includes('fetch')) {
209
- return ShipError.network(`${op} failed: ${cause.message}`, cause);
230
+ return ShipError.network(`${op} failed: ${cause.message}`, { cause });
210
231
  }
211
232
  return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
212
233
  }
213
234
  return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);
214
235
  }
215
- // Factory methods for common errors
236
+ // Factory methods. Uniform shape `(message, details?)` with two principled
237
+ // exceptions: `notFound` composes its message from (resource, id?), and
238
+ // `business` / `api` accept an optional status because they're the
239
+ // multi-status fallbacks.
216
240
  static validation(message, details) {
217
241
  return new ShipError(ErrorType.Validation, message, 400, details);
218
242
  }
@@ -220,23 +244,39 @@ export class ShipError extends Error {
220
244
  const message = id ? `${resource} ${id} not found` : `${resource} not found`;
221
245
  return new ShipError(ErrorType.NotFound, message, 404);
222
246
  }
247
+ static forbidden(message, details) {
248
+ return new ShipError(ErrorType.Forbidden, message, 403, details);
249
+ }
223
250
  static rateLimit(message = "Too many requests") {
224
251
  return new ShipError(ErrorType.RateLimit, message, 429);
225
252
  }
253
+ /**
254
+ * Construct an Authentication (401) error.
255
+ *
256
+ * **Telemetry pattern — `details: { internal: '<tag>' }`.** When the
257
+ * server creates an auth error with an `internal` key in `details`
258
+ * (e.g. `{ internal: 'jwt_missing_subject' }`), `toResponse()` strips the
259
+ * entire `details` object before serialization. This keeps the wire
260
+ * response a clean "Authentication failed" while preserving granular
261
+ * server-side telemetry (which strategy/check failed) for logs and tests.
262
+ *
263
+ * Use this pattern in API auth code; do not put client-visible info under
264
+ * `internal`. Other `details` keys round-trip normally.
265
+ */
226
266
  static authentication(message = "Authentication required", details) {
227
267
  return new ShipError(ErrorType.Authentication, message, 401, details);
228
268
  }
229
269
  static business(message, status = 400) {
230
270
  return new ShipError(ErrorType.Business, message, status);
231
271
  }
232
- static network(message, cause) {
233
- return new ShipError(ErrorType.Network, message, undefined, { cause });
272
+ static network(message, details) {
273
+ return new ShipError(ErrorType.Network, message, undefined, details);
234
274
  }
235
275
  static cancelled(message) {
236
276
  return new ShipError(ErrorType.Cancelled, message);
237
277
  }
238
- static file(message, filePath) {
239
- return new ShipError(ErrorType.File, message, undefined, { filePath });
278
+ static file(message, details) {
279
+ return new ShipError(ErrorType.File, message, undefined, details);
240
280
  }
241
281
  static config(message, details) {
242
282
  return new ShipError(ErrorType.Config, message, undefined, details);
@@ -244,11 +284,8 @@ export class ShipError extends Error {
244
284
  static api(message, status = 500) {
245
285
  return new ShipError(ErrorType.Api, message, status);
246
286
  }
247
- // Helper getter for accessing file path from details
248
- get filePath() {
249
- return this.details?.filePath;
250
- }
251
- // Helper methods for error type checking using categorization
287
+ // Semantic-category type guards. For specific-type checks, use
288
+ // `error.type === ErrorType.X` directly or the generic `isType(t)`.
252
289
  isClientError() {
253
290
  return ERROR_CATEGORIES.client.has(this.type);
254
291
  }
@@ -258,16 +295,6 @@ export class ShipError extends Error {
258
295
  isAuthError() {
259
296
  return ERROR_CATEGORIES.auth.has(this.type);
260
297
  }
261
- isValidationError() {
262
- return this.type === ErrorType.Validation;
263
- }
264
- isFileError() {
265
- return this.type === ErrorType.File;
266
- }
267
- isConfigError() {
268
- return this.type === ErrorType.Config;
269
- }
270
- // Generic type checker
271
298
  isType(errorType) {
272
299
  return this.type === errorType;
273
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -350,55 +350,65 @@ export interface AccountOverrides {
350
350
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
351
351
  */
352
352
  export const ErrorType = {
353
- /** Validation failed (400) */
353
+ /** Validation failed (400). Input shape is wrong. */
354
354
  Validation: 'validation_failed',
355
- /** Resource not found (404) */
355
+ /** Resource not found (404). */
356
356
  NotFound: 'not_found',
357
- /** Rate limit exceeded (429) */
357
+ /** Authenticated but not allowed (403). User lacks permission for this action. */
358
+ Forbidden: 'forbidden',
359
+ /** Rate limit exceeded (429). */
358
360
  RateLimit: 'rate_limit_exceeded',
359
- /** Authentication required (401) */
361
+ /** Authentication required or failed (401). Missing/invalid credentials. */
360
362
  Authentication: 'authentication_failed',
361
- /** Business logic error (400) */
363
+ /** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
362
364
  Business: 'business_logic_error',
363
- /** API server error (500) */
365
+ /** API server error (500). Generic server-side fault. */
364
366
  Api: 'internal_server_error',
365
367
  /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
366
368
  Network: 'network_error',
367
369
  /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
368
370
  Cancelled: 'operation_cancelled',
369
- /** File operation error */
371
+ /** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
370
372
  File: 'file_error',
371
- /** Configuration error */
373
+ /** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
372
374
  Config: 'config_error',
373
375
  } as const;
374
376
 
375
377
  export type ErrorType = typeof ErrorType[keyof typeof ErrorType];
376
378
 
379
+ /**
380
+ * Error types that originate exclusively on the client (HTTP clients, SDK
381
+ * file processing, local config parsing). These never appear on the wire
382
+ * from the server, so `fromHttpResponse` will not trust them even if a
383
+ * misbehaving server claims one in `body.error`.
384
+ */
385
+ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
386
+ ErrorType.Network,
387
+ ErrorType.Cancelled,
388
+ ErrorType.File,
389
+ ErrorType.Config,
390
+ ]);
391
+
377
392
  /**
378
393
  * Categorizes error types for the `isClientError` / `isNetworkError` /
379
394
  * `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`
380
395
  * union so `.has(error.type)` accepts any value from the union.
381
396
  */
382
397
  const ERROR_CATEGORIES = {
383
- client: new Set<ErrorType>([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Validation]),
398
+ client: new Set<ErrorType>([ErrorType.Business, ErrorType.Config, ErrorType.File, ErrorType.Forbidden, ErrorType.Validation]),
384
399
  network: new Set<ErrorType>([ErrorType.Network]),
385
400
  auth: new Set<ErrorType>([ErrorType.Authentication]),
386
401
  } as const;
387
402
 
388
403
  /**
389
- * Lookup set of error types that legitimately appear on the wire i.e.
390
- * server-thrown types. Used by `ShipError.fromHttpResponse` to validate the
391
- * body's `error` field before trusting it as the `ShipError.type`.
392
- *
393
- * Excludes `Network` and `Cancelled`, which are client-side-only by design:
394
- * they originate on the client (fetch failure, abort) and should never be
395
- * reconstructed from a server response, even if a misbehaving server were
396
- * to send them. A defensive omission, not a theoretical concern.
404
+ * Error types the server can legitimately produce on the wire. Used by
405
+ * `ShipError.fromHttpResponse` to validate the body's `error` field before
406
+ * trusting it as `ShipError.type`. Derived by exclusion from
407
+ * `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to
408
+ * `ErrorType` is automatically picked up.
397
409
  */
398
410
  const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
399
- Object.values(ErrorType).filter(
400
- t => t !== ErrorType.Network && t !== ErrorType.Cancelled,
401
- ),
411
+ Object.values(ErrorType).filter(t => !CLIENT_ONLY_ERROR_TYPES.has(t)),
402
412
  );
403
413
 
404
414
  /**
@@ -411,8 +421,8 @@ export interface ErrorResponse {
411
421
  message: string;
412
422
  /** HTTP status code (API contexts) */
413
423
  status?: number;
414
- /** Optional additional error details */
415
- details?: any;
424
+ /** Optional additional error details. Untyped by design — narrow at the read site. */
425
+ details?: unknown;
416
426
  }
417
427
 
418
428
  /**
@@ -423,7 +433,7 @@ export class ShipError extends Error {
423
433
  public readonly type: ErrorType,
424
434
  message: string,
425
435
  public readonly status?: number,
426
- public readonly details?: any
436
+ public readonly details?: unknown,
427
437
  ) {
428
438
  super(message);
429
439
  this.name = 'ShipError';
@@ -431,8 +441,11 @@ export class ShipError extends Error {
431
441
 
432
442
  /** Convert to wire format */
433
443
  toResponse(): ErrorResponse {
434
- // For security, exclude internal details from authentication errors in API responses
435
- const details = this.type === ErrorType.Authentication && this.details?.internal
444
+ // Strip authentication details when they carry an `internal` telemetry
445
+ // tag (see `ShipError.authentication` JSDoc) these are server-side
446
+ // diagnostics like 'jwt_missing_subject' that must not leak to clients.
447
+ const authDetails = this.details as { internal?: unknown } | undefined;
448
+ const details = this.type === ErrorType.Authentication && authDetails?.internal
436
449
  ? undefined
437
450
  : this.details;
438
451
 
@@ -448,21 +461,27 @@ export class ShipError extends Error {
448
461
  * Construct a `ShipError` from an HTTP error response.
449
462
  *
450
463
  * Best-effort body parse for `{ message, error?, details? }`. Message
451
- * resolution: `body.message` → `body.error` → `fallbackMessage`
452
- * `Request failed with status N`.
464
+ * resolution: `body.message` → `body.error` → `"<operationName> failed with
465
+ * status <N>"`.
466
+ *
467
+ * Type resolution: trusts `body.error` when it's a known server-producible
468
+ * `ErrorType` (preserves the wire's intent — server's
469
+ * `ShipError.validation(...)` round-trips back to `ErrorType.Validation`
470
+ * on the client). Falls back to status-derived (401 → Authentication,
471
+ * 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses
472
+ * (CDN errors, intermediaries) or malformed bodies. Client-only types
473
+ * (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the
474
+ * trusted set — a misbehaving server claiming one of those is ignored.
453
475
  *
454
- * Type resolution: trusts `body.error` when it's a known `ErrorType`
455
- * (preserves the wire's intent server's `ShipError.validation(...)`
456
- * round-trips back to `ErrorType.Validation` on the client). Falls back to
457
- * status-derived (401 → Authentication, 429 → RateLimit, else → Api) for
458
- * non-API responses (CDN errors, intermediaries) or malformed bodies.
476
+ * `operationName` (e.g. `"Get account"`) is used to compose the fallback
477
+ * message. Defaults to `"Request"`. Same convention as `fromFetchError`.
459
478
  *
460
479
  * Async because it reads the response body. Returns rather than throws so
461
480
  * callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.
462
481
  */
463
482
  static async fromHttpResponse(
464
483
  response: Response,
465
- fallbackMessage?: string,
484
+ operationName?: string,
466
485
  ): Promise<ShipError> {
467
486
  let message: string | undefined;
468
487
  let details: unknown;
@@ -486,13 +505,14 @@ export class ShipError extends Error {
486
505
  if (text) message = text;
487
506
  }
488
507
  } catch {
489
- // Body unreadable; fall through to fallback.
508
+ // Body unreadable; fall through to operationName-derived message.
490
509
  }
491
510
 
492
- message = message || fallbackMessage || `Request failed with status ${response.status}`;
511
+ message = message || `${operationName || 'Request'} failed with status ${response.status}`;
493
512
 
494
513
  const type = bodyType ?? (
495
514
  response.status === 401 ? ErrorType.Authentication :
515
+ response.status === 403 ? ErrorType.Forbidden :
496
516
  response.status === 429 ? ErrorType.RateLimit :
497
517
  ErrorType.Api
498
518
  );
@@ -528,7 +548,7 @@ export class ShipError extends Error {
528
548
  return ShipError.cancelled(`${op} was cancelled`);
529
549
  }
530
550
  if (cause instanceof TypeError && cause.message.includes('fetch')) {
531
- return ShipError.network(`${op} failed: ${cause.message}`, cause);
551
+ return ShipError.network(`${op} failed: ${cause.message}`, { cause });
532
552
  }
533
553
  return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
534
554
  }
@@ -536,8 +556,12 @@ export class ShipError extends Error {
536
556
  return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);
537
557
  }
538
558
 
539
- // Factory methods for common errors
540
- static validation(message: string, details?: any): ShipError {
559
+ // Factory methods. Uniform shape `(message, details?)` with two principled
560
+ // exceptions: `notFound` composes its message from (resource, id?), and
561
+ // `business` / `api` accept an optional status because they're the
562
+ // multi-status fallbacks.
563
+
564
+ static validation(message: string, details?: unknown): ShipError {
541
565
  return new ShipError(ErrorType.Validation, message, 400, details);
542
566
  }
543
567
 
@@ -546,11 +570,28 @@ export class ShipError extends Error {
546
570
  return new ShipError(ErrorType.NotFound, message, 404);
547
571
  }
548
572
 
573
+ static forbidden(message: string, details?: unknown): ShipError {
574
+ return new ShipError(ErrorType.Forbidden, message, 403, details);
575
+ }
576
+
549
577
  static rateLimit(message: string = "Too many requests"): ShipError {
550
578
  return new ShipError(ErrorType.RateLimit, message, 429);
551
579
  }
552
580
 
553
- static authentication(message: string = "Authentication required", details?: any): ShipError {
581
+ /**
582
+ * Construct an Authentication (401) error.
583
+ *
584
+ * **Telemetry pattern — `details: { internal: '<tag>' }`.** When the
585
+ * server creates an auth error with an `internal` key in `details`
586
+ * (e.g. `{ internal: 'jwt_missing_subject' }`), `toResponse()` strips the
587
+ * entire `details` object before serialization. This keeps the wire
588
+ * response a clean "Authentication failed" while preserving granular
589
+ * server-side telemetry (which strategy/check failed) for logs and tests.
590
+ *
591
+ * Use this pattern in API auth code; do not put client-visible info under
592
+ * `internal`. Other `details` keys round-trip normally.
593
+ */
594
+ static authentication(message: string = "Authentication required", details?: unknown): ShipError {
554
595
  return new ShipError(ErrorType.Authentication, message, 401, details);
555
596
  }
556
597
 
@@ -558,19 +599,19 @@ export class ShipError extends Error {
558
599
  return new ShipError(ErrorType.Business, message, status);
559
600
  }
560
601
 
561
- static network(message: string, cause?: Error): ShipError {
562
- return new ShipError(ErrorType.Network, message, undefined, { cause });
602
+ static network(message: string, details?: unknown): ShipError {
603
+ return new ShipError(ErrorType.Network, message, undefined, details);
563
604
  }
564
605
 
565
606
  static cancelled(message: string): ShipError {
566
607
  return new ShipError(ErrorType.Cancelled, message);
567
608
  }
568
609
 
569
- static file(message: string, filePath?: string): ShipError {
570
- return new ShipError(ErrorType.File, message, undefined, { filePath });
610
+ static file(message: string, details?: unknown): ShipError {
611
+ return new ShipError(ErrorType.File, message, undefined, details);
571
612
  }
572
613
 
573
- static config(message: string, details?: any): ShipError {
614
+ static config(message: string, details?: unknown): ShipError {
574
615
  return new ShipError(ErrorType.Config, message, undefined, details);
575
616
  }
576
617
 
@@ -578,12 +619,8 @@ export class ShipError extends Error {
578
619
  return new ShipError(ErrorType.Api, message, status);
579
620
  }
580
621
 
581
- // Helper getter for accessing file path from details
582
- get filePath(): string | undefined {
583
- return this.details?.filePath;
584
- }
585
-
586
- // Helper methods for error type checking using categorization
622
+ // Semantic-category type guards. For specific-type checks, use
623
+ // `error.type === ErrorType.X` directly or the generic `isType(t)`.
587
624
  isClientError(): boolean {
588
625
  return ERROR_CATEGORIES.client.has(this.type);
589
626
  }
@@ -596,19 +633,6 @@ export class ShipError extends Error {
596
633
  return ERROR_CATEGORIES.auth.has(this.type);
597
634
  }
598
635
 
599
- isValidationError(): boolean {
600
- return this.type === ErrorType.Validation;
601
- }
602
-
603
- isFileError(): boolean {
604
- return this.type === ErrorType.File;
605
- }
606
-
607
- isConfigError(): boolean {
608
- return this.type === ErrorType.Config;
609
- }
610
-
611
- // Generic type checker
612
636
  isType(errorType: ErrorType): boolean {
613
637
  return this.type === errorType;
614
638
  }