@12-apps/storage 1.0.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.
@@ -0,0 +1,57 @@
1
+ import { StorageNotConfiguredError } from '../problems';
2
+
3
+ /**
4
+ * The public URL of an object in an S3-compatible bucket.
5
+ *
6
+ * Pure, and in `./server` rather than in `./s3`, for two reasons: a host that
7
+ * serves display URLs from a CDN needs the same builder without the SDK, and
8
+ * every branch below is a real deployment that got this wrong once.
9
+ */
10
+ export interface RemoteObjectUrlConfig {
11
+ bucket: string;
12
+ /** AWS region — the only thing needed for an AWS-hosted bucket. */
13
+ region?: string;
14
+ /** Endpoint of a non-AWS S3-compatible store (Spaces, MinIO, R2…). */
15
+ endpoint?: string;
16
+ /** Path-style addressing, for stores that cannot do virtual-hosted. */
17
+ forcePathStyle?: boolean;
18
+ /** A CDN in front of the bucket. Wins over everything else. */
19
+ publicBaseUrl?: string;
20
+ }
21
+
22
+ /**
23
+ * Config that cannot produce a URL at all.
24
+ *
25
+ * Raised at MOUNT time rather than at render time: a driver that returned the
26
+ * bare key here would put a relative path in an `<img src>`, which resolves
27
+ * against the app's own origin and 404s — a broken image with no error anywhere,
28
+ * for every product in the catalog.
29
+ */
30
+ export function assertRemoteUrlConfig(config: RemoteObjectUrlConfig): void {
31
+ if (!config.bucket) {
32
+ throw new StorageNotConfiguredError('An S3-compatible driver needs a bucket.');
33
+ }
34
+ if (!config.region && !config.endpoint && !config.publicBaseUrl) {
35
+ throw new StorageNotConfiguredError(
36
+ 'An S3-compatible driver needs a region, an endpoint or a publicBaseUrl to build display URLs.',
37
+ );
38
+ }
39
+ }
40
+
41
+ export function remoteObjectUrl(config: RemoteObjectUrlConfig, key: string): string {
42
+ if (config.publicBaseUrl) {
43
+ return `${config.publicBaseUrl.replace(/\/$/, '')}/${key}`;
44
+ }
45
+ if (config.endpoint) {
46
+ const base = config.endpoint.replace(/\/$/, '');
47
+ if (config.forcePathStyle) {
48
+ // Path-style, matching how such a store addresses objects. The endpoint's
49
+ // own scheme is kept: a local http://localhost:9000 must not be rewritten
50
+ // to https.
51
+ return `${base}/${config.bucket}/${key}`;
52
+ }
53
+ // Virtual-hosted style on the S3-compatible endpoint (Spaces and friends).
54
+ return `https://${config.bucket}.${base.replace(/^https?:\/\//i, '')}/${key}`;
55
+ }
56
+ return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
57
+ }
@@ -0,0 +1,189 @@
1
+ import { CONTENT_TYPE_BY_EXTENSION, acceptedContentTypeOf } from '../content-types';
2
+ import { parseObjectMemberKey } from '../keys';
3
+ import { STORAGE_PATHS } from '../paths';
4
+ import { isStorageProblem, type StorageMessages } from '../problems';
5
+ import type { StorageDriver } from './driver';
6
+ import { readBodyCapped } from './read-body';
7
+ import { storeImage, type StoreImageDeps } from './store-image';
8
+
9
+ /**
10
+ * The endpoints, as framework-neutral descriptors. There are exactly two, and
11
+ * neither hands a caller an address to upload to.
12
+ *
13
+ * POST <mount>/uploads/image the ONE way a browser stores bytes
14
+ * GET <mount>/uploads/local/… how an object is read back
15
+ *
16
+ * ## Why one POST, and why never a presigned URL
17
+ *
18
+ * An earlier contract had the browser ask for an ADDRESS and then PUT to it. Two
19
+ * requests — and the key had to be minted at the first, before anybody had seen
20
+ * the bytes, which means PREDICTING whether crops will exist. The prediction
21
+ * needed flags and special cases to stay true and a 422 for when it did not.
22
+ *
23
+ * The obvious form of that second hop is a presigned bucket URL, and it is the
24
+ * one thing this package must never offer. A `PUT` with a `Content-Type` is never
25
+ * a "simple" request, so the browser sends an `OPTIONS` preflight; a freshly
26
+ * created bucket answers it with 403 and no `Access-Control-Allow-*`, and the
27
+ * upload is never sent. THE SERVER SEES NOTHING — no request, no log line, no
28
+ * error rate. The browser cannot say why, because a blocked cross-origin fetch is
29
+ * deliberately indistinguishable from an offline network. And the person who sees
30
+ * the message is a store owner, about a bucket only an operator can configure.
31
+ *
32
+ * One same-origin POST removes the second hop, the prediction and the CORS class
33
+ * together. It is also the only shape that CAN work now: a stored photo is a set
34
+ * of objects the server derives from the bytes, so an upload the server never
35
+ * sees cannot produce one.
36
+ */
37
+
38
+ /**
39
+ * Who is calling — resolved by the HOST and never computed here.
40
+ *
41
+ * `mayUpload` is one boolean on purpose. Two independent questions feed it —
42
+ * "may this person store objects?" (roles, memberships, plan) and "may this
43
+ * SESSION change anything?" (a read-only impersonation, a maintenance mode) — and
44
+ * both are the host's, answered before it delegates. A package that tried to
45
+ * compute either would be wrong for every host but the first.
46
+ */
47
+ export interface StorageActor {
48
+ /** The tenant whose objects this call may touch — the key's scope segment. */
49
+ scope: string;
50
+ mayUpload: boolean;
51
+ }
52
+
53
+ export interface StorageRouteContext {
54
+ actor: StorageActor | null;
55
+ params: Record<string, string | undefined>;
56
+ request: Request;
57
+ }
58
+
59
+ /** A JSON answer, an object's bytes, or a redirect to where the object lives. */
60
+ export type StorageRouteResponse =
61
+ | { status: number; body: unknown }
62
+ | { status: number; bytes: Uint8Array; headers: Record<string, string> }
63
+ | { status: number; redirect: string };
64
+
65
+ export interface StorageRoute {
66
+ method: 'GET' | 'POST';
67
+ /** Path suffix, relative to the mount. */
68
+ path: string;
69
+ /** `actor` routes answer 401 before the handler runs; `public` ones do not. */
70
+ auth: 'actor' | 'public';
71
+ /**
72
+ * The path ends in a catch-all whose whole remainder is the `key` param. An
73
+ * adapter spells that its own way; the descriptor says only that it is there.
74
+ */
75
+ wildcardParam?: 'key';
76
+ handle(context: StorageRouteContext): Promise<StorageRouteResponse>;
77
+ }
78
+
79
+ interface RoutesDeps extends StoreImageDeps {
80
+ driver: StorageDriver;
81
+ messages: StorageMessages;
82
+ }
83
+
84
+ /** Keys are content-addressed (a uuid), so an object may be cached forever. */
85
+ const IMMUTABLE = 'public, max-age=31536000, immutable';
86
+
87
+ async function handleUpload(
88
+ deps: RoutesDeps,
89
+ context: StorageRouteContext,
90
+ ): Promise<StorageRouteResponse> {
91
+ const actor = context.actor;
92
+ if (!actor?.mayUpload) {
93
+ // Stays a CODE, unlike the refusals below, and deliberately: `mayUpload` is the
94
+ // host's own boolean, so the package cannot phrase WHY without asserting a role
95
+ // model it does not have. The browser half owns that sentence and lets the host
96
+ // override it (`WebStorageMessages.forbidden`).
97
+ return { status: 403, body: { error: 'forbidden' } };
98
+ }
99
+ const contentType = acceptedContentTypeOf(context.request.headers.get('content-type'));
100
+ if (!contentType) {
101
+ return { status: 400, body: { error: deps.messages.unsupported_content_type } };
102
+ }
103
+ // Capped WHILE STREAMING, so an oversize body is refused without ever being
104
+ // held whole in memory.
105
+ const bytes = await readBodyCapped(context.request, deps.maxBytes);
106
+ if (bytes === null) {
107
+ // The configured SENTENCE, not the code `file_too_large`. These two refusals
108
+ // are decided before the write path runs, so they used to be the only ones the
109
+ // endpoint stated as a bare code — and the browser then re-derived the number
110
+ // from its OWN default. A mount capped at 4 MB refusing a 6 MB file produced
111
+ // "o limite é 8 MB": a ceiling nothing enforced, in the one package whose
112
+ // headline claim is that there is exactly ONE number. The sentence is built
113
+ // from this mount's `maxBytes` and carries this mount's `messages` overrides.
114
+ return { status: 413, body: { error: deps.messages.file_too_large } };
115
+ }
116
+ try {
117
+ // No byte check here: `storeImage` owns it, so the endpoint and a host write
118
+ // holding raw bytes cannot disagree about whether it ran (see store-image.ts).
119
+ const imageKey = await storeImage(deps, { bytes, contentType, scope: actor.scope });
120
+ return { status: 200, body: { data: { imageKey } } };
121
+ } catch (error) {
122
+ // The write path speaks in StorageProblemError precisely so both entrances
123
+ // answer with the same status and the same sentence.
124
+ if (isStorageProblem(error)) {
125
+ return { status: error.status, body: { error: error.message } };
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Serve a stored object, or send the caller where it actually lives.
133
+ *
134
+ * Which of the two applies is read off the DRIVER — a driver that holds its own
135
+ * bytes implements `read`, a bucket-backed one does not. That used to be an
136
+ * environment flag consulted inside the route, i.e. the same fact recorded in a
137
+ * second place where it could disagree with the driver in use.
138
+ *
139
+ * The redirect is not a nicety: a consumer may still be asking for this route
140
+ * after a switch to a bucket — a cached page, a storefront bundle mid-deploy that
141
+ * fell back to the bare key — and a 404 there is a catalog full of broken images.
142
+ */
143
+ async function handleServe(
144
+ deps: RoutesDeps,
145
+ context: StorageRouteContext,
146
+ ): Promise<StorageRouteResponse> {
147
+ const key = context.params.key ?? '';
148
+ const member = parseObjectMemberKey(key, deps.keyPrefix);
149
+ if (!member || !(member.extension in CONTENT_TYPE_BY_EXTENSION)) {
150
+ return { status: 400, body: { error: 'invalid_key' } };
151
+ }
152
+ if (!deps.driver.read) {
153
+ const url = deps.driver.publicUrl(key);
154
+ // `publicUrl` may answer a relative path on a misconfigured driver, and a
155
+ // redirect to one loops back here forever. 404 instead — the same answer the
156
+ // object being absent gives.
157
+ return /^https?:\/\//i.test(url)
158
+ ? { status: 302, redirect: url }
159
+ : { status: 404, body: { error: 'not_found' } };
160
+ }
161
+ const object = await deps.driver.read(key);
162
+ if (!object) return { status: 404, body: { error: 'not_found' } };
163
+ return {
164
+ status: 200,
165
+ bytes: object.bytes,
166
+ headers: { 'content-type': object.contentType, 'cache-control': IMMUTABLE },
167
+ };
168
+ }
169
+
170
+ export function storageRoutes(deps: RoutesDeps): readonly StorageRoute[] {
171
+ return [
172
+ {
173
+ method: 'POST',
174
+ path: STORAGE_PATHS.upload,
175
+ auth: 'actor',
176
+ handle: (context) => handleUpload(deps, context),
177
+ },
178
+ {
179
+ method: 'GET',
180
+ path: STORAGE_PATHS.serve,
181
+ // Objects are world-readable by design — a store's logo is printed in its
182
+ // own storefront HTML — and the key is an unguessable uuid path. Gating the
183
+ // read would mean every `<img>` in a public storefront carried a session.
184
+ auth: 'public',
185
+ wildcardParam: 'key',
186
+ handle: (context) => handleServe(deps, context),
187
+ },
188
+ ];
189
+ }
@@ -0,0 +1,168 @@
1
+ import { mintObjectKey, mintObjectSetKey, renditionKey } from '../keys';
2
+ import { decodeImagePayload, verifyImageBytes } from '../payload';
3
+ import {
4
+ StorageNotConfiguredError,
5
+ StorageProblemError,
6
+ type StorageMessages,
7
+ } from '../problems';
8
+ import { RENDITION_CONTENT_TYPE, type RenditionSpec } from '../renditions';
9
+ import type { InlineImage } from '../wire';
10
+ import type { StorageDriver } from './driver';
11
+ import type { ImagePipeline } from './pipeline/port';
12
+
13
+ /**
14
+ * THE writer. Every stored photo is stored here, and nothing else decides what a
15
+ * stored photo is made of.
16
+ *
17
+ * That single-owner rule is the point, and it was learned the hard way. An
18
+ * earlier design had the answer in three places: the inline path minted a key
19
+ * from what it had actually produced, the presign path GUESSED the same key's
20
+ * shape from a content type and a config flag before a single byte existed, and
21
+ * both then looped over the rendition list storing objects. Two of the three
22
+ * could disagree — and did, structurally: a key minted on a guess promises crops
23
+ * the bytes may turn out not to support, which is why that design needed a
24
+ * `purpose` flag, an animation special case, a direct-upload special case, and a
25
+ * 422 to break the promise afterwards. None of that is here. The key is minted
26
+ * from FINISHED work, so it cannot promise anything that is not already stored.
27
+ *
28
+ * The two ways bytes arrive keep their own TRANSPORT and share this LOGIC:
29
+ *
30
+ * - an AGENT sends them base64 inside the write that uses them, because a tool
31
+ * call is JSON and it cannot make an HTTP request of its own;
32
+ * - a BROWSER POSTs them to the upload endpoint and saves the key it gets back,
33
+ * which is what lets a draft reference a photo picked before the row exists.
34
+ *
35
+ * The order matters:
36
+ *
37
+ * 1. process the uncropped object — the whole picture, which is what a zoom
38
+ * shows;
39
+ * 2. cut the crops from the ORIGINAL bytes, not from step 1's output;
40
+ * 3. mint the key from the RESULT — the extension from the type the object was
41
+ * actually stored as, and the SHAPE from whether step 2 produced anything.
42
+ */
43
+
44
+ export interface StoreImageDeps {
45
+ driver: StorageDriver;
46
+ pipeline: ImagePipeline;
47
+ messages: StorageMessages;
48
+ maxBytes: number;
49
+ specs: readonly RenditionSpec[];
50
+ keyPrefix: string;
51
+ }
52
+
53
+ interface StoreImageInput {
54
+ bytes: Uint8Array;
55
+ /** The type the caller declares. Corroborated against the bytes themselves. */
56
+ contentType: string;
57
+ /** The tenant the object belongs to — the key's scope segment. */
58
+ scope: string;
59
+ }
60
+
61
+ function refuse(deps: StoreImageDeps, problem: keyof StorageMessages): never {
62
+ throw new StorageProblemError(problem, deps.messages[problem]);
63
+ }
64
+
65
+ /**
66
+ * Refuse bytes that are not really an image of `contentType`.
67
+ *
68
+ * Called by {@link storeImage} ITSELF, which is the only arrangement that holds.
69
+ * It used to be each entrance's job — the endpoint called it before delegating,
70
+ * and the base64 path got it as part of decoding — and the public `storeImage` a
71
+ * host calls with raw bytes in hand was then the one that forgot. An adversarial
72
+ * probe stored `<html><script>alert(1)</script></html>` under a `full.png` key,
73
+ * served as `image/png`, straight through that gap.
74
+ *
75
+ * The gap was reachable with the REAL pipeline and not only with a stub: a
76
+ * re-encode that is not SMALLER deliberately keeps the original bytes and the
77
+ * DECLARED type (see `sharp.ts`), and libvips decodes plenty of formats this
78
+ * allowlist excludes — so `process` had no reason to object either.
79
+ *
80
+ * `payload.ts` already said this check must run on both entrances "because
81
+ * neither may be the one that forgets". The fix is to stop asking entrances: the
82
+ * writer verifies once, and no path to storage goes around it.
83
+ */
84
+ function assertImageBytes(
85
+ deps: StoreImageDeps,
86
+ bytes: Uint8Array,
87
+ contentType: string,
88
+ ): void {
89
+ const problem = verifyImageBytes(bytes, contentType);
90
+ if (problem) refuse(deps, problem);
91
+ }
92
+
93
+ /** Write the crops first, then the object the row will name. */
94
+ async function writeSet(
95
+ deps: StoreImageDeps,
96
+ key: string,
97
+ full: { bytes: Uint8Array; contentType: string },
98
+ renditions: readonly { spec: RenditionSpec; bytes: Uint8Array }[],
99
+ ): Promise<void> {
100
+ try {
101
+ // The uncropped object goes LAST, after every crop its key implies is already
102
+ // in place. A request that dies partway then leaves objects nothing points at
103
+ // — which a bucket forgets — rather than a row pointing at a photo whose card
104
+ // crop 404s in every grid that draws it.
105
+ await Promise.all(
106
+ renditions.map((rendition) =>
107
+ // Non-null: `renditions` is only non-empty for a set key.
108
+ deps.driver.put(
109
+ renditionKey(key, rendition.spec.name, deps.keyPrefix) as string,
110
+ rendition.bytes,
111
+ RENDITION_CONTENT_TYPE,
112
+ ),
113
+ ),
114
+ );
115
+ await deps.driver.put(key, full.bytes, full.contentType);
116
+ } catch (error) {
117
+ if (error instanceof StorageNotConfiguredError) refuse(deps, 'storage_not_configured');
118
+ refuse(deps, 'storage_unavailable');
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Store `bytes` and return the key to save on the row.
124
+ *
125
+ * Throws {@link StorageProblemError} for anything the caller can fix, so a bad
126
+ * image fails the whole write with a usable status instead of creating a row with
127
+ * a silently missing photo.
128
+ */
129
+ export async function storeImage(
130
+ deps: StoreImageDeps,
131
+ input: StoreImageInput,
132
+ ): Promise<string> {
133
+ // FIRST, and before a decoder sees anything: bytes that are not really an image
134
+ // of the declared type never reach the pipeline at all.
135
+ assertImageBytes(deps, input.bytes, input.contentType);
136
+ const [processed, renditions] = await Promise.all([
137
+ deps.pipeline.process(input.bytes, input.contentType),
138
+ deps.pipeline.cut(input.bytes, deps.specs),
139
+ ]);
140
+ if (!processed.ok) refuse(deps, processed.problem);
141
+
142
+ const { contentType } = processed.image;
143
+ const mint = { scope: input.scope, contentType, prefix: deps.keyPrefix };
144
+ // An empty rendition list is the animated case and the pipeline-declined case,
145
+ // and both want the flat key: it is what tells every later reader that this
146
+ // photo has no crops to ask for. Decided from the RESULT, never a prediction.
147
+ const key = renditions.length > 0 ? mintObjectSetKey(mint) : mintObjectKey(mint);
148
+
149
+ await writeSet(deps, key, processed.image, renditions);
150
+ return key;
151
+ }
152
+
153
+ /**
154
+ * {@link storeImage} for bytes that arrived base64-encoded inside a write body.
155
+ *
156
+ * The decode is the only thing this adds. Everything that happens to the bytes
157
+ * afterwards is the same code a browser's upload runs, so a photo is the same set
158
+ * of objects however it arrived.
159
+ */
160
+ export async function storeInlineImage(
161
+ deps: StoreImageDeps,
162
+ image: InlineImage,
163
+ scope: string,
164
+ ): Promise<string> {
165
+ const decoded = decodeImagePayload(image.contentBase64, image.contentType, deps.maxBytes);
166
+ if (!decoded.ok) refuse(deps, decoded.problem);
167
+ return storeImage(deps, { bytes: decoded.bytes, contentType: image.contentType, scope });
168
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { hasRenditions, renditionKey } from './keys';
2
+ import {
3
+ CATALOG_RENDITIONS,
4
+ renditionFamilies,
5
+ type RenditionFamily,
6
+ type RenditionSpec,
7
+ } from './renditions';
8
+
9
+ /**
10
+ * Turning a stored KEY back into URLs a browser can load.
11
+ *
12
+ * The key, never the URL, is what lands in the host's database. Display code
13
+ * resolves it through the ACTIVE driver, so the same row renders correctly
14
+ * whether the object lives on a dev machine's disk or in a bucket behind a CDN —
15
+ * and only the server knows which of those is running, which is why these run
16
+ * server-side and the client receives finished `src`/`srcSet` pairs.
17
+ */
18
+
19
+ /** How a key becomes a loadable URL. A driver's `publicUrl`, in practice. */
20
+ export type PublicUrlResolver = (key: string) => string;
21
+
22
+ /** One `srcset` family, ready to hand an `<img>`. */
23
+ export interface ImageRenditionSet {
24
+ /**
25
+ * What a browser without `srcset` support falls back to — a real member of
26
+ * the family rather than the uncropped object, because a fallback that changed
27
+ * the framing would make one card in a grid taller than the rest.
28
+ */
29
+ src: string;
30
+ /** `w`-descriptor srcset — the widths are the canvases the crops were cut at. */
31
+ srcSet: string;
32
+ }
33
+
34
+ /**
35
+ * The derived crops of one photo, by the shape they are drawn in. `null` for a
36
+ * photo stored without them — an animated source, a passthrough pipeline, or
37
+ * anything uploaded before renditions existed — which every consumer reads as
38
+ * "draw the uncropped object the way it has always been drawn".
39
+ */
40
+ export type ImageSources = Readonly<Record<RenditionFamily, ImageRenditionSet>>;
41
+
42
+ /** Resolve a stored key, or an already-absolute URL, to something loadable. */
43
+ export function objectUrl(key: string | null, resolve: PublicUrlResolver): string | null {
44
+ if (!key) return null;
45
+ // A value that is already a URL is returned unchanged: a host migrating off
46
+ // an older column may still hold absolute URLs beside real keys.
47
+ if (/^https?:\/\//i.test(key)) return key;
48
+ return resolve(key);
49
+ }
50
+
51
+ function familySet(
52
+ key: string,
53
+ family: RenditionFamily,
54
+ specs: readonly RenditionSpec[],
55
+ resolve: PublicUrlResolver,
56
+ prefix: string | undefined,
57
+ ): ImageRenditionSet {
58
+ const members = specs.filter((spec) => spec.family === family);
59
+ // Non-null throughout: `hasRenditions(key)` is the caller's precondition, and
60
+ // `family` came from this same spec list.
61
+ const url = (spec: RenditionSpec): string =>
62
+ resolve(renditionKey(key, spec.name, prefix) as string);
63
+ const first = members[0] as RenditionSpec;
64
+ return {
65
+ // The SMALLEST of the family: `src` is only reached without srcset support,
66
+ // and over-fetching is the worse of the two ways to be wrong there.
67
+ src: url(first),
68
+ srcSet: members.map((spec) => `${url(spec)} ${spec.width}w`).join(', '),
69
+ };
70
+ }
71
+
72
+ export interface ImageSourcesOptions {
73
+ specs?: readonly RenditionSpec[];
74
+ prefix?: string;
75
+ }
76
+
77
+ /**
78
+ * The crop families stored beside `key`, or `null` when it has none.
79
+ *
80
+ * Derived from the key's SHAPE alone — see `keys.ts` for why that is a truthful
81
+ * answer and not a guess.
82
+ */
83
+ export function imageSources(
84
+ key: string | null,
85
+ resolve: PublicUrlResolver,
86
+ options: ImageSourcesOptions = {},
87
+ ): ImageSources | null {
88
+ const specs = options.specs ?? CATALOG_RENDITIONS;
89
+ if (!key || !hasRenditions(key, options.prefix)) return null;
90
+ return Object.fromEntries(
91
+ renditionFamilies(specs).map((family) => [
92
+ family,
93
+ familySet(key, family, specs, resolve, options.prefix),
94
+ ]),
95
+ ) as ImageSources;
96
+ }
97
+
98
+ /**
99
+ * {@link objectUrl} with a cache-busting stamp appended.
100
+ *
101
+ * Exists for an app icon, where a stable URL is a bug rather than a nicety: a
102
+ * manifest and its icons are cached hard, and an ALREADY-INSTALLED home-screen
103
+ * icon is the last asset a device re-fetches — so a store that replaces its icon
104
+ * keeps showing the old one on precisely the devices that matter, with nothing
105
+ * on screen to explain it.
106
+ *
107
+ * The separator is chosen rather than assumed: a CDN base may legitimately carry
108
+ * query parameters of its own, and a blind `?` would produce a URL with two.
109
+ */
110
+ export function versionedObjectUrl(
111
+ key: string | null,
112
+ version: string | null,
113
+ resolve: PublicUrlResolver,
114
+ ): string | null {
115
+ const url = objectUrl(key, resolve);
116
+ if (!url || !version) return url;
117
+ return `${url}${url.includes('?') ? '&' : '?'}v=${encodeURIComponent(version)}`;
118
+ }
package/src/wire.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod';
2
+
3
+ import { ACCEPTED_CONTENT_TYPES } from './content-types';
4
+ import { DEFAULT_KEY_PREFIX, isObjectKey } from './keys';
5
+ import { maxBase64Length } from './limits';
6
+
7
+ /**
8
+ * The wire shapes, as zod — including the one a TOOL schema is built from.
9
+ *
10
+ * `inlineImageSchema(maxBytes)` is a factory rather than a constant because its
11
+ * ceiling IS the mount's ceiling. That is the whole anti-drift point of the
12
+ * extraction: a host builds its agent-facing tool schema from
13
+ * `createApiStorage(…).schemas.inlineImage`, so the schema cannot advertise a
14
+ * limit the endpoint does not enforce, and raising one raises the other.
15
+ */
16
+
17
+ /** An image supplied by VALUE, base64-encoded because a tool call is JSON. */
18
+ export function inlineImageSchema(
19
+ maxBytes: number,
20
+ ): z.ZodObject<{ contentType: z.ZodEnum<Record<string, string>>; contentBase64: z.ZodString }> {
21
+ return z.object({
22
+ contentType: z.enum(
23
+ Object.fromEntries(ACCEPTED_CONTENT_TYPES.map((type) => [type, type])),
24
+ ),
25
+ /** The file's bytes, base64-encoded. A `data:` URL prefix is accepted. */
26
+ contentBase64: z.string().min(1).max(maxBase64Length(maxBytes)),
27
+ });
28
+ }
29
+
30
+ /** An image supplied by value: `{ contentType, contentBase64 }`. */
31
+ export interface InlineImage {
32
+ contentType: string;
33
+ contentBase64: string;
34
+ }
35
+
36
+ /**
37
+ * A key this scheme actually minted — the schema an `imageKey` FIELD should use.
38
+ *
39
+ * Ships beside {@link inlineImageSchema} because the pair is what a host's write
40
+ * body is made of, and because `z.string()` is the wrong answer in a way that is
41
+ * not obvious. An `imageKey` is not free text: it is a key the server minted, it is
42
+ * rendered straight into an `<img src>` by `objectUrl`, and `objectUrl`
43
+ * deliberately returns an ALREADY-ABSOLUTE value unchanged (a documented migration
44
+ * affordance for a host moving off a URL column).
45
+ *
46
+ * Compose those two and a recipe that accepts any string accepts
47
+ * `imageKey: 'https://tracker.example/p.png'` from anyone who may edit the row —
48
+ * after which every buyer loading that storefront page sends their IP and
49
+ * user-agent to a third party. No upload, no bucket and no driver is involved, so
50
+ * nothing on the storage path can catch it; the only place to refuse it is where
51
+ * the value arrives.
52
+ *
53
+ * The cross-tenant variant (`products/<otherTenant>/<uuid>/full.webp`) parses here
54
+ * and is refused later by the reclaim's `keyInScope` — but a host that wants it
55
+ * refused on the WRITE should pair this with `keyInScope(key, scope, …)`, which
56
+ * this schema cannot do because it does not know the caller.
57
+ *
58
+ * A `javascript:`-shaped value is already inert: `objectUrl` only passes through
59
+ * `^https?://`, so `javascript:alert(1)` is handed to the driver and comes back as
60
+ * a path under the mount. Inert is not the same as valid, and it is refused here too.
61
+ */
62
+ export function objectKeySchema(prefix: string = DEFAULT_KEY_PREFIX): z.ZodString {
63
+ return z.string().refine((key) => isObjectKey(key, prefix), {
64
+ message: 'Not an object key minted by this storage mount.',
65
+ });
66
+ }
67
+
68
+ /** The pair of fields an image-bearing write body carries. */
69
+ export interface ImageFields {
70
+ imageKey?: string | null;
71
+ image?: InlineImage;
72
+ }
73
+
74
+ /**
75
+ * Reject a body stating BOTH an existing key and new bytes.
76
+ *
77
+ * Silently preferring one makes the other's presence a no-op the caller cannot
78
+ * see, and the two mean opposite things — "keep pointing at this object" versus
79
+ * "store these bytes and point at the result". An explicit refusal is the only
80
+ * reading that cannot quietly discard an upload.
81
+ */
82
+ export function refineImageInput(
83
+ value: ImageFields,
84
+ ctx: z.RefinementCtx,
85
+ path: readonly (string | number)[] = [],
86
+ ): void {
87
+ if (value.image && value.imageKey) {
88
+ ctx.addIssue({
89
+ code: 'custom',
90
+ path: [...path, 'image'],
91
+ message:
92
+ 'Send either imageKey (an object already stored) or image (the bytes to store), not both.',
93
+ });
94
+ }
95
+ }
96
+
97
+ /** What `POST <mount>/uploads/image` answers on success. */
98
+ export const uploadAcceptedSchema = z.object({
99
+ data: z.object({ imageKey: z.string() }),
100
+ });
101
+
102
+ /** The success body of an upload, as the browser half parses it. */
103
+ export type UploadAccepted = z.infer<typeof uploadAcceptedSchema>;