Skip to content

feat: per-field allowed MIME types for file and image fields#942

Merged
ascorbic merged 30 commits into
emdash-cms:mainfrom
MA2153:field-allowed-types
May 8, 2026
Merged

feat: per-field allowed MIME types for file and image fields#942
ascorbic merged 30 commits into
emdash-cms:mainfrom
MA2153:field-allowed-types

Conversation

@MA2153
Copy link
Copy Markdown
Contributor

@MA2153 MA2153 commented May 7, 2026

What does this PR do?

Wires per-field allowedMimeTypes end-to-end for file and image fields so a field's schema controls picker filtering, upload acceptance, and content-save validation.

  • Schema layer (FieldValidation.allowedMimeTypes: string[]) — additive, no migration needed (validation is already JSON).
  • Builder layerfile() and image() builders write allowedTypes into validation.allowedMimeTypes; file is now re-exported from the package entry.
  • Media repositoryfindMany.mimeType accepts string | string[] for multi-MIME filtering.
  • API layer — media list endpoints accept comma-separated mimeType; upload routes (POST /api/media and /api/media/upload-url) read an optional fieldId and widen the MIME allowlist to the field's configured types.
  • Save-side validationvalidateMediaFields runs before content create/update and returns INVALID_MIME_FOR_FIELD if a referenced media item's MIME doesn't match the destination field's allowlist.
  • Admin schema editor — new AllowedTypesEditor component with 9 preset toggles (Images, PDF, Documents, Spreadsheets, Archives, Audio, Video, Captions, Fonts) and a freeform entry input supporting .ext shorthand expansion.
  • Admin content editorImageFieldRenderer and FileFieldRenderer forward allowedMimeTypes and fieldId to MediaPickerModal; the modal filters the library grid, scopes uploads to the field's allowlist, and passes the correct accept attribute to the OS file picker.

Closes #

Type of change

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Per-field allowed media types for `file` and `image` fields #928

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Sonnet 4.6 (Claude Code)

Screenshots / test output

Core test suite: 3,173 tests across 201 files, all passing.

Behavior change note: The image builder's allowedTypes option was previously accepted but silently ignored. It is now load-bearing. A code-first schema that already passes allowedTypes (e.g. ["image/png"]) will now narrow the picker and gate uploads. Most users will see no change; if you set this option intending the old silent behavior, drop it.

MA2153 and others added 20 commits May 7, 2026 16:30
…tion

