@shipstatic/types 2.6.0 → 2.7.0-beta.1

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 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
@@ -863,14 +863,17 @@ export declare class ShipError extends Error {
863
863
  */
864
864
  export declare function isShipError(error: unknown): error is ShipError;
865
865
  /**
866
- * Plan-based platform limits returned by the `/limits` endpoint.
866
+ * What the platform will refuse, returned by the `/limits` endpoint.
867
867
  *
868
- * The SDK fetches these once on first API call to drive client-side
869
- * file-size / file-count / total-size validation that mirrors what the API
870
- * would enforce server-side. Limits vary by account plan.
868
+ * The SDK fetches this once on first API call to drive client-side validation
869
+ * that mirrors what the API would enforce server-side. The caps vary by
870
+ * account plan; the blocklist does not.
871
871
  *
872
- * These are the *platform's* posted caps for the current account — server
873
- * truth delivered at runtime, never hard-coded on the client.
872
+ * These are the *platform's* posted rules for the current account — server
873
+ * truth delivered at runtime, never hard-coded on the client. That is the
874
+ * whole point of the shape: a rule the server owns and may change reaches the
875
+ * client as data, so a pinned client cannot enforce a policy the platform has
876
+ * moved on from (`npm/types/CLAUDE.md`, "Validation: format vs policy").
874
877
  *
875
878
  * A report: it answers a question and carries only the answer (`CLAUDE.md`,
876
879
  * "A report answers a question").
@@ -882,38 +885,61 @@ export interface PlatformLimits {
882
885
  maxFilesCount: number;
883
886
  /** Maximum total size in bytes across all files in a deployment. */
884
887
  maxTotalSize: number;
888
+ /**
889
+ * Lowercase extensions, without the dot, that the platform refuses to host
890
+ * (`exe`, `dmg`, …). Owned and evolved by the API — see
891
+ * `cloudflare/api/src/lib/blocklist.ts`.
892
+ *
893
+ * **Optional, and the absence is load-bearing.** An API deployed before this
894
+ * field existed sends nothing, so a client MUST read absence as "no
895
+ * client-side check" rather than as an empty policy. The hint fails open,
896
+ * the boundary fails closed: the server refuses the file either way, and a
897
+ * client that guessed would only ever be wrong in the direction that refuses
898
+ * a file the platform accepts.
899
+ *
900
+ * The optionality follows the additive-evolution law and retires with its
901
+ * reason: once every environment serves the field, it hardens to required at
902
+ * the entity's next natural break, and the clients' fail-open spellings
903
+ * retire with it (tracked in root `backlog.md`).
904
+ */
905
+ readonly blockedExtensions?: readonly string[];
885
906
  }
886
907
  /**
887
- * Blocked file extensions files that cannot be uploaded.
908
+ * Whether a file is one the platform refuses to host.
888
909
  *
889
- * We accept any file type by default and derive Content-Type from the
890
- * extension at serve time (via mime-db in the API worker). Unknown extensions
891
- * are served as `application/octet-stream` with `X-Content-Type-Options: nosniff`.
910
+ * **The list is not this package's, and that separation is the point.** What
911
+ * counts as a blocked extension is hosting POLICY it evolves, it is enforced
912
+ * at one security boundary, and `virus.exe` is a perfectly well-formed
913
+ * filename that breaks nothing about the upload→serve round-trip. So the API
914
+ * owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as
915
+ * `PlatformLimits.blockedExtensions`; a client passes what it was given.
892
916
  *
893
- * The blocklist targets file types that pose direct security risks when hosted:
894
- * executables, disk images, malware vectors, dangerous scripts, and shortcuts.
895
- */
896
- export declare const BLOCKED_EXTENSIONS: ReadonlySet<string>;
897
- /**
898
- * Check if a filename has a blocked extension.
899
- * Extracts the extension from the filename and checks against the blocklist.
900
- * Case-insensitive. Returns false for files without extensions.
917
+ * What lives here is the MATCHING RULE, and it earns its place by the
918
+ * constellation law's own test. The list's drift is loud in both directions —
919
+ * a stale client uploads a file the API refuses by name, on the first try.
920
+ * A second *matcher* drifts SILENTLY in the one direction that matters: a
921
+ * client stricter than the server refuses a legal file without the server ever
922
+ * being asked, and no error names it. Two holders, silent drift, one owner.
923
+ *
924
+ * The `blocked` collection is required rather than defaulted: this predicate
925
+ * guards a security boundary in the API, and a defaulted-empty argument there
926
+ * would block nothing while reading as though it did. Callers holding a
927
+ * possibly-absent wire field spell the fail-open themselves.
901
928
  *
902
929
  * @example
903
- * isBlockedExtension('virus.exe') // true
904
- * isBlockedExtension('app.dmg') // true
905
- * isBlockedExtension('style.css') // false
906
- * isBlockedExtension('data.custom') // false
907
- * isBlockedExtension('README') // false
930
+ * isBlockedExtension('virus.exe', ['exe']) // true
931
+ * isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive
932
+ * isBlockedExtension('style.css', ['exe']) // false
933
+ * isBlockedExtension('README', ['exe']) // false — no extension
908
934
  */
