Changelog

What changed, straight from the plugin repositories.

payload-puck

0.11.0 - 2026-11-09

Security

  • Anonymous reads no longer expose unpublished pages. The generated pages collection previously allowed anonymous callers to read drafts through Payload's REST API (GET /api/pages?draft=true and /api/pages/:id?draft=true). Default read access now filters anonymous callers to _status: 'published'; authenticated users can still read all pages. Consumer access.read and collectionOverrides.access.read continue to override the default. With drafts turned off (collectionOverrides: { versions: { drafts: false } }) there is no _status field and every save is live, so anonymous reads stay allowed. Breaking for code that reads drafts without a signed-in user, such as a preview route calling the REST API with draft=true: pass the user (or overrideAccess: true in a trusted server route), or set access.read.

Added

  • Starter example typecheck. pnpm typecheck:examples checks examples/** against the current source entry points using paths that mirror the package exports. CI runs it on Node 20 and 24.

Fixed

  • Starter root prop mappings preserve SEO edits. Removed mappings to nonexistent seo.* fields that overrode the working meta.* defaults. The remaining pageLayout example is configured identically on the plugin and custom route so admin loading and route saving agree.
  • Plugin theme reaches the admin editor. createPuckPlugin({ theme }) now passes the theme to the integrated editor's existing ThemeProvider, including its canvas and preview. An explicitly configured plugin theme takes precedence over PuckConfigProvider; omitting it preserves the provider fallback. Standalone editors still use PuckConfigProvider or PuckEditor, and frontend rendering uses ThemeProvider.
  • Working starter files. Replaced stale examples with the documented routes, layouts, theme and custom component. Imports use @puckeditor/core and public plugin exports; frontend queries require published pages; custom API authentication preserves payloadUser for collection access; the standalone editor includes its /restore route. The plugin configuration is an explicitly typed fragment to merge into an existing Payload config with its own db and secret.

Documentation

  • Documentation moved to https://plugins.delmaredigital.com/docs/puck. The GitHub Pages site now redirects there.
  • The example file guide and README now direct users to copy starters from GitHub or the docs page; examples are not shipped in the npm package.
  • Corrected the documented focus-ring default to focus:ring-ring, identified the public CTA theme helpers as utilities for custom components (unused by built-ins), fixed preset imports to use /config/editor, and updated the exported theme example to wrap frontend rendering in ThemeProvider.

payload-page-tree

0.4.1 - 2026-11-09

Security

  • folder-impact requires READ on the folder: The endpoint now checks the requesting user's READ access on folderId before counting. An unreadable folder returns the same 404 { error: 'Folder not found' } as a missing one, so the descendant page count of a hidden folder is no longer exposed. The count beneath a readable folder still includes hidden pages, because the cascade has to update them.
  • check-segment and create require READ on the parent folder: When parentId is given, both endpoints READ-check that folder before the trusted segment lookup. An unreadable parent returns the same 404 { error: 'Folder not found' } as a missing one, so hidden folders can no longer be probed for their contents. Root requests are unchanged. A "segment is taken" answer beneath a readable folder remains possible, as before.
  • move requires READ on the destination folder: Moving a page or folder into a newParentId the user can't read returns the same 404 { error: 'Folder not found' } as a missing folder, and nothing is written. The target is still checked first, so a missing or unreadable target keeps its own 404.
  • check-segment requires READ on excludeId: An excludeId naming a document the user can't read (or one that doesn't exist) returns 404 { error: 'Not found' }, so excluding a hidden document no longer reveals whether it holds the segment. The tree only sends the record being edited, which the user can already read.

Fixed

  • Recursive delete reports real errors: The READ preflight now turns only access outcomes (403/404) into the 403 "unreadable documents" response. Database and other errors propagate as 500 with their message, and nothing is deleted.
  • Folder update denials return 403: Moving or renaming a folder the user can read but not update now returns 403 { error: 'Update denied' }, matching pages. Previously move returned 404 and rename 500. Rename, create, check-segment and folder-impact now also pass through 403/404 statuses instead of reporting every failure as 500.
  • Sequential folder reorder: Folder reorder writes one folder at a time instead of running concurrent updates over the shared request, which could interleave inside a single database transaction.
  • Folder URL cascades include trashed pages: On trash-enabled page collections, the cascade inventory, READ preflight and slug update include soft-deleted pages, so restoring one no longer brings back a stale URL. Like any other page, an unreadable trashed page refuses the folder change. Pages beneath a trashed folder are still skipped, because their path cannot be resolved through a trashed ancestor.

payload-page-tree

0.4.0 - 2026-11-09

Documentation

Fixed

  • Collection READ access on tree operations: Reads that select user-operated documents or return data to the caller (tree listings, redirects, and migration/regeneration selections) share the requesting user's req and enforce collection READ rules. Hidden documents stay out of these results.
  • Complete slug maintenance: Stored slug ancestry, write-path collision checks, and cascade inventories use trusted access while retaining req and its transaction. Hidden ancestors remain in stored paths and hidden slug owners remain reserved. Folder URL changes require READ and UPDATE rights on every affected page; otherwise the change is refused and nothing is written within the request transaction. A READ preflight runs before the folder or any page is written, including without transactions; a later UPDATE denial requires transactions to roll back earlier writes. Server calls without a user keep full access. Cascade failures propagate instead of silently leaving stale URLs.
  • Consistent URL availability and impact counts: check-segment uses the same trusted sibling probe as create/edit-url, so hidden siblings are reported as unavailable before submission. folder-impact uses trusted descendant counts to show the full number of pages the cascade will change, including unreadable pages and pages beneath unreadable folders. These endpoints return only availability or a count, never documents.
  • Move/reorder results reflect writes: Missing and unreadable pages return the same generic 404; UPDATE denials after a successful READ return 403 instead of false success. The tree sends the page collection with moves. Both endpoints accept an optional validated collection for page operations, preserve lookup of the first existing ID for older callers, and never fall through to another collection after a READ or UPDATE denial. Empty reorder batches return 400.
  • Recursive deletion includes trash: On trash-enabled collections, recursive folder deletion includes soft-deleted pages/folders in its trusted inventory and READ preflight, and permanently deletes them along with the rest of the subtree.
  • Atomic recursive folder deletion: The entire subtree must pass READ checks and each deletion enforces DELETE access. A denied descendant rolls back the request transaction, preserving the subtree. Without database transactions (for example MongoDB without a replica set) the READ preflight still runs before anything is deleted, but a DELETE denied part-way cannot be rolled back.
  • Page deletion targets the selected collection: The tree now sends the page's collection, and the delete endpoint validates it and deletes only there. Documents with the same id in another collection are preserved. Missing pages and denied deletes retain their 404/403 responses.
  • Folder cascade history reasons: Descendant slug changes now retain the originating move or edit-url reason instead of manual. The URL History modal displays edit-url as "URL Edited". No new history enum value or database migration is needed.
  • Custom segment field names: segmentFieldName and pageSegmentFieldName now apply consistently to create, duplicate, migrate, edit/restore URL, availability checks, folder cascades, and the tree's folder URL display and confirmation text.
  • Empty folder segments preserve ancestors: A folder with no URL segment contributes nothing itself while descendant URLs retain the path segments of its ancestors.
  • Historical changelog version numbers: Corrected the March 0.4.0 and 0.4.1 headings to their actual releases, 0.3.10 and 0.3.11, restoring descending version order.

Changed

  • Delete endpoint contract (breaking for direct API callers): Deleting a page now requires a collection query parameter naming a configured page collection. Missing or invalid collections return 400. Folder deletion keeps its existing contract.
  • Request-aware server helpers: getRedirectMap and getFolderPath accept an optional trailing { req, overrideAccess } argument. Supplying req enforces READ access by default; existing calls without options retain their trusted server behavior. Explicit overrideAccess takes precedence.
  • BuildSlugArgs.folderPath type: Corrected from string | null to string, matching the existing runtime behavior: pages at root receive ''.

payload-better-auth

0.14.2 - 2026-11-07

Security

  • Postgres incrementOne and consumeOne now use one guarded SQL statement for eligible collections. The Local API's read-then-write path could lose concurrent counter updates or let multiple callers consume a single-use credential. Counters use COALESCE(column, 0) + delta; consumption uses DELETE … RETURNING. Both select at most one identity and repeat the caller's guard on the outer write. No migration is required.
  • adapterConfig.atomicWrites defaults to 'auto'. Sanitized collection/field hooks (except field beforeDuplicate), trash, and versions/drafts force the non-atomic Local API path. These policy refusals now warn once per collection and reason, including guidance to review hooks before opting into 'always'. Payload's duplicate-only defaults on unique fields do not disqualify OAuth token/client or rate-limit collections. Admin base filters do not apply to these Local API writes and no longer cause refusal. Virtual, join, and ui fields are skipped during column eligibility checks; stored nested/has-many/localized shapes remain unsupported. Table lookup follows Payload's exact snake-case rules.
  • 'always' bypasses hooks after application review and throws for SQL incompatibility. Missing connection/table/column metadata, unsupported stored fields or writes, and untranslatable predicates always log console.error once per collection and distinct reason; 'auto' falls back to the Local API, while 'always' throws. Trash and versions/drafts still require Payload's lifecycle operations and warn before falling back even under 'always'. A future Better Auth predicate shape that cannot be translated will throw under 'always' until patched; this is why 'auto' is the default. 'never' explicitly uses the Local API. Debug logging additionally reports the first atomic execution and first fallback per collection. Other stores retain the Local API and its concurrency limits.
  • Malformed ID guards fail closed. This is stricter than Payload's query builder, which validates primary-key UUIDs but passes relationship UUIDs through. Invalid UUIDs and nonnumeric serial primary/relationship IDs are nonmatching, including ne, rather than allowing an invalid exclusion to broaden a credential write to every non-null row. Better Auth's coercion of malformed serial IDs to NaN is covered. Explicit NULL semantics are preserved.
  • drizzle-orm 0.45.2 is now a regular dependency, installed automatically with no consumer action. Static imports match Payload 3.92.1 and include the atomic SQL path in production Next.js/Vercel/standalone bundles without runtime adapter discovery. A test compares the pin with installed dev Payload; a separate daily Payload Drizzle canary compares the latest published @payloadcms/drizzle requirement and opens or updates an issue labelled payload-drizzle-canary on drift before dev Payload is bumped.
  • CI runs the real Postgres 17 integration suite for both UUID and default serial IDs: concurrent increments, single-use consumption, OAuth refresh-token guards, malformed identifiers, row-shape parity, and Better Auth's ten-failure TOTP lockout (900 seconds). The job rejects an empty database URL or a report with skipped tests/fewer than 16 passing tests.

Internal

  • Dev dependencies refreshed within their ranges: Better Auth 1.7.15, Payload 3.92.1, Next 16.4.3, React 19.3.1. Generated schema snapshots are unchanged. No change to the published package.
  • TypeScript 7.0.2 (from 5.9.3). Generated declarations match the previous output apart from quote style in one literal type, and the adapter contract guard still fails on a missing method.
  • Vite 8, Vitest 5, jsdom 30, @vitejs/plugin-react 6, @testing-library/jest-dom 7 and @swc/cli 0.8. Build output is unchanged.

payload-experiments

0.4.0-rc.3 - 2026-11-07

plugin

  • Fixed: Puck publish refusals name the invalid click goal and its block in the Save Failed dialog, while preserving Payload's field-level errors. Migration impact: none.
  • Fixed: Advanced on the goal form and the test page now opens in the same update as the error that needs it. Migration impact: none.
  • Fixed: A duplicate short name on a non-click goal now points to the Short name field, not to Advanced. Migration impact: none.
  • Changed: README rewritten for the people who install and use it; full reference kept in REFERENCE.md until the docs site. Migration impact: none.

payload-experiments

0.4.0-rc.2 - 2026-11-07

plugin

  • Changed: choosing a page in the new-test wizard and in a test's Versions now uses one searchable field that shows the chosen page. Migration impact: none.
  • Changed: Tests and Goals appear as a regular "Tests" nav group, the first named group, visible to managers only. The nav highlights on the Tests home and Goals page; on deeper test pages the surface breadcrumbs show where you are. Import-map keys changed: added @delmaredigital/payload-experiments/admin/rsc#TestsCollectionListView, @delmaredigital/payload-experiments/admin/rsc#GoalsCollectionListView, @delmaredigital/payload-experiments/admin/rsc#GoalEditRedirect; no longer referenced: @delmaredigital/payload-experiments/admin/rsc#CollectionListRedirect, @delmaredigital/payload-experiments/admin/rsc#TestsNavLinkServer, @delmaredigital/payload-experiments/admin/client#TestsNavLink, @delmaredigital/payload-experiments/admin/client#TestsDashboardCards. Hosts must regenerate their import map; without regeneration, Payload falls back to its raw DefaultListView table at both collection URLs, and /collections/xp-metrics/create and /collections/xp-metrics/<id> show Payload's raw Goal form. The old exports remain until 0.5.0. Migration impact: none; regenerate the import map; generated types reorder only. Host migrate:create diffs by name, so this collection-order change is a no-op.
  • Fixed: Copy the Original, and other version changes in the block panel, failed when a version or any block on the page held an empty list. Migration impact: none.
  • Changed: Unwrap follows the test's status, with draft deletion, stopped-test confirmation, and specific error text when a change fails. Migration impact: none.
  • Fixed: click-goal field layout in the admin and contrast in the page editor's field sidebar when the admin is in dark mode. Sidebar styling keys off Puck's field-panel class, checked by a test against the installed Puck. Migration impact: none.
  • Fixed: Publishing a Puck page whose components name a click goal that does not exist is now refused, with the component's location and "No click goal has this name yet. Create it with "+ New click goal"." Draft saves are still allowed, matching Payload block fields. The check runs for managers (access.manage); a malformed goal name is refused for everyone. On a Puck collection without drafts every save is checked. Migration impact: none in code. A page that already names an undefined click goal cannot be published by a manager until the goal is created or the name is removed.
  • Fixed: Start and Decide on a stopped block test whose Experiment block was removed from its page are refused with "The block for this test was removed from its page, so it can't start again. Its results are kept. To test the block again, start a new test from the page." (new refusal code placement-removed). Archive still works. Migration impact: none. Hosts that switch over refusal codes gain placement-removed.
  • Fixed: REST errors from the tests collection keep their refusal code for every refusal, not only stale-edit, so the admin shows the specific message (for example deleting a test that is no longer a draft, invalid-status, 409). Migration impact: none.
  • Corrected (0.4.0-rc.1 entry): admin.editorUrl now receives a required editor argument ('puck' | 'blocks' | 'page') as well as doc. A host that types its own editorUrl function, or calls it in tests, must accept or pass editor. The rc.1 entry said "Migration impact: none"; that was wrong. Migration impact: TypeScript hosts update the editorUrl signature and any direct callers; runtime behaviour is unchanged.

payload-page-tree

0.3.18 - 2026-11-06

Fixed

  • Duplicating legacy pages no longer fails with "The following field is invalid: slug" or copied array/block row ids: Pages without a stored segment now use the last segment of their existing slug, and the copy goes through Payload's duplicate operation so row ids are regenerated and host beforeDuplicate hooks run. The copy starts with no URL history and picks a segment whose resulting slug is free, including with custom slug builders. The create endpoint uses the same slug-aware availability rule.

Upgrade notes

  • Duplicate now respects a collection's disableDuplicate: true and returns an error for it, as Payload's own duplicate does; previously the tree copied such documents anyway. With drafts enabled, the copy is made from the latest draft, and the segment and folder are read from that same version.

payload-puck

0.10.3 - 2026-11-05

Added

  • Integration bundles for config helpers and the editor. mergeConfigs / extendConfig, PuckConfigProvider and PuckEditor accept integrations to add components, categories and optional editor plugins. Host components win, category names are appended without duplicates, and named integration plugins are deduped against prop, context and earlier integration plugins. applyIntegrations and the PuckIntegration type are exported from /config, /config/editor and /client. The seam exists so bundles such as @delmaredigital/payload-experiments 0.4.0 can add components, categories and editor plugins without the host rebuilding its config. Existing configurations work unchanged without integrations.

payload-experiments

0.4.0-rc.1 - 2026-11-05

plugin

  • Changed: TestsSurfaceProps accepts optional collectionTitles, userId and userCollection so plugin pages no longer depend on Payload UI. Migration impact: none; the root view fills these props.
  • Added: the Goals page creates click goals with "A click on the page", with their short name under Advanced. Migration impact: none.
  • Added: goal pickers group defined goals found on the test's page under "On this test's page", with a note explaining that linked-document and code goals are not listed. Migration impact: none.
  • Added: xpGoalField() has a manager picker with inline "+ New click goal" creation and a warning for undefined keys. Migration impact: new importMap component key (XpGoalField). Run payload generate:importmap.
  • Added: xpGoalPuckEditorField() from /puck/editor provides the click-goal picker and inline creation for the Puck sidebar. Migration impact: use it in editor configs, with xpGoalPuckField() from /puck in render configs; no database change.
  • Added: each results round with a click primary goal explains that clicks show interest and suggests adding the desired outcome as another goal. Migration impact: none.
  • Added: manager-only GET /xp/pages accepts an optional path query to resolve a page's title and document link. Migration impact: none.
  • Added: readiness, add-goal warnings and results name and link page and block placements when they can be resolved. Migration impact: none.
  • Changed: PhaseSummary.metrics[] gains optional clickGoal: true, derived from the round's frozen goal definition. Migration impact: readers may adopt the optional marker; no database change.
  • Added: experimentsPuck() and experimentsPuckEditor() bundle the test block, its sidebar category and the outline plugin into one object per side for payload-puck 0.10.3's integrations; the separate pieces stay exported. Migration impact: none; hosts opt in by passing the objects to integrations.
  • Added: click goals (click:<short-name>), delegated client click beacons, defined-key validation and published-content fairness checks at Start, add-goal and results runs. Click goals may be primary or guardrail goals. Migration impact: no plugin database change; render data-xp-goal on the host's interactive elements when adopting click goals.
  • Added: xpGoalField() from the package root and xpGoalPuckField() from /puck mark interactive content with click-goal keys. Migration impact: hosts adopting the Payload field add one nullable text column per table using xpGoal, including version tables, with payload migrate:create; the Puck prop adds no column.
  • Added: AddMetricWarning, AddMetricResult and ClickKeysResult types from the package root, and manager-only GET /xp/experiments/:id/click-keys. Migration impact: no database change; hosts may adopt the typed response and key discovery endpoint.
  • Changed: /client assignments support optional clickGoalsArmed, supplied by toClientAssignments from /rsc. Migration impact: no database change; hosts constructing client assignments may adopt the optional field.
  • Changed: onInit refuses host metricSources ids starting with the reserved click: prefix. Migration impact: rename any registered source using that prefix before upgrading.
  • Changed: ReadinessCode and RefusalCode gain goal-in-some-versions and placement-unchecked. Migration impact: update exhaustive switches and records over these unions; no database change.
  • Changed: Snapshot gains optional goalFairnessNotice, and PreflightResult gains optional goalNotes. Migration impact: readers may display the notices; no database change.
  • Changed: add-metric responses always include warnings (goal-in-some-versions, goal-added-mid-test and placement-unchecked); the add-goal dialog displays them, and asymmetric guardrail click goals are refused with goal-in-some-versions. Migration impact: handle warnings and the new refusal when calling add-metric; no database change.
  • Changed: the Goals "Measured by" list (serializeMetricSources) offers "A click on the page". Migration impact: no database change; hosts can adopt click goals without registering a metric source.
  • Changed: publishing with an undefined xpGoal key is refused by field validation, as are draft saves when the collection validates drafts (versions.drafts.validate). Migration impact: define the click goal before publishing content that uses its key (or saving a validated draft); no plugin database change.
  • Changed: clicks before a goal was added mid-test are excluded from that goal's results for every version. Migration impact: no database change; historical clicks before the add do not contribute to the newly added goal.
  • Added: an "A whole page" test card that walks through choosing the Original and a page for each version, a page picker on whole-page tests, and step-by-step copy instructions (or the host's copyHint). Migration impact: none.
  • Added: "A block on a page" lists pages edited with a blocks field too. Migration impact: none.
  • Added: "Test a block on this page" for blocks collections, and the Experiment block's status line, version labels, "Copy the Original into this version", version sync, and Unwrap. Migration impact: no database columns; new importMap component keys (TestBlockField, BlockStatusField, BlockSlotRowLabel, CopyOriginalField). Run payload generate:importmap.
  • Added: Experiment blocks for registered Payload blocks fields, with Original/version rendering, viewport groups, draft wrapping and winner replacement in published content and drafts. Migration impact: hosts adopting createExperimentBlock add the Experiment block and slots tables, their version tables, and the live/version slot enum types (enum_<coll>_blocks_<slug>_slots_slot and its _v counterpart) with payload migrate:create.
  • Added: the package root exports createExperimentBlock, and /blocks exports controlOnlyBlocks, segmentExperimentBlocks and EXPERIMENT_BLOCK_SLUG for host blocks fields. Migration impact: import createExperimentBlock from the package root and the helpers from /blocks; no additional database change.
  • Added: resolveExperimentBlocks from /rsc resolves versions and rendered exposures for host block rendering. Migration impact: wire the resolver into the host renderer; no database change.
  • Added: ExperimentGroup from /client provides viewport exposure groups. Migration impact: wrap resolved viewport groups in the host renderer; no database change.
  • Added: manager-only POST /xp/blocks/wrap wraps a block in the latest draft, respects document locks and caps requests at 16 KiB. Migration impact: none.
  • Added: form-state helpers wrapExperimentBlock, syncExperimentSlots, copyOriginalIntoVersion, unwrapExperimentBlock and findRegisteredBlocksField from /admin/client. Copy helpers take field config to refresh only array and blocks row ids. Migration impact: pass the registered field and global block config when adopting the helpers; no database change.
  • Changed: onInit refuses registered blocks fields without the Experiment block or nested in a named group, array or tab. Migration impact: register a top-level blocks field through rows, collapsibles or unnamed tabs, and add the Experiment block before booting.
  • Changed: Start refuses placement-unpublished for Experiment blocks and Puck slots found only on drafts; empty-slot checks published rows only. Migration impact: publish placement pages before starting existing Puck tests; no database change.
  • Changed: saves on every registered pages, blocks and puck collection take a guard read and locks, and can refuse page-busy or interacting-tests. Migration impact: retry busy saves and finish interacting tests before publishing; no database change.
  • Changed: deciding a stopped page test refuses page-target-unpublished for an unpublished Original or winner page; losing variants do not block the decision, and lookup errors propagate. Migration impact: publish routed pages before deciding; no database change.
  • Added: frozen placement documents and guards against whole-page tests interacting with block or Puck tests, including bounded, retryable saves. The placement guard is installed on every pages, blocks and puck collection. Migration impact: nullable xp_phases.placement_docs; run payload migrate:create and payload generate:importmap (still required by the status field above).
  • Changed: results health preserves page and goal checks together, including placement-unchecked rows with placement identifiers when content cannot be read. Snapshot.health and SummaryResponse.health are unions; TypeScript readers of .path must narrow the health row first. Migration impact: no database change; update affected TypeScript readers.
  • Added: whole-page content checks and copying guidance per page collection, admin.descriptions.page, and page collection selection on tests. Identical versions warn; versions at the same address are refused. Migration impact: nullable xp_experiments.page_collection and _xp_experiments_v.version_page_collection; run payload migrate:create.
  • Added: manager-only page browsing and page/test status endpoints, preserving published addresses while drafts are edited. Page registrations get a sidebar status field; page and blocks registrations get composed content-change auditing. Migration impact: hosts with pages run payload generate:importmap; auditing adds no columns.
  • Added: pageKindSeoContract from /rsc to test host metadata and sitemap wiring. Migration impact: none.
  • Added: permanent retirement of deleted test short names; new tests choose an unused suffix. Migration impact: nullable xp_state.retired_slugs; run payload migrate:create.
  • Changed: legacy page tests resolve one collection from the Original (or the sole registration), then use it for every version. Migration impact: none; register pageCollection explicitly when several collections share an address.
  • Changed: Puck consumers share the document-editor appliesTo rule, and admin.editorUrl receives editor and doc. Migration impact: none. (see 0.4.0-rc.2: corrected)

BREAKING

  • onInit refuses a registered blocks field that lacks the Experiment block or sits inside a named group, array or tab, and refuses host metricSources ids starting with the reserved click: prefix. Fix the registration, or rename the source, before booting.
  • Saves on every registered pages, blocks and puck collection take a guard read and advisory locks, and can be refused page-busy (retry) or interacting-tests (finish the other test first).
  • Start refuses placement-unpublished for Experiment blocks and Puck slots found only on drafts, so an existing Puck test whose slot is only on a draft can't start until its page is published.
  • Deciding a stopped page test refuses an unpublished Original or winner page (page-target-unpublished).
  • TypeScript: Snapshot.health and SummaryResponse.health are unions (narrow before reading .path); ReadinessCode and RefusalCode gain goal-in-some-versions and placement-unchecked (exhaustive switches add them); add-metric responses always carry warnings.
  • Migration impact: four nullable plugin columns, no backfill: xp_experiments.page_collection and its version counterpart _xp_experiments_v.version_page_collection, xp_phases.placement_docs and xp_state.retired_slugs; run payload migrate:create. Hosts adopting createExperimentBlock also add, per blocks field, the Experiment block and slots tables, their _v version tables and the live/version slot enum types; hosts adopting xpGoalField() add one nullable text column per table using it, version tables included (xpGoalPuckField() adds none). Run payload generate:importmap for the new component keys PlacementStatus, TestBlockField, BlockStatusField, BlockSlotRowLabel, CopyOriginalField and XpGoalField (all @delmaredigital/payload-experiments/admin/client).

payload-experiments

0.3.0-rc.4 - 2026-11-04

plugin

  • Changed: the accent contrast grid tests are restructured to finish within CI's time limit; no runtime change. Migration impact: none.

payload-experiments

0.3.0-rc.3 - 2026-11-04

plugin

  • Fixed: the Tests group in the admin nav remembers being collapsed. A host nav group must not also be called "Tests" (it would share the group's state). The nav slot moves to /admin/rsc#TestsNavLinkServer; regenerate the import map (it adds /admin/rsc#TestsNavLinkServer; until then the Tests nav group does not appear). The old /admin/client#TestsNavLink export remains available.
  • Fixed: with a custom theme.accent, buttons always show a visible hover and pressed state, including accents right at the contrast limit; in dark mode such accents darken on hover so the step stays visible.
  • Added: @delmaredigital/payload-experiments/admin/theme.css and /admin/ui.css stylesheet exports, for hosts that run Puck outside Payload's admin; such a page also loads Payload's admin styles and sets data-theme (see the README). Inside the admin nothing changes.
  • Fixed: the block's test picker in the Puck sidebar renders inside its own field root, styled by the plugin's admin styles.
  • Added: the ExperimentsPageCollection type names one entry of the pages option. Migration impact: none.
  • Changed: page lookups at start stop at the first collection that has the page published, and run per version in parallel. Migration impact: none.
  • Fixed: the Tests home, goal and layer lists, the block's test picker and new-test names read every page, so sites with many tests no longer lose items past the first 100–200 or fail to create a test with a common name.
  • Changed: CI workflows pin every action to a commit and give each job only the permissions it needs; only the publish job can request an npm OIDC token. Migration impact: none.

payload-experiments

0.3.0-rc.2 - 2026-11-04

plugin

  • Fixed: starting a whole-page test and deleting, renaming or unpublishing one of its pages at the same moment can no longer leave the test running on a page that's gone: one waits for the other, and the later one is refused. A page whose test address was saved with a trailing slash is protected too. Migration impact: none.
  • Added: the Experiment block in the Puck editor is a test builder: add up to three variants, rename, copy the Original into a version, remove a version, and set the traffic split, while the test is a draft or stopped; a running test shows people per version and the current leader.
  • Fixed: publishing a page through the API or "Make it the content" no longer adds a false "content changed" marker to the results chart when only linked images or documents were expanded, including collections without drafts. Migration impact: none.
  • Fixed: a whole-page test can't start while another running or decided test uses one of its pages, so visitors are never counted for a page they didn't see. Deciding a stopped whole-page test is refused the same way if another test has since started on its pages. A page an archived test still sends to its winner counts as in use until you release its pages or discard its result. Migration impact: none; hosts switching exhaustively on ReadinessCode add 'page-in-use'.
  • Fixed: when someone else changed a test while you were editing it, the message is always the admin's own wording.
  • Added: experimentOutlinePlugin() (from /puck/editor) hides a block's empty version slots that its test doesn't use from the Puck outline; a slot with content that isn't part of the test stays visible with a warning. Add it to your Puck plugins.
  • Fixed: while a whole-page test is live, the Original's URL keeps a canonical to itself even when a visitor is served a version page there, so search engines never treat the Original as a copy of a noindexed version. Hosts merge experimentMetadata after their own metadata so its canonical wins. Migration impact: none.
  • Fixed: after a whole-page test is archived, trying to delete, rename or unpublish the Original page now says its address shows the winner, instead of calling the Original the winner. Migration impact: none.
  • Fixed: Preview opens its new tab in Safari and other browsers that block pop-ups opened after a delay; a refused preview shows plain-language copy instead of the server's message.
  • Added: admin.previewUrl may be async and receives the placement's title, published state and slug; returning null hides preview for that placement, and the admin never shows "Preview isn't set up". The old ({ collection, id }) => string form still works, and several slots on one page version share one lookup. Migration impact: none.
  • Fixed: an event recorded in a visitor's own browser is always credited to that visitor when they saw the test, even if their link to a signed-in account can't be used. Migration impact: none.
  • Changed: the Experiment block in the Puck editor uses the new layout: name and status, a tab per version, and clear actions.
  • Added: createExperimentSlotEditor / createExperimentSlot take the host's admin and API routes (and accent); hosts on non-default routes switch to them.

payload-experiments

0.3.0-rc.1 - 2026-11-03

plugin

  • Added: a manager can dismiss one failed entry in the "Make it the content" report (for a page deleted or copied by hand), so the test can be archived with its result. In the admin, each page that couldn't be updated has Dismiss (and Undo), Finish archiving appears once every page is updated or dismissed, and a test that archives itself says so. POST /xp/experiments/:id/content-report/dismiss dismisses an entry; POST /xp/experiments/:id/content-report/restore undoes a dismissal so the entry can be retried; contentDone(report) (exported from the package root) returns true when every entry is ok or dismissed (a present but empty report, as for page tests, is done; an absent report is not). Dismiss and restore refuse with not-decided, entry-not-found, entry-ok, entry-dismissed or entry-not-dismissed (new RefusalCode members; hosts switching exhaustively add them). Migration impact: none.
  • Fixed: a registration from someone else's browser no longer receives credit for the account owner's later activity. The identity conflict share now counts only users whose credited events span arms, so it can read lower than in 0.2.x. Migration impact: see BREAKING.
  • Changed: the Goals pages use the new layout; deleting a goal now asks for confirmation; choosing "Use this goal" from a test makes it that test's main goal.
  • Fixed: a decided test's results show the numbers recorded at the decision everywhere on the page; results tables scroll sideways on narrow screens with a visible edge.
  • Added: the results endpoint includes the test's name, kind and status (experiment), so people who can read results but not tests see the real title. Migration impact: none.
  • Fixed: a block test can't be given a page-path targeting rule, or started with one; it runs wherever the block is placed. Migration impact: none; hosts switching exhaustively on ReadinessCode add 'block-path-rule'.
  • Changed: the results page leads with the verdict and three key numbers, shows each outcome as a clear table ("Rate" or "Average"), and keeps the confidence chart under Details.
  • Fixed: when something a test refers to was removed, the admin says what is missing (a goal or a round) instead of saying the test no longer exists.
  • Fixed: a goal can't be deleted or have its short name or measurement changed while another save or Start is adding it to a test at the same moment. Migration impact: none.
  • Fixed: Make it the content now works on collections with hidden or restricted fields: it publishes from the live page, so draft edits never leak into the published version. Localized collections are refused with a clear message and flagged before Decide (not supported yet). Migration impact: none.
  • Added: ContentReportReason now includes 'localized' (exported from / and /rsc), and ReadinessCode includes 'make-content-unsupported'. Migration impact: none; hosts switching exhaustively on these unions add the new member.
  • Changed: test page sections use the new controls: a versions grid with a traffic split bar, rule cards for who takes part, and plain-language advanced settings.
  • Changed: the test page has a step list, numbered sections and a readiness bar with Start at the bottom.
  • Fixed: the retention sweep logs its "made no progress" warning whenever the results budget ends before it deletes anything, including between statements. Migration impact: none.
  • Changed: the Tests home shows tests as compact rows grouped by what needs doing, with a health label and one-line status for each; draft tests name the next step to finish setting them up.
  • Fixed: a test's round records the sequential tuning value it started with (5,000 when left on Automatic), and a rollout round keeps the test round's value, so results never change if the default changes later. Migration impact: none.
  • Added: the tests summary includes each draft or stopped test's next readiness step (next: { code, detail? }, ids only), so the home list can say what to do next. Migration impact: none.
  • Added: compileManifest and its input/output types are exported from /rsc, so host tests can build a manifest without importing from dist/ (a test helper: pass the same attributeKeys as the plugin options; its input shape may change in a pre-1.0 minor). Migration impact: none.
  • Fixed: when the analysis of a test fails, the server log now names the reason (a fixed code and message) instead of only the error type. Migration impact: none.
  • Fixed: a goal used by a test can't be deleted, and its short name and what it measures can't change, through any route (not only the Goals page). Migration impact: none.
  • Changed: an unknown pages.collections slug now throws Unknown experiments page collection during plugin setup. Register host page collections before this plugin. Migration impact: no database migration; hosts with collections added by later plugins must reorder those plugins.
  • Fixed: whole-page protection includes trash and version restores, with status-specific instructions; unchanged page health no longer creates repeated frozen snapshots, and page health reaches the Tests home. Decided tests check only the winner's and the Original's pages. Migration impact: none.
  • Fixed: pages in a whole-page test can't be deleted, renamed or unpublished while the test runs or while its result stands; the editor is told which test to stop or discard. Migration impact: none.
  • Added: a test whose version page stops resolving shows "Needs attention" after the next results run.
  • Added: optional theme.accent plugin option with derived light/dark fills, accessible accent text and focus rings, and foreground contrast validation.
  • Changed: admin design tokens and shared interface components for the 0.3.0 screens; Running and Ready use fixed green status colours, and the Puck editor stylesheet is generated from the same tokens.
  • Fixed: the Original's page is never marked noindex during or after a whole-page test; version pages reached at their own URL are noindex with a canonical to the Original while running or decided. Archived paths keep their canonical to the winner. experimentMetadata now reads the signed request context via headers(): call it only from a dynamic generateMetadata, never inside 'use cache'.
  • Fixed: a whole-page test cannot start while a version page is unpublished (hosts that set the new pages option).
  • Added: optional pages option (pathOf, findByPath) so the plugin can check version pages; see the README option table. Page protection hooks are not included. Migration impact: none.
  • Fixed: readiness checks no longer show raw error text from the site's code.
  • Fixed: results and make-content reports no longer store or show raw error text; they carry a reason code shown as plain copy, and the details go to the server log. Migration impact: none.
  • Added: an archived whole-page test's results offer "Release pages", which lets every address show its own page again; the result stays recorded.
  • Added: Release pages on archived whole-page tests removes their archived routing and page protection, and records who released them and when. Migration impact: four nullable columns: decision_pages_released_at and decision_pages_released_by on xp_experiments, and version_decision_pages_released_at and version_decision_pages_released_by on _xp_experiments_v; run payload migrate:create.

BREAKING

  • linkIdentity requires verified: boolean and returns 'linked' | 'verified' | 'skipped' | 'failed'. Pass true only from a request that proves account ownership in this browser; registration and session refreshes do not prove ownership.
  • Migration impact: one nullable column verified_at and one index on user_id in xp_identities; run payload migrate:create along with the release-pages columns above. Existing rows become unverified, which only narrows crediting. Optional backfill to keep old behaviour for old rows: UPDATE xp_identities SET verified_at = linked_at WHERE verified_at IS NULL;.

payload-experiments

0.2.0-rc.7 - 2026-11-03

plugin

  • Fixed: the test page refuses to overwrite someone else's newer changes; it shows "Someone else changed this test. Reload to see their changes." with a Reload button.
  • Added: edits to a test made from two places at once no longer overwrite each other: a save based on an outdated copy is refused with "Someone else changed this test. Reload to see their changes." (clients opt in with the x-xp-if-updated-at header; its name is exported as XP_IF_UPDATED_AT_HEADER from @delmaredigital/payload-experiments/admin/client).
  • Fixed: while a test is collecting data, results say which group is smallest and which has the fewest conversions, and how many each still needs, instead of counts that read as totals.
  • Fixed: after Recompute, the results page's status line shows the updated number of people.
  • Fixed: plugin logs no longer include error messages or database details; they carry the error name, code and stack frames. Migration impact: none.

payload-experiments

0.2.0-rc.6 - 2026-11-03

plugin

  • Fixed: the Puck outline names an Experiment block's versions (Original, Variant A, Variant B, Variant C) and lists the Original first.
  • Fixed: "Set up the test" and "View results" in the Puck editor open in a new tab, so the editor keeps unsaved changes and hosts that deny framing no longer break the canvas.
  • Fixed: newly started tests can show collecting results before anyone has seen a version; analysis failures report “Results could not be computed; this has been logged.” instead of “Try again”. Migration impact: none.
  • Added: the public /rsc runResults summary includes a failed count for non-budget errors, including scheduling and retention-sweep failures.

payload-experiments

0.2.0-rc.5 - 2026-11-03

plugin

  • Changed: the sidebar shows a Tests group (All tests, Goals) to users who can manage tests; the dashboard shows All tests and Goals cards to users who can manage tests. Hosts must run payload generate:importmap after upgrading to register the new dashboard component.
  • Every plugin collection and global now states versions explicitly (false, except Tests, which keep their version history). No schema change on Payload 3; prevents default versioning tables on Payload 4.
  • Fixed: preview links list each page once, even when it has a published version and a newer draft; a page holding the test more than once shows how many times.

Migration impact: none.

payload-experiments

0.2.0-rc.4 - 2026-11-03

plugin

  • Added Goals list and form pages inside Tests, reached through the "Manage goals" link. Migration impact: none.
  • Centralized admin theme tokens and moved Tests above collection groups, after any host-provided leading navigation links.
  • Audience list rules without fixed values now use keyboard-accessible chips and save arrays; fixed value lists keep their checkboxes, and existing comma-separated values remain readable.
  • Styled the Puck Experiment block header, warning, version tabs, selected-version copy action, empty drop zones and creation panel for host editor resets and both admin themes. The self-contained stylesheet is exported as @delmaredigital/payload-experiments/puck/editor.css. Hosts must load it inside the editor iframe: in the CSS that builds the iframe stylesheet, add @import "@delmaredigital/payload-experiments/puck/editor.css"; after @import "tailwindcss";. Alternatively, serve the exported file and append its URL to payload-puck's editorStylesheets, after the host stylesheet. The client import alone does not reach an iframe with syncHostStyles: false. A block already linked to a test no longer offers "Test this block"; it links to "Set up the test" while the test is a draft and to "View results" afterwards.
  • Release workflow: prereleases now publish under their own npm dist-tag (rc for this line), not next, and a tag whose version, or whose CHANGELOG notes, do not match the package is refused before publishing. Install release candidates with @rc or an exact version.

Migration impact: none. The Tests import-map path is unchanged.

payload-experiments

0.2.0-rc.3 - 2026-11-02

plugin

Fixed

  • Start and the other actions failed with 'The following field is invalid: Experiment' on hosts with numeric ids.
  • The Tests home failed to load summaries when the browser or server encoded the comma in the ids list.

Migration impact: none. Host step: update the package pin; no other action is needed when upgrading from rc.2. The importMap is unchanged.

payload-experiments

0.2.0-rc.2 - 2026-11-02

plugin

Fixed

  • Fixed next build failing on Next 16 for hosts whose route handlers or Payload config import /rsc, with "Could not parse module … app-route/vendored/contexts/app-router-context.js". In 0.2.0-rc.1, /rsc re-exported the Tests server views, which import next/navigation. The views now live on @delmaredigital/payload-experiments/admin/rsc; /rsc is route-handler-safe again and an import-graph test keeps it so.

BREAKING

  • ExperimentsRootView, CollectionListRedirect and CollectionEditRedirect moved from /rsc to /admin/rsc. Update direct imports and run payload generate:importmap after upgrading: the admin path constants changed.
  • Migration impact: none.

payload-experiments

0.2.0-rc.1 - 2026-11-02

plugin

Added

  • A full-screen Tests admin surface at /admin/experiments, reached from a single "Tests" navigation link: a home that groups tests (needs attention, running, ready to start, draft, decided, archived), a New test flow (a block on a page, or a setting), one test page with plain-language sections and an Advanced disclosure, a readiness banner that names the next step, and results with a recommendation sentence and Decide. Puck's Experiment block offers "Test this block" to create a test from the page.
  • ResultsResponse.decision lets results readers without manage access see the winner.
  • verdict.worstArm identifies the worse version on snapshots computed from this version.
  • Optional admin.editorUrl, admin.previewUrl and admin.descriptions host configuration, validated at registration and kept server-side.
  • Optional FlagPoint.description for setting help text.
  • summary.ready for draft and stopped tests when the caller can manage tests, using shared readiness reads across the batch.
  • Block preflight placements include optional url and editorUrl host links and per-slot content counts in slots.
  • Public ReadinessCode type covering the 28 readiness checks.

Changed

  • The old experiments edit-form components are no longer mounted. Goals and Test groups fields now have plain-language labels and descriptions.
  • New tests default to a 28-day planned duration. Preflight suggests a duration from power and reach even when a duration is saved.
  • Experiments list, edit and results routes hand over to the new Tests views. Experiments, layers and metrics leave the admin navigation while their routes remain reachable.
  • Metrics are labelled Goals; layers are labelled Test groups (advanced).
  • Experiment versions are capped at 50 per test.

Fixed

  • Start stays disabled, with the reason shown, until a test can start; the readiness banner names the next step. Previously Start could be pressed and then refused without a reason.

Removed

  • The listSummary list column and these /admin/client exports: StatusCell, SummaryCell, ExperimentActions, LockedFieldNotice, FlagPointSelect, ArmsField, AllocationField, TargetingAttributeField, TargetingOpField, TargetingValueField, ReviewPanel, PhaseHistory, ResultsView.

Migration impact

  • None (no column changes). At the release commit, regenerating the schema fixture and restoring its provenance header leaves git diff --stat -- tests/_fixtures/generated-schema.ts with no output.

BREAKING

  • /admin/client no longer exports StatusCell, SummaryCell, ExperimentActions, LockedFieldNotice, FlagPointSelect, ArmsField, AllocationField, TargetingAttributeField, TargetingOpField, TargetingValueField, ReviewPanel, PhaseHistory or ResultsView. Run payload generate:importmap and commit the regenerated import map; any host code importing these names must drop them. The Tests surface is registered through admin.dependencies, so without the regenerated import map the Tests routes show a 'not installed' message instead of the Tests screen.

payload-better-auth

0.14.1 - 2026-11-01

Fixed

  • The jwt plugin works at its defaults (#52). The adapter queried a jwkss collection while betterAuthCollections() registers jwks, so the first token request failed with "The collection with slug jwkss can't be found". The same mismatch affected any model name ending in s (class queried classs). The schema: { jwks: { modelName: 'jwk' } } workaround still works and can be removed. If you hand-registered a jwkss collection as your own workaround, rename its slug to jwks: the adapter now queries jwks.

payload-experiments

0.1.0-rc.7 - 2026-11-01

plugin

Fixed

  • Flag experiments created by a request without manage access (migrations, scripts) no longer roll back the caller's transaction: the flag-params hook read xp-state with access checks, and Payload killed the transaction on Forbidden before the hook swallowed the error.
  • The layers beforeDelete hook reads referencing experiments without a second access check, so a refused nested read cannot kill the caller's transaction or hide a referencing experiment. Read errors propagate and abort the delete.

Migration impact

  • None.

payload-experiments

0.1.0-rc.6 - 2026-11-01

plugin

Fixed

  • The release workflow builds the whole workspace before packing, so the plugin's declaration build finds the stats package.
  • 0.1.0-rc.4 and 0.1.0-rc.5 were tagged but never reached npm; their changes first ship in this version.

Migration impact

  • None.

payload-experiments

0.1.0-rc.5 - 2026-11-01

plugin

Fixed

  • CI typecheck passes on Linux; the admin arm-select test fixture now declares hideGutter. Test-only, no runtime change.
  • 0.1.0-rc.4 was tagged but never published (the Linux typecheck failed before the publish job ran); its changes first ship in this version.

Migration impact

  • None.

payload-experiments

0.1.0-rc.4 - 2026-11-01

plugin

Added

  • Object-form eventSources entries ({ name, clientWritable }) so a server-only event can be a metric without accepting browser writes.

Fixed

  • The plugin package now exports ./package.json.

Migration impact

  • None. Bare-string eventSources entries keep their existing behavior.

payload-experiments

0.1.0-rc.3 - 2026-10-31

plugin

Fixed

  • A content slot with no running experiment now renders exactly as it does with experiments turned off, with no extra wrapper element around its content.

Migration impact

  • None.

payload-experiments

0.1.0-rc.2 - 2026-10-31

plugin

Added

  • Optional frozen exposure dimensions from server-computed request attributes (events.dimensions), and retention batches indexed by experiment, event type and time.
  • Experiment actions: decide a winner, release a holdout, and archive a stopped experiment or a decided experiment after making the winner permanent, discarding it, or confirming that a developer has hard-coded a code experiment's winner. Delete draft or archived experiments with their rounds, events and results snapshots.
  • Permanent content: make the winner permanent for block, flag and page experiments. Blocks replace experiment slots with the winning content; flags keep the winning defaults and can update host content; pages keep serving the winner with its canonical URL. Pages that are localized, have hidden fields, or have fields the manager cannot read or update are refused individually, with a report of pages that still need a hand edit.
  • Results: runResults from /rsc for a host-owned cron route, with a time budget that bounds waiting for hung metric sources and database connections; results, experiment-summary and recompute endpoints supply the admin's results and list summaries.
  • Analysis: frequentist inference with sequential testing, Bayesian comparisons, CUPED variance reduction, guardrails, sample-ratio mismatch (SRM) checks, power estimates and plain-English verdicts.
  • Retention: by default, events are deleted 180 days after an experiment's final round ends, never while any round is open.
  • Content-change tracking: captureContentBaseline from /rsc for draft-enabled host collections outside Puck; publish markers compare with the previously published content, including the first publish of a page created as a draft.

Changed

  • Input refusals: 400 is reserved for requests that cannot be parsed against the declared schema; well-formed requests that cannot be accepted receive 422 with a distinct refusal code. New codes are undeclared-targeting-key, invalid-targeting-rule, duplicate-metric, unknown-metric-source, too-many-ids, invalid-phase, invalid-ids and recompute-failed.
  • Verdicts recommend the variant with the larger improvement in the metric's direction, including metrics where lower is better.

Migration impact

  • Since rc.1: new nullable xp-events.dimensions JSON column and composite (experiment_id, type, ts) retention index. The host runs payload migrate:create, then payload migrate for rc.2.
  • Hosts that declare a staff attribute must create their server runtime with defineExperimentsServerRuntime and pass an attributesOf function; startup refuses the configuration without it.

Known gaps

  • Exposure dimensions can be recorded, but results cannot yet be broken down by those dimensions.

payload-experiments

0.1.0-rc.1 - 2026-10-31

plugin

Added

  • Data and access: experiment, round, layer, metric, event, identity and results snapshot collections, plus the experiment state global. Required access.manage and configurable readResults permissions, with every access operation explicit.
  • Manifest: compiled experiment configuration, revision-based caching, a protected manifest endpoint, and Postgres and HTTP loaders.
  • Next.js runtime: createExperimentProxy, resolveExperiments and applyExperiments; signed xp_id cookies, x-xp-assignments and x-xp-request headers; consent handling, preview links and whole-page rewrites.
  • Server readers and writes from /rsc: getAssignments, resolveExperimentData, getFlagPoint, toClientAssignments, recordExposures, trackConversion, linkIdentity, eraseUser, recordContentChange, experimentMetadata and experimentSitemapFilter. Exposure logging counts rendered content rather than assignment alone.
  • Client integration: ExperimentsProvider, useFlagPoint, useExperiment, ExperimentPageview and trackConversionBeacon, with explicit exposure calls and allow-listed client assignments.
  • Events: POST /xp/events verifies signed cookies, recomputes assignments before accepting exposures, restricts conversions to client-writable sources, limits request size and supports the host's rate limiter.
  • Experiment actions: start, raise allocation, add a supporting metric or guardrail, and stop; setup preflight checks and preview-token endpoints.
  • Puck and host content: ExperimentSlot, ExperimentSlotEditor, createExperimentSlotField and createArmSelectField; content-change recording on publish, installed host hooks that never throw, and puck.collections registrations accepting { slug, field? } for custom content fields.
  • Admin: What, Who, Groups, Metrics and Review tabs; typed targeting and group fields, setup checks, placements, preview links and duration estimates. Status cells, round history, layer usage, action confirmations and a Results view with group comparisons, data-quality details and charts are available. Results and list summaries still need the results services listed below.

Migration impact

  • Initial schema: the host runs payload migrate:create, then payload migrate. Collections: xp-experiments, xp-phases, xp-layers, xp-metrics, xp-events, xp-identities and xp-results-snapshots. Global: xp-state.

Known gaps

  • Deciding a winner, releasing a holdout, archiving and deleting experiments are not available yet.
  • Making the winner permanent content is not available yet.
  • Computed results are not available yet: the results run (runResults) and the results, recompute and experiment-summary endpoints are still to come, so the admin cannot show computed results or list summaries.

payload-puck

0.10.2 - 2026-10-30

Security

  • The admin editor view now applies the collection's read access. /admin/puck-editor/:collection/:id read the page's draft through the Local API without an access posture, and the Local API defaults to overrideAccess: true. Any signed-in admin user could open the editor for a page the collection's read access denies them and see its draft content. Saving was never affected, because the save endpoints already enforce access. The view now reads as the signed-in user (req, overrideAccess: false), as does its read of the AI prompts collection. The static access-control guard now also covers src/views, and .tsx files.

Changed

  • The admin editor is full-screen. /admin/puck-editor/:collection/:id no longer renders inside Payload's DefaultTemplate (nav and header). Puck lays out its panels and auto-zooms the canvas against the space it's given. Inside the admin chrome that space was too narrow, so the canvas zoomed far below 100% and the component action bar, which Puck keeps at a constant screen size, came out oversized and clipped behind the left panel. The editor now fills the viewport (100dvh). It stays under /admin, so Payload's session and access handling are unchanged, and the editor's Back button returns to the document. "Edit with Puck" links need no changes.
  • The canvas no longer inherits the admin page's CSS. Puck mirrors every host-page stylesheet, plus the host <html>/<body> attributes, into the canvas iframe by default. In Payload admin that put the admin CSS, its data-theme, and the editor's dark-mode overrides into the page preview. The editor now passes iframe={{ syncHostStyles: false }}, so the canvas gets exactly your editorStylesheets and editorCss. If your preview was relying on a stylesheet loaded into the admin page rather than listed in editorStylesheets, add it there.

Fixed

  • Editor dark-mode detection follows Payload's theme setting. useDarkMode (and so DarkModeStyles) now reads Payload's data-theme attribute, so choosing dark or light in Payload's account settings is honoured even when it differs from the OS preference. Previously only a .dark class or the OS preference counted. DarkModeSource gains an 'attribute' value.

payload-experiments

0.1.0 - 2026-10-30

stats

  • Initial engine: assignment, statistics/CUPED, sequential and Bayesian tests, SRM, mid-experiment and pre-experiment power, capping/aggregation, parity harness and drift job.

payload-puck

0.10.1 - 2026-10-29

Changed

  • Dependency floors raised: @tiptap/* ^3.31.3 → ^3.32.1 and lucide-react ^1.39.0 → ^1.64.0, so installs pick up the current releases of the editor's bundled dependencies. Peer ranges are unchanged (payload and @payloadcms/* stay >=3.69.0, @puckeditor/core >=0.24.0).
  • Dev dependencies refreshed (payload / @payloadcms/* 3.92, vitest / @vitest/coverage-v8 4.1.12, @swc/core, @types/node), covering Dependabot #44–#47. TypeScript stays on 5.9 (#41); the 7.0 rewrite will be evaluated on its own, since it generates the published declarations.

payload-puck

0.10.0 - 2026-10-29

Breaking

  • @puckeditor/core peer dependency floor raised from >=0.23.0 to >=0.24.0. The built-in components now lay out their sidebar fields with Puck 0.24's FieldTab and FieldGroup, which don't exist in 0.23. The bundled @puckeditor/plugin-heading-analyzer moves to ^0.24.0 in lockstep. To upgrade: pnpm add @puckeditor/core@^0.24.0. See the Puck 0.24 release notes.

Added

  • Tabbed sidebar for the built-in components. Fields are split into General, Style and Layout tabs, with the reset button pinned above them; Section also groups its outer box and content area. Every component uses the same tab names, so the open tab stays put as you select different components. Spacer is left as-is, having only three fields. Only the editor sidebar changes: props, saved page data and rendering are untouched, so no migration is needed.
  • createFieldLayout() (from @delmaredigital/payload-puck/fields) builds a Puck renderFields function from a declarative tabs-and-groups layout, for your own components. Fields the layout doesn't mention are added to the first tab instead of being hidden, so extending a built-in component with extra fields keeps them visible.
  • Drag handle in the component action bar, on by default. Moving components in deeply nested layouts (Section → Flex → Columns) no longer means hunting for a clickable edge. PuckEditor takes a new dnd prop, passed to Puck's own; use dnd={{ enableDragHandle: false }} to turn the handle off.

Changed

  • AnimationField and TransformField use Puck's FieldGroup for their collapsible sections (Intensity, Transform Origin, Easing, Scroll Trigger, 3D Transforms) instead of hand-rolled toggles, so they match the native sidebar. Animation sections now remember whether they're open while you switch between components.

payload-better-auth

0.14.0 - 2026-10-22

Security floors. The minor bump is for the raised peer and Node requirements below; the plugin's own code is unchanged apart from tests and a comment. There is no migration and no API change.

BREAKING

  • Peer floors raised to the releases that fix critical advisories. pnpm install now warns (or fails, with engine-strict / strict peers) below these versions:

    • better-auth, @better-auth/api-key, @better-auth/passkey >=1.7.7 — 1.7.7 fixes a critical Magic Link account takeover (GHSA-965c-763c-88jm, CVSS 9.1: an unauthenticated attacker who knows a user's email gets that user's session) and an OAuth Proxy account takeover (GHSA-r4xp-prcw-77qf). The Magic Link issue needs the magicLink() plugin plus a social or generic OAuth provider with database-stored OAuth state, which is the default with this adapter and the setup the admin LoginView offers. Neither advisory appears in pnpm audit (they are repo-level only). After upgrading, request fresh magic links and restart pending OAuth/SAML flows, as the advisory requires. 1.7.12 is recommended; it also hardens JWT verification and OAuth-proxy callbacks.
    • next >=16.4.1 <17 — 16.4.1 fixes two critical remote code executions (GHSA-673q-mjm6-r5xr in use cache, CVE-2026-96889 in Image Optimization via sharp) and a Server Actions denial of service (GHSA-p3m5-59mp-jv5r). Next 15 is dropped from the range: Payload 3.90+ declares no 15.5.x in its own next peer range, so the old >=15.5.16 floor was never installable alongside it.
    • payload, @payloadcms/next, @payloadcms/ui >=3.90.0 — 3.90.0 closes the October batch of Payload advisories, including unauthorized document updates (CVE-2026-105859), field access-control bypass on auth collections (CVE-2026-105851), API key disclosure (CVE-2026-105849) and restricted user fields leaking in token-refresh responses (CVE-2026-105853). Several sit in auth collections and API keys, which this plugin manages.
  • Databases that ran the 0.11.0 account.issuer migration need a cleanup migration before this upgrade. Better Auth 1.7.3+ restores the 1.6 account schema, and the raised floor above means 0.14.0 forces you past 1.7.2. The generated accounts collection no longer emits issuer or the unique (issuer, accountId) index. Better Auth 1.7.3 reverted the issuer-keyed account identity introduced in 1.7.0 and keys accounts on (providerId, accountId) again, so new account rows no longer carry issuer. The collection generator is a projection of Better Auth's schema and follows it automatically; nothing in the adapter changed.

    If you applied the 0.11.0 migration (required issuer column + unique index), every sign-up and account link fails on the NOT NULL constraint once you upgrade Better Auth past 1.7.2. Relax it before or with the upgrade, per the Better Auth guide:

    ALTER TABLE accounts ALTER COLUMN issuer DROP NOT NULL;
    DROP INDEX accounts_issuer_account_id_idx;   -- use the name Payload generated; drop the index before any column drop on MySQL
    

    payload migrate:create will now propose dropping the issuer column and its index; applying that is the full cleanup. The 0.11.0 and 0.11.1 migration notes below describe the 1.7.0–1.7.2 schema and no longer apply.

  • Node 22.23.4 or later (engines). Node 20 reached end-of-life and did not receive the October 2026 Node.js security releases, which fix a Brotli decoder out-of-bounds write (CVE-2026-111497) and two TLS hostname-verification bypasses. Patched lines are 22.23.4, 24.22.1 and 26.12.1. CI now tests 22 and 24.

Fixed

  • Compound-index tests no longer assume the 1.7.0 account schema. Passthrough is now exercised through a plugin-declared table index, which also covers reference-field renaming (ownerId → owner) and non-unique indexes.

Docs

  • Upgrading Better Auth means reviewing the generated migration, even for a patch. README and the upgrade guide now require payload migrate:create after every Better Auth bump, with deliberate backfills for new NOT NULL columns and review of proposed column drops.

Internal

  • The Payload 4 prerelease line is renumbered from 0.14.0-next.x to 0.40.0-next.x. 0.14.0 on latest would otherwise outrank the next prereleases it does not contain. The next dist-tag keeps working; pin 0.40.0-next.0 or later for Payload 4 from now on.
  • Dev dependencies moved to Better Auth 1.7.12, Next 16.4.2, Payload 3.91.0, Vite 7.3.7 and Vitest 4.1.12 (the Vite and Vitest bumps clear the advisories published 2026-10-20/21, all dev-server-only). CI matrix is Node 22 and 24.
  • Schema snapshots and a daily Better Auth canary catch upstream drift. Versioned JSON snapshots cover default collections and the plugins used by type generation; the canary checks the latest Better Auth packages and opens or updates an issue when typecheck, tests or build fail.

payload-puck

0.9.6 - 2026-10-20

Fixed

  • Undo after moving or duplicating a block reverts it again. <Puck> received a new iframe object on every render, and the editor re-renders when a document first becomes dirty. That re-ran Puck's canvas viewport effect, which, while undo history holds a single entry, overwrites that entry with the current state, so the "initial" entry already held the change and undo restored it. The config is now a module constant with a stable reference. Thanks to @georgisoft2020 (#48).

payload-puck

0.9.5 - 2026-10-08

Fixed

  • Publishing a draft that is marked as homepage is checked for uniqueness again. 0.9.4 let draft saves set isHomepage without a conflict, but the uniqueness hook still skipped any page whose previous state "was already the homepage", and Payload hands the hook the latest version, which is that draft. Publishing it therefore skipped the check, never offered the swap, and left two published homepages. The shortcut is gone: the hook always looks for other homepages, which a page that really is the only homepage passes.

  • Live writes with no _status are checked again. 0.9.4's draft exemption also covered saves with no _status on a drafts-enabled collection, but those are live writes (Local API, or REST without ?draft=true) and could create a second published homepage. The exemption now applies only to _status: 'draft', which Payload sets on every draft-only save.

    Action: upgrade from 0.9.4. If a site saw a draft homepage published on 0.9.4, check for more than one page with isHomepage set and unset the extra. Earlier versions are not affected by these two issues.

payload-puck

0.9.4 - 2026-10-08

Fixed

  • Saving a draft no longer changes the live homepage. Marking a draft as the homepage made the uniqueness hook prompt for a swap on every draft save, and accepting it unset the current homepage immediately, even though the draft stayed unpublished, which left the site with no live homepage until it was. On a drafts-enabled collection, the hook now skips draft saves (Payload leaves the live document untouched on them), and the swap runs only when the page is published, which is when the editor now offers it. Applies to the built-in /api/puck/* endpoint, createPuckApiRoutesWithId, and Payload's own admin. Collections without drafts are unchanged: every save there is live, so the check and swap still apply on each one.

Security

  • Raised dependency floors so installs resolve patched transitive packages. @tiptap/* ^3.31.0 → ^3.31.3, which requires prosemirror-view ^1.42.3 (XSS in paste handling). @puckeditor/plugin-ai ^0.8.2 → ^0.8.3; current releases of the AI SDK it depends on have moved from undici 5 to the patched 6.28 line and no longer pull in @fastify/busboy. Compiled JavaScript and declaration output are byte-identical to 0.9.3.

    Action: none. If your own lockfile predates these releases, pnpm update @tiptap/pm prosemirror-view ai (or your package manager's equivalent) picks up the fixes without waiting on this release.

    Still open upstream: uuid@3 arrives through @puckeditor/plugin-ai → qler, whose latest release still requires uuid@^3. The advisory concerns v3/v5/v6 with a caller-supplied buffer. undici@7.29.0 is pinned exactly by payload itself (including the latest, 3.90.2), so it is present in every Payload app regardless of this plugin; it is overridden here only for this repository's own dev environment.

Changed

  • Dev dependencies refreshed (next 16.4.0, vitest / @vitest/coverage-v8 4.1.11, @swc/core, @types/*), clearing this repository's development-only alerts. TypeScript stays on 5.9; the 7.0 rewrite will be evaluated on its own, since it generates the published declarations.
  • Dependabot groups take only minor and patch updates, so a major version arrives as its own PR.

payload-puck

0.9.3 - 2026-10-08

Security

  • The homepage swap on the built-in update endpoint is now authorized before it changes anything (GHSA-cphw-vvv5-c8p7). For PATCH /api/puck/:collection/:id with swapHomepage: true and isHomepage: true, the handler unset the current homepage, a deliberate overrideAccess: true write made without the caller's req, before the target page's own update was checked against collection access. A user who could read pages but not edit the target could therefore clear the site's homepage: their update was rejected, but the unset had already committed, outside any transaction.

    The target page's update now runs first, under the caller's access control. Only once it succeeds are other homepages unset, inside the same transaction, so a failure in either step rolls back both. The previous-homepage lookup now also finds pages the editor cannot read, so a swap can no longer leave two homepages. swapHomepage now only bypasses the uniqueness check when isHomepage: true is also sent. Regression tests pin all four behaviours.

    Who can swap the homepage is unchanged: anyone allowed to set isHomepage on a page they can edit. Not affected: the standalone createPuckApiRoutesWithId factory, which already ran both writes under the caller's access.

    Action: upgrade. No configuration changes.

Changed

  • Dev dependencies: payload, @payloadcms/next and @payloadcms/ui 3.88.0 → 3.90.0, next 16.3.0 → 16.3.3. Dev-only; the published package is unaffected and peer floors are unchanged.
  • pnpm is pinned via packageManager (pnpm@10.9.0). Dependabot was regenerating pnpm-lock.yaml with a newer pnpm that dropped the overrides block, failing every dependency PR at install. CI and publish now read the same pin.

payload-better-auth

0.13.2 - 2026-10-07

A configurable role field, and lookups that keep working on Payload 4. Thanks to @mikevocalz for #44 and #45, and to @gregorbg for the request in #41.

Added

  • betterAuthCollections({ roleField }) (#41) names the user property that holds roles, for example roles as a string[]. It defaults to role, and the plugin publishes it on config.custom.betterAuth.roleField. The first-user-admin guard, the saveToJWT field list, the access helpers (isAdmin, isAdminField, isAdminOrSelf, canUpdateOwnFields, hasRole, hasRoleField, requireAllRoles), the admin login role gate and the API-key management gate all read that one value. firstUserAdmin.roleField is the older spelling of the same setting and still works; setting both to different values throws at startup. hasAnyRole / hasAllRoles take an optional third roleField argument, the access helpers accept a roleField override, and getRoleField(config), DEFAULT_ROLE_FIELD and the WithRoleField<User, 'roles', string[]> type are exported.

Fixed

  • betterAuthStrategy and the API-key create endpoint pass overrideAccess: true to their Local API lookups. It was already the default on Payload 3; Payload 4 removes the default, so naming it keeps the strategy working on both majors with no behaviour change on either.
  • canUpdateOwnFields reads the configured id field with Reflect.get. Payload 4 types req.user as AuthenticatedUser, which has no string index signature; Reflect.get compiles on both majors.
  • Generated collections opt out of Payload 4's default versioning. Payload 4 enables versions on every collection, which would give sessions, accounts, verifications and API keys a _versions table keeping up to 100 historical copies — old tokens and password hashes outliving deletion, which the v4 migration guide asks custom auth collections to opt out of. A versions set through customizeCollection still wins. One edge case: a customizeCollection callback now receives versions: false where it used to receive undefined, so a callback written as versions: collection.versions ?? true now leaves versioning off; set versions: true explicitly if you want it.
  • firstUserAdmin.roleField now applies to the "is the creator an admin?" check. The first-user-admin hook wrote the configured field but decided whether the creating user was an admin by reading role, so with a custom field an admin creating users from the Payload admin had their chosen role replaced by defaultRole.
  • A consumer-defined role field now gets saveToJWT. When the users collection already declared the role field, augmentation skipped it and roles never reached the JWT (or req.user). It is set unless the field sets saveToJWT explicitly.

Internal

  • Prereleases publish under their own npm dist-tag. A version with a prerelease identifier (e.g. 0.14.0-next.0) now publishes under that identifier (next) and its GitHub release is marked as a prerelease; plain x.y.z versions still publish as latest. This is groundwork for the Payload 4 line on the payload-4 branch, which ships under next while Payload 4 is in canary. CI also runs on pushes to payload-4.

payload-better-auth

0.13.1 - 2026-09-30

Fixed

  • The admin login hides the email form when no email-based method is enabled (#39). With password, magic link and email OTP all off (for example a social-only login), the page still showed an email field and a "Sign In" button that could only fail. The form, its button and the "or" divider now render only when one of those methods is on; social and passkey buttons stand alone, and errors still show.

Added

  • betterAuthCollections({ acknowledgeRoleGuardDisabled: true }) (#38) silences the startup warning printed when firstUserAdmin: false turns the role-forcing guard off. It acknowledges that one warning and changes no behavior; set it once the users collection's and role field's access.create reject anonymous and non-admin callers.

Docs

  • Recipe: only create social users who pass the role gate (#40). A Better Auth databaseHooks.user.create.before hook that returns false for OAuth sign-ups without an admin role, so role-less users never get a row while existing users keep theirs.

Internal

  • Development and test dependencies bumped: payload / @payloadcms/next / @payloadcms/ui 3.90, next 16.3.8, vitest 4.1.11 (#36). Peer dependency ranges are unchanged.

payload-page-tree

0.3.17 - 2026-09-09

Security

  • The /api/page-tree/* endpoints now require authentication and enforce collection access control. All fourteen tree endpoints (move, reorder, create, delete, duplicate, status, rename, regenerate-slugs, migrate, redirects, restore-slug, edit-url, check-segment, folder-impact) were registered as plain Payload custom endpoints with no user check, and their handlers called the Local API with Payload's default overrideAccess: true. Anyone who could reach the API, including unauthenticated visitors, could create, move, rename, publish and delete pages and folders regardless of the access rules on those collections.

    Every endpoint is now wrapped by an access gate that denies anonymous requests with 401 and denied users with 403 before any handler code runs, and every read, create, update and delete inside the handlers runs with overrideAccess: false as the requesting user, so collection and field access rules apply. The recursive folder delete, which previously ran outside the request entirely, now runs as the requesting user too.

Added

  • access plugin option: gate for the tree endpoints, ({ req }) => boolean | Promise<boolean>. Defaults to any authenticated user. Use it to restrict the tree to a role, e.g. access: ({ req }) => req.user?.role === 'admin'. Exported alongside withPageTreeAccess() and defaultPageTreeAccess for anyone registering their own endpoints.
  • Test suite (pnpm test, vitest) covering the access gate and asserting every registered endpoint is guarded. Runs in CI and gates publishing.

Upgrade notes

  • If you had non-admin users who relied on the tree, they now need create/update/delete access on your page collections and the folders collection. That is the intended behavior: the tree no longer has more power than the REST API.

payload-puck

0.9.2 - 2026-09-09

Fixed

  • collectionOverrides no longer clobbers the generated pages collection's merged access, admin, hooks and versions. generatePagesCollection() merged each of those carefully and then ended with a shallow ...collectionOverrides spread, so passing collectionOverrides: { access: { readVersions } } replaced the entire access object and silently handed read/create/update/delete back to Payload's defaults, ignoring whatever you passed via access. The same applied to partial admin and versions overrides (a versions: { maxPerDoc } override dropped drafts: true). Only keys not merged explicitly are now spread verbatim. Regression tests pin all four.

Added

  • access.readVersions on the plugin options, so version-history access can be restricted without reaching for collectionOverrides. Unset, Payload's default applies as before.

payload-puck

0.9.1 - 2026-09-09

Changed

  • Runtime dependencies refreshed: the @tiptap/* family 3.29.2 → 3.31.0, lucide-react 1.30.0 → 1.39.0, @radix-ui/react-popover 1.1.15 → 1.1.23. These change what consumers install, unlike the dev bumps below. Declaration output is byte-identical, so no public type surface moved, and the dependency contract tests pass: every Lucide icon imported across src/ still resolves, and rendered SVG defaults are unchanged (24×24, viewBox="0 0 24 24", stroke-width="2", currentColor, aria-hidden). Declared floors were raised to match.

  • Dev dependencies: payload, @payloadcms/next and @payloadcms/ui 3.87.1 → 3.88.0. Dev-only; declaration output verified byte-identical, so the published package is unaffected. The peer floor stays >=3.69.0.

    Checked explicitly against 3.88.0 because 0.9.0's access control depends on Payload internals: createLocalReq still threads the user option onto req.user (and still silently defaults a missing collection), still honours headers from a passed req, and all seven Local API operations still accept overrideAccess, user and req. This is also the version the GHSA-957g-hmmp-rchg reporter demonstrated against.

Security

  • The generated pages collection no longer defaults to allow-all write access. createPuckPlugin() filled any access function you did not pass with () => true, so a consumer who passed no access at all (the Quick Start, and the dd-starter template until today) shipped a pages collection that anonymous REST and GraphQL callers could create, update and delete. The /api/puck/* endpoints correctly enforced collection access since 0.6.23, which meant they enforced a rule that allowed everyone.

    read still defaults to public so the frontend can render published pages anonymously. create, update and delete now default to ({ req }) => Boolean(req.user), matching the puck-templates collection. Any access function you pass explicitly, and anything in collectionOverrides.access, still wins.

    Action: none if you already pass access. If you relied on the default, review it now: authenticated-but-untrusted users (public sign-up, for example) can still write pages, so pass role-aware functions such as update: ({ req }) => req.user?.role === 'admin'.

  • Cleared 45 of 53 Dependabot alerts via pnpm.overrides, pinning 13 transitive packages to their patched versions: brace-expansion (both major lines), dompurify, fast-uri, happy-dom, immutable, js-yaml, linkify-it, markdown-it, nanoid, piscina, postcss and ws. All are same-major bumps with no API surface change. Declaration output verified byte-identical to the 0.9.0 build, so the published package is unaffected.

    Two remain open, deliberately. undici@5.29.0 (7 alerts) and uuid@3.4.0 (1) arrive through @puckeditor/plugin-ai → ai → @ai-sdk/provider-utils, and → qler respectively. @puckeditor/plugin-ai is already at its latest published version (0.8.2), so there is no bump available to us.

    They are not suppressed, for two reasons. pnpm.overrides applies only to this workspace, so forcing them would clear the alert here without protecting anyone installing this package — the exposure is real and the alert is accurate. And both would require multi-major jumps (undici 5 → 6+, uuid 3 → 11+, across a documented API break) that nothing in this repo's test suite exercises, so a green build would be no evidence of safety. These need an upstream fix in @puckeditor/plugin-ai's dependency tree.

payload-better-auth

0.13.0 - 2026-09-04

Sliding sessions now work through Payload. Thanks to the contributor of #35, who traced the missing cookie refresh to the strategy and fixed it the way both frameworks intend.

Nothing in this release is breaking. This project reserves minor bumps for breaking changes while pre-1.0, and this one should have been 0.12.3 — the bump is a versioning slip, not a signal. No code, config, or data changes are needed to upgrade.

Fixed

  • betterAuthStrategy forwards Better Auth's Set-Cookie headers (#35). Better Auth's getSession() extends the session row once session.updateAge is reached and issues a refreshed cookie. The strategy called it without returnHeaders and threw the headers away, so the database session slid while the browser's cookie kept its original Max-Age: anyone whose traffic went only through Payload — the admin panel included — was logged out after expiresIn regardless of activity, and expired-cookie clearing and the cookie cache never reached the browser either. The strategy now passes returnHeaders: true and returns every Set-Cookie through Payload's responseHeaders, which Payload applies to REST and GraphQL responses.

Changed

  • Session reads that cannot deliver a cookie no longer extend the session. When Payload runs the strategy with canSetHeaders: false (the admin's server renders, payload.auth({ headers }) without canSetHeaders: true), and in getServerSession() / getServerUser() and the api-key endpoint guards, getSession() is now called with disableRefresh. Before, these calls extended the row in the database with no way to tell the browser, so the two expiries drifted apart. Refresh still happens everywhere the cookie can be delivered: through the strategy on REST/GraphQL, on Better Auth's own endpoints, and from payload.auth({ headers, canSetHeaders: true }) when you forward responseHeaders (see README §5).

  • betterAuthStrategy's authenticate is typed with Payload's exported AuthStrategyFunctionArgs rather than a hand-written subset, so canSetHeaders, isGraphQL and strategyName follow Payload's contract.

Upgrading

No action needed. If you call payload.auth({ headers }) in a Route Handler or Server Action and want the response to carry the refreshed cookie, pass canSetHeaders: true and forward responseHeaders. Otherwise that call is now a pure read — which is what it effectively was before, minus the stray database write.

payload-better-auth

0.12.2 - 2026-09-04

Performance and correctness of the Postgres census in migrateStringifiedArrays(). No behaviour change to what it converts. Thanks again to the downstream reporter, who read the 0.12.1 implementation and found all three of these.

Fixed

  • The Postgres census pages instead of materializing the whole result set. It selected every matching row with no LIMIT and built one array before converting any of them. In a pre-0.12 database every row is stringified, so the worst case was an entire table in memory — and oauthAccessTokens is the table most likely to be large in a real provider. It now pages at batchSize.

  • batchSize is honoured on Postgres. It was documented but only ever applied to the SQLite/MongoDB path, so passing it on Postgres silently did nothing.

  • Paging is keyset on id, not OFFSET. Converting a row makes it stop matching jsonb_typeof(...) = 'string', so the result set shrinks underneath the walk and an offset would step over rows. Rows that are skipped (a string that doesn't parse to an array) stay matching, which an offset would also mishandle. Advancing past the last handled id is correct for both, and terminates even when every row in a page is skipped.

Changed

  • All of a table's array columns are censused in one scan. oauthClients carries seven string[] columns and was read seven times over; it is now read once, with each column projected through its own CASE WHEN jsonb_typeof(...).

  • One update per row rather than one per converted field. A row with several stringified columns was written once per column, each firing Payload's hooks again.

payload-better-auth

0.12.1 - 2026-09-04

migrateStringifiedArrays() was a silent no-op on Postgres, and its converted: 0 read as "your database is clean". Combined with 0.12.0's advice to then delete your own tolerant parsing, that could take a legitimate OAuth connector offline. Thanks to the reporter who measured this against a live database and traced the cause.

Fixed

  • migrateStringifiedArrays() now censuses the stored shape on Postgres instead of the value the ORM returns. Payload writes these fields to a jsonb column, so a stringified value is stored as a jsonb string. On read, node-postgres parses the jsonb and hands drizzle a JS string — and drizzle's PgJsonb.mapFromDriverValue runs JSON.parse on any string it receives (a guard for drivers that return raw text). That second parse turns the stored string back into an array before Payload sees it, so a stringified row and a native one are indistinguishable through payload.find(). The migration's typeof value === 'string' check could therefore never fire: it reported scanned: 207, converted: 0 against a database with 62 genuinely stringified rows. The census now runs in SQL against jsonb_typeof, which reports what is actually stored.

    SQLite and MongoDB were never affected — SQLite stores json as TEXT and drizzle parses it exactly once, MongoDB stores the value verbatim — so on those backends the Local API is faithful and is still used.

  • Results carry observedVia ('stored-shape' | 'local-api'), so a converted: 0 is interpretable. "We inspected the columns and they were clean" and "we could not observe the stored shape" are different facts and printed identically before.

  • It throws instead of reporting a clean database it could not inspect. If the Postgres adapter doesn't expose drizzle, or a collection's table can't be resolved, that is now a hard error naming the collection and field rather than a converted: 0.

Changed

  • Corrected the 0.12.0 claim that an unmigrated row breaks /oauth2/authorize. It doesn't, on Postgres — the same double-parse launders the value before Better Auth sees it, so findRegisteredRedirectUri receives an array and works. The real exposure is narrower: code that reads these columns with raw SQL, which carries no column type and so bypasses drizzle's mapper entirely and sees the string. A DCR connector gate or consent screen reading redirect_uris via drizzle.execute(sql\…`)` is the case that matters.

  • Reordered the upgrade guidance so the workaround removal comes last, after the migration has been verified against the stored shape. 0.12.0 had it the other way round, which is what turned a false negative into a lockout: converted: 0 → conclude there is nothing to convert → delete the tolerant parse → every raw-SQL read of an unconverted row returns []. For a redirect-URI allowlist that is a hard lockout of a legitimate connector.

Upgrading

If you already ran the 0.12.0 migration and saw converted: 0 on Postgres, re-run it — that result was not trustworthy. Confirm each row reports observedVia: 'stored-shape', and verify independently before removing any tolerant parsing of your own:

SELECT jsonb_typeof(scopes) AS shape, count(*)
FROM oauth_access_tokens GROUP BY 1;

string rows are unconverted; you want only array (and null). If you need to convert by hand, or outside the plugin:

UPDATE oauth_access_tokens
SET scopes = (scopes #>> '{}')::jsonb
WHERE jsonb_typeof(scopes) = 'string';

Only after that census reads clean should you drop your own JSON.stringify-on-write or tolerant-parse-on-read.

payload-better-auth

0.12.0 - 2026-09-04

Array-typed fields were being written to Payload as JSON strings. The adapter now stores them as arrays — one shape in the database, no translation layer — and a one-time migration converts rows written by earlier releases.

Breaking

  • string[] / number[] values are now stored as real arrays (#34). Better Auth 1.7 split supportsArrays out of supportsJSON, and this adapter kept reporting supportsArrays: false — so the adapter factory JSON.stringify'd every array value on its way into Payload. A additionalFields: { roles: { type: 'string[]' } } backed by a field that validates its shape failed the write outright (ValidationError: data must be array); everything that did save landed as '["a","b"]' where an array belonged, unqueryable and rendered as a quoted string in the admin UI. supportsArrays describes whether the backend holds native arrays — the official Postgres, MongoDB and memory adapters all report true, and Payload's json field validation handles arrays explicitly. The adapter talks to Payload's Local API rather than a SQL driver, so this holds for every Payload database adapter, exactly as the already-unconditional supportsJSON: true does.

    This changes stored data, so existing rows need migrating once — see Upgrading. The oauth-provider plugin is where this bites: every array-typed field in Better Auth today belongs to it (redirectUris, scopes, grantTypes, responseTypes, contacts, resources, requestedUserInfoClaims, allowedScopes), and those rows are written at registration and then only read, so they never correct themselves. Left unmigrated, Better Auth's findRegisteredRedirectUri calls registered.find(...) on a string and throws, breaking /oauth2/authorize for that client.

  • number[] now generates a json collection field instead of text. mapFieldType handled string[] but let number[] fall through, which only worked because arrays were being stringified. No Better Auth table ships a number[] field, so this affects additionalFields only.

Added

  • migrateStringifiedArrays() — the one-time data migration for the above. It reads the array-typed fields out of your own Better Auth schema rather than a hardcoded list, so it covers whatever plugins you run, and it is safe to re-run: values that are already arrays are left alone. Supports dryRun for a report first, onProgress for output, and batchSize for paging. A string that doesn't parse to an array is skipped and counted, never guessed at.

  • Tests pin the shape of the user betterAuthStrategy returns. Every non-null return now has assertions for collection and _strategy (cookie/API-key path and OAuth JWT path), and the OAuth JWT path — previously untested — is covered end to end with a mocked verifyBearerToken: issuer/audience/JWKS derivation, oauthScopes and organization context from claims, and fail-closed behaviour when baseURL is missing, verification fails, or the sub has no Payload user. Downstream packages now discriminate on collection (payload-puck ≥ 0.9.0), so this is guaranteed behaviour rather than incidental.

Fixed

  • Docs: API keys must be sent as x-api-key unless Better Auth is configured otherwise. The strategy comment and the 0.8.0 changelog entry stated that betterAuthStrategy authenticates keys sent via both x-api-key and Authorization: Bearer. Which header counts as an API key is decided by Better Auth's api-key plugin inside getSession(), and by default it reads only x-api-key; a Bearer <key> request yields no session unless the app sets the plugin's apiKeyHeaders or customAPIKeyGetter (verified against Better Auth 1.7.1). The code is unchanged — the authorization fallback in the strategy and extractApiKeyFromRequest still serve apps that configured it — but the comments and docs now say so.

Upgrading

If you don't use the oauth-provider plugin and have no array-typed additionalFields, there is nothing to do. Nothing else in Better Auth uses an array field.

Otherwise, run the migration once after upgrading and before serving traffic:

import { migrateStringifiedArrays } from '@delmaredigital/payload-better-auth'
import { betterAuthOptions } from './lib/auth/config'

const results = await migrateStringifiedArrays({
  payload,
  betterAuthOptions,
  dryRun: true, // drop this once the report looks right
})
console.table(results)

Two more things worth checking:

  • Drop your own workarounds. If you were writing JSON.stringify(uris) into these columns or parsing them back out on read, stop — after migrating, the column holds one shape. Leaving a JSON.parse in place that assumes a string will now break.
  • number[] under session.additionalFields changes its generated Payload field from text to json, so it needs a Payload schema migration (payload migrate:create). string[] was already json and is unaffected.

payload-better-auth

0.11.3 - 2026-09-01

The admin login page decided which sign-in buttons to render by reading Better Auth's raw configuration. That object is not the list Better Auth signs people in against, so the page both hid providers that worked and showed providers that didn't. Detection now reads the resolved auth context (auth.$context) — the same list /sign-in/social matches against.

Fixed

  • Generic OAuth / OIDC providers appear on the admin login (#32). Since Better Auth 1.7, genericOAuth() registers its providers as first-class social providers — merged into the auth context during plugin init(), never into options.socialProviders, which is where this plugin looked. A Keycloak, Zitadel, Okta, Auth0 or Entra ID provider was silently filtered out of enableSocial, and the only way to offer SSO on the admin login was a custom login view. They now render alongside the built-in providers, sign in through the same signIn.social call, and are allowlisted by their providerId. A provider's configured name becomes its button label, so { providerId: 'zitadel', name: 'Company SSO' } reads as "Continue with Company SSO" instead of "Continue with Zitadel".
  • No more buttons that Better Auth would refuse. Reading raw config also meant rendering providers that never resolved: one switched off with enabled: false, or one whose config is a function that resolves to null. Clicking either produced "Provider not found". The login page now shows a provider if and only if sign-in accepts it.
  • Sign-in methods contributed by a plugin are detected. Better Auth merges options returned from a plugin's init() into the auth context, so auth.options — what detection read — can be missing configuration that is genuinely in effect. Method detection (enablePassword, enableSignUp, enableForgotPassword, the OTP lengths) now reads the post-init options, and no longer hides a method the server actually accepts.

Changed

  • The login view resolves its props from auth.$context rather than auth.options. $context is a single promise built once by betterAuth(), so this costs nothing per render. If it rejects — genericOAuth's init() throws when OIDC discovery fails and no accountIssuer is set — the login page logs and falls back to rendering password sign-in with no social buttons, rather than 500ing the one screen an admin needs to fix the config.

Upgrading

No action. If you use genericOAuth() and kept a custom login view solely to render its buttons, you can now drop it and set admin.login.enableSocial instead. The account-creation warning under Social Sign-In applies to generic providers exactly as it does to built-in ones: set disableImplicitSignUp if you don't want a public admin login creating user rows.

payload-puck

0.9.0 - 2026-09-01

Security

  • Breaking: the standalone Next.js route factories in src/api/ now enforce Payload collection access control (GHSA-957g-hmmp-rchg). createPuckApiRoutes, createPuckApiRoutesWithId and createPuckApiRoutesVersions called Payload's Local API with its default overrideAccess: true across all eleven sinks, so collection and field access rules were never evaluated. CVE-2026-39397 (fixed in 0.6.23) hardened src/endpoints/index.ts but left these three files untouched, and the advisory for it wrongly stated they "had their own authentication checks" — they authenticate, which is not authorization.

    Any caller the integrator's authenticate hook accepted could read full version history including unpublished drafts, restore arbitrary versions over live content, publish arbitrary content, and — where the optional canDelete/canCreate hooks were omitted, as the type documentation permitted — create and delete published pages.

    To upgrade: build authenticate on payload.auth({ headers: request.headers }). That runs whatever auth strategies your Payload config registers — Better Auth, Clerk, custom strategies — and returns a real Payload user, which is detected automatically. This is the recommended wiring for every auth system, not just Payload's own.

    Do not return your auth library's session user (auth.api.getSession(), getServerSession(), a decoded JWT): those carry no collection and the routes now fail closed on them. And do not map one back by email — a bare collection row silently drops the fields an auth strategy decorates onto the user. With payload-better-auth that means losing activeOrganizationId, organizationRole, apiKeyScopes and oauthScopes, so an API-key caller can be judged as an ordinary session. The toPayloadUser hook remains for callers that genuinely have no Payload user; whatever it returns is the principal access control evaluates.

    Better Auth compatibility: any published @delmaredigital/payload-better-auth works — its strategy stamps collection on the user, which is what the resolution needs. 0.9.0 or later is recommended: it is the first release where this wiring, combined with the headers 0.9 forwards into Payload, is side-effect free for API-key requests. Current is 0.11.3 (requires Better Auth 1.7).

    Routes fail closed: if the Payload user cannot be determined the request returns 500 / PUCK_ACCESS_MISCONFIGURED and performs no database operation, with the remediation in the server log. It never silently downgrades to anonymous and never silently skips authorization. dangerouslyDisableCollectionAccessControl: true restores the old behaviour as an emergency rollback and warns once per factory.

  • The AI context and prompts endpoints now enforce collection access control (GHSA-rrx7-m589-5wfq). The eight handlers in src/endpoints/context.ts and src/endpoints/prompts.ts gated only on if (!req.user) and then called the Local API without req or overrideAccess: false, contradicting the invariant documented at the top of src/endpoints/index.ts. Because entries from both collections are concatenated into the AI system prompt, any authenticated user — including one from an unrelated auth-enabled collection, such as a front-end customers collection — could tamper with the prompts used by trusted editors, and defeat any hardening a deployer had applied to those collections' access rules.

  • Breaking: createPromptApiRoutes / createPromptApiRoutesWithId now enforce collection access control. A fourth route factory, exported from @delmaredigital/payload-puck/ai and named in neither advisory, had the identical defect across all four sinks — with a weaker gate than the others, since it checked only authResult.authenticated and never authResult.user, and exposes no canX hooks at all. It now uses the same resolver, requires a user, and accepts the same toPayloadUser / dangerouslyDisableCollectionAccessControl configuration. Found by the static guard added in this release, not by either report.

  • AI tools now read as the operator. The queryCollection, searchMedia, findPages and get<Global> tools in src/ai/tools/index.ts queried Payload unfiltered, so a low-privilege editor could have the model read documents they cannot access and echo the content back through generated page output. They now pass overrideAccess: false and the invoking user. AiToolContext already declared user and the endpoint already populated it — the tools simply never used it.

  • src/plugin/hooks/isHomepageUnique.ts states its posture explicitly. Its two sinks keep overrideAccess: true, which is correct: the hook runs inside an already-authorized operation and enforces a global "only one homepage" invariant. Access-controlling it would let a user create a second homepage merely because they cannot see the first. Previously this rested on Payload's default, making it indistinguishable from the bugs above.

  • endpoints/ai.ts marks its one deliberate overrideAccess: true. The read that assembles the AI system prompt is intentionally trusted and unfiltered — the prompt must not vary with each operator's read permissions — but it previously rested on Payload's default, which is indistinguishable from the bug above. It now states its posture explicitly, so every sink in the request path is auditable.

Fixed

  • Request headers now reach access rules. The route factories forward the original request headers into every Payload operation. createLocalReq substitutes an empty Headers when none is passed, so an access rule that inspects them — an API-key scope check cannot see its own key otherwise — would silently misjudge the request; for an API-key caller that can fail open, treating a zero-scope key as a full session. Each operation receives its own request object, because Payload mutates the one it is given and sharing it across the several operations in a handler would leak locale, context and dataloader state between them.

  • Payload access denials are reported as denials. All request-facing handlers flattened every error into 500, so a Forbidden read the same as a database outage — to clients, to onError, and to error monitoring. They now return the status Payload intended (403, 404, 409), with non-public error messages replaced by generic ones so internal detail is not echoed back. This also affects the built-in /api/puck/* endpoints, where the behaviour was latent since 0.6.23.

  • createPuckApiRoutes POST returns 409 on a slug collision it cannot see. Its uniqueness pre-check now runs under access control (so it can no longer be used to probe for documents the caller may not read), which means a collision with an unreadable document falls through to Payload's unique constraint. That ValidationError is mapped back to the same 409 the pre-check would have returned, instead of a generic 500.

Added

  • Access-control regression tests (tests/api/accessControl.test.ts, tests/api/accessControlSources.test.ts, tests/endpoints/aiCollectionsAccess.test.ts) in two independent layers. The behavioural layer drives every handler and asserts each recorded Local API call carries overrideAccess: false and the resolved user. The static layer parses every payload.* call in src/api/ and src/endpoints/ — including indirect calls through a variable — and fails on any sink that does not state its access posture, walking src/api, src/endpoints, src/ai and src/plugin so a sink added in a new file is caught too. Every sink in src/ now either enforces access control or documents in a comment why it deliberately does not — there are four of the latter. The static layer is what surfaced the fourth route factory and the AI tool reads. Every guarantee was mutation-tested rather than assumed: removing a single access spread, dropping the forwarded headers, and sharing one request object across sinks each produce failures.

Changed

  • Dev tooling: vitest 3 → 4, @types/node 24 → 26, @swc/cli 0.6 → 0.8. Dev-only; the published package is unaffected (declaration output verified byte-identical to the 0.8.3 build).

Notes

  • No implicit payload.auth() fallback. An earlier draft resolved unmappable users by re-running Payload's auth pipeline per request. Validation against payload-better-auth ruled it out: it double-decrements an API key's remaining quota and rate-limit budget (Better Auth deletes keys on exhaustion), adds a full access-evaluation pass, and can resolve a different principal than the one the canX hooks already gated — a confused deputy. Resolution stays pure and never touches Payload.

Deferred

  • TypeScript 7 held at 5.9 deliberately. It typechecks and builds cleanly, but it changes the published .d.ts surface. Most differences are harmless — union members and object properties get reordered (order-insensitive), and inference sharpens (Extension<any, any> → Extension<{ types: string[] }, any>). One is not: components wrapped in React.memo emit as MemoExoticComponent<({ ...every destructured prop }) => JSX.Element> instead of NamedExoticComponent<Props>, which leaks internal parameter names into consumer-facing types and is far noisier on hover. Deferred until TypeScript 7 has more soak time, since there is no benefit to us today — 5.9 typechecks the same code without the churn.

payload-better-auth

0.11.2 - 2026-08-25

Two-factor authentication was reachable but not recoverable: the login form's second-factor step only accepted TOTP, and the bundled setup view could never succeed for an account with a password. Both are fixed, along with the smaller things found alongside them.

Added

  • admin.login.enableTwoFactorBackupCode / admin.login.enableTwoFactorEmailOtp (boolean | 'auto', default 'auto') — what the login form's two-factor step may offer beyond the authenticator app. 'auto' detects the twoFactor plugin and its otpOptions.sendOTP server-side. These are ceilings: the step then follows the factors Better Auth reports for the signed-in user, and never offers one they don't hold.
  • TwoFactorSetupViewWrapper (exported from /rsc) — server wrapper for TwoFactorSetupView that resolves whether the signed-in account has a password by listing its Better Auth accounts, mirroring LoginViewWrapper's detection pattern. Use it as the admin view; the client component also accepts an explicit hasPassword prop for custom flows.

Fixed

  • The login form's two-factor step accepts backup codes and emailed codes, not just TOTP. A user with a lost authenticator was locked out entirely: backup codes were issued by setup and redeemable by nothing, and the emailed second factor was unreachable even with otpOptions.sendOTP configured. Where admin access itself requires 2FA, that could mean recoverable by nobody. The step now offers "use a backup code" (free-text, verifyBackupCode) and "email me a code" (sendOtp/verifyOtp, with a resend action on a 30-second cooldown).
  • The step opens on a factor the user actually has. Better Auth reports them on sign-in (twoFactorMethods) and the login view discarded them, always opening on an authenticator input. Someone who enabled 2FA by email now lands on the emailed-code entry instead of a code they can't produce, and the "use your authenticator app" link is hidden when there is no verified TOTP secret.
  • TwoFactorSetupView can enable 2FA for password accounts at all. It fired a passwordless /two-factor/enable on mount, which Better Auth rejects for any account holding a password — a guaranteed "Invalid password" with no field to answer it. Credential accounts now confirm their password first; passwordless accounts start enablement immediately. Backup codes can also be downloaded as a file, and copying confirms visibly instead of failing silently when clipboard access is denied.
  • One-time code inputs honour the configured length. The forms assumed six digits, so a config setting emailOTP's otpLength, or the twoFactor plugin's totpOptions.digits / otpOptions.digits, to anything else produced a form that truncated the code and refused to submit it. All three are read from the plugin options server-side.
  • BeforeLogin is no longer injected where it can't render. Payload renders beforeLogin inside its own login view, which this plugin replaces by default — so the injected component, and any beforeLoginComponent a consumer passed, silently never rendered. It is now injected only when disableLoginView: true keeps Payload's login view alive, and passing one that can't be injected logs a warning instead of dropping it silently.
  • The backup-codes download link is attached to the document before it's clicked (Firefox ignores a detached anchor) and its object URL is revoked after the download starts rather than in the same tick, which cancelled it in some browsers.

Upgrading

No action for the common setup. Two narrow cases:

  • If you mount TwoFactorSetupView yourself (rather than the plugin's management UI) for accounts with no password — social- or passkey-only, with the twoFactor plugin's allowPasswordless — it now shows a password step those users can't answer, because hasPassword defaults to true. Render TwoFactorSetupViewWrapper from /rsc instead, which resolves it server-side, or pass hasPassword={false}.
  • If you pass admin.beforeLoginComponent and have not set admin.disableLoginView: true, it is no longer injected. It never rendered either way; the plugin now says so at config-build time instead of staying quiet.

payload-better-auth

0.11.1 - 2026-08-21

Documentation fix. No code change — but if you followed 0.11.0's migration guidance and use Google, Facebook, Apple, LINE, Cognito, Paybin or Microsoft Entra ID sign-in, your account.issuer backfill is wrong and needs repairing. See Migration under 0.11.0, now corrected.

Fixed

  • Corrected the account.issuer backfill values. 0.11.0 stated that built-in social providers use the synthetic local:oauth:<providerId> form. That is the fallback, applied only where a provider declares no issuer of its own — and in Better Auth 1.7.1 seven built-ins declare one: google (https://accounts.google.com), facebook (https://www.facebook.com), apple (https://appleid.apple.com), line, cognito, paybin and microsoft (per-tenant).

    The claim came from grepping better-auth/dist/social-providers/, which is a two-line re-export of @better-auth/core/social-providers. Zero matches read as "no provider declares one". The docs now name the affected providers, and give a one-liner that reads the value off the provider rather than restating it:

    node -e "import('better-auth/social-providers').then(m => console.log(m.google({clientId:'x',clientSecret:'y'}).accountIssuer))"
    
  • Documented that Microsoft Entra ID's accountId moves as well. 1.7 keys the subject on the oid claim where 1.6 stored sub, so an Entra row needs both halves rewritten, per-row, from the id_token already on it. 0.11.0 mentioned the issuer change but not this.

  • Documented that Facebook's accountId does not move, despite an accountSubject that reads like it changed — both the Limited Login (sub) and Graph (id) branches match what 1.6 stored.

    Why this matters: a wrong issuer fails silently. The row is filed under a key Better Auth never queries, so the next sign-in does not match it, takes the new-identity path and writes a second account row — which the unique index permits, because the pair differs. Nothing throws. It surfaces later as an unexpected account-link prompt, or account_not_linked where policy forbids linking. Confirmed in the wild: one Google user with the same sub across two rows, one local:oauth:google and one https://accounts.google.com.

    To check an existing database: SELECT provider_id, issuer, count(*) FROM accounts GROUP BY 1,2;. Two issuer forms for one provider means rows have already re-linked, and a repair has to drop the unique index, refuse where a repaired identity spans two users (that is a merge for a human), collapse the duplicates, then recreate the index.

payload-better-auth

0.11.0 - 2026-08-19

Better Auth 1.7 support. 1.7 is a large release with a wide breaking-change surface; most of it is absorbed here, but it raises the minimum Better Auth version and requires a one-time database migration. See Migration.

Changed

  • BREAKING: Better Auth >= 1.7.0 is now required (peer ranges bumped for better-auth, @better-auth/api-key and @better-auth/passkey). This is a hard floor, not a preference: 1.7 added two required methods to the database-adapter contract and its factory throws rather than falling back, so the previous release cannot run on 1.7 and this release cannot run on 1.6.
  • BREAKING: account gains a required issuer field and a unique (issuer, accountId) index. Better Auth 1.7 scopes account identity by issuer instead of providerId, so generated collections now emit both. Existing databases need a backfill before the NOT NULL column can be added — see Migration.

Added

  • Adapter consumeOne and incrementOne. Better Auth 1.7 requires these atomic primitives on every custom adapter and dispatches to them for single-use credentials (email verification, password reset, magic links, email OTP, device-authorization codes) and guarded counters (API-key quota and rate limits, two-factor backup codes, team member counts). Without them, those flows throw Adapter "payload-adapter" must implement consumeOne… at runtime.

    Payload's Local API offers no DELETE … RETURNING or SET n = n + d, so both are read-then-write with the race window narrowed rather than removed. consumeOne returns the row only if its own delete removed it (a racing consumer's delete 404s and yields null), preserving single-use semantics for tokens. incrementOne re-asserts Better Auth's own guard (e.g. remaining > 0) plus the counter values it computed from, and re-reads and retries — bounded at five attempts — when a concurrent writer wins, rather than clobbering it. The residual window and how to close it for API-key quota are documented under Atomic operations.

  • Table-level indexes from the Better Auth schema are passed through to generated collections. 1.7 moved constraints that were previously implicit into the schema as table indexes; the generator now maps them onto Payload's compound indexes (renaming reference fields, e.g. userId → user) and warns rather than emitting an index over a field it did not generate.

  • Compile-time guard against the next adapter-contract change. The adapter object is returned through an as CustomAdapter cast to reconcile the interface's generic return types — but a cast also silently accepts an object that is missing methods, which is exactly how 1.7's new requirements cleared the build while the factory would have thrown at runtime. A type-level completeness check now fails the build instead.

Fixed

  • OAuth JWT bearer verification in betterAuthStrategy. Better Auth 1.7 removed verifyAccessToken from better-auth/oauth2, splitting it into verifyBearerToken (raw token, bearer only) and verifyAccessTokenRequest (full request, also handles RFC 9449 DPoP sender-constrained tokens). The strategy now uses verifyBearerToken, which is the form its inputs support — Payload hands the strategy only headers, with no method or URL. DPoP-bound tokens are rejected here by design.
  • Two-factor setup no longer shows a blank manual-entry secret. /two-factor/enable returns { method, totpURI, backupCodes } and has no secret field; TwoFactorSetupView was reading data.secret and rendering nothing. Both 2FA components now derive the secret from the TOTP URI via a shared helper.
  • PasskeyLoginView builds again under 1.7. The new hydrateSession(session) client method puts the plugin-augmented user shape in parameter position, making the client type invariant — a plugin-laden client no longer assigns to a bare ReturnType<typeof createAuthClient>. The ref is now typed from the actual client factory.

Internal

  • Both two-factor components request method: 'totp' explicitly. It remains Better Auth's default, but 1.7 made the response a discriminated union on method, and these views only render the authenticator-app flow — they now narrow on it and surface an error instead of rendering an empty QR code if a server returns something else.
  • Test doubles for Payload now recurse through nested and/or groups, support the exists operator, and raise Payload's 404 on a delete-by-id for a missing row — all three are needed to exercise the guarded writes faithfully.
  • Plugin-id table re-verified against 1.7.

Migration

  1. Upgrade the Better Auth peers together — better-auth@^1.7, plus @better-auth/api-key / @better-auth/passkey at the same major if you use them.

  2. Generate the migration, then edit it to backfill issuer. payload migrate:create will produce a migration that adds issuer as NOT NULL and creates the unique index — and that migration fails on a populated table, because every existing row would violate the constraint. Split it into add-nullable → backfill → enforce:

    ALTER TABLE accounts ADD COLUMN issuer varchar;
    
    -- Email/password rows
    UPDATE accounts SET issuer = 'local:credential' WHERE provider_id = 'credential';
    -- OAuth rows: the issuer each provider DECLARES (see "Issuer values" below)
    UPDATE accounts SET issuer = 'https://accounts.google.com' WHERE provider_id = 'google';
    UPDATE accounts SET issuer = 'https://www.facebook.com'    WHERE provider_id = 'facebook';
    -- Only providers that declare no issuer of their own get the synthetic form
    UPDATE accounts SET issuer = 'local:oauth:' || provider_id WHERE issuer IS NULL;
    
    ALTER TABLE accounts ALTER COLUMN issuer SET NOT NULL;
    -- then the unique index exactly as Payload generated it
    

    Before enforcing, confirm the backfill produced no duplicates — this must return zero rows, or the unique index will fail:

    SELECT issuer, account_id, COUNT(*)
    FROM accounts GROUP BY issuer, account_id HAVING COUNT(*) > 1;
    

    Table and column names above assume the plugin's defaults on Postgres (pluralized slug accounts, snake_case columns). Adjust for usePlural: false, a custom account model name, or MongoDB.

    Issuer values — local:oauth:<providerId> is the FALLBACK, not the rule. Email/password uses local:credential. The synthetic OAuth form applies only to providers that declare no issuer of their own. In Better Auth 1.7.1 seven built-ins declare one and must NOT get it: google → https://accounts.google.com, facebook → https://www.facebook.com, apple → https://appleid.apple.com, plus line, cognito, paybin and microsoft. Every generic-OAuth/OIDC provider (Okta, Auth0, Keycloak) uses its discovery issuer. Read the value instead of guessing:

    node -e "import('better-auth/social-providers').then(m => console.log(m.google({clientId:'x',clientSecret:'y'}).accountIssuer))"
    

    Getting this wrong is silent. The row is filed under a key Better Auth never queries, so the next sign-in does not match it, takes the new-identity path and writes a SECOND account row — which the unique index permits, because the pair differs. Nothing throws. It surfaces later as an unexpected account-link prompt, or account_not_linked where the policy forbids linking.

    Microsoft Entra ID cannot be backfilled with a flat UPDATE, and its accountId moves too. Its issuer is per-tenant — https://login.microsoftonline.com/<tid>/v2.0 — so there is no single value to write. And 1.7 keys the subject on the oid claim (accountSubject: ({ profile }) => profile.oid) where 1.6 stored sub (its getUserInfo returned id: user.sub), so every existing Entra accountId is wrong as well. Both values are in the id_token already stored on the row: decode the claims segment (base64url) and take iss and oid. Decode only — do not verify the signature. This reads a claim out of a row already in your own database, not a token presented by a caller, so there is nothing to authenticate; stored id_tokens are short-lived and long expired, so verification would fail regardless. A row with no usable id_token cannot be repaired this way — decide per row (delete it and let the user re-link, or look the oid up through Graph) rather than guessing.

    Facebook's accountId does NOT move — worth stating, because its accountSubject reads like it changed. 1.7 declares "sub" in profile ? profile.sub : profile.id, which is the same branch 1.6 took inside getUserInfo: the Limited Login path (a 3-part id_token) yields sub, the Graph path (/me?fields=…) yields id. Both are unchanged, so Facebook rows need only the issuer update. Rows with no stored id_token came via Graph; only a Limited Login / configId consumer needs to look further.

    The same question is worth asking for any provider you use: accountSubject in 1.7 is an explicit declaration, where 1.6 derived the id inside getUserInfo. Where the two disagree, accountId needs migrating alongside issuer.

  3. Apply the migration and verify sign-in for each provider you support before rolling out. Better Auth writes issuer on every new account row from this point on.

  4. Only if they apply to you — these are Better Auth changes this package passes through rather than shields you from:

    • Database joins moved out of experimental: experimental: { joins: true } → advanced: { database: { joins: true } }.
    • Dynamic baseURL behind a proxy: 1.7 resolves the auth origin from Host by default. If your proxy exposes the public hostname via x-forwarded-host, set advanced: { trustedProxyHeaders: true }. Proxies that rewrite the host (Vercel, Cloudflare, Netlify) need no change.
    • Generic OAuth was rewritten: signIn.oauth2({ providerId }) → signIn.social({ provider }), oauth2.link() → linkSocial(), and callbacks moved to /api/auth/callback/:id. The bundled admin login already uses signIn.social.
    • The oidcProvider plugin was removed in favor of @better-auth/oauth-provider, and the MCP plugin moved to @better-auth/mcp.

payload-puck

0.8.3 - 2026-08-07

Changed

  • Dependency refresh, including two majors in bundled runtime dependencies. lucide-react 0.469 → 1.30 and html-react-parser 5.2.17 → 6.1.5, plus the @tiptap/* family 3.20.1 → 3.29.2 and a dev-tooling/React-types sweep. No API this package exposes changed.
    • lucide-react v1 — all 68 icons imported across src/ still resolve, and rendered SVG defaults are unchanged (24×24, viewBox="0 0 24 24", stroke-width="2", currentColor), so icons are visually identical. v1 additionally emits aria-hidden="true", which is an accessibility improvement.
    • html-react-parser v6 — the breaking changes are internal (html-dom-parser, domhandler, and an es5 → es2016 build target). This package calls parse(htmlString) with no options in exactly one place (RichText.server), and that signature is unchanged.
  • GitHub Actions updated: actions/checkout v4 → v7, actions/setup-node v4 → v7, pnpm/action-setup v4 → v6. This clears the Node 20 deprecation warning every CI run was emitting.

Added

  • Dependency contract tests (tests/deps/contracts.test.ts) for the two libraries whose major versions can break rendering silently rather than loudly. A renamed Lucide export or a changed parse() return shape yields a broken page, not a type error — lucide-react re-exports a very large namespace and parse() is typed loosely. The suite scans src/ for every icon actually imported and asserts each still exists, checks icons render to <svg>, and pins parse()'s handling of nested markup, attributes, inline styles, and empty input. It also guards its own scanner, so the icon assertion cannot pass vacuously.

payload-puck

0.8.2 - 2026-08-07

Changed

  • Breaking-change notices now sit at the top of the README, immediately after the badges rather than buried under Install > Requirements, matching the convention used across the sibling plugins. 0.7.0 and 0.8.0 were both breaking and shipped hours apart, so anyone upgrading from 0.6.x needs both migrations — and the 0.8 one is easy to miss precisely because the symptom (an unstyled editor) only appears in production. Docs-only.

payload-better-auth

0.10.0 - 2026-08-07

Fixed

  • ResetPasswordView no longer invalidates its own token. The view strips ?token= from the URL (correctly — it keeps the token out of history, referrers and analytics), but it re-derived the token from useSearchParams() in the same effect that did the stripping. Next's App Router mirrors history.replaceState into its router state, so the effect re-ran with an empty query and rendered "Invalid or missing reset token" over a perfectly working form. The token is now captured once, before the URL is touched, and the strip runs in a mount-only effect.
  • ForgotPasswordView actually sends the reset email. It posted to /forget-password, which Better Auth 1.6 renamed to /request-password-reset — and the anti-enumeration handling treated the resulting 404 as success, so the page said "check your email" while sending nothing, for every consumer. It now uses the Better Auth client's requestPasswordReset() (which tracks endpoint renames), and only reports success when the request succeeded: Better Auth already answers unknown emails vaguely server-side, so genuine failures (rate limits, misconfiguration) surface instead of being swallowed. The view also gained an optional authClient prop, consistent with the other components.
  • The plugin now works when Payload's API route isn't /api (routes: { api: '/api/payload' } previously 404'd every auth request and broke admin sign-in). Two independent mismatches were fixed:
    • Admin components no longer assume /api/auth. Every component that builds a Better Auth client (LoginView, the passkey login/buttons, the 2FA/passkey/API-key management clients) or hand-rolls a fetch (LogoutButton, ForgotPasswordView, ResetPasswordView, TwoFactorSetupView, TwoFactorVerifyView) now derives the endpoint mount from the live Payload config (routes.api + authBasePath) via a shared useAuthMountPath() / useAuthClientBaseURL() hook, exported from /components for custom UIs. The plugin exposes authBasePath on admin.custom.betterAuth (client-readable) and custom.betterAuth (server), and the login wrappers pass it across the RSC boundary since the unauthenticated login page's client config strips admin.custom.
    • basePath mismatches are flagged at init. Better Auth's router rejects any request outside its own basePath (default /api/auth) with an empty 404, and basePath also determines the path embedded in emailed reset/magic links. Since createAuth is consumer-supplied, the plugin can't set it — instead onInit now compares Better Auth's resolved basePath against the actual mount and logs an error naming the exact value to pass to betterAuth(). Documented in the README: with a custom routes.api, set basePath: '<routes.api><authBasePath>'.

Security

  • Secret fields on managed collections are now locked by default (secureSecretFields option on betterAuthCollections(), default true). Session tokens, TOTP secrets and backup codes, verification identifiers/values, stored OAuth access/refresh/ID tokens, hashed passwords and API keys, JWKS private keys, and OAuth client secrets get access: { create/read/update: () => false } and are hidden in the admin UI. Better Auth's own operation is unaffected — the adapter runs with overrideAccess: true; this closes the Payload REST/GraphQL and admin-UI read path, where anyone the collection's access.read admits (admins, by default) could previously lift a live session token or TOTP secret — enough to hijack a session or clone a second factor. When a locked field was the collection's useAsTitle (e.g. verification.identifier), the title falls back to id so row labels don't render blank. Locking also applies to secret fields added by augmentation to your pre-existing collections; fields you defined yourself are never modified. Opt out with secureSecretFields: false, or pass a partial map (merged over the defaults, exported as defaultSecretFieldsByModel) to customize per model — e.g. { session: [] } to unlock sessions only.

payload-puck

0.8.1 - 2026-08-07

Fixed

  • Corrected the 0.8.0 upgrade instructions, which did not work as written. Two problems, both found by running them against a real project: Tailwind v4 ships its CLI as a separate package, so pnpm add -D @tailwindcss/cli is required before the tailwindcss binary exists (v3 bundles it); and the example paths were unquoted, so an App Router route group like src/app/(frontend)/globals.css aborted with a shell syntax error on the parentheses. Docs-only — no code change.

payload-puck

0.8.0 - 2026-08-07

Breaking

  • Editor CSS is no longer compiled by this plugin. You now build it with Tailwind's own CLI and hand the plugin a URL. The three stylesheet options (editorStylesheet, editorStylesheetCompiled, editorStylesheetUrls) collapse into one: editorStylesheets: string[], a plain ordered list of URLs the editor iframe loads. Removed alongside them: the /api/puck/styles runtime-compilation endpoint, the @delmaredigital/payload-puck/next entry point and its withPuckCSS() wrapper, and the postcss / postcss-load-config peer dependencies (nothing in the plugin imports them any more).

    To upgrade, generate the stylesheet in your build and pass its URL:

    Tailwind v4 ships its CLI separately, so install it first: pnpm add -D @tailwindcss/cli (v3 users already have the tailwindcss binary).

    // package.json — quote the paths; App Router route groups like (frontend) are shell syntax
    "scripts": {
      "build:puck-css": "tailwindcss -i './src/app/(frontend)/globals.css' -o './public/puck-editor-styles.css'",
      "dev:puck-css": "tailwindcss -i './src/app/(frontend)/globals.css' -o './public/puck-editor-styles.css' --watch",
      "build": "pnpm build:puck-css && next build",
      "dev": "pnpm build:puck-css && next dev"
    }
    

    Add public/puck-editor-styles.css to .gitignore — it is a build artifact. Run dev:puck-css in a second terminal when you are actively editing theme CSS.

    // payload.config.ts — before
    createPuckPlugin({
      editorStylesheet: 'src/app/(frontend)/globals.css',
      editorStylesheetCompiled: '/puck-editor-styles.css',
      editorStylesheetUrls: ['https://fonts.googleapis.com/css2?family=Inter'],
    })
    
    // after
    createPuckPlugin({
      editorStylesheets: ['/puck-editor-styles.css', 'https://fonts.googleapis.com/css2?family=Inter'],
    })
    

    Then delete the withPuckCSS import and wrapper from next.config.js, and drop any editorStylesheets prop you were passing to PuckConfigProvider — the plugin publishes the resolved list on config.custom.puck.editorStylesheets and PuckEditorView passes it to the editor for you. (The prop still works as an escape hatch for custom editor routes.)

    Why: the old design resolved a different URL per environment — a runtime PostCSS/Tailwind compile in development, a build-time file in production — and the production half was produced by a webpack plugin. Next.js 16 defaults to Turbopack, which never calls the webpack() config hook, so on any Next 16 project the production stylesheet was silently never generated and the editor rendered completely unstyled — while local development, served by the runtime endpoint, looked perfect. Compiling at runtime was also wrong on its own terms: it put a full Tailwind build on the request path and required build tooling as production dependencies. Your app's own toolchain already compiles this CSS correctly; the plugin now consumes that artifact instead of reimplementing it, and the same URL resolves in every environment.

Fixed

  • The editor preview rendered unstyled in production on any Next.js 16 project. Root cause above: withPuckCSS was webpack-only and Next 16 defaults to Turbopack, so public/puck-editor-styles.css was never written and the iframe's stylesheet request 404'd. Now that the file is produced by an ordinary build script, it cannot depend on which bundler Next chose.

Removed

  • @delmaredigital/payload-puck/next entry point (withPuckCSS, getPuckCSSPath, PUCK_CSS_OUTPUT_DEFAULT).
  • /api/puck/styles endpoint and the PUCK_STYLES_ENDPOINT constant.
  • postcss and postcss-load-config peer dependencies.

Added

  • Tests pinning the stylesheet contract, including one asserting the resolved URLs are identical across development, production, and test — the environment-dependent resolution is what made the outage invisible locally, so it is now a test failure rather than a deploy-time surprise.

payload-puck

0.7.0 - 2026-08-07

Breaking

  • @puckeditor/core peer dependency floor raised from >=0.21.0 to >=0.23.0. The plugins this package bundles (@puckeditor/plugin-heading-analyzer, @puckeditor/plugin-ai) are versioned in lockstep with Puck core and import it from the host, so shipping the 0.23 line of those plugins against an older core is not a supported combination. To upgrade: pnpm add @puckeditor/core@^0.23.0. Note that Puck 0.23 ships a rewritten canvas drag-and-drop engine and a redesigned outline — the editing experience changes visibly even though no API you call has changed. See the Puck 0.23 release notes.
  • Minimum Node version raised from ^18.20.2 || >=20.9.0 to >=20.9.0. Node 18 reached end-of-life and Puck core 0.23 itself declares >=20.0.0, so the old floor was a claim nothing verified. CI now runs the full matrix on Node 20 and 24, making the engines field an enforced guarantee rather than an aspiration.

Fixed

  • The editor no longer flags a document as "Unsaved" on load without any user edit (production builds). handleChange called markDirty() unconditionally on every onChange, but Puck's mount-time resolve pass dispatches a no-op change whose only difference from the loaded data is undefined-valued keys — which strict deep-equality treats as a real change. Incoming data is now diffed against the last loaded/saved state using a new undefined-tolerant comparison ({} ≡ { k: undefined }), so a no-op resolve clears the flag instead of setting it while genuine edits still mark the document dirty. Thanks @georgisoft2020. (#12)
  • Saving from the preview modal left a stale "last saved" baseline. handleSaveFromPreview marked the document clean but never recorded what it had just saved, so the next no-op onChange diffed against the pre-save state and re-flagged the document as dirty — reintroducing the bug above immediately after a successful save.
  • Restoring a version left the document flagged as dirty despite having just been persisted. The restore dispatches setData into the editor, and the resulting onChange fired after markClean(), flipping the flag straight back. onRestoreSuccess now receives the restored data and adopts it as the new saved baseline. (createVersionHistoryPlugin's onRestoreSuccess callback gained an optional restoredData argument — additive, so existing zero-argument callbacks continue to work unchanged.)

Changed

  • Bundled Puck plugins updated: @puckeditor/plugin-ai and @puckeditor/cloud-client ^0.7.0 → ^0.8.2, @puckeditor/plugin-heading-analyzer ^0.21.2 → ^0.23.0. The AI bump also resolves a latent peer conflict: plugin-ai@0.7 pinned @puckeditor/core to ^0.21.0, which warned for anyone already running core 0.22 or newer. 0.8.x widens that to ^0. No API surface used by this package changed.

Added

  • Continuous integration. .github/workflows/ci.yml runs typecheck, tests, and build on every pull request and every push to main, across Node 20 and 24. publish.yml now calls the same workflow as a hard gate, so a version tag cannot publish to npm unless those checks pass on the exact commit the tag points at — previously a tag published unconditionally.
  • A test suite. vitest with 46 tests covering the new unsaved-changes comparison, the field-value → CSS converters (including the email-safe variants), and the editorVersion content-detection hook. pnpm test, pnpm test:watch, pnpm test:coverage.
  • Grouped Dependabot configuration (.github/dependabot.yml) for npm and GitHub Actions, so routine dependency refreshes arrive as a few reviewable PRs instead of many near-identical ones.

Changed (internal)

  • GitHub Releases now lead with the hand-written CHANGELOG entry for the version being released, followed by GitHub's auto-generated commit/contributor summary, instead of the generated summary alone.
  • pnpm typecheck now covers tests/ as well as src/, via a separate tsconfig.test.json (the root tsconfig.json remains the build config, rooted at src/, so tests can never leak into dist/).
  • typecheck and test no longer fail on a fresh clone. src/version.ts is generated (and git-ignored), but generation was attached only to prebuild — so any command that compiles src/ without building first failed with Cannot find module '../../version.js'. It went unnoticed because a previous local build always left the file behind. Generation is now chained explicitly into build, typecheck, and the test scripts rather than relying on pnpm's pre/post script hooks. Caught by the new CI on its first run.

payload-better-auth

0.9.2 - 2026-07-10

Added

  • Startup warning when firstUserAdmin is disabled. Setting firstUserAdmin: false removes the plugin's role-forcing guard from the users collection, making the consumer solely responsible for constraining create access. betterAuthCollections() now emits a one-time console.warn at config-build time reminding you to reject anonymous/non-admin callers on both the collection's access.create and the role field's access.create — otherwise Payload's auto-REST (POST /api/<users>) can let anyone seed a user with a privileged role. Guard behavior is unchanged; this is purely a diagnostic.

payload-puck

0.6.30 - 2026-07-03

Changed

  • The editor's /api/puck/styles dev-time compile endpoint now uses conditional HTTP caching (ETag/Last-Modified, Cache-Control: no-cache) instead of immutable, max-age=31536000. The previous header told browsers to cache the compiled CSS forever on a URL with no versioning, so browsers never learned when the underlying source CSS or the plugin's own compilation logic changed. Revalidation is now cheap (a 304 when unchanged) but always happens.
  • IframeWrapper's editor stylesheet/CSS handling was simplified internally. The consumer's stylesheets/CSS still render directly inside the Puck preview iframe only (never the host admin page — this was investigated and deliberately rejected; see below), but now as ordinary React children instead of manual document.createElement/appendChild calls, letting normal React reconciliation add/remove/update them instead of hand-rolled DOM bookkeeping. FOUC prevention is a small onLoad/onError-driven gate with a timeout safety net (in case a load event never fires for an already-cached resource), replacing the previous forced-reflow/remount-key machinery. No change to public props or behavior.

Investigated and rejected

  • Rendering the compiled stylesheet on the host admin document so Puck's native syncHostStyles could mirror it into the iframe. This was implemented, then reverted after live testing found it leaked the consumer's Tailwind preflight/reset rules into Payload's own admin UI (confirmed: it broke the admin nav sidebar's styling). A CSS @layer fix didn't work (Payload's own admin CSS is also Tailwind-based and also uses layers). A selector-scoping fix (a new postcss-prefix-selector dependency, marking the iframe's own <html> and rewriting selectors) closed that specific leak but only handled exact html/:root/body selectors — an independent review found it would silently fail to scope realistic compound selectors (e.g. .dark .foo, html.dark {}, :root[data-theme='dark']), a worse fragility class than what it replaced. Reverted in favor of keeping the stylesheet iframe-local, which has zero host-leak risk by construction. This also means the @puckeditor/core peer dependency did not need to move past its existing >=0.21.0 floor — the only mechanism actually used (waitForStyles) predates the 0.22 release this investigation started from, so no peer bump was needed after all.

payload-better-auth

0.9.1 - 2026-07-02

Added

  • Passkey-enabled admin login wrapper (components/login-passkey#LoginViewWrapperWithPasskey). 0.9.0 stopped the default LoginView from importing the optional @better-auth/passkey peer (to fix builds for consumers who don't use passkey), which meant passkey sign-in on the auto-injected admin login no longer worked — there was no way to inject a passkey-capable client. This adds a drop-in wrapper that does the same server-side method detection as the default and injects a passkey client. Point your login view at it:
    createBetterAuthPlugin({
      admin: {
        loginViewComponent:
          '@delmaredigital/payload-better-auth/components/login-passkey#LoginViewWrapperWithPasskey',
      },
    })
    
    It lives in a separate entry point, so consumers who don't use passkey never pull the peer into their bundle (the default login view stays passkey-free). No change needed unless you use passkey sign-in on the admin login.

Internal

  • LoginViewWrapper's prop resolution extracted to a shared resolveLoginViewProps() so the default and passkey wrappers share one source of truth (no prop-forwarding drift).

payload-better-auth

0.9.0 - 2026-07-02

Second hardening pass from the audit — correctness, robustness, and defensive fixes. Contains a few breaking changes (minor bump signals them, pre-1.0). See Migration.

Security

  • normalizeRoles no longer comma-splits a role string. A value like "super,admin" was split into ["super", "admin"], letting a fragment coincidentally match admin and grant access. A string is now treated as a single role; use an array for multiple roles.
  • canUpdateOwnFields no longer verifies the current password via payload.login. That made every access check a full, unthrottled login attempt — a brute-force oracle against the user's own password. Password changes must now go through Better Auth's native, rate-limited change-password flow; the access function denies updates that touch password fields.
  • Endpoint proxy prefers the configured Better Auth baseURL over client x-forwarded-proto/host headers when building the URL Better Auth signs/validates, reducing host-header influence on cookie/redirect/OAuth origins (falls back to headers only when no baseURL is set).
  • Session JWT field inclusion is now an exact allowlist, not a suffix match — a future session field ending in Token (e.g. a plugin's refreshToken) can no longer be auto-embedded in issued JWTs.
  • API-key permission checks prefer the strategy-resolved req.user.apiKeyScopes (a single side-effect-free read) over calling verifyApiKey — which consumes the key's usage quota and a rate-limit slot. Multi-permission checks (requireAllPermissions/requireAnyPermission) no longer burn quota once per permission; the fallback path verifies at most once per request.

Changed

  • BREAKING: passkey sign-in in the default LoginView now requires an injected authClient. The component no longer statically imports the optional @better-auth/passkey peer — doing so broke the build of every consumer who hadn't installed it, even those not using passkey. Pass an authClient built with passkeyClient() to enable passkey; without it, the passkey button shows a guidance message. (The passkey/api-key-specific components still import their peers, which is correct — using those features implies installing the peer.)
  • BREAKING: starts_with/ends_with are now correctly anchored. They previously mapped to Payload's like, which is not anchored (it matches words/substrings anywhere), so they returned over-broad results. They now narrow with contains at the database and anchor the match precisely in a post-filter. Note: deep pagination combined with these operators is best-effort (post-filtering can't use DB-level pagination); they scan a bounded window.
  • BREAKING: normalizeRoles / canUpdateOwnFields behavior — see Security and Migration.
  • Adapter create/update return the raw Payload result instead of merging input over it. Merging re-injected fields Payload silently dropped (a missing column), making Better Auth believe a field persisted when it did not. The factory maps field names back on output, so return values are unchanged for correctly-configured collections; genuinely dropped fields now emit a warning instead of vanishing silently.

Fixed

  • Collection augmentation no longer turns non-PK references into relationships. A non-PK reference (e.g. oauthRefreshToken.clientId → oauthClient.clientId) added to a pre-existing collection wrongly became a stripped relationship field, mismatching what the adapter writes. It now stays a plain field with its original name, matching generateCollection.
  • getExistingFieldNames recurses into presentational containers (row, collapsible, unnamed tabs). A users collection that organizes email/role inside tabs/rows no longer has those fields re-added by augmentation (which caused duplicate-field config errors).
  • Endpoint proxy fails closed on an unresolvable request path (falling back to parsing req.url) rather than proceeding with an empty pathname — which would target the wrong route and silently disable the api-key authorization guard.
  • Multi-instance safety: the auth instance and the api-key permissions config are now resolved per Payload instance (stored on the instance, read via req.payload) rather than a module-level singleton, so a second plugin instance in one process (monorepo dev, multi-tenant, parallel tests) can't be handed another instance's auth or relax its api-key role guard.
  • Shared plugin-id detection. detectEnabledPlugins and detectEnabledMethods now derive from one helper (robust to a non-array plugins), fixing drift between them.
  • Mount-effect cleanup. LoginView's session check and the management clients' initial fetches now bail on unmount (no stale router.push/setState, no StrictMode double-apply).

Notes

  • API-key security parameters (expiry, rate limit, key length) remain the consumer's responsibility, matching a native Better Auth setup — this package stays a thin adapter and does not impose opinionated key defaults.

Migration

  1. Passkey in the admin login: if you use passkey sign-in on the default LoginView, pass an authClient:
    import { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
    import { passkeyClient } from '@better-auth/passkey/client'
    const authClient = createAuthClient({ plugins: [...payloadAuthPlugins, passkeyClient()] })
    // <LoginView authClient={authClient} ... />
    
  2. Roles: if you stored multiple roles as a comma-separated string ("admin,editor"), switch the field to an array. A comma string is now a single role.
  3. Password changes: move any "change password" UI from a Payload update on the user collection to Better Auth's authClient.changePassword({ currentPassword, newPassword }) flow.
  4. starts_with/ends_with: if you relied on the old (over-broad) behavior, note these are now correctly anchored.

payload-better-auth

0.8.0 - 2026-07-02

Security-hardening release from a full audit. Contains breaking changes; the minor bump signals them (pre-1.0). Read the Migration section below before upgrading.

Security

  • API keys sent via x-api-key are now scope-enforced. extractApiKeyFromRequest only read the Authorization header, but the auth strategy authenticates keys via both x-api-key and Authorization: Bearer. As a result, a key sent via x-api-key populated req.user while the access helpers saw no key — so any access function using allowSessionOrPermission / allowSessionOrAnyPermission / allowAuthenticatedUsers: true treated a scoped (or zero-scope) key as a full session, bypassing scope enforcement for write/delete. extractApiKeyFromRequest now reads x-api-key, and API-key-derived users (those carrying apiKeyScopes) are additionally excluded from the "authenticated user" short-circuit as defense-in-depth.
  • 2FA TOTP secrets no longer leave the browser. The 2FA setup/management views rendered the QR code via https://api.qrserver.com/..., sending each user's full otpauth://…?secret=… provisioning URI (including the shared secret) to a third-party service. QR codes are now generated client-side as inline SVG via qrcode.react. Anyone who could read that third party's logs could previously generate valid TOTP codes.
  • Roles are assigned authoritatively on the server at sign-up. The admin login form no longer transmits a role, and both first-user-admin hooks now ignore a client-supplied role for non-first users (the Payload collection hook honors a role only when an already-authenticated admin performs the create). Previously a client could POST { role: 'admin' } to the sign-up endpoint and self-provision an admin when role was an input-writable field. See Migration.
  • First-user-admin concurrent-signup race narrowed. The auto-injected hook added an afterChange guard that resolves a concurrent first-signup race to a single canonical admin (only bootstrap-assigned admins are ever demoted; admins created deliberately by an existing admin are untouched). Note this remains a bootstrap convenience — enable first-user-admin before exposing public sign-up.
  • detectAuthConfig no longer misreads auth: true as disableLocalStrategy. A collection with auth: true (which enables Payload's local strategy) wrongly triggered admin-component injection, hijacking a working local login and potentially locking out admins whose credentials live only in Payload's local strategy. Only the object form with disableLocalStrategy now counts.
  • Adapter no longer logs credential-bearing query values. The findOne error path logged the full where clause unconditionally — which for sessions is the raw token, for verifications the OTP, for api-keys the key hash. It now logs field names only (full where remains under enableDebugLogs).
  • Password-reset token is stripped from the URL after capture (via history.replaceState), so it no longer lingers in browser history or analytics.

Changed

  • BREAKING: role is server-assigned on sign-up. See Migration for the input: false change and firstUserAdmin.defaultRole.
  • BREAKING: Node.js >= 20.9 required. The prior ^18.20.2 clause was dropped — Node 18 is EOL and the tested toolchain (Next 16, Vite 7) requires ≥ 20.9.
  • BREAKING: peer dependency ranges are capped to tested majors (better-auth >=1.6.0 <2, payload/@payloadcms/* >=3.69.0 <4, next >=15.5.16 <17, react >=19.2.1 <20) to prevent silently installing an untested major.
  • LoginView defaultSignUpRole prop is deprecated and ignored. It was previously sent to the server as a client-writable role. Configure the default self-sign-up role via firstUserAdmin: { defaultRole } instead.
  • Bumped Better Auth to 1.6.23 (from 1.6.18) across better-auth and @better-auth/*. Regenerated generated-types — the twoFactor schema gains failedVerificationCount and lockedUntil (BA's 2FA lockout hardening), which betterAuthCollections() now surfaces.
  • package.json exports reordered to types → import → default so the types condition resolves under node16/nodenext module resolution; added a ./package.json subpath export.

Added

  • qrcode.react is now a runtime dependency (used to render 2FA QR codes locally).
  • CI now runs pnpm test before publishing (the tag-triggered publish workflow previously had no test gate).

Fixed

  • Adapter pagination for non-aligned offsets. findMany derived the Payload page as floor(offset/limit)+1, which is only correct when offset is a multiple of limit; arbitrary offsets (e.g. admin listUsers) returned duplicated/skipped rows. Non-aligned offsets now over-fetch from page 1 and slice to the true offset.
  • Idempotent delete/update by id. Id-targeted deletes/updates threw Payload's 404 APIError (surfacing as a 500) when the row was already gone; they now no-op / return null, matching Better Auth's reference adapters (fixes spurious errors on double sign-out and concurrent session revocation).
  • sortBy on renamed reference fields. findMany({ sortBy: { field: 'userId' } }) sorted on a non-existent column (the factory maps where field names but not sortBy); the adapter now maps sortBy fields through the same userId → user rename, including under usePlural: true.
  • Bulk updateMany/deleteMany partial failures are now surfaced (previously docs.length reported partial failures as success).

Migration

  1. Make role server-only. In your Better Auth user.additionalFields, set the role field to input: false:
    user: { additionalFields: { role: { type: 'string', defaultValue: 'user', input: false } } }
    
    To change the default role assigned to self-sign-ups, use the collections option:
    betterAuthCollections({ firstUserAdmin: { defaultRole: 'user', adminRole: 'admin' } })
    
    Remove any reliance on the LoginView defaultSignUpRole prop (now ignored). If you need to assign specific roles to non-first users, do it from a trusted context (the Payload admin UI, a seed script, or your own access-gated hook) — not the public sign-up endpoint.
  2. Node.js >= 20.9. Upgrade your runtime/CI if you were on Node 18.
  3. qrcode.react installs automatically as a dependency; no action needed unless you vendor node_modules.
  4. API-key access unchanged in usage — if you relied on the (insecure) behavior where an x-api-key request bypassed scope checks under allowSessionOrPermission, note that keys are now scope-enforced on both header transports.
  5. Regenerate collections/types if you use betterAuthCollections() with the twoFactor plugin, to pick up the new failedVerificationCount / lockedUntil columns (a Better Auth 1.6 schema addition).

payload-better-auth

0.7.9 - 2026-06-27

Security

  • API-key requests no longer risk inheriting another user's scopes via ID collision. When betterAuthStrategy resolves an API key's organization/scope context, it reads the API key row keyed by the mock session's id. Because sessions and API keys are separate Payload collections with independent serial sequences (the default idType: 'number'), a cookie session's row id can collide with an unrelated API key's id — so a request carrying a stray Authorization: Bearer … header could coerce a lookup of an arbitrary key. The lookup is now bound to the authenticated user (referenceId === session user id), so only a key the session's user owns is ever read. For a genuine API-key session this binding always holds (Better Auth derives the mock session's user from the key's referenceId); a colliding cross-user session simply matches no row. idType: 'text' (UUID) was not practically affected.

Added

  • API-key scopes are now surfaced on req.user. Authenticated API-key requests expose req.user.apiKeyScopes — the key's stored permissions flattened to resource:action strings (e.g. { inquiries: ['write'] } → ['inquiries:write']), mirroring oauthScopes on the OAuth/JWT path. It is [] for a key with no permissions and absent for non-API-key requests, so consumers can distinguish "scoped to nothing" from "not an API key". Previously only OAuth tokens carried scopes; API keys reached access control with no scope information, forcing downstream workarounds and leaving Payload-native collection access effectively un-gated for keys.
  • apiKeysCollection option on betterAuthStrategy (default 'apikeys'; use 'apikey' when betterAuthCollections({ usePlural: false })) — the collection the strategy reads to resolve API-key scopes and organization metadata.

Changed

  • betterAuthStrategy resolves API-key organization context and scopes by reading the API key row directly, instead of calling auth.api.verifyApiKey. verifyApiKey is not a read — it consumes the key's usage quota (remaining) and a rate-limit slot, and can delete the key on exhaustion/expiry. Combined with the validation Better Auth already performs inside getSession, the previous code consumed quota/rate-limit twice per request for keys with remaining or rate limits configured. The strategy now performs a single, side-effect-free row read, so each API-key request consumes the key exactly once.

payload-better-auth

0.7.8 - 2026-06-24

Added

  • Social / OAuth provider sign-in buttons on the admin login (opt-in). A new admin.login.enableSocial option surfaces "Continue with {Provider}" buttons for the providers configured in your Better Auth socialProviders:

    • false (default) — no social buttons.
    • true — a button for every configured provider.
    • string[] — an allowlist of provider ids (intersected with what's configured).

    Providers are detected server-side from the Better Auth instance's resolved socialProviders — the same authoritative approach as the other admin.login methods, with no endpoint probing. Clicking a button calls Better Auth's core signIn.social(...); on success it returns to the login page so the existing session check + role gate run uniformly (a user who lacks the required role lands on Access Denied, not /admin). A new admin.login.socialCallbackURL overrides the success destination; sign-in errors always return to the login page and are surfaced inline. There is intentionally no 'auto' mode — social is opt-in.

    Account creation: a social button on a public admin login lets anyone with that provider account create a (non-admin) user row — the role gate blocks admin access, but the row (and any creation hooks) is still created. Set Better Auth's disableImplicitSignUp (per-provider or global) if you don't want open sign-up. This is why enableSocial defaults to false. (#21)

Internal

  • LoginView decomposed into focused, independently-tested components (src/components/login/: AuthCard, AuthField, AuthButton, AuthBanner, OrDivider, OtpInput, plus the per-screen forms). No change to the public LoginView API or its rendered output. Added a jsdom + React Testing Library component-test suite alongside a behavior characterization safety net; the test suite now stands at 197 tests.

payload-better-auth

0.7.7 - 2026-06-23

Fixed

  • Login-method auto-detection now resolves server-side instead of probing endpoints. The 'auto' mode for enablePassword, enableMagicLink, enableEmailOtp, enablePasskey, enableSignUp, and enableForgotPassword previously probed OPTIONS /sign-in/* from the client and treated any non-404 as "enabled". Better Auth answers every OPTIONS request with 200 (CORS preflight), so detection always reported enabled — surfacing magic-link / email-OTP buttons even when those plugins weren't installed, never hiding the password field when emailAndPassword.enabled: false, and over-showing the sign-up / forgot-password links. LoginViewWrapper (a server component) now reads the Better Auth instance's resolved options and passes concrete booleans to LoginView:

    • enablePassword ← emailAndPassword.enabled
    • enableSignUp ← emailAndPassword.enabled && !disableSignUp
    • enableForgotPassword ← emailAndPassword.enabled && sendResetPassword configured
    • enablePasskey / enableMagicLink / enableEmailOtp ← the passkey() / magicLink() / emailOTP() plugin being registered

    Explicit true / false config still takes precedence. The client-side OPTIONS probes were removed; for standalone <LoginView> use (without the plugin wrapper), an unresolved 'auto' now falls back to safe defaults — password shown, optional methods hidden. Thanks to @Tasztalos69 for the precise report (#20).

payload-better-auth

0.7.6 - 2026-06-12

Changed

  • Better Auth dev dependencies bumped 1.6.17 → 1.6.18 (better-auth, @better-auth/api-key, @better-auth/oauth-provider, @better-auth/passkey). Patch-level; the peer floor remains >=1.6.0, so consumers are unaffected. Upstream is bug fixes and security hardening only.

Fixed

  • createPayloadAuthClient() declaration build under Better Auth 1.6.18. 1.6.18's base client type added a refreshToken method; because payloadAuthPlugins is intentionally widened to BetterAuthClientPlugin[] for .d.ts portability, the inferred return type dropped refreshToken and broke tsc --emitDeclarationOnly. The return is now cast back to the stable PayloadAuthClient type — the runtime client is unchanged and still exposes refreshToken.

Documentation

  • Documented passwordless admin login (magic-link & email-OTP). Added a "Passwordless Sign-In" section to the docs site, refreshed the stale version badge (was v0.7.4), and noted the new login options in the README. (The feature itself shipped in 0.7.5.)

payload-better-auth

0.7.5 - 2026-06-12

Added

  • Magic-link and email-OTP sign-in in the admin LoginView. The generated login view now surfaces "Email me a link" (magic-link) and "Email me a code" (email-OTP) options, auto-detected when the Better Auth magicLink() / emailOTP() plugins are configured. Server-side plugin wiring and email delivery remain the integrator's responsibility. New admin.login options (each boolean | 'auto', default 'auto'): enableMagicLink, enableEmailOtp, plus magicLinkCallbackURL.
  • Passwordless-only login. New admin.login.enablePassword option (boolean | 'auto', default 'auto') hides the password field when the email/password strategy is disabled (emailAndPassword.enabled: false), promoting a passwordless method to the primary sign-in action. Resolves #20.

Changed

  • Better Auth bumped 1.6.14 → 1.6.17 (dev dependencies better-auth, @better-auth/api-key, @better-auth/passkey, @better-auth/oauth-provider). Patch-level upgrade — no breaking changes and no adapter code changes required; the adapter's updateMany already returns affected-row counts per the 1.6.17 adapter contract, and the new optional incrementOne method falls back transparently. Peer floor remains >=1.6.0. Upstream is predominantly security hardening (atomic single-use tokens/counters, OAuth/SSO provider identity validation, rate-limit correctness).

payload-page-tree

0.3.16 - 2026-06-06

Fixed

  • Duplicating a page with slugHistory no longer fails (#4): The duplicate handler shallow-copied the source document into payload.create(), including slugHistory array rows that carry Payload-generated row ids. Payload rejected those nested ids with The following field is invalid: id. slugHistory is now stripped before duplication — the new page correctly starts with no prior URL history. Thanks to @superxiao for the report.

payload-page-tree

0.3.15 - 2026-06-06

Fixed

  • adminView.navLabel and adminView.path are now respected: PageTreeNavLink hardcoded its nav-group label ("Page Tree") and its href (/page-tree), so the navLabel option (declared in the type but never wired up) had no effect, and setting a custom adminView.path produced a broken sidebar link that still pointed at /page-tree. Both values are now passed to the nav link as clientProps; defaults preserve existing behavior. Follow-on to the 0.3.14 #3 custom-admin-route fix.

payload-puck

0.6.29 - 2026-06-03

Fixed

  • Grid/Columns still stacked at all widths (incomplete 0.6.28 fix). 0.6.28 set the mobile base grid-template-columns: 1fr as an inline style, which outranks the @media (min-width: 768px) rule in the scoped <style> block — inline styles beat stylesheet rules, media queries included — so the layout was pinned to a single column at every width (and this also regressed projects where the Tailwind-based layout previously worked). Both the mobile base and the responsive multi-column rule now live in the scoped <style> block, so the media query applies correctly. Verified in-browser: single column below 768px, multi-column (with the configured ratios) at and above 768px. (#10)

payload-puck

0.6.28 - 2026-06-03

Fixed

  • Grid and Columns now lay out correctly without requiring the consumer's Tailwind to generate the plugin's utility classes. Both components previously relied on Tailwind classes (flex flex-col w-full md:grid) for their display: grid / responsive behavior, while only the dynamic grid-template-columns came from an inline <style>. In projects whose Tailwind build doesn't scan the plugin's dist (so those utilities, especially the md: variant, are never emitted), the container stayed a plain block and children stacked vertically at all widths. The grid is now fully self-contained: display: grid is applied inline (single column on mobile, multi-column at ≥768px via a scoped media query), with no dependency on consumer Tailwind classes. (#10)

payload-puck

0.6.27 - 2026-06-03

Added

  • Columns layout component with independent per-column drop zones. Unlike Grid (a single CSS-grid slot where dropped components auto-place into cells and share one drop zone), Columns exposes a separate slot per column, so components can be targeted into a specific column. Supports 2–4 columns and width-distribution presets (equal, or fr ratios like 2:1, 1:2, 1:1:2), stacks vertically on mobile, and switches to a CSS grid at ≥768px. Reuses the shared field set (background, padding, margin, border, gap, visibility, animation) for parity with Grid. (#10)

payload-puck

0.6.26 - 2026-06-03

Fixed

  • Plugin endpoints now sync Puck root.props back to Payload fields on save. The plugin's own CRUD endpoints (/api/puck/:collection/*) persisted root-only fields (meta.*, pageLayout, conversionTracking.*, etc.) inside the puckData blob but never wrote them to their Payload columns — only the standalone createPuckApiRoutesWithId handler did. Because the editor load path hydrates root.props from those columns and lets them win (see 0.6.22), edits made through Puck root fields reverted on the next load or publish. The create/update handlers now map root.props → Payload fields, mirroring the standalone handler.
  • The configured rootPropsMapping is now plumbed into both editor load views (mapPayloadFieldsToRootProps), so custom mappings round-trip symmetrically rather than only the built-in defaults. The homepage-swap check now reads the merged value, so it works whether isHomepage arrives as a top-level field or via root.props.

Changed

  • Bumped Puck dependencies: @puckeditor/core (dev) and @puckeditor/plugin-heading-analyzer to ^0.21.2 (performance improvements, no breaking changes), and @puckeditor/plugin-ai / @puckeditor/cloud-client to ^0.7.0. The 0.7.0 releases are backward-compatible additive changes (plugin-ai gains optional message attachments and a widened onSubmit signature; cloud-client extracts a named PuckAiOptions type, makes puckHandler's options optional, and adds an ./experimental subpath). No code changes were required and the peer range (@puckeditor/core >=0.21.0) is unchanged.

Docs

  • Added a light/dark theme toggle to the documentation site. It defaults to the visitor's OS prefers-color-scheme, persists the choice to localStorage, and is applied before first paint to avoid a flash. (#9)

payload-better-auth

0.7.4 - 2026-06-03

Fixed

  • Disabling two-factor authentication from the admin no longer fails with "Invalid password". TwoFactorManagementClient was calling client.twoFactor.disable({ password: '' }) with a hardcoded empty string, so Better Auth's /two-factor/disable rejected the request with a 400 INVALID_PASSWORD before the real password was ever checked. The disable flow now prompts for the account password (mirroring the enable flow) and submits it. Enabling 2FA was unaffected.

payload-puck

0.6.25 - 2026-05-16

Changed

  • Bumped payload, @payloadcms/next, @payloadcms/ui dev dependencies to ^3.84.1 (from 3.79.0). Picks up patched transitives (ajv 8.18.0, lodash 4.18.1, fast-uri 3.1.2), clearing the high-severity lodash code-injection and fast-uri path-traversal/host-confusion CVEs from the dev tree. Peer dependency range (>=3.69.0) is unchanged — consumers are unaffected.

payload-puck

0.6.24 - 2026-05-16

Changed

  • Bumped next dev dependency to ^16.2.6 to clear CVE-2026-44574 (middleware/proxy bypass via dynamic route parameter injection). The plugin itself ships no middleware and is not exploitable, but the dev-dep range was inside the vulnerable window.
  • Documented the CVE in the README requirements so consumers using Next.js middleware know the minimum patched versions (15.5.16 / 16.2.5, or 15.5.18 / 16.2.6 for Turbopack).

payload-better-auth

0.7.3 - 2026-05-16

Changed

  • Payload bumped 3.83.0 → 3.84.1. Patch-level upgrade — no breaking changes and no adapter/plugin code changes required. Dev dependency versions updated for payload, @payloadcms/next, and @payloadcms/ui. All tests pass against the new version.

payload-better-auth

0.7.2 - 2026-05-16

Security

  • Raised minimum next peer dependency to >=15.5.16 to mitigate CVE-2026-44574 — a high-severity middleware/proxy bypass via dynamic route parameter injection affecting Next.js 15.4.0–15.5.15 and 16.0.0–16.2.4. Fixed upstream in 15.5.16 and 16.2.5. Dev dependency bumped next 16.2.4 → ^16.2.5 (resolves to 16.2.6). This library doesn't ship middleware, but consumers using betterAuthStrategy() for route protection on vulnerable Next.js versions are exposed.

payload-better-auth

0.7.1 - 2026-05-11

Changed

  • Better Auth bumped 1.6.6 → 1.6.10. Patch-level upgrade — no breaking changes and no adapter/plugin code changes required. Dev/peer dependency versions updated for better-auth, @better-auth/api-key, @better-auth/oauth-provider, and @better-auth/passkey. Consumer-facing fixes inherited from upstream:
    • OAuth provider: generated schemas now include FK indexes on referenced fields — consumers running the @better-auth/oauth-provider plugin should generate a fresh Payload migration to capture them.
    • Stripe plugin: subscription lifecycle hooks (onSubscriptionUpdate, onSubscriptionCancel, onTrialEnd, onTrialExpired) now receive the post-update subscription row instead of a stale snapshot.
    • Auth flows: duplicate Set-Cookie headers removed from social sign-in and magic-link redirects; useSession now revalidates after admin impersonation toggles; callbackURL honored on signIn.username; OAuth provider prompt=login is now correctly enforced after consent continuation.
    • OAuth: mapProfileToUser fallback fixed for providers that omit email; OAuth callbacks no longer link accounts under an undefined provider account ID.
    • Organization plugin: cancelPendingInvitationsOnReInvite now functions; dynamic access control roles accepted in invitations; useActiveMemberRole no longer retains a stale role after sign-out; setActiveTeam validates team membership against the active organization.
    • Misc: additionalFields TS2742 type error in the organization plugin resolved; SAML2 /sso/saml2/sp/metadata works with defaultSSO; passkey autofill returns a handled cancellation instead of an unhandled error.

payload-better-auth

0.7.0 - 2026-04-21

Breaking

  • Better Auth 1.5 → 1.6 upgrade. Peer dependency requirements raised accordingly. Consumers must upgrade better-auth, @better-auth/api-key, and @better-auth/passkey to >=1.6.0. Upstream breaking changes surfaced in this release:
    • twoFactor table: new verified column. Better Auth 1.6.2 added a verified boolean field (default true) to the twoFactor schema to prevent unverified TOTP enrollment from blocking sign-in. Consumers using the twoFactor plugin must run a Payload schema migration to add the column. Existing rows do not need backfill — the column defaults to true.
    • session.freshAge calculation changed. Better Auth 1.6.0 now computes freshAge from session.createdAt instead of updatedAt. To disable the check entirely, set session: { freshAge: 0 }. Not used internally by this library, but consumers relying on the prior behavior should review their freshAge config.
  • Client exports widened for declaration-emit portability. Better Auth 1.6's zod v4 adoption made the inferred plugin/client types non-portable under tsc --emitDeclarationOnly. To restore a stable public API surface:
    • payloadAuthPlugins is now typed as BetterAuthClientPlugin[] (was an as const tuple).
    • createPayloadAuthClient() return type is now PayloadAuthClient = ReturnType<typeof createAuthClient>.
    • Consumer impact: the returned client no longer narrows to expose plugin-specific methods (e.g. client.twoFactor.verifyTotp) via this helper. Consumers who need typed plugin methods should call createAuthClient directly with twoFactorClient() passed explicitly, e.g. createAuthClient({ plugins: [twoFactorClient()] }).
  • Schema type exports migrated from oidcProvider → @better-auth/oauth-provider. The type-generation script now uses the actively maintained plugin since oidcProvider is deprecated in Better Auth 1.6 and will be removed in the next major. This changes the shape of OAuth-related generated types:
    • OauthApplication type removed. Replaced by OauthClient with a substantially different field set (adds scopes, redirectUris array, postLogoutRedirectUris, tokenEndpointAuthMethod, grantTypes, responseTypes, public, requirePKCE, referenceId, contacts, tos, policy, softwareId/Version/Statement, etc.; drops redirectUrls singular, metadata becomes unknown).
    • New OauthRefreshToken type — refresh tokens now live in their own model with token, clientId, sessionId, referenceId, authTime, scopes fields.
    • OauthAccessToken fields changed: accessToken/refreshToken → token/refreshId, added sessionId/referenceId, scopes is now string[] (was string).
    • OauthConsent fields changed: removed consentGiven, added referenceId, scopes is now string[].
    • PluginId string union: "oidc-provider" → "oauth-provider".
    • ModelKey: "oauthApplication" removed; "oauthClient" and "oauthRefreshToken" added.
    • Consumer impact: consumers still using oidcProvider() at runtime will continue to function (the runtime collection generator reads from getAuthTables() and reflects whichever plugin is configured), but the statically generated types now reflect oauth-provider's schema. Consumers narrowing on PluginId/ModelKey or importing OauthApplication must update to the new names. Recommended: migrate to @better-auth/oauth-provider and run the corresponding Payload schema migration.

Changed

  • Dependency bumps. @payloadcms/next and @payloadcms/ui 3.78 → 3.83, next 16.1.6 → 16.2.4, react 19.2.4 → 19.2.5, plus minor bumps to @swc/core/@swc/cli, @types/node.
  • Generated types regenerated against Better Auth 1.6.6 schema. TwoFactorFields.verified?: boolean added.

Fixed

  • tsc --emitDeclarationOnly build no longer fails with TS2742 / TS7056. Better Auth 1.6 moved to zod v4 internally, which caused the previously inferred types of payloadAuthPlugins and createPayloadAuthClient to reference .pnpm/zod@.../v4/core — a non-portable path that broke declaration emission. Resolved via the explicit type annotations described above under Breaking.

Added

  • @better-auth/oauth-provider as a devDependency for type generation. Not a runtime dependency — it is only imported by src/scripts/generate-types.ts to produce accurate schema types.

Known Issues

  • Pre-existing test failure in tests/adapter/adapter.test.ts (should default to number ID type without warning when generateId is undefined) — this test was already failing on v0.6.10 and is not introduced by this release. The adapter warns whenever idType === 'number' and generateId !== 'serial'; the test's comment asserts that disableIdGeneration: true should suppress the warning, but the implementation doesn't gate on that. Tracking separately.

payload-page-tree

0.3.14 - 2026-04-16

Fixed

  • Folder Edit URL no longer deadlocks on Postgres (#2): The cascadeSlugUpdates afterChange hook now passes req through to its internal find and update calls. Previously those operations ran on a separate connection from the outer folder update's transaction, causing Postgres to deadlock on the locked folder row whenever the folder had child pages. The outer transaction would then roll back — the folder name appeared to persist briefly but child page slugs never updated. All nested payload operations inside the cascade now share the request transaction.
  • Custom admin routes are respected (#3): The "Manage Pages" nav link and the context-menu "Edit in Payload" action previously hardcoded /admin, breaking anyone who set a custom routes.admin in their Payload config. The nav link now reads the admin route from useConfig(), and the context-menu action uses the adminRoute already plumbed through PageTreeClient.

Thanks to @superxiao for both reports.

payload-better-auth

0.6.10 - 2026-04-16

Fixed

  • Respect Payload's custom routes.admin and routes.api config — Admin UI components previously hardcoded /admin/* and /api/* paths, breaking projects that customize Payload's route config. All admin components (LoginView, LogoutButton, ForgotPasswordView, ResetPasswordView, BeforeLogin, TwoFactorVerifyView, TwoFactorSetupView, SecurityNavLinks) now read route prefixes from useConfig() and apply them to fetch calls and navigation. Defaults fall through to Payload's standard /admin / /api so existing consumers are unaffected. Thanks to @luochuanyuewu for #17 which seeded this fix.

payload-page-tree

0.3.13 - 2026-04-08

Changed

  • Title and URL are now independent: Renaming a page or folder display name no longer touches the URL segment. Previously, folder rename via the tree could cascade slug updates to all nested pages, and duplicate page/folder titles in the same parent were blocked. The new rule: display fields (title, name) and URL segments (pageSegment, pathSegment) are fully independent after create. To change a URL segment, use the Edit URL action explicitly.
  • Folder rename no longer prompts: The "Keep URLs / Update URLs" confirmation that appeared when renaming a folder with children has been removed. Folder rename now just renames — it never touches child page slugs. If you want to change a folder's URL segment and cascade to children, use Edit URL on the folder instead (which has its own explicit confirmation with type-to-confirm safety gate).
  • Duplicate page behavior: Duplicating a page still appends "(copy)" / "(copy 2)" to the title, but the new page's URL segment now auto-increments independently (thank-you, thank-you-2, thank-you-3) instead of being derived from the mangled title (thank-you-copy).
  • Create allows duplicate titles: Creating a new page or folder with a title that already exists in the same parent is now allowed. The display name is kept as-is; only the URL segment is auto-disambiguated. The success toast surfaces the resolved URL when a collision is resolved.

Added

  • Live URL availability check in Edit URL modal: As you type a new URL segment, the modal performs a debounced server-side check (300ms) and shows a green "Available" or red "URL is already in use" indicator. The Save button is disabled until a valid, available segment is entered. Closes a data-integrity hole where duplicate segments could previously be saved silently.
  • Folder cascade safety gate: Editing a folder's URL segment (via Edit URL) or moving a folder with children while choosing "Update URLs" now requires a type-to-confirm step. The modal shows the exact number of child pages affected and requires the user to type the new pathSegment to proceed. Applies to both single and bulk folder moves. Prevents accidental cascading URL rewrites.
  • Two new API endpoints:
    • GET /api/page-tree/check-segment — lightweight availability lookup backing the live check in the Edit URL modal.
    • GET /api/page-tree/folder-impact?folderId=X — returns the count of pages whose slugs would be rewritten if the folder's URL segment changed.
  • New 'edit-url' value in SlugChangeReason: Slug history entries created via the Edit URL modal now use this reason instead of 'rename'. Existing history entries are unchanged.

Fixed

  • Renaming a page no longer changes its URL: The rename endpoint previously had a code path that could rewrite pageSegment as a side effect of renaming. This was gated behind a flag the client never set, but the capability existed as a foot-gun. It has been removed entirely. Rename is now strictly a display-field-only write.
  • countDescendantPages no longer swallows errors: If a per-collection query fails, the error now propagates to the endpoint (which returns 500) and the modal refuses to proceed, rather than silently undercounting and misleading the user about cascade impact.

Removed

  • generateUniqueName internal helper: Replaced by the new src/utils/segments.ts module which owns all segment availability and auto-increment logic (findAvailableSegment, isSegmentAvailable, countDescendantPages). This is an internal refactor and does not affect the plugin's public API.
  • updateSlugs parameter from rename request body: Previously optional and unused by the tree UI. External callers (if any) that were passing this field will have it silently ignored.

Migration required for Postgres users: Adding 'edit-url' to the slug history reason field enum IS a schema change at the database level. Postgres consumers must generate and run a migration before using this version:

pnpm payload migrate:create
pnpm payload migrate

The generated migration will look approximately like:

import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-vercel-postgres'

export async function up({ db }: MigrateUpArgs): Promise<void> {
  await db.execute(sql`
    ALTER TYPE "public"."enum_pages_slug_history_reason" ADD VALUE 'edit-url';
    ALTER TYPE "public"."enum__pages_v_version_slug_history_reason" ADD VALUE 'edit-url';
  `)
}

(Adjust the enum type names to match your collection slugs if they're not named pages.) MongoDB and SQLite users do not need a migration — those adapters handle enum-shaped fields as flexible strings. No data migration is required for any adapter. Existing slug history entries with reason: 'rename' remain valid.

Apologies for the oversight — the initial release of 0.3.13 documented this as "no migrations required", which was incorrect for Postgres. The package code is correct; only the documentation was wrong.


payload-puck

0.6.23 - 2026-04-06

Fixed

  • Security: API endpoints now enforce collection access control. Previously, all Puck CRUD endpoints (/api/puck/:collection/*) bypassed collection-level access rules because they used Payload's local API with the default overrideAccess: true. The access config passed to createPuckPlugin() had no effect on these endpoints — unauthenticated users could read, create, update, and delete documents. All endpoint handlers now pass overrideAccess: false and forward req so Payload evaluates access rules against the current user. (#7)

Added

  • Locale-aware editing. The Puck editor now propagates the active locale through save, publish, version history, and restore operations, enabling multi-language content management. Locale is resolved from the request body, query params, or Payload middleware. (#6 — thanks @georgisoft2020)
  • Version restore endpoint fix. The version history UI now correctly calls /restore instead of /versions for restore operations. (#6)

payload-puck

0.6.22 - 2026-03-31

Fixed

  • Editor now hydrates root.props from Payload fields when loading a page. Previously, saved values like pageLayout, isHomepage, conversion settings, and SEO fields were only stored in Payload columns during save but never read back into puckData.root.props on load. This caused the editor UI to show default values instead of the actual saved settings, and re-saving would overwrite the correct values with defaults.

payload-better-auth

0.6.9 - 2026-03-24

Removed

  • Opaque OAuth access token DB lookup — Removed the direct database lookup for opaque tokens added in v0.6.8. Per Better Auth docs: "only accept JWT-formatted access tokens for your API." OAuth integrations should ensure all tokens (including refreshed ones) are JWTs by sending the resource parameter on both initial and refresh token exchanges. The opaque DB lookup bypassed Better Auth's validation logic and is not the recommended approach.

payload-better-auth

0.6.8 - 2026-03-24

Added (reverted in v0.6.9)

  • Opaque OAuth access token verification — Direct DB lookup for opaque tokens. Reverted — not aligned with Better Auth best practices.

payload-page-tree

0.3.12 - 2026-03-20

Fixed

  • "Keep existing URL" ignored on move: Choosing "Keep existing URL" when moving a page or folder still regenerated the slug. The hook now uses strict equality (=== false) to distinguish an explicit "don't update" from the default unset state.
  • Slug loses folder path when editing pageSegment in admin UI: Editing the pageSegment field through Payload's admin panel produced a slug with no folder prefix (e.g. complete instead of registration/complete). The hook now treats data.folder=null from admin form serialization as unchanged, only accepting null as an intentional "move to root" during tree operations.
  • pageSegment overwritten with slugified title during moves: Moving a page could replace its pageSegment with slugify(title) if the segment field was empty-string or null (rather than undefined). The fallback now uses a loose check and restricts auto-generation from title to create operations only.

payload-puck

0.6.21 - 2026-03-20

Fixed

  • Editor preview now defaults to dark mode when the active layout has editorDarkMode: true. Previously, the preview dark mode toggle always started as false, overriding the layout setting via the ?? operator. The toggle now reads the initial layout's editorDarkMode on mount.

payload-puck

0.6.20 - 2026-03-20

Fixed

  • createPuckApiRoutesWithId PATCH handler now accepts both _status and status in the request body. The editor sends _status (Payload convention) but the handler only read status, causing publish to silently fail. Pages remained in draft despite clicking Publish.

payload-better-auth

0.6.7 - 2026-03-20

Fixed

  • Non-PK reference fields no longer create Payload relationships — Better Auth's oauthRefreshToken.clientId and oauthAccessToken.clientId reference oauthClient.clientId (a non-PK field), not oauthClient.id. Payload relationships always FK to id, causing constraint violations. These fields are now created as plain text columns when the reference target is not id. The adapter factory's fieldName stripping (removing Id suffix) is also skipped for non-PK references, preserving the original column name. This eliminates the need for the id = clientId workaround when registering OAuth clients.

payload-better-auth

0.6.6 - 2026-03-20

Fixed

  • OAuth JWT verification issuer/audience mismatch — The verifyAccessToken call used baseURL for both issuer and audience, but the JWT issuer includes the auth base path (e.g., https://example.com/api/auth) while the audience is the app URL (e.g., https://example.com). Now correctly sets issuer = baseURL + basePath and audience = baseURL.

payload-better-auth

0.6.4 - 2026-03-19

Fixed

  • Double-s pluralization for models ending in 's' — Better Auth's getModelName factory appends s unconditionally, causing model names like jwks to become jwkss. The adapter's getCollection() now normalizes double-s suffixes. This fixes collection lookup failures when using the jwt() or any plugin with model names ending in s. Upstream issues: #3069, #2659. This fix is safe and becomes a no-op when upstream is fixed.

payload-better-auth

0.6.3 - 2026-03-19

Added

  • OAuth JWT access token verification — The betterAuthStrategy now verifies OAuth 2.1 JWT access tokens via JWKS when session cookie auth returns no result. When a Bearer token is not a session cookie or API key, the strategy attempts JWT verification using Better Auth's verifyAccessToken utility, fetching the signing keys from the /api/auth/jwks endpoint. On success, it extracts the user ID (sub), scopes, and organization context from JWT claims, enabling OAuth integrations (Zapier, third-party apps) to authenticate Payload REST API requests with access tokens. The oauthScopes array is available on req.user for scope-based access control.

payload-page-tree

0.3.11 - 2026-03-18

Fixed

  • Folder rename now updates pathSegment: Renaming a folder correctly updates its URL segment to match the new name. Previously, the folder's pathSegment was only updated when "Update URLs" was explicitly chosen, leaving the slug out of sync with the folder name.
  • Context menu viewport overflow: Right-click context menu now repositions to stay within the viewport when opened near the bottom or right edge of the screen.

Added

  • Rename confirmation for folders with children: Renaming a folder that contains pages now prompts whether to update child page URLs or keep them unchanged, preventing accidental URL changes.

payload-better-auth

0.6.2 - 2026-03-16

Fixed

  • nextCookies() incompatibility warning — Added runtime detection of Better Auth's nextCookies() plugin, which causes infinite form-state submissions and input resets in the Payload admin panel. The plugin hooks into every getSession() call and writes cookies via next/headers, conflicting with Payload's rendering cycle. A clear console warning now alerts users to remove it. The nextCookies() plugin is unnecessary with this adapter since the endpoint handler already proxies Set-Cookie headers naturally. (#15)

payload-better-auth

0.6.1 - 2026-03-15

Added

Organization-scoped API keys

API keys can now be bound to a specific organization at creation time. When enableSessionForAPIKeys is enabled in Better Auth, the betterAuthStrategy will automatically resolve the organization context from the key's metadata, enabling org-scoped access control to work with API key authentication.

How it works:

  • When creating an API key, pass organizationId in the request body
  • The plugin validates the user is a member of that org, then stores organizationId in the key's metadata
  • On authentication, if the mock session doesn't have activeOrganizationId, the strategy reads it from the key's metadata and verifies membership
  • req.user.activeOrganizationId and req.user.organizationRole are set identically to a normal session

Usage:

// Create an org-scoped API key
const key = await auth.api.createApiKey({
  body: {
    name: 'My API Key',
    organizationId: 'org_123',  // NEW — binds key to this org
    permissions: { clients: ['read'], invoices: ['read', 'write'] },
  },
})

// Authenticate via API key — org context is automatic
curl -H "x-api-key: sk_..." https://app.example.com/api/clients
// req.user.activeOrganizationId === 'org_123'
// req.user.organizationRole === 'owner' (or whatever their role is)

Management UI: When the organization plugin is detected, the API key creation form shows an organization selector dropdown. Existing keys display their org binding as a badge.

Backwards compatible: Keys without organizationId metadata continue to work as before.

payload-puck

0.6.19 - 2026-03-12

Fixed

  • Multiple createPuckPlugin() instances now work correctly together. Previously, each instance overwrote the previous one's endpoint registrations and custom.puck config, causing only the first collection's API routes to function. Collections are now merged across plugin instances, parameterized endpoints are deduplicated, and shared config is preserved. (#5)

payload-puck

0.6.17 - 2026-03-09

Added

Email Builder

First-class email building with Puck. 12 email-safe components using table-based layouts with inline styles for maximum client compatibility.

New export paths:

  • @delmaredigital/payload-puck/email — EmailRenderer, renderToEmailHtml(), getEmailDocumentParts(), emailBaseConfig, emailEditorConfig
  • @delmaredigital/payload-puck/email/components — Individual email component configs (server, editor, and types)

Email components: EmailWrapper, EmailSection, EmailColumns, EmailHeading, EmailText, EmailButton, EmailImage, EmailSpacer, EmailDivider, EmailHeader, EmailFooter, EmailSocial

Dual config system: emailBaseConfig (server-safe, for rendering) and emailEditorConfig (interactive, for Puck editor) — mirrors the existing web page config architecture.

Rendering pipeline:

  • EmailRenderer — React component wrapping Puck's <Render> with emailBaseConfig
  • renderToEmailHtml(data, options?) — Produces complete email HTML document string via renderToStaticMarkup
  • getEmailDocumentParts(options) — Returns { before, after } HTML shell for custom wrapping

Email CSS converters added to src/fields/shared.ts:

  • colorValueToEmailCSS() — Hex-only output, bakes opacity by blending with white
  • paddingValueToEmailCSS() — Always outputs px values
  • backgroundValueToEmailCSS() — Solid color only (gradients fall back to first stop)

Utilities in src/components/email/utils.ts:

  • tiptapHtmlToEmailHtml() — Converts TipTap HTML to inline-styled email-safe HTML
  • vmlBackgroundOpen() / vmlBackgroundClose() — Outlook VML background image support
  • TABLE_ATTRS, FULL_WIDTH_TABLE_STYLE, centeredTableStyle() — Table layout helpers
  • WEB_SAFE_FONTS — Font stack options for email

payload-better-auth

0.6.0 - 2026-03-06

Breaking Changes

API key permissions redesigned — scopes replaced with BA-native permissions

The custom scope system (requireScope, hasScope, scopesToPermissions, etc.) has been replaced with thin wrappers around Better Auth's native verifyApiKey() permission system. This is simpler, more robust, and eliminates the round-trip bug where compound scope names were lost during storage.

Permission model: Two actions per collection — read and write (previously read/write/delete).

Before (0.5.x):

import { requireScope, requireAllScopes, allowSessionOrScope } from '@delmaredigital/payload-better-auth'

access: {
  read: requireScope('posts:read'),
  create: requireScope('posts:write'),
  delete: requireAllScopes(['posts:delete', 'admin:write']),
}

read: allowSessionOrScope('posts:read')

After (0.6.0):

import { requirePermission, requireAllPermissions, allowSessionOrPermission } from '@delmaredigital/payload-better-auth'

access: {
  read: requirePermission('posts', 'read'),
  create: requirePermission('posts', 'write'),
  update: requirePermission('posts', 'write'),
  delete: requirePermission('posts', 'write'),
}

read: allowSessionOrPermission('posts', 'read')
RemovedReplacement
requireScope(scope)requirePermission(resource, action)
requireAnyScope(scopes)requireAnyPermission(permissions)
requireAllScopes(scopes)requireAllPermissions(permissions)
allowSessionOrScope(scope)allowSessionOrPermission(resource, action)
allowSessionOrAnyScope(scopes)allowSessionOrAnyPermission(permissions)
hasScope(), hasAnyScope(), hasAllScopes()Use requirePermission (BA verifies natively)
validateApiKey(req)requirePermission or BA's verifyApiKey directly
getApiKeyInfo()Removed — BA handles key lookup
scopesToPermissions()Removed — pass permissions directly
generateScopesFromCollections()generateCollectionPermissions()
getApiKeyScopesConfig()getApiKeyPermissionsConfig()
ScopeDefinition typePermissionDefinition type
ApiKeyScopesConfig typeApiKeyPermissionsConfig type
AvailableScope typeRemoved

Config property renamed

// Before
admin: { apiKey: { scopes: {...}, defaultScopes: [...] } }

// After
admin: { apiKey: { excludeCollections: [...], requiredRole: 'admin' } }

Permissions are now auto-generated from collections. Custom scope definitions are no longer needed — use BA's native permission format directly.

Added

  • requirePermission(resource, action) — thin wrapper around BA's verifyApiKey()
  • requireAnyPermission(permissions) — any of the permission pairs must match
  • requireAllPermissions(permissions) — all permission pairs must match
  • allowSessionOrPermission(resource, action) — session OR API key access
  • allowSessionOrAnyPermission(permissions) — session OR any matching API key
  • requireApiKey() — verify API key without checking specific permissions
  • generateCollectionPermissions() — generates permission definitions for admin UI
  • getApiKeyPermissionsConfig() — access stored permissions config

Fixed

  • API key scope round-trip bug: Compound scope names (e.g., posts:write) were decomposed into CRUD actions for storage, then lost during readback. Now uses BA's native permission format — what gets stored is what gets checked.
  • API key list endpoint unguarded: GET /api-key/list now requires admin role (previously only POST mutations were guarded).

Removed

  • All scope-related functions and types (see migration table above)
  • generateScopes.ts — replaced by generatePermissions.ts
  • metadata.scopes workaround — no longer needed
  • "Delete" as a separate permission level — write implies full CRUD

Backward Compatibility

Existing API keys with old CRUD permissions (create, update, delete) will still work. When requirePermission('posts', 'write') is checked, the system falls back to checking for old CRUD actions. Re-creating keys is recommended.

payload-better-auth

0.5.6 - 2026-03-05

Fixed

  • Turbopack compatibility: Removed /* webpackIgnore: true */ from dynamic imports in LoginView, PasskeySignInButton, PasskeyRegisterButton, PasskeysManagementClient, and ApiKeysManagementClient. The directive caused bare specifier imports to fail in the browser under Turbopack (Next.js 15 default dev server), silently breaking login and passkey flows. (#14)

  • Improved generateId: 'serial' warning: The adapter now warns when idType resolves to 'number' (Postgres default) but generateId: 'serial' is not set. Previously only warned for explicit non-serial values, missing the most common misconfiguration where generateId is simply omitted. (#14)

Removed

  • Stale docs: Removed references to idFieldsAllowlist and idFieldsBlocklist adapter options from documentation. These were removed in the 0.5.x factory refactor but lingered in the docs.

payload-page-tree

0.3.10 - 2026-03-03

Added

  • buildSlug callback: Optional custom slug builder function that replaces the default folderPath/pageSegment concatenation. Use this when you want flat slugs (tree is organizational only), random IDs, or any custom slug pattern. (#1)
pageTreePlugin({
  // Flat slugs — ignore folder hierarchy
  buildSlug: ({ pageSegment }) => pageSegment,

  // Random IDs
  buildSlug: ({ doc }) => doc.customId as string || nanoid(),
})

This is a fully additive, non-breaking change. Existing configurations work identically without any modifications.


payload-better-auth

0.5.4 - 2026-03-02

Upgrades to Better Auth 1.5. If upgrading from 0.4.x, review the breaking changes and migration steps below.

Breaking Changes

Requires Better Auth 1.5

pnpm add better-auth@latest

Client plugins are now opt-in

payloadAuthPlugins now only includes twoFactorClient() from the core better-auth package. Optional plugins (passkeyClient, apiKeyClient, etc.) must be added explicitly.

Before:

import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'
export const authClient = createPayloadAuthClient()
// passkey + apiKey were included automatically

After:

import { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'  // if using passkeys

export const authClient = createAuthClient({
  plugins: [...payloadAuthPlugins, passkeyClient()],
})

Add only what you use. createPayloadAuthClient() still works for minimal setups (email/password + 2FA only). The built-in admin UI components handle their own client creation internally.


Passkey and API key components moved to separate entry points

Components that depend on optional peer dependencies (@better-auth/passkey, @better-auth/api-key) now live in their own entry points instead of the main barrels. This prevents webpack build failures for consumers who don't have those packages installed.

// Passkey components (requires @better-auth/passkey)
import { PasskeySignInButton, PasskeyRegisterButton, PasskeysField, PasskeysManagementClient }
  from '@delmaredigital/payload-better-auth/components/passkey'

// API key components (requires @better-auth/api-key)
import { ApiKeysManagementClient }
  from '@delmaredigital/payload-better-auth/components/api-key'

The built-in admin UI (management views, sidebar nav, user document fields) auto-injects these internally — no consumer changes needed for the admin panel.


API key plugin extracted to @better-auth/api-key

Better Auth 1.5 moved the API key plugin to its own package.

pnpm add @better-auth/api-key
// Server — before                              // Server — after
import { apiKey } from 'better-auth/plugins'    import { apiKey } from '@better-auth/api-key'

// Client — before                                    // Client — after
import { apiKeyClient } from 'better-auth/client/plugins'    import { apiKeyClient } from '@better-auth/api-key/client'

apiKeyWithDefaults() removed

// Before
import { apiKeyWithDefaults } from '@delmaredigital/payload-better-auth'
plugins: [apiKeyWithDefaults()]

// After
import { apiKey } from '@better-auth/api-key'
plugins: [apiKey({ enableMetadata: true })]

API key schema: userId → referenceId

Better Auth 1.5 renamed the userId column to referenceId and added configId. If you use API keys, generate a Payload migration:

pnpm payload migrate:create

UP migration choices:

  • + config_id — new column, create it
  • ~ user_id → reference_id — rename (not drop + create)

DOWN migration choices:

  • ~ reference_id → user_id — rename back
pnpm payload migrate

Any code referencing apiKey.userId must change to apiKey.referenceId. A new referenceType: 'user' | 'organization' field is also available.


Adapter type → DBAdapter

// Before
import type { Adapter } from '@delmaredigital/payload-better-auth/adapter'
// After
import type { DBAdapter } from '@delmaredigital/payload-better-auth/adapter'

idFieldsAllowlist / idFieldsBlocklist config removed

The adapter now uses Better Auth's factory transforms via schema introspection. These manual config options are no longer needed.


Auth instance type invariance

If you store the auth instance with a typed return, widen it:

// Before — breaks in 1.5
let auth: ReturnType<typeof betterAuth>

// After
import type { Auth } from 'better-auth/types'
let auth: Auth<any>

The plugin's CreateAuthFunction return type has been widened accordingly.

Changed

Adapter refactored to use factory transforms

Field name transforms (userId ↔ user) and ID type coercion now use Better Auth's createAdapterFactory via schema fieldName mutation. Removes ~150 lines of manual transform code and automatically supports all current and future plugins.

BetterAuthReturn type simplified

Replaced with Auth<O> from better-auth/types.

Dynamic baseURL support

withBetterAuthDefaults() now handles Better Auth 1.5's object-form baseURL (with fallback, allowedHosts, protocol properties) in addition to string URLs.

Fixed

API key list response shape

Fixed the API keys management UI to handle Better Auth 1.5's new response format ({ apiKeys: [...] } instead of a plain array).

payload-better-auth

0.4.4 - 2026-02-25

Fixed

Date fields returned as strings instead of Date objects

The adapter's factory config declared supportsDates: true, which tells Better Auth's adapter factory that the database returns native Date objects. However, Payload's Local API returns dates as ISO strings. This caused date comparisons like session.expiresAt > new Date() to silently fail (string vs Date comparison returns false due to NaN coercion), breaking any Better Auth plugin that compares dates — most notably the multi-session plugin, where list-device-sessions always returned an empty array.

Changed supportsDates to false so the factory correctly converts ISO strings to Date objects on output, and Date objects to ISO strings on input. This is safe across all database types: MongoDB (which may return native Dates that pass through unchanged) and Postgres/SQLite (which return strings that get converted).

payload-better-auth

0.4.3 - 2026-02-23

Added

MongoDB Support

The adapter now auto-detects MongoDB and configures itself accordingly. No special adapter configuration needed — just use Payload's @payloadcms/db-mongodb adapter and everything works.

  • Auto-detection: Reads payload.db.name to determine database type (postgres, mongodb, or sqlite)
  • ID type: Automatically uses 'text' (ObjectId strings) for MongoDB, 'number' (SERIAL) for Postgres/SQLite
  • Database-aware operators: starts_with and ends_with use contains on MongoDB (SQL LIKE patterns aren't supported)
  • not_in operator: Added support for the not_in where clause operator
  • New dbType config option: Explicit override if auto-detection doesn't work for your adapter

New exported utilities: detectDbType(), resolveIdType(), and the DbType type.

Changed

2FA and Passkeys moved to user document

Two-Factor Authentication and Passkeys management UI are now embedded directly in the user document edit view as ui fields, rather than living as separate admin views in the sidebar. These are per-user security settings (like password) and belong on the user document.

API Keys management remains in the sidebar under the "Security" nav group, as it is an admin-level feature.

Fixed

2FA and Passkey fields now scoped to the viewed user

The Two-Factor Authentication and Passkeys management UI fields on user documents were displaying the logged-in admin's own data when viewing another user's document. Both fields now compare the document ID with the logged-in user's ID and show an informational message when viewing someone else's profile.

Security

API key endpoints now require admin role

API key mutation endpoints (/api-key/create, /api-key/update, /api-key/delete) now require the user to have an admin role before the request is forwarded to Better Auth. The required role defaults to admin.login.requiredRole (or 'admin' if unset), and can be overridden:

createBetterAuthPlugin({
  admin: {
    apiKey: {
      requiredRole: 'admin', // only admins can manage API keys
      // requiredRole: null, // disable guard (not recommended)
    },
  },
})

API key verification is unaffected — existing keys continue to work. This only restricts key management (create, update, delete).

payload-puck

0.6.15 - 2026-02-19

Changed

Puck AI 0.6.0 Upgrade

Bumped @puckeditor/plugin-ai and @puckeditor/cloud-client from ^0.5.0 to ^0.6.0. This is an additive update — no APIs were removed. The Puck packages now use Vercel AI SDK v6 internally (up from v5); payload-puck does not import from ai directly, so this is transparent.

Added

prepareRequest Callback for AI Requests

New option to intercept and modify outgoing AI requests before they're sent. Useful for adding custom headers, credentials, or body data.

<PuckEditor
  enableAi={true}
  aiOptions={{
    prepareRequest: (opts) => ({
      ...opts,
      headers: { ...opts.headers, 'X-Custom': 'value' },
    }),
  }}
/>

Available on PuckEditor aiOptions, createAiPlugin(), and AiPluginOptions.

scrollTracking Option for AI Chat

New boolean option to control automatic scroll tracking in the AI chat panel.

<PuckEditor
  enableAi={true}
  aiOptions={{
    scrollTracking: true,
  }}
/>

Tool mode Property

AI tools now support a mode property ("auto" | "preload" | "inline") that controls how the tool is executed by the AI.

const tools = {
  getProducts: {
    description: 'Fetch products',
    inputSchema: z.object({}),
    mode: 'preload', // Load results before generation
    execute: async () => payload.find({ collection: 'products' }),
  },
}

Supported in createPuckAiApiRoutes, createAiGenerate, and the Payload endpoint handler.

Tool outputSchema Property

AI tools can now define an outputSchema (Zod schema) to type-check tool return values.

onFinish Callback for AI Generation

New callback on all server-side AI paths, invoked when generation completes with usage metrics:

createPuckPlugin({
  ai: {
    enabled: true,
    onFinish: ({ totalCost, tokenUsage }) => {
      console.log(`Cost: $${totalCost}, tokens: ${tokenUsage.totalTokens}`)
    },
  },
})

The tokenUsage object includes inputTokens, outputTokens, totalTokens, reasoningTokens, and cachedInputTokens.

Available on createPuckPlugin AI config, createPuckAiApiRoutes, createAiGenerate, and the Payload endpoint handler.

Component AI exclude Property

Components can now be fully excluded from AI generation:

const aiConfig = {
  MyInternalComponent: {
    ai: { exclude: true },
  },
}

Supported in injectAiConfig() overrides and the AiComponentConfig type.

Component AI defaultZone Property

Components can now configure default zone behavior for AI generation:

const aiConfig = {
  Section: {
    ai: {
      defaultZone: { allow: ['Text', 'Heading'], disallow: ['Image'] },
    },
  },
}

Field AI bind Property

Fields can now specify a bind value for AI field binding:

fields: {
  title: {
    ai: { bind: 'heading' },
  },
}

Field AI stream Property

Fields can now opt into streaming AI generation with stream: boolean.

Deprecated

  • AiComponentConfig.schema — use component-level AI instructions instead

payload-better-auth

0.4.0 - 2026-02-17

Breaking Changes

@better-auth/passkey moved to peer dependency

@better-auth/passkey is no longer bundled as a direct dependency. If you use passkeys, you must install it yourself:

pnpm add @better-auth/passkey

This gives you direct control over the passkey package version, which is important for receiving upstream bug fixes (e.g., the passkey challenge expiration fix) without waiting for a new release of this package.

Migration: If you already have @better-auth/passkey in your package.json (most passkey users do), no action is needed. If you were relying on this package to provide it, add it to your dependencies.

Changed

Native admin sidebar navigation

Security management links (Two-Factor Auth, API Keys, Passkeys) now use Payload's native NavGroup component and nav__link CSS classes instead of custom-styled inline elements with emoji icons. The links now match the look and feel of Payload's built-in Collections and Globals navigation groups.

payload-better-auth

0.3.14 - 2026-02-10

Fixed

Session ID Coercion for Serial IDs

Better Auth's api.getSession() always returns string IDs, even when the database uses numeric serial IDs. This caused Payload relationship fields to reject values like "31207" when they expect 31207.

createSessionHelpers now accepts an idType option that mirrors the adapter's adapterConfig.idType. When set to 'number', all ID fields (id, userId, activeOrganizationId, etc.) on both the user and session objects are coerced from strings to numbers before returning.

import { createSessionHelpers } from '@delmaredigital/payload-better-auth'
import type { User } from '@/payload-types'

export const { getServerSession, getServerUser } = createSessionHelpers<User>({
  idType: 'number',
})

// session.user.id is now 31207 (number), not "31207" (string)

The standalone getServerSession<TUser>() and getServerUser<TUser>() functions are unaffected — coercion is opt-in through the factory only.

Strategy Session Field ID Coercion

betterAuthStrategy now coerces string IDs in session fields (e.g., activeOrganizationId, impersonatedBy) to numbers before merging them onto req.user. Previously, req.user.id was already correct (fetched from Payload's DB), but session fields from Better Auth were still strings.

The new idType option defaults to 'number' to match the adapter's default:

betterAuthStrategy({
  idType: 'number', // default — coerces session field IDs to numbers
})

Set to 'text' if using UUID IDs.

payload-better-auth

0.3.13 - 2026-02-09

Added

Type-Safe Session Helpers

getServerSession and getServerUser now accept a generic type parameter so the returned user is typed as your Payload User type instead of a loose Record<string, unknown>.

Option A — Pass the generic at each call site:

import { getServerSession } from '@delmaredigital/payload-better-auth'
import type { User } from '@/payload-types'

const session = await getServerSession<User>(payload, headers)
session.user.role       // ✅ fully typed
session.user.firstName  // ✅ fully typed

Option B (recommended) — Create typed helpers once, import everywhere:

// lib/auth.ts
import { createSessionHelpers } from '@delmaredigital/payload-better-auth'
import type { User } from '@/payload-types'

export const { getServerSession, getServerUser } = createSessionHelpers<User>()
// app/page.tsx — no generic needed
import { getServerSession } from '@/lib/auth'

const session = await getServerSession(payload, headers)
session.user.role  // ✅ typed

Both getServerSession and getServerUser are fully backward-compatible — omitting the generic returns the same loose type as before.

payload-page-tree

0.3.8 - 2026-01-28

Fixed

Reduced Published Package Size

Disabled source maps (.js.map) and declaration maps (.d.ts.map) from the published package. These files doubled the unpacked size and served no purpose since source files are no longer included in the package.

Package Exports Pointing to Source Files

Fixed issue where the published package's exports field pointed to TypeScript source files (src/) instead of compiled JavaScript (dist/). This caused "Unknown module type" errors with Turbopack. Changed to point exports directly to dist/.


payload-page-tree

0.3.7 - 2026-01-28

Changed

Build System Migration to SWC

Migrated from pure TypeScript compilation to SWC for faster builds:

  • Build time: Significantly faster compilation (~85ms vs several seconds with tsc)
  • TypeScript: Now only emits declaration files (.d.ts)

Build commands remain the same:

pnpm build      # Full build (SWC + types)
pnpm dev        # Watch mode with SWC

payload-puck

0.6.14 - 2026-01-28

Fixed

Reduced Published Package Size

Disabled source maps (.js.map) and declaration maps (.d.ts.map) from the published package. These files doubled the unpacked size and served no purpose since source files are no longer included in the package.

Package Exports Pointing to Source Files

Fixed issue where the published package's exports field pointed to TypeScript source files (src/) instead of compiled JavaScript (dist/). This caused "Unknown module type" errors with Turbopack. Changed to point exports directly to dist/.


payload-puck

0.6.13 - 2026-01-28

Changed

Build System Migration to SWC

Migrated from pure TypeScript compilation to SWC for faster builds:

  • Build time: Significantly faster compilation (~85ms vs several seconds with tsc)
  • TypeScript: Now only emits declaration files (.d.ts)

Build commands remain the same:

pnpm build      # Full build (SWC + types)
pnpm dev        # Watch mode with SWC

payload-better-auth

0.3.10 - 2026-01-28

Fixed

Reduced Published Package Size

Disabled source maps (.js.map) and declaration maps (.d.ts.map) from the published package. These files doubled the unpacked size and served no purpose since source files are no longer included in the package.


payload-better-auth

0.3.9 - 2026-01-28

Fixed

Package Exports Pointing to Source Files

Fixed critical issue where the published package's exports field pointed to TypeScript source files (src/) instead of compiled JavaScript (dist/). This caused "Unknown module type" errors when using the package with Turbopack.

The publishConfig.exports pattern does not work reliably with pnpm - the exports are not overwritten during publish. Changed to point exports directly to dist/.


payload-better-auth

0.3.8 - 2026-01-28

Fixed

Multi-Role Access Control for Generated Collections

The default access control for auto-generated collections (sessions, accounts, verifications, etc.) now correctly handles users with array-based roles. Previously, users with role: ['admin', 'editor'] were silently denied access because the inline check only matched exact string values.

The fix uses the existing isAdmin() utility which properly normalizes roles via normalizeRoles() and checks with hasAnyRole().

Thanks to @Rot4tion for contributing this fix in PR #6.

Changed

Build System Migration to SWC

Migrated from pure TypeScript compilation to SWC for faster builds:

  • Build time: ~85ms for 41 files (vs several seconds with tsc)
  • TypeScript: Now only emits declaration files (.d.ts)
  • Source maps: Enabled for debugging

Build commands remain the same:

pnpm build      # Full build (SWC + types)
pnpm dev        # Watch mode with SWC

Updated Documentation

Added caution note in README clarifying that the access option in betterAuthCollections() completely replaces the default access object rather than merging with it. Documents the default access settings for reference.

payload-better-auth

0.3.7 - 2026-01-26

Fixed

LoginView Plugin Options Now Typed Correctly (#5)

The following options were documented in the README and working in LoginView, but the TypeScript types in BetterAuthPluginAdminOptions.login were missing them:

  • enableSignUp?: boolean | 'auto' - Enable user registration (auto-detects by default)
  • defaultSignUpRole?: string - Default role for new users (default: 'user')
  • enableForgotPassword?: boolean | 'auto' - Enable password reset (auto-detects by default)
  • resetPasswordUrl?: string - Custom URL for password reset page
  • enablePasskey now also supports 'auto' for auto-detection

These options were already wired through to LoginView but TypeScript would error when trying to use them.

Added

payloadAuthPlugins Export for Custom Plugin Support (#4)

New payloadAuthPlugins export enables adding custom plugins (like Stripe) with full TypeScript type safety:

import { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
import { stripeClient } from '@better-auth/stripe/client'

export const authClient = createAuthClient({
  plugins: [...payloadAuthPlugins, stripeClient({ subscription: true })],
})

// authClient.subscription is fully typed!

This approach uses Better Auth's native createAuthClient (which we re-export) combined with our default plugins tuple. This gives you full type inference for any custom plugins you add, unlike wrapper functions that lose type information.

For simple setups without custom plugins, createPayloadAuthClient() still works and is the easiest option:

import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'

export const authClient = createPayloadAuthClient()

Changed

Documentation Clarifications

  • Auto-detection is the default: Clarified that enableSignUp, enableForgotPassword, and enablePasskey all support 'auto' (the default), which automatically detects availability from Better Auth's endpoints. No configuration needed for most cases.
  • Customization paths: Added clearer documentation for overriding/disabling the LoginView and using custom admin or frontend login components.
  • Custom plugins: Updated client documentation to show payloadAuthPlugins pattern for Stripe and other custom plugins with full type safety.

payload-better-auth

0.3.6 - 2026-01-26

Added

apiKeyWithDefaults Utility

New wrapper for Better Auth's apiKey() plugin that enables metadata by default:

import { apiKeyWithDefaults } from '@delmaredigital/payload-better-auth'

export const betterAuthOptions = {
  plugins: [
    apiKeyWithDefaults(),  // Use instead of apiKey()
  ],
}

This enables storing scope names in metadata so they display in the admin UI after key creation. The handler also gracefully retries without metadata if the plugin isn't configured with it enabled.

withBetterAuthDefaults Utility

New utility function that applies sensible defaults to Better Auth options. Currently handles:

  • trustedOrigins: If not explicitly provided but baseURL is set, defaults to [baseURL]

This simplifies the common single-domain case where users only need to set baseURL:

import { withBetterAuthDefaults, payloadAdapter } from '@delmaredigital/payload-better-auth'

createBetterAuthPlugin({
  createAuth: (payload) => betterAuth(withBetterAuthDefaults({
    database: payloadAdapter({ payloadClient: payload }),
    baseURL: process.env.BETTER_AUTH_URL,
    // trustedOrigins automatically becomes [baseURL]
  })),
})

Multi-domain setups can still explicitly set trustedOrigins to include multiple origins - the utility won't override explicit configuration.

First User Admin

The first registered user is now automatically made an admin. This is enabled by default via betterAuthCollections().

betterAuthCollections({
  betterAuthOptions,
  // firstUserAdmin: true  ← enabled by default
})

Customize:

betterAuthCollections({
  betterAuthOptions,
  firstUserAdmin: {
    adminRole: 'super-admin',  // default: 'admin'
    defaultRole: 'member',      // default: 'user'
    roleField: 'userRole',      // default: 'role'
  },
})

Disable:

betterAuthCollections({
  betterAuthOptions,
  firstUserAdmin: false,
})

A standalone firstUserAdminHooks() utility is also exported for use with Better Auth's databaseHooks in advanced scenarios.

Fixed

Admin Sidebar Only Shows Enabled Plugins (#2)

The Security navigation section now conditionally shows links based on which Better Auth plugins are actually enabled. Previously, all three links (Two-Factor Auth, API Keys, Passkeys) were shown regardless of configuration.

  • Links are now passed via clientProps injection
  • If no security plugins are enabled, the Security section doesn't appear

API Key Creation with Scopes (#3)

Creating API keys with permission scopes from the admin UI now works correctly. Previously, this failed with "THE_PROPERTY_YOURE_TRYING_TO_SET_CAN_ONLY_BE_SET_FROM_THE_SERVER_AUTH_INSTANCE_ONLY" because Better Auth marks permissions as server-only.

The fix intercepts API key creation requests in the endpoint handler and:

  1. Extracts scopes from the request
  2. Converts scopes to permissions server-side using scopesToPermissions()
  3. Calls auth.api.createApiKey() with the permissions

The UI now sends scopes instead of permissions, and the server handles the conversion.

Adapter Returns Database Results Over Input Data

The adapter now correctly prioritizes database results over input data when returning from create/update operations. This fixes an issue where Payload hooks that modify data (like firstUserAdmin setting role to 'admin') were being overwritten by the original input data.

Session Re-fetch After Signup

The LoginView now re-fetches the session after signup to get the updated user data. This ensures that role changes from Payload hooks (like firstUserAdmin) are reflected immediately, preventing the "Access Denied" screen after first user registration.

ID Field Type Conversion for Serial IDs

When using generateId: 'serial', fields like activeOrganizationId were returned as strings from Better Auth, but Payload expects numbers for relationship lookups. This caused access control queries like { organization: { equals: user.activeOrganizationId } } to fail.

The adapter now automatically converts ID fields matching *Id or *_id patterns to numbers when idType is 'number'.

Added

ID Field Conversion Customization

New adapter config options to customize which fields are converted:

payloadAdapter({
  payloadClient: payload,
  adapterConfig: {
    // Add fields that don't follow the *Id pattern
    idFieldsAllowlist: ['customOrgRef'],

    // Exclude fields that end in 'Id' but aren't ID references
    idFieldsBlocklist: ['visitorId', 'correlationId'],
  },
})

payload-page-tree

0.3.6 - 2026-01-24

Added

  • customizeFolderCollection option: Callback to customize the folders collection with custom fields, access control, or hooks. Enables organization scoping for multi-tenant apps without coupling the plugin to any specific auth system.

Fixed

  • Multi-tenant access control: Tree view now correctly applies access control - passes req to all Local API calls and sets overrideAccess: false on queries. Previously, users could see pages from all organizations.
  • API endpoint access control: All tree operation endpoints (move, reorder, create, delete, duplicate, etc.) now pass req to respect collection access rules.

payload-puck

0.6.12 - 2026-01-24

Added

Custom Preview URL Support

Added previewUrl plugin option to customize the "View" button in the editor. By default, the View button navigates to /{slug}. This option enables custom URL patterns for multi-tenant apps, preview environments, or non-standard routing.

// Static prefix for all pages
createPuckPlugin({
  previewUrl: '/preview',
})

// Organization-scoped pages (multi-tenant)
createPuckPlugin({
  previewUrl: (page) => {
    const orgSlug = page.organization?.slug || 'default'
    return (slug) => slug ? `/${orgSlug}/${slug}` : `/${orgSlug}`
  },
})

When previewUrl is a function, page data is fetched with depth: 1 to populate relationships like organization.

payload-better-auth

0.3.3 - 2026-01-23

Fixed

Session Fields Now Available on req.user

When using Better Auth plugins that store data on the session (like the organization plugin), those fields are now available on req.user in Payload access control functions.

Before: req.user.activeOrganizationId was always undefined

After: Session fields are merged onto the user object:

  • req.user.activeOrganizationId - from organization plugin
  • req.user.organizationRole - user's role in the active organization (auto-fetched from members collection)
  • Any other session fields from enabled plugins

This enables organization-scoped access control patterns:

export const orgReadAccess: Access = ({ req }) => {
  if (!req.user?.activeOrganizationId) return false
  return {
    organization: { equals: req.user.activeOrganizationId }
  }
}

Added

  • New membersCollection option for betterAuthStrategy() to customize the members collection slug (default: 'members')

payload-puck

0.6.4 - 2026-01-19

This release adds homepage swap functionality and fixes several issues with rich text editing, hook merging, and editor iframe styling.

Added

Homepage Swap Functionality

When attempting to set a page as the homepage and another page already has that designation, the editor now shows a confirmation dialog offering to swap homepages. This provides a much better UX than simply failing with an error.

How it works:

  1. User checks "Is Homepage" on a page
  2. If another page is already the homepage, a dialog appears: "Page X is currently the homepage. Would you like to swap?"
  3. If confirmed, the old homepage is unset and the new one is set atomically

New exports:

import {
  createIsHomepageUniqueHook,
  HomepageConflictError,
  unsetHomepage,
} from '@delmaredigital/payload-puck'

getPuckCollectionConfig() Helper

New helper function that returns both fields AND hooks for hybrid collection integration. This ensures the isHomepage uniqueness hook is included when using the field.

import { getPuckCollectionConfig } from '@delmaredigital/payload-puck'

const { fields, hooks } = getPuckCollectionConfig({
  includeIsHomepage: true,  // Includes uniqueness hook automatically
  includeSEO: true,
})

export const Pages: CollectionConfig = {
  slug: 'pages',
  hooks: {
    beforeChange: [
      ...(hooks.beforeChange ?? []),
      // Your other hooks...
    ],
  },
  fields: [
    { name: 'title', type: 'text' },
    ...fields,
  ],
}

Custom Plugin Rails Support

The PuckEditor component now accepts a plugins prop for custom Puck plugins. This enables adding custom panels to the plugin rail.

Fixed

Rich Text Style Changes Not Triggering Dirty State

Fixed an issue where changing font size, text color, or highlights via dropdown controls wouldn't mark the editor as having unsaved changes. The root cause is a focus state race condition in Puck core's useSyncedEditor hook. This release includes a workaround that forces Puck to recognize the changes.

See docs/PUCK_CORE_RICH_TEXT_ISSUE.md for technical details.

Dark Mode Styling for Font Size Buttons

Fixed white-on-white text in the font size dropdown and custom size input when Payload admin is in dark mode.

Hook Merging in generatePagesCollection

Fixed a bug where the isHomepage uniqueness hook wasn't being triggered because:

  1. When generating a new collection, the spread operator was overwriting the entire hooks object instead of merging arrays
  2. When augmenting an existing collection, hooks weren't being added at all

Editor Iframe Stylesheet Loading

Fixed FOUC (Flash of Unstyled Content) in the editor iframe by forcing a browser repaint after stylesheets finish loading. The fix uses Puck's waitForStyles option combined with a forced repaint via opacity toggle.

isHomepage Field Not Saving

Fixed isHomepage field not being included in save/publish requests from the editor.

./next Export Not Working with next.config.ts

Fixed the @delmaredigital/payload-puck/next import failing when used in TypeScript Next.js configs (next.config.ts). The export now includes a default condition to support all module resolution strategies.

Changed

  • TemplateField now uses Puck's getItemById API instead of manual tree traversal for accessing slot content, simplifying the code significantly

Documentation

  • Added docs/PUCK_CORE_RICH_TEXT_ISSUE.md documenting the Puck core focus state issue and our workaround

payload-better-auth

0.3.2 - 2026-01-19

Fixed

Login Configuration Props Now Work

The admin.login configuration options (like afterLoginPath, requiredRole, enablePasskey, etc.) were not being passed to the LoginView component. This has been fixed by:

  • Added LoginViewWrapper RSC that reads config from payload.config.custom.betterAuth.login
  • Plugin now stores login config in config.custom.betterAuth.login for the wrapper to access
  • The wrapper passes all configured props to the client LoginView component

All login configuration options now work as documented:

createBetterAuthPlugin({
  createAuth,
  admin: {
    login: {
      afterLoginPath: '/admin/page-tree',  // Now works!
      requiredRole: ['admin', 'editor'],
      enablePasskey: true,
    },
  },
})

payload-puck

0.6.3 - 2026-01-18

This release adds build-time CSS compilation for production deployments and fixes dark mode issues in Payload admin.

Added

Build-Time CSS Compilation for Production

The editorStylesheet runtime compilation works great in development, but fails on serverless platforms (Vercel, Netlify, etc.) because source CSS files aren't deployed. This release adds withPuckCSS() - a Next.js config wrapper that compiles your CSS at build time.

The problem:

  • Runtime CSS compilation reads source files (e.g., src/app/globals.css) at request time
  • Serverless deployments only include compiled .next output, not source files
  • Result: broken editor styles in production

The solution:

// next.config.js
import { withPuckCSS } from '@delmaredigital/payload-puck/next'
import { withPayload } from '@payloadcms/next/withPayload'

export default withPuckCSS({
  cssInput: 'src/app/(frontend)/globals.css',
})(withPayload(nextConfig))
// payload.config.ts
createPuckPlugin({
  editorStylesheet: 'src/app/(frontend)/globals.css',      // For dev (runtime)
  editorStylesheetCompiled: '/puck-editor-styles.css',     // For prod (static)
})

How it works:

  1. During next build, the wrapper compiles your CSS to public/puck-editor-styles.css
  2. In production, the plugin serves the static file instead of runtime compilation
  3. In development, runtime compilation continues working for hot reload

New exports:

import { withPuckCSS, getPuckCSSPath } from '@delmaredigital/payload-puck/next'

New plugin options:

  • editorStylesheetCompiled - Path to pre-compiled CSS file for production (e.g., /puck-editor-styles.css)

Fixed

Dark Mode Form Input Visibility

Fixed white-on-white text in Puck form inputs when Payload CMS is in dark mode. Puck's UI is always light-themed, but when nested inside Payload's dark mode admin, form inputs were inheriting white text color. The DarkModeStyles component now explicitly forces dark text in all Puck UI elements.

Editor Stylesheet Loading

Fixed editor iframe stylesheets not applying immediately on load. The compiled CSS (from editorStylesheet or editorStylesheetCompiled) now properly triggers a re-render when loaded, ensuring Tailwind classes and CSS variables are applied without requiring a dark mode toggle.


payload-puck

0.6.2 - 2026-01-17

This release adds dark mode support for the Puck editor and includes several bug fixes.

Added

Dark Mode Support

The Puck editor now automatically detects PayloadCMS dark mode and applies CSS overrides to fix visibility issues (white-on-white text). Also includes a preview toggle to test how pages look in both light and dark modes.

Features:

  • Auto-detection: Detects .dark class on document.documentElement (PayloadCMS) or prefers-color-scheme: dark (OS preference)
  • Preview toggle: Sun/Moon toggle in the header to switch preview iframe between light/dark modes
  • CSS overrides: Injects Puck color variable overrides when dark mode is detected

New components:

  • DarkModeStyles - Injects Puck color variable overrides when dark mode detected
  • PreviewModeToggle - Toggle for switching preview iframe dark/light mode
  • useDarkMode hook - Detects dark mode from class or media query

New props on PuckEditor:

  • autoDetectDarkMode (default: true) - Enable automatic dark mode detection for editor UI
  • showPreviewDarkModeToggle (default: true) - Show light/dark toggle for preview iframe
  • initialPreviewDarkMode (default: false) - Initial dark mode state for preview iframe
<PuckEditor
  autoDetectDarkMode={true}           // Auto-detect PayloadCMS dark mode
  showPreviewDarkModeToggle={true}    // Show sun/moon toggle
  initialPreviewDarkMode={false}      // Start preview in light mode
/>

New exports from @delmaredigital/payload-puck/editor:

import {
  DarkModeStyles,
  PreviewModeToggle,
  useDarkMode,
} from '@delmaredigital/payload-puck/editor'

createRenderLayouts Utility

New utility to strip header/footer from layouts for rendering. Use this when your host app already provides a global header/footer via its root layout:

import { HybridPageRenderer, createRenderLayouts } from '@delmaredigital/payload-puck/render'
import { siteLayouts } from '@/lib/puck-layouts'

// Strip header/footer for rendering (host app provides them)
const renderLayouts = createRenderLayouts(siteLayouts)

This keeps header/footer in editor layouts for realistic preview while avoiding double headers when rendering.

Also exported from @delmaredigital/payload-puck/layouts.

Fixed

  • Homepage preview URL: Preview button now correctly navigates to / for pages marked as homepage instead of using the page slug
  • Editor iframe stylesheet URLs: Relative stylesheet URLs (like /api/puck/styles) now resolve correctly in the Puck editor iframe, fixing broken styles when using editorStylesheets prop
  • Editor iframe data-theme attribute: IframeWrapper now sets data-theme attribute in addition to CSS classes, fixing styles for projects using [data-theme='dark'] selectors (common with shadcn/ui, Next.js themes)
  • CSS compilation now uses project's postcss.config: The /api/puck/styles endpoint now loads the project's postcss.config.js via postcss-load-config, ensuring all plugins and configuration are properly applied. Falls back to direct Tailwind import if config loading fails.
  • MediaField styling: Fixed border CSS syntax and resolved nested interactive element warning by replacing <button> with <span> inside the file input label

Notes

Fonts in Editor Preview: If your project uses custom fonts loaded via next/font (like Google Fonts or local fonts), these won't automatically appear in the editor iframe. Add the font URLs to editorStylesheetUrls:

createPuckPlugin({
  editorStylesheet: 'src/app/globals.css',
  editorStylesheetUrls: [
    'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;500;600;700&display=swap'
  ],
})

payload-puck

0.6.1 - 2026-01-17

This patch release fixes editor state synchronization and adds quality-of-life improvements for page-tree integration and TypeScript.

Added

Payload Fields Sync to Editor Root Props

When loading a page in the editor, Payload document fields are now properly synced to Puck's root.props:

  • isHomepage, title, slug, pageLayout now load correctly
  • SEO fields (meta.title, meta.description, meta.image) are synced
  • Page-tree fields (folder, pageSegment) are synced

New exports:

import { mapPayloadFieldsToRootProps } from '@delmaredigital/payload-puck/api'

// Reverse-map Payload fields back to root.props format
const rootProps = mapPayloadFieldsToRootProps(payloadDocument)

Locked PageSegmentField for Page-Tree

The page segment field is now locked by default in page-tree mode to prevent accidental URL changes:

  • Lock icon appears in the field header
  • Click to unlock and enable editing
  • Warning message displayed when unlocked: "Changing may break existing links"
  • Matches behavior of slug and isHomepage fields

New exports:

import {
  LockedPageSegmentField,
  createLockedPageSegmentField,
} from '@delmaredigital/payload-puck/fields'

// For custom page-tree implementations
const field = createLockedPageSegmentField({
  label: 'Page Segment',
  warningMessage: 'Changing may break existing links',
})

Server Component Type Safety

New TypeScript types for server-only Puck configs reduce field definition pressure:

import type { ServerComponentConfig, SlotField } from '@delmaredigital/payload-puck/config'

// No fields required for components without slots
export const ButtonConfig: ServerComponentConfig<ButtonProps> = {
  label: 'Button',
  render: ({ text }) => <button>{text}</button>,
}

// Only slot fields needed for container components
export const SectionConfig: ServerComponentConfig<SectionProps> = {
  fields: { content: { type: 'slot' } },
  render: ({ content: Content }) => <section><Content /></section>,
}

HybridPageRenderer Improvements

Type safety improvements for hybrid rendering:

  • Generic type parameter for typed legacyRenderer blocks
  • toHybridPageData() helper to convert Payload's generic JSON types
  • HybridPageDataInput type for loose input
import {
  HybridPageRenderer,
  toHybridPageData,
  type HybridPageDataInput,
} from '@delmaredigital/payload-puck/render'

// Typed legacy blocks
<HybridPageRenderer<NonNullable<Page['layout']>>
  page={toHybridPageData(page)}
  config={config}
  legacyRenderer={(blocks) => <RenderBlocks blocks={blocks} />}
/>

AnimatedWrapper Export

AnimatedWrapper component is now exported from the components entry point:

import { AnimatedWrapper } from '@delmaredigital/payload-puck/components'

Fixed

  • Editor field sync: Fields like isHomepage, title, pageLayout now correctly load from Payload documents into the editor root.props
  • Editor stylesheet passthrough: editorStylesheets from plugin config now properly passed to PuckEditor

Changed

  • Page-tree integration now uses createLockedPageSegmentField() instead of createPageSegmentField() by default

payload-puck

0.6.0 - 2026-01-15

This release significantly enhances AI-assisted page generation and introduces a rewritten RichText field implementation.

Note: AI integration is still in early stages and under active development. Expect changes as we refine the integration.

Breaking Changes

RichText Field API Change

The TipTap field exports have been replaced with a new RichText field API using Puck's native richtext with custom extensions.

Removed exports:

  • TiptapField, createTiptapField
  • TiptapModal
  • TiptapModalField, createTiptapModalField

Migration:

// Before
import { createTiptapField } from '@delmaredigital/payload-puck/fields'

const content = createTiptapField({ label: 'Content' })

// After
import { createRichTextField } from '@delmaredigital/payload-puck/fields'

const content = createRichTextField({ label: 'Content' })

New preset options:

import {
  createRichTextField,      // Full customization
  fullRichTextField,        // All features enabled
  minimalRichTextField,     // Basic formatting only
  sidebarRichTextField,     // Optimized for Puck sidebar
} from '@delmaredigital/payload-puck/fields'

Added

AI Integration Enhancements

Comprehensive Component Instructions

The AI now receives detailed instructions for all built-in components, teaching it:

  • Correct field names (e.g., Button uses text not label, link not href)
  • Component composition patterns (Section → Flex → Heading + Text + Button)
  • Page structure best practices (Hero → Features → FAQ → CTA flow)
  • Semantic HTML element selection

This dramatically improves generation quality—the AI now creates complete multi-section pages instead of minimal layouts.

New exports:

import {
  comprehensiveComponentAiConfig,  // Full component instructions
  pagePatternSystemContext,        // System context with page patterns
} from '@delmaredigital/payload-puck/ai'
Dynamic Business Context Collection

Business context can now be managed through Payload admin instead of hardcoded in config:

createPuckPlugin({
  ai: {
    enabled: true,
    contextCollection: true,  // Creates puck-ai-context collection
  },
})

The puck-ai-context collection includes:

  • name - Context entry name (e.g., "Brand Guidelines")
  • content - Markdown content for the AI
  • category - Categorization (brand, tone, product, industry, technical, patterns, other)
  • enabled - Toggle to include/exclude from AI prompts
  • order - Sort order in the prompt
Context Editor Plugin

When contextCollection: true, a new "AI Context" panel appears in the Puck plugin rail:

  • View all context entries with enable/disable toggles
  • Create new entries with name, category, and markdown content
  • Edit existing entries inline
  • Delete entries with confirmation
import { createContextEditorPlugin } from '@delmaredigital/payload-puck/ai'

const contextPlugin = createContextEditorPlugin({
  apiEndpoint: '/api/puck/ai-context',
  canCreate: true,
  canEdit: true,
  canDelete: true,
})
Context Hook

New React hook for managing AI context client-side:

import { useAiContext } from '@delmaredigital/payload-puck/ai'

const { context, loading, error, create, update, remove, refetch } = useAiContext({
  apiEndpoint: '/api/puck/ai-context',
  includeDisabled: false,
})

Experimental Full Screen Canvas

New prop to enable Puck's experimental full-screen canvas mode:

<PuckEditor experimentalFullScreenCanvas={true} />

New RichText Field Implementation

The RichText field has been rewritten using Puck's native richtext with custom TipTap extensions:

  • Font Sizes - 9 presets with custom input support
  • Text Colors - Theme-aware color picker with opacity
  • Highlights - Background color highlighting
  • Inline Editing - Edit directly on the canvas (no modal)

Changed

  • AI endpoint now combines static context (from config) + dynamic context (from collection) + page pattern system context
  • PuckEditor uses comprehensiveComponentAiConfig by default when AI is enabled
  • Context fetching is non-blocking—if collection doesn't exist or fetch fails, AI continues with static context only
  • RichText component now uses Puck's native richtext internally instead of custom TipTap implementation

payload-better-auth

0.3.1 - 2026-01-15

Added

User Registration in LoginView

The LoginView component now supports inline user registration:

createBetterAuthPlugin({
  createAuth,
  admin: {
    login: {
      enableSignUp: true,         // or 'auto' to detect availability (default)
      defaultSignUpRole: 'user',  // Role for new users (default: 'user')
    },
  },
})

Features:

  • "Create account" link appears when sign-up is available
  • Full registration form with name, email, password, and confirmation
  • Automatic role assignment via defaultSignUpRole
  • Email verification support (shows success message if verification required)
  • Role-based access control still applies after registration

Forgot Password in LoginView

The LoginView component now supports inline password reset:

createBetterAuthPlugin({
  createAuth,
  admin: {
    login: {
      enableForgotPassword: true,   // or 'auto' to detect availability (default)
      resetPasswordUrl: '/custom',  // Optional: redirect to custom page
    },
  },
})

Features:

  • "Forgot password?" link appears when password reset is available
  • Inline email form to request reset link
  • Confirmation view after sending reset email
  • Optional redirect to custom reset page via resetPasswordUrl

Changed

  • Improved README Quick Start example with cleaner adapterConfig usage (debug logging now opt-in via comment)
  • Added note about vanilla starter folder-based collection structure
  • Added important warning about not adding custom beforeLogin component

payload-page-tree

0.3.0 - 2026-01-14

Added

  • Multi-select drag-and-drop: Select multiple items with Cmd/Ctrl+click, then drag to move all at once
  • Bulk URL confirmation: When moving multiple items that need URL updates, "Update All URLs" / "Keep All URLs" buttons for batch confirmation
  • "Move to..." action: Right-click context menu option to select destination folder without dragging - useful for large trees
  • Sorting options: Sort tree by name (A-Z, Z-A), slug, or status (published first). Drag-drop is disabled while sorting is active
  • Folder select modal: New modal component (FolderSelectModal) for selecting destination folders with expandable tree view

Changed

  • Tree node layout: Restructured with fixed-width columns for consistent alignment (slug: 180px, status: 70px, actions: 88px)
  • Always-visible actions: Action buttons (edit, copy, view, delete) now always visible at reduced opacity instead of appearing on hover
  • Nav link grouping: Page Tree nav link now uses NavGroup wrapper with "Manage Pages" label

Fixed

  • Folder deletion: Fixed bug where deleting folders returned success but didn't actually delete
  • Delete performance: Optimized recursive delete with parallel batch operations to avoid database transaction timeouts
  • Page not found handling: Delete endpoint now returns proper 404 when page isn't found in any collection

payload-puck

0.5.0 - 2026-01-14

Breaking Changes

Puck 0.21 Package Scope Migration

Puck 0.21 introduced a breaking package scope change. All Puck packages have moved from @measured/* to @puckeditor/*.

Peer dependency change:

// Before
"@measured/puck": ">=0.20.0"

// After
"@puckeditor/core": ">=0.21.0"

Installation:

# Before
pnpm add @delmaredigital/payload-puck @measured/puck

# After
pnpm add @delmaredigital/payload-puck @puckeditor/core

Import changes for custom components:

// Before
import type { ComponentConfig } from '@measured/puck'

// After
import type { ComponentConfig } from '@puckeditor/core'

This plugin no longer supports Puck versions prior to 0.21.

Added

Version History Plugin Rail

Version history has moved from a header dropdown to a dedicated plugin rail panel, utilizing Puck 0.21's new plugin rail API.

What changed:

  • History icon now appears in the left plugin rail (alongside Blocks, Outline, Fields)
  • Clicking opens a full sidebar panel instead of a dropdown
  • Restore no longer triggers a page reload—uses Puck's dispatch to update editor state instantly
  • Success message appears briefly after restore

New exports:

import { createVersionHistoryPlugin } from '@delmaredigital/payload-puck/editor'

// For custom plugin configurations
const versionPlugin = createVersionHistoryPlugin({
  pageId: 'page-123',
  apiEndpoint: '/api/puck/pages',
  onRestoreSuccess: () => markEditorClean(),
})

<Puck plugins={[versionPlugin]} />

Deprecations:

  • VersionHistory component export (use createVersionHistoryPlugin instead)
  • showVersionHistory prop on HeaderActions (version history now lives in plugin rail)

Semantic HTML Elements

Layout components now support semantic HTML output for better SEO and accessibility. Each component has an "HTML Element" dropdown in the editor sidebar:

ComponentAvailable Elements
Sectionsection, article, aside, nav, header, footer, main, div
Flexdiv, nav, ul, ol, aside, section
Containerdiv, article, aside, section
Griddiv, ul, ol

This enables proper document structure without wrapper elements:

  • Use <nav> for navigation menus
  • Use <article> for blog posts
  • Use <aside> for sidebars
  • Use <ul>/<ol> for list layouts

Full-Width Viewport Option

The editor now includes a "Full Width" (100%) viewport option alongside Mobile (360px), Tablet (768px), and Desktop (1280px). This helps preview how components behave at any viewport width.

Dynamic Accordion Defaults

The Accordion component now uses dynamic defaultItemProps:

  • First item opens by default (defaultOpen: true)
  • Items are numbered automatically ("Accordion Item 1", "Accordion Item 2", etc.)

ContentAlignmentField (D-Pad Positioning)

New visual 3x3 grid selector for content positioning within containers. Provides an intuitive d-pad style control for setting both horizontal and vertical alignment.

import { createContentAlignmentField, alignmentToFlexCSS } from '@delmaredigital/payload-puck/fields'

const BannerConfig = {
  fields: {
    position: createContentAlignmentField({ label: 'Content Position' }),
  },
  render: ({ position }) => (
    <div style={{ display: 'flex', ...alignmentToFlexCSS(position) }}>
      {/* Content positioned at selected grid cell */}
    </div>
  ),
}

Helper functions:

  • alignmentToFlexCSS() - For Flexbox containers
  • alignmentToGridCSS() - For Grid containers
  • alignmentToPlaceSelfCSS() - For individual grid items
  • alignmentToTailwind() - Returns Tailwind classes

Unpublish Button

The editor header now includes an "Unpublish" link button that appears when viewing a published page. This allows reverting a published page back to draft status directly from the Puck editor, matching Payload's native document edit functionality.

Editor Stylesheet Injection

The Puck editor renders content in an iframe, which previously lacked access to your frontend CSS. New options enable stylesheet injection into the editor iframe:

Plugin-level configuration:

createPuckPlugin({
  editorStylesheet: 'src/app/(frontend)/globals.css',  // Compiled with PostCSS/Tailwind
  editorStylesheetUrls: ['https://fonts.googleapis.com/...'],  // External stylesheets
})

This creates an endpoint at /api/puck/styles that compiles and serves your CSS (cached after first request).

Component-level configuration:

<PuckConfigProvider
  config={editorConfig}
  editorStylesheets={['/api/puck/styles']}
  editorCss=":root { --custom: value; }"
/>

// Or per-layout in layout definitions
const layout: LayoutDefinition = {
  value: 'dark',
  editorStylesheets: ['/dark-theme.css'],
  editorCss: ':root { --background: #1a1a1a; }',
}

New props added to PuckEditor, PuckConfigProvider, and LayoutDefinition.

Fixed

Server/Client Boundary Error

Fixed getSizeClasses() and sizeValueToCSS() being called from server components. These utilities have been moved to a server-safe module (shared.ts) to prevent the "Attempted to call client function from server" error when using the Button component with custom sizes in PageRenderer.

Publish Button Status

Fixed the Publish button not correctly setting _status: 'published' when publishing a draft page. The API now correctly omits the draft parameter when publishing to ensure Payload properly updates the document status.

Responsive Breakpoint Styles

Fixed responsive field styles not applying correctly at different breakpoints. Styles now properly cascade through Mobile, Tablet, and Desktop viewports.

Overflow Styling

Fixed overflow styling issues in layout components that could cause content clipping in certain configurations.

Changed

  • Internal imports updated from @measured/puck to @puckeditor/core (74 files)
  • Plugin heading-analyzer import updated from @measured/puck-plugin-heading-analyzer to @puckeditor/plugin-heading-analyzer
  • CSS import updated from @measured/puck/puck.css to @puckeditor/core/puck.css

Notes

TipTap Implementation

This release evaluated Puck 0.21's built-in richtext field but retained the custom TipTap implementation. The custom implementation provides features not available in Puck's built-in:

  • 9 font size presets with custom input
  • Theme-aware color picker with opacity support
  • Modal editing mode (full-screen expand)
  • HTML source view/edit
  • Auto-adapting colors for dark/light themes

The custom implementation uses standard TipTap packages (@tiptap/react, @tiptap/starter-kit, extensions) and official APIs—the customization is in the toolbar UI, not TipTap internals.


payload-better-auth

0.3.0 - 2026-01-14

This is a major release with significant new features including security management UI, access control helpers, API key scopes, passkey support, and a comprehensive type system.

Breaking Changes

Adapter Configuration

The adapterConfig.collections option has been removed. Custom collection names are now configured using Better Auth's official modelName pattern:

Before (0.2.x):

payloadAdapter({
  payloadClient: payload,
  adapterConfig: {
    collections: { user: 'members', session: 'auth_sessions' },
  },
})

After (0.3.0):

// Configure in BetterAuthOptions instead:
betterAuth({
  database: payloadAdapter({ payloadClient: payload }),
  user: { modelName: 'member' },        // Singular - becomes 'members'
  session: { modelName: 'auth_session' }, // Singular - becomes 'auth_sessions'
})

Note: With usePlural: true (the default), provide singular model names. They get pluralized automatically.

Collection Generator

The slugOverrides option has been removed from betterAuthCollections(). Use Better Auth's modelName config instead (same as above).

Added

Schema-Aware Adapter

The adapter now uses Better Auth's createAdapterFactory for proper schema-aware transformations:

  • Automatic support for all Better Auth plugins (twoFactor, organization, passkey, admin, apiKey, etc.)
  • No more hardcoded field mappings - uses schema introspection
  • Automatic field name transformations (e.g., userId ↔ user for relationships)
  • Reference field value type conversion (string UUIDs ↔ numbers for SERIAL)
  • All operations use overrideAccess: true to prevent Payload access control from blocking auth operations

Default to SERIAL IDs

The adapter now defaults to 'number' (SERIAL) IDs to match Payload's default behavior:

  • No configuration needed for typical Payload setups
  • Set adapterConfig: { idType: 'text' } if using UUIDs
  • Helpful warnings for common configuration mistakes

Security Management UI

Auto-injected management views for security features based on which Better Auth plugins are enabled:

ViewPathPlugin Required
Two-Factor Auth/admin/security/two-factortwoFactor()
API Keys/admin/security/api-keysapiKey()
Passkeys/admin/security/passkeyspasskey()

Features:

  • Two-Factor Management: Enable/disable 2FA, QR code setup, backup codes display
  • API Keys Management: Create/delete keys, scope selection UI with collection grouping, bulk actions
  • Passkeys Management: Register/delete passkeys with WebAuthn

A "Security" navigation section is added to the admin sidebar automatically.

Configure via:

createBetterAuthPlugin({
  createAuth,
  admin: {
    betterAuthOptions,      // Required for plugin detection
    enableManagementUI: true,  // Default: true
    managementPaths: {
      twoFactor: '/security/two-factor',
      apiKeys: '/security/api-keys',
      passkeys: '/security/passkeys',
    },
  },
})

Access Control Helpers

New utility functions for common authorization patterns:

import {
  isAdmin,
  isAdminField,
  isAdminOrSelf,
  canUpdateOwnFields,
  isAuthenticated,
  isAuthenticatedField,
  hasRole,
  hasRoleField,
  requireAllRoles,
  normalizeRoles,
  hasAnyRole,
  hasAllRoles,
  hasAdminRoles,
} from '@delmaredigital/payload-better-auth'

export const Users: CollectionConfig = {
  slug: 'users',
  access: {
    read: isAdminOrSelf({ adminRoles: ['admin'] }),
    update: canUpdateOwnFields({
      allowedFields: ['name', 'image', 'password'],
      requireCurrentPassword: true,
    }),
    delete: isAdmin(),
  },
}

API Key Permission Scopes

API keys can now have granular permission scopes that control what resources they can access. Scopes are human-readable permission groups similar to GitHub OAuth scopes.

Zero Config (auto-generates from collections):

createBetterAuthPlugin({
  createAuth,
  // Auto-generates: posts:read, posts:write, posts:delete, etc.
})

Custom Scopes:

createBetterAuthPlugin({
  createAuth,
  admin: {
    apiKey: {
      scopes: {
        'content:read': {
          label: 'Read Content',
          description: 'View posts and pages',
          permissions: { posts: ['read'], pages: ['read'] }
        },
        'content:manage': {
          label: 'Manage Content',
          description: 'Full content management',
          permissions: { posts: ['*'], pages: ['*'] }
        }
      },
      defaultScopes: ['content:read']
    }
  }
})

The API Keys management UI shows:

  • Scopes grouped by collection with collapsible sections
  • Bulk action buttons: All Read, All Write, All Delete, Select All, Clear
  • Selection count badges and permission summary

API Key Scope Enforcement

New access control functions for enforcing API key scopes:

import {
  requireScope,
  requireAnyScope,
  requireAllScopes,
  allowSessionOrScope,
  allowSessionOrAnyScope,
  validateApiKey,
  hasScope,
  hasAnyScope,
  hasAllScopes,
  extractApiKeyFromRequest,
  getApiKeyInfo,
} from '@delmaredigital/payload-better-auth'

export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    read: requireScope('posts:read'),
    create: requireScope('posts:write'),
    delete: requireAllScopes(['posts:delete', 'admin:write']),
  },
}

Supports:

  • Wildcard scopes (posts:*, *)
  • Mixed session/API key authentication with allowSessionOrScope()

Pre-configured Auth Client Factory

New createPayloadAuthClient() convenience factory:

import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'

// Pre-configured with twoFactor, apiKey, and passkey plugins
const authClient = createPayloadAuthClient()

// All methods available:
await authClient.signIn.email({ email, password })
await authClient.twoFactor.verifyTotp({ code })
await authClient.passkey.addPasskey({ name: 'My Device' })

For full control, the raw createAuthClient is still available:

import { createAuthClient, twoFactorClient, passkeyClient, apiKeyClient } from '@delmaredigital/payload-better-auth/client'

Bundled Passkey Package

The @better-auth/passkey package is now bundled - no separate installation required.

Passkey Components

New components for passkey authentication:

import { PasskeySignInButton, PasskeyRegisterButton } from '@delmaredigital/payload-better-auth/components'

// Sign-in button
<PasskeySignInButton
  onSuccess={(user) => router.push('/dashboard')}
  onError={(error) => setError(error)}
  label="Sign in with Passkey"
/>

// Registration button
<PasskeyRegisterButton
  passkeyName="My MacBook"
  onSuccess={(passkey) => refetchPasskeys()}
/>

Enable in LoginView:

createBetterAuthPlugin({
  createAuth,
  admin: {
    login: {
      enablePasskey: true,  // Shows "Sign in with Passkey" button
    },
  },
})

Multiple Role Support in LoginView

The requiredRole option now accepts an array of roles:

createBetterAuthPlugin({
  createAuth,
  admin: {
    login: {
      // Any of these roles grants access
      requiredRole: ['admin', 'editor', 'moderator'],

      // Or require ALL roles
      requiredRole: ['admin', 'content-manager'],
      requireAllRoles: true,

      // Disable role checking
      requiredRole: null,
    },
  },
})

Inline Two-Factor Authentication

The LoginView component now handles 2FA verification inline:

  1. User enters email/password
  2. If 2FA is required, form transitions to TOTP code input
  3. User enters 6-digit code from authenticator app
  4. Upon verification, user is redirected to admin panel

No additional configuration needed.

SaveToJWT Auto-Configuration

The betterAuthCollections() plugin now auto-configures saveToJWT for session-critical fields:

  • Sessions: token, expiresAt, userId, ipAddress, userAgent
  • Users: role, email, twoFactorEnabled

Disable with configureSaveToJWT: false if needed.

Auto-Add Missing Fields

The betterAuthCollections() plugin automatically adds missing plugin-specific fields to existing collections. For example, with the twoFactor plugin enabled, the twoFactorEnabled field is automatically added to your Users collection:

[better-auth] Auto-adding fields to 'users': ['twoFactorEnabled']

Enhanced Type System

Comprehensive TypeScript types for Better Auth integration:

import type {
  // Core integration types
  BetterAuthReturn,
  PayloadWithAuth,
  PayloadRequestWithBetterAuth,
  CollectionHookWithBetterAuth,
  EndpointWithBetterAuth,
  RoleArray,

  // Generated schema types
  User,
  BetterAuthSession,
  Account,
  Apikey,
  Passkey,
  Organization,
  Member,
  TwoFactor,
  // ... and more

  // Configuration types
  ScopeDefinition,
  ApiKeyScopesConfig,
  AvailableScope,
  RoleCheckConfig,
  SelfAccessConfig,
  FieldUpdateConfig,
  ApiKeyInfo,
  ApiKeyAccessConfig,
} from '@delmaredigital/payload-better-auth'

Run pnpm generate:types to regenerate types after adding Better Auth plugins.

Password Reset UI Components

New components at @delmaredigital/payload-better-auth/components/auth:

  • ForgotPasswordView - Email input form to request password reset
  • ResetPasswordView - New password form with token validation

Two-Factor Authentication UI Components

New components at @delmaredigital/payload-better-auth/components/twoFactor:

  • TwoFactorSetupView - QR code display, manual secret, backup codes, verification
  • TwoFactorVerifyView - TOTP or backup code entry during login

Collection Customization Callback

New customizeCollection option for betterAuthCollections():

betterAuthCollections({
  betterAuthOptions,
  customizeCollection: (modelKey, collection) => {
    if (modelKey === 'session') {
      return {
        ...collection,
        hooks: { afterDelete: [cleanupHook] },
      }
    }
    return collection
  },
})

Test Infrastructure

Added Vitest test suite with unit tests covering adapter initialization, ID type detection, CRUD operations, and custom collection name handling.

Changed

  • All authentication components now use the official Better Auth client SDK instead of raw fetch() calls
  • betterAuthCollections() has usePlural option (default: true) to control collection name pluralization
  • Improved field type mapping with JSON and array type support
  • Better relationship detection using schema references

Fixed

  • Fixed critical bug where adapter database operations were blocked by Payload's access control (now uses overrideAccess: true)
  • Role check after 2FA now correctly fetches session before validation

Migration from 0.2.x

  1. Remove adapterConfig.collections - Move custom names to BetterAuthOptions:

    // In betterAuth() config, not payloadAdapter():
    user: { modelName: 'member' },
    session: { modelName: 'auth_session' },
    
  2. Remove slugOverrides from betterAuthCollections() - Same migration as above

  3. ID type now defaults to 'number' - Remove adapterConfig.idType: 'number' as it's now the default

  4. Update client imports (optional) - Consider using the new pre-configured client:

    // Old
    import { createAuthClient } from '@delmaredigital/payload-better-auth/client'
    const client = createAuthClient({ baseURL: '...', plugins: [...] })
    
    // New (recommended)
    import { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'
    const client = createPayloadAuthClient()  // Pre-configured with common plugins
    

payload-page-tree

0.2.0 - 2026-01-13

Added

  • URL history tracking: Automatic audit trail of previous URLs stored in slugHistory field (max 20 entries)
  • Redirects endpoint: GET /api/page-tree/redirects?collection=pages returns old→new URL mappings for redirect setup
  • Restore previous URL: "URL History" context menu action shows previous URLs with ability to restore any of them
  • Collection-aware tree view: Dropdown selector to switch between configured collections (Pages, Posts, etc.)
  • URL preservation on move: When moving folders, users can choose to keep existing URLs or update them
  • Regenerate URLs action: Right-click a folder to regenerate slugs for all nested pages
  • Migration endpoint: /api/page-tree/migrate to batch-update slugs for existing content
  • Admin view configuration: Options to enable/disable tree view and customize its path
  • Custom edit URLs: getEditUrl prop on PageTreeClient for integrating with visual editors
  • Exported utilities: buildTreeStructure function for building tree data in custom components
  • Default theme CSS: Import @delmaredigital/payload-page-tree/theme.css when using outside Payload admin
  • Auto-detect collections: Plugin now automatically filters to only collections that exist in your config
  • Folder visual distinction: Folders now have subtle background and left border to differentiate from pages

Changed

  • Default collections: Changed from ['pages'] to ['pages', 'posts'] to match common Payload setups
  • Slug preservation: Existing pages keep their slugs on update unless explicitly regenerated
  • pathSegment field: Now optional (was incorrectly marked as required), preventing schema conflicts on existing projects

Fixed

  • Schema push errors when adding plugin to existing projects with folder data
  • Slug regeneration no longer triggers unexpectedly on page updates

payload-puck

0.4.0 - 2026-01-12

Breaking Changes

Unified PuckEditor Component

The editor component architecture has been simplified to a single PuckEditor component with built-in page-tree support.

Removed exports:

  • PuckEditorCore - Use PuckEditor instead
  • PuckEditorClient - Use PuckEditor instead

Migration:

// Before
import { PuckEditorCore } from '@delmaredigital/payload-puck/editor'
// or
import { PuckEditorClient } from '@delmaredigital/payload-puck/client'

// After
import { PuckEditor } from '@delmaredigital/payload-puck/editor'
// or
import { PuckEditor } from '@delmaredigital/payload-puck/client'

The new PuckEditor component:

  • Accepts config prop directly OR reads from PuckConfigProvider context
  • Includes built-in page-tree support via hasPageTree prop
  • Handles all save/publish functionality
  • Includes dynamic loading to prevent hydration mismatches

Added

Page-Tree Props on PuckEditor

For custom editor UIs using page-tree integration:

<PuckEditor
  config={editorConfig}
  pageId={page.id}
  initialData={page.puckData}
  pageTitle={page.title}
  pageSlug={page.slug}
  apiEndpoint="/api/puck/pages"
  hasPageTree={true}           // Enable page-tree fields
  folder={page.folder}         // Initial folder ID
  pageSegment={page.pageSegment} // Initial page segment
/>

New Utility Exports

Page-tree field injection utilities exported from /client and /editor:

import { injectPageTreeFields, hasPageTreeFields } from '@delmaredigital/payload-puck/client'

// Check if config already has page-tree fields
if (!hasPageTreeFields(config)) {
  config = injectPageTreeFields(config)
}

Changed

  • PuckEditor now supports both direct config prop and context-based config
  • PuckEditorView (RSC) now renders PuckEditor directly instead of PuckEditorClient
  • Page-tree field injection moved into PuckEditor component

payload-better-auth

0.2.0 - 2026-01-11

Added

Automatic Auth API Endpoints

The plugin now auto-registers /api/auth/* endpoints via Payload's endpoint system, eliminating the need to manually create an app/api/auth/[...all]/route.ts file.

  • Endpoints are registered for GET, POST, PATCH, PUT, DELETE methods
  • Requests are proxied to Better Auth's handler
  • Configurable via authBasePath option (default: /auth)
  • Can be disabled with autoRegisterEndpoints: false for advanced use cases

Automatic Admin Components

When disableLocalStrategy: true is detected in your Users collection, the plugin automatically injects admin components:

  • LogoutButton: Styled to match Payload's admin nav using CSS variables
  • BeforeLogin: Redirects to /admin/login for custom authentication
  • LoginView: Full login page matching Payload's admin theme (light/dark mode)

All components use Payload's CSS variables for native theme integration.

Plugin Configuration Options

New options for createBetterAuthPlugin():

createBetterAuthPlugin({
  createAuth,
  authBasePath: '/auth',              // Customize auth endpoint path
  autoRegisterEndpoints: true,        // Auto-register API endpoints
  autoInjectAdminComponents: true,    // Auto-inject admin components
  admin: {
    disableLogoutButton: false,       // Disable logout button injection
    disableBeforeLogin: false,        // Disable BeforeLogin injection
    disableLoginView: false,          // Disable login view injection
    login: {
      title: 'Login',                 // Customize login page title
      afterLoginPath: '/admin',       // Redirect after successful login
    },
    // Override with custom components (import map format)
    logoutButtonComponent: '@/components/MyLogout',
    beforeLoginComponent: '@/components/MyBeforeLogin',
    loginViewComponent: '@/components/MyLoginView',
  },
})

New Export: Components

Admin components are now available for direct use or customization:

import { LogoutButton, BeforeLogin, LoginView } from '@delmaredigital/payload-better-auth/components'

New Export: detectAuthConfig

Utility function to detect auth configuration in Payload config:

import { detectAuthConfig } from '@delmaredigital/payload-better-auth'

const result = detectAuthConfig(config)
// { hasDisableLocalStrategy: boolean, authCollectionSlug: string | null, ... }

Changed

  • BetterAuthPluginOptions expanded with new configuration options
  • README simplified with reduced setup steps (7 steps → 4 steps)
  • Plugin now scans collections to detect disableLocalStrategy configuration

Migration from 0.1.x

If upgrading from 0.1.x, you can simplify your setup:

  1. Remove manual API route - Delete app/api/auth/[...all]/route.ts
  2. Remove manual admin components - Delete custom BeforeLogin, Logout, and login page components
  3. Remove admin.components configuration - Remove from payload.config.ts

The plugin now handles all of the above automatically when disableLocalStrategy: true is detected.


payload-puck

0.3.0 - 2026-01-09

Breaking Changes

Payload Admin UI Integration

The Puck editor now runs inside Payload's admin UI using DefaultTemplate. This provides a native admin experience with proper navigation, permissions, and styling.

Migration:

  • Remove custom editor routes (app/(manage)/pages/[id]/edit/page.tsx)
  • Remove custom editor layouts
  • The plugin now auto-registers the editor view at /admin/puck-editor/:collection/:id
  • "Edit with Puck" buttons in Payload admin now navigate to the integrated editor

PuckConfigProvider Pattern

The Puck configuration is now provided via React context instead of being passed directly to components.

Before:

// app/(manage)/pages/[id]/edit/page.tsx
import { PuckEditor } from '@delmaredigital/payload-puck/editor'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'

<PuckEditor config={editorConfig} ... />

After:

// app/(admin)/layout.tsx (or root layout)
import { PuckConfigProvider } from '@delmaredigital/payload-puck/client'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'

export default function Layout({ children }) {
  return (
    <PuckConfigProvider config={editorConfig}>
      {children}
    </PuckConfigProvider>
  )
}

The editor view automatically retrieves the config from context.

Build System Change

Migrated from tsup to tsc for simpler, more reliable builds.

  • tsup.config.ts removed
  • Build output structure unchanged
  • No changes needed for consumers

Added

Page-Tree Plugin Integration

Automatic integration with @delmaredigital/payload-page-tree when detected:

  • Auto-detection: Checks if collection has pageSegment field (page-tree's signature)
  • Folder Picker Field: Hierarchical folder selection in Puck sidebar
  • Page Segment Field: Editable URL segment with live slugification
  • Slug Preview Field: Read-only computed slug preview

When page-tree is active, the Puck editor sidebar shows:

Root Fields:
├── Page Title
├── Folder (picker dropdown with tree)
├── Page Segment (editable text, auto-slugified)
└── URL Slug (read-only preview)

Configuration:

createPuckPlugin({
  // Auto-detect (default) - checks for pageSegment field
  pageTreeIntegration: undefined,

  // Explicitly enable with custom config
  pageTreeIntegration: {
    folderSlug: 'payload-folders',
    pageSegmentFieldName: 'pageSegment',
  },

  // Explicitly disable
  pageTreeIntegration: false,
})

New Field Exports

Three new custom Puck fields for page-tree integration:

import {
  createFolderPickerField,
  createPageSegmentField,
  createSlugPreviewField,
} from '@delmaredigital/payload-puck/fields'

// Folder picker with hierarchical tree
const folderField = createFolderPickerField({
  label: 'Folder',
  folderSlug: 'payload-folders',
})

// Page segment with auto-slugification
const segmentField = createPageSegmentField({
  label: 'Page Segment',
})

// Read-only slug preview
const slugField = createSlugPreviewField({
  label: 'URL Slug',
  hint: 'Auto-generated from folder + page segment',
})

New Export Paths

  • @delmaredigital/payload-puck/client - Client components including PuckConfigProvider
  • @delmaredigital/payload-puck/rsc - React Server Component exports
  • @delmaredigital/payload-puck/admin/client - Admin-specific client components

Changed

  • Editor view now uses DefaultTemplate from @payloadcms/next/templates
  • API routes now support folder and pageSegment fields in save payload
  • Folder picker includes "Manage folders" link to /admin/page-tree

Fixed

  • Folder picker dropdown now appears inline (fixed position: relative)
  • Folder picker refreshes folder list when opened (catches newly created folders)
  • "No folder" selection no longer breaks the dropdown

payload-puck

0.2.0 - 2026-01-09

Breaking Changes

Section Component Redesign

The Section component now has a two-layer architecture for more powerful layout control:

  • Section layer (outer, full-width): Controls the full-bleed background, border, padding, and margin
  • Content layer (inner, constrained): Controls the content area with max-width, background, border, and padding

Field renames:

  • background → sectionBackground
  • border → sectionBorder
  • customPadding → sectionPadding
  • margin → sectionMargin

New fields:

  • contentDimensions - Max-width, min-height for content area (default: 1200px centered)
  • contentBackground - Background for the content area
  • contentBorder - Border around the content area
  • contentPadding - Padding inside the content area

Removed fields:

  • fullWidth - No longer needed; set contentDimensions to full width instead

Container Component Simplified

The Container component has been simplified to a single-layer organizational wrapper:

Removed fields:

  • innerBackground - Use Section for two-layer backgrounds
  • innerBorder - Use Section for two-layer borders
  • innerPadding - Now just padding

Migration: If you were using Container's inner/outer backgrounds, migrate to Section which now provides this functionality with clearer naming.

Added

  • Changelog file to track breaking changes and new features

Fixed

  • Slot/DropZone now expands to fill container's minHeight in the editor
  • RichText component now fills available width (removed Tailwind prose max-width constraint)
  • Removed hardcoded padding defaults across components; now properly set via defaultProps

Changed

  • Section component now provides full-bleed background with constrained content area out of the box
  • Container component simplified for basic organizational use cases
  • Better field grouping in the editor panel (Section styling → Content styling)
  • Default content area max-width of 1200px makes the two-layer design immediately visible

payload-better-auth

0.1.4 - 2026-01-09

Fixed

  • Initial stable release
  • Payload adapter for Better Auth
  • Collection auto-generation from Better Auth schema
  • Auth strategy for Payload collections
  • Session utilities for server-side access

payload-puck

0.6.10 - 2025-01-23

Changed

legacyRenderer Now Optional on HybridPageRenderer

The legacyRenderer prop is now optional for new projects that will only have Puck pages. Previously it was required even when there were no legacy blocks to render.

// Before: required even for new projects
<HybridPageRenderer
  page={page}
  config={baseConfig}
  legacyRenderer={() => null}  // unnecessary boilerplate
/>

// After: optional for Puck-only projects
<HybridPageRenderer page={page} config={baseConfig} />

For migration projects with existing Payload blocks, provide legacyRenderer as before—behavior is unchanged.

Fixed

Preview Modal Not Rendering Custom Components

Fixed a bug where the full-screen preview modal (opened via the "Preview" button) would show only the header/footer but no page content.

Root cause: The PreviewModal component was not passing the Puck config to PageRenderer, causing it to fall back to baseConfig which doesn't include custom components.

The fix: Added config prop to PreviewModal and passed it through from PuckEditorImpl to PageRenderer.

Preview Modal Now Strips Editor-Only Props

Fixed an issue where editor-only props (like editorPreviewFilter used for panel sync) would persist in component data and affect how the preview rendered, causing interactive features like gallery filter tabs to not respond to user clicks.

The fix: PreviewModal now strips known editor-only props from the data before passing it to PageRenderer, ensuring the preview renders as it would on the actual frontend.

Added

Preview Theme Context for Reactive Dark Mode Detection

Added PuckPreviewThemeContext and usePuckPreviewTheme() hook to allow Puck components to reactively respond to the preview dark mode toggle without polling or MutationObserver hacks.

The problem: When users toggle dark mode in the Puck editor preview, CSS dark mode variants work correctly (via data-theme attribute), but JavaScript-based theme detection would fail due to race conditions with component remounting.

The solution: IframeWrapper now provides a React Context with the current dark mode state, which components can consume directly.

import { usePuckPreviewTheme, PuckPreviewThemeContext } from '@delmaredigital/payload-puck/editor'

function MyComponent() {
  const previewTheme = usePuckPreviewTheme()

  // previewTheme is:
  // - null: not in editor (use DOM-based detection)
  // - boolean: in editor, indicates dark mode state

  const isDark = previewTheme !== null
    ? previewTheme
    : document.documentElement.getAttribute('data-theme') === 'dark'

  // Now render based on isDark...
}

This is particularly useful for components that need to dynamically change JavaScript-controlled styles (like overlay colors) based on the preview theme.

Fixed

Rich Text Dropdown Controls Not Syncing Changes (Supersedes 0.6.4 Workaround)

Fixed the root cause of rich text dropdown controls (font size, text color, highlight) not triggering Puck's dirty state or updating the preview.

What was happening:

  1. User clicks our dropdown (font size, color picker, etc.)
  2. Focus moves from TipTap editor to the dropdown
  3. Puck's blur handler checks: e.relatedTarget?.closest("[data-puck-rte-menu]")
  4. Our dropdown didn't have this attribute, so Puck cleared currentRichText
  5. All menu controls lost their editor reference, including native Puck ones like HeadingSelect
  6. Changes were silently dropped due to Puck's isFocused guard

The fix: Add data-puck-rte-menu attribute to our dropdown content so Puck recognizes it as part of the rich text menu.

What changed:

  • Dropdown controls now use @radix-ui/react-popover (matches Puck's pattern)
  • Added data-puck-rte-menu attribute for Puck's focus management
  • Removed the forcePuckUpdate() workaround from 0.6.4 (no longer needed)
  • Fixed React warning about mixing border shorthand/non-shorthand properties

Note: The 0.6.4 release incorrectly identified this as a Puck core issue. It was actually a bug in our own code—missing the focus preservation attribute that Puck requires.

Removed

  • docs/PUCK_CORE_RICH_TEXT_ISSUE.md - This document incorrectly blamed Puck for an issue in our own code