emdash 0.7.0 → 0.8.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 (225) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
  3. package/dist/{apply-5uslYdUu.mjs → apply-x0eMK1lX.mjs} +18 -17
  4. package/dist/apply-x0eMK1lX.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +86 -15
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +22 -2
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.mjs +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +259 -71
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +16 -8
  20. package/dist/astro/types.d.mts.map +1 -1
  21. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  22. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  23. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  24. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  25. package/dist/cli/index.mjs +16 -12
  26. package/dist/cli/index.mjs.map +1 -1
  27. package/dist/client/cf-access.d.mts +1 -1
  28. package/dist/client/index.d.mts +1 -1
  29. package/dist/client/index.mjs +1 -1
  30. package/dist/{content-D7J5y73J.mjs → content-BcQPYxdV.mjs} +13 -15
  31. package/dist/content-BcQPYxdV.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/libsql.d.mts +1 -1
  34. package/dist/db/postgres.d.mts +1 -1
  35. package/dist/db/sqlite.d.mts +1 -1
  36. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  37. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  38. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  39. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  40. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  41. package/dist/error-zG5T1UGA.mjs.map +1 -0
  42. package/dist/{index-De6_Xv3v.d.mts → index-DIb-CzNx.d.mts} +157 -14
  43. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +22 -20
  46. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  47. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  48. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  49. package/dist/loader-CndGj8kM.mjs.map +1 -0
  50. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  51. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/local-runtime.d.mts +7 -7
  54. package/dist/media/local-runtime.mjs +2 -2
  55. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  56. package/dist/media-D8FbNsl0.mjs.map +1 -0
  57. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  58. package/dist/mode-BnAOqItE.mjs.map +1 -0
  59. package/dist/page/index.d.mts +2 -2
  60. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  61. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  62. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  63. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  64. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  65. package/dist/{query-g4Ug-9j9.mjs → query-fqEdLFms.mjs} +9 -9
  66. package/dist/{query-g4Ug-9j9.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  67. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-D_pshWdf.mjs} +4 -4
  68. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  69. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  70. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  71. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  72. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  73. package/dist/{runner-BR2xKwhn.d.mts → runner-OURCaApa.d.mts} +2 -2
  74. package/dist/{runner-BR2xKwhn.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  75. package/dist/runtime.d.mts +6 -6
  76. package/dist/runtime.mjs +2 -2
  77. package/dist/{search-B0effn3j.mjs → search-BoZYFuUk.mjs} +227 -84
  78. package/dist/search-BoZYFuUk.mjs.map +1 -0
  79. package/dist/seed/index.d.mts +2 -2
  80. package/dist/seed/index.mjs +12 -12
  81. package/dist/seo/index.d.mts +1 -1
  82. package/dist/storage/local.d.mts +1 -1
  83. package/dist/storage/local.mjs +1 -1
  84. package/dist/storage/s3.d.mts +1 -1
  85. package/dist/storage/s3.d.mts.map +1 -1
  86. package/dist/storage/s3.mjs +4 -4
  87. package/dist/storage/s3.mjs.map +1 -1
  88. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  89. package/dist/{taxonomies-K2z0Uhnj.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  90. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  91. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  92. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  93. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  94. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  95. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  96. package/dist/types-BIgulNsW.mjs +68 -0
  97. package/dist/types-BIgulNsW.mjs.map +1 -0
  98. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  99. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  100. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  101. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  102. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  103. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  104. package/dist/{types-C2v0c34j.d.mts → types-CS8FIX7L.d.mts} +1 -1
  105. package/dist/{types-C2v0c34j.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  106. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  107. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  108. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  109. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  110. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  111. package/dist/types-i36XcA_X.d.mts.map +1 -0
  112. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  113. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  114. package/dist/{validate-kM8Pjuf7.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  115. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  116. package/dist/validation-C-ZpN2GI.mjs +144 -0
  117. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  118. package/dist/version-Bbq8TCrz.mjs +7 -0
  119. package/dist/{version-BnTKdfam.mjs.map → version-Bbq8TCrz.mjs.map} +1 -1
  120. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  121. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  122. package/package.json +18 -5
  123. package/src/api/auth-storage.ts +37 -0
  124. package/src/api/error.ts +6 -0
  125. package/src/api/errors.ts +8 -0
  126. package/src/api/handlers/comments.ts +13 -0
  127. package/src/api/handlers/content.ts +122 -3
  128. package/src/api/handlers/index.ts +2 -0
  129. package/src/api/handlers/media.ts +8 -1
  130. package/src/api/handlers/menus.ts +160 -21
  131. package/src/api/handlers/redirects.ts +16 -3
  132. package/src/api/handlers/sections.ts +8 -1
  133. package/src/api/handlers/taxonomies.ts +128 -16
  134. package/src/api/handlers/validation.ts +212 -0
  135. package/src/api/openapi/document.ts +4 -1
  136. package/src/api/public-url.ts +6 -3
  137. package/src/api/route-utils.ts +14 -0
  138. package/src/api/schemas/common.ts +1 -1
  139. package/src/api/schemas/setup.ts +8 -0
  140. package/src/api/schemas/widgets.ts +12 -10
  141. package/src/api/setup-complete.ts +40 -0
  142. package/src/astro/integration/index.ts +13 -2
  143. package/src/astro/integration/routes.ts +28 -0
  144. package/src/astro/integration/runtime.ts +19 -1
  145. package/src/astro/integration/virtual-modules.ts +41 -0
  146. package/src/astro/integration/vite-config.ts +43 -12
  147. package/src/astro/middleware/auth.ts +21 -0
  148. package/src/astro/middleware.ts +18 -1
  149. package/src/astro/routes/PluginRegistry.tsx +10 -1
  150. package/src/astro/routes/api/auth/mode.ts +57 -0
  151. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  152. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  153. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  154. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  155. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  156. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  157. package/src/astro/routes/api/settings/email.ts +4 -9
  158. package/src/astro/routes/api/setup/admin.ts +8 -2
  159. package/src/astro/routes/api/setup/index.ts +2 -2
  160. package/src/astro/routes/api/setup/status.ts +3 -1
  161. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  162. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  163. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  164. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  165. package/src/astro/types.ts +9 -0
  166. package/src/auth/mode.ts +15 -3
  167. package/src/auth/providers/github-admin.tsx +29 -0
  168. package/src/auth/providers/github.ts +31 -0
  169. package/src/auth/providers/google-admin.tsx +44 -0
  170. package/src/auth/providers/google.ts +31 -0
  171. package/src/auth/types.ts +114 -4
  172. package/src/cli/commands/bundle.ts +3 -1
  173. package/src/components/EmDashImage.astro +7 -6
  174. package/src/components/Gallery.astro +5 -3
  175. package/src/components/Image.astro +8 -3
  176. package/src/components/InlinePortableTextEditor.tsx +2 -1
  177. package/src/components/LiveSearch.astro +5 -14
  178. package/src/database/repositories/audit.ts +6 -8
  179. package/src/database/repositories/byline.ts +6 -8
  180. package/src/database/repositories/comment.ts +12 -16
  181. package/src/database/repositories/content.ts +40 -40
  182. package/src/database/repositories/index.ts +1 -1
  183. package/src/database/repositories/media.ts +10 -13
  184. package/src/database/repositories/plugin-storage.ts +4 -6
  185. package/src/database/repositories/redirect.ts +12 -16
  186. package/src/database/repositories/taxonomy.ts +14 -3
  187. package/src/database/repositories/types.ts +57 -8
  188. package/src/database/repositories/user.ts +6 -8
  189. package/src/emdash-runtime.ts +306 -90
  190. package/src/index.ts +5 -1
  191. package/src/loader.ts +6 -5
  192. package/src/mcp/server.ts +678 -105
  193. package/src/media/normalize.ts +1 -1
  194. package/src/media/url.ts +78 -0
  195. package/src/plugins/email-console.ts +10 -3
  196. package/src/plugins/hooks.ts +11 -0
  197. package/src/plugins/manifest-schema.ts +12 -0
  198. package/src/plugins/types.ts +23 -2
  199. package/src/query.ts +1 -1
  200. package/src/request-cache.ts +3 -0
  201. package/src/schema/registry.ts +41 -5
  202. package/src/search/fts-manager.ts +0 -2
  203. package/src/search/query.ts +111 -26
  204. package/src/search/types.ts +8 -1
  205. package/src/sections/index.ts +7 -9
  206. package/src/storage/s3.ts +12 -6
  207. package/src/virtual-modules.d.ts +21 -1
  208. package/src/widgets/index.ts +1 -1
  209. package/dist/apply-5uslYdUu.mjs.map +0 -1
  210. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  211. package/dist/content-D7J5y73J.mjs.map +0 -1
  212. package/dist/error-CiYn9yDu.mjs.map +0 -1
  213. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  214. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  215. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  216. package/dist/media-DqHVh136.mjs.map +0 -1
  217. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  218. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  219. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  220. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  221. package/dist/search-B0effn3j.mjs.map +0 -1
  222. package/dist/types-CMMN0pNg.mjs +0 -31
  223. package/dist/types-CMMN0pNg.mjs.map +0 -1
  224. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  225. package/dist/version-BnTKdfam.mjs +0 -7
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { MediaProvider, MediaProviderItem, MediaValue } from "./types.js";
13
13
 
14
- const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
14
+ export const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
15
15
  const URL_PATTERN = /^https?:\/\//;
16
16
 
17
17
  /**
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Public media URL resolution.
3
+ *
4
+ * Used at render time by the Image components to decide whether a storage
5
+ * key should be served from the configured `publicUrl` (R2 custom domain,
6
+ * S3 CDN) or through the internal `/_emdash/api/media/file/{key}` route.
7
+ */
8
+ import type { Storage } from "../storage/types.js";
9
+ import { INTERNAL_MEDIA_PREFIX } from "./normalize.js";
10
+
11
+ // Keys accepted by the public-URL rewrite: the `{ulid}{ext}` shape produced by
12
+ // the upload pipeline, with letters, digits, dots, dashes, and underscores.
13
+ // Slashes, `?`, `#`, and `%` are rejected so attacker-controlled content in a
14
+ // portable-text `asset.url` cannot traverse or reroute on the CDN origin.
15
+ const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/;
16
+
17
+ /**
18
+ * Resolve the public URL for a locally stored media key. Returns an empty
19
+ * string when no key is given. When a storage adapter is supplied, defers to
20
+ * `storage.getPublicUrl()`; otherwise returns the internal proxy route.
21
+ */
22
+ export function resolvePublicMediaUrl(
23
+ storage: Storage | null | undefined,
24
+ storageKey: string,
25
+ ): string {
26
+ if (!storageKey) return "";
27
+ if (storage) return storage.getPublicUrl(storageKey);
28
+ return `/_emdash/api/media/file/${storageKey}`;
29
+ }
30
+
31
+ /**
32
+ * Build the `getPublicMediaUrl` closure attached to `Astro.locals.emdash`.
33
+ * Shared by the anonymous fast path and the full-runtime path in middleware.
34
+ *
35
+ * @internal
36
+ */
37
+ export function createPublicMediaUrlResolver(
38
+ storage: Storage | null | undefined,
39
+ ): (key: string) => string {
40
+ return (key) => resolvePublicMediaUrl(storage, key);
41
+ }
42
+
43
+ /** Input shape for {@link buildRenderMediaUrl}. */
44
+ export interface RenderMediaRef {
45
+ /** Storage key with extension (the canonical shape from the upload pipeline). */
46
+ storageKey?: string;
47
+ /** Pre-baked URL (either an internal proxy URL or an external URL). */
48
+ url?: string;
49
+ /** Bare media id (ULID without extension); only the internal proxy can look this up. */
50
+ id?: string;
51
+ }
52
+
53
+ /**
54
+ * Build a render-time media URL. Prefers `storageKey`, then rewrites an
55
+ * internal `url` via `resolve`, then falls back to the internal proxy for a
56
+ * bare `id`. External URLs and non-matching internal-looking URLs pass
57
+ * through untouched. Returns `""` when nothing usable is present.
58
+ *
59
+ * @internal
60
+ */
61
+ export function buildRenderMediaUrl(
62
+ resolve: ((key: string) => string) | undefined,
63
+ ref: RenderMediaRef,
64
+ ): string {
65
+ const { storageKey, url, id } = ref;
66
+ if (storageKey) {
67
+ return resolve ? resolve(storageKey) : `${INTERNAL_MEDIA_PREFIX}${storageKey}`;
68
+ }
69
+ if (url) {
70
+ if (resolve && url.startsWith(INTERNAL_MEDIA_PREFIX)) {
71
+ const key = url.slice(INTERNAL_MEDIA_PREFIX.length);
72
+ if (SAFE_STORAGE_KEY.test(key)) return resolve(key);
73
+ }
74
+ return url;
75
+ }
76
+ if (id) return `${INTERNAL_MEDIA_PREFIX}${id}`;
77
+ return "";
78
+ }
@@ -30,9 +30,16 @@ export interface StoredEmail {
30
30
  * instances (the runtime and the route handler may load separate copies
31
31
  * of this module, but globalThis is always the same object).
32
32
  */
33
- const GLOBAL_KEY = "__emdash_dev_emails__" as const;
34
- const storedEmails: StoredEmail[] = ((globalThis as Record<string, unknown>)[GLOBAL_KEY] ??=
35
- []) as StoredEmail[];
33
+ const GLOBAL_KEY = Symbol.for("emdash:dev-emails");
34
+ const g = globalThis as Record<symbol, unknown>;
35
+ const storedEmails: StoredEmail[] = (() => {
36
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern (see request-context.ts)
37
+ const existing = g[GLOBAL_KEY] as StoredEmail[] | undefined;
38
+ if (existing) return existing;
39
+ const fresh: StoredEmail[] = [];
40
+ g[GLOBAL_KEY] = fresh;
41
+ return fresh;
42
+ })();
36
43
 
37
44
  /**
38
45
  * Get all stored dev emails (most recent first).
@@ -1218,6 +1218,17 @@ export class HookPipeline {
1218
1218
  return hooks.filter((h) => h.exclusive).map((h) => ({ pluginId: h.pluginId }));
1219
1219
  }
1220
1220
 
1221
+ /**
1222
+ * Get all plugins that registered a non-exclusive handler for a given
1223
+ * hook (e.g. `email:beforeSend`, `email:afterSend`), preserving priority
1224
+ * order. Partitions with `getExclusiveHookProviders()`, which returns
1225
+ * plugins whose registration is marked `exclusive: true`.
1226
+ */
1227
+ getHookProviders(hookName: string): Array<{ pluginId: string }> {
1228
+ const hooks = this.hooks.get(hookName as HookNameV2) ?? [];
1229
+ return hooks.filter((h) => !h.exclusive).map((h) => ({ pluginId: h.pluginId }));
1230
+ }
1231
+
1221
1232
  /**
1222
1233
  * Invoke an exclusive hook — dispatch only to the selected provider.
1223
1234
  * Returns null if no provider is selected or if the selected hook
@@ -131,6 +131,18 @@ const settingFieldSchema = z.discriminatedUnion("type", [
131
131
  default: z.string().optional(),
132
132
  }),
133
133
  z.object({ ...baseSettingFields, type: z.literal("secret") }),
134
+ z.object({
135
+ ...baseSettingFields,
136
+ type: z.literal("url"),
137
+ default: z.string().optional(),
138
+ placeholder: z.string().optional(),
139
+ }),
140
+ z.object({
141
+ ...baseSettingFields,
142
+ type: z.literal("email"),
143
+ default: z.string().optional(),
144
+ placeholder: z.string().optional(),
145
+ }),
134
146
  ]);
135
147
 
136
148
  const adminPageSchema = z.object({
@@ -1114,7 +1114,14 @@ export interface PluginDashboardWidget {
1114
1114
  /**
1115
1115
  * Settings field types (for admin UI generation)
1116
1116
  */
1117
- export type SettingFieldType = "string" | "number" | "boolean" | "select" | "secret";
1117
+ export type SettingFieldType =
1118
+ | "string"
1119
+ | "number"
1120
+ | "boolean"
1121
+ | "select"
1122
+ | "secret"
1123
+ | "url"
1124
+ | "email";
1118
1125
 
1119
1126
  export interface BaseSettingField {
1120
1127
  type: SettingFieldType;
@@ -1150,12 +1157,26 @@ export interface SecretSettingField extends BaseSettingField {
1150
1157
  type: "secret";
1151
1158
  }
1152
1159
 
1160
+ export interface UrlSettingField extends BaseSettingField {
1161
+ type: "url";
1162
+ default?: string;
1163
+ placeholder?: string;
1164
+ }
1165
+
1166
+ export interface EmailSettingField extends BaseSettingField {
1167
+ type: "email";
1168
+ default?: string;
1169
+ placeholder?: string;
1170
+ }
1171
+
1153
1172
  export type SettingField =
1154
1173
  | StringSettingField
1155
1174
  | NumberSettingField
1156
1175
  | BooleanSettingField
1157
1176
  | SelectSettingField
1158
- | SecretSettingField;
1177
+ | SecretSettingField
1178
+ | UrlSettingField
1179
+ | EmailSettingField;
1159
1180
 
1160
1181
  /**
1161
1182
  * Block Kit element for block editing fields.
package/src/query.ts CHANGED
@@ -478,7 +478,7 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
478
478
  // Edit mode (authenticated editors) has collection-wide draft access.
479
479
  if (isPreviewMode && !isEditMode) {
480
480
  const dbId = entryDatabaseId(baseEntry);
481
- if (preview!.id !== dbId && preview!.id !== id) {
481
+ if (preview.id !== dbId && preview.id !== id) {
482
482
  // Token doesn't match — serve only if publicly visible, without draft access
483
483
  if (isVisible(baseEntry)) {
484
484
  return successResult(wrapEntry(baseEntry), {
@@ -22,6 +22,7 @@ type CacheStore = WeakMap<EmDashRequestContext, Map<string, Promise<unknown>>>;
22
22
  const STORE_KEY = Symbol.for("emdash:request-cache");
23
23
  const g = globalThis as Record<symbol, unknown>;
24
24
  const store: CacheStore =
25
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern (see request-context.ts)
25
26
  (g[STORE_KEY] as CacheStore | undefined) ??
26
27
  (() => {
27
28
  const wm: CacheStore = new WeakMap();
@@ -47,6 +48,7 @@ export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T>
47
48
  }
48
49
 
49
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
50
52
  if (existing) return existing as Promise<T>;
51
53
 
52
54
  const promise = Promise.resolve()
@@ -74,6 +76,7 @@ export function peekRequestCache<T>(key: string): Promise<T> | undefined {
74
76
  const ctx = getRequestContext();
75
77
  if (!ctx) return undefined;
76
78
  const cache = store.get(ctx);
79
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- heterogeneous cache; caller is responsible for using a T-compatible key
77
80
  return cache?.get(key) as Promise<T> | undefined;
78
81
  }
79
82
 
@@ -11,6 +11,7 @@ import { FTSManager } from "../search/fts-manager.js";
11
11
  import {
12
12
  type Collection,
13
13
  type CollectionSource,
14
+ type CollectionSupport,
14
15
  type ColumnType,
15
16
  type Field,
16
17
  type CreateCollectionInput,
@@ -49,6 +50,34 @@ function isColumnType(value: string): value is ColumnType {
49
50
  return COLUMN_TYPES.has(value);
50
51
  }
51
52
 
53
+ const VALID_COLLECTION_SUPPORTS: ReadonlySet<string> = new Set<CollectionSupport>([
54
+ "drafts",
55
+ "revisions",
56
+ "preview",
57
+ "scheduling",
58
+ "search",
59
+ "seo",
60
+ ]);
61
+
62
+ function isCollectionSupport(value: unknown): value is CollectionSupport {
63
+ return typeof value === "string" && VALID_COLLECTION_SUPPORTS.has(value);
64
+ }
65
+
66
+ /**
67
+ * Parse a collection's `supports` column (stored as a JSON array of
68
+ * CollectionSupport keys). Unknown/invalid entries are filtered out so the
69
+ * runtime value matches the declared `CollectionSupport[]` type.
70
+ *
71
+ * Throws on malformed JSON so corruption surfaces loudly; returns an empty
72
+ * array only for explicitly null/empty values or non-array JSON.
73
+ */
74
+ function parseSupports(raw: string | null | undefined): CollectionSupport[] {
75
+ if (!raw) return [];
76
+ const parsed: unknown = JSON.parse(raw);
77
+ if (!Array.isArray(parsed)) return [];
78
+ return parsed.filter(isCollectionSupport);
79
+ }
80
+
52
81
  /**
53
82
  * Error thrown when a schema operation fails
54
83
  */
@@ -132,11 +161,18 @@ export class SchemaRegistry {
132
161
 
133
162
  const id = ulid();
134
163
 
164
+ // Default `supports` to drafts + revisions when the caller didn't
165
+ // specify it. Explicit empty array (`[]`) is preserved as an opt-out
166
+ // — only `undefined` triggers the default. This is the canonical
167
+ // default for new collections; the MCP and admin UI layers used to
168
+ // duplicate this default but now defer to the registry.
169
+ const supports = input.supports ?? ["drafts", "revisions"];
170
+
135
171
  // Insert collection record and create content table in a transaction
136
172
  // so a failure in table creation doesn't leave an orphaned row.
137
173
  // Uses withTransaction for D1 compatibility (no transaction support).
138
174
  // Derive hasSeo from supports array if not explicitly set
139
- const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false;
175
+ const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false;
140
176
 
141
177
  await withTransaction(this.db, async (trx) => {
142
178
  await trx
@@ -148,7 +184,7 @@ export class SchemaRegistry {
148
184
  label_singular: input.labelSingular ?? null,
149
185
  description: input.description ?? null,
150
186
  icon: input.icon ?? null,
151
- supports: input.supports ? JSON.stringify(input.supports) : null,
187
+ supports: JSON.stringify(supports),
152
188
  source: input.source ?? "manual",
153
189
  has_seo: hasSeo ? 1 : 0,
154
190
  comments_enabled: input.commentsEnabled ? 1 : 0,
@@ -243,7 +279,7 @@ export class SchemaRegistry {
243
279
  // Sync FTS state when the supports array changes (e.g. search toggled on/off)
244
280
  if (input.supports !== undefined) {
245
281
  const hadSearch = existing.supports.includes("search");
246
- const hasSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
282
+ const hasSearch = parseSupports(row.supports).includes("search");
247
283
  if (hadSearch !== hasSearch) {
248
284
  await this.syncSearchState(slug, trx);
249
285
  }
@@ -525,7 +561,7 @@ export class SchemaRegistry {
525
561
  .executeTakeFirst();
526
562
  if (!row) return;
527
563
 
528
- const wantsSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
564
+ const wantsSearch = parseSupports(row.supports).includes("search");
529
565
  const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
530
566
  const config = await ftsManager.getSearchConfig(collectionSlug);
531
567
  const ftsActive = config?.enabled === true;
@@ -881,7 +917,7 @@ export class SchemaRegistry {
881
917
  labelSingular: row.label_singular ?? undefined,
882
918
  description: row.description ?? undefined,
883
919
  icon: row.icon ?? undefined,
884
- supports: row.supports ? JSON.parse(row.supports) : [],
920
+ supports: parseSupports(row.supports),
885
921
  source: row.source && isCollectionSource(row.source) ? row.source : undefined,
886
922
  hasSeo: row.has_seo === 1,
887
923
  urlPattern: row.url_pattern ?? undefined,
@@ -418,8 +418,6 @@ export class FTSManager {
418
418
  console.warn(
419
419
  `FTS index for "${collectionSlug}" has ${ftsRows} rows but content table has ${contentRows}. Rebuilding.`,
420
420
  );
421
- const fields = await this.getSearchableFields(collectionSlug);
422
- const config = await this.getSearchConfig(collectionSlug);
423
421
  if (fields.length > 0) {
424
422
  await this.rebuildIndex(collectionSlug, fields, config?.weights);
425
423
  }
@@ -26,6 +26,23 @@ const WHITESPACE_SPLIT_PATTERN = /\s+/;
26
26
  const FTS_OPERATORS_PATTERN = /\b(AND|OR|NOT|NEAR)\b/i;
27
27
  const DOUBLE_QUOTE_PATTERN = /"/g;
28
28
 
29
+ /**
30
+ * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5
31
+ * error fingerprints rather than a broad "fts5" / "syntax error" filter
32
+ * (which would also swallow internal table-corruption errors). The two
33
+ * fingerprints we care about are:
34
+ *
35
+ * - "fts5: syntax error near …" — unbalanced quotes, stray operators,
36
+ * other malformed user input
37
+ * - "unknown special query: …" — bare special tokens like `^*` that
38
+ * parse but don't resolve to a real FTS5 directive
39
+ */
40
+ function isFts5SyntaxError(error: unknown): boolean {
41
+ if (!(error instanceof Error)) return false;
42
+ const message = error.message.toLowerCase();
43
+ return message.includes("fts5: syntax error") || message.includes("unknown special query");
44
+ }
45
+
29
46
  /**
30
47
  * Search across multiple collections
31
48
  *
@@ -198,14 +215,16 @@ async function searchSingleCollection(
198
215
  const bm25Expr = bm25Args ? `bm25("${ftsTable}", ${bm25Args})` : `bm25("${ftsTable}")`;
199
216
 
200
217
  // Snippet column index is 2 (after id=0, locale=1, first searchable field=2)
201
- const results = await sql<{
202
- id: string;
203
- slug: string | null;
204
- locale: string;
205
- title: string | null;
206
- snippet: string;
207
- score: number;
208
- }>`
218
+ let results;
219
+ try {
220
+ results = await sql<{
221
+ id: string;
222
+ slug: string | null;
223
+ locale: string;
224
+ title: string | null;
225
+ snippet: string | null;
226
+ score: number;
227
+ }>`
209
228
  SELECT
210
229
  c.id,
211
230
  c.slug,
@@ -222,6 +241,20 @@ async function searchSingleCollection(
222
241
  ORDER BY score
223
242
  LIMIT ${limit}
224
243
  `.execute(db);
244
+ } catch (error) {
245
+ // FTS5 returns syntax errors for queries with unbalanced quotes,
246
+ // stray operators, or other malformed input. Treat these as
247
+ // "no matches" so the user gets an empty result rather than an
248
+ // internals-leaking error. Other errors (table missing, IO) still
249
+ // propagate. Intentionally not logged: any anonymous client can
250
+ // trigger this path, and the underlying error message embeds the
251
+ // raw query, so logging would be both noisy and a log-injection
252
+ // vector.
253
+ if (isFts5SyntaxError(error)) {
254
+ return [];
255
+ }
256
+ throw error;
257
+ }
225
258
 
226
259
  return results.rows.map((row) => ({
227
260
  collection,
@@ -229,11 +262,51 @@ async function searchSingleCollection(
229
262
  slug: row.slug,
230
263
  locale: row.locale,
231
264
  title: row.title ?? undefined,
232
- snippet: row.snippet,
265
+ // SQLite's snippet() returns NULL when the targeted column is
266
+ // NULL for that row — even if the row matched via a different
267
+ // searchable column. Skip sanitization in that case so we don't
268
+ // throw on `null.replace`. The SearchResult.snippet field is
269
+ // already optional, so omitting it is the documented contract.
270
+ snippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),
233
271
  score: Math.abs(row.score), // bm25 returns negative scores
234
272
  }));
235
273
  }
236
274
 
275
+ // Module-scope regexes so the engine doesn't recompile per call —
276
+ // snippet sanitization runs on every search result.
277
+ const SNIPPET_AMP_RE = /&/g;
278
+ const SNIPPET_LT_RE = /</g;
279
+ const SNIPPET_GT_RE = />/g;
280
+ const SNIPPET_QUOT_RE = /"/g;
281
+ const SNIPPET_APOS_RE = /'/g;
282
+
283
+ /**
284
+ * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.
285
+ *
286
+ * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`
287
+ * markers around matched terms but does not escape the surrounding
288
+ * source text. Posts that legitimately contain `<`, `>`, `&`, `"` or
289
+ * `'` would render as broken markup, and a `<script>` literal in a
290
+ * title (or any other indexed field) would execute when displayed.
291
+ *
292
+ * The fix: HTML-escape the whole string, which turns the markers into
293
+ * `&lt;mark&gt;` / `&lt;/mark&gt;`. Then restore those two patterns to
294
+ * their original tag form. The result is "the indexed text with all
295
+ * HTML metacharacters escaped, plus a small set of literal `<mark>`
296
+ * highlight tags around matched terms" — which matches the API's
297
+ * documented contract.
298
+ */
299
+ function sanitizeSnippet(snippet: string): string {
300
+ return snippet
301
+ .replace(SNIPPET_AMP_RE, "&amp;")
302
+ .replace(SNIPPET_LT_RE, "&lt;")
303
+ .replace(SNIPPET_GT_RE, "&gt;")
304
+ .replace(SNIPPET_QUOT_RE, "&quot;")
305
+ .replace(SNIPPET_APOS_RE, "&#39;")
306
+ .replaceAll("&lt;mark&gt;", "<mark>")
307
+ .replaceAll("&lt;/mark&gt;", "</mark>");
308
+ }
309
+
237
310
  /**
238
311
  * Get search suggestions for autocomplete
239
312
  *
@@ -282,23 +355,35 @@ export async function getSuggestions(
282
355
  continue;
283
356
  }
284
357
 
285
- const results = await sql<{
286
- id: string;
287
- title: string;
288
- }>`
289
- SELECT
290
- c.id,
291
- c.title
292
- FROM "${sql.raw(ftsTable)}" f
293
- JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
294
- WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
295
- AND c.status = 'published'
296
- AND c.deleted_at IS NULL
297
- AND c.title IS NOT NULL
298
- ${locale ? sql`AND c.locale = ${locale}` : sql``}
299
- ORDER BY bm25("${sql.raw(ftsTable)}")
300
- LIMIT ${limit}
301
- `.execute(db);
358
+ let results;
359
+ try {
360
+ results = await sql<{
361
+ id: string;
362
+ title: string;
363
+ }>`
364
+ SELECT
365
+ c.id,
366
+ c.title
367
+ FROM "${sql.raw(ftsTable)}" f
368
+ JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
369
+ WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
370
+ AND c.status = 'published'
371
+ AND c.deleted_at IS NULL
372
+ AND c.title IS NOT NULL
373
+ ${locale ? sql`AND c.locale = ${locale}` : sql``}
374
+ ORDER BY bm25("${sql.raw(ftsTable)}")
375
+ LIMIT ${limit}
376
+ `.execute(db);
377
+ } catch (error) {
378
+ // Same swallow as searchSingleCollection: malformed prefix
379
+ // queries should yield no suggestions, not surface DB errors.
380
+ // Intentionally not logged (anonymous-triggerable, echoes
381
+ // user input -- see searchSingleCollection for rationale).
382
+ if (isFts5SyntaxError(error)) {
383
+ continue;
384
+ }
385
+ throw error;
386
+ }
302
387
 
303
388
  for (const row of results.rows) {
304
389
  suggestions.push({
@@ -58,7 +58,14 @@ export interface SearchResult {
58
58
  locale: string;
59
59
  /** Entry title (if available) */
60
60
  title?: string;
61
- /** Highlighted snippet showing match context */
61
+ /**
62
+ * Highlighted snippet showing match context.
63
+ *
64
+ * Sanitized server-side to be safe for `set:html` / `innerHTML`:
65
+ * all HTML metacharacters in the source text are escaped, and
66
+ * matched terms are wrapped in literal `<mark>...</mark>` tags
67
+ * (the only HTML the snippet is allowed to contain).
68
+ */
62
69
  snippet?: string;
63
70
  /** Relevance score (higher = more relevant) */
64
71
  score: number;
@@ -137,17 +137,15 @@ export async function getSectionsWithDb(
137
137
  // Order by title ASC, id ASC for stable cursor pagination
138
138
  query = query.orderBy("title", "asc").orderBy("id", "asc");
139
139
 
140
- // Cursor-based pagination
140
+ // Cursor-based pagination — throws on invalid cursor.
141
141
  if (options.cursor) {
142
142
  const decoded = decodeCursor(options.cursor);
143
- if (decoded) {
144
- query = query.where((eb) =>
145
- eb.or([
146
- eb("title", ">", decoded.orderValue),
147
- eb.and([eb("title", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
148
- ]),
149
- );
150
- }
143
+ query = query.where((eb) =>
144
+ eb.or([
145
+ eb("title", ">", decoded.orderValue),
146
+ eb.and([eb("title", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
147
+ ]),
148
+ );
151
149
  }
152
150
 
153
151
  query = query.limit(limit + 1);
package/src/storage/s3.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  S3Client,
10
+ type S3ClientConfig,
10
11
  PutObjectCommand,
11
12
  GetObjectCommand,
12
13
  DeleteObjectCommand,
@@ -131,9 +132,14 @@ export class S3Storage implements Storage {
131
132
  this.publicUrl = config.publicUrl;
132
133
  this.endpoint = config.endpoint;
133
134
 
134
- this.client = new S3Client({
135
+ // S3ClientConfig types `credentials` as required, but the SDK accepts
136
+ // omitted credentials at runtime (falls back to the provider chain).
137
+ /* eslint-disable typescript-eslint(no-unsafe-type-assertion) -- upstream @aws-sdk/client-s3 overstates required fields */
138
+ const clientConfig = {
135
139
  endpoint: config.endpoint,
136
140
  region: config.region || "auto",
141
+ // Required for R2 and some S3-compatible services
142
+ forcePathStyle: true,
137
143
  ...(config.accessKeyId && config.secretAccessKey
138
144
  ? {
139
145
  credentials: {
@@ -142,9 +148,9 @@ export class S3Storage implements Storage {
142
148
  },
143
149
  }
144
150
  : {}),
145
- // Required for R2 and some S3-compatible services
146
- forcePathStyle: true,
147
- } as ConstructorParameters<typeof S3Client>[0]);
151
+ } as S3ClientConfig;
152
+ /* eslint-enable typescript-eslint(no-unsafe-type-assertion) */
153
+ this.client = new S3Client(clientConfig);
148
154
  }
149
155
 
150
156
  async upload(options: {
@@ -317,8 +323,8 @@ export class S3Storage implements Storage {
317
323
  if (this.publicUrl) {
318
324
  return `${this.publicUrl.replace(TRAILING_SLASH_PATTERN, "")}/${key}`;
319
325
  }
320
- // Default to endpoint + bucket + key
321
- return `${this.endpoint.replace(TRAILING_SLASH_PATTERN, "")}/${this.bucket}/${key}`;
326
+ // No public URL configured; defer to the /_emdash/api/media/file route.
327
+ return `/_emdash/api/media/file/${key}`;
322
328
  }
323
329
  }
324
330
 
@@ -7,12 +7,18 @@
7
7
 
8
8
  declare module "virtual:emdash/config" {
9
9
  import type { I18nConfig } from "./i18n/config.js";
10
- import type { DatabaseDescriptor, StorageDescriptor, AuthDescriptor } from "./index.js";
10
+ import type {
11
+ AuthDescriptor,
12
+ AuthProviderDescriptor,
13
+ DatabaseDescriptor,
14
+ StorageDescriptor,
15
+ } from "./index.js";
11
16
 
12
17
  interface VirtualConfig {
13
18
  database?: DatabaseDescriptor;
14
19
  storage?: StorageDescriptor;
15
20
  auth?: AuthDescriptor;
21
+ authProviders?: AuthProviderDescriptor[];
16
22
  i18n?: I18nConfig | null;
17
23
  }
18
24
 
@@ -103,6 +109,20 @@ declare module "virtual:emdash/block-components" {
103
109
  export const pluginBlockComponents: Record<string, unknown>;
104
110
  }
105
111
 
112
+ declare module "virtual:emdash/auth-providers" {
113
+ import type { ComponentType } from "react";
114
+
115
+ interface AuthProviderEntry {
116
+ id: string;
117
+ label: string;
118
+ LoginButton?: ComponentType;
119
+ LoginForm?: ComponentType;
120
+ SetupStep?: ComponentType<{ onComplete: () => void }>;
121
+ }
122
+
123
+ export const authProviders: Record<string, AuthProviderEntry>;
124
+ }
125
+
106
126
  declare module "virtual:emdash/wait-until" {
107
127
  /**
108
128
  * Optional host-provided lifetime extender for work deferred past the
@@ -125,7 +125,7 @@ export function getWidgetComponents(): WidgetComponentDef[] {
125
125
  /**
126
126
  * Convert a widget row to the API type
127
127
  */
128
- function rowToWidget(row: WidgetRow): Widget {
128
+ export function rowToWidget(row: WidgetRow): Widget {
129
129
  const widget: Widget = {
130
130
  id: row.id,
131
131
  type: row.type,