feat(agent): support get-one projection via the Forest-Projection header - #1813
Conversation
The get-one route now reads the projection from the Forest-Projection header (format: `field1,field2,relation:subfield`), taking precedence over the `fields[...]` query params. This lets the frontend send large projections without hitting query string size limits enforced by common WAF rules (e.g. AWS WAF SizeRestrictions_QUERYSTRING, 2KB). The agent announces a new `canUseProjectionViaHeader` capability so the frontend only sends the header to agents that understand it. The existing query-string parsing is kept as fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 new issue
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (2)
🛟 Help
|
| ), | ||
| agentCapabilities: { | ||
| canUseProjectionOnGetOne: true, | ||
| canUseProjectionViaHeader: true, |
There was a problem hiding this comment.
Claude Opus 5 (claude-opus-5[1m]): Must fix
Applies to: the CORS/deployment contract of this capability, not only this line — this is the line that triggers it.
Once the frontend switches on this flag, any agent mounted inside a host app whose own CORS middleware answers the OPTIONS preflight (app.use(cors({ allowedHeaders: [...] })) registered before the mount) will have the get-one request blocked by the browser: Forest-Projection is not in that allow-list, the preflight fails, and the record details view stops loading entirely. There is no fallback — per PRD-892 the frontend prioritizes the header whenever the capability is announced, so the request never leaves the browser.
The PR's "no change needed" holds only when the agent is the middleware that answers the preflight: @koa/cors at agent.ts:351 reflects Access-Control-Request-Headers only in that case, and the new integration test exercises only that case (no host-owned CORS layer on any of the 8 mounts).
This is a known class here, not a hypothetical: Forest-Context-Url is already read on every request (query-string.ts:167), so working customers' host allow-lists already had to be extended once. Unlike that header — whose absence degrades silently by design — this one is fatal.
PRD-892 makes this an explicit criterion ("verify Access-Control-Allow-Headers is actually controlled by the agent … otherwise the capability must only be announced when CORS is handled by the agent"). Either gate the announcement behind an option, or ship an upgrade note telling self-hosted users with a custom CORS allow-list to add Forest-Projection.
There was a problem hiding this comment.
Addressed with the second resolution you proposed: an Forest-Projection to it — to be propagated to the release notes and the self-hosted CORS documentation.
Why not the option: it would only be discovered after the breakage anyway, and the natural remedy for those hosts is extending the allow-list they already maintain (as they had to for Authorization and Forest-Context-Url), not learning a new agent option and redeploying.
A systemic mitigation is also being considered on the frontend side (retry the get-one via query string when the header request fails at the preflight), which would make this failure mode non-fatal regardless of host config.
| try { | ||
| const fields = header.split(',').map(field => field.trim()); | ||
|
|
||
| // Keep parity with the `fields[...]` query params, which cannot express |
There was a problem hiding this comment.
Claude Opus 5 (claude-opus-5[1m]): Should fix
The stated invariant is false: fields[...] can express two relation levels. parseProjection (line 63) builds `${field}:${subField}` from fields[<relation>] without inspecting subField, and FieldValidator.validate recurses through to-one chains — so fields[books]=author&fields[author]=publisher:name produces author:publisher:name and validates fine.
Consequence: the header is strictly less expressive than the query string it takes precedence over, so a caller with a working 2-level query-param projection gets 400 Invalid projection: nested projections are not supported (...) the moment it migrates — the one thing this comment says cannot happen. And the next person to touch this inherits a parity claim that doesn't hold.
The depth-1 cap itself is right (PRD-893 records it as a deliberate contract decision); only the justification needs rewording — e.g. that the get-one serializer has never emitted depth ≥ 2, with the general case deferred to canUseNestedProjection.
There was a problem hiding this comment.
Good catch — the parity claim was indeed false (parseProjection never inspects the sub-field, so fields[author]=publisher:name does produce a valid 2-level path). Fixed in 1042ae8: the comment now states the actual invariant — the frontend never sends projections deeper than one relation level on get-one, and deeper projections stay rejected until a dedicated capability announces support (tracked as a follow-up ticket).
There was a problem hiding this comment.
Update: plan changed in eeca14e — the frontend will soon send projections deeper than one relation level on get-one, so the depth cap is removed entirely rather than re-justified. Since the query string already accepts such paths (as you pointed out) and no agent has shipped the rejection yet, canUseProjectionViaHeader now implies arbitrary depth from day one, avoiding a second capability handshake later. Nested projections are covered end to end: parsing, intermediate pks at every level, and JSON:API serialization of nested included resources.
|
|
||
| return new Projection(...fields); | ||
| } catch (e) { | ||
| throw new ValidationError(`Invalid projection: ${e.message}`); |
There was a problem hiding this comment.
Claude Opus 5 (claude-opus-5[1m]): Preferential
A header-caused 400 is indistinguishable from a query-string-caused one: this prefix is byte-identical to parseProjection's (line 70), and debugLogError prints request.query and the body but never headers.
Consequence: a developer debugging the 400 sees a perfectly valid ?fields[books]=... printed next to Invalid projection: The 'foo' field was not found and goes auditing the query string. Invalid Forest-Projection header: ... costs nothing and makes the 400 self-diagnosing — which is the point of the ticket's no-silent-fallback rule.
There was a problem hiding this comment.
Fixed in 1042ae8: header parsing errors now use a dedicated Invalid Forest-Projection header: prefix (including the nested-projection rejection), so a header-caused 400 is self-diagnosing next to debug logs that only print the query string. Tests updated accordingly.
- Use a dedicated `Invalid Forest-Projection header:` error prefix so a header-caused 400 is distinguishable from a query-string one (debug logs print the query string but not the headers). - Fix the comment justifying the one-relation-level cap: the query string can technically express deeper paths, the real invariant is that the frontend never sends them on get-one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frontend will soon project relations of relations on get-one (e.g. `author:publisher:name`). The query string already accepts such paths, so the header now does too instead of rejecting them with a 400: shipping this before the first release keeps a single capability (canUseProjectionViaHeader implies arbitrary depth) instead of requiring a second capability handshake later. Covered end to end: parsing, intermediate pks added at every level by the route, and JSON:API serialization of nested included resources. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n header Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…list The BFF has no get-one route today, but its hardcoded CORS allow-list would reject the header's preflight the day it grows one; adding it now keeps every first-party CORS layer consistent with the agents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude Fable 5 (claude-fable-5): Spec divergence to validate eeca14e goes further than the review asked: it removes the depth cap entirely, while PRD-892 explicitly mandates rejecting nested projections (one relation level max) and PRD-893 plans lifting that later behind a separate The rationale (no agent has shipped the rejection yet, avoids a second capability handshake, frontend needs it soon) is coherent, but it's a contract change that currently lives only in a commit message and a review thread. Two asks:
Minor, same root: the PR description's "Changes"/"Tests" sections still describe the nested-projection rejection ("strict parity", "nested projection rejection") — worth a pass so the recorded contract matches what merges. |
|
Confirmed — the divergence is an intentional spec decision, not a review side-effect: the frontend will need nested get-one projections soon, and since no agent has released the depth-1 rejection yet, folding arbitrary depth into The tickets already reflect it:
The stale "nested projection rejection" mention in this PR's Tests section is fixed as well; the Changes section already described the final contract. |
# @forestadmin/agent-bff [1.12.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-bff@1.11.0...@forestadmin/agent-bff@1.12.0) (2026-08-10) ### Features * **agent:** support get-one projection via the Forest-Projection header ([#1813](#1813)) ([83d3ab8](83d3ab8))
# @forestadmin/agent [1.93.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.92.1...@forestadmin/agent@1.93.0) (2026-08-10) ### Features * **agent:** support get-one projection via the Forest-Projection header ([#1813](#1813)) ([83d3ab8](83d3ab8))

Motivation
The get-one projection currently travels in the query string (
fields[Collection]=...&fields[relation]=id). On large collections it exceeds query string size limits enforced by common WAF configurations (e.g. the AWS WAF managed ruleSizeRestrictions_QUERYSTRING, 2,048 bytes), which blocks the CORS preflight and breaks the details view.fixes PRD-892
Changes
QueryStringParser.parseProjectionFromHeader: reads the projection from theForest-Projectionheader. Contract:field1,field2,relation:subfield(e.g.id,title,author:id), whitespace around commas tolerated, repeated header supported;author:publisher:name), same as the query string already accepts;fields[...]query params; the query-string parsing is kept as fallback.canUseProjectionViaHeader: true(next tocanUseProjectionOnGetOne, which is kept — the frontend does the prioritization).@koa/corswithout anallowHeadersoption echoesAccess-Control-Request-Headers, so the preflight already allows the header. An integration test pins this across all 8 supported mount targets (standalone, express, koa, fastify v2/v3/v4, nestjs express/fastify).Tests
🤖 Generated with Claude Code
Note
Add
Forest-Projectionheader support for GET one record field selectionGetRoute.handleGetreads projection from theForest-ProjectionHTTP header first, falling back to query string params when the header is absent or empty; primary keys are always appended viaprojection.withPks.QueryStringParser.parseProjectionFromHeaderis a new static method that parses and validates the header value, supporting single-level relation notation (e.g.relation:field) and throwing aValidationErrorfor invalid or deeply nested paths./_internal/capabilitiesendpoint now includescanUseProjectionViaHeader: trueinagentCapabilitiesto signal this feature to clients.Changes since #1813 opened
ValidationErrormessage prefix inQueryStringParser.parseProjectionFromHeadermethod from 'Invalid projection' to 'Invalid Forest-Projection header' when parsing theforest-projectionheader fails [1042ae8]QueryStringParser.parseProjectionFromHeadermethod within the@forestadmin/agentpackage [eeca14e]@forestadmin/agentpackage [eeca14e]Forest-Projectionto the allowed CORS request headers in theagent-bffpackage and updated the corresponding test to verify the header is included in the ALLOWED_HEADERS constant [2405b96]Macroscope summarized bceed7c.
The agent's own CORS middleware reflects
Access-Control-Request-Headers, so no action is needed when the agent answers the preflight. However, if the agent is mounted inside a host app whose own CORS middleware answers theOPTIONSpreflight with an explicit allow-list (e.g.app.use(cors({ allowedHeaders: [...] }))registered before the agent mount),Forest-Projectionmust be added to that allow-list. Otherwise, once the frontend uses thecanUseProjectionViaHeadercapability, get-one requests will be blocked by the browser at the preflight and the record details view will not load. This must be called out in the release notes and the self-hosted CORS documentation.