Minor Changes
-
#884
e2b3c6cThanks @ascorbic! - Removes the worker-isolate manifest cache and stops loading the manifest on public requests.The admin manifest (collection schemas, plugins, taxonomies) is built fresh from the live database on every admin request via constant-shape queries (
SchemaRegistry.listCollectionsWithFields()— one collection query plus one batched field query, chunked at the D1 bound-parameter limit; two queries in practice for typical sites), deduplicated within a single request byrequestCached. Logged-out / public requests no longer touch it at all — the global middleware no longer pre-loadslocals.emdashManifest. Admin routes that need it callawait emdash.getManifest().This closes the cross-isolate staleness bug class behind #776, #873, #876, and #877 by elimination: there is no cache to invalidate, so there is nothing to fan out across warm sibling isolates on Cloudflare Workers, and there is nothing to leave stale after a fire-and-forget delete is cancelled at response-time.
Breaking changes
locals.emdash.invalidateManifestis removed. The shim that survived earlier was a misnomer once the manifest cache itself was gone. Plugin code that called this after schema changes should switch tolocals.emdash.invalidateUrlPatternCache(the only side effect that survived) — or drop the call entirely if the mutation didn't affect collection URL patterns (field/taxonomy/plugin mutations don't).locals.emdashManifestis removed. Read it viaawait locals.emdash.getManifest()instead. The only in-tree consumers were the admin manifest endpoint and the WordPress importer routes, both updated.EmDashRuntime.invalidateManifest()is removed.EmDashRuntime.getManifest()is preserved with the same signature; its body now skips the cache layer.
Performance
The admin manifest build is now O(1) query shapes (one for collections, one batched query for the fields of every returned collection, chunked at the D1 bound-parameter limit) instead of N+1. This is the cost the cache was hiding; the rebuild is cheap enough to run per request.
-
#731
9dfc65cThanks @drudge! - Adds amedia_pickerBlock Kit element: a thumbnail preview with a modal library picker and mime-type filter. Usable in plugin block forms and in Block Kit field widgets. The stored value is the selected asset's URL string, so it is value-compatible with a plaintext_input— existing content continues to work after swapping. Themime_type_filteris restricted to image MIME types (image/orimage/<subtype>); wildcards and non-image types are rejected. -
#809
e7df21fThanks @ascorbic! - Adds an optionalcategoryfield toPortableTextBlockConfigfor plugin-contributed block types. Plugins can now choose how their blocks are grouped in the admin slash menu (e.g. "Sections", "Marketing", "Media", "Layout") instead of always falling under "Embeds". Existing plugins that omit the field continue to render under "Embeds" exactly as before. -
#890
8ae227cThanks @ascorbic! - AddspublishedAttocontent_publish(MCP and REST) and exposesseo,bylines, andpublishedAton the MCPcontent_updatetool.content_publishnow accepts an optional ISO 8601publishedAtto backdate a publish, which is useful when migrating content from another CMS or correcting a historical publish date. The override requires thecontent:publish_anypermission. Without it, the existingpublished_atis preserved on re-publish (idempotent) and falls back to the current time on first publish.The MCP
content_updatetool previously droppedseo,bylines, andpublishedAteven though the underlying handler accepted them. Callers had to fall back to raw SQL against_emdash_seoand_emdash_content_bylinesto set these fields. They now flow through the MCP tool and are persisted in the same transaction as field updates. SettingpublishedAtrequirescontent:publish_any, mirroring the REST PUT route. Closes #621 and #622. -
#800
e2d5d16Thanks @csfalcao! - Adds support for accepting passkey assertions from multiple origins that share anrpId, for deployments reachable under several hostnames (apex + preview/staging) under one registrable parent. Declare additional origins viaEmDashConfig.allowedOrigins(inastro.config.mjs) or theEMDASH_ALLOWED_ORIGINSenv var (comma-separated); the two sources merge at runtime. EmDash validates the merged set againstsiteUrland rejects dead config (non-subdomain entries, IP-literalsiteUrl, trailing dots, empty labels) with source-attributed errors.PasskeyConfig.origin: stringis replaced byPasskeyConfig.origins: string[]. -
#837
e81aa0fThanks @netogregorio! - Make the preview URL pattern locale-aware.getPreviewUrl()now accepts a{locale}placeholder and alocaleoption (empty string collapses adjacent slashes so default-locale entries onprefixDefaultLocale: falsesites stay unprefixed). ThePOST /_emdash/api/content/{collection}/{id}/preview-urlroute resolves the locale automatically from the entry and the site's i18n config, and reads a project-wide default pattern from the newEMDASH_PREVIEW_PATH_PATTERNenv var so the admin's "View on site" link can match locale-prefixed routes (e.g./{locale}/{id}). -
#811
cee403dThanks @ascorbic! - Adds a centralized secrets module andemdash secretsCLI command group.
The preview HMAC secret and commenter-IP hash salt are now generated and
persisted in the options table on first need, withEMDASH_PREVIEW_SECRET
andEMDASH_IP_SALTas optional env overrides. This replaces the previous
empty-string preview fallback (which silently disabled token verification)
and the hardcoded"emdash-ip-salt"constant (which was correlatable
across installs).Adds:
emdash secrets generate [--write <file> [--force]]— emits a fresh
EMDASH_ENCRYPTION_KEY(versionedemdash_enc_v1_<43 chars>format),
optionally writes it to.dev.varsor.envidempotently.emdash secrets fingerprint <key>— prints the kid (8-char fingerprint)
for a key without exposing its value.
Lays groundwork for plugin-secret encryption-at-rest in a follow-up.
Deprecates:
emdash auth secret— kept as a working alias that prints a stderr
deprecation note. Will be removed in a future minor.EMDASH_AUTH_SECRET
itself is now legacy: it's only consulted as a fallback IP-salt source
for upgrade compatibility (so existing installs keep stable
commenter-IP hashes). New installs don't need to set it.
API changes:
fingerprintKey()(exported fromemdash's config module) now
validates its input and throwsEmDashSecretsErrorfor malformed or
non-canonical keys, where it previously silently hashed any string.
Callers that want the previous "fingerprint anything" behavior should
hash the input themselves withcrypto.subtle.digest.
User-visible side effects on upgrade:
- Installs that hadn't set
EMDASH_PREVIEW_SECRETget a fresh random
preview secret on first start, which invalidates any outstanding
preview URLs (typically short-lived). - Installs that hadn't set
EMDASH_AUTH_SECRETget a fresh random IP
salt, resetting active comment rate-limit windows once. - Installs that did set
EMDASH_AUTH_SECRETkeep the same IP salt via a
legacy fallback, so existing rate-limit data carries over. - If you sign preview URLs from a separate process without access to the
EmDash database (e.g. a remote preview Worker), you must continue to
setEMDASH_PREVIEW_SECRETin both processes. Processes that share
the database converge on the same auto-generated value automatically;
the env override is only needed when the verifying process can't read
the options table.
-
#816
d4be24fThanks @ask-bonk! - Unifies plugin capability names under a single<resource>[.<sub-resource>]:<verb>[:<qualifier>]formula so capabilities read like RBAC permissions, separates hook-registration permissions from data-access ones for clearer audits, and replaces the overloaded:anyqualifier with the more conspicuous:unrestricted. Old names are still accepted with@deprecatedwarnings;emdash plugin bundleandemdash plugin validatewarn for each deprecated name andemdash plugin publishrefuses manifests that still use them.The Cloudflare sandbox bridge and HTTP fetch helper now enforce canonical names (
content:read,content:write,media:read,media:write,users:read,network:request,network:request:unrestricted). Manifests that still declare legacy names continue to work — the runner normalizes capabilities before passing them into the bridge, so installed plugins withread:contentresolve tocontent:readand reach the same code path.Old New read:contentcontent:readwrite:contentcontent:writeread:mediamedia:readwrite:mediamedia:writeread:usersusers:readnetwork:fetchnetwork:requestnetwork:fetch:anynetwork:request:unrestrictedemail:providehooks.email-transport:registeremail:intercepthooks.email-events:registerpage:injecthooks.page-fragments:registerExisting installs keep working — manifests are normalized at every external boundary and
diffCapabilitiesnormalizes both sides so version upgrades that only rename do not trigger a "capability changed" prompt. Deprecated names will be removed in the next minor.
Patch Changes
-
#858
e0dc6fbThanks @ask-bonk! - Adds CSS custom-property hooks to portable-text block defaults inImage,Embed,Gallery, andBreakso host sites can theme figcaptions and horizontal rules without overriding component CSS. Resolution order is--emdash-caption-color→--color-muted→#666for captions,--emdash-break-color→--color-border→#e0e0e0for the break line, and--emdash-break-dots-color→--color-muted→#999for break dots. Backward compatible: sites that don't define any of these variables get the previous hex defaults; sites that already expose the conventional--color-muted/--color-bordertokens (e.g. the blog template) now get correct dark-mode theming automatically. -
#838
c22fb3aThanks @ascorbic! - Removes a redundantSELECT id, author_idlookup that fired after every collection-list and entry fetch when computing the byline-fallback for entries without explicit credits. The column is already on the row data, so it is now read directly. Saves up to one round-trip per list query and two on post-detail routes (~30 fewer queries across the perf-fixture suite). -
#805
6a4e9b8Thanks @ascorbic! - Fixes data loss in the visual-editing inline editor for plugin-contributed Portable Text block types. Previously, custom blocks likemarketing.herolost every field exceptidwhen the page was opened in edit mode, and the next save persisted the loss. Blocks now round-trip losslessly and render as a read-only placeholder labelled with the block type. -
#702
0ee372aThanks @ilicfilip! - Adds@emdash-cms/plugin-field-kit— composable field widgets forjsonfields. Four widgets (object-form,list,grid,tags) are configured entirely through seedoptionsso site builders don't need to write React to get a usable editing UI. Widgets store clean JSON (no nesting, no mutation of shape), so removing the plugin leaves valid data in the database. See discussion #571 for background.Widens
FieldDescriptor.optionstoArray<{ value: string; label: string }> | Record<string, unknown>so plugin widgets can accept arbitrary widget config (not only enum choices). The array shape forselect/multiSelectcontinues to work unchanged. -
#861
22a16eeThanks @ask-bonk! - Fixes "Cannot find module 'kysely'" at runtime afterastro buildfollowed byastro previewornode dist/server/entry.mjson Node deployments using SQLite or libSQL (#741). The SQLite and libSQL dialect runtime modules used CJSrequire("kysely")andrequire("better-sqlite3"), ostensibly to defer loading at config time — though in practice these modules are only ever loaded at runtime viavirtual:emdash/dialect, so the deferral served no purpose. Vite preserved those literalrequire()calls in the bundled SSR chunks; under pnpm's strictnode_moduleslayout, Node's CJS resolver could not findkysely(a transitive dep ofemdash) from the user'sdist/server/chunks/directory. The dialect modules now use static imports — matching the existingdb/postgres.tsadapter — so Vite resolves the deps correctly at build time. -
#847
1e2b024Thanks @ascorbic! - Fixes site favicon injection so user-configured favicons render on the public site, including SVG favicons in Chromium browsers (#831).EmDashHeadnow emits a<link rel="icon">tag with the correcttypeattribute (e.g.image/svg+xml) sourced from the stored media's MIME type. The bundled templates and demos have been updated to drop their per-template favicon link in favour of the centralized injection; existing user sites that still emit their own<link rel="icon">continue to work because browsers tolerate the duplicate.MediaReferencenow carriesurl,contentType,width, andheightwhen resolved viaresolveMediaReference, so callers can emit correct head tags without a second round-trip to the media table. -
#851
81662e9Thanks @ask-bonk! - Fixes admin branding (logo, siteName, favicon) configured via the integration'sadminoption not being delivered to the React admin SPA. The/_emdash/api/manifestroute now reads admin branding from the per-request config plumbed through middleware (the same sourceadmin.astroalready used), instead of a build-time global that was never assigned. -
#857
2f22f57Thanks @ask-bonk! - Fixes a migration race on D1 where two concurrent Workers isolates could both try to apply the same migration, causing one to fail withUNIQUE constraint failed: _emdash_migrations.name. The losing isolate would throw before reaching auto-seed, leaving the manifest cache empty and the admin UI reporting collections as not found while the API reported them correctly.runMigrationsnow treats this specific error as benign, waits for the concurrent migrator to finish, and verifies the schema is fully migrated before returning success. Closes #762. -
#856
ef3f076Thanks @ask-bonk! - Fixesnpm installpeer dependency conflicts (#819) by removing@tanstack/react-queryand@tanstack/react-routerfrompeerDependencies. These libraries are internal implementation details of the bundled admin UI (@emdash-cms/admin) and consuming Astro apps don't import them directly. Listing them as peers ofemdashwas forcing every npm-based install to install and resolve them at the top level, which produced ERESOLVE errors and bloat. The admin package continues to declare them as its own runtime dependencies. -
#817
a9c29eaThanks @all3f0r1! - Fixes redirect middleware so 301/302 rules from_emdash_redirectsactually fire for unauthenticated visitors. Previously, the lookup was silently skipped on the public-visitor branch becauselocals.emdash.dbis intentionally omitted there — only logged-in admins, edit-mode sessions and preview tokens ever saw redirects (so WordPress migration 301s, manual rewrites andAuto: slug changerows did nothing for real traffic, andhits/_emdash_404_logstayed at zero). The middleware now falls back togetDb()(ALS-aware) whenlocals.emdash.dbis absent. Resolves #808. -
#874
d5f7c48Thanks @ask-bonk! - FixesEmDashRuntime.invalidateManifest()leaving the persisted manifest cache row stale on Cloudflare Workers. The D1 row delete was a fire-and-forget promise — on Workers, unawaited work is cancelled when the isolate is torn down post-response, sooptions.emdash:manifest_cachewas almost never actually wiped after a schema mutation. Cold-starting isolates downstream then adopted the pre-mutation snapshot and servedCollection '<slug>' not founduntil something else cleared the row. The delete now goes throughafter(), which hands it toctx.waitUntilunder workerd. (#873) -
#839
0d98c62Thanks @ascorbic! - Caches thesite:*settings prefix-scan across requests within a worker isolate. Site settings change rarely; reading them once per route was wasted work. Writes viasetSiteSettings()invalidate the cache so other isolates pick up changes within their lifetime. -
#840
64bf5b9Thanks @ascorbic! - Reduces duplicate queries on pages that render multiple taxonomy or "recent posts" widgets.getTaxonomyDef(name)now reuses the full taxonomy-defs list when it has already been loaded in the same request, andgetEmDashCollectionbuckets small limits so a post-detail page asking for 4 posts in the body and 5 in a sidebar widget shares one fetch instead of two. Cuts ~6 queries from the perf-fixture post-detail render. -
#803
0041d76Thanks @mvanhorn! - Fixes migrations 034 and 035 so they can safely re-run when a previous attempt left the schema partially applied without recording it in_emdash_migrations. Resolves the "index already exists" error reported on upgrade from 0.1.1 to 0.6.0+. -
#869
a8bac5dThanks @ask-bonk! - Fixes autosave validation errors on content seeded from the blog,
portfolio, and starter templates (issue #867).Two related issues:
_keywas strictly required on Portable Text blocks by the
generated Zod schema, but the rest of the block schema is
.passthrough()and the editor regenerates_keyon every change,
so requiring it on input rejected legitimate seed/import data
without protecting any real invariant._keyis now optional in the
validator.- The portfolio template shipped
featured_imageas bare URL strings.
imagefields validate as{ id, ... }objects, so any user who
edited a different field on a portfolio entry hit
featured_image: expected object, received string. The portfolio
seeds now use$mediareferences in the same shape as the blog
template, and every shipped template seed has stable_keys on its
Portable Text nodes.
A regression test runs every shipped template seed through the same
validator the autosave endpoint uses, so future template changes that
break this invariant fail before release. -
#882
5b6f059Thanks @ascorbic! - Fixes the seed virtual module to also look at the conventionalseed/seed.jsonpath when no.emdash/seed.jsonorpackage.json#emdash.seedpointer is configured. Without this fallback, a site that only hadseed/seed.jsonwould silently fall through to the built-in default seed -- the setup wizard would not offer demo content, and the wrong schema would be applied. The loader now warns when it falls through to the default seed so misconfiguration is loud during dev. -
#855
a86ff80Thanks @ask-bonk! - Fixes Astro session lookups firing on every anonymous public SSR request (#733). The middleware now skipscontext.session.get("user")when noastro-sessioncookie is present, which on Cloudflare Workers (where the Astro session backend is KV) was turning normal anonymous traffic into a flood of KV read misses. Logged-in editors, admin routes, edit/preview flows, and any request that actually carries the session cookie continue to read the session as before. -
#853
eb6dbd0Thanks @drudge! - Fixes content saves on collections with boolean fields. Boolean fields map toINTEGERcolumns and the repository writes booleans as0/1, but never converts them back on read, so a GET → edit → POST round-trip surfaced numbers where the per-collection zod schema expected booleans, and every save was rejected. The boolean field schema now coerces the0/1shape to real booleans at the validation boundary; other numbers and strings still fail validation as before. -
Updated dependencies [
9dfc65c,d6754ae,0ee372a,ef3f076,8d0feb3,8354088,254a443,25128b2,e7df21f,ab45916,0913a39,e2d5d16,a838000,ddbf808,1c958fb,491aeec,d4be24f]:- @emdash-cms/admin@0.9.0
- @emdash-cms/auth@0.9.0
- @emdash-cms/auth-atproto@0.2.1
- @emdash-cms/gutenberg-to-portable-text@0.9.0