Skip to content

feat(agent): support get-one projection via the Forest-Projection header - #1813

Merged
PMerlet merged 5 commits into
mainfrom
feature/prd-892-move-get-one-projection-from-query-string-to-a-dedicated
Aug 10, 2026
Merged

feat(agent): support get-one projection via the Forest-Projection header#1813
PMerlet merged 5 commits into
mainfrom
feature/prd-892-move-get-one-projection-from-query-string-to-a-dedicated

Conversation

@PMerlet

@PMerlet PMerlet commented Aug 7, 2026

Copy link
Copy Markdown
Member

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 rule SizeRestrictions_QUERYSTRING, 2,048 bytes), which blocks the CORS preflight and breaks the details view.

fixes PRD-892

Changes

  • QueryStringParser.parseProjectionFromHeader: reads the projection from the Forest-Projection header. Contract:
    • format field1,field2,relation:subfield (e.g. id,title,author:id), whitespace around commas tolerated, repeated header supported;
    • empty/whitespace-only header treated as absent;
    • arbitrary relation depth through to-one chains (author:publisher:name), same as the query string already accepts;
    • invalid header → 400 with no silent fallback to query params, so frontend bugs surface instead of being masked.
  • Get-one route: the header takes precedence over the fields[...] query params; the query-string parsing is kept as fallback.
  • Capabilities: the agent announces canUseProjectionViaHeader: true (next to canUseProjectionOnGetOne, which is kept — the frontend does the prioritization).
  • CORS: no change needed — @koa/cors without an allowHeaders option echoes Access-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

  • Unit tests on the parser (missing/empty/whitespace header, repeated header as array, duplicates, trailing comma, subfield on a column, nested projections through to-one chains (covered up to 3 relation levels), unknown field).
  • Route tests: header honored, precedence over query params, no fallback on invalid header, fallback on empty header.
  • End-to-end integration tests on all 8 frameworks: real GET with the header returns only the projected fields; invalid header returns 400; CORS preflight allows the header.

🤖 Generated with Claude Code

Note

Add Forest-Projection header support for GET one record field selection

  • GetRoute.handleGet reads projection from the Forest-Projection HTTP header first, falling back to query string params when the header is absent or empty; primary keys are always appended via projection.withPks.
  • QueryStringParser.parseProjectionFromHeader is a new static method that parses and validates the header value, supporting single-level relation notation (e.g. relation:field) and throwing a ValidationError for invalid or deeply nested paths.
  • The /_internal/capabilities endpoint now includes canUseProjectionViaHeader: true in agentCapabilities to signal this feature to clients.

Changes since #1813 opened

  • Changed the ValidationError message prefix in QueryStringParser.parseProjectionFromHeader method from 'Invalid projection' to 'Invalid Forest-Projection header' when parsing the forest-projection header fails [1042ae8]
  • Removed validation that rejected nested projection fields in the QueryStringParser.parseProjectionFromHeader method within the @forestadmin/agent package [eeca14e]
  • Added test coverage for nested projection support across get-one routes, serialization, and query string parsing in the @forestadmin/agent package [eeca14e]
  • Extended test schema and projection expectations for nested relation projections [a9468b8]
  • Added Forest-Projection to the allowed CORS request headers in the agent-bff package and updated the corresponding test to verify the header is included in the ALLOWED_HEADERS constant [2405b96]

Macroscope summarized bceed7c.

⚠️ Upgrade note (self-hosted CORS)

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 the OPTIONS preflight with an explicit allow-list (e.g. app.use(cors({ allowedHeaders: [...] })) registered before the agent mount), Forest-Projection must be added to that allow-list. Otherwise, once the frontend uses the canUseProjectionViaHeader capability, 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.

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>
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

PRD-892

@qltysh

qltysh Bot commented Aug 7, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): fetchCapabilities 1

@qltysh

qltysh Bot commented Aug 7, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent/src/utils/query-string.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/get.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

),
agentCapabilities: {
canUseProjectionOnGetOne: true,
canUseProjectionViaHeader: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed with the second resolution you proposed: an ⚠️ Upgrade note (self-hosted CORS) section is now in the PR body, stating that hosts whose own CORS middleware answers the preflight with an explicit allow-list must add 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

PMerlet and others added 4 commits August 10, 2026 15:05
- 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>
@hercemer42

Copy link
Copy Markdown
Contributor

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 canUseNestedProjection capability. This PR collapses both — canUseProjectionViaHeader now implies arbitrary depth from day one.

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:

  1. Confirm the divergence is intended, as a spec decision rather than a review side-effect.
  2. Update PRD-892/PRD-893 accordingly — this matters beyond bookkeeping: agent-ruby implements from the ticket, and as written it would ship the depth-1 rejection. The same capability name would then mean different contracts on the two stacks, and the frontend couldn't trust canUseProjectionViaHeader on either.

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.

@PMerlet

PMerlet commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

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 canUseProjectionViaHeader now avoids a permanent second capability handshake.

The tickets already reflect it:

  • PRD-892 — the header contract is rewritten: arbitrary relation depth through to-one chains, with the explicit requirement that agent-ruby ships the same semantics with its first header release (so the capability never means different contracts across stacks), and the note that authorization stays root-collection-scoped exactly as on the long-standing query-string path.
  • PRD-893 — canceled, with the full rationale in its description (no agent ever shipped the rejection, so the dedicated canUseNestedProjection capability has no population to discriminate).

The stale "nested projection rejection" mention in this PR's Tests section is fixed as well; the Changes section already described the final contract.

@PMerlet
PMerlet merged commit 83d3ab8 into main Aug 10, 2026
60 of 61 checks passed
@PMerlet
PMerlet deleted the feature/prd-892-move-get-one-projection-from-query-string-to-a-dedicated branch August 10, 2026 14:36
forest-bot added a commit that referenced this pull request Aug 10, 2026
# @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))
forest-bot added a commit that referenced this pull request Aug 10, 2026
# @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))
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