@fonderie/media 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fonderie, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @fonderie/media
2
+
3
+ Provider-abstracted asset storage for Fonderie — upload, store, and serve
4
+ user-owned images (avatars, workspace logos, customer photos) with **zero
5
+ infrastructure by default** and object storage as a one-line swap.
6
+
7
+ Every SaaS needs profile images; getting the *bytes* right (magic-byte
8
+ validation, size caps, SVG/stored-XSS rejection, cacheable public serving) is
9
+ security-sensitive boilerplate you shouldn't re-derive per app. This brick owns
10
+ it once.
11
+
12
+ ## What you get
13
+
14
+ - `POST /media` — upload a base64 image (guarded: magic-byte sniffing, size cap,
15
+ SVG rejected), returns a `/media/:id` **URL**.
16
+ - `GET /media/:id` — **public**, cached image serving (an `<img src>` can't send
17
+ a Bearer token), with `ETag`/`304`.
18
+ - `DELETE /media/:id` — uploader-only removal.
19
+
20
+ The read contract is monomorphic: consumers always get a **URL**, whatever the
21
+ backend. Point `@fonderie/auth`'s `avatarUrl` (or `@fonderie/customers`') at it.
22
+
23
+ ## Wire it
24
+
25
+ ```ts
26
+ import { MediaModule, DbBlobProvider } from '@fonderie/media';
27
+
28
+ app.register(new MediaModule(store, {
29
+ provider: new DbBlobProvider(store), // zero infra — bytes live in Postgres
30
+ maxBytes: 1_000_000,
31
+ }));
32
+ ```
33
+
34
+ Run its migration alongside the others (it owns `fonderie_*` tables):
35
+
36
+ ```ts
37
+ import { getMigrationsPath } from '@fonderie/media/migrations';
38
+ await new InternalMigrationRunner(store, getMigrationsPath()).run();
39
+ ```
40
+
41
+ ## Storage providers
42
+
43
+ `IStorageProvider` is the seam — `put` / `get` / `delete`, with `get` returning
44
+ either bytes (app serves them) or a redirect URL (backend serves them, e.g. an
45
+ S3 signed URL). Two zero-infra backends ship built in:
46
+
47
+ - **`DbBlobProvider`** — bytes in Postgres (`fonderie_media_blobs`). One process,
48
+ one database, atomic backups. The default.
49
+ - **`LocalFsProvider`** — bytes on the server filesystem.
50
+
51
+ Implement the interface to add object storage (`S3Provider`, etc.) without
52
+ touching product code — the same pattern `@fonderie/billing` uses for payment
53
+ providers.
@@ -0,0 +1,42 @@
1
+ <!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
2
+
3
+ # @fonderie/media — outcomes
4
+
5
+ What this package does to a running app: tables its migrations create,
6
+ rows it seeds, routes it registers. Generated from the migration SQL and
7
+ route tables in source — trust this file instead of reading `dist/` or
8
+ downloading tarballs.
9
+
10
+ ## Database tables (after all migrations)
11
+
12
+ ### `fonderie_media_assets`
13
+
14
+ ```sql
15
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
16
+ owner_type TEXT NOT NULL
17
+ owner_id UUID NOT NULL
18
+ purpose TEXT NOT NULL
19
+ content_type TEXT NOT NULL
20
+ byte_size INTEGER NOT NULL
21
+ storage_ref TEXT NOT NULL
22
+ created_by UUID
23
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
24
+ ```
25
+
26
+ ### `fonderie_media_blobs`
27
+
28
+ ```sql
29
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
30
+ bytes BYTEA NOT NULL
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
32
+ ```
33
+
34
+ Raw SQL ships in `node_modules/@fonderie/media/dist/migrations/sql/` — read it there if you must; never download tarballs.
35
+
36
+ ## HTTP routes registered
37
+
38
+ | Method | Path | Middleware chain (auth / validation / handler) |
39
+ |---|---|---|
40
+ | POST | `/media` | `requireAuth → async (ctx) => { const userId = ctx.user!.id; const body = (ctx.meta['body'] ?? {}) as { dataBase64?: unknown; ownerType?: unknown; ownerId?: unknown; purpose?: unknown; }; if (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.'); } let bytes: Uint8Array; try { bytes = decodeBase64(body.dataBase64); } catch { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.'); } if (bytes.byteLength === 0) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.'); } if (bytes.byteLength > maxBytes) { return setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`); } const contentType = sniffImageType(bytes); if (!contentType || !allowed.includes(contentType)) { return setApiResponse( HTTP.UNPROCESSABLE, 'ASSET_UNSUPPORTED', `Unsupported image type. Allowed: ${allowed.join(', ')}.`, ); } const ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user'; const ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId; const purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar'; const { ref } = await config.provider.put({ bytes, contentType }); const asset = await assets.create({ ownerType, ownerId, purpose, contentType, byteSize: bytes.byteLength, storageRef: ref, createdBy: userId, }); // Build the URL at whatever prefix this route is mounted under // (e.g. '/v1/media/:id'), derived from the request path. const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, ''); return setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', { asset: toMediaAssetDTO(asset, basePath), }); }` |
41
+ | DELETE | `/media/:id` | `requireAuth → async (ctx) => { const id = ctx.meta.params?.['id']; if (!id || !UUID_RE.test(id)) { return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.'); } const asset = await assets.get(id); if (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.'); if (asset.createdBy !== ctx.user!.id) { return setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.'); } await config.provider.delete(asset.storageRef); await assets.delete(id); return setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id }); }` |
42
+ | GET | `/media/:id` | `async (ctx) => { const id = ctx.meta.params?.['id']; if (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 }); const asset = await assets.get(id); if (!asset) return new Response('Not found', { status: 404 }); const etag = `"${asset.id}"`; if (ctx.request.headers.get('if-none-match') === etag) { return new Response(null, { status: 304, headers: { ETag: etag } }); } const fetched = await config.provider.get(asset.storageRef); if (!fetched) return new Response('Not found', { status: 404 }); if (fetched.kind === 'redirect') { return new Response(null, { status: 302, headers: { Location: fetched.url } }); } // Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a // pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects. return new Response(new Uint8Array(fetched.bytes), { status: 200, headers: { 'Content-Type': asset.contentType, 'Content-Length': String(asset.byteSize), 'Cache-Control': 'public, max-age=300', ETag: etag, }, }); }` |
@@ -0,0 +1,103 @@
1
+ <!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
2
+
3
+ # @fonderie/media — signatures
4
+
5
+ ## @fonderie/media
6
+
7
+ Subpath exports: `@fonderie/media/migrations`
8
+
9
+ ```ts
10
+ new MediaModule(store: IStoreAdapter, config: IMediaConfig): MediaModule
11
+ .name: "@fonderie/media"
12
+ .deps: string[]
13
+ .install(app: IFonderieApp): void
14
+
15
+ const DEFAULT_ALLOWED_TYPES: string[]
16
+
17
+ const DEFAULT_MAX_BYTES: 1000000
18
+
19
+ interface IMediaConfig {
20
+ provider: IStorageProvider;
21
+ maxBytes?: number;
22
+ allowedTypes?: string[];
23
+ }
24
+
25
+ new DbBlobProvider(store: IStoreAdapter): DbBlobProvider
26
+ .name: "db-blob"
27
+ .put({ bytes }: { bytes: Uint8Array<ArrayBufferLike>; contentType: string; }): Promise<IStoredRef>
28
+ .get(ref: string): Promise<IFetched | null>
29
+ .delete(ref: string): Promise<void>
30
+
31
+ new LocalFsProvider(dir: string): LocalFsProvider
32
+ .name: "local-fs"
33
+ .put({ bytes }: { bytes: Uint8Array<ArrayBufferLike>; contentType: string; }): Promise<IStoredRef>
34
+ .get(ref: string): Promise<IFetched | null>
35
+ .delete(ref: string): Promise<void>
36
+
37
+ interface IStorageProvider {
38
+ readonly name: string;
39
+ put(input: {
40
+ bytes: Uint8Array;
41
+ contentType: string;
42
+ }): Promise<IStoredRef>;
43
+ get(ref: string): Promise<IFetched | null>;
44
+ delete(ref: string): Promise<void>;
45
+ }
46
+
47
+ type IFetched = {
48
+ kind: 'bytes';
49
+ bytes: Uint8Array;
50
+ } | {
51
+ kind: 'redirect';
52
+ url: string;
53
+ };
54
+
55
+ interface IStoredRef {
56
+ ref: string;
57
+ }
58
+
59
+ new MediaAssetModel(store: IStoreAdapter): MediaAssetModel
60
+ .create(input: ICreateAssetInput): Promise<IMediaAsset>
61
+ .get(id: string): Promise<IMediaAsset | null>
62
+ .latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null>
63
+ .delete(id: string): Promise<void>
64
+
65
+ function toMediaAssetDTO(asset: IMediaAsset, basePath?: string): IMediaAssetDTO
66
+
67
+ interface IMediaAssetDTO {
68
+ id: string;
69
+ url: string;
70
+ contentType: string;
71
+ byteSize: number;
72
+ ownerType: string;
73
+ ownerId: string;
74
+ purpose: string;
75
+ createdAt: string;
76
+ }
77
+
78
+ interface IMediaAsset {
79
+ id: string;
80
+ ownerType: string;
81
+ ownerId: string;
82
+ purpose: string;
83
+ contentType: string;
84
+ byteSize: number;
85
+ storageRef: string;
86
+ createdBy: string | null;
87
+ createdAt: Date;
88
+ }
89
+
90
+ interface ICreateAssetInput {
91
+ ownerType: string;
92
+ ownerId: string;
93
+ purpose: string;
94
+ contentType: string;
95
+ byteSize: number;
96
+ storageRef: string;
97
+ createdBy: string | null;
98
+ }
99
+
100
+ function decodeBase64(input: string): Uint8Array<ArrayBufferLike>
101
+
102
+ function sniffImageType(bytes: Uint8Array<ArrayBufferLike>): string | null
103
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DEFAULT_ALLOWED_TYPES: () => DEFAULT_ALLOWED_TYPES,
24
+ DEFAULT_MAX_BYTES: () => DEFAULT_MAX_BYTES,
25
+ DbBlobProvider: () => DbBlobProvider,
26
+ LocalFsProvider: () => LocalFsProvider,
27
+ MediaAssetModel: () => MediaAssetModel,
28
+ MediaModule: () => MediaModule,
29
+ decodeBase64: () => decodeBase64,
30
+ sniffImageType: () => sniffImageType,
31
+ toMediaAssetDTO: () => toMediaAssetDTO
32
+ });
33
+ module.exports = __toCommonJS(index_exports);
34
+
35
+ // src/routes.ts
36
+ var import_core = require("@fonderie/core");
37
+ var import_middlewares = require("@fonderie/core/middlewares");
38
+
39
+ // src/config.ts
40
+ var DEFAULT_MAX_BYTES = 1e6;
41
+ var DEFAULT_ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
42
+
43
+ // src/models/asset.model.ts
44
+ var toAsset = (r) => ({
45
+ id: r.id,
46
+ ownerType: r.owner_type,
47
+ ownerId: r.owner_id,
48
+ purpose: r.purpose,
49
+ contentType: r.content_type,
50
+ byteSize: r.byte_size,
51
+ storageRef: r.storage_ref,
52
+ createdBy: r.created_by,
53
+ createdAt: r.created_at
54
+ });
55
+ var MediaAssetModel = class {
56
+ constructor(store) {
57
+ this.store = store;
58
+ }
59
+ store;
60
+ async create(input) {
61
+ const rows = await this.store.query(
62
+ `INSERT INTO fonderie_media_assets
63
+ (owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)
64
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
65
+ RETURNING *`,
66
+ [input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy]
67
+ );
68
+ return toAsset(rows[0]);
69
+ }
70
+ async get(id) {
71
+ const rows = await this.store.query("SELECT * FROM fonderie_media_assets WHERE id = $1", [id]);
72
+ return rows[0] ? toAsset(rows[0]) : null;
73
+ }
74
+ /** The most recent asset for an owner + purpose (e.g. a user's current avatar). */
75
+ async latestFor(ownerType, ownerId, purpose) {
76
+ const rows = await this.store.query(
77
+ `SELECT * FROM fonderie_media_assets
78
+ WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3
79
+ ORDER BY created_at DESC LIMIT 1`,
80
+ [ownerType, ownerId, purpose]
81
+ );
82
+ return rows[0] ? toAsset(rows[0]) : null;
83
+ }
84
+ async delete(id) {
85
+ await this.store.query("DELETE FROM fonderie_media_assets WHERE id = $1", [id]);
86
+ }
87
+ };
88
+
89
+ // src/dtos/media.ts
90
+ function toMediaAssetDTO(asset, basePath = "") {
91
+ return {
92
+ id: asset.id,
93
+ url: `${basePath}/media/${asset.id}`,
94
+ contentType: asset.contentType,
95
+ byteSize: asset.byteSize,
96
+ ownerType: asset.ownerType,
97
+ ownerId: asset.ownerId,
98
+ purpose: asset.purpose,
99
+ createdAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt)
100
+ };
101
+ }
102
+
103
+ // src/services/image.ts
104
+ function decodeBase64(input) {
105
+ const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
106
+ const b64 = comma >= 0 ? input.slice(comma + 1) : input;
107
+ return new Uint8Array(Buffer.from(b64, "base64"));
108
+ }
109
+ function sniffImageType(bytes) {
110
+ const b = bytes;
111
+ if (b.length >= 8 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10) {
112
+ return "image/png";
113
+ }
114
+ if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255) {
115
+ return "image/jpeg";
116
+ }
117
+ if (b.length >= 6 && b[0] === 71 && b[1] === 73 && b[2] === 70 && b[3] === 56 && (b[4] === 55 || b[4] === 57) && b[5] === 97) {
118
+ return "image/gif";
119
+ }
120
+ if (b.length >= 12 && b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) {
121
+ return "image/webp";
122
+ }
123
+ return null;
124
+ }
125
+
126
+ // src/routes.ts
127
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
128
+ function buildMediaRoutes(store, config) {
129
+ const assets = new MediaAssetModel(store);
130
+ const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;
131
+ const allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
132
+ return [
133
+ // POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }
134
+ // Accepts base64 (bare or a data URI), verifies it's a real image by its
135
+ // magic bytes (never the client's claim), caps the decoded size, stores
136
+ // the bytes via the provider, and records metadata. Returns a URL.
137
+ [
138
+ "POST",
139
+ "/media",
140
+ import_middlewares.requireAuth,
141
+ async (ctx) => {
142
+ const userId = ctx.user.id;
143
+ const body = ctx.meta["body"] ?? {};
144
+ if (typeof body.dataBase64 !== "string" || body.dataBase64.length === 0) {
145
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "dataBase64 (a base64 string) is required.");
146
+ }
147
+ let bytes;
148
+ try {
149
+ bytes = decodeBase64(body.dataBase64);
150
+ } catch {
151
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "dataBase64 is not valid base64.");
152
+ }
153
+ if (bytes.byteLength === 0) {
154
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "The image is empty.");
155
+ }
156
+ if (bytes.byteLength > maxBytes) {
157
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "ASSET_TOO_LARGE", `Image exceeds the ${maxBytes}-byte limit.`);
158
+ }
159
+ const contentType = sniffImageType(bytes);
160
+ if (!contentType || !allowed.includes(contentType)) {
161
+ return (0, import_core.setApiResponse)(
162
+ import_core.HTTP.UNPROCESSABLE,
163
+ "ASSET_UNSUPPORTED",
164
+ `Unsupported image type. Allowed: ${allowed.join(", ")}.`
165
+ );
166
+ }
167
+ const ownerType = typeof body.ownerType === "string" ? body.ownerType : "user";
168
+ const ownerId = typeof body.ownerId === "string" ? body.ownerId : userId;
169
+ const purpose = typeof body.purpose === "string" ? body.purpose : "avatar";
170
+ const { ref } = await config.provider.put({ bytes, contentType });
171
+ const asset = await assets.create({
172
+ ownerType,
173
+ ownerId,
174
+ purpose,
175
+ contentType,
176
+ byteSize: bytes.byteLength,
177
+ storageRef: ref,
178
+ createdBy: userId
179
+ });
180
+ const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, "");
181
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ASSET_CREATED", "Asset uploaded.", {
182
+ asset: toMediaAssetDTO(asset, basePath)
183
+ });
184
+ }
185
+ ],
186
+ // GET /media/:id (PUBLIC — an <img src> can't send an Authorization
187
+ // header) -> the image bytes with cache headers, or a 302 to a
188
+ // provider-served URL. Assets are immutable, so the id is a stable ETag.
189
+ [
190
+ "GET",
191
+ "/media/:id",
192
+ async (ctx) => {
193
+ const id = ctx.meta.params?.["id"];
194
+ if (!id || !UUID_RE.test(id)) return new Response("Not found", { status: 404 });
195
+ const asset = await assets.get(id);
196
+ if (!asset) return new Response("Not found", { status: 404 });
197
+ const etag = `"${asset.id}"`;
198
+ if (ctx.request.headers.get("if-none-match") === etag) {
199
+ return new Response(null, { status: 304, headers: { ETag: etag } });
200
+ }
201
+ const fetched = await config.provider.get(asset.storageRef);
202
+ if (!fetched) return new Response("Not found", { status: 404 });
203
+ if (fetched.kind === "redirect") {
204
+ return new Response(null, { status: 302, headers: { Location: fetched.url } });
205
+ }
206
+ return new Response(new Uint8Array(fetched.bytes), {
207
+ status: 200,
208
+ headers: {
209
+ "Content-Type": asset.contentType,
210
+ "Content-Length": String(asset.byteSize),
211
+ "Cache-Control": "public, max-age=300",
212
+ ETag: etag
213
+ }
214
+ });
215
+ }
216
+ ],
217
+ // DELETE /media/:id -> removes the asset; only the uploader may delete it.
218
+ [
219
+ "DELETE",
220
+ "/media/:id",
221
+ import_middlewares.requireAuth,
222
+ async (ctx) => {
223
+ const id = ctx.meta.params?.["id"];
224
+ if (!id || !UUID_RE.test(id)) {
225
+ return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "ASSET_NOT_FOUND", "No such asset.");
226
+ }
227
+ const asset = await assets.get(id);
228
+ if (!asset) return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "ASSET_NOT_FOUND", "No such asset.");
229
+ if (asset.createdBy !== ctx.user.id) {
230
+ return (0, import_core.setApiResponse)(import_core.HTTP.FORBIDDEN, "FORBIDDEN", "You can only delete assets you uploaded.");
231
+ }
232
+ await config.provider.delete(asset.storageRef);
233
+ await assets.delete(id);
234
+ return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ASSET_DELETED", "Asset deleted.", { id });
235
+ }
236
+ ]
237
+ ];
238
+ }
239
+
240
+ // src/module.ts
241
+ var MediaModule = class {
242
+ constructor(store, config) {
243
+ this.store = store;
244
+ this.config = config;
245
+ }
246
+ store;
247
+ config;
248
+ name = "@fonderie/media";
249
+ deps = ["@fonderie/auth"];
250
+ install(app) {
251
+ for (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {
252
+ app.addRoute(method, path, ...handlers);
253
+ }
254
+ }
255
+ };
256
+
257
+ // src/providers/db-blob.ts
258
+ var DbBlobProvider = class {
259
+ constructor(store) {
260
+ this.store = store;
261
+ }
262
+ store;
263
+ name = "db-blob";
264
+ async put({ bytes }) {
265
+ const rows = await this.store.query(
266
+ "INSERT INTO fonderie_media_blobs (bytes) VALUES ($1) RETURNING id",
267
+ [Buffer.from(bytes)]
268
+ );
269
+ return { ref: rows[0].id };
270
+ }
271
+ async get(ref) {
272
+ const rows = await this.store.query(
273
+ "SELECT bytes FROM fonderie_media_blobs WHERE id = $1",
274
+ [ref]
275
+ );
276
+ const row = rows[0];
277
+ return row ? { kind: "bytes", bytes: row.bytes } : null;
278
+ }
279
+ async delete(ref) {
280
+ await this.store.query("DELETE FROM fonderie_media_blobs WHERE id = $1", [ref]);
281
+ }
282
+ };
283
+
284
+ // src/providers/local-fs.ts
285
+ var import_promises = require("fs/promises");
286
+ var import_node_crypto = require("crypto");
287
+ var import_node_path = require("path");
288
+ var LocalFsProvider = class {
289
+ name = "local-fs";
290
+ dir;
291
+ ready = null;
292
+ constructor(dir) {
293
+ this.dir = (0, import_node_path.resolve)(dir);
294
+ }
295
+ ensureDir() {
296
+ if (!this.ready) this.ready = (0, import_promises.mkdir)(this.dir, { recursive: true }).then(() => void 0);
297
+ return this.ready;
298
+ }
299
+ // Reject any ref that isn't a bare id, so a ref can never escape `dir`
300
+ // (path traversal). Ids we mint are UUIDs.
301
+ pathFor(ref) {
302
+ if (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error("invalid media ref");
303
+ return (0, import_node_path.join)(this.dir, ref);
304
+ }
305
+ async put({ bytes }) {
306
+ await this.ensureDir();
307
+ const ref = (0, import_node_crypto.randomUUID)();
308
+ await (0, import_promises.writeFile)(this.pathFor(ref), bytes);
309
+ return { ref };
310
+ }
311
+ async get(ref) {
312
+ try {
313
+ const bytes = await (0, import_promises.readFile)(this.pathFor(ref));
314
+ return { kind: "bytes", bytes };
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+ async delete(ref) {
320
+ await (0, import_promises.rm)(this.pathFor(ref), { force: true });
321
+ }
322
+ };
323
+ // Annotate the CommonJS export names for ESM import in node:
324
+ 0 && (module.exports = {
325
+ DEFAULT_ALLOWED_TYPES,
326
+ DEFAULT_MAX_BYTES,
327
+ DbBlobProvider,
328
+ LocalFsProvider,
329
+ MediaAssetModel,
330
+ MediaModule,
331
+ decodeBase64,
332
+ sniffImageType,
333
+ toMediaAssetDTO
334
+ });
335
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/routes.ts","../src/config.ts","../src/models/asset.model.ts","../src/dtos/media.ts","../src/services/image.ts","../src/module.ts","../src/providers/db-blob.ts","../src/providers/local-fs.ts"],"sourcesContent":["export { MediaModule } from './module';\nexport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES } from './config';\nexport type { IMediaConfig } from './config';\n\n// Storage providers — DbBlobProvider (zero infra) ships built in; implement\n// IStorageProvider to add object storage without touching product code.\nexport { DbBlobProvider, LocalFsProvider } from './providers';\nexport type { IStorageProvider, IFetched, IStoredRef } from './providers';\n\n// For server-side resolution (e.g. wiring a user's avatar URL) and custom flows.\nexport { MediaAssetModel } from './models/asset.model';\nexport { toMediaAssetDTO } from './dtos/media';\nexport type { IMediaAssetDTO } from './dtos/media';\nexport type { IMediaAsset, ICreateAssetInput } from './types';\nexport { decodeBase64, sniffImageType } from './services/image';\n","import type { Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAuth } from '@fonderie/core/middlewares';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type IMediaConfig } from './config';\nimport { MediaAssetModel } from './models/asset.model';\nimport { toMediaAssetDTO } from './dtos/media';\nimport { decodeBase64, sniffImageType } from './services/image';\n\ntype Route = [string, string, ...Middleware[]];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function buildMediaRoutes(store: IStoreAdapter, config: IMediaConfig): Route[] {\n\tconst assets = new MediaAssetModel(store);\n\tconst maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;\n\tconst allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;\n\n\treturn [\n\t\t// POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }\n\t\t// Accepts base64 (bare or a data URI), verifies it's a real image by its\n\t\t// magic bytes (never the client's claim), caps the decoded size, stores\n\t\t// the bytes via the provider, and records metadata. Returns a URL.\n\t\t[\n\t\t\t'POST',\n\t\t\t'/media',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst userId = ctx.user!.id;\n\t\t\t\tconst body = (ctx.meta['body'] ?? {}) as {\n\t\t\t\t\tdataBase64?: unknown;\n\t\t\t\t\townerType?: unknown;\n\t\t\t\t\townerId?: unknown;\n\t\t\t\t\tpurpose?: unknown;\n\t\t\t\t};\n\n\t\t\t\tif (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.');\n\t\t\t\t}\n\n\t\t\t\tlet bytes: Uint8Array;\n\t\t\t\ttry {\n\t\t\t\t\tbytes = decodeBase64(body.dataBase64);\n\t\t\t\t} catch {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength > maxBytes) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tconst contentType = sniffImageType(bytes);\n\t\t\t\tif (!contentType || !allowed.includes(contentType)) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t\t\t'ASSET_UNSUPPORTED',\n\t\t\t\t\t\t`Unsupported image type. Allowed: ${allowed.join(', ')}.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user';\n\t\t\t\tconst ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId;\n\t\t\t\tconst purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar';\n\n\t\t\t\tconst { ref } = await config.provider.put({ bytes, contentType });\n\t\t\t\tconst asset = await assets.create({\n\t\t\t\t\townerType,\n\t\t\t\t\townerId,\n\t\t\t\t\tpurpose,\n\t\t\t\t\tcontentType,\n\t\t\t\t\tbyteSize: bytes.byteLength,\n\t\t\t\t\tstorageRef: ref,\n\t\t\t\t\tcreatedBy: userId,\n\t\t\t\t});\n\n\t\t\t\t// Build the URL at whatever prefix this route is mounted under\n\t\t\t\t// (e.g. '/v1/media/:id'), derived from the request path.\n\t\t\t\tconst basePath = new URL(ctx.request.url).pathname.replace(/\\/media$/, '');\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', {\n\t\t\t\t\tasset: toMediaAssetDTO(asset, basePath),\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// GET /media/:id (PUBLIC — an <img src> can't send an Authorization\n\t\t// header) -> the image bytes with cache headers, or a 302 to a\n\t\t// provider-served URL. Assets are immutable, so the id is a stable ETag.\n\t\t[\n\t\t\t'GET',\n\t\t\t'/media/:id',\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst etag = `\"${asset.id}\"`;\n\t\t\t\tif (ctx.request.headers.get('if-none-match') === etag) {\n\t\t\t\t\treturn new Response(null, { status: 304, headers: { ETag: etag } });\n\t\t\t\t}\n\n\t\t\t\tconst fetched = await config.provider.get(asset.storageRef);\n\t\t\t\tif (!fetched) return new Response('Not found', { status: 404 });\n\t\t\t\tif (fetched.kind === 'redirect') {\n\t\t\t\t\treturn new Response(null, { status: 302, headers: { Location: fetched.url } });\n\t\t\t\t}\n\t\t\t\t// Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a\n\t\t\t\t// pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects.\n\t\t\t\treturn new Response(new Uint8Array(fetched.bytes), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': asset.contentType,\n\t\t\t\t\t\t'Content-Length': String(asset.byteSize),\n\t\t\t\t\t\t'Cache-Control': 'public, max-age=300',\n\t\t\t\t\t\tETag: etag,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// DELETE /media/:id -> removes the asset; only the uploader may delete it.\n\t\t[\n\t\t\t'DELETE',\n\t\t\t'/media/:id',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) {\n\t\t\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\t}\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\tif (asset.createdBy !== ctx.user!.id) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.');\n\t\t\t\t}\n\t\t\t\tawait config.provider.delete(asset.storageRef);\n\t\t\t\tawait assets.delete(id);\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id });\n\t\t\t},\n\t\t],\n\t];\n}\n","import type { IStorageProvider } from './providers/types';\n\nexport interface IMediaConfig {\n\t/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */\n\tprovider: IStorageProvider;\n\t/** Max decoded size per asset, in bytes. Default 1 MB. */\n\tmaxBytes?: number;\n\t/**\n\t * Content types accepted on upload (matched against magic bytes, not the\n\t * client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —\n\t * it's a stored-XSS vector.\n\t */\n\tallowedTypes?: string[];\n}\n\nexport const DEFAULT_MAX_BYTES = 1_000_000;\nexport const DEFAULT_ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { ICreateAssetInput, IMediaAsset } from '../types';\n\ninterface AssetRow {\n\tid: string;\n\towner_type: string;\n\towner_id: string;\n\tpurpose: string;\n\tcontent_type: string;\n\tbyte_size: number;\n\tstorage_ref: string;\n\tcreated_by: string | null;\n\tcreated_at: Date;\n}\n\nconst toAsset = (r: AssetRow): IMediaAsset => ({\n\tid: r.id,\n\townerType: r.owner_type,\n\townerId: r.owner_id,\n\tpurpose: r.purpose,\n\tcontentType: r.content_type,\n\tbyteSize: r.byte_size,\n\tstorageRef: r.storage_ref,\n\tcreatedBy: r.created_by,\n\tcreatedAt: r.created_at,\n});\n\n/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */\nexport class MediaAssetModel {\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync create(input: ICreateAssetInput): Promise<IMediaAsset> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`INSERT INTO fonderie_media_assets\n\t\t\t (owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)\n\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\t RETURNING *`,\n\t\t\t[input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy],\n\t\t);\n\t\treturn toAsset(rows[0]!);\n\t}\n\n\tasync get(id: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>('SELECT * FROM fonderie_media_assets WHERE id = $1', [id]);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\t/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */\n\tasync latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`SELECT * FROM fonderie_media_assets\n\t\t\t WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3\n\t\t\t ORDER BY created_at DESC LIMIT 1`,\n\t\t\t[ownerType, ownerId, purpose],\n\t\t);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\tasync delete(id: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_assets WHERE id = $1', [id]);\n\t}\n}\n","import type { IMediaAsset } from '../types';\n\n/**\n * The wire shape for a stored asset. `url` is the monomorphic read contract:\n * always a `/media/:id` path, whatever the backend — clients render it in an\n * `<img>` and never care whether the bytes came from Postgres, disk, or S3.\n */\nexport interface IMediaAssetDTO {\n\tid: string;\n\turl: string;\n\tcontentType: string;\n\tbyteSize: number;\n\townerType: string;\n\townerId: string;\n\tpurpose: string;\n\tcreatedAt: string;\n}\n\n/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */\nexport function toMediaAssetDTO(asset: IMediaAsset, basePath = ''): IMediaAssetDTO {\n\treturn {\n\t\tid: asset.id,\n\t\turl: `${basePath}/media/${asset.id}`,\n\t\tcontentType: asset.contentType,\n\t\tbyteSize: asset.byteSize,\n\t\townerType: asset.ownerType,\n\t\townerId: asset.ownerId,\n\t\tpurpose: asset.purpose,\n\t\tcreatedAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt),\n\t};\n}\n","/**\n * Decode a base64 payload to bytes. Accepts both a bare base64 string and a\n * data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces\n * the latter, so callers don't have to strip it.\n */\nexport function decodeBase64(input: string): Uint8Array {\n\tconst comma = input.startsWith('data:') ? input.indexOf(',') : -1;\n\tconst b64 = comma >= 0 ? input.slice(comma + 1) : input;\n\treturn new Uint8Array(Buffer.from(b64, 'base64'));\n}\n\n/**\n * Identify an image from its magic bytes — NOT from a client-claimed MIME type,\n * which is trivially spoofed. Returns the canonical content type or `null` for\n * anything unrecognised. SVG is deliberately not detected (it's XML, can carry\n * scripts, and is a stored-XSS vector), so it falls through to `null` and is\n * rejected upstream.\n */\nexport function sniffImageType(bytes: Uint8Array): string | null {\n\tconst b = bytes;\n\t// PNG: 89 50 4E 47 0D 0A 1A 0A\n\tif (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {\n\t\treturn 'image/png';\n\t}\n\t// JPEG: FF D8 FF\n\tif (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {\n\t\treturn 'image/jpeg';\n\t}\n\t// GIF: \"GIF87a\" / \"GIF89a\"\n\tif (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) {\n\t\treturn 'image/gif';\n\t}\n\t// WEBP: \"RIFF\" .... \"WEBP\" (bytes 0-3 and 8-11)\n\tif (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {\n\t\treturn 'image/webp';\n\t}\n\treturn null;\n}\n","import type { IFonderieApp, IFonderieModule } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMediaConfig } from './config';\nimport { buildMediaRoutes } from './routes';\n\n/**\n * Provider-abstracted asset storage. Register it like any other brick; it adds\n * `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores\n * bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero\n * infra, swappable for object storage). Depends on `@fonderie/auth` for the\n * authenticated caller on upload/delete.\n */\nexport class MediaModule implements IFonderieModule {\n\treadonly name = '@fonderie/media';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(\n\t\tprivate readonly store: IStoreAdapter,\n\t\tprivate readonly config: IMediaConfig,\n\t) {}\n\n\tinstall(app: IFonderieApp): void {\n\t\tfor (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {\n\t\t\tapp.addRoute(method, path, ...handlers);\n\t\t}\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live in Postgres (`fonderie_media_blobs`, created by\n * this package's migration). Great for getting started and self-hosting — the\n * whole app is one Node process + one database, and a `pg_dump` captures the\n * images atomically with their metadata. Swap to `S3Provider` when bandwidth or\n * table size make object storage worth the extra moving part; no app code\n * changes, only the `MediaModule` config line.\n */\nexport class DbBlobProvider implements IStorageProvider {\n\treadonly name = 'db-blob';\n\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t'INSERT INTO fonderie_media_blobs (bytes) VALUES ($1) RETURNING id',\n\t\t\t[Buffer.from(bytes)],\n\t\t);\n\t\treturn { ref: rows[0]!.id };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\tconst rows = await this.store.query<{ bytes: Buffer }>(\n\t\t\t'SELECT bytes FROM fonderie_media_blobs WHERE id = $1',\n\t\t\t[ref],\n\t\t);\n\t\tconst row = rows[0];\n\t\treturn row ? { kind: 'bytes', bytes: row.bytes } : null;\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_blobs WHERE id = $1', [ref]);\n\t}\n}\n","import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\nimport { join, resolve } from 'node:path';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful\n * for a single-box deployment that wants images off the database without\n * standing up object storage. The `ref` is an opaque filename; the asset's\n * content type is tracked in `fonderie_media_assets`, so nothing about the\n * bytes-on-disk needs to encode it.\n *\n * (Serves inline through the app like `DbBlobProvider`. It has no CDN in front,\n * so at real scale prefer `S3Provider` — same interface, one config line.)\n */\nexport class LocalFsProvider implements IStorageProvider {\n\treadonly name = 'local-fs';\n\tprivate readonly dir: string;\n\tprivate ready: Promise<void> | null = null;\n\n\tconstructor(dir: string) {\n\t\tthis.dir = resolve(dir);\n\t}\n\n\tprivate ensureDir(): Promise<void> {\n\t\tif (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => undefined);\n\t\treturn this.ready;\n\t}\n\n\t// Reject any ref that isn't a bare id, so a ref can never escape `dir`\n\t// (path traversal). Ids we mint are UUIDs.\n\tprivate pathFor(ref: string): string {\n\t\tif (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error('invalid media ref');\n\t\treturn join(this.dir, ref);\n\t}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tawait this.ensureDir();\n\t\tconst ref = randomUUID();\n\t\tawait writeFile(this.pathFor(ref), bytes);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\ttry {\n\t\t\tconst bytes = await readFile(this.pathFor(ref));\n\t\t\treturn { kind: 'bytes', bytes };\n\t\t} catch {\n\t\t\treturn null; // ENOENT (or an invalid ref) → treated as not found\n\t\t}\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait rm(this.pathFor(ref), { force: true }); // force → no throw when already gone\n\t}\n}\n"],"mappings":"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","names":[]}