emdash 0.10.0 → 0.11.1

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.
Files changed (148) hide show
  1. package/dist/{apply-UsrFuO7l.mjs → apply-Ded_1vng.mjs} +36 -25
  2. package/dist/{apply-UsrFuO7l.mjs.map → apply-Ded_1vng.mjs.map} +1 -1
  3. package/dist/astro/index.d.mts +5 -5
  4. package/dist/astro/index.mjs +1 -1
  5. package/dist/astro/middleware/auth.d.mts +5 -5
  6. package/dist/astro/middleware/redirect.mjs +2 -2
  7. package/dist/astro/middleware.d.mts.map +1 -1
  8. package/dist/astro/middleware.mjs +83 -33
  9. package/dist/astro/middleware.mjs.map +1 -1
  10. package/dist/astro/types.d.mts +10 -7
  11. package/dist/astro/types.d.mts.map +1 -1
  12. package/dist/{byline-C3vnhIpU.mjs → byline-gFn1r0vA.mjs} +2 -2
  13. package/dist/{byline-C3vnhIpU.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
  14. package/dist/{bylines-esI7ioa9.mjs → bylines-DTFI8nDM.mjs} +4 -4
  15. package/dist/{bylines-esI7ioa9.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
  16. package/dist/{cache-fTzxgMFJ.mjs → cache-BAJbeoZ8.mjs} +2 -2
  17. package/dist/{cache-fTzxgMFJ.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
  18. package/dist/{chunks-Da2-b-oA.mjs → chunks-BK1oZS-l.mjs} +2 -2
  19. package/dist/{chunks-Da2-b-oA.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
  20. package/dist/cli/index.mjs +102 -27
  21. package/dist/cli/index.mjs.map +1 -1
  22. package/dist/{content-C7G4QXkK.mjs → content-CERxPUN0.mjs} +2 -2
  23. package/dist/{content-C7G4QXkK.mjs.map → content-CERxPUN0.mjs.map} +1 -1
  24. package/dist/database/instrumentation.d.mts +6 -4
  25. package/dist/database/instrumentation.d.mts.map +1 -1
  26. package/dist/database/instrumentation.mjs +19 -7
  27. package/dist/database/instrumentation.mjs.map +1 -1
  28. package/dist/db/index.d.mts +2 -2
  29. package/dist/db/index.mjs +1 -1
  30. package/dist/{index-DjPMOfO0.d.mts → index-BogfvE-z.d.mts} +32 -24
  31. package/dist/index-BogfvE-z.d.mts.map +1 -0
  32. package/dist/index.d.mts +7 -7
  33. package/dist/index.mjs +19 -19
  34. package/dist/{load-sXRuM7Us.mjs → load-DR1VwFXR.mjs} +2 -2
  35. package/dist/{load-sXRuM7Us.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
  36. package/dist/{loader-Bx2_9-5e.mjs → loader-ou_PXAjg.mjs} +2 -2
  37. package/dist/{loader-Bx2_9-5e.mjs.map → loader-ou_PXAjg.mjs.map} +1 -1
  38. package/dist/media/local-runtime.d.mts +5 -5
  39. package/dist/media/local-runtime.mjs +1 -1
  40. package/dist/{media-D8FbNsl0.mjs → media-1fFhub9c.mjs} +21 -9
  41. package/dist/media-1fFhub9c.mjs.map +1 -0
  42. package/dist/page/index.d.mts +2 -2
  43. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  44. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  45. package/dist/{query-Bo-msrmu.mjs → query-8c_meo_K.mjs} +10 -10
  46. package/dist/{query-Bo-msrmu.mjs.map → query-8c_meo_K.mjs.map} +1 -1
  47. package/dist/{registry-Beb7wxFc.mjs → registry-Do34mz_P.mjs} +6 -5
  48. package/dist/registry-Do34mz_P.mjs.map +1 -0
  49. package/dist/{request-cache-C-tIpYIw.mjs → request-cache-D4I69LeL.mjs} +6 -2
  50. package/dist/request-cache-D4I69LeL.mjs.map +1 -0
  51. package/dist/request-context.d.mts +27 -1
  52. package/dist/request-context.d.mts.map +1 -1
  53. package/dist/request-context.mjs +16 -3
  54. package/dist/request-context.mjs.map +1 -1
  55. package/dist/{runner-DMnlIkh4.mjs → runner-DIcU2UCC.mjs} +174 -152
  56. package/dist/runner-DIcU2UCC.mjs.map +1 -0
  57. package/dist/{runner-Clwe4Mme.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
  58. package/dist/{runner-Clwe4Mme.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
  59. package/dist/runtime.d.mts +5 -5
  60. package/dist/runtime.mjs +1 -1
  61. package/dist/{search-DkN-BqsS.mjs → search-DuWhx4NG.mjs} +172 -30
  62. package/dist/search-DuWhx4NG.mjs.map +1 -0
  63. package/dist/seed/index.d.mts +2 -2
  64. package/dist/seed/index.mjs +10 -10
  65. package/dist/{taxonomies-CTtewrSQ.mjs → taxonomies-Bw76xAxo.mjs} +6 -6
  66. package/dist/{taxonomies-CTtewrSQ.mjs.map → taxonomies-Bw76xAxo.mjs.map} +1 -1
  67. package/dist/{taxonomy-DSxx2K2L.mjs → taxonomy-D6NvlKo8.mjs} +3 -3
  68. package/dist/{taxonomy-DSxx2K2L.mjs.map → taxonomy-D6NvlKo8.mjs.map} +1 -1
  69. package/dist/{types-Eg829jj9.mjs → types-56BKbld_.mjs} +1 -1
  70. package/dist/types-56BKbld_.mjs.map +1 -0
  71. package/dist/{types-Dtx1mSMX.d.mts → types-BQx6ZXpR.d.mts} +2 -1
  72. package/dist/types-BQx6ZXpR.d.mts.map +1 -0
  73. package/dist/{types-Dl1fgFjn.d.mts → types-BTe41zL6.d.mts} +4 -3
  74. package/dist/types-BTe41zL6.d.mts.map +1 -0
  75. package/dist/types-DiI8NOG_.mjs +16 -0
  76. package/dist/types-DiI8NOG_.mjs.map +1 -0
  77. package/dist/{types-D19uBYWn.d.mts → types-IjUrQMVe.d.mts} +21 -245
  78. package/dist/types-IjUrQMVe.d.mts.map +1 -0
  79. package/dist/{validate-DHGwADqO.d.mts → validate-CcVQQpmH.d.mts} +7 -3
  80. package/dist/validate-CcVQQpmH.d.mts.map +1 -0
  81. package/dist/{validate-CBIbxM3L.mjs → validate-UK4Ja1uo.mjs} +3 -3
  82. package/dist/{validate-CBIbxM3L.mjs.map → validate-UK4Ja1uo.mjs.map} +1 -1
  83. package/dist/{validation-B1NYiEos.mjs → validation-Vc5DQkJa.mjs} +4 -4
  84. package/dist/{validation-B1NYiEos.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
  85. package/dist/version-JjSqv90m.mjs +7 -0
  86. package/dist/{version-CMD42IRC.mjs.map → version-JjSqv90m.mjs.map} +1 -1
  87. package/dist/{zod-generator-BNJDQBSZ.mjs → zod-generator-CHnJUP2l.mjs} +1 -1
  88. package/dist/{zod-generator-BNJDQBSZ.mjs.map → zod-generator-CHnJUP2l.mjs.map} +1 -1
  89. package/package.json +9 -8
  90. package/src/api/errors.ts +5 -0
  91. package/src/api/handlers/content.ts +9 -0
  92. package/src/api/handlers/media-allowlist.ts +40 -0
  93. package/src/api/handlers/media.ts +1 -1
  94. package/src/api/handlers/menus.ts +158 -28
  95. package/src/api/handlers/validate-media-fields.ts +125 -0
  96. package/src/api/schemas/media.ts +23 -3
  97. package/src/api/schemas/schema.ts +11 -2
  98. package/src/astro/middleware.ts +46 -11
  99. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +1 -1
  100. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +1 -1
  101. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  102. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +1 -1
  103. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +1 -1
  104. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +2 -2
  105. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +1 -1
  106. package/src/astro/routes/api/content/[collection]/[id].ts +2 -2
  107. package/src/astro/routes/api/content/[collection]/index.ts +1 -1
  108. package/src/astro/routes/api/media/upload-url.ts +10 -4
  109. package/src/astro/routes/api/media.ts +12 -4
  110. package/src/astro/types.ts +5 -1
  111. package/src/auth/rate-limit.ts +3 -3
  112. package/src/cli/commands/bundle-utils.ts +81 -6
  113. package/src/cli/commands/bundle.ts +18 -15
  114. package/src/cli/commands/export-seed.ts +57 -3
  115. package/src/database/instrumentation.ts +22 -8
  116. package/src/database/migrations/016_api_tokens.ts +18 -3
  117. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  118. package/src/database/migrations/runner.ts +2 -0
  119. package/src/database/repositories/media.ts +40 -10
  120. package/src/database/types.ts +2 -1
  121. package/src/emdash-runtime.ts +16 -3
  122. package/src/fields/file.ts +7 -6
  123. package/src/fields/image.ts +12 -11
  124. package/src/fields/types.ts +3 -0
  125. package/src/index.ts +1 -1
  126. package/src/mcp/server.ts +37 -8
  127. package/src/media/mime.ts +75 -0
  128. package/src/plugins/types.ts +81 -191
  129. package/src/request-cache.ts +6 -2
  130. package/src/request-context.ts +42 -2
  131. package/src/schema/registry.ts +5 -5
  132. package/src/schema/types.ts +3 -2
  133. package/src/seed/apply.ts +25 -8
  134. package/src/seed/types.ts +4 -0
  135. package/dist/index-DjPMOfO0.d.mts.map +0 -1
  136. package/dist/media-D8FbNsl0.mjs.map +0 -1
  137. package/dist/registry-Beb7wxFc.mjs.map +0 -1
  138. package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
  139. package/dist/runner-DMnlIkh4.mjs.map +0 -1
  140. package/dist/search-DkN-BqsS.mjs.map +0 -1
  141. package/dist/types-CoO6mpV3.mjs +0 -68
  142. package/dist/types-CoO6mpV3.mjs.map +0 -1
  143. package/dist/types-D19uBYWn.d.mts.map +0 -1
  144. package/dist/types-Dl1fgFjn.d.mts.map +0 -1
  145. package/dist/types-Dtx1mSMX.d.mts.map +0 -1
  146. package/dist/types-Eg829jj9.mjs.map +0 -1
  147. package/dist/validate-DHGwADqO.d.mts.map +0 -1
  148. package/dist/version-CMD42IRC.mjs +0 -7
@@ -0,0 +1,125 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ import type { Database } from "../../database/types.js";
4
+ import { matchesMimeAllowlist, parseAllowedMimeTypes } from "../../media/mime.js";
5
+ import { requestCached } from "../../request-cache.js";
6
+ import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
7
+ import type { ApiResult } from "../types.js";
8
+
9
+ interface FieldRow {
10
+ slug: string;
11
+ type: string;
12
+ allowedMimeTypes: string[];
13
+ }
14
+
15
+ interface MediaRefValue {
16
+ id?: unknown;
17
+ provider?: unknown;
18
+ mimeType?: unknown;
19
+ }
20
+
21
+ function asMediaRef(value: unknown): MediaRefValue | null {
22
+ if (value === null || value === undefined) return null;
23
+ if (typeof value !== "object" || Array.isArray(value)) return null;
24
+ return value as MediaRefValue;
25
+ }
26
+
27
+ function fail(message: string): ApiResult<never> {
28
+ return { success: false, error: { code: "INVALID_MIME_FOR_FIELD", message } };
29
+ }
30
+
31
+ async function loadMediaFieldsForCollection(
32
+ db: Kysely<Database>,
33
+ collectionSlug: string,
34
+ ): Promise<FieldRow[]> {
35
+ const rows = await db
36
+ .selectFrom("_emdash_fields")
37
+ .innerJoin("_emdash_collections", "_emdash_collections.id", "_emdash_fields.collection_id")
38
+ .select(["_emdash_fields.slug", "_emdash_fields.type", "_emdash_fields.validation"])
39
+ .where("_emdash_collections.slug", "=", collectionSlug)
40
+ .where("_emdash_fields.type", "in", ["file", "image"])
41
+ .execute();
42
+
43
+ const out: FieldRow[] = [];
44
+ for (const row of rows) {
45
+ const list = parseAllowedMimeTypes(row.validation);
46
+ if (!list) continue;
47
+ out.push({ slug: row.slug, type: row.type, allowedMimeTypes: list });
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export async function validateMediaFields(
53
+ db: Kysely<Database>,
54
+ collectionSlug: string,
55
+ data: Record<string, unknown>,
56
+ ): Promise<ApiResult<true>> {
57
+ // Cache is keyed on slug only. If a handler creates/modifies a field and
58
+ // then writes content in the same request (e.g. bulk import), the cached
59
+ // list will be stale for that request. This is an edge case in normal use.
60
+ const fields = await requestCached(`mediaFields:${collectionSlug}`, () =>
61
+ loadMediaFieldsForCollection(db, collectionSlug),
62
+ );
63
+ if (fields.length === 0) return { success: true, data: true };
64
+
65
+ // Collect local media ids that need a MIME lookup
66
+ const localIds = new Set<string>();
67
+ for (const field of fields) {
68
+ const ref = asMediaRef(data[field.slug]);
69
+ if (!ref) continue;
70
+ const provider = typeof ref.provider === "string" ? ref.provider : "local";
71
+ if (provider === "local" && typeof ref.id === "string") {
72
+ localIds.add(ref.id);
73
+ }
74
+ }
75
+
76
+ // Batch-load local media MIMEs
77
+ const idList = [...localIds];
78
+ const mimeById = new Map<string, string>();
79
+ if (idList.length > 0) {
80
+ for (const batch of chunks(idList, SQL_BATCH_SIZE)) {
81
+ const rows = await db
82
+ .selectFrom("media")
83
+ .select(["id", "mime_type"])
84
+ .where("id", "in", batch)
85
+ .execute();
86
+ for (const r of rows) mimeById.set(r.id, r.mime_type);
87
+ }
88
+ }
89
+
90
+ for (const field of fields) {
91
+ const value = data[field.slug];
92
+ if (value === null || value === undefined) continue;
93
+ const ref = asMediaRef(value);
94
+ if (!ref) continue;
95
+
96
+ const provider = typeof ref.provider === "string" ? ref.provider : "local";
97
+
98
+ // External providers carry mimeType in the ref; trust it as-is.
99
+ // Local media: look up the stored mimeType by id.
100
+ let mime: string | undefined;
101
+ if (provider === "local") {
102
+ if (typeof ref.id !== "string") {
103
+ return fail(`Field '${field.slug}' references media with an invalid id`);
104
+ }
105
+ mime = mimeById.get(ref.id);
106
+ if (!mime) {
107
+ return fail(`Field '${field.slug}' references media with unknown MIME type`);
108
+ }
109
+ } else {
110
+ if (typeof ref.mimeType !== "string") {
111
+ return fail(`Field '${field.slug}' requires a mimeType declaration for non-local media`);
112
+ }
113
+ // TODO: long-term, consider a server-side HEAD probe or provider-vouched
114
+ // MIMEs for non-local refs; for now the constraint is only as strong as
115
+ // the client that constructed the ref.
116
+ mime = ref.mimeType;
117
+ }
118
+
119
+ if (!matchesMimeAllowlist(mime, field.allowedMimeTypes)) {
120
+ return fail(`Field '${field.slug}' does not accept ${mime}`);
121
+ }
122
+ }
123
+
124
+ return { success: true, data: true };
125
+ }
@@ -6,9 +6,21 @@ import { cursorPaginationQuery } from "./common.js";
6
6
  // Media: Input schemas
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
+ /**
10
+ * Accepts a comma-separated string (from URL query params) or an array of
11
+ * strings (from JSON body or programmatic use) and normalises to string[].
12
+ */
13
+ const mimeTypeFilter = z
14
+ .union([z.string(), z.array(z.string())])
15
+ .transform((v) => {
16
+ const arr = Array.isArray(v) ? v : v.split(",");
17
+ return arr.map((s) => s.trim()).filter((s) => s.length > 0);
18
+ })
19
+ .optional();
20
+
9
21
  export const mediaListQuery = cursorPaginationQuery
10
22
  .extend({
11
- mimeType: z.string().optional(),
23
+ mimeType: mimeTypeFilter,
12
24
  })
13
25
  .meta({ id: "MediaListQuery" });
14
26
 
@@ -30,6 +42,10 @@ export function formatFileSize(bytes: number): string {
30
42
  return `${Math.floor(bytes / 1024 / 1024)}MB`;
31
43
  }
32
44
 
45
+ // Matches a full MIME type (type/subtype) with an optional semicolon-delimited
46
+ // parameter section. Forbids CR/LF to prevent header injection.
47
+ const CONTENT_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]+(\s*;[^\r\n]*)?$/i;
48
+
33
49
  export function mediaUploadUrlBody(maxSize: number) {
34
50
  if (!Number.isFinite(maxSize) || maxSize <= 0) {
35
51
  throw new Error(`EmDash: maxUploadSize must be a positive finite number, got ${maxSize}`);
@@ -37,13 +53,17 @@ export function mediaUploadUrlBody(maxSize: number) {
37
53
  return z
38
54
  .object({
39
55
  filename: z.string().min(1, "filename is required"),
40
- contentType: z.string().min(1, "contentType is required"),
56
+ contentType: z
57
+ .string()
58
+ .min(1, "contentType is required")
59
+ .regex(CONTENT_TYPE_RE, "Invalid content type"),
41
60
  size: z
42
61
  .number()
43
62
  .int()
44
63
  .positive()
45
64
  .max(maxSize, `File size must not exceed ${formatFileSize(maxSize)}`),
46
65
  contentHash: z.string().optional(),
66
+ fieldId: z.string().optional(),
47
67
  })
48
68
  .meta({ id: "MediaUploadUrlBody" });
49
69
  }
@@ -59,7 +79,7 @@ export const mediaConfirmBody = z
59
79
  export const mediaProviderListQuery = cursorPaginationQuery
60
80
  .extend({
61
81
  query: z.string().optional(),
62
- mimeType: z.string().optional(),
82
+ mimeType: mimeTypeFilter,
63
83
  })
64
84
  .meta({ id: "MediaProviderListQuery" });
65
85
 
@@ -49,6 +49,15 @@ const fieldValidation = z
49
49
  subFields: z.array(repeaterSubFieldSchema).min(1).optional(),
50
50
  minItems: z.number().int().min(0).optional(),
51
51
  maxItems: z.number().int().min(1).optional(),
52
+ allowedMimeTypes: z
53
+ .array(
54
+ z
55
+ .string()
56
+ .regex(/^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i, "Invalid MIME type"),
57
+ )
58
+ .min(1, "allowedMimeTypes must not be empty — omit the field to allow all types")
59
+ .max(64, "allowedMimeTypes may contain at most 64 entries")
60
+ .optional(),
52
61
  })
53
62
  .optional();
54
63
 
@@ -92,7 +101,7 @@ export const createFieldBody = z
92
101
  required: z.boolean().optional(),
93
102
  unique: z.boolean().optional(),
94
103
  defaultValue: z.unknown().optional(),
95
- validation: fieldValidation,
104
+ validation: fieldValidation.nullable(),
96
105
  widget: z.string().optional(),
97
106
  options: fieldWidgetOptions,
98
107
  sortOrder: z.number().int().min(0).optional(),
@@ -107,7 +116,7 @@ export const updateFieldBody = z
107
116
  required: z.boolean().optional(),
108
117
  unique: z.boolean().optional(),
109
118
  defaultValue: z.unknown().optional(),
110
- validation: fieldValidation,
119
+ validation: fieldValidation.nullable(),
111
120
  widget: z.string().optional(),
112
121
  options: fieldWidgetOptions,
113
122
  sortOrder: z.number().int().min(0).optional(),
@@ -47,7 +47,12 @@ import { createPublicMediaUrlResolver } from "../media/url.js";
47
47
  import type { SandboxRunner } from "../plugins/sandbox/types.js";
48
48
  import type { ResolvedPlugin } from "../plugins/types.js";
49
49
  import { invalidateUrlPatternCache } from "../query.js";
50
- import { getRequestContext, runWithContext } from "../request-context.js";
50
+ import {
51
+ createRequestMetrics,
52
+ getRequestContext,
53
+ type RequestMetrics,
54
+ runWithContext,
55
+ } from "../request-context.js";
51
56
  import type { EmDashConfig } from "./integration/runtime.js";
52
57
  import type { EmDashHandlers } from "./types.js";
53
58
 
@@ -209,6 +214,33 @@ function finalizeResponse(
209
214
  return res;
210
215
  }
211
216
 
217
+ /**
218
+ * Append always-on counters (db.*, cache.*) to the Server-Timing list.
219
+ *
220
+ * dur values for `count`, `hit`, `miss` are integer counts — Server-Timing
221
+ * spec only models milliseconds, but browsers show whatever number is given,
222
+ * which is the convention most projects use for non-time samples.
223
+ */
224
+ function pushMetricsTimings(
225
+ timings: Array<{ name: string; dur: number; desc?: string }>,
226
+ metrics: RequestMetrics,
227
+ ): void {
228
+ if (metrics.dbCount > 0) {
229
+ timings.push({ name: "db.total", dur: metrics.dbTotalMs, desc: "DB total" });
230
+ timings.push({ name: "db.count", dur: metrics.dbCount, desc: "Query count" });
231
+ if (metrics.dbFirstOffset !== null) {
232
+ timings.push({ name: "db.first", dur: metrics.dbFirstOffset, desc: "First query at" });
233
+ }
234
+ if (metrics.dbLastOffset !== null) {
235
+ timings.push({ name: "db.last", dur: metrics.dbLastOffset, desc: "Last query at" });
236
+ }
237
+ }
238
+ if (metrics.cacheHits + metrics.cacheMisses > 0) {
239
+ timings.push({ name: "cache.hit", dur: metrics.cacheHits, desc: "Cache hits" });
240
+ timings.push({ name: "cache.miss", dur: metrics.cacheMisses, desc: "Cache misses" });
241
+ }
242
+ }
243
+
212
244
  /** Public routes that require the runtime (sitemap, robots.txt, etc.) */
213
245
  const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
214
246
  const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
@@ -252,6 +284,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
252
284
  ? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
253
285
  : undefined;
254
286
 
287
+ const metrics = createRequestMetrics(performance.now());
288
+
255
289
  const run = async (): Promise<Response> => {
256
290
  // Process /_emdash routes and public routes with an active session
257
291
  // (logged-in editors need the runtime for toolbar/visual editing on public pages)
@@ -355,13 +389,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
355
389
  const response = await next();
356
390
  timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
357
391
  timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
392
+ pushMetricsTimings(timings, metrics);
358
393
  return finalizeResponse(response, timings);
359
394
  };
360
395
  if (anonScoped) {
361
396
  const parent = getRequestContext();
362
397
  const ctx = parent
363
398
  ? { ...parent, db: anonScoped.db }
364
- : { editMode: false, db: anonScoped.db };
399
+ : { editMode: false, db: anonScoped.db, metrics };
365
400
  return runWithContext(ctx, async () => {
366
401
  const response = await runAnon();
367
402
  anonScoped.commit();
@@ -516,12 +551,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
516
551
  const response = await next();
517
552
  timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" });
518
553
  timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" });
554
+ pushMetricsTimings(timings, metrics);
519
555
  return finalizeResponse(response, timings);
520
556
  };
521
557
 
522
558
  if (scoped) {
523
559
  const parent = getRequestContext();
524
- const ctx = parent ? { ...parent, db: scoped.db } : { editMode: false, db: scoped.db };
560
+ const ctx = parent
561
+ ? { ...parent, db: scoped.db }
562
+ : { editMode: false, db: scoped.db, metrics };
525
563
  return runWithContext(ctx, async () => {
526
564
  const response = await renderAndFinalize();
527
565
  scoped.commit();
@@ -542,20 +580,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
542
580
  const parent = getRequestContext();
543
581
  const ctx = parent
544
582
  ? { ...parent, editMode, db: playgroundDb, dbIsIsolated: true }
545
- : { editMode, db: playgroundDb, dbIsIsolated: true };
583
+ : { editMode, db: playgroundDb, dbIsIsolated: true, metrics };
546
584
  return runWithContext(ctx, doInit);
547
585
  }
548
586
  return doInit();
549
587
  };
550
588
 
551
- if (queryRecorder) {
552
- try {
553
- return await runWithContext({ editMode: false, queryRecorder }, run);
554
- } finally {
555
- flushRecorder(queryRecorder);
556
- }
589
+ try {
590
+ return await runWithContext({ editMode: false, queryRecorder, metrics }, run);
591
+ } finally {
592
+ if (queryRecorder) flushRecorder(queryRecorder);
557
593
  }
558
- return run();
559
594
  });
560
595
 
561
596
  export default onRequest;
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
50
50
 
51
51
  if (!result.success) return unwrapResult(result);
52
52
 
53
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
53
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
54
 
55
55
  return unwrapResult(result);
56
56
  };
@@ -55,7 +55,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
55
55
 
56
56
  if (!result.success) return unwrapResult(result);
57
57
 
58
- if (cache.enabled) await cache.invalidate({ tags: [collection] });
58
+ if (cache?.enabled) await cache.invalidate({ tags: [collection] });
59
59
 
60
60
  return unwrapResult(result, 201);
61
61
  };
@@ -27,7 +27,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
27
27
 
28
28
  if (!result.success) return unwrapResult(result);
29
29
 
30
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
30
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, id] });
31
31
 
32
32
  return unwrapResult(result);
33
33
  };
@@ -80,7 +80,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
80
80
 
81
81
  if (!result.success) return unwrapResult(result);
82
82
 
83
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
83
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
84
84
 
85
85
  return unwrapResult(result);
86
86
  };
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
50
50
 
51
51
  if (!result.success) return unwrapResult(result);
52
52
 
53
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
53
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
54
 
55
55
  return unwrapResult(result);
56
56
  };
@@ -63,7 +63,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
63
63
 
64
64
  if (!result.success) return unwrapResult(result);
65
65
 
66
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
66
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
67
67
 
68
68
  return unwrapResult(result);
69
69
  };
@@ -95,7 +95,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
95
95
 
96
96
  if (!result.success) return unwrapResult(result);
97
97
 
98
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
98
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
99
99
 
100
100
  return unwrapResult(result);
101
101
  };
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
50
50
 
51
51
  if (!result.success) return unwrapResult(result);
52
52
 
53
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
53
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
54
54
 
55
55
  return unwrapResult(result);
56
56
  };