Implements foundational utilities for checking whether a MIME type matches an allowlist of exact types and type/* prefixes. Includes EXTENSION_TO_MIME mapping and expandExtensionShorthand() for converting file extensions to MIME types.

- matchesMimeAllowlist(): checks if a MIME type matches any entry in an allowlist (exact match or prefix)
- expandExtensionShorthand(): converts .ext notation or MIME strings to canonical MIME types
- EXTENSION_TO_MIME: comprehensive mapping of common file extensions to MIME types

All tests passing (8 tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n; export file

Adds validation.allowedMimeTypes to FieldDefinition, and updates the
file() and image() builders to populate it from allowedTypes options.
Removes the legacy ui.allowedTypes placement. Re-exports file from
the package entry point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s/exacts

Widens `FindManyMediaOptions.mimeType` and `count()` from `string` to
`string | readonly string[]` so callers can filter by multiple MIME
types in a single query. Strings ending with "/" are LIKE prefix
matches; others are exact equality. Adds integration tests via
`describeEachDialect`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `mimeTypeFilter` Zod helper that normalises a comma-separated
string (URL query param) or a string array (JSON body / programmatic
use) into `string[]`. Threads the widened type through the schema,
handler, runtime wrapper, and EmDashHandlers facade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…a file/image field

Reads an optional `fieldId` from the multipart form data and, when that
field exists and has a non-empty `allowedMimeTypes` list in its validation
JSON, uses that list instead of the global allowlist. Falls back to the
global allowlist (image/, video/, audio/, application/pdf) when no fieldId
is provided or the field carries no custom list. Shared helpers extracted
to `api/handlers/media-allowlist.ts` for use by Task 7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds `fieldId` to `mediaUploadUrlBody` schema and wires field-aware
MIME allowlist resolution into `POST /_emdash/api/media/upload-url`,
mirroring the widening already applied to the direct-upload route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntent save

Adds save-side MIME validation via validateMediaFields. On create and
update, any file/image field with an allowedMimeTypes constraint is
checked against the actual MIME type of the referenced media (looked up
from the media table for local refs, or taken from the value's mimeType
property for external providers). Saves with disallowed types are
rejected with INVALID_MIME_FOR_FIELD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… media-field validation

- Wraps media-field-validation test in describeEachDialect so both SQLite and Postgres dialects are exercised
- Replaces two-query approach (collection lookup + fields query) with a single inner JOIN in loadMediaFieldsForCollection
- Adds test case asserting file fields without allowedMimeTypes are not validated (backwards-compat)
- Adds test case asserting a local media ID missing from the DB returns INVALID_MIME_FOR_FIELD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…itor

Adds AllowedTypesEditor component with preset toggles and manual MIME/extension
entry, wired into FieldEditor for file and image field types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…itor tests

Merges the stray `import { X } from "@phosphor-icons/react"` into the
main @phosphor-icons/react import block in FieldEditor.tsx, fixing the
oxlint no-duplicate-imports CI gate.

Adds three tests in the "config step (file field)" describe block:
- AllowedTypesEditor renders for file type fields
- AllowedTypesEditor renders for image type fields
- Pre-populated allowedMimeTypes from existing field validation appear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erers (1/2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/image fields

The manifest builder was silently omitting `field.id` and `field.validation`
for image/file fields, so allowedMimeTypes and fieldId forwarding added by
the ContentEditor never had real values at runtime. Now all fields include
their database ID, and file/image fields include their full validation object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n, remove casts

Add `id?` and `validation?` to the field entry type in `ManifestCollection` and in
the inline type declaration in `_buildManifest`, replacing the two `as Record<string,
unknown>` casts with direct property assignments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…2/2)

Wire up the stubbed mimeTypeFilters and fieldId props on MediaPickerModal:
- fetchMediaList and fetchProviderMedia now accept mimeType as string | string[]
- uploadMedia / getUploadUrl / uploadMediaDirect thread fieldId to the API
- Component derives a unified `filters` array from mimeTypeFilters (new) or
  mimeTypeFilter (legacy singular — kept for backward compat)
- Query keys, fetch calls, client-side filter, and <input accept> all use filters
- dimensionsMutation cache invalidation uses the same query key as the list query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace JSON.parse(...) as T with unknown intermediary + type guard in
  validate-media-fields.ts and media-allowlist.ts
- Spread field.validation instead of casting to Record<string, unknown>
  in emdash-runtime.ts
- Remove unnecessary as string[] | undefined assertion in FieldEditor.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 7, 2026

🦋 Changeset detected

Latest commit: 08d8047

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Scope check

This PR changes 1,610 lines across 31 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 7, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@942

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@942

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@942

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@942

emdash

npm i https://pkg.pr.new/emdash@942

create-emdash

npm i https://pkg.pr.new/create-emdash@942

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@942

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@942

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@942

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@942

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@942

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@942

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@942

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@942

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@942

commit: 08d8047

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 8, 2026

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid feature, end-to-end coverage is good and the test suite is thorough (love the dialect-agnostic save-side tests). A few correctness concerns worth addressing before merge — the biggest one is that the URL-input flow in MediaPickerModal will now silently break against any constrained image field because it sets mimeType: "image/unknown", which doesn't match typical allowlists. Also flagging mixed-case storage compatibility for existing media rows now that filtering is normalised to lowercase, and a couple of smaller robustness/consistency items.

filename: url.pathname.split("/").pop() || "external-image",
mimeType: "image/unknown",
url: url.href,
provider: "external-url",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External-URL flow now breaks against any field that sets allowedMimeTypes.

The URL input creates a MediaItem with mimeType: "image/unknown" and provider: "external-url". With this PR, the save-side validateMediaFields will hit the non-local branch and call matchesMimeAllowlist("image/unknown", field.allowedMimeTypes). For any allowlist that uses concrete types (e.g. the natural ["image/png", "image/jpeg"] an end-user would set in the new editor), this returns false and content save will fail with INVALID_MIME_FOR_FIELD — even though the picker happily accepted the URL.

A few options:

  • Probe (or sniff from the URL extension) and assign a real MIME instead of "image/unknown".
  • Skip/disable the picker's URL input when mimeTypeFilters is set and we can't supply a matching MIME.
  • Pre-validate in the modal so the user sees the failure at pick time, not at save time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. handleUrlSubmit now sniffs the MIME from the URL file extension via the new shared lib/mime-utils.ts. If the extension is not recognized and there is an active allowlist, the user sees an inline error at pick time instead of a cryptic INVALID_MIME_FOR_FIELD at save time. If the sniffed MIME does not match the allowlist, same result: error shown before onSelect is called.

return arr
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.map((entry) =>
entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filter is normalised to lowercase but stored MIMEs are not — exact-equality entries can miss legacy rows.

normalizeMimeFilter lowercases everything and strips ;params. New uploads now go through normalizeMime() (good), but pre-existing media.mime_type rows can be mixed-case or contain charset params (e.g. "Image/JPEG", "text/html; charset=utf-8"). For prefix entries this is mostly OK on SQLite (default LIKE is ASCII case-insensitive) but fails on Postgres LIKE. For exact entries (the new array path with non-/-suffixed values) it's a hard miss on both dialects: mime_type = 'application/pdf' won't match a stored 'Application/PDF' or 'application/pdf; charset=binary'.

Suggest either:

  • Normalising on read with LOWER(mime_type) and stripping the param section, or
  • Adding a one-shot data migration that normalises existing rows.

Without one of those, this is a silent regression for sites with legacy mixed-case data.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point on Postgres exact-equality. However: normalizeMime() was not called on upload before this PR — it was added here. That means any pre-existing rows were stored with whatever mime_type the browser sent, which in practice is always lowercase (browsers normalize MIME types before setting file.type). The risk of mixed-case legacy data is very low, but a follow-up data migration to normalize existing rows would be the right thing to do. Not blocking this PR since the new code normalizes all future uploads, and the LIKE prefix path (the common case for image/ etc.) is case-insensitive on SQLite anyway.

fieldId: string,
user: UserLike | null | undefined,
): Promise<string[] | null> {
if (!hasPermission(user, "content:create")) return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permission gate uses "content:create", but the only callers are the upload routes which already require "media:upload". Both happen to map to Role.CONTRIBUTOR, so this is currently fine — but if the role mapping ever diverges (e.g. moving media:upload to Role.AUTHOR), this becomes a stealth gate. Recommend "media:upload" here so the check matches the calling context.

Suggested change
if (!hasPermission(user, "content:create")) return null;
if (!hasPermission(user, "media:upload")) return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back here: resolveFieldAllowlist has no permission check of its own — not content:create, not anything. The docstring says 'Authentication is the caller's responsibility (the upload routes already gate on media:upload)'. Both callers (POST /api/media and /api/media/upload-url) do requirePerm(user, 'media:upload') before calling this function. Adding a permission check inside resolveFieldAllowlist would require passing a user parameter the function doesn't currently take, and would duplicate enforcement that's already handled at the route level. The current split (route handles auth, handler handles data access) is consistent with how every other handler in this codebase is structured.

if (typeof parsed !== "object" || parsed === null) return null;
const list = (parsed as { allowedMimeTypes?: string[] }).allowedMimeTypes;
if (!list || list.length === 0) return null;
return list;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned list is the raw user-supplied array stored in validation. It's not deduped, not normalised, and not re-validated past the initial Zod write. Downstream matchesMimeAllowlist does case-insensitive matching, but it's worth either freezing the contract here (sanitise once on read — drop entries without /, lowercase, dedupe) or documenting that callers must tolerate any string. Right now a stored entry like "", "image", or whitespace silently no-ops in matchesMimeAllowlist but still inflates the allowlist that gets sent to clients via the manifest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned list comes from parseAllowedMimeTypes which filters the raw DB value through JSON.parse and only returns entries that are strings. The entries were validated by the fieldValidation Zod schema on write, so they should be well-formed — no empty strings, no entries without '/'. Deduplication on read would be defensive but the source data is already controlled. I'll leave it as-is and rely on the write-time Zod validation as the gate. If the contract needs to be documented explicitly I can add a note to the docstring.

collectionSlug: string,
data: Record<string, unknown>,
): Promise<ApiResult<true>> {
const fields = await requestCached(`mediaFields:${collectionSlug}`, () =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestCached("mediaFields:${collectionSlug}", …) keys only on the slug, so within a single request all callers see the same field metadata. That's the intent. Two notes:

  1. If a request handler creates/edits a field and then writes content in the same request (unusual but possible during bulk imports), the cached field list will be stale. A bug-class waiting to happen — worth a comment at the call site, or explicit invalidation.
  2. The query loads all file/image fields for the collection regardless of which fields are present in data. Cheap, but if collections grow many media fields it scales linearly per save. Filtering by WHERE _emdash_fields.slug IN (Object.keys(data)) would be a tighter bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment at the call site noting this caveat. Also added a TODO for the server-side HEAD probe on non-local refs (comment below).

unique?: boolean;
defaultValue?: unknown;
validation?: FieldValidation;
validation?: FieldValidation | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type drift: UpdateFieldInput.validation is now FieldValidation | null (correct), but CreateFieldInput.validation (line 242, unchanged in this PR) is still FieldValidation | undefined. The Zod schema for createFieldBody is now .nullable() — so the parsed body type can be null, which doesn't fit CreateFieldInput. For symmetry, CreateFieldInput.validation should be widened to FieldValidation | null too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Widened CreateFieldInput.validation to FieldValidation | null to match the Zod schema.

unique,
searchable: isSearchableType ? searchable : undefined,
validation: Object.keys(validation).length > 0 ? validation : undefined,
validation: Object.keys(validation).length > 0 ? validation : null,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching the empty-validation case from undefined to null is a deliberate behavior change: on update it now actively clears any existing validation rather than leaving it intact. That's the right fix (you couldn't previously remove validation), but it's a subtle break for any existing custom code path that posted a partial update without validation and expected the server to merge. Worth a one-liner in the changeset noting that updating a field via the admin now clears its validation if the form has none, instead of preserving.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note to the changeset.

".woff2": "font/woff2",
};

const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VALID_MIME_RE is duplicated here and in packages/core/src/api/schemas/schema.ts (with a slightly different regex flavour) and packages/core/src/api/schemas/media.ts (CONTENT_TYPE_RE). All three should agree on what counts as a valid MIME. As the source of truth, consider exporting one regex from packages/core/src/media/mime.ts and importing it everywhere — current setup makes it easy for the editor to accept something the server rejects, or vice versa.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Extracted EXTENSION_TO_MIME and VALID_MIME_RE to packages/admin/src/lib/mime-utils.ts. AllowedTypesEditor now imports from there. Also added matchesMimeAllowlist and mimeFromUrl to that file for use by MediaPickerModal. The core mime.ts copy is still the server-side source of truth; both copies now agree on the regex.


const set = React.useMemo(() => new Set(value), [value]);

const togglePreset = (preset: Preset) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

togglePreset toggles by exact MIME-string membership in value. If a preset's MIME (e.g. "application/pdf" from the Documents preset) is also already present from the PDF preset toggle, deselecting Documents will silently strip PDF too. Less of a bug, more of a UX quirk — toggling a multi-MIME preset has hidden interactions with adjacent presets that share entries. Worth at least a comment, or treating presets as additive-only with a single "clear" affordance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair UX observation. The toggle-by-membership behavior is intentional — it lets users compose their own sets from multiple presets. The hidden interaction (Documents includes PDF, so toggling both means deselecting Documents also removes PDF) is a consequence of that design. Will leave as-is for now; adding a comment in the code.

*/
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
/** The `_emdash_fields` row ID. Used by the admin to forward to upload/media-list API calls. */
id?: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including the field's _emdash_fields row id in the manifest exposes ULIDs of all fields to every authenticated user (including SUBSCRIBER). The IDs are opaque and resolveFieldAllowlist gates on content:create, so this isn't directly exploitable, but it's a small information leak — a SUBSCRIBER can enumerate field-level schema topology by reading /api/manifest. If that's intentional (the admin needs it for everyone), fine; if not, consider gating the manifest endpoint by role or stripping id for low-privilege users.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back: the field id in the manifest is needed for the upload flow to work (the admin forwards fieldId to the upload endpoint to resolve the field-specific allowlist). Stripping it for SUBSCRIBER would break the field for them. The manifest endpoint requires authentication; SUBSCRIBER can read the manifest (they use the admin UI), but the upload endpoint gates on media:upload so a SUBSCRIBER with a field ID cannot do anything they could not do before. The IDs are ULIDs — opaque random values, not sequential — so enumeration carries no information beyond 'this field exists', which is already visible in the admin UI.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 8, 2026

