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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,13 +20,17 @@ npm install @shipstatic/types
20
20
 
21
21
  ```typescript
22
22
  import type {
23
- ListResponse,
24
- Deployment, DeploymentListResponse, DeploymentDeleteResponse,
25
- Domain, DomainSetResult, DomainListResponse, DnsRecord, DomainDnsResponse, DomainRecordsResponse, DomainValidateResponse, DomainDeleteResponse, DomainVerifyResponse,
26
- Token, TokenListResponse, TokenCreateResponse, TokenDeleteResponse,
23
+ ListResponse, ListOptions,
24
+ Deployment, DeploymentListResponse, DeploymentDeleteResponse, DeploymentSetOptions,
25
+ Domain, DomainSetResult, DomainSetOptions, DomainListResponse, DnsRecord, DnsLookup, DomainDnsResponse, DomainRecordsResponse, DomainShareResponse, DomainValidateResponse, DomainDeleteResponse, DomainVerifyResponse,
26
+ Token, TokenListResponse, TokenCreateResponse, TokenCreateOptions, TokenDeleteResponse,
27
27
  Account, AccountUsage, AccountOverrides, AccountDeleteResponse, AccountKeyResponse,
28
+ LabelsResponse, SetupInstructionsResponse,
28
29
  StaticFile
29
30
  } from '@shipstatic/types';
31
+
32
+ // Every public path, declared once — the API mounts from it, clients request against it.
33
+ import { API_PATHS } from '@shipstatic/types';
30
34
  ```
31
35
 
32
36
  A mutation answers with the resource it affected — the entity when it
