@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.
package/ADOPTING.md ADDED
@@ -0,0 +1,281 @@
1
+ # Adopting @12-apps/storage
2
+
3
+ This package is a **plug-and-play upload plugin** (12-20): one library, reusable
4
+ across repositories, exposing standardized surfaces. A host only *points* at
5
+ those surfaces — when the library updates, every host updates with **no app
6
+ changes**. The contract is the one `@12-apps/report-builder`, `@12-apps/rbac` and
7
+ `@12-apps/payments-*` established.
8
+
9
+ ## The standardized plugin surfaces
10
+
11
+ | Surface | Export | What the host does |
12
+ |---|---|---|
13
+ | **Core** | `@12-apps/storage` | Nothing to wire — the format allowlist, the ceiling, the object-key grammar, the rendition set, the refusal taxonomy, the URL builders and the wire schemas. Isomorphic: the browser half reads the same constants the server enforces. |
14
+ | **Server** | `@12-apps/storage/server` | Call `createApiStorage(config)` and mount the `routes` it returns. Parsing, the streaming cap, the magic-number check, key minting, the rendition write order, status codes and the envelope are all inside. The same object also hands back the write path, the URL builders and the reclaim, so every OTHER host surface stores and resolves through one wiring. |
15
+ | **Hono** | `@12-apps/storage/hono` | `const storage = storageRouter({ …config, resolveActor }); app.route('/api', storage.router)`. A one-call mount; `hono` is an OPTIONAL peer, so importing the root or `/server` never resolves it. |
16
+ | **S3** | `@12-apps/storage/s3` | `createS3Driver({ bucket, region \| endpoint, … })`. `@aws-sdk/client-s3` is an OPTIONAL peer resolved ONLY here, so a host on local disk never installs it. |
17
+ | **React** | `@12-apps/storage/react` | Call `createWebStorage({ apiBase })` and render `page`, drop `ImageField` into a form, or build your own affordance on `useUpload`. |
18
+ | **Prisma** | — none | This package owns **no models**. See "Why there is no Prisma partial" below. |
19
+
20
+ ## The five values you must decide, and why none of them defaults
21
+
22
+ `createApiStorage` requires `driver`, `maxBytes`, `imagePipeline`, `unscopedKeys`
23
+ and `references`. The reason is the same for all five and it is not tidiness:
24
+ **each one fails quietly when it is wrong.** A loud wrong default gets fixed on
25
+ the first deploy. These produce a green deploy, successful uploads, and a
26
+ consequence weeks later that no log line explains — so the only place the
27
+ decision can be made correctly is the host, in the open, once.
28
+
29
+ | Config | What a default would cost you, specifically |
30
+ |---|---|
31
+ | `driver` | Uploads succeed onto a container's ephemeral disk. One container holds the object, the others 404, the next deploy discards it. `writeFile` returned success every time, so nothing is logged. |
32
+ | `maxBytes` | Your tool schemas advertise one ceiling and the endpoint enforces another; an agent's upload is refused for a size its own schema accepted. |
33
+ | `imagePipeline` | Whether crops exist at all. Discovered from the shape of a key, in production, by a card that renders the uncropped photo. |
34
+ | `unscopedKeys` | Whether one tenant's reclaim can reach another tenant's bytes. Wrong in either direction and silent in both. |
35
+ | `references` | Whether a restored row still has its photo. `[]` is not "no opinion" — it means *reclaim everything immediately*. |
36
+
37
+ `logger` is the one that legitimately defaults: a reclaim may only report, never
38
+ raise, so the worst a defaulted logger costs is silence. Pass yours anyway.
39
+
40
+ ## Host wiring rules (the ones that bite)
41
+
42
+ 1. **The host resolves WHO and WHERE; the package decides WHAT HAPPENS TO THE
43
+ BYTES.** `resolveActor` answers `{ scope, mayUpload }`. `scope` is the tenant
44
+ the objects belong to — it becomes the key's scope segment, so it must be
45
+ stable for the lifetime of an object (an id, not a renameable slug, unless
46
+ slugs are immutable in your host). `mayUpload` is ONE boolean because two
47
+ independent host questions feed it: "may this person store objects?" (roles,
48
+ memberships, plan) and "may this SESSION change anything?" (a read-only
49
+ impersonation, a maintenance mode). Both are answered before delegating. A
50
+ package that computed either would be wrong for every host but the first.
51
+ 2. **`unscopedKeys` is a migration decision, and you must make it.** A host with
52
+ existing rows minted before the scope segment passes `'accept'`: such a key
53
+ carries no tenant, so the structural cross-tenant guard cannot apply to it and
54
+ only `references` stands between one tenant and another's objects. A fresh
55
+ host passes `'reject'`, and then every key it will ever touch is scoped.
56
+ There is no default because the wrong one is silent in both directions.
57
+ 3. **`references` are YOUR tables, and the parameter is REQUIRED.** A key can
58
+ outlive the row that pointed at it, and some of those states are ones a person
59
+ is about to act on — a duplicated row sharing one object, a soft-deleted row
60
+ whose "restore" is expected to bring the photo back, a draft or a pending
61
+ approval holding a key waiting to be published. Deleting the object would break
62
+ the very next thing they do. Each of those is a probe:
63
+
64
+ ```ts
65
+ references: [
66
+ { name: 'live-rows', referenced: (scope, key) => rowPointsAt(scope, key) },
67
+ { name: 'pending', referenced: (scope, key) => draftOrRequestHolds(scope, key) },
68
+ ]
69
+ ```
70
+
71
+ Scope each probe to the state that is genuinely PENDING (an `OPEN` draft, a
72
+ `PENDING` request, a `DELETED` bin entry). A probe that matches every row ever
73
+ written quietly restores "nothing is ever reclaimed", one abandoned edit at a
74
+ time. And think hard before adding **version history** as a probe: versioning
75
+ is usually on by default, so counting a version as a reference pins the object
76
+ for the whole retention window and "replace a photo" reclaims nothing, ever.
77
+
78
+ `references: []` is a legal answer and you must write it out. It says "nothing
79
+ in this host copies a key", and it is the value that made this parameter
80
+ required: it used to be the DEFAULT, so a host that had never thought about the
81
+ question got *reclaim everything immediately* — and found out weeks later, when
82
+ a store owner restored a product and the row came back without its photo.
83
+
84
+ **WHEN to call the reclaim: after commit, and after any copy-on-write path has
85
+ landed.** The probes are read OUTSIDE your transaction, and `deleteIfOrphaned`
86
+ is read-validate-write with no claim-once — it consults every probe, then
87
+ deletes. So there is a window, and it is real:
88
+
89
+ 1. user B replaces product P's photo; the reclaim probes key `K`, nothing holds it;
90
+ 2. concurrently user A duplicates P from a stale tab, and the duplicate row is
91
+ written with `imageKey: K`;
92
+ 3. the purge runs. `K` and its five crops are gone, and A's new row points at
93
+ six objects that do not exist.
94
+
95
+ Closing that window needs refcounting, which would live in your tables beside
96
+ the keys — it is not something the package can do for you. What you can do is
97
+ order the call so the window is empty: **commit first, then reclaim**, and never
98
+ reclaim from inside a transaction or before a duplicate/restore path has
99
+ written. The package makes the failure discoverable rather than silent: if a
100
+ probe claims the key immediately after the purge, it logs through your `logger`
101
+ naming the probe — which is the only warning you will get, and the reason rule 4
102
+ matters.
103
+ 4. **`logger` is how you hear about a bucket that stopped accepting deletes.**
104
+ The reclaim runs after the write it follows and can only report, never raise —
105
+ a store owner who successfully changed a photo must not see an error because a
106
+ delete timed out. Pass your feature logger. The default drops everything, and
107
+ note that a bare `console.error` is invisible to some server-side error
108
+ reporters.
109
+ 5. **`publicPathPrefix` must be the mount plus `STORAGE_PATHS.serve`.** The local
110
+ driver builds display URLs from it, and the serve route answers them. Compose
111
+ it rather than retyping the path, or a deployment writes objects at one path
112
+ and serves them from another.
113
+ 6. **Static imports only.** This package publishes TypeScript source. A dynamic
114
+ non-literal `import()` of a subpath crashes a bundled server.
115
+ 7. **Do not add a presigned-URL path in the host either.** The endpoint is
116
+ deliberately the only way bytes arrive from a browser; the reasons are in the
117
+ README and they apply to a host that reintroduces the hop by hand.
118
+
119
+ ## Why there is no Prisma partial
120
+
121
+ The other plugins in this series own tables. This one owns none, and that is a
122
+ finding rather than an omission: everything storage persists is an object in a
123
+ bucket or on a disk. The columns that hold a key — `menu_items.image_key`,
124
+ `client_branding.logo_key` — are columns on **host** models, and a package that
125
+ declared them would be declaring the host's catalog.
126
+
127
+ The consequence is rule 3: the "is this object still needed?" question cannot be
128
+ answered inside the package, so it arrives as predicates over the host's own
129
+ tables. There is nothing to sync, no migration to replay, and no
130
+ `prisma:sync-*:check` for this package.
131
+
132
+ ## A worked mount (Hono + local disk + sharp)
133
+
134
+ This is a **fresh host**: no rows predate the scope segment, so `unscopedKeys` is
135
+ `'reject'`. See the note after it if that is not you.
136
+
137
+ Read the `root` line before you copy it. There is **no `??`** on it deliberately:
138
+ `process.env.UPLOADS_DIR ?? \`${process.cwd()}/.uploads\`` in this position is
139
+ precisely the fail-open default the table above refuses to have, and it is the one
140
+ mistake this document used to hand you ready-made.
141
+
142
+ ```ts
143
+ import sharp from 'sharp';
144
+ import { DEFAULT_MAX_UPLOAD_BYTES, STORAGE_PATHS } from '@12-apps/storage';
145
+ import { createLocalDiskDriver, createSharpImagePipeline } from '@12-apps/storage/server';
146
+ import { storageRouter } from '@12-apps/storage/hono';
147
+
148
+ /**
149
+ * Fail at BOOT for a missing value, not at the next deploy.
150
+ *
151
+ * `UPLOADS_DIR` must be a MOUNTED VOLUME. A container path "works" in every test
152
+ * you will run and loses every object the first time the app is redeployed.
153
+ */
154
+ function requireEnv(name: string): string {
155
+ const value = process.env[name];
156
+ if (!value) throw new Error(`${name} is required (a mounted volume, not a container path)`);
157
+ return value;
158
+ }
159
+
160
+ const storage = storageRouter({
161
+ driver: createLocalDiskDriver({
162
+ root: requireEnv('UPLOADS_DIR'),
163
+ publicPathPrefix: `/api${STORAGE_PATHS.serve}`,
164
+ }),
165
+ maxBytes: DEFAULT_MAX_UPLOAD_BYTES,
166
+ imagePipeline: createSharpImagePipeline({ sharp }),
167
+ // A fresh host: every key it will ever mint is scoped, so a key WITHOUT a scope
168
+ // can only be someone else's or nobody's. See rule 2 before changing this.
169
+ unscopedKeys: 'reject',
170
+ // Your tables. `[]` only if you have checked that nothing here copies a key.
171
+ references: [
172
+ { name: 'live-rows', referenced: liveRowHoldsKey },
173
+ { name: 'pending-state', referenced: pendingStateHoldsKey },
174
+ ],
175
+ logger: createFeatureLogger('uploads'),
176
+ resolveActor: async (c) => {
177
+ const actor = await resolveEffectiveActor(c.req.raw);
178
+ if (!actor.tenantId) return null;
179
+ return { scope: actor.tenantId, mayUpload: await mayUpload(actor) };
180
+ },
181
+ });
182
+
183
+ app.route('/api', storage.router);
184
+ ```
185
+
186
+ **If your host already has image keys**, they were minted before the scope segment
187
+ existed, so they carry no tenant and `'reject'` would refuse to touch every one of
188
+ them. Pass `unscopedKeys: 'accept'` instead — and understand what you are taking
189
+ on: for those legacy keys the tenant boundary on reclaim is your `references`
190
+ probes ALONE, because there is no scope in the key for the structural guard to
191
+ check. That makes rule 3 sharper for you than for a greenfield host, not looser.
192
+
193
+ **In development**, and only there, the `??` is fine — a lost object on a laptop
194
+ costs nothing:
195
+
196
+ ```ts
197
+ // DEV ONLY. Never in the mount above.
198
+ root: process.env.UPLOADS_DIR ?? `${process.cwd()}/.uploads`,
199
+ ```
200
+
201
+ Switching to a bucket is a driver swap and nothing else:
202
+
203
+ ```ts
204
+ import { createS3Driver } from '@12-apps/storage/s3';
205
+
206
+ driver: createS3Driver({
207
+ bucket: process.env.S3_BUCKET ?? '',
208
+ region: process.env.S3_REGION,
209
+ endpoint: process.env.S3_ENDPOINT, // Spaces, MinIO, R2…
210
+ forcePathStyle: process.env.S3_FORCE_PATH_STYLE === '1',
211
+ publicBaseUrl: process.env.STORAGE_PUBLIC_BASE_URL, // a CDN in front
212
+ }),
213
+ ```
214
+
215
+ ## Feeding a tool schema without drifting
216
+
217
+ A host that lets an agent send bytes inside a write needs the same ceiling in
218
+ its tool schema. Read **both** fields off the mount, not off a constant:
219
+
220
+ ```ts
221
+ const body = z.object({
222
+ imageKey: storage.schemas.objectKey.nullish(), // grammar === this mount's keyPrefix
223
+ image: storage.schemas.inlineImage.optional(), // ceiling === this mount's maxBytes
224
+ }).superRefine(refineImageInput);
225
+ ```
226
+
227
+ `refineImageInput` refuses a body stating BOTH an existing key and new bytes.
228
+ They mean opposite things — "keep pointing at this object" versus "store these
229
+ bytes and point at the result" — so silently preferring one discards an upload
230
+ the caller cannot see was discarded.
231
+
232
+ ### `imageKey` is not a string field, and this recipe used to say it was
233
+
234
+ It said `imageKey: z.string().nullish()`, which accepts **any** string. Combine
235
+ that with `objectUrl`, which deliberately returns an already-absolute value
236
+ unchanged (a documented affordance for a host migrating off a URL column), and an
237
+ adopter following this page ends up accepting an arbitrary URL on a write and
238
+ rendering it into an `<img src>`:
239
+
240
+ ```ts
241
+ updateProduct({ imageKey: 'https://tracker.example/p.png' })
242
+ ```
243
+
244
+ Any actor who may edit a product can then make every buyer loading that
245
+ storefront page send their IP and user-agent to a third party. No upload, no
246
+ bucket and no driver is involved, so **nothing on the storage path can catch
247
+ it** — the only place to refuse it is where the value arrives.
248
+ `storage.schemas.objectKey` is that refusal, and it is built from the mount's own
249
+ `keyPrefix` so it cannot drift from what the mount actually mints.
250
+
251
+ Two things it does not do, and you may want both:
252
+
253
+ - **It does not check the tenant.** `products/<otherTenant>/<uuid>/full.webp` is a
254
+ well-formed key, so it parses. The reclaim refuses to delete it (`keyInScope`),
255
+ but it is still a key you never minted sitting in your own catalog, and it makes
256
+ one store display another's photo. Pair the schema with
257
+ `keyInScope(key, scope, unscopedKeys)` in the handler, which the schema cannot do
258
+ because it does not know who is calling.
259
+ - **It does not verify the object EXISTS.** A well-formed key for an object that was
260
+ never stored passes; the `<img>` 404s. That is a cheap `driver` read if you care.
261
+
262
+ For the record, a `javascript:`-shaped value is already inert without the schema —
263
+ `objectUrl` only passes through `^https?://`, so it is handed to the driver and
264
+ comes back as a path under your mount rather than as an executable href. Inert is
265
+ not the same as valid; the schema refuses it too.
266
+
267
+ ## What the host keeps
268
+
269
+ Config, and nothing else: who is calling, which tenant they act for, where bytes
270
+ live, which of its tables can pin an object, the ceiling, and the choice of
271
+ pipeline. Everything else — the endpoint, the cap, the byte check, the key
272
+ grammar, the crop set, the URLs, the reclaim, the picker, the browser-side
273
+ re-encode and every pt-BR sentence — is the package's.
274
+
275
+ ## Not in scope for this package
276
+
277
+ - **Publishing setup.** A brand-new package's first npm publish and its trusted
278
+ publisher are a manual step for a maintainer.
279
+ - **`base-app` adoption.** Mounting this behind an OFF-by-default feature flag in
280
+ `12-apps/base-app` is tracked with the ticket; that repository is not part of
281
+ this change.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # @12-apps/storage
2
+
3
+ Image uploads that land at **your own origin**, never at the bucket.
4
+
5
+ One backend factory, one frontend factory, a driver port with two
6
+ implementations, and an image-pipeline port so a native dependency stays the
7
+ host's choice.
8
+
9
+ > **Adopting this?** This README is the API reference; **[`ADOPTING.md`](./ADOPTING.md)**
10
+ > is the step-by-step wiring, including the five required config values and why
11
+ > each of them has no default.
12
+
13
+ ## The rule the whole package is built around
14
+
15
+ **The browser POSTs its bytes to the app server. There is no presigned-URL
16
+ mode, and there must never be one.**
17
+
18
+ A presigned bucket URL makes a working upload depend on the bucket's **CORS
19
+ rules** — configuration that lives in neither this repository nor any deploy it
20
+ runs. A `PUT` carrying a `Content-Type` is never a "simple" request, so the
21
+ browser sends an `OPTIONS` preflight first; a freshly created bucket answers it
22
+ with `403` and no `Access-Control-Allow-*` headers, and the upload is never
23
+ sent. What makes that failure mode particularly bad:
24
+
25
+ - **The server sees nothing.** No request ever arrives — no log line, no error
26
+ rate, no 5xx.
27
+ - **The browser will not say why.** A blocked cross-origin fetch is deliberately
28
+ indistinguishable from an offline network at the `fetch` layer.
29
+ - **The person who sees it cannot fix it.** The message reaches a store owner,
30
+ about a bucket only an operator can configure.
31
+
32
+ A second reason it cannot come back: a stored photo is a **set** of objects the
33
+ server derives from the bytes, so an upload the server never sees cannot produce
34
+ one.
35
+
36
+ ## Subpath exports
37
+
38
+ | Import | Contents |
39
+ | --- | --- |
40
+ | `@12-apps/storage` | Isomorphic core: format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, URL builders, wire schemas. |
41
+ | `@12-apps/storage/server` | `createApiStorage(config)`, the driver port, the local-disk driver, the image-pipeline port with both implementations, the reclaim. |
42
+ | `@12-apps/storage/hono` | `storageRouter({ …config, resolveActor })` — a one-call mount. `hono` is an OPTIONAL peer. |
43
+ | `@12-apps/storage/s3` | `createS3Driver(config)` for every S3-compatible vendor. `@aws-sdk/client-s3` is an OPTIONAL peer, resolved only here. |
44
+ | `@12-apps/storage/react` | `createWebStorage({ apiBase })` — the bound picker, the preview, the upload hook, the browser-side re-encode. |
45
+
46
+ The exports map points at `./src` directly — no build step is required at
47
+ runtime.
48
+
49
+ ## The two endpoints
50
+
51
+ ```
52
+ POST <mount>/uploads/image raw bytes in, { data: { imageKey } } out
53
+ GET <mount>/uploads/local/… the object back, or a redirect to where it lives
54
+ ```
55
+
56
+ The serve route has two behaviours and the **driver** decides which: a driver
57
+ that keeps its own bytes implements `read` and the route streams them; a
58
+ bucket-backed driver does not, and the route redirects to `publicUrl`. That used
59
+ to be an environment flag read inside the route — the same fact in a second
60
+ place, free to disagree with the driver actually in use.
61
+
62
+ ## What a stored photo is
63
+
64
+ ```
65
+ products/<scope>/<uuid>/full.webp the whole picture — the zoom
66
+ products/<scope>/<uuid>/card-320.webp 4:3, a card on a phone
67
+ products/<scope>/<uuid>/card-640.webp 4:3, a retina card / a phone hero
68
+ products/<scope>/<uuid>/card-1280.webp 4:3, a retina hero on a desktop
69
+ products/<scope>/<uuid>/thumb-128.webp 1:1, a 64px row thumbnail
70
+ products/<scope>/<uuid>/thumb-256.webp 1:1, the same row at 2×
71
+ ```
72
+
73
+ The key saved on the row names the uncropped member, and **the key's shape is
74
+ what says the crops exist**: `…/<uuid>/full.<ext>` has them, the flat
75
+ `…/<uuid>.<ext>` does not. Nothing else records it — no column, and therefore
76
+ nothing to keep in step across the draft, the change request, the recycle-bin
77
+ snapshot and the version history a row is copied into.
78
+
79
+ `<scope>` is the tenant. It is what makes cross-tenant reclaim a parse rather
80
+ than a query: a reclaim refuses a key whose scope is not the caller's *before*
81
+ consulting any reference probe. A scheme without it can only answer "may this
82
+ tenant delete this object?" by proving no row of **theirs** points at it — which
83
+ is true of every other tenant's objects too.
84
+
85
+ ## The five values with no default
86
+
87
+ `createApiStorage` requires `driver`, `maxBytes`, `imagePipeline`,
88
+ `unscopedKeys` and `references`. Each safe value is host-specific, so a default
89
+ would be a decision made by whoever typed the first host and silently inherited
90
+ by every one after it — and each of these fails **quietly**, which is the actual
91
+ argument. A loud wrong default gets fixed on the first deploy; these do not:
92
+
93
+ | Config | What a default would cost |
94
+ | --- | --- |
95
+ | `driver` | A container's ephemeral disk in production, reporting every upload as a success and losing the objects at the next deploy. |
96
+ | `maxBytes` | Whatever number this file happens to carry, in a host whose tool schemas advertise something else. |
97
+ | `imagePipeline` | Whether crops exist at all — discovered from the shape of a key in production. |
98
+ | `unscopedKeys` | Whether one tenant's reclaim can reach another tenant's bytes. |
99
+ | `references` | Whether a restored row still has its photo. `[]` is not neutral: it means *reclaim everything immediately*. |
100
+
101
+ `logger` still defaults, and it is the one that legitimately can: a reclaim may
102
+ only report, never raise, so the worst a defaulted logger costs is silence.
103
+ `references` defaulted too, until it didn't — because its default **destroys**.
104
+
105
+ ## Backend, in full
106
+
107
+ **Note the absence of `??`.** Every value below either comes from the
108
+ environment and must be there, or is written out. That is deliberate: a
109
+ `process.env.UPLOADS_DIR ?? \`${process.cwd()}/.uploads\`` in this position is
110
+ exactly the fail-open default the table above refuses to have. `UPLOADS_DIR`
111
+ unset in production means uploads succeed, one container holds the object, the
112
+ other 404s half the `<img>` requests, and the next deploy discards both — with
113
+ nothing in any log, because `writeFile` succeeded every time.
114
+
115
+ ```ts
116
+ import { createApiStorage } from '@12-apps/storage/server';
117
+ import { createLocalDiskDriver, passthroughImagePipeline } from '@12-apps/storage/server';
118
+ import { DEFAULT_MAX_UPLOAD_BYTES, STORAGE_PATHS } from '@12-apps/storage';
119
+
120
+ /** Fail at BOOT for a missing value, rather than at the next deploy. */
121
+ function requireEnv(name: string): string {
122
+ const value = process.env[name];
123
+ if (!value) throw new Error(`${name} is required — it must be a MOUNTED VOLUME, not a container path.`);
124
+ return value;
125
+ }
126
+
127
+ const storage = createApiStorage({
128
+ driver: createLocalDiskDriver({
129
+ root: requireEnv('UPLOADS_DIR'),
130
+ publicPathPrefix: `/api${STORAGE_PATHS.serve}`,
131
+ }),
132
+ maxBytes: DEFAULT_MAX_UPLOAD_BYTES,
133
+ imagePipeline: passthroughImagePipeline(),
134
+ // 'reject' because this host is fresh: every key it will ever mint is scoped, so
135
+ // a key without a scope can only be someone else's or nobody's. A host with rows
136
+ // that predate the scope segment passes 'accept' — see ADOPTING.md rule 2.
137
+ unscopedKeys: 'reject',
138
+ // Which of YOUR tables can still need an object after the row stopped pointing at
139
+ // it. `[]` says "nothing here copies a key" — write it only if you have checked.
140
+ references: [
141
+ { name: 'live-rows', referenced: (scope, key) => rowPointsAt(scope, key) },
142
+ ],
143
+ });
144
+ ```
145
+
146
+ For **development** the `??` is fine, and only here, because a lost object on a
147
+ laptop costs nothing:
148
+
149
+ ```ts
150
+ // DEV ONLY. Never in the block above.
151
+ root: process.env.UPLOADS_DIR ?? `${process.cwd()}/.uploads`,
152
+ ```
153
+
154
+ `storage` carries everything else a host needs:
155
+
156
+ | Member | Use |
157
+ | --- | --- |
158
+ | `routes` | Framework-neutral descriptors. `@12-apps/storage/hono` mounts them. |
159
+ | `limits` | What this mount enforces — feed a tool schema from it, never from a constant of your own. |
160
+ | `schemas.inlineImage` | A zod schema whose base64 ceiling **is** `maxBytes`. |
161
+ | `schemas.objectKey` | The schema for an `imageKey` field. **Not `z.string()`** — see below. |
162
+ | `storeImage` / `storeInlineImage` | The write path for a host write that carries bytes (an agent's only option). |
163
+ | `urls.objectUrl` / `urls.imageSources` / `urls.versionedObjectUrl` | Key → URL, through the active driver. |
164
+ | `reclaim.*` | Reclaiming a replaced object, a superseded set, or keys a failed write just minted. |
165
+
166
+ ### An `imageKey` field is not a string field
167
+
168
+ `objectUrl` returns an already-absolute value **unchanged** — a deliberate
169
+ affordance for a host migrating off a URL column. So a write body that accepts
170
+ `imageKey: z.string()` accepts `https://tracker.example/p.png`, and every buyer
171
+ loading that storefront page then sends their IP and user-agent to a third
172
+ party. No upload, no bucket and no driver is involved, so nothing on the storage
173
+ path can catch it. Use the mount's own schema:
174
+
175
+ ```ts
176
+ const body = z.object({
177
+ imageKey: storage.schemas.objectKey.nullish(),
178
+ image: storage.schemas.inlineImage.optional(),
179
+ }).superRefine(refineImageInput);
180
+ ```
181
+
182
+ ## Frontend, in full
183
+
184
+ ```ts
185
+ import { createWebStorage } from '@12-apps/storage/react';
186
+
187
+ const storage = createWebStorage({ apiBase: '/api' });
188
+ // storage.page a standalone upload surface
189
+ // storage.ImageField the field a form drops in — a key comes out
190
+ // storage.useUpload the primitive, for a bespoke affordance
191
+ ```
192
+
193
+ ## The image pipeline is a port
194
+
195
+ Image processing means a native module, and a native module is build surface
196
+ every consumer would pay for on every deploy whether or not it wants crops. So
197
+ `sharp` is neither a dependency nor a peer here: a host that has it passes the
198
+ module in.
199
+
200
+ ```ts
201
+ import sharp from 'sharp';
202
+ import { createSharpImagePipeline } from '@12-apps/storage/server';
203
+
204
+ imagePipeline: createSharpImagePipeline({ sharp });
205
+ ```
206
+
207
+ `passthroughImagePipeline()` is the other choice: the bytes are stored as they
208
+ arrived, no crops, flat keys — which is exactly what every catalog image was
209
+ before crops existed.
210
+
211
+ **The magic-number check is not the pipeline's job**, and this README used to say
212
+ it was. It belongs to the WRITER: `storeImage` verifies the bytes against the
213
+ declared type as its first statement, before any pipeline sees them. That
214
+ placement is deliberate, because the two implementations cannot both be trusted
215
+ with it — `passthrough` verifies as part of doing nothing, while `sharp` cannot:
216
+ "did libvips decode this" and "is this the format that was declared" are
217
+ different questions, and libvips decodes plenty of formats this allowlist
218
+ excludes. Putting the check in the writer means no path to storage goes around
219
+ it, whichever pipeline a host injected and whether the bytes arrived at the
220
+ endpoint, base64 inside a write, or through a direct `storeImage` call.
221
+
222
+ ## A second vendor is a config entry
223
+
224
+ `createS3Driver` covers every S3-compatible store. AWS needs a `region`;
225
+ DigitalOcean Spaces, MinIO, R2 and the rest need an `endpoint`, plus
226
+ `forcePathStyle` for the ones that cannot do virtual-hosted addressing. No
227
+ branch in the upload path changes, and no host code is written.
228
+
229
+ Nothing in this package reads `process.env`.
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@12-apps/storage",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the local-disk driver, the image-pipeline port, the orphan reclaim), Hono adapter (./hono), the S3-compatible driver behind its own subpath (./s3) and the bound picker + upload hook (./react). Standardized adoption contract in ADOPTING.md.",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./server": "./src/server/index.ts",
9
+ "./react": "./src/react/index.ts",
10
+ "./hono": "./src/hono/index.ts",
11
+ "./s3": "./src/s3/index.ts",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "scripts": {
15
+ "clean": "rm -rf node_modules coverage",
16
+ "test": "node ../../scripts/vitest-with-teardown.mjs run",
17
+ "test:watch": "vitest watch",
18
+ "lint": "eslint src --max-warnings 0",
19
+ "check-types": "tsc --noEmit",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "peerDependencies": {
23
+ "@aws-sdk/client-s3": ">=3.700.0",
24
+ "hono": ">=4.0.0",
25
+ "react": ">=19.0.0",
26
+ "react-dom": ">=19.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@aws-sdk/client-s3": {
30
+ "optional": true
31
+ },
32
+ "hono": {
33
+ "optional": true
34
+ },
35
+ "react": {
36
+ "optional": true
37
+ },
38
+ "react-dom": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "dependencies": {
43
+ "@12-apps/ui": "^4.0.1",
44
+ "zod": "^4.3.5"
45
+ },
46
+ "devDependencies": {
47
+ "@12-apps/eslint-config": "^1.20.0",
48
+ "@12-apps/typescript-config": "^1.20.0",
49
+ "@aws-sdk/client-s3": "^3.703.0",
50
+ "@testing-library/react": "^16.1.0",
51
+ "@types/react": "19.2.2",
52
+ "@types/react-dom": "19.2.2",
53
+ "eslint": "^9.39.1",
54
+ "eslint-plugin-test-flakiness": "^1.4.0",
55
+ "hono": "^4.6.0",
56
+ "jsdom": "^25.0.1",
57
+ "react": "^19.2.0",
58
+ "react-dom": "^19.2.0",
59
+ "typescript": "^5.9.2",
60
+ "vitest": "^3.2.4"
61
+ },
62
+ "engines": {
63
+ "node": ">=22.0.0"
64
+ },
65
+ "license": "MIT",
66
+ "publishConfig": {
67
+ "registry": "https://registry.npmjs.org",
68
+ "access": "public"
69
+ },
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "git+https://github.com/12-apps/shared-packages.git",
73
+ "directory": "packages/storage"
74
+ },
75
+ "files": [
76
+ "src",
77
+ "*.js",
78
+ "*.mjs",
79
+ "*.md",
80
+ "!eslint.config.js",
81
+ "!**/__tests__/**",
82
+ "!**/tests/**",
83
+ "!**/*.test.*",
84
+ "!**/*.spec.*",
85
+ "!**/*.stories.*",
86
+ "!**/*.test-story.*",
87
+ "!**/test-helpers.*"
88
+ ]
89
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The image formats an upload surface accepts, and the extension each is
3
+ * stored under.
4
+ *
5
+ * One map, because three separate places read it and none of them may
6
+ * disagree: the endpoint validates the declared `Content-Type` against it, the
7
+ * key is minted with the extension it names, and the serve path answers with
8
+ * the type that extension maps back to. A format added on one of those three
9
+ * paths only is a file that uploads and then cannot be read, or reads as the
10
+ * wrong type.
11
+ */
12
+
13
+ /** Image MIME types an upload accepts, mapped to their file extension. */
14
+ export const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
15
+ 'image/png': 'png',
16
+ 'image/jpeg': 'jpg',
17
+ 'image/webp': 'webp',
18
+ 'image/gif': 'gif',
19
+ };
20
+
21
+ /** Inverse lookup: extension → MIME type (for `Content-Type` response headers). */
22
+ export const CONTENT_TYPE_BY_EXTENSION: Readonly<Record<string, string>> = Object.fromEntries(
23
+ Object.entries(EXTENSION_BY_CONTENT_TYPE).map(([contentType, extension]) => [
24
+ extension,
25
+ contentType,
26
+ ]),
27
+ );
28
+
29
+ /** The accepted types, as a list — the browser half's `accept` attribute. */
30
+ export const ACCEPTED_CONTENT_TYPES: readonly string[] = Object.keys(EXTENSION_BY_CONTENT_TYPE);
31
+
32
+ /** Is `contentType` one of the formats this surface stores? */
33
+ export function isAcceptedContentType(contentType: string): boolean {
34
+ return contentType in EXTENSION_BY_CONTENT_TYPE;
35
+ }
36
+
37
+ /**
38
+ * `image/png; charset=…` → `image/png`, lowercased; `undefined` for anything
39
+ * outside the allowlist.
40
+ *
41
+ * Parsing and allowlisting in one step on purpose: a caller that got the type
42
+ * back but had to check it itself is a caller that can forget to.
43
+ */
44
+ export function acceptedContentTypeOf(header: string | null | undefined): string | undefined {
45
+ const raw = header?.split(';')[0]?.trim().toLowerCase();
46
+ return raw && isAcceptedContentType(raw) ? raw : undefined;
47
+ }