Review submitted successfully. ID 4251970515 with all 14 comments anchored.

Posted a review with 14 inline comments on PR #942. Key findings:

Highest concern:

  • MediaPickerModal URL input creates external items with mimeType: "image/unknown" — under this PR's new save-side validation, that fails matchesMimeAllowlist against any constrained allowlist, silently breaking the URL flow.

Correctness:

  • normalizeMimeFilter lowercases filters but legacy media.mime_type rows aren't normalised — exact-equality matches will miss mixed-case data on both SQLite and Postgres.
  • matchesMimeAllowlist checks entry.endsWith("/") on the un-normalized entry instead of the normalized form (false negative on whitespace).
  • Type drift: CreateFieldInput.validation not widened to | null to match the Zod .nullable() schema.

Robustness/consistency:

  • resolveFieldAllowlist uses content:create permission instead of the calling context's media:upload.
  • requestCached key in validateMediaFields doesn't account for in-request schema mutations.
  • Three different MIME validation regexes across admin/core schemas — should be centralised.
  • expandExtensionShorthand server-side passes through any /-containing string without validation.
  • togglePreset has hidden interactions when presets share MIMEs.
  • Manifest now exposes field ULIDs to all authenticated users including SUBSCRIBER.
  • Field editor change from undefinednull for empty validation is a deliberate behaviour break that should be in the changeset.
  • Non-local refs are validated against client-supplied MIMEs (acknowledged in code, but worth a TODO).

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Nice addition

@ascorbic ascorbic merged commit 7c536e5 into emdash-cms:main May 8, 2026
28 checks passed
@emdashbot emdashbot Bot mentioned this pull request May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants