Skip to content

feat(agent-bff): serve the agent schema contract on GET /agent/v1/context - #1838

Open
Tonours wants to merge 29 commits into
mainfrom
feat/prd-944-agent-context-proxy
Open

feat(agent-bff): serve the agent schema contract on GET /agent/v1/context#1838
Tonours wants to merge 29 commits into
mainfrom
feat/prd-944-agent-context-proxy

Conversation

@Tonours

@Tonours Tonours commented Aug 19, 2026

Copy link
Copy Markdown
Member

fixes PRD-944

What

New route GET /agent/v1/context, declared in the served OpenAPI document. Serves the allow-listed agent schema so a trusted UI can build its own requests.

{ "collections": [ { "name": "articles",
    "fields": [ { "field": "id", "type": "Uuid", "isPrimaryKey": true },
                { "field": "status", "type": "Enum", "enums": ["DRAFT","PUBLISHED"] },
                { "field": "thumbnail", "type": "String",
                  "validations": [{ "type": "is like", "value": "/^data:.*;base64,.*/" }] },
                { "field": "orders", "type": "String", "relationship": "HasMany",
                  "reference": "orders.customerId", "inverseOf": "customer" } ],
    "actions": [ { "id": "", "name": "Ban user", "type": "single", "fields": [ ] } ] } ],
  "meta": { "schemaRevision": 3, "environmentId": 42 } }

Both auth modes reach it — an OAuth session and a BFF API key get the same document, since the schema is not caller-scoped. No timezone required.

Why

The client needs the schema to know which collections exist, which fields are filterable text, which actions take records. Today it gets that from the Forest SaaS directly, which means a SaaS token in the browser. The BFF already caches this schema for its own data routes.

How

  • reads SchemaCache (raw schema), not ReadModel (a projection that drops most field metadata)
  • ReadModelStore.getSchemaSnapshot() returns {collections, readModel, revision} in one step, so collections and the read-model filtering them always belong to the same generation
  • mounted before createTimezoneMiddleware, which otherwise rejects any /agent request without a timezone

Field types are passed through verbatim. A Binary column is advertised as String — that is what it is on the wire, bytes travel as a data uri or hex — so validations is the only reliable way to tell an encoded field from plain text. relationship and polymorphicTargets tell a to-one from a to-many, which reference alone cannot.

Scope

No AI relay (POST /agent/v1/ai/query) — separate PR. No rendering/project/team identity. Per-field operators stay out (PRD-685).

Known limitation

meta.schemaRevision is a process-local counter, reset on restart: a redeploy shipping a schema change can reproduce a previous value. Matters only if a consumer uses it to invalidate a cache.

Test

yarn workspace @forestadmin/agent-bff test

1121 tests. One fixture pins every edge the wire produces: array and composite types, relations with and without inverseOf, polymorphic, the three action types, an endpoint-less action, enums: null, falsy defaults, malformed validations, dotted and spaced collection names.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown

PRD-944

@qltysh

qltysh Bot commented Aug 19, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (8)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/openapi-document.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/read-model/read-model.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/agent-route-helpers.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/schemas.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/read-model/read-model-store.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/context/context-routes-middleware.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/context/build-context.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.

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Solid work — the snapshot seam is the right call, and the fixture pins a genuinely wide set of wire edges. One blocking point on the relation metadata, plus a few smaller things.

Comment thread packages/agent-bff/src/context/build-context.ts
return Array.isArray(value) ? value : [];
}

type FieldWithWireEnums = ForestSchemaField & { enums?: string[] };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ForestSchemaField declares enum, the agent emits enums — fixing the type upstream in forestadmin-client would drop this intersection and the as unknown as cast in the fixture.

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.

Agreed on the diagnosis: ForestSchemaField declares enum, the agent emits enums (generator-fields.ts:71, generator-actions.ts:45), and unfolding.ts:80 already works around the same gap.

Not doing it in this PR though — fixing the declared type in forestadmin-client changes a published type for every consumer of that package, which is a wider blast radius than a BFF route deserves to carry. Worth its own ticket; I'll open one. Until then the intersection type is named FieldWithWireEnums so the reason is visible at the use site rather than hidden in a cast.

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.

Follow-up on the ticket I promised: PRD-993 — "forestadmin-client: ForestSchemaField declares enum but the wire sends enums", currently In Review. Once that lands, FieldWithWireEnums here and the as unknown as casts in the fixture both go away.

Leaving this PR as is unless you want the upstream fix folded in — say so and I will.

Comment thread packages/agent-bff/src/context/build-context.ts Outdated
Comment thread packages/agent-bff/src/context/context-routes-middleware.ts Outdated
Comment thread packages/agent-bff/src/openapi/schemas.ts
Comment thread packages/agent-bff/src/openapi/schemas.ts Outdated
Comment thread packages/agent-bff/src/openapi/openapi-document.ts

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Second pass. Relation metadata and the helper extraction look good.

One thing to flag up front: my previous comment on the declared status codes was wrong in both halves, and you acted on it — details in the thread on openapi-document.ts. Net effect is that 403 should come back and 413/415 should go.

On the API-key reversal: the reasoning holds — the document reads no principal, and it is meant to be crossed with /agent/v1/permissions. But the ticket still carries Given le mode API-key, when la route est appelée, then elle est refusée — mode OAuth exigé, and the widening is real (a rendering-scoped key can now enumerate the whole environment schema). Worth updating PRD-944 rather than leaving the AC contradicted by the PR body.

Still open from the first pass: the allow-list filter in build-context.ts is now provably dead — your own new read-model-store.test.ts:32-34 asserts the two can never diverge.