@@ -123,6 +127,7 @@ import type {
123
127
  import {
124
128
  validateApiKey,
125
129
  validateDeployToken,
130
+ validateIdempotencyKey,
126
131
  validateApiUrl,
127
132
  isDeployment,
128
133
  isBlockedExtension,
package/dist/index.d.ts CHANGED
@@ -49,6 +49,62 @@ export interface DeploymentCreateResponse extends Deployment {
49
49
  /** Claim URL for public deployments. Present when deployed without credentials. */
50
50
  readonly claim?: string;
51
51
  }
52
+ /**
53
+ * Every path the public API answers on, declared once.
54
+ *
55
+ * The URL surface was written out in four places — the API's mounts, the
56
+ * SDK's client, the dashboard's client, and the post-deploy smoke — so a
57
+ * rename meant finding all four. The first three now read this table.
58
+ *
59
+ * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
60
+ * five of its nine paths are `/admin/*`, which this table excludes by
61
+ * design, and splitting one list between a registry and literals reads worse
62
+ * than keeping it uniform.
63
+ *
64
+ * **What this guarantees, exactly.** Collection paths are mounted from here,
65
+ * so producer and consumer cannot diverge. Item paths are declared here and
66
+ * consumed by clients, but the API spells them relative to their mount
67
+ * (`/:deployment/config`), so the table does not *generate* them — it is
68
+ * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
69
+ * any entry names a path no route answers. Some entries have no client yet
70
+ * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
71
+ * deliberately does not reach); the fence is what keeps those honest rather
72
+ * than merely asserted.
73
+ *
74
+ * **The operator surface is deliberately absent.** `/admin/*` paths belong
75
+ * to `web/my`, for the same reason its row types do: this package is
76
+ * published, and the operator surface is not public (see `CLAUDE.md`, "Admin
77
+ * types"). A path here is a promise to every npm consumer; `/admin` is a
78
+ * promise to one dashboard.
79
+ *
80
+ * Item paths are functions rather than templates so the key is interpolated
81
+ * in one place, encoded the same way by every caller.
82
+ */
83
+ export declare const API_PATHS: {
84
+ readonly DEPLOYMENTS: "/deployments";
85
+ readonly DEPLOYMENT: (deployment: string) => string;
86
+ readonly DEPLOYMENT_CONFIG: (deployment: string) => string;
87
+ readonly DOMAINS: "/domains";
88
+ readonly DOMAIN: (domain: string) => string;
89
+ readonly DOMAIN_VERIFY: (domain: string) => string;
90
+ readonly DOMAIN_DNS: (domain: string) => string;
91
+ readonly DOMAIN_RECORDS: (domain: string) => string;
92
+ readonly DOMAIN_SHARE: (domain: string) => string;
93
+ readonly DOMAIN_PROPAGATION: (domain: string) => string;
94
+ readonly DOMAINS_VALIDATE: "/domains/validate";
95
+ readonly TOKENS: "/tokens";
96
+ readonly TOKEN: (token: string) => string;
97
+ readonly ACCOUNT: "/account";
98
+ readonly ACCOUNT_KEY: "/account/key";
99
+ readonly ACCOUNT_CLAIM: "/account/claim";
100
+ readonly ACTIVITIES: "/activities";
101
+ readonly LABELS: "/labels";
102
+ readonly LIMITS: "/limits";
103
+ readonly PING: "/ping";
104
+ readonly SETUP: "/setup";
105
+ readonly SPA_CHECK: "/spa-check";
106
+ readonly UPLOAD: "/upload";
107
+ };
52
108
  /**
53
109
  * The half of a list response that is identical on every list.
54
110
  *
@@ -86,12 +142,23 @@ export interface DeploymentListResponse extends ListResponse {
86
142
  * Where the resource is simply gone, the key alone is the whole answer
87
143
  * ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
88
144
  *
89
- * Nothing else rides along. No prose (`message`), because an acknowledgement
90
- * is data and each surface composes its own copy; and no constant
91
- * (`changed: true`, `queued: true`, `success: true`), because a field whose
92
- * value the type already fixes tells a caller nothing it did not know before
93
- * it made the request. Sync versus accepted is the HTTP status code's job —
94
- * 200 versus 202 — not a boolean's.
145
+ * Put positively: **an acknowledgement is a projection of the resource** —
146
+ * its key, plus its own state field where the state changed. That is the
147
+ * test to apply, and it is sharper than "no constant", which this shape
148
+ * would fail on its own terms: `status` here is the literal `'deleting'` on
149
+ * every success, exactly as fixed as a `changed: true` would be.
150
+ *
151
+ * The difference is not how predictable the value is, it is what the field
152
+ * IS. `status` is the deployment's own field — the same one `GET
153
+ * /deployments/:deployment` returns — so this response is `Deployment`
154
+ * narrowed to two members, and a client renders it with the code it already
155
+ * has. `changed: true`, `queued: true` and `success: true` are not fields of
156
+ * any entity; they exist only to assert that the call worked, which the
157
+ * status code already said. Sync versus accepted is likewise the status
158
+ * code's job — 200 versus 202 — not a boolean's.
159
+ *
160
+ * No prose either (`message`): an acknowledgement is data, and each surface
161
+ * composes its own copy.
95
162
  */
96
163
  export interface DeploymentDeleteResponse {
97
164
  /** The deployment hostname that was marked for removal */
@@ -200,13 +267,33 @@ export interface DnsProvider {
200
267
  /**
201
268
  * Response for domain DNS provider lookup
202
269
  */
270
+ /**
271
+ * What a DNS lookup found for a domain. An envelope rather than a bare
272
+ * {@link DnsProvider} because a lookup can succeed and learn more than the
273
+ * provider later; the shape is named so a consumer can hold one.
274
+ */
275
+ export interface DnsLookup {
276
+ /** The provider serving this domain's DNS, absent when unidentified */
277
+ provider?: DnsProvider;
278
+ }
203
279
  export interface DomainDnsResponse {
204
280
  /** The domain name */
205
281
  domain: string;
206
282
  /** DNS provider information, null if not yet looked up */
207
- dns: {
208
- provider?: DnsProvider;
209
- } | null;
283
+ dns: DnsLookup | null;
284
+ }
285
+ /**
286
+ * Response for `GET /domains/:domain/share` — the domain plus the salted
287
+ * hash that lets someone else complete its DNS setup without an account.
288
+ *
289
+ * `/admin/domains/:domain/share` answers the same shape, which is the admin
290
+ * law working: the operator surface is the public grammar with a prefix.
291
+ */
292
+ export interface DomainShareResponse {
293
+ /** The domain the setup link is for */
294
+ readonly domain: string;
295
+ /** The salted setup hash that authorizes the share */
296
+ readonly hash: string;
210
297
  }
211
298
  /**
212
299
  * Response for domain DNS records
@@ -219,6 +306,53 @@ export interface DomainRecordsResponse {
219
306
  /** Required DNS records for configuration */
220
307
  records: DnsRecord[];
221
308
  }
309
+ /**
310
+ * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
311
+ *
312
+ * Format lives here rather than on the server alone by the format-vs-policy
313
+ * rule: a client can decide offline whether a key is well-formed, and the
314
+ * API would reject the same value the same way.
315
+ */
316
+ export declare const IDEMPOTENCY_KEY_CONSTRAINTS: {
317
+ readonly MAX_LENGTH: 256;
318
+ /** How long a stored 201 stays replayable. */
319
+ readonly WINDOW_SECONDS: number;
320
+ };
321
+ /**
322
+ * Validate an idempotency key, returning the trimmed value or `undefined`
323
+ * when none was supplied. Throws {@link ShipError.validation} when the value
324
+ * cannot be sent — the same verdict the API would reach, reached earlier.
325
+ */
326
+ export declare function validateIdempotencyKey(value: unknown): string | undefined;
327
+ /**
328
+ * Response for `GET /labels` — every label in use across the caller's
329
+ * deployments, domains and tokens, grouped and ordered by last use.
330
+ *
331
+ * The one plural noun outside the list contract, deliberately: labels have
332
+ * no identity, no row and no `created`, so there is nothing for a keyset
333
+ * cursor to resume after, and its consumer is an autocomplete that wants the
334
+ * whole set. Bounded by `PAGINATION.GLOBAL_LIMIT` rather than paginated.
335
+ */
336
+ export interface LabelsResponse {
337
+ readonly labels: string[];
338
+ }
339
+ /**
340
+ * Response for `POST /setup` — the DNS instructions for one domain, written
341
+ * for a human to follow at their registrar.
342
+ *
343
+ * `custom` is the provider-specific walkthrough when the provider is known;
344
+ * `generic` always answers, so a caller never has nothing to show.
345
+ */
346
+ export interface SetupInstructionsResponse {
347
+ /** One-line summary of what to do */
348
+ readonly tldr: string;
349
+ /** Provider-specific instructions, null when the provider is unknown */
350
+ readonly custom: string | null;
351
+ /** Provider-agnostic instructions — always present */
352
+ readonly generic: string;
353
+ /** The identified DNS provider, null when unknown */
354
+ readonly provider: string | null;
355
+ }
222
356
  /**
223
357
  * Response for domain validation
224
358
  */
@@ -839,16 +973,22 @@ export interface SPACheckRequest {
839
973
  /**
840
974
  * Response from SPA check endpoint
841
975
  */
976
+ /**
977
+ * Which of the classifier's tiers reached the verdict, and why. Named rather
978
+ * than inline so the API's own `checkSPA` can return `SPACheckResponse`
979
+ * instead of restating its shape.
980
+ */
981
+ export interface SPACheckDebug {
982
+ /** Which tier made the detection */
983
+ tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
984
+ /** The reason for the detection result */
985
+ reason: string;
986
+ }
842
987
  export interface SPACheckResponse {
843
988
  /** Whether the project is detected as a Single Page Application */
844
989
  isSPA: boolean;
845
990
  /** Debugging information about detection */
846
- debug: {
847
- /** Which tier made the detection: 'exclusions', 'inclusions', 'scoring', 'ai', or 'fallback' */
848
- tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
849
- /** The reason for the detection result */
850
- reason: string;
851
- };
991
+ debug: SPACheckDebug;
852
992
  }
853
993
  /**
854
994
  * Represents a file that has been processed and is ready for deploy.
@@ -920,6 +1060,25 @@ export interface DeploymentUploadOptions {
920
1060
  spa?: boolean;
921
1061
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
922
1062
  captcha?: string;
1063
+ /**
1064
+ * Makes this deploy replayable instead of repeatable.
1065
+ *
1066
+ * A deploy is not naturally idempotent: a client-side timeout on a slow
1067
+ * one leaves the caller unable to tell "it never landed" from "it landed
1068
+ * and the response was lost", and retrying produces a second deployment.
1069
+ * Send the same key on the retry and the platform replays the original
1070
+ * 201 verbatim rather than creating anything
1071
+ * ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
1072
+ *
1073
+ * **Agents are the audience.** A human notices a duplicate; an automated
1074
+ * retry does not. Pick a key that identifies the ATTEMPT — a run id, a
1075
+ * commit sha, a uuid minted before the first try — never one that varies
1076
+ * per attempt, which would defeat the point.
1077
+ *
1078
+ * The replay is per-caller, and it stores successes only: a failed deploy
1079
+ * retries fresh under the same key.
1080
+ */
1081
+ idempotencyKey?: string;
923
1082
  }
924
1083
  /**
925
1084
  * Pagination options for every list endpoint. The response's `cursor` feeds
@@ -943,6 +1102,33 @@ export interface ListOptions {
943
1102
  /** Opaque cursor from the previous page's response. */
944
1103
  cursor?: string;
945
1104
  }
1105
+ /**
1106
+ * What a caller may change on an existing deployment.
1107
+ *
1108
+ * Labels and nothing else: a deployment's content is immutable by design, so
1109
+ * this is the whole mutable surface rather than a subset someone chose.
1110
+ */
1111
+ export interface DeploymentSetOptions {
1112
+ labels: string[];
1113
+ }
1114
+ /**
1115
+ * What `domains.set()` may create or change. Every field is optional because
1116
+ * the call is a natural-key upsert: omitting `deployment` reserves the
1117
+ * domain, naming one links or re-points it, and labels travel either way.
1118
+ *
1119
+ * `deployment` is deliberately not nullable — unlinking is refused (400).
1120
+ * See `npm/ship/CLAUDE.md`, "Domain Write Semantics".
1121
+ */
1122
+ export interface DomainSetOptions {
1123
+ deployment?: string;
1124
+ labels?: string[];
1125
+ }
1126
+ /** What a caller may set when minting a deploy token. */
1127
+ export interface TokenCreateOptions {
1128
+ /** Seconds until expiry; omit for a token that never expires. */
1129
+ ttl?: number;
1130
+ labels?: string[];
1131
+ }
946
1132
  /**
947
1133
  * Deployment resource interface - the contract all implementations must follow.
948
1134
  *
@@ -955,30 +1141,22 @@ export interface DeploymentResource<UploadOptions extends DeploymentUploadOption
955
1141
  upload: (input: DeployInput, options?: UploadOptions) => Promise<DeploymentCreateResponse>;
956
1142
  list: (options?: ListOptions) => Promise<DeploymentListResponse>;
957
1143
  get: (id: string) => Promise<Deployment>;
958
- set: (id: string, options: {
959
- labels: string[];
960
- }) => Promise<Deployment>;
961
- remove: (id: string) => Promise<void>;
1144
+ set: (id: string, options: DeploymentSetOptions) => Promise<Deployment>;
1145
+ remove: (id: string) => Promise<DeploymentDeleteResponse>;
962
1146
  }
963
1147
  /**
964
1148
  * Domain resource interface - the contract all implementations must follow
965
1149
  */
966
1150
  export interface DomainResource {
967
- set: (name: string, options?: {
968
- deployment?: string;
969
- labels?: string[];
970
- }) => Promise<DomainSetResult>;
1151
+ set: (name: string, options?: DomainSetOptions) => Promise<DomainSetResult>;
971
1152
  list: (options?: ListOptions) => Promise<DomainListResponse>;
972
1153
  get: (name: string) => Promise<Domain>;
973
- remove: (name: string) => Promise<void>;
1154
+ remove: (name: string) => Promise<DomainDeleteResponse>;
974
1155
  verify: (name: string) => Promise<DomainVerifyResponse>;
975
1156
  validate: (name: string) => Promise<DomainValidateResponse>;
976
1157
  dns: (name: string) => Promise<DomainDnsResponse>;
977
1158
  records: (name: string) => Promise<DomainRecordsResponse>;
978
- share: (name: string) => Promise<{
979
- domain: string;
980
- hash: string;
981
- }>;
1159
+ share: (name: string) => Promise<DomainShareResponse>;
982
1160
  }
983
1161
  /**
984
1162
  * Account resource interface - the contract all implementations must follow
@@ -990,12 +1168,10 @@ export interface AccountResource {
990
1168
  * Token resource interface - the contract all implementations must follow
991
1169
  */
992
1170
  export interface TokenResource {
993
- create: (options?: {
994
- ttl?: number;
995
- labels?: string[];
996
- }) => Promise<TokenCreateResponse>;
1171
+ create: (options?: TokenCreateOptions) => Promise<TokenCreateResponse>;
997
1172
  list: (options?: ListOptions) => Promise<TokenListResponse>;
998
- remove: (token: string) => Promise<void>;
1173
+ get: (token: string) => Promise<Token>;
1174
+ remove: (token: string) => Promise<TokenDeleteResponse>;
999
1175
  }
1000
1176
  /**
1001
1177
  * Billing status response from GET /billing/status
package/dist/index.js CHANGED
@@ -14,6 +14,62 @@ export const DeploymentStatus = {
14
14
  FAILED: 'failed',
15
15
  DELETING: 'deleting',
16
16
  };
17
+ /**
18
+ * Every path the public API answers on, declared once.
19
+ *
20
+ * The URL surface was written out in four places — the API's mounts, the
21
+ * SDK's client, the dashboard's client, and the post-deploy smoke — so a
22
+ * rename meant finding all four. The first three now read this table.
23
+ *
24
+ * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
25
+ * five of its nine paths are `/admin/*`, which this table excludes by
26
+ * design, and splitting one list between a registry and literals reads worse
27
+ * than keeping it uniform.
28
+ *
29
+ * **What this guarantees, exactly.** Collection paths are mounted from here,
30
+ * so producer and consumer cannot diverge. Item paths are declared here and
31
+ * consumed by clients, but the API spells them relative to their mount
32
+ * (`/:deployment/config`), so the table does not *generate* them — it is
33
+ * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
34
+ * any entry names a path no route answers. Some entries have no client yet
35
+ * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
36
+ * deliberately does not reach); the fence is what keeps those honest rather
37
+ * than merely asserted.
38
+ *
39
+ * **The operator surface is deliberately absent.** `/admin/*` paths belong
40
+ * to `web/my`, for the same reason its row types do: this package is
41
+ * published, and the operator surface is not public (see `CLAUDE.md`, "Admin
42
+ * types"). A path here is a promise to every npm consumer; `/admin` is a
43
+ * promise to one dashboard.
44
+ *
45
+ * Item paths are functions rather than templates so the key is interpolated
46
+ * in one place, encoded the same way by every caller.
47
+ */
48
+ export const API_PATHS = {
49
+ DEPLOYMENTS: '/deployments',
50
+ DEPLOYMENT: (deployment) => `/deployments/${deployment}`,
51
+ DEPLOYMENT_CONFIG: (deployment) => `/deployments/${deployment}/config`,
52
+ DOMAINS: '/domains',
53
+ DOMAIN: (domain) => `/domains/${domain}`,
54
+ DOMAIN_VERIFY: (domain) => `/domains/${domain}/verify`,
55
+ DOMAIN_DNS: (domain) => `/domains/${domain}/dns`,
56
+ DOMAIN_RECORDS: (domain) => `/domains/${domain}/records`,
57
+ DOMAIN_SHARE: (domain) => `/domains/${domain}/share`,
58
+ DOMAIN_PROPAGATION: (domain) => `/domains/${domain}/propagation`,
59
+ DOMAINS_VALIDATE: '/domains/validate',
60
+ TOKENS: '/tokens',
61
+ TOKEN: (token) => `/tokens/${token}`,
62
+ ACCOUNT: '/account',
63
+ ACCOUNT_KEY: '/account/key',
64
+ ACCOUNT_CLAIM: '/account/claim',
65
+ ACTIVITIES: '/activities',
66
+ LABELS: '/labels',
67
+ LIMITS: '/limits',
68
+ PING: '/ping',
69
+ SETUP: '/setup',
70
+ SPA_CHECK: '/spa-check',
71
+ UPLOAD: '/upload',
72
+ };
17
73
  // =============================================================================
18
74
  // DOMAIN TYPES
19
75
  // =============================================================================
@@ -31,6 +87,38 @@ export const DomainStatus = {
31
87
  SUCCESS: 'success',
32
88
  PAUSED: 'paused',
33
89
  };
90
+ /**
91
+ * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
92
+ *
93
+ * Format lives here rather than on the server alone by the format-vs-policy
94
+ * rule: a client can decide offline whether a key is well-formed, and the
95
+ * API would reject the same value the same way.
96
+ */
97
+ export const IDEMPOTENCY_KEY_CONSTRAINTS = {
98
+ MAX_LENGTH: 256,
99
+ /** How long a stored 201 stays replayable. */
100
+ WINDOW_SECONDS: 24 * 60 * 60,
101
+ };
102
+ /**
103
+ * Validate an idempotency key, returning the trimmed value or `undefined`
104
+ * when none was supplied. Throws {@link ShipError.validation} when the value
105
+ * cannot be sent — the same verdict the API would reach, reached earlier.
106
+ */
107
+ export function validateIdempotencyKey(value) {
108
+ if (value === undefined || value === null)
109
+ return undefined;
110
+ if (typeof value !== 'string') {
111
+ throw ShipError.validation('Idempotency key must be a string.');
112
+ }
113
+ const key = value.trim();
114
+ if (!key) {
115
+ throw ShipError.validation('Idempotency key must not be empty.');
116
+ }
117
+ if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {
118
+ throw ShipError.validation(`Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`);
119
+ }
120
+ return key;
121
+ }
34
122
  // =============================================================================
35
123
  // ACCOUNT TYPES
36
124
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.5.0-beta.11",
3
+ "version": "2.5.0-beta.13",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -58,6 +58,63 @@ export interface DeploymentCreateResponse extends Deployment {
58
58
  readonly claim?: string;
59
59
  }
60
60
 
61
+ /**
62
+ * Every path the public API answers on, declared once.
63
+ *
64
+ * The URL surface was written out in four places — the API's mounts, the
65
+ * SDK's client, the dashboard's client, and the post-deploy smoke — so a
66
+ * rename meant finding all four. The first three now read this table.
67
+ *
68
+ * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
69
+ * five of its nine paths are `/admin/*`, which this table excludes by
70
+ * design, and splitting one list between a registry and literals reads worse
71
+ * than keeping it uniform.
72
+ *
73
+ * **What this guarantees, exactly.** Collection paths are mounted from here,
74
+ * so producer and consumer cannot diverge. Item paths are declared here and
75
+ * consumed by clients, but the API spells them relative to their mount
76
+ * (`/:deployment/config`), so the table does not *generate* them — it is
77
+ * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
78
+ * any entry names a path no route answers. Some entries have no client yet
79
+ * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
80
+ * deliberately does not reach); the fence is what keeps those honest rather
81
+ * than merely asserted.
82
+ *
83
+ * **The operator surface is deliberately absent.** `/admin/*` paths belong
84
+ * to `web/my`, for the same reason its row types do: this package is
85
+ * published, and the operator surface is not public (see `CLAUDE.md`, "Admin
86
+ * types"). A path here is a promise to every npm consumer; `/admin` is a
87
+ * promise to one dashboard.
88
+ *
89
+ * Item paths are functions rather than templates so the key is interpolated
90
+ * in one place, encoded the same way by every caller.
91
+ */
92
+ export const API_PATHS = {
93
+ DEPLOYMENTS: '/deployments',
94
+ DEPLOYMENT: (deployment: string) => `/deployments/${deployment}`,
95
+ DEPLOYMENT_CONFIG: (deployment: string) => `/deployments/${deployment}/config`,
96
+ DOMAINS: '/domains',
97
+ DOMAIN: (domain: string) => `/domains/${domain}`,
98
+ DOMAIN_VERIFY: (domain: string) => `/domains/${domain}/verify`,
99
+ DOMAIN_DNS: (domain: string) => `/domains/${domain}/dns`,
100
+ DOMAIN_RECORDS: (domain: string) => `/domains/${domain}/records`,
101
+ DOMAIN_SHARE: (domain: string) => `/domains/${domain}/share`,
102
+ DOMAIN_PROPAGATION: (domain: string) => `/domains/${domain}/propagation`,
103
+ DOMAINS_VALIDATE: '/domains/validate',
104
+ TOKENS: '/tokens',
105
+ TOKEN: (token: string) => `/tokens/${token}`,
106
+ ACCOUNT: '/account',
107
+ ACCOUNT_KEY: '/account/key',
108
+ ACCOUNT_CLAIM: '/account/claim',
109
+ ACTIVITIES: '/activities',
110
+ LABELS: '/labels',
111
+ LIMITS: '/limits',
112
+ PING: '/ping',
113
+ SETUP: '/setup',
114
+ SPA_CHECK: '/spa-check',
115
+ UPLOAD: '/upload',
116
+ } as const;
117
+
61
118
  /**
62
119
  * The half of a list response that is identical on every list.
63
120
  *
@@ -97,12 +154,23 @@ export interface DeploymentListResponse extends ListResponse {
97
154
  * Where the resource is simply gone, the key alone is the whole answer
98
155
  * ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
99
156
  *
100
- * Nothing else rides along. No prose (`message`), because an acknowledgement
101
- * is data and each surface composes its own copy; and no constant
102
- * (`changed: true`, `queued: true`, `success: true`), because a field whose
103
- * value the type already fixes tells a caller nothing it did not know before
104
- * it made the request. Sync versus accepted is the HTTP status code's job —
105
- * 200 versus 202 — not a boolean's.
157
+ * Put positively: **an acknowledgement is a projection of the resource** —
158
+ * its key, plus its own state field where the state changed. That is the
159
+ * test to apply, and it is sharper than "no constant", which this shape
160
+ * would fail on its own terms: `status` here is the literal `'deleting'` on
161
+ * every success, exactly as fixed as a `changed: true` would be.
162
+ *
163
+ * The difference is not how predictable the value is, it is what the field
164
+ * IS. `status` is the deployment's own field — the same one `GET
165
+ * /deployments/:deployment` returns — so this response is `Deployment`
166
+ * narrowed to two members, and a client renders it with the code it already
167
+ * has. `changed: true`, `queued: true` and `success: true` are not fields of
168
+ * any entity; they exist only to assert that the call worked, which the
169
+ * status code already said. Sync versus accepted is likewise the status
170
+ * code's job — 200 versus 202 — not a boolean's.
171
+ *
172
+ * No prose either (`message`): an acknowledgement is data, and each surface
173
+ * composes its own copy.
106
174
  */
107
175
  export interface DeploymentDeleteResponse {
108
176
  /** The deployment hostname that was marked for removal */
@@ -226,11 +294,35 @@ export interface DnsProvider {
226
294
  /**
227
295
  * Response for domain DNS provider lookup
228
296
  */
297
+ /**
298
+ * What a DNS lookup found for a domain. An envelope rather than a bare
299
+ * {@link DnsProvider} because a lookup can succeed and learn more than the
300
+ * provider later; the shape is named so a consumer can hold one.
301
+ */
302
+ export interface DnsLookup {
303
+ /** The provider serving this domain's DNS, absent when unidentified */
304
+ provider?: DnsProvider;
305
+ }
306
+
229
307
  export interface DomainDnsResponse {
230
308
  /** The domain name */
231
309
  domain: string;
232
310
  /** DNS provider information, null if not yet looked up */
233
- dns: { provider?: DnsProvider } | null;
311
+ dns: DnsLookup | null;
312
+ }
313
+
314
+ /**
315
+ * Response for `GET /domains/:domain/share` — the domain plus the salted
316
+ * hash that lets someone else complete its DNS setup without an account.
317
+ *
318
+ * `/admin/domains/:domain/share` answers the same shape, which is the admin
319
+ * law working: the operator surface is the public grammar with a prefix.
320
+ */
321
+ export interface DomainShareResponse {
322
+ /** The domain the setup link is for */
323
+ readonly domain: string;
324
+ /** The salted setup hash that authorizes the share */
325
+ readonly hash: string;
234
326
  }
235
327
 
236
328
  /**
@@ -245,6 +337,72 @@ export interface DomainRecordsResponse {
245
337
  records: DnsRecord[];
246
338
  }
247
339
 
340
+ /**
341
+ * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
342
+ *
343
+ * Format lives here rather than on the server alone by the format-vs-policy
344
+ * rule: a client can decide offline whether a key is well-formed, and the
345
+ * API would reject the same value the same way.
346
+ */
347
+ export const IDEMPOTENCY_KEY_CONSTRAINTS = {
348
+ MAX_LENGTH: 256,
349
+ /** How long a stored 201 stays replayable. */
350
+ WINDOW_SECONDS: 24 * 60 * 60,
351
+ } as const;
352
+
353
+ /**
354
+ * Validate an idempotency key, returning the trimmed value or `undefined`
355
+ * when none was supplied. Throws {@link ShipError.validation} when the value
356
+ * cannot be sent — the same verdict the API would reach, reached earlier.
357
+ */
358
+ export function validateIdempotencyKey(value: unknown): string | undefined {
359
+ if (value === undefined || value === null) return undefined;
360
+ if (typeof value !== 'string') {
361
+ throw ShipError.validation('Idempotency key must be a string.');
362
+ }
363
+ const key = value.trim();
364
+ if (!key) {
365
+ throw ShipError.validation('Idempotency key must not be empty.');
366
+ }
367
+ if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {
368
+ throw ShipError.validation(
369
+ `Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`,
370
+ );
371
+ }
372
+ return key;
373
+ }
374
+
375
+ /**
376
+ * Response for `GET /labels` — every label in use across the caller's
377
+ * deployments, domains and tokens, grouped and ordered by last use.
378
+ *
379
+ * The one plural noun outside the list contract, deliberately: labels have
380
+ * no identity, no row and no `created`, so there is nothing for a keyset
381
+ * cursor to resume after, and its consumer is an autocomplete that wants the
382
+ * whole set. Bounded by `PAGINATION.GLOBAL_LIMIT` rather than paginated.
383
+ */
384
+ export interface LabelsResponse {
385
+ readonly labels: string[];
386
+ }
387
+
388
+ /**
389
+ * Response for `POST /setup` — the DNS instructions for one domain, written
390
+ * for a human to follow at their registrar.
391
+ *
392
+ * `custom` is the provider-specific walkthrough when the provider is known;
393
+ * `generic` always answers, so a caller never has nothing to show.
394
+ */
395
+ export interface SetupInstructionsResponse {
396
+ /** One-line summary of what to do */
397
+ readonly tldr: string;
398
+ /** Provider-specific instructions, null when the provider is unknown */
399
+ readonly custom: string | null;
400
+ /** Provider-agnostic instructions — always present */
401
+ readonly generic: string;
402
+ /** The identified DNS provider, null when unknown */
403
+ readonly provider: string | null;
404
+ }
405
+
248
406
  /**
249
407
  * Response for domain validation
250
408
  */
@@ -1320,16 +1478,23 @@ export interface SPACheckRequest {
1320
1478
  /**
1321
1479
  * Response from SPA check endpoint
1322
1480
  */
1481
+ /**
1482
+ * Which of the classifier's tiers reached the verdict, and why. Named rather
1483
+ * than inline so the API's own `checkSPA` can return `SPACheckResponse`
1484
+ * instead of restating its shape.
1485
+ */
1486
+ export interface SPACheckDebug {
1487
+ /** Which tier made the detection */
1488
+ tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1489
+ /** The reason for the detection result */
1490
+ reason: string;
1491
+ }
1492
+
1323
1493
  export interface SPACheckResponse {
1324
1494
  /** Whether the project is detected as a Single Page Application */
1325
1495
  isSPA: boolean;
1326
1496
  /** Debugging information about detection */
1327
- debug: {
1328
- /** Which tier made the detection: 'exclusions', 'inclusions', 'scoring', 'ai', or 'fallback' */
1329
- tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
1330
- /** The reason for the detection result */
1331
- reason: string;
1332
- };
1497
+ debug: SPACheckDebug;
1333
1498
  }
1334
1499
 
1335
1500
  // =============================================================================
@@ -1417,6 +1582,25 @@ export interface DeploymentUploadOptions {
1417
1582
  spa?: boolean;
1418
1583
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
1419
1584
  captcha?: string;
1585
+ /**
1586
+ * Makes this deploy replayable instead of repeatable.
1587
+ *
1588
+ * A deploy is not naturally idempotent: a client-side timeout on a slow
1589
+ * one leaves the caller unable to tell "it never landed" from "it landed
1590
+ * and the response was lost", and retrying produces a second deployment.
1591
+ * Send the same key on the retry and the platform replays the original
1592
+ * 201 verbatim rather than creating anything
1593
+ * ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
1594
+ *
1595
+ * **Agents are the audience.** A human notices a duplicate; an automated
1596
+ * retry does not. Pick a key that identifies the ATTEMPT — a run id, a
1597
+ * commit sha, a uuid minted before the first try — never one that varies
1598
+ * per attempt, which would defeat the point.
1599
+ *
1600
+ * The replay is per-caller, and it stores successes only: a failed deploy
1601
+ * retries fresh under the same key.
1602
+ */
1603
+ idempotencyKey?: string;
1420
1604
  }
1421
1605
 
1422
1606
  /**
@@ -1442,6 +1626,36 @@ export interface ListOptions {
1442
1626
  cursor?: string;
1443
1627
  }
1444
1628
 
1629
+ /**
1630
+ * What a caller may change on an existing deployment.
1631
+ *
1632
+ * Labels and nothing else: a deployment's content is immutable by design, so
1633
+ * this is the whole mutable surface rather than a subset someone chose.
1634
+ */
1635
+ export interface DeploymentSetOptions {
1636
+ labels: string[];
1637
+ }
1638
+
1639
+ /**
1640
+ * What `domains.set()` may create or change. Every field is optional because
1641
+ * the call is a natural-key upsert: omitting `deployment` reserves the
1642
+ * domain, naming one links or re-points it, and labels travel either way.
1643
+ *
1644
+ * `deployment` is deliberately not nullable — unlinking is refused (400).
1645
+ * See `npm/ship/CLAUDE.md`, "Domain Write Semantics".
1646
+ */
1647
+ export interface DomainSetOptions {
1648
+ deployment?: string;
1649
+ labels?: string[];
1650
+ }
1651
+
1652
+ /** What a caller may set when minting a deploy token. */
1653
+ export interface TokenCreateOptions {
1654
+ /** Seconds until expiry; omit for a token that never expires. */
1655
+ ttl?: number;
1656
+ labels?: string[];
1657
+ }
1658
+
1445
1659
  /**
1446
1660
  * Deployment resource interface - the contract all implementations must follow.
1447
1661
  *
@@ -1456,26 +1670,23 @@ export interface DeploymentResource<
1456
1670
  upload: (input: DeployInput, options?: UploadOptions) => Promise<DeploymentCreateResponse>;
1457
1671
  list: (options?: ListOptions) => Promise<DeploymentListResponse>;
1458
1672
  get: (id: string) => Promise<Deployment>;
1459
- set: (id: string, options: { labels: string[] }) => Promise<Deployment>;
1460
- remove: (id: string) => Promise<void>;
1673
+ set: (id: string, options: DeploymentSetOptions) => Promise<Deployment>;
1674
+ remove: (id: string) => Promise<DeploymentDeleteResponse>;
1461
1675
  }
1462
1676
 
1463
1677
  /**
1464
1678
  * Domain resource interface - the contract all implementations must follow
1465
1679
  */
1466
1680
  export interface DomainResource {
1467
- set: (
1468
- name: string,
1469
- options?: { deployment?: string; labels?: string[] },
1470
- ) => Promise<DomainSetResult>;
1681
+ set: (name: string, options?: DomainSetOptions) => Promise<DomainSetResult>;
1471
1682
  list: (options?: ListOptions) => Promise<DomainListResponse>;
1472
1683
  get: (name: string) => Promise<Domain>;
1473
- remove: (name: string) => Promise<void>;
1684
+ remove: (name: string) => Promise<DomainDeleteResponse>;
1474
1685
  verify: (name: string) => Promise<DomainVerifyResponse>;
1475
1686
  validate: (name: string) => Promise<DomainValidateResponse>;
1476
1687
  dns: (name: string) => Promise<DomainDnsResponse>;
1477
1688
  records: (name: string) => Promise<DomainRecordsResponse>;
1478
- share: (name: string) => Promise<{ domain: string; hash: string }>;
1689
+ share: (name: string) => Promise<DomainShareResponse>;
1479
1690
  }
1480
1691
 
1481
1692
  /**
@@ -1489,9 +1700,10 @@ export interface AccountResource {
1489
1700
  * Token resource interface - the contract all implementations must follow
1490
1701
  */
1491
1702
  export interface TokenResource {
1492
- create: (options?: { ttl?: number; labels?: string[] }) => Promise<TokenCreateResponse>;
1703
+ create: (options?: TokenCreateOptions) => Promise<TokenCreateResponse>;
1493
1704
  list: (options?: ListOptions) => Promise<TokenListResponse>;
1494
- remove: (token: string) => Promise<void>;
1705
+ get: (token: string) => Promise<Token>;
1706
+ remove: (token: string) => Promise<TokenDeleteResponse>;
1495
1707
  }
1496
1708
 
1497
1709
  // =============================================================================