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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -14,6 +14,118 @@ export const DeploymentStatus = {
14
14
  FAILED: 'failed',
15
15
  DELETING: 'deleting',
16
16
  };
17
+ /**
18
+ * Which client made a deployment — the origin-tracking vocabulary.
19
+ *
20
+ * A closed set with many authors: the CLI, the SDK, the dashboard, both MCP
21
+ * transports, the GitHub Action, the n8n node and the VS Code extension each
22
+ * name themselves here. It lived in the API's config until 2026-08-06, where
23
+ * being server-side made it unenforceable in the one direction that matters —
24
+ * every client wrote a bare string, and a value outside the set was **silently
25
+ * dropped** by the server, so a typo did not fail anywhere. It stopped
26
+ * recording where deploys came from and said nothing.
27
+ */
28
+ export const DeploymentVia = {
29
+ WEB: 'web',
30
+ SDK: 'sdk',
31
+ CLI: 'cli',
32
+ MCP: 'mcp',
33
+ GIT: 'git',
34
+ N8N: 'n8n',
35
+ GPT: 'gpt',
36
+ VSC: 'vsc',
37
+ };
38
+ /**
39
+ * Every path the public API answers on, declared once.
40
+ *
41
+ * The URL surface was written out in four places — the API's mounts, the
42
+ * SDK's client, the dashboard's client, and the post-deploy smoke — so a
43
+ * rename meant finding all four. The first three now read this table.
44
+ *
45
+ * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
46
+ * five of its nine paths are `/admin/*`, which this table excludes by
47
+ * design, and splitting one list between a registry and literals reads worse
48
+ * than keeping it uniform.
49
+ *
50
+ * **What this guarantees, exactly.** Collection paths are mounted from here,
51
+ * so producer and consumer cannot diverge. Item paths are declared here and
52
+ * consumed by clients, but the API spells them relative to their mount
53
+ * (`/:deployment/config`), so the table does not *generate* them — it is
54
+ * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
55
+ * any entry names a path no route answers. Some entries have no client yet
56
+ * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
57
+ * deliberately does not reach); the fence is what keeps those honest rather
58
+ * than merely asserted.
59
+ *
60
+ * **The operator surface is deliberately absent.** `/admin/*` paths belong
61
+ * to `web/my`, for the same reason its row types do: this package is
62
+ * published, and the operator surface is not public (see `CLAUDE.md`, "Admin
63
+ * types"). A path here is a promise to every npm consumer; `/admin` is a
64
+ * promise to one dashboard.
65
+ *
66
+ * Item paths are functions rather than templates so the key is interpolated
67
+ * in one place, encoded the same way by every caller.
68
+ */
69
+ export const API_PATHS = {
70
+ DEPLOYMENTS: '/deployments',
71
+ DEPLOYMENT: (deployment) => `/deployments/${deployment}`,
72
+ DEPLOYMENT_CONFIG: (deployment) => `/deployments/${deployment}/config`,
73
+ DOMAINS: '/domains',
74
+ DOMAIN: (domain) => `/domains/${domain}`,
75
+ DOMAIN_VERIFY: (domain) => `/domains/${domain}/verify`,
76
+ DOMAIN_DNS: (domain) => `/domains/${domain}/dns`,
77
+ DOMAIN_RECORDS: (domain) => `/domains/${domain}/records`,
78
+ DOMAIN_SHARE: (domain) => `/domains/${domain}/share`,
79
+ DOMAIN_PROPAGATION: (domain) => `/domains/${domain}/propagation`,
80
+ DOMAINS_VALIDATE: '/domains/validate',
81
+ TOKENS: '/tokens',
82
+ TOKEN: (token) => `/tokens/${token}`,
83
+ ACCOUNT: '/account',
84
+ ACCOUNT_KEY: '/account/key',
85
+ ACCOUNT_CLAIM: '/account/claim',
86
+ ACTIVITIES: '/activities',
87
+ LABELS: '/labels',
88
+ LIMITS: '/limits',
89
+ PING: '/ping',
90
+ SETUP: '/setup',
91
+ SPA_CHECK: '/spa-check',
92
+ UPLOAD: '/upload',
93
+ };
94
+ /**
95
+ * The deploy request's multipart field names — the other half of the wire
96
+ * surface beside {@link API_PATHS}. `POST /deployments` (and the first-party
97
+ * `/upload`) is multipart/form-data, and these are the names the API reads.
98
+ *
99
+ * Declared once because the body has three independent WRITERS — the SDK's
100
+ * Node and browser body builders, and the n8n community node's hand-rolled
101
+ * client (which cannot import this under n8n Cloud's zero-dependency rule,
102
+ * and fences its restated copy instead) — and until this export every writer
103
+ * restated the strings the API parses, with nothing comparing them.
104
+ *
105
+ * `FILES` carries one entry per file (the API reads it with `getAll`); every
106
+ * other field is single. The `@internal` flags are serialized as the literal
107
+ * string `'true'` and belong to first-party surfaces only.
108
+ */
109
+ export const DEPLOY_FIELDS = {
110
+ /** One entry per file — read with `getAll`. */
111
+ FILES: 'files[]',
112
+ /** JSON array of MD5 hex digests, index-aligned with `FILES`. */
113
+ CHECKSUMS: 'checksums',
114
+ /** JSON array of label strings. */
115
+ LABELS: 'labels',
116
+ /** The deploying surface's {@link DeploymentVia} member. */
117
+ VIA: 'via',
118
+ /** Plaintext password — the API hashes it server-side. */
119
+ PASSWORD: 'password',
120
+ /** @internal Server-processing flag — first-party `/upload` only. */
121
+ BUILD: 'build',
122
+ /** @internal Server-processing flag — first-party `/upload` only. */
123
+ PRERENDER: 'prerender',
124
+ /** @internal Server-processing flag — first-party `/upload` only. */
125
+ SPA: 'spa',
126
+ /** @internal reCAPTCHA proof — `web/www`'s public uploader only. */
127
+ CAPTCHA: 'captcha',
128
+ };
17
129
  // =============================================================================
