@stapel/cdn-react 0.1.0

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.
Files changed (164) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +124 -0
  3. package/README.md +103 -0
  4. package/dist/api/cdnApi.d.ts +122 -0
  5. package/dist/api/cdnApi.d.ts.map +1 -0
  6. package/dist/api/cdnApi.js +27 -0
  7. package/dist/api/cdnApi.js.map +1 -0
  8. package/dist/api/generated/schema.d.ts +1050 -0
  9. package/dist/api/generated/schema.d.ts.map +1 -0
  10. package/dist/api/generated/schema.js +2 -0
  11. package/dist/api/generated/schema.js.map +1 -0
  12. package/dist/api/types.d.ts +76 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +2 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/ErrorAlert.d.ts +8 -0
  17. package/dist/default/ErrorAlert.d.ts.map +1 -0
  18. package/dist/default/ErrorAlert.js +26 -0
  19. package/dist/default/ErrorAlert.js.map +1 -0
  20. package/dist/default/ImageUploadField.d.ts +12 -0
  21. package/dist/default/ImageUploadField.d.ts.map +1 -0
  22. package/dist/default/ImageUploadField.js +48 -0
  23. package/dist/default/ImageUploadField.js.map +1 -0
  24. package/dist/default/MediaGalleryField.d.ts +14 -0
  25. package/dist/default/MediaGalleryField.d.ts.map +1 -0
  26. package/dist/default/MediaGalleryField.js +65 -0
  27. package/dist/default/MediaGalleryField.js.map +1 -0
  28. package/dist/default/index.d.ts +15 -0
  29. package/dist/default/index.d.ts.map +1 -0
  30. package/dist/default/index.js +13 -0
  31. package/dist/default/index.js.map +1 -0
  32. package/dist/default/phase.d.ts +17 -0
  33. package/dist/default/phase.d.ts.map +1 -0
  34. package/dist/default/phase.js +25 -0
  35. package/dist/default/phase.js.map +1 -0
  36. package/dist/flows/registry.d.ts +28 -0
  37. package/dist/flows/registry.d.ts.map +1 -0
  38. package/dist/flows/registry.js +26 -0
  39. package/dist/flows/registry.js.map +1 -0
  40. package/dist/headless/CdnProvider.d.ts +18 -0
  41. package/dist/headless/CdnProvider.d.ts.map +1 -0
  42. package/dist/headless/CdnProvider.js +14 -0
  43. package/dist/headless/CdnProvider.js.map +1 -0
  44. package/dist/headless/ImageUpload.d.ts +18 -0
  45. package/dist/headless/ImageUpload.d.ts.map +1 -0
  46. package/dist/headless/ImageUpload.js +16 -0
  47. package/dist/headless/ImageUpload.js.map +1 -0
  48. package/dist/headless/MediaUploader.d.ts +31 -0
  49. package/dist/headless/MediaUploader.d.ts.map +1 -0
  50. package/dist/headless/MediaUploader.js +30 -0
  51. package/dist/headless/MediaUploader.js.map +1 -0
  52. package/dist/headless/useUploadImage.d.ts +33 -0
  53. package/dist/headless/useUploadImage.d.ts.map +1 -0
  54. package/dist/headless/useUploadImage.js +118 -0
  55. package/dist/headless/useUploadImage.js.map +1 -0
  56. package/dist/headless/useUploadPreview.d.ts +22 -0
  57. package/dist/headless/useUploadPreview.d.ts.map +1 -0
  58. package/dist/headless/useUploadPreview.js +37 -0
  59. package/dist/headless/useUploadPreview.js.map +1 -0
  60. package/dist/headless/useUploadQueue.d.ts +83 -0
  61. package/dist/headless/useUploadQueue.d.ts.map +1 -0
  62. package/dist/headless/useUploadQueue.js +0 -0
  63. package/dist/headless/useUploadQueue.js.map +1 -0
  64. package/dist/i18n/errorsMap.d.ts +12 -0
  65. package/dist/i18n/errorsMap.d.ts.map +1 -0
  66. package/dist/i18n/errorsMap.js +22 -0
  67. package/dist/i18n/errorsMap.js.map +1 -0
  68. package/dist/i18n/es.d.ts +17 -0
  69. package/dist/i18n/es.d.ts.map +1 -0
  70. package/dist/i18n/es.js +65 -0
  71. package/dist/i18n/es.js.map +1 -0
  72. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  73. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  74. package/dist/i18n/generated/errors.es.gen.js +58 -0
  75. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  76. package/dist/i18n/generated/errors.gen.d.ts +353 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  78. package/dist/i18n/generated/errors.gen.js +180 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -0
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  83. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  84. package/dist/i18n/keys.d.ts +61 -0
  85. package/dist/i18n/keys.d.ts.map +1 -0
  86. package/dist/i18n/keys.js +110 -0
  87. package/dist/i18n/keys.js.map +1 -0
  88. package/dist/i18n/ru.d.ts +21 -0
  89. package/dist/i18n/ru.d.ts.map +1 -0
  90. package/dist/i18n/ru.js +70 -0
  91. package/dist/i18n/ru.js.map +1 -0
  92. package/dist/index.d.ts +89 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +83 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/model/context.d.ts +11 -0
  97. package/dist/model/context.d.ts.map +1 -0
  98. package/dist/model/context.js +15 -0
  99. package/dist/model/context.js.map +1 -0
  100. package/dist/model/hash.d.ts +36 -0
  101. package/dist/model/hash.d.ts.map +1 -0
  102. package/dist/model/hash.js +59 -0
  103. package/dist/model/hash.js.map +1 -0
  104. package/dist/model/limits.d.ts +86 -0
  105. package/dist/model/limits.d.ts.map +1 -0
  106. package/dist/model/limits.js +163 -0
  107. package/dist/model/limits.js.map +1 -0
  108. package/dist/model/queries.d.ts +29 -0
  109. package/dist/model/queries.d.ts.map +1 -0
  110. package/dist/model/queries.js +32 -0
  111. package/dist/model/queries.js.map +1 -0
  112. package/dist/model/queryKeys.d.ts +18 -0
  113. package/dist/model/queryKeys.d.ts.map +1 -0
  114. package/dist/model/queryKeys.js +18 -0
  115. package/dist/model/queryKeys.js.map +1 -0
  116. package/dist/model/refs.d.ts +54 -0
  117. package/dist/model/refs.d.ts.map +1 -0
  118. package/dist/model/refs.js +82 -0
  119. package/dist/model/refs.js.map +1 -0
  120. package/dist/model/runtime.d.ts +40 -0
  121. package/dist/model/runtime.d.ts.map +1 -0
  122. package/dist/model/runtime.js +23 -0
  123. package/dist/model/runtime.js.map +1 -0
  124. package/dist/model/upload.d.ts +88 -0
  125. package/dist/model/upload.d.ts.map +1 -0
  126. package/dist/model/upload.js +254 -0
  127. package/dist/model/upload.js.map +1 -0
  128. package/llms.txt +83 -0
  129. package/manifest.json +601 -0
  130. package/package.json +120 -0
  131. package/src/analytics/generated/events.json +7 -0
  132. package/src/api/cdnApi.ts +187 -0
  133. package/src/api/generated/schema.ts +1052 -0
  134. package/src/api/types.ts +86 -0
  135. package/src/default/ErrorAlert.tsx +43 -0
  136. package/src/default/ImageUploadField.tsx +131 -0
  137. package/src/default/MediaGalleryField.tsx +233 -0
  138. package/src/default/index.ts +14 -0
  139. package/src/default/phase.ts +34 -0
  140. package/src/flows/registry.ts +38 -0
  141. package/src/headless/CdnProvider.tsx +19 -0
  142. package/src/headless/ImageUpload.tsx +24 -0
  143. package/src/headless/MediaUploader.tsx +43 -0
  144. package/src/headless/useUploadImage.ts +155 -0
  145. package/src/headless/useUploadPreview.ts +48 -0
  146. package/src/headless/useUploadQueue.ts +411 -0
  147. package/src/i18n/errorsMap.ts +33 -0
  148. package/src/i18n/es.ts +81 -0
  149. package/src/i18n/generated/errors.es.gen.ts +64 -0
  150. package/src/i18n/generated/errors.gen.ts +211 -0
  151. package/src/i18n/generated/errors.json +403 -0
  152. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  153. package/src/i18n/keys.ts +131 -0
  154. package/src/i18n/ru.ts +85 -0
  155. package/src/index.ts +154 -0
  156. package/src/model/context.tsx +25 -0
  157. package/src/model/hash.ts +67 -0
  158. package/src/model/limits.ts +207 -0
  159. package/src/model/queries.ts +64 -0
  160. package/src/model/queryKeys.ts +23 -0
  161. package/src/model/refs.ts +95 -0
  162. package/src/model/runtime.ts +52 -0
  163. package/src/model/upload.ts +365 -0
  164. package/tsconfig.json +26 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * SHA-256 of a file's bytes — the key the dedup pre-check is asked with.
