@shipstatic/types 2.5.0-beta.2 → 2.5.0-beta.20
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 +16 -6
- package/dist/index.d.ts +577 -77
- package/dist/index.js +416 -7
- package/package.json +4 -4
- package/src/index.ts +815 -70
package/src/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The client/tool that created this deployment, null if unknown.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately wider than {@link DeploymentViaType}: this is stored data,
|
|
70
|
+
* and rows predate the vocabulary being closed. Narrowing the ENTITY would
|
|
71
|
+
* be a claim about every row already in the database; narrowing the
|
|
72
|
+
* REQUEST option ({@link DeploymentUploadOptions.via}) is a claim about
|
|
73
|
+
* what a client may send, which is ours to make.
|
|
74
|
+
*/
|
|
43
75
|
readonly via: string | null;
|
|
44
76
|
/** Unix timestamp (seconds) when deployment was created */
|
|
45
77
|
readonly created: number;
|
|
@@ -58,16 +90,125 @@ export interface DeploymentCreateResponse extends Deployment {
|
|
|
58
90
|
readonly claim?: string;
|
|
59
91
|
}
|
|
60
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Every path the public API answers on, declared once.
|
|
95
|
+
*
|
|
96
|
+
* The URL surface was written out in four places — the API's mounts, the
|
|
97
|
+
* SDK's client, the dashboard's client, and the post-deploy smoke — so a
|
|
98
|
+
* rename meant finding all four. The first three now read this table.
|
|
99
|
+
*
|
|
100
|
+
* The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:
|
|
101
|
+
* five of its nine paths are `/admin/*`, which this table excludes by
|
|
102
|
+
* design, and splitting one list between a registry and literals reads worse
|
|
103
|
+
* than keeping it uniform.
|
|
104
|
+
*
|
|
105
|
+
* **What this guarantees, exactly.** Collection paths are mounted from here,
|
|
106
|
+
* so producer and consumer cannot diverge. Item paths are declared here and
|
|
107
|
+
* consumed by clients, but the API spells them relative to their mount
|
|
108
|
+
* (`/:deployment/config`), so the table does not *generate* them — it is
|
|
109
|
+
* held to them by `api/tests/architecture/api-paths.test.ts`, which fails if
|
|
110
|
+
* any entry names a path no route answers. Some entries have no client yet
|
|
111
|
+
* (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK
|
|
112
|
+
* deliberately does not reach); the fence is what keeps those honest rather
|
|
113
|
+
* than merely asserted.
|
|
114
|
+
*
|
|
115
|
+
* **The operator surface is deliberately absent.** `/admin/*` paths belong
|
|
116
|
+
* to `web/my`, for the same reason its row types do: this package is
|
|
117
|
+
* published, and the operator surface is not public (see `CLAUDE.md`, "Admin
|
|
118
|
+
* types"). A path here is a promise to every npm consumer; `/admin` is a
|
|
119
|
+
* promise to one dashboard.
|
|
120
|
+
*
|
|
121
|
+
* Item paths are functions rather than templates so the key is interpolated
|
|
122
|
+
* in one place, encoded the same way by every caller.
|
|
123
|
+
*/
|
|
124
|
+
export const API_PATHS = {
|
|
125
|
+
DEPLOYMENTS: '/deployments',
|
|
126
|
+
DEPLOYMENT: (deployment: string) => `/deployments/${deployment}`,
|
|
127
|
+
DEPLOYMENT_CONFIG: (deployment: string) => `/deployments/${deployment}/config`,
|
|
128
|
+
DOMAINS: '/domains',
|
|
129
|
+
DOMAIN: (domain: string) => `/domains/${domain}`,
|
|
130
|
+
DOMAIN_VERIFY: (domain: string) => `/domains/${domain}/verify`,
|
|
131
|
+
DOMAIN_DNS: (domain: string) => `/domains/${domain}/dns`,
|
|
132
|
+
DOMAIN_RECORDS: (domain: string) => `/domains/${domain}/records`,
|
|
133
|
+
DOMAIN_SHARE: (domain: string) => `/domains/${domain}/share`,
|
|
134
|
+
DOMAIN_PROPAGATION: (domain: string) => `/domains/${domain}/propagation`,
|
|
135
|
+
DOMAINS_VALIDATE: '/domains/validate',
|
|
136
|
+
TOKENS: '/tokens',
|
|
137
|
+
TOKEN: (token: string) => `/tokens/${token}`,
|
|
138
|
+
ACCOUNT: '/account',
|
|
139
|
+
ACCOUNT_KEY: '/account/key',
|
|
140
|
+
ACCOUNT_CLAIM: '/account/claim',
|
|
141
|
+
ACTIVITIES: '/activities',
|
|
142
|
+
LABELS: '/labels',
|
|
143
|
+
LIMITS: '/limits',
|
|
144
|
+
PING: '/ping',
|
|
145
|
+
SETUP: '/setup',
|
|
146
|
+
SPA_CHECK: '/spa-check',
|
|
147
|
+
UPLOAD: '/upload',
|
|
148
|
+
} as const;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The half of a list response that is identical on every list.
|
|
152
|
+
*
|
|
153
|
+
* `GET /<collection>` answers exactly two fields — the collection under its
|
|
154
|
+
* own plural noun, and this cursor — so the cursor is declared once here and
|
|
155
|
+
* each response below adds only its noun. `cursor: null` means last page and
|
|
156
|
+
* is the ENTIRE has-more signal, which is why there is no `has_more`.
|
|
157
|
+
*
|
|
158
|
+
* There is deliberately no `total`. A count is an aggregate over a
|
|
159
|
+
* collection, not a property of a page; producing one would cost a COUNT
|
|
160
|
+
* beside every page read, which is precisely what keyset pagination exists
|
|
161
|
+
* to avoid. Counts live on the resource that summarises the collection —
|
|
162
|
+
* `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
|
|
163
|
+
*/
|
|
164
|
+
export interface ListResponse {
|
|
165
|
+
/** Opaque cursor from this page; `null` on the last page. */
|
|
166
|
+
cursor: string | null;
|
|
167
|
+
}
|
|
168
|
+
|
|
61
169
|
/**
|
|
62
170
|
* Response for listing deployments
|
|
63
171
|
*/
|
|
64
|
-
export interface DeploymentListResponse {
|
|
172
|
+
export interface DeploymentListResponse extends ListResponse {
|
|
65
173
|
/** Array of deployments */
|
|
66
174
|
deployments: Deployment[];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Acknowledgement of `DELETE /deployments/:deployment` — and the shape every
|
|
179
|
+
* mutation with no entity left to return follows.
|
|
180
|
+
*
|
|
181
|
+
* **The law:** a mutation answers with the resource it affected. If the
|
|
182
|
+
* resource still exists, that means the entity itself (`Deployment`,
|
|
183
|
+
* `Domain`, …). Otherwise it means this: the resource noun carrying the
|
|
184
|
+
* item's canonical key, plus the resource's own state field — and ONLY when
|
|
185
|
+
* the resource survived in a transitional state, as an async deletion's does.
|
|
186
|
+
* Where the resource is simply gone, the key alone is the whole answer
|
|
187
|
+
* ({@link DomainDeleteResponse}, {@link TokenDeleteResponse}).
|
|
188
|
+
*
|
|
189
|
+
* Put positively: **an acknowledgement is a projection of the resource** —
|
|
190
|
+
* its key, plus its own state field where the state changed. That is the
|
|
191
|
+
* test to apply, and it is sharper than "no constant", which this shape
|
|
192
|
+
* would fail on its own terms: `status` here is the literal `'deleting'` on
|
|
193
|
+
* every success, exactly as fixed as a `changed: true` would be.
|
|
194
|
+
*
|
|
195
|
+
* The difference is not how predictable the value is, it is what the field
|
|
196
|
+
* IS. `status` is the deployment's own field — the same one `GET
|
|
197
|
+
* /deployments/:deployment` returns — so this response is `Deployment`
|
|
198
|
+
* narrowed to two members, and a client renders it with the code it already
|
|
199
|
+
* has. `changed: true`, `queued: true` and `success: true` are not fields of
|
|
200
|
+
* any entity; they exist only to assert that the call worked, which the
|
|
201
|
+
* status code already said. Sync versus accepted is likewise the status
|
|
202
|
+
* code's job — 200 versus 202 — not a boolean's.
|
|
203
|
+
*
|
|
204
|
+
* No prose either (`message`): an acknowledgement is data, and each surface
|
|
205
|
+
* composes its own copy.
|
|
206
|
+
*/
|
|
207
|
+
export interface DeploymentDeleteResponse {
|
|
208
|
+
/** The deployment hostname that was marked for removal */
|
|
209
|
+
readonly deployment: string;
|
|
210
|
+
/** The state the deployment is in while background cleanup runs */
|
|
211
|
+
readonly status: DeploymentStatusType;
|
|
71
212
|
}
|
|
72
213
|
|
|
73
214
|
// =============================================================================
|
|
@@ -107,7 +248,7 @@ export interface Domain {
|
|
|
107
248
|
labels: string[];
|
|
108
249
|
/** Unix timestamp (seconds) when domain was created */
|
|
109
250
|
readonly created: number;
|
|
110
|
-
/**
|
|
251
|
+
/** Unix timestamp (seconds) when deployment was last linked, null if never linked */
|
|
111
252
|
linked: number | null;
|
|
112
253
|
/** Total deployment links */
|
|
113
254
|
links: number;
|
|
@@ -131,13 +272,30 @@ export interface DomainSetResult extends Domain {
|
|
|
131
272
|
/**
|
|
132
273
|
* Response for listing domains
|
|
133
274
|
*/
|
|
134
|
-
export interface DomainListResponse {
|
|
275
|
+
export interface DomainListResponse extends ListResponse {
|
|
135
276
|
/** Array of domains */
|
|
136
277
|
domains: Domain[];
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Acknowledgement of `DELETE /domains/:domain`. The row is gone, so there is
|
|
282
|
+
* no state to state — the canonical domain name is the whole answer. See
|
|
283
|
+
* {@link DeploymentDeleteResponse} for the law.
|
|
284
|
+
*/
|
|
285
|
+
export interface DomainDeleteResponse {
|
|
286
|
+
/** The domain name that was removed, normalized */
|
|
287
|
+
readonly domain: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Acknowledgement of `POST /domains/:domain/verify` (202). The DNS check is
|
|
292
|
+
* queued, not performed — the accepted status code says so, and the domain's
|
|
293
|
+
* own status is unchanged until the check runs, which is why none is stated
|
|
294
|
+
* here. See {@link DeploymentDeleteResponse} for the law.
|
|
295
|
+
*/
|
|
296
|
+
export interface DomainVerifyResponse {
|
|
297
|
+
/** The domain whose DNS verification was queued, normalized */
|
|
298
|
+
readonly domain: string;
|
|
141
299
|
}
|
|
142
300
|
|
|
143
301
|
/**
|
|
@@ -168,15 +326,49 @@ export interface DnsProvider {
|
|
|
168
326
|
/**
|
|
169
327
|
* Response for domain DNS provider lookup
|
|
170
328
|
*/
|
|
329
|
+
/**
|
|
330
|
+
* What a DNS lookup found for a domain. An envelope rather than a bare
|
|
331
|
+
* {@link DnsProvider} because a lookup can succeed and learn more than the
|
|
332
|
+
* provider later; the shape is named so a consumer can hold one.
|
|
333
|
+
*/
|
|
334
|
+
export interface DnsLookup {
|
|
335
|
+
/** The provider serving this domain's DNS, absent when unidentified */
|
|
336
|
+
provider?: DnsProvider;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
341
|
+
* "A report answers a question").
|
|
342
|
+
*/
|
|
171
343
|
export interface DomainDnsResponse {
|
|
172
344
|
/** The domain name */
|
|
173
345
|
domain: string;
|
|
174
346
|
/** DNS provider information, null if not yet looked up */
|
|
175
|
-
dns:
|
|
347
|
+
dns: DnsLookup | null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Response for `GET /domains/:domain/share` — the domain plus the salted
|
|
352
|
+
* hash that lets someone else complete its DNS setup without an account.
|
|
353
|
+
*
|
|
354
|
+
* `/admin/domains/:domain/share` answers the same shape, which is the admin
|
|
355
|
+
* law working: the operator surface is the public grammar with a prefix.
|
|
356
|
+
*
|
|
357
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
358
|
+
* "A report answers a question").
|
|
359
|
+
*/
|
|
360
|
+
export interface DomainShareResponse {
|
|
361
|
+
/** The domain the setup link is for */
|
|
362
|
+
readonly domain: string;
|
|
363
|
+
/** The salted setup hash that authorizes the share */
|
|
364
|
+
readonly hash: string;
|
|
176
365
|
}
|
|
177
366
|
|
|
178
367
|
/**
|
|
179
368
|
* Response for domain DNS records
|
|
369
|
+
*
|
|
370
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
371
|
+
* "A report answers a question").
|
|
180
372
|
*/
|
|
181
373
|
export interface DomainRecordsResponse {
|
|
182
374
|
/** The domain name */
|
|
@@ -188,7 +380,118 @@ export interface DomainRecordsResponse {
|
|
|
188
380
|
}
|
|
189
381
|
|
|
190
382
|
/**
|
|
191
|
-
*
|
|
383
|
+
* The envelope an `Idempotency-Key` must fit, and how long a replay lasts.
|
|
384
|
+
*
|
|
385
|
+
* Format lives here rather than on the server alone by the format-vs-policy
|
|
386
|
+
* rule: a client can decide offline whether a key is well-formed, and the
|
|
387
|
+
* API would reject the same value the same way.
|
|
388
|
+
*/
|
|
389
|
+
export const IDEMPOTENCY_KEY_CONSTRAINTS = {
|
|
390
|
+
/**
|
|
391
|
+
* HTTP header name. Here for the same reason {@link CALLER.HEADER} is: a
|
|
392
|
+
* wire header has two ends, and the package that owns the value's format
|
|
393
|
+
* is the only place both ends can read its name from.
|
|
394
|
+
*/
|
|
395
|
+
HEADER: 'Idempotency-Key',
|
|
396
|
+
MAX_LENGTH: 256,
|
|
397
|
+
/** How long a stored 201 stays replayable. */
|
|
398
|
+
WINDOW_SECONDS: 24 * 60 * 60,
|
|
399
|
+
} as const;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Normalize a `via` value from any transport — trimmed, lowercased, and a
|
|
403
|
+
* member of {@link DeploymentVia}, or `undefined`.
|
|
404
|
+
*
|
|
405
|
+
* A format rule by this package's own test: a client can decide offline
|
|
406
|
+
* whether a value is well-formed, and the API reaches the same verdict on the
|
|
407
|
+
* same input. It lived server-side until 2026-08-06, which meant clients could
|
|
408
|
+
* only learn their label was unusable by noticing analytics had gone quiet.
|
|
409
|
+
*
|
|
410
|
+
* **Not knowing your `via` is not an error** — an unrecognized value yields
|
|
411
|
+
* `undefined` rather than throwing, because origin tracking is telemetry and a
|
|
412
|
+
* deploy must never fail over it. A caller that has an honest default should
|
|
413
|
+
* prefer it (`normalizeVia(process.env.SHIP_VIA) ?? DeploymentVia.CLI`): the
|
|
414
|
+
* deploy really did come from the CLI, so recording that beats recording
|
|
415
|
+
* nothing.
|
|
416
|
+
*/
|
|
417
|
+
export function normalizeVia(value: unknown): DeploymentViaType | undefined {
|
|
418
|
+
if (!value || typeof value !== 'string') return undefined;
|
|
419
|
+
const via = value.trim().toLowerCase();
|
|
420
|
+
return (Object.values(DeploymentVia) as string[]).includes(via)
|
|
421
|
+
? (via as DeploymentViaType)
|
|
422
|
+
: undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Validate an idempotency key, returning the trimmed value or `undefined`
|
|
427
|
+
* when none was supplied. Throws {@link ShipError.validation} when the value
|
|
428
|
+
* cannot be sent — the same verdict the API would reach, reached earlier.
|
|
429
|
+
*/
|
|
430
|
+
export function validateIdempotencyKey(value: unknown): string | undefined {
|
|
431
|
+
if (value === undefined || value === null) return undefined;
|
|
432
|
+
if (typeof value !== 'string') {
|
|
433
|
+
throw ShipError.validation('Idempotency key must be a string.');
|
|
434
|
+
}
|
|
435
|
+
const key = value.trim();
|
|
436
|
+
if (!key) {
|
|
437
|
+
throw ShipError.validation('Idempotency key must not be empty.');
|
|
438
|
+
}
|
|
439
|
+
if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {
|
|
440
|
+
throw ShipError.validation(
|
|
441
|
+
`Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return key;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Response for `GET /labels` — every label in use across the caller's
|
|
449
|
+
* deployments, domains and tokens, grouped and ordered by last use.
|
|
450
|
+
*
|
|
451
|
+
* The one plural noun outside the list contract, deliberately: labels have
|
|
452
|
+
* no identity, no row and no `created`, so there is nothing for a keyset
|
|
453
|
+
* cursor to resume after, and its consumer is an autocomplete that wants the
|
|
454
|
+
* whole set. Bounded by `PAGINATION.GLOBAL_LIMIT` rather than paginated.
|
|
455
|
+
*
|
|
456
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
457
|
+
* "A report answers a question").
|
|
458
|
+
*/
|
|
459
|
+
export interface LabelsResponse {
|
|
460
|
+
readonly labels: string[];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Response for `POST /setup` — the DNS instructions for one domain, written
|
|
465
|
+
* for a human to follow at their registrar.
|
|
466
|
+
*
|
|
467
|
+
* `custom` is the provider-specific walkthrough when the provider is known;
|
|
468
|
+
* `generic` always answers, so a caller never has nothing to show.
|
|
469
|
+
*
|
|
470
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
471
|
+
* "A report answers a question").
|
|
472
|
+
*/
|
|
473
|
+
export interface SetupInstructionsResponse {
|
|
474
|
+
/** The domain the instructions are for — a report names its subject */
|
|
475
|
+
readonly domain: string;
|
|
476
|
+
/** One-line summary of what to do */
|
|
477
|
+
readonly tldr: string;
|
|
478
|
+
/** Provider-specific instructions, null when the provider is unknown */
|
|
479
|
+
readonly custom: string | null;
|
|
480
|
+
/** Provider-agnostic instructions — always present */
|
|
481
|
+
readonly generic: string;
|
|
482
|
+
/** The identified DNS provider, null when unknown */
|
|
483
|
+
readonly provider: string | null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* `POST /domains/validate` — a report answering "is this name usable, and if
|
|
488
|
+
* not, why".
|
|
489
|
+
*
|
|
490
|
+
* An unusable name is a legitimate ANSWER, not a failure, so this is a 200 and
|
|
491
|
+
* the verdict rides the body. `reason` was named `error` until 2026-07-29,
|
|
492
|
+
* which collided with {@link ErrorResponse}'s reserved key — there `error` is
|
|
493
|
+
* an `ErrorType` a client branches on, here it is prose a client displays, and
|
|
494
|
+
* one key cannot mean both. See {@link DeploymentDeleteResponse} for the law.
|
|
192
495
|
*/
|
|
193
496
|
export interface DomainValidateResponse {
|
|
194
497
|
/** Whether the domain is valid */
|
|
@@ -197,8 +500,8 @@ export interface DomainValidateResponse {
|
|
|
197
500
|
normalized: string | null;
|
|
198
501
|
/** Whether the domain is available, null when invalid */
|
|
199
502
|
available: boolean | null;
|
|
200
|
-
/**
|
|
201
|
-
|
|
503
|
+
/** Why the name is unusable, null when valid — displayed verbatim. */
|
|
504
|
+
reason: string | null;
|
|
202
505
|
}
|
|
203
506
|
|
|
204
507
|
// =============================================================================
|
|
@@ -206,11 +509,13 @@ export interface DomainValidateResponse {
|
|
|
206
509
|
// =============================================================================
|
|
207
510
|
|
|
208
511
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
512
|
+
* Core deploy token object - used in both API responses and SDK.
|
|
513
|
+
*
|
|
514
|
+
* The secret is never here: it is shown once at creation
|
|
515
|
+
* ({@link TokenCreateResponse.secret}) and never again, so an entity read
|
|
516
|
+
* carries only the management identifier and lifecycle metadata.
|
|
212
517
|
*/
|
|
213
|
-
export interface
|
|
518
|
+
export interface Token {
|
|
214
519
|
/** 7-char management identifier (e.g., "a1b2c3d") */
|
|
215
520
|
readonly token: string;
|
|
216
521
|
/** Labels for categorization and filtering. Always present, empty array when none. */
|
|
@@ -226,25 +531,30 @@ export interface TokenListItem {
|
|
|
226
531
|
/**
|
|
227
532
|
* Response for listing tokens
|
|
228
533
|
*/
|
|
229
|
-
export interface TokenListResponse {
|
|
230
|
-
/** Array of tokens (
|
|
231
|
-
tokens:
|
|
232
|
-
/** Total number of tokens */
|
|
233
|
-
total: number;
|
|
534
|
+
export interface TokenListResponse extends ListResponse {
|
|
535
|
+
/** Array of tokens (the secret is never among them) */
|
|
536
|
+
tokens: Token[];
|
|
234
537
|
}
|
|
235
538
|
|
|
236
539
|
/**
|
|
237
|
-
* Response
|
|
540
|
+
* Response from token creation. Extends Token with the one field that
|
|
541
|
+
* exists only on creation — the same shape as
|
|
542
|
+
* {@link DeploymentCreateResponse}, because a 201 returns the resource it
|
|
543
|
+
* created plus whatever is knowable only once.
|
|
238
544
|
*/
|
|
239
|
-
export interface TokenCreateResponse {
|
|
240
|
-
/** 7-char management identifier */
|
|
241
|
-
token: string;
|
|
545
|
+
export interface TokenCreateResponse extends Token {
|
|
242
546
|
/** The raw credential value (shown once at creation, then never again) */
|
|
243
|
-
secret: string;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
547
|
+
readonly secret: string;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Acknowledgement of `DELETE /tokens/:token`. The credential is revoked and
|
|
552
|
+
* its row is gone, so the management identifier is the whole answer. See
|
|
553
|
+
* {@link DeploymentDeleteResponse} for the law.
|
|
554
|
+
*/
|
|
555
|
+
export interface TokenDeleteResponse {
|
|
556
|
+
/** The 7-char management identifier that was revoked */
|
|
557
|
+
readonly token: string;
|
|
248
558
|
}
|
|
249
559
|
|
|
250
560
|
// =============================================================================
|
|
@@ -268,10 +578,34 @@ export type AccountPlanType = (typeof AccountPlan)[keyof typeof AccountPlan];
|
|
|
268
578
|
|
|
269
579
|
/**
|
|
270
580
|
* Account usage metrics — always available regardless of billing provider.
|
|
581
|
+
*
|
|
582
|
+
* This is where a caller's own totals live. Lists answer pages and carry no
|
|
583
|
+
* `total` (see {@link ListOptions}); a count is an aggregate over a
|
|
584
|
+
* collection, so it belongs to the summary resource that owns the
|
|
585
|
+
* collection. `GET /account` is that resource for one caller, `GET
|
|
586
|
+
* /admin/stats` for the platform.
|
|
587
|
+
*
|
|
588
|
+
* The counted dimensions are the ones the plan caps — deployments and
|
|
589
|
+
* domains (`PlatformLimits`) — plus the billable custom-domain subset, so a
|
|
590
|
+
* surface can render "3 of 10" without a second request.
|
|
271
591
|
*/
|
|
272
592
|
export interface AccountUsage {
|
|
273
593
|
/** Number of active custom domains (excludes paused) */
|
|
274
594
|
customDomains: number;
|
|
595
|
+
/**
|
|
596
|
+
* Deployments counted against the plan's deployment cap — every row
|
|
597
|
+
* whatever its status, because that is what the cap counts, so a surface
|
|
598
|
+
* renders "3 of 10" against the denominator the 403 divides by. (`GET
|
|
599
|
+
* /deployments` lists successful ones only; that is a different question
|
|
600
|
+
* asked of a different resource.) Optional by the additive-evolution law:
|
|
601
|
+
* an API predating this field omits it.
|
|
602
|
+
*/
|
|
603
|
+
deployments?: number;
|
|
604
|
+
/**
|
|
605
|
+
* Domains counted against the plan's domain cap — every domain, platform
|
|
606
|
+
* and custom alike, unlike `customDomains`. Optional for the same reason.
|
|
607
|
+
*/
|
|
608
|
+
domains?: number;
|
|
275
609
|
}
|
|
276
610
|
|
|
277
611
|
/**
|
|
@@ -321,6 +655,37 @@ export interface AccountGetResponse extends Account {
|
|
|
321
655
|
readonly impersonatedBy?: string;
|
|
322
656
|
}
|
|
323
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Acknowledgement of `DELETE /account` (202). Termination is asynchronous —
|
|
660
|
+
* a cleanup consumer finishes the job — so the account survives long enough
|
|
661
|
+
* to state the plan it is transitioning through. `plan` is the account's
|
|
662
|
+
* state field, the way `status` is a deployment's. See
|
|
663
|
+
* {@link DeploymentDeleteResponse} for the law.
|
|
664
|
+
*/
|
|
665
|
+
export interface AccountDeleteResponse {
|
|
666
|
+
/** The account that was marked for termination */
|
|
667
|
+
readonly account: string;
|
|
668
|
+
/** The plan the account is in while cleanup runs */
|
|
669
|
+
readonly plan: AccountPlanType;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Response from `PUT /account/key` — the account's single API key, minted in
|
|
674
|
+
* place of whatever was there before.
|
|
675
|
+
*
|
|
676
|
+
* There is no entity to return: only the key's last-4 `hint` is durable
|
|
677
|
+
* (`Account.hint`), and the plaintext exists exactly once, in this response.
|
|
678
|
+
* The raw credential is `secret` on every surface that mints one — the same
|
|
679
|
+
* field `TokenCreateResponse` carries — because one concept gets one name.
|
|
680
|
+
*
|
|
681
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
682
|
+
* "A report answers a question").
|
|
683
|
+
*/
|
|
684
|
+
export interface AccountKeyResponse {
|
|
685
|
+
/** The raw API key (shown once at mint, then never again) */
|
|
686
|
+
readonly secret: string;
|
|
687
|
+
}
|
|
688
|
+
|
|
324
689
|
/**
|
|
325
690
|
* Account-specific configuration overrides
|
|
326
691
|
* Allows per-account customization of limits without changing plan
|
|
@@ -352,7 +717,15 @@ export interface AccountOverrides {
|
|
|
352
717
|
* (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.
|
|
353
718
|
*/
|
|
354
719
|
export const ErrorType = {
|
|
355
|
-
/**
|
|
720
|
+
/**
|
|
721
|
+
* Validation failed. Input shape is wrong.
|
|
722
|
+
*
|
|
723
|
+
* Carries 400 when an API judged it — including a client-side pre-check of a
|
|
724
|
+
* rule the server enforces too, which keeps the error identical wherever it
|
|
725
|
+
* was caught. **Statusless** when a client rejects something no API judges,
|
|
726
|
+
* such as a CLI's own command grammar: `status` is documented "(API
|
|
727
|
+
* contexts)" on `ErrorResponse`, so there is none to report.
|
|
728
|
+
*/
|
|
356
729
|
Validation: 'validation_failed',
|
|
357
730
|
/** Resource not found (404). */
|
|
358
731
|
NotFound: 'not_found',
|
|
@@ -397,11 +770,26 @@ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
|
|
|
397
770
|
* union so `.has(error.type)` accepts any value from the union.
|
|
398
771
|
*/
|
|
399
772
|
const ERROR_CATEGORIES = {
|
|
773
|
+
/**
|
|
774
|
+
* Client-attributable types. Exhaustive over the 4xx-carrying types, and
|
|
775
|
+
* over the statusless ones too — those are raised locally and have no
|
|
776
|
+
* status for `isClientError`'s second arm to read, so omitting one makes it
|
|
777
|
+
* read as a server fault. The rule is the membership test: every type in
|
|
778
|
+
* `CLIENT_ONLY_ERROR_TYPES` except `Network` (which `isNetworkError` owns)
|
|
779
|
+
* belongs here.
|
|
780
|
+
*
|
|
781
|
+
* `Cancelled` was missing until 2026-07-29, which is exactly that failure:
|
|
782
|
+
* a caller who aborted their own deploy was told "server error: please try
|
|
783
|
+
* again" — the CLI's fallback for everything this set does not claim.
|
|
784
|
+
*/
|
|
400
785
|
client: new Set<ErrorType>([
|
|
401
786
|
ErrorType.Business,
|
|
787
|
+
ErrorType.Cancelled,
|
|
402
788
|
ErrorType.Config,
|
|
403
789
|
ErrorType.File,
|
|
404
790
|
ErrorType.Forbidden,
|
|
791
|
+
ErrorType.NotFound,
|
|
792
|
+
ErrorType.RateLimit,
|
|
405
793
|
ErrorType.Validation,
|
|
406
794
|
]),
|
|
407
795
|
network: new Set<ErrorType>([ErrorType.Network]),
|
|
@@ -419,6 +807,51 @@ const SERVER_PRODUCIBLE_ERROR_TYPES = new Set<string>(
|
|
|
419
807
|
Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)),
|
|
420
808
|
);
|
|
421
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Ceiling on a message adopted from a **non-JSON** error body — a foreign
|
|
812
|
+
* responder's, never this platform's. Generous for the plain-text one-liners
|
|
813
|
+
* intermediaries actually send (`error code: 1015`), far below a document.
|
|
814
|
+
* Our own messages are never measured against it: a JSON body is the API's
|
|
815
|
+
* contract, and truncating a long validation message would be the bug.
|
|
816
|
+
*/
|
|
817
|
+
const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Did the runtime say the exchange never completed?
|
|
821
|
+
*
|
|
822
|
+
* WHATWG has `fetch` reject with a **TypeError** on network error, and undici,
|
|
823
|
+
* Chromium and Firefox comply. Bun does not: it rejects with a plain `Error`
|
|
824
|
+
* carrying a system `code` string. Captured 2026-08-05 (the capture script is
|
|
825
|
+
* in `tests/errors.test.ts`, "runtime failure shapes"):
|
|
826
|
+
*
|
|
827
|
+
* | failure | Node 22 / undici | Bun 1.3.14 |
|
|
828
|
+
* |---------------|---------------------------|----------------------------------------------|
|
|
829
|
+
* | refused | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
|
|
830
|
+
* | DNS failure | `TypeError: fetch failed` | `Error` `code: 'ConnectionRefused'` |
|
|
831
|
+
* | reset | `TypeError: fetch failed` | `Error` `code: 'ECONNRESET'` |
|
|
832
|
+
* | TLS rejected | `TypeError: fetch failed` | `Error` `code: 'UNKNOWN_CERTIFICATE_…ERROR'` |
|
|
833
|
+
*
|
|
834
|
+
* So the test is the **evidence, not a list of dialect strings**: a string
|
|
835
|
+
* `code` is a runtime naming a transport-level failure. An allowlist of codes
|
|
836
|
+
* was written first and rejected — the TLS row alone would mean enumerating
|
|
837
|
+
* BoringSSL's certificate table, and a code nobody guessed is precisely the bug
|
|
838
|
+
* this closes. Two kinds of error are deliberately NOT caught: ordinary JS
|
|
839
|
+
* faults carry no `code` at all, and a `DOMException`'s is a **number**, so
|
|
840
|
+
* aborts and timeouts fall through to their own arms.
|
|
841
|
+
*
|
|
842
|
+
* The accepted trade: a caller's `TokenProvider` that throws a coded error
|
|
843
|
+
* (`ENOENT` from a keychain read) is typed `Network` rather than `Api`. Both
|
|
844
|
+
* are wrong for it, `Network` is the cheaper wrong — it says "nothing was
|
|
845
|
+
* exchanged", which is true, where `Api` claims a server answered.
|
|
846
|
+
*/
|
|
847
|
+
function isTransportFailure(cause: Error): boolean {
|
|
848
|
+
if (typeof (cause as { code?: unknown }).code === 'string') return true;
|
|
849
|
+
// Spec runtimes put no code on the rejection itself. The message test is what
|
|
850
|
+
// keeps fetch's ARGUMENT errors out — `Failed to parse URL from …` is a
|
|
851
|
+
// caller's config mistake, not a transport failure.
|
|
852
|
+
return cause instanceof TypeError && cause.message.includes('fetch');
|
|
853
|
+
}
|
|
854
|
+
|
|
422
855
|
/**
|
|
423
856
|
* Standard error response format used everywhere
|
|
424
857
|
*/
|
|
@@ -505,8 +938,17 @@ export class ShipError extends Error {
|
|
|
505
938
|
}
|
|
506
939
|
}
|
|
507
940
|
} else {
|
|
508
|
-
|
|
509
|
-
|
|
941
|
+
// A non-JSON body did not come from this platform — every API error
|
|
942
|
+
// is `ErrorResponse` JSON — so it is an intermediary's output, and
|
|
943
|
+
// the two kinds it produces need opposite treatment. A CDN's plain
|
|
944
|
+
// `error code: 1015` is the most useful thing there is to say. A
|
|
945
|
+
// proxy's HTML error page is a *document*, not a message: adopting it
|
|
946
|
+
// verbatim made a misconfigured `apiUrl` print 2,059 characters of
|
|
947
|
+
// markup as the error. Trust it only when it reads as a message.
|
|
948
|
+
const text = (await response.text()).trim();
|
|
949
|
+
if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {
|
|
950
|
+
message = text;
|
|
951
|
+
}
|
|
510
952
|
}
|
|
511
953
|
} catch {
|
|
512
954
|
// Body unreadable; fall through to operationName-derived message.
|
|
@@ -556,7 +998,8 @@ export class ShipError extends Error {
|
|
|
556
998
|
* Routing:
|
|
557
999
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
558
1000
|
* - `AbortError` → `ShipError.cancelled(...)`
|
|
559
|
-
* -
|
|
1001
|
+
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
1002
|
+
* for what each runtime offers as evidence
|
|
560
1003
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
561
1004
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
562
1005
|
*
|
|
@@ -573,7 +1016,7 @@ export class ShipError extends Error {
|
|
|
573
1016
|
if (cause.name === 'AbortError') {
|
|
574
1017
|
return ShipError.cancelled(`${op} was cancelled`);
|
|
575
1018
|
}
|
|
576
|
-
if (cause
|
|
1019
|
+
if (isTransportFailure(cause)) {
|
|
577
1020
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
578
1021
|
}
|
|
579
1022
|
return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);
|
|
@@ -645,10 +1088,24 @@ export class ShipError extends Error {
|
|
|
645
1088
|
return new ShipError(ErrorType.Api, message, status, details);
|
|
646
1089
|
}
|
|
647
1090
|
|
|
648
|
-
// Semantic-category
|
|
1091
|
+
// Semantic-category guards. For specific-type checks, use
|
|
649
1092
|
// `error.type === ErrorType.X` directly or the generic `isType(t)`.
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* The caller is at fault — by HTTP's own definition of a 4xx, or by a type
|
|
1096
|
+
* that is client-attributable without ever having a status (`Config`,
|
|
1097
|
+
* `File`, raised locally by the SDK).
|
|
1098
|
+
*
|
|
1099
|
+
* Both arms are load-bearing, because type and status are independent
|
|
1100
|
+
* axes. `fromHttpResponse` trusts `body.error` only when it names a
|
|
1101
|
+
* server-producible type; a non-OK response without one is status-derived,
|
|
1102
|
+
* so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
|
|
1103
|
+
* *type* carrying a client *status*. Judging by type alone would report it
|
|
1104
|
+
* as a platform failure and bury the server's own message.
|
|
1105
|
+
*/
|
|
650
1106
|
isClientError(): boolean {
|
|
651
|
-
|
|
1107
|
+
if (ERROR_CATEGORIES.client.has(this.type)) return true;
|
|
1108
|
+
return this.status !== undefined && this.status >= 400 && this.status < 500;
|
|
652
1109
|
}
|
|
653
1110
|
|
|
654
1111
|
isNetworkError(): boolean {
|
|
@@ -698,6 +1155,9 @@ export function isShipError(error: unknown): error is ShipError {
|
|
|
698
1155
|
*
|
|
699
1156
|
* These are the *platform's* posted caps for the current account — server
|
|
700
1157
|
* truth delivered at runtime, never hard-coded on the client.
|
|
1158
|
+
*
|
|
1159
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
1160
|
+
* "A report answers a question").
|
|
701
1161
|
*/
|
|
702
1162
|
export interface PlatformLimits {
|
|
703
1163
|
/** Maximum size in bytes for a single file. */
|
|
@@ -786,6 +1246,128 @@ export function isBlockedExtension(filename: string): boolean {
|
|
|
786
1246
|
return BLOCKED_EXTENSIONS.has(ext);
|
|
787
1247
|
}
|
|
788
1248
|
|
|
1249
|
+
// =============================================================================
|
|
1250
|
+
// PICKER ACCEPT HINT
|
|
1251
|
+
// =============================================================================
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* The extensions a browser file picker offers by default, grouped by role.
|
|
1255
|
+
*
|
|
1256
|
+
* Private on purpose: the only published form is `WEB_FILE_ACCEPT`, the
|
|
1257
|
+
* attribute value itself. A published set would invite a call site to ask it
|
|
1258
|
+
* whether a file is allowed — which is the one thing this list must never
|
|
1259
|
+
* answer. See `WEB_FILE_ACCEPT`.
|
|
1260
|
+
*
|
|
1261
|
+
* Extensionless files (`LICENSE`, most `.well-known` entries) are inexpressible
|
|
1262
|
+
* in `accept`, and reach a deployment by folder pick, ZIP, or drag-and-drop.
|
|
1263
|
+
*/
|
|
1264
|
+
const WEB_FILE_EXTENSIONS = [
|
|
1265
|
+
// Markup & documents
|
|
1266
|
+
'html',
|
|
1267
|
+
'htm',
|
|
1268
|
+
'xhtml',
|
|
1269
|
+
'xml',
|
|
1270
|
+
'txt',
|
|
1271
|
+
'md',
|
|
1272
|
+
'markdown',
|
|
1273
|
+
'pdf',
|
|
1274
|
+
'csv',
|
|
1275
|
+
// Data & config
|
|
1276
|
+
'json',
|
|
1277
|
+
'jsonc',
|
|
1278
|
+
'webmanifest',
|
|
1279
|
+
'map',
|
|
1280
|
+
'toml',
|
|
1281
|
+
'yaml',
|
|
1282
|
+
'yml',
|
|
1283
|
+
'rss',
|
|
1284
|
+
'atom',
|
|
1285
|
+
// Styles
|
|
1286
|
+
'css',
|
|
1287
|
+
'scss',
|
|
1288
|
+
'sass',
|
|
1289
|
+
'less',
|
|
1290
|
+
// Scripts & modules
|
|
1291
|
+
'js',
|
|
1292
|
+
'mjs',
|
|
1293
|
+
'cjs',
|
|
1294
|
+
'jsx',
|
|
1295
|
+
'ts',
|
|
1296
|
+
'tsx',
|
|
1297
|
+
'wasm',
|
|
1298
|
+
'vue',
|
|
1299
|
+
'svelte',
|
|
1300
|
+
// Images
|
|
1301
|
+
'png',
|
|
1302
|
+
'jpg',
|
|
1303
|
+
'jpeg',
|
|
1304
|
+
'gif',
|
|
1305
|
+
'webp',
|
|
1306
|
+
'avif',
|
|
1307
|
+
'svg',
|
|
1308
|
+
'ico',
|
|
1309
|
+
'bmp',
|
|
1310
|
+
'tif',
|
|
1311
|
+
'tiff',
|
|
1312
|
+
'heic',
|
|
1313
|
+
'heif',
|
|
1314
|
+
// Fonts
|
|
1315
|
+
'woff',
|
|
1316
|
+
'woff2',
|
|
1317
|
+
'ttf',
|
|
1318
|
+
'otf',
|
|
1319
|
+
'eot',
|
|
1320
|
+
// Audio
|
|
1321
|
+
'mp3',
|
|
1322
|
+
'wav',
|
|
1323
|
+
'ogg',
|
|
1324
|
+
'oga',
|
|
1325
|
+
'opus',
|
|
1326
|
+
'm4a',
|
|
1327
|
+
'aac',
|
|
1328
|
+
'flac',
|
|
1329
|
+
'weba',
|
|
1330
|
+
// Video
|
|
1331
|
+
'mp4',
|
|
1332
|
+
'webm',
|
|
1333
|
+
'ogv',
|
|
1334
|
+
'mov',
|
|
1335
|
+
'm4v',
|
|
1336
|
+
'avi',
|
|
1337
|
+
// 3D models
|
|
1338
|
+
'glb',
|
|
1339
|
+
'gltf',
|
|
1340
|
+
'usdz',
|
|
1341
|
+
// Text tracks
|
|
1342
|
+
'vtt',
|
|
1343
|
+
'srt',
|
|
1344
|
+
// Archive — a whole site in one file
|
|
1345
|
+
'zip',
|
|
1346
|
+
] as const;
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* The `accept` attribute value for a browser file picker offering web files.
|
|
1350
|
+
*
|
|
1351
|
+
* **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
|
|
1352
|
+
* gate and the only thing that decides what may be hosted; this constant
|
|
1353
|
+
* decides what a *file dialog* shows first. The two are not two halves of one
|
|
1354
|
+
* policy, and this one must never be consulted to accept or reject a file.
|
|
1355
|
+
*
|
|
1356
|
+
* The distinction is structural, not stylistic. `accept` can express only an
|
|
1357
|
+
* allowlist, while the platform's rule is a blocklist — so this list is
|
|
1358
|
+
* necessarily *narrower* than what the platform hosts, and reading it as
|
|
1359
|
+
* authority would reject files the platform serves happily. It is also not
|
|
1360
|
+
* enforcement in the browser's own terms: every file dialog offers an
|
|
1361
|
+
* all-files escape, and **drag-and-drop ignores `accept` entirely**. The
|
|
1362
|
+
* dropzone and the picker must reach the same verdict on the same files, and
|
|
1363
|
+
* they do — because the verdict is `validateFiles`, downstream of both.
|
|
1364
|
+
*
|
|
1365
|
+
* Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
|
|
1366
|
+
* `tests/validation-constants.test.ts` fence the invariant that matters: the
|
|
1367
|
+
* picker must never offer a file the platform will refuse.
|
|
1368
|
+
*/
|
|
1369
|
+
export const WEB_FILE_ACCEPT: string = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
|
|
1370
|
+
|
|
789
1371
|
// =============================================================================
|
|
790
1372
|
// FILENAME CHARACTER VALIDATION
|
|
791
1373
|
// =============================================================================
|
|
@@ -848,13 +1430,20 @@ export function hasUnbuiltMarker(filePath: string): boolean {
|
|
|
848
1430
|
// =============================================================================
|
|
849
1431
|
|
|
850
1432
|
/**
|
|
851
|
-
*
|
|
1433
|
+
* `GET /ping` — a report of the server clock.
|
|
1434
|
+
*
|
|
1435
|
+
* Liveness is the STATUS CODE's answer, not a field's: a 200 means reachable,
|
|
1436
|
+
* and any other outcome throws before a body is read. So the body carries the
|
|
1437
|
+
* one thing a status code cannot — the server's own clock, which is what lets a
|
|
1438
|
+
* client detect skew against a token expiry. It read `{ success: true,
|
|
1439
|
+
* timestamp? }` until 2026-07-29, where `success` was a literal constant in the
|
|
1440
|
+
* route (zero bits, and the platform's own named anti-pattern) while the field
|
|
1441
|
+
* that IS the payload was optional. See {@link DeploymentDeleteResponse} for
|
|
1442
|
+
* the law, and `tests/response-shapes.test.ts` for the fence that holds it.
|
|
852
1443
|
*/
|
|
853
1444
|
export interface PingResponse {
|
|
854
|
-
/** Always true if service is healthy */
|
|
855
|
-
success: boolean;
|
|
856
1445
|
/** Server time in unix seconds — the one wire unit for timestamps. */
|
|
857
|
-
timestamp
|
|
1446
|
+
readonly timestamp: number;
|
|
858
1447
|
}
|
|
859
1448
|
|
|
860
1449
|
// =============================================================================
|
|
@@ -1004,6 +1593,54 @@ export const SPA_DEFAULT_CONFIG = {
|
|
|
1004
1593
|
rewrites: [{ source: '/(.*)', destination: '/index.html' }],
|
|
1005
1594
|
} as const;
|
|
1006
1595
|
|
|
1596
|
+
/**
|
|
1597
|
+
* Assert that a ship.json file is *syntactically* loadable. Syntax only —
|
|
1598
|
+
* never schema.
|
|
1599
|
+
*
|
|
1600
|
+
* ship.json is validated and compiled on the server, deliberately: the schema
|
|
1601
|
+
* and the compiler evolve, and a client that judged them would reject configs
|
|
1602
|
+
* a newer platform accepts. That reasoning bounds what a client may check to
|
|
1603
|
+
* the properties which are true of *every* past and future schema:
|
|
1604
|
+
*
|
|
1605
|
+
* 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
|
|
1606
|
+
* does not parse can never be a valid config;
|
|
1607
|
+
* 2. its top level is an object — ship.json is `{ ... }` in every version.
|
|
1608
|
+
*
|
|
1609
|
+
* Both are monotonic: neither can ever reject something the server would
|
|
1610
|
+
* accept. Everything beyond them (field names, types, rule semantics, which
|
|
1611
|
+
* keys are permitted) stays server-side, where it can change.
|
|
1612
|
+
*
|
|
1613
|
+
* The payoff is the common case. Hand-edited JSON fails on a trailing comma,
|
|
1614
|
+
* a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
|
|
1615
|
+
* documentation — mistakes that otherwise cost a full upload round-trip to
|
|
1616
|
+
* discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
|
|
1617
|
+
* before parsing rather than rejected, because the server accepts it too;
|
|
1618
|
+
* diverging there would reintroduce exactly the false rejection this
|
|
1619
|
+
* function exists to avoid.
|
|
1620
|
+
*
|
|
1621
|
+
* @throws {ShipError} `ErrorType.Config` — the same type the server's own
|
|
1622
|
+
* config rejection carries, so the error contract is identical wherever the
|
|
1623
|
+
* failure is detected.
|
|
1624
|
+
*/
|
|
1625
|
+
export function assertShipJsonSyntax(text: string): void {
|
|
1626
|
+
const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
1627
|
+
|
|
1628
|
+
let parsed: unknown;
|
|
1629
|
+
try {
|
|
1630
|
+
parsed = JSON.parse(withoutBom);
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
throw ShipError.config(`invalid JSON format in config: ${(error as Error).message}`, {
|
|
1633
|
+
filePath: DEPLOYMENT_CONFIG_FILENAME,
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1638
|
+
throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
|
|
1639
|
+
filePath: DEPLOYMENT_CONFIG_FILENAME,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1007
1644
|
// =============================================================================
|
|
1008
1645
|
// VALIDATION UTILITIES
|
|
1009
1646
|
// =============================================================================
|
|
@@ -1133,16 +1770,27 @@ export interface SPACheckRequest {
|
|
|
1133
1770
|
/**
|
|
1134
1771
|
* Response from SPA check endpoint
|
|
1135
1772
|
*/
|
|
1773
|
+
/**
|
|
1774
|
+
* Which of the classifier's tiers reached the verdict, and why. Named rather
|
|
1775
|
+
* than inline so the API's own `checkSPA` can return `SPACheckResponse`
|
|
1776
|
+
* instead of restating its shape.
|
|
1777
|
+
*/
|
|
1778
|
+
export interface SPACheckDebug {
|
|
1779
|
+
/** Which tier made the detection */
|
|
1780
|
+
tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
|
|
1781
|
+
/** The reason for the detection result */
|
|
1782
|
+
reason: string;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
1787
|
+
* "A report answers a question").
|
|
1788
|
+
*/
|
|
1136
1789
|
export interface SPACheckResponse {
|
|
1137
1790
|
/** Whether the project is detected as a Single Page Application */
|
|
1138
1791
|
isSPA: boolean;
|
|
1139
1792
|
/** Debugging information about detection */
|
|
1140
|
-
debug:
|
|
1141
|
-
/** Which tier made the detection: 'exclusions', 'inclusions', 'scoring', 'ai', or 'fallback' */
|
|
1142
|
-
tier: 'exclusions' | 'inclusions' | 'scoring' | 'ai' | 'fallback';
|
|
1143
|
-
/** The reason for the detection result */
|
|
1144
|
-
reason: string;
|
|
1145
|
-
};
|
|
1793
|
+
debug: SPACheckDebug;
|
|
1146
1794
|
}
|
|
1147
1795
|
|
|
1148
1796
|
// =============================================================================
|
|
@@ -1186,6 +1834,22 @@ export interface StaticFile {
|
|
|
1186
1834
|
/** Default API URL if not otherwise configured. */
|
|
1187
1835
|
export const DEFAULT_API = 'https://api.shipstatic.com';
|
|
1188
1836
|
|
|
1837
|
+
/**
|
|
1838
|
+
* How long an anonymous deployment lives before it expires.
|
|
1839
|
+
*
|
|
1840
|
+
* The lifetime of the public tier, and one fact with several readers. The API
|
|
1841
|
+
* stamps a deployment's `expires` from it and gives a claim code exactly the
|
|
1842
|
+
* same window — a live site with a dead claim link is a coherence bug, so the
|
|
1843
|
+
* two are one constant rather than two that agree. Both MCP transports quote
|
|
1844
|
+
* the duration in prose an agent reads, and derive it from here rather than
|
|
1845
|
+
* writing it out, which they did in eight places until this export existed.
|
|
1846
|
+
*
|
|
1847
|
+
* Seconds, spelled in the name: this platform has both second- and
|
|
1848
|
+
* millisecond-valued durations, and the pair is only safe when each says which
|
|
1849
|
+
* it is.
|
|
1850
|
+
*/
|
|
1851
|
+
export const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;
|
|
1852
|
+
|
|
1189
1853
|
// =============================================================================
|
|
1190
1854
|
// RESOURCE INTERFACE CONTRACTS
|
|
1191
1855
|
// =============================================================================
|
|
@@ -1209,8 +1873,12 @@ export type DeployInput = File[] | string | string[];
|
|
|
1209
1873
|
export interface DeploymentUploadOptions {
|
|
1210
1874
|
/** Optional labels for categorization and filtering */
|
|
1211
1875
|
labels?: string[];
|
|
1212
|
-
/**
|
|
1213
|
-
|
|
1876
|
+
/**
|
|
1877
|
+
* Which client is making this deploy. Closed, because the server silently
|
|
1878
|
+
* ignores anything outside the set — so an unchecked string turned a typo
|
|
1879
|
+
* into missing analytics rather than an error. See {@link DeploymentVia}.
|
|
1880
|
+
*/
|
|
1881
|
+
via?: DeploymentViaType;
|
|
1214
1882
|
/**
|
|
1215
1883
|
* Optional password that protects this deployment.
|
|
1216
1884
|
*
|
|
@@ -1230,13 +1898,42 @@ export interface DeploymentUploadOptions {
|
|
|
1230
1898
|
spa?: boolean;
|
|
1231
1899
|
/** @internal reCAPTCHA proof for the anonymous human deploy channel. Only available via /upload endpoint. */
|
|
1232
1900
|
captcha?: string;
|
|
1901
|
+
/**
|
|
1902
|
+
* Makes this deploy replayable instead of repeatable.
|
|
1903
|
+
*
|
|
1904
|
+
* A deploy is not naturally idempotent: a client-side timeout on a slow
|
|
1905
|
+
* one leaves the caller unable to tell "it never landed" from "it landed
|
|
1906
|
+
* and the response was lost", and retrying produces a second deployment.
|
|
1907
|
+
* Send the same key on the retry and the platform replays the original
|
|
1908
|
+
* 201 verbatim rather than creating anything
|
|
1909
|
+
* ({@link IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS}).
|
|
1910
|
+
*
|
|
1911
|
+
* **Agents are the audience.** A human notices a duplicate; an automated
|
|
1912
|
+
* retry does not. Pick a key that identifies the ATTEMPT — a run id, a
|
|
1913
|
+
* commit sha, a uuid minted before the first try — never one minted fresh
|
|
1914
|
+
* on each retry, which would defeat the point.
|
|
1915
|
+
*
|
|
1916
|
+
* The replay is per-caller, and it stores successes only: a failed deploy
|
|
1917
|
+
* retries fresh under the same key.
|
|
1918
|
+
*/
|
|
1919
|
+
idempotencyKey?: string;
|
|
1233
1920
|
}
|
|
1234
1921
|
|
|
1235
1922
|
/**
|
|
1236
|
-
* Pagination options for
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1923
|
+
* Pagination options for every list endpoint. The response's `cursor` feeds
|
|
1924
|
+
* the next request; a `null` cursor means the last page. Omitting both
|
|
1925
|
+
* returns the server's default first page.
|
|
1926
|
+
*
|
|
1927
|
+
* A list answers `{ <collection>, cursor }` and nothing else — `cursor`
|
|
1928
|
+
* carries the entire has-more signal, so no redundant boolean, and no
|
|
1929
|
+
* `total`. **A count is an aggregate over a collection, not a property of a
|
|
1930
|
+
* page:** including one makes every read pay for a full scan it did not ask
|
|
1931
|
+
* for, which is precisely the cost keyset pagination exists to avoid.
|
|
1932
|
+
*
|
|
1933
|
+
* Counts therefore live on the summary resource that owns them —
|
|
1934
|
+
* `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
|
|
1935
|
+
* platform-wide ones. Ask for a count when you want a count; ask for a page
|
|
1936
|
+
* when you want a page.
|
|
1240
1937
|
*/
|
|
1241
1938
|
export interface ListOptions {
|
|
1242
1939
|
/** Maximum number of items to return in one page. */
|
|
@@ -1245,6 +1942,36 @@ export interface ListOptions {
|
|
|
1245
1942
|
cursor?: string;
|
|
1246
1943
|
}
|
|
1247
1944
|
|
|
1945
|
+
/**
|
|
1946
|
+
* What a caller may change on an existing deployment.
|
|
1947
|
+
*
|
|
1948
|
+
* Labels and nothing else: a deployment's content is immutable by design, so
|
|
1949
|
+
* this is the whole mutable surface rather than a subset someone chose.
|
|
1950
|
+
*/
|
|
1951
|
+
export interface DeploymentSetOptions {
|
|
1952
|
+
labels: string[];
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* What `domains.set()` may create or change. Every field is optional because
|
|
1957
|
+
* the call is a natural-key upsert: omitting `deployment` reserves the
|
|
1958
|
+
* domain, naming one links or re-points it, and labels travel either way.
|
|
1959
|
+
*
|
|
1960
|
+
* `deployment` is deliberately not nullable — unlinking is refused (400).
|
|
1961
|
+
* See `npm/ship/CLAUDE.md`, "Domain Write Semantics".
|
|
1962
|
+
*/
|
|
1963
|
+
export interface DomainSetOptions {
|
|
1964
|
+
deployment?: string;
|
|
1965
|
+
labels?: string[];
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/** What a caller may set when minting a deploy token. */
|
|
1969
|
+
export interface TokenCreateOptions {
|
|
1970
|
+
/** Seconds until expiry; omit for a token that never expires. */
|
|
1971
|
+
ttl?: number;
|
|
1972
|
+
labels?: string[];
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1248
1975
|
/**
|
|
1249
1976
|
* Deployment resource interface - the contract all implementations must follow.
|
|
1250
1977
|
*
|
|
@@ -1259,26 +1986,23 @@ export interface DeploymentResource<
|
|
|
1259
1986
|
upload: (input: DeployInput, options?: UploadOptions) => Promise<DeploymentCreateResponse>;
|
|
1260
1987
|
list: (options?: ListOptions) => Promise<DeploymentListResponse>;
|
|
1261
1988
|
get: (id: string) => Promise<Deployment>;
|
|
1262
|
-
set: (id: string, options:
|
|
1263
|
-
|
|
1989
|
+
set: (id: string, options: DeploymentSetOptions) => Promise<Deployment>;
|
|
1990
|
+
delete: (id: string) => Promise<DeploymentDeleteResponse>;
|
|
1264
1991
|
}
|
|
1265
1992
|
|
|
1266
1993
|
/**
|
|
1267
1994
|
* Domain resource interface - the contract all implementations must follow
|
|
1268
1995
|
*/
|
|
1269
1996
|
export interface DomainResource {
|
|
1270
|
-
set: (
|
|
1271
|
-
name: string,
|
|
1272
|
-
options?: { deployment?: string; labels?: string[] },
|
|
1273
|
-
) => Promise<DomainSetResult>;
|
|
1997
|
+
set: (name: string, options?: DomainSetOptions) => Promise<DomainSetResult>;
|
|
1274
1998
|
list: (options?: ListOptions) => Promise<DomainListResponse>;
|
|
1275
1999
|
get: (name: string) => Promise<Domain>;
|
|
1276
|
-
|
|
1277
|
-
verify: (name: string) => Promise<
|
|
2000
|
+
delete: (name: string) => Promise<DomainDeleteResponse>;
|
|
2001
|
+
verify: (name: string) => Promise<DomainVerifyResponse>;
|
|
1278
2002
|
validate: (name: string) => Promise<DomainValidateResponse>;
|
|
1279
2003
|
dns: (name: string) => Promise<DomainDnsResponse>;
|
|
1280
2004
|
records: (name: string) => Promise<DomainRecordsResponse>;
|
|
1281
|
-
share: (name: string) => Promise<
|
|
2005
|
+
share: (name: string) => Promise<DomainShareResponse>;
|
|
1282
2006
|
}
|
|
1283
2007
|
|
|
1284
2008
|
/**
|
|
@@ -1292,9 +2016,10 @@ export interface AccountResource {
|
|
|
1292
2016
|
* Token resource interface - the contract all implementations must follow
|
|
1293
2017
|
*/
|
|
1294
2018
|
export interface TokenResource {
|
|
1295
|
-
create: (options?:
|
|
1296
|
-
list: () => Promise<TokenListResponse>;
|
|
1297
|
-
|
|
2019
|
+
create: (options?: TokenCreateOptions) => Promise<TokenCreateResponse>;
|
|
2020
|
+
list: (options?: ListOptions) => Promise<TokenListResponse>;
|
|
2021
|
+
get: (token: string) => Promise<Token>;
|
|
2022
|
+
delete: (token: string) => Promise<TokenDeleteResponse>;
|
|
1298
2023
|
}
|
|
1299
2024
|
|
|
1300
2025
|
// =============================================================================
|
|
@@ -1320,6 +2045,26 @@ export interface BillingStatus {
|
|
|
1320
2045
|
portal: string | null;
|
|
1321
2046
|
}
|
|
1322
2047
|
|
|
2048
|
+
/**
|
|
2049
|
+
* Acknowledgement of `POST /billing/cancel`.
|
|
2050
|
+
*
|
|
2051
|
+
* Cancelling leaves no billing entity to return, so it answers with the
|
|
2052
|
+
* account and the one field of the account the call changed — the plan it
|
|
2053
|
+
* landed on. See {@link DeploymentDeleteResponse} for the law.
|
|
2054
|
+
*
|
|
2055
|
+
* This read `{ success: true, message: 'Subscription canceled successfully…' }`
|
|
2056
|
+
* until 2026-07-29, an anonymous shape that `web/my` redeclared inline and
|
|
2057
|
+
* whose prose no surface ever displayed: both callers await the promise and
|
|
2058
|
+
* discard the body, then compose their own toast. The message was written,
|
|
2059
|
+
* serialized, and thrown away on every cancellation.
|
|
2060
|
+
*/
|
|
2061
|
+
export interface BillingCancelResponse {
|
|
2062
|
+
/** The account whose subscription was cancelled */
|
|
2063
|
+
readonly account: string;
|
|
2064
|
+
/** The plan the account now holds — `free` on a successful cancellation */
|
|
2065
|
+
readonly plan: AccountPlanType;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1323
2068
|
/**
|
|
1324
2069
|
* Checkout session response from POST /billing/checkout
|
|
1325
2070
|
*/
|
|
@@ -1477,7 +2222,7 @@ export interface ActivityMeta {
|
|
|
1477
2222
|
/**
|
|
1478
2223
|
* Response from GET /activities endpoint
|
|
1479
2224
|
*/
|
|
1480
|
-
export interface ActivityListResponse {
|
|
2225
|
+
export interface ActivityListResponse extends ListResponse {
|
|
1481
2226
|
/** Array of activities */
|
|
1482
2227
|
activities: Activity[];
|
|
1483
2228
|
}
|