Skip to content

[2.x] feat(api): validate-only (Precognition) requests - #5393

Open
novacuum wants to merge 9 commits into
flarum:2.xfrom
glowingblue:sh/api-precognition
Open

novacuum wants to merge 9 commits into
flarum:2.xfrom
glowingblue:sh/api-precognition

Conversation

@novacuum

@novacuum novacuum commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Adds Precognition-style validate-only requests to the JSON:API. A client sends the normal create or update request with Precognition: true, and the endpoint authorizes and validates it as usual but doesn't save it. Frontends can then validate a form field by field against the server's real rules, including unique checks and rules added by extensions, without copying them into JavaScript. The admin resource forms from #5324 will use this to check rules only the server can evaluate, once both are merged.

Request

  • POST /api/{type} or PATCH /api/{type}/{id} (or any other Create/Update endpoint of a resource, whatever its name) with Precognition: true (case-insensitive; a duplicated true, true is accepted).
  • Precognition-Validate-Only: name,email limits validation to those fields. If the list is empty or names no field of the resource, every field is validated, so a typo can't produce a false pass.

Response (always with Precognition: true and Vary: Precognition)

  • 204 with Precognition-Success: true if the request is valid.
  • 422 with the same body the normal request would return.
  • 401/403/404 wherever the normal request would be refused. The model lookup, endpoint authorization and field writability are checked before validation, so the answer doesn't reveal anything about records the actor can't reach.
  • 400 (precognition_not_supported) for a Precognition header on any other route (index, show, delete, /api/token, /api/settings, ...) or with any value other than true. The request isn't run, because running it normally would perform the write the client only wanted to validate.
  • 429 from the precognition limiter (below).

A 204 means the fields are valid and the endpoint is visible to the actor. It doesn't mean the write would succeed. The endpoint's action is never invoked, so before/after hooks, the resource's creating/updating/saving hooks, field setters and events don't run, and neither does any authorization inside them (e.g. the editCredentials check in UserResource's email setter). This also holds when an extension replaces the action.

Throttling

Precognitive requests skip the endpoints' write throttlers (flood control, email timeouts) and use their own limiter instead, flarum.api.precognition_throttler. It uses Laravel's RateLimiter and allows 60 requests a minute per user, or per IP for guests, so unique rules can't be used to enumerate usernames and emails. The guest key comes from the resolved ipAddress attribute, not from X-Forwarded-For and similar headers. Rebind the key to change the limit.

Implementation

HandlePrecognitiveRequests runs after CheckCsrfToken and before ThrottleApi. It is the only code that reads the header, and it marks supported requests with a request attribute. Support is decided by matching the complete route name ([prefix.]{type}.{endpoint}) against the resources' endpoints, so endpoint names containing dots work. Create and Update use a new HandlesPrecognition trait. For a precognitive request, it does the same preparation as Endpoint::handle(), then the endpoint's steps up to assertDataValid(). assertDataValid() keeps its signature, so endpoint subclasses that override it still load; it narrows the rules to the Validate-Only fields itself, through a precognitiveFields() hook. The internal API client excludes the middleware.

Testing

46 integration tests in framework/core/tests/integration/api/precognition/. They cover the response codes and headers, Validate-Only and its fallbacks, refusals before validation on users, discussions, posts, groups and access tokens, malformed headers and unsupported routes (400, nothing written), CSRF, and identical 422 bodies. They also cover the limiter (per IP for guests, not bypassable through forwarding headers, per user across IPs and tokens, and a counter expiring mid-request still expires), custom endpoints (dotted names, subclasses overriding assertDataValid()) and a side-effects test showing that valid precognitive writes on core resources change no rows and dispatch no write events. Run on SQLite and on MariaDB with a table prefix.

A client can send the normal create (POST /api/{type}) or update
(PATCH /api/{type}/{id}) request with `Precognition: true` to have it
authorized and validated without being run. `Precognition-Validate-Only:
name,email` limits validation to the listed fields; an empty list, or one
that names no field of the resource, validates every field.

Responses carry `Precognition: true` and `Vary: Precognition`:
- 204 with `Precognition-Success: true` when the request passes;
- 422 with the same body the normal request would get;
- 401/403/404 whenever the normal request would be refused, before
  validation runs.

A 204 means the fields are valid and the endpoint is visible to the actor,
not that the write would succeed: the endpoint's before hooks, the
resource's creating/updating/saving hooks and the field setters don't run,
and neither does any authorization that lives in them. Nothing is saved
and no events are dispatched.

Precognitive requests skip the endpoints' own throttlers and are limited
by `flarum.api.precognition_throttler` instead: 60 per minute per user,
or per IP address for guests. CSRF protection still applies.

A `Precognition` header on any other route (index, show, delete, non-
resource routes), or with any value other than `true`, fails with a 400
(`precognition_not_supported`) and the request is not run.
@novacuum
novacuum marked this pull request as ready for review November 2, 2026 19:49
@novacuum
novacuum requested a review from a team as a code owner November 2, 2026 19:49
@imorland

imorland commented Nov 2, 2026

Copy link
Copy Markdown
Member

Note

Automated review for @novacuum. Multiple AI models with Flarum-specific knowledge reviewed this pull request for me, and a further agent verified their findings; I haven't read it myself yet. I'll look at this pull request personally as soon as I can.

Warning

The security review found one or more security issues while reviewing this pull request. I have been informed of the details securely and will follow up.

Reviewed commit 2de028336014391c17e372f1802015fdb99fe777. It merges cleanly into 2.x. The head moved to c357fc6232ec4894135256dc649241e31ddded56 during review; I checked the intervening comment/docblock edits. All three reviewers completed. Tests were not run.

  • P2 — Preserve compatibility for validation overrides — framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php:40 (Claude). Adding the optional third parameter changes the inherited method signature. An extension subclass overriding the previous assertDataValid(Context $context, array $data): void now fails at class loading with an incompatible-declaration fatal, even for ordinary requests. Keep the existing signature and pass the narrowing list through a separate method or the context.

  • P2 — Match complete endpoint names when detecting support — framework/core/src/Api/Middleware/HandlePrecognitiveRequests.php:80 (Codex). A saving endpoint named profile.update generates a route such as users.profile.update, which is valid under the existing route registration code. Splitting off the final two segments treats profile as the resource type, so its precognitive requests return 400 despite targeting a Create/Update endpoint. Match complete registered route names or explicit route metadata.

  • P3 — Preserve the limiter expiry across an expired-key increment — framework/core/src/Api/PrecognitionThrottler.php:45 (Claude). If the existing key expires between add() and increment(), FileStore recreates the missing key with TTL 0 (forever); Redis increment likewise creates a missing key without an expiry. After the count reaches the limit, that user or IP receives 429 indefinitely until the cache entry is removed. Handle missing/expired counters explicitly and ensure the recreated count always has an expiry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants