feat(agent-bff): expose collection list and count with flat mapping#1742
feat(agent-bff): expose collection list and count with flat mapping#1742nbouliol wants to merge 18 commits into
Conversation
… and count Add a pure syntactic guard that rejects any top-level field path carrying the relation separator ":" with 422 relation_field_not_supported. Closes an agent authority gap where a relation-target field could be projected without the target collection browse/scope check. Guard + tests only; wiring into live list/count handlers belongs to the data-endpoints slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GqCtMftj4gwCo2AwicMNL
…-list Add a read-model layer that caches the agent schema (24h), derives collection/relation/action allow-list metadata with relation targets and an action-endpoint map, and caches per-collection capabilities coupled to the schema refresh. Adds a Metrics port emitting schema-cache and action-endpoint counters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…argets
A reference is `${foreignCollection}.${key}`; the collection name may itself
contain dots (mongoose nested collections like `User.address`). Split on the
last dot only instead of taking the first segment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… store Add tests for createReadModel wiring, ForestSchemaClient (mocked SchemaService), ReadModelStore.ageSeconds delegation, and the read-model defensive branches (no-reference relation, actions-less collection, unknown collection). read-model files at 100% coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… mutation Address review feedback: - guard `collection.fields ?? []` so a malformed collection cannot wedge the store for the 24h TTL - treat an empty schema as a failed fetch (cold -> SchemaUnavailableError, warm -> serve last good) instead of caching an all-denying schema - detect a schema refresh via an explicit revision counter instead of array reference identity - defensively copy action fields/hooks so a consumer cannot corrupt the shared cached schema - strengthen the console-metrics fallback test to assert routing, not presence Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cache Deep-freeze the action-endpoint map and relation targets at construction (after the field/hook clone that detaches them from the source schema), so the getters can return live references safely. Add a note on the getCapabilities TOCTOU to resolve when the data endpoints wire it in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add mapAgentError() translating agent-client AgentHttpError failures into the BFF error envelope, plus the complete registry of BFF-local error factories the Slice-3 endpoints emit. Fixes PRD-670 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- unmapped agent error name now returns 500 mapping_error instead of a silent status fallback - extract the unmapped-name warn out of fallbackTypeByStatus (single responsibility) - use a named constant for the validation_error type - make the name-to-type test use an explicit table instead of deriving expectations from the map under test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mapping - fall back to the outer AgentHttpError status when the JSON:API error object omits its own status, instead of hardcoding 400 - fall back to errors[0].message when detail is absent, before the generic default message Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dies When the agent body is not a JSON error object, use AgentHttpError.responseText as the message source before the generic default, so plain-text or empty bodies still surface the agent-supplied text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JSON:API expresses errors[0].status as a string; a string status reached BffHttpError and was rejected by isSerializableError, downgrading a mapped 4xx to a generic 500. Coerce it to a number before constructing the error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lities-and-allow-list' into feature/prd-671-expose-collection-list-and-count-with-flat-mapping
…op-level-list-and' into feature/prd-671-expose-collection-list-and-count-with-flat-mapping
…act' into feature/prd-671-expose-collection-list-and-count-with-flat-mapping
Add POST /v1/:collection/list and /count as flat REST over the agent:
- list returns flat records with __forest { collection, primaryKey } and
meta.countStatus "not_requested"; primaryKey is the opaque id unpacked to a
typed { field: value } map via read-model key metadata.
- count returns { count, countStatus }, deriving "deactivated" from the raw
agent payload (meta.count) instead of the lossy Number() helper; an active
empty collection is 0/available, a missing signal is a mapping_error.
- Wire the nested-relation guard on list projection/filter/sort and count
filter (422 relation_field_not_supported).
- Resolve the collection via the read-model allow-list. Absent name maps to
404 unknown_collection; 403 collection_not_allowed has no local trigger yet
(single allow-list) — see TODO and the ticket escalation.
- All agent calls go through the exported HttpRequester so the BFF controls
the query params (notably the resolved timezone) for both list and count.
- Add schema_unavailable (503) local error and expose ReadModel.getPrimaryKeys.
Fixes PRD-671
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 new issues
|
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (20) 🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
| if (agentError.name !== undefined) { | ||
| const type = AGENT_ERROR_TYPE_MAP[agentError.name]; | ||
|
|
||
| if (type === undefined) { | ||
| logger('Error', 'Unmapped agent error name', { name: agentError.name, status }); | ||
|
|
||
| return mappingError(`Unmapped agent error name: ${agentError.name}`); | ||
| } | ||
|
|
||
| return new BffHttpError(status, type, message, agentError.data); | ||
| } |
There was a problem hiding this comment.
🟡 Medium http/agent-error-mapper.ts:85
When a JSON:API error from the agent has a name not present in AGENT_ERROR_TYPE_MAP, mapJsonApiError returns a 500 mapping_error instead of preserving the agent's original status and type. For example, a custom agent error subclass named CustomNotFoundError that carries status: 404 gets remapped to a 500 internal BFF failure, turning a legitimate 4xx client error into a server error.
This happens in the branch where agentError.name !== undefined and AGENT_ERROR_TYPE_MAP[agentError.name] returns undefined. Consider falling back to fallbackTypeByStatus(status) (the same path used when name is absent) rather than calling mappingError(), so unmapped error names still preserve their original HTTP status and a sensible error type.
if (agentError.name !== undefined) {
- const type = AGENT_ERROR_TYPE_MAP[agentError.name];
-
- if (type === undefined) {
- logger('Error', 'Unmapped agent error name', { name: agentError.name, status });
-
- return mappingError(`Unmapped agent error name: ${agentError.name}`);
- }
-
- return new BffHttpError(status, type, message, agentError.data);
+ const type = AGENT_ERROR_TYPE_MAP[agentError.name] ?? fallbackTypeByStatus(status);
+
+ return new BffHttpError(status, type, message, agentError.data);Also found in 1 other location(s)
packages/agent-bff/src/read-model/action-endpoint-resolver.ts:39
resolve()swallowsSchemaUnavailableErrorfromgetReadModel()and returnsundefinedas if the action mapping were simply missing. On a cold schema cache while Forest is unavailable, callers cannot distinguish "schema unavailable" from "unknown action" and will mis-handle the request (typically turning a transient503condition into a false miss/404).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/http/agent-error-mapper.ts around lines 85-95:
When a JSON:API error from the agent has a `name` not present in `AGENT_ERROR_TYPE_MAP`, `mapJsonApiError` returns a 500 `mapping_error` instead of preserving the agent's original status and type. For example, a custom agent error subclass named `CustomNotFoundError` that carries `status: 404` gets remapped to a 500 internal BFF failure, turning a legitimate 4xx client error into a server error.
This happens in the branch where `agentError.name !== undefined` and `AGENT_ERROR_TYPE_MAP[agentError.name]` returns `undefined`. Consider falling back to `fallbackTypeByStatus(status)` (the same path used when `name` is absent) rather than calling `mappingError()`, so unmapped error names still preserve their original HTTP status and a sensible error type.
Also found in 1 other location(s):
- packages/agent-bff/src/read-model/action-endpoint-resolver.ts:39 -- `resolve()` swallows `SchemaUnavailableError` from `getReadModel()` and returns `undefined` as if the action mapping were simply missing. On a cold schema cache while Forest is unavailable, callers cannot distinguish "schema unavailable" from "unknown action" and will mis-handle the request (typically turning a transient `503` condition into a false miss/`404`).
| if (field.reference) { | ||
| // reference is `${foreignCollection}.${key}`; the collection name may itself contain dots | ||
| // (e.g. mongoose nested collections like `User.address`), so drop only the trailing key. | ||
| return { type, polymorphic: false, target: field.reference.split('.').slice(0, -1).join('.') }; |
There was a problem hiding this comment.
🟡 Medium read-model/read-model.ts:24
toRelationTarget returns an empty string as the target collection for relations whose field.reference is a bare collection name (e.g. 'orders') with no trailing .<key> segment. split('.').slice(0, -1).join('.') strips the only token, so getRelationTarget() records target: '' for a valid HasMany/BelongsToMany relation, and callers that rely on the resolved target mis-handle or reject the relation. The code assumes every reference is ${foreignCollection}.${key}, but schemas also use bare collection names. Consider falling back to the full field.reference when no . is present.
- return { type, polymorphic: false, target: field.reference.split('.').slice(0, -1).join('.') };
+ const target = field.reference.includes('.')
+ ? field.reference.split('.').slice(0, -1).join('.')
+ : field.reference;
+ return { type, polymorphic: false, target };🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/read-model/read-model.ts around line 24:
`toRelationTarget` returns an empty string as the target collection for relations whose `field.reference` is a bare collection name (e.g. `'orders'`) with no trailing `.<key>` segment. `split('.').slice(0, -1).join('.')` strips the only token, so `getRelationTarget()` records `target: ''` for a valid `HasMany`/`BelongsToMany` relation, and callers that rely on the resolved target mis-handle or reject the relation. The code assumes every `reference` is `${foreignCollection}.${key}`, but schemas also use bare collection names. Consider falling back to the full `field.reference` when no `.` is present.
| createPerKeyOriginMiddleware(), | ||
| createTimezoneMiddleware({ defaultTimezone }), | ||
| createAgentStubMiddleware(), | ||
| buildDataRoutesMiddleware(config, logger), |
There was a problem hiding this comment.
🟠 High src/cli-core.ts:191
In OAuth mode, requests to /agent/v1/:collection/list|count fail because buildDataRoutesMiddleware is mounted for every authenticated /agent request but only the API-key branch populates ctx.state.agentToken. When the OAuth path runs, createAuthModeMiddleware sets only ctx.state.principal, leaving ctx.state.agentToken undefined, so the data routes call the agent with Authorization: Bearer undefined. Either pass the OAuth bearer token into ctx.state.agentToken for the data routes or gate buildDataRoutesMiddleware so it only runs in API-key mode.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/cli-core.ts around line 191:
In OAuth mode, requests to `/agent/v1/:collection/list|count` fail because `buildDataRoutesMiddleware` is mounted for every authenticated `/agent` request but only the API-key branch populates `ctx.state.agentToken`. When the OAuth path runs, `createAuthModeMiddleware` sets only `ctx.state.principal`, leaving `ctx.state.agentToken` undefined, so the data routes call the agent with `Authorization: Bearer undefined`. Either pass the OAuth bearer token into `ctx.state.agentToken` for the data routes or gate `buildDataRoutesMiddleware` so it only runs in API-key mode.
- Split dataRoutesMiddleware into handleList/handleCount and a callAgent wrapper (removes the duplicated try/catch and the oversized function). - Drop the unused CountStatus export and the unread ConditionTreeBranch aggregator field. - Name the 'Number' column-type token in pack-id. - Document why count deactivation is derived payload-only (no schema/ capabilities count flag exists). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed review feedback in d885e3b:
build + lint + 499 tests green. |
- Trim the pack-id and mapCountResponse comments to one line each. - Rename RequestHandlerContext to RequestHandlerDeps. - Drop readModel from the shared deps; pass primaryKeys only to handleList (handleCount no longer receives an unused read-model). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Fail closed with 401 when no agent token is present (OAuth mode does not
mint one yet) instead of calling the agent with "Bearer undefined".
- Return 400 invalid_request on a malformed percent-encoded collection name
instead of a 500 from decodeURIComponent.
- Throw a mapping error when a Number primary-key segment is not numeric,
mirroring the agent's IdUtils.unpackId, instead of emitting { id: NaN }.
- Pass primaryKeys via a dedicated list deps type (handleList back to 3 params).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Macroscope review triaged in e86119b. Fixed (this PR's files):
Not changing (dependency-PR files, not owned here):
build + lint + 502 tests green. |
| timezone: ctx.state.timezone as string, | ||
| logger, | ||
| }; | ||
| const body = (ctx.request.body ?? {}) as ListRequestBody & CountRequestBody; |
There was a problem hiding this comment.
🟡 Medium data/data-routes-middleware.ts:128
A request with projection set to a string (e.g. { "projection": "id" }) or sort set to a string causes the list endpoint to throw a plain TypeError and return a 500 instead of a 400 invalid_request error. The body is cast to ListRequestBody & CountRequestBody without validating that projection and sort are arrays, so non-array values reach buildListAgentQuery() / collectListFieldPaths() and fail when calling array methods like .join/.forEach/.map on a string. Consider validating the shape of body before dispatching to handleList / handleCount so malformed input returns invalidRequest().
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/data/data-routes-middleware.ts around line 128:
A request with `projection` set to a string (e.g. `{ "projection": "id" }`) or `sort` set to a string causes the list endpoint to throw a plain `TypeError` and return a 500 instead of a 400 `invalid_request` error. The body is cast to `ListRequestBody & CountRequestBody` without validating that `projection` and `sort` are arrays, so non-array values reach `buildListAgentQuery()` / `collectListFieldPaths()` and fail when calling array methods like `.join`/`.forEach`/`.map` on a string. Consider validating the shape of `body` before dispatching to `handleList` / `handleCount` so malformed input returns `invalidRequest()`.

What
Slice 3 of the Forest BFF: expose
POST /v1/:collection/listand/countas flat REST over the agent.{ data: [{ ...directFields, id, __forest: { collection, primaryKey } }], meta: { countStatus: "not_requested" } }.primaryKeyis the opaque packed id unpacked into a typed{ field: value }map using the read-model's key metadata (composite ids supported; a composite id without key metadata is surfaced as a mapping error, never guessed).{ count, countStatus }. Deactivation is derived from the raw agent payload (meta.count === "deactivated"), not the lossyNumber()helper. Active empty collection →0/available; no usable signal →mapping_error(never a silent0).projection/filter/sortand countfilter→422 relation_field_not_supported.mapAgentErrorfor agent failures; local envelope for BFF-origin errors, incl. a new503 schema_unavailable).HttpRequester, so the BFF controls the query params — notably the resolvedtimezone(spec: never lean on agent-client'sEurope/Parisdefault).Stacking / dependencies
This work depends on two PRs still in review, whose branches are merged into this one. Until they land on
main, this PR's diff temporarily includes their code:PRD-669 (#1736) is already merged to
main; its content here is identical. Rebase ontomainonce #1732 and #1738 land, then this diff narrows to thedata/additions +read-model/cli-core/bff-local-errorschanges.Open item — 403 not satisfiable in Slice 3
The read-model exposes a single allow-list (the exposed collections), so it cannot distinguish an unknown collection from a known-but-disallowed one. Every absent name maps to
404 unknown_collection;collection_not_allowed(403) has no local trigger. Documented with aTODOindata-routes-middleware.tsand escalated on the ticket.Tests
Focused tests with a stubbed agent client + read-model: flat mapping +
__forest+meta.countStatus; packed/composite id roundtrip; count deactivated vs active-zero vs mapping-error fallback; guard 422 across all four surfaces; 404 resolution;503 schema_unavailable; agent failure via the error contract; timezone propagated to the agent query. Full package: build + lint + 499 tests green.Fixes PRD-671
🤖 Generated with Claude Code
Note
Expose collection list and count endpoints in agent-bff with flat mapping
POST /agent/v1/:collection/listandPOST /agent/v1/:collection/countroutes indata-routes-middleware.ts, proxying requests to the configured agent with validation and response shaping.ReadModelbuilt from the Forest schema that exposes allowed collections, relations, actions, and primary keys; cached viaSchemaCacheandCapabilitiesCachewith TTL and single-flight refresh.mapListResponseandmapCountResponseinresponse-mappers.tsto produce flat records with__forestmetadata (including structuredprimaryKey) and deactivation-aware count status.assertNoRelationFieldPathsguard that rejects nested relation field paths (containing:) with a 422 error across projection, filter, and sort inputs.cli-core.ts, falling back to a stub middleware with a warning when agent URL or API key config is missing.Macroscope summarized e86119b.