@shipstatic/types 2.5.0-beta.9 → 2.6.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;
@@ -71,15 +103,35 @@ export interface DeploymentCreateResponse extends Deployment {
71
103
  * beside every page read, which is precisely what keyset pagination exists
72
104
  * to avoid. Counts live on the resource that summarises the collection —
73
105
  * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
74
- *
75
- * The operator lists (`/admin/*`) answer this same shape behind the prefix;
76
- * their types live in `web/my`, not here — see `CLAUDE.md`, "Admin types".
77
106
  */
78
107
  export interface ListResponse {
79
108
  /** Opaque cursor from this page; `null` on the last page. */
80
109
  cursor: string | null;
81
110
  }
82
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
+
83
135
  /**
84
136
  * Response for listing deployments
85
137
  */
@@ -88,6 +140,43 @@ export interface DeploymentListResponse extends ListResponse {
88
140
  deployments: Deployment[];
89
141
  }
90
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;
178
+ }
179
+
91
180
  // =============================================================================
92
181
  // DOMAIN TYPES
93
182
  // =============================================================================
@@ -154,6 +243,27 @@ export interface DomainListResponse extends ListResponse {
154
243
  domains: Domain[];
155
244
  }
156
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;
265
+ }
266
+
157
267
  /**
158
268
  * DNS record types supported for domain configuration
159
269
  */