909
- export declare function isBlockedExtension(filename: string): boolean;
935
+ export declare function isBlockedExtension(filename: string, blocked: ReadonlySet<string> | readonly string[]): boolean;
910
936
  /**
911
937
  * The `accept` attribute value for a browser file picker offering web files.
912
938
  *
913
- * **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
914
- * gate and the only thing that decides what may be hosted; this constant
915
- * decides what a *file dialog* shows first. The two are not two halves of one
916
- * policy, and this one must never be consulted to accept or reject a file.
939
+ * **This is a hint, never a rule.** The API's blocklist is the platform's gate
940
+ * and the only thing that decides what may be hosted; this constant decides
941
+ * what a *file dialog* shows first. The two are not two halves of one policy,
942
+ * and this one must never be consulted to accept or reject a file.
917
943
  *
918
944
  * The distinction is structural, not stylistic. `accept` can express only an
919
945
  * allowlist, while the platform's rule is a blocklist — so this list is
@@ -924,9 +950,12 @@ export declare function isBlockedExtension(filename: string): boolean;
924
950
  * dropzone and the picker must reach the same verdict on the same files, and
925
951
  * they do — because the verdict is `validateFiles`, downstream of both.
926
952
  *
927
- * Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
928
- * `tests/validation-constants.test.ts` fence the invariant that matters: the
929
- * picker must never offer a file the platform will refuse.
953
+ * The invariant that matters the picker must never offer a file the platform
954
+ * will refuse — is fenced where the authority lives, in the API's own suite
955
+ * (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published
956
+ * string and holds it against the list it owns. It sat here until the
957
+ * blocklist became the API's, and moving it was the price of that: a fence
958
+ * belongs with whichever side can change and break it.
930
959
  */
931
960
  export declare const WEB_FILE_ACCEPT: string;