@@ -125,7 +125,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
125
125
 
126
126
  if (!result.success) return unwrapResult(result);
127
127
 
128
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
128
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
129
129
 
130
130
  return unwrapResult(result);
131
131
  };
@@ -171,7 +171,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
171
171
 
172
172
  if (!result.success) return unwrapResult(result);
173
173
 
174
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
174
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
175
175
 
176
176
  return unwrapResult(result);
177
177
  };
@@ -91,7 +91,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
91
91
 
92
92
  if (!result.success) return unwrapResult(result);
93
93
 
94
- if (cache.enabled) await cache.invalidate({ tags: [collection] });
94
+ if (cache?.enabled) await cache.invalidate({ tags: [collection] });
95
95
 
96
96
  return unwrapResult(result, 201);
97
97
  };
@@ -15,8 +15,10 @@ import { ulid } from "ulidx";
15
15
 
16
16
  import { requirePerm } from "#api/authorize.js";
17
17
  import { apiError, apiSuccess, handleError } from "#api/error.js";
18
+ import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js";
18
19
  import { isParseError, parseBody } from "#api/parse.js";
19
20
  import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js";
21
+ import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js";
20
22
 
21
23
  export const prerender = false;
22
24
 
@@ -70,9 +72,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
70
72
  const body = await parseBody(request, mediaUploadUrlBody(maxSize));
