Media Library
Organization-scoped media assets, folders, quotas, thumbnails, and AI image generation.
Overview
The media library is the shared organization asset store for content workflows. It is available from:
/dashboard/content/media, the library/dashboard/content/media/trash, its trash, for media managers
Media assets are organization-scoped. They are used by the page editor, Page AI, and content workflows that need durable URLs for images, video, audio, PDFs, or other supported files.
CMS Role
Media is the CMS asset layer. It owns binary files, generated thumbnails, generated images, public URLs, quota enforcement, and folder organization. Structured CMS data can reference media URLs, and pages or sections can bind media fields to selected assets, but the asset itself stays in the media library.
Use media when a workflow needs a stored file. Use CMS data for typed content, sections for reusable layout, and page documents for composition.
Data Model
Media uses five system tables:
media_foldersmedia_assetsmedia_asset_activitymedia_tagsmedia_asset_tags
Assets store:
owning
organization_idoptional
folder_iddisplay name and normalized display name
MIME type and asset kind
size in bytes
object-storage bucket and storage key
public URL
optional thumbnail metadata
upload or generation metadata
Folder ownership is scoped to the same organization as the asset. Server actions must reject cross-organization folder or asset mutations.
Tags (migration 0093) belong to the organization too: media_tags holds each
tag's name, unique in the organization without case, accents or extra spaces,
and its color; media_asset_tags holds one row per file and tag. A link names
its organization and references the file and the tag through it, so the
database refuses to give a file another organization's tag, and deleting a
file, a tag or the organization deletes its links.
Storage
Media storage uses an S3-compatible provider such as MinIO, AWS S3, or R2. Storage keys follow the organization path convention:
organizations/<organizationId>/media/<asset>
organizations/<organizationId>/media/thumbnails/<assetId>.webp
global/site-variables/<asset>.<content-hash>.<ext>The database stores publicUrl because published pages and CMS-rendered
content need stable public asset URLs. Existing media URLs are not rewritten
when STORAGE_PROVIDER changes, so operators must keep old public URLs
reachable or run a deliberate migration later.
Storage provider:
STORAGE_PROVIDER=s3
S3-compatible storage requires:
STORAGE_MEDIA_BUCKETSTORAGE_PUBLIC_BASE_URLS3_ENDPOINTS3_REGIONS3_ACCESS_KEY_IDS3_SECRET_ACCESS_KEYS3_FORCE_PATH_STYLE
The media library writes to the configured media bucket, and organization logo
uploads use the organization-logos bucket. When the public base URL is the app
or CDN origin, both bucket prefixes must be routed to the S3-compatible object
store before the app fallback.
The seed script uploads the default global site-variable assets, such as the
logo and social image, into the configured media bucket before publishing the
site-variables/main global-data entry. Existing custom site-variable asset
URLs are preserved; only old seed defaults or previous seed-owned storage URLs
are repaired.
See Deployment Environment Setup for provider setup steps.
Supported Asset Kinds
Asset kind is derived from MIME type:
imagevideoaudiopdfdocumentarchiveother
Visual assets can receive WebP thumbnails. Missing thumbnails are generated for the files of the page being listed (never in the trash) when the asset is eligible and the retry cooldown allows another attempt.
Permissions
Media operations are organization-scoped.
Typical access:
organization members can list/read assets
organization managers/admins/owners can upload, edit, move, and delete assets
superadmins can operate globally when the underlying authorization allows it
All server actions must include the active organization scope. Navigation or UI visibility is not a substitute for server-side authorization.
Every media action, in the dashboard and through MCP, is authorized by can()
on the media resource in the organization, and by nothing else. The access
above comes from the seeded roles bound to the organization's groups:
Organization Member lists and reads media, Organization Manager and
Organization Admin manage it. A Better Auth member role string (owner, admin,
manager or member) never grants media access by itself: a person reaches the
library only through a group whose role holds a media policy.
Quotas
Upload quotas come from runtime billing plan limits. Defaults are defined in the billing config and can be overridden through billing plan data:
| Plan | Max file size | Max organization storage |
|---|---|---|
| Free | 25 MB | 1 GB |
| Pro | 100 MB | 10 GB |
| Business | 250 MB | 50 GB |
Quota checks happen before persistence. Generated image assets use the same quota path as manual uploads, so AI generation cannot bypass plan limits.
Dashboard UX
The media library is a dashboard screen (dashboard.content.media, see
Dashboard). Its default shows the storage summary (the plan's
storage against what the organization uses, the trash included), then the
library as a full-page table with three display modes of the same files:
Gallery (default), Table and File tree (see below).
System views. The table offers three views out of the box, before the views members save: Media library (the Gallery, newest first: the screen's own view, named after its table), File tree (folders and files by name) and Table (newest first). They are named in the viewer's language (Médiathèque, Arborescence and Table in French) and cannot be changed or deleted; a member saves a copy to change one. The File tree and Table views come with the media source, not with the screen document, so every organization has them, its screen customized or not.
Folders. Folders are opened, created, renamed, moved and deleted in the File tree, and Gallery and Table filter on a folder, the library's root included.
Tags and facets. Files hold tags of the organization's own catalog: the Tags column, the Gallery cards (screen version 2), a file's details and the File tree's details pane, and the facet panel beside every view, with Tags and Folders. See Tags.
Search. The server searches the whole library, whatever the folder: display name, original file name and MIME type. Filters, sorts and pages run in the database, text sorts in its collation.
Toolbar. Media managers upload files, import an image from a public URL, generate an image when image generation is on, and open the trash. Files can also be dropped on the File tree.
Editing. The edit form changes a file's name, folder and tags, or the folder of a selection. Files can be duplicated or moved to the trash, and previews and record details keep their kind-aware rendering.
Trash. The trash is a page of its own,
/dashboard/content/media/trash, open to media managers (media:manage): its Trash view (the Gallery) and a Table system view, the files deleted last first with the day each one goes for good, and restoration.Saved views. The library's views are saved under
media-assets-v1:alland the trash's undermedia-assets-v1:trash. Views that were saved for one folder became views of the whole library filtered on that folder, named after it (migration 0092).
Keep the interface dense and operational. Media is a working library, not a marketing gallery.
Tags
Tags label files across folders: a file sits in one folder and holds any number of tags. Each organization has its own catalog, for its media library only, and anyone who manages media can add to it.
Tagging. Media managers (
media:manage) tag a file in the Tags column (double-click the cell: a picker that searches the tags without case or accents and creates one from what is typed), in the edit form, or a whole selection with Add tags and Remove tags in the selection bar. Adding and removing keep a file's other tags, those someone else added meanwhile included. Members who only list media see the tags.Facets. The library's facet panel sits on the left of every view (a sheet on phones; the toolbar button shows and hides it). It lists the Tags and the Folders with how many files hold each, under the view's search and other filters. Clicking values filters on them: files with any of the chosen tags, in any of the chosen folders; No value lists the untagged files (for folders, the library's root). The choice is ordinary filter state: in the URL, saved with a view and listed in the filter menu, where a tags filter can also require all the chosen tags, or none of them.
Manage tags. The Tags column's menu › Manage tags renames a tag, gives it a color of the palette (or none: its automatic color), merges it into another tag (every file holding it holds the other instead) or deletes it (every file loses it), each tag with how many files outside the trash hold it. Merging and deleting cannot be undone. Media managers only.
Undo. A change of files' tags is written in each file's Activity with the tags before and after. Its author undoes it from the file's details or with Ctrl/Cmd+Z, like a deletion, while it is the file's last change of tags and every tag it brings back still exists; the change of a selection is undone together.
Files stay as they are. Tagging changes no file: its
updatedAtstays, and the files of an earlier page prototype, read-only, can be tagged. A file keeps its tags in the trash and comes back with them; the trash shows them read-only.Limits. 500 tags per organization, 64 characters per name, 500 files and 50 tags per change.
Server. The media tags service (
src/lib/server/services/media/media-tags.ts) checkscan()on media in the organization for every read and change:media:listreads the catalog,media:managechanges it and tags files. Changes of the catalog follow each other per organization; a merge moves the links and deletes the merged tags in one transaction. A change of files' tags locks the files, refuses a tag that no longer exists and reports the files outside the library or in the trash. Thetagscolumn ofsystem:mediafilters on any, all or none of the tags and on untagged files, and counts each file once under each of its tags; the media sources' aggregates are read live, never cached, so facets and tag counts follow every change.MCP.
yayaw_media_tags_listreads the catalog with each tag's number of files;yayaw_media_tags_applyadds, removes or sets tags on files (by id or by name,createMissingto create names, a reason, a dry run by default);yayaw_media_listandyayaw_media_searchreturn each file's tags and filter ontagIds. Renaming, recoloring, merging and deleting tags stay in the dashboard. See Control plane.
Page Builder Integration
The page editor can bind media fields to existing organization media assets.
Bindings keep the original asset publicUrl so published pages resolve the
same file that was selected in the picker.
Page AI can generate at most one image asset for a prompt that asks for media or
strongly implies a rich landing-page visual. The generated image is persisted
through the media library pipeline and then bound to a compatible generated
section field such as heroImage.
Global public pages can use generated assets from the active organization's media library because the stored URL is public. If no active organization or media permission is available, image generation is skipped with a warning and the page draft still succeeds.
Thumbnail Pipeline
Thumbnail generation:
downloads the source visual asset
converts to WebP
uploads to the
mediabucket under the thumbnails pathstores thumbnail URL/storage metadata on
media_assetsrecords failure metadata when conversion or upload fails
Thumbnail failures should not block listing or rendering the original asset. The retry cooldown prevents repeated expensive attempts for the same broken source.
AI Image Generation
Image generation is gated by:
OPENAI_API_KEYOPENAI_IMAGE_GENERATION_ENABLEDthe managed site setting
media-image-generation-enabledmedia permissions and quotas
Generated images use the configured image model from OPENAI_IMAGE_MODEL and
are saved as WebP media assets.
Server Entry Points
Key files:
src/lib/server/services/screens/defaults/dashboard-content-media.tsanddashboard-content-media-trash.ts(the default screens)src/lib/server/services/screens/sources/system/media.tsandsrc/lib/server/services/media/media-catalog-query.ts(the library and the trash as sources, in SQL)src/blocks/dashboard/screens/sources/media-extensions.tsx(the media table, its toolbar, writes and dialogs)src/lib/server/actions/media/media-library-actions.tssrc/lib/server/actions/media/media-screen-actions.tssrc/lib/server/services/media/media-trash.ts(the trash, its batches and restoration)src/lib/server/services/media/media-tags.tsandsrc/lib/server/actions/media/media-tag-actions.ts(tags)src/lib/shared/media-tags.ts(tag names, colors and limits)src/lib/server/services/media/media-permissions.tssrc/lib/server/services/media/media-asset-storage.tssrc/lib/server/services/media/media-asset-thumbnails.tssrc/lib/server/services/media/media-image-asset-generation.tssrc/lib/server/services/media/media-quotas.tssrc/lib/server/services/media/media-folder-move.tssrc/lib/server/services/media/media-folder-create.tssrc/lib/server/services/media/media-folder-store.tssrc/lib/shared/media-folder-rules.tssrc/lib/shared/media-tree-ids.ts
Operational Notes
Do not store media binaries in Postgres.
Do not use a private URL for published page assets unless the runtime also implements signed URL refresh.
Do not count generated thumbnails against user upload quota.
Do not allow one organization to select, mutate, or delete another organization's folder or asset.
Keep S3 credentials server-only.
Validation
Useful checks after media changes:
bun test src/lib/server/services/media/media-mappers.test.ts
bun test src/lib/server/services/media/media-quotas.test.ts
bun test src/lib/server/services/media/media-asset-storage.test.ts
bun test src/lib/server/services/media/media-asset-thumbnails.test.ts
bun test src/lib/server/services/media/media-folder-move.test.ts src/lib/server/services/media/media-folder-create.test.ts
bun test src/lib/server/services/media/media-permissions.test.ts
.github/scripts/with-throwaway-postgres.sh env MEDIA_FOLDERS_TEST_THROWAWAY=1 bun test src/lib/server/services/media/media-folder-store.postgres.test.ts
.github/scripts/with-throwaway-postgres.sh env MEDIA_TRASH_TEST_THROWAWAY=1 bun test src/lib/server/services/media/media-trash.postgres.test.ts
bun test src/lib/shared/media-tags.test.ts src/lib/server/services/media/media-tags.test.ts
bun test src/lib/server/actions/media/media-tag-actions.test.ts
.github/scripts/with-throwaway-postgres.sh env MEDIA_TAGS_TEST_THROWAWAY=1 bun test src/lib/db/migrations/media-tags.postgres.test.ts src/lib/server/services/media/media-tags.postgres.test.ts
bun test src/lib/server/services/screens/sources/system/media.test.ts src/lib/shared/media-tree-ids.test.ts
bun test src/lib/server/actions/media/media-library-actions.test.ts
bun test src/lib/server/actions/media/media-screen-actions.test.ts
bun test src/blocks/dashboard/screens/sources/media-source-actions.test.ts src/blocks/dashboard/screens/sources/media-source-config.test.ts
bun test src/blocks/dashboard/screens/sources/media-extensions.test.ts
.github/scripts/with-throwaway-postgres.sh env SCREENS_TEST_THROWAWAY=1 bun test src/lib/server/services/screens/sources/system/media.postgres.test.ts
bun run check
bunx tsc --noEmitRestorable trash and activity
Deleting a media asset moves it to Trash for 30 days. The original file and its thumbnail remain in storage, keep their existing public URLs, and continue to count toward storage quota. Active media lists and new CMS media searches exclude trashed assets. Folder/name reservations remain until restoration or purge.
Managers can restore individual files or a selection from Trash. Ctrl/Cmd+Z in the active media table or gallery reverses the current user's newest eligible deletion using the record's Activity history. A bulk deletion shares a transaction identifier; one shortcut restores its members, and partial failure leaves only failed members for retry. Restoring does not erase history: an inverse event references the original deletion.
Deleting a selection is one request and one database transaction (trashMediaAssetsAction, 500 files at most; a larger selection goes in several requests under the same transaction identifier). The files it cannot move stay in the library, and the table says how many and why: files no longer in the library, and files that belong to an earlier page prototype, which the database keeps read-only once the exact page runtime took over (renaming, moving or restoring them is refused the same way). The other files are trashed together. Folders keep their rule and are deleted one by one, only when empty. No media action renders the page again in its answer: the screen reloads its own data, and a failed reload of the folders or the history never replaces the answer of the change itself.
media_assets.deleted_at defines expiry; purge_started_at prevents restoration during cleanup. media_asset_activity records the actor, before/after values and inverse references. The existing page-AI worker checks expired trash periodically and removes the source and thumbnail after 30 days. Storage failures retain the database row for retry. Retention ends exactly 30 days after deletion even if the worker has not yet removed the object. Run the migration and worker when deploying this feature.
The shared permission-checked service is used by the dashboard and MCP. yayaw_media_list defaults to active assets and accepts trashed: true. yayaw_media_trash and yayaw_media_restore require orgId, a nonempty reason, write permission and media:manage in the organization, and default to dryRun: true. They take one file or a batch:
One file:
assetIdwithexpectedUpdatedAt; restore optionally acceptsrevertEventId. Version and organization checks run inside the locked transaction, and the answer is{ assetId, dryRun }.A batch:
assetIds, 1 to 500 distinct ids. An empty list, a repeated id, more than 500 ids, orassetId,expectedUpdatedAtorrevertEventIdalongside are refused before anything changes. The batch goes through the dashboard's batch service: one transaction, the rows locked in id order, one savepoint per file, one activity entry per file under the batch's transaction identifier. A restoration undoes each file's latest move to the trash. A file the batch cannot change never stops the others.
A batch answers results, one { assetId, status } per file in the order sent: trashed or restored (a file already in the trash, or already out of it, included), refused with a code and a reason, or not_found (not in the organization's library). The codes are read_only (a file of an earlier page prototype), failed (try again) and, for a restoration, not_restorable (its 30 days are over or its removal has started) and not_reversible (its move to the trash can no longer be undone). Then come counts (requested, trashed or restored, refused, notFound) and, once applied, transactionId: in the dashboard, one undo by the same person restores a trashed batch together. A batch dry run makes the changes in a transaction it rolls back, so it answers exactly what the call would do, the database's refusals included.
Duplicate media
Use Duplicate in the row menu or Ctrl/Cmd+D on the selected media. Each copy receives an independent storage object and a unique name in the same folder through the existing upload pipeline. Copies count toward storage quotas. Read and create permissions are checked on the server, and trash items cannot be duplicated. A successful batch selects the copies and displays a localized count.
File tree view
The media table (media-assets-v1) offers the YaYaw Table
File tree next to Gallery and Table. Gallery stays the
default; the choice is saved per view like any display mode. The trash keeps
Gallery and Table only, since trashed files have no folder to browse.
Rows. Gallery and Table list assets only. The File tree lists folders and assets together: a folder becomes a row
folder:<id>withnodeKind: "folder", an asset keeps its id withnodeKind: "file", and both carryparentId(folder:<id>of their folder, or empty at the root). The tree askssystem:mediafor rows with ascope, answered on the server:children(a folder's subfolders, then a page of its files),subtree(every folder and file under a folder, the upper levels first) andtree-matches(the files the search or filters match, and for a plain search the folders named like it, with their folders asancestors).subtreeandtree-matcheshold at most 200 rows, then saytruncated.Sizes and counts. A folder's size is the sum of every file inside it, subfolders included (active files only); each answer carries the
childCountsandsizesof its folder rows.Root. The tree starts at the library's root: creating a folder or dropping files at the tree's root targets the library's root.
Moves. Files move with the existing move action (a clashing name gets a unique suffix). Folders move with
moveMediaFolderAction, which needs the mediaupdatepermission and refuses, with a typedcodetranslated in the interface: a move into the folder itself or one of its subfolders (cycle), deeper than two folder levels for the whole moved subtree (depth_limit), next to a folder with the same name (name_conflict, also when a concurrent change hits the unique index), or a folder or target outside the active organization (not_found,target_not_found). The organization's folders are locked during the check, so concurrent moves cannot combine into a cycle or a third level.Other actions. New folder uses the create action (two levels at most), which needs the media
createpermission and takes the same lock as a move: the parent, the depth limit and the name are checked on the locked folders, so a subfolder created while its parent is moved cannot end up on a third level. Rename uses the folder or asset rename action, and delete keeps the existing rules: files go to the trash, folders are deleted only when empty. Files dropped from the desktop onto a folder go through the regular upload pipeline, with the upload dialog's limits (20 files, the plan's file size) checked first and permissions, types and quotas checked again on the server.Permissions. Members who cannot manage media browse the tree without moving, renaming, creating or dropping. The interface only mirrors the server's rules; every server action checks them again.
MCP.
yayaw_media_listhas no folder argument yet; exposing folders and folder moves to MCP is a follow-up. Tags are exposed (see Tags).
Private media
Private media is the files one person keeps for themselves inside an
organization, such as an avatar or an icon they chose. It is not part of
the library above: it never appears in the dashboard, the File tree, search,
MCP media tools or anyone else's list. Only its owner lists, reads and deletes
it, through /api/account/media with the bearer credential their application
already uses (OAuth access token for /api/mcp or device session) and the
public-client CORS origins.
Authorization:
can()on theprivate-mediaresource in the organization, for a member of it. The seeded Organization Member, Manager and Admin roles and Super Admin holdprivate-media:manage; Super Admin keeps private media only in the organizations they belong to. The service then only ever reaches the caller's own rows, so an organization administrator sees their own private media, not their members'.Types: PNG, JPEG and WebP are re-encoded with sharp; SVG is screened (no DOCTYPE, scripts, foreign content or external references) and rasterized to PNG. Only raster images are stored and served.
Limits: 5 MiB per file and 25 MiB per person and organization by default (
PRIVATE_MEDIA_MAX_FILE_BYTES,PRIVATE_MEDIA_MAX_USER_BYTES). Private media counts against the organization's plan storage quota, under the same lock as library uploads.Storage and serving: objects live in a private bucket (
STORAGE_PRIVATE_MEDIA_BUCKET, or the staging bucket). There is no public or signed URL:GET /api/account/media/{id}/contentreturns the bytes to the authenticated owner withCache-Control: private, no-cache, anETagand a sandboxing Content-Security-Policy.Cleanup: leaving an organization purges the member's private media there, and deleting an organization purges its members' private files first; the page AI worker also reaps expired uploads, deleted accounts and former members.
The endpoint contract for client applications (requests, responses, errors) is
in docs/private-media.md.