@shipstatic/types 2.5.0-beta.12 → 2.5.0-beta.14

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
@@ -127,6 +127,7 @@ import type {
127
127
  import {
128
128
  validateApiKey,
129
129
  validateDeployToken,
130
+ validateIdempotencyKey,
130
131
  validateApiUrl,
131
132
  isDeployment,
132
133
  isBlockedExtension,
package/dist/index.d.ts CHANGED
@@ -52,11 +52,24 @@ export interface DeploymentCreateResponse extends Deployment {
52
52
  /**
53
53
  * Every path the public API answers on, declared once.
54
54
  *
55
- * The URL surface used to be written out in four places — the API's mounts,
56
- * the SDK's client, the dashboard's client, and the post-deploy smoke — so a
57
- * rename meant finding all four. Here it is one table that the producer
58
- * mounts from and the consumers request against, which is the only way a
59
- * path and its handler cannot drift apart.
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.
60
73
  *
61
74
  * **The operator surface is deliberately absent.** `/admin/*` paths belong
62
75
  * to `web/my`, for the same reason its row types do: this package is
@@ -293,6 +306,24 @@ export interface DomainRecordsResponse {
293
306
  /** Required DNS records for configuration */
294
307
  records: DnsRecord[];
295
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;
296
327
  /**
297
328
  * Response for `GET /labels` — every label in use across the caller's
298
329
  * deployments, domains and tokens, grouped and ordered by last use.
@@ -1029,6 +1060,25 @@ export interface DeploymentUploadOptions {
1029
1060
  spa?: boolean;
1030
1061
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
1031
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;
1032
1082
  }
1033
1083
  /**
1034
1084
  * Pagination options for every list endpoint. The response's `cursor` feeds
package/dist/index.js CHANGED
@@ -17,11 +17,24 @@ export const DeploymentStatus = {
17
17
  /**
18
18
  * Every path the public API answers on, declared once.
19
19
  *
20
- * The URL surface used to be written out in four places — the API's mounts,
21
- * the SDK's client, the dashboard's client, and the post-deploy smoke — so a
22
- * rename meant finding all four. Here it is one table that the producer
23
- * mounts from and the consumers request against, which is the only way a
24
- * path and its handler cannot drift apart.
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.
25
38
  *
26
39
  * **The operator surface is deliberately absent.** `/admin/*` paths belong
27
40
  * to `web/my`, for the same reason its row types do: this package is
@@ -74,6 +87,38 @@ export const DomainStatus = {
74
87
  SUCCESS: 'success',
75
88
  PAUSED: 'paused',
76
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
+ }
77
122
  // =============================================================================
78
123
  // ACCOUNT TYPES
79
124
  // =============================================================================
@@ -169,6 +214,14 @@ const ERROR_CATEGORIES = {
169
214
  * `ErrorType` is automatically picked up.
170
215
  */
171
216
  const SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)));
217
+ /**
218
+ * Ceiling on a message adopted from a **non-JSON** error body — a foreign
219
+ * responder's, never this platform's. Generous for the plain-text one-liners
220
+ * intermediaries actually send (`error code: 1015`), far below a document.
221
+ * Our own messages are never measured against it: a JSON body is the API's
222
+ * contract, and truncating a long validation message would be the bug.
223
+ */
224
+ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
172
225
  /**
173
226
  * Simple unified error class for both API and SDK
174
227
  */
@@ -240,9 +293,17 @@ export class ShipError extends Error {
240
293
  }
241
294
  }
242
295
  else {
243
- const text = await response.text();
244
- if (text)
296
+ // A non-JSON body did not come from this platform — every API error
297
+ // is `ErrorResponse` JSON — so it is an intermediary's output, and
298
+ // the two kinds it produces need opposite treatment. A CDN's plain
299
+ // `error code: 1015` is the most useful thing there is to say. A
300
+ // proxy's HTML error page is a *document*, not a message: adopting it
301
+ // verbatim made a misconfigured `apiUrl` print 2,059 characters of
302
+ // markup as the error. Trust it only when it reads as a message.
303
+ const text = (await response.text()).trim();
304
+ if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {
245
305
  message = text;
306
+ }
246
307
  }