71
73
  if (isParseError(body)) return body;
72
74
 
73
- // Validate content type
74
- const allowedTypes = ["image/", "video/", "audio/", "application/pdf"];
75
- if (!allowedTypes.some((type) => body.contentType.startsWith(type))) {
75
+ // Validate content type (field-aware widening)
76
+ const fieldAllowlist = body.fieldId
77
+ ? await resolveFieldAllowlist(emdash.db, body.fieldId)
78
+ : null;
79
+ const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST];
80
+
81
+ if (!matchesMimeAllowlist(body.contentType, allowlist)) {
76
82
  return apiError("INVALID_TYPE", "File type not allowed", 400);
77
83
  }
78
84
 
@@ -100,7 +106,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
100
106
  // Create pending media record with content hash
101
107
  const mediaItem = await repo.createPending({
102
108
  filename: body.filename,
103
- mimeType: body.contentType,
109
+ mimeType: normalizeMime(body.contentType),
104
110
  size: body.size,
105
111
  storageKey,
106
112
  contentHash: body.contentHash,
@@ -12,9 +12,11 @@ import { ulid } from "ulidx";
12
12
 
13
13
  import { requirePerm } from "#api/authorize.js";
14
14
  import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js";
15
+ import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/media-allowlist.js";
15
16
  import { isParseError, parseQuery } from "#api/parse.js";
16
17
  import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js";
17
18
  import { MediaRepository } from "#db/repositories/media.js";
19
+ import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js";
18
20
  import { generatePlaceholder } from "#media/placeholder.js";
19
21
  import { computeContentHash } from "#utils/hash.js";
20
22
 
@@ -106,9 +108,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
106
108
  return apiError("NO_FILE", "No file provided", 400);
107
109
  }
108
110
 
109
- // Validate file type
110
- const allowedTypes = ["image/", "video/", "audio/", "application/pdf"];
111
- if (!allowedTypes.some((type) => file.type.startsWith(type))) {
111
+ // Validate file type — widen the allowlist when a field-specific list is configured
112
+ const fieldIdEntry = formData.get("fieldId");
113
+ const fieldId =
114
+ typeof fieldIdEntry === "string" && fieldIdEntry.length > 0 ? fieldIdEntry : null;
115
+
116
+ const fieldAllowlist = fieldId ? await resolveFieldAllowlist(emdash.db, fieldId) : null;
117
+ const allowlist = fieldAllowlist ?? [...GLOBAL_UPLOAD_ALLOWLIST];
118
+
119
+ if (!matchesMimeAllowlist(file.type, allowlist)) {
112
120
  return apiError("INVALID_TYPE", "File type not allowed", 400);
113
121
  }
114
122
 
@@ -174,7 +182,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
174
182
  // Create media record
175
183
  const result = await emdash.handleMediaCreate({
176
184
  filename: file.name,
177
- mimeType: file.type,
185
+ mimeType: normalizeMime(file.type),
178
186
  size: file.size,
179
187
  width,
180
188
  height,
@@ -43,6 +43,10 @@ export interface ManifestCollection {
43
43
  * (e.g. a checkbox grid receiving its column definitions)
44
44
  */
45
45
  options?: Array<{ value: string; label: string }> | Record<string, unknown>;
46
+ /** The `_emdash_fields` row ID. Used by the admin to forward to upload/media-list API calls. */
47
+ id?: string;
48
+ /** Validation config for the field (e.g. `allowedMimeTypes` for file/image fields, subFields for repeater). */
49
+ validation?: Record<string, unknown>;
46
50
  }
47
51
  >;
48
52
  }
@@ -292,7 +296,7 @@ export interface EmDashHandlers {
292
296
  handleMediaList: (params: {
293
297
  cursor?: string;
294
298
  limit?: number;
295
- mimeType?: string;
299
+ mimeType?: string | readonly string[];
296
300
  }) => Promise<HandlerResponse>;
297
301
 
298
302
  handleMediaGet: (id: string) => Promise<HandlerResponse>;
@@ -63,9 +63,9 @@ export async function checkRateLimit(
63
63
 
64
64
  // Atomic upsert: insert or increment, return current count
65
65
  const result = await sql<{ count: number }>`
66
- INSERT INTO _emdash_rate_limits (key, window, count)
66
+ INSERT INTO _emdash_rate_limits (key, "window", count)
67
67
  VALUES (${key}, ${windowStart}, 1)
68
- ON CONFLICT (key, window)
68
+ ON CONFLICT (key, "window")
69
69
  DO UPDATE SET count = _emdash_rate_limits.count + 1
70
70
  RETURNING count
71
71
  `.execute(db);
@@ -179,7 +179,7 @@ export async function cleanupExpiredRateLimits(
179
179
  const cutoff = new Date(Date.now() - maxAgeSeconds * 1000).toISOString();
180
180
 
181
181
  const result = await sql`
182
- DELETE FROM _emdash_rate_limits WHERE window < ${cutoff}
182
+ DELETE FROM _emdash_rate_limits WHERE "window" < ${cutoff}
183
183
  `.execute(db);
184
184
 
185
185
  return Number(result.numAffectedRows ?? 0);