mirror of
https://github.com/payloadcms/payload.git
synced 2026-09-14 20:07:19 +08:00
feat/authorship
2607 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
748cced830 | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
3b8f361833 | feat: add authorship field builders, overrides, and graphql/raw-field tests | ||
|
|
3aa4043b0a |
feat: stubs out cohort telemetry (#17876)
Adds `projectCohorts` and `figmaProduct` groups to our captured telemetry. Note: the `enterprise` cohort is a partial solution currently (only captures enterprise clients using enterprise plugins) - we will be strengthening this with an additional identifier soon. |
||
|
|
eabe959f14 |
feat: add some, none, and every operators to has-many relationship queries (#17838)
This PR adds `some`, `none`, and `every` operators for non-polymorphic, has-many relationship and upload fields. These operators make it possible to apply a complete nested `where` query to the related documents. Support is intentionally API-only in this PR. The Admin UI Where Builder does not expose `some`, `none`, or `every`. This matches existing dot-notation relationship queries, which can query related document properties through the APIs but cannot be constructed in the generic Where Builder. Supporting these in the UI Where Builder would require a recursive UI for building the nested related-document `where` query and can be handled separately. This work also fixes #16109 by ensuring that Drizzle uses the current related table alias when a query traverses a transitive has-many join. ## Problem Payload already supports querying fields on related documents with dot notation. For example, given a `directors` collection with a has-many `movies` relationship, this query finds directors who directed at least one movie named `recalls`: ```ts const result = await payload.find({ collection: 'directors', where: { 'movies.name': { equals: 'recalls', }, }, }) ``` This behaves like the following JavaScript expression: ```ts movies.some((movie) => movie.name === 'recalls') ``` Before this PR, it was tempting to reverse the condition with `not_equals`: ```ts const result = await payload.find({ collection: 'directors', where: { 'movies.name': { not_equals: 'recalls', }, }, }) ``` However, that query means “find a director with at least one movie whose name is not `recalls`.” It behaves like this: ```ts movies.some((movie) => movie.name !== 'recalls') ``` A director related to both `recalls` and `electric-cars` therefore still matches. The `electric-cars` relationship row satisfies `not_equals`, even though another relationship row points to `recalls`. There was no query syntax for saying that no related document may match a condition. ## New API The new `none` operator expresses the intended query directly: ```ts const result = await payload.find({ collection: 'directors', where: { movies: { none: { name: { equals: 'recalls', }, }, }, }, }) ``` This behaves like the following JavaScript expression: ```ts !movies.some((movie) => movie.name === 'recalls') ``` A director related to both `recalls` and `electric-cars` is excluded. A director related only to `recalls` is also excluded. A director related only to `electric-cars`, or a director with no movies, is included. The same nested query structure supports `some`: ```ts const result = await payload.find({ collection: 'directors', where: { movies: { some: { name: { equals: 'recalls', }, }, }, }, }) ``` It also supports `every`: ```ts const result = await payload.find({ collection: 'directors', where: { movies: { every: { rating: { greater_than_equal: 4, }, }, }, }, }) ``` ## Operator behavior `some` matches when at least one related document matches the nested query. `none` matches when no related document matches the nested query. `every` matches when all related documents match the nested query. An empty relationship does not match `some`. It does match `none` and `every`. This follows normal JavaScript array behavior, where `[].some(...)` is false and `[].every(...)` is true. The nested value is a complete Payload `where` query. It can contain multiple field conditions, `and`, `or`, direct ID conditions, and supported nested relationship queries. All conditions inside one `some`, `none`, or `every` block are evaluated against the same related document. ## Existing dot-notation behavior Existing dot-notation relationship queries remain supported and are unchanged. A positive dotted query continues to behave like an implicit `some` query: ```ts where: { 'movies.name': { equals: 'recalls', }, } ``` The equivalent explicit query is: ```ts where: { movies: { some: { name: { equals: 'recalls', }, }, }, } ``` The existing meaning of dotted `not_equals` is also unchanged. Applications that need “no related document matches” can now use `none` without changing the behavior of existing queries. ## Implementation The shared Payload query types now distinguish normal field operators from has-many relationship operators. Normal operators accept a JSON value, while `some`, `none`, and `every` accept another `Where` object. Query validation confirms that these operators are used on a supported has-many field and then validates the nested query against the related collection. The Drizzle adapter builds a correlated subquery for the relationship. `some` checks that a matching relationship row exists. `none` checks that no matching relationship row exists. `every` checks that no relationship row points to a related document that fails the nested query. The nested query is still parsed by the normal Drizzle query builder, so existing field operators and deeper relationship paths are reused. The MongoDB adapter builds the nested query against the related collection and retrieves the matching related document IDs. `some` uses `$in` with matching IDs, while `none` uses `$nin`. For `every`, MongoDB finds related documents that do not match and uses `$nin` with those IDs. The GraphQL schema exposes the three operators on supported fields. REST requests use the same nested query structure through the existing query-string parser. Virtual relationship paths, localized relationships, arrays, and blocks continue through the existing path-resolution logic. The MCP `where` schema keeps value operators and relationship operators separate. It validates `some`, `none`, and `every` with the same recursive `where` schema used for `and` and `or` groups. ## Supported fields The new operators are supported on relationship and upload fields where `hasMany` is enabled and `relationTo` contains one collection slug. Singular relationships are intentionally excluded because their existing dotted queries already operate on at most one related document. Polymorphic relationships are also excluded because a single nested query cannot be validated against multiple potentially different collection schemas. Unsupported uses return the normal query validation error with the operator path. |
||
|
|
a872aad1a3 |
refactor!: improve access defaults for jobs (#17768)
Generic create, read, update, and delete access on `payload-jobs` is now disabled by default. The Jobs documentation now explains the difference between dedicated Jobs APIs and raw collection CRUD. ## Why? Job documents are internal system data, so generic collection access should be opt-in. Dedicated APIs such as `payload.jobs.*`, `/run`, and `/handle-schedules` remain the recommended ways to work with jobs. Projects that intentionally expose the jobs collection can still configure its access through `jobsCollectionOverrides`. ## Breaking changes Projects that use REST or GraphQL CRUD directly on `payload-jobs`, or Local API CRUD with `overrideAccess: false`, will now be denied by default. The dedicated `payload.jobs.*` APIs and trusted Local API calls with access override enabled continue to work as before. If your project intentionally exposes job documents, add the required collection access through `jobsCollectionOverrides`. Prefer read-only access for trusted administrators because job documents can contain sensitive information. |
||
|
|
f0dbca9984 | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
07e8ef3db6 |
fix: prevent read operations from rolling back the caller's transaction (#17748)
Read operations never call `initTransaction`, so they never own a transaction - but every one of them called `killTransaction(req)` in its catch block. Because `req` is shared with the caller, a failed read rolled back the caller's in-flight writes and deleted `req.transactionID`. When the caller swallows the error, the damage is silent. A hook doing `payload.findByID(...).catch(() => null)` against a trashed doc (issue #17723) kills the transaction opened by the surrounding `create`; `create` sees no failure and goes on to `commitTransaction(req)`, which no-ops because the adapter session is already gone. The client gets a 201 with a real doc id and a populated doc, the Postgres sequence advances, and no row is ever written. Removes the `killTransaction` call - and the resulting useless try/catch - from the 14 operations that never open a transaction: collection `find`/`findByID`/`count`/`countVersions`/`findVersions`/`findVersionByID`/`findDistinct`/ `docAccess`, their global equivalents, and `auth`/`access`. Errors that propagate are still rolled back by the owning operation's own catch; errors that a hook catches now leave the transaction intact so it commits normally. The diff is mostly re-indentation. Documents the ownership rule on `killTransaction` so it isn't reintroduced. Fixes #17723 <!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. ## Which branch should this PR target? - **`main`** — Payload v4 development. New features, enhancements, and v4 bug fixes go here. - **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are not accepted against v3.** If this PR adds a feature to v3, please retarget it at `main` (v4) instead. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> |
||
|
|
411bb4d60b |
fix: expose payload build child-process signal failures (#17781)
`payload build` now exits non-zero when the framework build _is killed_ unexpectedly by a signal. A signal kill never returned the appropriate exit code, hiding the real failure. Resolves [PYLD-3469](https://linear.app/figma/issue/PYLD-3469/expose-payload-build-child-process-signal-failures) ## Root Cause The spawn handler resolved `code ?? 0` on the child `exit` event. A signal termination (SIGKILL, or an OOM kill) reports `code === null`. The `?? 0` converted that null into exit code 0. The build reported success, the deploy script continued, and it then failed later on a missing `.next/standalone` directory. The signal was lost. ## Key Changes - **Signal-aware exit handling** - The handler now listens on `close` instead of `exit`. `close` also waits for the child's inherited output streams to flush before resolving. - A present `signal` logs the signal name and resolves 1. A missing code resolves `code ?? 1`, so an abnormal termination never reports success. ## Usage / Before-after Before: a SIGKILL'd `next build` caused `payload build` to exit 0. After: it exits 1 and logs `Build terminated by signal SIGKILL`. ## Tested Manually - Terminal 1: `pnpm build` - Terminal 2: `pgrep -f 'next-build \(v' | xargs kill -9` during next build - Verify terminal 2 has non-zero exit code: `echo $?` |
||
|
|
ef69ab17ae |
feat: add base access control (#17662)
## Summary Adds a top-level `baseAccess` config with separate `collections` and `globals` access maps. This gives applications and plugins one core-owned policy layer without iterating over resources or hardcoding Payload-managed slugs. ## Motivation This originated from Figma Make's need to enforce a read-only policy for viewer users across every Payload resource. Its plugin currently has to find resources and wrap their access callbacks, which is order-dependent and can miss Payload-managed resources created during sanitization. Plugins can currently apply a shared policy only by finding every Collection and Global and wrapping each access callback. That is order-dependent and can miss resources added by later plugins or by Payload during sanitization, making omissions difficult to detect and potentially security-sensitive. `baseAccess` moves that composition into Payload so the same policy is applied consistently to configured, plugin-provided, and Payload-generated resources. ## Alternatives considered - **Plugin ordering and callback wrapping:** still depends on when resources are registered and cannot reliably cover resources created during sanitization. - **One boolean-only function:** simpler, but cannot express the `Where` constraints supported by existing Collection and Global Access Control. - **A new policies API:** more flexible, but introduces a larger new abstraction when the existing access shapes already describe the required operations. ## Changes - Reuses `CollectionAccess` and `GlobalAccess` directly for `baseAccess.collections` and `baseAccess.globals` - Adds the resource `slug` to all Collection and Global Access Control callback arguments, including `access.admin` - Combines each configured base function with the matching resource access function using AND semantics - Applies the policy to resources created during sanitization, including Payload-generated resources - Resolves the base function from the current request so reused sanitized resources cannot retain another config's policy - Fails closed when either access function returns an invalid falsy result - Preserves `overrideAccess` behavior and existing resource-specific access - Keeps `baseAccess` out of the client config - Rejects query constraints returned by collection base access for create operations, which only support boolean access - Documents the API, composition rules, scope, and Payload 4 migration note --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
24ac89558c |
fix: hide hierarchy virtual path fields from the admin panel (#17763)
## What? Hides the hierarchy virtual path fields (`_h_slugPath` and `_h_titlePath`) from the admin panel again. `admin.hidden` was commented out on both field definitions, so they rendered as read-only text inputs in the edit view of every hierarchy-enabled collection. ## Why? These fields are computed in the `afterRead` hook and are not editable. Showing them adds two empty read-only inputs to the edit view with no value to the user. ## How? - Restore `admin.hidden: true` on both generated fields in `addHierarchyToCollection`. - Add a regression test in `test/hierarchy/int.spec.ts` that asserts both fields are hidden on the sanitized collection config. |
||
|
|
6c4df77021 |
fix: prevent multipart content-type backtracking (#17682)
Updates multipart `Content-Type` validation so parameter separators cannot also be consumed as parameter content. This allows malformed values to be rejected promptly while preserving the multipart subtype and parameter formats Payload already supports. --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> |
||
|
|
aa0152043c |
test: fix sanitize unit test (#17752)
Fixes our CI issues with unit tests |
||
|
|
7bac6ab82a |
feat: support PAYLOAD_SECRET rotation with a versioned keyring (#17674)
## What
Adds a supported way to rotate `PAYLOAD_SECRET` — read data encrypted
under a previous secret and re-key it under the new one — without
silently corrupting data or breaking authentication.
Today the secret is used for reversible encryption (API keys), the
API-key HMAC lookup index, and JWT signing. There was no supported
rotation path, and the "obvious" hand-rolled migration silently corrupts
every API key because of the `apiKey` field's own encrypt/decrypt hooks.
## Changes
**Primitives**
- `payload.encrypt` / `payload.decrypt` accept an optional `{ secret }`
override (the raw `PAYLOAD_SECRET`, derived internally).
- New `payload.reencrypt(value, { oldSecret })` — decrypts with a
previous secret and re-encrypts with the active one.
**`rotateSecret` utility** (exported from `payload`)
- Re-keys the built-in `apiKey` **and** the coupled `apiKeyIndex` for
every auth collection using `useAPIKey`.
- Runs at the database-adapter layer to bypass the field hooks that
would otherwise corrupt data mid-rotation.
- **Idempotent** (re-run skips already-migrated rows via the HMAC index)
and **fail-closed** (a row matching neither the old nor the current
secret aborts before it is written).
- `dryRun` verifies every row without writing.
**Versioned, authenticated envelope**
- New writes use `v1:<keyId>:<iv>:<authTag>:<ciphertext>` (AES-256-GCM),
with an HKDF-derived 32-byte key and a non-secret `keyId` fingerprint.
- Legacy AES-256-CTR values are still read transparently and upgraded to
v1 on re-encrypt.
- A wrong-key / tampered / unknown-key value now fails loud (throws)
instead of returning garbage.
**Keyring for zero-downtime rotation**
- New `previousSecrets: string[]` config builds a keyring accepted for
**reads** — JWT verification and API-key lookup try each secret — while
writes always use the active `secret`.
- Enables rotation with no forced logout: add the old secret to
`previousSecrets`, deploy, run `rotateSecret`, then remove it.
- `apiKey` `afterRead` masks an undecryptable value (returns `null`)
instead of failing the whole document read.
## Notes
- **Password logins are unaffected** by a rotation — passwords use
pbkdf2 with a random per-user salt and never involve `PAYLOAD_SECRET`.
- Backwards compatible: the `encrypt`/`decrypt` additions are optional;
`previousSecrets` defaults to none; existing CTR data keeps reading.
- The v1 envelope is a forward format change — data written by a
v1-capable version is not readable by older Payload versions.
## Docs
New page: `docs/authentication/rotating-secret` (zero-downtime
procedure, `previousSecrets`, `rotateSecret` reference, envelope
format).
## Tests
Adds integration coverage under `test/auth` (isolated collections):
re-key happy path, idempotent re-run, wrong-secret abort, mid-run abort
+ resume, dry run, null-`apiKeyIndex` skip, legacy CTR → v1 upgrade,
masked read of an undecryptable value, password login unaffected,
API-key auth via a previous secret, JWT verification under a previous
secret (and rejection of an unknown one), plus v1 round-trip / legacy
back-compat / GCM tamper / unknown-keyId envelope tests. **97/97 pass on
MongoDB and Postgres.**
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f48de23691 |
feat!: replace collections dashboard widget with recently viewed widget (#17181)
## Summary Removes the built-in `collections` dashboard widget (the `CollectionCards` component) entirely and injects the `activity` (recently viewed documents) widget into the stock `defaultLayout` at `small` (33%) width in its place. No recently viewed documents <img width="1685" height="964" alt="image" src="https://github.com/user-attachments/assets/321625d6-de53-42a0-a619-33c7bdb43623" /> With recently viewed documents <img width="1680" height="968" alt="image" src="https://github.com/user-attachments/assets/37d6513d-90c6-41da-a8dc-474c637725e5" /> ## Why The `collections` widget rendered a grid of collection and global cards on the dashboard. In practice it did not add much value: it just repeated information that is already available in the navigation sidebar. It mostly existed as filler so the dashboard would not look empty on a fresh install. Now that we ship the `activity` (recently viewed documents) widget, we have a better default that gives users something genuinely useful on the dashboard, so the collections widget is no longer worth maintaining. ## Breaking change This is a breaking change. Anything relying on the removed widget will fail: - The `CollectionCards` export was removed from `@payloadcms/ui/rsc`, so any import or string component path (`@payloadcms/ui/rsc#CollectionCards`, or the pre-v4 `@payloadcms/next/rsc#CollectionCards`) no longer resolves. - `defaultLayout` entries with `widgetSlug: 'collections'` no longer match a registered widget and are dropped from the rendered dashboard. - Saved user dashboard preferences that contain the `collections` widget silently drop it on next render; the rest of the layout is unaffected. The navigation sidebar still provides access to every collection and global, so no navigation capability is lost. This is documented in the v4 migration guide. ## What changed - **Core**: removed the `collections` widget registration in `sanitize.ts` and pointed the stock `defaultLayout` at `{ widgetSlug: 'activity', width: 'small' }`. - **UI**: deleted the `CollectionCards` widget (`index.tsx` + `index.css`) and its `@payloadcms/ui/rsc` export. - **Templates**: regenerated import maps no longer import the removed `CollectionCards` symbol. - **Docs**: added a dedicated breaking-change section to `docs/migration-guide/v4.mdx` and updated `docs/custom-components/dashboard.mdx` (default layout example + built-in widgets). - **Tests**: reviewed every e2e/int suite that depended on the widget or its card DOM: - `test/dashboard`: dropped the `collections` widget from the layout and renumbered widget-position assertions; the type-level test and generated types no longer reference `CollectionsWidget`. - `test/admin`: removed the dashboard-card navigation and hidden/`group: false` card tests (all already covered by the equivalent nav tests) and rewired the history-replacement test to navigate through the nav sidebar. - `test/access-control`: removed the card-list visibility and quick-create card tests (covered by the nav and list-view tests). - `test/locked-documents`: removed the `dashboard - globals` block that asserted lock indicators on the dashboard cards. - `test/base-path`: the "navigate by clicking nav link" test now actually clicks the nav link instead of a dashboard card. ## Test plan - [ ] `pnpm test:int dashboard` - [ ] `pnpm test:e2e dashboard` - [ ] `pnpm test:e2e admin` - [ ] `pnpm test:e2e access-control` - [ ] `pnpm test:e2e locked-documents` - [ ] `pnpm test:e2e base-path` - [ ] Verify a fresh dashboard renders the recently viewed widget as the default --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
82bccd1d37 |
chore(skills): clarify no virtual fields for useAsTitle (#17746)
Clarifies the Payload documentation and agent skill guidance for using virtual fields with `admin.useAsTitle`. Computed fields configured with `virtual: true` cannot be used as `useAsTitle` because they are not queryable. Doing so causes Payload to throw an `InvalidConfiguration` error during startup. Relationship-path virtual fields, such as `virtual: 'author.name'`, remain supported because they can be queried and sorted. |
||
|
|
a5a4f78a18 | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
eed0152441 | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
4fe5a1ed5f |
perf!: remove DeepRequired from sanitized global config types (#17742)
This PR removes `DeepRequired` from `SanitizedGlobalConfig`. It uses shallow `Pick`, `Omit`, and `Required` types so only properties populated during global config sanitization are required. ## Why `DeepRequired` recursively processes large field, hook, admin, and conditional types, adding unnecessary TypeScript checker work. It was also inaccurate. `access.readVersions`, `admin.components`, `graphQL`, `lockDocuments`, and `typescript` could be undefined but were typed as required. Meanwhile, `_sanitized`, `admin`, `custom`, `label`, standard access functions, and hook arrays are always present. Global sanitization now also defaults `hooks.beforeOperation` to an empty array, matching collection sanitization and the required sanitized hook shape. With the final `DeepRequired` usage removed from sanitized config types, this also removes the `DeepClone<Metadata>` workaround and its paired `formatMetadata` assertion, which were only needed to support it. ## Breaking change Code using `SanitizedGlobalConfig` may now need to handle `undefined` for properties Payload does not default. ```ts if (global.access.readVersions) await global.access.readVersions(args) const views = global.admin.components?.views const interfaceName = global.typescript?.interface ``` |
||
|
|
dc3e6664f8 |
perf!: remove DeepRequired from sanitized config types (#17665)
Follow up of https://github.com/payloadcms/payload/pull/17541 This PR removes `DeepRequired` from `SanitizedConfig`. It uses shallow `Pick`, `Omit`, and `Required` types so only properties populated during root config sanitization are required. ## Why `DeepRequired` recursively processes large admin, field, hook, GraphQL, and plugin types, adding unnecessary TypeScript checker work. It was also inaccurate. `admin.autoLogin`, `admin.livePreview`, `graphQL.mutations`, `graphQL.queries`, `queryPresets`, and `typescript.postProcess` could be undefined but were typed as required. Meanwhile, `blocks` is always populated, `auth.jwtOrder` is supplied by Payload despite previously being required in the incoming `Config`, and `SanitizedConfig.paths` was typed but never existed at runtime. The runtime defaults now also preserve the guarantees made by `SanitizedConfig`: explicitly passing `undefined` cannot erase required admin, GraphQL, route, TypeScript, or auth defaults, and localization always receives `localeCodes` and `fallback` when enabled. ## Breaking change Code using `SanitizedConfig` may now need to handle `undefined` for properties Payload does not default. `Config.auth.jwtOrder` is now optional for incoming configs, and the unused `SanitizedConfig.paths` property has been removed. |
||
|
|
f0251ae769 |
feat: bundle payload skill into payload package (#17652)
Payload's agent skill now ships inside the `payload` npm package. The
guidance an AI assistant reads matches the version of Payload a project
has installed.
The skill previously reached users only through a Claude Code plugin
marketplace that tracks `main`. Guidance could describe APIs that a
project's installed version does not have.
Resolves PYLD-3360
`create-payload-app` no longer asks which assistant you use. It writes
two files at the project root:
```md
AGENTS.md
# AI Agent
For any Payload-related work, reference the skill at `node_modules/payload/skills/payload/SKILL.md`.
```
```md
CLAUDE.md
@AGENTS.md
```
Pass `--no-agent` to skip both.
## Key Changes
- **Skill ships with the package**
- The skill moved to `packages/payload/skills/payload/`. The package
`files` allowlist now includes `skills`.
- npm publishes the directory in the tarball. Consumers find it at
`node_modules/payload/skills/payload/`.
- **create-payload-app stops downloading the skill**
- A `buildAgentConfigFiles` builder replaces the tarball fetch from
GitHub. The generated files point into `node_modules`.
- The `try`/`catch` around the download is gone. A failed local write
now surfaces instead of degrading to a warning.
- **The agent prompt is removed**
- `select-agent.ts`, the `AgentType` union, and the `-a`/`--agent` flag
are deleted. All three former choices produced identical content.
- `--no-agent` is now the only opt-out. Runs passing `-a claude` will
fail on an unknown flag.
- **Marketplace entry retargeted**
- `source` is now `./packages/payload`. The deleted `plugin.json` is
inlined into the marketplace entry under `strict: false`.
- The plugin entry no longer sets `version`. That field pinned the
plugin and blocked updates.
- **Docs and evals follow the move**
- A new page, `docs/getting-started/ai-tooling.mdx`, covers both
delivery channels.
- The eval suite reads the skill from its new location. A spec now
guards that path resolution.
## Design Decisions
npm is the only channel that can version-match. Every marketplace
variant serves whatever sits on `main`.
Claude Code does not scan `node_modules`. A bundled skill stays
invisible until something points at it. Consumers therefore add a
pointer file. The cost is that the skill loses its frontmatter
advertisement, the roughly 100 tokens Claude Code normally loads into
the system prompt to make a skill self-triggering. An assistant will not
surface it unprompted.
The pointer names `SKILL.md` rather than the directory. `SKILL.md` opens
with a table linking to specific anchors under `reference/`, so it acts
as an index. Naming the directory instead leaves an agent to guess an
entry point among twelve files.
Claude Code reads `CLAUDE.md` and not `AGENTS.md`, so one file cannot
serve every assistant. `CLAUDE.md` imports `AGENTS.md` rather than
duplicating it, the interop pattern Claude Code documents. Editing
`AGENTS.md` updates both.
The marketplace plugin root is `packages/payload`. Claude Code's default
scan of `<root>/skills/*/SKILL.md` finds the skill without a `skills`
field. Inlining the manifest into the marketplace entry avoids
recreating a `plugin.json` inside a published npm package.
Dropping the plugin entry's `version` is deliberate. A version string
pins the plugin, and users receive updates only when that string
changes. A stale local cache holding an empty `skills/` directory is
what exposed this.
Both channels expose a plugin named `payload`. Enabling both puts two
copies at different versions in scope. The docs tell consumers to pick
one.
## Overall Flow
```mermaid
flowchart LR
subgraph before ["Before"]
A["GitHub main branch"] -->|"create-payload-app<br/>downloads tarball"| B["project/.claude/skills/payload"]
end
subgraph after ["After"]
C["packages/payload/skills/payload"] -->|"npm publish"| D["node_modules/payload/skills/payload"]
D -->|"AGENTS.md pointer,<br/>imported by CLAUDE.md"| E["AI assistant"]
end
```
The skill used to arrive over the network at project creation, decoupled
from the installed version. It now arrives with the dependency itself.
## References / Links
- [Create and distribute a plugin
marketplace](https://code.claude.com/docs/en/plugin-marketplaces):
marketplace entry fields, source types, and the default skill scan.
- [How Claude remembers your
project](https://code.claude.com/docs/en/memory): confirms Claude Code
reads `CLAUDE.md` and not `AGENTS.md`, and documents the import pattern.
- [npm
package.json](https://docs.npmjs.com/cli/v11/configuring-npm/package-json):
the `files` allowlist that controls tarball contents.
|
||
|
|
a06593d030 |
fix(tanstack-start): hmr with payload config changes in dev (#17602)
Editing payload.config.ts, or anything it imports, had no effect until a manual restart and logged nothing. Vite did re-evaluate the module, but getPayload returns its cached instance unless a DevReloadStrategy marks it stale, and endpoint routing reads payload.config off that stale instance. From there down this behaves exactly like Next.js: mark the instance stale, then let the next getPayload call swap in the new config, regenerate the import map and re-init the db. Only the signal and its delivery differ. Next.js gets that signal from defaultNextJsDevReloadStrategy, a websocket to /_next/webpack-hmr watching for serverComponentChanges. Nothing serves that path under Vite, so the connect failed and ws.onerror swallowed it, which is where the silence came from. It is also coarse: any server component change reloads Payload. The Vite plugin instead uses the hotUpdate plugin hook, in-process and socketless, and walks the changed module's importers so only edits that actually reach the config trigger a reload. Delivery differs because Next.js needs none. Its strategy is core's built-in default, so it already covers the getPayload calls made inside handleEndpoints and createPayloadRequest, which never see InitOptions. An adapter has no such fallback, so core gains a globalThis-backed strategy registry to give the plugin that same reach. <!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. ## Which branch should this PR target? - **`main`** — Payload v4 development. New features, enhancements, and v4 bug fixes go here. - **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are not accepted against v3.** If this PR adds a feature to v3, please retarget it at `main` (v4) instead. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216949983441202 |
||
|
|
c7de1c4e17 |
chore: bump next.js from 16.2.7 to 16.3.0 (#17633)
Bumps the Next.js version used in the monorepo from `16.2.7` to `16.3.0`, along with the pinned `@next/env` and `@next/eslint-plugin-next` packages. Stacked on #17632 — review that one first. ## TypeScript detection Next.js 16.3 changes the default of `experimental.useTypeScriptCli` to `true`. In CLI mode Next looks for a `typescript/bin/tsc` binary instead of the compiler API at `typescript/lib/typescript.js`: ```js const typescriptCliPackage = { file: 'typescript/bin/tsc', pkg: 'typescript', exportsRestrict: true } ``` This repo aliases `typescript` to `@typescript/typescript6`, which only ships a `tsc6` bin. `getTypeScriptPackageInfo` reads `packageJson.bin.tsc`, gets `undefined`, and Next reports the package as missing — then tries to run `pnpm add --save-dev typescript` at the workspace root, which fails: ``` It looks like you're trying to use TypeScript but do not have the required package(s) installed. [ERR_PNPM_ADDING_TO_ROOT] Running this command will add the dependency to the workspace root... ``` Both `next.config.mjs` and `test/next.config.mjs` now set `useTypeScriptCli: false` so Next keeps using the compiler API, which the alias does provide. ## Scope Templates stay on `16.2.7` and are bumped separately, to keep this diff reviewable. ## Testing - `@next/env` resolves at `16.3.0` from both `packages/payload` and `test`; `@next/eslint-plugin-next` resolves at `16.3.0` from `packages/next`. |
||
|
|
e710608ca9 |
fix: connect to the correct Next.js dev HMR endpoint per version (#17632)
### What? Payload's config hot reload in development stopped working on Next.js 16.3. Adding a collection or a field had no effect until the dev server was restarted. Next.js 16.3 renamed the dev HMR WebSocket endpoint: - Old: `/_next/webpack-hmr` — [`router-server.ts#L859-L860` on `v16.2.7`](https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/lib/router-server.ts#L859-L860) - New: `/_next/hmr` — [`router-server.ts#L946-L947` on `v16.3.0`](https://github.com/vercel/next.js/blob/v16.3.0/packages/next/src/server/lib/router-server.ts#L946-L947) The client moved with it, in [`client/dev/hot-reloader/app/web-socket.ts`](https://github.com/vercel/next.js/blob/v16.3.0/packages/next/src/client/dev/hot-reloader/app/web-socket.ts). There is no alias in either direction: `/_next/webpack-hmr` does not appear anywhere in the 16.3.0 build, and `/_next/hmr` does not appear anywhere in 16.2.7. ### Why? Payload hardcoded `/_next/webpack-hmr`. On 16.3 the upgrade request no longer matched, so the socket never opened. The failure was invisible because the `onerror` handler and the surrounding `connect` call both swallow errors, so `onReload` never fired, `cached.reload` stayed `false`, and `reload()` never ran. No type regeneration, no import map regeneration, and no client config cache clear. Just an incorrect endpoint. [Related Vercel PR ](https://github.com/vercel/next.js/pull/91415)which went into canary several months ago ### How? Read the installed Next.js version and connect to the path that version serves: `/_next/hmr` at 16.3 and above, `/_next/webpack-hmr` below it. One socket, no configuration, correct across supported Next.js versions. Version detection reads `next/package.json` resolved from `process.cwd()`, reusing the existing `resolveFrom` and `compareVersions` helpers. `resolveFrom` goes through `Module._resolveFilename`, so Next's `exports` map cannot block it. Since resolution starts at the app root, an app on 16.3 is detected as 16.3 regardless of what this repo pins for its own tests. Two deliberate calls in that decision: - Pre-release identifiers are ignored, so `16.3.0-canary.12` maps to `/_next/hmr`. Strict semver would rank it below `16.3.0` and pick the legacy path. - An unreadable version uses `/_next/hmr`. Payload being unable to resolve Next.js usually means it is not running under Next.js, where this strategy does not apply. Trying the new path first and falling back when it fails is **not** viable, which is worth recording for future reference. Next.js answers only on the path its own version serves, and leaves any other upgrade request open and unanswered so that a custom WebSocket server can claim it. Probing a real 16.2.7 dev server: ``` /_next/hmr => no response after 8s /_next/webpack-hmr => open /_next/not-a-real-path => no response after 8s ``` A wrong path never errors and never closes, so a fallback triggered by failure would hang forever on Next 16.2 and below and break HMR for every older version. `PAYLOAD_HMR_URL_OVERRIDE` keeps working and is still used verbatim, skipping version detection. The strategy also moved out of `packages/payload/src/index.ts` into `packages/payload/src/utilities/nextJsDevReloadStrategy.ts` so it can be tested directly. ### Testing Unit tests cover path selection per version, pre-release handling, the unknown-version default, message handling, cleanup, and the override. Written first and confirmed failing against the old behaviour before the fix. Verified end-to-end against a running dev server on 16.2.7 to confirm no regression on the older endpoint: editing a collection regenerated the import map and the new field appeared in the admin UI without a restart. The 16.3 path is covered by the endpoint rename in Next's source plus unit tests, not by a live reload — booting this repo's dev server on 16.3.0 is blocked by an unrelated problem in Next 16.3's rewritten TypeScript verification, which reports `typescript` as missing and tries to install it at the workspace root. That is a separate piece of work, and this fix is version-independent, so the repo's Next pin stays at 16.2.7 here. |
||
|
|
2cdaa1f017 |
refactor!: remove deprecated auth result strategy (#17597)
Removes the deprecated top-level `strategy` property from `/me` and `/refresh-token` results across REST, GraphQL, SDK types, refresh hooks, and the UI auth context. The supported `user._strategy` replacement remains available and is covered by real REST and GraphQL response tests. ## Breaking Changes Consumers reading the top-level `strategy` property from `/me`, `/refresh-token`, their GraphQL equivalents, or the SDK `MeResult` and `RefreshResult` types must migrate to the authenticated user's `_strategy` property. Old: ```ts result.strategy ``` New: ```ts result.user._strategy ``` --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> |
||
|
|
b5b6a2b403 |
feat(ui)!: refresh auth sessions from user activity (#17481)
Refreshes authenticated admin sessions from recent mouse, keyboard,
focus, and route activity without sending a request for every event.
Activity within the tracking window is remembered until the refresh
checkpoint, while the final reminder window still requires the user to
explicitly remain signed in.
## Breaking Changes
Consumers reading `tokenExpirationMs` from `useAuth()` must use
`authSession.expiresAt` instead.
```diff
- const { tokenExpirationMs } = useAuth()
+ const { authSession } = useAuth()
+ const tokenExpirationMs = authSession?.expiresAt
```
## Before and after
Before:
Admin route changes invoked the refresh scheduler. For a normal-length
token, a route change only sent a refresh request once the token had
less than two minutes remaining. Mouse movement, keyboard input, and
window focus did not count as session activity.
After:
Mouse movement, keyboard input, window focus, and pathname or
search-parameter changes count as activity while the tracking window is
open. Activity recorded before the refresh window is remembered until
the refresh checkpoint; activity inside the refresh window triggers a
refresh. Once the final reminder window opens, background activity no
longer refreshes the token and the user must explicitly choose to remain
signed in.
## Token windows
For a token issued with a 5 minute lifespan (5 is arbitrary for example
sake):
- **5:00–4:00 remaining · Waiting** — Activity does not queue a refresh
yet.
- **4:00–2:00 remaining · Tracking** — Qualifying activity is recorded.
If activity occurs, the token refreshes when the refresh window opens
with two minutes remaining.
- **2:00–1:00 remaining · Refresh window** — Qualifying activity
triggers a token refresh.
- **1:00–0:00 remaining · Reminder** — Automatic activity refresh is
closed. The user must explicitly choose to remain signed in.
- **0:00 remaining · Expired** — The session logs out if it was not
refreshed.
A successful refresh starts the lifecycle again using the new token
expiration. Without qualifying activity during the tracking or refresh
windows, the token is not refreshed in the background. For shorter
tokens, any window that would begin before issuance opens immediately.
The reminder window shrinks to half the token lifespan for tokens under
two minutes.
### Debugger helper component
Exposes the typed `authSession` lifecycle through `useAuth()` and
exports `AuthSessionDebug` for inspecting expiration, refresh, and
activity timing in development:
```ts
admin: {
components: {
providers: ['@payloadcms/ui#AuthSessionDebug'],
},
}
```
Coordinates refresh and logout across tabs through both
`BroadcastChannel` and the storage fallback. Stale request results are
discarded so a delayed refresh cannot restore a session after explicit
logout or provider-side revocation.
Adds a real auth-session test suite with opaque HTTP-only tokens,
server-side rotation and revocation, and context-wide virtual time. Its
Playwright coverage exercises activity refresh, provider revocation,
cross-tab synchronization, inactivity expiration, and logout races
without request interception.
### Video reference
The video shows how the auth session debugger works. I paused the video
between windows so it was not 5m long.
https://github.com/user-attachments/assets/6c4f1168-e4e8-4cd9-9b64-9e58178a2f79
|
||
|
|
4736d2514f |
perf!: remove DeepRequired from sanitized collection types (#17541)
This PR removes `DeepRequired` from `SanitizedCollectionConfig` and its auth type. It uses shallow `Pick`, `Omit`, and `Required` types so only properties populated during sanitization are required. ## Why `DeepRequired` recursively processes large field, hook, admin, and conditional types, adding unnecessary TypeScript checker work. It was also inaccurate. `access.readVersions`, `auth.cookies.domain`, `auth.useAPIKey`, and `auth.depth` could be undefined but were typed as required. Meanwhile, `auth.forgotPassword`, `auth.verify`, and normalized username-login options were typed as optional despite always being populated. ## Breaking change Code using `SanitizedCollectionConfig` may now need to handle `undefined` for properties Payload does not default. ```ts const domain = collection.auth.cookies.domain if (domain) useDomain(domain) if (collection.access.readVersions) await collection.access.readVersions(args) const useAPIKey = collection.auth.useAPIKey ?? false ``` |
||
|
|
555045d132 |
perf!: 37% faster config sanitization by making it synchronous (#17580)
## What? This PR makes config sanitization synchronous: - `sanitizeConfig`, `sanitizeField`, and `sanitizeFields` now return their result directly instead of a promise. - Collections and globals are sanitized synchronously. - Lexical editor config and server features are sanitized synchronously. - Rich-text fields are sanitized on the spot, instead of queueing up promises to await at the end. ## Why? Sanitizing the config is pure CPU work - it never touches a database, the filesystem, or the network. So marking it `async` gave us nothing in return, and it made the code harder to follow: every caller had to `await`, and rich-text fields needed a list of pending promises just to finish the job later. Returning values directly removes all of that, and the lower-level sanitizer and editor-config APIs become easier to use because you get a result immediately. Being synchronous also sends a clearer signal to anyone writing a rich-text adapter or a Lexical server feature: setting up a feature should be cheap. When these callbacks are allowed to return promises, it invites slow I/O into code that runs on every cold start. Dropping the promise overhead also speeds up cold starts. ## Performance Lambda cold-start measurements used a realistic 20-collection project with deeply nested fields and complex Lexical content. Cold starts were averaged within each deployment, and the final average gives all three deployments equal weight. | Deployment | Main (`4.0.0-canary.19`) | This PR (`4.0.0-internal.d1cc1d3`) | Change | | ---------- | --------------------------------: | --------------------------------------: | -----: | | 1 | 621.85 ms | 411.63 ms | -33.8% | | 2 | 859.27 ms | 424.16 ms | -50.6% | | 3 | 2,248.65 ms | 1,501.03 ms | -33.2% | | **Average** | **1,243.25 ms** | **778.94 ms** | **-37.3%** | Deployment 1 and 2 used the same config. Deployment 3 had additional lexical fields and blocks added. We also measured the change in a synthetic config-sanitization benchmark. The benchmark uses 925 collections and 15,153 field definitions: | | Before | After | Change | | ---- | -----: | ----: | -----: | | Mean | 75.29 ms | 69.42 ms | -7.8% | The synthetic benchmark scales up the fields test suite by duplicating its fields and was run on an M4 Max. The deployment benchmark instead uses a realistic large config on a slower Lambda CPU, where the improvement was more noticeable. ## Breaking change Most projects are unaffected. You still use `buildConfig(...)` and `lexicalEditor(...)` exactly as before, and `buildConfig` itself is still `async`. You are affected if you: - Call the lower-level sanitizer APIs yourself. - Ship a custom rich-text adapter that returns a promise. - Ship a Lexical server feature callback that returns a promise. If you call a sanitizer directly, drop the `await`: ```diff - const sanitizedConfig = await sanitizeConfig(config) + const sanitizedConfig = sanitizeConfig(config) ``` Custom rich-text adapters and Lexical server feature callbacks now have to return their value synchronously. If you were doing slow work or I/O there, move it either into an async plugin that runs before sanitization, or into a runtime hook. <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> |
||
|
|
21f70856a8 |
fix: preserve parent localization through tabs (#17578)
This fixes two related localization bugs in `traverseFields`. First, entering a non-localized tab always reset `parentIsLocalized` to `false`. Fields after the tab no longer knew that an earlier parent was localized. During Copy to Locale, this could reuse nested array or block row IDs and cause a database unique-constraint error. Second, traversal checked `tab.localized` directly. A tab with `localized: true` inside an already-localized parent was incorrectly treated as another locale layer. This could make traversal interpret field names as locale names and skip nested fields. Tabs now preserve inherited localization and only create a new locale layer when `fieldShouldBeLocalized` says they should. This matches localization handling behavior we have in other parts of Payload, like our hooks. |
||
|
|
6d26f905d7 |
fix: respect locale when counting global versions (#17549)
Passes `req.locale` to `payload.db.countGlobalVersions` in the
`countGlobalVersions` operation so queries on localized fields resolve
against the correct locale.
Previously the operation omitted `locale`, so a `where` clause targeting
a localized field (e.g. `version.text`) was matched against the wrong
locale's data, returning incorrect counts.
Before:
```ts
const result = await payload.db.countGlobalVersions({
global: global.slug,
req,
where: fullWhere,
})
```
After:
```ts
const result = await payload.db.countGlobalVersions({
global: global.slug,
locale: req?.locale || undefined,
req,
where: fullWhere,
})
```
|
||
|
|
257d681b4b |
fix: replace image-size with image-dimensions dependency for upload dimensions (#17077)
## What Removes the direct runtime dependency on the archived `image-size` package. Upload dimensions now come from `sharp` when it's configured, then from `image-dimensions` for the formats it supports, with a small set of hand-written parsers as the last-resort fallback. ## Why `image-size` is archived and stuck at `2.0.2` with unpatched DoS advisories (CVE-2025-71330, CVE-2025-71319) reachable via crafted uploads. No upstream fix is coming. `sharp` stays optional. The first version of this PR replaced `image-size` with a single hand-rolled probe covering every format. On review, maintaining a full custom image-format parser indefinitely was flagged as more burden than it's worth, so this now delegates to [`image-dimensions`](https://github.com/sindresorhus/image-dimensions) (zero dependencies, actively maintained, MIT) for PNG, JPEG, GIF, WebP, AVIF and HEIC/HEIF — including the ISO-BMFF box-walking logic where both CVEs originated. Its box reader already rejects a zero-size box, independently closing the hole those CVEs exploited. Only BMP, ICO/CUR, SVG and TIFF have no maintained equivalent, so those stay hand-written here. ## How - `getImageSize` prefers `sharp.metadata()` when `sharp` is configured. - `probeImageSize` tries `image-dimensions` first, then falls back to bounded parsers for BMP, ICO/CUR, SVG and TIFF — each advances by a fixed amount or is bounded by an explicit guard, so malformed input can't loop. - Drops the TIFF temp-file workaround (parsed from buffer now). - Removes `image-size` from `package.json` / the lockfile; adds `image-dimensions`. --------- Co-authored-by: Paul Popus <paul@payloadcms.com> |
||
|
|
b292343a89 |
feat: framework agnostic auth server functions (#17525)
Moves `login`, `logout`, and `refresh` server functions into `payload` core behind `payload/auth`, where they share the same auth operations and cookie utilities as the rest of Payload. The Next.js and TanStack Start adapters are now thin wrappers around one implementation instead of maintaining separate copies. This standardizes cookie and request handling across frameworks and prevents the adapters from drifting independently. Projects relying on the previous adapter-specific cookie behavior may be affected by the following fixes: - `cookies.sameSite` boolean values now match Payload core: `true` emits `Strict` and `false` omits the attribute. The framework adapters previously emitted `Lax` for either value. The default `'Lax'` behavior and explicit string values are unchanged. - `logout` expires cookies using the same `domain`, `path`, and `secure` attributes they were created with, allowing scoped cookies to clear correctly. - `login` failing to set the auth cookie when `removeTokenFromResponses` is enabled. Previously, the token was removed before the cookie could be written. Other related changes: - Tests: the `test/server-functions` suite has been merged into the `test/auth` suite for better visibility. It's also been reworked into a cross-framework setup. - Docs: moves the server function docs from the Local API section into Authentication and documents their TanStack Start equivalents. TL;DR: - Standardizes all auth server functions into payload core - The Next.js and TanStack adapters are now thin wrappers around these core utils - Fixes a few found issues within the server functions - The `test/server-functions` suite has been absorbed into `test/auth` |
||
|
|
b7a76c13b1 |
fix!: apply the documented auth.depth default of 0 (#17540)
`auth.depth` has been documented as defaulting to `0` since it was introduced, but that default was never applied. `addDefaultsToAuthConfig` has a line for every other auth default except `depth`, so it stayed `undefined`, the auth strategies passed that straight into `findByID`, and `afterRead` fell back to `defaultDepth`. The result is that `req.user` has been populated on every authenticated request rather than not at all: two levels deep before #17510, one level after. The fix is the one missing line, `auth.depth = auth.depth ?? 0`. Labelled breaking because relationship and upload fields on `req.user` become IDs, in access control, hooks and `/api/users/me`. Access control that reads something like `user.tenant.owner.id` now gets an ID and denies quietly, so the failure is silent rather than an error. Anyone who wants the old behaviour can set `auth.depth` on the collection, which is what the prop is for. Added a test asserting the sanitized value on an auth collection that omits `depth`; without the fix it fails with `expected undefined to be +0`. The full int matrix, unit and type tests are green. I also checked the rest of the repo for code that would need updating alongside this: `test/auth/config.ts` is the only place that sets `auth.depth`, no template or example has a relationship or upload field on an auth collection (their `roles` fields are all `select`s, which depth does not touch), and the multi-tenant and ecommerce plugins already normalize both the ID and the document shape. Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
76a95043ea |
perf!: reduce default query depth from 2 to 1 (#17510)
Reduces Payload's default query depth from `2` to `1`, improving query performance out of the box for every application without requiring configuration changes. Queries now avoid second-level relationship population—and its associated database, transformation, and serialization work—unless explicitly requested. Although a default of `0` would produce the fastest possible queries, `depth: 1` strikes a better balance between performance and DX. `2` is unnecessarily expensive as a default, while the tradeoff between `0` and `1` is less clear. Ultimately, `1` gives us the best of both worlds: substantially less relationship population by default without making the default response shape too limited. Related discussion: https://github.com/payloadcms/payload/discussions/15351 ## Breaking Changes Applications that rely on implicit second-level relationship population are affected. Queries that omit `depth` now populate one relationship level instead of two. Request the additional depth where needed: ```diff const posts = await payload.find({ collection: 'posts', + depth: 2, }) ``` To preserve the previous behavior application-wide: ```diff export default buildConfig({ + defaultDepth: 2, }) ``` --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216910993520508 |
||
|
|
34e5b464f7 |
fix: pass id to delete access control on soft delete in updateByID (#17453)
### What?
Fixes a missing `id` in the `access.delete` control callback when a
document is soft-deleted (trashed) via the `updateByID` operation.
### Why?
When a collection has `trash: true`, soft-deleting a document goes
through the `updateByID` operation. The `access.update` callback
correctly receives `{ id, data, req }`, but the `access.delete` callback
(called right below it for the trash check) only received `{ data, req
}` — `id` was missing. This makes it impossible for a `delete` access
control function to reliably identify which document is being trashed,
forcing developers to rely on `data.deletedAt` alone, which is poor DX
and was already flagged as confusing in the linked issue.
### How?
One-line fix in
`packages/payload/src/collections/operations/updateByID.ts` — `id` was
already destructured and in scope at that point in the function, so it
just needed to be passed through:
```diff
- const deleteAccessResult = await executeAccess({ data, req }, collectionConfig.access.delete)
+ const deleteAccessResult = await executeAccess({ id, data, req }, collectionConfig.access.delete)
```
Fixes #17452
|
||
|
|
9dd83ab1fb |
feat!: turn job concurrency and parent logging always on (#17505)
This follow-up PR finishes the remaining v4 jobs queue breaking changes on top of `feat/job-queue-deprecations`. Concurrency controls and parent task logging are now always enabled. ## Default job behavior The jobs collection always includes the indexed `concurrencyKey` field and concurrency rules are always enforced when a task or workflow configures them. Nested task logs always include their parent task information to allow for observability. The `jobs.enableConcurrencyControl` and `jobs.addParentToTaskLog` configuration properties have been removed. ## Type cleanup `TaskType` has been renamed to `TaskSlug`, and `WorkflowTypes` has been renamed to `WorkflowSlug`. The deprecated `RunningJob` and `RunningJobSimple` types have been removed. `Job` is now the supported job document type. Task log input is required and falls back to an empty object when a task has no input. ## Migration notes Existing projects that use jobs and sql should generate a database migration. The default jobs schema now includes the indexed `concurrencyKey` field and parent task log fields, and task log input is required. The removed configuration properties should be deleted from existing Payload configs. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216905504637355 |
||
|
|
1ee8407c2e |
fix!: type DefaultValue supporting promises to reflect being awaited internally (#17522)
Updates the type of `DefaultValue` to also accept a Promise, reflects
the actual code being awaited.
## Breaking changes
You now need to await the defaultValue functions to cover for the return
value potentially being a promise:
```diff
import type { DefaultValue } from 'payload'
- const resolveDefault = (fn: DefaultValue, args: Args) => {
- const value = fn(args)
- return value.toString()
- }
+ const resolveDefault = async (fn: DefaultValue, args: Args) => {
+ const value = await fn(args)
+ return value.toString()
+ }
```
|
||
|
|
8c1769f039 |
feat: support async filterOptions on select fields (#17521)
## Summary `filterOptions` on `relationship`, `upload`, and `blocks` fields has always supported returning a `Promise`, and it's correctly `await`ed everywhere it's called. `select` fields (and `queryPresets.filterConstraints`, which is aliased to the same type) were the odd one out — the type only allowed a sync `Option[]` return, and the one place it's invoked (`validations.ts`) never awaited it. If you defined an async `filterOptions` on a `select` field anyway (nothing stopped you at runtime), the validator would call `.some()` on a `Promise` object and throw `TypeError: filteredOptions.some is not a function`, and the admin UI would assign a raw `Promise` to `selectFilterOptions` instead of the resolved array. This PR brings `select.filterOptions` to parity with the other fields so `await` is the uniform calling convention everywhere. |
||
|
|
02e3762940 | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
779e387458 |
fix: restore version fails validation on relationship filterOptions fields (#17446)
Restoring a collection version through the REST API could report valid relationship selections as invalid when the field defines `filterOptions`. 1. Version restore validates data using the default locale, requiring an isolated `locale` and `fallbackLocale`. 2. The derived request inherited native `Request` getters backed by private fields. 3. Accessing properties such as `headers`, `url`, or `method` used the derived object as the receiver and threw. Relationship validation caught the error and reported the selection as invalid. The fix is to isolate only `locale` and `fallbackLocale` with `isolateObjectProperty`, preserving access to the original request’s native properties. Adds a REST regression test covering version restore with a `filterOptions` relationship field. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216597988811194 --------- Co-authored-by: Jake Fletcher <jacobsfletch@gmail.com> |
||
|
|
0721c6f86b | fix: prevent client-side authorship spoofing and re-attribute on duplicate | ||
|
|
e65de7a555 | chore: simplify comments | ||
|
|
3adde613da | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
51fd3bee91 |
feat!: replace BaseJob with a single Job type, enable job stats and meta field (#17504)
This PR removes the deprecated `BaseJob` type and makes `Job` the single type for queued jobs. `Job` now uses the generated `payload-jobs` collection type when it is available. When generated job types are not available, it uses a fallback defined in `UntypedPayloadTypes`. ## What changed ### One job type The public `BaseJob` type has been removed. `Job` now automatically uses the generated `payload-jobs` collection type when it exists and the default job document from `UntypedPayloadTypes['collections']` when it does not. ### Simpler workflow types Workflow type generics are now simpler. They now accept either a workflow slug or an input object. They no longer use `false` as a fallback value. This reduces the data types accepted by this generic from 3 (false / object / slug string) to 2 (object / slug string). ### Default job metadata The `payload-jobs-stats` global and the job `meta` field are now always added whenever jobs are enabled. They are no longer opt-in. This requires a database migration. ## Why Previously, core code had to choose between `Job` and `BaseJob` depending on whether generated types existed. This made job types harder to understand and caused different parts of the codebase to use different fallback logic. Now there is one `Job` type with one automatic fallback. Core code can use the same collection access pattern that users use: ```ts TypedCollection['payload-jobs'] ``` ## Breaking changes `BaseJob` is no longer exported and should be replaced with `Job`. Job and workflow generics now use `object` as their default instead of `false`. Projects with jobs enabled will always include the `payload-jobs-stats` global and the job `meta` field. Existing projects may need a database migration for these managed fields. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216905504637347 |
||
|
|
e60ebcd676 |
feat!: prevent stale jobs queue jobs (#17220)
This PR lets Payload recover Jobs Queue jobs after a worker crashes. It replaces the permanent `processing` flag with a renewable `processingUntil` lease and builds on [#17441](https://github.com/payloadcms/payload/pull/17441), which makes the initial job claim safe when multiple workers poll the same queue. ## Why Previously, a worker set `processing: true` when it claimed a job. If that process crashed, nothing reset the flag, so Payload treated the job as running forever. For example, a schedule runs every 10 minutes but does not queue a new job while the previous one is processing. If its worker crashes, the old job stays at `processing: true`, cannot be picked up again, and can block every future scheduled run. ## How it works A job is claimable when `processingUntil` is missing or expired. Claiming it atomically writes: - `processingUntil`: how long Payload should consider the worker active - `processingToken`: which worker attempt currently owns the job While the job runs, a heartbeat renews `processingUntil` every third of the lease duration. A healthy job can therefore run longer than one lease; this is not a task timeout. Worker-owned updates - including heartbeats, task logs, completion, and handled errors - must still match the worker's token and have enough lease time remaining. If an update no longer matches, Payload throws an internal `JobRunAbortedError` and stops accepting results from that worker. This prevents a timed-out worker from overwriting a newer attempt. When a worker disappears, its heartbeat stops. After the lease expires, the next normal `payload.jobs.run()` or `runByID()` call can claim the job directly with a new token and lease. No separate cleanup step is required. A crash intentionally does not increment `totalTried` or consume the handler's retry configuration. Completed task logs are kept, so recovery continues from the remaining tasks. ## Configuration The default lease is 20 minutes and the default safety buffer is 30 seconds: ```ts jobs: { processingLease: { duration: 20 * 60 * 1000, safetyBuffer: 30 * 1000, }, } ``` `duration` controls how long a job remains owned without a successful heartbeat, and will delay recovery. 20 minutes was chosen as the default, because recovery usually is rare and not time-sensitive. `safetyBuffer` is the minimum time that must remain before a worker starts a job update. It gives db adapters that read and write separately time to finish before the lease expires. It should be longer than a normal database update and shorter than the lease duration. ## Why the token and safety buffer are both needed [#17441](https://github.com/payloadcms/payload/pull/17441) protects claim time: only one worker can change a pending job into a claimed job. This PR must also protect later writes. After a lease expires, Worker B can recover a job while Worker A is still finishing old work. `processingUntil` alone only says that some worker has an active lease. The stable `processingToken` identifies whether the update belongs to Worker A or Worker B. Some db adapters first find a matching job and then write it. Without a safety window, this race is possible: 1. Worker A verifies its token just before its lease expires. 2. The lease expires before A's job update write finishes. 3. Worker B claims the job with a new token. 4. A's delayed write could overwrite B's state. The safety buffer prevents A from starting that update near lease expiry. This is a cross-db-adapter safety margin, not an atomic database guarantee. It can be increased for slower remote adapters, if you expect an update to take longer than 30 seconds. MongoDB applies the ownership query and update atomically with `findOneAndUpdate()` or `updateMany()`, so it does not rely on the buffer for correctness. The buffer can be set to `0` for a db adapter like mongodb with the same guarantee. ## Breaking change The `processing` field on `payload-jobs` is replaced by the nullable, indexed `processingUntil` date. Custom collection overrides and direct database queries that use `processing` must be updated. Existing SQL databases need a migration that replaces this column. Migration files will be added in a separate PR; MongoDB does not require a schema migration. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216353649387086 |
||
|
|
0776da590d |
feat: generate static slugs with unique fallback and optional source (#17458)
Makes the native `slug` field static: it's set once on create instead of continuously tracking its source field. A `<singular>-<N>` fallback guarantees the slug is present the moment the doc is created — including on autosave drafts. This is what live preview needs. The preview loads a document by its slug on mount — but an autosave collection creates a draft on navigation, before the user has typed a title. Without a slug at that point, the preview has nothing to query and the page ultimately 404s. The fallback gives that initial draft a stable, immediately discoverable slug the page can query. This also cuts iframe churn in dynamic live preview URLs. The preview URL is built from the slug, so every slug change reloads the iframe `src`. A static slug only changes when you explicitly generate it, so creating an autosave document no longer thrashes the iframe. The slug is only ever auto-filled while empty or when you explicitly regenerate it: - An empty slug is derived from its source field - With no source, it falls back to `<singular>-<N>`, where `N` is the first available integer, e.g. `posts-1` - Every value is normalized through the field's `slugify`, e.g. `Hello World` → `hello-world` **This replaces the previous generate-while-you-type approach during initial autosave doc creation.** For context: one goal of #17215 was to remove the extra injected checkbox field of the old `slugField()` util, which acted as the auto-generation gate. Without that flag, inferring "follow the title until the user edits the slug" has to be done statelessly — inherently flaky, since the server can't reliably tell a genuine edit from an override. A static slug removes the guesswork without reintroducing that field: it's computed once, never recomputed. This works across localization. A localized slug is unique per locale — you can reuse the same value across a document's own locales, but not across documents within one locale. Every locale is populated on create, so switching locales never lands on a blank slug, and generation is locale-aware when the source is localized. ### Optional Source **The `useAsSlug` field property is now optional.** It previously threw `InvalidConfiguration` at sanitize time when missing, because a slug had no way to populate itself without a source field to derive from. That constraint no longer holds. Every generation path now ends in a guaranteed-unique fallback. So a slug field is fully functional with no source: the user types an explicit value (slugified and uniqueness-checked) or the field auto-increments a collision-free fallback. `useAsSlug` is now purely a convenience for deriving a human-readable slug from a sibling field (e.g. `title`). ```ts { name: 'slug', type: 'slug', useAsSlug: 'title' // NOW OPTIONAL } ``` ### Final Result https://github.com/user-attachments/assets/d313c4af-f9e3-49c1-91bb-3e43532f62c1 |
||
|
|
7ae0cda45a |
feat!: support multiple workers on the same queue (#17441)
This PR prevents two workers on the same queue from claiming and running the same job. Closes https://github.com/payloadcms/payload/issues/16043 and https://github.com/payloadcms/payload/discussions/17129 ## The problem Two workers can look for work at the same time and find the same available job. The database adapter must ensure that only one worker returns that job as successfully claimed. ## The solution Each worker writes a unique `processingToken` when it claims jobs. MongoDB and Drizzle use different database operations, so they use the token differently. ### MongoDB: find, update, find MongoDB uses three steps because `updateMany` does not return the updated documents: 1. Find candidate jobs. 2. Update candidates that are still available, setting `processing: true` and this worker's token. 3. Find candidates with this worker's token. The final find now tells us which jobs **this worker's update** claimed. `processing: true` could not identify them because every worker writes the same value. ### PostgreSQL and SQLite: find, update and return Drizzle first finds candidates, then runs one conditional `UPDATE ... RETURNING` statement. The update only changes jobs that are still `processing: false` and directly returns the IDs it changed. If two workers found the same job, only one update can change it from `processing: false` to `true`. The other update does not return that job, so only the winner runs it. For this specific race, Drizzle does not need the `processingToken` to identify the winner because the update already returns the winning IDs. The token is mainly needed by MongoDB's final find and is stored consistently by every adapter. `FOR UPDATE SKIP LOCKED` is not needed to prevent duplicate claims. It could reduce waiting under heavy contention, but would add PostgreSQL-specific code. Payload clears `processingToken` when a job stops processing. ## What this does not fix This PR does not add leases or crash recovery. A crashed worker can still leave `processing: true` behind; the follow-up PR will address that. ## How this helps crash recovery later The token also gives Payload a clear way to identify which worker currently owns a job. In the follow-up crash-recovery PR, a replacement worker can claim an abandoned job with a new token. Updates from the old worker can then be rejected because its token no longer matches. This PR does not add that recovery behavior yet, but `processingToken` gives it a safe ownership check to build on. => efficient MongoDB queries are not the only reason we're adding this new `processingToken` field. ## Breaking change for custom adapters This adds the nullable `processingToken` field to `payload-jobs`, so existing SQL databases need a migration that adds the corresponding column. That migration will be added in a separate PR. MongoDB does not require a data migration. `updateJobs` is now required when calling `createDatabaseAdapter`. The old fallback could not guarantee an atomic claim because it selected jobs before updating them by ID. Custom adapters must ensure that a claim only updates jobs that are still not processing. When a `processingToken` is provided, it must return only the jobs claimed by that runner. ## Testing Added a regression test that simulates two workers polling the same queue and verifies that only one can claim the job. It passes on MongoDB, SQLite, and PostgreSQL. It consistently fails before this PR. |
||
|
|
e4ebd90f52 |
fix(ui): refresh join tables on update (#17366)
## What Uses existing `useDocumentEvent` provider to sync join tables when related documents change. ## Why When a document contains multiple join fields that reference the same related collection, updates made through one field (create/delete) can leave the other field stale until a full page reload. This causes inconsistent UI state and makes it look like changes did not persist. Syncing through shared document events ensures all related tables reflect the same data immediately. ## How Subscribes join/relationship tables to the latest document event. |
||
|
|
27d74a2909 | Merge branch 'main' of github.com:payloadcms/payload into feat/authorship | ||
|
|
d6c7e374fd |
fix: allow multiple autosave drafts with empty slugs (#17440)
Slug fields are unique, but a new autosave draft starts out without a slug. Empty slugs were being saved as `null`, and Mongo's unique index treats two `null`s as a duplicate — so creating a second draft failed with a "value must be unique" error, which also left the create view blank. Empty slugs are now left unset instead of `null`. The unique index is sparse, so it skips documents that have no slug yet, letting multiple drafts coexist. Adds a test that creates two empty-slug drafts and confirms they no longer collide. |
||
|
|
2c61454553 |
feat: support registered image size options (#17434)
## Description Adds a module-augmentable image-size options registry so integrations can replace Payload’s default Sharp options with provider-specific configuration while preserving Sharp as the default. ## Changes - Split shared image-size fields from Sharp-specific resize options. - Add `RegisteredImageSizeOptions` as a module augmentation point for image-processing providers. - Fall back to Sharp options when no provider is registered. - Add isolated type tests for default Sharp behavior and registered provider behavior. ## Test Plan - `pnpm test:types` — 6 files, 194 tests, and 274 assertions passed. |