Comment thread packages/agent-bff/src/openapi/openapi-document.ts
Comment thread packages/agent-bff/src/openapi/openapi-document.ts Outdated
Comment thread packages/agent-bff/src/http/agent-route-helpers.ts
Comment thread packages/agent-bff/src/openapi/schemas.ts Outdated
Comment thread packages/agent-bff/src/openapi/schemas.ts Outdated
Comment thread packages/agent-bff/test/context/context-routes-middleware.test.ts Outdated

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Dropping the filter is right — buildContext has one non-test caller, isn't exported from the package index, getSchemaSnapshot has no await between the collections and the revision read, and the deleted test's invariant is covered directly at read-model-store.test.ts:30-37.

Ran locally on this head: 1123 tests / 72 suites pass, tsc --noEmit clean, prettier --check clean. The only red CI job is the ai-proxy LLM integration suite, which is a live OpenAI call unrelated to this PR.

One knock-on from the new conformance test, in the thread below: it also pins fields: [] for the fields-less fixture collection as contract-valid, which hardens the gap against jamais de fields: [].

Still outstanding at the ticket level, not the code: PRD-944 still carries Given le mode API-key, then elle est refusée — mode OAuth exigé, and there is now a green test asserting the opposite. Worth updating the AC rather than leaving it contradicted.

Comment on lines +82 to +86
if (field.reference) serialized.reference = field.reference;
if (field.inverseOf) serialized.inverseOf = field.inverseOf;

const polymorphicTargets = toArray(field.polymorphicReferencedModels);
if (polymorphicTargets.length > 0) serialized.polymorphicTargets = [...polymorphicTargets];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

collect-unfolding.ts:157 drops relations whose foreign collection is outside the allow-list so the document doesn't "promise a dead path" — here reference and polymorphicTargets go out unfiltered, so the contract can name a target absent from collections[].

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.

Real gap — the fixture already exhibits it: it references orders and teams while serving only users, User.address, My Coll and collectionWithoutFieldsNorActions. Documented rather than filtered, in c917ac3.

The two cases are not symmetric. collect-unfolding emits routes, so documenting orders while it is hidden promises an endpoint that 404s — dropping it is right there. /context emits a schema: reference: "orders.customerId" claims the field points at orders, which stays true whether or not this document serves it.

Filtering would also undo your round-1 point. Strip reference and polymorphicTargets and ownerPolymorphic goes back to {"field":"ownerPolymorphic","type":"String","relationship":"BelongsTo"} — a relation indistinguishable from a text column, which is exactly the bug those two fields were added to fix. I would rather not trade a blocking finding for a smaller one.

So the contract now states it: the description says a target named by reference or polymorphicTargets is not guaranteed to appear in collections[], and the consumer must cross-check. A test pins the behaviour so it reads as a decision rather than an oversight. If you would rather the document never name an unserved target, that is a contract change worth its own ticket — say so and I will open it.

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.

No answer on the contract-change question, so I am leaving the behaviour as it stands and treating the point as settled by documentation plus a test. The document states the target may be absent from collections[], and 1720cf4 strengthened the test with expect(served).not.toContain('teams') so both unserved targets the fixture names are proven absent.

If you would rather the document never name an unserved target, that is a contract change — reopen this and I will open the ticket.

Comment thread packages/agent-bff/test/context/build-context.test.ts Outdated
Comment thread packages/agent-bff/test/context/context-routes-middleware.test.ts Outdated

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All three of the last round's points landed. The round-trip assertion is exactly the fix, and documenting the unserved-target behaviour rather than filtering it is the better call — I'm not reopening that.

Ran locally on this head: 1124 tests / 72 suites pass, tsc --noEmit clean, prettier --check clean. The only red CI job is still the ai-proxy LLM integration suite, a live OpenAI call unrelated to this PR.

What's left is small. Nothing here blocks.

Still outstanding at the ticket level rather than in the code: PRD-944 carries Given le mode API-key, then elle est refusée — mode OAuth exigé, and there is a green test asserting the opposite. Worth updating the AC.

Comment thread packages/agent-bff/src/openapi/schemas.ts Outdated
Comment thread packages/agent-bff/test/context/build-context.test.ts Outdated
Comment thread packages/agent-bff/test/context/build-context.test.ts
Comment thread packages/agent-bff/test/context/fixtures.ts
Comment thread packages/agent-bff/test/context/build-context.test.ts Outdated
Comment thread packages/agent-bff/test/context/context-routes-middleware.test.ts Outdated

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good batch. Every point from the last round is closed, and the ticket amendment settles the API-key question I'd been carrying since pass 2 — I'm treating that as resolved.

Verified on this head: tsc --noEmit clean, prettier --check clean, CI green on agent-bff. (My local jest run showed failures, but all of them are the same supertest .send() resolution error in timezone-middleware.test.ts, which this PR never touches — the worktree has no installed node_modules. Environmental, ignore it.)

The recursive ContextFieldType renders correctly: components.schemas.ContextFieldType emits anyOf: [string, array→$ref self, object{fields:[…$ref self]}], and the spec-validity and unfolded tests pass over it.

Two things below. Neither blocks.

One last doc gap with no diff line to hang it on: packages/agent-bff/README.md never mentions /agent/v1/context, and its Timezone section still states None → 400 missing_timezone unconditionally, which is no longer true for this route.

Comment thread packages/agent-bff/src/openapi/schemas.ts Outdated
Comment thread packages/agent-bff/src/context/build-context.ts
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