@shipstatic/types 2.5.0-beta.1 → 2.5.0-beta.11

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
@@ -20,14 +20,20 @@ npm install @shipstatic/types
20
20
 
21
21
  ```typescript
22
22
  import type {
23
- Deployment, DeploymentListResponse,
24
- Domain, DomainSetResult, DomainListResponse, DnsRecord, DomainDnsResponse, DomainRecordsResponse, DomainValidateResponse,
25
- Token, TokenListItem, TokenListResponse, TokenCreateResponse,
26
- Account, AccountUsage, AccountOverrides,
23
+ ListResponse,
24
+ Deployment, DeploymentListResponse, DeploymentDeleteResponse,
25
+ Domain, DomainSetResult, DomainListResponse, DnsRecord, DomainDnsResponse, DomainRecordsResponse, DomainValidateResponse, DomainDeleteResponse, DomainVerifyResponse,
26
+ Token, TokenListResponse, TokenCreateResponse, TokenDeleteResponse,
27
+ Account, AccountUsage, AccountOverrides, AccountDeleteResponse, AccountKeyResponse,
27
28
  StaticFile
28
29
  } from '@shipstatic/types';
29
30
  ```
30
31
 
32
+ A mutation answers with the resource it affected — the entity when it
33
+ survives, otherwise the `*DeleteResponse` shape: the resource noun carrying
34
+ the canonical key, plus the resource's own state field where the resource is
35
+ mid-transition. No `message`, no `success`, no constant flags.
36
+
31
37
  ### Error System
32
38
 
33
39
  ```typescript
@@ -82,7 +88,7 @@ import {
82
88
  DomainStatus, // pending | partial | success | paused
83
89
  AccountPlan, // free | standard | sponsored | enterprise | suspended | terminating | terminated
84
90
  FileValidationStatus, // pending | processing_error | excluded | validation_failed | ready
85
- AuthMethod, // jwt | apiKey | token | webhook | system
91
+ AuthMethod, // session | apiKey | token | agent | oauth | webhook | system
86
92
  } from '@shipstatic/types';
87
93
  ```
88
94
 
@@ -132,7 +138,6 @@ import type {
132
138
  FileValidationResult,
133
139
  ValidationIssue,
134
140
  UploadedFile,
135
- ProgressInfo,
136
141
  } from '@shipstatic/types';