932
961
  /**
package/dist/index.js CHANGED
@@ -633,80 +633,81 @@ export function isShipError(error) {
633
633
  'status' in error);
634
634
  }
635
635
  // =============================================================================
636
- // EXTENSION BLOCKLIST
636
+ // EXTENSION MATCHING
637
637
  // =============================================================================
638
638
  /**
639
- * Blocked file extensions files that cannot be uploaded.
639
+ * The rule for reading a file's extension: lowercase, after the last dot of
640
+ * the last path segment. `null` when there is no extension to read.
640
641
  *
641
- * We accept any file type by default and derive Content-Type from the
642
- * extension at serve time (via mime-db in the API worker). Unknown extensions
643
- * are served as `application/octet-stream` with `X-Content-Type-Options: nosniff`.
642
+ * A leading dot names the file rather than its type, so `.gitignore` and
643
+ * `.htaccess` have no extension but `.env.exe` has `exe`.
644
644
  *
645
- * The blocklist targets file types that pose direct security risks when hosted:
646
- * executables, disk images, malware vectors, dangerous scripts, and shortcuts.
645
+ * Segment-aware on purpose: the callers pass deploy PATHS, not basenames, and
646
+ * a naive `lastIndexOf('.')` over `dir.v1/README` reads the extension
647
+ * `v1/README` — safe only by accident, since no entry in a real blocklist
648
+ * contains a slash, which is the kind of correctness nobody should have to
649
+ * re-derive.
650
+ *
651
+ * **Private, and the reason is the asymmetry rather than any hazard.** Nothing
652
+ * outside this file reads it: `isBlockedExtension` is the only question anyone
653
+ * asks, and the API's refusal names the FILE, not its extension. Exporting a
654
+ * pure function is harmless, which is exactly the argument that talks a
655
+ * published package into surface it has not earned — and the costs do not
656
+ * match, since adding an export later is free under the additive law while
657
+ * removing one is a major. So it stays private until a caller exists. Its
658
+ * behaviour is fenced through `isBlockedExtension`, which is where it is
659
+ * observable.
660
+ *
661
+ * (`WEB_FILE_EXTENSIONS` above is private for a different reason — publishing
662
+ * it would invite a wrong question. Both are private; only one is a hazard.)
663
+ *
664
+ * @example
665
+ * fileExtension('virus.exe') // 'exe'
666
+ * fileExtension('assets/style.CSS') // 'css'
667
+ * fileExtension('dir.v1/README') // null
668
+ * fileExtension('.gitignore') // null
669
+ * fileExtension('file.') // null
647
670
  */
648
- export const BLOCKED_EXTENSIONS = new Set([
649
- // Executables
650
- 'exe',
651
- 'msi',
652
- 'dll',
653
- 'scr',
654
- 'bat',
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
- ]);
671
+ function fileExtension(filename) {
672
+ const basename = filename.replace(/\\/g, '/').split('/').pop() ?? '';
673
+ const dotIndex = basename.lastIndexOf('.');
674
+ if (dotIndex <= 0 || dotIndex === basename.length - 1)
675
+ return null;
676
+ return basename.slice(dotIndex + 1).toLowerCase();
677
+ }
692
678
  /**
693
- * Check if a filename has a blocked extension.
694
- * Extracts the extension from the filename and checks against the blocklist.
695
- * Case-insensitive. Returns false for files without extensions.
679
+ * Whether a file is one the platform refuses to host.
680
+ *
681
+ * **The list is not this package's, and that separation is the point.** What
682
+ * counts as a blocked extension is hosting POLICY — it evolves, it is enforced
683
+ * at one security boundary, and `virus.exe` is a perfectly well-formed
684
+ * filename that breaks nothing about the upload→serve round-trip. So the API
685
+ * owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as
686
+ * `PlatformLimits.blockedExtensions`; a client passes what it was given.
687
+ *
688
+ * What lives here is the MATCHING RULE, and it earns its place by the
689
+ * constellation law's own test. The list's drift is loud in both directions —
690
+ * a stale client uploads a file the API refuses by name, on the first try.
691
+ * A second *matcher* drifts SILENTLY in the one direction that matters: a
692
+ * client stricter than the server refuses a legal file without the server ever
693
+ * being asked, and no error names it. Two holders, silent drift, one owner.
694
+ *
695
+ * The `blocked` collection is required rather than defaulted: this predicate
696
+ * guards a security boundary in the API, and a defaulted-empty argument there
697
+ * would block nothing while reading as though it did. Callers holding a
698
+ * possibly-absent wire field spell the fail-open themselves.
696
699
  *
697
700
  * @example
698
- * isBlockedExtension('virus.exe') // true
699
- * isBlockedExtension('app.dmg') // true
700
- * isBlockedExtension('style.css') // false
701
- * isBlockedExtension('data.custom') // false
702
- * isBlockedExtension('README') // false
701
+ * isBlockedExtension('virus.exe', ['exe']) // true
702
+ * isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive
703
+ * isBlockedExtension('style.css', ['exe']) // false
704
+ * isBlockedExtension('README', ['exe']) // false — no extension
703
705
  */
