emdash 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) 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 +9 -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-Cg-rC4Gj.d.mts} +32 -24
  31. package/dist/index-Cg-rC4Gj.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-DiI8NOG_.mjs +16 -0
  74. package/dist/types-DiI8NOG_.mjs.map +1 -0
  75. package/dist/{types-D19uBYWn.d.mts → types-IN5z_S3P.d.mts} +19 -98
  76. package/dist/types-IN5z_S3P.d.mts.map +1 -0
  77. package/dist/{types-Dl1fgFjn.d.mts → types-IZSZfEwv.d.mts} +4 -3
  78. package/dist/types-IZSZfEwv.d.mts.map +1 -0
  79. package/dist/{validate-DHGwADqO.d.mts → validate-CO3JjFV5.d.mts} +7 -3
  80. package/dist/validate-CO3JjFV5.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-Bg31I_Ff.mjs +7 -0
  86. package/dist/{version-CMD42IRC.mjs.map → version-Bg31I_Ff.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/media/upload-url.ts +10 -4
  100. package/src/astro/routes/api/media.ts +12 -4
  101. package/src/astro/types.ts +5 -1
  102. package/src/auth/rate-limit.ts +3 -3
  103. package/src/cli/commands/bundle-utils.ts +81 -6
  104. package/src/cli/commands/bundle.ts +18 -15
  105. package/src/cli/commands/export-seed.ts +57 -3
  106. package/src/database/instrumentation.ts +22 -8
  107. package/src/database/migrations/016_api_tokens.ts +18 -3
  108. package/src/database/migrations/037_credential_algorithm.ts +18 -0
  109. package/src/database/migrations/runner.ts +2 -0
  110. package/src/database/repositories/media.ts +40 -10
  111. package/src/database/types.ts +2 -1
  112. package/src/emdash-runtime.ts +16 -3
  113. package/src/fields/file.ts +7 -6
  114. package/src/fields/image.ts +12 -11
  115. package/src/fields/types.ts +3 -0
  116. package/src/index.ts +1 -1
  117. package/src/mcp/server.ts +37 -8
  118. package/src/media/mime.ts +75 -0
  119. package/src/plugins/types.ts +81 -191
  120. package/src/request-cache.ts +6 -2
  121. package/src/request-context.ts +42 -2
  122. package/src/schema/registry.ts +5 -5
  123. package/src/schema/types.ts +3 -2
  124. package/src/seed/apply.ts +25 -8
  125. package/src/seed/types.ts +4 -0
  126. package/dist/index-DjPMOfO0.d.mts.map +0 -1
  127. package/dist/media-D8FbNsl0.mjs.map +0 -1
  128. package/dist/registry-Beb7wxFc.mjs.map +0 -1
  129. package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
  130. package/dist/runner-DMnlIkh4.mjs.map +0 -1
  131. package/dist/search-DkN-BqsS.mjs.map +0 -1
  132. package/dist/types-CoO6mpV3.mjs +0 -68
  133. package/dist/types-CoO6mpV3.mjs.map +0 -1
  134. package/dist/types-D19uBYWn.d.mts.map +0 -1
  135. package/dist/types-Dl1fgFjn.d.mts.map +0 -1
  136. package/dist/types-Dtx1mSMX.d.mts.map +0 -1
  137. package/dist/types-Eg829jj9.mjs.map +0 -1
  138. package/dist/validate-DHGwADqO.d.mts.map +0 -1
  139. package/dist/version-CMD42IRC.mjs +0 -7
@@ -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;
package/src/seed/apply.ts CHANGED
@@ -512,6 +512,8 @@ export async function applySeed(
512
512
  if (seed.menus) {
513
513
  // seed-local id -> resolved info, used to wire `translationOf` refs.
514
514
  const menuSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
515
+ // Shared across menus: translated items reference anchor items in sibling menus.
516
+ const itemSeedIdMap = new Map<string, { id: string; translationGroup: string }>();
515
517
  const fallbackLocale = getI18nConfig()?.defaultLocale ?? "en";
516
518
 
517
519
  for (const menu of seed.menus) {
@@ -569,6 +571,7 @@ export async function applySeed(
569
571
  null, // parent_id
570
572
  0, // sort_order
571
573
  seedIdMap,
574
+ itemSeedIdMap,
572
575
  );
573
576
  result.menus.items += itemCount;
574
577
  }
@@ -920,11 +923,10 @@ async function applyContentTaxonomies(
920
923
  /**
921
924
  * Apply menu items recursively.
922
925
  *
923
- * Each item gets a fresh `translation_group` (= its own id). The seed format's
924
- * `SeedMenuItem` has no `id`/`translationOf` fields, so we can't express the
925
- * cross-locale "same nav entry" link here items diverge across locales on
926
- * re-apply. Runtime navigation still resolves correctly because `reference_id`
927
- * already holds the content's translation_group.
926
+ * When a `SeedMenuItem` carries `id`/`translationOf`, the import resolves the
927
+ * source item's `translation_group` so cross-locale "same nav entry" links
928
+ * survive export apply. Items without `translationOf` get a fresh group
929
+ * (= their own id).
928
930
  */
929
931
  async function applyMenuItems(
930
932
  db: Kysely<Database>,
@@ -934,12 +936,14 @@ async function applyMenuItems(
934
936
  parentId: string | null,
935
937
  startOrder: number,
936
938
  seedIdMap: Map<string, string>,
939
+ itemSeedIdMap: Map<string, { id: string; translationGroup: string }>,
937
940
  ): Promise<number> {
938
941
  let count = 0;
939
942
  let order = startOrder;
940
943
 
941
944
  for (const item of items) {
942
945
  const itemId = ulid();
946
+ const itemLocale = item.locale ?? locale;
943
947
 
944
948
  // Resolve reference if needed
945
949
  let referenceId: string | null = null;
@@ -955,6 +959,16 @@ async function applyMenuItems(
955
959
  // If not in map, the content might not exist yet (will be broken link)
956
960
  }
957
961
 
962
+ let translationGroup = itemId;
963
+ if (item.translationOf) {
964
+ const source = itemSeedIdMap.get(item.translationOf);
965
+ if (source) translationGroup = source.translationGroup;
966
+ else
967
+ console.warn(
968
+ `menu item "${item.label ?? item.url ?? item.ref ?? "(unlabeled)"}" (${itemLocale}): translationOf "${item.translationOf}" not found yet; minting a fresh group.`,
969
+ );
970
+ }
971
+
958
972
  await db
959
973
  .insertInto("_emdash_menu_items")
960
974
  .values({
@@ -971,11 +985,13 @@ async function applyMenuItems(
971
985
  target: item.target ?? null,
972
986
  css_classes: item.cssClasses ?? null,
973
987
  created_at: new Date().toISOString(),
974
- locale,
975
- translation_group: itemId,
988
+ locale: itemLocale,
989
+ translation_group: translationGroup,
976
990
  })
977
991
  .execute();
978
992
 
993
+ if (item.id) itemSeedIdMap.set(item.id, { id: itemId, translationGroup });
994
+
979
995
  count++;
980
996
  order++;
981
997
 
@@ -983,11 +999,12 @@ async function applyMenuItems(
983
999
  const childCount = await applyMenuItems(
984
1000
  db,
985
1001
  menuId,
986
- locale,
1002
+ itemLocale,
987
1003
  item.children,
988
1004
  itemId,
989
1005
  0,
990
1006
  seedIdMap,
1007
+ itemSeedIdMap,
991
1008
  );
992
1009
  count += childCount;
993
1010
  }
package/src/seed/types.ts CHANGED
@@ -134,6 +134,8 @@ export interface SeedMenu {
134
134
  * Menu item in seed
135
135
  */
136
136
  export interface SeedMenuItem {
137
+ /** Optional seed-local id, e.g. "item:primary:home:en". */
138
+ id?: string;
137
139
  type: string;
138
140
  label?: string;
139
141
  url?: string; // For custom type
@@ -142,6 +144,8 @@ export interface SeedMenuItem {
142
144
  target?: "_blank" | "_self";
143
145
  titleAttr?: string;
144
146
  cssClasses?: string;
147
+ locale?: string;
148
+ translationOf?: string;
145
149
  children?: SeedMenuItem[];
146
150
  }
147
151