@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 +281 -0
- package/README.md +229 -0
- package/package.json +89 -0
- package/src/content-types.ts +47 -0
- package/src/hono/index.ts +96 -0
- package/src/index.ts +98 -0
- package/src/keys.ts +331 -0
- package/src/limits.ts +37 -0
- package/src/paths.ts +14 -0
- package/src/payload.ts +127 -0
- package/src/problems.ts +116 -0
- package/src/react/create-web-storage.tsx +100 -0
- package/src/react/failures.ts +201 -0
- package/src/react/image-field.tsx +119 -0
- package/src/react/index.ts +44 -0
- package/src/react/optimize-image.ts +215 -0
- package/src/react/use-upload.ts +121 -0
- package/src/renditions.ts +71 -0
- package/src/s3/index.ts +105 -0
- package/src/server/create-api-storage.ts +221 -0
- package/src/server/driver.ts +43 -0
- package/src/server/drivers/local-disk.ts +104 -0
- package/src/server/index.ts +61 -0
- package/src/server/pipeline/passthrough.ts +28 -0
- package/src/server/pipeline/port.ts +59 -0
- package/src/server/pipeline/sharp-module.ts +57 -0
- package/src/server/pipeline/sharp-renditions.ts +238 -0
- package/src/server/pipeline/sharp.ts +142 -0
- package/src/server/read-body.ts +42 -0
- package/src/server/reclaim.ts +240 -0
- package/src/server/remote-url.ts +57 -0
- package/src/server/routes.ts +189 -0
- package/src/server/store-image.ts +168 -0
- package/src/urls.ts +118 -0
- package/src/wire.ts +103 -0
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
|
+
}
|