704
- export function isBlockedExtension(filename) {
705
- const dotIndex = filename.lastIndexOf('.');
706
- if (dotIndex === -1 || dotIndex === filename.length - 1)
706
+ export function isBlockedExtension(filename, blocked) {
707
+ const ext = fileExtension(filename);
708
+ if (ext === null)
707
709
  return false;
708
- const ext = filename.slice(dotIndex + 1).toLowerCase();
709
- return BLOCKED_EXTENSIONS.has(ext);
710
+ return Array.isArray(blocked) ? blocked.includes(ext) : blocked.has(ext);
710
711
  }
711
712
  // =============================================================================
712
713
  // PICKER ACCEPT HINT
@@ -808,10 +809,10 @@ const WEB_FILE_EXTENSIONS = [
808
809
  /**
809
810
  * The `accept` attribute value for a browser file picker offering web files.
810
811
  *
811
- * **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
812
- * gate and the only thing that decides what may be hosted; this constant
813
- * decides what a *file dialog* shows first. The two are not two halves of one
814
- * policy, and this one must never be consulted to accept or reject a file.
812
+ * **This is a hint, never a rule.** The API's blocklist is the platform's gate
813
+ * and the only thing that decides what may be hosted; this constant decides
814
+ * what a *file dialog* shows first. The two are not two halves of one policy,
815
+ * and this one must never be consulted to accept or reject a file.
815
816
  *
816
817
  * The distinction is structural, not stylistic. `accept` can express only an
817
818
  * allowlist, while the platform's rule is a blocklist — so this list is
@@ -822,9 +823,12 @@ const WEB_FILE_EXTENSIONS = [
822
823
  * dropzone and the picker must reach the same verdict on the same files, and
823
824
  * they do — because the verdict is `validateFiles`, downstream of both.
824
825
  *
825
- * Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
826
- * `tests/validation-constants.test.ts` fence the invariant that matters: the
827
- * picker must never offer a file the platform will refuse.
826
+ * The invariant that matters the picker must never offer a file the platform
827
+ * will refuse — is fenced where the authority lives, in the API's own suite
828
+ * (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published
829
+ * string and holds it against the list it owns. It sat here until the
830
+ * blocklist became the API's, and moving it was the price of that: a fence
831
+ * belongs with whichever side can change and break it.
828
832
  */
829
833
  export const WEB_FILE_ACCEPT = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
830
834
  // =============================================================================
@@ -887,27 +891,34 @@ export function hasUnbuiltMarker(filePath) {
887
891
  // the single dispatch over them (TokenKind, classifyToken), and the
888
892
  // delegated-access scopes (OAuthScope).
889
893
  //
890
- // THE SHAPE LAW, in three clauses. Every secret the platform mints obeys it,
891
- // and `tests/validation-constants.test.ts` holds all three mechanically.
894
+ // THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's
895
+ // two populations below. The deployment claim code is the API's own
896
+ // (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so
897
+ // it has one holder and stays there) and shares only clause 1 — it is the
898
+ // platform's one deliberately BARE secret, because it never enters the
899
+ // Bearer slot: minted into one URL, consumed by one endpoint's one field,
900
+ // its context names it and a prefix would restate its route.
901
+ // `tests/validation-constants.test.ts` holds the clauses over this file's
902
+ // populations; the API's suite holds its own.
892
903
  //
893
- // 1. ONE ENTROPY STANDARD. Every minted random secret is `HEX_LENGTH` hex
894
- // characters — one width for the whole platform, so "how long is a
895
- // credential" has a single answer rather than one per population.
904
+ // 1. ONE ENTROPY STANDARD. Every minted secret is `HEX_LENGTH` hex characters
905
+ // — one width for the whole platform, so "how long is a credential" has a
906
+ // single answer rather than one per population. Generators read the width
907
+ // from the population's own constant, so a minted value and an accepted
908
+ // value cannot differ.
896
909
  //
897
- // 2. A PREFIX MARKS A SHARED SLOT, AND NOTHING ELSE. API keys and deploy
898
- // tokens both arrive as `Authorization: Bearer`, so something must say
899
- // which population a value belongs to: that is what the prefix IS, and
900
- // `classifyToken` below is its only reader. Secrets that arrive somewhere
901
- // unambiguous carry none the deployment claim code reaches its own
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.
910
+ // 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what
911
+ // it is before anything parses it which is what lets `classifyToken`
912
+ // below dispatch two populations sharing one `Authorization: Bearer`
913
+ // slot, and what lets a value found in a log, a support ticket or a
914
+ // pasted URL be recognised and revoked on sight.
904
915
  //
905
916
  // 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
906
917
  // order-independent, and it is the reason the populations are named on
907
918
  // different axes (`ship-` for the product, `deploy-` for the capability)
908
- // rather than sharing a stem. A `ship-` / `ship-deploy-` pair reads tidier
909
- // and is a trap: every deploy token would also match the API-key branch,
910
- // leaving correctness resting on the order of two `if`s.
919
+ // rather than sharing a stem. A `ship-` / `ship-deploy-` pair
920
+ // reads tidier and is a trap: every deploy token would also match the
921
+ // API-key branch, leaving correctness resting on the order of two `if`s.
911
922
  /**
912
923
  * Where human identity is mounted on the API host. The API mounts Better
913
924
  * Auth at this path (sign-in, sign-out, session reads, admin impersonation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.6.0",
3
+ "version": "2.7.0-beta.1",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1234,14 +1234,17 @@ export function isShipError(error: unknown): error is ShipError {
1234
1234
  // =============================================================================
1235
1235
 
1236
1236
  /**
1237
- * Plan-based platform limits returned by the `/limits` endpoint.
1237
+ * What the platform will refuse, returned by the `/limits` endpoint.
1238
1238
  *
1239
- * The SDK fetches these once on first API call to drive client-side
1240
- * file-size / file-count / total-size validation that mirrors what the API
1241
- * would enforce server-side. Limits vary by account plan.
1239
+ * The SDK fetches this once on first API call to drive client-side validation
1240
+ * that mirrors what the API would enforce server-side. The caps vary by
1241
+ * account plan; the blocklist does not.
1242
1242
  *
1243
- * These are the *platform's* posted caps for the current account — server
1244
- * truth delivered at runtime, never hard-coded on the client.
1243
+ * These are the *platform's* posted rules for the current account — server
1244
+ * truth delivered at runtime, never hard-coded on the client. That is the
1245
+ * whole point of the shape: a rule the server owns and may change reaches the
1246
+ * client as data, so a pinned client cannot enforce a policy the platform has
1247
+ * moved on from (`npm/types/CLAUDE.md`, "Validation: format vs policy").
1245
1248
  *
1246
1249
  * A report: it answers a question and carries only the answer (`CLAUDE.md`,
1247
1250
  * "A report answers a question").
@@ -1253,84 +1256,105 @@ export interface PlatformLimits {
1253
1256
  maxFilesCount: number;
1254
1257
  /** Maximum total size in bytes across all files in a deployment. */
1255
1258
  maxTotalSize: number;
1259
+ /**
1260
+ * Lowercase extensions, without the dot, that the platform refuses to host
1261
+ * (`exe`, `dmg`, …). Owned and evolved by the API — see
1262
+ * `cloudflare/api/src/lib/blocklist.ts`.
1263
+ *
1264
+ * **Optional, and the absence is load-bearing.** An API deployed before this
1265
+ * field existed sends nothing, so a client MUST read absence as "no
1266
+ * client-side check" rather than as an empty policy. The hint fails open,
1267
+ * the boundary fails closed: the server refuses the file either way, and a
1268
+ * client that guessed would only ever be wrong in the direction that refuses
1269
+ * a file the platform accepts.
1270
+ *
1271
+ * The optionality follows the additive-evolution law and retires with its
1272
+ * reason: once every environment serves the field, it hardens to required at
1273
+ * the entity's next natural break, and the clients' fail-open spellings
1274
+ * retire with it (tracked in root `backlog.md`).
1275
+ */
1276
+ readonly blockedExtensions?: readonly string[];
1256
1277
  }
1257
1278
 
1258
1279
  // =============================================================================
1259
- // EXTENSION BLOCKLIST
1280
+ // EXTENSION MATCHING
1260
1281
  // =============================================================================
1261
1282
 
1262
1283
  /**
1263
- * Blocked file extensions files that cannot be uploaded.
1264
- *
1265
- * We accept any file type by default and derive Content-Type from the
1266
- * extension at serve time (via mime-db in the API worker). Unknown extensions
1267
- * are served as `application/octet-stream` with `X-Content-Type-Options: nosniff`.
1268
- *
1269
- * The blocklist targets file types that pose direct security risks when hosted:
1270
- * executables, disk images, malware vectors, dangerous scripts, and shortcuts.
1271
- */
1272
- export const BLOCKED_EXTENSIONS: ReadonlySet<string> = new Set([
1273
- // Executables
1274
- 'exe',
1275
- 'msi',
1276
- 'dll',
1277
- 'scr',
1278
- 'bat',
1279
- 'cmd',
1280
- 'com',
1281
- 'pif',
1282
- 'app',
1283
- 'deb',
1284
- 'rpm',
1285
- // Installers
1286
- 'pkg',
1287
- 'mpkg',
1288
- // Disk images
1289
- 'dmg',
1290
- 'iso',
1291
- 'img',
1292
- // Malware vectors
1293
- 'cab',
1294
- 'cpl',
1295
- 'chm',
1296
- // Dangerous scripts
1297
- 'ps1',
1298
- 'vbs',
1299
- 'vbe',
1300
- 'ws',
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
- ]);
1284
+ * The rule for reading a file's extension: lowercase, after the last dot of
1285
+ * the last path segment. `null` when there is no extension to read.
1286
+ *
1287
+ * A leading dot names the file rather than its type, so `.gitignore` and
1288
+ * `.htaccess` have no extension — but `.env.exe` has `exe`.
1289
+ *
1290
+ * Segment-aware on purpose: the callers pass deploy PATHS, not basenames, and
1291
+ * a naive `lastIndexOf('.')` over `dir.v1/README` reads the extension
1292
+ * `v1/README` — safe only by accident, since no entry in a real blocklist
1293
+ * contains a slash, which is the kind of correctness nobody should have to
1294
+ * re-derive.
1295
+ *
1296
+ * **Private, and the reason is the asymmetry rather than any hazard.** Nothing
1297
+ * outside this file reads it: `isBlockedExtension` is the only question anyone
1298
+ * asks, and the API's refusal names the FILE, not its extension. Exporting a
1299
+ * pure function is harmless, which is exactly the argument that talks a
1300
+ * published package into surface it has not earned — and the costs do not
1301
+ * match, since adding an export later is free under the additive law while
1302
+ * removing one is a major. So it stays private until a caller exists. Its
1303
+ * behaviour is fenced through `isBlockedExtension`, which is where it is
1304
+ * observable.
1305
+ *
1306
+ * (`WEB_FILE_EXTENSIONS` above is private for a different reason — publishing
1307
+ * it would invite a wrong question. Both are private; only one is a hazard.)
1308
+ *
1309
+ * @example
1310
+ * fileExtension('virus.exe') // 'exe'
1311
+ * fileExtension('assets/style.CSS') // 'css'
1312
+ * fileExtension('dir.v1/README') // null
1313
+ * fileExtension('.gitignore') // null
1314
+ * fileExtension('file.') // null
1315
+ */
1316
+ function fileExtension(filename: string): string | null {
1317
+ const basename = filename.replace(/\\/g, '/').split('/').pop() ?? '';
1318
+ const dotIndex = basename.lastIndexOf('.');
1319
+ if (dotIndex <= 0 || dotIndex === basename.length - 1) return null;
1320
+ return basename.slice(dotIndex + 1).toLowerCase();
1321
+ }
1316
1322
 
1317
1323
  /**
1318
- * Check if a filename has a blocked extension.
1319
- * Extracts the extension from the filename and checks against the blocklist.
1320
- * Case-insensitive. Returns false for files without extensions.
1324
+ * Whether a file is one the platform refuses to host.
1325
+ *
1326
+ * **The list is not this package's, and that separation is the point.** What
1327
+ * counts as a blocked extension is hosting POLICY — it evolves, it is enforced
1328
+ * at one security boundary, and `virus.exe` is a perfectly well-formed
1329
+ * filename that breaks nothing about the upload→serve round-trip. So the API
1330
+ * owns the list (`cloudflare/api/src/lib/blocklist.ts`) and delivers it as
1331
+ * `PlatformLimits.blockedExtensions`; a client passes what it was given.
1332
+ *
1333
+ * What lives here is the MATCHING RULE, and it earns its place by the
1334
+ * constellation law's own test. The list's drift is loud in both directions —
1335
+ * a stale client uploads a file the API refuses by name, on the first try.
1336
+ * A second *matcher* drifts SILENTLY in the one direction that matters: a
1337
+ * client stricter than the server refuses a legal file without the server ever
1338
+ * being asked, and no error names it. Two holders, silent drift, one owner.
1339
+ *
1340
+ * The `blocked` collection is required rather than defaulted: this predicate
1341
+ * guards a security boundary in the API, and a defaulted-empty argument there
1342
+ * would block nothing while reading as though it did. Callers holding a
1343
+ * possibly-absent wire field spell the fail-open themselves.
1321
1344
  *
1322
1345
  * @example
1323
- * isBlockedExtension('virus.exe') // true
1324
- * isBlockedExtension('app.dmg') // true
1325
- * isBlockedExtension('style.css') // false
1326
- * isBlockedExtension('data.custom') // false
1327
- * isBlockedExtension('README') // false
1346
+ * isBlockedExtension('virus.exe', ['exe']) // true
1347
+ * isBlockedExtension('virus.EXE', ['exe']) // true — case-insensitive
1348
+ * isBlockedExtension('style.css', ['exe']) // false
1349
+ * isBlockedExtension('README', ['exe']) // false — no extension
1328
1350
  */
1329
- export function isBlockedExtension(filename: string): boolean {
1330
- const dotIndex = filename.lastIndexOf('.');
1331
- if (dotIndex === -1 || dotIndex === filename.length - 1) return false;
1332
- const ext = filename.slice(dotIndex + 1).toLowerCase();
1333
- return BLOCKED_EXTENSIONS.has(ext);
1351
+ export function isBlockedExtension(
1352
+ filename: string,
1353
+ blocked: ReadonlySet<string> | readonly string[],
1354
+ ): boolean {
1355
+ const ext = fileExtension(filename);
1356
+ if (ext === null) return false;
1357
+ return Array.isArray(blocked) ? blocked.includes(ext) : (blocked as ReadonlySet<string>).has(ext);
1334
1358
  }
1335
1359
 
1336
1360
  // =============================================================================
@@ -1435,10 +1459,10 @@ const WEB_FILE_EXTENSIONS = [
1435
1459
  /**
1436
1460
  * The `accept` attribute value for a browser file picker offering web files.
1437
1461
  *
1438
- * **This is a hint, never a rule.** `BLOCKED_EXTENSIONS` is the platform's
1439
- * gate and the only thing that decides what may be hosted; this constant
1440
- * decides what a *file dialog* shows first. The two are not two halves of one
1441
- * policy, and this one must never be consulted to accept or reject a file.
1462
+ * **This is a hint, never a rule.** The API's blocklist is the platform's gate
1463
+ * and the only thing that decides what may be hosted; this constant decides
1464
+ * what a *file dialog* shows first. The two are not two halves of one policy,
1465
+ * and this one must never be consulted to accept or reject a file.
1442
1466
  *
1443
1467
  * The distinction is structural, not stylistic. `accept` can express only an
1444
1468
  * allowlist, while the platform's rule is a blocklist — so this list is
@@ -1449,9 +1473,12 @@ const WEB_FILE_EXTENSIONS = [
1449
1473
  * dropzone and the picker must reach the same verdict on the same files, and
1450
1474
  * they do — because the verdict is `validateFiles`, downstream of both.
1451
1475
  *
1452
- * Kept beside `BLOCKED_EXTENSIONS` so one file holds both, which is what lets
1453
- * `tests/validation-constants.test.ts` fence the invariant that matters: the
1454
- * picker must never offer a file the platform will refuse.
1476
+ * The invariant that matters the picker must never offer a file the platform
1477
+ * will refuse — is fenced where the authority lives, in the API's own suite
1478
+ * (`cloudflare/api/tests/lib/blocklist.test.ts`), which reads this published
1479
+ * string and holds it against the list it owns. It sat here until the
1480
+ * blocklist became the API's, and moving it was the price of that: a fence
1481
+ * belongs with whichever side can change and break it.
1455
1482
  */
1456
1483
  export const WEB_FILE_ACCEPT: string = WEB_FILE_EXTENSIONS.map((ext) => `.${ext}`).join(',');
1457
1484
 
@@ -1542,27 +1569,34 @@ export interface PingResponse {
1542
1569
  // the single dispatch over them (TokenKind, classifyToken), and the
1543
1570
  // delegated-access scopes (OAuthScope).
1544
1571
  //
1545
- // THE SHAPE LAW, in three clauses. Every secret the platform mints obeys it,
1546
- // and `tests/validation-constants.test.ts` holds all three mechanically.
1572
+ // THE SHAPE LAW, in three clauses, over the `Authorization: Bearer` slot's
1573
+ // two populations below. The deployment claim code is the API's own
1574
+ // (`AUTH.CLAIM`, server-side: the API mints it and the API validates it, so
1575
+ // it has one holder and stays there) and shares only clause 1 — it is the
1576
+ // platform's one deliberately BARE secret, because it never enters the
1577
+ // Bearer slot: minted into one URL, consumed by one endpoint's one field,
1578
+ // its context names it and a prefix would restate its route.
1579
+ // `tests/validation-constants.test.ts` holds the clauses over this file's
1580
+ // populations; the API's suite holds its own.
1547
1581
  //
1548
- // 1. ONE ENTROPY STANDARD. Every minted random secret is `HEX_LENGTH` hex
1549
- // characters — one width for the whole platform, so "how long is a
1550
- // credential" has a single answer rather than one per population.
1582
+ // 1. ONE ENTROPY STANDARD. Every minted secret is `HEX_LENGTH` hex characters
1583
+ // — one width for the whole platform, so "how long is a credential" has a
1584
+ // single answer rather than one per population. Generators read the width
1585
+ // from the population's own constant, so a minted value and an accepted
1586
+ // value cannot differ.
1551
1587
  //
1552
- // 2. A PREFIX MARKS A SHARED SLOT, AND NOTHING ELSE. API keys and deploy
1553
- // tokens both arrive as `Authorization: Bearer`, so something must say
1554
- // which population a value belongs to: that is what the prefix IS, and
1555
- // `classifyToken` below is its only reader. Secrets that arrive somewhere
1556
- // unambiguous carry none the deployment claim code reaches its own
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.
1588
+ // 2. EVERY BEARER POPULATION IS NAMED BY ITS PREFIX. A credential says what
1589
+ // it is before anything parses it which is what lets `classifyToken`
1590
+ // below dispatch two populations sharing one `Authorization: Bearer`
1591
+ // slot, and what lets a value found in a log, a support ticket or a
1592
+ // pasted URL be recognised and revoked on sight.
1559
1593
  //
1560
1594
  // 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
1561
1595
  // order-independent, and it is the reason the populations are named on
1562
1596
  // different axes (`ship-` for the product, `deploy-` for the capability)
1563
- // rather than sharing a stem. A `ship-` / `ship-deploy-` pair reads tidier
1564
- // and is a trap: every deploy token would also match the API-key branch,
1565
- // leaving correctness resting on the order of two `if`s.
1597
+ // rather than sharing a stem. A `ship-` / `ship-deploy-` pair
1598
+ // reads tidier and is a trap: every deploy token would also match the
1599
+ // API-key branch, leaving correctness resting on the order of two `if`s.
1566
1600
 
1567
1601
  /**
1568
1602
  * Where human identity is mounted on the API host. The API mounts Better