@shipstatic/types 2.5.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 +11 -1
- package/dist/index.d.ts +73 -39
- package/dist/index.js +117 -79
- package/package.json +6 -1
- package/src/index.ts +149 -88
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
|
-
*
|
|
866
|
+
* What the platform will refuse, returned by the `/limits` endpoint.
|
|
867
867
|
*
|
|
868
|
-
* The SDK fetches
|
|
869
|
-
*
|
|
870
|
-
*
|
|
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
|
|
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
|
-
*
|
|
908
|
+
* Whether a file is one the platform refuses to host.
|
|
888
909
|
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
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
|
-
*
|
|
894
|
-
*
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
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')
|
|
904
|
-
* isBlockedExtension('
|
|
905
|
-
* isBlockedExtension('style.css')
|
|
906
|
-
* isBlockedExtension('
|
|
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.**
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
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
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
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
|
/**
|
|
@@ -1009,30 +1038,35 @@ export declare const AuthMethod: {
|
|
|
1009
1038
|
};
|
|
1010
1039
|
export type AuthMethodType = (typeof AuthMethod)[keyof typeof AuthMethod];
|
|
1011
1040
|
/**
|
|
1012
|
-
* Shape constants for API keys (`ship-{
|
|
1041
|
+
* Shape constants for API keys (`ship-{32 hex chars}`).
|
|
1013
1042
|
* Single source of truth used by validation utilities and auth middleware.
|
|
1014
1043
|
*/
|
|
1015
1044
|
export declare const API_KEY: {
|
|
1016
1045
|
/** Prefix that identifies an API key. */
|
|
1017
1046
|
readonly PREFIX: "ship-";
|
|
1018
1047
|
/** Number of hex characters following the prefix. */
|
|
1019
|
-
readonly HEX_LENGTH:
|
|
1020
|
-
/** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH =
|
|
1021
|
-
readonly TOTAL_LENGTH:
|
|
1048
|
+
readonly HEX_LENGTH: 32;
|
|
1049
|
+
/** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 37`). */
|
|
1050
|
+
readonly TOTAL_LENGTH: 37;
|
|
1022
1051
|
/** Number of trailing characters used to display a redacted hint (e.g. last 4). */
|
|
1023
1052
|
readonly HINT_LENGTH: 4;
|
|
1024
1053
|
};
|
|
1025
1054
|
/**
|
|
1026
|
-
* Shape constants for deploy tokens (`deploy-{
|
|
1055
|
+
* Shape constants for deploy tokens (`deploy-{32 hex chars}`).
|
|
1027
1056
|
* Single source of truth used by validation utilities and auth middleware.
|
|
1057
|
+
*
|
|
1058
|
+
* Deliberately the same width as `API_KEY`: both are minted by one generator
|
|
1059
|
+
* and classified by prefix alone, so a length that differed between them
|
|
1060
|
+
* would be a second thing to know about a credential whose prefix already
|
|
1061
|
+
* says what it is.
|
|
1028
1062
|
*/
|
|
1029
1063
|
export declare const DEPLOY_TOKEN: {
|
|
1030
1064
|
/** Prefix that identifies a deploy token. */
|
|
1031
1065
|
readonly PREFIX: "deploy-";
|
|
1032
1066
|
/** Number of hex characters following the prefix. */
|
|
1033
|
-
readonly HEX_LENGTH:
|
|
1034
|
-
/** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH =
|
|
1035
|
-
readonly TOTAL_LENGTH:
|
|
1067
|
+
readonly HEX_LENGTH: 32;
|
|
1068
|
+
/** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */
|
|
1069
|
+
readonly TOTAL_LENGTH: 39;
|
|
1036
1070
|
};
|
|
1037
1071
|
/**
|
|
1038
1072
|
* Shape constants for caller identifiers (the `X-Caller` instance-identity
|
package/dist/index.js
CHANGED
|
@@ -633,80 +633,81 @@ export function isShipError(error) {
|
|
|
633
633
|
'status' in error);
|
|
634
634
|
}
|
|
635
635
|
// =============================================================================
|
|
636
|
-
// EXTENSION
|
|
636
|
+
// EXTENSION MATCHING
|
|
637
637
|
// =============================================================================
|
|
638
638
|
/**
|
|
639
|
-
*
|
|
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
|
-
*
|
|
642
|
-
*
|
|
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
|
-
*
|
|
646
|
-
*
|
|
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
|
-
|
|
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
|
-
]);
|
|
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
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
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')
|
|
699
|
-
* isBlockedExtension('
|
|
700
|
-
* isBlockedExtension('style.css')
|
|
701
|
-
* isBlockedExtension('
|
|
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
|
|
706
|
-
if (
|
|
706
|
+
export function isBlockedExtension(filename, blocked) {
|
|
707
|
+
const ext = fileExtension(filename);
|
|
708
|
+
if (ext === null)
|
|
707
709
|
return false;
|
|
708
|
-
|
|
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.**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
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
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
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
|
// =============================================================================
|
|
@@ -886,6 +890,35 @@ export function hasUnbuiltMarker(filePath) {
|
|
|
886
890
|
// that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN, CALLER),
|
|
887
891
|
// the single dispatch over them (TokenKind, classifyToken), and the
|
|
888
892
|
// delegated-access scopes (OAuthScope).
|
|
893
|
+
//
|
|
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.
|
|
903
|
+
//
|
|
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.
|
|
909
|
+
//
|
|
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.
|
|
915
|
+
//
|
|
916
|
+
// 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
|
|
917
|
+
// order-independent, and it is the reason the populations are named on
|
|
918
|
+
// different axes (`ship-` for the product, `deploy-` for the capability)
|
|
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.
|
|
889
922
|
/**
|
|
890
923
|
* Where human identity is mounted on the API host. The API mounts Better
|
|
891
924
|
* Auth at this path (sign-in, sign-out, session reads, admin impersonation)
|
|
@@ -913,30 +946,35 @@ export const AuthMethod = {
|
|
|
913
946
|
SYSTEM: 'system',
|
|
914
947
|
};
|
|
915
948
|
/**
|
|
916
|
-
* Shape constants for API keys (`ship-{
|
|
949
|
+
* Shape constants for API keys (`ship-{32 hex chars}`).
|
|
917
950
|
* Single source of truth used by validation utilities and auth middleware.
|
|
918
951
|
*/
|
|
919
952
|
export const API_KEY = {
|
|
920
953
|
/** Prefix that identifies an API key. */
|
|
921
954
|
PREFIX: 'ship-',
|
|
922
955
|
/** Number of hex characters following the prefix. */
|
|
923
|
-
HEX_LENGTH:
|
|
924
|
-
/** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH =
|
|
925
|
-
TOTAL_LENGTH:
|
|
956
|
+
HEX_LENGTH: 32,
|
|
957
|
+
/** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 37`). */
|
|
958
|
+
TOTAL_LENGTH: 37,
|
|
926
959
|
/** Number of trailing characters used to display a redacted hint (e.g. last 4). */
|
|
927
960
|
HINT_LENGTH: 4,
|
|
928
961
|
};
|
|
929
962
|
/**
|
|
930
|
-
* Shape constants for deploy tokens (`deploy-{
|
|
963
|
+
* Shape constants for deploy tokens (`deploy-{32 hex chars}`).
|
|
931
964
|
* Single source of truth used by validation utilities and auth middleware.
|
|
965
|
+
*
|
|
966
|
+
* Deliberately the same width as `API_KEY`: both are minted by one generator
|
|
967
|
+
* and classified by prefix alone, so a length that differed between them
|
|
968
|
+
* would be a second thing to know about a credential whose prefix already
|
|
969
|
+
* says what it is.
|
|
932
970
|
*/
|
|
933
971
|
export const DEPLOY_TOKEN = {
|
|
934
972
|
/** Prefix that identifies a deploy token. */
|
|
935
973
|
PREFIX: 'deploy-',
|
|
936
974
|
/** Number of hex characters following the prefix. */
|
|
937
|
-
HEX_LENGTH:
|
|
938
|
-
/** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH =
|
|
939
|
-
TOTAL_LENGTH:
|
|
975
|
+
HEX_LENGTH: 32,
|
|
976
|
+
/** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */
|
|
977
|
+
TOTAL_LENGTH: 39,
|
|
940
978
|
};
|
|
941
979
|
/**
|
|
942
980
|
* Shape constants for caller identifiers (the `X-Caller` instance-identity
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipstatic/types",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
|
+
"prepack": "pnpm run build",
|
|
16
17
|
"clean": "rm -rf dist",
|
|
17
18
|
"test": "vitest",
|
|
18
19
|
"lint": "biome check .",
|
|
@@ -47,5 +48,9 @@
|
|
|
47
48
|
"@types/node": "^24.13.3",
|
|
48
49
|
"typescript": "^5.9.3",
|
|
49
50
|
"vitest": "^2.1.9"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public",
|
|
54
|
+
"provenance": true
|
|
50
55
|
}
|
|
51
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -1234,14 +1234,17 @@ export function isShipError(error: unknown): error is ShipError {
|
|
|
1234
1234
|
// =============================================================================
|
|
1235
1235
|
|
|
1236
1236
|
/**
|
|
1237
|
-
*
|
|
1237
|
+
* What the platform will refuse, returned by the `/limits` endpoint.
|
|
1238
1238
|
*
|
|
1239
|
-
* The SDK fetches
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
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
|
|
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
|
|
1280
|
+
// EXTENSION MATCHING
|
|
1260
1281
|
// =============================================================================
|
|
1261
1282
|
|
|
1262
1283
|
/**
|
|
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
|
-
]);
|
|
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
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
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')
|
|
1324
|
-
* isBlockedExtension('
|
|
1325
|
-
* isBlockedExtension('style.css')
|
|
1326
|
-
* isBlockedExtension('
|
|
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(
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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.**
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
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
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
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
|
|
|
@@ -1541,6 +1568,35 @@ export interface PingResponse {
|
|
|
1541
1568
|
// that distinguish populations on the wire (API_KEY, DEPLOY_TOKEN, CALLER),
|
|
1542
1569
|
// the single dispatch over them (TokenKind, classifyToken), and the
|
|
1543
1570
|
// delegated-access scopes (OAuthScope).
|
|
1571
|
+
//
|
|
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.
|
|
1581
|
+
//
|
|
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.
|
|
1587
|
+
//
|
|
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.
|
|
1593
|
+
//
|
|
1594
|
+
// 3. NO PREFIX IS A PREFIX OF ANOTHER. This is what makes the dispatch
|
|
1595
|
+
// order-independent, and it is the reason the populations are named on
|
|
1596
|
+
// different axes (`ship-` for the product, `deploy-` for the capability)
|
|
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.
|
|
1544
1600
|
|
|
1545
1601
|
/**
|
|
1546
1602
|
* Where human identity is mounted on the API host. The API mounts Better
|
|
@@ -1573,31 +1629,36 @@ export const AuthMethod = {
|
|
|
1573
1629
|
export type AuthMethodType = (typeof AuthMethod)[keyof typeof AuthMethod];
|
|
1574
1630
|
|
|
1575
1631
|
/**
|
|
1576
|
-
* Shape constants for API keys (`ship-{
|
|
1632
|
+
* Shape constants for API keys (`ship-{32 hex chars}`).
|
|
1577
1633
|
* Single source of truth used by validation utilities and auth middleware.
|
|
1578
1634
|
*/
|
|
1579
1635
|
export const API_KEY = {
|
|
1580
1636
|
/** Prefix that identifies an API key. */
|
|
1581
1637
|
PREFIX: 'ship-',
|
|
1582
1638
|
/** Number of hex characters following the prefix. */
|
|
1583
|
-
HEX_LENGTH:
|
|
1584
|
-
/** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH =
|
|
1585
|
-
TOTAL_LENGTH:
|
|
1639
|
+
HEX_LENGTH: 32,
|
|
1640
|
+
/** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 37`). */
|
|
1641
|
+
TOTAL_LENGTH: 37,
|
|
1586
1642
|
/** Number of trailing characters used to display a redacted hint (e.g. last 4). */
|
|
1587
1643
|
HINT_LENGTH: 4,
|
|
1588
1644
|
} as const;
|
|
1589
1645
|
|
|
1590
1646
|
/**
|
|
1591
|
-
* Shape constants for deploy tokens (`deploy-{
|
|
1647
|
+
* Shape constants for deploy tokens (`deploy-{32 hex chars}`).
|
|
1592
1648
|
* Single source of truth used by validation utilities and auth middleware.
|
|
1649
|
+
*
|
|
1650
|
+
* Deliberately the same width as `API_KEY`: both are minted by one generator
|
|
1651
|
+
* and classified by prefix alone, so a length that differed between them
|
|
1652
|
+
* would be a second thing to know about a credential whose prefix already
|
|
1653
|
+
* says what it is.
|
|
1593
1654
|
*/
|
|
1594
1655
|
export const DEPLOY_TOKEN = {
|
|
1595
1656
|
/** Prefix that identifies a deploy token. */
|
|
1596
1657
|
PREFIX: 'deploy-',
|
|
1597
1658
|
/** Number of hex characters following the prefix. */
|
|
1598
|
-
HEX_LENGTH:
|
|
1599
|
-
/** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH =
|
|
1600
|
-
TOTAL_LENGTH:
|
|
1659
|
+
HEX_LENGTH: 32,
|
|
1660
|
+
/** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 39`). */
|
|
1661
|
+
TOTAL_LENGTH: 39,
|
|
1601
1662
|
} as const;
|
|
1602
1663
|
|
|
1603
1664
|
/**
|