@adonis-agora/media 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,302 @@
1
+ ---
2
+ name: media-stores-delivery
3
+ description: >-
4
+ Pluggable storage seams and delivery in @adonis-agora/media — the MediaStore SPI
5
+ (stores.memory / stores.lucid with the published migration, custom stores via
6
+ StoreFactory, cross-owner keyset list() with encodeMediaCursor/clampMediaListLimit),
7
+ the ImageProcessor SPI (processors.sharp, custom processors), the structural Disk
8
+ contract (putStream, getVisibility), createDriveBackedResolver (module namespace,
9
+ not default export), the bundled disks.s3() with extended operations
10
+ (copy/move/deleteMany/list/size/stat) and declarative visibility, presignS3Url,
11
+ delivery.mode auto|public|signed|proxy, MediaDeliveryHandler and HlsDeliveryHandler
12
+ route mounting, and the @adonis-agora/media/testing doubles. Use when choosing or
13
+ writing a store, wiring S3, serving media, signing URLs, or testing without disks.
14
+ license: MIT
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/media"
18
+ library_version: "0.12.1"
19
+ framework: adonisjs
20
+ sources:
21
+ - "DavideCarvalho/adonis-media:docs/stores-and-processors.mdx"
22
+ - "DavideCarvalho/adonis-media:docs/storage/s3-disk.mdx"
23
+ - "DavideCarvalho/adonis-media:docs/delivery.mdx"
24
+ - "DavideCarvalho/adonis-media:docs/testing.mdx"
25
+ - "DavideCarvalho/adonis-media:packages/adonis/src/disks/drive.ts"
26
+ ---
27
+
28
+ # Storage seams & delivery
29
+
30
+ The library delegates everything heavy to three pluggable seams — the `MediaStore`
31
+ (metadata persistence), the `ImageProcessor` (conversions), and a structural `Disk`
32
+ contract that real `@adonisjs/drive` disks satisfy directly. `delivery` is the mirror-image
33
+ strategy for **reads**.
34
+
35
+ ## Setup
36
+
37
+ ```ts title="config/media.ts"
38
+ import { defineConfig, stores, processors, disks } from '@adonis-agora/media'
39
+
40
+ export default defineConfig({
41
+ disk: 's3',
42
+ disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
43
+ store: 'lucid',
44
+ stores: { lucid: stores.lucid({ connection: 'pg', table: 'media' }) },
45
+ imageProcessor: processors.sharp(),
46
+ delivery: { mode: 'auto', signedTtlSeconds: 300 },
47
+ })
48
+ ```
49
+
50
+ ```sh
51
+ node ace migration:run # publishes the media table for stores.lucid()
52
+ npm i @aws-sdk/client-s3 # optional peer, imported only because s3 is selected
53
+ ```
54
+
55
+ Source: `docs/configuration.mdx`, `docs/stores-and-processors.mdx`.
56
+
57
+ ## Core patterns
58
+
59
+ ### Pattern 1 — the cross-owner `MediaStore.list` read
60
+
61
+ `listByOwner` answers "what has this entity got"; `list` answers "everything, paginated,
62
+ newest first" — the read the dashboard's Collections view is built on:
63
+
64
+ ```ts
65
+ import media from '@adonis-agora/media/services/main'
66
+
67
+ const page = await media.store.list({
68
+ collection: 'gallery',
69
+ ownerType: 'Post',
70
+ prefix: 'Post/42/',
71
+ limit: 50, // default 50, clamped to 200
72
+ })
73
+
74
+ page.items // ordered createdAt desc, then id desc (stable keyset under writes)
75
+ page.nextCursor // opaque string, or null on the last page
76
+ ```
77
+
78
+ Ordering is a stable keyset so paging stays consistent while rows are written underneath.
79
+ `encodeMediaCursor` / `decodeMediaCursor` / `clampMediaListLimit` and the
80
+ `DEFAULT_MEDIA_LIST_LIMIT` / `MAX_MEDIA_LIST_LIMIT` constants are exported for implementers.
81
+
82
+ Source: `docs/dashboard/collections.mdx`, `docs/stores-and-processors.mdx`.
83
+
84
+ ### Pattern 2 — wire a custom store or processor
85
+
86
+ Both seams are tiny interfaces; wire them through a factory thunk so any peer loads lazily:
87
+
88
+ ```ts
89
+ import { defineConfig } from '@adonis-agora/media'
90
+ import type { ImageProcessor, MediaStore, StoreFactory } from '@adonis-agora/media'
91
+
92
+ class RedisMediaStore implements MediaStore { /* save/find/listByOwner/list/delete/nextOrder */ }
93
+ class PassthroughProcessor implements ImageProcessor {
94
+ async convert(input: Buffer, preset) {
95
+ return { data: input, format: preset.format ?? 'webp', contentType: 'image/webp' }
96
+ }
97
+ }
98
+
99
+ defineConfig({
100
+ store: 'redis',
101
+ stores: {
102
+ redis: (() => async () => new RedisMediaStore()) as unknown as StoreFactory,
103
+ },
104
+ imageProcessor: new PassthroughProcessor(), // or a factory thunk
105
+ })
106
+ ```
107
+
108
+ A `StoreFactory` receives the booted app as `StoreContext`, so a driver can resolve a peer's
109
+ service when it builds.
110
+
111
+ Source: `docs/stores-and-processors.mdx` (Writing your own store / processor).
112
+
113
+ ### Pattern 3 — the bundled S3 disk and its extended operations
114
+
115
+ `disks.s3()` implements the base `Disk` contract plus `ExtendedDisk` (detected structurally
116
+ with `isExtendedDisk`) and `MultipartUploadDisk` (`isMultipartCapable`):
117
+
118
+ ```ts
119
+ const s3 = media.disk('s3')
120
+
121
+ if (isExtendedDisk(s3)) {
122
+ await s3.copy('a/photo.jpg', 'b/photo.jpg', { toBucket: 'archive' })
123
+ await s3.deleteMany(['a/1.jpg', 'a/2.jpg'])
124
+ await s3.stat('a/photo.jpg') // { size, contentType?, lastModified? }
125
+ const page = await s3.list('uploads/', { delimiter: '/', limit: 100 })
126
+ page.folders // sub-prefixes from CommonPrefixes
127
+ }
128
+ ```
129
+
130
+ Key options: `endpoint` + `forcePathStyle` for MinIO/R2/Spaces, `publicEndpoint` for the
131
+ host presigned URLs are signed against (the browser's endpoint), `publicBaseUrl` for stable
132
+ `getUrl()` output, and `visibility: 'public' | 'private'` (default `private`) — declarative,
133
+ read by `delivery.mode: 'auto'`, never applied as an ACL and never probed.
134
+
135
+ Source: `docs/storage/s3-disk.mdx`.
136
+
137
+ ### Pattern 4 — serve media through a delivery route you own
138
+
139
+ `delivery.mode` (`auto` default) decides what `deliver()` returns: `public`/`signed` yield
140
+ `{ kind: 'redirect', url }`; `proxy` yields `{ kind: 'stream', stream, mimeType, size,
141
+ fileName }`. `auto` asks the disk's `getVisibility` — public ⇒ public, private ⇒ signed,
142
+ unknown ⇒ signed. `MediaDeliveryHandler` is framework-agnostic: YOU mount the route behind
143
+ YOUR auth:
144
+
145
+ ```ts title="start/routes.ts"
146
+ import { MediaDeliveryHandler, MediaManager } from '@adonis-agora/media'
147
+
148
+ const media = await app.container.make(MediaManager)
149
+ const delivery = new MediaDeliveryHandler({ library: media })
150
+
151
+ router.get('/media/:id', async ({ params, request, response, auth }) => {
152
+ await authorizeMediaAccess(auth.user, params.id) // ← your rule, before handle()
153
+
154
+ const result = await delivery.handle({ mediaId: params.id, conversion: request.input('conversion') })
155
+ if (result.kind === 'redirect') return response.redirect(result.url)
156
+
157
+ response.header('content-type', result.mimeType)
158
+ if (result.size !== undefined) response.header('content-length', String(result.size))
159
+ response.header('content-disposition', `inline; filename="${result.fileName}"`)
160
+ return response.stream(result.stream)
161
+ }).use(middleware.auth())
162
+ ```
163
+
164
+ The handler's own `mode` / `signedTtlSeconds` override the configured ones per call. For
165
+ transformer-generated HLS packages use `HlsDeliveryHandler` — playlists are rewritten per
166
+ request, segment files are validated against the persisted artifact list (no caller input
167
+ ever reaches the disk as a path).
168
+
169
+ Source: `docs/delivery.mdx`, `docs/transformers.mdx` (Serving HLS).
170
+
171
+ ## Common mistakes
172
+
173
+ ### [CRITICAL] Passing the drive service default export instead of the module namespace
174
+
175
+ Wrong:
176
+
177
+ ```ts
178
+ import driveService from '@adonisjs/drive/services/main' // captured default
179
+ const resolve = createDriveBackedResolver({ driveService, configuredDisks: {}, defaultDisk: 's3' })
180
+ ```
181
+
182
+ Correct:
183
+
184
+ ```ts
185
+ import * as driveService from '@adonisjs/drive/services/main' // the NAMESPACE
186
+ const resolve = createDriveBackedResolver({ driveService, configuredDisks: {}, defaultDisk: 's3' })
187
+ ```
188
+
189
+ Mechanism: Drive's service module assigns its manager inside an `app.booted()` callback, so
190
+ at import time the default export is still `undefined` — a captured value stays `undefined`
191
+ forever (writes fail with `DriveNotReadyError`), while the namespace's ESM live binding
192
+ reflects the later assignment.
193
+ Source: `docs/stores-and-processors.mdx` (createDriveBackedResolver).
194
+
195
+ ### [CRITICAL] Exposing a delivery route without authorization
196
+
197
+ Wrong:
198
+
199
+ ```ts
200
+ router.get('/media/:id', async ({ params, response }) => {
201
+ const result = await delivery.handle({ mediaId: params.id })
202
+ if (result.kind === 'redirect') return response.redirect(result.url)
203
+ return response.stream(result.stream)
204
+ }) // no middleware — every record is public to anyone who can guess an id
205
+ ```
206
+
207
+ Correct:
208
+
209
+ ```ts
210
+ router.get('/media/:id', async ({ params, request, response, auth }) => {
211
+ await authorizeMediaAccess(auth.user, params.id)
212
+ const result = await delivery.handle({ mediaId: params.id })
213
+ /* ... */
214
+ }).use(middleware.auth())
215
+ ```
216
+
217
+ Mechanism: `MediaDeliveryHandler` resolves a media id to bytes or a URL and performs NO
218
+ authorization — the same split as `TusUploadHandler`. This is also why the provider mounts
219
+ no delivery route of its own: there is no safe default.
220
+ Source: `docs/delivery.mdx` (error callout: The handler performs NO authorization).
221
+
222
+ ### [HIGH] Relying on auto delivery for storage the browser cannot reach
223
+
224
+ Wrong:
225
+
226
+ ```ts
227
+ disks.s3({ bucket: 'media', endpoint: 'http://minio.internal:9000' }) // internal host
228
+ // delivery auto signs URLs against the INTERNAL host → unresolvable in the browser
229
+ ```
230
+
231
+ Correct:
232
+
233
+ ```ts
234
+ disks.s3({
235
+ bucket: 'media',
236
+ endpoint: 'http://minio.internal:9000', // server-side operations
237
+ publicEndpoint: 'https://files.example.com', // what presigned URLs are signed for
238
+ forcePathStyle: true,
239
+ })
240
+ ```
241
+
242
+ Mechanism: `auto` resolves to `signed` (never `proxy`) when visibility is unknown, and
243
+ SigV4 bakes the host into the signature — presigned URLs must be signed for the endpoint
244
+ the BROWSER reaches. Proxying is a deployment decision and must be opted into explicitly.
245
+ Source: `docs/delivery.mdx` (auto never picks proxy), `docs/storage/s3-disk.mdx` (publicEndpoint).
246
+
247
+ ### [MEDIUM] Implementing a custom MediaStore without a working list()
248
+
249
+ Wrong:
250
+
251
+ ```ts
252
+ class MyStore implements MediaStore {
253
+ async list() { return { items: [], nextCursor: null } } // stubbed "for now"
254
+ // save/find/listByOwner/delete/nextOrder implemented
255
+ }
256
+ ```
257
+
258
+ Correct:
259
+
260
+ ```ts
261
+ class MyStore implements MediaStore {
262
+ async list(options?: MediaListOptions): Promise<MediaListPage> {
263
+ // keyset page over { createdAt desc, id desc } with cursor + clamped limit
264
+ }
265
+ }
266
+ ```
267
+
268
+ Mechanism: nothing inside `attach`/`url`/`delete` calls `list()`, so a stubbed
269
+ implementation surfaces only later — the dashboard's Collections view renders empty and
270
+ admin tooling sees nothing. `list` is part of the `MediaStore` interface, not optional.
271
+ Source: `docs/stores-and-processors.mdx` (The MediaStore), `docs/dashboard/collections.mdx`.
272
+
273
+ ### [MEDIUM] Setting s3 visibility public and expecting an ACL to be applied
274
+
275
+ Wrong:
276
+
277
+ ```ts
278
+ disks.s3({ bucket: 'media', visibility: 'public' }) // expecting objects to become readable
279
+ // → 403s from the bucket: no bucket policy grants anonymous reads
280
+ ```
281
+
282
+ Correct:
283
+
284
+ ```ts
285
+ // make the bucket readable via its policy/CDN, THEN declare it:
286
+ disks.s3({ bucket: 'media', visibility: 'public', publicBaseUrl: 'https://cdn.example.com' })
287
+ ```
288
+
289
+ Mechanism: `visibility` is declarative metadata read by `delivery.mode: 'auto'` — the disk
290
+ never applies an ACL and never probes the bucket, so the declaration must match reality the
291
+ infrastructure already enforces.
292
+ Source: `docs/storage/s3-disk.mdx` (visibility option, getVisibility).
293
+
294
+ ## References
295
+
296
+ - Testing doubles for all three seams: use `@adonis-agora/media/testing` —
297
+ `InMemoryMediaStore`, `inMemoryDiskResolver` (with a `DiskVisibility` argument to test
298
+ `auto` delivery's unknown-visibility branch), `FakeImageProcessor`, `InMemoryUploadSessionStore`,
299
+ `FakeTransformer`. See `docs/testing.mdx`.
300
+
301
+ See also: `media-dashboard-console/SKILL.md` — the console browses through these same
302
+ ExtendedDisk operations and `MediaStore.list`.
@@ -0,0 +1,347 @@
1
+ ---
2
+ name: media-uploads-resumable
3
+ description: >-
4
+ Large-file uploads in @adonis-agora/media — the uploads config key (mode auto|proxy|
5
+ direct, partSize, presignTtlSeconds, opt-in routes), media.uploads
6
+ (initiateDirectUpload/completeDirectUpload/proxyUpload, resolveUploadMode), resumable
7
+ TUS sessions via media.resumable and TusUploadHandler (UploadSessionStore, in-memory +
8
+ Lucid, byte-offset resume), direct sessions via media.direct (presigned part URLs,
9
+ part-granular resume, confirmPart/status), the DirectUploadPolicy hooks
10
+ (onInitiate/resolveComplete/onComplete/mapError, server-side keys), and adopting
11
+ finished uploads with completeUploadToLibrary / completeDirectUploadToLibrary /
12
+ attachExisting. Use when uploading big files, resuming interrupted uploads, writing an
13
+ upload policy, or fixing UploadNotSupportedError / UploadPartsIncompleteError /
14
+ ResumableUploadsNotConfiguredError.
15
+ license: MIT
16
+ metadata:
17
+ type: core
18
+ library: "@adonis-agora/media"
19
+ library_version: "0.12.1"
20
+ framework: adonisjs
21
+ sources:
22
+ - "DavideCarvalho/adonis-media:docs/uploads/upload-modes.mdx"
23
+ - "DavideCarvalho/adonis-media:docs/uploads/resumable-tus.mdx"
24
+ - "DavideCarvalho/adonis-media:docs/uploads/direct-sessions.mdx"
25
+ - "DavideCarvalho/adonis-media:docs/uploads/direct-upload-policy.mdx"
26
+ - "DavideCarvalho/adonis-media:packages/adonis/src/upload_mode.ts"
27
+ ---
28
+
29
+ # Large-file uploads: proxy, direct-S3, TUS, and policies
30
+
31
+ Beyond buffer-once `attach`, the library ships upload coordinators with three strategies:
32
+ **proxy** (bytes stream through your app — any disk), **direct** (browser PUTs presigned
33
+ multipart parts straight to S3 — multipart-capable disk only), and **resumable TUS**
34
+ (byte-offset resume over the tus 1.0.0 protocol). `media.direct` adds session-backed direct
35
+ uploads whose state survives a reload.
36
+
37
+ ## Setup
38
+
39
+ ```ts title="config/media.ts"
40
+ import { defineConfig, disks, stores, uploadSessions } from '@adonis-agora/media'
41
+
42
+ export default defineConfig({
43
+ disk: 's3',
44
+ disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
45
+ store: 'lucid',
46
+ stores: { lucid: stores.lucid() },
47
+
48
+ uploads: {
49
+ mode: 'auto', // auto picks direct on a multipart-capable disk, else proxy
50
+ partSize: 8 * 1024 * 1024, // S3 floor is 5 MiB, cap 10,000 parts
51
+ routes: { enabled: true, prefix: '/media/uploads' }, // opt-in, UNGUARDED routes
52
+
53
+ resumable: { // opt-in — makes media.resumable exist
54
+ store: 'lucid',
55
+ stores: { lucid: uploadSessions.lucid() }, // durable, multi-process sessions
56
+ routes: { enabled: true, prefix: '/media/uploads/tus', maxSize: 5 * 1024 ** 3 },
57
+ },
58
+
59
+ direct: { // opt-in — makes media.direct exist
60
+ store: 'lucid',
61
+ stores: { lucid: uploadSessions.lucid() }, // same session store SPI as TUS
62
+ partSize: 20 * 1024 * 1024,
63
+ routes: {
64
+ enabled: true,
65
+ prefix: '/media/uploads/direct/sessions',
66
+ collection: 'videos', // gate the declared type at initiate
67
+ middleware: [middleware.auth()], // the ONLY gate on these routes
68
+ },
69
+ },
70
+ },
71
+ })
72
+ ```
73
+
74
+ Naming a session `store` with no matching factory throws
75
+ `UploadSessionStoreNotConfiguredError` — never a silent in-memory fallback.
76
+
77
+ Source: `docs/uploads/upload-modes.mdx`, `docs/uploads/resumable-tus.mdx`, `docs/uploads/direct-sessions.mdx`.
78
+
79
+ ## Core patterns
80
+
81
+ ### Pattern 1 — resolve the mode yourself
82
+
83
+ `proxy` is always allowed; `direct` is a REQUIREMENT that throws
84
+ `UploadNotSupportedError` on an incapable disk; `auto` is a PREFERENCE that downgrades:
85
+
86
+ ```ts
87
+ import { resolveUploadMode, isMultipartCapable } from '@adonis-agora/media'
88
+ import type { ResolvedUploadMode, UploadModeLevels } from '@adonis-agora/media'
89
+
90
+ const mode: ResolvedUploadMode = resolveUploadMode(
91
+ { global: 'auto', perCall: request.input('mode') } satisfies UploadModeLevels,
92
+ isMultipartCapable(media.disk('s3')),
93
+ 's3',
94
+ )
95
+ ```
96
+
97
+ Source: `docs/uploads/upload-modes.mdx` (Resolving the mode yourself).
98
+
99
+ ### Pattern 2 — resumable TUS sessions
100
+
101
+ The engine is framework-agnostic (`TusUploadHandler`); the provider mounts thin routes.
102
+ Programmatically:
103
+
104
+ ```ts
105
+ import media from '@adonis-agora/media/services/main'
106
+
107
+ const session = await media.resumable.createUpload({
108
+ disk: 's3', key: `users/1/video.mp4`, size: file.size, contentType: 'video/mp4',
109
+ })
110
+
111
+ let { offset } = await media.resumable.writeChunk(session.id, 0, chunk1)
112
+ ;({ offset } = await media.resumable.writeChunk(session.id, offset, chunk2))
113
+ await media.resumable.status(session.id) // { offset, size, expiresAt }
114
+
115
+ const { key, disk, size } = await media.resumable.complete(session.id)
116
+ ```
117
+
118
+ `media.hasResumable` guards access; `media.resumable` throws
119
+ `ResumableUploadsNotConfiguredError` without config. A standard tus client (or
120
+ `@adonis-agora/media-react`) points at `routes.prefix` and works unchanged.
121
+
122
+ Source: `docs/uploads/resumable-tus.mdx` (media.resumable).
123
+
124
+ ### Pattern 3 — adopt finished uploads into the library (zero-copy)
125
+
126
+ Do NOT read the bytes back through `attach()` — that buffers and rewrites the object.
127
+ Register the object where it already is:
128
+
129
+ ```ts
130
+ // TUS finalize:
131
+ const record = await media.completeUploadToLibrary(session.id, {
132
+ ownerType: 'Patient', ownerId: patient.id, collection: 'exams',
133
+ fileName: 'exam.pdf', mimeType: 'application/pdf',
134
+ })
135
+
136
+ // direct sessions:
137
+ const record2 = await media.completeDirectUploadToLibrary(created.id, {
138
+ ownerType: 'Lesson', ownerId: lesson.id, collection: 'videos',
139
+ fileName: 'original.mp4', mimeType: 'video/mp4',
140
+ }, partsFromClient)
141
+ ```
142
+
143
+ Both chain `library.attachExisting({ ..., key, disk, size })` — the collection's whitelist
144
+ is re-validated against the real bytes (a short head read), `single: true` replace and
145
+ ordering still apply, and nothing streams through the app.
146
+
147
+ Source: `docs/uploads/resumable-tus.mdx` (Turning a finished upload into a media record).
148
+
149
+ ### Pattern 4 — a DirectUploadPolicy owns every app decision
150
+
151
+ The handler owns mechanics; the policy owns decisions. With a policy, `keyFor` is never
152
+ called — the key is computed server-side from authenticated context:
153
+
154
+ ```ts title="app/media/lesson_video_policy.ts"
155
+ import { cuid } from '@adonisjs/core/helpers'
156
+ import type { HttpContext } from '@adonisjs/core/http'
157
+ import type { DirectUploadPolicy, InitiateDecision, CompleteResolution } from '@adonis-agora/media'
158
+
159
+ export default class LessonVideoPolicy implements DirectUploadPolicy<HttpContext, { uploadRowId: string }> {
160
+ async onInitiate(ctx: HttpContext, input): Promise<InitiateDecision<{ uploadRowId: string }>> {
161
+ const user = ctx.auth.getUserOrFail()
162
+ const lesson = await Lesson.findOrFail(ctx.params.lessonId)
163
+ await ctx.bouncer.authorize('uploadLessonVideo', lesson) // ← authorization lives HERE
164
+
165
+ const key = `tenants/${user.tenantId}/lessons/${lesson.id}/${cuid()}.mp4`
166
+ const row = await LessonUpload.create({ lessonId: lesson.id, key, status: 'pending' })
167
+
168
+ return {
169
+ key,
170
+ collection: 'videos',
171
+ context: { uploadRowId: row.id },
172
+ rollback: () => row.delete(), // runs if anything AFTER this decision throws
173
+ }
174
+ }
175
+
176
+ async resolveComplete(ctx, input): Promise<CompleteResolution<{ uploadRowId: string }>> {
177
+ const row = await LessonUpload.findByOrFail('uploadSessionId', input.id)
178
+ return {
179
+ sessionId: input.id,
180
+ target: { ownerType: 'Lesson', ownerId: row.lessonId, collection: 'videos',
181
+ fileName: row.fileName, mimeType: 'video/mp4' },
182
+ context: { uploadRowId: row.id },
183
+ }
184
+ }
185
+
186
+ async onComplete(_ctx, { record, resolution }) {
187
+ await LessonUpload.query().where('id', resolution.context!.uploadRowId)
188
+ .update({ status: 'stored', mediaId: record.id })
189
+ return { mediaId: record.id, status: 'processing' } // ← IS the response body
190
+ }
191
+
192
+ mapError(_ctx, error) {
193
+ if (error instanceof AuthorizationException) return { status: 403, body: { error: 'forbidden' } }
194
+ return undefined // fall through to the handler's mapping
195
+ }
196
+ }
197
+ ```
198
+
199
+ Register it as a lazy thunk: `routes: { policy: () => import('#media/lesson_video_policy') }`.
200
+ The module's default export is used — a class is instantiated with no arguments.
201
+
202
+ Source: `docs/uploads/direct-upload-policy.mdx`.
203
+
204
+ ## Common mistakes
205
+
206
+ ### [CRITICAL] Shipping upload routes without middleware or a policy
207
+
208
+ Wrong:
209
+
210
+ ```ts
211
+ uploads: { direct: { routes: { enabled: true } } } // middleware defaults to []
212
+ ```
213
+
214
+ Correct:
215
+
216
+ ```ts
217
+ uploads: {
218
+ direct: {
219
+ routes: { enabled: true, middleware: [middleware.auth()],
220
+ policy: () => import('#media/lesson_video_policy') },
221
+ },
222
+ }
223
+ ```
224
+
225
+ Mechanism: the built-in routes are unguarded by design — `routes.middleware` defaults to
226
+ `[]` and the handlers perform no authorization whatsoever, so the endpoints are open to the
227
+ internet. Use middleware for "are you logged in" and policy hooks for "may you upload to
228
+ THIS resource".
229
+ Source: `docs/uploads/direct-sessions.mdx` (routes.middleware), `docs/uploads/direct-upload-policy.mdx` (warn callout).
230
+
231
+ ### [CRITICAL] Re-reading finished upload bytes through library.attach()
232
+
233
+ Wrong:
234
+
235
+ ```ts
236
+ const { key, disk } = await media.resumable.complete(session.id)
237
+ const bytes = await media.disk(disk).getBytes(key) // buffers the whole file…
238
+ await media.library.attach({ ..., contents: bytes }) // …and rewrites it to a new key
239
+ ```
240
+
241
+ Correct:
242
+
243
+ ```ts
244
+ const record = await media.completeUploadToLibrary(session.id, {
245
+ ownerType: 'Patient', ownerId: patient.id, collection: 'exams',
246
+ fileName: 'exam.pdf', mimeType: 'application/pdf',
247
+ })
248
+ ```
249
+
250
+ Mechanism: `attach` buffers the payload once and writes a NEW object — exactly what a
251
+ resumable upload exists to avoid. `completeUploadToLibrary` /
252
+ `completeDirectUploadToLibrary` chain `attachExisting`, which registers the existing key
253
+ zero-copy.
254
+ Source: `docs/uploads/resumable-tus.mdx` (Turning a finished upload into a media record).
255
+
256
+ ### [CRITICAL] Expecting direct uploads to work without bucket CORS exposing ETag
257
+
258
+ Wrong:
259
+
260
+ ```json
261
+ // bucket CORS: allowed origins/methods only
262
+ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"] }
263
+ ```
264
+
265
+ Correct:
266
+
267
+ ```json
268
+ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT"],
269
+ "ExposeHeaders": ["ETag"] }
270
+ ```
271
+
272
+ Mechanism: the browser must read the `ETag` response header of each part PUT to confirm it;
273
+ without `ExposeHeaders: ["ETag"]` no part can ever be confirmed — the single most common
274
+ direct-upload misconfiguration (the client fails with an explicit "S3 did not expose an
275
+ ETag" message).
276
+ Source: `docs/uploads/direct-sessions.mdx` (warn callout: Bucket CORS must expose ETag), `docs/react/client.mdx`.
277
+
278
+ ### [MEDIUM] Forcing mode:direct on a disk without native multipart
279
+
280
+ Wrong:
281
+
282
+ ```ts
283
+ uploads: { mode: 'direct' } // disk is the local FS driver
284
+ ```
285
+
286
+ Correct:
287
+
288
+ ```ts
289
+ uploads: { mode: 'auto' } // downgrades to proxy on incapable disks
290
+ ```
291
+
292
+ Mechanism: `direct` is a requirement, `auto` is a preference — forcing `direct` on a disk
293
+ without native multipart throws `UploadNotSupportedError` (`E_MEDIA_UPLOAD_NOT_SUPPORTED`,
294
+ mapped to 400 by the routes) rather than silently downgrading.
295
+ Source: `docs/uploads/upload-modes.mdx` (Resolving the mode yourself).
296
+
297
+ ### [HIGH] Expecting a media record from policy-less direct completion
298
+
299
+ Wrong:
300
+
301
+ ```ts
302
+ await media.direct.complete(id) // returns { key, disk, size } — no MediaRecord created
303
+ await media.library.url('???') // nothing to look up
304
+ ```
305
+
306
+ Correct:
307
+
308
+ ```ts
309
+ const record = await media.completeDirectUploadToLibrary(id, {
310
+ ownerType: 'Lesson', ownerId: lesson.id, collection: 'videos',
311
+ fileName: 'original.mp4', mimeType: 'video/mp4',
312
+ })
313
+ ```
314
+
315
+ Mechanism: without a `DirectUploadPolicy`, `complete` returns the raw assembled object and
316
+ creates NO media record — adopting it into the library is the caller's job. With a policy,
317
+ `resolveComplete` → `attachExisting` → `onComplete` runs automatically.
318
+ Source: `docs/uploads/direct-upload-policy.mdx` (Without a policy).
319
+
320
+ ### [HIGH] Trusting client-supplied keys or TUS metadata as key segments
321
+
322
+ Wrong:
323
+
324
+ ```ts
325
+ // routes take `key` from the request body and use it verbatim
326
+ const key = request.input('key')
327
+ await media.proxyUpload({ key, contents })
328
+ ```
329
+
330
+ Correct:
331
+
332
+ ```ts
333
+ // resolve the key server-side from authenticated context (or use a policy)
334
+ const user = await auth.getUserOrFail()
335
+ await media.proxyUpload({ key: `users/${user.id}/${cuid()}.mp4`, contents })
336
+ // TUS metadata: parse + validate before use
337
+ const meta = parseTusMetadata(request.header('upload-metadata'))
338
+ ```
339
+
340
+ Mechanism: the object key is yours to resolve server-side — a client-supplied key lets one
341
+ tenant overwrite another's objects. TUS `Upload-Metadata` is browser input: validate it and
342
+ never use it as a key segment unsanitized.
343
+ Source: `docs/uploads/upload-modes.mdx` (media.uploads), `docs/react/client.mdx` (Custom TUS metadata warn).
344
+
345
+ See also: `media-react-uploads/SKILL.md` — the browser half that speaks these routes;
346
+ `media-library-collections/SKILL.md` — the collection policy that re-validates adopted
347
+ uploads.