@shipstatic/types 2.5.0-beta.8 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  // =============================================================================
7
- // I. CORE ENTITIES
7
+ // DEPLOYMENT TYPES
8
8
  // =============================================================================
9
9
 
10
10
  /**
@@ -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,14 +90,91 @@ export interface DeploymentCreateResponse extends Deployment {
58
90
  readonly claim?: string;
59
91
  }
60
92
 
93
+ /**
94
+ * The half of a list response that is identical on every list.
95
+ *
96
+ * `GET /<collection>` answers exactly two fields — the collection under its
97
+ * own plural noun, and this cursor — so the cursor is declared once here and
98
+ * each response below adds only its noun. `cursor: null` means last page and
99
+ * is the ENTIRE has-more signal, which is why there is no `has_more`.
100
+ *
101
+ * There is deliberately no `total`. A count is an aggregate over a
102
+ * collection, not a property of a page; producing one would cost a COUNT
103
+ * beside every page read, which is precisely what keyset pagination exists
104
+ * to avoid. Counts live on the resource that summarises the collection —
105
+ * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
106
+ */
107
+ export interface ListResponse {
108
+ /** Opaque cursor from this page; `null` on the last page. */
109
+ cursor: string | null;
110
+ }
111
+
112
+ /**
113
+ * Pagination options for every list endpoint. The response's `cursor` feeds
114
+ * the next request; a `null` cursor means the last page. Omitting both
115
+ * returns the server's default first page.
116
+ *
117
+ * A list answers `{ <collection>, cursor }` and nothing else — `cursor`
118
+ * carries the entire has-more signal, so no redundant boolean, and no
119
+ * `total`. **A count is an aggregate over a collection, not a property of a
120
+ * page:** including one makes every read pay for a full scan it did not ask
121
+ * for, which is precisely the cost keyset pagination exists to avoid.
122
+ *
123
+ * Counts therefore live on the summary resource that owns them —
124
+ * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
125
+ * platform-wide ones. Ask for a count when you want a count; ask for a page
126
+ * when you want a page.
127
+ */
128
+ export interface ListOptions {
129
+ /** Maximum number of items to return in one page. */
130
+ limit?: number;
131
+ /** Opaque cursor from the previous page's response. */
132
+ cursor?: string;
133
+ }
134
+
61
135
  /**
62
136
  * Response for listing deployments
63
137
  */