18
130
  // DOMAIN TYPES
19
131
  // =============================================================================
@@ -31,6 +143,68 @@ export const DomainStatus = {
31
143
  SUCCESS: 'success',
32
144
  PAUSED: 'paused',
33
145
  };
146
+ /**
147
+ * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
148
+ *
149
+ * Format lives here rather than on the server alone by the format-vs-policy
150
+ * rule: a client can decide offline whether a key is well-formed, and the
151
+ * API would reject the same value the same way.
152
+ */
153
+ export const IDEMPOTENCY_KEY_CONSTRAINTS = {
154
+ /**
155
+ * HTTP header name. Here for the same reason {@link CALLER.HEADER} is: a
156
+ * wire header has two ends, and the package that owns the value's format
157
+ * is the only place both ends can read its name from.
158
+ */
159
+ HEADER: 'Idempotency-Key',
160
+ MAX_LENGTH: 256,
161
+ /** How long a stored 201 stays replayable. */
162
+ WINDOW_SECONDS: 24 * 60 * 60,
163
+ };
164
+ /**
165
+ * Normalize a `via` value from any transport — trimmed, lowercased, and a
166
+ * member of {@link DeploymentVia}, or `undefined`.
167
+ *
168
+ * A format rule by this package's own test: a client can decide offline
169
+ * whether a value is well-formed, and the API reaches the same verdict on the
170
+ * same input. It lived server-side until 2026-08-06, which meant clients could
171
+ * only learn their label was unusable by noticing analytics had gone quiet.
172
+ *
173
+ * **Not knowing your `via` is not an error** — an unrecognized value yields
174
+ * `undefined` rather than throwing, because origin tracking is telemetry and a
175
+ * deploy must never fail over it. A caller that has an honest default should
176
+ * prefer it (`normalizeVia(process.env.SHIP_VIA) ?? DeploymentVia.CLI`): the
177
+ * deploy really did come from the CLI, so recording that beats recording
178
+ * nothing.
179
+ */
180
+ export function normalizeVia(value) {
181
+ if (!value || typeof value !== 'string')
182
+ return undefined;
183
+ const via = value.trim().toLowerCase();
184
+ return Object.values(DeploymentVia).includes(via)
185
+ ? via
186
+ : undefined;
187
+ }
188
+ /**
189
+ * Validate an idempotency key, returning the trimmed value or `undefined`
190
+ * when none was supplied. Throws {@link ShipError.validation} when the value
191
+ * cannot be sent — the same verdict the API would reach, reached earlier.
192
+ */
193
+ export function validateIdempotencyKey(value) {
194
+ if (value === undefined || value === null)
195
+ return undefined;
196
+ if (typeof value !== 'string') {
197
+ throw ShipError.validation('Idempotency key must be a string.');
198
+ }
199
+ const key = value.trim();
200
+ if (!key) {
201
+ throw ShipError.validation('Idempotency key must not be empty.');
202
+ }
203
+ if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {
204
+ throw ShipError.validation(`Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`);
205
+ }
206
+ return key;
207
+ }
34
208
  // =============================================================================
