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
@@ -1,5 +1,7 @@
1
1
  import type { z } from "astro/zod";
2
2
 
3
+ import type { FieldValidation } from "../schema/types.js";
4
+
3
5
  /**
4
6
  * SQLite column types that map from field types
5
7
  */
@@ -19,6 +21,7 @@ export interface FieldDefinition<_T = unknown> {
19
21
  schema: z.ZodTypeAny;
20
22
  options?: unknown;
21
23
  ui?: FieldUIHints;
24
+ validation?: FieldValidation;
22
25
  }
23
26
 
24
27
  /**
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export type {
27
27
  export type { MediaItem, CreateMediaInput } from "./database/repositories/media.js";
28
28
 
29
29
  // Fields
30
- export { portableText, image, reference } from "./fields/index.js";
30
+ export { portableText, image, file, reference } from "./fields/index.js";
31
31
  export { normalizeMediaValue } from "./media/normalize.js";
32
32
  export { generatePlaceholder } from "./media/placeholder.js";
33
33
  export type { PlaceholderData } from "./media/placeholder.js";
package/src/mcp/server.ts CHANGED
@@ -1993,13 +1993,23 @@ export function createMcpServer(): McpServer {
1993
1993
  description:
1994
1994
  "Create a new navigation menu. The `name` is the stable identifier used " +
1995
1995
  "by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
1996
- "name shown in the admin. Add items afterwards with menu_set_items.",
1996
+ "name shown in the admin. Menus are per-locale, so pass `locale` when " +
1997
+ "the same menu name exists in multiple translations. Add items afterwards " +
1998
+ "with menu_set_items. If `translationOf` is set, `locale` must also be set.",
1999
+ // `locale`-when-`translationOf` is enforced inside handleMenuCreate
2000
+ // so REST/SDK callers get the same guard. The description above
2001
+ // documents the rule; the handler returns VALIDATION_ERROR.
1997
2002
  inputSchema: z.object({
1998
2003
  name: z
1999
2004
  .string()
2000
2005
  .regex(COLLECTION_SLUG_PATTERN)
2001
2006
  .describe("Stable identifier (lowercase letters, numbers, underscores)"),
2002
2007
  label: z.string().describe("Display name for the admin"),
2008
+ locale: z.string().optional().describe("Locale for this menu (e.g. 'fr-fr')"),
2009
+ translationOf: z
2010
+ .string()
2011
+ .optional()
2012
+ .describe("Existing menu id to create this locale variant from"),
2003
2013
  }),
2004
2014
  },
2005
2015
  async (args, extra) => {
@@ -2008,7 +2018,14 @@ export function createMcpServer(): McpServer {
2008
2018
  const ec = getEmDash(extra);
2009
2019
  try {
2010
2020
  const { handleMenuCreate } = await import("../api/handlers/menus.js");
2011
- return unwrap(await handleMenuCreate(ec.db, { name: args.name, label: args.label }));
2021
+ return unwrap(
2022
+ await handleMenuCreate(ec.db, {
2023
+ name: args.name,
2024
+ label: args.label,
2025
+ locale: args.locale,
2026
+ translationOf: args.translationOf,
2027
+ }),
2028
+ );
2012
2029
  } catch (error) {
2013
2030
  return respondHandlerError(error, "MENU_CREATE_ERROR");
2014
2031
  }
@@ -2019,10 +2036,13 @@ export function createMcpServer(): McpServer {
2019
2036
  "menu_update",
2020
2037
  {
2021
2038
  title: "Update Menu",
2022
- description: "Update a menu's label. The `name` (stable identifier) cannot be changed.",
2039
+ description:
2040
+ "Update a menu's label. The `name` (stable identifier) cannot be changed. " +
2041
+ "On multi-locale installs, pass `locale` so the correct translation is updated.",
2023
2042
  inputSchema: z.object({
2024
2043
  name: z.string().describe("Menu name to update"),
2025
2044
  label: z.string().describe("New display label"),
2045
+ locale: z.string().optional().describe("Locale of the menu to update"),
2026
2046
  }),
2027
2047
  },
2028
2048
  async (args, extra) => {
@@ -2031,7 +2051,9 @@ export function createMcpServer(): McpServer {
2031
2051
  const ec = getEmDash(extra);
2032
2052
  try {
2033
2053
  const { handleMenuUpdate } = await import("../api/handlers/menus.js");
2034
- return unwrap(await handleMenuUpdate(ec.db, args.name, { label: args.label }));
2054
+ return unwrap(
2055
+ await handleMenuUpdate(ec.db, args.name, { label: args.label, locale: args.locale }),
2056
+ );
2035
2057
  } catch (error) {
2036
2058
  return respondHandlerError(error, "MENU_UPDATE_ERROR");
2037
2059
  }
@@ -2042,9 +2064,12 @@ export function createMcpServer(): McpServer {
2042
2064
  "menu_delete",
2043
2065
  {
2044
2066
  title: "Delete Menu",
2045
- description: "Delete a menu. Items are also removed. Cannot be undone.",
2067
+ description:
2068
+ "Delete a menu. Items are also removed. Cannot be undone. On multi-locale " +
2069
+ "installs, pass `locale` so only the intended translation is removed.",
2046
2070
  inputSchema: z.object({
2047
2071
  name: z.string().describe("Menu name to delete"),
2072
+ locale: z.string().optional().describe("Locale of the menu to delete"),
2048
2073
  }),
2049
2074
  annotations: { destructiveHint: true },
2050
2075
  },
@@ -2054,7 +2079,7 @@ export function createMcpServer(): McpServer {
2054
2079
  const ec = getEmDash(extra);
2055
2080
  try {
2056
2081
  const { handleMenuDelete } = await import("../api/handlers/menus.js");
2057
- return unwrap(await handleMenuDelete(ec.db, args.name));
2082
+ return unwrap(await handleMenuDelete(ec.db, args.name, { locale: args.locale }));
2058
2083
  } catch (error) {
2059
2084
  return respondHandlerError(error, "MENU_DELETE_ERROR");
2060
2085
  }
@@ -2069,9 +2094,11 @@ export function createMcpServer(): McpServer {
2069
2094
  "Replace the entire item list of a menu in one call. This is atomic: the " +
2070
2095
  "existing items are deleted and the new list is inserted in the order " +
2071
2096
  "provided. Use this rather than per-item add/remove tools so the resulting " +
2072
- "order and parent links are unambiguous.",
2097
+ "order and parent links are unambiguous. On multi-locale installs, pass " +
2098
+ "`locale` so only the intended translation is rewritten.",
2073
2099
  inputSchema: z.object({
2074
2100
  name: z.string().describe("Menu name to update"),
2101
+ locale: z.string().optional().describe("Locale of the menu to rewrite"),
2075
2102
  items: z
2076
2103
  .array(
2077
2104
  z.object({
@@ -2115,7 +2142,9 @@ export function createMcpServer(): McpServer {
2115
2142
  const ec = getEmDash(extra);
2116
2143
  try {
2117
2144
  const { handleMenuSetItems } = await import("../api/handlers/menus.js");
2118
- return unwrap(await handleMenuSetItems(ec.db, args.name, args.items));
2145
+ return unwrap(
2146
+ await handleMenuSetItems(ec.db, args.name, args.items, { locale: args.locale }),
2147
+ );
2119
2148
  } catch (error) {
2120
2149
  return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
2121
2150
  }
@@ -0,0 +1,75 @@
1
+ export function normalizeMime(mime: string): string {
2
+ return mime.split(";")[0]!.trim().toLowerCase();
3
+ }
4
+
5
+ export function matchesMimeAllowlist(mime: string, allowList: readonly string[]): boolean {
6
+ const normalized = normalizeMime(mime);
7
+ for (const entry of allowList) {
8
+ if (!entry || !entry.includes("/")) continue;
9
+ const normalizedEntry = normalizeMime(entry);
10
+ if (normalizedEntry.endsWith("/")) {
11
+ if (normalized.startsWith(normalizedEntry)) return true;
12
+ } else if (normalized === normalizedEntry) {
13
+ return true;
14
+ }
15
+ }
16
+ return false;
17
+ }
18
+
19
+ export const EXTENSION_TO_MIME: Readonly<Record<string, string>> = {
20
+ ".pdf": "application/pdf",
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ ".svg": "image/svg+xml",
27
+ ".mp3": "audio/mpeg",
28
+ ".wav": "audio/wav",
29
+ ".mp4": "video/mp4",
30
+ ".webm": "video/webm",
31
+ ".zip": "application/zip",
32
+ ".tar": "application/x-tar",
33
+ ".gz": "application/gzip",
34
+ ".csv": "text/csv",
35
+ ".doc": "application/msword",
36
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
37
+ ".xls": "application/vnd.ms-excel",
38
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39
+ ".txt": "text/plain",
40
+ ".rtf": "application/rtf",
41
+ ".vtt": "text/vtt",
42
+ ".srt": "application/x-subrip",
43
+ ".woff": "font/woff",
44
+ ".woff2": "font/woff2",
45
+ };
46
+
47
+ const VALID_MIME_RE = /^[a-z0-9][a-z0-9!#$&^_+\-.]*\/[a-z0-9!#$&^_+\-.]*$/i;
48
+
49
+ export function expandExtensionShorthand(entry: string): string | null {
50
+ const trimmed = entry.trim();
51
+ if (!trimmed) return null;
52
+ if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null;
53
+ if (trimmed.startsWith(".")) {
54
+ return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Extract the `allowedMimeTypes` list from a `_emdash_fields.validation` row
61
+ * (raw JSON string). Returns null when the value is missing, malformed, or the
62
+ * list is empty — callers treat that as "no field-specific constraint".
63
+ */
64
+ export function parseAllowedMimeTypes(rawValidation: string | null | undefined): string[] | null {
65
+ if (!rawValidation) return null;
66
+ try {
67
+ const parsed: unknown = JSON.parse(rawValidation);
68
+ if (typeof parsed !== "object" || parsed === null) return null;
69
+ const list = (parsed as { allowedMimeTypes?: unknown }).allowedMimeTypes;
70
+ if (!Array.isArray(list) || list.length === 0) return null;
71
+ return list.filter((entry): entry is string => typeof entry === "string");
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
@@ -10,187 +10,56 @@
10
10
  */
11
11
 
12
12
  import type { Element } from "@emdash-cms/blocks";
13
+ // The plugin capability vocabulary, the legacy-rename map, and the manifest
14
+ // shape are authored once in @emdash-cms/plugin-types and shared between core
15
+ // (the manifest reader at install/runtime) and @emdash-cms/registry-cli (the
16
+ // manifest writer at bundle/publish time).
17
+ //
18
+ // We import-and-re-export here so existing internal callers keep working
19
+ // (e.g. `import { PluginCapability } from "../plugins/types.js"`).
20
+ import {
21
+ CAPABILITY_RENAMES,
22
+ isDeprecatedCapability,
23
+ normalizeCapabilities,
24
+ normalizeCapability,
25
+ type CurrentPluginCapability,
26
+ type DeprecatedPluginCapability,
27
+ type ManifestHookEntry,
28
+ type ManifestRouteEntry,
29
+ type PluginCapability,
30
+ type PluginStorageConfig,
31
+ type StorageCollectionConfig,
32
+ } from "@emdash-cms/plugin-types";
13
33
  import type { JSX } from "astro/jsx-runtime";
14
34
  import type { z } from "astro/zod";
15
-
16
- import type { FieldType } from "../schema/types.js";
17
-
18
35
  // =============================================================================
19
36
  // Core Types
20
37
  // =============================================================================
21
38
 
22
- /**
23
- * Plugin capabilities determine what APIs are available in context.
24
- *
25
- * Capabilities follow the formula `<resource>[.<sub-resource>]:<verb>[:<qualifier>]`
26
- * — resource first, verb second, matching RBAC. The `unrestricted` qualifier
27
- * (used by `network:request:unrestricted`) is intentionally verbose so that
28
- * granting it stands out in manifest review.
29
- *
30
- * Hook-registration capabilities (`hooks.<family>:register`) are a distinct
31
- * audit category from data-access capabilities — they gate which hooks a
32
- * plugin is allowed to register, not which context APIs it gets.
33
- *
34
- * @see CAPABILITY_RENAMES for the legacy → current mapping, and
35
- * `normalizeCapability()` for the runtime alias layer.
36
- */
37
- export type PluginCapability =
38
- // ── Network ─────────────────────────────────────────────────
39
- | "network:request" // ctx.http is available (host-restricted via allowedHosts)
40
- | "network:request:unrestricted" // ctx.http is available (unrestricted outbound — use for user-configured URLs)
41
- // ── Content ─────────────────────────────────────────────────
42
- | "content:read" // ctx.content.get/list available
43
- | "content:write" // ctx.content.create/update/delete available
44
- // ── Media ───────────────────────────────────────────────────
45
- | "media:read" // ctx.media.get/list available
46
- | "media:write" // ctx.media.getUploadUrl/delete available
47
- // ── Users ───────────────────────────────────────────────────
48
- | "users:read" // ctx.users is available
49
- // ── Email ───────────────────────────────────────────────────
50
- | "email:send" // ctx.email is available (when a provider is configured)
51
- // ── Hook registration ───────────────────────────────────────
52
- | "hooks.email-transport:register" // can register email:deliver exclusive hook (transport provider)
53
- | "hooks.email-events:register" // can register email:beforeSend / email:afterSend hooks
54
- | "hooks.page-fragments:register" // can register page:fragments hook (inject scripts/styles into pages)
55
- // ── Deprecated (legacy aliases) ─────────────────────────────
56
- // Kept in the union for one minor with @deprecated tags so existing
57
- // plugins typecheck during migration. Normalized to current names at
58
- // definition time via normalizeCapability(). Will be removed in the
59
- // following minor.
60
- /** @deprecated Use `network:request` instead. */
61
- | "network:fetch"
62
- /** @deprecated Use `network:request:unrestricted` instead. */
63
- | "network:fetch:any"
64
- /** @deprecated Use `content:read` instead. */
65
- | "read:content"
66
- /** @deprecated Use `content:write` instead. */
67
- | "write:content"
68
- /** @deprecated Use `media:read` instead. */
69
- | "read:media"
70
- /** @deprecated Use `media:write` instead. */
71
- | "write:media"
72
- /** @deprecated Use `users:read` instead. */
73
- | "read:users"
74
- /** @deprecated Use `hooks.email-transport:register` instead. */
75
- | "email:provide"
76
- /** @deprecated Use `hooks.email-events:register` instead. */
77
- | "email:intercept"
78
- /** @deprecated Use `hooks.page-fragments:register` instead. */
79
- | "page:inject";
80
-
81
- /**
82
- * Deprecated capability names that map to current names.
83
- *
84
- * These are accepted at every external boundary (manifest parse, definePlugin,
85
- * adaptSandboxEntry) and silently normalized to the new names before reaching
86
- * the runtime. The runtime never sees deprecated names.
87
- *
88
- * Authors are warned at `bundle` / `validate`, and hard-failed at `publish`.
89
- */
90
- export type DeprecatedPluginCapability =
91
- | "network:fetch"
92
- | "network:fetch:any"
93
- | "read:content"
94
- | "write:content"
95
- | "read:media"
96
- | "write:media"
97
- | "read:users"
98
- | "email:provide"
99
- | "email:intercept"
100
- | "page:inject";
101
-
102
- /**
103
- * Current (non-deprecated) capability names.
104
- */
105
- export type CurrentPluginCapability = Exclude<PluginCapability, DeprecatedPluginCapability>;
106
-
107
- /**
108
- * Mapping from deprecated capability names to their current replacements.
109
- *
110
- * Used by `normalizeCapability()` and the marketplace `diffCapabilities`
111
- * helper to compare manifests across the rename without flagging spurious
112
- * "capability changed" prompts on upgrade.
113
- */
114
- export const CAPABILITY_RENAMES: Readonly<
115
- Record<DeprecatedPluginCapability, CurrentPluginCapability>
116
- > = Object.freeze({
117
- "network:fetch": "network:request",
118
- "network:fetch:any": "network:request:unrestricted",
119
- "read:content": "content:read",
120
- "write:content": "content:write",
121
- "read:media": "media:read",
122
- "write:media": "media:write",
123
- "read:users": "users:read",
124
- "email:provide": "hooks.email-transport:register",
125
- "email:intercept": "hooks.email-events:register",
126
- "page:inject": "hooks.page-fragments:register",
127
- });
128
-
129
- /**
130
- * Type guard: is this capability one of the deprecated legacy names?
131
- *
132
- * Uses an own-property check so that prototype keys like "toString" or
133
- * "constructor" don't accidentally pass.
134
- */
135
- export function isDeprecatedCapability(cap: string): cap is DeprecatedPluginCapability {
136
- return Object.hasOwn(CAPABILITY_RENAMES, cap);
137
- }
138
-
139
- /**
140
- * Normalize a capability string — deprecated names map to current names,
141
- * current names pass through unchanged. Unknown strings are returned as-is
142
- * so that downstream validators can produce a precise error.
143
- */
144
- export function normalizeCapability(cap: string): string {
145
- if (isDeprecatedCapability(cap)) {
146
- return CAPABILITY_RENAMES[cap];
147
- }
148
- return cap;
149
- }
39
+ import type { FieldType } from "../schema/types.js";
150
40
 
151
- /**
152
- * Normalize an array of capabilities. Deduplicates by normalized name so
153
- * that a plugin declaring both `read:content` and `content:read` ends up
154
- * with a single `content:read` entry.
155
- */
156
- export function normalizeCapabilities(caps: readonly string[]): string[] {
157
- const seen = new Set<string>();
158
- const out: string[] = [];
159
- for (const cap of caps) {
160
- const normalized = normalizeCapability(cap);
161
- if (!seen.has(normalized)) {
162
- seen.add(normalized);
163
- out.push(normalized);
164
- }
165
- }
166
- return out;
167
- }
41
+ export {
42
+ CAPABILITY_RENAMES,
43
+ isDeprecatedCapability,
44
+ normalizeCapabilities,
45
+ normalizeCapability,
46
+ type CurrentPluginCapability,
47
+ type DeprecatedPluginCapability,
48
+ type ManifestHookEntry,
49
+ type ManifestRouteEntry,
50
+ type PluginCapability,
51
+ type PluginStorageConfig,
52
+ type StorageCollectionConfig,
53
+ };
168
54
 
169
55
  // =============================================================================
170
56
  // Storage Types
171
57
  // =============================================================================
172
-
173
- /**
174
- * Storage collection declaration in plugin definition
175
- */
176
- export interface StorageCollectionConfig {
177
- /**
178
- * Fields to index for querying.
179
- * Each entry can be a single field name or an array for composite indexes.
180
- */
181
- indexes: Array<string | string[]>;
182
- /**
183
- * Fields with unique constraints.
184
- * Each entry can be a single field name or an array for composite unique indexes.
185
- * Unique indexes are also queryable (no need to duplicate in `indexes`).
186
- */
187
- uniqueIndexes?: Array<string | string[]>;
188
- }
189
-
190
- /**
191
- * Plugin storage configuration
192
- */
193
- export type PluginStorageConfig = Record<string, StorageCollectionConfig>;
58
+ //
59
+ // `StorageCollectionConfig` and `PluginStorageConfig` are re-exported above
60
+ // from `@emdash-cms/plugin-types`. The manifest carries these shapes
61
+ // verbatim; both this package (reader) and registry-cli (writer) agree on
62
+ // the same types via the shared package.
194
63
 
195
64
  /**
196
65
  * Query filter operators
@@ -1128,27 +997,14 @@ export interface PluginHooks {
1128
997
  /**
1129
998
  * Hook names
1130
999
  */
1131
- export type HookName = keyof PluginHooks;
1132
-
1133
1000
  /**
1134
- * Hook metadata entry in a plugin manifest.
1135
- * Replaces the plain hook name string with structured metadata.
1001
+ * Hook name in a manifest. Core's exhaustive union of recognised hook names,
1002
+ * derived from the `PluginHooks` registry. The serialised manifest carries
1003
+ * these as opaque strings; this stricter type is only used for type-checking
1004
+ * inside core. `ManifestHookEntry` is re-exported from
1005
+ * `@emdash-cms/plugin-types` near the top of this file.
1136
1006
  */
1137
- export interface ManifestHookEntry {
1138
- name: string;
1139
- exclusive?: boolean;
1140
- priority?: number;
1141
- timeout?: number;
1142
- }
1143
-
1144
- /**
1145
- * Route metadata entry in a plugin manifest.
1146
- * Replaces the plain route name string with structured metadata.
1147
- */
1148
- export interface ManifestRouteEntry {
1149
- name: string;
1150
- public?: boolean;
1151
- }
1007
+ export type HookName = keyof PluginHooks;
1152
1008
 
1153
1009
  /**
1154
1010
  * Resolved hook with normalized config
@@ -1543,8 +1399,16 @@ export interface PluginAdminExports {
1543
1399
  // =============================================================================
1544
1400
 
1545
1401
  /**
1546
- * Plugin manifest - the metadata portion of a plugin bundle
1547
- * Used for sandboxed plugins loaded from marketplace
1402
+ * Plugin manifest the metadata portion of a plugin bundle, used for
1403
+ * sandboxed plugins loaded from the marketplace.
1404
+ *
1405
+ * This interface is core's stricter version of the manifest contract: it
1406
+ * uses the exhaustive `HookName` union and core's typed `PluginAdminConfig`.
1407
+ * The wire-shape lives in `@emdash-cms/plugin-types` as `PluginManifest`
1408
+ * with looser types (so the registry CLI can serialise hook names it
1409
+ * doesn't know about). Both must stay structurally compatible: every value
1410
+ * of this type must be assignable to the shared one. The static assertion
1411
+ * below catches any drift at compile time.
1548
1412
  */
1549
1413
  export interface PluginManifest {
1550
1414
  id: string;
@@ -1558,3 +1422,29 @@ export interface PluginManifest {
1558
1422
  routes: Array<ManifestRouteEntry | string>;
1559
1423
  admin: PluginAdminConfig;
1560
1424
  }
1425
+
1426
+ // Type-level guard: core's `PluginManifest` is intentionally a SUBTYPE of
1427
+ // the shared wire shape (`@emdash-cms/plugin-types` `PluginManifest`). The
1428
+ // wire shape uses looser types like `string` for hook names so the registry
1429
+ // CLI can serialise plugins targeting hook versions this core doesn't yet
1430
+ // know about. Core narrows `string` to `HookName` and `Record<string,
1431
+ // unknown>` to `PluginAdminConfig` because core's loader actually executes
1432
+ // against those types.
1433
+ //
1434
+ // We assert one direction at compile time: `core extends shared`. The
1435
+ // reverse direction (`shared extends core`) intentionally does NOT hold
1436
+ // because shared is wider -- a manifest written against the wire shape
1437
+ // could carry a hook name core doesn't know. That runtime narrowing is the
1438
+ // job of `manifest-schema.ts` (zod-validated, called at every JSON.parse
1439
+ // of a manifest.json), not of the type system. The static check below
1440
+ // catches the OTHER failure mode: core adding a required field or
1441
+ // non-assignable type that the wire shape doesn't allow.
1442
+ //
1443
+ // `type X = never` is itself legal as a type alias, so the assertion has to
1444
+ // be in a value position (`const _check: T = true`) for the compiler to
1445
+ // error when T resolves to `never`. Don't replace this with a bare type
1446
+ // alias.
1447
+ type _AssertManifestCompat =
1448
+ PluginManifest extends import("@emdash-cms/plugin-types").PluginManifest ? true : never;
1449
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1450
+ const _MANIFEST_COMPAT: _AssertManifestCompat = true;
@@ -48,8 +48,12 @@ export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T>
48
48
  }
49
49
 
50
50
  const existing = cache.get(key);
51
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- heterogeneous cache; key namespacing guarantees the stored promise resolves to T
52
- if (existing) return existing as Promise<T>;
51
+ if (existing) {
52
+ if (ctx.metrics) ctx.metrics.cacheHits += 1;
53
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- heterogeneous cache; key namespacing guarantees the stored promise resolves to T
54
+ return existing as Promise<T>;
55
+ }
56
+ if (ctx.metrics) ctx.metrics.cacheMisses += 1;
53
57
 
54
58
  const promise = Promise.resolve()
55
59
  .then(fn)
@@ -5,8 +5,10 @@
5
5
  * without requiring explicit parameter passing. The middleware wraps next()
6
6
  * in als.run(), making the context available to all code during rendering.
7
7
  *
8
- * For logged-out users with no CMS signals (no edit cookie, no preview param),
9
- * the middleware skips ALS entirely zero overhead for normal traffic.
8
+ * Middleware always wraps each request in a context so per-request
9
+ * metrics (db.*, cache.*) can be surfaced via Server-Timing. The cost is
10
+ * one ALS frame per request — sub-microsecond, negligible compared to
11
+ * any real work.
10
12
  *
11
13
  * The AsyncLocalStorage instance is stored on globalThis with a Symbol key
12
14
  * to guarantee a singleton even when bundlers duplicate this module across
@@ -19,6 +21,38 @@ import { AsyncLocalStorage } from "node:async_hooks";
19
21
 
20
22
  import type { QueryRecorder } from "./database/instrumentation.js";
21
23
 
24
+ /**
25
+ * Lightweight always-on counters surfaced in Server-Timing.
26
+ *
27
+ * Bumped by the Kysely log hook (db queries) and by `requestCached`
28
+ * (cache hits/misses). Read by middleware after the response is
29
+ * generated to emit `db.*` and `cache.*` Server-Timing fields.
30
+ *
31
+ * Offsets are milliseconds from `start` (the request's entry into
32
+ * middleware), captured via `performance.now()`.
33
+ */
34
+ export interface RequestMetrics {
35
+ start: number;
36
+ dbCount: number;
37
+ dbTotalMs: number;
38
+ dbFirstOffset: number | null;
39
+ dbLastOffset: number | null;
40
+ cacheHits: number;
41
+ cacheMisses: number;
42
+ }
43
+
44
+ export function createRequestMetrics(start: number): RequestMetrics {
45
+ return {
46
+ start,
47
+ dbCount: 0,
48
+ dbTotalMs: 0,
49
+ dbFirstOffset: null,
50
+ dbLastOffset: null,
51
+ cacheHits: 0,
52
+ cacheMisses: 0,
53
+ };
54
+ }
55
+
22
56
  export interface EmDashRequestContext {
23
57
  /** Whether the current request is in visual editing mode */
24
58
  editMode: boolean;
@@ -54,6 +88,12 @@ export interface EmDashRequestContext {
54
88
  * to NDJSON after the response.
55
89
  */
56
90
  queryRecorder?: QueryRecorder;
91
+ /**
92
+ * Per-request metrics for Server-Timing. Always attached by middleware
93
+ * for requests that emit timing headers; bumped by the Kysely log hook
94
+ * and `requestCached`.
95
+ */
96
+ metrics?: RequestMetrics;
57
97
  }
58
98
 
59
99
  const ALS_KEY = Symbol.for("emdash:request-context");
@@ -526,6 +526,10 @@ export class SchemaRegistry {
526
526
  );
527
527
  }
528
528
 
529
+ // `input.validation === undefined` means "no change" (keep existing);
530
+ // an explicit `null` clears the column.
531
+ const nextValidation = input.validation === undefined ? field.validation : input.validation;
532
+
529
533
  return withTransaction(this.db, async (trx) => {
530
534
  await trx
531
535
  .updateTable("_emdash_fields")
@@ -550,11 +554,7 @@ export class SchemaRegistry {
550
554
  : field.defaultValue !== undefined
551
555
  ? JSON.stringify(field.defaultValue)
552
556
  : null,
553
- validation: input.validation
554
- ? JSON.stringify(input.validation)
555
- : field.validation
556
- ? JSON.stringify(field.validation)
557
- : null,
557
+ validation: nextValidation ? JSON.stringify(nextValidation) : null,
558
558
  widget: input.widget ?? field.widget ?? null,
559
559
  options: input.options
560
560
  ? JSON.stringify(input.options)
@@ -131,6 +131,7 @@ export interface FieldValidation {
131
131
  subFields?: RepeaterSubField[]; // For repeater fields
132
132
  minItems?: number; // For repeater fields
133
133
  maxItems?: number; // For repeater fields
134
+ allowedMimeTypes?: string[];
134
135
  }
135
136
 
136
137
  /**
@@ -238,7 +239,7 @@ export interface CreateFieldInput {
238
239
  required?: boolean;
239
240
  unique?: boolean;
240
241
  defaultValue?: unknown;
241
- validation?: FieldValidation;
242
+ validation?: FieldValidation | null;
242
243
  widget?: string;
243
244
  options?: FieldWidgetOptions;
244
245
  sortOrder?: number;
@@ -256,7 +257,7 @@ export interface UpdateFieldInput {
256
257
  required?: boolean;
257
258
  unique?: boolean;
258
259
  defaultValue?: unknown;
259
- validation?: FieldValidation;
260
+ validation?: FieldValidation | null;
260
261
  widget?: string;
261
262
  options?: FieldWidgetOptions;
262
263
  sortOrder?: number;