@shipstatic/types 0.9.6 → 0.9.8
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 +1 -0
- package/dist/index.d.ts +45 -26
- package/dist/index.js +73 -36
- package/package.json +1 -1
- package/src/index.ts +82 -44
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
|
|
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
|
-
/**
|
|
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
|
|
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?:
|
|
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,8 +348,8 @@ 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?:
|
|
350
|
-
constructor(type: ErrorType, message: string, status?: number | undefined, details?:
|
|
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
|
/**
|
|
@@ -357,11 +359,14 @@ export declare class ShipError extends Error {
|
|
|
357
359
|
* resolution: `body.message` → `body.error` → `"<operationName> failed with
|
|
358
360
|
* status <N>"`.
|
|
359
361
|
*
|
|
360
|
-
* Type resolution: trusts `body.error` when it's a known
|
|
361
|
-
* (preserves the wire's intent — server's
|
|
362
|
-
* round-trips back to `ErrorType.Validation`
|
|
363
|
-
* status-derived (401 → Authentication,
|
|
364
|
-
*
|
|
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.
|
|
365
370
|
*
|
|
366
371
|
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
367
372
|
* message. Defaults to `"Request"`. Same convention as `fromFetchError`.
|
|
@@ -389,16 +394,30 @@ export declare class ShipError extends Error {
|
|
|
389
394
|
* `"Request"` when omitted.
|
|
390
395
|
*/
|
|
391
396
|
static fromFetchError(cause: unknown, operationName?: string): ShipError;
|
|
392
|
-
static validation(message: string, details?:
|
|
397
|
+
static validation(message: string, details?: unknown): ShipError;
|
|
393
398
|
static notFound(resource: string, id?: string): ShipError;
|
|
394
|
-
static
|
|
395
|
-
static
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
399
|
+
static forbidden(message: string, details?: unknown): ShipError;
|
|
400
|
+
static rateLimit(message?: string, details?: unknown): 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;
|
|
415
|
+
static business(message: string, status?: number, details?: unknown): ShipError;
|
|
416
|
+
static network(message: string, details?: unknown): ShipError;
|
|
417
|
+
static cancelled(message: string, details?: unknown): ShipError;
|
|
418
|
+
static file(message: string, details?: unknown): ShipError;
|
|
419
|
+
static config(message: string, details?: unknown): ShipError;
|
|
420
|
+
static api(message: string, status?: number, details?: unknown): ShipError;
|
|
402
421
|
isClientError(): boolean;
|
|
403
422
|
isNetworkError(): boolean;
|
|
404
423
|
isAuthError(): boolean;
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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 =>
|
|
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
|
-
//
|
|
121
|
-
|
|
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 {
|
|
@@ -135,11 +149,14 @@ export class ShipError extends Error {
|
|
|
135
149
|
* resolution: `body.message` → `body.error` → `"<operationName> failed with
|
|
136
150
|
* status <N>"`.
|
|
137
151
|
*
|
|
138
|
-
* Type resolution: trusts `body.error` when it's a known
|
|
139
|
-
* (preserves the wire's intent — server's
|
|
140
|
-
* round-trips back to `ErrorType.Validation`
|
|
141
|
-
* status-derived (401 → Authentication,
|
|
142
|
-
*
|
|
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.
|
|
143
160
|
*
|
|
144
161
|
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
145
162
|
* message. Defaults to `"Request"`. Same convention as `fromFetchError`.
|
|
@@ -178,8 +195,9 @@ export class ShipError extends Error {
|
|
|
178
195
|
}
|
|
179
196
|
message = message || `${operationName || 'Request'} failed with status ${response.status}`;
|
|
180
197
|
const type = bodyType ?? (response.status === 401 ? ErrorType.Authentication :
|
|
181
|
-
response.status ===
|
|
182
|
-
ErrorType.
|
|
198
|
+
response.status === 403 ? ErrorType.Forbidden :
|
|
199
|
+
response.status === 429 ? ErrorType.RateLimit :
|
|
200
|
+
ErrorType.Api);
|
|
183
201
|
return new ShipError(type, message, response.status, details);
|
|
184
202
|
}
|
|
185
203
|
/**
|
|
@@ -215,7 +233,10 @@ export class ShipError extends Error {
|
|
|
215
233
|
}
|
|
216
234
|
return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);
|
|
217
235
|
}
|
|
218
|
-
// Factory methods
|
|
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.
|
|
219
240
|
static validation(message, details) {
|
|
220
241
|
return new ShipError(ErrorType.Validation, message, 400, details);
|
|
221
242
|
}
|
|
@@ -223,20 +244,36 @@ export class ShipError extends Error {
|
|
|
223
244
|
const message = id ? `${resource} ${id} not found` : `${resource} not found`;
|
|
224
245
|
return new ShipError(ErrorType.NotFound, message, 404);
|
|
225
246
|
}
|
|
226
|
-
static
|
|
227
|
-
return new ShipError(ErrorType.
|
|
247
|
+
static forbidden(message, details) {
|
|
248
|
+
return new ShipError(ErrorType.Forbidden, message, 403, details);
|
|
249
|
+
}
|
|
250
|
+
static rateLimit(message = "Too many requests", details) {
|
|
251
|
+
return new ShipError(ErrorType.RateLimit, message, 429, details);
|
|
228
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
|
+
*/
|
|
229
266
|
static authentication(message = "Authentication required", details) {
|
|
230
267
|
return new ShipError(ErrorType.Authentication, message, 401, details);
|
|
231
268
|
}
|
|
232
|
-
static business(message, status = 400) {
|
|
233
|
-
return new ShipError(ErrorType.Business, message, status);
|
|
269
|
+
static business(message, status = 400, details) {
|
|
270
|
+
return new ShipError(ErrorType.Business, message, status, details);
|
|
234
271
|
}
|
|
235
272
|
static network(message, details) {
|
|
236
273
|
return new ShipError(ErrorType.Network, message, undefined, details);
|
|
237
274
|
}
|
|
238
|
-
static cancelled(message) {
|
|
239
|
-
return new ShipError(ErrorType.Cancelled, message);
|
|
275
|
+
static cancelled(message, details) {
|
|
276
|
+
return new ShipError(ErrorType.Cancelled, message, undefined, details);
|
|
240
277
|
}
|
|
241
278
|
static file(message, details) {
|
|
242
279
|
return new ShipError(ErrorType.File, message, undefined, details);
|
|
@@ -244,8 +281,8 @@ export class ShipError extends Error {
|
|
|
244
281
|
static config(message, details) {
|
|
245
282
|
return new ShipError(ErrorType.Config, message, undefined, details);
|
|
246
283
|
}
|
|
247
|
-
static api(message, status = 500) {
|
|
248
|
-
return new ShipError(ErrorType.Api, message, status);
|
|
284
|
+
static api(message, status = 500, details) {
|
|
285
|
+
return new ShipError(ErrorType.Api, message, status, details);
|
|
249
286
|
}
|
|
250
287
|
// Semantic-category type guards. For specific-type checks, use
|
|
251
288
|
// `error.type === ErrorType.X` directly or the generic `isType(t)`.
|
package/package.json
CHANGED
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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?:
|
|
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?:
|
|
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
|
-
//
|
|
435
|
-
|
|
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
|
|
|
@@ -451,11 +464,14 @@ export class ShipError extends Error {
|
|
|
451
464
|
* resolution: `body.message` → `body.error` → `"<operationName> failed with
|
|
452
465
|
* status <N>"`.
|
|
453
466
|
*
|
|
454
|
-
* Type resolution: trusts `body.error` when it's a known
|
|
455
|
-
* (preserves the wire's intent — server's
|
|
456
|
-
* round-trips back to `ErrorType.Validation`
|
|
457
|
-
* status-derived (401 → Authentication,
|
|
458
|
-
*
|
|
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.
|
|
459
475
|
*
|
|
460
476
|
* `operationName` (e.g. `"Get account"`) is used to compose the fallback
|
|
461
477
|
* message. Defaults to `"Request"`. Same convention as `fromFetchError`.
|
|
@@ -496,6 +512,7 @@ export class ShipError extends Error {
|
|
|
496
512
|
|
|
497
513
|
const type = bodyType ?? (
|
|
498
514
|
response.status === 401 ? ErrorType.Authentication :
|
|
515
|
+
response.status === 403 ? ErrorType.Forbidden :
|
|
499
516
|
response.status === 429 ? ErrorType.RateLimit :
|
|
500
517
|
ErrorType.Api
|
|
501
518
|
);
|
|
@@ -539,8 +556,12 @@ export class ShipError extends Error {
|
|
|
539
556
|
return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);
|
|
540
557
|
}
|
|
541
558
|
|
|
542
|
-
// Factory methods
|
|
543
|
-
|
|
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 {
|
|
544
565
|
return new ShipError(ErrorType.Validation, message, 400, details);
|
|
545
566
|
}
|
|
546
567
|
|
|
@@ -549,36 +570,53 @@ export class ShipError extends Error {
|
|
|
549
570
|
return new ShipError(ErrorType.NotFound, message, 404);
|
|
550
571
|
}
|
|
551
572
|
|
|
552
|
-
static
|
|
553
|
-
return new ShipError(ErrorType.
|
|
573
|
+
static forbidden(message: string, details?: unknown): ShipError {
|
|
574
|
+
return new ShipError(ErrorType.Forbidden, message, 403, details);
|
|
554
575
|
}
|
|
555
576
|
|
|
556
|
-
static
|
|
577
|
+
static rateLimit(message: string = "Too many requests", details?: unknown): ShipError {
|
|
578
|
+
return new ShipError(ErrorType.RateLimit, message, 429, details);
|
|
579
|
+
}
|
|
580
|
+
|
|
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 {
|
|
557
595
|
return new ShipError(ErrorType.Authentication, message, 401, details);
|
|
558
596
|
}
|
|
559
597
|
|
|
560
|
-
static business(message: string, status: number = 400): ShipError {
|
|
561
|
-
return new ShipError(ErrorType.Business, message, status);
|
|
598
|
+
static business(message: string, status: number = 400, details?: unknown): ShipError {
|
|
599
|
+
return new ShipError(ErrorType.Business, message, status, details);
|
|
562
600
|
}
|
|
563
601
|
|
|
564
|
-
static network(message: string, details?:
|
|
602
|
+
static network(message: string, details?: unknown): ShipError {
|
|
565
603
|
return new ShipError(ErrorType.Network, message, undefined, details);
|
|
566
604
|
}
|
|
567
605
|
|
|
568
|
-
static cancelled(message: string): ShipError {
|
|
569
|
-
return new ShipError(ErrorType.Cancelled, message);
|
|
606
|
+
static cancelled(message: string, details?: unknown): ShipError {
|
|
607
|
+
return new ShipError(ErrorType.Cancelled, message, undefined, details);
|
|
570
608
|
}
|
|
571
609
|
|
|
572
|
-
static file(message: string, details?:
|
|
610
|
+
static file(message: string, details?: unknown): ShipError {
|
|
573
611
|
return new ShipError(ErrorType.File, message, undefined, details);
|
|
574
612
|
}
|
|
575
613
|
|
|
576
|
-
static config(message: string, details?:
|
|
614
|
+
static config(message: string, details?: unknown): ShipError {
|
|
577
615
|
return new ShipError(ErrorType.Config, message, undefined, details);
|
|
578
616
|
}
|
|
579
617
|
|
|
580
|
-
static api(message: string, status: number = 500): ShipError {
|
|
581
|
-
return new ShipError(ErrorType.Api, message, status);
|
|
618
|
+
static api(message: string, status: number = 500, details?: unknown): ShipError {
|
|
619
|
+
return new ShipError(ErrorType.Api, message, status, details);
|
|
582
620
|
}
|
|
583
621
|
|
|
584
622
|
// Semantic-category type guards. For specific-type checks, use
|