247
308
  }
248
309
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.5.0-beta.12",
3
+ "version": "2.5.0-beta.14",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -61,11 +61,24 @@ export interface DeploymentCreateResponse extends Deployment {
61
61
  /**
62
62
  * Every path the public API answers on, declared once.
63
63
  *
64
- * The URL surface used to be written out in four places — the API's mounts,
65
- * the SDK's client, the dashboard's client, and the post-deploy smoke — so a
66
- * rename meant finding all four. Here it is one table that the producer
67
- * mounts from and the consumers request against, which is the only way a
68
- * path and its handler cannot drift apart.
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.
69
82
  *
70
83
  * **The operator surface is deliberately absent.** `/admin/*` paths belong
71
84
  * to `web/my`, for the same reason its row types do: this package is
@@ -324,6 +337,41 @@ export interface DomainRecordsResponse {
324
337
  records: DnsRecord[];
325
338
  }
326
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
+
327
375
  /**
328
376
  * Response for `GET /labels` — every label in use across the caller's
329
377
  * deployments, domains and tokens, grouped and ordered by last use.
@@ -654,6 +702,15 @@ const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
654
702
  Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)),
655
703
  );
656
704
 
705
+ /**
706
+ * Ceiling on a message adopted from a **non-JSON** error body — a foreign
707
+ * responder's, never this platform's. Generous for the plain-text one-liners
708
+ * intermediaries actually send (`error code: 1015`), far below a document.
709
+ * Our own messages are never measured against it: a JSON body is the API's
710
+ * contract, and truncating a long validation message would be the bug.
711
+ */
712
+ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
713
+
657
714
  /**
658
715
  * Standard error response format used everywhere
659
716
  */
@@ -740,8 +797,17 @@ export class ShipError extends Error {
740
797
  }
741
798
  }
742
799
  } else {
743
- const text = await response.text();
744
- if (text) message = text;
800
+ // A non-JSON body did not come from this platform — every API error
801
+ // is `ErrorResponse` JSON — so it is an intermediary's output, and
802
+ // the two kinds it produces need opposite treatment. A CDN's plain
803
+ // `error code: 1015` is the most useful thing there is to say. A
804
+ // proxy's HTML error page is a *document*, not a message: adopting it
805
+ // verbatim made a misconfigured `apiUrl` print 2,059 characters of
806
+ // markup as the error. Trust it only when it reads as a message.
807
+ const text = (await response.text()).trim();
808
+ if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {
809
+ message = text;
810
+ }
745
811
  }
746
812
  } catch {
747
813
  // Body unreadable; fall through to operationName-derived message.
@@ -1534,6 +1600,25 @@ export interface DeploymentUploadOptions {
1534
1600
  spa?: boolean;
1535
1601
  /** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
1536
1602
  captcha?: string;
1603
+ /**
1604
+ * Makes this deploy replayable instead of repeatable.
1605
+ *
1606
+ * A deploy is not naturally idempotent: a client-side timeout on a slow
1607
+ * one leaves the caller unable to tell "it never landed" from "it landed
1608
+ * and the response was lost", and retrying produces a second deployment.
1609
+ * Send the same key on the retry and the platform replays the original
1610
+ * 201 verbatim rather than creating anything
1611
+ * ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
1612
+ *
1613
+ * **Agents are the audience.** A human notices a duplicate; an automated
1614
+ * retry does not. Pick a key that identifies the ATTEMPT — a run id, a
1615
+ * commit sha, a uuid minted before the first try — never one that varies
1616
+ * per attempt, which would defeat the point.
1617
+ *
1618
+ * The replay is per-caller, and it stores successes only: a failed deploy
1619
+ * retries fresh under the same key.
1620
+ */
1621
+ idempotencyKey?: string;
1537
1622
  }
1538
1623
 
1539
1624
  /**