@shipstatic/drop 2.2.0 → 2.2.1-beta.2

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../node_modules/.pnpm/@shipstatic+types@2.8.0/node_modules/@shipstatic/types/dist/index.js","../src/testing.ts"],"names":[],"mappings":"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","file":"testing.js","sourcesContent":["/**\n * @file Shared TypeScript types, constants, and utilities for the ShipStatic platform.\n * This package is the single source of truth for all shared data structures.\n */\n// =============================================================================\n// DEPLOYMENT TYPES\n// =============================================================================\n/**\n * Deployment status constants\n */\nexport const DeploymentStatus = {\n PENDING: 'pending',\n SUCCESS: 'success',\n FAILED: 'failed',\n DELETING: 'deleting',\n};\n/**\n * Which client made a deployment — the origin-tracking vocabulary.\n *\n * A closed set with many authors: the CLI, the SDK, the dashboard, both MCP\n * transports, the GitHub Action, the n8n node and the VS Code extension each\n * name themselves here. It lived in the API's config until 2026-08-06, where\n * being server-side made it unenforceable in the one direction that matters —\n * every client wrote a bare string, and a value outside the set was **silently\n * dropped** by the server, so a typo did not fail anywhere. It stopped\n * recording where deploys came from and said nothing.\n */\nexport const DeploymentVia = {\n WEB: 'web',\n SDK: 'sdk',\n CLI: 'cli',\n MCP: 'mcp',\n GIT: 'git',\n N8N: 'n8n',\n GPT: 'gpt',\n VSC: 'vsc',\n};\n// =============================================================================\n// DOMAIN TYPES\n// =============================================================================\n/**\n * Domain status constants\n *\n * - PENDING: DNS not configured\n * - PARTIAL: DNS partially configured\n * - SUCCESS: DNS fully verified\n * - PAUSED: Domain paused due to plan enforcement (billing)\n */\nexport const DomainStatus = {\n PENDING: 'pending',\n PARTIAL: 'partial',\n SUCCESS: 'success',\n PAUSED: 'paused',\n};\n/**\n * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.\n *\n * Format lives here rather than on the server alone by the format-vs-policy\n * rule: a client can decide offline whether a key is well-formed, and the\n * API would reject the same value the same way.\n */\nexport const IDEMPOTENCY_KEY_CONSTRAINTS = {\n /**\n * HTTP header name. Here for the same reason {@link CALLER.HEADER} is: a\n * wire header has two ends, and the package that owns the value's format\n * is the only place both ends can read its name from.\n */\n HEADER: 'Idempotency-Key',\n MAX_LENGTH: 256,\n /** How long a stored 201 stays replayable. */\n WINDOW_SECONDS: 24 * 60 * 60,\n};\n/**\n * Normalize a `via` value from any transport — trimmed, lowercased, and a\n * member of {@link DeploymentVia}, or `undefined`.\n *\n * A format rule by this package's own test: a client can decide offline\n * whether a value is well-formed, and the API reaches the same verdict on the\n * same input. It lived server-side until 2026-08-06, which meant clients could\n * only learn their label was unusable by noticing analytics had gone quiet.\n *\n * **Not knowing your `via` is not an error** — an unrecognized value yields\n * `undefined` rather than throwing, because origin tracking is telemetry and a\n * deploy must never fail over it. A caller that has an honest default should\n * prefer it (`normalizeVia(process.env.SHIP_VIA) ?? DeploymentVia.CLI`): the\n * deploy really did come from the CLI, so recording that beats recording\n * nothing.\n */\nexport function normalizeVia(value) {\n if (!value || typeof value !== 'string')\n return undefined;\n const via = value.trim().toLowerCase();\n return Object.values(DeploymentVia).includes(via)\n ? via\n : undefined;\n}\n/**\n * Validate an idempotency key, returning the trimmed value or `undefined`\n * when none was supplied. Throws {@link ShipError.validation} when the value\n * cannot be sent — the same verdict the API would reach, reached earlier.\n */\nexport function validateIdempotencyKey(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'string') {\n throw ShipError.validation('Idempotency key must be a string.');\n }\n const key = value.trim();\n if (!key) {\n throw ShipError.validation('Idempotency key must not be empty.');\n }\n if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {\n throw ShipError.validation(`Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`);\n }\n return key;\n}\n// =============================================================================\n// ACCOUNT TYPES\n// =============================================================================\n/**\n * Account plan constants\n */\nexport const AccountPlan = {\n FREE: 'free',\n STANDARD: 'standard',\n SPONSORED: 'sponsored',\n ENTERPRISE: 'enterprise',\n SUSPENDED: 'suspended',\n TERMINATING: 'terminating',\n TERMINATED: 'terminated',\n};\n// =============================================================================\n// WIRE SURFACE\n// =============================================================================\n/**\n * Every path the public API answers on, declared once.\n *\n * The URL surface was written out in four places — the API's mounts, the\n * SDK's client, the dashboard's client, and the post-deploy smoke — so a\n * rename meant finding all four. The first three now read this table.\n *\n * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:\n * five of its nine paths are `/admin/*`, which this table excludes by\n * design, and splitting one list between a registry and literals reads worse\n * than keeping it uniform.\n *\n * **What this guarantees, exactly.** Collection paths are mounted from here,\n * so producer and consumer cannot diverge. Item paths are declared here and\n * consumed by clients, but the API spells them relative to their mount\n * (`/:deployment/config`), so the table does not *generate* them — it is\n * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if\n * any entry names a path no route answers. Some entries have no client yet\n * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK\n * deliberately does not reach); the fence is what keeps those honest rather\n * than merely asserted.\n *\n * **The operator surface is deliberately absent.** `/admin/*` paths belong\n * to `web/my`, for the same reason its row types do: this package is\n * published, and the operator surface is not public (see `CLAUDE.md`, \"Admin\n * types\"). A path here is a promise to every npm consumer; `/admin` is a\n * promise to one dashboard.\n *\n * Item paths are functions rather than templates so the key is interpolated\n * in one place, encoded the same way by every caller.\n */\nexport const API_PATHS = {\n DEPLOYMENTS: '/deployments',\n DEPLOYMENT: (deployment) => `/deployments/${deployment}`,\n DEPLOYMENT_CONFIG: (deployment) => `/deployments/${deployment}/config`,\n DOMAINS: '/domains',\n DOMAIN: (domain) => `/domains/${domain}`,\n DOMAIN_VERIFY: (domain) => `/domains/${domain}/verify`,\n DOMAIN_DNS: (domain) => `/domains/${domain}/dns`,\n DOMAIN_RECORDS: (domain) => `/domains/${domain}/records`,\n DOMAIN_SHARE: (domain) => `/domains/${domain}/share`,\n DOMAIN_PROPAGATION: (domain) => `/domains/${domain}/propagation`,\n DOMAINS_VALIDATE: '/domains/validate',\n TOKENS: '/tokens',\n TOKEN: (token) => `/tokens/${token}`,\n ACCOUNT: '/account',\n ACCOUNT_KEY: '/account/key',\n ACCOUNT_CLAIM: '/account/claim',\n ACTIVITIES: '/activities',\n LABELS: '/labels',\n LIMITS: '/limits',\n PING: '/ping',\n SETUP: '/setup',\n SPA_CHECK: '/spa-check',\n UPLOAD: '/upload',\n};\n/**\n * The deploy request's multipart field names — the other half of the wire\n * surface beside {@link API_PATHS}. `POST /deployments` (and the first-party\n * `/upload`) is multipart/form-data, and these are the names the API reads.\n *\n * Declared once because the body has three independent WRITERS — the SDK's\n * Node and browser body builders, and the n8n community node's hand-rolled\n * client (which cannot import this under n8n Cloud's zero-dependency rule,\n * and fences its restated copy instead) — and until this export every writer\n * restated the strings the API parses, with nothing comparing them.\n *\n * `FILES` carries one entry per file (the API reads it with `getAll`); every\n * other field is single. The `@internal` flags are serialized as the literal\n * string `'true'` and belong to first-party surfaces only.\n */\nexport const DEPLOY_FIELDS = {\n /** One entry per file — read with `getAll`. */\n FILES: 'files[]',\n /** JSON array of MD5 hex digests, index-aligned with `FILES`. */\n CHECKSUMS: 'checksums',\n /** JSON array of label strings. */\n LABELS: 'labels',\n /** The deploying surface's {@link DeploymentVia} member. */\n VIA: 'via',\n /** Plaintext password — the API hashes it server-side. */\n PASSWORD: 'password',\n /**\n * Requested lifetime in SECONDS — a duration, never an instant. The API\n * computes and stores the expiry, so the wire carries no client clock.\n * See {@link validateTtl}.\n */\n TTL: 'ttl',\n /** @internal Server-processing flag — first-party `/upload` only. */\n BUILD: 'build',\n /** @internal Server-processing flag — first-party `/upload` only. */\n PRERENDER: 'prerender',\n /** @internal Server-processing flag — first-party `/upload` only. */\n SPA: 'spa',\n /** @internal reCAPTCHA proof — `web/www`'s public uploader only. */\n CAPTCHA: 'captcha',\n};\n// =============================================================================\n// ERROR SYSTEM\n// =============================================================================\n/**\n * All possible error types in the ShipStatic platform.\n *\n * Developer-friendly key names map to stable wire-format string values.\n * Both the value and the type are exported under the same name so callers\n * can use `ErrorType.Validation` (value comparison) and `: ErrorType` (type\n * annotation) without ceremony — matching the pattern other status objects\n * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.\n */\nexport const ErrorType = {\n /**\n * Validation failed. Input shape is wrong.\n *\n * Carries 400 when an API judged it — including a client-side pre-check of a\n * rule the server enforces too, which keeps the error identical wherever it\n * was caught. **Statusless** when a client rejects something no API judges,\n * such as a CLI's own command grammar: `status` is documented \"(API\n * contexts)\" on `ErrorResponse`, so there is none to report.\n */\n Validation: 'validation_failed',\n /** Resource not found (404). */\n NotFound: 'not_found',\n /** Authenticated but not allowed (403). User lacks permission for this action. */\n Forbidden: 'forbidden',\n /** Rate limit exceeded (429). */\n RateLimit: 'rate_limit_exceeded',\n /** Authentication required or failed (401). Missing/invalid credentials. */\n Authentication: 'authentication_failed',\n /** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */\n Business: 'business_logic_error',\n /** API server error (500). Generic server-side fault. */\n Api: 'internal_server_error',\n /**\n * The platform is closed for maintenance (503). A deliberate operator\n * state, not a fault — nothing errored; the API is refusing work on\n * purpose, and deployed sites keep serving throughout.\n *\n * Distinct from `Api` at 503, which the platform already uses for a\n * dependency that failed (moderation unavailable). A consumer has to tell\n * \"we closed the door\" from \"something broke\": the two get opposite words\n * and opposite retry behaviour.\n */\n Maintenance: 'maintenance',\n /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */\n Network: 'network_error',\n /**\n * A deadline expired before the exchange completed. Client-side only — set\n * by HTTP clients when a timeout signal fires; never produced server-side.\n *\n * A member of the NETWORK category rather than a sibling of it:\n * `isNetworkError()` answers \"nothing was exchanged\", which is true of a\n * deadline exactly as it is of a refused connection, so every consumer that\n * retries, declines to report, or declines to relay a wire message on that\n * category is already right about a timeout. The distinct TYPE exists for\n * the one decision the category cannot make — what to SAY. \"Check your\n * internet connection\" is the wrong sentence for a five-minute deploy\n * ceiling, and a surface can only tell the two apart by type.\n *\n * The same relationship every comparable SDK ships:\n * `APIConnectionTimeoutError extends APIConnectionError`.\n */\n Timeout: 'timeout_error',\n /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */\n Cancelled: 'operation_cancelled',\n /** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */\n File: 'file_error',\n /** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */\n Config: 'config_error',\n};\n/**\n * Error types that originate exclusively on the client (HTTP clients, SDK\n * file processing, local config parsing). These never appear on the wire\n * from the server, so `fromHttpResponse` will not trust them even if a\n * misbehaving server claims one in `body.error`.\n */\nconst CLIENT_ONLY_ERROR_TYPES = new Set([\n ErrorType.Network,\n ErrorType.Timeout,\n ErrorType.Cancelled,\n ErrorType.File,\n ErrorType.Config,\n]);\n/**\n * Categorizes error types for the `isClientError` / `isNetworkError` /\n * `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`\n * union so `.has(error.type)` accepts any value from the union.\n */\nconst ERROR_CATEGORIES = {\n /**\n * Client-attributable types. Exhaustive over the 4xx-carrying types, and\n * over the statusless ones too — those are raised locally and have no\n * status for `isClientError`'s second arm to read, so omitting one makes it\n * read as a server fault. The rule is the membership test: every type in\n * `CLIENT_ONLY_ERROR_TYPES` except the two `isNetworkError` owns belongs\n * here.\n *\n * `Cancelled` was missing until 2026-07-29, which is exactly that failure:\n * a caller who aborted their own deploy was told \"server error: please try\n * again\" — the CLI's fallback for everything this set does not claim.\n *\n * `Timeout` is deliberately NOT here, and it is the sharper case, because\n * it is the one client-only type that is not the client's fault: the\n * caller set a ceiling, but what exhausted it was the network or the\n * server. Reading it as client-attributable would say the caller erred,\n * and it would silently disarm every consumer whose retry predicate\n * declines `isClientError()` — a deadline is precisely the failure worth\n * a second attempt.\n */\n client: new Set([\n ErrorType.Business,\n ErrorType.Cancelled,\n ErrorType.Config,\n ErrorType.File,\n ErrorType.Forbidden,\n ErrorType.NotFound,\n ErrorType.RateLimit,\n ErrorType.Validation,\n ]),\n /**\n * The exchange never happened. Two types, one category: a refused\n * connection and an expired deadline differ in what a surface should SAY\n * and in nothing else a consumer decides on — both are retryable, neither\n * carries a wire message to relay, neither is worth reporting as an\n * incident. See `ErrorType.Timeout` for why the type is distinct anyway.\n */\n network: new Set([ErrorType.Network, ErrorType.Timeout]),\n auth: new Set([ErrorType.Authentication]),\n};\n/**\n * Error types the server can legitimately produce on the wire. Used by\n * `ShipError.fromHttpResponse` to validate the body's `error` field before\n * trusting it as `ShipError.type`. Derived by exclusion from\n * `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to\n * `ErrorType` is automatically picked up.\n */\nconst SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)));\n/**\n * Ceiling on a message adopted from a **non-JSON** error body — a foreign\n * responder's, never this platform's. Generous for the plain-text one-liners\n * intermediaries actually send (`error code: 1015`), far below a document.\n * Our own messages are never measured against it: a JSON body is the API's\n * contract, and truncating a long validation message would be the bug.\n */\nconst MAX_FOREIGN_MESSAGE_LENGTH = 200;\n/**\n * Did the runtime say the exchange never completed?\n *\n * Clients branch on the TYPE, never on message strings, so a misclassified\n * transport failure is a lie every consumer inherits — and the one that costs\n * most: `Api` claims a server answered when nothing was exchanged, and a\n * retrying caller will not retry it.\n *\n * **Every row below is a transcript, not a belief.** Captured 2026-08-12\n * against real runtimes — Node and Bun by direct run, the three engines by a\n * one-off playwright probe, workerd through miniflare. The capture scripts are\n * in `tests/errors.test.ts`, \"runtime failure shapes\".\n *\n * | runtime | connection refused / DNS failure | malformed URL |\n * |------------------|------------------------------------------------------|--------------------------------------------------|\n * | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |\n * | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |\n * | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |\n * | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |\n * | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |\n * | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |\n *\n * Reading that table gives the rule, and it is the INVERSE of the obvious one.\n * The transport class is unbounded — every OS, TLS and DNS failure any engine\n * will ever name — while the class fetch raises for its own ARGUMENTS is\n * small, and every runtime names the URL when it complains about one. So the\n * bounded side is the one worth testing, and the residual risk points the safe\n * way: an unrecognised sentence lands on `Network`, which says only that\n * nothing was exchanged.\n *\n * That inversion is what fixes **WebKit**, whose `Load failed` carries no code\n * and no \"fetch\", and which every browser-SDK and `@shipstatic/drop` user on\n * Safari was hitting as `Api`. It also makes the six runtimes AGREE about a\n * malformed URL, which they did not before: the previous rule tested the\n * message for \"fetch\", and Chromium's and Firefox's URL complaints both\n * contain it, so the same mistake was `Network` on three engines and `Api` on\n * three.\n *\n * **workerd is the recorded gap.** It rejects with a plain `Error`, no code\n * and no shared sentence — and its two failure modes produce two unrelated\n * ones — so nothing here can classify it and it lands on `Api`. Left alone\n * rather than patched with a dialect string: the one consumer running ship in\n * that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,\n * which is in-process and does not produce transport rejections at all.\n *\n * **The `TokenProvider` case stopped being a trade when clients gained\n * retries.** A caller's provider that throws a coded error is typed `Network`\n * here, which was recorded as \"both are wrong for it; `Network` is the cheaper\n * wrong\" — written when the classification decided only what a surface would\n * SAY. It now also decides whether the call is retried, and that turns the\n * cheaper wrong into the right answer: a `TokenProvider` is where minting and\n * refresh live, so the common one is an OAuth refresh over the network, and a\n * transient failure there is precisely what another attempt repairs.\n *\n * The residual cost is a deterministic provider fault — a genuinely missing\n * keychain entry — invoking the provider three times over a few hundred\n * milliseconds before failing with the same error. No request leaves the\n * process on any of them. That is the cheap direction of a bet whose other\n * side is a refused deploy, and suppressing it would need a way to mark\n * credential faults non-retryable: machinery with one holder, refused by the\n * estate's stopping rule. Provider failures that carry no code are `Api` and\n * are not retried at all, and a provider yielding nothing is `Authentication`\n * by the fail-closed invariant, which is likewise terminal.\n */\nfunction isTransportFailure(cause) {\n const code = cause.code;\n // Bun is the one runtime that puts a CODE on an argument error, so it is\n // excluded before the code arm can claim it.\n if (code === 'ERR_INVALID_URL')\n return false;\n // A string `code` is a runtime naming a transport-level failure. An\n // allowlist of codes was written first and rejected — the TLS row alone\n // would mean enumerating BoringSSL's certificate table, and a code nobody\n // guessed is precisely the bug this closes. A `DOMException`'s code is a\n // NUMBER, so aborts and timeouts never reach here.\n if (typeof code === 'string')\n return true;\n // WHATWG has fetch reject with a TypeError for BOTH halves — network error\n // and argument error — so among TypeErrors the URL is the discriminator.\n if (cause instanceof TypeError)\n return !/\\burl\\b/i.test(cause.message);\n // Anything else — an ordinary JS fault, or workerd — is not evidence.\n return false;\n}\n/**\n * Simple unified error class for both API and SDK\n */\nexport class ShipError extends Error {\n type;\n status;\n details;\n constructor(type, message, status, details) {\n super(message);\n this.type = type;\n this.status = status;\n this.details = details;\n this.name = 'ShipError';\n }\n /** Convert to wire format */\n toResponse() {\n // Strip authentication details when they carry an `internal` telemetry\n // tag (see `ShipError.authentication` JSDoc) — these are server-side\n // diagnostics like 'session_invalid' that must not leak to clients.\n const authDetails = this.details;\n const details = this.type === ErrorType.Authentication && authDetails?.internal ? undefined : this.details;\n return {\n error: this.type,\n message: this.message,\n status: this.status,\n details,\n };\n }\n /**\n * Construct a `ShipError` from an HTTP error response.\n *\n * Best-effort body parse for `{ message, error?, details? }`. Message\n * resolution: `body.message` → `body.error` → `\"<operationName> failed with\n * status <N>\"`.\n *\n * Type resolution: trusts `body.error` when it's a known server-producible\n * `ErrorType` (preserves the wire's intent — server's\n * `ShipError.validation(...)` round-trips back to `ErrorType.Validation`\n * on the client). Falls back to status-derived (401 → Authentication,\n * 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses\n * (CDN errors, intermediaries) or malformed bodies. Client-only types\n * (`Network`, `Timeout`, `Cancelled`, `File`, `Config`) are filtered out of the\n * trusted set — a misbehaving server claiming one of those is ignored.\n *\n * `operationName` (e.g. `\"Get account\"`) is used to compose the fallback\n * message. Defaults to `\"Request\"`. Same convention as `fromFetchError`.\n *\n * Async because it reads the response body. Returns rather than throws so\n * callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.\n */\n static async fromHttpResponse(response, operationName) {\n let message;\n let details;\n let bodyType;\n try {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n const json = await response.json();\n if (json && typeof json === 'object') {\n const obj = json;\n if (typeof obj.message === 'string')\n message = obj.message;\n else if (typeof obj.error === 'string')\n message = obj.error;\n details = obj.details;\n if (typeof obj.error === 'string' && SERVER_PRODUCIBLE_ERROR_TYPES.has(obj.error)) {\n bodyType = obj.error;\n }\n }\n }\n else {\n // A non-JSON body did not come from this platform — every API error\n // is `ErrorResponse` JSON — so it is an intermediary's output, and\n // the two kinds it produces need opposite treatment. A CDN's plain\n // `error code: 1015` is the most useful thing there is to say. A\n // proxy's HTML error page is a *document*, not a message: adopting it\n // verbatim made a misconfigured `apiUrl` print 2,059 characters of\n // markup as the error. Trust it only when it reads as a message.\n const text = (await response.text()).trim();\n if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {\n message = text;\n }\n }\n }\n catch {\n // Body unreadable; fall through to operationName-derived message.\n }\n // Rate-limit (and 503) timing rides the `Retry-After` HEADER, which a\n // body-only reader would drop. Lift it into `details` as seconds so\n // consumers can back off from the typed error alone, without keeping the\n // raw Response around. Body-carried fields are preserved and win.\n const retryAfterHeader = response.headers.get('retry-after');\n if (retryAfterHeader !== null) {\n const value = retryAfterHeader.trim();\n const seconds = /^\\d+$/.test(value)\n ? Number(value)\n : Math.ceil((Date.parse(value) - Date.now()) / 1000);\n if (Number.isFinite(seconds) && seconds >= 0) {\n const existing = details && typeof details === 'object' ? details : {};\n if (existing.retryAfter === undefined) {\n details = { ...existing, retryAfter: seconds };\n }\n }\n }\n message = message || `${operationName || 'Request'} failed with status ${response.status}`;\n const type = bodyType ??\n (response.status === 401\n ? ErrorType.Authentication\n : response.status === 403\n ? ErrorType.Forbidden\n : response.status === 429\n ? ErrorType.RateLimit\n : ErrorType.Api);\n return new ShipError(type, message, response.status, details);\n }\n /**\n * Construct a `ShipError` from an error caught around a `fetch()` call.\n *\n * The mirror of `fromHttpResponse` for the *other* side of the HTTP error\n * story — the network layer failing (offline, CORS, abort) rather than the\n * server returning a non-OK response.\n *\n * Routing:\n * - Already a `ShipError` → returned as-is (caller's intent preserved)\n * - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose\n * - `TimeoutError` → `ShipError.timeout(...)` — a deadline expired; the\n * message names the timeout, and the type is in the network CATEGORY\n * because nothing was exchanged\n * - A transport failure → `ShipError.network(...)` — see `isTransportFailure`\n * for what each runtime offers as evidence\n * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)\n * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`\n *\n * **Abort and timeout are read from `name` BEFORE any `instanceof Error`\n * gate.** A `DOMException` satisfies that gate in every runtime measured\n * (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the\n * inheritance is a comparatively recent spec change and this classification\n * has no reason to depend on it: `name` is where the meaning lives, and\n * reading it first costs nothing. The suite plants a non-`Error`\n * `DOMException` shape to hold the arm, since no runtime on the table\n * produces one.\n *\n * A caller's own `AbortSignal.timeout()` is the reachable source of\n * `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit\n * reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a\n * deadline is indistinguishable from a cancellation and lands on\n * `Cancelled`. Recorded rather than worked around; `Cancelled` is honest\n * there, since the caller's signal is what stopped it.\n *\n * The optional `operationName` is composed into the message for context:\n * `\"Get account was cancelled\"`, `\"Get account failed: ...\"`. Defaults to\n * `\"Request\"` when omitted.\n */\n static fromFetchError(cause, operationName) {\n if (isShipError(cause))\n return cause;\n const op = operationName || 'Request';\n // Read by NAME, ahead of the Error gate — see the note above.\n const name = cause?.name;\n if (name === 'AbortError') {\n return ShipError.cancelled(`${op} was cancelled`);\n }\n if (name === 'TimeoutError') {\n // A deadline: not a fault, not a cancellation, and — since it has its\n // own type — no longer merely \"network\". Nothing was exchanged, which\n // is what keeps it in the network CATEGORY and therefore retryable; the\n // type is what lets a surface say \"timed out\" instead of sending\n // someone to check their Wi-Fi. The runtime's own sentence is dropped\n // rather than relayed: \"The operation was aborted due to timeout\" is\n // the mechanism, not the news.\n return ShipError.timeout(`${op} timed out`, { cause });\n }\n if (cause instanceof Error) {\n if (isTransportFailure(cause)) {\n return ShipError.network(`${op} failed: ${cause.message}`, { cause });\n }\n return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);\n }\n return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);\n }\n // Factory methods. Uniform shape `(message, details?)` with two principled\n // exceptions: `notFound` composes its message from (resource, id?), and\n // `business` / `api` accept an optional status because they're the\n // multi-status fallbacks.\n static validation(message, details) {\n return new ShipError(ErrorType.Validation, message, 400, details);\n }\n static notFound(resource, id) {\n const message = id ? `${resource} ${id} not found` : `${resource} not found`;\n return new ShipError(ErrorType.NotFound, message, 404);\n }\n static forbidden(message, details) {\n return new ShipError(ErrorType.Forbidden, message, 403, details);\n }\n static rateLimit(message = 'Too many requests', details) {\n return new ShipError(ErrorType.RateLimit, message, 429, details);\n }\n /**\n * Construct an Authentication (401) error.\n *\n * **Telemetry pattern — `details: { internal: '<tag>' }`.** When the\n * server creates an auth error with an `internal` key in `details`\n * (e.g. `{ internal: 'session_invalid' }`), `toResponse()` strips the\n * entire `details` object before serialization. This keeps the wire\n * response a clean \"Authentication failed\" while preserving granular\n * server-side telemetry (which strategy/check failed) for logs and tests.\n *\n * Use this pattern in API auth code; do not put client-visible info under\n * `internal`. Other `details` keys round-trip normally.\n */\n static authentication(message = 'Authentication required', details) {\n return new ShipError(ErrorType.Authentication, message, 401, details);\n }\n static business(message, status = 400, details) {\n return new ShipError(ErrorType.Business, message, status, details);\n }\n static network(message, details) {\n return new ShipError(ErrorType.Network, message, undefined, details);\n }\n /**\n * A deadline expired before the exchange completed.\n *\n * Statusless like its four client-only siblings: no exchange completed, so\n * there is no HTTP status to report. `isNetworkError()` is true — see\n * `ErrorType.Timeout` for why the category is shared and the type is not.\n */\n static timeout(message, details) {\n return new ShipError(ErrorType.Timeout, message, undefined, details);\n }\n static cancelled(message, details) {\n return new ShipError(ErrorType.Cancelled, message, undefined, details);\n }\n static file(message, details) {\n return new ShipError(ErrorType.File, message, undefined, details);\n }\n static config(message, details) {\n return new ShipError(ErrorType.Config, message, undefined, details);\n }\n static api(message, status = 500, details) {\n return new ShipError(ErrorType.Api, message, status, details);\n }\n /**\n * The platform is closed for maintenance (503).\n *\n * `message` is REQUIRED and has no default here. The API is the only\n * producer of that sentence, and a default in this file would be a second\n * owner of one fact — see CLAUDE.md, \"The Constellation Law\" (stopping\n * rule). It is also the one factory whose status is fixed rather than\n * defaulted: a maintenance refusal is 503 or it is not this error.\n */\n static maintenance(message, details) {\n return new ShipError(ErrorType.Maintenance, message, 503, details);\n }\n // Semantic-category guards. For specific-type checks, use\n // `error.type === ErrorType.X` directly or the generic `isType(t)`.\n /**\n * The caller is at fault — by HTTP's own definition of a 4xx, or by a type\n * that is client-attributable without ever having a status (`Config`,\n * `File`, raised locally by the SDK).\n *\n * Both arms are load-bearing, because type and status are independent\n * axes. `fromHttpResponse` trusts `body.error` only when it names a\n * server-producible type; a non-OK response without one is status-derived,\n * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault\n * *type* carrying a client *status*. Judging by type alone would report it\n * as a platform failure and bury the server's own message.\n */\n isClientError() {\n if (ERROR_CATEGORIES.client.has(this.type))\n return true;\n return this.status !== undefined && this.status >= 400 && this.status < 500;\n }\n isNetworkError() {\n return ERROR_CATEGORIES.network.has(this.type);\n }\n isAuthError() {\n return ERROR_CATEGORIES.auth.has(this.type);\n }\n isType(errorType) {\n return this.type === errorType;\n }\n}\n/**\n * Type guard to check if an unknown value is a ShipError.\n *\n * Uses structural checking instead of instanceof to handle module duplication\n * in bundled applications where multiple copies of the ShipError class may exist.\n *\n * @example\n * if (isShipError(error)) {\n * console.log(error.status, error.message);\n * }\n */\nexport function isShipError(error) {\n return (error !== null &&\n typeof error === 'object' &&\n 'name' in error &&\n error.name === 'ShipError' &&\n 'status' in error);\n}\n// =============================================================================\n// EXTENSION MATCHING\n// =============================================================================\n/**\n * The rule for reading a file's extension: lowercase, after the last dot of\n * the last path segment. `null` when there is no extension to read.\n *\n * A leading dot names the file rather than its type, so `.gitignore` and\n * `.htaccess` have no extension — but `.env.exe` has `exe`.\n *\n * Segment-aware on purpose: the callers pass deploy PATHS, not basenames, and\n * a naive `lastIndexOf('.')` over `dir.v1/README` reads the extension\n * `v1/README` — safe only by accident, since no entry in a real blocklist\n * contains a slash, which is the kind of correctness nobody should have to\n * re-derive.\n *\n * **Private, and the reason is the asymmetry rather than any hazard.** Nothing\n * outside this file reads it: `isBlockedExtension` is the only question anyone\n * asks, and the API's refusal names the FILE, not its extension. Exporting a\n * pure function is harmless, which is exactly the argument that talks a\n * published package into surface it has not earned — and the costs do not\n * match, since adding an export later is free under the additive law while\n * removing one is a major. So it stays private until a caller exists. Its\n * behaviour is fenced through `isBlockedExtension`, which is where it is\n * observable.\n *\n * (`WEB_FILE_EXTENSIONS` above is private for a different reason — publishing\n * it would invite a wrong question. Both are private; only one is a hazard.)\n *\n * @example\n * fileExtension('virus.exe') // 'exe'\n * fileExtension('assets/style.CSS') // 'css'\n * fileExtension('dir.v1/README') // null\n * fileExtension('.gitignore') // null\n * fileExtension('file.') // null\n */\nfunction fileExtension(filename) {\n const basename = filename.replace(/\\\\/g, '/').split('/').pop() ?? '';\n const dotIndex = basename.lastIndexOf('.');\n if (dotIndex <= 0 || dotIndex === basename.length - 1)\n return null;\n return basename.slice(dotIndex + 1).toLowerCase();\n}\n/**\n * Whether a file is one the platform refuses to host.\n *\n * **The list is not this package's, and that separation is the point.** What\n * counts as a blocked extension is hosting POLICY — it evolves, it is enforced\n * at one security boundary, and `virus.exe` is a perfectly well-formed\n * filename that breaks nothing about the upload→serve round-trip. So the API\n * owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as\n * `PlatformLimits.blockedExtensions`; a client passes what it was given.\n *\n * What lives here is the MATCHING RULE, and it earns its place by the\n * constellation law's own test. The list's drift is loud in both directions —\n * a stale client uploads a file the API refuses by name, on the first try.\n * A second *matcher* drifts SILENTLY in the one direction that matters: a\n * client stricter than the server refuses a legal file without the server ever\n * being asked, and no error names it. Two holders, silent drift, one owner.\n *\n * The `blocked` collection is required rather than defaulted: this predicate\n * guards a security boundary in the API, and a defaulted-empty argument there\n * would block nothing while reading as though it did. Callers holding a\n * possibly-absent wire field spell the fail-open themselves.\n *\n * @example\n * isBlockedExtension('virus.exe', ['exe']) // true\n * isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive\n * isBlockedExtension('style.css', ['exe']) // false\n * isBlockedExtension('README', ['exe']) // false — no extension\n */\nexport function isBlockedExtension(filename, blocked) {\n const ext = fileExtension(filename);\n if (ext === null)\n return false;\n return Array.isArray(blocked) ? blocked.includes(ext) : blocked.has(ext);\n}\n// =============================================================================\n// PICKER ACCEPT HINT\n// =============================================================================\n/**\n * The extensions a browser file picker offers by default, grouped by role.\n *\n * Private on purpose: the only published form is `WEB_FILE_ACCEPT`, the\n * attribute value itself. A published set would invite a call site to ask it\n * whether a file is allowed — which is the one thing this list must never\n * answer. See `WEB_FILE_ACCEPT`.\n *\n * Extensionless files (`LICENSE`, most `.well-known` entries) are inexpressible\n * in `accept`, and reach a deployment by folder pick, ZIP, or drag-and-drop.\n */\nconst WEB_FILE_EXTENSIONS = [\n // Markup & documents\n 'html',\n 'htm',\n 'xhtml',\n 'xml',\n 'txt',\n 'md',\n 'markdown',\n 'pdf',\n 'csv',\n // Data & config\n 'json',\n 'jsonc',\n 'webmanifest',\n 'map',\n 'toml',\n 'yaml',\n 'yml',\n 'rss',\n 'atom',\n // Styles\n 'css',\n 'scss',\n 'sass',\n 'less',\n // Scripts & modules\n 'js',\n 'mjs',\n 'cjs',\n 'jsx',\n 'ts',\n 'tsx',\n 'wasm',\n 'vue',\n 'svelte',\n // Images\n 'png',\n 'jpg',\n 'jpeg',\n 'gif',\n 'webp',\n 'avif',\n 'svg',\n 'ico',\n 'bmp',\n 'tif',\n 'tiff',\n 'heic',\n 'heif',\n // Fonts\n 'woff',\n 'woff2',\n 'ttf',\n 'otf',\n 'eot',\n // Audio\n 'mp3',\n 'wav',\n 'ogg',\n 'oga',\n 'opus',\n 'm4a',\n 'aac',\n 'flac',\n 'weba',\n // Video\n 'mp4',\n 'webm',\n 'ogv',\n 'mov',\n 'm4v',\n 'avi',\n // 3D models\n 'glb',\n 'gltf',\n 'usdz',\n // Text tracks\n 'vtt',\n 'srt',\n // Archive — a whole site in one file\n 'zip',\n];\n/**\n * The `accept` attribute value for a browser file picker offering web files.\n *\n * **This is a hint, never a rule.** The API's blocklist is the platform's gate\n * and the only thing that decides what may be hosted; this constant decides\n * what a *file dialog* shows first. The two are not two halves of one policy,\n * and this one must never be consulted to accept or reject a file.\n *\n * The distinction is structural, not stylistic. `accept` can express only an\n * allowlist, while the platform's rule is a blocklist — so this list is\n * necessarily *narrower* than what the platform hosts, and reading it as\n * authority would reject files the platform serves happily. It is also not\n * enforcement in the browser's own terms: every file dialog offers an\n * all-files escape, and **drag-and-drop ignores `accept` entirely**. The\n * dropzone and the picker must reach the same verdict on the same files, and\n * they do — because the verdict is `validateFiles`, downstream of both.\n *\n * The invariant that matters — the picker must never offer a file the platform\n * will refuse — is fenced where the authority lives, in the API's own suite\n * (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published\n * string and holds it against the list it owns. It sat here until the\n * blocklist became the API's, and moving it was the price of that: a fence\n * belongs with whichever side can change and break it.\n */\nexport const WEB_FILE_ACCEPT = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');\n// =============================================================================\n// FILENAME CHARACTER VALIDATION\n// =============================================================================\n/**\n * Characters that are unsafe in filenames for static hosting.\n *\n * Blocks only characters that genuinely break the upload→serve round-trip:\n * - # ? % URL round-trip breakers (fragment, query, encoding ambiguity)\n * - \\ Path separator confusion (upload splits on backslash)\n * - < > \" XSS vectors with zero legitimate use in filenames\n * - \\x00-\\x1f \\x7f Control characters (header injection, display corruption)\n *\n * Everything else is allowed — browser percent-encodes, Worker decodes, R2 matches.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: blocking control characters is this regex's purpose\nexport const UNSAFE_FILENAME_CHARS = /[\\x00-\\x1f\\x7f#?%\\\\<>\"]/;\n/**\n * Check if a filename contains unsafe characters.\n *\n * @example\n * hasUnsafeChars('saved_resource(1).html') // false — parentheses are safe\n * hasUnsafeChars('page[slug].js') // false — brackets are safe\n * hasUnsafeChars('file#anchor.html') // true — # breaks URL resolution\n * hasUnsafeChars('file<tag>.html') // true — < is an XSS vector\n */\nexport function hasUnsafeChars(filename) {\n return UNSAFE_FILENAME_CHARS.test(filename);\n}\n// =============================================================================\n// UNBUILT PROJECT MARKERS\n// =============================================================================\n/**\n * Path segment names that indicate an unbuilt project was uploaded instead of build output.\n * Used for early detection in CLI, browser, and server validation.\n */\nexport const UNBUILT_PROJECT_MARKERS = new Set([\n 'node_modules',\n 'package.json',\n]);\n/**\n * Check if a file path contains an unbuilt project marker.\n *\n * @example\n * hasUnbuiltMarker('node_modules/react/index.js') // true\n * hasUnbuiltMarker('package.json') // true\n * hasUnbuiltMarker('dist/index.html') // false\n */\nexport function hasUnbuiltMarker(filePath) {\n const segments = filePath.replace(/\\\\/g, '/').split('/').filter(Boolean);\n return segments.some((s) => UNBUILT_PROJECT_MARKERS.has(s));\n}\n// =============================================================================\n// CREDENTIAL SHAPES\n// =============================================================================\n// The one address for credential vocabulary: where human identity lives\n// (AUTH_BASE_PATH), how a request is authorized (AuthMethod), the shapes\n// that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN,\n// OAUTH_TOKEN, CALLER), the two halves of the one Bearer slot\n// (readBearerValue reads it, classifyToken/TokenKind dispatch on what came\n// out), and the delegated-access scopes (OAuthScope).\n//\n// THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's\n// three populations below. The deployment claim code is the API's own\n// (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so\n// it has one holder and stays there) and shares only clause 1 — it is the\n// platform's one deliberately BARE secret, because it never enters the\n// Bearer slot: minted into one URL, consumed by one endpoint's one field,\n// its context names it and a prefix would restate its route.\n// `tests/validation-constants.test.ts` holds the clauses over this file's\n// populations; the API's suite holds its own.\n//\n// 1. ONE ENTROPY STANDARD. Every minted secret is `HEX_LENGTH` hex characters\n// — one width for the whole platform, so \"how long is a credential\" has a\n// single answer rather than one per population. Generators read the width\n// from the population's own constant, so a minted value and an accepted\n// value cannot differ.\n//\n// 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what\n// it is before anything parses it — which is what lets `classifyToken`\n// below dispatch three populations sharing one `Authorization: Bearer`\n// slot, and what lets a value found in a log, a support ticket or a\n// pasted URL be recognised and revoked on sight. The OAuth access token\n// was this clause's one standing exception until 2026-08-14 — the\n// authorization server it was born on had no mint hook to give it a\n// prefix, and its successor does.\n//\n// 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch\n// order-independent, and it is the reason the populations are named on\n// different axes (`ship-` for the product, `deploy-` for the capability)\n// rather than sharing a stem. A `ship-` / `ship-deploy-` pair\n// reads tidier and is a trap: every deploy token would also match the\n// API-key branch, leaving correctness resting on the order of two `if`s.\n/**\n * Where human identity is mounted on the API host. The API mounts Better\n * Auth at this path (sign-in, sign-out, session reads, admin impersonation)\n * and the web console's auth client posts to it — shared here so the two\n * halves of the auth pair agree by construction, the same way both sides\n * already share the credential prefixes below.\n */\nexport const AUTH_BASE_PATH = '/auth';\n/**\n * How a request (or recorded activity) was authorized.\n *\n * Client populations: `SESSION` (first-party cookie), `API_KEY` (`ship-`\n * key), `TOKEN` (`deploy-` deploy token), `AGENT` (anonymous public deploy —\n * no credential; the platform grants the public-account identity per\n * request), `OAUTH` (delegated access token). Server populations: `WEBHOOK`\n * (signed webhook processing), `SYSTEM` (scheduled/background jobs).\n */\nexport const AuthMethod = {\n SESSION: 'session',\n API_KEY: 'apiKey',\n TOKEN: 'token',\n AGENT: 'agent',\n OAUTH: 'oauth',\n WEBHOOK: 'webhook',\n SYSTEM: 'system',\n};\n/**\n * Shape constants for API keys (`ship-{32 hex chars}`).\n * Single source of truth used by validation utilities and auth middleware.\n */\nexport const API_KEY = {\n /** Prefix that identifies an API key. */\n PREFIX: 'ship-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 32,\n /** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 37`). */\n TOTAL_LENGTH: 37,\n /** Number of trailing characters used to display a redacted hint (e.g. last 4). */\n HINT_LENGTH: 4,\n};\n/**\n * Shape constants for deploy tokens (`deploy-{32 hex chars}`).\n * Single source of truth used by validation utilities and auth middleware.\n *\n * Deliberately the same width as `API_KEY`: both are minted by one generator\n * and classified by prefix alone, so a length that differed between them\n * would be a second thing to know about a credential whose prefix already\n * says what it is.\n */\nexport const DEPLOY_TOKEN = {\n /** Prefix that identifies a deploy token. */\n PREFIX: 'deploy-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 32,\n /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */\n TOTAL_LENGTH: 39,\n};\n/**\n * Shape constants for OAuth access tokens (`oauth-{32 hex chars}`) — the\n * delegated population, minted by the platform's own authorization server\n * for a connected app acting on a user's behalf.\n *\n * Same width as the other two, and for the same reason: one entropy standard\n * across the platform, so \"how long is a credential\" has one answer.\n *\n * **This population is the access token alone.** Refresh tokens, authorization\n * codes and client secrets are deliberately NOT here and are deliberately not\n * prefixed by this constant: none of them ever enters the `Authorization:\n * Bearer` slot — a refresh token is posted as a form field to the token\n * endpoint, which knows what it is receiving — so `classifyToken` never sees\n * one and a prefix would name a population no dispatcher dispatches. The same\n * reasoning that keeps the deployment claim code bare.\n *\n * **The prefix must be applied at the MINT, never as a display wrapper.** The\n * authorization server hashes what it stores and the API hashes what it is\n * presented, so the prefix has to be inside the hashed string on both sides.\n * `@better-auth/oauth-provider` offers a `prefix.opaqueAccessToken` option\n * that prepends AFTER hashing and strips on its own read paths; using it would\n * store a hash of the UNPREFIXED token and silently break the platform's read\n * arm. The API therefore mints through `generateOpaqueAccessToken` — recorded\n * beside the config in `cloudflare/api/src/lib/auth/instance.ts`.\n */\nexport const OAUTH_TOKEN = {\n /** Prefix that identifies an OAuth access token. */\n PREFIX: 'oauth-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 32,\n /** Total length including prefix (`PREFIX.length + HEX_LENGTH = 38`). */\n TOTAL_LENGTH: 38,\n};\n/**\n * Shape constants for caller identifiers (the `X-Caller` instance-identity\n * header — rate-limit bucketing for multi-tenant orchestrators). The API\n * normalizes case and silently ignores malformed values (the header is\n * unauthenticated); clients validate at the boundary via `validateCaller`,\n * so a value the server would drop fails fast instead.\n */\nexport const CALLER = {\n /** HTTP header name. */\n HEADER: 'X-Caller',\n /** Maximum identifier length. */\n MAX_LENGTH: 128,\n /** Allowed characters: alphanumeric, dot, underscore, hyphen. */\n PATTERN: /^[a-zA-Z0-9._-]+$/,\n};\n/**\n * Token populations distinguishable by shape. The platform carries every\n * client token in one wire slot (`Authorization: Bearer <value>`) and\n * classifies by value, never by a side channel — this is the classifier.\n *\n * `API_KEY`, `DEPLOY_TOKEN` and `OAUTH` *are* `AuthMethod.API_KEY`,\n * `AuthMethod.TOKEN` and `AuthMethod.OAUTH` — the equality is structural, so\n * a classification flows straight into an auth method and the trio can never\n * drift.\n *\n * `OPAQUE` is any other value, and since 2026-08-14 it names NO population:\n * every credential this platform mints for the Bearer slot carries a prefix,\n * so an opaque bearer is a bearer we did not mint. It stays a member rather\n * than becoming a `null` return because a dispatcher with a total codomain\n * reads better than one with an absence in it — and because it is where a\n * future population would land before anyone gave it a shape, which is\n * exactly what the OAuth token itself did until its prefix existed.\n */\nexport const TokenKind = {\n API_KEY: AuthMethod.API_KEY,\n DEPLOY_TOKEN: AuthMethod.TOKEN,\n OAUTH: AuthMethod.OAUTH,\n OPAQUE: 'opaque',\n};\n/**\n * Classify a client token by shape. The single dispatch used by both sides\n * of the wire: API auth middleware (which population is this credential?)\n * and SDK validation (which format rules apply before sending?). Sharing it\n * is what guarantees client and server can never disagree on dispatch.\n */\nexport function classifyToken(token) {\n if (token.startsWith(API_KEY.PREFIX))\n return TokenKind.API_KEY;\n if (token.startsWith(DEPLOY_TOKEN.PREFIX))\n return TokenKind.DEPLOY_TOKEN;\n if (token.startsWith(OAUTH_TOKEN.PREFIX))\n return TokenKind.OAUTH;\n return TokenKind.OPAQUE;\n}\n/** The auth-scheme, lowercased — the form the comparison is made in. */\nconst BEARER_SCHEME = 'bearer ';\n/**\n * Read the credential out of an `Authorization` header value — the step\n * BEFORE `classifyToken`, and the other half of the one wire slot this\n * section owns.\n *\n * Returns the credential's own bytes, or `null` when the header carries a\n * foreign scheme or nothing after the scheme.\n *\n * **The scheme is folded; the credential is not.** RFC 7235 §2.1 makes the\n * auth-scheme case-insensitive, so `bearer`, `Bearer` and `BEARER` are the\n * same header. The value after it is opaque and is compared literally\n * everywhere it is used — `ship-`/`deploy-`/`oauth-` are lowercase hex, and\n * folding them would make a credential match values it is not.\n *\n * **This platform has paid for the rule twice, which is why it has an owner\n * rather than a convention.** A spec-conformant `bearer ship-…` client was\n * refused for as long as the API's scheme test was spelled case-sensitively;\n * and `@better-auth/oauth-provider` carries the same defect in four places\n * today (`startsWith(\"Bearer \")`), which is precisely why the platform folds\n * the scheme itself and hands the provider a bare token.\n *\n * **ABSENCE is deliberately not this function's business.** A missing header\n * and an unreadable one are different facts, and the callers that care split\n * on them: the API worker's middleware distinguishes `absent` (the only\n * anonymous path) from `unreadable` (a presented credential that is refused),\n * and collapsing the two here would take that distinction away from the layer\n * that needs it. Callers check for the header themselves and pass its value.\n *\n * **Why this lives in the constitution rather than in a worker's `shared/`.**\n * It is the same wire boundary `classifyToken` already owns — one reads the\n * slot, the other dispatches on what came out — and a rule with two holders\n * whose drift is silent earns exactly one owner regardless of what the\n * convoy costs. The estate's recorded refusal to own a `Bearer` CONSTANT\n * stands and is a different thing: that is RFC vocabulary, the same reason\n * this package owns no `\"POST\"`. A parser is not a spelling.\n */\nexport function readBearerValue(header) {\n if (header.slice(0, BEARER_SCHEME.length).toLowerCase() !== BEARER_SCHEME)\n return null;\n return header.slice(BEARER_SCHEME.length) || null;\n}\n/**\n * OAuth scope vocabulary for delegated third-party access tokens.\n * Single source of truth used by the authorization server (advertised in\n * `scopes_supported`), the API's scope-enforcement middleware, and consent UI\n * copy. The standard `offline_access` scope (refresh tokens) is not platform\n * vocabulary and is deliberately absent — the middleware never checks it.\n *\n * Deliberately absent by design: any `tokens:*` scope, `account:write`, or\n * admin scope — a delegated app must never mint credentials, delete the\n * account, or act as admin.\n */\nexport const OAuthScope = {\n ACCOUNT_READ: 'account:read',\n DEPLOYMENTS_READ: 'deployments:read',\n DEPLOYMENTS_WRITE: 'deployments:write',\n DOMAINS_READ: 'domains:read',\n DOMAINS_WRITE: 'domains:write',\n};\n// =============================================================================\n// DEPLOYMENT CONFIGURATION CONSTANTS\n// =============================================================================\nexport const DEPLOYMENT_CONFIG_FILENAME = 'ship.json';\n/** Default ship.json config for SPA routing. Single source of truth — used by both API and SDK. */\nexport const SPA_DEFAULT_CONFIG = {\n rewrites: [{ source: '/(.*)', destination: '/index.html' }],\n};\n/**\n * The `/spa-check` pre-flight's client-side envelope: which file is the\n * check's subject, and how large it may be before a client skips the call.\n *\n * One fact with three holders until this export — the API's config declared\n * the cap, the SDK's `checkSPA` hardcoded `100 * 1024`, and prose restated\n * \"100KB\". `INDEX_FILE` is the selection rule (the file whose content rides\n * `SPACheckRequest.index`), restated by every client that builds the request.\n *\n * Neither member is a validation boundary: a client over the cap simply\n * skips the pre-flight, because the server answers an oversized index\n * `isSPA: false` anyway. A consumer that cannot import this (n8n) needs no\n * size copy at all — outcome parity is the server's, not the client's.\n */\nexport const SPA_CHECK_CONSTRAINTS = {\n /** The file whose content is the check's subject. */\n INDEX_FILE: 'index.html',\n /** Skip the pre-flight above this size — the server would answer false. */\n MAX_INDEX_BYTES: 100 * 1024,\n};\n/**\n * Assert that a ship.json file is *syntactically* loadable. Syntax only —\n * never schema.\n *\n * ship.json is validated and compiled on the server, deliberately: the schema\n * and the compiler evolve, and a client that judged them would reject configs\n * a newer platform accepts. That reasoning bounds what a client may check to\n * the properties which are true of *every* past and future schema:\n *\n * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that\n * does not parse can never be a valid config;\n * 2. its top level is an object — ship.json is `{ ... }` in every version.\n *\n * Both are monotonic: neither can ever reject something the server would\n * accept. Everything beyond them (field names, types, rule semantics, which\n * keys are permitted) stays server-side, where it can change.\n *\n * The payoff is the common case. Hand-edited JSON fails on a trailing comma,\n * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from\n * documentation — mistakes that otherwise cost a full upload round-trip to\n * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped\n * before parsing rather than rejected, because the server accepts it too;\n * diverging there would reintroduce exactly the false rejection this\n * function exists to avoid.\n *\n * @throws {ShipError} `ErrorType.Config` — the same type the server's own\n * config rejection carries, so the error contract is identical wherever the\n * failure is detected.\n */\nexport function assertShipJsonSyntax(text) {\n const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n let parsed;\n try {\n parsed = JSON.parse(withoutBom);\n }\n catch (error) {\n throw ShipError.config(`invalid JSON format in config: ${error.message}`, {\n filePath: DEPLOYMENT_CONFIG_FILENAME,\n });\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {\n filePath: DEPLOYMENT_CONFIG_FILENAME,\n });\n }\n}\n// =============================================================================\n// VALIDATION UTILITIES\n// =============================================================================\n/**\n * Shared rule for prefixed credentials: `{PREFIX}{HEX_LENGTH hex chars}`.\n * The regex derives from the shape constants, so the validators can never\n * drift from the shapes `classifyToken` dispatches on.\n */\nfunction validatePrefixedCredential(value, shape, label) {\n if (!value.startsWith(shape.PREFIX)) {\n throw ShipError.validation(`${label} must start with \"${shape.PREFIX}\"`);\n }\n if (value.length !== shape.TOTAL_LENGTH) {\n throw ShipError.validation(`${label} must be ${shape.TOTAL_LENGTH} characters total (${shape.PREFIX} + ${shape.HEX_LENGTH} hex chars)`);\n }\n const hexPart = value.slice(shape.PREFIX.length);\n if (!new RegExp(`^[a-f0-9]{${shape.HEX_LENGTH}}$`, 'i').test(hexPart)) {\n throw ShipError.validation(`${label} must contain ${shape.HEX_LENGTH} hexadecimal characters after \"${shape.PREFIX}\" prefix`);\n }\n}\n/**\n * Validate API key format\n */\nexport function validateApiKey(apiKey) {\n validatePrefixedCredential(apiKey, API_KEY, 'API key');\n}\n/**\n * Validate deploy token format\n */\nexport function validateDeployToken(deployToken) {\n validatePrefixedCredential(deployToken, DEPLOY_TOKEN, 'Deploy token');\n}\n/**\n * Validate OAuth access token format\n */\nexport function validateOAuthToken(oauthToken) {\n validatePrefixedCredential(oauthToken, OAUTH_TOKEN, 'OAuth access token');\n}\n/**\n * Validate a client token of any population. Classifies by shape and applies\n * the matching format rules: all three prefixed populations are validated\n * strictly; an OPAQUE token only needs to be non-empty.\n *\n * **The OPAQUE arm stays permissive on purpose**, even though the platform no\n * longer mints an unprefixed credential. It is the fallback for a population\n * that does not exist yet, and a client refusing a shape the server would\n * accept is the one failure mode this boundary must never have — the server\n * decides, and it refuses an unrecognised bearer anyway. Unprefixed OAuth\n * tokens from before 2026-08-14 land here and are refused server-side, which\n * is correct: they were revoked by the change, not grandfathered.\n */\nexport function validateToken(token) {\n switch (classifyToken(token)) {\n case TokenKind.API_KEY:\n validateApiKey(token);\n return;\n case TokenKind.DEPLOY_TOKEN:\n validateDeployToken(token);\n return;\n case TokenKind.OAUTH:\n validateOAuthToken(token);\n return;\n case TokenKind.OPAQUE:\n if (!token)\n throw ShipError.validation('Token must be a non-empty string');\n }\n}\n/**\n * Validate a caller identifier against the `CALLER` shape. The server\n * silently ignores malformed values (the header is unauthenticated); clients\n * call this at configuration time so the drop never silently happens.\n */\nexport function validateCaller(caller) {\n if (!caller || caller.length > CALLER.MAX_LENGTH || !CALLER.PATTERN.test(caller)) {\n throw ShipError.validation(`Caller must be 1-${CALLER.MAX_LENGTH} characters: letters, digits, dots, underscores, or hyphens`);\n }\n}\n/**\n * Validate API URL format\n */\nexport function validateApiUrl(apiUrl) {\n try {\n const url = new URL(apiUrl);\n if (!['http:', 'https:'].includes(url.protocol)) {\n throw ShipError.validation('API URL must use http:// or https:// protocol');\n }\n if (url.pathname !== '/' && url.pathname !== '') {\n throw ShipError.validation('API URL must not contain a path');\n }\n if (url.search || url.hash) {\n throw ShipError.validation('API URL must not contain query parameters or fragments');\n }\n }\n catch (error) {\n if (isShipError(error)) {\n throw error;\n }\n throw ShipError.validation('API URL must be a valid URL');\n }\n}\n/**\n * Check if a string matches the deployment identifier pattern (word-word-alphanumeric7).\n * Example: \"happy-cat-abc1234.shipstatic.com\"\n */\nexport function isDeployment(input) {\n return /^[a-z]+-[a-z]+-[a-z0-9]{7}(\\.[a-z0-9.-]+)?$/i.test(input);\n}\n/**\n * The envelope a requested lifetime must fit — one word, one grammar, wherever\n * the platform lets a caller choose how long something lives.\n *\n * Two resources wear it: `TokenCreateOptions.ttl` and\n * `DeploymentUploadOptions.ttl`. It lives here rather than on the server by\n * the format-vs-policy rule — a client can decide offline whether a duration\n * is well-formed, and the API rejects the same value the same way. What is\n * NOT here is any per-plan ceiling: no such policy exists, and one delivered\n * speculatively through `/limits` would be an owner for a decision nobody has\n * made.\n */\nexport const TTL_CONSTRAINTS = {\n /**\n * Shortest requestable lifetime, in seconds. One rather than zero: a\n * deployment that expires the instant it is created is not a shorter lease,\n * it is a deploy that was never live, and `0` is how an unset variable\n * arrives.\n */\n MIN_SECONDS: 1,\n /** Longest requestable lifetime, in seconds — one year. */\n MAX_SECONDS: 365 * 24 * 60 * 60,\n};\n/**\n * Validate a requested lifetime in SECONDS and return it, or `undefined` when\n * none was asked for.\n *\n * **A duration, never an instant.** The caller says how long; the server owns\n * what time it is and stamps the expiry — so a client's clock, however wrong,\n * cannot shorten or extend a lease. That is the tokens precedent, and it is\n * why this rule measures a count of seconds rather than checking a timestamp\n * against `now`.\n *\n * Fractions are refused rather than rounded: a caller who wrote `1.5` meant\n * something the wire cannot carry, and silently choosing `1` or `2` for them\n * is a decision the platform has no standing to make.\n *\n * Single source of truth shared by the API (the tokens route and the deploy\n * schema), the SDK's request boundary, and the CLI's parser.\n */\nexport function validateTtl(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw ShipError.validation('TTL must be a number of seconds');\n }\n if (!Number.isInteger(value)) {\n throw ShipError.validation('TTL must be a whole number of seconds');\n }\n if (value < TTL_CONSTRAINTS.MIN_SECONDS || value > TTL_CONSTRAINTS.MAX_SECONDS) {\n throw ShipError.validation(`TTL must be between ${TTL_CONSTRAINTS.MIN_SECONDS} and ${TTL_CONSTRAINTS.MAX_SECONDS} seconds`);\n }\n return value;\n}\n// =============================================================================\n// PLATFORM CONSTANTS\n// =============================================================================\n/** Default API URL if not otherwise configured. */\nexport const DEFAULT_API = 'https://api.shipstatic.com';\n/**\n * The Node SDK's ambient configuration pair — the ONLY environment variables\n * the SDK reads, and therefore the COMPLETE list an embedding host must\n * scrub (per `npm/ship`'s strict-isolation contract, scrubbing is the host's\n * job, not the SDK's). A host that derives its scrub from this object's\n * values — as the VS Code extension's child-process env block does — picks\n * up a grown contract at the next pin bump instead of by remembered prose.\n *\n * Browser builds read no environment at all, and the CLI-only variables\n * (`SHIP_PASSWORD`, `SHIP_VIA`) are deliberately NOT here: they are the\n * CLI's operational levers, not the SDK's ambient contract — see\n * `npm/ship/CLAUDE.md`, \"CLI-only env vars\".\n */\nexport const SHIP_ENV = {\n /** The one credential slot — any platform token. */\n TOKEN: 'SHIP_TOKEN',\n /** The API endpoint override. */\n API_URL: 'SHIP_API_URL',\n};\n/**\n * Where a human creates an API key — the console deep link quoted by every\n * surface that teaches authentication (the CLI's config wizard, the VS Code\n * and n8n listings, the n8n rate-limit hint and credential copy). Written\n * out in five files across three repos until this export.\n *\n * Production-branded by design: published artifacts name the product, never\n * an environment (root `CLAUDE.md`, \"Environment-Aware URLs\").\n */\nexport const MY_API_KEY_URL = 'https://my.shipstatic.com/api-key';\n/**\n * How long an anonymous deployment lives before it expires.\n *\n * The lifetime of the public tier, and one fact with several readers. The API\n * stamps a deployment's `expires` from it and gives a claim code exactly the\n * same window — a live site with a dead claim link is a coherence bug, so the\n * two are one constant rather than two that agree. Both MCP transports quote\n * the duration in prose an agent reads, and derive it from here rather than\n * writing it out, which they did in eight places until this export existed.\n *\n * Seconds, spelled in the name: this platform has both second- and\n * millisecond-valued durations, and the pair is only safe when each says which\n * it is.\n */\nexport const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;\n// =============================================================================\n// FILE UPLOAD TYPES\n// =============================================================================\n/**\n * File status constants for validation state tracking\n */\nexport const FileValidationStatus = {\n /** File is pending validation */\n PENDING: 'pending',\n /** File failed during processing (before validation) */\n PROCESSING_ERROR: 'processing_error',\n /** File was excluded by validation warning (not an error) */\n EXCLUDED: 'excluded',\n /** File failed validation (blocks deployment) */\n VALIDATION_FAILED: 'validation_failed',\n /** File passed validation and is ready for deployment */\n READY: 'ready',\n};\n// =============================================================================\n// DOMAIN UTILITIES\n// =============================================================================\n/**\n * Check if a domain is a platform domain (subdomain of our platform).\n * Platform domains are free and don't require DNS verification.\n *\n * @example isPlatformDomain(\"www.shipstatic.com\", \"shipstatic.com\") → true\n * @example isPlatformDomain(\"example.com\", \"shipstatic.com\") → false\n */\nexport function isPlatformDomain(domain, platformDomain) {\n return domain.endsWith(`.${platformDomain}`);\n}\n/**\n * Check if a domain is a custom domain (not a platform subdomain).\n * Custom domains are billable and require DNS verification.\n *\n * @example isCustomDomain(\"example.com\", \"shipstatic.com\") → true\n * @example isCustomDomain(\"www.shipstatic.com\", \"shipstatic.com\") → false\n */\nexport function isCustomDomain(domain, platformDomain) {\n return !isPlatformDomain(domain, platformDomain);\n}\n/**\n * Extract subdomain from a platform domain.\n * Returns null if not a platform domain.\n *\n * @example extractSubdomain(\"www.shipstatic.com\", \"shipstatic.com\") → \"www\"\n * @example extractSubdomain(\"example.com\", \"shipstatic.com\") → null\n */\nexport function extractSubdomain(domain, platformDomain) {\n if (!isPlatformDomain(domain, platformDomain)) {\n return null;\n }\n return domain.slice(0, -(platformDomain.length + 1)); // +1 for the dot\n}\n/**\n * Generate HTTPS URL for a deployment hostname.\n */\nexport function generateDeploymentUrl(deployment) {\n return `https://${deployment}`;\n}\n/**\n * Generate HTTPS URL for a domain.\n */\nexport function generateDomainUrl(domain) {\n return `https://${domain}`;\n}\n// =============================================================================\n// LABEL UTILITIES\n// =============================================================================\n/**\n * Label validation constraints shared across UI and API.\n * These rules define the single source of truth for label validation.\n */\nexport const LABEL_CONSTRAINTS = {\n /** Minimum label length in characters */\n MIN_LENGTH: 3,\n /** Maximum label length in characters (concise labels, matches Stack Overflow's original limit) */\n MAX_LENGTH: 25,\n /** Maximum number of labels allowed per resource */\n MAX_COUNT: 10,\n /** Allowed separator characters between label segments */\n SEPARATORS: '._-',\n};\n/**\n * Label validation pattern.\n * Must start and end with alphanumeric (a-z, 0-9).\n * Can contain separators (. _ -) between segments, but not consecutive.\n *\n * Valid examples: 'production', 'v1.2.3', 'api_v2', 'us-east-1'\n * Invalid examples: 'ab' (too short), '-prod' (starts with separator), 'foo--bar' (consecutive separators)\n */\nexport const LABEL_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;\n/**\n * Serialize labels array to JSON string for database storage.\n * Returns null for empty or undefined arrays.\n *\n * @example serializeLabels(['web', 'production']) → '[\"web\",\"production\"]'\n * @example serializeLabels([]) → null\n * @example serializeLabels(undefined) → null\n */\nexport function serializeLabels(labels) {\n if (!labels || labels.length === 0)\n return null;\n return JSON.stringify(labels);\n}\n/**\n * Deserialize labels from JSON string to array.\n * Always returns an array — empty array for null/empty/invalid input.\n *\n * @example deserializeLabels('[\"web\",\"production\"]') → ['web', 'production']\n * @example deserializeLabels(null) → []\n * @example deserializeLabels('') → []\n */\nexport function deserializeLabels(labelsJson) {\n if (!labelsJson)\n return [];\n try {\n const parsed = JSON.parse(labelsJson);\n return Array.isArray(parsed) ? parsed : [];\n }\n catch {\n return [];\n }\n}\n// =============================================================================\n// PASSWORD UTILITIES\n// =============================================================================\n/**\n * Length constraints for the optional deployment password\n * (`DeploymentUploadOptions.password`). Single source of truth shared across\n * platform consumers.\n */\nexport const PASSWORD_CONSTRAINTS = {\n /** Minimum password length in characters */\n MIN_LENGTH: 6,\n /** Maximum password length in characters */\n MAX_LENGTH: 128,\n};\n/**\n * Validate an optional deployment password and return it normalized.\n *\n * Absent (`undefined` / `null`) → returns `undefined`. Present → trim\n * leading/trailing whitespace, then validate against `PASSWORD_CONSTRAINTS`\n * length bounds (internal whitespace is significant and counts toward\n * length). Throws `ShipError.validation` on breach; returns the trimmed\n * value.\n *\n * The trim is canonical: at upload, the API hashes the trimmed value; at\n * unlock, the router trims submissions before hashing. Submission and storage\n * agree byte-for-byte. Length validation runs on the trimmed value because\n * that's the user's actual intent — and it disarms a class of invisible\n * foot-guns (trailing newlines from copy/paste, mobile auto-spacing,\n * password-manager artifacts).\n *\n * Single source of truth shared by SDK (client-side validation, return\n * ignored) and API (server-side enforcement, return threaded into config).\n * Length is part of the wire-format contract; strength rules, if added later,\n * stay server-side. See `CLAUDE.md` \"Validation: format vs policy\".\n */\nexport function validatePassword(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'string') {\n throw ShipError.validation('Password must be a string');\n }\n const trimmed = value.trim();\n if (trimmed.length < PASSWORD_CONSTRAINTS.MIN_LENGTH ||\n trimmed.length > PASSWORD_CONSTRAINTS.MAX_LENGTH) {\n throw ShipError.validation(`Password must be between ${PASSWORD_CONSTRAINTS.MIN_LENGTH} and ${PASSWORD_CONSTRAINTS.MAX_LENGTH} characters`);\n }\n return trimmed;\n}\n","/**\n * Test utilities for consumers of `useDrop`.\n *\n * ```typescript\n * import { createMockDrop } from '@shipstatic/drop/testing';\n * ```\n *\n * The whole subpath exists for one reason: a `DropReturn` has twenty fields, and\n * a component test that takes `drop` as a prop should not have to build them.\n * Everything else — spying, matching, asserting — belongs to your test framework,\n * so this file deliberately ships none of it.\n */\n\nimport {\n FileValidationStatus,\n type FileValidationStatusType,\n WEB_FILE_ACCEPT,\n} from '@shipstatic/types';\nimport type { ProcessedFile } from './types';\nimport type { DropInputProps, DropReturn, DropzonePropsOptions, PickerMode } from './useDrop';\n\nconst noop = () => {};\n\n/**\n * Build a `DropReturn` for rendering tests.\n *\n * Any field can be overridden, including with your own spies — which is how you\n * assert on interactions:\n *\n * ```tsx\n * const reset = vi.fn();\n * const drop = createMockDrop({ phase: 'ready', files: [...], reset });\n *\n * render(<DeployDropArea drop={drop} />);\n * await userEvent.click(screen.getByText('Clear'));\n *\n * expect(reset).toHaveBeenCalled();\n * ```\n *\n * The convenience booleans (`isProcessing`, `hasError`, `isInteractive`) and\n * `validFiles` are derived from `phase` and `files` unless you override them, so\n * the mock can never present a state the real hook could not reach by accident.\n */\nexport function createMockDrop(overrides: Partial<DropReturn> = {}): DropReturn {\n const phase = overrides.phase ?? 'idle';\n const files = overrides.files ?? [];\n const validFiles =\n overrides.validFiles ?? files.filter((f) => f.status === FileValidationStatus.READY);\n\n return {\n phase,\n isProcessing: phase === 'processing',\n isDragging: false,\n isInteractive: phase === 'idle' || phase === 'ready',\n hasError: phase === 'error',\n files,\n sourceName: '',\n status: null,\n needsBuild: false,\n\n getDropzoneProps: (options?: DropzonePropsOptions) => ({\n onDragOver: noop,\n onDragLeave: noop,\n onDrop: noop,\n ...(options?.clickable !== false && { onClick: noop }),\n }),\n // Mirrors the real getter's one branch: folder is the default, and exactly\n // one attribute tells the two pickers apart.\n getInputProps: (mode?: PickerMode): DropInputProps => ({\n ref: { current: null },\n type: 'file' as const,\n style: { display: 'none' },\n multiple: true,\n ...(mode === 'files' ? { accept: WEB_FILE_ACCEPT } : { webkitdirectory: '' }),\n onChange: noop,\n }),\n\n open: noop,\n processFiles: async () => {},\n reset: noop,\n\n validFiles,\n getFilesForUpload: () => validFiles.map((f) => f.file),\n\n // Explicit values win over every derivation above.\n ...overrides,\n };\n}\n\n/**\n * A `useDrop` replacement for consumers that call the hook rather than receiving\n * `drop` as a prop.\n *\n * ```tsx\n * vi.mock('@shipstatic/drop', () => ({ useDrop: mockUseDrop({ phase: 'ready' }) }));\n * ```\n *\n * Framework-agnostic on purpose — it returns a function, and your test framework\n * installs it. The value is not the three lines it saves: it is that the mock's\n * shape comes from `createMockDrop`, so it cannot describe a hook this package\n * does not have. A hand-written module mock can, and did — one consumer described\n * react-dropzone's API (`rejectedFiles`, `isDragActive`, `getRootProps`, `clear`)\n * for months, because nothing typechecked it.\n *\n * Note this replaces the WHOLE module. If you also import `processFiles` or a\n * type from `@shipstatic/drop`, spread the real module in first:\n *\n * ```tsx\n * vi.mock('@shipstatic/drop', async (importOriginal) => ({\n * ...(await importOriginal<typeof import('@shipstatic/drop')>()),\n * useDrop: mockUseDrop({ phase: 'ready' }),\n * }));\n * ```\n */\nexport function mockUseDrop(overrides: Partial<DropReturn> = {}): () => DropReturn {\n return () => createMockDrop(overrides);\n}\n\nlet mockFileIdCounter = 0;\n\n/** Build a `ProcessedFile` backed by a real `File`. */\nexport function createMockProcessedFile(\n name: string,\n options: {\n path?: string;\n content?: string;\n type?: string;\n status?: FileValidationStatusType;\n statusMessage?: string;\n } = {},\n): ProcessedFile {\n const {\n path = name,\n content = 'test content',\n type = 'text/plain',\n status = FileValidationStatus.READY,\n statusMessage,\n } = options;\n\n const file = new File([content], name, { type });\n\n return {\n id: `mock-file-${++mockFileIdCounter}`,\n file,\n path,\n name,\n size: file.size,\n type,\n lastModified: file.lastModified,\n status,\n statusMessage,\n };\n}\n\n/**\n * Build a real `File` carrying a folder-relative path, the way a browser\n * presents a folder drop. (`webkitRelativePath` is read-only, hence the\n * redefinition — the one part of this that is not a one-liner.)\n */\nexport function createMockFileWithPath(\n name: string,\n webkitRelativePath: string,\n content = 'test content',\n type = 'text/plain',\n): File {\n const file = new File([content], name, { type });\n Object.defineProperty(file, 'webkitRelativePath', {\n value: webkitRelativePath,\n writable: false,\n enumerable: true,\n configurable: true,\n });\n return file;\n}\n"]}
1
+ {"version":3,"sources":["../node_modules/.pnpm/@shipstatic+types@2.9.0-beta.2/node_modules/@shipstatic/types/dist/index.js","../src/testing.ts"],"names":[],"mappings":"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","file":"testing.js","sourcesContent":["/**\n * @file Shared TypeScript types, constants, and utilities for the ShipStatic platform.\n * This package is the single source of truth for all shared data structures.\n */\n// =============================================================================\n// DEPLOYMENT TYPES\n// =============================================================================\n/**\n * Deployment status constants\n */\nexport const DeploymentStatus = {\n PENDING: 'pending',\n SUCCESS: 'success',\n FAILED: 'failed',\n DELETING: 'deleting',\n};\n/**\n * Where a deployment came from — the origin-tracking vocabulary.\n *\n * A closed set with many authors. It lived in the API's config until\n * 2026-08-06, where being server-side made it unenforceable in the one\n * direction that matters — every client wrote a bare string, and a value\n * outside the set was **silently dropped** by the server, so a typo did not\n * fail anywhere. It stopped recording where deploys came from and said nothing.\n *\n * **The origin law: origin is declared by whatever we control — our code where\n * we ship code, our URL where we ship only a URL.** One rule decides every\n * member here and every future one:\n *\n * - **Where the platform ships CODE, the code declares it.** `web`, `sdk`,\n * `cli`, `git`, `n8n` and `vsc` are surfaces this platform authors, so each\n * names itself in its own source and nothing external is needed to tell them\n * apart.\n * - **Where the platform ships only a URL, the URL declares it.** A\n * marketplace listing runs somebody else's client against a bare endpoint —\n * every one of them the same server speaking the same protocol, and\n * indistinguishable in a request. The only thing such a listing's traffic\n * has in common is the URL its users were handed, so the hosted MCP serves\n * one DOOR per listing and the door's path IS the value: `gpt`, `cld`,\n * `crs`.\n *\n * **A member names the most specific surface the platform can honestly\n * claim**, which is what makes the two FALLBACKS fallbacks rather than peers\n * of the named surfaces. `mcp` is any MCP host that was never handed a door of\n * its own; `api` is a call that reached the REST API naming nothing at all.\n * Guessing past either would be inventing attribution rather than recording\n * it, which is the one thing this vocabulary exists to prevent.\n *\n * Every member is three lowercase characters — the property that lets a\n * channel door's path and its attribution be spelled the same. The suite pins\n * both the width and the channel members by name.\n */\nexport const DeploymentVia = {\n /** The web dashboard. */\n WEB: 'web',\n /** A program embedding the SDK directly. */\n SDK: 'sdk',\n /** The `ship` CLI. */\n CLI: 'cli',\n /** Any MCP host with no door of its own — the stdio server included. The family fallback. */\n MCP: 'mcp',\n /** The GitHub Action. */\n GIT: 'git',\n /** The n8n community node. */\n N8N: 'n8n',\n /** Channel: the ChatGPT App listing → `mcp.<domain>/gpt`. */\n GPT: 'gpt',\n /** The VS Code extension. */\n VSC: 'vsc',\n /** Channel: the Claude connectors directory listing → `mcp.<domain>/cld`. */\n CLD: 'cld',\n /** Channel: the Cursor marketplace listing → `mcp.<domain>/crs`. */\n CRS: 'crs',\n /**\n * A deploy that reached the REST API naming no origin at all — the\n * platform-wide fallback, one altitude below `mcp`'s family fallback.\n *\n * **Declared ahead of its emitter, deliberately.** Nothing sends it yet and\n * the server still records an unattributed deploy as `null`. Vocabulary must\n * exist before a consumer can adopt it, and adding a member costs a full\n * constellation convoy — so the word ships first and the API adopts it as a\n * default whenever that decision is taken, with no convoy standing between\n * the decision and the deploy.\n */\n API: 'api',\n};\n// =============================================================================\n// DOMAIN TYPES\n// =============================================================================\n/**\n * Domain status constants\n *\n * - PENDING: DNS not configured\n * - PARTIAL: DNS partially configured\n * - SUCCESS: DNS fully verified\n * - PAUSED: Domain paused due to plan enforcement (billing)\n */\nexport const DomainStatus = {\n PENDING: 'pending',\n PARTIAL: 'partial',\n SUCCESS: 'success',\n PAUSED: 'paused',\n};\n/**\n * The envelope an `Idempotency-Key` must fit, and how long a replay lasts.\n *\n * Format lives here rather than on the server alone by the format-vs-policy\n * rule: a client can decide offline whether a key is well-formed, and the\n * API would reject the same value the same way.\n */\nexport const IDEMPOTENCY_KEY_CONSTRAINTS = {\n /**\n * HTTP header name. Here for the same reason {@link CALLER.HEADER} is: a\n * wire header has two ends, and the package that owns the value's format\n * is the only place both ends can read its name from.\n */\n HEADER: 'Idempotency-Key',\n MAX_LENGTH: 256,\n /** How long a stored 201 stays replayable. */\n WINDOW_SECONDS: 24 * 60 * 60,\n};\n/**\n * Normalize a `via` value from any transport — trimmed, lowercased, and a\n * member of {@link DeploymentVia}, or `undefined`.\n *\n * A format rule by this package's own test: a client can decide offline\n * whether a value is well-formed, and the API reaches the same verdict on the\n * same input. It lived server-side until 2026-08-06, which meant clients could\n * only learn their label was unusable by noticing analytics had gone quiet.\n *\n * **Not knowing your `via` is not an error** — an unrecognized value yields\n * `undefined` rather than throwing, because origin tracking is telemetry and a\n * deploy must never fail over it. A caller that has an honest default should\n * prefer it (`normalizeVia(process.env.SHIP_VIA) ?? DeploymentVia.CLI`): the\n * deploy really did come from the CLI, so recording that beats recording\n * nothing.\n */\nexport function normalizeVia(value) {\n if (!value || typeof value !== 'string')\n return undefined;\n const via = value.trim().toLowerCase();\n return Object.values(DeploymentVia).includes(via)\n ? via\n : undefined;\n}\n/**\n * Validate an idempotency key, returning the trimmed value or `undefined`\n * when none was supplied. Throws {@link ShipError.validation} when the value\n * cannot be sent — the same verdict the API would reach, reached earlier.\n */\nexport function validateIdempotencyKey(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'string') {\n throw ShipError.validation('Idempotency key must be a string.');\n }\n const key = value.trim();\n if (!key) {\n throw ShipError.validation('Idempotency key must not be empty.');\n }\n if (key.length > IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH) {\n throw ShipError.validation(`Idempotency key must be at most ${IDEMPOTENCY_KEY_CONSTRAINTS.MAX_LENGTH} characters.`);\n }\n return key;\n}\n// =============================================================================\n// ACCOUNT TYPES\n// =============================================================================\n/**\n * Account plan constants\n */\nexport const AccountPlan = {\n FREE: 'free',\n STANDARD: 'standard',\n SPONSORED: 'sponsored',\n ENTERPRISE: 'enterprise',\n SUSPENDED: 'suspended',\n TERMINATING: 'terminating',\n TERMINATED: 'terminated',\n};\n// =============================================================================\n// WIRE SURFACE\n// =============================================================================\n/**\n * Every path the public API answers on, declared once.\n *\n * The URL surface was written out in four places — the API's mounts, the\n * SDK's client, the dashboard's client, and the post-deploy smoke — so a\n * rename meant finding all four. The first three now read this table.\n *\n * The smoke (`cloudflare/api/smoke.mjs`) deliberately still spells its own:\n * five of its nine paths are `/admin/*`, which this table excludes by\n * design, and splitting one list between a registry and literals reads worse\n * than keeping it uniform.\n *\n * **What this guarantees, exactly.** Collection paths are mounted from here,\n * so producer and consumer cannot diverge. Item paths are declared here and\n * consumed by clients, but the API spells them relative to their mount\n * (`/:deployment/config`), so the table does not *generate* them — it is\n * held to them by `api/tests/architecture/api-paths.test.ts`, which fails if\n * any entry names a path no route answers. Some entries have no client yet\n * (`DEPLOYMENT_CONFIG`, `DOMAIN_PROPAGATION` — endpoints the SDK\n * deliberately does not reach); the fence is what keeps those honest rather\n * than merely asserted.\n *\n * **The operator surface is deliberately absent.** `/admin/*` paths belong\n * to `web/my`, for the same reason its row types do: this package is\n * published, and the operator surface is not public (see `CLAUDE.md`, \"Admin\n * types\"). A path here is a promise to every npm consumer; `/admin` is a\n * promise to one dashboard.\n *\n * Item paths are functions rather than templates so the key is interpolated\n * in one place, encoded the same way by every caller.\n */\nexport const API_PATHS = {\n DEPLOYMENTS: '/deployments',\n DEPLOYMENT: (deployment) => `/deployments/${deployment}`,\n DEPLOYMENT_CONFIG: (deployment) => `/deployments/${deployment}/config`,\n DOMAINS: '/domains',\n DOMAIN: (domain) => `/domains/${domain}`,\n DOMAIN_VERIFY: (domain) => `/domains/${domain}/verify`,\n DOMAIN_DNS: (domain) => `/domains/${domain}/dns`,\n DOMAIN_RECORDS: (domain) => `/domains/${domain}/records`,\n DOMAIN_SHARE: (domain) => `/domains/${domain}/share`,\n DOMAIN_PROPAGATION: (domain) => `/domains/${domain}/propagation`,\n DOMAINS_VALIDATE: '/domains/validate',\n TOKENS: '/tokens',\n TOKEN: (token) => `/tokens/${token}`,\n ACCOUNT: '/account',\n ACCOUNT_KEY: '/account/key',\n ACCOUNT_CLAIM: '/account/claim',\n ACTIVITIES: '/activities',\n LABELS: '/labels',\n LIMITS: '/limits',\n PING: '/ping',\n SETUP: '/setup',\n SPA_CHECK: '/spa-check',\n UPLOAD: '/upload',\n};\n/**\n * The deploy request's multipart field names — the other half of the wire\n * surface beside {@link API_PATHS}. `POST /deployments` (and the first-party\n * `/upload`) is multipart/form-data, and these are the names the API reads.\n *\n * Declared once because the body has three independent WRITERS — the SDK's\n * Node and browser body builders, and the n8n community node's hand-rolled\n * client (which cannot import this under n8n Cloud's zero-dependency rule,\n * and fences its restated copy instead) — and until this export every writer\n * restated the strings the API parses, with nothing comparing them.\n *\n * `FILES` carries one entry per file (the API reads it with `getAll`); every\n * other field is single. The `@internal` flags are serialized as the literal\n * string `'true'` and belong to first-party surfaces only.\n */\nexport const DEPLOY_FIELDS = {\n /** One entry per file — read with `getAll`. */\n FILES: 'files[]',\n /** JSON array of MD5 hex digests, index-aligned with `FILES`. */\n CHECKSUMS: 'checksums',\n /** JSON array of label strings. */\n LABELS: 'labels',\n /** The deploying surface's {@link DeploymentVia} member. */\n VIA: 'via',\n /** Plaintext password — the API hashes it server-side. */\n PASSWORD: 'password',\n /**\n * Requested lifetime in SECONDS — a duration, never an instant. The API\n * computes and stores the expiry, so the wire carries no client clock.\n * See {@link validateTtl}.\n */\n TTL: 'ttl',\n /** @internal Server-processing flag — first-party `/upload` only. */\n BUILD: 'build',\n /** @internal Server-processing flag — first-party `/upload` only. */\n PRERENDER: 'prerender',\n /** @internal Server-processing flag — first-party `/upload` only. */\n SPA: 'spa',\n /** @internal reCAPTCHA proof — `web/www`'s public uploader only. */\n CAPTCHA: 'captcha',\n};\n// =============================================================================\n// ERROR SYSTEM\n// =============================================================================\n/**\n * All possible error types in the ShipStatic platform.\n *\n * Developer-friendly key names map to stable wire-format string values.\n * Both the value and the type are exported under the same name so callers\n * can use `ErrorType.Validation` (value comparison) and `: ErrorType` (type\n * annotation) without ceremony — matching the pattern other status objects\n * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.\n */\nexport const ErrorType = {\n /**\n * Validation failed. Input shape is wrong.\n *\n * Carries 400 when an API judged it — including a client-side pre-check of a\n * rule the server enforces too, which keeps the error identical wherever it\n * was caught. **Statusless** when a client rejects something no API judges,\n * such as a CLI's own command grammar: `status` is documented \"(API\n * contexts)\" on `ErrorResponse`, so there is none to report.\n */\n Validation: 'validation_failed',\n /** Resource not found (404). */\n NotFound: 'not_found',\n /** Authenticated but not allowed (403). User lacks permission for this action. */\n Forbidden: 'forbidden',\n /** Rate limit exceeded (429). */\n RateLimit: 'rate_limit_exceeded',\n /** Authentication required or failed (401). Missing/invalid credentials. */\n Authentication: 'authentication_failed',\n /** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */\n Business: 'business_logic_error',\n /** API server error (500). Generic server-side fault. */\n Api: 'internal_server_error',\n /**\n * The platform is closed for maintenance (503). A deliberate operator\n * state, not a fault — nothing errored; the API is refusing work on\n * purpose, and deployed sites keep serving throughout.\n *\n * Distinct from `Api` at 503, which the platform already uses for a\n * dependency that failed (moderation unavailable). A consumer has to tell\n * \"we closed the door\" from \"something broke\": the two get opposite words\n * and opposite retry behaviour.\n */\n Maintenance: 'maintenance',\n /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */\n Network: 'network_error',\n /**\n * A deadline expired before the exchange completed. Client-side only — set\n * by HTTP clients when a timeout signal fires; never produced server-side.\n *\n * A member of the NETWORK category rather than a sibling of it:\n * `isNetworkError()` answers \"nothing was exchanged\", which is true of a\n * deadline exactly as it is of a refused connection, so every consumer that\n * retries, declines to report, or declines to relay a wire message on that\n * category is already right about a timeout. The distinct TYPE exists for\n * the one decision the category cannot make — what to SAY. \"Check your\n * internet connection\" is the wrong sentence for a five-minute deploy\n * ceiling, and a surface can only tell the two apart by type.\n *\n * The same relationship every comparable SDK ships:\n * `APIConnectionTimeoutError extends APIConnectionError`.\n */\n Timeout: 'timeout_error',\n /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */\n Cancelled: 'operation_cancelled',\n /** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */\n File: 'file_error',\n /** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */\n Config: 'config_error',\n};\n/**\n * Error types that originate exclusively on the client (HTTP clients, SDK\n * file processing, local config parsing). These never appear on the wire\n * from the server, so `fromHttpResponse` will not trust them even if a\n * misbehaving server claims one in `body.error`.\n */\nconst CLIENT_ONLY_ERROR_TYPES = new Set([\n ErrorType.Network,\n ErrorType.Timeout,\n ErrorType.Cancelled,\n ErrorType.File,\n ErrorType.Config,\n]);\n/**\n * Categorizes error types for the `isClientError` / `isNetworkError` /\n * `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`\n * union so `.has(error.type)` accepts any value from the union.\n */\nconst ERROR_CATEGORIES = {\n /**\n * Client-attributable types. Exhaustive over the 4xx-carrying types, and\n * over the statusless ones too — those are raised locally and have no\n * status for `isClientError`'s second arm to read, so omitting one makes it\n * read as a server fault. The rule is the membership test: every type in\n * `CLIENT_ONLY_ERROR_TYPES` except the two `isNetworkError` owns belongs\n * here.\n *\n * `Cancelled` was missing until 2026-07-29, which is exactly that failure:\n * a caller who aborted their own deploy was told \"server error: please try\n * again\" — the CLI's fallback for everything this set does not claim.\n *\n * `Timeout` is deliberately NOT here, and it is the sharper case, because\n * it is the one client-only type that is not the client's fault: the\n * caller set a ceiling, but what exhausted it was the network or the\n * server. Reading it as client-attributable would say the caller erred,\n * and it would silently disarm every consumer whose retry predicate\n * declines `isClientError()` — a deadline is precisely the failure worth\n * a second attempt.\n */\n client: new Set([\n ErrorType.Business,\n ErrorType.Cancelled,\n ErrorType.Config,\n ErrorType.File,\n ErrorType.Forbidden,\n ErrorType.NotFound,\n ErrorType.RateLimit,\n ErrorType.Validation,\n ]),\n /**\n * The exchange never happened. Two types, one category: a refused\n * connection and an expired deadline differ in what a surface should SAY\n * and in nothing else a consumer decides on — both are retryable, neither\n * carries a wire message to relay, neither is worth reporting as an\n * incident. See `ErrorType.Timeout` for why the type is distinct anyway.\n */\n network: new Set([ErrorType.Network, ErrorType.Timeout]),\n auth: new Set([ErrorType.Authentication]),\n};\n/**\n * Error types the server can legitimately produce on the wire. Used by\n * `ShipError.fromHttpResponse` to validate the body's `error` field before\n * trusting it as `ShipError.type`. Derived by exclusion from\n * `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to\n * `ErrorType` is automatically picked up.\n */\nconst SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)));\n/**\n * Ceiling on a message adopted from a **non-JSON** error body — a foreign\n * responder's, never this platform's. Generous for the plain-text one-liners\n * intermediaries actually send (`error code: 1015`), far below a document.\n * Our own messages are never measured against it: a JSON body is the API's\n * contract, and truncating a long validation message would be the bug.\n */\nconst MAX_FOREIGN_MESSAGE_LENGTH = 200;\n/**\n * Did the runtime say the exchange never completed?\n *\n * Clients branch on the TYPE, never on message strings, so a misclassified\n * transport failure is a lie every consumer inherits — and the one that costs\n * most: `Api` claims a server answered when nothing was exchanged, and a\n * retrying caller will not retry it.\n *\n * **Every row below is a transcript, not a belief.** Captured 2026-08-12\n * against real runtimes — Node and Bun by direct run, the three engines by a\n * one-off playwright probe, workerd through miniflare. The capture scripts are\n * in `tests/errors.test.ts`, \"runtime failure shapes\".\n *\n * | runtime | connection refused / DNS failure | malformed URL |\n * |------------------|------------------------------------------------------|--------------------------------------------------|\n * | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |\n * | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |\n * | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |\n * | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |\n * | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |\n * | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |\n *\n * Reading that table gives the rule, and it is the INVERSE of the obvious one.\n * The transport class is unbounded — every OS, TLS and DNS failure any engine\n * will ever name — while the class fetch raises for its own ARGUMENTS is\n * small, and every runtime names the URL when it complains about one. So the\n * bounded side is the one worth testing, and the residual risk points the safe\n * way: an unrecognised sentence lands on `Network`, which says only that\n * nothing was exchanged.\n *\n * That inversion is what fixes **WebKit**, whose `Load failed` carries no code\n * and no \"fetch\", and which every browser-SDK and `@shipstatic/drop` user on\n * Safari was hitting as `Api`. It also makes the six runtimes AGREE about a\n * malformed URL, which they did not before: the previous rule tested the\n * message for \"fetch\", and Chromium's and Firefox's URL complaints both\n * contain it, so the same mistake was `Network` on three engines and `Api` on\n * three.\n *\n * **workerd is the recorded gap.** It rejects with a plain `Error`, no code\n * and no shared sentence — and its two failure modes produce two unrelated\n * ones — so nothing here can classify it and it lands on `Api`. Left alone\n * rather than patched with a dialect string: the one consumer running ship in\n * that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,\n * which is in-process and does not produce transport rejections at all.\n *\n * **The `TokenProvider` case stopped being a trade when clients gained\n * retries.** A caller's provider that throws a coded error is typed `Network`\n * here, which was recorded as \"both are wrong for it; `Network` is the cheaper\n * wrong\" — written when the classification decided only what a surface would\n * SAY. It now also decides whether the call is retried, and that turns the\n * cheaper wrong into the right answer: a `TokenProvider` is where minting and\n * refresh live, so the common one is an OAuth refresh over the network, and a\n * transient failure there is precisely what another attempt repairs.\n *\n * The residual cost is a deterministic provider fault — a genuinely missing\n * keychain entry — invoking the provider three times over a few hundred\n * milliseconds before failing with the same error. No request leaves the\n * process on any of them. That is the cheap direction of a bet whose other\n * side is a refused deploy, and suppressing it would need a way to mark\n * credential faults non-retryable: machinery with one holder, refused by the\n * estate's stopping rule. Provider failures that carry no code are `Api` and\n * are not retried at all, and a provider yielding nothing is `Authentication`\n * by the fail-closed invariant, which is likewise terminal.\n */\nfunction isTransportFailure(cause) {\n const code = cause.code;\n // Bun is the one runtime that puts a CODE on an argument error, so it is\n // excluded before the code arm can claim it.\n if (code === 'ERR_INVALID_URL')\n return false;\n // A string `code` is a runtime naming a transport-level failure. An\n // allowlist of codes was written first and rejected — the TLS row alone\n // would mean enumerating BoringSSL's certificate table, and a code nobody\n // guessed is precisely the bug this closes. A `DOMException`'s code is a\n // NUMBER, so aborts and timeouts never reach here.\n if (typeof code === 'string')\n return true;\n // WHATWG has fetch reject with a TypeError for BOTH halves — network error\n // and argument error — so among TypeErrors the URL is the discriminator.\n if (cause instanceof TypeError)\n return !/\\burl\\b/i.test(cause.message);\n // Anything else — an ordinary JS fault, or workerd — is not evidence.\n return false;\n}\n/**\n * Simple unified error class for both API and SDK\n */\nexport class ShipError extends Error {\n type;\n status;\n details;\n constructor(type, message, status, details) {\n super(message);\n this.type = type;\n this.status = status;\n this.details = details;\n this.name = 'ShipError';\n }\n /** Convert to wire format */\n toResponse() {\n // Strip authentication details when they carry an `internal` telemetry\n // tag (see `ShipError.authentication` JSDoc) — these are server-side\n // diagnostics like 'session_invalid' that must not leak to clients.\n const authDetails = this.details;\n const details = this.type === ErrorType.Authentication && authDetails?.internal ? undefined : this.details;\n return {\n error: this.type,\n message: this.message,\n status: this.status,\n details,\n };\n }\n /**\n * Construct a `ShipError` from an HTTP error response.\n *\n * Best-effort body parse for `{ message, error?, details? }`. Message\n * resolution: `body.message` → `body.error` → `\"<operationName> failed with\n * status <N>\"`.\n *\n * Type resolution: trusts `body.error` when it's a known server-producible\n * `ErrorType` (preserves the wire's intent — server's\n * `ShipError.validation(...)` round-trips back to `ErrorType.Validation`\n * on the client). Falls back to status-derived (401 → Authentication,\n * 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses\n * (CDN errors, intermediaries) or malformed bodies. Client-only types\n * (`Network`, `Timeout`, `Cancelled`, `File`, `Config`) are filtered out of the\n * trusted set — a misbehaving server claiming one of those is ignored.\n *\n * `operationName` (e.g. `\"Get account\"`) is used to compose the fallback\n * message. Defaults to `\"Request\"`. Same convention as `fromFetchError`.\n *\n * Async because it reads the response body. Returns rather than throws so\n * callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.\n */\n static async fromHttpResponse(response, operationName) {\n let message;\n let details;\n let bodyType;\n try {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n const json = await response.json();\n if (json && typeof json === 'object') {\n const obj = json;\n if (typeof obj.message === 'string')\n message = obj.message;\n else if (typeof obj.error === 'string')\n message = obj.error;\n details = obj.details;\n if (typeof obj.error === 'string' && SERVER_PRODUCIBLE_ERROR_TYPES.has(obj.error)) {\n bodyType = obj.error;\n }\n }\n }\n else {\n // A non-JSON body did not come from this platform — every API error\n // is `ErrorResponse` JSON — so it is an intermediary's output, and\n // the two kinds it produces need opposite treatment. A CDN's plain\n // `error code: 1015` is the most useful thing there is to say. A\n // proxy's HTML error page is a *document*, not a message: adopting it\n // verbatim made a misconfigured `apiUrl` print 2,059 characters of\n // markup as the error. Trust it only when it reads as a message.\n const text = (await response.text()).trim();\n if (text && !text.startsWith('<') && text.length <= MAX_FOREIGN_MESSAGE_LENGTH) {\n message = text;\n }\n }\n }\n catch {\n // Body unreadable; fall through to operationName-derived message.\n }\n // Rate-limit (and 503) timing rides the `Retry-After` HEADER, which a\n // body-only reader would drop. Lift it into `details` as seconds so\n // consumers can back off from the typed error alone, without keeping the\n // raw Response around. Body-carried fields are preserved and win.\n const retryAfterHeader = response.headers.get('retry-after');\n if (retryAfterHeader !== null) {\n const value = retryAfterHeader.trim();\n const seconds = /^\\d+$/.test(value)\n ? Number(value)\n : Math.ceil((Date.parse(value) - Date.now()) / 1000);\n if (Number.isFinite(seconds) && seconds >= 0) {\n const existing = details && typeof details === 'object' ? details : {};\n if (existing.retryAfter === undefined) {\n details = { ...existing, retryAfter: seconds };\n }\n }\n }\n message = message || `${operationName || 'Request'} failed with status ${response.status}`;\n const type = bodyType ??\n (response.status === 401\n ? ErrorType.Authentication\n : response.status === 403\n ? ErrorType.Forbidden\n : response.status === 429\n ? ErrorType.RateLimit\n : ErrorType.Api);\n return new ShipError(type, message, response.status, details);\n }\n /**\n * Construct a `ShipError` from an error caught around a `fetch()` call.\n *\n * The mirror of `fromHttpResponse` for the *other* side of the HTTP error\n * story — the network layer failing (offline, CORS, abort) rather than the\n * server returning a non-OK response.\n *\n * Routing:\n * - Already a `ShipError` → returned as-is (caller's intent preserved)\n * - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose\n * - `TimeoutError` → `ShipError.timeout(...)` — a deadline expired; the\n * message names the timeout, and the type is in the network CATEGORY\n * because nothing was exchanged\n * - A transport failure → `ShipError.network(...)` — see `isTransportFailure`\n * for what each runtime offers as evidence\n * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)\n * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`\n *\n * **Abort and timeout are read from `name` BEFORE any `instanceof Error`\n * gate.** A `DOMException` satisfies that gate in every runtime measured\n * (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the\n * inheritance is a comparatively recent spec change and this classification\n * has no reason to depend on it: `name` is where the meaning lives, and\n * reading it first costs nothing. The suite plants a non-`Error`\n * `DOMException` shape to hold the arm, since no runtime on the table\n * produces one.\n *\n * A caller's own `AbortSignal.timeout()` is the reachable source of\n * `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit\n * reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a\n * deadline is indistinguishable from a cancellation and lands on\n * `Cancelled`. Recorded rather than worked around; `Cancelled` is honest\n * there, since the caller's signal is what stopped it.\n *\n * The optional `operationName` is composed into the message for context:\n * `\"Get account was cancelled\"`, `\"Get account failed: ...\"`. Defaults to\n * `\"Request\"` when omitted.\n */\n static fromFetchError(cause, operationName) {\n if (isShipError(cause))\n return cause;\n const op = operationName || 'Request';\n // Read by NAME, ahead of the Error gate — see the note above.\n const name = cause?.name;\n if (name === 'AbortError') {\n return ShipError.cancelled(`${op} was cancelled`);\n }\n if (name === 'TimeoutError') {\n // A deadline: not a fault, not a cancellation, and — since it has its\n // own type — no longer merely \"network\". Nothing was exchanged, which\n // is what keeps it in the network CATEGORY and therefore retryable; the\n // type is what lets a surface say \"timed out\" instead of sending\n // someone to check their Wi-Fi. The runtime's own sentence is dropped\n // rather than relayed: \"The operation was aborted due to timeout\" is\n // the mechanism, not the news.\n return ShipError.timeout(`${op} timed out`, { cause });\n }\n if (cause instanceof Error) {\n if (isTransportFailure(cause)) {\n return ShipError.network(`${op} failed: ${cause.message}`, { cause });\n }\n return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);\n }\n return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);\n }\n // Factory methods. Uniform shape `(message, details?)` with two principled\n // exceptions: `notFound` composes its message from (resource, id?), and\n // `business` / `api` accept an optional status because they're the\n // multi-status fallbacks.\n static validation(message, details) {\n return new ShipError(ErrorType.Validation, message, 400, details);\n }\n static notFound(resource, id) {\n const message = id ? `${resource} ${id} not found` : `${resource} not found`;\n return new ShipError(ErrorType.NotFound, message, 404);\n }\n static forbidden(message, details) {\n return new ShipError(ErrorType.Forbidden, message, 403, details);\n }\n static rateLimit(message = 'Too many requests', details) {\n return new ShipError(ErrorType.RateLimit, message, 429, details);\n }\n /**\n * Construct an Authentication (401) error.\n *\n * **Telemetry pattern — `details: { internal: '<tag>' }`.** When the\n * server creates an auth error with an `internal` key in `details`\n * (e.g. `{ internal: 'session_invalid' }`), `toResponse()` strips the\n * entire `details` object before serialization. This keeps the wire\n * response a clean \"Authentication failed\" while preserving granular\n * server-side telemetry (which strategy/check failed) for logs and tests.\n *\n * Use this pattern in API auth code; do not put client-visible info under\n * `internal`. Other `details` keys round-trip normally.\n */\n static authentication(message = 'Authentication required', details) {\n return new ShipError(ErrorType.Authentication, message, 401, details);\n }\n static business(message, status = 400, details) {\n return new ShipError(ErrorType.Business, message, status, details);\n }\n static network(message, details) {\n return new ShipError(ErrorType.Network, message, undefined, details);\n }\n /**\n * A deadline expired before the exchange completed.\n *\n * Statusless like its four client-only siblings: no exchange completed, so\n * there is no HTTP status to report. `isNetworkError()` is true — see\n * `ErrorType.Timeout` for why the category is shared and the type is not.\n */\n static timeout(message, details) {\n return new ShipError(ErrorType.Timeout, message, undefined, details);\n }\n static cancelled(message, details) {\n return new ShipError(ErrorType.Cancelled, message, undefined, details);\n }\n static file(message, details) {\n return new ShipError(ErrorType.File, message, undefined, details);\n }\n static config(message, details) {\n return new ShipError(ErrorType.Config, message, undefined, details);\n }\n static api(message, status = 500, details) {\n return new ShipError(ErrorType.Api, message, status, details);\n }\n /**\n * The platform is closed for maintenance (503).\n *\n * `message` is REQUIRED and has no default here. The API is the only\n * producer of that sentence, and a default in this file would be a second\n * owner of one fact — see CLAUDE.md, \"The Constellation Law\" (stopping\n * rule). It is also the one factory whose status is fixed rather than\n * defaulted: a maintenance refusal is 503 or it is not this error.\n */\n static maintenance(message, details) {\n return new ShipError(ErrorType.Maintenance, message, 503, details);\n }\n // Semantic-category guards. For specific-type checks, use\n // `error.type === ErrorType.X` directly or the generic `isType(t)`.\n /**\n * The caller is at fault — by HTTP's own definition of a 4xx, or by a type\n * that is client-attributable without ever having a status (`Config`,\n * `File`, raised locally by the SDK).\n *\n * Both arms are load-bearing, because type and status are independent\n * axes. `fromHttpResponse` trusts `body.error` only when it names a\n * server-producible type; a non-OK response without one is status-derived,\n * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault\n * *type* carrying a client *status*. Judging by type alone would report it\n * as a platform failure and bury the server's own message.\n */\n isClientError() {\n if (ERROR_CATEGORIES.client.has(this.type))\n return true;\n return this.status !== undefined && this.status >= 400 && this.status < 500;\n }\n isNetworkError() {\n return ERROR_CATEGORIES.network.has(this.type);\n }\n isAuthError() {\n return ERROR_CATEGORIES.auth.has(this.type);\n }\n isType(errorType) {\n return this.type === errorType;\n }\n}\n/**\n * Type guard to check if an unknown value is a ShipError.\n *\n * Uses structural checking instead of instanceof to handle module duplication\n * in bundled applications where multiple copies of the ShipError class may exist.\n *\n * @example\n * if (isShipError(error)) {\n * console.log(error.status, error.message);\n * }\n */\nexport function isShipError(error) {\n return (error !== null &&\n typeof error === 'object' &&\n 'name' in error &&\n error.name === 'ShipError' &&\n 'status' in error);\n}\n// =============================================================================\n// EXTENSION MATCHING\n// =============================================================================\n/**\n * The rule for reading a file's extension: lowercase, after the last dot of\n * the last path segment. `null` when there is no extension to read.\n *\n * A leading dot names the file rather than its type, so `.gitignore` and\n * `.htaccess` have no extension — but `.env.exe` has `exe`.\n *\n * Segment-aware on purpose: the callers pass deploy PATHS, not basenames, and\n * a naive `lastIndexOf('.')` over `dir.v1/README` reads the extension\n * `v1/README` — safe only by accident, since no entry in a real blocklist\n * contains a slash, which is the kind of correctness nobody should have to\n * re-derive.\n *\n * **Private, and the reason is the asymmetry rather than any hazard.** Nothing\n * outside this file reads it: `isBlockedExtension` is the only question anyone\n * asks, and the API's refusal names the FILE, not its extension. Exporting a\n * pure function is harmless, which is exactly the argument that talks a\n * published package into surface it has not earned — and the costs do not\n * match, since adding an export later is free under the additive law while\n * removing one is a major. So it stays private until a caller exists. Its\n * behaviour is fenced through `isBlockedExtension`, which is where it is\n * observable.\n *\n * (`WEB_FILE_EXTENSIONS` above is private for a different reason — publishing\n * it would invite a wrong question. Both are private; only one is a hazard.)\n *\n * @example\n * fileExtension('virus.exe') // 'exe'\n * fileExtension('assets/style.CSS') // 'css'\n * fileExtension('dir.v1/README') // null\n * fileExtension('.gitignore') // null\n * fileExtension('file.') // null\n */\nfunction fileExtension(filename) {\n const basename = filename.replace(/\\\\/g, '/').split('/').pop() ?? '';\n const dotIndex = basename.lastIndexOf('.');\n if (dotIndex <= 0 || dotIndex === basename.length - 1)\n return null;\n return basename.slice(dotIndex + 1).toLowerCase();\n}\n/**\n * Whether a file is one the platform refuses to host.\n *\n * **The list is not this package's, and that separation is the point.** What\n * counts as a blocked extension is hosting POLICY — it evolves, it is enforced\n * at one security boundary, and `virus.exe` is a perfectly well-formed\n * filename that breaks nothing about the upload→serve round-trip. So the API\n * owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as\n * `PlatformLimits.blockedExtensions`; a client passes what it was given.\n *\n * What lives here is the MATCHING RULE, and it earns its place by the\n * constellation law's own test. The list's drift is loud in both directions —\n * a stale client uploads a file the API refuses by name, on the first try.\n * A second *matcher* drifts SILENTLY in the one direction that matters: a\n * client stricter than the server refuses a legal file without the server ever\n * being asked, and no error names it. Two holders, silent drift, one owner.\n *\n * The `blocked` collection is required rather than defaulted: this predicate\n * guards a security boundary in the API, and a defaulted-empty argument there\n * would block nothing while reading as though it did. Callers holding a\n * possibly-absent wire field spell the fail-open themselves.\n *\n * @example\n * isBlockedExtension('virus.exe', ['exe']) // true\n * isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive\n * isBlockedExtension('style.css', ['exe']) // false\n * isBlockedExtension('README', ['exe']) // false — no extension\n */\nexport function isBlockedExtension(filename, blocked) {\n const ext = fileExtension(filename);\n if (ext === null)\n return false;\n return Array.isArray(blocked) ? blocked.includes(ext) : blocked.has(ext);\n}\n// =============================================================================\n// PICKER ACCEPT HINT\n// =============================================================================\n/**\n * The extensions a browser file picker offers by default, grouped by role.\n *\n * Private on purpose: the only published form is `WEB_FILE_ACCEPT`, the\n * attribute value itself. A published set would invite a call site to ask it\n * whether a file is allowed — which is the one thing this list must never\n * answer. See `WEB_FILE_ACCEPT`.\n *\n * Extensionless files (`LICENSE`, most `.well-known` entries) are inexpressible\n * in `accept`, and reach a deployment by folder pick, ZIP, or drag-and-drop.\n */\nconst WEB_FILE_EXTENSIONS = [\n // Markup & documents\n 'html',\n 'htm',\n 'xhtml',\n 'xml',\n 'txt',\n 'md',\n 'markdown',\n 'pdf',\n 'csv',\n // Data & config\n 'json',\n 'jsonc',\n 'webmanifest',\n 'map',\n 'toml',\n 'yaml',\n 'yml',\n 'rss',\n 'atom',\n // Styles\n 'css',\n 'scss',\n 'sass',\n 'less',\n // Scripts & modules\n 'js',\n 'mjs',\n 'cjs',\n 'jsx',\n 'ts',\n 'tsx',\n 'wasm',\n 'vue',\n 'svelte',\n // Images\n 'png',\n 'jpg',\n 'jpeg',\n 'gif',\n 'webp',\n 'avif',\n 'svg',\n 'ico',\n 'bmp',\n 'tif',\n 'tiff',\n 'heic',\n 'heif',\n // Fonts\n 'woff',\n 'woff2',\n 'ttf',\n 'otf',\n 'eot',\n // Audio\n 'mp3',\n 'wav',\n 'ogg',\n 'oga',\n 'opus',\n 'm4a',\n 'aac',\n 'flac',\n 'weba',\n // Video\n 'mp4',\n 'webm',\n 'ogv',\n 'mov',\n 'm4v',\n 'avi',\n // 3D models\n 'glb',\n 'gltf',\n 'usdz',\n // Text tracks\n 'vtt',\n 'srt',\n // Archive — a whole site in one file\n 'zip',\n];\n/**\n * The `accept` attribute value for a browser file picker offering web files.\n *\n * **This is a hint, never a rule.** The API's blocklist is the platform's gate\n * and the only thing that decides what may be hosted; this constant decides\n * what a *file dialog* shows first. The two are not two halves of one policy,\n * and this one must never be consulted to accept or reject a file.\n *\n * The distinction is structural, not stylistic. `accept` can express only an\n * allowlist, while the platform's rule is a blocklist — so this list is\n * necessarily *narrower* than what the platform hosts, and reading it as\n * authority would reject files the platform serves happily. It is also not\n * enforcement in the browser's own terms: every file dialog offers an\n * all-files escape, and **drag-and-drop ignores `accept` entirely**. The\n * dropzone and the picker must reach the same verdict on the same files, and\n * they do — because the verdict is `validateFiles`, downstream of both.\n *\n * The invariant that matters — the picker must never offer a file the platform\n * will refuse — is fenced where the authority lives, in the API's own suite\n * (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published\n * string and holds it against the list it owns. It sat here until the\n * blocklist became the API's, and moving it was the price of that: a fence\n * belongs with whichever side can change and break it.\n */\nexport const WEB_FILE_ACCEPT = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');\n// =============================================================================\n// FILENAME CHARACTER VALIDATION\n// =============================================================================\n/**\n * Characters that are unsafe in filenames for static hosting.\n *\n * Blocks only characters that genuinely break the upload→serve round-trip:\n * - # ? % URL round-trip breakers (fragment, query, encoding ambiguity)\n * - \\ Path separator confusion (upload splits on backslash)\n * - < > \" XSS vectors with zero legitimate use in filenames\n * - \\x00-\\x1f \\x7f Control characters (header injection, display corruption)\n *\n * Everything else is allowed — browser percent-encodes, Worker decodes, R2 matches.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: blocking control characters is this regex's purpose\nexport const UNSAFE_FILENAME_CHARS = /[\\x00-\\x1f\\x7f#?%\\\\<>\"]/;\n/**\n * Check if a filename contains unsafe characters.\n *\n * @example\n * hasUnsafeChars('saved_resource(1).html') // false — parentheses are safe\n * hasUnsafeChars('page[slug].js') // false — brackets are safe\n * hasUnsafeChars('file#anchor.html') // true — # breaks URL resolution\n * hasUnsafeChars('file<tag>.html') // true — < is an XSS vector\n */\nexport function hasUnsafeChars(filename) {\n return UNSAFE_FILENAME_CHARS.test(filename);\n}\n// =============================================================================\n// UNBUILT PROJECT MARKERS\n// =============================================================================\n/**\n * Path segment names that indicate an unbuilt project was uploaded instead of build output.\n * Used for early detection in CLI, browser, and server validation.\n */\nexport const UNBUILT_PROJECT_MARKERS = new Set([\n 'node_modules',\n 'package.json',\n]);\n/**\n * Check if a file path contains an unbuilt project marker.\n *\n * @example\n * hasUnbuiltMarker('node_modules/react/index.js') // true\n * hasUnbuiltMarker('package.json') // true\n * hasUnbuiltMarker('dist/index.html') // false\n */\nexport function hasUnbuiltMarker(filePath) {\n const segments = filePath.replace(/\\\\/g, '/').split('/').filter(Boolean);\n return segments.some((s) => UNBUILT_PROJECT_MARKERS.has(s));\n}\n// =============================================================================\n// CREDENTIAL SHAPES\n// =============================================================================\n// The one address for credential vocabulary: where human identity lives\n// (AUTH_BASE_PATH), how a request is authorized (AuthMethod), the shapes\n// that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN,\n// OAUTH_TOKEN, CALLER), the two halves of the one Bearer slot\n// (readBearerValue reads it, classifyToken/TokenKind dispatch on what came\n// out), and the delegated-access scopes (OAuthScope).\n//\n// THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's\n// three populations below. The deployment claim code is the API's own\n// (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so\n// it has one holder and stays there) and shares only clause 1 — it is the\n// platform's one deliberately BARE secret, because it never enters the\n// Bearer slot: minted into one URL, consumed by one endpoint's one field,\n// its context names it and a prefix would restate its route.\n// `tests/validation-constants.test.ts` holds the clauses over this file's\n// populations; the API's suite holds its own.\n//\n// 1. ONE ENTROPY STANDARD. Every minted secret is `HEX_LENGTH` hex characters\n// — one width for the whole platform, so \"how long is a credential\" has a\n// single answer rather than one per population. Generators read the width\n// from the population's own constant, so a minted value and an accepted\n// value cannot differ.\n//\n// 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what\n// it is before anything parses it — which is what lets `classifyToken`\n// below dispatch three populations sharing one `Authorization: Bearer`\n// slot, and what lets a value found in a log, a support ticket or a\n// pasted URL be recognised and revoked on sight. The OAuth access token\n// was this clause's one standing exception until 2026-08-14 — the\n// authorization server it was born on had no mint hook to give it a\n// prefix, and its successor does.\n//\n// 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch\n// order-independent, and it is the reason the populations are named on\n// different axes (`ship-` for the product, `deploy-` for the capability)\n// rather than sharing a stem. A `ship-` / `ship-deploy-` pair\n// reads tidier and is a trap: every deploy token would also match the\n// API-key branch, leaving correctness resting on the order of two `if`s.\n/**\n * Where human identity is mounted on the API host. The API mounts Better\n * Auth at this path (sign-in, sign-out, session reads, admin impersonation)\n * and the web console's auth client posts to it — shared here so the two\n * halves of the auth pair agree by construction, the same way both sides\n * already share the credential prefixes below.\n */\nexport const AUTH_BASE_PATH = '/auth';\n/**\n * How a request (or recorded activity) was authorized.\n *\n * Client populations: `SESSION` (first-party cookie), `API_KEY` (`ship-`\n * key), `TOKEN` (`deploy-` deploy token), `AGENT` (anonymous public deploy —\n * no credential; the platform grants the public-account identity per\n * request), `OAUTH` (delegated access token). Server populations: `WEBHOOK`\n * (signed webhook processing), `SYSTEM` (scheduled/background jobs).\n */\nexport const AuthMethod = {\n SESSION: 'session',\n API_KEY: 'apiKey',\n TOKEN: 'token',\n AGENT: 'agent',\n OAUTH: 'oauth',\n WEBHOOK: 'webhook',\n SYSTEM: 'system',\n};\n/**\n * Shape constants for API keys (`ship-{32 hex chars}`).\n * Single source of truth used by validation utilities and auth middleware.\n */\nexport const API_KEY = {\n /** Prefix that identifies an API key. */\n PREFIX: 'ship-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 32,\n /** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 37`). */\n TOTAL_LENGTH: 37,\n /** Number of trailing characters used to display a redacted hint (e.g. last 4). */\n HINT_LENGTH: 4,\n};\n/**\n * Shape constants for deploy tokens (`deploy-{32 hex chars}`).\n * Single source of truth used by validation utilities and auth middleware.\n *\n * Deliberately the same width as `API_KEY`: both are minted by one generator\n * and classified by prefix alone, so a length that differed between them\n * would be a second thing to know about a credential whose prefix already\n * says what it is.\n */\nexport const DEPLOY_TOKEN = {\n /** Prefix that identifies a deploy token. */\n PREFIX: 'deploy-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 32,\n /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */\n TOTAL_LENGTH: 39,\n};\n/**\n * Shape constants for OAuth access tokens (`oauth-{32 hex chars}`) — the\n * delegated population, minted by the platform's own authorization server\n * for a connected app acting on a user's behalf.\n *\n * Same width as the other two, and for the same reason: one entropy standard\n * across the platform, so \"how long is a credential\" has one answer.\n *\n * **This population is the access token alone.** Refresh tokens, authorization\n * codes and client secrets are deliberately NOT here and are deliberately not\n * prefixed by this constant: none of them ever enters the `Authorization:\n * Bearer` slot — a refresh token is posted as a form field to the token\n * endpoint, which knows what it is receiving — so `classifyToken` never sees\n * one and a prefix would name a population no dispatcher dispatches. The same\n * reasoning that keeps the deployment claim code bare.\n *\n * **The prefix must be applied at the MINT, never as a display wrapper.** The\n * authorization server hashes what it stores and the API hashes what it is\n * presented, so the prefix has to be inside the hashed string on both sides.\n * `@better-auth/oauth-provider` offers a `prefix.opaqueAccessToken` option\n * that prepends AFTER hashing and strips on its own read paths; using it would\n * store a hash of the UNPREFIXED token and silently break the platform's read\n * arm. The API therefore mints through `generateOpaqueAccessToken` — recorded\n * beside the config in `cloudflare/api/src/lib/auth/instance.ts`.\n */\nexport const OAUTH_TOKEN = {\n /** Prefix that identifies an OAuth access token. */\n PREFIX: 'oauth-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 32,\n /** Total length including prefix (`PREFIX.length + HEX_LENGTH = 38`). */\n TOTAL_LENGTH: 38,\n};\n/**\n * Shape constants for caller identifiers (the `X-Caller` instance-identity\n * header — rate-limit bucketing for multi-tenant orchestrators). The API\n * normalizes case and silently ignores malformed values (the header is\n * unauthenticated); clients validate at the boundary via `validateCaller`,\n * so a value the server would drop fails fast instead.\n */\nexport const CALLER = {\n /** HTTP header name. */\n HEADER: 'X-Caller',\n /** Maximum identifier length. */\n MAX_LENGTH: 128,\n /** Allowed characters: alphanumeric, dot, underscore, hyphen. */\n PATTERN: /^[a-zA-Z0-9._-]+$/,\n};\n/**\n * Token populations distinguishable by shape. The platform carries every\n * client token in one wire slot (`Authorization: Bearer <value>`) and\n * classifies by value, never by a side channel — this is the classifier.\n *\n * `API_KEY`, `DEPLOY_TOKEN` and `OAUTH` *are* `AuthMethod.API_KEY`,\n * `AuthMethod.TOKEN` and `AuthMethod.OAUTH` — the equality is structural, so\n * a classification flows straight into an auth method and the trio can never\n * drift.\n *\n * `OPAQUE` is any other value, and since 2026-08-14 it names NO population:\n * every credential this platform mints for the Bearer slot carries a prefix,\n * so an opaque bearer is a bearer we did not mint. It stays a member rather\n * than becoming a `null` return because a dispatcher with a total codomain\n * reads better than one with an absence in it — and because it is where a\n * future population would land before anyone gave it a shape, which is\n * exactly what the OAuth token itself did until its prefix existed.\n */\nexport const TokenKind = {\n API_KEY: AuthMethod.API_KEY,\n DEPLOY_TOKEN: AuthMethod.TOKEN,\n OAUTH: AuthMethod.OAUTH,\n OPAQUE: 'opaque',\n};\n/**\n * Classify a client token by shape. The single dispatch used by both sides\n * of the wire: API auth middleware (which population is this credential?)\n * and SDK validation (which format rules apply before sending?). Sharing it\n * is what guarantees client and server can never disagree on dispatch.\n */\nexport function classifyToken(token) {\n if (token.startsWith(API_KEY.PREFIX))\n return TokenKind.API_KEY;\n if (token.startsWith(DEPLOY_TOKEN.PREFIX))\n return TokenKind.DEPLOY_TOKEN;\n if (token.startsWith(OAUTH_TOKEN.PREFIX))\n return TokenKind.OAUTH;\n return TokenKind.OPAQUE;\n}\n/** The auth-scheme, lowercased — the form the comparison is made in. */\nconst BEARER_SCHEME = 'bearer ';\n/**\n * Read the credential out of an `Authorization` header value — the step\n * BEFORE `classifyToken`, and the other half of the one wire slot this\n * section owns.\n *\n * Returns the credential's own bytes, or `null` when the header carries a\n * foreign scheme or nothing after the scheme.\n *\n * **The scheme is folded; the credential is not.** RFC 7235 §2.1 makes the\n * auth-scheme case-insensitive, so `bearer`, `Bearer` and `BEARER` are the\n * same header. The value after it is opaque and is compared literally\n * everywhere it is used — `ship-`/`deploy-`/`oauth-` are lowercase hex, and\n * folding them would make a credential match values it is not.\n *\n * **This platform has paid for the rule twice, which is why it has an owner\n * rather than a convention.** A spec-conformant `bearer ship-…` client was\n * refused for as long as the API's scheme test was spelled case-sensitively;\n * and `@better-auth/oauth-provider` carries the same defect in four places\n * today (`startsWith(\"Bearer \")`), which is precisely why the platform folds\n * the scheme itself and hands the provider a bare token.\n *\n * **ABSENCE is deliberately not this function's business.** A missing header\n * and an unreadable one are different facts, and the callers that care split\n * on them: the API worker's middleware distinguishes `absent` (the only\n * anonymous path) from `unreadable` (a presented credential that is refused),\n * and collapsing the two here would take that distinction away from the layer\n * that needs it. Callers check for the header themselves and pass its value.\n *\n * **Why this lives in the constitution rather than in a worker's `shared/`.**\n * It is the same wire boundary `classifyToken` already owns — one reads the\n * slot, the other dispatches on what came out — and a rule with two holders\n * whose drift is silent earns exactly one owner regardless of what the\n * convoy costs. The estate's recorded refusal to own a `Bearer` CONSTANT\n * stands and is a different thing: that is RFC vocabulary, the same reason\n * this package owns no `\"POST\"`. A parser is not a spelling.\n */\nexport function readBearerValue(header) {\n if (header.slice(0, BEARER_SCHEME.length).toLowerCase() !== BEARER_SCHEME)\n return null;\n return header.slice(BEARER_SCHEME.length) || null;\n}\n/**\n * OAuth scope vocabulary for delegated third-party access tokens.\n * Single source of truth used by the authorization server (advertised in\n * `scopes_supported`), the API's scope-enforcement middleware, and consent UI\n * copy. The standard `offline_access` scope (refresh tokens) is not platform\n * vocabulary and is deliberately absent — the middleware never checks it.\n *\n * Deliberately absent by design: any `tokens:*` scope, `account:write`, or\n * admin scope — a delegated app must never mint credentials, delete the\n * account, or act as admin.\n */\nexport const OAuthScope = {\n ACCOUNT_READ: 'account:read',\n DEPLOYMENTS_READ: 'deployments:read',\n DEPLOYMENTS_WRITE: 'deployments:write',\n DOMAINS_READ: 'domains:read',\n DOMAINS_WRITE: 'domains:write',\n};\n// =============================================================================\n// DEPLOYMENT CONFIGURATION CONSTANTS\n// =============================================================================\nexport const DEPLOYMENT_CONFIG_FILENAME = 'ship.json';\n/** Default ship.json config for SPA routing. Single source of truth — used by both API and SDK. */\nexport const SPA_DEFAULT_CONFIG = {\n rewrites: [{ source: '/(.*)', destination: '/index.html' }],\n};\n/**\n * The `/spa-check` pre-flight's client-side envelope: which file is the\n * check's subject, and how large it may be before a client skips the call.\n *\n * One fact with three holders until this export — the API's config declared\n * the cap, the SDK's `checkSPA` hardcoded `100 * 1024`, and prose restated\n * \"100KB\". `INDEX_FILE` is the selection rule (the file whose content rides\n * `SPACheckRequest.index`), restated by every client that builds the request.\n *\n * Neither member is a validation boundary: a client over the cap simply\n * skips the pre-flight, because the server answers an oversized index\n * `isSPA: false` anyway. A consumer that cannot import this (n8n) needs no\n * size copy at all — outcome parity is the server's, not the client's.\n */\nexport const SPA_CHECK_CONSTRAINTS = {\n /** The file whose content is the check's subject. */\n INDEX_FILE: 'index.html',\n /** Skip the pre-flight above this size — the server would answer false. */\n MAX_INDEX_BYTES: 100 * 1024,\n};\n/**\n * Assert that a ship.json file is *syntactically* loadable. Syntax only —\n * never schema.\n *\n * ship.json is validated and compiled on the server, deliberately: the schema\n * and the compiler evolve, and a client that judged them would reject configs\n * a newer platform accepts. That reasoning bounds what a client may check to\n * the properties which are true of *every* past and future schema:\n *\n * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that\n * does not parse can never be a valid config;\n * 2. its top level is an object — ship.json is `{ ... }` in every version.\n *\n * Both are monotonic: neither can ever reject something the server would\n * accept. Everything beyond them (field names, types, rule semantics, which\n * keys are permitted) stays server-side, where it can change.\n *\n * The payoff is the common case. Hand-edited JSON fails on a trailing comma,\n * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from\n * documentation — mistakes that otherwise cost a full upload round-trip to\n * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped\n * before parsing rather than rejected, because the server accepts it too;\n * diverging there would reintroduce exactly the false rejection this\n * function exists to avoid.\n *\n * @throws {ShipError} `ErrorType.Config` — the same type the server's own\n * config rejection carries, so the error contract is identical wherever the\n * failure is detected.\n */\nexport function assertShipJsonSyntax(text) {\n const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n let parsed;\n try {\n parsed = JSON.parse(withoutBom);\n }\n catch (error) {\n throw ShipError.config(`invalid JSON format in config: ${error.message}`, {\n filePath: DEPLOYMENT_CONFIG_FILENAME,\n });\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {\n filePath: DEPLOYMENT_CONFIG_FILENAME,\n });\n }\n}\n// =============================================================================\n// VALIDATION UTILITIES\n// =============================================================================\n/**\n * Shared rule for prefixed credentials: `{PREFIX}{HEX_LENGTH hex chars}`.\n * The regex derives from the shape constants, so the validators can never\n * drift from the shapes `classifyToken` dispatches on.\n */\nfunction validatePrefixedCredential(value, shape, label) {\n if (!value.startsWith(shape.PREFIX)) {\n throw ShipError.validation(`${label} must start with \"${shape.PREFIX}\"`);\n }\n if (value.length !== shape.TOTAL_LENGTH) {\n throw ShipError.validation(`${label} must be ${shape.TOTAL_LENGTH} characters total (${shape.PREFIX} + ${shape.HEX_LENGTH} hex chars)`);\n }\n const hexPart = value.slice(shape.PREFIX.length);\n if (!new RegExp(`^[a-f0-9]{${shape.HEX_LENGTH}}$`, 'i').test(hexPart)) {\n throw ShipError.validation(`${label} must contain ${shape.HEX_LENGTH} hexadecimal characters after \"${shape.PREFIX}\" prefix`);\n }\n}\n/**\n * Validate API key format\n */\nexport function validateApiKey(apiKey) {\n validatePrefixedCredential(apiKey, API_KEY, 'API key');\n}\n/**\n * Validate deploy token format\n */\nexport function validateDeployToken(deployToken) {\n validatePrefixedCredential(deployToken, DEPLOY_TOKEN, 'Deploy token');\n}\n/**\n * Validate OAuth access token format\n */\nexport function validateOAuthToken(oauthToken) {\n validatePrefixedCredential(oauthToken, OAUTH_TOKEN, 'OAuth access token');\n}\n/**\n * Validate a client token of any population. Classifies by shape and applies\n * the matching format rules: all three prefixed populations are validated\n * strictly; an OPAQUE token only needs to be non-empty.\n *\n * **The OPAQUE arm stays permissive on purpose**, even though the platform no\n * longer mints an unprefixed credential. It is the fallback for a population\n * that does not exist yet, and a client refusing a shape the server would\n * accept is the one failure mode this boundary must never have — the server\n * decides, and it refuses an unrecognised bearer anyway. Unprefixed OAuth\n * tokens from before 2026-08-14 land here and are refused server-side, which\n * is correct: they were revoked by the change, not grandfathered.\n */\nexport function validateToken(token) {\n switch (classifyToken(token)) {\n case TokenKind.API_KEY:\n validateApiKey(token);\n return;\n case TokenKind.DEPLOY_TOKEN:\n validateDeployToken(token);\n return;\n case TokenKind.OAUTH:\n validateOAuthToken(token);\n return;\n case TokenKind.OPAQUE:\n if (!token)\n throw ShipError.validation('Token must be a non-empty string');\n }\n}\n/**\n * Validate a caller identifier against the `CALLER` shape. The server\n * silently ignores malformed values (the header is unauthenticated); clients\n * call this at configuration time so the drop never silently happens.\n */\nexport function validateCaller(caller) {\n if (!caller || caller.length > CALLER.MAX_LENGTH || !CALLER.PATTERN.test(caller)) {\n throw ShipError.validation(`Caller must be 1-${CALLER.MAX_LENGTH} characters: letters, digits, dots, underscores, or hyphens`);\n }\n}\n/**\n * Validate API URL format\n */\nexport function validateApiUrl(apiUrl) {\n try {\n const url = new URL(apiUrl);\n if (!['http:', 'https:'].includes(url.protocol)) {\n throw ShipError.validation('API URL must use http:// or https:// protocol');\n }\n if (url.pathname !== '/' && url.pathname !== '') {\n throw ShipError.validation('API URL must not contain a path');\n }\n if (url.search || url.hash) {\n throw ShipError.validation('API URL must not contain query parameters or fragments');\n }\n }\n catch (error) {\n if (isShipError(error)) {\n throw error;\n }\n throw ShipError.validation('API URL must be a valid URL');\n }\n}\n/**\n * Check if a string matches the deployment identifier pattern (word-word-alphanumeric7).\n * Example: \"happy-cat-abc1234.shipstatic.com\"\n */\nexport function isDeployment(input) {\n return /^[a-z]+-[a-z]+-[a-z0-9]{7}(\\.[a-z0-9.-]+)?$/i.test(input);\n}\n/**\n * The envelope a requested lifetime must fit — one word, one grammar, wherever\n * the platform lets a caller choose how long something lives.\n *\n * Two resources wear it: `TokenCreateOptions.ttl` and\n * `DeploymentUploadOptions.ttl`. It lives here rather than on the server by\n * the format-vs-policy rule — a client can decide offline whether a duration\n * is well-formed, and the API rejects the same value the same way. What is\n * NOT here is any per-plan ceiling: no such policy exists, and one delivered\n * speculatively through `/limits` would be an owner for a decision nobody has\n * made.\n */\nexport const TTL_CONSTRAINTS = {\n /**\n * Shortest requestable lifetime, in seconds. One rather than zero: a\n * deployment that expires the instant it is created is not a shorter lease,\n * it is a deploy that was never live, and `0` is how an unset variable\n * arrives.\n */\n MIN_SECONDS: 1,\n /** Longest requestable lifetime, in seconds — one year. */\n MAX_SECONDS: 365 * 24 * 60 * 60,\n};\n/**\n * Validate a requested lifetime in SECONDS and return it, or `undefined` when\n * none was asked for.\n *\n * **A duration, never an instant.** The caller says how long; the server owns\n * what time it is and stamps the expiry — so a client's clock, however wrong,\n * cannot shorten or extend a lease. That is the tokens precedent, and it is\n * why this rule measures a count of seconds rather than checking a timestamp\n * against `now`.\n *\n * Fractions are refused rather than rounded: a caller who wrote `1.5` meant\n * something the wire cannot carry, and silently choosing `1` or `2` for them\n * is a decision the platform has no standing to make.\n *\n * Single source of truth shared by the API (the tokens route and the deploy\n * schema), the SDK's request boundary, and the CLI's parser.\n */\nexport function validateTtl(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw ShipError.validation('TTL must be a number of seconds');\n }\n if (!Number.isInteger(value)) {\n throw ShipError.validation('TTL must be a whole number of seconds');\n }\n if (value < TTL_CONSTRAINTS.MIN_SECONDS || value > TTL_CONSTRAINTS.MAX_SECONDS) {\n throw ShipError.validation(`TTL must be between ${TTL_CONSTRAINTS.MIN_SECONDS} and ${TTL_CONSTRAINTS.MAX_SECONDS} seconds`);\n }\n return value;\n}\n// =============================================================================\n// PLATFORM CONSTANTS\n// =============================================================================\n/** Default API URL if not otherwise configured. */\nexport const DEFAULT_API = 'https://api.shipstatic.com';\n/**\n * The Node SDK's ambient configuration pair — the ONLY environment variables\n * the SDK reads, and therefore the COMPLETE list an embedding host must\n * scrub (per `npm/ship`'s strict-isolation contract, scrubbing is the host's\n * job, not the SDK's). A host that derives its scrub from this object's\n * values — as the VS Code extension's child-process env block does — picks\n * up a grown contract at the next pin bump instead of by remembered prose.\n *\n * Browser builds read no environment at all, and the CLI-only variables\n * (`SHIP_PASSWORD`, `SHIP_VIA`) are deliberately NOT here: they are the\n * CLI's operational levers, not the SDK's ambient contract — see\n * `npm/ship/CLAUDE.md`, \"CLI-only env vars\".\n */\nexport const SHIP_ENV = {\n /** The one credential slot — any platform token. */\n TOKEN: 'SHIP_TOKEN',\n /** The API endpoint override. */\n API_URL: 'SHIP_API_URL',\n};\n/**\n * Where a human creates an API key — the console deep link quoted by every\n * surface that teaches authentication (the CLI's config wizard, the VS Code\n * and n8n listings, the n8n rate-limit hint and credential copy). Written\n * out in five files across three repos until this export.\n *\n * Production-branded by design: published artifacts name the product, never\n * an environment (root `CLAUDE.md`, \"Environment-Aware URLs\").\n */\nexport const MY_API_KEY_URL = 'https://my.shipstatic.com/api-key';\n/**\n * How long an anonymous deployment lives before it expires.\n *\n * The lifetime of the public tier, and one fact with several readers. The API\n * stamps a deployment's `expires` from it and gives a claim code exactly the\n * same window — a live site with a dead claim link is a coherence bug, so the\n * two are one constant rather than two that agree. Both MCP transports quote\n * the duration in prose an agent reads, and derive it from here rather than\n * writing it out, which they did in eight places until this export existed.\n *\n * Seconds, spelled in the name: this platform has both second- and\n * millisecond-valued durations, and the pair is only safe when each says which\n * it is.\n */\nexport const PUBLIC_DEPLOYMENT_TTL_SECONDS = 3 * 24 * 60 * 60;\n// =============================================================================\n// FILE UPLOAD TYPES\n// =============================================================================\n/**\n * File status constants for validation state tracking\n */\nexport const FileValidationStatus = {\n /** File is pending validation */\n PENDING: 'pending',\n /** File failed during processing (before validation) */\n PROCESSING_ERROR: 'processing_error',\n /** File was excluded by validation warning (not an error) */\n EXCLUDED: 'excluded',\n /** File failed validation (blocks deployment) */\n VALIDATION_FAILED: 'validation_failed',\n /** File passed validation and is ready for deployment */\n READY: 'ready',\n};\n// =============================================================================\n// DOMAIN UTILITIES\n// =============================================================================\n/**\n * Check if a domain is a platform domain (subdomain of our platform).\n * Platform domains are free and don't require DNS verification.\n *\n * @example isPlatformDomain(\"www.shipstatic.com\", \"shipstatic.com\") → true\n * @example isPlatformDomain(\"example.com\", \"shipstatic.com\") → false\n */\nexport function isPlatformDomain(domain, platformDomain) {\n return domain.endsWith(`.${platformDomain}`);\n}\n/**\n * Check if a domain is a custom domain (not a platform subdomain).\n * Custom domains are billable and require DNS verification.\n *\n * @example isCustomDomain(\"example.com\", \"shipstatic.com\") → true\n * @example isCustomDomain(\"www.shipstatic.com\", \"shipstatic.com\") → false\n */\nexport function isCustomDomain(domain, platformDomain) {\n return !isPlatformDomain(domain, platformDomain);\n}\n/**\n * Extract subdomain from a platform domain.\n * Returns null if not a platform domain.\n *\n * @example extractSubdomain(\"www.shipstatic.com\", \"shipstatic.com\") → \"www\"\n * @example extractSubdomain(\"example.com\", \"shipstatic.com\") → null\n */\nexport function extractSubdomain(domain, platformDomain) {\n if (!isPlatformDomain(domain, platformDomain)) {\n return null;\n }\n return domain.slice(0, -(platformDomain.length + 1)); // +1 for the dot\n}\n/**\n * Generate HTTPS URL for a deployment hostname.\n */\nexport function generateDeploymentUrl(deployment) {\n return `https://${deployment}`;\n}\n/**\n * Generate HTTPS URL for a domain.\n */\nexport function generateDomainUrl(domain) {\n return `https://${domain}`;\n}\n// =============================================================================\n// LABEL UTILITIES\n// =============================================================================\n/**\n * Label validation constraints shared across UI and API.\n * These rules define the single source of truth for label validation.\n */\nexport const LABEL_CONSTRAINTS = {\n /** Minimum label length in characters */\n MIN_LENGTH: 3,\n /** Maximum label length in characters (concise labels, matches Stack Overflow's original limit) */\n MAX_LENGTH: 25,\n /** Maximum number of labels allowed per resource */\n MAX_COUNT: 10,\n /** Allowed separator characters between label segments */\n SEPARATORS: '._-',\n};\n/**\n * Label validation pattern.\n * Must start and end with alphanumeric (a-z, 0-9).\n * Can contain separators (. _ -) between segments, but not consecutive.\n *\n * Valid examples: 'production', 'v1.2.3', 'api_v2', 'us-east-1'\n * Invalid examples: 'ab' (too short), '-prod' (starts with separator), 'foo--bar' (consecutive separators)\n */\nexport const LABEL_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;\n/**\n * Serialize labels array to JSON string for database storage.\n * Returns null for empty or undefined arrays.\n *\n * @example serializeLabels(['web', 'production']) → '[\"web\",\"production\"]'\n * @example serializeLabels([]) → null\n * @example serializeLabels(undefined) → null\n */\nexport function serializeLabels(labels) {\n if (!labels || labels.length === 0)\n return null;\n return JSON.stringify(labels);\n}\n/**\n * Deserialize labels from JSON string to array.\n * Always returns an array — empty array for null/empty/invalid input.\n *\n * @example deserializeLabels('[\"web\",\"production\"]') → ['web', 'production']\n * @example deserializeLabels(null) → []\n * @example deserializeLabels('') → []\n */\nexport function deserializeLabels(labelsJson) {\n if (!labelsJson)\n return [];\n try {\n const parsed = JSON.parse(labelsJson);\n return Array.isArray(parsed) ? parsed : [];\n }\n catch {\n return [];\n }\n}\n// =============================================================================\n// PASSWORD UTILITIES\n// =============================================================================\n/**\n * Length constraints for the optional deployment password\n * (`DeploymentUploadOptions.password`). Single source of truth shared across\n * platform consumers.\n */\nexport const PASSWORD_CONSTRAINTS = {\n /** Minimum password length in characters */\n MIN_LENGTH: 6,\n /** Maximum password length in characters */\n MAX_LENGTH: 128,\n};\n/**\n * Validate an optional deployment password and return it normalized.\n *\n * Absent (`undefined` / `null`) → returns `undefined`. Present → trim\n * leading/trailing whitespace, then validate against `PASSWORD_CONSTRAINTS`\n * length bounds (internal whitespace is significant and counts toward\n * length). Throws `ShipError.validation` on breach; returns the trimmed\n * value.\n *\n * The trim is canonical: at upload, the API hashes the trimmed value; at\n * unlock, the router trims submissions before hashing. Submission and storage\n * agree byte-for-byte. Length validation runs on the trimmed value because\n * that's the user's actual intent — and it disarms a class of invisible\n * foot-guns (trailing newlines from copy/paste, mobile auto-spacing,\n * password-manager artifacts).\n *\n * Single source of truth shared by SDK (client-side validation, return\n * ignored) and API (server-side enforcement, return threaded into config).\n * Length is part of the wire-format contract; strength rules, if added later,\n * stay server-side. See `CLAUDE.md` \"Validation: format vs policy\".\n */\nexport function validatePassword(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'string') {\n throw ShipError.validation('Password must be a string');\n }\n const trimmed = value.trim();\n if (trimmed.length < PASSWORD_CONSTRAINTS.MIN_LENGTH ||\n trimmed.length > PASSWORD_CONSTRAINTS.MAX_LENGTH) {\n throw ShipError.validation(`Password must be between ${PASSWORD_CONSTRAINTS.MIN_LENGTH} and ${PASSWORD_CONSTRAINTS.MAX_LENGTH} characters`);\n }\n return trimmed;\n}\n","/**\n * Test utilities for consumers of `useDrop`.\n *\n * ```typescript\n * import { createMockDrop } from '@shipstatic/drop/testing';\n * ```\n *\n * The whole subpath exists for one reason: a `DropReturn` has twenty fields, and\n * a component test that takes `drop` as a prop should not have to build them.\n * Everything else — spying, matching, asserting — belongs to your test framework,\n * so this file deliberately ships none of it.\n */\n\nimport {\n FileValidationStatus,\n type FileValidationStatusType,\n WEB_FILE_ACCEPT,\n} from '@shipstatic/types';\nimport type { ProcessedFile } from './types';\nimport type { DropInputProps, DropReturn, DropzonePropsOptions, PickerMode } from './useDrop';\n\nconst noop = () => {};\n\n/**\n * Build a `DropReturn` for rendering tests.\n *\n * Any field can be overridden, including with your own spies — which is how you\n * assert on interactions:\n *\n * ```tsx\n * const reset = vi.fn();\n * const drop = createMockDrop({ phase: 'ready', files: [...], reset });\n *\n * render(<DeployDropArea drop={drop} />);\n * await userEvent.click(screen.getByText('Clear'));\n *\n * expect(reset).toHaveBeenCalled();\n * ```\n *\n * The convenience booleans (`isProcessing`, `hasError`, `isInteractive`) and\n * `validFiles` are derived from `phase` and `files` unless you override them, so\n * the mock can never present a state the real hook could not reach by accident.\n */\nexport function createMockDrop(overrides: Partial<DropReturn> = {}): DropReturn {\n const phase = overrides.phase ?? 'idle';\n const files = overrides.files ?? [];\n const validFiles =\n overrides.validFiles ?? files.filter((f) => f.status === FileValidationStatus.READY);\n\n return {\n phase,\n isProcessing: phase === 'processing',\n isDragging: false,\n isInteractive: phase === 'idle' || phase === 'ready',\n hasError: phase === 'error',\n files,\n sourceName: '',\n status: null,\n needsBuild: false,\n\n getDropzoneProps: (options?: DropzonePropsOptions) => ({\n onDragOver: noop,\n onDragLeave: noop,\n onDrop: noop,\n ...(options?.clickable !== false && { onClick: noop }),\n }),\n // Mirrors the real getter's one branch: folder is the default, and exactly\n // one attribute tells the two pickers apart.\n getInputProps: (mode?: PickerMode): DropInputProps => ({\n ref: { current: null },\n type: 'file' as const,\n style: { display: 'none' },\n multiple: true,\n ...(mode === 'files' ? { accept: WEB_FILE_ACCEPT } : { webkitdirectory: '' }),\n onChange: noop,\n }),\n\n open: noop,\n processFiles: async () => {},\n reset: noop,\n\n validFiles,\n getFilesForUpload: () => validFiles.map((f) => f.file),\n\n // Explicit values win over every derivation above.\n ...overrides,\n };\n}\n\n/**\n * A `useDrop` replacement for consumers that call the hook rather than receiving\n * `drop` as a prop.\n *\n * ```tsx\n * vi.mock('@shipstatic/drop', () => ({ useDrop: mockUseDrop({ phase: 'ready' }) }));\n * ```\n *\n * Framework-agnostic on purpose — it returns a function, and your test framework\n * installs it. The value is not the three lines it saves: it is that the mock's\n * shape comes from `createMockDrop`, so it cannot describe a hook this package\n * does not have. A hand-written module mock can, and did — one consumer described\n * react-dropzone's API (`rejectedFiles`, `isDragActive`, `getRootProps`, `clear`)\n * for months, because nothing typechecked it.\n *\n * Note this replaces the WHOLE module. If you also import `processFiles` or a\n * type from `@shipstatic/drop`, spread the real module in first:\n *\n * ```tsx\n * vi.mock('@shipstatic/drop', async (importOriginal) => ({\n * ...(await importOriginal<typeof import('@shipstatic/drop')>()),\n * useDrop: mockUseDrop({ phase: 'ready' }),\n * }));\n * ```\n */\nexport function mockUseDrop(overrides: Partial<DropReturn> = {}): () => DropReturn {\n return () => createMockDrop(overrides);\n}\n\nlet mockFileIdCounter = 0;\n\n/** Build a `ProcessedFile` backed by a real `File`. */\nexport function createMockProcessedFile(\n name: string,\n options: {\n path?: string;\n content?: string;\n type?: string;\n status?: FileValidationStatusType;\n statusMessage?: string;\n } = {},\n): ProcessedFile {\n const {\n path = name,\n content = 'test content',\n type = 'text/plain',\n status = FileValidationStatus.READY,\n statusMessage,\n } = options;\n\n const file = new File([content], name, { type });\n\n return {\n id: `mock-file-${++mockFileIdCounter}`,\n file,\n path,\n name,\n size: file.size,\n type,\n lastModified: file.lastModified,\n status,\n statusMessage,\n };\n}\n\n/**\n * Build a real `File` carrying a folder-relative path, the way a browser\n * presents a folder drop. (`webkitRelativePath` is read-only, hence the\n * redefinition — the one part of this that is not a one-liner.)\n */\nexport function createMockFileWithPath(\n name: string,\n webkitRelativePath: string,\n content = 'test content',\n type = 'text/plain',\n): File {\n const file = new File([content], name, { type });\n Object.defineProperty(file, 'webkitRelativePath', {\n value: webkitRelativePath,\n writable: false,\n enumerable: true,\n configurable: true,\n });\n return file;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/drop",
3
- "version": "2.2.0",
3
+ "version": "2.2.1-beta.2",
4
4
  "description": "Headless React hook for file dropping, processing, ZIP extraction, and validation - purpose-built for Ship SDK",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -86,13 +86,13 @@
86
86
  "react-dom": "^18.0.0 || ^19.0.0"
87
87
  },
88
88
  "dependencies": {
89
- "@shipstatic/ship": "2.3.0",
89
+ "@shipstatic/ship": "2.3.1-beta.2",
90
90
  "fflate": "^0.8.3"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@arethetypeswrong/cli": "^0.18.5",
94
94
  "@biomejs/biome": "2.5.5",
95
- "@shipstatic/types": "2.8.0",
95
+ "@shipstatic/types": "2.9.0-beta.2",
96
96
  "@testing-library/react": "^16.3.2",
97
97
  "@types/node": "^20.19.43",
98
98
  "@types/react": "^19.2.17",