@shipstatic/types 2.5.0-beta.2 → 2.5.0-beta.21

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/src/index.ts CHANGED
@@ -19,6 +19,30 @@ export const DeploymentStatus = {
19
19
 
20
20
  export type DeploymentStatusType = (typeof DeploymentStatus)[keyof typeof DeploymentStatus];
21
21
 
22
+ /**
23
+ * Which client made a deployment — the origin-tracking vocabulary.
24
+ *
25
+ * A closed set with many authors: the CLI, the SDK, the dashboard, both MCP
26
+ * transports, the GitHub Action, the n8n node and the VS Code extension each
27
+ * name themselves here. It lived in the API's config until 2026-08-06, where
28
+ * being server-side made it unenforceable in the one direction that matters —
29
+ * every client wrote a bare string, and a value outside the set was **silently
30
+ * dropped** by the server, so a typo did not fail anywhere. It stopped
31
+ * recording where deploys came from and said nothing.
32
+ */
33
+ export const DeploymentVia = {
34
+ WEB: 'web',
35
+ SDK: 'sdk',
36
+ CLI: 'cli',
37
+ MCP: 'mcp',
38
+ GIT: 'git',
39
+ N8N: 'n8n',
40
+ GPT: 'gpt',
41
+ VSC: 'vsc',
42
+ } as const;
43
+
44
+ export type DeploymentViaType = (typeof DeploymentVia)[keyof typeof DeploymentVia];
45
+
22
46
  /**
23
47
  * Core deployment object - used in both API responses and SDK
24
48
  */
@@ -39,7 +63,15 @@ export interface Deployment {
39
63
  readonly password: boolean;
40
64
  /** Labels for categorization and filtering (lowercase, alphanumeric with separators). Always present, empty array when none. */
41
65
  labels: string[];
42
- /** The client/tool used to create this deployment (e.g., 'web', 'sdk', 'cli'), null if unknown */
66
+ /**
67
+ * The client/tool that created this deployment, null if unknown.
68
+ *
69
+ * Deliberately wider than {@link DeploymentViaType}: this is stored data,
70
+ * and rows predate the vocabulary being closed. Narrowing the ENTITY would
71
+ * be a claim about every row already in the database; narrowing the
72
+ * REQUEST option ({@link DeploymentUploadOptions.via}) is a claim about
73
+ * what a client may send, which is ours to make.
74
+ */
43
75
  readonly via: string | null;
44
76
  /** Unix timestamp (seconds) when deployment was created */
45
77
  readonly created: number;
@@ -58,16 +90,161 @@ export interface DeploymentCreateResponse extends Deployment {
58
90
  readonly claim?: string;
59
91
  }
60
92
 
93
+ /**
94
+ * Every path the public API answers on, declared once.
95
+ *
96
+ * The URL surface was written out in four places — the API's mounts, the
97
+ * SDK's client, the dashboard's client, and the post-deploy smoke — so a
98
+ * rename meant finding all four. The first three now read this table.
99
+ *
100
+ * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
101
+ * five of its nine paths are `/admin/*`, which this table excludes by
102
+ * design, and splitting one list between a registry and literals reads worse
103
+ * than keeping it uniform.
104
+ *
105
+ * **What this guarantees, exactly.** Collection paths are mounted from here,
106
+ * so producer and consumer cannot diverge. Item paths are declared here and
107
+ * consumed by clients, but the API spells them relative to their mount
108
+ * (`/:deployment/config`), so the table does not *generate* them — it is
109
+ * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
110
+ * any entry names a path no route answers. Some entries have no client yet
111
+ * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
112
+ * deliberately does not reach); the fence is what keeps those honest rather
113
+ * than merely asserted.
114
+ *
115
+ * **The operator surface is deliberately absent.** `/admin/*` paths belong
116
+ * to `web/my`, for the same reason its row types do: this package is
117
+ * published, and the operator surface is not public (see `CLAUDE.md`, "Admin
118
+ * types"). A path here is a promise to every npm consumer; `/admin` is a
119
+ * promise to one dashboard.
120
+ *
121
+ * Item paths are functions rather than templates so the key is interpolated
122
+ * in one place, encoded the same way by every caller.
123
+ */
124
+ export const API_PATHS = {
125
+ DEPLOYMENTS: '/deployments',
126
+ DEPLOYMENT: (deployment: string) => `/deployments/${deployment}`,
127
+ DEPLOYMENT_CONFIG: (deployment: string) => `/deployments/${deployment}/config`,
128
+ DOMAINS: '/domains',
129
+ DOMAIN: (domain: string) => `/domains/${domain}`,
130
+ DOMAIN_VERIFY: (domain: string) => `/domains/${domain}/verify`,
131
+ DOMAIN_DNS: (domain: string) => `/domains/${domain}/dns`,
132
+ DOMAIN_RECORDS: (domain: string) => `/domains/${domain}/records`,
133
+ DOMAIN_SHARE: (domain: string) => `/domains/${domain}/share`,
134
+ DOMAIN_PROPAGATION: (domain: string) => `/domains/${domain}/propagation`,
135
+ DOMAINS_VALIDATE: '/domains/validate',
136
+ TOKENS: '/tokens',
137
+ TOKEN: (token: string) => `/tokens/${token}`,
138
+ ACCOUNT: '/account',
139
+ ACCOUNT_KEY: '/account/key',
140
+ ACCOUNT_CLAIM: '/account/claim',
141
+ ACTIVITIES: '/activities',
142
+ LABELS: '/labels',
143
+ LIMITS: '/limits',
144
+ PING: '/ping',
145
+ SETUP: '/setup',
146
+ SPA_CHECK: '/spa-check',
147
+ UPLOAD: '/upload',
148
+ } as const;
149
+
150
+ /**
151
+ * The deploy request's multipart field names — the other half of the wire
152
+ * surface beside {@link API_PATHS}. `POST /deployments` (and the first-party
153
+ * `/upload`) is multipart/form-data, and these are the names the API reads.
154
+ *
155
+ * Declared once because the body has three independent WRITERS — the SDK's
156
+ * Node and browser body builders, and the n8n community node's hand-rolled
157
+ * client (which cannot import this under n8n Cloud's zero-dependency rule,
158
+ * and fences its restated copy instead) — and until this export every writer
159
+ * restated the strings the API parses, with nothing comparing them.
160
+ *
161
+ * `FILES` carries one entry per file (the API reads it with `getAll`); every
162
+ * other field is single. The `@internal` flags are serialized as the literal
163
+ * string `'true'` and belong to first-party surfaces only.
164
+ */
165
+ export const DEPLOY_FIELDS = {
166
+ /** One entry per file — read with `getAll`. */
167
+ FILES: 'files[]',
168
+ /** JSON array of MD5 hex digests, index-aligned with `FILES`. */
169
+ CHECKSUMS: 'checksums',
170
+ /** JSON array of label strings. */
171
+ LABELS: 'labels',
172
+ /** The deploying surface's {@link DeploymentVia} member. */
173
+ VIA: 'via',
174
+ /** Plaintext password — the API hashes it server-side. */
175
+ PASSWORD: 'password',
176
+ /** @internal Server-processing flag — first-party `/upload` only. */
177
+ BUILD: 'build',
178
+ /** @internal Server-processing flag — first-party `/upload` only. */
179
+ PRERENDER: 'prerender',
180
+ /** @internal Server-processing flag — first-party `/upload` only. */
181
+ SPA: 'spa',
182
+ /** @internal reCAPTCHA proof — `web/www`'s public uploader only. */
183
+ CAPTCHA: 'captcha',
184
+ } as const;
185
+
186
+ /**
187
+ * The half of a list response that is identical on every list.
188
+ *
189
+ * `GET /<collection>` answers exactly two fields — the collection under its
190
+ * own plural noun, and this cursor — so the cursor is declared once here and
191
+ * each response below adds only its noun. `cursor: null` means last page and
192
+ * is the ENTIRE has-more signal, which is why there is no `has_more`.
193
+ *
194
+ * There is deliberately no `total`. A count is an aggregate over a
195
+ * collection, not a property of a page; producing one would cost a COUNT
196
+ * beside every page read, which is precisely what keyset pagination exists
197
+ * to avoid. Counts live on the resource that summarises the collection —
198
+ * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
199
+ */
200
+ export interface ListResponse {
201
+ /** Opaque cursor from this page; `null` on the last page. */
202
+ cursor: string | null;
203
+ }
204
+
61
205
  /**
62
206
  * Response for listing deployments
63
207
  */
64
- export interface DeploymentListResponse {
208
+ export interface DeploymentListResponse extends ListResponse {
65
209
  /** Array of deployments */
66
210
  deployments: Deployment[];
67
- /** Cursor for pagination, null if no more pages */
68
- cursor: string | null;
69
- /** Total number of deployments */
70
- total: number;
211
+ }
212
+
213
+ /**
214
+ * Acknowledgement of `DELETE /deployments/:deployment` — and the shape every
215
+ * mutation with no entity left to return follows.
216
+ *
217
+ * **The law:** a mutation answers with the resource it affected. If the
218
+ * resource still exists, that means the entity itself (`Deployment`,
219
+ * `Domain`, …). Otherwise it means this: the resource noun carrying the
220
+ * item's canonical key, plus the resource's own state field — and ONLY when
221
+ * the resource survived in a transitional state, as an async deletion's does.
222
+ * Where the resource is simply gone, the key alone is the whole answer
223
+ * ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
224
+ *
225
+ * Put positively: **an acknowledgement is a projection of the resource** —
226
+ * its key, plus its own state field where the state changed. That is the
227
+ * test to apply, and it is sharper than "no constant", which this shape
228
+ * would fail on its own terms: `status` here is the literal `'deleting'` on
229
+ * every success, exactly as fixed as a `changed: true` would be.
230
+ *
231
+ * The difference is not how predictable the value is, it is what the field
232
+ * IS. `status` is the deployment's own field — the same one `GET
233
+ * /deployments/:deployment` returns — so this response is `Deployment`
234
+ * narrowed to two members, and a client renders it with the code it already
235
+ * has. `changed: true`, `queued: true` and `success: true` are not fields of
236
+ * any entity; they exist only to assert that the call worked, which the
237
+ * status code already said. Sync versus accepted is likewise the status
238
+ * code's job — 200 versus 202 — not a boolean's.
239
+ *
240
+ * No prose either (`message`): an acknowledgement is data, and each surface
241
+ * composes its own copy.
242
+ */
243
+ export interface DeploymentDeleteResponse {
244
+ /** The deployment hostname that was marked for removal */
245
+ readonly deployment: string;
246
+ /** The state the deployment is in while background cleanup runs */
247
+ readonly status: DeploymentStatusType;
71
248
  }
72
249
 
73
250
  // =============================================================================
@@ -107,7 +284,7 @@ export interface Domain {
107
284
  labels: string[];
108
285
  /** Unix timestamp (seconds) when domain was created */
109
286
  readonly created: number;
110
- /** When deployment was last linked (Unix timestamp), null if never linked */
287
+ /** Unix timestamp (seconds) when deployment was last linked, null if never linked */
111
288
  linked: number | null;
112
289
  /** Total deployment links */
113
290
  links: number;
@@ -131,13 +308,30 @@ export interface DomainSetResult extends Domain {
131
308
  /**
132
309
  * Response for listing domains
133
310
  */
134
- export interface DomainListResponse {
311
+ export interface DomainListResponse extends ListResponse {
135
312
  /** Array of domains */
136
313
  domains: Domain[];
137
- /** Cursor for pagination, null if no more pages */
138
- cursor: string | null;
139
- /** Total number of domains */
140
- total: number;
314
+ }
315
+
316
+ /**
317
+ * Acknowledgement of `DELETE /domains/:domain`. The row is gone, so there is
318
+ * no state to state — the canonical domain name is the whole answer. See
319
+ * {@link DeploymentDeleteResponse} for the law.
320
+ */
321
+ export interface DomainDeleteResponse {
322
+ /** The domain name that was removed, normalized */
323
+ readonly domain: string;
324
+ }
325
+
326
+ /**
327
+ * Acknowledgement of `POST /domains/:domain/verify` (202). The DNS check is
328
+ * queued, not performed — the accepted status code says so, and the domain's
329
+ * own status is unchanged until the check runs, which is why none is stated
330
+ * here. See {@link DeploymentDeleteResponse} for the law.
331
+ */
332
+ export interface DomainVerifyResponse {
333
+ /** The domain whose DNS verification was queued, normalized */
334
+ readonly domain: string;
141
335
  }
142
336
 
143
337
  /**
@@ -168,15 +362,49 @@ export interface DnsProvider {
168
362
  /**
169
363
  * Response for domain DNS provider lookup
170
364
  */
365
+ /**
366
+ * What a DNS lookup found for a domain. An envelope rather than a bare
367
+ * {@link DnsProvider} because a lookup can succeed and learn more than the
368
+ * provider later; the shape is named so a consumer can hold one.
369
+ */
370
+ export interface DnsLookup {
371
+ /** The provider serving this domain's DNS, absent when unidentified */
372
+ provider?: DnsProvider;
373
+ }
374
+
375
+ /**
376
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
377
+ * "A report answers a question").
378
+ */
171
379
  export interface DomainDnsResponse {
172
380
  /** The domain name */
173
381
  domain: string;
174
382
  /** DNS provider information, null if not yet looked up */
175
- dns: { provider?: DnsProvider } | null;
383
+ dns: DnsLookup | null;
384
+ }
385
+
386
+ /**
387
+ * Response for `GET /domains/:domain/share` — the domain plus the salted
388
+ * hash that lets someone else complete its DNS setup without an account.
389
+ *
390
+ * `/admin/domains/:domain/share` answers the same shape, which is the admin
391
+ * law working: the operator surface is the public grammar with a prefix.
392
+ *
393
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
394
+ * "A report answers a question").
395
+ */
396
+ export interface DomainShareResponse {
397
+ /** The domain the setup link is for */
398
+ readonly domain: string;
399
+ /** The salted setup hash that authorizes the share */
400
+ readonly hash: string;
176
401
  }
177
402
 
178
403
  /**
179
404
  * Response for domain DNS records
405
+ *
406
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
407
+ * "A report answers a question").
180
408
  */
181
409
  export interface DomainRecordsResponse {
182
410
  /** The domain name */
@@ -188,7 +416,118 @@ export interface DomainRecordsResponse {
188
416
  }
189
417
 
190
418
  /**
191
- * Response for domain validation
419
+ * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
420
+ *
421
+ * Format lives here rather than on the server alone by the format-vs-policy
422
+ * rule: a client can decide offline whether a key is well-formed, and the
423
+ * API would reject the same value the same way.
424
+ */
425
+ export const IDEMPOTENCY_KEY_CONSTRAINTS = {
426
+ /**
427
+ * HTTP header name. Here for the same reason {@link CALLER.HEADER} is: a
428
+ * wire header has two ends, and the package that owns the value's format
429
+ * is the only place both ends can read its name from.
430
+ */
431
+ HEADER: 'Idempotency-Key',
432
+ MAX_LENGTH: 256,
433
+ /** How long a stored 201 stays replayable. */
434
+ WINDOW_SECONDS: 24 * 60 * 60,
435
+ } as const;
436
+
437
+ /**
438
+ * Normalize a `via` value from any transport — trimmed, lowercased, and a
439
+ * member of {@link DeploymentVia}, or `undefined`.
440
+ *
441
+ * A format rule by this package's own test: a client can decide offline
442
+ * whether a value is well-formed, and the API reaches the same verdict on the
443
+ * same input. It lived server-side until 2026-08-06, which meant clients could
444
+ * only learn their label was unusable by noticing analytics had gone quiet.
445
+ *
446
+ * **Not knowing your `via` is not an error** — an unrecognized value yields
447
+ * `undefined` rather than throwing, because origin tracking is telemetry and a
448
+ * deploy must never fail over it. A caller that has an honest default should
449
+ * prefer it (`normalizeVia(process.env.SHIP_VIA) ?? DeploymentVia.CLI`): the
450
+ * deploy really did come from the CLI, so recording that beats recording
451
+ * nothing.
452
+ */
453
+ export function normalizeVia(value: unknown): DeploymentViaType | undefined {
454
+ if (!value || typeof value !== 'string') return undefined;
455
+ const via = value.trim().toLowerCase();
456
+ return (Object.values(DeploymentVia) as string[]).includes(via)
457
+ ? (via as DeploymentViaType)
458
+ : undefined;
459
+ }
460
+
461
+ /**
462
+ * Validate an idempotency key, returning the trimmed value or `undefined`
463
+ * when none was supplied. Throws {@link ShipError.validation} when the value
464
+ * cannot be sent — the same verdict the API would reach, reached earlier.
465
+ */
466
+ export function validateIdempotencyKey(value: unknown): string | undefined {
467
+ if (value === undefined || value === null) return undefined;
468
+ if (typeof value !== 'string') {
469
+ throw ShipError.validation('Idempotency key must be a string.');
470
+ }
471
+ const key = value.trim();
472
+ if (!key) {
473
+ throw ShipError.validation('Idempotency key must not be empty.');
474
+ }
475
+ if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {
476
+ throw ShipError.validation(
477
+ `Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`,
478
+ );
479
+ }
480
+ return key;
481
+ }
482
+
483
+ /**
484
+ * Response for `GET /labels` — every label in use across the caller's
485
+ * deployments, domains and tokens, grouped and ordered by last use.
486
+ *
487
+ * The one plural noun outside the list contract, deliberately: labels have
488
+ * no identity, no row and no `created`, so there is nothing for a keyset
489
+ * cursor to resume after, and its consumer is an autocomplete that wants the
490
+ * whole set. Bounded by `PAGINATION.GLOBAL_LIMIT` rather than paginated.
491
+ *
492
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
493
+ * "A report answers a question").
494
+ */
495
+ export interface LabelsResponse {
496
+ readonly labels: string[];
497
+ }
498
+
499
+ /**
500
+ * Response for `POST /setup` — the DNS instructions for one domain, written
501
+ * for a human to follow at their registrar.
502
+ *
503
+ * `custom` is the provider-specific walkthrough when the provider is known;
504
+ * `generic` always answers, so a caller never has nothing to show.
505
+ *
506
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
507
+ * "A report answers a question").
508
+ */
509
+ export interface SetupInstructionsResponse {
510
+ /** The domain the instructions are for — a report names its subject */
511
+ readonly domain: string;
512
+ /** One-line summary of what to do */
513
+ readonly tldr: string;
514
+ /** Provider-specific instructions, null when the provider is unknown */
515
+ readonly custom: string | null;
516
+ /** Provider-agnostic instructions — always present */
517
+ readonly generic: string;
518
+ /** The identified DNS provider, null when unknown */
519
+ readonly provider: string | null;
520
+ }
521
+
522
+ /**
523
+ * `POST /domains/validate` — a report answering "is this name usable, and if
524
+ * not, why".
525
+ *
526
+ * An unusable name is a legitimate ANSWER, not a failure, so this is a 200 and
527
+ * the verdict rides the body. `reason` was named `error` until 2026-07-29,
528
+ * which collided with {@link ErrorResponse}'s reserved key — there `error` is
529
+ * an `ErrorType` a client branches on, here it is prose a client displays, and
530
+ * one key cannot mean both. See {@link DeploymentDeleteResponse} for the law.
192
531
  */
193
532
  export interface DomainValidateResponse {
194
533
  /** Whether the domain is valid */
@@ -197,8 +536,8 @@ export interface DomainValidateResponse {
197
536
  normalized: string | null;
198
537
  /** Whether the domain is available, null when invalid */
199
538
  available: boolean | null;
200
- /** Error message, null when valid */
201
- error: string | null;
539
+ /** Why the name is unusable, null when valid — displayed verbatim. */
540
+ reason: string | null;
202
541
  }
203
542
 
204
543
  // =============================================================================
@@ -206,11 +545,13 @@ export interface DomainValidateResponse {
206
545
  // =============================================================================
207
546
 
208
547
  /**
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.
548
+ * Core deploy token object - used in both API responses and SDK.
549
+ *
550
+ * The secret is never here: it is shown once at creation
551
+ * ({@link TokenCreateResponse.secret}) and never again, so an entity read
552
+ * carries only the management identifier and lifecycle metadata.
212
553
  */
213
- export interface TokenListItem {
554
+ export interface Token {
214
555
  /** 7-char management identifier (e.g., "a1b2c3d") */
215
556
  readonly token: string;
216
557
  /** Labels for categorization and filtering. Always present, empty array when none. */
@@ -226,25 +567,30 @@ export interface TokenListItem {
226
567
  /**
227
568
  * Response for listing tokens
228
569
  */
229
- export interface TokenListResponse {
230
- /** Array of tokens (security-redacted for list display) */
231
- tokens: TokenListItem[];
232
- /** Total number of tokens */
233
- total: number;
570
+ export interface TokenListResponse extends ListResponse {
571
+ /** Array of tokens (the secret is never among them) */
572
+ tokens: Token[];
234
573
  }
235
574
 
236
575
  /**
237
- * Response for token creation
576
+ * Response from token creation. Extends Token with the one field that
577
+ * exists only on creation — the same shape as
578
+ * {@link DeploymentCreateResponse}, because a 201 returns the resource it
579
+ * created plus whatever is knowable only once.
238
580
  */
239
- export interface TokenCreateResponse {
240
- /** 7-char management identifier */
241
- token: string;
581
+ export interface TokenCreateResponse extends Token {
242
582
  /** 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;
583
+ readonly secret: string;
584
+ }
585
+
586
+ /**
587
+ * Acknowledgement of `DELETE /tokens/:token`. The credential is revoked and
588
+ * its row is gone, so the management identifier is the whole answer. See
589
+ * {@link DeploymentDeleteResponse} for the law.
590
+ */
591
+ export interface TokenDeleteResponse {
592
+ /** The 7-char management identifier that was revoked */
593
+ readonly token: string;
248
594
  }
249
595
 
250
596
  // =============================================================================
@@ -268,10 +614,34 @@ export type AccountPlanType = (typeof AccountPlan)[keyof typeof AccountPlan];
268
614
 
269
615
  /**
270
616
  * Account usage metrics — always available regardless of billing provider.
617
+ *
618
+ * This is where a caller's own totals live. Lists answer pages and carry no
619
+ * `total` (see {@link ListOptions}); a count is an aggregate over a
620
+ * collection, so it belongs to the summary resource that owns the
621
+ * collection. `GET /account` is that resource for one caller, `GET
622
+ * /admin/stats` for the platform.
623
+ *
624
+ * The counted dimensions are the ones the plan caps — deployments and
625
+ * domains (`PlatformLimits`) — plus the billable custom-domain subset, so a
626
+ * surface can render "3 of 10" without a second request.
271
627
  */
272
628
  export interface AccountUsage {
273
629
  /** Number of active custom domains (excludes paused) */
274
630
  customDomains: number;
631
+ /**
632
+ * Deployments counted against the plan's deployment cap — every row
633
+ * whatever its status, because that is what the cap counts, so a surface
634
+ * renders "3 of 10" against the denominator the 403 divides by. (`GET
635
+ * /deployments` lists successful ones only; that is a different question
636
+ * asked of a different resource.) Optional by the additive-evolution law:
637
+ * an API predating this field omits it.
638
+ */
639
+ deployments?: number;
640
+ /**
641
+ * Domains counted against the plan's domain cap — every domain, platform
642
+ * and custom alike, unlike `customDomains`. Optional for the same reason.
643
+ */
644
+ domains?: number;
275
645
  }
276
646
 
277
647
  /**
@@ -321,6 +691,37 @@ export interface AccountGetResponse extends Account {
321
691
  readonly impersonatedBy?: string;
322
692
  }
323
693
 
694
+ /**
695
+ * Acknowledgement of `DELETE /account` (202). Termination is asynchronous —
696
+ * a cleanup consumer finishes the job — so the account survives long enough
697
+ * to state the plan it is transitioning through. `plan` is the account's
698
+ * state field, the way `status` is a deployment's. See
699
+ * {@link DeploymentDeleteResponse} for the law.
700
+ */
701
+ export interface AccountDeleteResponse {
702
+ /** The account that was marked for termination */
703
+ readonly account: string;
704
+ /** The plan the account is in while cleanup runs */
705
+ readonly plan: AccountPlanType;
706
+ }
707
+
708
+ /**
709
+ * Response from `PUT /account/key` — the account's single API key, minted in
710
+ * place of whatever was there before.
711
+ *
712
+ * There is no entity to return: only the key's last-4 `hint` is durable
713
+ * (`Account.hint`), and the plaintext exists exactly once, in this response.
714
+ * The raw credential is `secret` on every surface that mints one — the same
715
+ * field `TokenCreateResponse` carries — because one concept gets one name.
716
+ *
717
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
718
+ * "A report answers a question").
719
+ */
720
+ export interface AccountKeyResponse {
721
+ /** The raw API key (shown once at mint, then never again) */
722
+ readonly secret: string;
723
+ }
724
+
324
725
  /**
325
726
  * Account-specific configuration overrides
326
727
  * Allows per-account customization of limits without changing plan
@@ -352,7 +753,15 @@ export interface AccountOverrides {
352
753
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
353
754
  */
354
755
  export const ErrorType = {
355
- /** Validation failed (400). Input shape is wrong. */
756
+ /**
757
+ * Validation failed. Input shape is wrong.
758
+ *
759
+ * Carries 400 when an API judged it — including a client-side pre-check of a
760
+ * rule the server enforces too, which keeps the error identical wherever it
761
+ * was caught. **Statusless** when a client rejects something no API judges,
762
+ * such as a CLI's own command grammar: `status` is documented "(API
763
+ * contexts)" on `ErrorResponse`, so there is none to report.
764
+ */
356
765
  Validation: 'validation_failed',
357
766
  /** Resource not found (404). */
358
767
  NotFound: 'not_found',
@@ -397,11 +806,26 @@ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
397
806
  * union so `.has(error.type)` accepts any value from the union.
398
807
  */
399
808
  const ERROR_CATEGORIES = {
809
+ /**
810
+ * Client-attributable types. Exhaustive over the 4xx-carrying types, and
811
+ * over the statusless ones too — those are raised locally and have no
812
+ * status for `isClientError`'s second arm to read, so omitting one makes it
813
+ * read as a server fault. The rule is the membership test: every type in
814
+ * `CLIENT_ONLY_ERROR_TYPES` except `Network` (which `isNetworkError` owns)
815
+ * belongs here.
816
+ *
817
+ * `Cancelled` was missing until 2026-07-29, which is exactly that failure:
818
+ * a caller who aborted their own deploy was told "server error: please try
819
+ * again" — the CLI's fallback for everything this set does not claim.
820
+ */
400
821
  client: new Set<ErrorType>([
401
822
  ErrorType.Business,
823
+ ErrorType.Cancelled,
402
824
  ErrorType.Config,
403
825
  ErrorType.File,
404
826
  ErrorType.Forbidden,
827
+ ErrorType.NotFound,
828
+ ErrorType.RateLimit,
405
829
  ErrorType.Validation,
406
830
  ]),
407
831
  network: new Set<ErrorType>([ErrorType.Network]),
@@ -419,6 +843,51 @@ const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
419
843
  Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)),
420
844
  );
421
845
 
846
+ /**
847
+ * Ceiling on a message adopted from a **non-JSON** error body — a foreign
848
+ * responder's, never this platform's. Generous for the plain-text one-liners
849
+ * intermediaries actually send (`error code: 1015`), far below a document.
850
+ * Our own messages are never measured against it: a JSON body is the API's
851
+ * contract, and truncating a long validation message would be the bug.
852
+ */
853
+ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
854
+
855
+ /**
856
+ * Did the runtime say the exchange never completed?
857
+ *
858
+ * WHATWG has `fetch` reject with a **TypeError** on network error, and undici,
859
+ * Chromium and Firefox comply. Bun does not: it rejects with a plain `Error`
860
+ * carrying a system `code` string. Captured 2026-08-05 (the capture script is
861
+ * in `tests/errors.test.ts`, "runtime failure shapes"):
862
+ *
863
+ * | failure | Node 22 / undici | Bun 1.3.14 |
864
+ * |---------------|---------------------------|----------------------------------------------|
865
+ * | refused | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
866
+ * | DNS failure | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
867
+ * | reset | `TypeError: fetch failed` | `Error` `code: 'ECONNRESET'` |
868
+ * | TLS rejected | `TypeError: fetch failed` | `Error` `code: 'UNKNOWN_CERTIFICATE_…ERROR'` |
869
+ *
870
+ * So the test is the **evidence, not a list of dialect strings**: a string
871
+ * `code` is a runtime naming a transport-level failure. An allowlist of codes
872
+ * was written first and rejected — the TLS row alone would mean enumerating
873
+ * BoringSSL's certificate table, and a code nobody guessed is precisely the bug
874
+ * this closes. Two kinds of error are deliberately NOT caught: ordinary JS
875
+ * faults carry no `code` at all, and a `DOMException`'s is a **number**, so
876
+ * aborts and timeouts fall through to their own arms.
877
+ *
878
+ * The accepted trade: a caller's `TokenProvider` that throws a coded error
879
+ * (`ENOENT` from a keychain read) is typed `Network` rather than `Api`. Both
880
+ * are wrong for it, `Network` is the cheaper wrong — it says "nothing was
881
+ * exchanged", which is true, where `Api` claims a server answered.
882
+ */
883
+ function isTransportFailure(cause: Error): boolean {
884
+ if (typeof (cause as { code?: unknown }).code === 'string') return true;
885
+ // Spec runtimes put no code on the rejection itself. The message test is what
886
+ // keeps fetch's ARGUMENT errors out — `Failed to parse URL from …` is a
887
+ // caller's config mistake, not a transport failure.
888
+ return cause instanceof TypeError && cause.message.includes('fetch');
889
+ }
890
+
422
891
  /**
423
892
  * Standard error response format used everywhere
424
893
  */
@@ -505,8 +974,17 @@ export class ShipError extends Error {
505
974
  }
506
975
  }
507
976
  } else {
508
- const text = await response.text();
509
- if (text) message = text;
977
+ // A non-JSON body did not come from this platform — every API error
978
+ // is `ErrorResponse` JSON — so it is an intermediary's output, and
979
+ // the two kinds it produces need opposite treatment. A CDN's plain
980
+ // `error code: 1015` is the most useful thing there is to say. A
981
+ // proxy's HTML error page is a *document*, not a message: adopting it
982
+ // verbatim made a misconfigured `apiUrl` print 2,059 characters of
983
+ // markup as the error. Trust it only when it reads as a message.
984
+ const text = (await response.text()).trim();
985
+ if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {
986
+ message = text;
987
+ }
510
988
  }
511
989
  } catch {
512
990
  // Body unreadable; fall through to operationName-derived message.
@@ -556,7 +1034,8 @@ export class ShipError extends Error {
556
1034
  * Routing:
557
1035
  * - Already a `ShipError` → returned as-is (caller's intent preserved)
558
1036
  * - `AbortError` → `ShipError.cancelled(...)`
559
- * - `TypeError` whose message mentions "fetch" → `ShipError.network(...)`
1037
+ * - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
1038
+ * for what each runtime offers as evidence
560
1039
  * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
561
1040
  * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
562
1041
  *
@@ -573,7 +1052,7 @@ export class ShipError extends Error {
573
1052
  if (cause.name === 'AbortError') {
574
1053
  return ShipError.cancelled(`${op} was cancelled`);
575
1054
  }
576
- if (cause instanceof TypeError && cause.message.includes('fetch')) {
1055
+ if (isTransportFailure(cause)) {
577
1056
  return ShipError.network(`${op} failed: ${cause.message}`, { cause });
578
1057
  }
579
1058
  return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
@@ -645,10 +1124,24 @@ export class ShipError extends Error {
645
1124
  return new ShipError(ErrorType.Api, message, status, details);
646
1125
  }
647
1126
 
648
- // Semantic-category type guards. For specific-type checks, use
1127
+ // Semantic-category guards. For specific-type checks, use
649
1128
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
1129
+
1130
+ /**
1131
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
1132
+ * that is client-attributable without ever having a status (`Config`,
1133
+ * `File`, raised locally by the SDK).
1134
+ *
1135
+ * Both arms are load-bearing, because type and status are independent
1136
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
1137
+ * server-producible type; a non-OK response without one is status-derived,
1138
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
1139
+ * *type* carrying a client *status*. Judging by type alone would report it
1140
+ * as a platform failure and bury the server's own message.
1141
+ */
650
1142
  isClientError(): boolean {
651
- return ERROR_CATEGORIES.client.has(this.type);
1143
+ if (ERROR_CATEGORIES.client.has(this.type)) return true;
1144
+ return this.status !== undefined && this.status >= 400 && this.status < 500;
652
1145
  }
653
1146
 
654
1147
  isNetworkError(): boolean {
@@ -698,6 +1191,9 @@ export function isShipError(error: unknown): error is ShipError {
698
1191
  *
699
1192
  * These are the *platform's* posted caps for the current account — server
700
1193
  * truth delivered at runtime, never hard-coded on the client.
1194
+ *
1195
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
1196
+ * "A report answers a question").
701
1197
  */
702
1198
  export interface PlatformLimits {
703
1199
  /** Maximum size in bytes for a single file. */
@@ -786,6 +1282,128 @@ export function isBlockedExtension(filename: string): boolean {
786
1282
  return BLOCKED_EXTENSIONS.has(ext);
787
1283
  }
788
1284
 
1285
+ // =============================================================================
1286
+ // PICKER ACCEPT HINT
1287
+ // =============================================================================
1288
+
1289
+ /**
1290
+ * The extensions a browser file picker offers by default, grouped by role.
1291
+ *
1292
+ * Private on purpose: the only published form is `WEB_FILE_ACCEPT`, the
1293
+ * attribute value itself. A published set would invite a call site to ask it
1294
+ * whether a file is allowed — which is the one thing this list must never
1295
+ * answer. See `WEB_FILE_ACCEPT`.
1296
+ *
1297
+ * Extensionless files (`LICENSE`, most `.well-known` entries) are inexpressible
1298
+ * in `accept`, and reach a deployment by folder pick, ZIP, or drag-and-drop.
1299
+ */
1300
+ const WEB_FILE_EXTENSIONS = [
1301
+ // Markup & documents
1302
+ 'html',
1303
+ 'htm',
1304
+ 'xhtml',
1305
+ 'xml',
1306
+ 'txt',
1307
+ 'md',
1308
+ 'markdown',
1309
+ 'pdf',
1310
+ 'csv',
1311
+ // Data & config
1312
+ 'json',
1313
+ 'jsonc',
1314
+ 'webmanifest',
1315
+ 'map',
1316
+ 'toml',
1317
+ 'yaml',
1318
+ 'yml',
1319
+ 'rss',
1320
+ 'atom',
1321
+ // Styles
1322
+ 'css',
1323
+ 'scss',
1324
+ 'sass',
1325
+ 'less',
1326
+ // Scripts & modules
1327
+ 'js',
1328
+ 'mjs',
1329
+ 'cjs',
1330
+ 'jsx',
1331
+ 'ts',
1332
+ 'tsx',
1333
+ 'wasm',
1334
+ 'vue',
1335
+ 'svelte',
1336
+ // Images
1337
+ 'png',
1338
+ 'jpg',
1339
+ 'jpeg',
1340
+ 'gif',
1341
+ 'webp',
1342
+ 'avif',
1343
+ 'svg',
1344
+ 'ico',
1345
+ 'bmp',
1346
+ 'tif',
1347
+ 'tiff',
1348
+ 'heic',
1349
+ 'heif',
1350
+ // Fonts
1351
+ 'woff',
1352
+ 'woff2',
1353
+ 'ttf',
1354
+ 'otf',
1355
+ 'eot',
1356
+ // Audio
1357
+ 'mp3',
1358
+ 'wav',
1359
+ 'ogg',
1360
+ 'oga',
1361
+ 'opus',
1362
+ 'm4a',
1363
+ 'aac',
1364
+ 'flac',
1365
+ 'weba',
1366
+ // Video
1367
+ 'mp4',
1368
+ 'webm',
1369
+ 'ogv',
1370
+ 'mov',
1371
+ 'm4v',
1372
+ 'avi',
1373
+ // 3D models
1374
+ 'glb',
1375
+ 'gltf',
1376
+ 'usdz',
1377
+ // Text tracks
1378
+ 'vtt',
1379
+ 'srt',
1380
+ // Archive — a whole site in one file
1381
+ 'zip',
1382
+ ] as const;
1383
+
1384
+ /**
1385
+ * The `accept` attribute value for a browser file picker offering web files.
1386
+ *
1387
+ * **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
1388
+ * gate and the only thing that decides what may be hosted; this constant
1389
+ * decides what a *file dialog* shows first. The two are not two halves of one
1390
+ * policy, and this one must never be consulted to accept or reject a file.
1391
+ *
1392
+ * The distinction is structural, not stylistic. `accept` can express only an
1393
+ * allowlist, while the platform's rule is a blocklist — so this list is
1394
+ * necessarily *narrower* than what the platform hosts, and reading it as
1395
+ * authority would reject files the platform serves happily. It is also not
1396
+ * enforcement in the browser's own terms: every file dialog offers an
1397
+ * all-files escape, and **drag-and-drop ignores `accept` entirely**. The
1398
+ * dropzone and the picker must reach the same verdict on the same files, and
1399
+ * they do — because the verdict is `validateFiles`, downstream of both.
1400
+ *
1401
+ * Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
1402
+ * `tests/validation-constants.test.ts` fence the invariant that matters: the
1403
+ * picker must never offer a file the platform will refuse.
1404
+ */
1405
+ export const WEB_FILE_ACCEPT: string = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
1406
+
789
1407
  // =============================================================================
790
1408
  // FILENAME CHARACTER VALIDATION
791
1409
  // =============================================================================
@@ -848,13 +1466,20 @@ export function hasUnbuiltMarker(filePath: string): boolean {
848
1466
  // =============================================================================
849
1467
 
850
1468
  /**
851
- * Simple ping response for health checks
1469
+ * `GET /ping` a report of the server clock.
1470
+ *
1471
+ * Liveness is the STATUS CODE's answer, not a field's: a 200 means reachable,
1472
+ * and any other outcome throws before a body is read. So the body carries the
1473
+ * one thing a status code cannot — the server's own clock, which is what lets a
1474
+ * client detect skew against a token expiry. It read `{ success: true,
1475
+ * timestamp? }` until 2026-07-29, where `success` was a literal constant in the
1476
+ * route (zero bits, and the platform's own named anti-pattern) while the field
1477
+ * that IS the payload was optional. See {@link DeploymentDeleteResponse} for
1478
+ * the law, and `tests/response-shapes.test.ts` for the fence that holds it.
852
1479
  */
853
1480
  export interface PingResponse {
854
- /** Always true if service is healthy */
855
- success: boolean;
856
1481
  /** Server time in unix seconds — the one wire unit for timestamps. */
857
- timestamp?: number;
1482
+ readonly timestamp: number;
858
1483
  }
859
1484
 
860
1485
  // =============================================================================
@@ -1004,6 +1629,75 @@ export const SPA_DEFAULT_CONFIG = {
1004
1629
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
1005
1630
  } as const;
1006
1631
 
1632
+ /**
1633
+ * The `/spa-check` pre-flight's client-side envelope: which file is the
1634
+ * check's subject, and how large it may be before a client skips the call.
1635
+ *
1636
+ * One fact with three holders until this export — the API's config declared
1637
+ * the cap, the SDK's `checkSPA` hardcoded `100 * 1024`, and prose restated
1638
+ * "100KB". `INDEX_FILE` is the selection rule (the file whose content rides
1639
+ * `SPACheckRequest.index`), restated by every client that builds the request.
1640
+ *
1641
+ * Neither member is a validation boundary: a client over the cap simply
1642
+ * skips the pre-flight, because the server answers an oversized index
1643
+ * `isSPA: false` anyway. A consumer that cannot import this (n8n) needs no
1644
+ * size copy at all — outcome parity is the server's, not the client's.
1645
+ */
1646
+ export const SPA_CHECK_CONSTRAINTS = {
1647
+ /** The file whose content is the check's subject. */
1648
+ INDEX_FILE: 'index.html',
1649
+ /** Skip the pre-flight above this size — the server would answer false. */
1650
+ MAX_INDEX_BYTES: 100 * 1024,
1651
+ } as const;
1652
+
1653
+ /**
1654
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
1655
+ * never schema.
1656
+ *
1657
+ * ship.json is validated and compiled on the server, deliberately: the schema
1658
+ * and the compiler evolve, and a client that judged them would reject configs
1659
+ * a newer platform accepts. That reasoning bounds what a client may check to
1660
+ * the properties which are true of *every* past and future schema:
1661
+ *
1662
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
1663
+ * does not parse can never be a valid config;
1664
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
1665
+ *
1666
+ * Both are monotonic: neither can ever reject something the server would
1667
+ * accept. Everything beyond them (field names, types, rule semantics, which
1668
+ * keys are permitted) stays server-side, where it can change.
1669
+ *
1670
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
1671
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
1672
+ * documentation — mistakes that otherwise cost a full upload round-trip to
1673
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
1674
+ * before parsing rather than rejected, because the server accepts it too;
1675
+ * diverging there would reintroduce exactly the false rejection this
1676
+ * function exists to avoid.
1677
+ *
1678
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
1679
+ * config rejection carries, so the error contract is identical wherever the
1680
+ * failure is detected.
1681
+ */
1682
+ export function assertShipJsonSyntax(text: string): void {
1683
+ const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
1684
+
1685
+ let parsed: unknown;
1686
+ try {
1687
+ parsed = JSON.parse(withoutBom);
1688
+ } catch (error) {
1689
+ throw ShipError.config(`invalid JSON format in config: ${(error as Error).message}`, {
1690
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1691
+ });
1692
+ }
1693
+
1694
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1695
+ throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
1696
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1697
+ });
1698
+ }
1699
+ }
1700
+
1007
1701
  // =============================================================================
1008
1702
  // VALIDATION UTILITIES
1009
1703
  // =============================================================================
@@ -1133,16 +1827,27 @@ export interface SPACheckRequest {
1133
1827
  /**
1134
1828
  * Response from SPA check endpoint
1135
1829
  */
1830
+ /**
1831
+ * Which of the classifier's tiers reached the verdict, and why. Named rather
1832
+ * than inline so the API's own `checkSPA` can return `SPACheckResponse`
1833
+ * instead of restating its shape.
1834
+ */
1835
+ export interface SPACheckDebug {
1836
+ /** Which tier made the detection */
1837
+ tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1838
+ /** The reason for the detection result */
1839
+ reason: string;
1840
+ }
1841
+
1842
+ /**
1843
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
1844
+ * "A report answers a question").
1845
+ */
1136
1846
  export interface SPACheckResponse {
1137
1847
  /** Whether the project is detected as a Single Page Application */
1138
1848
  isSPA: boolean;
1139
1849
  /** Debugging information about detection */
1140
- debug: {
1141
- /** Which tier made the detection: 'exclusions', 'inclusions', 'scoring', 'ai', or 'fallback' */
1142
- tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1143
- /** The reason for the detection result */
1144
- reason: string;
1145
- };
1850
+ debug: SPACheckDebug;
1146
1851
  }
1147
1852
 
1148
1853
  // =============================================================================
@@ -1186,6 +1891,53 @@ export interface StaticFile {
1186
1891
  /** Default API URL if not otherwise configured. */
1187
1892
  export const DEFAULT_API = 'https://api.shipstatic.com';
1188
1893
 
1894
+ /**
1895
+ * The Node SDK's ambient configuration pair — the ONLY environment variables
1896
+ * the SDK reads, and therefore the COMPLETE list an embedding host must
1897
+ * scrub (per `npm/ship`'s strict-isolation contract, scrubbing is the host's
1898
+ * job, not the SDK's). A host that derives its scrub from this object's
1899
+ * values — as the VS Code extension's child-process env block does — picks
1900
+ * up a grown contract at the next pin bump instead of by remembered prose.
1901
+ *
1902
+ * Browser builds read no environment at all, and the CLI-only variables
1903
+ * (`SHIP_PASSWORD`, `SHIP_VIA`) are deliberately NOT here: they are the
1904
+ * CLI's operational levers, not the SDK's ambient contract — see
1905
+ * `npm/ship/CLAUDE.md`, "CLI-only env vars".
1906
+ */
1907
+ export const SHIP_ENV = {
1908
+ /** The one credential slot — any platform token. */
1909
+ TOKEN: 'SHIP_TOKEN',
1910
+ /** The API endpoint override. */
1911
+ API_URL: 'SHIP_API_URL',
1912
+ } as const;
1913
+
1914
+ /**
1915
+ * Where a human creates an API key — the console deep link quoted by every
1916
+ * surface that teaches authentication (the CLI's config wizard, the VS Code
1917
+ * and n8n listings, the n8n rate-limit hint and credential copy). Written
1918
+ * out in five files across three repos until this export.
1919
+ *
1920
+ * Production-branded by design: published artifacts name the product, never
1921
+ * an environment (root `CLAUDE.md`, "Environment-Aware URLs").
1922
+ */
1923
+ export const MY_API_KEY_URL = 'https://my.shipstatic.com/api-key';
1924
+
1925
+ /**
1926
+ * How long an anonymous deployment lives before it expires.
1927
+ *
1928
+ * The lifetime of the public tier, and one fact with several readers. The API
1929
+ * stamps a deployment's `expires` from it and gives a claim code exactly the
1930
+ * same window — a live site with a dead claim link is a coherence bug, so the
1931
+ * two are one constant rather than two that agree. Both MCP transports quote
1932
+ * the duration in prose an agent reads, and derive it from here rather than
1933
+ * writing it out, which they did in eight places until this export existed.
1934
+ *
1935
+ * Seconds, spelled in the name: this platform has both second- and
1936
+ * millisecond-valued durations, and the pair is only safe when each says which
1937
+ * it is.
1938
+ */
1939
+ export const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;
1940
+
1189
1941
  // =============================================================================
1190
1942
  // RESOURCE INTERFACE CONTRACTS
1191
1943
  // =============================================================================
@@ -1209,8 +1961,12 @@ export type DeployInput = File[] | string | string[];
1209
1961
  export interface DeploymentUploadOptions {
1210
1962
  /** Optional labels for categorization and filtering */
1211
1963
  labels?: string[];
1212
- /** Client identifier (e.g., 'cli', 'sdk', 'web') */
1213
- via?: string;
1964
+ /**
1965
+ * Which client is making this deploy. Closed, because the server silently
1966
+ * ignores anything outside the set — so an unchecked string turned a typo
1967
+ * into missing analytics rather than an error. See {@link DeploymentVia}.
1968
+ */
1969
+ via?: DeploymentViaType;
1214
1970
  /**
1215
1971
  * Optional password that protects this deployment.
1216
1972
  *
@@ -1230,13 +1986,42 @@ export interface DeploymentUploadOptions {
1230
1986
  spa?: boolean;
1231
1987
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
1232
1988
  captcha?: string;
1989
+ /**
1990
+ * Makes this deploy replayable instead of repeatable.
1991
+ *
1992
+ * A deploy is not naturally idempotent: a client-side timeout on a slow
1993
+ * one leaves the caller unable to tell "it never landed" from "it landed
1994
+ * and the response was lost", and retrying produces a second deployment.
1995
+ * Send the same key on the retry and the platform replays the original
1996
+ * 201 verbatim rather than creating anything
1997
+ * ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
1998
+ *
1999
+ * **Agents are the audience.** A human notices a duplicate; an automated
2000
+ * retry does not. Pick a key that identifies the ATTEMPT — a run id, a
2001
+ * commit sha, a uuid minted before the first try — never one minted fresh
2002
+ * on each retry, which would defeat the point.
2003
+ *
2004
+ * The replay is per-caller, and it stores successes only: a failed deploy
2005
+ * retries fresh under the same key.
2006
+ */
2007
+ idempotencyKey?: string;
1233
2008
  }
1234
2009
 
1235
2010
  /**
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.
2011
+ * Pagination options for every list endpoint. The response's `cursor` feeds
2012
+ * the next request; a `null` cursor means the last page. Omitting both
2013
+ * returns the server's default first page.
2014
+ *
2015
+ * A list answers `{ <collection>, cursor }` and nothing else — `cursor`
2016
+ * carries the entire has-more signal, so no redundant boolean, and no
2017
+ * `total`. **A count is an aggregate over a collection, not a property of a
2018
+ * page:** including one makes every read pay for a full scan it did not ask
2019
+ * for, which is precisely the cost keyset pagination exists to avoid.
2020
+ *
2021
+ * Counts therefore live on the summary resource that owns them —
2022
+ * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
2023
+ * platform-wide ones. Ask for a count when you want a count; ask for a page
2024
+ * when you want a page.
1240
2025
  */
1241
2026
  export interface ListOptions {
1242
2027
  /** Maximum number of items to return in one page. */
@@ -1245,6 +2030,36 @@ export interface ListOptions {
1245
2030
  cursor?: string;
1246
2031
  }
1247
2032
 
2033
+ /**
2034
+ * What a caller may change on an existing deployment.
2035
+ *
2036
+ * Labels and nothing else: a deployment's content is immutable by design, so
2037
+ * this is the whole mutable surface rather than a subset someone chose.
2038
+ */
2039
+ export interface DeploymentSetOptions {
2040
+ labels: string[];
2041
+ }
2042
+
2043
+ /**
2044
+ * What `domains.set()` may create or change. Every field is optional because
2045
+ * the call is a natural-key upsert: omitting `deployment` reserves the
2046
+ * domain, naming one links or re-points it, and labels travel either way.
2047
+ *
2048
+ * `deployment` is deliberately not nullable — unlinking is refused (400).
2049
+ * See `npm/ship/CLAUDE.md`, "Domain Write Semantics".
2050
+ */
2051
+ export interface DomainSetOptions {
2052
+ deployment?: string;
2053
+ labels?: string[];
2054
+ }
2055
+
2056
+ /** What a caller may set when minting a deploy token. */
2057
+ export interface TokenCreateOptions {
2058
+ /** Seconds until expiry; omit for a token that never expires. */
2059
+ ttl?: number;
2060
+ labels?: string[];
2061
+ }
2062
+
1248
2063
  /**
1249
2064
  * Deployment resource interface - the contract all implementations must follow.
1250
2065
  *
@@ -1259,26 +2074,23 @@ export interface DeploymentResource<
1259
2074
  upload: (input: DeployInput, options?: UploadOptions) => Promise<DeploymentCreateResponse>;
1260
2075
  list: (options?: ListOptions) => Promise<DeploymentListResponse>;
1261
2076
  get: (id: string) => Promise<Deployment>;
1262
- set: (id: string, options: { labels: string[] }) => Promise<Deployment>;
1263
- remove: (id: string) => Promise<void>;
2077
+ set: (id: string, options: DeploymentSetOptions) => Promise<Deployment>;
2078
+ delete: (id: string) => Promise<DeploymentDeleteResponse>;
1264
2079
  }
1265
2080
 
1266
2081
  /**
1267
2082
  * Domain resource interface - the contract all implementations must follow
1268
2083
  */
1269
2084
  export interface DomainResource {
1270
- set: (
1271
- name: string,
1272
- options?: { deployment?: string; labels?: string[] },
1273
- ) => Promise<DomainSetResult>;
2085
+ set: (name: string, options?: DomainSetOptions) => Promise<DomainSetResult>;
1274
2086
  list: (options?: ListOptions) => Promise<DomainListResponse>;
1275
2087
  get: (name: string) => Promise<Domain>;
1276
- remove: (name: string) => Promise<void>;
1277
- verify: (name: string) => Promise<{ message: string }>;
2088
+ delete: (name: string) => Promise<DomainDeleteResponse>;
2089
+ verify: (name: string) => Promise<DomainVerifyResponse>;
1278
2090
  validate: (name: string) => Promise<DomainValidateResponse>;
1279
2091
  dns: (name: string) => Promise<DomainDnsResponse>;
1280
2092
  records: (name: string) => Promise<DomainRecordsResponse>;
1281
- share: (name: string) => Promise<{ domain: string; hash: string }>;
2093
+ share: (name: string) => Promise<DomainShareResponse>;
1282
2094
  }
1283
2095
 
1284
2096
  /**
@@ -1292,9 +2104,10 @@ export interface AccountResource {
1292
2104
  * Token resource interface - the contract all implementations must follow
1293
2105
  */
1294
2106
  export interface TokenResource {
1295
- create: (options?: { ttl?: number; labels?: string[] }) => Promise<TokenCreateResponse>;
1296
- list: () => Promise<TokenListResponse>;
1297
- remove: (token: string) => Promise<void>;
2107
+ create: (options?: TokenCreateOptions) => Promise<TokenCreateResponse>;
2108
+ list: (options?: ListOptions) => Promise<TokenListResponse>;
2109
+ get: (token: string) => Promise<Token>;
2110
+ delete: (token: string) => Promise<TokenDeleteResponse>;
1298
2111
  }
1299
2112
 
1300
2113
  // =============================================================================
@@ -1320,6 +2133,26 @@ export interface BillingStatus {
1320
2133
  portal: string | null;
1321
2134
  }
1322
2135
 
2136
+ /**
2137
+ * Acknowledgement of `POST /billing/cancel`.
2138
+ *
2139
+ * Cancelling leaves no billing entity to return, so it answers with the
2140
+ * account and the one field of the account the call changed — the plan it
2141
+ * landed on. See {@link DeploymentDeleteResponse} for the law.
2142
+ *
2143
+ * This read `{ success: true, message: 'Subscription canceled successfully…' }`
2144
+ * until 2026-07-29, an anonymous shape that `web/my` redeclared inline and
2145
+ * whose prose no surface ever displayed: both callers await the promise and
2146
+ * discard the body, then compose their own toast. The message was written,
2147
+ * serialized, and thrown away on every cancellation.
2148
+ */
2149
+ export interface BillingCancelResponse {
2150
+ /** The account whose subscription was cancelled */
2151
+ readonly account: string;
2152
+ /** The plan the account now holds — `free` on a successful cancellation */
2153
+ readonly plan: AccountPlanType;
2154
+ }
2155
+
1323
2156
  /**
1324
2157
  * Checkout session response from POST /billing/checkout
1325
2158
  */
@@ -1477,7 +2310,7 @@ export interface ActivityMeta {
1477
2310
  /**
1478
2311
  * Response from GET /activities endpoint
1479
2312
  */
1480
- export interface ActivityListResponse {
2313
+ export interface ActivityListResponse extends ListResponse {
1481
2314
  /** Array of activities */
1482
2315
  activities: Activity[];
1483
2316
  }