3
+ *
4
+ * The backend computes the same digest over the same bytes
5
+ * (`Image.calculate_file_hash`, `hashlib.sha256(...).hexdigest()`), so the two
6
+ * agree by construction: 64 lowercase hex characters of the ORIGINAL upload,
7
+ * before any variant exists.
8
+ *
9
+ * ── Why this can be unavailable, and why that is not an error ──────────────
10
+ *
11
+ * `crypto.subtle` exists only in a SECURE CONTEXT. On `http://` (a LAN test
12
+ * box, an old staging host) it is simply not there, and neither is the
13
+ * pre-check. That is a lost optimisation, not a lost upload: the server
14
+ * deduplicates on its own side regardless, so the POST is still correct — it
15
+ * just costs the bytes. {@link canHashLocally} lets the flow say which of the
16
+ * two paths it took instead of failing, and `UploadItem.dedupSkipped` carries
17
+ * the reason all the way to a skin that wants to explain it.
18
+ *
19
+ * ── Why there is no progress here ──────────────────────────────────────────
20
+ *
21
+ * `SubtleCrypto.digest` takes the whole buffer and returns one promise; it
22
+ * reports nothing in between and cannot be chunked without hand-rolling
23
+ * SHA-256, which is not a thing to hand-roll. Hashing is therefore a PHASE
24
+ * with a duration, not a percentage — see `model/upload.ts` on why the whole
25
+ * bag is phase-shaped.
26
+ */
27
+ /** Whether this context can compute the digest at all (see the header). */
28
+ export function canHashLocally() {
29
+ return (typeof globalThis.crypto !== "undefined" &&
30
+ typeof globalThis.crypto.subtle?.digest === "function");
31
+ }
32
+ function toHex(buffer) {
33
+ const bytes = new Uint8Array(buffer);
34
+ let out = "";
35
+ for (const byte of bytes)
36
+ out += byte.toString(16).padStart(2, "0");
37
+ return out;
38
+ }
39
+ /**
40
+ * `digest` is handed a `Uint8Array` VIEW rather than the raw `ArrayBuffer`.
41
+ * Both are valid `BufferSource`, but a buffer that crossed a realm boundary
42
+ * fails a strict `instanceof ArrayBuffer` check in some implementations (jsdom
43
+ * is one, which is where this surfaced), while a typed-array view is accepted
44
+ * everywhere. The view costs nothing — no copy — and removes a portability
45
+ * hazard that would otherwise only show up in somebody else's environment.
46
+ */
47
+ const bufferSource = (buffer) => new Uint8Array(buffer);
48
+ /**
49
+ * The 64-character lowercase hex SHA-256 of `blob`'s bytes.
50
+ *
51
+ * Throws whatever the platform throws when there is no `crypto.subtle` — call
52
+ * {@link canHashLocally} first; the upload flow does.
53
+ */
54
+ export async function sha256Hex(blob) {
55
+ const buffer = await blob.arrayBuffer();
56
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bufferSource(buffer));
57
+ return toHex(digest);
58
+ }
59
+ //# sourceMappingURL=hash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.js","sourceRoot":"","sources":["../../src/model/hash.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,2EAA2E;AAC3E,MAAM,UAAU,cAAc;IAC5B,OAAO,CACL,OAAO,UAAU,CAAC,MAAM,KAAK,WAAW;QACxC,OAAO,UAAU,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,KAAK,UAAU,CACvD,CAAC;AACJ,CAAC;AAED,SAAS,KAAK,CAAC,MAAmB;IAChC,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;IACrC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACpE,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,YAAY,GAAG,CAAC,MAAmB,EAA2B,EAAE,CACpE,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAU;IACxC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;IACxC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAClD,SAAS,EACT,YAAY,CAAC,MAAM,CAAC,CACrB,CAAC;IACF,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC;AACvB,CAAC"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The client-side mirror of stapel-cdn's own intake gates.
3
+ *
4
+ * ── Why mirror at all ──────────────────────────────────────────────────────
5
+ *
6
+ * Because the alternative is uploading 40 MB over a phone connection to be
7
+ * told "File is too large" by the server that just received all of it. The
8
+ * gates below are the cheap half of what `_validate_image_upload` does before
9
+ * it hashes a byte (`stapel_cdn/views.py`): the size ceiling and the extension
10
+ * allowlist. The expensive half — an actual libvips decode, which is what
11
+ * catches a `.jpg` full of HTML — has no browser equivalent and is not
12
+ * simulated here. A file that passes this mirror can still be refused, and
13
+ * that refusal renders through the same error contour as any other.
14
+ *
15
+ * ── The rule the mirror must never break ───────────────────────────────────
16
+ *
17
+ * A MIRROR THAT REFUSES WHAT THE SERVER WOULD ACCEPT IS WORSE THAN NO MIRROR.
18
+ * It blocks a valid upload with no way for the person to appeal, and the
19
+ * server never even hears about it. So every ceiling here is CONFIGURABLE on
20
+ * the runtime and defaults to the library's own default — because all of them
21
+ * are `STAPEL_CDN` settings a deployment moves (`stapel_cdn/conf.py`), and a
22
+ * hardcoded 20 MB would be a client-side lie on a host that raised it. The
23
+ * same argument in the opposite direction: a host that LOWERED the ceiling
24
+ * passes its number here and gets the refusal early, which is the point.
25
+ *
26
+ * The refusals below are raised with stapel-cdn's OWN error codes, so a skin
27
+ * renders "File is too large" from the generated bundle whether the sentence
28
+ * came from here or from the server. There is no second vocabulary for
29
+ * client-side refusals.
30
+ */
31
+ import { StapelApiError } from "@stapel/core";
32
+ /** `error.413.file_too_large` — the byte ceiling, from `MAX_*_SIZE`. */
33
+ export declare const ERROR_FILE_TOO_LARGE = "error.413.file_too_large";
34
+ /** `error.400.invalid_format` — the extension allowlist. */
35
+ export declare const ERROR_INVALID_FORMAT = "error.400.invalid_format";
36
+ /** `error.400.no_file` — an empty pick. */
37
+ export declare const ERROR_NO_FILE = "error.400.no_file";
38
+ /**
39
+ * The ceilings for one intake kind. Defaults reproduce `stapel_cdn/conf.py`'s
40
+ * `DEFAULTS` at the pinned contract (v0.12.0).
41
+ */
42
+ export interface CdnIntakeLimits {
43
+ /** Byte ceiling. `STAPEL_CDN["MAX_IMAGE_SIZE" | …]`. */
44
+ readonly maxBytes: number;
45
+ /** Lowercase extensions INCLUDING the dot, as the backend stores them. */
46
+ readonly extensions: readonly string[];
47
+ /**
48
+ * MIME types the backend narrows on, when it narrows on MIME at all. Only
49
+ * the generic-file intake does (`ALLOWED_FILE_MIME_TYPES`); images and
50
+ * videos are gated on the extension plus a decode/sniff, so this is
51
+ * `undefined` for them rather than a guess. An empty array would mean
52
+ * "nothing is allowed", which is a different and wrong statement.
53
+ */
54
+ readonly mimeTypes?: readonly string[];
55
+ }
56
+ export interface CdnLimits {
57
+ readonly image: CdnIntakeLimits;
58
+ readonly video: CdnIntakeLimits;
59
+ readonly file: CdnIntakeLimits;
60
+ }
61
+ /** stapel-cdn's library defaults — the mirror's starting point. */
62
+ export declare const CDN_DEFAULT_LIMITS: CdnLimits;
63
+ /** Host overrides — every field optional, merged over the defaults. */
64
+ export interface CdnLimitsOverride {
65
+ readonly image?: Partial<CdnIntakeLimits>;
66
+ readonly video?: Partial<CdnIntakeLimits>;
67
+ readonly file?: Partial<CdnIntakeLimits>;
68
+ }
69
+ export declare function resolveCdnLimits(override?: CdnLimitsOverride): CdnLimits;
70
+ /** The lowercase extension of a filename, dot included, or `""`. */
71
+ export declare function fileExtension(name: string): string;
72
+ /**
73
+ * The `accept` attribute for a file input, built from the same allowlist the
74
+ * refusal is built from — so the picker and the gate cannot disagree.
75
+ */
76
+ export declare function acceptAttribute(limits: CdnIntakeLimits): string;
77
+ /**
78
+ * Check one file against one intake's ceilings.
79
+ *
80
+ * Returns the refusal as a {@link StapelApiError} in stapel-cdn's own
81
+ * vocabulary, or `null` when the file may be sent. Never throws: the caller
82
+ * (a queue admitting ten files at once) needs a per-file verdict, not a
83
+ * control-flow interruption on the first bad one.
84
+ */
85
+ export declare function validateFile(file: File, limits: CdnIntakeLimits): StapelApiError | null;
86
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/model/limits.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,wEAAwE;AACxE,eAAO,MAAM,oBAAoB,6BAA6B,CAAC;AAC/D,4DAA4D;AAC5D,eAAO,MAAM,oBAAoB,6BAA6B,CAAC;AAC/D,2CAA2C;AAC3C,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAEjD;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;CAChC;AAID,mEAAmE;AACnE,eAAO,MAAM,kBAAkB,EAAE,SA+BhC,CAAC;AAEF,uEAAuE;AACvE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAC1C,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAC1C,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;CAC1C;AAkBD,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAMxE;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIlD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAI/D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,eAAe,GACtB,cAAc,GAAG,IAAI,CA+CvB"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The client-side mirror of stapel-cdn's own intake gates.
3
+ *
4
+ * ── Why mirror at all ──────────────────────────────────────────────────────
5
+ *
6
+ * Because the alternative is uploading 40 MB over a phone connection to be
7
+ * told "File is too large" by the server that just received all of it. The
8
+ * gates below are the cheap half of what `_validate_image_upload` does before
9
+ * it hashes a byte (`stapel_cdn/views.py`): the size ceiling and the extension
10
+ * allowlist. The expensive half — an actual libvips decode, which is what
11
+ * catches a `.jpg` full of HTML — has no browser equivalent and is not
12
+ * simulated here. A file that passes this mirror can still be refused, and
13
+ * that refusal renders through the same error contour as any other.
14
+ *
15
+ * ── The rule the mirror must never break ───────────────────────────────────
16
+ *
17
+ * A MIRROR THAT REFUSES WHAT THE SERVER WOULD ACCEPT IS WORSE THAN NO MIRROR.
18
+ * It blocks a valid upload with no way for the person to appeal, and the
19
+ * server never even hears about it. So every ceiling here is CONFIGURABLE on
20
+ * the runtime and defaults to the library's own default — because all of them
21
+ * are `STAPEL_CDN` settings a deployment moves (`stapel_cdn/conf.py`), and a
22
+ * hardcoded 20 MB would be a client-side lie on a host that raised it. The
23
+ * same argument in the opposite direction: a host that LOWERED the ceiling
24
+ * passes its number here and gets the refusal early, which is the point.
25
+ *
26
+ * The refusals below are raised with stapel-cdn's OWN error codes, so a skin
27
+ * renders "File is too large" from the generated bundle whether the sentence
28
+ * came from here or from the server. There is no second vocabulary for
29
+ * client-side refusals.
30
+ */
31
+ import { StapelApiError } from "@stapel/core";
32
+ /** `error.413.file_too_large` — the byte ceiling, from `MAX_*_SIZE`. */
33
+ export const ERROR_FILE_TOO_LARGE = "error.413.file_too_large";
34
+ /** `error.400.invalid_format` — the extension allowlist. */
35
+ export const ERROR_INVALID_FORMAT = "error.400.invalid_format";
36
+ /** `error.400.no_file` — an empty pick. */
37
+ export const ERROR_NO_FILE = "error.400.no_file";
38
+ const MB = 1024 * 1024;
39
+ /** stapel-cdn's library defaults — the mirror's starting point. */
40
+ export const CDN_DEFAULT_LIMITS = {
41
+ image: {
42
+ maxBytes: 20 * MB,
43
+ extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".heif"],
44
+ },
45
+ video: {
46
+ maxBytes: 100 * MB,
47
+ extensions: [".mp4", ".webm", ".mov", ".avi", ".mkv"],
48
+ },
49
+ file: {
50
+ maxBytes: 50 * MB,
51
+ extensions: [
52
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
53
+ ".txt", ".csv", ".zip", ".rar", ".7z", ".gz",
54
+ ],
55
+ mimeTypes: [
56
+ "application/pdf",
57
+ "application/msword",
58
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
59
+ "application/vnd.ms-excel",
60
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
61
+ "application/vnd.ms-powerpoint",
62
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
63
+ "text/plain",
64
+ "text/csv",
65
+ "application/zip",
66
+ "application/x-rar-compressed",
67
+ "application/x-7z-compressed",
68
+ "application/gzip",
69
+ ],
70
+ },
71
+ };
72
+ function mergeIntake(base, override) {
73
+ if (override === undefined)
74
+ return base;
75
+ return {
76
+ maxBytes: override.maxBytes ?? base.maxBytes,
77
+ extensions: override.extensions ?? base.extensions,
78
+ ...(override.mimeTypes !== undefined
79
+ ? { mimeTypes: override.mimeTypes }
80
+ : base.mimeTypes !== undefined
81
+ ? { mimeTypes: base.mimeTypes }
82
+ : {}),
83
+ };
84
+ }
85
+ export function resolveCdnLimits(override) {
86
+ return {
87
+ image: mergeIntake(CDN_DEFAULT_LIMITS.image, override?.image),
88
+ video: mergeIntake(CDN_DEFAULT_LIMITS.video, override?.video),
89
+ file: mergeIntake(CDN_DEFAULT_LIMITS.file, override?.file),
90
+ };
91
+ }
92
+ /** The lowercase extension of a filename, dot included, or `""`. */
93
+ export function fileExtension(name) {
94
+ const dot = name.lastIndexOf(".");
95
+ if (dot <= 0 || dot === name.length - 1)
96
+ return "";
97
+ return name.slice(dot).toLowerCase();
98
+ }
99
+ /**
100
+ * The `accept` attribute for a file input, built from the same allowlist the
101
+ * refusal is built from — so the picker and the gate cannot disagree.
102
+ */
103
+ export function acceptAttribute(limits) {
104
+ const parts = [...limits.extensions];
105
+ if (limits.mimeTypes !== undefined)
106
+ parts.push(...limits.mimeTypes);
107
+ return parts.join(",");
108
+ }
109
+ /**
110
+ * Check one file against one intake's ceilings.
111
+ *
112
+ * Returns the refusal as a {@link StapelApiError} in stapel-cdn's own
113
+ * vocabulary, or `null` when the file may be sent. Never throws: the caller
114
+ * (a queue admitting ten files at once) needs a per-file verdict, not a
115
+ * control-flow interruption on the first bad one.
116
+ */
117
+ export function validateFile(file, limits) {
118
+ if (file.size === 0) {
119
+ return new StapelApiError({
120
+ code: ERROR_NO_FILE,
121
+ message: "No file provided",
122
+ status: 400,
123
+ });
124
+ }
125
+ if (file.size > limits.maxBytes) {
126
+ return new StapelApiError({
127
+ code: ERROR_FILE_TOO_LARGE,
128
+ message: "File is too large",
129
+ // The server answers 413 for this; the mirror says the same number so a
130
+ // host branching on `status` cannot tell the two apart — which is the
131
+ // intent. `params` carries what the sentence would need to be specific,
132
+ // even though the backend's own copy has no slots today.
133
+ status: 413,
134
+ params: { max_bytes: limits.maxBytes, size: file.size },
135
+ });
136
+ }
137
+ const extension = fileExtension(file.name);
138
+ if (!limits.extensions.includes(extension)) {
139
+ return new StapelApiError({
140
+ code: ERROR_INVALID_FORMAT,
141
+ message: "Unsupported file format",
142
+ status: 400,
143
+ params: { extension, allowed: limits.extensions.join(", ") },
144
+ });
145
+ }
146
+ // MIME is checked only where the backend checks it, and only when the
147
+ // browser actually supplied one. A blank `file.type` is common (an
148
+ // extension the OS does not know) and is NOT evidence of a bad file — the
149
+ // backend reads the declared Content-Type the same way, as a narrowing
150
+ // device rather than a verdict.
151
+ if (limits.mimeTypes !== undefined &&
152
+ file.type.length > 0 &&
153
+ !limits.mimeTypes.includes(file.type)) {
154
+ return new StapelApiError({
155
+ code: ERROR_INVALID_FORMAT,
156
+ message: "Unsupported file format",
157
+ status: 400,
158
+ params: { mime: file.type },
159
+ });
160
+ }
161
+ return null;
162
+ }
163
+ //# sourceMappingURL=limits.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.js","sourceRoot":"","sources":["../../src/model/limits.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import type { LoadState } from "@stapel/core";
2
+ import type { CdnImage, CdnRef } from "../api/types.js";
3
+ /**
4
+ * Resolve a stored `<type>/<hash>` reference back to the CDN row — the read
5
+ * that lets a composer REOPEN a draft and show what is already attached to it.
6
+ *
7
+ * ── The scope this read has, stated rather than discovered ─────────────────
8
+ *
9
+ * `file/exists/` filters on `uploaded_by=request.user`, always. So this
10
+ * resolves the CALLER'S OWN references and nothing else: it is the right
11
+ * instrument for "my draft's photos" and the wrong one for "this seller's
12
+ * photos". A buyer's storefront renders a listing's images from what the
13
+ * listings API gives it, not from here — stapel-cdn exposes no public
14
+ * read-by-reference endpoint at all (recorded as an upstream gap in the
15
+ * package README).
16
+ *
17
+ * A reference that resolves to nothing answers `ready(null)`, not `failed`:
18
+ * `{exists: false}` is a 200 and a true answer. "This reference is not mine /
19
+ * no longer stored" and "we could not ask" are different sentences and a skin
20
+ * must be able to tell them apart — which is why the value behind `ready` is
21
+ * nullable and the failure lives in the discriminant.
22
+ */
23
+ export interface CdnRefBag {
24
+ readonly state: LoadState<CdnImage | null>;
25
+ readonly isFetching: boolean;
26
+ refetch(): void;
27
+ }
28
+ export declare function useCdnRef(ref: CdnRef | null | undefined): CdnRefBag;
29
+ //# sourceMappingURL=queries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.d.ts","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAyB,QAAQ,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAK/E;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IAC3C,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CA6BnE"}
@@ -0,0 +1,32 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { loadStateFromQuery, useActiveSessionReady } from "@stapel/core";
3
+ import { useCdnApi } from "./context.js";
4
+ import { cdnQueryKeys } from "./queryKeys.js";
5
+ import { parseCdnRef } from "./refs.js";
6
+ export function useCdnRef(ref) {
7
+ const api = useCdnApi();
8
+ // Gated on the session for the reason core's own doc comment gives: the
9
+ // endpoint is `IsAuthenticated`, so firing it during the login bootstrap
10
+ // buys one guaranteed 401 per mounted thumbnail.
11
+ const sessionReady = useActiveSessionReady();
12
+ const parsed = ref === null || ref === undefined ? null : parseCdnRef(ref);
13
+ const query = useQuery({
14
+ queryKey: cdnQueryKeys.exists(parsed?.fileHash ?? ""),
15
+ queryFn: ({ signal }) => api.fileExists(parsed?.fileHash ?? "", { signal }),
16
+ enabled: sessionReady && parsed !== null,
17
+ // A content-addressed row does not change. Its VARIANTS do, once, when the
18
+ // background task finishes — the upload flow waits for that itself, so a
19
+ // refetch here would only re-ask a settled question.
20
+ staleTime: Number.POSITIVE_INFINITY,
21
+ retry: false,
22
+ select: (data) => data.exists && data.type === "image" && data.file !== null
23
+ ? data.file
24
+ : null,
25
+ });
26
+ return {
27
+ state: loadStateFromQuery(query),
28
+ isFetching: query.isFetching,
29
+ refetch: () => void query.refetch(),
30
+ };
31
+ }
32
+ //# sourceMappingURL=queries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queries.js","sourceRoot":"","sources":["../../src/model/queries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAGzE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AA4BxC,MAAM,UAAU,SAAS,CAAC,GAA8B;IACtD,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC;IACxB,wEAAwE;IACxE,yEAAyE;IACzE,iDAAiD;IACjD,MAAM,YAAY,GAAG,qBAAqB,EAAE,CAAC;IAC7C,MAAM,MAAM,GAAG,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAE3E,MAAM,KAAK,GAAG,QAAQ,CAAC;QACrB,QAAQ,EAAE,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,IAAI,EAAE,CAAC;QACrD,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,EAAkC,EAAE,CACtD,GAAG,CAAC,UAAU,CAAC,MAAM,EAAE,QAAQ,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC;QACpD,OAAO,EAAE,YAAY,IAAI,MAAM,KAAK,IAAI;QACxC,2EAA2E;QAC3E,yEAAyE;QACzE,qDAAqD;QACrD,SAAS,EAAE,MAAM,CAAC,iBAAiB;QACnC,KAAK,EAAE,KAAK;QACZ,MAAM,EAAE,CAAC,IAAI,EAAmB,EAAE,CAChC,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,IAAI,KAAK,OAAO,IAAI,IAAI,CAAC,IAAI,KAAK,IAAI;YACxD,CAAC,CAAE,IAAI,CAAC,IAAiB;YACzB,CAAC,CAAC,IAAI;KACX,CAAC,CAAC;IAEH,OAAO;QACL,KAAK,EAAE,kBAAkB,CAAC,KAAK,CAAC;QAChC,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE;KACpC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2). Everything under the
3
+ * `"cdn"` root so a host can invalidate the whole module or match a single
4
+ * read.
5
+ *
6
+ * There is exactly one cached read in this pair, and it is keyed on the CONTENT
7
+ * HASH rather than on the reference string. Two references that differ only in
8
+ * asset type (`avatar/<h>` and `product/<h>`) resolve through the same
9
+ * `file/exists/` call and must not fetch twice; and because the hash IS the
10
+ * identity of the bytes, a key built from it can never go stale in the way a
11
+ * mutable row's key can. Uploads are mutations and cache nothing.
12
+ */
13
+ export declare const cdnQueryKeys: {
14
+ readonly all: readonly ["cdn"];
15
+ /** The owner-scoped `file/exists/` read for one content hash. */
16
+ exists(fileHash: string): readonly ["cdn", "exists", string];
17
+ };
18
+ //# sourceMappingURL=queryKeys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryKeys.d.ts","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,eAAO,MAAM,YAAY,EAAE;IACzB,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;IAC/B,iEAAiE;IACjE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;CAI9D,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2). Everything under the
3
+ * `"cdn"` root so a host can invalidate the whole module or match a single
4
+ * read.
5
+ *
6
+ * There is exactly one cached read in this pair, and it is keyed on the CONTENT
7
+ * HASH rather than on the reference string. Two references that differ only in
8
+ * asset type (`avatar/<h>` and `product/<h>`) resolve through the same
9
+ * `file/exists/` call and must not fetch twice; and because the hash IS the
10
+ * identity of the bytes, a key built from it can never go stale in the way a
11
+ * mutable row's key can. Uploads are mutations and cache nothing.
12
+ */
13
+ const ROOT = "cdn";
14
+ export const cdnQueryKeys = {
15
+ all: [ROOT],
16
+ exists: (fileHash) => [ROOT, "exists", fileHash],
17
+ };
18
+ //# sourceMappingURL=queryKeys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryKeys.js","sourceRoot":"","sources":["../../src/model/queryKeys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,IAAI,GAAG,KAAc,CAAC;AAE5B,MAAM,CAAC,MAAM,YAAY,GAIrB;IACF,GAAG,EAAE,CAAC,IAAI,CAAC;IACX,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC;CACjD,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The `<type>/<hash>` reference — the unit this pair hands out — and the one
3
+ * conversion between stapel-cdn's variant ladder and `@stapel/image`'s.
4
+ *
5
+ * `Profile.avatar` stores a reference. `Listing.images_draft` stores a list of
6
+ * them ("Opaque list of CDN image references", `stapel_listings/models.py`).
7
+ * Neither stores a URL, and this pair does not invent one: the reference is
8
+ * content-addressed and opaque, and the URLs live on the row the CDN returns.
9
+ */
10
+ import type { StapelImage } from "@stapel/image";
11
+ import type { CdnImage, CdnRef, ParsedCdnRef } from "../api/types.js";
12
+ /** Build the reference for an asset type and a content hash. */
13
+ export declare function formatCdnRef(assetType: string, fileHash: string): CdnRef;
14
+ /**
15
+ * Split a reference into its halves, or `null` when the string is not one.
16
+ *
17
+ * Strict about the hash (64 lowercase hex — what the backend writes) and
18
+ * deliberately loose about the type, which is whatever this deployment put in
19
+ * `STAPEL_CDN["ASSET_TYPES"]` plus the hardcoded `product` of the general
20
+ * image endpoint. Rejecting an unfamiliar type here would refuse a host's own
21
+ * configuration.
22
+ */
23
+ export declare function parseCdnRef(ref: CdnRef): ParsedCdnRef | null;
24
+ /**
25
+ * Convert an uploaded image row into the source-agnostic descriptor
26
+ * `@stapel/image`'s `<Image>` consumes.
27
+ *
28
+ * THE TWO CONTRACTS DISAGREE ON ONE FIELD AND THIS IS THE ONLY PLACE THAT
29
+ * KNOWS. stapel-cdn's `variants_meta[].tier` is an integer; `@stapel/image`
30
+ * reads the `stapel_core.media.dto` form, where `tier` is a decimal STRING
31
+ * with an `"original"` sentinel. Converting at the boundary costs one map and
32
+ * keeps both sides honest; teaching either one the other's shape would put a
33
+ * conditional in every renderer.
34
+ *
35
+ * `branch` is optional upstream and absent for thumbnail-class tiers; the
36
+ * renderer's contract wants an explicit `null` there, which is the same
37
+ * statement said out loud.
38
+ *
39
+ * The type import above is TYPE-ONLY and `@stapel/image` is an OPTIONAL peer:
40
+ * a host that renders its own images carries no dependency on it and this
41
+ * function still typechecks, because nothing of it survives to runtime.
42
+ */
43
+ export declare function toStapelImage(image: CdnImage): StapelImage;
44
+ /**
45
+ * The reference for an uploaded image row.
46
+ *
47
+ * Reads `prefix` — the serializer's own `<type>/<hash>` — rather than
48
+ * rebuilding it from `type` and `file_hash`, so a host that reconfigures the
49
+ * layout upstream does not get a reference this pair invented.
50
+ */
51
+ export declare function refOf(image: {
52
+ readonly prefix: string;
53
+ }): CdnRef;
54
+ //# sourceMappingURL=refs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refs.d.ts","sourceRoot":"","sources":["../../src/model/refs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,eAAe,CAAC;AAC9D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAItE,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAExE;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAO5D;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,QAAQ,GAAG,WAAW,CA2B1D;AAED;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAEhE"}
@@ -0,0 +1,82 @@
1
+ const HASH = /^[0-9a-f]{64}$/;
2
+ /** Build the reference for an asset type and a content hash. */
3
+ export function formatCdnRef(assetType, fileHash) {
4
+ return `${assetType}/${fileHash}`;
5
+ }
6
+ /**
7
+ * Split a reference into its halves, or `null` when the string is not one.
8
+ *
9
+ * Strict about the hash (64 lowercase hex — what the backend writes) and
10
+ * deliberately loose about the type, which is whatever this deployment put in
11
+ * `STAPEL_CDN["ASSET_TYPES"]` plus the hardcoded `product` of the general
12
+ * image endpoint. Rejecting an unfamiliar type here would refuse a host's own
13
+ * configuration.
14
+ */
15
+ export function parseCdnRef(ref) {
16
+ const slash = ref.indexOf("/");
17
+ if (slash <= 0)
18
+ return null;
19
+ const assetType = ref.slice(0, slash);
20
+ const fileHash = ref.slice(slash + 1);
21
+ if (!HASH.test(fileHash))
22
+ return null;
23
+ return { assetType, fileHash };
24
+ }
25
+ /**
26
+ * Convert an uploaded image row into the source-agnostic descriptor
27
+ * `@stapel/image`'s `<Image>` consumes.
28
+ *
29
+ * THE TWO CONTRACTS DISAGREE ON ONE FIELD AND THIS IS THE ONLY PLACE THAT
30
+ * KNOWS. stapel-cdn's `variants_meta[].tier` is an integer; `@stapel/image`
31
+ * reads the `stapel_core.media.dto` form, where `tier` is a decimal STRING
32
+ * with an `"original"` sentinel. Converting at the boundary costs one map and
33
+ * keeps both sides honest; teaching either one the other's shape would put a
34
+ * conditional in every renderer.
35
+ *
36
+ * `branch` is optional upstream and absent for thumbnail-class tiers; the
37
+ * renderer's contract wants an explicit `null` there, which is the same
38
+ * statement said out loud.
39
+ *
40
+ * The type import above is TYPE-ONLY and `@stapel/image` is an OPTIONAL peer:
41
+ * a host that renders its own images carries no dependency on it and this
42
+ * function still typechecks, because nothing of it survives to runtime.
43
+ */
44
+ export function toStapelImage(image) {
45
+ const variants = image.variants_meta.map((variant) => ({
46
+ tier: String(variant.tier),
47
+ branch: variant.branch ?? null,
48
+ url: variant.url,
49
+ width: variant.width,
50
+ height: variant.height,
51
+ }));
52
+ const width = image.original_width;
53
+ const height = image.original_height;
54
+ return {
55
+ source: "cdn",
56
+ url: image.original_url,
57
+ // The CDN row carries the extension, not the MIME type, and the variants
58
+ // are all WebP regardless of what came in. Saying `null` is the true
59
+ // statement; guessing a MIME from the extension would be the wrong one.
60
+ mime: null,
61
+ width,
62
+ height,
63
+ aspect: height > 0 ? width / height : null,
64
+ square: width === height,
65
+ // stapel-cdn generates no inline blur placeholder. `<Image>` degrades to
66
+ // the aspect box, which is the layout-shift protection that actually
67
+ // matters here.
68
+ preview_b64: null,
69
+ variants,
70
+ };
71
+ }
72
+ /**
73
+ * The reference for an uploaded image row.
74
+ *
75
+ * Reads `prefix` — the serializer's own `<type>/<hash>` — rather than
76
+ * rebuilding it from `type` and `file_hash`, so a host that reconfigures the
77
+ * layout upstream does not get a reference this pair invented.
78
+ */
79
+ export function refOf(image) {
80
+ return image.prefix;
81
+ }
82
+ //# sourceMappingURL=refs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refs.js","sourceRoot":"","sources":["../../src/model/refs.ts"],"names":[],"mappings":"AAYA,MAAM,IAAI,GAAG,gBAAgB,CAAC;AAE9B,gEAAgE;AAChE,MAAM,UAAU,YAAY,CAAC,SAAiB,EAAE,QAAgB;IAC9D,OAAO,GAAG,SAAS,IAAI,QAAQ,EAAE,CAAC;AACpC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACtC,MAAM,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;IACtC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,aAAa,CAAC,KAAe;IAC3C,MAAM,QAAQ,GAAkB,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACpE,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC;QAC1B,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI;QAC9B,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,MAAM,EAAE,OAAO,CAAC,MAAM;KACvB,CAAC,CAAC,CAAC;IACJ,MAAM,KAAK,GAAG,KAAK,CAAC,cAAc,CAAC;IACnC,MAAM,MAAM,GAAG,KAAK,CAAC,eAAe,CAAC;IACrC,OAAO;QACL,MAAM,EAAE,KAAK;QACb,GAAG,EAAE,KAAK,CAAC,YAAY;QACvB,yEAAyE;QACzE,qEAAqE;QACrE,wEAAwE;QACxE,IAAI,EAAE,IAAI;QACV,KAAK;QACL,MAAM;QACN,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI;QAC1C,MAAM,EAAE,KAAK,KAAK,MAAM;QACxB,yEAAyE;QACzE,qEAAqE;QACrE,gBAAgB;QAChB,WAAW,EAAE,IAAI;QACjB,QAAQ;KACT,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,KAAK,CAAC,KAAkC;IACtD,OAAO,KAAK,CAAC,MAAM,CAAC;AACtB,CAAC"}
@@ -0,0 +1,40 @@
1
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
2
+ import type { CdnApi } from "../api/cdnApi.js";
3
+ import type { CdnLimits, CdnLimitsOverride } from "./limits.js";
4
+ import type { CdnVariantWaitOptions } from "./upload.js";
5
+ /**
6
+ * The wired CDN runtime — core's `ModuleRuntime` bound to this pair's API,
7
+ * plus the two things an upload pair has and a plain read pair does not: the
8
+ * DEPLOYMENT's intake ceilings, and how long to wait for a variant ladder.
9
+ *
10
+ * Both are runtime configuration rather than module state, so they ride here
11
+ * instead of through a second provider — the same argument chat-react makes
12
+ * for its socket URL.
13
+ */
14
+ export type CdnRuntime = ModuleRuntime<CdnApi> & {
15
+ readonly limits: CdnLimits;
16
+ readonly variants: CdnVariantWaitOptions | undefined;
17
+ };
18
+ export interface CreateCdnRuntimeOptions extends CreateModuleRuntimeOptions {
19
+ /**
20
+ * Override the client-side mirror of `STAPEL_CDN`'s ceilings. Defaults to
21
+ * the library's own defaults — see `model/limits.ts` for why this is a knob
22
+ * and not a constant.
23
+ */
24
+ readonly limits?: CdnLimitsOverride;
25
+ /** How long the flow waits for the variant ladder after a store. */
26
+ readonly variants?: CdnVariantWaitOptions;
27
+ }
28
+ /**
29
+ * ```tsx
30
+ * const runtime = createCdnRuntime({ baseUrl: "/cdn/api/v1/" });
31
+ * <CdnProvider runtime={runtime}>{app}</CdnProvider>
32
+ * ```
33
+ *
34
+ * NOT ANONYMOUS. Every endpoint this pair calls needs at least a guest
35
+ * identity (`IsNotAnonymousUser`), and the avatar intake and the dedup
36
+ * pre-check need a real session (`IsAuthenticated`). A storefront mounts this
37
+ * behind its member routes; the public catalogue never touches it.
38
+ */
39
+ export declare function createCdnRuntime(options: CreateCdnRuntimeOptions): CdnRuntime;
40
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/model/runtime.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE9E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE/C,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,GAAG;IAC/C,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,qBAAqB,GAAG,SAAS,CAAC;CACtD,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,0BAA0B;IACzE;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IACpC,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,qBAAqB,CAAC;CAC3C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,UAAU,CAO7E"}