{"version":3,"sources":["../node_modules/.pnpm/@shipstatic+types@3.15.0/node_modules/@shipstatic/types/dist/index.js","../src/testing.ts"],"names":[],"mappings":"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","file":"testing.cjs","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`, `vsc` and `gmn` are surfaces this platform authors,\n *   so each names itself in its own source and nothing external is needed to\n *   tell them apart. A first-party WRAPPER counts as code even when it is a\n *   manifest: the GitHub Action and the Gemini extension each compose an\n *   invocation of a platform executable and relabel it through\n *   {@link SHIP_VIA_ENV}.\n *\n * **And only a surface that DEPLOYS gets a member — `@shipstatic/drop` is the\n * recorded refusal.** The widget collects and validates files and hands raw\n * `File[]` to its embedder, whose own SDK call is the deploy; drop makes no\n * upload call anywhere, so a `drp` member would be a word nothing can emit.\n * Those deploys read `sdk`, which is the truth: the embedder is a program\n * embedding the SDK directly. Mint the member the day drop grows an upload\n * arm, not before.\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 plugin → `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    /** The Gemini CLI extension. */\n    GMN: 'gmn',\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     * **The API stamps it, since 2026-08-15.** A deploy that names no origin —\n     * or names one this vocabulary does not know — is stored as `api`, so a\n     * stored `null` now means only that the row predates attribution.\n     *\n     * It was declared one wave ahead of that decision, deliberately: vocabulary\n     * must exist before a consumer can adopt it, and adding a member costs a\n     * full constellation convoy, so the word shipped first and the server\n     * adopted it with no convoy standing between the decision and the deploy.\n     */\n    API: 'api',\n};\n// =============================================================================\n// DOMAIN TYPES\n// =============================================================================\n/**\n * A domain's STANDING: the one derived word, and what it asks of the owner.\n *\n * Not a DNS state and not a lifecycle. It answers the only question a reader\n * of a domain actually has, which is \"what, if anything, do I have to do about\n * this one\":\n *\n * - `live`: it serves the linked deployment. Nothing to do. Open it.\n * - `unlinked`: DNS is verified and nothing is published there, so a visitor\n *   meets the platform's reserved page. Link a deployment.\n * - `unverified`: the required DNS records are not all in place yet, in full\n *   or in part. Configure DNS.\n * - `paused`: the plan no longer has room for it. DNS is intact and the\n *   platform has stopped serving it; nothing DNS-shaped will help.\n *\n * **Derived, with exactly one owner: `domainStatus(row)` in the platform's\n * `shared/db/domains.ts`, beside `domainMapping(row)`.** The precedence is\n * policy rather than arithmetic, and it is stated here ONCE so that no\n * consumer has to restate it:\n *\n * ```\n * paused !== null               -> 'paused'\n * verification !== 'verified'   -> 'unverified'   (DNS comes first)\n * deployment === null           -> 'unlinked'\n * otherwise                     -> 'live'\n * ```\n *\n * This constant is the PUBLISHED RECORD of that rule, not a second\n * implementation of it. A consumer reads the word off the wire and switches\n * on it; a consumer that recomputes it from `deployment` and `verification`\n * has become the fourth copy of a precedence that drifts, which is exactly\n * how an unlinked domain wore a green \"success\" dot for months.\n *\n * The raw facts are still on the row, each said once, for a reader who wants\n * one of them specifically: {@link Domain.verification}, {@link Domain.verified},\n * {@link Domain.deployment}, {@link Domain.paused}.\n *\n * Note this is a repurposing of a wire name, which the API compatibility law\n * otherwise forbids: until types 3.0.0 `status` carried the DNS enum\n * (`pending`, `partial`, `success`) with `paused` glued on at the API's\n * projection. The exception was granted deliberately on 2026-09-20, on the\n * grounds that `status` is the right name for this and a `standing` beside a\n * deprecated `status` would preserve the half-design forever. It is recorded\n * beside the law it bends and is not precedent.\n */\nexport const DomainStatus = {\n    LIVE: 'live',\n    UNLINKED: 'unlinked',\n    UNVERIFIED: 'unverified',\n    PAUSED: 'paused',\n};\n/**\n * The DNS fact: how far the platform's one verifying act has got.\n *\n * - `pending`: the site's CNAME does not point at the platform.\n * - `partial`: it does, and the rest does not. Only a `www` domain has a\n *   rest, the apex A record, which serves nothing but the redirect to `www`.\n * - `verified`: every required record does. Platform names are born here,\n *   having no records to configure.\n *\n * **The CNAME divides the three because it is the record that serves the\n * site.** `partial` says the site is served and only the redirect is missing;\n * an apex A record without the CNAME serves nothing, so it is `pending`. The\n * verifier's drift line depends on exactly that: on a domain that has verified\n * before, a move into `pending` is the site no longer being served, and it is\n * the drift the operator is told about (`cloudflare/consumer`'s\n * `verifyDnsMapping`).\n *\n * Written by the DNS verifier and at birth, and by nothing else. **It is the\n * only field that says whether DNS is verified NOW**: its sibling instant\n * {@link Domain.verified} is monotonic and answers when it last was. A reader\n * who only wants to know whether to go and fix DNS reads {@link Domain.status};\n * this field is the diagnostic underneath it, and it is the ONLY place\n * `partial` is distinguishable from `pending`. Do not fold that distinction\n * into `status`: the standing says what to do, and \"configure DNS\" is the\n * same instruction either way.\n *\n * **On the noun, decided twice (2026-09-20), with the refusals recorded here\n * because an unrecorded one gets re-proposed forever.** `records` was the\n * first choice and is refused: on this same resource it would be a state word\n * on {@link Domain} and an array of DNS records on `DomainRecordsResponse`,\n * and the audience reading both schemas is largely agents. `dns` is refused\n * because it is taken twice over, by the provider lookup on\n * `GET /domains/:domain/dns` and by the column of that name. `verification`\n * is the platform's own family noun, shared by the `verify_dns` job, the\n * `POST /domains/:domain/verify` route, the {@link Domain.verified} instant\n * and the {@link Domain.verifications} count, so the three read as one act.\n * Its near-twin with `verifications` survives only in prose: in code the\n * compiler separates a string from a number.\n */\nexport const DomainVerification = {\n    PENDING: 'pending',\n    PARTIAL: 'partial',\n    VERIFIED: 'verified',\n};\n/**\n * The anchor of the troubleshooting section on a domain's setup instructions\n * page, so a link can open the page there: `${share.url}#troubleshooting`.\n *\n * The API's instructions template gives the section this id and the console's\n * connect page links to it: two repos, one spelling, which is why it lives\n * here. The section is written for the domain's own DNS provider, which is why\n * the console sends a stuck reader to it rather than to the general docs. If\n * the spellings ever diverged, nothing would fail: the link would open the page\n * at its top and nobody would be told, the silent-drift class this\n * constitution exists to delete.\n */\nexport const SETUP_TROUBLESHOOTING_ANCHOR = 'troubleshooting';\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 * The most one deploy request may carry, and the framing a client counts\n * toward it. A deployment larger than one request arrives in several: a\n * declaration with its {@link DEPLOY_FIELDS.MANIFEST}, then\n * {@link API_PATHS.DEPLOYMENT_FILES} for the rest.\n *\n * Framing, not plan policy, which is why it is static rather than delivered\n * by `/limits`: a client splitting at a smaller number is always correct.\n *\n * **`MAX_BYTES` is a one-way door.** This package is bundled into every\n * published client, so an installed client keeps its cap forever. Raising it\n * is free (an older client splits smaller, which is always valid); lowering\n * it turns every installed client's largest request into a refusal.\n *\n * 64 MiB because it holds the largest file any plan allows (50 MiB) with its\n * framing, keeps a request inside the API's memory, finishes within the\n * SDK's request timeout at 2.5 Mbit/s, and sits under the edge's 100 MiB\n * request limit. Measured on the development environment at the boundary\n * (2026-10-18), a request of exactly 64 MiB, one 50 MiB file and 14 MiB of\n * others, stored in under half a second of CPU, sent with or without a\n * `Content-Length`, alone or two at once. What a request costs when its\n * files are rewritten is the API's to bound, and it does so per file.\n */\nexport const DEPLOY_REQUEST_CONSTRAINTS = {\n    /** The most one deploy request may carry, as `Content-Length` counts it. */\n    MAX_BYTES: 64 * 1024 * 1024,\n    /**\n     * The most multipart framing one part adds beyond its bytes and its name,\n     * and the body's closing delimiter at most the same. A ceiling by the\n     * multipart standard (a boundary is at most 70 characters), not a\n     * measurement.\n     */\n    PART_OVERHEAD: 256,\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 * Every plan an account can hold — the platform's whole plan vocabulary, in\n * one place, and nothing about what a plan is WORTH.\n *\n * A plan is a TIER and nothing else. Whether an account may act is a separate\n * fact (`Account.suspended`; deletion ends the session outright), so an\n * account keeps its tier through suspension and into deletion.\n *\n * - **Free** — `free`.\n * - **Billed** — `pro`, `team`. The plans a customer buys; the only plans\n *   Stripe knows about, and the only ones the platform never sets by hand —\n *   each is derived from the Stripe Subscription, which names its plan on the\n *   Price it is on. They form a ladder: a dearer tier is a superset of the one\n *   below it, and the API says which is next in {@link Account.upgrade}.\n * - **Granted** — `scale`, `sponsored`. Paid plans the operator confers by\n *   hand, never reached through Checkout: no Stripe subscription and no\n *   Stripe object behind them. These and `free` are the only plans an\n *   operator can set; a billed plan is only ever Stripe's to confer.\n *\n * The numbers each plan confers — caps, sizes — are POLICY and are delivered\n * by the API (`GET /plans`, `GET /account`, `GET /limits`), never published\n * here: a price or a cap in a published package is pinned to whatever version\n * a client installed (`CLAUDE.md`, \"Validation: format vs policy\").\n */\nexport const AccountPlan = {\n    FREE: 'free',\n    PRO: 'pro',\n    TEAM: 'team',\n    SCALE: 'scale',\n    SPONSORED: 'sponsored',\n};\n/** The member places an account has taken: its members, and its pending invitations. */\nexport function placesTaken(usage) {\n    return usage.members + usage.invitations;\n}\n/**\n * The kind each count of {@link AccountUsage} names, as a person reads it:\n * one and many. The one vocabulary every surface that says a count uses, the\n * api's cap refusals and the console's room notes alike, so \"custom domain\"\n * is one word wherever it is met. A person reads \"ShipStatic domain\" where\n * the code says platform domain; the code's word never reaches them.\n */\nexport const USAGE_KIND = {\n    deployments: { one: 'deployment', many: 'deployments' },\n    platformDomains: { one: 'ShipStatic domain', many: 'ShipStatic domains' },\n    customDomains: { one: 'custom domain', many: 'custom domains' },\n    members: { one: 'member', many: 'members' },\n    invitations: { one: 'invitation', many: 'invitations' },\n};\n/**\n * What an account holds beyond a set of caps, per kind, only the kinds that\n * are over: what a move down a tier must shed before it is booked, and what\n * the console notes on a cheaper card.\n *\n * Member places are read against `caps.members`: the places over the cap are\n * counted once ({@link placesTaken} less the cap), members are named first,\n * because a member over the cap has to go whatever else the account holds,\n * and what is over after them is the invitations to cancel. So two members\n * and one invitation against a cap of one is one member and one invitation,\n * and five members against the same cap is four members and no invitation.\n * The two domain kinds are read against their own caps. Deployments are not\n * read at all: nothing pauses them, and above their cap they only block\n * creation (see {@link Caps}).\n */\nexport function excessOver(usage, caps) {\n    const places = Math.max(0, placesTaken(usage) - caps.members);\n    const members = Math.max(0, usage.members - caps.members);\n    const over = {\n        members,\n        invitations: places - members,\n        customDomains: usage.customDomains - caps.customDomains,\n        platformDomains: usage.platformDomains - caps.platformDomains,\n    };\n    return Object.fromEntries(Object.entries(over).filter(([, count]) => count > 0));\n}\n/**\n * Counts of kinds in words, people first: \"4 members and 2 custom domains\",\n * or nothing. The order is {@link USAGE_KIND}'s minus the two kinds a count never\n * names here, deployments and invitations, which have their own sentence\n * ({@link fitPhrase}).\n */\nexport function countPhrase(excess) {\n    return listOf(['members', 'customDomains', 'platformDomains']\n        .filter((kind) => (excess[kind] ?? 0) > 0)\n        .map((kind) => counted(kind, excess[kind] ?? 0)));\n}\n/**\n * What to do before an account fits a set of caps, in words: \"remove 1\n * member and cancel 1 invitation\", \"remove 2 custom domains\", \"cancel 2\n * invitations\", or nothing when it fits. Two verbs, because a member place is\n * freed two ways: a member is removed, a pending invitation is cancelled. The\n * api's change door and the console's room notes say this one phrase, so a\n * refusal and the note that preceded it cannot differ.\n */\nexport function fitPhrase(excess) {\n    const remove = countPhrase(excess);\n    const invitations = excess.invitations ?? 0;\n    return listOf([\n        ...(remove ? [`remove ${remove}`] : []),\n        ...(invitations > 0 ? [`cancel ${counted('invitations', invitations)}`] : []),\n    ]);\n}\n/** \"4 members\", \"1 custom domain\". */\nconst counted = (kind, count) => `${count} ${count === 1 ? USAGE_KIND[kind].one : USAGE_KIND[kind].many}`;\n/** \"a\", \"a and b\", \"a, b and c\". */\nconst listOf = (parts) => parts.length > 1\n    ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`\n    : (parts[0] ?? '');\n/**\n * The word a refusal about the account carries in `details.account`, so a\n * client can tell the two apart without reading the sentence:\n *\n * - `paused`: a 403. The person is a member, and the account holds more\n *   members than its plan allows (see {@link AccountAccess}).\n * - `unknown`: a 404. The account the request names ({@link ACCOUNT_HEADER})\n *   is one the person is not a member of, or one that does not exist; the\n *   two are indistinguishable by design. A tab whose person was removed from\n *   the account meets it on its next request.\n */\nexport const AccountRefusal = {\n    PAUSED: 'paused',\n    UNKNOWN: 'unknown',\n};\n// =============================================================================\n// PERSON NAME\n// =============================================================================\n/**\n * The name a person is ADDRESSED by: their name, else their address. A\n * person always has an email and only sometimes a name (a magic-link signup\n * has none), so every surface that addresses one needs the same fallback,\n * and it lives here because two independent holders applied it, the API in\n * its letters and pulses and the console in its labels, with nothing but\n * their docblocks holding the copies together. The console cannot avoid\n * deriving it: a roster's rows come from the identity provider's own member\n * list, not from this wire.\n *\n * Coalesced with `||` rather than `??` deliberately: a provider that hands\n * back an empty or whitespace-only name is a name nobody can be called by.\n *\n * This is the ADDRESSING register. A surface that STATES the name as a fact\n * (an operator table, a CLI printout, a machine payload) shows it as stored\n * and marks absence its own way, and does not call this.\n */\nexport function personName(person) {\n    return person.name?.trim() || person.email;\n}\n/**\n * The short form, where the room is narrow: the first word of the name, else\n * the address. The whole address rather than its local part, since a\n * truncated address still reads as an address while a bare local part reads\n * as a username the platform does not have. The sidebar, a greeting, and the\n * name an account is born with all read this.\n */\nexport function personShortName(person) {\n    return person.name?.trim().split(' ')[0] || person.email;\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    /** Fills a declared deployment with more of its files. */\n    DEPLOYMENT_FILES: (deployment) => `/deployments/${deployment}/files`,\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    PLANS: '/plans',\n    PING: '/ping',\n    SETUP: '/setup',\n    /**\n     * The single-page question, asked without deploying. A deploy that sends\n     * {@link DEPLOY_FIELDS.SPA} `true` is asked it at its declaration, so\n     * a client never needs this path to get the routing config: it answers the\n     * clients that predate the field, which ask here and write the config\n     * themselves.\n     */\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}. These are the names the API reads from a\n * multipart body on `POST /deployments` and the first-party `/upload`.\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. Flags are serialized as the literal string\n * `'true'`; the `@internal` ones belong to first-party surfaces only.\n */\nexport const DEPLOY_FIELDS = {\n    /** One entry per file — read with `getAll`. */\n    FILES: 'files[]',\n    /**\n     * JSON array of {@link ManifestEntry}, one per file of the deployment: its\n     * declaration. Optional; a request without one is the whole deployment.\n     */\n    MANIFEST: 'manifest',\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    /**\n     * Whether the platform asks the single-page question of the deployment's\n     * files: `true` asks, and `false` or absence does not, so a request that\n     * names nothing is never given a config it did not ask for. Asked, when the\n     * root `index.html` is an empty application shell and no `ship.json` is\n     * shipped, the platform adds the routing config that serves every path from\n     * `index.html`. The question is asked of the `index.html` the asking request\n     * carries. A client sends `true` unless\n     * {@link DeploymentUploadOptions.spaDetect} is `false`: the option and the\n     * field are two names for two layers. The name and its `true` are the ones\n     * production's console has always sent; the field is read on both endpoints\n     * and by a deploy split across several requests.\n     */\n    SPA: 'spa',\n    /**\n     * @internal The build settings, first-party `/upload` only and meaningful\n     * only with `BUILD`: the command to run instead of the manifest's build\n     * script, and the folder the site lands in. See {@link BUILD_SETTINGS}.\n     */\n    BUILD_COMMAND: 'buildCommand',\n    /** @internal See {@link DEPLOY_FIELDS.BUILD_COMMAND}. */\n    OUTPUT_DIR: 'outputDir',\n    /** @internal reCAPTCHA proof — `web/www`'s public uploader only. */\n    CAPTCHA: 'captcha',\n};\n/**\n * The JSON deploy body's file grammar: the OTHER body both deploy doors\n * accept, beside the multipart one {@link DEPLOY_FIELDS} names. A JSON caller\n * sends `{ files: [{ path, content, encoding? }] }`, and this is the whole\n * of what one entry may say: three field names, two encodings, one default.\n *\n * Declared once because the grammar has three independent holders that the\n * wire forces to restate it: the API's zod schema (the original), the hosted\n * MCP's tool input, and the n8n community node, which cannot import this\n * under n8n Cloud's zero-dependency rule and fences its copy against this\n * object instead. Each had its own literal table until 2.24.0; the last two\n * types convoys walked without minting the owner, which is exactly the drift\n * the No-Fourth-Category Law's deferral clause names.\n *\n * `content` is the file's bytes as text: raw for `utf-8` (the default, and\n * the right choice for HTML, CSS, JS, JSON and SVG), base64 for binary.\n */\nexport const DEPLOY_FILE_GRAMMAR = {\n    /** Relative path within the site, no leading slash. */\n    PATH: 'path',\n    /** The file's bytes, as text in the entry's encoding. */\n    CONTENT: 'content',\n    /** Optional; one of {@link DEPLOY_FILE_GRAMMAR.ENCODINGS}. */\n    ENCODING: 'encoding',\n    /** What an entry that names no encoding means. */\n    DEFAULT_ENCODING: 'utf-8',\n    /** The closed set. `utf-8` is raw text; `base64` is for binary only. */\n    ENCODINGS: ['utf-8', 'base64'],\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. 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    /**\n     * The project could not be built as submitted (422). The platform ran the\n     * caller's own instructions, the dependency install and then the build\n     * script, and they failed. So the verdict is the caller's: deterministic,\n     * not worth a retry, and answered with the evidence. `details` is a\n     * `BuildFailureDetails`, and the message is the one sentence the builder\n     * authored for the person who will fix the project.\n     *\n     * Distinct from `Api` at 500, which is what the platform answers when it\n     * could not RUN the build at all (the sandbox failed to start, a host\n     * fault). A consumer has to tell \"your project cannot be built\" from \"we\n     * could not build it\": the two get opposite words, opposite retry\n     * behaviour, and only the second pages an operator.\n     */\n    Build: 'build_failed',\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.Build,\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 first factory whose status is fixed rather than\n     * defaulted: a maintenance refusal is 503 or it is not this error\n     * (`build` is the second, on the same reasoning).\n     */\n    static maintenance(message, details) {\n        return new ShipError(ErrorType.Maintenance, message, 503, details);\n    }\n    /**\n     * The project could not be built as submitted (422).\n     *\n     * The second factory with a FIXED status, on Maintenance's reasoning: a\n     * build verdict is 422 or it is not this error. `message` is the builder's\n     * own sentence, relayed rather than rewritten, since the builder is the\n     * throw site and the only party that knows why; `details` carries the log\n     * (`BuildFailureDetails`), which is required for the same reason a verdict\n     * without evidence is an opinion.\n     */\n    static build(message, details) {\n        return new ShipError(ErrorType.Build, message, 422, 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// DEPLOY RULES\n// =============================================================================\n/**\n * The page a deployment serves at `/`. Every deployment has one at its root:\n * ShipStatic publishes websites, and a website starts at its home page.\n * Beside {@link DEPLOYMENT_CONFIG_FILENAME}, the other file that sets a\n * deployment up.\n */\nexport const DEPLOYMENT_INDEX_FILENAME = 'index.html';\n/** Max deploy-path bytes: R2 keys cap at 1024 bytes and the deployment prefix consumes up to 22. */\nexport const MAX_PATH_BYTES = 1000;\n/**\n * A deploy path as the deployment stores it: ONE leading slash names the same\n * path, so it is dropped. Every rule and every store compares paths in this\n * form, so `index.html` and `/index.html` are one file wherever they are\n * judged. Two leading slashes are not one, so `//index.html` is left as it is\n * and {@link isDeployablePath} refuses its empty segment. That makes the\n * function idempotent: a path canonical once is canonical however many times\n * a boundary asks again, so every judge and every store sees one identity.\n */\nexport function canonicalDeployPath(path) {\n    return path.startsWith('/') && !path.startsWith('//') ? path.slice(1) : path;\n}\n/**\n * What a deployment is told when it names one path twice: two files where\n * the deployment holds one. The deploy rules say it, and so does the API's\n * intake, which refuses the repeat before it can index the files by path.\n */\nexport function namedTwice(path) {\n    return `The file ${path} appears twice.`;\n}\n/**\n * Whether a deploy path can be served as it is: within {@link MAX_PATH_BYTES},\n * with no empty segment, none over 255 characters, and none starting with a\n * dot, root-level `.well-known` excepted (RFC 8615). The router refuses to\n * serve a hidden segment, so a file at one could never be reached. Read in\n * its canonical form ({@link canonicalDeployPath}).\n */\nexport function isDeployablePath(path) {\n    const normalized = canonicalDeployPath(path);\n    if (new TextEncoder().encode(normalized).length > MAX_PATH_BYTES)\n        return false;\n    return normalized\n        .split('/')\n        .every((segment, index) => segment !== '' &&\n        segment.length <= 255 &&\n        (!segment.startsWith('.') || (segment === '.well-known' && index === 0)));\n}\n/**\n * Whether a file name can make the upload-to-serve round trip, and if not, the\n * sentence that says why, naming the file. Only what genuinely breaks it is\n * refused: characters that break a URL or open an XSS vector\n * ({@link hasUnsafeChars}), a leading or trailing space, a trailing dot, and\n * a reserved system name. A `..` segment is a segment starting with a dot,\n * which {@link isDeployablePath} refuses, so `report..final.pdf` is a name.\n */\nexport function validateFileName(filename) {\n    const refuse = (why) => ({ valid: false, reason: `File name \"${filename}\" ${why}.` });\n    if (filename.trim() === '')\n        return { valid: false, reason: 'A file has no name.' };\n    if (hasUnsafeChars(filename))\n        return refuse('contains unsafe characters');\n    if (filename.startsWith(' ') || filename.endsWith(' ')) {\n        return refuse('cannot start or end with spaces');\n    }\n    if (filename.endsWith('.'))\n        return refuse('cannot end with dots');\n    const name = filename.split('/').pop() || filename;\n    if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\\.|$)/i.test(name)) {\n        return refuse('uses a reserved system name');\n    }\n    return { valid: true };\n}\n/**\n * Whether a file sent to a deployment is published. An empty one is not: no\n * deployment stores an empty file, so it is left out rather than refused, by\n * every judge and every store alike. The rules about the deployment as a whole\n * still see it, which is how an empty `index.html` is told apart from a\n * missing one.\n */\nexport function isPublishedFile(file) {\n    return file.size > 0;\n}\n/**\n * A limit's sentence, followed by the way forward the API delivered for that\n * limit ({@link PlatformLimits.suggestions}), verbatim. The API writes it with\n * the function its own plans answer to, so a refusal ends the same way\n * wherever it is made, and no client learns what a plan is.\n */\nfunction withSuggestion(sentence, limits, key) {\n    const suggestion = limits.suggestions?.[key];\n    return suggestion ? `${sentence} ${suggestion}` : sentence;\n}\n/**\n * Every rule each file of a deployment is held to, in the order they apply:\n * a file that breaks two reports the first, because fixing it may fix the\n * other. Every sentence names its file once and is complete on its own, so\n * every surface shows it as it is.\n *\n * The API judges a deployment by these rules at its declaration, and every\n * client judges its files by them before sending a byte, so a refusal reads\n * the same wherever it is made. Read through {@link deployRefusal}, or\n * {@link firstBrokenRule} and {@link brokenSetRule} where a caller judges a\n * deployment as it walks it.\n */\nexport const FILE_RULES = [\n    {\n        name: 'name',\n        broken: ({ path }) => !validateFileName(path).valid,\n        sentence: ({ path }) => validateFileName(path).reason ?? `File name \"${path}\" is not allowed.`,\n    },\n    {\n        // The list is the platform's, delivered through `/limits`. Absent means\n        // no check here, never an empty policy: the API holds the list.\n        name: 'extension',\n        broken: ({ path }, limits) => isBlockedExtension(path, limits.blockedExtensions ?? []),\n        sentence: ({ path }) => `File \"${path}\" has an extension that is not allowed.`,\n    },\n    {\n        name: 'project',\n        broken: ({ path }) => hasUnbuiltMarker(path),\n        sentence: ({ path }) => `File \"${path}\" belongs to a project that isn't built yet. Build it, then publish the folder the build makes, like dist.`,\n    },\n    {\n        name: 'path',\n        broken: ({ path }) => !isDeployablePath(path),\n        sentence: ({ path }) => `File \"${path}\" can't be published: a folder or file name in its path starts with a dot, is empty, or is too long.`,\n    },\n    {\n        name: 'fileSize',\n        broken: ({ size }, limits) => size > limits.maxFileSize,\n        sentence: ({ path }, limits) => withSuggestion(`File \"${path}\" is too large. Maximum ${formatFileSize(limits.maxFileSize)} allowed.`, limits, 'maxFileSize'),\n    },\n    {\n        name: 'totalSize',\n        broken: ({ totalSize }, limits) => totalSize > limits.maxTotalSize,\n        sentence: ({ totalSize }, limits) => withSuggestion(`Files add up to ${formatFileSize(totalSize)}. Maximum ${formatFileSize(limits.maxTotalSize)} allowed.`, limits, 'maxTotalSize'),\n    },\n];\n/** Whether a path names an HTML page, the kind of file a site's main page is. */\nfunction isPagePath(path) {\n    return /\\.html?$/i.test(path);\n}\n/**\n * What to do about a missing home page, read from what the files show, so\n * the refusal names the step that fits: the folder that holds the site when\n * it sits one folder down (the site published from the folder above its\n * build), the page to rename when there are pages but none is the home page\n * (a single `report.html`), and the small page to add when there is no page\n * at all (an image or a PDF published on its own, measured as the most\n * common case).\n */\nfunction missingIndexAdvice(paths) {\n    const folder = nestedIndexFolder(paths);\n    if (folder)\n        return `There's one in ${folder}/. If that's your site, publish that folder instead.`;\n    if (paths.some(isPagePath)) {\n        return `If one of your pages is the main one, put it at the top and name it ${DEPLOYMENT_INDEX_FILENAME}.`;\n    }\n    return `To publish a file on its own, like an image or a PDF, add a small ${DEPLOYMENT_INDEX_FILENAME} that links to it.`;\n}\n/**\n * Where the home page is, when it is one folder down: the shallowest nested\n * `index.html`, if exactly one folder holds it. A site deployed from the\n * folder above its build is the common way to miss the root, so the refusal\n * names the folder to publish instead.\n */\nfunction nestedIndexFolder(paths) {\n    const suffix = `/${DEPLOYMENT_INDEX_FILENAME}`;\n    const depth = (path) => path.split('/').length;\n    const nested = paths.filter((path) => path.endsWith(suffix));\n    if (nested.length === 0)\n        return undefined;\n    const shallowest = Math.min(...nested.map(depth));\n    const top = nested.filter((path) => depth(path) === shallowest);\n    return top.length === 1 ? top[0]?.slice(0, -suffix.length) : undefined;\n}\n/** The paths a deployment publishes, out of the files it was sent, in their canonical form. */\nfunction publishedPaths(files) {\n    return files.filter(isPublishedFile).map((file) => canonicalDeployPath(file.path));\n}\n/** The first path the files name twice, empty files included, or `undefined`. */\nfunction repeatedPath(files) {\n    const seen = new Set();\n    for (const { path } of files) {\n        const canonical = canonicalDeployPath(path);\n        if (seen.has(canonical))\n            return canonical;\n        seen.add(canonical);\n    }\n    return undefined;\n}\n/**\n * The rules asked of the deployment as a whole, after every file has passed\n * {@link FILE_RULES}: it has files, it names each path once, it has not more\n * than the plan allows, and it has a home page at its root. They read the\n * files as SENT, an empty one included, and count only what is published\n * ({@link isPublishedFile}), so the home-page rule can say an `index.html` is\n * empty rather than missing. Paths compare in their canonical form\n * ({@link canonicalDeployPath}). These name no one file, so a surface that\n * lists refusals per file files them under the deployment.\n */\nexport const SET_RULES = [\n    {\n        name: 'files',\n        broken: (files) => publishedPaths(files).length === 0,\n        sentence: () => 'There are no files to publish.',\n    },\n    {\n        name: 'unique',\n        broken: (files) => repeatedPath(files) !== undefined,\n        sentence: (files) => namedTwice(repeatedPath(files) ?? ''),\n    },\n    {\n        name: 'filesCount',\n        broken: (files, limits) => publishedPaths(files).length > limits.maxFilesCount,\n        sentence: (files, limits) => withSuggestion(`Too many files (${publishedPaths(files).length}). Maximum ${limits.maxFilesCount} files allowed.`, limits, 'maxFilesCount'),\n    },\n    {\n        name: 'index',\n        broken: (files) => !publishedPaths(files).includes(DEPLOYMENT_INDEX_FILENAME),\n        sentence: (files) => {\n            if (files.some((file) => canonicalDeployPath(file.path) === DEPLOYMENT_INDEX_FILENAME)) {\n                return `The ${DEPLOYMENT_INDEX_FILENAME} at the top of your site is empty. It's the page visitors see first, so it needs content.`;\n            }\n            return `There's no ${DEPLOYMENT_INDEX_FILENAME} at the top of your site. It's the page visitors see first, so your site needs one. ${missingIndexAdvice(publishedPaths(files))}`;\n        },\n    },\n];\n/** The first rule a file breaks, or `undefined`. */\nexport function firstBrokenRule(file, limits) {\n    return FILE_RULES.find((rule) => rule.broken(file, limits));\n}\n/** The first rule the deployment as a whole breaks, or `undefined`. */\nexport function brokenSetRule(files, limits) {\n    return SET_RULES.find((rule) => rule.broken(files, limits));\n}\n/**\n * Why a deployment of these files is refused, as the one sentence a person\n * reads, or `undefined` when every rule holds: each published file in order\n * against {@link FILE_RULES}, then the whole against {@link SET_RULES}. Pass\n * the files as sent, empty ones included: an empty file is left out of the\n * per-file rules and the totals, never refused.\n */\nexport function deployRefusal(files, limits) {\n    let totalSize = 0;\n    for (const { path, size } of files.filter(isPublishedFile)) {\n        totalSize += size;\n        const file = { path, size, totalSize };\n        const rule = firstBrokenRule(file, limits);\n        if (rule)\n            return rule.sentence(file, limits);\n    }\n    return brokenSetRule(files, limits)?.sentence(files, limits);\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, no credential:\n * public deploys are granted the public-account identity per request),\n * `OAUTH` (delegated access token). The one server population:\n * `SYSTEM` (scheduled/background jobs). Webhook receipt is deliberately not\n * a population: a signed delivery is verified, never authorized — it acts\n * as no one and audits as no one.\n */\nexport const AuthMethod = {\n    SESSION: 'session',\n    API_KEY: 'apiKey',\n    TOKEN: 'token',\n    AGENT: 'agent',\n    OAUTH: 'oauth',\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 * The account a session request is about.\n *\n * A person may belong to several accounts, so a request made with a browser\n * session names the one it means, on every request to an account route; the\n * console takes the value from its URL (`/<account>/`). The header names and\n * membership authorizes: the API resolves the person's membership in the\n * account named, answers 404 `details.account: 'unknown'` for an account the\n * person is not in (or one that does not exist), and 400 when a session\n * request names none. Machine credentials never send it, since a key, a\n * deploy token or an OAuth grant already belongs to one account. One owner\n * for the name, since the API's CORS allowlist and every browser transport\n * spell it.\n */\nexport const ACCOUNT_HEADER = 'X-Account';\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/**\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// CONSOLE PATHS\n// =============================================================================\n// The console's path grammar: every console path a server producer emits,\n// and the readers that tell those paths apart.\n//\n// One fact, two holders. The console mounts these paths as routes, and the\n// API, its letters and its billing returns emit them as links; a drift\n// between the two is a link that 404s with nothing to report it, and no\n// fence can reach across the two repositories. So the grammar lives here\n// and both import it.\n//\n// The console's URL names the account: every page inside an account lives\n// under `/<account>/`, where `<account>` is the account's id\n// (`Account.account`). Every section is also reachable at the root, where\n// the console opens it in the account the person entered last (or, for a\n// section flagged `owner`, in their own); that bare form is what a producer\n// whose subject is a person emits. Outside both sit the doors, which exist\n// before an account is known, and the operator partition.\n/**\n * The sections of an account, by first segment, each mounted twice by the\n * console: under `/<account>/` and at the root.\n *\n * `owner` marks a section that means the person's OWN account: a bare path\n * into it opens the person's own account rather than the one entered last.\n * A pricing link's `/upgrade/pro` and the published API-key link speak about\n * the reader's plan and key, and `/success` is where a purchase lands: only\n * an owner buys, and a person owns exactly one account. Modals inside a\n * section carry their own owner rule in the console; this flag is the\n * section's alone.\n */\nexport const SECTIONS = {\n    new: { owner: false },\n    deployments: { owner: false },\n    domains: { owner: false },\n    settings: { owner: false },\n    // A claim link (`/claim/<code>`) is mailed and answered without an account,\n    // so it opens where `/` opens: the account the person is standing in. The\n    // site is kept there. Not an owner section, since a member may keep a\n    // site in a team.\n    claim: { owner: false },\n    upgrade: { owner: true },\n    success: { owner: true },\n    'api-key': { owner: true },\n};\n/** The pages that exist before an account is known, by first segment. */\nconst DOOR_SEGMENTS = ['login', 'register', 'logout', 'invitation', 'consent'];\n/** The operator partition's first segment. */\nconst ADMIN_SEGMENT = 'admin';\n// Sections, doors and the operator partition are disjoint sets of first\n// segments: a path's first segment decides which of the three it is, and\n// anything that is none of them names an account. A shared name fails the\n// build here.\nconst disjoint = true;\nvoid disjoint;\n/**\n * The query markers a Stripe return lands with, each written by a builder\n * below and read by `billingReturnOf`, so neither side spells one.\n */\nconst BILLING_RETURNS = {\n    checkout: ['upgrading', 'true'],\n    portal: ['billing', 'returned'],\n};\nconst marker = (kind) => BILLING_RETURNS[kind].join('=');\n/** A link with an optional `?next=`, the console's own return path. */\nconst withNext = (door, next) => next ? `${door}?next=${encodeURIComponent(next)}` : door;\n/**\n * The paths inside one account, or, with no account, the bare form the\n * console opens in the account entered last.\n *\n * ```ts\n * consolePaths(account).domains.connect('www.example.com')\n * // '/<account>/domains/www.example.com/connect'\n * consolePaths().apiKey()\n * // '/api-key'\n * ```\n */\nexport function consolePaths(account) {\n    const base = account ? `/${account}` : '';\n    const at = (path) => `${base}${path}`;\n    return {\n        dashboard: () => base || '/',\n        newSite: () => at('/new'),\n        deployments: Object.assign(() => at('/deployments'), {\n            upload: () => at('/deployments/upload'),\n        }),\n        domains: Object.assign(() => at('/domains'), {\n            connect: (domain) => at(`/domains/${domain}/connect`),\n            deployment: (domain) => at(`/domains/${domain}/deployment`),\n        }),\n        settings: (fragment) => at(fragment ? `/settings#${fragment}` : '/settings'),\n        /** The claim of an anonymous deployment into this account; the API mails the bare form. */\n        claim: (code) => at(`/claim/${code}`),\n        /** Settings, marked as the landing of a Stripe Customer Portal return. */\n        settingsAfterBilling: () => at(`/settings?${marker('portal')}`),\n        upgrade: (plan) => at(plan ? `/upgrade/${plan}` : '/upgrade'),\n        success: () => at('/success'),\n        /** The success page, marked as the landing of a Stripe Checkout return. */\n        successAfterCheckout: () => at(`/success?${marker('checkout')}`),\n        apiKey: () => at('/api-key'),\n    };\n}\n/**\n * The login door: `/login`, the journey on its query. `login.link` is the page\n * a sign-in email's link opens, `/login/link/<token>`: the door's object, the\n * link's token, rides the path, and the journey still rides the query.\n */\nconst login = Object.assign(({ next } = {}) => withNext('/login', next), {\n    link: ({ token, next }) => withNext(`/login/link/${token}`, next),\n});\n/**\n * The doors: the pages a PERSON stands on before an account is known, one\n * builder per door segment and named for it: signing in and out, joining an\n * account, granting an app access. A door's object rides its path (the\n * sign-in link's token, the invitation's id), and the journey rides the\n * query, so the three doors a sign-in moves through take it by name. What a\n * person does IN an account is a section (`consolePaths`), the claim among\n * them.\n *\n * ```ts\n * doors.login.link({ token, next: '/claim/abc' })\n * // '/login/link/<token>?next=%2Fclaim%2Fabc'\n * ```\n */\nexport const doors = {\n    login,\n    register: ({ next } = {}) => withNext('/register', next),\n    logout: ({ next } = {}) => withNext('/logout', next),\n    invitation: (invitation) => `/invitation/${invitation}`,\n    consent: () => '/consent',\n};\n/**\n * Which Stripe return a query string marks: `checkout` for the success page\n * after Checkout, `portal` for settings after the Customer Portal, or null.\n */\nexport function billingReturnOf(search) {\n    const params = new URLSearchParams(search);\n    for (const [kind, [key, value]] of Object.entries(BILLING_RETURNS)) {\n        if (params.get(key) === value)\n            return kind;\n    }\n    return null;\n}\nconst isDoor = (segment) => DOOR_SEGMENTS.includes(segment);\nconst isSection = (segment) => Object.hasOwn(SECTIONS, segment);\nfunction placeOf(path) {\n    const pathname = path.split(/[?#]/)[0].toLowerCase();\n    const [first = '', second = ''] = pathname.split('/').filter(Boolean);\n    if (isDoor(first))\n        return { kind: 'door', door: first };\n    if (first === ADMIN_SEGMENT)\n        return { kind: 'admin' };\n    if (first === '' || isSection(first)) {\n        return { kind: 'account', account: null, section: first === '' ? null : first };\n    }\n    return { kind: 'account', account: first, section: isSection(second) ? second : null };\n}\n/**\n * The account a path names, or null for a door, the operator partition, or\n * the bare form. Any first segment that is not a section, a door or the\n * partition is an account: the grammar has no other reading, and whether\n * the account exists is the server's answer, never the path's.\n */\nexport function accountOfPath(path) {\n    const place = placeOf(path);\n    return place.kind === 'account' ? place.account : null;\n}\n/**\n * The door a path names, or null for an account place or the operator\n * partition. Every question about doors reads this one answer: which page is\n * an auth page, which errand a destination carries, what a vendor must not\n * see. `/login/link/<token>` is the login door, as `/invitation/<id>` is the\n * invitation door: a door's object is the rest of its path.\n */\nexport function doorOf(path) {\n    const place = placeOf(path);\n    return place.kind === 'door' ? place.door : null;\n}\n/**\n * Whether a path names the claim section, in either form: `/claim/:code`, and\n * the same under `/<account>/`. The code is a bearer credential, which is why\n * a reader asks about this section by name: the doors title their visitor by\n * it, and analytics elides it.\n */\nexport const isClaimPath = (path) => {\n    const place = placeOf(path);\n    return place.kind === 'account' && place.section === 'claim';\n};\n/**\n * Whether a path names the upgrade section, in either form: `/upgrade`,\n * `/upgrade/:plan`, and the same under `/<account>/`.\n */\nexport const isUpgradePath = (path) => {\n    const place = placeOf(path);\n    return place.kind === 'account' && place.section === 'upgrade';\n};\n/**\n * The errand a destination path carries, in specificity order: a pricing\n * link is the broadest, and the narrower ones do not normally coexist with\n * it.\n */\nexport function errandOf(path) {\n    if (isClaimPath(path))\n        return 'claim';\n    if (doorOf(path) === 'invitation')\n        return 'invite';\n    if (isUpgradePath(path))\n        return 'upgrade';\n    return null;\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 wrapper-tier variables\n * are deliberately NOT here: `SHIP_PASSWORD` stays the CLI's own operational\n * lever (see `npm/ship/CLAUDE.md`, \"CLI-only env vars\"), and the origin\n * relabel slot has its own owner beside this object ({@link SHIP_VIA_ENV})\n * because two executables read it and neither reading is the SDK's.\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 * The origin-relabel slot for integrations that wrap a first-party\n * executable as a subprocess. A wrapper composes an argv and an env it does\n * not otherwise reach, so this variable is its one way to name the surface\n * the traffic is really from. Two executables read it through\n * {@link normalizeVia}: the `ship` CLI (default `cli`; the GitHub Action\n * sets `git`) and the stdio MCP bin (default `mcp`; the Gemini CLI\n * extension sets `gmn`). In-process SDK consumers pass the programmatic\n * `via` option instead: same destination, different mechanism.\n *\n * Deliberately not a member of {@link SHIP_ENV}, which is the SDK's ambient\n * contract; the SDK never reads this. The name earned an owner the day the\n * stdio bin became its second reader: one spelling, two executables, and a\n * misspelling on either side drops attribution silently.\n */\nexport const SHIP_VIA_ENV = 'SHIP_VIA';\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). The bare\n * form of the path, which the console opens in the reader's own account.\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${consolePaths().apiKey()}`;\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 * How long a build may take, start to finish: installing, building and\n * prerendering together.\n *\n * The platform reaches its build service through a proxy that ends a request\n * after 125 seconds of silence, and only an Enterprise plan can change that,\n * so a build answers inside this limit or is refused with a sentence that\n * names it. One number with several readers: the API waits a little longer\n * than it before giving up, the SDK waits for the upload plus it, the build\n * service enforces it (its `config.env`, held to this by `builds/check.sh`),\n * and the docs state it in minutes (held by `scripts/check-doc-limits.sh`).\n *\n * The day builds are sold by plan, this moves to `/limits`, where a plan's\n * numbers are delivered.\n */\nexport const BUILD_TIME_LIMIT_SECONDS = 120;\n// =============================================================================\n// TIME REMAINING\n// =============================================================================\n/**\n * A span of time in words: `\"3 days\"`, `\"5 hours\"`, `\"45 minutes\"`.\n *\n * Minutes under two hours, hours under two days, and days beyond, each rounded\n * to the nearest. The unit changes exactly where the larger one rounds to two,\n * so the number never jumps as time passes (119 minutes, then 2 hours), and\n * the two vaguest things a rounded clock can say, \"1 hour\" and \"1 day\", are\n * never said. Rounding rather than truncating is what lets a deployment made a\n * second ago read the lifetime it was given: \"3 days\", not \"2 days\". Days are\n * the largest unit because nobody counts a deadline in weeks, and a month has\n * no fixed length.\n *\n * A span that rounds to no minutes at all, zero and negative included, reads\n * \"less than a minute\".\n */\nexport function formatDuration(seconds) {\n    const minutes = Math.round(seconds / 60);\n    if (!(minutes >= 1))\n        return 'less than a minute';\n    if (minutes < 120)\n        return countOf(minutes, 'minute');\n    const hours = Math.round(seconds / 3_600);\n    if (hours < 48)\n        return countOf(hours, 'hour');\n    return countOf(Math.round(seconds / 86_400), 'day');\n}\n/**\n * The time left before a deadline, in words (`\"3 days\"`), or `null` once it\n * has passed.\n *\n * `expires` is unix SECONDS, the wire's own field, taken as it arrives so that\n * no caller converts it: the deploy card once read it as a date string and\n * showed the same three days on every deployment for months. `now` is seconds\n * too, and defaults to the clock.\n *\n * A surface that tells a person how long a deployment has left says it with\n * this, so one deployment reads the same everywhere, whenever it is looked at.\n * What a surface says around the words is its own (\"Expires in\", \"It stays\n * live for\"), and so is what it says once the deadline has passed, which is\n * why that case is `null` rather than a sentence.\n */\nexport function formatTimeRemaining(expires, now = Date.now() / 1_000) {\n    const seconds = expires - now;\n    return seconds > 0 ? formatDuration(seconds) : null;\n}\nfunction countOf(count, unit) {\n    return `${count} ${unit}${count === 1 ? '' : 's'}`;\n}\n// =============================================================================\n// FILE SIZE\n// =============================================================================\n/**\n * A byte count as a person reads it: `\"180 KB\"`, `\"2.5 MB\"`, `\"0 Bytes\"`.\n *\n * Binary units, one decimal by default, and `decimals` for a surface with its\n * own density (a dense table reads `\"3 MB\"` at 0). A deployment's size reads\n * this way on every surface that states one: the console, the CLI, the deploy\n * card and the API's own refusals, so one deployment is one number everywhere.\n */\nexport function formatFileSize(bytes, decimals = 1) {\n    if (bytes === 0)\n        return '0 Bytes';\n    const k = 1024;\n    const sizes = ['Bytes', 'KB', 'MB', 'GB'];\n    const i = Math.floor(Math.log(bytes) / Math.log(k));\n    return `${Number.parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;\n}\n// The billing boundary: the stream records the PLATFORM'S own acts, and\n// Stripe records the subscription's life. `checkout.open` is ours — the\n// platform mints the Session, and it is the one billing act that exists\n// before Stripe holds anything. `account.plan.transition` is ours — what a\n// change MEANS, entitlement crossing our threshold. Everything between those\n// two edges (invoices, refunds, disputes, retries, scheduled changes) is the\n// subscription's own history, which belongs to Stripe as merchant of record\n// and is shown to the customer in the Customer Portal; none of it is\n// restated here. Refusals write no history either: the stream records what\n// happened to resources, never what was declined — a detection\n// (`deployment.flagged`) is a fact discovered about content, not a refusal.\n/**\n * The events an account's own feed shows: curated meaning, where everything\n * else in the stream is the operator's forensics. One list, the owner of\n * both the API's filter and the type; the retention floor covers exactly\n * these, since the floor exists to keep a dormant account's feed from\n * reading empty.\n */\nexport const USER_VISIBLE_EVENTS = [\n    'account.create',\n    'account.update',\n    'account.delete',\n    'account.key.generate',\n    'account.plan.transition',\n    'deployment.create',\n    'deployment.update',\n    'deployment.delete',\n    'deployment.claim',\n    'domain.create',\n    'domain.update',\n    'domain.delete',\n    'domain.verify',\n    'token.create',\n    'token.consume',\n    'token.delete',\n    'member.join',\n    'member.leave',\n    'consent.grant',\n];\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 domain).\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 * A label is a short identifier attached to a deployment, a domain or a token.\n * It has one of two forms:\n *\n * - a word: `production`, `v1.2.3`, `us-east-1`\n * - a key and a value, split by one `:`: `env:production`, `team:web`\n *\n * Each side is lowercase letters and digits, with single `.`, `_` or `-`\n * between them. The whole label, the key and the `:` and the value together,\n * counts toward {@link LABEL_CONSTRAINTS.MAX_LENGTH}.\n *\n * The platform stores a label in lowercase and shows it as stored. It reads\n * nothing from a key or a value and changes no behaviour because of one: a\n * label describes a resource and lets its owner find it. So a resource may carry several\n * values for one key (`team:web` and `team:api`), and only the same label\n * twice is refused.\n *\n * Labels stay a list of strings on the wire and in storage. A caller that\n * wants a key's value splits on the `:` itself.\n */\nexport const LABEL_CONSTRAINTS = {\n    /** Minimum label length in characters. */\n    MIN_LENGTH: 3,\n    /**\n     * Maximum label length in characters. A key, its `:` and its value share it,\n     * and it fits a key with a UUID (36 characters) or a full git SHA (40).\n     */\n    MAX_LENGTH: 63,\n    /** Maximum number of labels on one resource. */\n    MAX_COUNT: 10,\n};\n/**\n * The shape of a label: one side, or two with a `:` between them. A side is\n * lowercase letters and digits with single `.`, `_` or `-` between them, and\n * the pattern spells it twice. Length is {@link LABEL_CONSTRAINTS}' rule, and\n * {@link validateLabel} applies both.\n *\n * Matches: `production`, `v1.2.3`, `env:production`, `commit:3f9c2ab`\n * Refuses: `-prod`, `foo--bar`, `:production`, `env:`, `a:b:c`, `Env:Prod`\n */\nexport const LABEL_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-z0-9]+(?:[._-][a-z0-9]+)*)?$/;\n/**\n * Validate one label and return it normalized: trimmed and lowercased, then\n * held to {@link LABEL_CONSTRAINTS}' length and to {@link LABEL_PATTERN}.\n * Throws `ShipError.validation` when it does not fit.\n *\n * Lowercasing is the one normalization and it applies to both sides of a\n * `:`, so `Env:Prod` is stored as `env:prod`. A value that must keep its\n * case does not belong in a label.\n *\n * The one label check on the platform: the SDK and the console call it for\n * fast feedback and the API calls it as the boundary, so all three refuse\n * the same strings in the same words.\n */\nexport function validateLabel(value) {\n    if (typeof value !== 'string') {\n        throw ShipError.validation('Labels must be strings');\n    }\n    const label = value.trim().toLowerCase();\n    if (label.length < LABEL_CONSTRAINTS.MIN_LENGTH) {\n        throw ShipError.validation(`Labels must be at least ${LABEL_CONSTRAINTS.MIN_LENGTH} characters long`);\n    }\n    if (label.length > LABEL_CONSTRAINTS.MAX_LENGTH) {\n        throw ShipError.validation(`Labels must be no more than ${LABEL_CONSTRAINTS.MAX_LENGTH} characters long`);\n    }\n    if (!LABEL_PATTERN.test(label)) {\n        throw ShipError.validation('Labels use letters and numbers, with . _ or - between them, and can have one : between a key and a value, like env:production');\n    }\n    return label;\n}\nexport function validateLabels(labels) {\n    if (labels === undefined || labels === null)\n        return undefined;\n    if (!Array.isArray(labels)) {\n        throw ShipError.validation('Labels must be an array');\n    }\n    if (labels.length > LABEL_CONSTRAINTS.MAX_COUNT) {\n        throw ShipError.validation(`Maximum ${LABEL_CONSTRAINTS.MAX_COUNT} labels allowed`);\n    }\n    const normalized = labels.map(validateLabel);\n    if (new Set(normalized).size !== normalized.length) {\n        throw ShipError.validation('Duplicate labels are not allowed');\n    }\n    return normalized;\n}\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// BUILD SETTINGS\n// =============================================================================\n/**\n * The format of the two per-deploy build settings a caller may name when the\n * builder's own detection is not enough: the command to run and the folder\n * the site lands in. Format rules only, the format/policy split this file\n * keeps everywhere: WHAT a value may look like lives here so both the console\n * (fast feedback) and the API (the boundary) refuse the same strings; whether\n * the command builds anything is the build's verdict, not a rule.\n *\n * Both are read only by first-party `/upload`, only with `build`, and both\n * run inside the throwaway container that already runs the project's own\n * arbitrary code, which is why the command's format is a shape rule (one\n * line, bounded) and not a safety rule. The folder's rule is what keeps it a\n * folder OF the project: relative, no parent segments, no leading slash.\n */\nexport const BUILD_SETTINGS = {\n    /** A build command is one line, non-empty, and bounded. */\n    COMMAND_MAX_LENGTH: 200,\n    /** An output folder is a bounded relative path. */\n    OUTPUT_DIR_MAX_LENGTH: 100,\n    /** Path segments and separators only: `dist`, `dist/site`, `.output/public`. */\n    OUTPUT_DIR_PATTERN: /^[A-Za-z0-9._-]+(\\/[A-Za-z0-9._-]+)*$/,\n};\n/**\n * Validate an optional build command and return it normalized (trimmed).\n * Absent → `undefined`. Present → one line, 1 to\n * {@link BUILD_SETTINGS.COMMAND_MAX_LENGTH} characters, no control characters.\n */\nexport function validateBuildCommand(value) {\n    if (value === undefined || value === null || value === '')\n        return undefined;\n    if (typeof value !== 'string') {\n        throw ShipError.validation('Build command must be a string');\n    }\n    const trimmed = value.trim();\n    if (trimmed.length === 0 || trimmed.length > BUILD_SETTINGS.COMMAND_MAX_LENGTH) {\n        throw ShipError.validation(`Build command must be between 1 and ${BUILD_SETTINGS.COMMAND_MAX_LENGTH} characters`);\n    }\n    // biome-ignore lint/suspicious/noControlCharactersInRegex: a command is one line; a newline or any other control character is a second one\n    if (/[\\x00-\\x1f\\x7f]/.test(trimmed)) {\n        throw ShipError.validation('Build command must be a single line');\n    }\n    return trimmed;\n}\n/**\n * Validate an optional output folder and return it normalized (trimmed, no\n * trailing slash). Absent → `undefined`. Present → a relative path of plain\n * segments, no `..`, no leading slash, at most\n * {@link BUILD_SETTINGS.OUTPUT_DIR_MAX_LENGTH} characters.\n */\nexport function validateOutputDir(value) {\n    if (value === undefined || value === null || value === '')\n        return undefined;\n    if (typeof value !== 'string') {\n        throw ShipError.validation('Output folder must be a string');\n    }\n    const trimmed = value.trim().replace(/\\/+$/, '');\n    if (trimmed.length === 0 || trimmed.length > BUILD_SETTINGS.OUTPUT_DIR_MAX_LENGTH) {\n        throw ShipError.validation(`Output folder must be between 1 and ${BUILD_SETTINGS.OUTPUT_DIR_MAX_LENGTH} characters`);\n    }\n    if (!BUILD_SETTINGS.OUTPUT_DIR_PATTERN.test(trimmed) ||\n        trimmed.split('/').some((segment) => segment === '..' || segment === '.')) {\n        throw ShipError.validation('Output folder must be a relative path inside the project, like dist or dist/site');\n    }\n    return trimmed;\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"]}