@@ -182,15 +292,49 @@ export interface DnsProvider {
182
292
  /**
183
293
  * Response for domain DNS provider lookup
184
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
+ */
185
309
  export interface DomainDnsResponse {
186
310
  /** The domain name */
187
311
  domain: string;
188
312
  /** DNS provider information, null if not yet looked up */
189
- 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;
190
331
  }
191
332
 
192
333
  /**
193
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").
194
338
  */
195
339
  export interface DomainRecordsResponse {
196
340
  /** The domain name */
@@ -202,7 +346,118 @@ export interface DomainRecordsResponse {
202
346
  }
203
347
 
204
348
  /**
205
- * 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.
206
461
  */
207
462
  export interface DomainValidateResponse {
208
463
  /** Whether the domain is valid */
@@ -211,8 +466,8 @@ export interface DomainValidateResponse {
211
466
  normalized: string | null;
212
467
  /** Whether the domain is available, null when invalid */
213
468
  available: boolean | null;
214
- /** Error message, null when valid */
215
- error: string | null;
469
+ /** Why the name is unusable, null when valid — displayed verbatim. */
470
+ reason: string | null;
216
471
  }
217
472
 
218
473
  // =============================================================================
@@ -222,13 +477,7 @@ export interface DomainValidateResponse {
222
477
  /**
223
478
  * Core deploy token object - used in both API responses and SDK.
224
479
  *
225
- * A single noun, like every other entity here: the platform's unit types are
226
- * `Deployment`, `Domain`, `Account`, `Activity` and this. It was once called
227
- * `TokenListItem`, named for the surface that returned it rather than for
228
- * what it is, which is exactly why {@link TokenCreateResponse} used to
229
- * restate its fields instead of extending it.
230
- *
231
- * The token itself is never here. The secret is shown once at creation
480
+ * The secret is never here: it is shown once at creation
232
481
  * ({@link TokenCreateResponse.secret}) and never again, so an entity read
233
482
  * carries only the management identifier and lifecycle metadata.
234
483
  */
@@ -264,6 +513,16 @@ export interface TokenCreateResponse extends Token {
264
513
  readonly secret: string;
265
514
  }
266
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;
524
+ }
525
+
267
526
  // =============================================================================
268
527
  // ACCOUNT TYPES
269
528
  // =============================================================================
@@ -362,6 +621,37 @@ export interface AccountGetResponse extends Account {
362
621
  readonly impersonatedBy?: string;
363
622
  }
364
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
+
365
655
  /**
366
656
  * Account-specific configuration overrides
367
657
  * Allows per-account customization of limits without changing plan
@@ -379,6 +669,103 @@ export interface AccountOverrides {
379
669
  totalSize?: number;
380
670
  }
381
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
+
382
769
  // =============================================================================
383
770
  // ERROR SYSTEM
384
771
  // =============================================================================
@@ -393,7 +780,15 @@ export interface AccountOverrides {
393
780
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
394
781
  */
395
782
  export const ErrorType = {
396
- /** 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
+ */
397
792
  Validation: 'validation_failed',
398
793
  /** Resource not found (404). */
399
794
  NotFound: 'not_found',
@@ -407,6 +802,17 @@ export const ErrorType = {
407
802
  Business: 'business_logic_error',
408
803
  /** API server error (500). Generic server-side fault. */
409
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',
410
816
  /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
411
817
  Network: 'network_error',
412
818
  /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
@@ -440,12 +846,19 @@ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
440
846
  const ERROR_CATEGORIES = {
441
847
  /**
442
848
  * Client-attributable types. Exhaustive over the 4xx-carrying types, and
443
- * it must include the statusless ones (`Config`, `File`) — those are
444
- * raised locally by the SDK and have no status for `isClientError`'s
445
- * 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.
446
858
  */
447
859
  client: new Set<ErrorType>([
448
860
  ErrorType.Business,
861
+ ErrorType.Cancelled,
449
862
  ErrorType.Config,
450
863
  ErrorType.File,
451
864
  ErrorType.Forbidden,
@@ -468,6 +881,51 @@ const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
468
881
  Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)),
469
882
  );
470
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
+
471
929
  /**
472
930
  * Standard error response format used everywhere
473
931
  */
@@ -554,8 +1012,17 @@ export class ShipError extends Error {
554
1012
  }
555
1013
  }
556
1014
  } else {
557
- const text = await response.text();
558
- 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
+ }
559
1026
  }
560
1027
  } catch {
561
1028
  // Body unreadable; fall through to operationName-derived message.
@@ -605,7 +1072,8 @@ export class ShipError extends Error {
605
1072
  * Routing:
606
1073
  * - Already a `ShipError` → returned as-is (caller's intent preserved)
607
1074
  * - `AbortError` → `ShipError.cancelled(...)`
608
- * - `TypeError` whose message mentions "fetch" → `ShipError.network(...)`
1075
+ * - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
1076
+ * for what each runtime offers as evidence
609
1077
  * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
610
1078
  * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
611
1079
  *
@@ -622,7 +1090,7 @@ export class ShipError extends Error {
622
1090
  if (cause.name === 'AbortError') {
623
1091
  return ShipError.cancelled(`${op} was cancelled`);
624
1092
  }
625
- if (cause instanceof TypeError && cause.message.includes('fetch')) {
1093
+ if (isTransportFailure(cause)) {
626
1094
  return ShipError.network(`${op} failed: ${cause.message}`, { cause });
627
1095
  }
628
1096
  return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
@@ -694,6 +1162,19 @@ export class ShipError extends Error {
694
1162
  return new ShipError(ErrorType.Api, message, status, details);
695
1163
  }
696
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
+
697
1178
  // Semantic-category guards. For specific-type checks, use
698
1179
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
699
1180
 
@@ -749,7 +1230,7 @@ export function isShipError(error: unknown): error is ShipError {
749
1230
  }
750
1231
 
751
1232
  // =============================================================================
752
- // CONFIG TYPES
1233
+ // PLATFORM LIMITS
753
1234
  // =============================================================================
754
1235
 
755
1236
  /**
@@ -761,6 +1242,9 @@ export function isShipError(error: unknown): error is ShipError {
761
1242
  *
762
1243
  * These are the *platform's* posted caps for the current account — server
763
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").
764
1248
  */
765
1249
  export interface PlatformLimits {
766
1250
  /** Maximum size in bytes for a single file. */
@@ -849,6 +1333,128 @@ export function isBlockedExtension(filename: string): boolean {
849
1333
  return BLOCKED_EXTENSIONS.has(ext);
850
1334
  }
851
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
+
852
1458
  // =============================================================================
853
1459
  // FILENAME CHARACTER VALIDATION
854
1460
  // =============================================================================
@@ -911,13 +1517,20 @@ export function hasUnbuiltMarker(filePath: string): boolean {
911
1517
  // =============================================================================
912
1518
 
913
1519
  /**
914
- * 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.
915
1530
  */
916
1531
  export interface PingResponse {
917
- /** Always true if service is healthy */
918
- success: boolean;
919
1532
  /** Server time in unix seconds — the one wire unit for timestamps. */
920
- timestamp?: number;
1533
+ readonly timestamp: number;
921
1534
  }
922
1535
 
923
1536
  // =============================================================================
@@ -928,6 +1541,28 @@ export interface PingResponse {
928
1541
  // that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN, CALLER),
929
1542
  // the single dispatch over them (TokenKind, classifyToken), and the
930
1543
  // delegated-access scopes (OAuthScope).
1544
+ //
1545
+ // THE SHAPE LAW, in three clauses. Every secret the platform mints obeys it,
1546
+ // and `tests/validation-constants.test.ts` holds all three mechanically.
1547
+ //
1548
+ // 1. ONE ENTROPY STANDARD. Every minted random secret is `HEX_LENGTH` hex
1549
+ // characters — one width for the whole platform, so "how long is a
1550
+ // credential" has a single answer rather than one per population.
1551
+ //
1552
+ // 2. A PREFIX MARKS A SHARED SLOT, AND NOTHING ELSE. API keys and deploy
1553
+ // tokens both arrive as `Authorization: Bearer`, so something must say
1554
+ // which population a value belongs to: that is what the prefix IS, and
1555
+ // `classifyToken` below is its only reader. Secrets that arrive somewhere
1556
+ // unambiguous carry none — the deployment claim code reaches its own
1557
+ // route in its own field, inside a URL whose path already says `/claim/`,
1558
+ // so a prefix there would be a second name for what the route states.
1559
+ //
1560
+ // 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
1561
+ // order-independent, and it is the reason the populations are named on
1562
+ // different axes (`ship-` for the product, `deploy-` for the capability)
1563
+ // rather than sharing a stem. A `ship-` / `ship-deploy-` pair reads tidier
1564
+ // and is a trap: every deploy token would also match the API-key branch,
1565
+ // leaving correctness resting on the order of two `if`s.
931
1566
 
932
1567
  /**
933
1568
  * Where human identity is mounted on the API host. The API mounts Better
@@ -960,31 +1595,36 @@ export const AuthMethod = {
960
1595
  export type AuthMethodType = (typeof AuthMethod)[keyof typeof AuthMethod];
961
1596
 
962
1597
  /**
963
- * Shape constants for API keys (`ship-{64 hex chars}`).
1598
+ * Shape constants for API keys (`ship-{32 hex chars}`).
964
1599
  * Single source of truth used by validation utilities and auth middleware.
965
1600
  */
966
1601
  export const API_KEY = {
967
1602
  /** Prefix that identifies an API key. */
968
1603
  PREFIX: 'ship-',
969
1604
  /** Number of hex characters following the prefix. */
970
- HEX_LENGTH: 64,
971
- /** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 69`). */
972
- TOTAL_LENGTH: 69,
1605
+ HEX_LENGTH: 32,
1606
+ /** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 37`). */
1607
+ TOTAL_LENGTH: 37,
973
1608
  /** Number of trailing characters used to display a redacted hint (e.g. last 4). */
974
1609
  HINT_LENGTH: 4,
975
1610
  } as const;
976
1611
 
977
1612
  /**
978
- * Shape constants for deploy tokens (`deploy-{64 hex chars}`).
1613
+ * Shape constants for deploy tokens (`deploy-{32 hex chars}`).
979
1614
  * Single source of truth used by validation utilities and auth middleware.
1615
+ *
1616
+ * Deliberately the same width as `API_KEY`: both are minted by one generator
1617
+ * and classified by prefix alone, so a length that differed between them
1618
+ * would be a second thing to know about a credential whose prefix already
1619
+ * says what it is.
980
1620
  */
981
1621
  export const DEPLOY_TOKEN = {
982
1622
  /** Prefix that identifies a deploy token. */
983
1623
  PREFIX: 'deploy-',
984
1624
  /** Number of hex characters following the prefix. */
985
- HEX_LENGTH: 64,
986
- /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 71`). */
987
- TOTAL_LENGTH: 71,
1625
+ HEX_LENGTH: 32,
1626
+ /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */
1627
+ TOTAL_LENGTH: 39,
988
1628
  } as const;
989
1629
 
990
1630
  /**
@@ -1067,6 +1707,27 @@ export const SPA_DEFAULT_CONFIG = {
1067
1707
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
1068
1708
  } as const;
1069
1709
 
1710
+ /**
1711
+ * The `/spa-check` pre-flight's client-side envelope: which file is the
1712
+ * check's subject, and how large it may be before a client skips the call.
1713
+ *
1714
+ * One fact with three holders until this export — the API's config declared
1715
+ * the cap, the SDK's `checkSPA` hardcoded `100 * 1024`, and prose restated
1716
+ * "100KB". `INDEX_FILE` is the selection rule (the file whose content rides
1717
+ * `SPACheckRequest.index`), restated by every client that builds the request.
1718
+ *
1719
+ * Neither member is a validation boundary: a client over the cap simply
1720
+ * skips the pre-flight, because the server answers an oversized index
1721
+ * `isSPA: false` anyway. A consumer that cannot import this (n8n) needs no
1722
+ * size copy at all — outcome parity is the server's, not the client's.
1723
+ */
1724
+ export const SPA_CHECK_CONSTRAINTS = {
1725
+ /** The file whose content is the check's subject. */
1726
+ INDEX_FILE: 'index.html',
1727
+ /** Skip the pre-flight above this size — the server would answer false. */
1728
+ MAX_INDEX_BYTES: 100 * 1024,
1729
+ } as const;
1730
+
1070
1731
  /**
1071
1732
  * Assert that a ship.json file is *syntactically* loadable. Syntax only —
1072
1733
  * never schema.
@@ -1244,16 +1905,27 @@ export interface SPACheckRequest {
1244
1905
  /**
1245
1906
  * Response from SPA check endpoint
1246
1907
  */
1908
+ /**
1909
+ * Which of the classifier's tiers reached the verdict, and why. Named rather
1910
+ * than inline so the API's own `checkSPA` can return `SPACheckResponse`
1911
+ * instead of restating its shape.
1912
+ */
1913
+ export interface SPACheckDebug {
1914
+ /** Which tier made the detection */
1915
+ tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1916
+ /** The reason for the detection result */
1917
+ reason: string;
1918
+ }
1919
+
1920
+ /**
1921
+ * A report: it answers a question and carries only the answer (`CLAUDE.md`,
1922
+ * "A report answers a question").
1923
+ */
1247
1924
  export interface SPACheckResponse {
1248
1925
  /** Whether the project is detected as a Single Page Application */
1249
1926
  isSPA: boolean;
1250
1927
  /** Debugging information about detection */
1251
- debug: {
1252
- /** Which tier made the detection: 'exclusions', 'inclusions', 'scoring', 'ai', or 'fallback' */
1253
- tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1254
- /** The reason for the detection result */
1255
- reason: string;
1256
- };
1928
+ debug: SPACheckDebug;
1257
1929
  }
1258
1930
 
1259
1931
  // =============================================================================
@@ -1297,6 +1969,53 @@ export interface StaticFile {
1297
1969
  /** Default API URL if not otherwise configured. */
1298
1970
  export const DEFAULT_API = 'https://api.shipstatic.com';
1299
1971
 
1972
+ /**
1973
+ * The Node SDK's ambient configuration pair — the ONLY environment variables
1974
+ * the SDK reads, and therefore the COMPLETE list an embedding host must
1975
+ * scrub (per `npm/ship`'s strict-isolation contract, scrubbing is the host's
1976
+ * job, not the SDK's). A host that derives its scrub from this object's
1977
+ * values — as the VS Code extension's child-process env block does — picks
1978
+ * up a grown contract at the next pin bump instead of by remembered prose.
1979
+ *
1980
+ * Browser builds read no environment at all, and the CLI-only variables
1981
+ * (`SHIP_PASSWORD`, `SHIP_VIA`) are deliberately NOT here: they are the
1982
+ * CLI's operational levers, not the SDK's ambient contract — see
1983
+ * `npm/ship/CLAUDE.md`, "CLI-only env vars".
1984
+ */
1985
+ export const SHIP_ENV = {
1986
+ /** The one credential slot — any platform token. */
1987
+ TOKEN: 'SHIP_TOKEN',
1988
+ /** The API endpoint override. */
1989
+ API_URL: 'SHIP_API_URL',
1990
+ } as const;
1991
+
1992
+ /**
1993
+ * Where a human creates an API key — the console deep link quoted by every
1994
+ * surface that teaches authentication (the CLI's config wizard, the VS Code
1995
+ * and n8n listings, the n8n rate-limit hint and credential copy). Written
1996
+ * out in five files across three repos until this export.
1997
+ *
1998
+ * Production-branded by design: published artifacts name the product, never
1999
+ * an environment (root `CLAUDE.md`, "Environment-Aware URLs").
2000
+ */
2001
+ export const MY_API_KEY_URL = 'https://my.shipstatic.com/api-key';
2002
+
2003
+ /**
2004
+ * How long an anonymous deployment lives before it expires.
2005
+ *
2006
+ * The lifetime of the public tier, and one fact with several readers. The API
2007
+ * stamps a deployment's `expires` from it and gives a claim code exactly the
2008
+ * same window — a live site with a dead claim link is a coherence bug, so the
2009
+ * two are one constant rather than two that agree. Both MCP transports quote
2010
+ * the duration in prose an agent reads, and derive it from here rather than
2011
+ * writing it out, which they did in eight places until this export existed.
2012
+ *
2013
+ * Seconds, spelled in the name: this platform has both second- and
2014
+ * millisecond-valued durations, and the pair is only safe when each says which
2015
+ * it is.
2016
+ */
2017
+ export const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;
2018
+
1300
2019
  // =============================================================================
1301
2020
  // RESOURCE INTERFACE CONTRACTS
1302
2021
  // =============================================================================
@@ -1320,8 +2039,12 @@ export type DeployInput = File[] | string | string[];
1320
2039
  export interface DeploymentUploadOptions {
1321
2040
  /** Optional labels for categorization and filtering */
1322
2041
  labels?: string[];
1323
- /** Client identifier (e.g., 'cli', 'sdk', 'web') */
1324
- via?: string;
2042
+ /**
2043
+ * Which client is making this deploy. Closed, because the server silently
2044
+ * ignores anything outside the set — so an unchecked string turned a typo
2045
+ * into missing analytics rather than an error. See {@link DeploymentVia}.
2046
+ */
2047
+ via?: DeploymentViaType;
1325
2048
  /**
1326
2049
  * Optional password that protects this deployment.
1327
2050
  *
@@ -1341,29 +2064,55 @@ export interface DeploymentUploadOptions {
1341
2064
  spa?: boolean;
1342
2065
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
1343
2066
  captcha?: string;
2067
+ /**
2068
+ * Makes this deploy replayable instead of repeatable.
2069
+ *
2070
+ * A deploy is not naturally idempotent: a client-side timeout on a slow
2071
+ * one leaves the caller unable to tell "it never landed" from "it landed
2072
+ * and the response was lost", and retrying produces a second deployment.
2073
+ * Send the same key on the retry and the platform replays the original
2074
+ * 201 verbatim rather than creating anything
2075
+ * ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
2076
+ *
2077
+ * **Agents are the audience.** A human notices a duplicate; an automated
2078
+ * retry does not. Pick a key that identifies the ATTEMPT — a run id, a
2079
+ * commit sha, a uuid minted before the first try — never one minted fresh
2080
+ * on each retry, which would defeat the point.
2081
+ *
2082
+ * The replay is per-caller, and it stores successes only: a failed deploy
2083
+ * retries fresh under the same key.
2084
+ */
2085
+ idempotencyKey?: string;
1344
2086
  }
1345
2087
 
1346
2088
  /**
1347
- * Pagination options for every list endpoint. The response's `cursor` feeds
1348
- * the next request; a `null` cursor means the last page. Omitting both
1349
- * returns the server's default first page.
2089
+ * What a caller may change on an existing deployment.
1350
2090
  *
1351
- * A list answers `{ <collection>, cursor }` and nothing else `cursor`
1352
- * carries the entire has-more signal, so no redundant boolean, and no
1353
- * `total`. **A count is an aggregate over a collection, not a property of a
1354
- * page:** including one makes every read pay for a full scan it did not ask
1355
- * for, which is precisely the cost keyset pagination exists to avoid.
2091
+ * Labels and nothing else: a deployment's content is immutable by design, so
2092
+ * this is the whole mutable surface rather than a subset someone chose.
2093
+ */
2094
+ export interface DeploymentSetOptions {
2095
+ labels: string[];
2096
+ }
2097
+
2098
+ /**
2099
+ * What `domains.set()` may create or change. Every field is optional because
2100
+ * the call is a natural-key upsert: omitting `deployment` reserves the
2101
+ * domain, naming one links or re-points it, and labels travel either way.
1356
2102
  *
1357
- * Counts therefore live on the summary resource that owns them —
1358
- * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
1359
- * platform-wide ones. Ask for a count when you want a count; ask for a page
1360
- * when you want a page.
2103
+ * `deployment` is deliberately not nullable unlinking is refused (400).
2104
+ * See `npm/ship/CLAUDE.md`, "Domain Write Semantics".
1361
2105
  */
1362
- export interface ListOptions {
1363
- /** Maximum number of items to return in one page. */
1364
- limit?: number;
1365
- /** Opaque cursor from the previous page's response. */
1366
- cursor?: string;
2106
+ export interface DomainSetOptions {
2107
+ deployment?: string;
2108
+ labels?: string[];
2109
+ }
2110
+
2111
+ /** What a caller may set when minting a deploy token. */
2112
+ export interface TokenCreateOptions {
2113
+ /** Seconds until expiry; omit for a token that never expires. */
2114
+ ttl?: number;
2115
+ labels?: string[];
1367
2116
  }
1368
2117
 
1369
2118
  /**
@@ -1380,26 +2129,23 @@ export interface DeploymentResource<
1380
2129
  upload: (input: DeployInput, options?: UploadOptions) => Promise<DeploymentCreateResponse>;
1381
2130
  list: (options?: ListOptions) => Promise<DeploymentListResponse>;
1382
2131
  get: (id: string) => Promise<Deployment>;
1383
- set: (id: string, options: { labels: string[] }) => Promise<Deployment>;
1384
- remove: (id: string) => Promise<void>;
2132
+ set: (id: string, options: DeploymentSetOptions) => Promise<Deployment>;
2133
+ delete: (id: string) => Promise<DeploymentDeleteResponse>;
1385
2134
  }
1386
2135
 
1387
2136
  /**
1388
2137
  * Domain resource interface - the contract all implementations must follow
1389
2138
  */
1390
2139
  export interface DomainResource {
1391
- set: (
1392
- name: string,
1393
- options?: { deployment?: string; labels?: string[] },
1394
- ) => Promise<DomainSetResult>;
2140
+ set: (name: string, options?: DomainSetOptions) => Promise<DomainSetResult>;
1395
2141
  list: (options?: ListOptions) => Promise<DomainListResponse>;
1396
2142
  get: (name: string) => Promise<Domain>;
1397
- remove: (name: string) => Promise<void>;
1398
- verify: (name: string) => Promise<{ message: string }>;
2143
+ delete: (name: string) => Promise<DomainDeleteResponse>;
2144
+ verify: (name: string) => Promise<DomainVerifyResponse>;
1399
2145
  validate: (name: string) => Promise<DomainValidateResponse>;
1400
2146
  dns: (name: string) => Promise<DomainDnsResponse>;
1401
2147
  records: (name: string) => Promise<DomainRecordsResponse>;
1402
- share: (name: string) => Promise<{ domain: string; hash: string }>;
2148
+ share: (name: string) => Promise<DomainShareResponse>;
1403
2149
  }
1404
2150
 
1405
2151
  /**
@@ -1413,9 +2159,10 @@ export interface AccountResource {
1413
2159
  * Token resource interface - the contract all implementations must follow
1414
2160
  */
1415
2161
  export interface TokenResource {
1416
- create: (options?: { ttl?: number; labels?: string[] }) => Promise<TokenCreateResponse>;
2162
+ create: (options?: TokenCreateOptions) => Promise<TokenCreateResponse>;
1417
2163
  list: (options?: ListOptions) => Promise<TokenListResponse>;
1418
- remove: (token: string) => Promise<void>;
2164
+ get: (token: string) => Promise<Token>;
2165
+ delete: (token: string) => Promise<TokenDeleteResponse>;
1419
2166
  }
1420
2167
 
1421
2168
  // =============================================================================
@@ -1441,6 +2188,26 @@ export interface BillingStatus {
1441
2188
  portal: string | null;
1442
2189
  }
1443
2190
 
2191
+ /**
2192
+ * Acknowledgement of `POST /billing/cancel`.
2193
+ *
2194
+ * Cancelling leaves no billing entity to return, so it answers with the
2195
+ * account and the one field of the account the call changed — the plan it
2196
+ * landed on. See {@link DeploymentDeleteResponse} for the law.
2197
+ *
2198
+ * This read `{ success: true, message: 'Subscription canceled successfully…' }`
2199
+ * until 2026-07-29, an anonymous shape that `web/my` redeclared inline and
2200
+ * whose prose no surface ever displayed: both callers await the promise and
2201
+ * discard the body, then compose their own toast. The message was written,
2202
+ * serialized, and thrown away on every cancellation.
2203
+ */
2204
+ export interface BillingCancelResponse {
2205
+ /** The account whose subscription was cancelled */
2206
+ readonly account: string;
2207
+ /** The plan the account now holds — `free` on a successful cancellation */
2208
+ readonly plan: AccountPlanType;
2209
+ }
2210
+
1444
2211
  /**
1445
2212
  * Checkout session response from POST /billing/checkout
1446
2213
  */