@shipstatic/types 2.6.0 → 2.7.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/index.d.ts +79 -32
- package/dist/index.js +190 -119
- package/package.json +1 -1
- package/src/index.ts +225 -128
package/README.md
CHANGED
|
@@ -131,10 +131,20 @@ import {
|
|
|
131
131
|
validateApiUrl,
|
|
132
132
|
isDeployment,
|
|
133
133
|
isBlockedExtension,
|
|
134
|
-
BLOCKED_EXTENSIONS,
|
|
135
134
|
} from '@shipstatic/types';
|
|
136
135
|
```
|
|
137
136
|
|
|
137
|
+
`isBlockedExtension(filename, blocked)` takes the blocklist rather than owning
|
|
138
|
+
one — the platform's list is hosting policy that the API owns and evolves, and
|
|
139
|
+
it reaches clients as `PlatformLimits.blockedExtensions` from `GET /limits`.
|
|
140
|
+
The field is optional: an API that predates it sends nothing, which means "no
|
|
141
|
+
client-side check", never "an empty policy".
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const limits = await ship.getLimits();
|
|
145
|
+
isBlockedExtension('virus.exe', limits.blockedExtensions ?? []);
|
|
146
|
+
```
|
|
147
|
+
|
|
138
148
|
### File Upload Types
|
|
139
149
|
|
|
140
150
|
```typescript
|
package/dist/index.d.ts
CHANGED
|
@@ -788,12 +788,30 @@ export declare class ShipError extends Error {
|
|
|
788
788
|
*
|
|
789
789
|
* Routing:
|
|
790
790
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
791
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
791
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
792
|
+
* - `TimeoutError` → `ShipError.network(...)` — a deadline expired, so
|
|
793
|
+
* nothing was exchanged; the message names the timeout
|
|
792
794
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
793
795
|
* for what each runtime offers as evidence
|
|
794
796
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
795
797
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
796
798
|
*
|
|
799
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
800
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
801
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
802
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
803
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
804
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
805
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
806
|
+
* produces one.
|
|
807
|
+
*
|
|
808
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
809
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
810
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
811
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
812
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
813
|
+
* there, since the caller's signal is what stopped it.
|
|
814
|
+
*
|
|
797
815
|
* The optional `operationName` is composed into the message for context:
|
|
798
816
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
799
817
|
* `"Request"` when omitted.
|
|
@@ -863,14 +881,17 @@ export declare class ShipError extends Error {
|
|
|
863
881
|
*/
|
|
864
882
|
export declare function isShipError(error: unknown): error is ShipError;
|
|
865
883
|
/**
|
|
866
|
-
*
|
|
884
|
+
* What the platform will refuse, returned by the `/limits` endpoint.
|
|
867
885
|
*
|
|
868
|
-
* The SDK fetches
|
|
869
|
-
*
|
|
870
|
-
*
|
|
886
|
+
* The SDK fetches this once on first API call to drive client-side validation
|
|
887
|
+
* that mirrors what the API would enforce server-side. The caps vary by
|
|
888
|
+
* account plan; the blocklist does not.
|
|
871
889
|
*
|
|
872
|
-
* These are the *platform's* posted
|
|
873
|
-
* truth delivered at runtime, never hard-coded on the client.
|
|
890
|
+
* These are the *platform's* posted rules for the current account — server
|
|
891
|
+
* truth delivered at runtime, never hard-coded on the client. That is the
|
|
892
|
+
* whole point of the shape: a rule the server owns and may change reaches the
|
|
893
|
+
* client as data, so a pinned client cannot enforce a policy the platform has
|
|
894
|
+
* moved on from (`npm/types/CLAUDE.md`, "Validation: format vs policy").
|
|
874
895
|
*
|
|
875
896
|
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
876
897
|
* "A report answers a question").
|
|
@@ -882,38 +903,61 @@ export interface PlatformLimits {
|
|
|
882
903
|
maxFilesCount: number;
|
|
883
904
|
/** Maximum total size in bytes across all files in a deployment. */
|
|
884
905
|
maxTotalSize: number;
|
|
906
|
+
/**
|
|
907
|
+
* Lowercase extensions, without the dot, that the platform refuses to host
|
|
908
|
+
* (`exe`, `dmg`, …). Owned and evolved by the API — see
|
|
909
|
+
* `cloudflare/api/src/lib/blocklist.ts`.
|
|
910
|
+
*
|
|
911
|
+
* **Optional, and the absence is load-bearing.** An API deployed before this
|
|
912
|
+
* field existed sends nothing, so a client MUST read absence as "no
|
|
913
|
+
* client-side check" rather than as an empty policy. The hint fails open,
|
|
914
|
+
* the boundary fails closed: the server refuses the file either way, and a
|
|
915
|
+
* client that guessed would only ever be wrong in the direction that refuses
|
|
916
|
+
* a file the platform accepts.
|
|
917
|
+
*
|
|
918
|
+
* The optionality follows the additive-evolution law and retires with its
|
|
919
|
+
* reason: once every environment serves the field, it hardens to required at
|
|
920
|
+
* the entity's next natural break, and the clients' fail-open spellings
|
|
921
|
+
* retire with it (tracked in root `backlog.md`).
|
|
922
|
+
*/
|
|
923
|
+
readonly blockedExtensions?: readonly string[];
|
|
885
924
|
}
|
|
886
925
|
/**
|
|
887
|
-
*
|
|
926
|
+
* Whether a file is one the platform refuses to host.
|
|
888
927
|
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
928
|
+
* **The list is not this package's, and that separation is the point.** What
|
|
929
|
+
* counts as a blocked extension is hosting POLICY — it evolves, it is enforced
|
|
930
|
+
* at one security boundary, and `virus.exe` is a perfectly well-formed
|
|
931
|
+
* filename that breaks nothing about the upload→serve round-trip. So the API
|
|
932
|
+
* owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as
|
|
933
|
+
* `PlatformLimits.blockedExtensions`; a client passes what it was given.
|
|
892
934
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
935
|
+
* What lives here is the MATCHING RULE, and it earns its place by the
|
|
936
|
+
* constellation law's own test. The list's drift is loud in both directions —
|
|
937
|
+
* a stale client uploads a file the API refuses by name, on the first try.
|
|
938
|
+
* A second *matcher* drifts SILENTLY in the one direction that matters: a
|
|
939
|
+
* client stricter than the server refuses a legal file without the server ever
|
|
940
|
+
* being asked, and no error names it. Two holders, silent drift, one owner.
|
|
941
|
+
*
|
|
942
|
+
* The `blocked` collection is required rather than defaulted: this predicate
|
|
943
|
+
* guards a security boundary in the API, and a defaulted-empty argument there
|
|
944
|
+
* would block nothing while reading as though it did. Callers holding a
|
|
945
|
+
* possibly-absent wire field spell the fail-open themselves.
|
|
901
946
|
*
|
|
902
947
|
* @example
|
|
903
|
-
* isBlockedExtension('virus.exe')
|
|
904
|
-
* isBlockedExtension('
|
|
905
|
-
* isBlockedExtension('style.css')
|
|
906
|
-
* isBlockedExtension('
|
|
907
|
-
* isBlockedExtension('README') // false
|
|
948
|
+
* isBlockedExtension('virus.exe', ['exe']) // true
|
|
949
|
+
* isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive
|
|
950
|
+
* isBlockedExtension('style.css', ['exe']) // false
|
|
951
|
+
* isBlockedExtension('README', ['exe']) // false — no extension
|
|
908
952
|
*/
|
|
909
|
-
export declare function isBlockedExtension(filename: string): boolean;
|
|
953
|
+
export declare function isBlockedExtension(filename: string, blocked: ReadonlySet<string> | readonly string[]): boolean;
|
|
910
954
|
/**
|
|
911
955
|
* The `accept` attribute value for a browser file picker offering web files.
|
|
912
956
|
*
|
|
913
|
-
* **This is a hint, never a rule.**
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
957
|
+
* **This is a hint, never a rule.** The API's blocklist is the platform's gate
|
|
958
|
+
* and the only thing that decides what may be hosted; this constant decides
|
|
959
|
+
* what a *file dialog* shows first. The two are not two halves of one policy,
|
|
960
|
+
* and this one must never be consulted to accept or reject a file.
|
|
917
961
|
*
|
|
918
962
|
* The distinction is structural, not stylistic. `accept` can express only an
|
|
919
963
|
* allowlist, while the platform's rule is a blocklist — so this list is
|
|
@@ -924,9 +968,12 @@ export declare function isBlockedExtension(filename: string): boolean;
|
|
|
924
968
|
* dropzone and the picker must reach the same verdict on the same files, and
|
|
925
969
|
* they do — because the verdict is `validateFiles`, downstream of both.
|
|
926
970
|
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
971
|
+
* The invariant that matters — the picker must never offer a file the platform
|
|
972
|
+
* will refuse — is fenced where the authority lives, in the API's own suite
|
|
973
|
+
* (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published
|
|
974
|
+
* string and holds it against the list it owns. It sat here until the
|
|
975
|
+
* blocklist became the API's, and moving it was the price of that: a fence
|
|
976
|
+
* belongs with whichever side can change and break it.
|
|
930
977
|
*/
|
|
931
978
|
export declare const WEB_FILE_ACCEPT: string;
|
|
932
979
|
/**
|
package/dist/index.js
CHANGED
|
@@ -340,38 +340,71 @@ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
|
340
340
|
/**
|
|
341
341
|
* Did the runtime say the exchange never completed?
|
|
342
342
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* |
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* The
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
343
|
+
* Clients branch on the TYPE, never on message strings, so a misclassified
|
|
344
|
+
* transport failure is a lie every consumer inherits — and the one that costs
|
|
345
|
+
* most: `Api` claims a server answered when nothing was exchanged, and a
|
|
346
|
+
* retrying caller will not retry it.
|
|
347
|
+
*
|
|
348
|
+
* **Every row below is a transcript, not a belief.** Captured 2026-08-12
|
|
349
|
+
* against real runtimes — Node and Bun by direct run, the three engines by a
|
|
350
|
+
* one-off playwright probe, workerd through miniflare. The capture scripts are
|
|
351
|
+
* in `tests/errors.test.ts`, "runtime failure shapes".
|
|
352
|
+
*
|
|
353
|
+
* | runtime | connection refused / DNS failure | malformed URL |
|
|
354
|
+
* |------------------|------------------------------------------------------|--------------------------------------------------|
|
|
355
|
+
* | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |
|
|
356
|
+
* | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |
|
|
357
|
+
* | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |
|
|
358
|
+
* | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |
|
|
359
|
+
* | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |
|
|
360
|
+
* | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |
|
|
361
|
+
*
|
|
362
|
+
* Reading that table gives the rule, and it is the INVERSE of the obvious one.
|
|
363
|
+
* The transport class is unbounded — every OS, TLS and DNS failure any engine
|
|
364
|
+
* will ever name — while the class fetch raises for its own ARGUMENTS is
|
|
365
|
+
* small, and every runtime names the URL when it complains about one. So the
|
|
366
|
+
* bounded side is the one worth testing, and the residual risk points the safe
|
|
367
|
+
* way: an unrecognised sentence lands on `Network`, which says only that
|
|
368
|
+
* nothing was exchanged.
|
|
369
|
+
*
|
|
370
|
+
* That inversion is what fixes **WebKit**, whose `Load failed` carries no code
|
|
371
|
+
* and no "fetch", and which every browser-SDK and `@shipstatic/drop` user on
|
|
372
|
+
* Safari was hitting as `Api`. It also makes the six runtimes AGREE about a
|
|
373
|
+
* malformed URL, which they did not before: the previous rule tested the
|
|
374
|
+
* message for "fetch", and Chromium's and Firefox's URL complaints both
|
|
375
|
+
* contain it, so the same mistake was `Network` on three engines and `Api` on
|
|
376
|
+
* three.
|
|
377
|
+
*
|
|
378
|
+
* **workerd is the recorded gap.** It rejects with a plain `Error`, no code
|
|
379
|
+
* and no shared sentence — and its two failure modes produce two unrelated
|
|
380
|
+
* ones — so nothing here can classify it and it lands on `Api`. Left alone
|
|
381
|
+
* rather than patched with a dialect string: the one consumer running ship in
|
|
382
|
+
* that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,
|
|
383
|
+
* which is in-process and does not produce transport rejections at all.
|
|
384
|
+
*
|
|
385
|
+
* The accepted trade is unchanged: a caller's `TokenProvider` that throws a
|
|
386
|
+
* coded error (`ENOENT` from a keychain read) is typed `Network` rather than
|
|
387
|
+
* `Api`. Both are wrong for it; `Network` is the cheaper wrong.
|
|
367
388
|
*/
|
|
368
389
|
function isTransportFailure(cause) {
|
|
369
|
-
|
|
390
|
+
const code = cause.code;
|
|
391
|
+
// Bun is the one runtime that puts a CODE on an argument error, so it is
|
|
392
|
+
// excluded before the code arm can claim it.
|
|
393
|
+
if (code === 'ERR_INVALID_URL')
|
|
394
|
+
return false;
|
|
395
|
+
// A string `code` is a runtime naming a transport-level failure. An
|
|
396
|
+
// allowlist of codes was written first and rejected — the TLS row alone
|
|
397
|
+
// would mean enumerating BoringSSL's certificate table, and a code nobody
|
|
398
|
+
// guessed is precisely the bug this closes. A `DOMException`'s code is a
|
|
399
|
+
// NUMBER, so aborts and timeouts never reach here.
|
|
400
|
+
if (typeof code === 'string')
|
|
370
401
|
return true;
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
402
|
+
// WHATWG has fetch reject with a TypeError for BOTH halves — network error
|
|
403
|
+
// and argument error — so among TypeErrors the URL is the discriminator.
|
|
404
|
+
if (cause instanceof TypeError)
|
|
405
|
+
return !/\burl\b/i.test(cause.message);
|
|
406
|
+
// Anything else — an ordinary JS fault, or workerd — is not evidence.
|
|
407
|
+
return false;
|
|
375
408
|
}
|
|
376
409
|
/**
|
|
377
410
|
* Simple unified error class for both API and SDK
|
|
@@ -497,12 +530,30 @@ export class ShipError extends Error {
|
|
|
497
530
|
*
|
|
498
531
|
* Routing:
|
|
499
532
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
500
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
533
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
534
|
+
* - `TimeoutError` → `ShipError.network(...)` — a deadline expired, so
|
|
535
|
+
* nothing was exchanged; the message names the timeout
|
|
501
536
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
502
537
|
* for what each runtime offers as evidence
|
|
503
538
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
504
539
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
505
540
|
*
|
|
541
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
542
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
543
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
544
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
545
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
546
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
547
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
548
|
+
* produces one.
|
|
549
|
+
*
|
|
550
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
551
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
552
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
553
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
554
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
555
|
+
* there, since the caller's signal is what stopped it.
|
|
556
|
+
*
|
|
506
557
|
* The optional `operationName` is composed into the message for context:
|
|
507
558
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
508
559
|
* `"Request"` when omitted.
|
|
@@ -511,10 +562,19 @@ export class ShipError extends Error {
|
|
|
511
562
|
if (isShipError(cause))
|
|
512
563
|
return cause;
|
|
513
564
|
const op = operationName || 'Request';
|
|
565
|
+
// Read by NAME, ahead of the Error gate — see the note above.
|
|
566
|
+
const name = cause?.name;
|
|
567
|
+
if (name === 'AbortError') {
|
|
568
|
+
return ShipError.cancelled(`${op} was cancelled`);
|
|
569
|
+
}
|
|
570
|
+
if (name === 'TimeoutError') {
|
|
571
|
+
// A deadline, not a fault and not a cancellation: nothing was exchanged,
|
|
572
|
+
// which is exactly what `Network` claims. The runtime's own sentence is
|
|
573
|
+
// dropped here rather than relayed — "The operation was aborted due to
|
|
574
|
+
// timeout" is the mechanism, not the news.
|
|
575
|
+
return ShipError.network(`${op} timed out`, { cause });
|
|
576
|
+
}
|
|
514
577
|
if (cause instanceof Error) {
|
|
515
|
-
if (cause.name === 'AbortError') {
|
|
516
|
-
return ShipError.cancelled(`${op} was cancelled`);
|
|
517
|
-
}
|
|
518
578
|
if (isTransportFailure(cause)) {
|
|
519
579
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
520
580
|
}
|
|
@@ -633,80 +693,81 @@ export function isShipError(error) {
|
|
|
633
693
|
'status' in error);
|
|
634
694
|
}
|
|
635
695
|
// =============================================================================
|
|
636
|
-
// EXTENSION
|
|
696
|
+
// EXTENSION MATCHING
|
|
637
697
|
// =============================================================================
|
|
638
698
|
/**
|
|
639
|
-
*
|
|
699
|
+
* The rule for reading a file's extension: lowercase, after the last dot of
|
|
700
|
+
* the last path segment. `null` when there is no extension to read.
|
|
701
|
+
*
|
|
702
|
+
* A leading dot names the file rather than its type, so `.gitignore` and
|
|
703
|
+
* `.htaccess` have no extension — but `.env.exe` has `exe`.
|
|
704
|
+
*
|
|
705
|
+
* Segment-aware on purpose: the callers pass deploy PATHS, not basenames, and
|
|
706
|
+
* a naive `lastIndexOf('.')` over `dir.v1/README` reads the extension
|
|
707
|
+
* `v1/README` — safe only by accident, since no entry in a real blocklist
|
|
708
|
+
* contains a slash, which is the kind of correctness nobody should have to
|
|
709
|
+
* re-derive.
|
|
640
710
|
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
711
|
+
* **Private, and the reason is the asymmetry rather than any hazard.** Nothing
|
|
712
|
+
* outside this file reads it: `isBlockedExtension` is the only question anyone
|
|
713
|
+
* asks, and the API's refusal names the FILE, not its extension. Exporting a
|
|
714
|
+
* pure function is harmless, which is exactly the argument that talks a
|
|
715
|
+
* published package into surface it has not earned — and the costs do not
|
|
716
|
+
* match, since adding an export later is free under the additive law while
|
|
717
|
+
* removing one is a major. So it stays private until a caller exists. Its
|
|
718
|
+
* behaviour is fenced through `isBlockedExtension`, which is where it is
|
|
719
|
+
* observable.
|
|
644
720
|
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
721
|
+
* (`WEB_FILE_EXTENSIONS` above is private for a different reason — publishing
|
|
722
|
+
* it would invite a wrong question. Both are private; only one is a hazard.)
|
|
723
|
+
*
|
|
724
|
+
* @example
|
|
725
|
+
* fileExtension('virus.exe') // 'exe'
|
|
726
|
+
* fileExtension('assets/style.CSS') // 'css'
|
|
727
|
+
* fileExtension('dir.v1/README') // null
|
|
728
|
+
* fileExtension('.gitignore') // null
|
|
729
|
+
* fileExtension('file.') // null
|
|
647
730
|
*/
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
'
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
'cmd',
|
|
656
|
-
'com',
|
|
657
|
-
'pif',
|
|
658
|
-
'app',
|
|
659
|
-
'deb',
|
|
660
|
-
'rpm',
|
|
661
|
-
// Installers
|
|
662
|
-
'pkg',
|
|
663
|
-
'mpkg',
|
|
664
|
-
// Disk images
|
|
665
|
-
'dmg',
|
|
666
|
-
'iso',
|
|
667
|
-
'img',
|
|
668
|
-
// Malware vectors
|
|
669
|
-
'cab',
|
|
670
|
-
'cpl',
|
|
671
|
-
'chm',
|
|
672
|
-
// Dangerous scripts
|
|
673
|
-
'ps1',
|
|
674
|
-
'vbs',
|
|
675
|
-
'vbe',
|
|
676
|
-
'ws',
|
|
677
|
-
'wsf',
|
|
678
|
-
'wsc',
|
|
679
|
-
'wsh',
|
|
680
|
-
'reg',
|
|
681
|
-
// Java
|
|
682
|
-
'jar',
|
|
683
|
-
'jnlp',
|
|
684
|
-
// Mobile/browser packages
|
|
685
|
-
'apk',
|
|
686
|
-
'crx',
|
|
687
|
-
// Shortcut/link
|
|
688
|
-
'lnk',
|
|
689
|
-
'inf',
|
|
690
|
-
'hta',
|
|
691
|
-
]);
|
|
731
|
+
function fileExtension(filename) {
|
|
732
|
+
const basename = filename.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
733
|
+
const dotIndex = basename.lastIndexOf('.');
|
|
734
|
+
if (dotIndex <= 0 || dotIndex === basename.length - 1)
|
|
735
|
+
return null;
|
|
736
|
+
return basename.slice(dotIndex + 1).toLowerCase();
|
|
737
|
+
}
|
|
692
738
|
/**
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
739
|
+
* Whether a file is one the platform refuses to host.
|
|
740
|
+
*
|
|
741
|
+
* **The list is not this package's, and that separation is the point.** What
|
|
742
|
+
* counts as a blocked extension is hosting POLICY — it evolves, it is enforced
|
|
743
|
+
* at one security boundary, and `virus.exe` is a perfectly well-formed
|
|
744
|
+
* filename that breaks nothing about the upload→serve round-trip. So the API
|
|
745
|
+
* owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as
|
|
746
|
+
* `PlatformLimits.blockedExtensions`; a client passes what it was given.
|
|
747
|
+
*
|
|
748
|
+
* What lives here is the MATCHING RULE, and it earns its place by the
|
|
749
|
+
* constellation law's own test. The list's drift is loud in both directions —
|
|
750
|
+
* a stale client uploads a file the API refuses by name, on the first try.
|
|
751
|
+
* A second *matcher* drifts SILENTLY in the one direction that matters: a
|
|
752
|
+
* client stricter than the server refuses a legal file without the server ever
|
|
753
|
+
* being asked, and no error names it. Two holders, silent drift, one owner.
|
|
754
|
+
*
|
|
755
|
+
* The `blocked` collection is required rather than defaulted: this predicate
|
|
756
|
+
* guards a security boundary in the API, and a defaulted-empty argument there
|
|
757
|
+
* would block nothing while reading as though it did. Callers holding a
|
|
758
|
+
* possibly-absent wire field spell the fail-open themselves.
|
|
696
759
|
*
|
|
697
760
|
* @example
|
|
698
|
-
* isBlockedExtension('virus.exe')
|
|
699
|
-
* isBlockedExtension('
|
|
700
|
-
* isBlockedExtension('style.css')
|
|
701
|
-
* isBlockedExtension('
|
|
702
|
-
* isBlockedExtension('README') // false
|
|
761
|
+
* isBlockedExtension('virus.exe', ['exe']) // true
|
|
762
|
+
* isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive
|
|
763
|
+
* isBlockedExtension('style.css', ['exe']) // false
|
|
764
|
+
* isBlockedExtension('README', ['exe']) // false — no extension
|
|
703
765
|
*/
|
|
704
|
-
export function isBlockedExtension(filename) {
|
|
705
|
-
const
|
|
706
|
-
if (
|
|
766
|
+
export function isBlockedExtension(filename, blocked) {
|
|
767
|
+
const ext = fileExtension(filename);
|
|
768
|
+
if (ext === null)
|
|
707
769
|
return false;
|
|
708
|
-
|
|
709
|
-
return BLOCKED_EXTENSIONS.has(ext);
|
|
770
|
+
return Array.isArray(blocked) ? blocked.includes(ext) : blocked.has(ext);
|
|
710
771
|
}
|
|
711
772
|
// =============================================================================
|
|
712
773
|
// PICKER ACCEPT HINT
|
|
@@ -808,10 +869,10 @@ const WEB_FILE_EXTENSIONS = [
|
|
|
808
869
|
/**
|
|
809
870
|
* The `accept` attribute value for a browser file picker offering web files.
|
|
810
871
|
*
|
|
811
|
-
* **This is a hint, never a rule.**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
872
|
+
* **This is a hint, never a rule.** The API's blocklist is the platform's gate
|
|
873
|
+
* and the only thing that decides what may be hosted; this constant decides
|
|
874
|
+
* what a *file dialog* shows first. The two are not two halves of one policy,
|
|
875
|
+
* and this one must never be consulted to accept or reject a file.
|
|
815
876
|
*
|
|
816
877
|
* The distinction is structural, not stylistic. `accept` can express only an
|
|
817
878
|
* allowlist, while the platform's rule is a blocklist — so this list is
|
|
@@ -822,9 +883,12 @@ const WEB_FILE_EXTENSIONS = [
|
|
|
822
883
|
* dropzone and the picker must reach the same verdict on the same files, and
|
|
823
884
|
* they do — because the verdict is `validateFiles`, downstream of both.
|
|
824
885
|
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
886
|
+
* The invariant that matters — the picker must never offer a file the platform
|
|
887
|
+
* will refuse — is fenced where the authority lives, in the API's own suite
|
|
888
|
+
* (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published
|
|
889
|
+
* string and holds it against the list it owns. It sat here until the
|
|
890
|
+
* blocklist became the API's, and moving it was the price of that: a fence
|
|
891
|
+
* belongs with whichever side can change and break it.
|
|
828
892
|
*/
|
|
829
893
|
export const WEB_FILE_ACCEPT = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
|
|
830
894
|
// =============================================================================
|
|
@@ -887,27 +951,34 @@ export function hasUnbuiltMarker(filePath) {
|
|
|
887
951
|
// the single dispatch over them (TokenKind, classifyToken), and the
|
|
888
952
|
// delegated-access scopes (OAuthScope).
|
|
889
953
|
//
|
|
890
|
-
// THE SHAPE LAW, in three clauses
|
|
891
|
-
//
|
|
954
|
+
// THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's
|
|
955
|
+
// two populations below. The deployment claim code is the API's own
|
|
956
|
+
// (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so
|
|
957
|
+
// it has one holder and stays there) and shares only clause 1 — it is the
|
|
958
|
+
// platform's one deliberately BARE secret, because it never enters the
|
|
959
|
+
// Bearer slot: minted into one URL, consumed by one endpoint's one field,
|
|
960
|
+
// its context names it and a prefix would restate its route.
|
|
961
|
+
// `tests/validation-constants.test.ts` holds the clauses over this file's
|
|
962
|
+
// populations; the API's suite holds its own.
|
|
892
963
|
//
|
|
893
|
-
// 1. ONE ENTROPY STANDARD. Every minted
|
|
894
|
-
//
|
|
895
|
-
//
|
|
964
|
+
// 1. ONE ENTROPY STANDARD. Every minted secret is `HEX_LENGTH` hex characters
|
|
965
|
+
// — one width for the whole platform, so "how long is a credential" has a
|
|
966
|
+
// single answer rather than one per population. Generators read the width
|
|
967
|
+
// from the population's own constant, so a minted value and an accepted
|
|
968
|
+
// value cannot differ.
|
|
896
969
|
//
|
|
897
|
-
// 2.
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
// route in its own field, inside a URL whose path already says `/claim/`,
|
|
903
|
-
// so a prefix there would be a second name for what the route states.
|
|
970
|
+
// 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what
|
|
971
|
+
// it is before anything parses it — which is what lets `classifyToken`
|
|
972
|
+
// below dispatch two populations sharing one `Authorization: Bearer`
|
|
973
|
+
// slot, and what lets a value found in a log, a support ticket or a
|
|
974
|
+
// pasted URL be recognised and revoked on sight.
|
|
904
975
|
//
|
|
905
976
|
// 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
|
|
906
977
|
// order-independent, and it is the reason the populations are named on
|
|
907
978
|
// different axes (`ship-` for the product, `deploy-` for the capability)
|
|
908
|
-
// rather than sharing a stem. A `ship-` / `ship-deploy-` pair
|
|
909
|
-
// and is a trap: every deploy token would also match the
|
|
910
|
-
// leaving correctness resting on the order of two `if`s.
|
|
979
|
+
// rather than sharing a stem. A `ship-` / `ship-deploy-` pair
|
|
980
|
+
// reads tidier and is a trap: every deploy token would also match the
|
|
981
|
+
// API-key branch, leaving correctness resting on the order of two `if`s.
|
|
911
982
|
/**
|
|
912
983
|
* Where human identity is mounted on the API host. The API mounts Better
|
|
913
984
|
* Auth at this path (sign-in, sign-out, session reads, admin impersonation)
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -893,37 +893,72 @@ const MAX_FOREIGN_MESSAGE_LENGTH = 200;
|
|
|
893
893
|
/**
|
|
894
894
|
* Did the runtime say the exchange never completed?
|
|
895
895
|
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
* |
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
* The
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
896
|
+
* Clients branch on the TYPE, never on message strings, so a misclassified
|
|
897
|
+
* transport failure is a lie every consumer inherits — and the one that costs
|
|
898
|
+
* most: `Api` claims a server answered when nothing was exchanged, and a
|
|
899
|
+
* retrying caller will not retry it.
|
|
900
|
+
*
|
|
901
|
+
* **Every row below is a transcript, not a belief.** Captured 2026-08-12
|
|
902
|
+
* against real runtimes — Node and Bun by direct run, the three engines by a
|
|
903
|
+
* one-off playwright probe, workerd through miniflare. The capture scripts are
|
|
904
|
+
* in `tests/errors.test.ts`, "runtime failure shapes".
|
|
905
|
+
*
|
|
906
|
+
* | runtime | connection refused / DNS failure | malformed URL |
|
|
907
|
+
* |------------------|------------------------------------------------------|--------------------------------------------------|
|
|
908
|
+
* | Node 22 / undici | `TypeError: fetch failed` | `TypeError: Failed to parse URL from …` |
|
|
909
|
+
* | Bun 1.3.14 | `Error` `code:'ConnectionRefused'` | `TypeError` `code:'ERR_INVALID_URL'` |
|
|
910
|
+
* | Chromium 151 | `TypeError: Failed to fetch` | `TypeError: …Failed to parse URL from …` |
|
|
911
|
+
* | Firefox 153 | `TypeError: NetworkError when attempting to fetch …` | `TypeError: … is not a valid URL.` |
|
|
912
|
+
* | WebKit 26.5 | `TypeError: Load failed` | `TypeError: URL is not valid or contains user …` |
|
|
913
|
+
* | workerd | `Error: Network connection lost.` (DNS: `internal error; reference = …`) | `TypeError: Invalid URL: …` |
|
|
914
|
+
*
|
|
915
|
+
* Reading that table gives the rule, and it is the INVERSE of the obvious one.
|
|
916
|
+
* The transport class is unbounded — every OS, TLS and DNS failure any engine
|
|
917
|
+
* will ever name — while the class fetch raises for its own ARGUMENTS is
|
|
918
|
+
* small, and every runtime names the URL when it complains about one. So the
|
|
919
|
+
* bounded side is the one worth testing, and the residual risk points the safe
|
|
920
|
+
* way: an unrecognised sentence lands on `Network`, which says only that
|
|
921
|
+
* nothing was exchanged.
|
|
922
|
+
*
|
|
923
|
+
* That inversion is what fixes **WebKit**, whose `Load failed` carries no code
|
|
924
|
+
* and no "fetch", and which every browser-SDK and `@shipstatic/drop` user on
|
|
925
|
+
* Safari was hitting as `Api`. It also makes the six runtimes AGREE about a
|
|
926
|
+
* malformed URL, which they did not before: the previous rule tested the
|
|
927
|
+
* message for "fetch", and Chromium's and Firefox's URL complaints both
|
|
928
|
+
* contain it, so the same mistake was `Network` on three engines and `Api` on
|
|
929
|
+
* three.
|
|
930
|
+
*
|
|
931
|
+
* **workerd is the recorded gap.** It rejects with a plain `Error`, no code
|
|
932
|
+
* and no shared sentence — and its two failure modes produce two unrelated
|
|
933
|
+
* ones — so nothing here can classify it and it lands on `Api`. Left alone
|
|
934
|
+
* rather than patched with a dialect string: the one consumer running ship in
|
|
935
|
+
* that runtime (`cloudflare/mcp`) reaches the API through a service BINDING,
|
|
936
|
+
* which is in-process and does not produce transport rejections at all.
|
|
937
|
+
*
|
|
938
|
+
* The accepted trade is unchanged: a caller's `TokenProvider` that throws a
|
|
939
|
+
* coded error (`ENOENT` from a keychain read) is typed `Network` rather than
|
|
940
|
+
* `Api`. Both are wrong for it; `Network` is the cheaper wrong.
|
|
920
941
|
*/
|
|
921
942
|
function isTransportFailure(cause: Error): boolean {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
943
|
+
const code = (cause as { code?: unknown }).code;
|
|
944
|
+
|
|
945
|
+
// Bun is the one runtime that puts a CODE on an argument error, so it is
|
|
946
|
+
// excluded before the code arm can claim it.
|
|
947
|
+
if (code === 'ERR_INVALID_URL') return false;
|
|
948
|
+
|
|
949
|
+
// A string `code` is a runtime naming a transport-level failure. An
|
|
950
|
+
// allowlist of codes was written first and rejected — the TLS row alone
|
|
951
|
+
// would mean enumerating BoringSSL's certificate table, and a code nobody
|
|
952
|
+
// guessed is precisely the bug this closes. A `DOMException`'s code is a
|
|
953
|
+
// NUMBER, so aborts and timeouts never reach here.
|
|
954
|
+
if (typeof code === 'string') return true;
|
|
955
|
+
|
|
956
|
+
// WHATWG has fetch reject with a TypeError for BOTH halves — network error
|
|
957
|
+
// and argument error — so among TypeErrors the URL is the discriminator.
|
|
958
|
+
if (cause instanceof TypeError) return !/\burl\b/i.test(cause.message);
|
|
959
|
+
|
|
960
|
+
// Anything else — an ordinary JS fault, or workerd — is not evidence.
|
|
961
|
+
return false;
|
|
927
962
|
}
|
|
928
963
|
|
|
929
964
|
/**
|
|
@@ -1071,12 +1106,30 @@ export class ShipError extends Error {
|
|
|
1071
1106
|
*
|
|
1072
1107
|
* Routing:
|
|
1073
1108
|
* - Already a `ShipError` → returned as-is (caller's intent preserved)
|
|
1074
|
-
* - `AbortError` → `ShipError.cancelled(...)`
|
|
1109
|
+
* - `AbortError` → `ShipError.cancelled(...)` — someone stopped it on purpose
|
|
1110
|
+
* - `TimeoutError` → `ShipError.network(...)` — a deadline expired, so
|
|
1111
|
+
* nothing was exchanged; the message names the timeout
|
|
1075
1112
|
* - A transport failure → `ShipError.network(...)` — see `isTransportFailure`
|
|
1076
1113
|
* for what each runtime offers as evidence
|
|
1077
1114
|
* - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)
|
|
1078
1115
|
* - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`
|
|
1079
1116
|
*
|
|
1117
|
+
* **Abort and timeout are read from `name` BEFORE any `instanceof Error`
|
|
1118
|
+
* gate.** A `DOMException` satisfies that gate in every runtime measured
|
|
1119
|
+
* (Node, Bun, Chromium, Firefox, WebKit, workerd — all six), but the
|
|
1120
|
+
* inheritance is a comparatively recent spec change and this classification
|
|
1121
|
+
* has no reason to depend on it: `name` is where the meaning lives, and
|
|
1122
|
+
* reading it first costs nothing. The suite plants a non-`Error`
|
|
1123
|
+
* `DOMException` shape to hold the arm, since no runtime on the table
|
|
1124
|
+
* produces one.
|
|
1125
|
+
*
|
|
1126
|
+
* A caller's own `AbortSignal.timeout()` is the reachable source of
|
|
1127
|
+
* `TimeoutError` — and the two are NOT interchangeable per runtime: WebKit
|
|
1128
|
+
* reports a fired `AbortSignal.timeout()` as `AbortError`, so on Safari a
|
|
1129
|
+
* deadline is indistinguishable from a cancellation and lands on
|
|
1130
|
+
* `Cancelled`. Recorded rather than worked around; `Cancelled` is honest
|
|
1131
|
+
* there, since the caller's signal is what stopped it.
|
|
1132
|
+
*
|
|
1080
1133
|
* The optional `operationName` is composed into the message for context:
|
|
1081
1134
|
* `"Get account was cancelled"`, `"Get account failed: ..."`. Defaults to
|
|
1082
1135
|
* `"Request"` when omitted.
|
|
@@ -1086,10 +1139,20 @@ export class ShipError extends Error {
|
|
|
1086
1139
|
|
|
1087
1140
|
const op = operationName || 'Request';
|
|
1088
1141
|
|
|
1142
|
+
// Read by NAME, ahead of the Error gate — see the note above.
|
|
1143
|
+
const name = (cause as { name?: unknown } | null | undefined)?.name;
|
|
1144
|
+
if (name === 'AbortError') {
|
|
1145
|
+
return ShipError.cancelled(`${op} was cancelled`);
|
|
1146
|
+
}
|
|
1147
|
+
if (name === 'TimeoutError') {
|
|
1148
|
+
// A deadline, not a fault and not a cancellation: nothing was exchanged,
|
|
1149
|
+
// which is exactly what `Network` claims. The runtime's own sentence is
|
|
1150
|
+
// dropped here rather than relayed — "The operation was aborted due to
|
|
1151
|
+
// timeout" is the mechanism, not the news.
|
|
1152
|
+
return ShipError.network(`${op} timed out`, { cause });
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1089
1155
|
if (cause instanceof Error) {
|
|
1090
|
-
if (cause.name === 'AbortError') {
|
|
1091
|
-
return ShipError.cancelled(`${op} was cancelled`);
|
|
1092
|
-
}
|
|
1093
1156
|
if (isTransportFailure(cause)) {
|
|
1094
1157
|
return ShipError.network(`${op} failed: ${cause.message}`, { cause });
|
|
1095
1158
|
}
|
|
@@ -1234,14 +1297,17 @@ export function isShipError(error: unknown): error is ShipError {
|
|
|
1234
1297
|
// =============================================================================
|
|
1235
1298
|
|
|
1236
1299
|
/**
|
|
1237
|
-
*
|
|
1300
|
+
* What the platform will refuse, returned by the `/limits` endpoint.
|
|
1238
1301
|
*
|
|
1239
|
-
* The SDK fetches
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1302
|
+
* The SDK fetches this once on first API call to drive client-side validation
|
|
1303
|
+
* that mirrors what the API would enforce server-side. The caps vary by
|
|
1304
|
+
* account plan; the blocklist does not.
|
|
1242
1305
|
*
|
|
1243
|
-
* These are the *platform's* posted
|
|
1244
|
-
* truth delivered at runtime, never hard-coded on the client.
|
|
1306
|
+
* These are the *platform's* posted rules for the current account — server
|
|
1307
|
+
* truth delivered at runtime, never hard-coded on the client. That is the
|
|
1308
|
+
* whole point of the shape: a rule the server owns and may change reaches the
|
|
1309
|
+
* client as data, so a pinned client cannot enforce a policy the platform has
|
|
1310
|
+
* moved on from (`npm/types/CLAUDE.md`, "Validation: format vs policy").
|
|
1245
1311
|
*
|
|
1246
1312
|
* A report: it answers a question and carries only the answer (`CLAUDE.md`,
|
|
1247
1313
|
* "A report answers a question").
|
|
@@ -1253,84 +1319,105 @@ export interface PlatformLimits {
|
|
|
1253
1319
|
maxFilesCount: number;
|
|
1254
1320
|
/** Maximum total size in bytes across all files in a deployment. */
|
|
1255
1321
|
maxTotalSize: number;
|
|
1322
|
+
/**
|
|
1323
|
+
* Lowercase extensions, without the dot, that the platform refuses to host
|
|
1324
|
+
* (`exe`, `dmg`, …). Owned and evolved by the API — see
|
|
1325
|
+
* `cloudflare/api/src/lib/blocklist.ts`.
|
|
1326
|
+
*
|
|
1327
|
+
* **Optional, and the absence is load-bearing.** An API deployed before this
|
|
1328
|
+
* field existed sends nothing, so a client MUST read absence as "no
|
|
1329
|
+
* client-side check" rather than as an empty policy. The hint fails open,
|
|
1330
|
+
* the boundary fails closed: the server refuses the file either way, and a
|
|
1331
|
+
* client that guessed would only ever be wrong in the direction that refuses
|
|
1332
|
+
* a file the platform accepts.
|
|
1333
|
+
*
|
|
1334
|
+
* The optionality follows the additive-evolution law and retires with its
|
|
1335
|
+
* reason: once every environment serves the field, it hardens to required at
|
|
1336
|
+
* the entity's next natural break, and the clients' fail-open spellings
|
|
1337
|
+
* retire with it (tracked in root `backlog.md`).
|
|
1338
|
+
*/
|
|
1339
|
+
readonly blockedExtensions?: readonly string[];
|
|
1256
1340
|
}
|
|
1257
1341
|
|
|
1258
1342
|
// =============================================================================
|
|
1259
|
-
// EXTENSION
|
|
1343
|
+
// EXTENSION MATCHING
|
|
1260
1344
|
// =============================================================================
|
|
1261
1345
|
|
|
1262
1346
|
/**
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
'
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
'wsf',
|
|
1302
|
-
'wsc',
|
|
1303
|
-
'wsh',
|
|
1304
|
-
'reg',
|
|
1305
|
-
// Java
|
|
1306
|
-
'jar',
|
|
1307
|
-
'jnlp',
|
|
1308
|
-
// Mobile/browser packages
|
|
1309
|
-
'apk',
|
|
1310
|
-
'crx',
|
|
1311
|
-
// Shortcut/link
|
|
1312
|
-
'lnk',
|
|
1313
|
-
'inf',
|
|
1314
|
-
'hta',
|
|
1315
|
-
]);
|
|
1347
|
+
* The rule for reading a file's extension: lowercase, after the last dot of
|
|
1348
|
+
* the last path segment. `null` when there is no extension to read.
|
|
1349
|
+
*
|
|
1350
|
+
* A leading dot names the file rather than its type, so `.gitignore` and
|
|
1351
|
+
* `.htaccess` have no extension — but `.env.exe` has `exe`.
|
|
1352
|
+
*
|
|
1353
|
+
* Segment-aware on purpose: the callers pass deploy PATHS, not basenames, and
|
|
1354
|
+
* a naive `lastIndexOf('.')` over `dir.v1/README` reads the extension
|
|
1355
|
+
* `v1/README` — safe only by accident, since no entry in a real blocklist
|
|
1356
|
+
* contains a slash, which is the kind of correctness nobody should have to
|
|
1357
|
+
* re-derive.
|
|
1358
|
+
*
|
|
1359
|
+
* **Private, and the reason is the asymmetry rather than any hazard.** Nothing
|
|
1360
|
+
* outside this file reads it: `isBlockedExtension` is the only question anyone
|
|
1361
|
+
* asks, and the API's refusal names the FILE, not its extension. Exporting a
|
|
1362
|
+
* pure function is harmless, which is exactly the argument that talks a
|
|
1363
|
+
* published package into surface it has not earned — and the costs do not
|
|
1364
|
+
* match, since adding an export later is free under the additive law while
|
|
1365
|
+
* removing one is a major. So it stays private until a caller exists. Its
|
|
1366
|
+
* behaviour is fenced through `isBlockedExtension`, which is where it is
|
|
1367
|
+
* observable.
|
|
1368
|
+
*
|
|
1369
|
+
* (`WEB_FILE_EXTENSIONS` above is private for a different reason — publishing
|
|
1370
|
+
* it would invite a wrong question. Both are private; only one is a hazard.)
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* fileExtension('virus.exe') // 'exe'
|
|
1374
|
+
* fileExtension('assets/style.CSS') // 'css'
|
|
1375
|
+
* fileExtension('dir.v1/README') // null
|
|
1376
|
+
* fileExtension('.gitignore') // null
|
|
1377
|
+
* fileExtension('file.') // null
|
|
1378
|
+
*/
|
|
1379
|
+
function fileExtension(filename: string): string | null {
|
|
1380
|
+
const basename = filename.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
1381
|
+
const dotIndex = basename.lastIndexOf('.');
|
|
1382
|
+
if (dotIndex <= 0 || dotIndex === basename.length - 1) return null;
|
|
1383
|
+
return basename.slice(dotIndex + 1).toLowerCase();
|
|
1384
|
+
}
|
|
1316
1385
|
|
|
1317
1386
|
/**
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1387
|
+
* Whether a file is one the platform refuses to host.
|
|
1388
|
+
*
|
|
1389
|
+
* **The list is not this package's, and that separation is the point.** What
|
|
1390
|
+
* counts as a blocked extension is hosting POLICY — it evolves, it is enforced
|
|
1391
|
+
* at one security boundary, and `virus.exe` is a perfectly well-formed
|
|
1392
|
+
* filename that breaks nothing about the upload→serve round-trip. So the API
|
|
1393
|
+
* owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as
|
|
1394
|
+
* `PlatformLimits.blockedExtensions`; a client passes what it was given.
|
|
1395
|
+
*
|
|
1396
|
+
* What lives here is the MATCHING RULE, and it earns its place by the
|
|
1397
|
+
* constellation law's own test. The list's drift is loud in both directions —
|
|
1398
|
+
* a stale client uploads a file the API refuses by name, on the first try.
|
|
1399
|
+
* A second *matcher* drifts SILENTLY in the one direction that matters: a
|
|
1400
|
+
* client stricter than the server refuses a legal file without the server ever
|
|
1401
|
+
* being asked, and no error names it. Two holders, silent drift, one owner.
|
|
1402
|
+
*
|
|
1403
|
+
* The `blocked` collection is required rather than defaulted: this predicate
|
|
1404
|
+
* guards a security boundary in the API, and a defaulted-empty argument there
|
|
1405
|
+
* would block nothing while reading as though it did. Callers holding a
|
|
1406
|
+
* possibly-absent wire field spell the fail-open themselves.
|
|
1321
1407
|
*
|
|
1322
1408
|
* @example
|
|
1323
|
-
* isBlockedExtension('virus.exe')
|
|
1324
|
-
* isBlockedExtension('
|
|
1325
|
-
* isBlockedExtension('style.css')
|
|
1326
|
-
* isBlockedExtension('
|
|
1327
|
-
* isBlockedExtension('README') // false
|
|
1409
|
+
* isBlockedExtension('virus.exe', ['exe']) // true
|
|
1410
|
+
* isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive
|
|
1411
|
+
* isBlockedExtension('style.css', ['exe']) // false
|
|
1412
|
+
* isBlockedExtension('README', ['exe']) // false — no extension
|
|
1328
1413
|
*/
|
|
1329
|
-
export function isBlockedExtension(
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1414
|
+
export function isBlockedExtension(
|
|
1415
|
+
filename: string,
|
|
1416
|
+
blocked: ReadonlySet<string> | readonly string[],
|
|
1417
|
+
): boolean {
|
|
1418
|
+
const ext = fileExtension(filename);
|
|
1419
|
+
if (ext === null) return false;
|
|
1420
|
+
return Array.isArray(blocked) ? blocked.includes(ext) : (blocked as ReadonlySet<string>).has(ext);
|
|
1334
1421
|
}
|
|
1335
1422
|
|
|
1336
1423
|
// =============================================================================
|
|
@@ -1435,10 +1522,10 @@ const WEB_FILE_EXTENSIONS = [
|
|
|
1435
1522
|
/**
|
|
1436
1523
|
* The `accept` attribute value for a browser file picker offering web files.
|
|
1437
1524
|
*
|
|
1438
|
-
* **This is a hint, never a rule.**
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1525
|
+
* **This is a hint, never a rule.** The API's blocklist is the platform's gate
|
|
1526
|
+
* and the only thing that decides what may be hosted; this constant decides
|
|
1527
|
+
* what a *file dialog* shows first. The two are not two halves of one policy,
|
|
1528
|
+
* and this one must never be consulted to accept or reject a file.
|
|
1442
1529
|
*
|
|
1443
1530
|
* The distinction is structural, not stylistic. `accept` can express only an
|
|
1444
1531
|
* allowlist, while the platform's rule is a blocklist — so this list is
|
|
@@ -1449,9 +1536,12 @@ const WEB_FILE_EXTENSIONS = [
|
|
|
1449
1536
|
* dropzone and the picker must reach the same verdict on the same files, and
|
|
1450
1537
|
* they do — because the verdict is `validateFiles`, downstream of both.
|
|
1451
1538
|
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1539
|
+
* The invariant that matters — the picker must never offer a file the platform
|
|
1540
|
+
* will refuse — is fenced where the authority lives, in the API's own suite
|
|
1541
|
+
* (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published
|
|
1542
|
+
* string and holds it against the list it owns. It sat here until the
|
|
1543
|
+
* blocklist became the API's, and moving it was the price of that: a fence
|
|
1544
|
+
* belongs with whichever side can change and break it.
|
|
1455
1545
|
*/
|
|
1456
1546
|
export const WEB_FILE_ACCEPT: string = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
|
|
1457
1547
|
|
|
@@ -1542,27 +1632,34 @@ export interface PingResponse {
|
|
|
1542
1632
|
// the single dispatch over them (TokenKind, classifyToken), and the
|
|
1543
1633
|
// delegated-access scopes (OAuthScope).
|
|
1544
1634
|
//
|
|
1545
|
-
// THE SHAPE LAW, in three clauses
|
|
1546
|
-
//
|
|
1635
|
+
// THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's
|
|
1636
|
+
// two populations below. The deployment claim code is the API's own
|
|
1637
|
+
// (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so
|
|
1638
|
+
// it has one holder and stays there) and shares only clause 1 — it is the
|
|
1639
|
+
// platform's one deliberately BARE secret, because it never enters the
|
|
1640
|
+
// Bearer slot: minted into one URL, consumed by one endpoint's one field,
|
|
1641
|
+
// its context names it and a prefix would restate its route.
|
|
1642
|
+
// `tests/validation-constants.test.ts` holds the clauses over this file's
|
|
1643
|
+
// populations; the API's suite holds its own.
|
|
1547
1644
|
//
|
|
1548
|
-
// 1. ONE ENTROPY STANDARD. Every minted
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1645
|
+
// 1. ONE ENTROPY STANDARD. Every minted secret is `HEX_LENGTH` hex characters
|
|
1646
|
+
// — one width for the whole platform, so "how long is a credential" has a
|
|
1647
|
+
// single answer rather than one per population. Generators read the width
|
|
1648
|
+
// from the population's own constant, so a minted value and an accepted
|
|
1649
|
+
// value cannot differ.
|
|
1551
1650
|
//
|
|
1552
|
-
// 2.
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
// route in its own field, inside a URL whose path already says `/claim/`,
|
|
1558
|
-
// so a prefix there would be a second name for what the route states.
|
|
1651
|
+
// 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what
|
|
1652
|
+
// it is before anything parses it — which is what lets `classifyToken`
|
|
1653
|
+
// below dispatch two populations sharing one `Authorization: Bearer`
|
|
1654
|
+
// slot, and what lets a value found in a log, a support ticket or a
|
|
1655
|
+
// pasted URL be recognised and revoked on sight.
|
|
1559
1656
|
//
|
|
1560
1657
|
// 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
|
|
1561
1658
|
// order-independent, and it is the reason the populations are named on
|
|
1562
1659
|
// different axes (`ship-` for the product, `deploy-` for the capability)
|
|
1563
|
-
// rather than sharing a stem. A `ship-` / `ship-deploy-` pair
|
|
1564
|
-
// and is a trap: every deploy token would also match the
|
|
1565
|
-
// leaving correctness resting on the order of two `if`s.
|
|
1660
|
+
// rather than sharing a stem. A `ship-` / `ship-deploy-` pair
|
|
1661
|
+
// reads tidier and is a trap: every deploy token would also match the
|
|
1662
|
+
// API-key branch, leaving correctness resting on the order of two `if`s.
|
|
1566
1663
|
|
|
1567
1664
|
/**
|
|
1568
1665
|
* Where human identity is mounted on the API host. The API mounts Better
|