payload-better-auth
Unreleased
Documentation
- Documentation moved to https://plugins.delmaredigital.com/docs/better-auth. The GitHub Pages site now redirects there.
What changed, straight from the plugin repositories.
payload-better-auth
payload-puck
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.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.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.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.@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.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
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.403 "unreadable documents" response. Database and other errors propagate as 500 with their message, and nothing is deleted.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.payload-page-tree
req and enforce collection READ rules. Hidden documents stay out of these results.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.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.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.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.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.0.4.0 and 0.4.1 headings to their actual releases, 0.3.10 and 0.3.11, restoring descending version order.collection query parameter naming a configured page collection. Missing or invalid collections return 400. Folder deletion keeps its existing contract.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
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.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.@vitejs/plugin-react 6, @testing-library/jest-dom 7 and @swc/cli 0.8. Build output is unchanged.payload-experiments
payload-experiments
@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.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.placement-removed). Archive still works.
Migration impact: none. Hosts that switch over refusal codes gain placement-removed.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.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
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.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
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
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.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.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.GET /xp/pages accepts an optional path query to resolve a page's title and document link. Migration impact: none.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.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.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.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.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./client assignments support optional clickGoalsArmed, supplied by toClientAssignments from /rsc. Migration impact: no database change; hosts constructing client assignments may adopt the optional field.onInit refuses host metricSources ids starting with the reserved click: prefix. Migration impact: rename any registered source using that prefix before upgrading.ReadinessCode and RefusalCode gain goal-in-some-versions and placement-unchecked. Migration impact: update exhaustive switches and records over these unions; no database change.Snapshot gains optional goalFairnessNotice, and PreflightResult gains optional goalNotes. Migration impact: readers may display the notices; no database change.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.serializeMetricSources) offers "A click on the page". Migration impact: no database change; hosts can adopt click goals without registering a metric source.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.copyHint). Migration impact: none.blocks field too. Migration impact: none.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.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.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.resolveExperimentBlocks from /rsc resolves versions and rendered exposures for host block rendering. Migration impact: wire the resolver into the host renderer; no database change.ExperimentGroup from /client provides viewport exposure groups. Migration impact: wrap resolved viewport groups in the host renderer; no database change.POST /xp/blocks/wrap wraps a block in the latest draft, respects document locks and caps requests at 16 KiB. Migration impact: none.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.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.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.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.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.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).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.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.pages run payload generate:importmap; auditing adds no columns.pageKindSeoContract from /rsc to test host metadata and sitemap wiring. Migration impact: none.xp_state.retired_slugs; run payload migrate:create.pageCollection explicitly when several collections share an address.appliesTo rule, and admin.editorUrl receives editor and doc. Migration impact: none. (see 0.4.0-rc.2: corrected)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.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).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.page-target-unpublished).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.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
payload-experiments
/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.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.@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.payload-experiments
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.experimentMetadata after their own metadata so its canonical wins. Migration impact: none.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.createExperimentSlotEditor / createExperimentSlot take the host's admin and API routes (and accent); hosts on non-default routes switch to them.payload-experiments
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.experiment), so people who can read results but not tests see the real title. Migration impact: none.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.next: { code, detail? }, ids only), so the home list can say what to do next. Migration impact: none.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.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.theme.accent plugin option with derived light/dark fills, accessible accent text and focus rings, and foreground contrast validation.experimentMetadata now reads the signed request context via headers(): call it only from a dynamic generateMetadata, never inside 'use cache'.pages option).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.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.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
x-xp-if-updated-at header; its name is exported as XP_IF_UPDATED_AT_HEADER from @delmaredigital/payload-experiments/admin/client).payload-experiments
/rsc runResults summary includes a failed count for non-budget errors, including scheduling and retention-sweep failures.payload-experiments
payload generate:importmap after upgrading to register the new dashboard component.versions explicitly (false, except Tests, which keep their version history). No schema change on Payload 3; prevents default versioning tables on Payload 4.Migration impact: none.
payload-experiments
@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.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
Fixed
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
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.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.payload-experiments
Added
/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.admin.editorUrl, admin.previewUrl and admin.descriptions host configuration, validated at registration and kept server-side.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.url and editorUrl host links and per-slot content counts in slots.ReadinessCode type covering the 28 readiness checks.Changed
Fixed
Removed
listSummary list column and these /admin/client exports: StatusCell, SummaryCell, ExperimentActions, LockedFieldNotice, FlagPointSelect, ArmsField, AllocationField, TargetingAttributeField, TargetingOpField, TargetingValueField, ReviewPanel, PhaseHistory, ResultsView.Migration impact
git diff --stat -- tests/_fixtures/generated-schema.ts with no output./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
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
Fixed
Migration impact
payload-experiments
Fixed
Migration impact
payload-experiments
Fixed
hideGutter. Test-only, no runtime change.Migration impact
payload-experiments
Added
eventSources entries ({ name, clientWritable }) so a server-only
event can be a metric without accepting browser writes.Fixed
./package.json.Migration impact
eventSources entries keep their existing behavior.payload-experiments
Fixed
Migration impact
payload-experiments
Added
events.dimensions), and retention batches indexed by experiment, event type and time.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.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
undeclared-targeting-key,
invalid-targeting-rule, duplicate-metric, unknown-metric-source,
too-many-ids, invalid-phase, invalid-ids and recompute-failed.Migration impact
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.staff attribute must create their server runtime with
defineExperimentsServerRuntime and pass an attributesOf function; startup
refuses the configuration without it.Known gaps
payload-experiments
Added
access.manage
and configurable readResults permissions, with every access operation explicit.createExperimentProxy, resolveExperiments and
applyExperiments; signed xp_id cookies, x-xp-assignments and x-xp-request
headers; consent handling, preview links and whole-page rewrites./rsc: getAssignments, resolveExperimentData,
getFlagPoint, toClientAssignments, recordExposures, trackConversion,
linkIdentity, eraseUser, recordContentChange, experimentMetadata and
experimentSitemapFilter. Exposure logging counts rendered content rather than
assignment alone.ExperimentsProvider, useFlagPoint, useExperiment,
ExperimentPageview and trackConversionBeacon, with explicit exposure calls
and allow-listed client assignments.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.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.Migration impact
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
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
/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./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.<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.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
payload-puck
@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).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
@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.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.PuckEditor takes a new dnd prop, passed to Puck's own; use dnd={{ enableDragHandle: false }} to turn the handle off.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
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.
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.
ownerId → owner) and non-unique indexes.payload migrate:create after every Better Auth bump, with deliberate backfills for new NOT NULL columns and review of proposed column drops.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.payload-puck
<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
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
/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.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.
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.payload-puck
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.
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.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
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.
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.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._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.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.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
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.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.payload / @payloadcms/next / @payloadcms/ui 3.90, next 16.3.8, vitest 4.1.11 (#36). Peer dependency ranges are unchanged.payload-page-tree
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.
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.pnpm test, vitest) covering the access gate and asserting every registered endpoint is guarded. Runs in CI and gates publishing.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
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.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
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.
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
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.
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.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.
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
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.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:
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
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.
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".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.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.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.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
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.
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.
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.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).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..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
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.
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.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).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.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.No action for the common setup. Two narrow cases:
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}.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
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.
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
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.
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.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.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.
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/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.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.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.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.
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.
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.
Only if they apply to you — these are Better Auth changes this package passes through rather than shields you from:
experimental: { joins: true } → advanced: { database: { joins: true } }.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.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.oidcProvider plugin was removed in favor of @better-auth/oauth-provider, and the MCP plugin moved to @better-auth/mcp.payload-puck
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.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.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
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
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./api (routes: { api: '/api/payload' } previously 404'd every auth request and broke admin sign-in). Two independent mismatches were fixed:
/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>'.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
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
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.
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.@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.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
@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.^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.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)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.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.)@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..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.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..github/dependabot.yml) for npm and GitHub Actions, so routine dependency refreshes arrive as a few reviewable PRs instead of many near-identical ones.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
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
/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.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
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.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
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.
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.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).Token (e.g. a plugin's refreshToken) can no longer be auto-embedded in issued JWTs.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.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.)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.normalizeRoles / canUpdateOwnFields behavior — see Security and Migration.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.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).req.url) rather than proceeding with an empty pathname — which would target the wrong route and silently disable the api-key authorization guard.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.detectEnabledPlugins and detectEnabledMethods now derive from one helper (robust to a non-array plugins), fixing drift between them.LoginView's session check and the management clients' initial fetches now bail on unmount (no stale router.push/setState, no StrictMode double-apply).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} ... />
"admin,editor"), switch the field to an array. A comma string is now a single role.authClient.changePassword({ currentPassword, newPassword }) flow.starts_with/ends_with: if you relied on the old (over-broad) behavior, note these are now correctly anchored.payload-better-auth
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.
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.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.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.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.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).history.replaceState), so it no longer lingers in browser history or analytics.role is server-assigned on sign-up. See Migration for the input: false change and firstUserAdmin.defaultRole.>= 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.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.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.qrcode.react is now a runtime dependency (used to render 2FA QR codes locally).pnpm test before publishing (the tag-triggered publish workflow previously had no test gate).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.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.updateMany/deleteMany partial failures are now surfaced (previously docs.length reported partial failures as success).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.>= 20.9. Upgrade your runtime/CI if you were on Node 18.qrcode.react installs automatically as a dependency; no action needed unless you vendor node_modules.x-api-key request bypassed scope checks under allowSessionOrPermission, note that keys are now scope-enforced on both header transports.betterAuthCollections() with the twoFactor plugin, to pick up the new failedVerificationCount / lockedUntil columns (a Better Auth 1.6 schema addition).payload-better-auth
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.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.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
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 whyenableSocialdefaults tofalse. (#21)
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
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.enabledenableSignUp ← emailAndPassword.enabled && !disableSignUpenableForgotPassword ← emailAndPassword.enabled && sendResetPassword configuredenablePasskey / enableMagicLink / enableEmailOtp ← the passkey() / magicLink() / emailOTP() plugin being registeredExplicit 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
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.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.payload-better-auth
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.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.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
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
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
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
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
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
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.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.@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.prefers-color-scheme, persists the choice to localStorage, and is applied before first paint to avoid a flash. (#9)payload-better-auth
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
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
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.payload-better-auth
payload, @payloadcms/next, and @payloadcms/ui. All tests pass against the new version.payload-better-auth
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
better-auth, @better-auth/api-key, @better-auth/oauth-provider, and @better-auth/passkey. Consumer-facing fixes inherited from upstream:
@better-auth/oauth-provider plugin should generate a fresh Payload migration to capture them.onSubscriptionUpdate, onSubscriptionCancel, onTrialEnd, onTrialExpired) now receive the post-update subscription row instead of a stale snapshot.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.mapProfileToUser fallback fixed for providers that omit email; OAuth callbacks no longer link accounts under an undefined provider account ID.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.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
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.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>.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()] }).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).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.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.@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.TwoFactorFields.verified?: boolean added.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.@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.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
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./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
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
title, name) and URL segments (pageSegment, pathSegment) are fully independent after create. To change a URL segment, use the Edit URL action explicitly.thank-you, thank-you-2, thank-you-3) instead of being derived from the mangled title (thank-you-copy).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.'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.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.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
/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)/restore instead of /versions for restore operations. (#6)payload-puck
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
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
payload-page-tree
=== false) to distinguish an explicit "don't update" from the default unset state.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 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
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
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
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
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
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
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
pathSegment was only updated when "Update URLs" was explicitly chosen, leaving the slug out of sync with the folder name.payload-better-auth
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
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:
organizationId in the request bodyorganizationId in the key's metadataactiveOrganizationId, the strategy reads it from the key's metadata and verifies membershipreq.user.activeOrganizationId and req.user.organizationRole are set identically to a normal sessionUsage:
// 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
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
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 emailBaseConfigrenderToEmailHtml(data, options?) — Produces complete email HTML document string via renderToStaticMarkupgetEmailDocumentParts(options) — Returns { before, after } HTML shell for custom wrappingEmail CSS converters added to src/fields/shared.ts:
colorValueToEmailCSS() — Hex-only output, bakes opacity by blending with whitepaddingValueToEmailCSS() — Always outputs px valuesbackgroundValueToEmailCSS() — 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 HTMLvmlBackgroundOpen() / vmlBackgroundClose() — Outlook VML background image supportTABLE_ATTRS, FULL_WIDTH_TABLE_STYLE, centeredTableStyle() — Table layout helpersWEB_SAFE_FONTS — Font stack options for emailpayload-better-auth
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')
| Removed | Replacement |
|---|---|
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 type | PermissionDefinition type |
ApiKeyScopesConfig type | ApiKeyPermissionsConfig type |
AvailableScope type | Removed |
// 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.
requirePermission(resource, action) — thin wrapper around BA's verifyApiKey()requireAnyPermission(permissions) — any of the permission pairs must matchrequireAllPermissions(permissions) — all permission pairs must matchallowSessionOrPermission(resource, action) — session OR API key accessallowSessionOrAnyPermission(permissions) — session OR any matching API keyrequireApiKey() — verify API key without checking specific permissionsgenerateCollectionPermissions() — generates permission definitions for admin UIgetApiKeyPermissionsConfig() — access stored permissions configposts: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 now requires admin role (previously only POST mutations were guarded).generateScopes.ts — replaced by generatePermissions.tsmetadata.scopes workaround — no longer neededExisting 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
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)
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
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
Upgrades to Better Auth 1.5. If upgrading from 0.4.x, review the breaking changes and migration steps below.
pnpm add better-auth@latest
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.
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.
@better-auth/api-keyBetter 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 })]
userId → referenceIdBetter 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 backpnpm 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 removedThe adapter now uses Better Auth's factory transforms via schema introspection. These manual config options are no longer needed.
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.
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 simplifiedReplaced with Auth<O> from better-auth/types.
baseURL supportwithBetterAuthDefaults() now handles Better Auth 1.5's object-form baseURL (with fallback, allowedHosts, protocol properties) in addition to string URLs.
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
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
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.
payload.db.name to determine database type (postgres, mongodb, or sqlite)'text' (ObjectId strings) for MongoDB, 'number' (SERIAL) for Postgres/SQLitestarts_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 operatordbType config option: Explicit override if auto-detection doesn't work for your adapterNew exported utilities: detectDbType(), resolveIdType(), and the DbType type.
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.
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.
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
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.
prepareRequest Callback for AI RequestsNew 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 ChatNew boolean option to control automatic scroll tracking in the AI chat panel.
<PuckEditor
enableAi={true}
aiOptions={{
scrollTracking: true,
}}
/>
mode PropertyAI 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.
outputSchema PropertyAI tools can now define an outputSchema (Zod schema) to type-check tool return values.
onFinish Callback for AI GenerationNew 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.
exclude PropertyComponents can now be fully excluded from AI generation:
const aiConfig = {
MyInternalComponent: {
ai: { exclude: true },
},
}
Supported in injectAiConfig() overrides and the AiComponentConfig type.
defaultZone PropertyComponents can now configure default zone behavior for AI generation:
const aiConfig = {
Section: {
ai: {
defaultZone: { allow: ['Text', 'Heading'], disallow: ['Image'] },
},
},
}
bind PropertyFields can now specify a bind value for AI field binding:
fields: {
title: {
ai: { bind: 'heading' },
},
}
stream PropertyFields can now opt into streaming AI generation with stream: boolean.
AiComponentConfig.schema — use component-level AI instructions insteadpayload-better-auth
@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.
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
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.
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
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
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.
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
Migrated from pure TypeScript compilation to SWC for faster builds:
.d.ts)Build commands remain the same:
pnpm build # Full build (SWC + types)
pnpm dev # Watch mode with SWC
payload-puck
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.
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
Migrated from pure TypeScript compilation to SWC for faster builds:
.d.ts)Build commands remain the same:
pnpm build # Full build (SWC + types)
pnpm dev # Watch mode with SWC
payload-better-auth
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
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
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.
Migrated from pure TypeScript compilation to SWC for faster builds:
.d.ts)Build commands remain the same:
pnpm build # Full build (SWC + types)
pnpm dev # Watch mode with SWC
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
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 pageenablePasskey now also supports 'auto' for auto-detectionThese options were already wired through to LoginView but TypeScript would error when trying to use them.
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()
enableSignUp, enableForgotPassword, and enablePasskey all support 'auto' (the default), which automatically detects availability from Better Auth's endpoints. No configuration needed for most cases.LoginView and using custom admin or frontend login components.payloadAuthPlugins pattern for Stripe and other custom plugins with full type safety.payload-better-auth
apiKeyWithDefaults UtilityNew 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 UtilityNew utility function that applies sensible defaults to Better Auth options. Currently handles:
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.
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.
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.
clientProps injectionCreating 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:
scopesToPermissions()auth.api.createApiKey() with the permissionsThe UI now sends scopes instead of permissions, and the server handles the conversion.
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.
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.
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'.
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
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.req to all Local API calls and sets overrideAccess: false on queries. Previously, users could see pages from all organizations.req to respect collection access rules.payload-puck
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
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 pluginreq.user.organizationRole - user's role in the active organization (auto-fetched from members collection)This enables organization-scoped access control patterns:
export const orgReadAccess: Access = ({ req }) => {
if (!req.user?.activeOrganizationId) return false
return {
organization: { equals: req.user.activeOrganizationId }
}
}
membersCollection option for betterAuthStrategy() to customize the members collection slug (default: 'members')payload-puck
This release adds homepage swap functionality and fixes several issues with rich text editing, hook merging, and editor iframe styling.
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:
New exports:
import {
createIsHomepageUniqueHook,
HomepageConflictError,
unsetHomepage,
} from '@delmaredigital/payload-puck'
getPuckCollectionConfig() HelperNew 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,
],
}
The PuckEditor component now accepts a plugins prop for custom Puck plugins. This enables adding custom panels to the plugin rail.
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.
Fixed white-on-white text in the font size dropdown and custom size input when Payload admin is in dark mode.
generatePagesCollectionFixed a bug where the isHomepage uniqueness hook wasn't being triggered because:
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 SavingFixed isHomepage field not being included in save/publish requests from the editor.
./next Export Not Working with next.config.tsFixed 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.
TemplateField now uses Puck's getItemById API instead of manual tree traversal for accessing slot content, simplifying the code significantlydocs/PUCK_CORE_RICH_TEXT_ISSUE.md documenting the Puck core focus state issue and our workaroundpayload-better-auth
The admin.login configuration options (like afterLoginPath, requiredRole, enablePasskey, etc.) were not being passed to the LoginView component. This has been fixed by:
LoginViewWrapper RSC that reads config from payload.config.custom.betterAuth.loginconfig.custom.betterAuth.login for the wrapper to accessAll login configuration options now work as documented:
createBetterAuthPlugin({
createAuth,
admin: {
login: {
afterLoginPath: '/admin/page-tree', // Now works!
requiredRole: ['admin', 'editor'],
enablePasskey: true,
},
},
})
payload-puck
This release adds build-time CSS compilation for production deployments and fixes dark mode issues in Payload admin.
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:
src/app/globals.css) at request time.next output, not source filesThe 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:
next build, the wrapper compiles your CSS to public/puck-editor-styles.cssNew 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 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.
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
This release adds dark mode support for the Puck editor and includes several bug fixes.
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:
.dark class on document.documentElement (PayloadCMS) or prefers-color-scheme: dark (OS preference)New components:
DarkModeStyles - Injects Puck color variable overrides when dark mode detectedPreviewModeToggle - Toggle for switching preview iframe dark/light modeuseDarkMode hook - Detects dark mode from class or media queryNew props on PuckEditor:
autoDetectDarkMode (default: true) - Enable automatic dark mode detection for editor UIshowPreviewDarkModeToggle (default: true) - Show light/dark toggle for preview iframeinitialPreviewDarkMode (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'
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.
/ for pages marked as homepage instead of using the page slug/api/puck/styles) now resolve correctly in the Puck editor iframe, fixing broken styles when using editorStylesheets propdata-theme attribute in addition to CSS classes, fixing styles for projects using [data-theme='dark'] selectors (common with shadcn/ui, Next.js themes)/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.<button> with <span> inside the file input labelFonts 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
This patch release fixes editor state synchronization and adds quality-of-life improvements for page-tree integration and TypeScript.
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 correctlymeta.title, meta.description, meta.image) are syncedfolder, pageSegment) are syncedNew exports:
import { mapPayloadFieldsToRootProps } from '@delmaredigital/payload-puck/api'
// Reverse-map Payload fields back to root.props format
const rootProps = mapPayloadFieldsToRootProps(payloadDocument)
The page segment field is now locked by default in page-tree mode to prevent accidental URL changes:
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',
})
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>,
}
Type safety improvements for hybrid rendering:
legacyRenderer blockstoHybridPageData() helper to convert Payload's generic JSON typesHybridPageDataInput type for loose inputimport {
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 component is now exported from the components entry point:
import { AnimatedWrapper } from '@delmaredigital/payload-puck/components'
isHomepage, title, pageLayout now correctly load from Payload documents into the editor root.propseditorStylesheets from plugin config now properly passed to PuckEditorcreateLockedPageSegmentField() instead of createPageSegmentField() by defaultpayload-puck
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.
The TipTap field exports have been replaced with a new RichText field API using Puck's native richtext with custom extensions.
Removed exports:
TiptapField, createTiptapFieldTiptapModalTiptapModalField, createTiptapModalFieldMigration:
// 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'
The AI now receives detailed instructions for all built-in components, teaching it:
text not label, link not href)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'
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:
When contextCollection: true, a new "AI Context" panel appears in the Puck plugin rail:
import { createContextEditorPlugin } from '@delmaredigital/payload-puck/ai'
const contextPlugin = createContextEditorPlugin({
apiEndpoint: '/api/puck/ai-context',
canCreate: true,
canEdit: true,
canDelete: true,
})
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,
})
New prop to enable Puck's experimental full-screen canvas mode:
<PuckEditor experimentalFullScreenCanvas={true} />
The RichText field has been rewritten using Puck's native richtext with custom TipTap extensions:
PuckEditor uses comprehensiveComponentAiConfig by default when AI is enabledpayload-better-auth
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:
defaultSignUpRoleThe 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:
resetPasswordUrladapterConfig usage (debug logging now opt-in via comment)beforeLogin componentpayload-page-tree
FolderSelectModal) for selecting destination folders with expandable tree viewNavGroup wrapper with "Manage Pages" labelpayload-puck
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.
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:
dispatch to update editor state instantlyNew 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)Layout components now support semantic HTML output for better SEO and accessibility. Each component has an "HTML Element" dropdown in the editor sidebar:
| Component | Available Elements |
|---|---|
| Section | section, article, aside, nav, header, footer, main, div |
| Flex | div, nav, ul, ol, aside, section |
| Container | div, article, aside, section |
| Grid | div, ul, ol |
This enables proper document structure without wrapper elements:
<nav> for navigation menus<article> for blog posts<aside> for sidebars<ul>/<ol> for list layoutsThe 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.
The Accordion component now uses dynamic defaultItemProps:
defaultOpen: true)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 containersalignmentToGridCSS() - For Grid containersalignmentToPlaceSelfCSS() - For individual grid itemsalignmentToTailwind() - Returns Tailwind classesThe 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.
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 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.
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.
Fixed responsive field styles not applying correctly at different breakpoints. Styles now properly cascade through Mobile, Tablet, and Desktop viewports.
Fixed overflow styling issues in layout components that could cause content clipping in certain configurations.
@measured/puck to @puckeditor/core (74 files)@measured/puck-plugin-heading-analyzer to @puckeditor/plugin-heading-analyzer@measured/puck/puck.css to @puckeditor/core/puck.cssThis 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:
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
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.
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.
The slugOverrides option has been removed from betterAuthCollections(). Use Better Auth's modelName config instead (same as above).
The adapter now uses Better Auth's createAdapterFactory for proper schema-aware transformations:
userId ↔ user for relationships)overrideAccess: true to prevent Payload access control from blocking auth operationsThe adapter now defaults to 'number' (SERIAL) IDs to match Payload's default behavior:
adapterConfig: { idType: 'text' } if using UUIDsAuto-injected management views for security features based on which Better Auth plugins are enabled:
| View | Path | Plugin Required |
|---|---|---|
| Two-Factor Auth | /admin/security/two-factor | twoFactor() |
| API Keys | /admin/security/api-keys | apiKey() |
| Passkeys | /admin/security/passkeys | passkey() |
Features:
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',
},
},
})
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 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:
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:
posts:*, *)allowSessionOrScope()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'
The @better-auth/passkey package is now bundled - no separate installation required.
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
},
},
})
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,
},
},
})
The LoginView component now handles 2FA verification inline:
No additional configuration needed.
The betterAuthCollections() plugin now auto-configures saveToJWT for session-critical fields:
Disable with configureSaveToJWT: false if needed.
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']
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.
New components at @delmaredigital/payload-better-auth/components/auth:
ForgotPasswordView - Email input form to request password resetResetPasswordView - New password form with token validationNew components at @delmaredigital/payload-better-auth/components/twoFactor:
TwoFactorSetupView - QR code display, manual secret, backup codes, verificationTwoFactorVerifyView - TOTP or backup code entry during loginNew customizeCollection option for betterAuthCollections():
betterAuthCollections({
betterAuthOptions,
customizeCollection: (modelKey, collection) => {
if (modelKey === 'session') {
return {
...collection,
hooks: { afterDelete: [cleanupHook] },
}
}
return collection
},
})
Added Vitest test suite with unit tests covering adapter initialization, ID type detection, CRUD operations, and custom collection name handling.
fetch() callsbetterAuthCollections() has usePlural option (default: true) to control collection name pluralizationoverrideAccess: true)Remove adapterConfig.collections - Move custom names to BetterAuthOptions:
// In betterAuth() config, not payloadAdapter():
user: { modelName: 'member' },
session: { modelName: 'auth_session' },
Remove slugOverrides from betterAuthCollections() - Same migration as above
ID type now defaults to 'number' - Remove adapterConfig.idType: 'number' as it's now the default
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
slugHistory field (max 20 entries)GET /api/page-tree/redirects?collection=pages returns old→new URL mappings for redirect setup/api/page-tree/migrate to batch-update slugs for existing contentgetEditUrl prop on PageTreeClient for integrating with visual editorsbuildTreeStructure function for building tree data in custom components@delmaredigital/payload-page-tree/theme.css when using outside Payload admin['pages'] to ['pages', 'posts'] to match common Payload setupspayload-puck
The editor component architecture has been simplified to a single PuckEditor component with built-in page-tree support.
Removed exports:
PuckEditorCore - Use PuckEditor insteadPuckEditorClient - Use PuckEditor insteadMigration:
// 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:
config prop directly OR reads from PuckConfigProvider contexthasPageTree propFor 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
/>
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)
}
PuckEditor now supports both direct config prop and context-based configPuckEditorView (RSC) now renders PuckEditor directly instead of PuckEditorClientPuckEditor componentpayload-better-auth
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.
authBasePath option (default: /auth)autoRegisterEndpoints: false for advanced use casesWhen disableLocalStrategy: true is detected in your Users collection, the plugin automatically injects admin components:
/admin/login for custom authenticationAll components use Payload's CSS variables for native theme integration.
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',
},
})
Admin components are now available for direct use or customization:
import { LogoutButton, BeforeLogin, LoginView } from '@delmaredigital/payload-better-auth/components'
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, ... }
BetterAuthPluginOptions expanded with new configuration optionsdisableLocalStrategy configurationIf upgrading from 0.1.x, you can simplify your setup:
app/api/auth/[...all]/route.tsThe plugin now handles all of the above automatically when disableLocalStrategy: true is detected.
payload-better-auth
payload-puck
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:
app/(manage)/pages/[id]/edit/page.tsx)/admin/puck-editor/:collection/:idThe 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.
Migrated from tsup to tsc for simpler, more reliable builds.
tsup.config.ts removedAutomatic integration with @delmaredigital/payload-page-tree when detected:
pageSegment field (page-tree's signature)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,
})
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',
})
@delmaredigital/payload-puck/client - Client components including PuckConfigProvider@delmaredigital/payload-puck/rsc - React Server Component exports@delmaredigital/payload-puck/admin/client - Admin-specific client componentsDefaultTemplate from @payloadcms/next/templatesfolder and pageSegment fields in save payload/admin/page-treeposition: relative)payload-puck
The Section component now has a two-layer architecture for more powerful layout control:
Field renames:
background → sectionBackgroundborder → sectionBordercustomPadding → sectionPaddingmargin → sectionMarginNew fields:
contentDimensions - Max-width, min-height for content area (default: 1200px centered)contentBackground - Background for the content areacontentBorder - Border around the content areacontentPadding - Padding inside the content areaRemoved fields:
fullWidth - No longer needed; set contentDimensions to full width insteadThe Container component has been simplified to a single-layer organizational wrapper:
Removed fields:
innerBackground - Use Section for two-layer backgroundsinnerBorder - Use Section for two-layer bordersinnerPadding - Now just paddingMigration: If you were using Container's inner/outer backgrounds, migrate to Section which now provides this functionality with clearer naming.
payload-better-auth
payload-puck
legacyRenderer Now Optional on HybridPageRendererThe 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 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.
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 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 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:
e.relatedTarget?.closest("[data-puck-rte-menu]")currentRichTextisFocused guardThe fix: Add data-puck-rte-menu attribute to our dropdown content so Puck recognizes it as part of the rich text menu.
What changed:
@radix-ui/react-popover (matches Puck's pattern)data-puck-rte-menu attribute for Puck's focus managementforcePuckUpdate() workaround from 0.6.4 (no longer needed)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.
docs/PUCK_CORE_RICH_TEXT_ISSUE.md - This document incorrectly blamed Puck for an issue in our own code