64
- export interface DeploymentListResponse {
138
+ export interface DeploymentListResponse extends ListResponse {
65
139
  /** Array of deployments */
66
140
  deployments: Deployment[];
67
- /** Cursor for pagination, null if no more pages */
68
- cursor: string | null;
141
+ }
142
+
143
+ /**
144
+ * Acknowledgement of `DELETE /deployments/:deployment` — and the shape every
145
+ * mutation with no entity left to return follows.
146
+ *
147
+ * **The law:** a mutation answers with the resource it affected. If the
148
+ * resource still exists, that means the entity itself (`Deployment`,
149
+ * `Domain`, …). Otherwise it means this: the resource noun carrying the
150
+ * item's canonical key, plus the resource's own state field — and ONLY when
151
+ * the resource survived in a transitional state, as an async deletion's does.
152
+ * Where the resource is simply gone, the key alone is the whole answer
153
+ * ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
154
+ *
155
+ * Put positively: **an acknowledgement is a projection of the resource** —
156
+ * its key, plus its own state field where the state changed. That is the
157
+ * test to apply, and it is sharper than "no constant", which this shape
158
+ * would fail on its own terms: `status` here is the literal `'deleting'` on
159
+ * every success, exactly as fixed as a `changed: true` would be.
160
+ *
161
+ * The difference is not how predictable the value is, it is what the field
162
+ * IS. `status` is the deployment's own field — the same one `GET
163
+ * /deployments/:deployment` returns — so this response is `Deployment`
164
+ * narrowed to two members, and a client renders it with the code it already
165
+ * has. `changed: true`, `queued: true` and `success: true` are not fields of
166
+ * any entity; they exist only to assert that the call worked, which the
167
+ * status code already said. Sync versus accepted is likewise the status
168
+ * code's job — 200 versus 202 — not a boolean's.
169
+ *
170
+ * No prose either (`message`): an acknowledgement is data, and each surface
171
+ * composes its own copy.
172
+ */
173
+ export interface DeploymentDeleteResponse {
174
+ /** The deployment hostname that was marked for removal */
175
+ readonly deployment: string;
176
+ /** The state the deployment is in while background cleanup runs */
177
+ readonly status: DeploymentStatusType;
69
178
  }
70
179
 
71
180
  // =============================================================================
@@ -105,7 +214,7 @@ export interface Domain {
105
214
  labels: string[];
106
215
  /** Unix timestamp (seconds) when domain was created */
107
216
  readonly created: number;
108
- /** When deployment was last linked (Unix timestamp), null if never linked */
217
+ /** Unix timestamp (seconds) when deployment was last linked, null if never linked */
109
218
  linked: number | null;
110
219
  /** Total deployment links */
111
220
  links: number;
@@ -129,11 +238,30 @@ export interface DomainSetResult extends Domain {
129
238
  /**
130
239
  * Response for listing domains
131
240
  */
132
- export interface DomainListResponse {
241
+ export interface DomainListResponse extends ListResponse {
133
242
  /** Array of domains */
134
243
  domains: Domain[];
135
- /** Cursor for pagination, null if no more pages */
136
- cursor: string | null;
244
+ }
245
+
246
+ /**
247
+ * Acknowledgement of `DELETE /domains/:domain`. The row is gone, so there is
248
+ * no state to state — the canonical domain name is the whole answer. See
249
+ * {@link DeploymentDeleteResponse} for the law.
250
+ */
251
+ export interface DomainDeleteResponse {
252
+ /** The domain name that was removed, normalized */
253
+ readonly domain: string;
254
+ }
255
+
256
+ /**
257
+ * Acknowledgement of `POST /domains/:domain/verify` (202). The DNS check is
258
+ * queued, not performed — the accepted status code says so, and the domain's
259
+ * own status is unchanged until the check runs, which is why none is stated
260
+ * here. See {@link DeploymentDeleteResponse} for the law.
261
+ */
262
+ export interface DomainVerifyResponse {
263
+ /** The domain whose DNS verification was queued, normalized */
264
+ readonly domain: string;
137
265
  }
138
266
 
139
267
  /**
@@ -164,15 +292,49 @@ export interface DnsProvider {
164
292
  /**
165
293
  * Response for domain DNS provider lookup
166
294
  */
295
+ /**
296
+ * What a DNS lookup found for a domain. An envelope rather than a bare
297
+ * {@link DnsProvider} because a lookup can succeed and learn more than the
298
+ * provider later; the shape is named so a consumer can hold one.
299
+ */
300
+ export interface DnsLookup {
301
+ /** The provider serving this domain's DNS, absent when unidentified */
302
+ provider?: DnsProvider;
303
+ }
304
+
305
+ /**
306
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
307
+ * "A report answers a question").
308
+ */
167
309
  export interface DomainDnsResponse {
168
310
  /** The domain name */
169
311
  domain: string;
170
312
  /** DNS provider information, null if not yet looked up */
171
- dns: { provider?: DnsProvider } | null;
313
+ dns: DnsLookup | null;
314
+ }
315
+
316
+ /**
317
+ * Response for `GET /domains/:domain/share` — the domain plus the salted
318
+ * hash that lets someone else complete its DNS setup without an account.
319
+ *
320
+ * `/admin/domains/:domain/share` answers the same shape, which is the admin
321
+ * law working: the operator surface is the public grammar with a prefix.
322
+ *
323
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
324
+ * "A report answers a question").
325
+ */
326
+ export interface DomainShareResponse {
327
+ /** The domain the setup link is for */
328
+ readonly domain: string;
329
+ /** The salted setup hash that authorizes the share */
330
+ readonly hash: string;
172
331
  }
173
332
 
174
333
  /**
175
334
  * Response for domain DNS records
335
+ *
336
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
337
+ * "A report answers a question").
176
338
  */
177
339
  export interface DomainRecordsResponse {
178
340
  /** The domain name */
@@ -184,7 +346,118 @@ export interface DomainRecordsResponse {
184
346
  }
185
347
 
186
348
  /**
187
- * Response for domain validation
349
+ * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
350
+ *
351
+ * Format lives here rather than on the server alone by the format-vs-policy
352
+ * rule: a client can decide offline whether a key is well-formed, and the
353
+ * API would reject the same value the same way.
354
+ */
355
+ export const IDEMPOTENCY_KEY_CONSTRAINTS = {
356
+ /**
357
+ * HTTP header name. Here for the same reason {@link CALLER.HEADER} is: a
358
+ * wire header has two ends, and the package that owns the value's format
359
+ * is the only place both ends can read its name from.
360
+ */
361
+ HEADER: 'Idempotency-Key',
362
+ MAX_LENGTH: 256,
363
+ /** How long a stored 201 stays replayable. */
364
+ WINDOW_SECONDS: 24 * 60 * 60,
365
+ } as const;
366
+
367
+ /**
368
+ * Normalize a `via` value from any transport — trimmed, lowercased, and a
369
+ * member of {@link DeploymentVia}, or `undefined`.
370
+ *
371
+ * A format rule by this package's own test: a client can decide offline
372
+ * whether a value is well-formed, and the API reaches the same verdict on the
373
+ * same input. It lived server-side until 2026-08-06, which meant clients could
374
+ * only learn their label was unusable by noticing analytics had gone quiet.
375
+ *
376
+ * **Not knowing your `via` is not an error** — an unrecognized value yields
377
+ * `undefined` rather than throwing, because origin tracking is telemetry and a
378
+ * deploy must never fail over it. A caller that has an honest default should
379
+ * prefer it (`normalizeVia(process.env.SHIP_VIA) ?? DeploymentVia.CLI`): the
380
+ * deploy really did come from the CLI, so recording that beats recording
381
+ * nothing.
382
+ */
383
+ export function normalizeVia(value: unknown): DeploymentViaType | undefined {
384
+ if (!value || typeof value !== 'string') return undefined;
385
+ const via = value.trim().toLowerCase();
386
+ return (Object.values(DeploymentVia) as string[]).includes(via)
387
+ ? (via as DeploymentViaType)
388
+ : undefined;
389
+ }
390
+
391
+ /**
392
+ * Validate an idempotency key, returning the trimmed value or `undefined`
393
+ * when none was supplied. Throws {@link ShipError.validation} when the value
394
+ * cannot be sent — the same verdict the API would reach, reached earlier.
395
+ */
396
+ export function validateIdempotencyKey(value: unknown): string | undefined {
397
+ if (value === undefined || value === null) return undefined;
398
+ if (typeof value !== 'string') {
399
+ throw ShipError.validation('Idempotency key must be a string.');
400
+ }
401
+ const key = value.trim();
402
+ if (!key) {
403
+ throw ShipError.validation('Idempotency key must not be empty.');
404
+ }
405
+ if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {
406
+ throw ShipError.validation(
407
+ `Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`,
408
+ );
409
+ }
410
+ return key;
411
+ }
412
+
413
+ /**
414
+ * Response for `GET /labels` — every label in use across the caller's
415
+ * deployments, domains and tokens, grouped and ordered by last use.
416
+ *
417
+ * The one plural noun outside the list contract, deliberately: labels have
418
+ * no identity, no row and no `created`, so there is nothing for a keyset
419
+ * cursor to resume after, and its consumer is an autocomplete that wants the
420
+ * whole set. Bounded by `PAGINATION.GLOBAL_LIMIT` rather than paginated.
421
+ *
422
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
423
+ * "A report answers a question").
424
+ */
425
+ export interface LabelsResponse {
426
+ readonly labels: string[];
427
+ }
428
+
429
+ /**
430
+ * Response for `POST /setup` — the DNS instructions for one domain, written
431
+ * for a human to follow at their registrar.
432
+ *
433
+ * `custom` is the provider-specific walkthrough when the provider is known;
434
+ * `generic` always answers, so a caller never has nothing to show.
435
+ *
436
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
437
+ * "A report answers a question").
438
+ */
439
+ export interface SetupInstructionsResponse {
440
+ /** The domain the instructions are for — a report names its subject */
441
+ readonly domain: string;
442
+ /** One-line summary of what to do */
443
+ readonly tldr: string;
444
+ /** Provider-specific instructions, null when the provider is unknown */
445
+ readonly custom: string | null;
446
+ /** Provider-agnostic instructions — always present */
447
+ readonly generic: string;
448
+ /** The identified DNS provider, null when unknown */
449
+ readonly provider: string | null;
450
+ }
451
+
452
+ /**
453
+ * `POST /domains/validate` — a report answering "is this name usable, and if
454
+ * not, why".
455
+ *
456
+ * An unusable name is a legitimate ANSWER, not a failure, so this is a 200 and
457
+ * the verdict rides the body. `reason` was named `error` until 2026-07-29,
458
+ * which collided with {@link ErrorResponse}'s reserved key — there `error` is
459
+ * an `ErrorType` a client branches on, here it is prose a client displays, and
460
+ * one key cannot mean both. See {@link DeploymentDeleteResponse} for the law.
188
461
  */
189
462
  export interface DomainValidateResponse {
190
463
  /** Whether the domain is valid */
@@ -193,8 +466,8 @@ export interface DomainValidateResponse {
193
466
  normalized: string | null;
194
467
  /** Whether the domain is available, null when invalid */
195
468
  available: boolean | null;
196
- /** Error message, null when valid */
197
- error: string | null;
469
+ /** Why the name is unusable, null when valid — displayed verbatim. */
470
+ reason: string | null;
198
471
  }
199
472
 
200
473
  // =============================================================================
@@ -202,11 +475,13 @@ export interface DomainValidateResponse {
202
475
  // =============================================================================
203
476
 
204
477
  /**
205
- * Token as returned by the list endpoint.
206
- * The secret is shown once at creation and never again — listings carry
207
- * only the management identifier and lifecycle metadata.
478
+ * Core deploy token object - used in both API responses and SDK.
479
+ *
480
+ * The secret is never here: it is shown once at creation
481
+ * ({@link TokenCreateResponse.secret}) and never again, so an entity read
482
+ * carries only the management identifier and lifecycle metadata.
208
483
  */
209
- export interface TokenListItem {
484
+ export interface Token {
210
485
  /** 7-char management identifier (e.g., "a1b2c3d") */
211
486
  readonly token: string;
212
487
  /** Labels for categorization and filtering. Always present, empty array when none. */
@@ -222,25 +497,30 @@ export interface TokenListItem {
222
497
  /**
223
498
  * Response for listing tokens
224
499
  */
225
- export interface TokenListResponse {
226
- /** Array of tokens (security-redacted for list display) */
227
- tokens: TokenListItem[];
228
- /** Cursor for pagination, null if no more pages */
229
- cursor: string | null;
500
+ export interface TokenListResponse extends ListResponse {
501
+ /** Array of tokens (the secret is never among them) */
502
+ tokens: Token[];
230
503
  }
231
504
 
232
505
  /**
233
- * Response for token creation
506
+ * Response from token creation. Extends Token with the one field that
507
+ * exists only on creation — the same shape as
508
+ * {@link DeploymentCreateResponse}, because a 201 returns the resource it
509
+ * created plus whatever is knowable only once.
234
510
  */
235
- export interface TokenCreateResponse {
236
- /** 7-char management identifier */
237
- token: string;
511
+ export interface TokenCreateResponse extends Token {
238
512
  /** The raw credential value (shown once at creation, then never again) */
239
- secret: string;
240
- /** Labels for categorization and filtering. Always present, empty array when none. */
241
- labels: string[];
242
- /** Unix timestamp (seconds) when token expires, null for never */
243
- expires: number | null;
513
+ readonly secret: string;
514
+ }
515
+
516
+ /**
517
+ * Acknowledgement of `DELETE /tokens/:token`. The credential is revoked and
518
+ * its row is gone, so the management identifier is the whole answer. See
519
+ * {@link DeploymentDeleteResponse} for the law.
520
+ */
521
+ export interface TokenDeleteResponse {
522
+ /** The 7-char management identifier that was revoked */
523
+ readonly token: string;
244
524
  }
245
525
 
246
526
  // =============================================================================
@@ -341,6 +621,37 @@ export interface AccountGetResponse extends Account {
341
621
  readonly impersonatedBy?: string;
342
622
  }
343
623
 
624
+ /**
625
+ * Acknowledgement of `DELETE /account` (202). Termination is asynchronous —
626
+ * a cleanup consumer finishes the job — so the account survives long enough
627
+ * to state the plan it is transitioning through. `plan` is the account's
628
+ * state field, the way `status` is a deployment's. See
629
+ * {@link DeploymentDeleteResponse} for the law.
630
+ */
631
+ export interface AccountDeleteResponse {
632
+ /** The account that was marked for termination */
633
+ readonly account: string;
634
+ /** The plan the account is in while cleanup runs */
635
+ readonly plan: AccountPlanType;
636
+ }
637
+
638
+ /**
639
+ * Response from `PUT /account/key` — the account's single API key, minted in
640
+ * place of whatever was there before.
641
+ *
642
+ * There is no entity to return: only the key's last-4 `hint` is durable
643
+ * (`Account.hint`), and the plaintext exists exactly once, in this response.
644
+ * The raw credential is `secret` on every surface that mints one — the same
645
+ * field `TokenCreateResponse` carries — because one concept gets one name.
646
+ *
647
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
648
+ * "A report answers a question").
649
+ */
650
+ export interface AccountKeyResponse {
651
+ /** The raw API key (shown once at mint, then never again) */
652
+ readonly secret: string;
653
+ }
654
+
344
655
  /**
345
656
  * Account-specific configuration overrides
346
657
  * Allows per-account customization of limits without changing plan
@@ -358,6 +669,103 @@ export interface AccountOverrides {
358
669
  totalSize?: number;
359
670
  }
360
671
 
672
+ // =============================================================================
673
+ // WIRE SURFACE
674
+ // =============================================================================
675
+
676
+ /**
677
+ * Every path the public API answers on, declared once.
678
+ *
679
+ * The URL surface was written out in four places — the API's mounts, the
680
+ * SDK's client, the dashboard's client, and the post-deploy smoke — so a
681
+ * rename meant finding all four. The first three now read this table.
682
+ *
683
+ * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
684
+ * five of its nine paths are `/admin/*`, which this table excludes by
685
+ * design, and splitting one list between a registry and literals reads worse
686
+ * than keeping it uniform.
687
+ *
688
+ * **What this guarantees, exactly.** Collection paths are mounted from here,
689
+ * so producer and consumer cannot diverge. Item paths are declared here and
690
+ * consumed by clients, but the API spells them relative to their mount
691
+ * (`/:deployment/config`), so the table does not *generate* them — it is
692
+ * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
693
+ * any entry names a path no route answers. Some entries have no client yet
694
+ * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
695
+ * deliberately does not reach); the fence is what keeps those honest rather
696
+ * than merely asserted.
697
+ *
698
+ * **The operator surface is deliberately absent.** `/admin/*` paths belong
699
+ * to `web/my`, for the same reason its row types do: this package is
700
+ * published, and the operator surface is not public (see `CLAUDE.md`, "Admin
701
+ * types"). A path here is a promise to every npm consumer; `/admin` is a
702
+ * promise to one dashboard.
703
+ *
704
+ * Item paths are functions rather than templates so the key is interpolated
705
+ * in one place, encoded the same way by every caller.
706
+ */
707
+ export const API_PATHS = {
708
+ DEPLOYMENTS: '/deployments',
709
+ DEPLOYMENT: (deployment: string) => `/deployments/${deployment}`,
710
+ DEPLOYMENT_CONFIG: (deployment: string) => `/deployments/${deployment}/config`,
711
+ DOMAINS: '/domains',
712
+ DOMAIN: (domain: string) => `/domains/${domain}`,
713
+ DOMAIN_VERIFY: (domain: string) => `/domains/${domain}/verify`,
714
+ DOMAIN_DNS: (domain: string) => `/domains/${domain}/dns`,
715
+ DOMAIN_RECORDS: (domain: string) => `/domains/${domain}/records`,
716
+ DOMAIN_SHARE: (domain: string) => `/domains/${domain}/share`,
717
+ DOMAIN_PROPAGATION: (domain: string) => `/domains/${domain}/propagation`,
718
+ DOMAINS_VALIDATE: '/domains/validate',
719
+ TOKENS: '/tokens',
720
+ TOKEN: (token: string) => `/tokens/${token}`,
721
+ ACCOUNT: '/account',
722
+ ACCOUNT_KEY: '/account/key',
723
+ ACCOUNT_CLAIM: '/account/claim',
724
+ ACTIVITIES: '/activities',
725
+ LABELS: '/labels',
726
+ LIMITS: '/limits',
727
+ PING: '/ping',
728
+ SETUP: '/setup',
729
+ SPA_CHECK: '/spa-check',
730
+ UPLOAD: '/upload',
731
+ } as const;
732
+
733
+ /**
734
+ * The deploy request's multipart field names — the other half of the wire
735
+ * surface beside {@link API_PATHS}. `POST /deployments` (and the first-party
736
+ * `/upload`) is multipart/form-data, and these are the names the API reads.
737
+ *
738
+ * Declared once because the body has three independent WRITERS — the SDK's
739
+ * Node and browser body builders, and the n8n community node's hand-rolled
740
+ * client (which cannot import this under n8n Cloud's zero-dependency rule,
741
+ * and fences its restated copy instead) — and until this export every writer
742
+ * restated the strings the API parses, with nothing comparing them.
743
+ *
744
+ * `FILES` carries one entry per file (the API reads it with `getAll`); every
745
+ * other field is single. The `@internal` flags are serialized as the literal
746
+ * string `'true'` and belong to first-party surfaces only.
747
+ */
748
+ export const DEPLOY_FIELDS = {
749
+ /** One entry per file — read with `getAll`. */
750
+ FILES: 'files[]',
751
+ /** JSON array of MD5 hex digests, index-aligned with `FILES`. */
752
+ CHECKSUMS: 'checksums',
753
+ /** JSON array of label strings. */
754
+ LABELS: 'labels',
755
+ /** The deploying surface's {@link DeploymentVia} member. */
756
+ VIA: 'via',
757
+ /** Plaintext password — the API hashes it server-side. */
758
+ PASSWORD: 'password',
759
+ /** @internal Server-processing flag — first-party `/upload` only. */
760
+ BUILD: 'build',
761
+ /** @internal Server-processing flag — first-party `/upload` only. */
762
+ PRERENDER: 'prerender',
763
+ /** @internal Server-processing flag — first-party `/upload` only. */
764
+ SPA: 'spa',
765
+ /** @internal reCAPTCHA proof — `web/www`'s public uploader only. */
766
+ CAPTCHA: 'captcha',
767
+ } as const;
768
+
361
769
  // =============================================================================
362
770
  // ERROR SYSTEM
363
771
  // =============================================================================
@@ -372,7 +780,15 @@ export interface AccountOverrides {
372
780
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
373
781
  */
374
782
  export const ErrorType = {
375
- /** Validation failed (400). Input shape is wrong. */
783
+ /**
784
+ * Validation failed. Input shape is wrong.
785
+ *
786
+ * Carries 400 when an API judged it — including a client-side pre-check of a
787
+ * rule the server enforces too, which keeps the error identical wherever it
788
+ * was caught. **Statusless** when a client rejects something no API judges,
789
+ * such as a CLI's own command grammar: `status` is documented "(API
790
+ * contexts)" on `ErrorResponse`, so there is none to report.
791
+ */
376
792
  Validation: 'validation_failed',
377
793
  /** Resource not found (404). */
378
794
  NotFound: 'not_found',
@@ -386,6 +802,17 @@ export const ErrorType = {
386
802
  Business: 'business_logic_error',
387
803
  /** API server error (500). Generic server-side fault. */
388
804
  Api: 'internal_server_error',
805
+ /**
806
+ * The platform is closed for maintenance (503). A deliberate operator
807
+ * state, not a fault — nothing errored; the API is refusing work on
808
+ * purpose, and deployed sites keep serving throughout.
809
+ *
810
+ * Distinct from `Api` at 503, which the platform already uses for a
811
+ * dependency that failed (moderation unavailable). A consumer has to tell
812
+ * "we closed the door" from "something broke": the two get opposite words
813
+ * and opposite retry behaviour.
814
+ */
815
+ Maintenance: 'maintenance',
389
816
  /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
390
817
  Network: 'network_error',
391
818
  /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
@@ -419,12 +846,19 @@ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
419
846
  const ERROR_CATEGORIES = {
420
847
  /**
421
848
  * Client-attributable types. Exhaustive over the 4xx-carrying types, and
422
- * it must include the statusless ones (`Config`, `File`) — those are
423
- * raised locally by the SDK and have no status for `isClientError`'s
424
- * second arm to read.
849
+ * over the statusless ones too — those are raised locally and have no
850
+ * status for `isClientError`'s second arm to read, so omitting one makes it
851
+ * read as a server fault. The rule is the membership test: every type in
852
+ * `CLIENT_ONLY_ERROR_TYPES` except `Network` (which `isNetworkError` owns)
853
+ * belongs here.
854
+ *
855
+ * `Cancelled` was missing until 2026-07-29, which is exactly that failure:
856
+ * a caller who aborted their own deploy was told "server error: please try
857
+ * again" — the CLI's fallback for everything this set does not claim.
425
858
  */
426
859
  client: new Set<ErrorType>([
427
860
  ErrorType.Business,
861
+ ErrorType.Cancelled,
428
862
  ErrorType.Config,
429
863
  ErrorType.File,
430
864
  ErrorType.Forbidden,
@@ -447,6 +881,51 @@ const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
447
881
  Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)),
448
882
  );
449
883
 
884
+ /**
885
+ * Ceiling on a message adopted from a **non-JSON** error body — a foreign
886
+ * responder's, never this platform's. Generous for the plain-text one-liners
887
+ * intermediaries actually send (`error code: 1015`), far below a document.
888
+ * Our own messages are never measured against it: a JSON body is the API's
889
+ * contract, and truncating a long validation message would be the bug.
890
+ */
891
+ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
892
+
893
+ /**
894
+ * Did the runtime say the exchange never completed?
895
+ *
896
+ * WHATWG has `fetch` reject with a **TypeError** on network error, and undici,
897
+ * Chromium and Firefox comply. Bun does not: it rejects with a plain `Error`
898
+ * carrying a system `code` string. Captured 2026-08-05 (the capture script is
899
+ * in `tests/errors.test.ts`, "runtime failure shapes"):
900
+ *
901
+ * | failure | Node 22 / undici | Bun 1.3.14 |
902
+ * |---------------|---------------------------|----------------------------------------------|
903
+ * | refused | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
904
+ * | DNS failure | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
905
+ * | reset | `TypeError: fetch failed` | `Error` `code: 'ECONNRESET'` |
906
+ * | TLS rejected | `TypeError: fetch failed` | `Error` `code: 'UNKNOWN_CERTIFICATE_…ERROR'` |
907
+ *
908
+ * So the test is the **evidence, not a list of dialect strings**: a string
909
+ * `code` is a runtime naming a transport-level failure. An allowlist of codes
910
+ * was written first and rejected — the TLS row alone would mean enumerating
911
+ * BoringSSL's certificate table, and a code nobody guessed is precisely the bug
912
+ * this closes. Two kinds of error are deliberately NOT caught: ordinary JS
913
+ * faults carry no `code` at all, and a `DOMException`'s is a **number**, so
914
+ * aborts and timeouts fall through to their own arms.
915
+ *
916
+ * The accepted trade: a caller's `TokenProvider` that throws a coded error
917
+ * (`ENOENT` from a keychain read) is typed `Network` rather than `Api`. Both
918
+ * are wrong for it, `Network` is the cheaper wrong — it says "nothing was
919
+ * exchanged", which is true, where `Api` claims a server answered.
920
+ */
921
+ function isTransportFailure(cause: Error): boolean {
922
+ if (typeof (cause as { code?: unknown }).code === 'string') return true;
923
+ // Spec runtimes put no code on the rejection itself. The message test is what
924
+ // keeps fetch's ARGUMENT errors out — `Failed to parse URL from …` is a
925
+ // caller's config mistake, not a transport failure.
926
+ return cause instanceof TypeError && cause.message.includes('fetch');
927
+ }
928
+
450
929
  /**
451
930
  * Standard error response format used everywhere
452
931
  */
@@ -533,8 +1012,17 @@ export class ShipError extends Error {
533
1012
  }
534
1013
  }
535
1014
  } else {
536
- const text = await response.text();
537
- if (text) message = text;
1015
+ // A non-JSON body did not come from this platform — every API error
1016
+ // is `ErrorResponse` JSON — so it is an intermediary's output, and
1017
+ // the two kinds it produces need opposite treatment. A CDN's plain
1018
+ // `error code: 1015` is the most useful thing there is to say. A
1019
+ // proxy's HTML error page is a *document*, not a message: adopting it
1020
+ // verbatim made a misconfigured `apiUrl` print 2,059 characters of
1021
+ // markup as the error. Trust it only when it reads as a message.
1022
+ const text = (await response.text()).trim();
1023
+ if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {
1024
+ message = text;
1025
+ }
538
1026
  }
539
1027
  } catch {
540
1028
  // Body unreadable; fall through to operationName-derived message.
@@ -584,7 +1072,8 @@ export class ShipError extends Error {
584
1072
  * Routing:
585
1073
  * - Already a `ShipError` → returned as-is (caller's intent preserved)
586
1074
  * - `AbortError` → `ShipError.cancelled(...)`
587
- * - `TypeError` whose message mentions "fetch" → `ShipError.network(...)`
1075
+ * - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
1076
+ * for what each runtime offers as evidence
588
1077
  * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
589
1078
  * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
590
1079
  *
@@ -601,7 +1090,7 @@ export class ShipError extends Error {
601
1090
  if (cause.name === 'AbortError') {
602
1091
  return ShipError.cancelled(`${op} was cancelled`);
603
1092
  }
604
- if (cause instanceof TypeError && cause.message.includes('fetch')) {
1093
+ if (isTransportFailure(cause)) {
605
1094
  return ShipError.network(`${op} failed: ${cause.message}`, { cause });
606
1095
  }
607
1096
  return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
@@ -673,6 +1162,19 @@ export class ShipError extends Error {
673
1162
  return new ShipError(ErrorType.Api, message, status, details);
674
1163
  }
675
1164
 
1165
+ /**
1166
+ * The platform is closed for maintenance (503).
1167
+ *
1168
+ * `message` is REQUIRED and has no default here. The API is the only
1169
+ * producer of that sentence, and a default in this file would be a second
1170
+ * owner of one fact — see CLAUDE.md, "The Constellation Law" (stopping
1171
+ * rule). It is also the one factory whose status is fixed rather than
1172
+ * defaulted: a maintenance refusal is 503 or it is not this error.
1173
+ */
1174
+ static maintenance(message: string, details?: unknown): ShipError {
1175
+ return new ShipError(ErrorType.Maintenance, message, 503, details);
1176
+ }
1177
+
676
1178
  // Semantic-category guards. For specific-type checks, use
677
1179
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
678
1180
 
@@ -728,7 +1230,7 @@ export function isShipError(error: unknown): error is ShipError {
728
1230
  }
729
1231
 
730
1232
  // =============================================================================
731
- // CONFIG TYPES
1233
+ // PLATFORM LIMITS
732
1234
  // =============================================================================
733
1235
 
734
1236
  /**
@@ -740,6 +1242,9 @@ export function isShipError(error: unknown): error is ShipError {
740
1242
  *
741
1243
  * These are the *platform's* posted caps for the current account — server
742
1244
  * truth delivered at runtime, never hard-coded on the client.
1245
+ *
1246
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
1247
+ * "A report answers a question").
743
1248
  */
744
1249
  export interface PlatformLimits {
745
1250
  /** Maximum size in bytes for a single file. */
@@ -828,6 +1333,128 @@ export function isBlockedExtension(filename: string): boolean {
828
1333
  return BLOCKED_EXTENSIONS.has(ext);
829
1334
  }
830
1335
 
1336
+ // =============================================================================
1337
+ // PICKER ACCEPT HINT
1338
+ // =============================================================================
1339
+
1340
+ /**
1341
+ * The extensions a browser file picker offers by default, grouped by role.
1342
+ *
1343
+ * Private on purpose: the only published form is `WEB_FILE_ACCEPT`, the
1344
+ * attribute value itself. A published set would invite a call site to ask it
1345
+ * whether a file is allowed — which is the one thing this list must never
1346
+ * answer. See `WEB_FILE_ACCEPT`.
1347
+ *
1348
+ * Extensionless files (`LICENSE`, most `.well-known` entries) are inexpressible
1349
+ * in `accept`, and reach a deployment by folder pick, ZIP, or drag-and-drop.
1350
+ */
1351
+ const WEB_FILE_EXTENSIONS = [
1352
+ // Markup & documents
1353
+ 'html',
1354
+ 'htm',
1355
+ 'xhtml',
1356
+ 'xml',
1357
+ 'txt',
1358
+ 'md',
1359
+ 'markdown',
1360
+ 'pdf',
1361
+ 'csv',
1362
+ // Data & config
1363
+ 'json',
1364
+ 'jsonc',
1365
+ 'webmanifest',
1366
+ 'map',
1367
+ 'toml',
1368
+ 'yaml',
1369
+ 'yml',
1370
+ 'rss',
1371
+ 'atom',
1372
+ // Styles
1373
+ 'css',
1374
+ 'scss',
1375
+ 'sass',
1376
+ 'less',
1377
+ // Scripts & modules
1378
+ 'js',
1379
+ 'mjs',
1380
+ 'cjs',
1381
+ 'jsx',
1382
+ 'ts',
1383
+ 'tsx',
1384
+ 'wasm',
1385
+ 'vue',
1386
+ 'svelte',
1387
+ // Images
1388
+ 'png',
1389
+ 'jpg',
1390
+ 'jpeg',
1391
+ 'gif',
1392
+ 'webp',
1393
+ 'avif',
1394
+ 'svg',
1395
+ 'ico',
1396
+ 'bmp',
1397
+ 'tif',
1398
+ 'tiff',
1399
+ 'heic',
1400
+ 'heif',
1401
+ // Fonts
1402
+ 'woff',
1403
+ 'woff2',
1404
+ 'ttf',
1405
+ 'otf',
1406
+ 'eot',
1407
+ // Audio
1408
+ 'mp3',
1409
+ 'wav',
1410
+ 'ogg',
1411
+ 'oga',
1412
+ 'opus',
1413
+ 'm4a',
1414
+ 'aac',
1415
+ 'flac',
1416
+ 'weba',
1417
+ // Video
1418
+ 'mp4',
1419
+ 'webm',
1420
+ 'ogv',
1421
+ 'mov',
1422
+ 'm4v',
1423
+ 'avi',
1424
+ // 3D models
1425
+ 'glb',
1426
+ 'gltf',
1427
+ 'usdz',
1428
+ // Text tracks
1429
+ 'vtt',
1430
+ 'srt',
1431
+ // Archive — a whole site in one file
1432
+ 'zip',
1433
+ ] as const;
1434
+
1435
+ /**
1436
+ * The `accept` attribute value for a browser file picker offering web files.
1437
+ *
1438
+ * **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
1439
+ * gate and the only thing that decides what may be hosted; this constant
1440
+ * decides what a *file dialog* shows first. The two are not two halves of one
1441
+ * policy, and this one must never be consulted to accept or reject a file.
1442
+ *
1443
+ * The distinction is structural, not stylistic. `accept` can express only an
1444
+ * allowlist, while the platform's rule is a blocklist — so this list is
1445
+ * necessarily *narrower* than what the platform hosts, and reading it as
1446
+ * authority would reject files the platform serves happily. It is also not
1447
+ * enforcement in the browser's own terms: every file dialog offers an
1448
+ * all-files escape, and **drag-and-drop ignores `accept` entirely**. The
1449
+ * dropzone and the picker must reach the same verdict on the same files, and
1450
+ * they do — because the verdict is `validateFiles`, downstream of both.
1451
+ *
1452
+ * Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
1453
+ * `tests/validation-constants.test.ts` fence the invariant that matters: the
1454
+ * picker must never offer a file the platform will refuse.
1455
+ */
1456
+ export const WEB_FILE_ACCEPT: string = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
1457
+
831
1458
  // =============================================================================
832
1459
  // FILENAME CHARACTER VALIDATION
833
1460
  // =============================================================================
@@ -890,13 +1517,20 @@ export function hasUnbuiltMarker(filePath: string): boolean {
890
1517
  // =============================================================================
891
1518
 
892
1519
  /**
893
- * Simple ping response for health checks
1520
+ * `GET /ping` a report of the server clock.
1521
+ *
1522
+ * Liveness is the STATUS CODE's answer, not a field's: a 200 means reachable,
1523
+ * and any other outcome throws before a body is read. So the body carries the
1524
+ * one thing a status code cannot — the server's own clock, which is what lets a
1525
+ * client detect skew against a token expiry. It read `{ success: true,
1526
+ * timestamp? }` until 2026-07-29, where `success` was a literal constant in the
1527
+ * route (zero bits, and the platform's own named anti-pattern) while the field
1528
+ * that IS the payload was optional. See {@link DeploymentDeleteResponse} for
1529
+ * the law, and `tests/response-shapes.test.ts` for the fence that holds it.
894
1530
  */
895
1531
  export interface PingResponse {
896
- /** Always true if service is healthy */
897
- success: boolean;
898
1532
  /** Server time in unix seconds — the one wire unit for timestamps. */
899
- timestamp?: number;
1533
+ readonly timestamp: number;
900
1534
  }
901
1535
 
902
1536
  // =============================================================================
@@ -1046,6 +1680,27 @@ export const SPA_DEFAULT_CONFIG = {
1046
1680
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
1047
1681
  } as const;
1048
1682
 
1683
+ /**
1684
+ * The `/spa-check` pre-flight's client-side envelope: which file is the
1685
+ * check's subject, and how large it may be before a client skips the call.
1686
+ *
1687
+ * One fact with three holders until this export — the API's config declared
1688
+ * the cap, the SDK's `checkSPA` hardcoded `100 * 1024`, and prose restated
1689
+ * "100KB". `INDEX_FILE` is the selection rule (the file whose content rides
1690
+ * `SPACheckRequest.index`), restated by every client that builds the request.
1691
+ *
1692
+ * Neither member is a validation boundary: a client over the cap simply
1693
+ * skips the pre-flight, because the server answers an oversized index
1694
+ * `isSPA: false` anyway. A consumer that cannot import this (n8n) needs no
1695
+ * size copy at all — outcome parity is the server's, not the client's.
1696
+ */
1697
+ export const SPA_CHECK_CONSTRAINTS = {
1698
+ /** The file whose content is the check's subject. */
1699
+ INDEX_FILE: 'index.html',
1700
+ /** Skip the pre-flight above this size — the server would answer false. */
1701
+ MAX_INDEX_BYTES: 100 * 1024,
1702
+ } as const;
1703
+
1049
1704
  /**
1050
1705
  * Assert that a ship.json file is *syntactically* loadable. Syntax only —
1051
1706
  * never schema.
@@ -1223,16 +1878,27 @@ export interface SPACheckRequest {
1223
1878
  /**
1224
1879
  * Response from SPA check endpoint
1225
1880
  */
1881
+ /**
1882
+ * Which of the classifier's tiers reached the verdict, and why. Named rather
1883
+ * than inline so the API's own `checkSPA` can return `SPACheckResponse`
1884
+ * instead of restating its shape.
1885
+ */
1886
+ export interface SPACheckDebug {
1887
+ /** Which tier made the detection */
1888
+ tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1889
+ /** The reason for the detection result */
1890
+ reason: string;
1891
+ }
1892
+
1893
+ /**
1894
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
1895
+ * "A report answers a question").
1896
+ */
1226
1897
  export interface SPACheckResponse {
1227
1898
  /** Whether the project is detected as a Single Page Application */
1228
1899
  isSPA: boolean;
1229
1900
  /** Debugging information about detection */
1230
- debug: {
1231
- /** Which tier made the detection: 'exclusions', 'inclusions', 'scoring', 'ai', or 'fallback' */
1232
- tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1233
- /** The reason for the detection result */
1234
- reason: string;
1235
- };
1901
+ debug: SPACheckDebug;
1236
1902
  }
1237
1903
 
1238
1904
  // =============================================================================
@@ -1276,6 +1942,53 @@ export interface StaticFile {
1276
1942
  /** Default API URL if not otherwise configured. */
1277
1943
  export const DEFAULT_API = 'https://api.shipstatic.com';
1278
1944
 
1945
+ /**
1946
+ * The Node SDK's ambient configuration pair — the ONLY environment variables
1947
+ * the SDK reads, and therefore the COMPLETE list an embedding host must
1948
+ * scrub (per `npm/ship`'s strict-isolation contract, scrubbing is the host's
1949
+ * job, not the SDK's). A host that derives its scrub from this object's
1950
+ * values — as the VS Code extension's child-process env block does — picks
1951
+ * up a grown contract at the next pin bump instead of by remembered prose.
1952
+ *
1953
+ * Browser builds read no environment at all, and the CLI-only variables
1954
+ * (`SHIP_PASSWORD`, `SHIP_VIA`) are deliberately NOT here: they are the
1955
+ * CLI's operational levers, not the SDK's ambient contract — see
1956
+ * `npm/ship/CLAUDE.md`, "CLI-only env vars".
1957
+ */
1958
+ export const SHIP_ENV = {
1959
+ /** The one credential slot — any platform token. */
1960
+ TOKEN: 'SHIP_TOKEN',
1961
+ /** The API endpoint override. */
1962
+ API_URL: 'SHIP_API_URL',
1963
+ } as const;
1964
+
1965
+ /**
1966
+ * Where a human creates an API key — the console deep link quoted by every
1967
+ * surface that teaches authentication (the CLI's config wizard, the VS Code
1968
+ * and n8n listings, the n8n rate-limit hint and credential copy). Written
1969
+ * out in five files across three repos until this export.
1970
+ *
1971
+ * Production-branded by design: published artifacts name the product, never
1972
+ * an environment (root `CLAUDE.md`, "Environment-Aware URLs").
1973
+ */
1974
+ export const MY_API_KEY_URL = 'https://my.shipstatic.com/api-key';
1975
+
1976
+ /**
1977
+ * How long an anonymous deployment lives before it expires.
1978
+ *
1979
+ * The lifetime of the public tier, and one fact with several readers. The API
1980
+ * stamps a deployment's `expires` from it and gives a claim code exactly the
1981
+ * same window — a live site with a dead claim link is a coherence bug, so the
1982
+ * two are one constant rather than two that agree. Both MCP transports quote
1983
+ * the duration in prose an agent reads, and derive it from here rather than
1984
+ * writing it out, which they did in eight places until this export existed.
1985
+ *
1986
+ * Seconds, spelled in the name: this platform has both second- and
1987
+ * millisecond-valued durations, and the pair is only safe when each says which
1988
+ * it is.
1989
+ */
1990
+ export const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;
1991
+
1279
1992
  // =============================================================================
1280
1993
  // RESOURCE INTERFACE CONTRACTS
1281
1994
  // =============================================================================
@@ -1299,8 +2012,12 @@ export type DeployInput = File[] | string | string[];
1299
2012
  export interface DeploymentUploadOptions {
1300
2013
  /** Optional labels for categorization and filtering */
1301
2014
  labels?: string[];
1302
- /** Client identifier (e.g., 'cli', 'sdk', 'web') */
1303
- via?: string;
2015
+ /**
2016
+ * Which client is making this deploy. Closed, because the server silently
2017
+ * ignores anything outside the set — so an unchecked string turned a typo
2018
+ * into missing analytics rather than an error. See {@link DeploymentVia}.
2019
+ */
2020
+ via?: DeploymentViaType;
1304
2021
  /**
1305
2022
  * Optional password that protects this deployment.
1306
2023
  *
@@ -1320,29 +2037,55 @@ export interface DeploymentUploadOptions {
1320
2037
  spa?: boolean;
1321
2038
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
1322
2039
  captcha?: string;
2040
+ /**
2041
+ * Makes this deploy replayable instead of repeatable.
2042
+ *
2043
+ * A deploy is not naturally idempotent: a client-side timeout on a slow
2044
+ * one leaves the caller unable to tell "it never landed" from "it landed
2045
+ * and the response was lost", and retrying produces a second deployment.
2046
+ * Send the same key on the retry and the platform replays the original
2047
+ * 201 verbatim rather than creating anything
2048
+ * ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
2049
+ *
2050
+ * **Agents are the audience.** A human notices a duplicate; an automated
2051
+ * retry does not. Pick a key that identifies the ATTEMPT — a run id, a
2052
+ * commit sha, a uuid minted before the first try — never one minted fresh
2053
+ * on each retry, which would defeat the point.
2054
+ *
2055
+ * The replay is per-caller, and it stores successes only: a failed deploy
2056
+ * retries fresh under the same key.
2057
+ */
2058
+ idempotencyKey?: string;
1323
2059
  }
1324
2060
 
1325
2061
  /**
1326
- * Pagination options for every list endpoint. The response's `cursor` feeds
1327
- * the next request; a `null` cursor means the last page. Omitting both
1328
- * returns the server's default first page.
2062
+ * What a caller may change on an existing deployment.
1329
2063
  *
1330
- * A list answers `{ <collection>, cursor }` and nothing else `cursor`
1331
- * carries the entire has-more signal, so no redundant boolean, and no
1332
- * `total`. **A count is an aggregate over a collection, not a property of a
1333
- * page:** including one makes every read pay for a full scan it did not ask
1334
- * for, which is precisely the cost keyset pagination exists to avoid.
2064
+ * Labels and nothing else: a deployment's content is immutable by design, so
2065
+ * this is the whole mutable surface rather than a subset someone chose.
2066
+ */
2067
+ export interface DeploymentSetOptions {
2068
+ labels: string[];
2069
+ }
2070
+
2071
+ /**
2072
+ * What `domains.set()` may create or change. Every field is optional because
2073
+ * the call is a natural-key upsert: omitting `deployment` reserves the
2074
+ * domain, naming one links or re-points it, and labels travel either way.
1335
2075
  *
1336
- * Counts therefore live on the summary resource that owns them —
1337
- * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
1338
- * platform-wide ones. Ask for a count when you want a count; ask for a page
1339
- * when you want a page.
2076
+ * `deployment` is deliberately not nullable unlinking is refused (400).
2077
+ * See `npm/ship/CLAUDE.md`, "Domain Write Semantics".
1340
2078
  */
1341
- export interface ListOptions {
1342
- /** Maximum number of items to return in one page. */
1343
- limit?: number;
1344
- /** Opaque cursor from the previous page's response. */
1345
- cursor?: string;
2079
+ export interface DomainSetOptions {
2080
+ deployment?: string;
2081
+ labels?: string[];
2082
+ }
2083
+
2084
+ /** What a caller may set when minting a deploy token. */
2085
+ export interface TokenCreateOptions {
2086
+ /** Seconds until expiry; omit for a token that never expires. */
2087
+ ttl?: number;
2088
+ labels?: string[];
1346
2089
  }
1347
2090
 
1348
2091
  /**
@@ -1359,26 +2102,23 @@ export interface DeploymentResource<
1359
2102
  upload: (input: DeployInput, options?: UploadOptions) => Promise<DeploymentCreateResponse>;
1360
2103
  list: (options?: ListOptions) => Promise<DeploymentListResponse>;
1361
2104
  get: (id: string) => Promise<Deployment>;
1362
- set: (id: string, options: { labels: string[] }) => Promise<Deployment>;
1363
- remove: (id: string) => Promise<void>;
2105
+ set: (id: string, options: DeploymentSetOptions) => Promise<Deployment>;
2106
+ delete: (id: string) => Promise<DeploymentDeleteResponse>;
1364
2107
  }
1365
2108
 
1366
2109
  /**
1367
2110
  * Domain resource interface - the contract all implementations must follow
1368
2111
  */
1369
2112
  export interface DomainResource {
1370
- set: (
1371
- name: string,
1372
- options?: { deployment?: string; labels?: string[] },
1373
- ) => Promise<DomainSetResult>;
2113
+ set: (name: string, options?: DomainSetOptions) => Promise<DomainSetResult>;
1374
2114
  list: (options?: ListOptions) => Promise<DomainListResponse>;
1375
2115
  get: (name: string) => Promise<Domain>;
1376
- remove: (name: string) => Promise<void>;
1377
- verify: (name: string) => Promise<{ message: string }>;
2116
+ delete: (name: string) => Promise<DomainDeleteResponse>;
2117
+ verify: (name: string) => Promise<DomainVerifyResponse>;
1378
2118
  validate: (name: string) => Promise<DomainValidateResponse>;
1379
2119
  dns: (name: string) => Promise<DomainDnsResponse>;
1380
2120
  records: (name: string) => Promise<DomainRecordsResponse>;
1381
- share: (name: string) => Promise<{ domain: string; hash: string }>;
2121
+ share: (name: string) => Promise<DomainShareResponse>;
1382
2122
  }
1383
2123
 
1384
2124
  /**
@@ -1392,9 +2132,10 @@ export interface AccountResource {
1392
2132
  * Token resource interface - the contract all implementations must follow
1393
2133
  */
1394
2134
  export interface TokenResource {
1395
- create: (options?: { ttl?: number; labels?: string[] }) => Promise<TokenCreateResponse>;
2135
+ create: (options?: TokenCreateOptions) => Promise<TokenCreateResponse>;
1396
2136
  list: (options?: ListOptions) => Promise<TokenListResponse>;
1397
- remove: (token: string) => Promise<void>;
2137
+ get: (token: string) => Promise<Token>;
2138
+ delete: (token: string) => Promise<TokenDeleteResponse>;
1398
2139
  }
1399
2140
 
1400
2141
  // =============================================================================
@@ -1420,6 +2161,26 @@ export interface BillingStatus {
1420
2161
  portal: string | null;
1421
2162
  }
1422
2163
 
2164
+ /**
2165
+ * Acknowledgement of `POST /billing/cancel`.
2166
+ *
2167
+ * Cancelling leaves no billing entity to return, so it answers with the
2168
+ * account and the one field of the account the call changed — the plan it
2169
+ * landed on. See {@link DeploymentDeleteResponse} for the law.
2170
+ *
2171
+ * This read `{ success: true, message: 'Subscription canceled successfully…' }`
2172
+ * until 2026-07-29, an anonymous shape that `web/my` redeclared inline and
2173
+ * whose prose no surface ever displayed: both callers await the promise and
2174
+ * discard the body, then compose their own toast. The message was written,
2175
+ * serialized, and thrown away on every cancellation.
2176
+ */
2177
+ export interface BillingCancelResponse {
2178
+ /** The account whose subscription was cancelled */
2179
+ readonly account: string;
2180
+ /** The plan the account now holds — `free` on a successful cancellation */
2181
+ readonly plan: AccountPlanType;
2182
+ }
2183
+
1423
2184
  /**
1424
2185
  * Checkout session response from POST /billing/checkout
1425
2186
  */
@@ -1577,11 +2338,9 @@ export interface ActivityMeta {
1577
2338
  /**
1578
2339
  * Response from GET /activities endpoint
1579
2340
  */
1580
- export interface ActivityListResponse {
2341
+ export interface ActivityListResponse extends ListResponse {
1581
2342
  /** Array of activities */
1582
2343
  activities: Activity[];
1583
- /** Cursor for pagination, null if no more pages */
1584
- cursor: string | null;
1585
2344
  }
1586
2345
 
1587
2346
  // =============================================================================