35
209
  // ACCOUNT TYPES
36
210
  // =============================================================================
@@ -59,7 +233,15 @@ export const AccountPlan = {
59
233
  * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
60
234
  */
61
235
  export const ErrorType = {
62
- /** Validation failed (400). Input shape is wrong. */
236
+ /**
237
+ * Validation failed. Input shape is wrong.
238
+ *
239
+ * Carries 400 when an API judged it — including a client-side pre-check of a
240
+ * rule the server enforces too, which keeps the error identical wherever it
241
+ * was caught. **Statusless** when a client rejects something no API judges,
242
+ * such as a CLI's own command grammar: `status` is documented "(API
243
+ * contexts)" on `ErrorResponse`, so there is none to report.
244
+ */
63
245
  Validation: 'validation_failed',
64
246
  /** Resource not found (404). */
65
247
  NotFound: 'not_found',
@@ -100,11 +282,26 @@ const CLIENT_ONLY_ERROR_TYPES = new Set([
100
282
  * union so `.has(error.type)` accepts any value from the union.
101
283
  */
102
284
  const ERROR_CATEGORIES = {
285
+ /**
286
+ * Client-attributable types. Exhaustive over the 4xx-carrying types, and
287
+ * over the statusless ones too — those are raised locally and have no
288
+ * status for `isClientError`'s second arm to read, so omitting one makes it
289
+ * read as a server fault. The rule is the membership test: every type in
290
+ * `CLIENT_ONLY_ERROR_TYPES` except `Network` (which `isNetworkError` owns)
291
+ * belongs here.
292
+ *
293
+ * `Cancelled` was missing until 2026-07-29, which is exactly that failure:
294
+ * a caller who aborted their own deploy was told "server error: please try
295
+ * again" — the CLI's fallback for everything this set does not claim.
296
+ */
103
297
  client: new Set([
104
298
  ErrorType.Business,
299
+ ErrorType.Cancelled,
105
300
  ErrorType.Config,
106
301
  ErrorType.File,
107
302
  ErrorType.Forbidden,
303
+ ErrorType.NotFound,
304
+ ErrorType.RateLimit,
108
305
  ErrorType.Validation,
109
306
  ]),
110
307
  network: new Set([ErrorType.Network]),
@@ -118,6 +315,50 @@ const ERROR_CATEGORIES = {
118
315
  * `ErrorType` is automatically picked up.
119
316
  */
120
317
  const SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)));
318
+ /**
319
+ * Ceiling on a message adopted from a **non-JSON** error body — a foreign
320
+ * responder's, never this platform's. Generous for the plain-text one-liners
321
+ * intermediaries actually send (`error code: 1015`), far below a document.
322
+ * Our own messages are never measured against it: a JSON body is the API's
323
+ * contract, and truncating a long validation message would be the bug.
324
+ */
325
+ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
326
+ /**
327
+ * Did the runtime say the exchange never completed?
328
+ *
329
+ * WHATWG has `fetch` reject with a **TypeError** on network error, and undici,
330
+ * Chromium and Firefox comply. Bun does not: it rejects with a plain `Error`
331
+ * carrying a system `code` string. Captured 2026-08-05 (the capture script is
332
+ * in `tests/errors.test.ts`, "runtime failure shapes"):
333
+ *
334
+ * | failure | Node 22 / undici | Bun 1.3.14 |
335
+ * |---------------|---------------------------|----------------------------------------------|
336
+ * | refused | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
337
+ * | DNS failure | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
338
+ * | reset | `TypeError: fetch failed` | `Error` `code: 'ECONNRESET'` |
339
+ * | TLS rejected | `TypeError: fetch failed` | `Error` `code: 'UNKNOWN_CERTIFICATE_…ERROR'` |
340
+ *
341
+ * So the test is the **evidence, not a list of dialect strings**: a string
342
+ * `code` is a runtime naming a transport-level failure. An allowlist of codes
343
+ * was written first and rejected — the TLS row alone would mean enumerating
344
+ * BoringSSL's certificate table, and a code nobody guessed is precisely the bug
345
+ * this closes. Two kinds of error are deliberately NOT caught: ordinary JS
346
+ * faults carry no `code` at all, and a `DOMException`'s is a **number**, so
347
+ * aborts and timeouts fall through to their own arms.
348
+ *
349
+ * The accepted trade: a caller's `TokenProvider` that throws a coded error
350
+ * (`ENOENT` from a keychain read) is typed `Network` rather than `Api`. Both
351
+ * are wrong for it, `Network` is the cheaper wrong — it says "nothing was
352
+ * exchanged", which is true, where `Api` claims a server answered.
353
+ */
354
+ function isTransportFailure(cause) {
355
+ if (typeof cause.code === 'string')
356
+ return true;
357
+ // Spec runtimes put no code on the rejection itself. The message test is what
358
+ // keeps fetch's ARGUMENT errors out — `Failed to parse URL from …` is a
359
+ // caller's config mistake, not a transport failure.
360
+ return cause instanceof TypeError && cause.message.includes('fetch');
361
+ }
121
362
  /**
122
363
  * Simple unified error class for both API and SDK
123
364
  */
@@ -189,9 +430,17 @@ export class ShipError extends Error {
189
430
  }
190
431
  }
191
432
  else {
192
- const text = await response.text();
193
- if (text)
433
+ // A non-JSON body did not come from this platform — every API error
434
+ // is `ErrorResponse` JSON — so it is an intermediary's output, and
435
+ // the two kinds it produces need opposite treatment. A CDN's plain
436
+ // `error code: 1015` is the most useful thing there is to say. A
437
+ // proxy's HTML error page is a *document*, not a message: adopting it
438
+ // verbatim made a misconfigured `apiUrl` print 2,059 characters of
439
+ // markup as the error. Trust it only when it reads as a message.
440
+ const text = (await response.text()).trim();
441
+ if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {
194
442
  message = text;
443
+ }
195
444
  }
196
445
  }
197
446
  catch {
@@ -235,7 +484,8 @@ export class ShipError extends Error {
235
484
  * Routing:
236
485
  * - Already a `ShipError` → returned as-is (caller's intent preserved)
237
486
  * - `AbortError` → `ShipError.cancelled(...)`
238
- * - `TypeError` whose message mentions "fetch" → `ShipError.network(...)`
487
+ * - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
488
+ * for what each runtime offers as evidence
239
489
  * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
240
490
  * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
241
491
  *
@@ -251,7 +501,7 @@ export class ShipError extends Error {
251
501
  if (cause.name === 'AbortError') {
252
502
  return ShipError.cancelled(`${op} was cancelled`);
253
503
  }
254
- if (cause instanceof TypeError && cause.message.includes('fetch')) {
504
+ if (isTransportFailure(cause)) {
255
505
  return ShipError.network(`${op} failed: ${cause.message}`, { cause });
256
506
  }
257
507
  return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
@@ -309,10 +559,24 @@ export class ShipError extends Error {
309
559
  static api(message, status = 500, details) {
310
560
  return new ShipError(ErrorType.Api, message, status, details);
311
561
  }
312
- // Semantic-category type guards. For specific-type checks, use
562
+ // Semantic-category guards. For specific-type checks, use
313
563
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
564
+ /**
565
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
566
+ * that is client-attributable without ever having a status (`Config`,
567
+ * `File`, raised locally by the SDK).
568
+ *
569
+ * Both arms are load-bearing, because type and status are independent
570
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
571
+ * server-producible type; a non-OK response without one is status-derived,
572
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
573
+ * *type* carrying a client *status*. Judging by type alone would report it
574
+ * as a platform failure and bury the server's own message.
575
+ */
314
576
  isClientError() {
315
- return ERROR_CATEGORIES.client.has(this.type);
577
+ if (ERROR_CATEGORIES.client.has(this.type))
578
+ return true;
579
+ return this.status !== undefined && this.status >= 400 && this.status < 500;
316
580
  }
317
581
  isNetworkError() {
318
582
  return ERROR_CATEGORIES.network.has(this.type);
@@ -419,6 +683,125 @@ export function isBlockedExtension(filename) {
419
683
  return BLOCKED_EXTENSIONS.has(ext);
420
684
  }
421
685
  // =============================================================================
686
+ // PICKER ACCEPT HINT
687
+ // =============================================================================
688
+ /**
689
+ * The extensions a browser file picker offers by default, grouped by role.
690
+ *
691
+ * Private on purpose: the only published form is `WEB_FILE_ACCEPT`, the
692
+ * attribute value itself. A published set would invite a call site to ask it
693
+ * whether a file is allowed — which is the one thing this list must never
694
+ * answer. See `WEB_FILE_ACCEPT`.
695
+ *
696
+ * Extensionless files (`LICENSE`, most `.well-known` entries) are inexpressible
697
+ * in `accept`, and reach a deployment by folder pick, ZIP, or drag-and-drop.
698
+ */
699
+ const WEB_FILE_EXTENSIONS = [
700
+ // Markup & documents
701
+ 'html',
702
+ 'htm',
703
+ 'xhtml',
704
+ 'xml',
705
+ 'txt',
706
+ 'md',
707
+ 'markdown',
708
+ 'pdf',
709
+ 'csv',
710
+ // Data & config
711
+ 'json',
712
+ 'jsonc',
713
+ 'webmanifest',
714
+ 'map',
715
+ 'toml',
716
+ 'yaml',
717
+ 'yml',
718
+ 'rss',
719
+ 'atom',
720
+ // Styles
721
+ 'css',
722
+ 'scss',
723
+ 'sass',
724
+ 'less',
725
+ // Scripts & modules
726
+ 'js',
727
+ 'mjs',
728
+ 'cjs',
729
+ 'jsx',
730
+ 'ts',
731
+ 'tsx',
732
+ 'wasm',
733
+ 'vue',
734
+ 'svelte',
735
+ // Images
736
+ 'png',
737
+ 'jpg',
738
+ 'jpeg',
739
+ 'gif',
740
+ 'webp',
741
+ 'avif',
742
+ 'svg',
743
+ 'ico',
744
+ 'bmp',
745
+ 'tif',
746
+ 'tiff',
747
+ 'heic',
748
+ 'heif',
749
+ // Fonts
750
+ 'woff',
751
+ 'woff2',
752
+ 'ttf',
753
+ 'otf',
754
+ 'eot',
755
+ // Audio
756
+ 'mp3',
757
+ 'wav',
758
+ 'ogg',
759
+ 'oga',
760
+ 'opus',
761
+ 'm4a',
762
+ 'aac',
763
+ 'flac',
764
+ 'weba',
765
+ // Video
766
+ 'mp4',
767
+ 'webm',
768
+ 'ogv',
769
+ 'mov',
770
+ 'm4v',
771
+ 'avi',
772
+ // 3D models
773
+ 'glb',
774
+ 'gltf',
775
+ 'usdz',
776
+ // Text tracks
777
+ 'vtt',
778
+ 'srt',
779
+ // Archive — a whole site in one file
780
+ 'zip',
781
+ ];
782
+ /**
783
+ * The `accept` attribute value for a browser file picker offering web files.
784
+ *
785
+ * **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
786
+ * gate and the only thing that decides what may be hosted; this constant
787
+ * decides what a *file dialog* shows first. The two are not two halves of one
788
+ * policy, and this one must never be consulted to accept or reject a file.
789
+ *
790
+ * The distinction is structural, not stylistic. `accept` can express only an
791
+ * allowlist, while the platform's rule is a blocklist — so this list is
792
+ * necessarily *narrower* than what the platform hosts, and reading it as
793
+ * authority would reject files the platform serves happily. It is also not
794
+ * enforcement in the browser's own terms: every file dialog offers an
795
+ * all-files escape, and **drag-and-drop ignores `accept` entirely**. The
796
+ * dropzone and the picker must reach the same verdict on the same files, and
797
+ * they do — because the verdict is `validateFiles`, downstream of both.
798
+ *
799
+ * Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
800
+ * `tests/validation-constants.test.ts` fence the invariant that matters: the
801
+ * picker must never offer a file the platform will refuse.
802
+ */
803
+ export const WEB_FILE_ACCEPT = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
804
+ // =============================================================================
422
805
  // FILENAME CHARACTER VALIDATION
423
806
  // =============================================================================
424
807
  /**
@@ -600,6 +983,72 @@ export const DEPLOYMENT_CONFIG_FILENAME = 'ship.json';
600
983
  export const SPA_DEFAULT_CONFIG = {
601
984
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
602
985
  };
986
+ /**
987
+ * The `/spa-check` pre-flight's client-side envelope: which file is the
988
+ * check's subject, and how large it may be before a client skips the call.
989
+ *
990
+ * One fact with three holders until this export — the API's config declared
991
+ * the cap, the SDK's `checkSPA` hardcoded `100 * 1024`, and prose restated
992
+ * "100KB". `INDEX_FILE` is the selection rule (the file whose content rides
993
+ * `SPACheckRequest.index`), restated by every client that builds the request.
994
+ *
995
+ * Neither member is a validation boundary: a client over the cap simply
996
+ * skips the pre-flight, because the server answers an oversized index
997
+ * `isSPA: false` anyway. A consumer that cannot import this (n8n) needs no
998
+ * size copy at all — outcome parity is the server's, not the client's.
999
+ */
1000
+ export const SPA_CHECK_CONSTRAINTS = {
1001
+ /** The file whose content is the check's subject. */
1002
+ INDEX_FILE: 'index.html',
1003
+ /** Skip the pre-flight above this size — the server would answer false. */
1004
+ MAX_INDEX_BYTES: 100 * 1024,
1005
+ };
1006
+ /**
1007
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
1008
+ * never schema.
1009
+ *
1010
+ * ship.json is validated and compiled on the server, deliberately: the schema
1011
+ * and the compiler evolve, and a client that judged them would reject configs
1012
+ * a newer platform accepts. That reasoning bounds what a client may check to
1013
+ * the properties which are true of *every* past and future schema:
1014
+ *
1015
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
1016
+ * does not parse can never be a valid config;
1017
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
1018
+ *
1019
+ * Both are monotonic: neither can ever reject something the server would
1020
+ * accept. Everything beyond them (field names, types, rule semantics, which
1021
+ * keys are permitted) stays server-side, where it can change.
1022
+ *
1023
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
1024
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
1025
+ * documentation — mistakes that otherwise cost a full upload round-trip to
1026
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
1027
+ * before parsing rather than rejected, because the server accepts it too;
1028
+ * diverging there would reintroduce exactly the false rejection this
1029
+ * function exists to avoid.
1030
+ *
1031
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
1032
+ * config rejection carries, so the error contract is identical wherever the
1033
+ * failure is detected.
1034
+ */
1035
+ export function assertShipJsonSyntax(text) {
1036
+ const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
1037
+ let parsed;
1038
+ try {
1039
+ parsed = JSON.parse(withoutBom);
1040
+ }
1041
+ catch (error) {
1042
+ throw ShipError.config(`invalid JSON format in config: ${error.message}`, {
1043
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1044
+ });
1045
+ }
1046
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1047
+ throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
1048
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1049
+ });
1050
+ }
1051
+ }
603
1052
  // =============================================================================
604
1053
  // VALIDATION UTILITIES
605
1054
  // =============================================================================
@@ -696,6 +1145,50 @@ export function isDeployment(input) {
696
1145
  // =============================================================================
697
1146
  /** Default API URL if not otherwise configured. */
698
1147
  export const DEFAULT_API = 'https://api.shipstatic.com';
1148
+ /**
1149
+ * The Node SDK's ambient configuration pair — the ONLY environment variables
1150
+ * the SDK reads, and therefore the COMPLETE list an embedding host must
1151
+ * scrub (per `npm/ship`'s strict-isolation contract, scrubbing is the host's
1152
+ * job, not the SDK's). A host that derives its scrub from this object's
1153
+ * values — as the VS Code extension's child-process env block does — picks
1154
+ * up a grown contract at the next pin bump instead of by remembered prose.
1155
+ *
1156
+ * Browser builds read no environment at all, and the CLI-only variables
1157
+ * (`SHIP_PASSWORD`, `SHIP_VIA`) are deliberately NOT here: they are the
1158
+ * CLI's operational levers, not the SDK's ambient contract — see
1159
+ * `npm/ship/CLAUDE.md`, "CLI-only env vars".
1160
+ */
1161
+ export const SHIP_ENV = {
1162
+ /** The one credential slot — any platform token. */
1163
+ TOKEN: 'SHIP_TOKEN',
1164
+ /** The API endpoint override. */
1165
+ API_URL: 'SHIP_API_URL',
1166
+ };
1167
+ /**
1168
+ * Where a human creates an API key — the console deep link quoted by every
1169
+ * surface that teaches authentication (the CLI's config wizard, the VS Code
1170
+ * and n8n listings, the n8n rate-limit hint and credential copy). Written
1171
+ * out in five files across three repos until this export.
1172
+ *
1173
+ * Production-branded by design: published artifacts name the product, never
1174
+ * an environment (root `CLAUDE.md`, "Environment-Aware URLs").
1175
+ */
1176
+ export const MY_API_KEY_URL = 'https://my.shipstatic.com/api-key';
1177
+ /**
1178
+ * How long an anonymous deployment lives before it expires.
1179
+ *
1180
+ * The lifetime of the public tier, and one fact with several readers. The API
1181
+ * stamps a deployment's `expires` from it and gives a claim code exactly the
1182
+ * same window — a live site with a dead claim link is a coherence bug, so the
1183
+ * two are one constant rather than two that agree. Both MCP transports quote
1184
+ * the duration in prose an agent reads, and derive it from here rather than
1185
+ * writing it out, which they did in eight places until this export existed.
1186
+ *
1187
+ * Seconds, spelled in the name: this platform has both second- and
1188
+ * millisecond-valued durations, and the pair is only safe when each says which
1189
+ * it is.
1190
+ */
1191
+ export const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;
699
1192
  // =============================================================================
700
1193
  // FILE UPLOAD TYPES
701
1194
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.5.0-beta.2",
3
+ "version": "2.5.0-beta.21",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,7 @@
18
18
  "lint": "biome check .",
19
19
  "format": "biome format --write .",
20
20
  "prepare": "git config core.hooksPath scripts/githooks",
21
- "typecheck": "tsc --noEmit"
21
+ "typecheck": "tsc -p tsconfig.check.json --noEmit"
22
22
  },
23
23
  "packageManager": "pnpm@10.12.4",
24
24
  "files": [
@@ -44,8 +44,8 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@biomejs/biome": "2.5.5",
47
- "@types/node": "^24.10.9",
47
+ "@types/node": "^24.13.3",
48
48
  "typescript": "^5.9.3",
49
- "vitest": "^2.1.8"
49
+ "vitest": "^2.1.9"
50
50
  }
51
51
  }