137
142
  ```
138
143
 
package/dist/index.d.ts CHANGED
@@ -49,16 +49,55 @@ export interface DeploymentCreateResponse extends Deployment {
49
49
  /** Claim URL for public deployments. Present when deployed without credentials. */
50
50
  readonly claim?: string;
51
51
  }
52
+ /**
53
+ * The half of a list response that is identical on every list.
54
+ *
55
+ * `GET /<collection>` answers exactly two fields — the collection under its
56
+ * own plural noun, and this cursor — so the cursor is declared once here and
57
+ * each response below adds only its noun. `cursor: null` means last page and
58
+ * is the ENTIRE has-more signal, which is why there is no `has_more`.
59
+ *
60
+ * There is deliberately no `total`. A count is an aggregate over a
61
+ * collection, not a property of a page; producing one would cost a COUNT
62
+ * beside every page read, which is precisely what keyset pagination exists
63
+ * to avoid. Counts live on the resource that summarises the collection —
64
+ * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
65
+ */
66
+ export interface ListResponse {
67
+ /** Opaque cursor from this page; `null` on the last page. */
68
+ cursor: string | null;
69
+ }
52
70
  /**
53
71
  * Response for listing deployments
54
72
  */
55
- export interface DeploymentListResponse {
73
+ export interface DeploymentListResponse extends ListResponse {
56
74
  /** Array of deployments */
57
75
  deployments: Deployment[];
58
- /** Cursor for pagination, null if no more pages */
59
- cursor: string | null;
60
- /** Total number of deployments */
61
- total: number;
76
+ }
77
+ /**
78
+ * Acknowledgement of `DELETE /deployments/:deployment` — and the shape every
79
+ * mutation with no entity left to return follows.
80
+ *
81
+ * **The law:** a mutation answers with the resource it affected. If the
82
+ * resource still exists, that means the entity itself (`Deployment`,
83
+ * `Domain`, …). Otherwise it means this: the resource noun carrying the
84
+ * item's canonical key, plus the resource's own state field — and ONLY when
85
+ * the resource survived in a transitional state, as an async deletion's does.
86
+ * Where the resource is simply gone, the key alone is the whole answer
87
+ * ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
88
+ *
89
+ * Nothing else rides along. No prose (`message`), because an acknowledgement
90
+ * is data and each surface composes its own copy; and no constant
91
+ * (`changed: true`, `queued: true`, `success: true`), because a field whose
92
+ * value the type already fixes tells a caller nothing it did not know before
93
+ * it made the request. Sync versus accepted is the HTTP status code's job —
94
+ * 200 versus 202 — not a boolean's.
95
+ */
96
+ export interface DeploymentDeleteResponse {
97
+ /** The deployment hostname that was marked for removal */
98
+ readonly deployment: string;
99
+ /** The state the deployment is in while background cleanup runs */
100
+ readonly status: DeploymentStatusType;
62
101
  }
63
102
  /**
64
103
  * Domain status constants
@@ -91,7 +130,7 @@ export interface Domain {
91
130
  labels: string[];
92
131
  /** Unix timestamp (seconds) when domain was created */
93
132
  readonly created: number;
94
- /** When deployment was last linked (Unix timestamp), null if never linked */
133
+ /** Unix timestamp (seconds) when deployment was last linked, null if never linked */
95
134
  linked: number | null;
96
135
  /** Total deployment links */
97
136
  links: number;
@@ -113,13 +152,28 @@ export interface DomainSetResult extends Domain {
113
152
  /**
114
153
  * Response for listing domains
115
154
  */
116
- export interface DomainListResponse {
155
+ export interface DomainListResponse extends ListResponse {
117
156
  /** Array of domains */
118
157
  domains: Domain[];
119
- /** Cursor for pagination, null if no more pages */
120
- cursor: string | null;
121
- /** Total number of domains */
122
- total: number;
158
+ }
159
+ /**
160
+ * Acknowledgement of `DELETE /domains/:domain`. The row is gone, so there is
161
+ * no state to state — the canonical domain name is the whole answer. See
162
+ * {@link DeploymentDeleteResponse} for the law.
163
+ */
164
+ export interface DomainDeleteResponse {
165
+ /** The domain name that was removed, normalized */
166
+ readonly domain: string;
167
+ }
168
+ /**
169
+ * Acknowledgement of `POST /domains/:domain/verify` (202). The DNS check is
170
+ * queued, not performed — the accepted status code says so, and the domain's
171
+ * own status is unchanged until the check runs, which is why none is stated
172
+ * here. See {@link DeploymentDeleteResponse} for the law.
173
+ */
174
+ export interface DomainVerifyResponse {
175
+ /** The domain whose DNS verification was queued, normalized */
176
+ readonly domain: string;
123
177
  }
124
178
  /**
125
179
  * DNS record types supported for domain configuration
@@ -179,11 +233,13 @@ export interface DomainValidateResponse {
179
233
  error: string | null;
180
234
  }
181
235
  /**
182
- * Token as returned by the list endpoint.
183
- * The secret is shown once at creation and never again — listings carry
184
- * only the management identifier and lifecycle metadata.
236
+ * Core deploy token object - used in both API responses and SDK.
237
+ *
238
+ * The secret is never here: it is shown once at creation
239
+ * ({@link TokenCreateResponse.secret}) and never again, so an entity read
240
+ * carries only the management identifier and lifecycle metadata.
185
241
  */
186
- export interface TokenListItem {
242
+ export interface Token {
187
243
  /** 7-char management identifier (e.g., "a1b2c3d") */
188
244
  readonly token: string;
189
245
  /** Labels for categorization and filtering. Always present, empty array when none. */
@@ -198,24 +254,28 @@ export interface TokenListItem {
198
254
  /**
199
255
  * Response for listing tokens
200
256
  */
201
- export interface TokenListResponse {
202
- /** Array of tokens (security-redacted for list display) */
203
- tokens: TokenListItem[];
204
- /** Total number of tokens */
205
- total: number;
257
+ export interface TokenListResponse extends ListResponse {
258
+ /** Array of tokens (the secret is never among them) */
259
+ tokens: Token[];
206
260
  }
207
261
  /**
208
- * Response for token creation
262
+ * Response from token creation. Extends Token with the one field that
263
+ * exists only on creation — the same shape as
264
+ * {@link DeploymentCreateResponse}, because a 201 returns the resource it
265
+ * created plus whatever is knowable only once.
209
266
  */
210
- export interface TokenCreateResponse {
211
- /** 7-char management identifier */
212
- token: string;
267
+ export interface TokenCreateResponse extends Token {
213
268
  /** The raw credential value (shown once at creation, then never again) */
214
- secret: string;
215
- /** Labels for categorization and filtering. Always present, empty array when none. */
216
- labels: string[];
217
- /** Unix timestamp (seconds) when token expires, null for never */
218
- expires: number | null;
269
+ readonly secret: string;
270
+ }
271
+ /**
272
+ * Acknowledgement of `DELETE /tokens/:token`. The credential is revoked and
273
+ * its row is gone, so the management identifier is the whole answer. See
274
+ * {@link DeploymentDeleteResponse} for the law.
275
+ */
276
+ export interface TokenDeleteResponse {
277
+ /** The 7-char management identifier that was revoked */
278
+ readonly token: string;
219
279
  }
220
280
  /**
221
281
  * Account plan constants
@@ -232,10 +292,34 @@ export declare const AccountPlan: {
232
292
  export type AccountPlanType = (typeof AccountPlan)[keyof typeof AccountPlan];
233
293
  /**
234
294
  * Account usage metrics — always available regardless of billing provider.
295
+ *
296
+ * This is where a caller's own totals live. Lists answer pages and carry no
297
+ * `total` (see {@link ListOptions}); a count is an aggregate over a
298
+ * collection, so it belongs to the summary resource that owns the
299
+ * collection. `GET /account` is that resource for one caller, `GET
300
+ * /admin/stats` for the platform.
301
+ *
302
+ * The counted dimensions are the ones the plan caps — deployments and
303
+ * domains (`PlatformLimits`) — plus the billable custom-domain subset, so a
304
+ * surface can render "3 of 10" without a second request.
235
305
  */
236
306
  export interface AccountUsage {
237
307
  /** Number of active custom domains (excludes paused) */
238
308
  customDomains: number;
309
+ /**
310
+ * Deployments counted against the plan's deployment cap — every row
311
+ * whatever its status, because that is what the cap counts, so a surface
312
+ * renders "3 of 10" against the denominator the 403 divides by. (`GET
313
+ * /deployments` lists successful ones only; that is a different question
314
+ * asked of a different resource.) Optional by the additive-evolution law:
315
+ * an API predating this field omits it.
316
+ */
317
+ deployments?: number;
318
+ /**
319
+ * Domains counted against the plan's domain cap — every domain, platform
320
+ * and custom alike, unlike `customDomains`. Optional for the same reason.
321
+ */
322
+ domains?: number;
239
323
  }
240
324
  /**
241
325
  * Core account object - used in both API responses and SDK
@@ -282,6 +366,32 @@ export interface AccountGetResponse extends Account {
282
366
  /** Present only during read-only admin impersonation: the operator's account id. */
283
367
  readonly impersonatedBy?: string;
284
368
  }
369
+ /**
370
+ * Acknowledgement of `DELETE /account` (202). Termination is asynchronous —
371
+ * a cleanup consumer finishes the job — so the account survives long enough
372
+ * to state the plan it is transitioning through. `plan` is the account's
373
+ * state field, the way `status` is a deployment's. See
374
+ * {@link DeploymentDeleteResponse} for the law.
375
+ */
376
+ export interface AccountDeleteResponse {
377
+ /** The account that was marked for termination */
378
+ readonly account: string;
379
+ /** The plan the account is in while cleanup runs */
380
+ readonly plan: AccountPlanType;
381
+ }
382
+ /**
383
+ * Response from `PUT /account/key` — the account's single API key, minted in
384
+ * place of whatever was there before.
385
+ *
386
+ * There is no entity to return: only the key's last-4 `hint` is durable
387
+ * (`Account.hint`), and the plaintext exists exactly once, in this response.
388
+ * The raw credential is `secret` on every surface that mints one — the same
389
+ * field `TokenCreateResponse` carries — because one concept gets one name.
390
+ */
391
+ export interface AccountKeyResponse {
392
+ /** The raw API key (shown once at mint, then never again) */
393
+ readonly secret: string;
394
+ }
285
395
  /**
286
396
  * Account-specific configuration overrides
287
397
  * Allows per-account customization of limits without changing plan
@@ -421,6 +531,18 @@ export declare class ShipError extends Error {
421
531
  static file(message: string, details?: unknown): ShipError;
422
532
  static config(message: string, details?: unknown): ShipError;
423
533
  static api(message: string, status?: number, details?: unknown): ShipError;
534
+ /**
535
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
536
+ * that is client-attributable without ever having a status (`Config`,
537
+ * `File`, raised locally by the SDK).
538
+ *
539
+ * Both arms are load-bearing, because type and status are independent
540
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
541
+ * server-producible type; a non-OK response without one is status-derived,
542
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
543
+ * *type* carrying a client *status*. Judging by type alone would report it
544
+ * as a platform failure and bury the server's own message.
545
+ */
424
546
  isClientError(): boolean;
425
547
  isNetworkError(): boolean;
426
548
  isAuthError(): boolean;
@@ -645,6 +767,36 @@ export declare const SPA_DEFAULT_CONFIG: {
645
767
  readonly destination: "/index.html";
646
768
  }];
647
769
  };
770
+ /**
771
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
772
+ * never schema.
773
+ *
774
+ * ship.json is validated and compiled on the server, deliberately: the schema
775
+ * and the compiler evolve, and a client that judged them would reject configs
776
+ * a newer platform accepts. That reasoning bounds what a client may check to
777
+ * the properties which are true of *every* past and future schema:
778
+ *
779
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
780
+ * does not parse can never be a valid config;
781
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
782
+ *
783
+ * Both are monotonic: neither can ever reject something the server would
784
+ * accept. Everything beyond them (field names, types, rule semantics, which
785
+ * keys are permitted) stays server-side, where it can change.
786
+ *
787
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
788
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
789
+ * documentation — mistakes that otherwise cost a full upload round-trip to
790
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
791
+ * before parsing rather than rejected, because the server accepts it too;
792
+ * diverging there would reintroduce exactly the false rejection this
793
+ * function exists to avoid.
794
+ *
795
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
796
+ * config rejection carries, so the error contract is identical wherever the
797
+ * failure is detected.
798
+ */
799
+ export declare function assertShipJsonSyntax(text: string): void;
648
800
  /**
649
801
  * Validate API key format
650
802
  */
@@ -770,10 +922,20 @@ export interface DeploymentUploadOptions {
770
922
  captcha?: string;
771
923
  }
772
924
  /**
773
- * Pagination options for the paginated list endpoints (`GET /deployments`,
774
- * `GET /domains`). The response's `cursor` feeds the next request; a `null`
775
- * cursor on the response means the last page. Omitting both returns the
776
- * server's default first page.
925
+ * Pagination options for every list endpoint. The response's `cursor` feeds
926
+ * the next request; a `null` cursor means the last page. Omitting both
927
+ * returns the server's default first page.
928
+ *
929
+ * A list answers `{ <collection>, cursor }` and nothing else — `cursor`
930
+ * carries the entire has-more signal, so no redundant boolean, and no
931
+ * `total`. **A count is an aggregate over a collection, not a property of a
932
+ * page:** including one makes every read pay for a full scan it did not ask
933
+ * for, which is precisely the cost keyset pagination exists to avoid.
934
+ *
935
+ * Counts therefore live on the summary resource that owns them —
936
+ * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
937
+ * platform-wide ones. Ask for a count when you want a count; ask for a page
938
+ * when you want a page.
777
939
  */
778
940
  export interface ListOptions {
779
941
  /** Maximum number of items to return in one page. */
@@ -809,9 +971,7 @@ export interface DomainResource {
809
971
  list: (options?: ListOptions) => Promise<DomainListResponse>;
810
972
  get: (name: string) => Promise<Domain>;
811
973
  remove: (name: string) => Promise<void>;
812
- verify: (name: string) => Promise<{
813
- message: string;
814
- }>;
974
+ verify: (name: string) => Promise<DomainVerifyResponse>;
815
975
  validate: (name: string) => Promise<DomainValidateResponse>;
816
976
  dns: (name: string) => Promise<DomainDnsResponse>;
817
977
  records: (name: string) => Promise<DomainRecordsResponse>;
@@ -834,7 +994,7 @@ export interface TokenResource {
834
994
  ttl?: number;
835
995
  labels?: string[];
836
996
  }) => Promise<TokenCreateResponse>;
837
- list: () => Promise<TokenListResponse>;
997
+ list: (options?: ListOptions) => Promise<TokenListResponse>;
838
998
  remove: (token: string) => Promise<void>;
839
999
  }
840
1000
  /**
@@ -929,7 +1089,7 @@ export interface ActivityMeta {
929
1089
  /**
930
1090
  * Response from GET /activities endpoint
931
1091
  */
932
- export interface ActivityListResponse {
1092
+ export interface ActivityListResponse extends ListResponse {
933
1093
  /** Array of activities */
934
1094
  activities: Activity[];
935
1095
  }
package/dist/index.js CHANGED
@@ -100,11 +100,19 @@ const CLIENT_ONLY_ERROR_TYPES = new Set([
100
100
  * union so `.has(error.type)` accepts any value from the union.
101
101
  */
102
102
  const ERROR_CATEGORIES = {
103
+ /**
104
+ * Client-attributable types. Exhaustive over the 4xx-carrying types, and
105
+ * it must include the statusless ones (`Config`, `File`) — those are
106
+ * raised locally by the SDK and have no status for `isClientError`'s
107
+ * second arm to read.
108
+ */
103
109
  client: new Set([
104
110
  ErrorType.Business,
105
111
  ErrorType.Config,
106
112
  ErrorType.File,
107
113
  ErrorType.Forbidden,
114
+ ErrorType.NotFound,
115
+ ErrorType.RateLimit,
108
116
  ErrorType.Validation,
109
117
  ]),
110
118
  network: new Set([ErrorType.Network]),
@@ -309,10 +317,24 @@ export class ShipError extends Error {
309
317
  static api(message, status = 500, details) {
310
318
  return new ShipError(ErrorType.Api, message, status, details);
311
319
  }
312
- // Semantic-category type guards. For specific-type checks, use
320
+ // Semantic-category guards. For specific-type checks, use
313
321
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
322
+ /**
323
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
324
+ * that is client-attributable without ever having a status (`Config`,
325
+ * `File`, raised locally by the SDK).
326
+ *
327
+ * Both arms are load-bearing, because type and status are independent
328
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
329
+ * server-producible type; a non-OK response without one is status-derived,
330
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
331
+ * *type* carrying a client *status*. Judging by type alone would report it
332
+ * as a platform failure and bury the server's own message.
333
+ */
314
334
  isClientError() {
315
- return ERROR_CATEGORIES.client.has(this.type);
335
+ if (ERROR_CATEGORIES.client.has(this.type))
336
+ return true;
337
+ return this.status !== undefined && this.status >= 400 && this.status < 500;
316
338
  }
317
339
  isNetworkError() {
318
340
  return ERROR_CATEGORIES.network.has(this.type);
@@ -600,6 +622,52 @@ export const DEPLOYMENT_CONFIG_FILENAME = 'ship.json';
600
622
  export const SPA_DEFAULT_CONFIG = {
601
623
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
602
624
  };
625
+ /**
626
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
627
+ * never schema.
628
+ *
629
+ * ship.json is validated and compiled on the server, deliberately: the schema
630
+ * and the compiler evolve, and a client that judged them would reject configs
631
+ * a newer platform accepts. That reasoning bounds what a client may check to
632
+ * the properties which are true of *every* past and future schema:
633
+ *
634
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
635
+ * does not parse can never be a valid config;
636
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
637
+ *
638
+ * Both are monotonic: neither can ever reject something the server would
639
+ * accept. Everything beyond them (field names, types, rule semantics, which
640
+ * keys are permitted) stays server-side, where it can change.
641
+ *
642
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
643
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
644
+ * documentation — mistakes that otherwise cost a full upload round-trip to
645
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
646
+ * before parsing rather than rejected, because the server accepts it too;
647
+ * diverging there would reintroduce exactly the false rejection this
648
+ * function exists to avoid.
649
+ *
650
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
651
+ * config rejection carries, so the error contract is identical wherever the
652
+ * failure is detected.
653
+ */
654
+ export function assertShipJsonSyntax(text) {
655
+ const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
656
+ let parsed;
657
+ try {
658
+ parsed = JSON.parse(withoutBom);
659
+ }
660
+ catch (error) {
661
+ throw ShipError.config(`invalid JSON format in config: ${error.message}`, {
662
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
663
+ });
664
+ }
665
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
666
+ throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
667
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
668
+ });
669
+ }
670
+ }
603
671
  // =============================================================================
604
672
  // VALIDATION UTILITIES
605
673
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.5.0-beta.1",
3
+ "version": "2.5.0-beta.11",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,16 @@
11
11
  "default": "./dist/index.js"
12
12
  }
13
13
  },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "clean": "rm -rf dist",
17
+ "test": "vitest",
18
+ "lint": "biome check .",
19
+ "format": "biome format --write .",
20
+ "prepare": "git config core.hooksPath scripts/githooks",
21
+ "typecheck": "tsc -p tsconfig.check.json --noEmit"
22
+ },
23
+ "packageManager": "pnpm@10.12.4",
14
24
  "files": [
15
25
  "dist",
16
26
  "src"
@@ -34,16 +44,8 @@
34
44
  },
35
45
  "devDependencies": {
36
46
  "@biomejs/biome": "2.5.5",
37
- "@types/node": "^24.10.9",
47
+ "@types/node": "^24.13.3",
38
48
  "typescript": "^5.9.3",
39
- "vitest": "^2.1.8"
40
- },
41
- "scripts": {
42
- "build": "tsc",
43
- "clean": "rm -rf dist",
44
- "test": "vitest",
45
- "lint": "biome check .",
46
- "format": "biome format --write .",
47
- "typecheck": "tsc --noEmit"
49
+ "vitest": "^2.1.9"
48
50
  }
49
- }
51
+ }
package/src/index.ts CHANGED
@@ -58,16 +58,57 @@ export interface DeploymentCreateResponse extends Deployment {
58
58
  readonly claim?: string;
59
59
  }
60
60
 
61
+ /**
62
+ * The half of a list response that is identical on every list.
63
+ *
64
+ * `GET /<collection>` answers exactly two fields — the collection under its
65
+ * own plural noun, and this cursor — so the cursor is declared once here and
66
+ * each response below adds only its noun. `cursor: null` means last page and
67
+ * is the ENTIRE has-more signal, which is why there is no `has_more`.
68
+ *
69
+ * There is deliberately no `total`. A count is an aggregate over a
70
+ * collection, not a property of a page; producing one would cost a COUNT
71
+ * beside every page read, which is precisely what keyset pagination exists
72
+ * to avoid. Counts live on the resource that summarises the collection —
73
+ * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
74
+ */
75
+ export interface ListResponse {
76
+ /** Opaque cursor from this page; `null` on the last page. */
77
+ cursor: string | null;
78
+ }
79
+
61
80
  /**
62
81
  * Response for listing deployments
63
82
  */
64
- export interface DeploymentListResponse {
83
+ export interface DeploymentListResponse extends ListResponse {
65
84
  /** Array of deployments */
66
85
  deployments: Deployment[];
67
- /** Cursor for pagination, null if no more pages */
68
- cursor: string | null;
69
- /** Total number of deployments */
70
- total: number;
86
+ }
87
+
88
+ /**
89
+ * Acknowledgement of `DELETE /deployments/:deployment` — and the shape every
90
+ * mutation with no entity left to return follows.
91
+ *
92
+ * **The law:** a mutation answers with the resource it affected. If the
93
+ * resource still exists, that means the entity itself (`Deployment`,
94
+ * `Domain`, …). Otherwise it means this: the resource noun carrying the
95
+ * item's canonical key, plus the resource's own state field — and ONLY when
96
+ * the resource survived in a transitional state, as an async deletion's does.
97
+ * Where the resource is simply gone, the key alone is the whole answer
98
+ * ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
99
+ *
100
+ * Nothing else rides along. No prose (`message`), because an acknowledgement
101
+ * is data and each surface composes its own copy; and no constant
102
+ * (`changed: true`, `queued: true`, `success: true`), because a field whose
103
+ * value the type already fixes tells a caller nothing it did not know before
104
+ * it made the request. Sync versus accepted is the HTTP status code's job —
105
+ * 200 versus 202 — not a boolean's.
106
+ */
107
+ export interface DeploymentDeleteResponse {
108
+ /** The deployment hostname that was marked for removal */
109
+ readonly deployment: string;
110
+ /** The state the deployment is in while background cleanup runs */
111
+ readonly status: DeploymentStatusType;
71
112
  }
72
113
 
73
114
  // =============================================================================
@@ -107,7 +148,7 @@ export interface Domain {
107
148
  labels: string[];
108
149
  /** Unix timestamp (seconds) when domain was created */
109
150
  readonly created: number;
110
- /** When deployment was last linked (Unix timestamp), null if never linked */
151
+ /** Unix timestamp (seconds) when deployment was last linked, null if never linked */
111
152
  linked: number | null;
112
153
  /** Total deployment links */
113
154
  links: number;
@@ -131,13 +172,30 @@ export interface DomainSetResult extends Domain {
131
172
  /**
132
173
  * Response for listing domains
133
174
  */
134
- export interface DomainListResponse {
175
+ export interface DomainListResponse extends ListResponse {
135
176
  /** Array of domains */
136
177
  domains: Domain[];
137
- /** Cursor for pagination, null if no more pages */
138
- cursor: string | null;
139
- /** Total number of domains */
140
- total: number;
178
+ }
179
+
180
+ /**
181
+ * Acknowledgement of `DELETE /domains/:domain`. The row is gone, so there is
182
+ * no state to state — the canonical domain name is the whole answer. See
183
+ * {@link DeploymentDeleteResponse} for the law.
184
+ */
185
+ export interface DomainDeleteResponse {
186
+ /** The domain name that was removed, normalized */
187
+ readonly domain: string;
188
+ }
189
+
190
+ /**
191
+ * Acknowledgement of `POST /domains/:domain/verify` (202). The DNS check is
192
+ * queued, not performed — the accepted status code says so, and the domain's
193
+ * own status is unchanged until the check runs, which is why none is stated
194
+ * here. See {@link DeploymentDeleteResponse} for the law.
195
+ */
196
+ export interface DomainVerifyResponse {
197
+ /** The domain whose DNS verification was queued, normalized */
198
+ readonly domain: string;
141
199
  }
142
200
 
143
201
  /**
@@ -206,11 +264,13 @@ export interface DomainValidateResponse {
206
264
  // =============================================================================
207
265
 
208
266
  /**
209
- * Token as returned by the list endpoint.
210
- * The secret is shown once at creation and never again — listings carry
211
- * only the management identifier and lifecycle metadata.
267
+ * Core deploy token object - used in both API responses and SDK.
268
+ *
269
+ * The secret is never here: it is shown once at creation
270
+ * ({@link TokenCreateResponse.secret}) and never again, so an entity read
271
+ * carries only the management identifier and lifecycle metadata.
212
272
  */
213
- export interface TokenListItem {
273
+ export interface Token {
214
274
  /** 7-char management identifier (e.g., "a1b2c3d") */
215
275
  readonly token: string;
216
276
  /** Labels for categorization and filtering. Always present, empty array when none. */
@@ -226,25 +286,30 @@ export interface TokenListItem {
226
286
  /**
227
287
  * Response for listing tokens
228
288
  */
229
- export interface TokenListResponse {
230
- /** Array of tokens (security-redacted for list display) */
231
- tokens: TokenListItem[];
232
- /** Total number of tokens */
233
- total: number;
289
+ export interface TokenListResponse extends ListResponse {
290
+ /** Array of tokens (the secret is never among them) */
291
+ tokens: Token[];
234
292
  }
235
293
 
236
294
  /**
237
- * Response for token creation
295
+ * Response from token creation. Extends Token with the one field that
296
+ * exists only on creation — the same shape as
297
+ * {@link DeploymentCreateResponse}, because a 201 returns the resource it
298
+ * created plus whatever is knowable only once.
238
299
  */
239
- export interface TokenCreateResponse {
240
- /** 7-char management identifier */
241
- token: string;
300
+ export interface TokenCreateResponse extends Token {
242
301
  /** The raw credential value (shown once at creation, then never again) */
243
- secret: string;
244
- /** Labels for categorization and filtering. Always present, empty array when none. */
245
- labels: string[];
246
- /** Unix timestamp (seconds) when token expires, null for never */
247
- expires: number | null;
302
+ readonly secret: string;
303
+ }
304
+
305
+ /**
306
+ * Acknowledgement of `DELETE /tokens/:token`. The credential is revoked and
307
+ * its row is gone, so the management identifier is the whole answer. See
308
+ * {@link DeploymentDeleteResponse} for the law.
309
+ */
310
+ export interface TokenDeleteResponse {
311
+ /** The 7-char management identifier that was revoked */
312
+ readonly token: string;
248
313
  }
249
314
 
250
315
  // =============================================================================
@@ -268,10 +333,34 @@ export type AccountPlanType = (typeof AccountPlan)[keyof typeof AccountPlan];
268
333
 
269
334
  /**
270
335
  * Account usage metrics — always available regardless of billing provider.
336
+ *
337
+ * This is where a caller's own totals live. Lists answer pages and carry no
338
+ * `total` (see {@link ListOptions}); a count is an aggregate over a
339
+ * collection, so it belongs to the summary resource that owns the
340
+ * collection. `GET /account` is that resource for one caller, `GET
341
+ * /admin/stats` for the platform.
342
+ *
343
+ * The counted dimensions are the ones the plan caps — deployments and
344
+ * domains (`PlatformLimits`) — plus the billable custom-domain subset, so a
345
+ * surface can render "3 of 10" without a second request.
271
346
  */
272
347
  export interface AccountUsage {
273
348
  /** Number of active custom domains (excludes paused) */
274
349
  customDomains: number;
350
+ /**
351
+ * Deployments counted against the plan's deployment cap — every row
352
+ * whatever its status, because that is what the cap counts, so a surface
353
+ * renders "3 of 10" against the denominator the 403 divides by. (`GET
354
+ * /deployments` lists successful ones only; that is a different question
355
+ * asked of a different resource.) Optional by the additive-evolution law:
356
+ * an API predating this field omits it.
357
+ */
358
+ deployments?: number;
359
+ /**
360
+ * Domains counted against the plan's domain cap — every domain, platform
361
+ * and custom alike, unlike `customDomains`. Optional for the same reason.
362
+ */
363
+ domains?: number;
275
364
  }
276
365
 
277
366
  /**
@@ -321,6 +410,34 @@ export interface AccountGetResponse extends Account {
321
410
  readonly impersonatedBy?: string;
322
411
  }
323
412
 
413
+ /**
414
+ * Acknowledgement of `DELETE /account` (202). Termination is asynchronous —
415
+ * a cleanup consumer finishes the job — so the account survives long enough
416
+ * to state the plan it is transitioning through. `plan` is the account's
417
+ * state field, the way `status` is a deployment's. See
418
+ * {@link DeploymentDeleteResponse} for the law.
419
+ */
420
+ export interface AccountDeleteResponse {
421
+ /** The account that was marked for termination */
422
+ readonly account: string;
423
+ /** The plan the account is in while cleanup runs */
424
+ readonly plan: AccountPlanType;
425
+ }
426
+
427
+ /**
428
+ * Response from `PUT /account/key` — the account's single API key, minted in
429
+ * place of whatever was there before.
430
+ *
431
+ * There is no entity to return: only the key's last-4 `hint` is durable
432
+ * (`Account.hint`), and the plaintext exists exactly once, in this response.
433
+ * The raw credential is `secret` on every surface that mints one — the same
434
+ * field `TokenCreateResponse` carries — because one concept gets one name.
435
+ */
436
+ export interface AccountKeyResponse {
437
+ /** The raw API key (shown once at mint, then never again) */
438
+ readonly secret: string;
439
+ }
440
+
324
441
  /**
325
442
  * Account-specific configuration overrides
326
443
  * Allows per-account customization of limits without changing plan
@@ -397,11 +514,19 @@ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
397
514
  * union so `.has(error.type)` accepts any value from the union.
398
515
  */
399
516
  const ERROR_CATEGORIES = {
517
+ /**
518
+ * Client-attributable types. Exhaustive over the 4xx-carrying types, and
519
+ * it must include the statusless ones (`Config`, `File`) — those are
520
+ * raised locally by the SDK and have no status for `isClientError`'s
521
+ * second arm to read.
522
+ */
400
523
  client: new Set<ErrorType>([
401
524
  ErrorType.Business,
402
525
  ErrorType.Config,
403
526
  ErrorType.File,
404
527
  ErrorType.Forbidden,
528
+ ErrorType.NotFound,
529
+ ErrorType.RateLimit,
405
530
  ErrorType.Validation,
406
531
  ]),
407
532
  network: new Set<ErrorType>([ErrorType.Network]),
@@ -645,10 +770,24 @@ export class ShipError extends Error {
645
770
  return new ShipError(ErrorType.Api, message, status, details);
646
771
  }
647
772
 
648
- // Semantic-category type guards. For specific-type checks, use
773
+ // Semantic-category guards. For specific-type checks, use
649
774
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
775
+
776
+ /**
777
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
778
+ * that is client-attributable without ever having a status (`Config`,
779
+ * `File`, raised locally by the SDK).
780
+ *
781
+ * Both arms are load-bearing, because type and status are independent
782
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
783
+ * server-producible type; a non-OK response without one is status-derived,
784
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
785
+ * *type* carrying a client *status*. Judging by type alone would report it
786
+ * as a platform failure and bury the server's own message.
787
+ */
650
788
  isClientError(): boolean {
651
- return ERROR_CATEGORIES.client.has(this.type);
789
+ if (ERROR_CATEGORIES.client.has(this.type)) return true;
790
+ return this.status !== undefined && this.status >= 400 && this.status < 500;
652
791
  }
653
792
 
654
793
  isNetworkError(): boolean {
@@ -1004,6 +1143,54 @@ export const SPA_DEFAULT_CONFIG = {
1004
1143
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
1005
1144
  } as const;
1006
1145
 
1146
+ /**
1147
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
1148
+ * never schema.
1149
+ *
1150
+ * ship.json is validated and compiled on the server, deliberately: the schema
1151
+ * and the compiler evolve, and a client that judged them would reject configs
1152
+ * a newer platform accepts. That reasoning bounds what a client may check to
1153
+ * the properties which are true of *every* past and future schema:
1154
+ *
1155
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
1156
+ * does not parse can never be a valid config;
1157
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
1158
+ *
1159
+ * Both are monotonic: neither can ever reject something the server would
1160
+ * accept. Everything beyond them (field names, types, rule semantics, which
1161
+ * keys are permitted) stays server-side, where it can change.
1162
+ *
1163
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
1164
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
1165
+ * documentation — mistakes that otherwise cost a full upload round-trip to
1166
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
1167
+ * before parsing rather than rejected, because the server accepts it too;
1168
+ * diverging there would reintroduce exactly the false rejection this
1169
+ * function exists to avoid.
1170
+ *
1171
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
1172
+ * config rejection carries, so the error contract is identical wherever the
1173
+ * failure is detected.
1174
+ */
1175
+ export function assertShipJsonSyntax(text: string): void {
1176
+ const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
1177
+
1178
+ let parsed: unknown;
1179
+ try {
1180
+ parsed = JSON.parse(withoutBom);
1181
+ } catch (error) {
1182
+ throw ShipError.config(`invalid JSON format in config: ${(error as Error).message}`, {
1183
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1184
+ });
1185
+ }
1186
+
1187
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1188
+ throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
1189
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1190
+ });
1191
+ }
1192
+ }
1193
+
1007
1194
  // =============================================================================
1008
1195
  // VALIDATION UTILITIES
1009
1196
  // =============================================================================
@@ -1233,10 +1420,20 @@ export interface DeploymentUploadOptions {
1233
1420
  }
1234
1421
 
1235
1422
  /**
1236
- * Pagination options for the paginated list endpoints (`GET /deployments`,
1237
- * `GET /domains`). The response's `cursor` feeds the next request; a `null`
1238
- * cursor on the response means the last page. Omitting both returns the
1239
- * server's default first page.
1423
+ * Pagination options for every list endpoint. The response's `cursor` feeds
1424
+ * the next request; a `null` cursor means the last page. Omitting both
1425
+ * returns the server's default first page.
1426
+ *
1427
+ * A list answers `{ <collection>, cursor }` and nothing else — `cursor`
1428
+ * carries the entire has-more signal, so no redundant boolean, and no
1429
+ * `total`. **A count is an aggregate over a collection, not a property of a
1430
+ * page:** including one makes every read pay for a full scan it did not ask
1431
+ * for, which is precisely the cost keyset pagination exists to avoid.
1432
+ *
1433
+ * Counts therefore live on the summary resource that owns them —
1434
+ * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
1435
+ * platform-wide ones. Ask for a count when you want a count; ask for a page
1436
+ * when you want a page.
1240
1437
  */
1241
1438
  export interface ListOptions {
1242
1439
  /** Maximum number of items to return in one page. */
@@ -1274,7 +1471,7 @@ export interface DomainResource {
1274
1471
  list: (options?: ListOptions) => Promise<DomainListResponse>;
1275
1472
  get: (name: string) => Promise<Domain>;
1276
1473
  remove: (name: string) => Promise<void>;
1277
- verify: (name: string) => Promise<{ message: string }>;
1474
+ verify: (name: string) => Promise<DomainVerifyResponse>;
1278
1475
  validate: (name: string) => Promise<DomainValidateResponse>;
1279
1476
  dns: (name: string) => Promise<DomainDnsResponse>;
1280
1477
  records: (name: string) => Promise<DomainRecordsResponse>;
@@ -1293,7 +1490,7 @@ export interface AccountResource {
1293
1490
  */
1294
1491
  export interface TokenResource {
1295
1492
  create: (options?: { ttl?: number; labels?: string[] }) => Promise<TokenCreateResponse>;
1296
- list: () => Promise<TokenListResponse>;
1493
+ list: (options?: ListOptions) => Promise<TokenListResponse>;
1297
1494
  remove: (token: string) => Promise<void>;
1298
1495
  }
1299
1496
 
@@ -1477,7 +1674,7 @@ export interface ActivityMeta {
1477
1674
  /**
1478
1675
  * Response from GET /activities endpoint
1479
1676
  */
1480
- export interface ActivityListResponse {
1677
+ export interface ActivityListResponse extends ListResponse {
1481
1678
  /** Array of activities */
1482
1679
  activities: Activity[];
1483
1680
  }