Commit Graph

120 Commits

Author SHA1 Message Date
Matt Kane 48806e224b fix(plugins): honor locale when creating content (#2498)
* fix(plugins): honor configured locale on content creation

* fix(plugins): align sandbox content locale types

* fix(workerd): apply default locale to batch creates

* docs(plugins): clarify explicit locale validation

* fix(plugins): initialize sandbox translation groups
2026-08-16 17:25:28 +00:00
Matt Kane 1c4d4f04b2 feat(core): expose outer user middleware hook (#2499)
* feat(core): wrap EmDash with user middleware

* fix(core): validate outer middleware config
2026-08-16 17:39:47 +01:00
Matt Kane 13db62c82f fix(core): match FileValue to persisted media snapshots (#2489)
* fix(core): match file values to persisted media snapshots

* fix(core): preserve sparse file snapshots safely
2026-08-16 17:02:11 +01:00
Matt Kane 144e378161 fix(taxonomies): resolve omitted locales at write time (#2488)
* fix(taxonomies): avoid stale locale defaults

* ci: update query-count snapshots

* fix(perf): keep locale diagnostic off public cold path

* Revert "ci: update query-count snapshots"

This reverts commit 3dd19c561e889b60a88584a8c7863856118c1bc9.

* refactor(taxonomies): remove unreachable locale branches

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-08-16 16:49:50 +01:00
logelog 4c565ea7f9 feat(admin): allow trusted plugins to add content list columns (#2195)
* feat(admin): allow trusted plugins to add content list columns

* fix(admin): use ESM extensions for plugin columns

* test(admin): cover combined content list columns

* fix(admin): pass visible page to plugin columns

* fix(admin): use ASCII plugin column fallback

* fix(admin): enforce plugin column contracts

* docs(admin): use neutral column examples

* fix(admin): preserve plugin column state

* fix(admin): accept wrapped plugin column components

---------

Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-16 12:13:37 +00:00
logelog e5cda04660 feat(content): filter indexed custom fields (#2213)
* feat(content): filter indexed custom fields

* fix(content): normalize field filters before validation

* test(content): cover indexed field filter contracts

* fix(content): cap indexed filter bind budget

* fix(content): preserve indexed filter query plans

* fix(content): keep the missing-collection error contract with field filters

* fix(content): settle both list queries before rethrowing

`findMany` races the page query against the count query. A collection whose
table is missing rejects both, and `Promise.all` returns on the first while
the other stays in flight holding a pooled connection. A Postgres pool
destroyed inside that window never finishes closing.

This predates the branch. The missing-collection regression test added here
is the first to reach the path on Postgres, so the suite cannot pass without
it.

* fix(content): resolve a missing collection before filter identifiers

Identifier validation ran first, so a request naming both a collection that
does not exist and an invalid filter field answered VALIDATION_ERROR while
the same request without filters answered COLLECTION_NOT_FOUND. The field
names reach the lookup as bound parameters, so deferring validation past the
collection check keeps the query parameterized.

Drop four comments that justify decisions rather than record an invariant.

* fix(content): resolve a missing collection before the filter cap

The cap on filter count threw before the collection lookup, so a request
naming a collection that does not exist alongside more than the allowed
number of filters answered VALIDATION_ERROR while the same request without
filters answered COLLECTION_NOT_FOUND. The cap stays where it is, since it
also bounds how many field slugs reach the lookup as bound parameters.

Drop the comment above the settled list queries. The missing-collection test
fails without them, which is where that contract belongs.

* refactor(content): drop the note above the filter cap

The tests around the cap already say what it protects.

---------

Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-16 12:07:54 +00:00
Kevin Kyburz 8313255a60 feat(plugins): expose authenticated caller to route handlers as ctx.user (#1947)
* feat(plugins): expose authenticated caller to route handlers as ctx.user

The plugin API catch-all resolves and authorizes the caller, then drops
it before dispatch, leaving plugins no safe way to know who is calling
a private route. Thread the validated user through handlePluginApiRoute
into RouteContext (native format) and routeCtx.user (standard format,
in-process and worker sandboxes alike) as the read-only UserInfo shape.

Public routes and machine tokens with no bound user receive undefined;
the catch-all only forwards the caller after private-route auth, so an
ambient admin session is never bound to a public route.

Fixes #812

* docs(core): use canonical users capability name

* fix(core): forward callers to MCP plugin routes

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-16 12:36:43 +01:00
Mason James a9ace36a0d feat(mcp): add safe schema update tools (#2354)
* feat(mcp): add safe schema update tools

* fix(mcp): harden schema update safety

* fix(schema): preserve partial update invariants

* fix(schema): skip unchanged field indexes

* fix(mcp): expose indexed field updates

* fix(schema): preserve collection update behavior

* fix(mcp): align schema update validation

* fix(core): share URL pattern cache state

* fix(mcp): reuse collection support schema

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-16 10:21:41 +01:00
Scott Buscemi 13a33b1906 docs: define PostgreSQL ownership requirements (#2442)
* docs: define PostgreSQL ownership requirements

* docs: clarify optional PostgreSQL schema setup

* docs: require a non-expiring PostgreSQL role

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-13 14:43:49 +01:00
logelog fefb702763 feat(content): support indexed custom field sorting (#2212)
* feat(content): support indexed custom field sorting

* fix(content): remove redundant indexed cursor cast

* fix(content): use generated custom field indexes

* fix(content): keep indexed cursor scans ordered

* test(content): capture qualified list queries

* fix(admin): clear the indexed flag for non-indexable field types

* test(admin): cover clearing the indexed flag on type change

* refactor: drop comments that address the reviewer

Three blocks defend sending `indexed: false` or narrate a Playwright
workaround. The tests already name the contract they cover. The bind-budget
note above the seed batch size stays: it records a limit a reader would
otherwise raise.

* test(core): cover indexed ordering on PostgreSQL

* refactor(schema): simplify indexable field comment

* fix(test): import the indexed migration from its current slot

The file moved to 061 when upstream took 059; this import still named 060,
so the suite could not load the module at all.

* fix(seed): reject unsupported indexed field types

* test(core): allow PostgreSQL ordering setup

* fix(content): preserve indexed field contracts

* fix(test): import indexed migration from slot 066

* fix(core): keep localized custom ordering seekable

---------

Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-13 14:34:36 +01:00
logelog 6a7786217a feat(admin): allow trusted plugins to add editor panels (#2187)
* feat(admin): allow trusted plugins to add editor panels

* fix(admin): use Kumo button in panel fallback

* fix(admin): integrate plugin panels with sortable settings

* test(admin): cover plugin panel retry recovery

* fix(admin): reset a failed plugin panel when the entry changes

The boundary kept its error state across entries, so a panel that threw on
one entry stayed replaced by the fallback after navigating to another. Keying
it on collection and entry id rebuilds it with the content it renders.

Retry already recovers: React unmounts the subtree when the boundary catches,
so clearing the flag mounts the panel fresh. The added test asserts that
mount count rather than assuming it.

* fix(admin): localize plugin panel titles

---------

Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
2026-08-13 14:24:01 +01:00
Noah (Nguyen Pham) 3ceabc4746 fix(core): reconcile media usage indexes automatically (#2443)
* feat(core): add guarded collection deletion foundation

* feat(core): safely detach activated collections

* feat(core): process bounded collection deletion cleanup

* feat(core): expose collection deletion recovery controls

* fix(cloudflare): harden collection deletion guards

* fix(core): bound collection deletion completion checks

* fix(core): use database time for deletion progress

* chore(core): register collection deletion schemas

* chore(core): keep deletion lease guards transaction-scoped

* fix(core): preserve collection deletion compatibility

* fix(cloudflare): pin mutation reads to DO primary

* feat(media): add reconciliation coordinator state

* feat(media): add bounded reconciliation scan

* feat(media): finalize automatic reconciliation

* feat(media): schedule automatic reconciliation

* fix(media): preserve scheduled maintenance compatibility

* docs: clarify automatic reconciliation changeset

* fix(core): guard null media usage revisions on postgres

* fix(cloudflare): default scheduled cron routing

* docs(cloudflare): keep worker comments current
2026-08-13 13:29:05 +01:00
Daniel 628a21f24e docs: document the Cloudflare IMAGES binding behind media transforms (#2444)
Media transforms on Workers run on the `IMAGES` binding, and neither the
deployment guide nor the media library guide mentioned it. When the binding
is absent, media on the internal route is streamed unchanged and nothing is
logged, while media on a public bucket URL fails outright, so a reader had
no way to tell a working setup from a degraded one.

The adapter supplies the binding itself, so the new section explains which
image services it covers, how to confirm it in the generated Worker config,
what each failure mode looks like, and how the transforms are billed, rather
than asking readers to declare it by hand.
2026-08-13 10:09:27 +01:00
Noah (Nguyen Pham) 0cd7c73b9a fix(core): make media usage collection deletion crash-safe (#2433)
* feat(core): add guarded collection deletion foundation

* feat(core): safely detach activated collections

* feat(core): process bounded collection deletion cleanup

* feat(core): expose collection deletion recovery controls

* fix(cloudflare): harden collection deletion guards

* fix(core): bound collection deletion completion checks

* fix(core): use database time for deletion progress

* chore(core): register collection deletion schemas

* chore(core): keep deletion lease guards transaction-scoped

* fix(core): preserve collection deletion compatibility

* fix(cloudflare): pin mutation reads to DO primary

* chore(core): scope deletion SQL to transaction

* test(cloudflare): remove descriptor config pins

* docs: clarify collection deletion changeset

* fix(core): reject stale collection projections
2026-08-12 17:58:19 +01:00
Noah (Nguyen Pham) 170c966978 feat(core): add reliable incremental media usage indexing (#2394)
* feat: add media usage incremental capture foundation

* feat: add media usage work transitions and projection no-op

* feat(core): process durable media usage work

* feat(core): apply media usage coverage epochs

* feat(core): add media usage work controls

* feat(core): add media usage activation service

* fix(core): address media usage sequence 1 review findings

* chore: untrack local media usage notes

* fix(core): remove incomplete media usage systems

* fix(core): qualify activation conflict guards

* docs: remove dormant media usage guidance
2026-08-11 19:08:55 +01:00
Dipak Chaudhari 425e7c0fee fix(plugins): parse query params as input for GET/HEAD/DELETE routes (#2163)
* fix(plugins): parse query params as input for GET/HEAD/DELETE routes

Plugin route input was always read via request.json(), so a GET, HEAD,
or DELETE route with an input schema always failed validation — those
methods carry no body, so request.json() resolves to undefined and the
Zod check rejects it (#2146).

A new parseRouteInput helper switches on method: POST/PUT/PATCH parse
the JSON body as before; GET/HEAD/DELETE parse the URL query string into
an object (repeated keys become arrays). Applied to both the trusted and
sandboxed plugin route dispatch paths.

Fixes #2146

* style: format

* lint: drop redundant non-null assertion in parseRouteInput

oxlint --deny-warnings flagged values[0]! as an unnecessary type
assertion (noUncheckedIndexedAccess is off, so values[0] is already
string). Remove it; behaviour is identical.

* docs(plugins): document query-string input for GET/HEAD/DELETE routes

The route dispatcher now parses bodyless-method input from the query
string. Document the parsing semantics (repeated keys become arrays,
single keys stay scalars, values are always strings so use z.coerce)
and fix the method list to GET/HEAD/DELETE.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-08-11 08:10:03 +01:00
Scott Buscemi f58610074b fix: prefer uncached Hyperdrive after content writes (#2280)
* fix: prefer uncached Hyperdrive after content writes

After a content publish, anonymous public reads briefly use the primary
Hyperdrive binding so edge/object caches are not reseeded from stale
query-cache results (default 60s when cachedBinding is set).

* fix(core): cache confirmed-zero lastContentWriteAt marker

Avoid an object-cache backend round-trip on every logged-out request when
no content write has occurred yet. Also drop a low-value config-pin test.

* fix: address Hyperdrive cache routing review

* docs(cloudflare): clarify cross-isolate cache routing

* test(core): use public content write timestamp reader

* fix(core): limit content marker reads to cacheable requests

* fix(core): keep background workloads off cached bindings

* fix(cloudflare): preserve Hyperdrive selector compatibility

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-10 15:33:01 +01:00
Matt Kane f6385dab2c refactor(cli): deprecate emdash dev (#2405)
* refactor(cli): remove dev command

* refactor(cli): deprecate dev command
2026-08-10 13:41:21 +01:00
David Pivert e8048e40b4 feat(admin): explicit collection order in the sidebar (#2267)
* feat(admin): explicit collection order in the sidebar

The sidebar order came straight from `listCollections`, which sorted by
slug. A site's collections then appear in an order nobody chose —
"Certifications, Education, Endorsements, Pages, Positions, Posts,
Projects" — with no way out short of renaming slugs (breaking URLs and
queries) or forking the admin.

Adds `sort_order` on `_emdash_collections`, settable by dragging rows on
the Content Types screen or via `sortOrder` in a seed file. The column is
nullable rather than defaulting to 0: NULL means "no explicit position",
and those collections keep the alphabetical order behind the ordered
ones, so a site that never reorders renders exactly as before. Reads
materialise the fallback with COALESCE instead of relying on NULL
ordering, which SQLite puts first and Postgres last on ASC.

`reorderCollections` takes the full desired order and clears the position
of anything left out, so the stored state stays a faithful picture of
what the admin renders instead of a sparse set the UI has to reconcile.

`reorder` becomes a reserved collection slug: the static
POST /schema/collections/reorder route would otherwise shadow a
collection by that name. Same defence in depth already applied to
byline-fields/reorder.

Closes #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(admin): expose sortOrder on the schema export, tighten comments

Pre-empts the review notes already raised on the sibling `hidden` PR.

The full schema export the CLI reads for `emdash types` builds its
collection shape by hand and omitted the new field. The comment changes
apply AGENTS.md: drop justification and narrative, keep the non-obvious
constraints (route ordering, NULL sorting across dialects), and remove
issue references from test titles. The migration docstring also pointed
at a symbol name that never existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:59:31 +00:00
David Pivert 741c40cad6 feat(admin): allow hiding a collection from the admin sidebar (#2264)
* feat(admin): allow hiding a collection from the admin sidebar

Plugins that own a collection end to end — autopopulated entries plus
their own admin page — had no way to suppress the auto-generated CRUD
entry the sidebar builds from the manifest. Editors saw raw collections
they never use next to the ones they actually edit, and the only
workarounds were CSS injection or renaming the label to discourage
clicks.

Adds a `hidden` flag on the collection definition (seed file, schema
API, and the collection row). The flag is deliberately scoped to
navigation only: the collection still ships in the manifest and stays
reachable through the REST API, MCP tools, plugin hooks, and its editor
at /content/:collection, so plugins keep managing the data and admins
can still navigate there directly.

Closes #1131

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(admin): expose hidden on the schema API and manifest contracts

Review follow-up. The flag was returned by the handlers but missing from
three published contracts: the OpenAPI collection response schema, the
full schema export the CLI reads for `emdash types`, and the admin
client's manifest type — which typechecked only because the sidebar
declared its own inline shape.

Also drops a comment that justified a decision rather than explaining
the code, and the issue references from test titles, per AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: reference the published core package in changeset

* test: include 057_collection_hidden in the trailing re-run list

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mason James <297610+masonjames@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
2026-08-09 09:50:20 +00:00
MA2153 ea4c39bb18 feat(taxonomies): let terms carry a manual order (#2353)
* feat(taxonomies): let terms carry a manual order

Term listings were ordered by label alone, so the only way to control
how a taxonomy renders on a site was to rename its terms. Add a
`sort_order` column and order every term read by (sort_order, label, id).

Existing rows default to 0, which keeps a taxonomy nobody has reordered
alphabetical -- the behaviour before this change. A group only becomes
manually ordered once it is reordered, at which point it is renumbered
0..n-1 and new terms append to it; groups still at a uniform 0 keep
inserting new terms alphabetically.

Ordering is scoped to one sibling group in one locale so an order set in
one language cannot decide placement in another, and reordering never
reparents -- the endpoint rejects any list that is not the group's exact
membership rather than applying a stale one.

sort_order leads the ORDER BY and no index satisfies it, so the seeked
group is sorted in a temp b-tree. That costs no extra rows read (what D1
bills) and ORDER BY label had the same sort before; the alternative is a
five-column index paid on every term write. The query-plan test pins the
index seek so the #1723 full-locale scan cannot come back unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(taxonomies): tie a term's order to the group it is rendered in

Two places assumed a term's `parent_id` names the group it belongs to.
Neither holds once the tree is hierarchical.

The admin derived the group of a move from the moving term, so a term
whose parent has no row in the current locale -- which the term list
shows at the top level -- addressed its absent parent's group. That group
has no members, so every move on such a row failed with
REORDER_MISMATCH. The group now travels with the siblings it is rendered
from, which is what the server resolves membership against.

Reparenting left `sort_order` alone, so a term carried a position from
the group it left. Beyond landing mid-list, one non-uniform value makes a
group nobody has ordered read as ordered, switching it from alphabetical
insertion to append-at-end. An update that changes `parent_id` now
re-places the term by the same rule a new term uses.

`nextSortOrder` still scopes a group by the raw `parent_id` column while
the reorder endpoint scopes it by the effective parent; they agree except
under an untranslated parent, where a new top-level term can land
mid-list. Closing that costs a parent-existence check plus a correlated
NOT EXISTS on every term create -- documented on the PR rather than paid
for here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(taxonomies): place a translation by the group it lands in

A translation copied its source's `sort_order` unconditionally, so one
created under a different parent than its source -- including with no
parent at all, which makes it a root in the target locale -- carried in a
position belonging to a group it was not joining. It lands mid-list, and
one non-uniform value makes a group nobody has ordered read as ordered,
switching it from alphabetical insertion to append-at-end.

The copy now applies only when the new row joins the group that mirrors
the source's; otherwise the row is placed by the same rule a new or
reparented term uses. A translation of a term whose parent is unchanged
still inherits its source's position, so a translated tree keeps the
order its source was given.

Reachable from `POST /taxonomies/{name}/terms` with `translationOf` and
from a seed whose translated term declares a different parent. The
dedicated translations route passes the source's parent through, so the
admin's Translate button never hit this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(taxonomies): trim comments to what a future reader needs

The manual-order work left comments that argue for the code rather than
explain it: a query-plan test header that justified its own assertions and
told a reviewer which ones to delete, issue references that go stale on
merge, and "rather than X" notes restating decisions the code already makes
plain.

Also drops a filler assertion that only existed to consume a destructured
binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: update query-count snapshots

* fix migration number

* feat(taxonomies): give a term one order across its locales

Reworks manual term ordering so a position belongs to a term rather than
to one of its rows, and drops the alphabetical fallback entirely.

`sort_order` is now per translation_group: every row of a group carries
the same value, mirroring `parent_id`, which already stores the parent's
translation_group rather than a row id. Sibling groups key on the raw
`parent_id` column everywhere -- placement, reordering, and the
migration -- so one definition decides what a group is.

That removes the cases the previous model had to reason about. There is
no rule for placing a translation, because a translation is the same
term at the term's position. A half-translated locale can't produce
gaps or duplicate positions. And the reorder endpoint no longer takes a
`locale`, because a position doesn't have one -- which also removes the
cross-locale renumbering that omitting it used to cause.

Migration 056 mints a position for every existing term instead of
defaulting them all to 0. Numbering by (label, id) keeps rendered order
identical the moment it runs; what changes is that a term created
afterwards appends to the end of its group rather than slotting in
alphabetically. Inferring "has this group been ordered?" from whether
its values were uniform is gone with it.

Reorder now permutes the listed terms within the positions they already
occupy instead of demanding the group's exact membership. A locale
renders only the terms translated into it, so a caller often cannot name
every member; a member left out keeps its place, which also makes a
stale list harmless rather than rejected.

A child whose parent has no row in the current locale is rendered at the
top level but belongs to its parent's group. Its carets are disabled and
it is left out of the ids sent for the group it is drawn in, rather than
silently addressing a group it isn't in.

Ordering applies to reads of a sibling group. The terms attached to one
entry are a flat list that can span groups, so those reads are unchanged
and still order by label; ordering terms within an entry is a separate
feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: update query-count snapshots

* fix(taxonomies): move a term's parent with the whole group

A term's `parent_id` holds the parent's translation_group, so it is
locale-agnostic and every row of a group should carry the same value.
`update()` wrote it with `WHERE id = ?`, so reparenting a term left it
nested in the locale it was moved in and a root in all the others.
Migration 045 rewrote parent_id values but scoped its UPDATE to
`parent_id IS NOT NULL`, so it converged groups whose rows all named a
parent and never the ones where a row still held null -- the exact shape
a one-row reparent produces. Reparenting now writes the group.

Sibling positions are keyed the same way, so a divergent group also put
two roots on the same position, and `reorder()` cannot permute positions
that tie: it returned success while the list never moved, leaving the
group unorderable. It now renumbers 0..n-1 when the positions it is given
tie, which honours the request and clears the tie. Renumbering keys on
each member's index in the rendered order rather than on a row id, since
ULIDs minted in the same millisecond have no order and would make the
result arbitrary. `reorder` takes the sibling list in rendered order for
that reason.

Creating a translation into a different parent inherited the source's
position, colliding with whatever already held it. A translation now only
takes the group's position while it stays in that group, and appends
otherwise.

Migration 056 guarded its whole body on the column it adds, so a run that
died during the backfill was never recorded, retried, and immediately
early-returned -- stranding most groups at 0 with no way to re-run.
Only the ALTER is guarded now and both passes run unconditionally,
following 051. The backfill was one UPDATE per translation group, which
spends a D1 subrequest per term and exhausts the 1,000 a Worker gets on
the free plan on any sizeable taxonomy; it is now chunked CASE statements
at 32 groups, three bound parameters each, inside D1's 100-parameter
ceiling. The parent_id repair runs before numbering, because numbering
keys on parent_id and would otherwise place a divergent group by whichever
row it happened to read.

The reorder endpoint's `order` response was never read: the admin client
returned it and the caller discarded it, and its localeCompare tiebreak
could disagree with the next list read. It now answers `{ reordered: true }`
and `repo.reorder` returns void. `ids` caps at 100 to match the comparable
arrays in the same schema, which also bounds the writes in one transaction.

Tests: the runtime-helper test could not fail, because `cachedQuery` runs
its loader when no cache backend is configured, so invalidation is
invisible in that file. It is renamed to say it covers ordering, and
term-list-object-cache.test.ts covers invalidation against a real backend.
Adds coverage for optimistic rendering, for the queued-move refetch guard,
and for the bridge ORDER BY on both sandbox implementations -- the workerd
fixture's rows all sat at the default position, so its order assertion was
purely alphabetical. Drops the half of the findByName test that asserted
`repo.reorder`'s calling convention rather than any behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(taxonomies): write a reorder as one statement per chunk

A reorder applied one UPDATE per translation group inside withTransaction,
which degrades to a bare passthrough on D1. A failure partway left the
sibling group half-permuted, and the tie-repair path spent a subrequest
per member of the group.

Batch the writes into one CASE statement per 32 groups, the same
parameter arithmetic migration 056 uses. A swap is now a single
statement and cannot tear; only a renumbering wide enough to span chunks
can, and that leaves ties the next reorder renumbers away.

The migration keeps its own copy of the helper: a migration has to stay
frozen, and sharing one would let a later edit rewrite history for
anyone mid-upgrade.

Also:

- Say that the migration preserves the *source* locale's order and
  re-sorts the others. One position per translation group means one
  collation wins; the docstring and changeset claimed more than that.
- Stop claiming term listings arrive "in rendered order" — with locales
  interleaved a tie falls to whichever locale's label sorts first.
- Explain the disabled carets on a term whose parent has no translation
  in the current locale, in the accessible name and as a hover title.
- Narrow the changeset: getTerms() honours the manual order, the terms
  attached to an entry are still alphabetical.
- Make reorderTermsBody strict, and document that omitted terms hold
  absolute positions, so a one-step move in a partial list can be a
  two-step move in a fuller one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(taxonomies): type the reorder CASE value for Postgres

Postgres resolves a CASE whose THEN arms are all untyped bind parameters
to text, then refuses to assign that to an integer column:

  column "sort_order" is of type integer but expression is of type text

The WHEN parameters take text from the comparison against the selector,
and nothing pins the THEN parameters, so the whole expression lands on
text. node-postgres sends numbers with an unspecified type OID, so a JS
number does not settle it either. SQLite does not type-check the
assignment, which is why this only showed on the Postgres dialect runs.

Both the batched reorder write and migration 056's backfill emit that
shape, so on Postgres every term reorder failed -- swallowed into a
failed ApiResult rather than surfacing -- and the migration could not
mint positions at all.

CAST the value in each arm. applyByGroup serves parent_id as well as
sort_order, so it takes the cast target from its column argument. The
cast binds no parameters, so the three-per-group budget behind
GROUPS_PER_UPDATE is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(taxonomies): seed a missing translation_group before numbering terms

Reorder and reparent both key sibling groups on `translation_group`, while
callers derive a group as `translationGroup ?? id`. A row whose column is
null therefore matches nothing: the write reports success and changes
nothing.

Migration 036 seeded the column on every row it rebuilt and the repository
has set it on every insert since, so no supported path produces a null. 056
normalises anyway, since it is the migration that starts keying group writes
on the column in SQL. Repairing the row is strictly better than coalescing
at each read site: a null is equally invisible to translation lookups and to
the `content_taxonomies` join, and an expression key would cost the index
seek on every reorder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-08-09 08:51:04 +01:00
Matt Van Horn e7c445ca0e fix: address self-review findings (#2257)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-08 11:19:32 +01:00
Scott Buscemi 51cbe2e631 docs: prefer native Workers Caching over legacy cloudflareCache() (#2277)
* docs: prefer native Workers Caching over legacy cloudflareCache()

The Cloudflare demo and cloudflareCache() JSDoc pointed agents and
humans at the Cache API + zone REST purge path (CF_ZONE_ID tokens).
Point demos/cloudflare and deploy docs at wrangler cache.enabled +
cacheCloudflare() / cache.purge() instead, and mark the EmDash helper
as legacy.

* style: format

* chore(cloudflare): warn once when cloudflareCache() is used

JSDoc @deprecated alone is easy to miss; emit a one-time console.warn
at config time pointing at cacheCloudflare() + wrangler cache.enabled.

* Update docs/src/content/docs/deployment/cloudflare.mdx

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-08-08 08:41:52 +00:00
Daniel 8c5e8e7be8 docs: correct the auto-selection note for the Cloudflare email provider (#2371) 2026-08-08 07:06:39 +00:00
Noah (Nguyen Pham) 640da63dd0 fix: preserve numbering across separated ordered-list segments (#2348)
* fix(portable-text): preserve logical numbered-list identity

* fix(editor): retain numbering when ordered lists are split

* fix(core): render continued ordered-list segments

* fix(core): defer lossy Markdown list metadata

* fix(editor): isolate ordered lists by parent context

* refactor(core): trim numbered-list API surface

* refactor(core): simplify numbered-list traversal

* test(admin): avoid pinning RTL icon classes

* test(core): cover cross-segment list base

* Update packages/core/src/components/PortableText.astro

Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>

* chore: remove stale issue references from comments

---------

Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
2026-08-06 13:33:11 +01:00
ttmx 215f36ebbd feat: AI Search plugin with indexing, search modal, and demo site (#633)
* feat(cloudflare): add AI Search plugin

Cloudflare-native AI Search integration: content sync hooks, metadata-based
documents with always-on metadata-only retrieval, locale-aware search, a
cron-flushed reindex queue, query synonyms, an index-status endpoint, and the
settings admin UI.

* feat(demo): wire AI Search into the Cloudflare demo

Registers the AI_SEARCH namespace binding, adds a cron trigger to flush the
reindex queue, documents the aiSearch() options at their defaults, and adds the
search UI: Cloudflare's AI Search snippet (search-modal-snippet) opened by a nav
button or Cmd/Ctrl+K, backed by a /api/ai-search/search endpoint that queries the
binding directly. Replaces the previous custom /search page.

* chore: AI Search changeset and lockfile

* fix(ai-search): address review feedback

* Return 503 instead of 500 if AI Search fails.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Stop needlessly stripping markdown from bylines

* Apply suggestion from @emdashbot[bot]

Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>

* feat(ai-search): package snippet endpoint

* feat(ai-search): add Astro snippet component

* Fixed scheduled handler in worker.ts

* fix(ai-search): make public search endpoint work on anonymous requests

The snippet search endpoint read `locals.emdash.db` to load synonyms, but
on the anonymous request path EmDash only attaches the partial fast-path
facade, where `db` is undefined. Every public (unauthenticated) search
therefore threw "Cannot read properties of undefined (reading 'selectFrom')"
and returned 503 "Search is temporarily unavailable" — i.e. site search was
broken for all real visitors.

Resolve a request-scoped runtime via `withEmDashRuntime()` (whose `db` works
regardless of auth) instead. Import it lazily with a dynamic `import()`: a
top-level import of `emdash/middleware` pulls the `astro:middleware` virtual
module into Astro config evaluation (via `aiSearch()` in astro.config) and
breaks `astro build` with an 'astro:' scheme error.

Add tests:
- ai-search-endpoint.test.ts: the endpoint wrapper succeeds when
  `locals.emdash.db` is undefined (and when `emdash` is absent), and never
  constructs OptionsRepository from an undefined db.
- ai-search-build-safety.test.ts: guards that `emdash/middleware` is only
  referenced as `import type` or a dynamic import, so config-time evaluation
  never loads an 'astro:' module.

* style: format

* Add docs for the Cloudflare AI Search plugin

* Change plugin name from AI Search to Cloudflare AI Search

* Simplify uploadItem()

* Remove "limit" from AISearchQueryInput, it is configurable in the instance.

* Don't index draft content if post was previously indexed.

* Actually disable ai search plugin endpoint when plugin is disabled.

* style: format

* Clarify AI Search result limit ownership

* Remove narrative comments from Cloudflare demo

* Remove narrative AI Search test comments

* Remove weak AI Search build safety test

* Fix hotpath db query, limited to 1 per minute per isolate

* style: format

* Fix AI Search removal hooks for deselected collections

* Reconcile AI Search metadata configuration

* Validate and forward AI Search result limits

* Refresh AI Search synonyms after updates

* Omit AI Search indexing timestamps from results

* Filter AI Search collections during retrieval

* Persist default AI Search collections

* Remove unused AI Search install hook

* style: format

* fix(cloudflare): recognize Cloudflare's ai_search_not_found error

Cloudflare reports a missing AI Search instance as AiSearchNotFoundError
with the message ai_search_not_found, which the missing-instance check did
not match. The metadata route therefore answered 503 for a fresh or deleted
instance instead of reporting it as repairable.

* fix(cloudflare): only create an AI Search instance when it is missing

The info() probe treated every failure as a missing instance, so a transient
error or an auth failure triggered a spurious create and a second failure.
Rethrow anything that is not a recognized missing-instance error, and when a
concurrent initializer wins the create, re-probe rather than surfacing it.

* refactor(cloudflare): import cloudflare:workers once per isolate

Several AI Search call sites imported the module independently on every
operation. Share one cached import, keeping the rejection uncached so a
later call can retry.

* fix(cloudflare): drop deselected collections from the AI Search index

Saving a narrower collection selection left the already-uploaded items of the
removed collections searchable, since the selection only gated later content
hooks. Persist the selection first, then delete the mirrored documents whose
collection is no longer selected.

* docs: require an initial AI Search sync during setup

Configured plugins have no install hook, so the content hooks only cover
content created or updated after AI Search is enabled. Document the manual
full sync as a required setup step, and correct the admin page name.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: emdashbot[bot] <273199577+emdashbot[bot]@users.noreply.github.com>
Co-authored-by: scottbuscemi <sbuscemi@cloudflare.com>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-08-05 17:28:45 +00:00
Noah (Nguyen Pham) b0c7880c74 fix: make media uploads reliable across storage backends (#2273)
* fix: support streamed media uploads

* fix: harden streamed media uploads

* fix: prevent media cleanup confirmation race

* fix: harden media upload integrity

* fix: close media upload integrity gaps

* test: synchronize media upload failure assertion

* chore: tighten media upload comments

* fix: preserve media upload retry integrity

* fix: reject stale media confirmations
2026-07-30 12:29:08 +01:00
logelog f81aa6842c fix(core): compute taxonomy counts on demand (#2219)
* fix(core): compute taxonomy counts on demand

* fix(core): version taxonomy term-list cache key

---------

Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
2026-07-24 15:00:40 +01:00
Bobby Bones 0eb389f7a2 fix(core): allow reducing OAuth consent scopes (#2188)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 07:36:08 +01:00
jc e52dea9b72 feat(plugins): allow explicit MCP tool declarations (#2002)
* feat(plugins): add explicit MCP tool declarations

* fix(plugins): harden MCP request and consent handling

* fix(admin): send marketplace capability consent flag
2026-07-21 07:02:20 +01:00
Kevin Kyburz d4c565ef99 feat(plugins): auto-generate admin settings UI from settingsSchema (#341) (#1893)
* feat(plugins): auto-generate admin settings UI from settingsSchema

Plugins that declare `admin.settingsSchema` in definePlugin() now get
an auto-generated settings form in the admin, reachable via a gear
icon on the plugin card. Values persist to the plugin's KV store
(`settings:` prefix); secrets are write-only and never echoed back.
Both GET and PUT require `plugins:manage`.

Fixes #341

* fix: resolve settingsSchema for bypassed and runtime-installed plugins

Review follow-up: loadBypassedPlugins now forwards settingsSchema to
adaptSandboxEntry, and marketplace/registry plugins loaded at runtime
resolve their schema from the cached manifest via a new
getRuntimePluginSettingsSchema accessor (settings endpoint fallback +
hasSettings in the plugin list).

* fix(admin): cleared settings fields revert to schema default

Review follow-up: emptying a non-secret field now sends null so the
server deletes the stored value and the schema default applies again.
Previously the empty string was persisted and shadowed the default.

* test(plugins): cover runtime-installed plugin settings schema fallback

The settings route tests mocked getRuntimePluginSettingsSchema to always
return null, so the runtime-installed (marketplace) plugin fallback added
to fix the prior review gap was never exercised. Add a GET case where the
plugin is absent from configuredPlugins/sandboxedPluginEntries and the
runtime lookup supplies the schema, asserting 200 with the schema keys and
masked secrets.

* fix(admin): correct PluginSettings select items shape and toast usage

Addressing emdashbot review: Kumo Select.items expects a value->label
Record (or string[]), not {value,label}[] — building the record first so
the select trigger renders the chosen label. Also align with admin
conventions: use Toast.useToastManager() with type:"error" instead of
useKumoToastManager() + variant, and re-export the plugin-settings client
functions and SettingField from the api barrel.

* style: format

* fix(plugins): wrap settings update writes in a transaction

The update handler validated every field up front but then ran the
delete/set writes in a bare loop, so a failure partway through left the
options table half-updated. Wrap the write loop + read-back in
withTransaction (real transaction on SQLite/Postgres; degrades to a
direct run on D1, which is single-writer so per-statement atomicity
still holds), using a transaction-scoped OptionsRepository.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-07-21 06:46:59 +01:00
Kevin Kyburz 23a740ad3d feat(mcp): add media_upload tool for programmatic media management (#1954)
* feat(mcp): add media_upload tool for programmatic media management

Adds a media_upload MCP tool that accepts base64-encoded file data or a
public URL, runs the same pipeline as the REST upload route (global MIME
allowlist, size limit, content-hash dedupe, storage upload, image
metadata enrichment), and returns the media item ready to reference from
content fields.

URL fetches go through ssrfSafeFetch so redirects and private hosts are
rejected. Closes #620, closes #1825.

* fix(mcp): validate MIME string before allowlist and storage upload

The prefix-based allowlist check let a crafted contentType like
'image/png\r\nX-Evil: 1' through to the storage backend's ContentType
header (echoed by the media file route). Validate against the existing
CONTENT_TYPE_RE first, and tighten the MCP input schema (regex on
contentType, .url() on url) so malformed input is rejected before any
bytes are buffered or fetched.

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-07-20 19:47:55 +00:00
Kevin Kyburz e8b7ddc9fc fix(docker): install node-gyp toolchain in build stage for blocked prebuilt downloads (#2148)
better-sqlite3 installs via `prebuild-install || node-gyp rebuild`. The
prebuilt binary is fetched from GitHub Releases, which corporate proxies
and offline mirrors commonly block. The fallback then compiles from
source and dies in node:22-slim because no compiler toolchain is
present. Install python3/make/g++ in the deps stage only; the runtime
image starts from a fresh node:22-slim and is unaffected.

Also documents the same failure mode in the Node.js deployment guide and
clarifies that the guide's Dockerfile (single site) and the repo-root
Dockerfile (monorepo) are different scenarios.

Follow-up to #2132 (#2118).
2026-07-20 17:42:17 +01:00
Kevin Kyburz 6a79b03e6f fix(core): warn at build time when @astrojs/react is missing; add existing-project guide (#1958)
Two of the four friction points in #962 were already fixed (#991 cache
guard, #1545 tiptap peer deps). This covers the remaining two:

- The integration now checks astro:config:done for @astrojs/react and
  logs an actionable warning when missing, instead of the admin silently
  never hydrating.
- New docs page "Add EmDash to an Existing Astro Project" covering peer
  deps, required integrations, live.config.ts, Cloudflare Pages caveat,
  and a troubleshooting table.
- Existing astro.config examples in the docs now include react().

Closes #962

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-07-20 16:22:05 +01:00
Kevin Kyburz 9b41ad87a2 docs(i18n): document prefixDefaultLocale breaking the admin routes (#369) (#1895)
* docs(i18n): document prefixDefaultLocale breaking the admin routes

Astro's i18n prefixDefaultLocale option 404s all injected page routes,
including the entire admin SPA (#369, blocked on upstream Astro).
Document the limitation and the compatible default routing strategy.

* docs(i18n): plain-text Aside title, cover prefix-always too

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-07-20 14:10:39 +00:00
Kevin Kyburz 3f8b77822b feat(plugins): opt-in Cache-Control for public plugin routes (#1985)
* feat(plugins): opt-in Cache-Control for public plugin routes

Public routes can set cacheControl to have successful GET/HEAD responses
carry that Cache-Control value, enabling CDN/browser caching for
endpoints that serve identical data to every visitor.

Guardrails: route metadata only exposes cacheControl for public routes,
so authenticated responses always keep private, no-store; errors and
non-GET methods keep the default.

* fix(plugins): wire cacheControl through runtime meta and all plugin formats

Review follow-up: EmDashRuntime.getPluginRouteMeta rebuilt metadata
inline and dropped cacheControl, so the header was never applied.
Extracted buildRouteMeta() as the single source of the public-only
invariant and used it for trusted routes and all four sandboxed
route-meta cache sites.

Extends the surface to every plugin format: manifest schema +
ManifestRouteEntry carry cacheControl, extractManifest emits structured
entries, adaptSandboxEntry threads the field, and the bundle CLI probe
preserves it. Fixes two stale getPluginRouteMeta return types.

Adds an integration test exercising the real runtime path (ResolvedPlugin
-> runtime.getPluginRouteMeta -> catch-all handler) that fails without
the runtime fix.
2026-07-20 15:06:38 +01:00
Kevin Kyburz e649af8a79 feat(core): add withEmDashRuntime() so queue/scheduled handlers can invoke plugin routes (#1961)
* feat(core): add withEmDashRuntime() for queue and scheduled handlers

Request-free handlers (Cloudflare Queue consumers, custom scheduled()
handlers) had no supported way to reach the EmDash runtime or invoke
plugin routes: handlePluginApiRoute is only attached to locals.emdash
on authenticated HTTP requests, and a cookieless SELF.fetch() gets the
limited locals without it.

withEmDashRuntime() exposes the same request-free plumbing plugin cron
already uses (runtime singleton + event-scoped db for connection-backed
adapters), refactored into a shared runOutsideRequest() helper that
runScheduledTasks now also goes through.

Closes #1887

* test: cover the unconfigured error contract of withEmDashRuntime()

Review follow-up: the documented throw when EmDash is not configured had
no regression test. Lives in its own file because the
virtual:emdash/config mock is module-level; also pins runScheduledTasks'
contrasting silent no-op so the two contracts can't silently converge.
2026-07-20 15:05:32 +01:00
Kevin Kyburz 3b0fd64c4c docs: add Secrets & Key Management inventory (#1946)
* docs: add secrets and key management inventory (#1688)

* docs: separate site/marketplace CLI from atproto registry CLI credentials

Review follow-up: the CLI section conflated two CLIs. `emdash login`
uses the instance's OAuth device flow and `emdash plugin publish`
stores a marketplace JWT (EMDASH_MARKETPLACE_TOKEN in CI) in the same
auth.json; the separate emdash-plugin CLI holds atproto OAuth state in
~/.emdash/oauth/ with EMDASH_PUBLISHER_* env identity for CI. Adds
EMDASH_MARKETPLACE_TOKEN to the service-credentials table.

* docs(secrets): note credentials.json identity cache + CI OAuth-session caveat
2026-07-20 14:44:09 +01:00
Kevin Kyburz 64b2e73994 feat(admin): localize plugin admin page labels via the shared Lingui instance (#2013)
* feat(admin): localize plugin admin page labels via the shared Lingui instance

Plugin adminPages labels were rendered verbatim in the sidebar and command
palette, so a fully localized admin still showed hard-coded English nav
items for plugins. Run declared labels through i18n._() at render time:
plugins that merge a Lingui catalog into the shared instance (English label
as msgid) get localized navigation, and labels without a catalog entry fall
back to the literal string, keeping existing plugins unchanged.

* docs(admin): document that plugin labels share the admin's Lingui catalog

* fix(admin): add i18n.locale to nav-items memo deps; fix docs catalog shape

Review follow-ups: the palette memo now also depends on i18n.locale (the
documented locale-switch signal) rather than relying solely on the context
_ rebind, and the docs example uses plain-string catalog messages instead
of the compiled-token array shape.
2026-07-20 14:37:47 +01:00
Noah (Nguyen Pham) a27a5ccda3 Add media usage read APIs (#2092)
* feat(core): add media usage read queries

* feat(core): add coverage-aware media usage summaries

* feat(core): add media Used in details endpoint

* test(core): consolidate media usage read coverage

* perf(core): remove redundant media usage join

* feat(core): add media usage client methods

* docs(core): document media usage read APIs

* perf(core): skip empty media usage queries
2026-07-20 13:11:15 +01:00
Kevin Kyburz c350e86d77 fix(core): honor siteUrl in /.well-known OAuth discovery on the anonymous fast path (#2030)
* fix(core): honor siteUrl in /.well-known OAuth discovery on the anonymous fast path

The middleware's anonymous fast path attaches locals.emdash without
`config`, and the root-level /.well-known routes are public by design —
so MCP clients always hit them with locals.emdash?.config undefined,
getPublicOrigin() fell through to url.origin, and behind Cloudflare's
proxy the discovery document advertised http:// (clients refuse to
attach). Fall back to the build-time virtual config, which carries the
origin-normalized siteUrl.

Also corrects the public-url.ts comment (process.env IS readable on
Workers with nodejs_compat_populate_process_env) and documents the
Workers env-var setup in the siteUrl reference.

Fixes #2016

* docs: tighten fallback comments per review (khoinguyenpham04)

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-07-20 09:23:48 +00:00
Noah (Nguyen Pham) 6fb52b0813 feat(core): add media usage repair tooling (#2007)
* test(core): cover media usage repair CLI contract

* feat(core): add media usage repair CLI command

* fix(core): harden media usage repair CLI

* feat(core): add media usage repair MCP tool

* chore(core): polish CLI docs and integration CI
2026-07-13 11:44:16 +01:00
Kevin Kyburz 82827d3f8f feat(backups): admin backups — one-click download and scheduled archives to storage (#1890)
* feat(backups): admin backups — one-click download and scheduled archives to storage

Adds a Backups page under admin settings: download a complete content
backup (all content including drafts and trash, schema, taxonomies,
menus, widgets, media metadata, site settings — never users or secrets),
plus optional daily automatic backups written to the site's storage
bucket with configurable retention. Scheduled runs piggyback on the
existing maintenance tick. New admin-only backups:manage permission.
The public media route now denies keys under backups/.

Ref: Discussion #142

* fix(backups): make archive listing work on LocalStorage, doc bucket-exposure caveat

LocalStorage.list matches directory + filename prefix rather than flat
keys, so include the emdash-backup- filename prefix in the list call.
Docs now warn that publicly exposed buckets serve archives by URL.

* chore: revert local typegen churn in demo

* refactor(backups): address review — Kumo tokens, central ErrorCode, localized dates

- Error/warning callouts use Kumo semantic tokens (DialogError,
  kumo-warning) instead of raw Tailwind colors and dark: prefixes
- Backup error codes registered in the central ErrorCode object and
  referenced from handler and routes
- Archive timestamps formatted through the active Lingui locale

* docs: drop the stacked note aside, keep the cron detail as body text
2026-07-11 13:58:18 +01:00
Kevin Kyburz 7c5de08f63 feat(plugins): read-only taxonomy access via new taxonomies:read capability (#1719)
* feat(plugins): read-only taxonomy access via new taxonomies:read capability

* test(cloudflare): cover PluginBridge taxonomy methods

Review follow-up: capability enforcement, locale/taxonomy filter SQL
wiring, and D1 row mapping (JSON parsing, int→bool, nullable columns)
for taxonomyList/taxonomyTerms/taxonomyEntryTerms.

* test/fix: review follow-ups for taxonomies:read

Guard the in-process collections JSON parse like both bridges (an
in-process plugin no longer crashes on malformed definition data), and
extend the workerd conformance suite to taxonomy/terms and
taxonomy/entryTerms: capability gating, locale filtering, data JSON
parsing, and the pivot join on translation_group.
2026-07-10 14:21:14 +01:00
Kevin Kyburz 15f4057abf feat: render hreflang alternates in page head for translated content (#1907)
* feat: render hreflang alternates in page head for translated content

Multilingual SEO previously stopped at the sitemap: translation
siblings were cross-linked with xhtml alternates there, but rendered
pages carried no hreflang annotations in their <head>.

Add a getHreflangAlternates() helper that resolves the alternate set
for a content entry with the same semantics as the sitemap route: one
alternate per published translation sibling (including a
self-referencing entry, per Google's recommendation), x-default on the
default-locale variant (falling back to the first routable variant),
unroutable locales dropped, drafts excluded, and an empty result when
i18n is disabled (no queries run). URLs are built from the collection's
urlPattern and localized through the Astro i18n routing config, so head
and sitemap always agree.

EmDashHead emits the links automatically when the page context carries
a content reference, as base-layer contributions so plugins can
override individual hreflang entries. The helper is also exported from
"emdash" and "emdash/seo" for hand-rolled heads, with a *WithDb variant
for callers that already hold a database handle. Result resolution is
request-cached.

Fixes #1690

* fix: exclude noindex variants from hreflang and validate site URL scheme

Match the sitemap's _emdash_seo filter: variants flagged noindex are
excluded from alternate sets, and a noindex entry emits no hreflang set
at all, so head and sitemap can't disagree on which variants are
discoverable. Also reject relative site URLs — hreflang requires
fully-qualified URLs, and EmDashHead's isSafeHref would silently drop
the malformed links.
2026-07-10 14:15:17 +01:00
Kevin Kyburz 60811c0313 feat: toolbar config option with client-side bootstrap mode for shared caches (#1886)
* feat: add toolbar config with client-side bootstrap mode for shared caches

Implements the design agreed in Discussion #1742: a `toolbar` option with
"server" (default, unchanged), "client" (cache-identical public HTML with a
client-rendered Edit pill and an `_edit` query param for fresh editor
renders), and `false` (disabled). The toolbar is now also dismissible in
the browser.

* fix: mark _edit canonical redirect and render as uncacheable

Addresses review: the 302 for non-editors now carries
Cache-Control: private, no-store, and both the redirect and the
editor render opt out of the route cache (a cached redirect would
bounce editors back to the canonical URL).

* test: extract bootstrap script by index instead of regex

CodeQL flags the <script>…</script> regex as a bad HTML-filtering
pattern (case-sensitive tags). Use indexOf/lastIndexOf — we are
slicing our own generated string, not filtering HTML.
2026-07-10 12:11:10 +01:00
marcusbellamyshaw-cell 58f594b596 fix(core): implement pagination for search() and searchCollection() (#1463) (#1578)
* fix(core): implement pagination for search() and searchCollection() (#1463)

The search API advertised keyset pagination through its types
(SearchOptions.cursor, SearchResponse.nextCursor) but searchWithDb never
read the incoming cursor nor populated nextCursor, so results were silently
capped at `limit` with no way to fetch a second page — "load more" buttons
wired against the documented shape never appeared.

Results are merged from per-collection FTS queries and re-sorted by score, so
there is no single stable keyset column to encode the way getEmDashCollection
does. Page the merged, score-sorted set by offset instead, carried opaquely in
the cursor's orderValue (reusing encodeCursor/decodeCursor for the same
base64-JSON shape and InvalidCursorError handling). Each collection fetches its
top (offset + limit + 1) rows so the merged window ranks correctly and a
further page is detectable; a nextCursor is issued only when more results
exist past the page. searchCollection() gets the same treatment.

The /_emdash/api/search endpoint now accepts a `cursor` query param and returns
nextCursor; a malformed cursor surfaces as a 400 INVALID_CURSOR via the
existing handleError mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: format search-pagination changeset with oxfmt

Convert code-block indentation to tabs to satisfy `oxfmt --check`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(core): expose search cursor to MCP, validate search cursor marker, cap offset

Addresses review findings on #1578:
- MCP `search` tool now accepts and passes through a `cursor` argument
  so MCP clients can consume the pagination the API and search()
  already advertise.
- decodeSearchOffset now checks the cursor's marker before trusting
  orderValue, so a numeric orderValue from an unrelated cursor type
  (e.g. content-list) is rejected instead of silently misread.
- Adds a MAX_SEARCH_OFFSET ceiling so a forged cursor can't force an
  unbounded offset + limit + 1 fetch per collection.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:59:15 +01:00
Kevin Kyburz e4e76f5a55 fix(comments): verify Turnstile tokens server-side on comment submission (#1854)
* fix(comments): verify Turnstile tokens server-side on comment submission

The CommentForm widget rendered Turnstile and submitted its token, but
the public POST endpoint never verified it — bots POSTing directly to
the API bypassed the challenge entirely (#1589).

When a Turnstile secret key is configured (EMDASH_TURNSTILE_SECRET_KEY
or TURNSTILE_SECRET_KEY), the route now verifies the token against
Cloudflare's siteverify API before persisting and rejects submissions
without a valid token (fails closed on siteverify errors too). Without
a configured secret the behavior is unchanged.

Also resets the Turnstile widget after failed submissions so a retry
gets a fresh single-use token.

* fix(comments): timeout siteverify, reset Turnstile on network errors, test IP forwarding

Review follow-ups: abort the siteverify subrequest after 10s so a slow
Cloudflare API fails closed instead of hanging the comment POST, move
the widget reset into finally so network errors also produce a fresh
single-use token, and add a test asserting the trusted remote IP is
forwarded to siteverify.
2026-07-09 10:08:48 +01:00
Noah (Nguyen Pham) c57b12ba07 Add media usage repair admin API (#1867)
* feat(core): add media usage repair admin API

* feat(core): complete media usage repair API surface

* chore(core): polish media usage repair API

* style: format

* test(core): remove media repair GET export assertion

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-07-08 11:43:51 +01:00
Matt Kane 8107edf8cf docs: document Cloudflare scheduled publishing setup (#1836)
* docs: document Cloudflare scheduled publishing setup

* ci: update query-count snapshots

* Update wording and asides

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-07-06 15:07:33 +01:00