Skip to content

feat(agent-bff): proxy the ai query on post /agent/v1/ai/query - #1845

Open
Tonours wants to merge 15 commits into
mainfrom
feat/prd-1033-ai-query-proxy
Open

feat(agent-bff): proxy the ai query on post /agent/v1/ai/query#1845
Tonours wants to merge 15 commits into
mainfrom
feat/prd-1033-ai-query-proxy

Conversation

@Tonours

@Tonours Tonours commented Aug 24, 2026

Copy link
Copy Markdown
Member

fixes PRD-1033

What

POST /agent/v1/ai/query relays the body unchanged to /api/ai-proxy/ai-query, authenticated with the OAuth session the BFF already holds. Second half of PRD-944; GET /agent/v1/context shipped in #1838.

Why

After the OAuth switch the Zendesk app has no SaaS token left — it sends its bff_access to api.forestadmin.com, which rejects a JWT it never issued. The provider keys live on the SaaS, so this one route is a relay rather than a contract.

How

  • Outgoing headers are a closed allow-list built server-side: Authorization (session saasAccessToken via ensureFreshServerAccess), Content-Type, forest-environment-id, forest-rendering-id. Nothing incoming is copied — the SaaS prefers a session cookie over the Bearer (make-fetch-token.ts:26-30), and x-mcp-oauth-tokens is injected as Authorization toward an MCP server.
  • Upstream path is fixed. Never a dynamic :route.
  • Session required. An API key gets 403 oauth_required: it produces no sid, so there is no saasAccessToken to forward. A third-party app authenticating via OAuth will work with no code change.
  • 1 MB body on this route only. A scoped parser mounted before the global one — @koa/bodyparser skips when ctx.request.body is set. Data routes keep 16 KB, /oauth/token keeps parsing. The global 16 KB breaks AI prompts past ~30 collections (measured).
  • No upstream body reaches the iframe unless it is JSON and below 500. A WAF answers HTML on 403/429; status is preserved, body replaced. Timeout → 504, bounded by BFF_AI_TIMEOUT_MS (default 120s) — nothing else on the chain bounds an AI generation today.
  • ai-name is a constant, not an env var: it selects the SaaS billing line and model, so a caller must not choose it.
  • The OpenAPI document publishes this path only when OAuth is configured, since that is the only case where the route is mounted. The forest-bff openapi export therefore omits it: it cannot know the runtime configuration.

Scope and safety

ALLOWED_HEADERS is deliberately unchanged. The forest-* headers the app sends today are exactly what the BFF now derives server-side; letting them through the preflight would mean proving we ignore them. The app drops them instead.

No body validation (the SaaS validates, and its schema is deliberately loose), no rate limiting, no session store change.

How to test

yarn workspace @forestadmin/agent-bff test   # 1250 tests
yarn workspace @forestadmin/agent-bff lint
yarn workspace @forestadmin/agent-bff build

Two tests exist only to catch a mount-order inversion, and both were verified to fail when the lines are swapped: "900 KB passes" (a 2 MB → 413 test does not catch it — both orders give 413), and the runCli test reaching the route without X-Forest-Timezone.

Known limitation

Not reachable from the Zendesk app until its own switch lands: fetchAIProxy still targets the SaaS and still sends forest-* headers, which this preflight rejects. Separate PR, same ticket.

Behind the BFF every user exits on one IP, so the SaaS per-IP anti-brute-force becomes a shared quota. Degrades cleanly (the 429 is relayed), worth watching in production.

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 24, 2026

Copy link
Copy Markdown

PRD-1033

Comment thread packages/agent-bff/src/ai/ai-proxy-client.ts Outdated
Comment thread packages/agent-bff/src/openapi/openapi-document.ts Outdated
@qltysh

qltysh Bot commented Aug 24, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (16)

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/auth/auth-mode.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/body-limit.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-dispatch.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/oauth/session-lifecycle.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/api-key/agent-token.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts95.5%73
Coverage rating: A Coverage rating: A
packages/agent-bff/src/oauth/oauth-error.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/config/env-config.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/openapi/unfolded-document.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/openapi/openapi-routes.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-local-errors.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/ai/ai-routes-middleware.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/ai/ai-proxy-client.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/ai/ai-proxy-timeout-error.ts100.0%
Total99.4%
🤖 Increase coverage with AI coding...
In the `feat/prd-1033-ai-query-proxy` branch, add test coverage for this new code:

- `packages/agent-bff/src/cli-core.ts` -- Line 73

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

@qltysh

qltysh Bot commented Aug 24, 2026

Copy link
Copy Markdown

6 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): resolveSessionAccessToken 4
qlty Structure Function with many returns (count = 5): createOpenApiRoutes 2

Comment thread packages/agent-bff/src/ai/ai-proxy-client.ts Outdated
Comment thread packages/agent-bff/README.md
const app = new Koa();

app.use(createErrorMiddleware({ logger: () => undefined }));
app.use(bodyParser({ jsonLimit: '1mb', enableTypes: ['json'] }));

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.

Importing AI_BODY_LIMIT here instead of the literal would make the fixture track the real limit.

.post('/agent/v1/ai/query')
.set('Authorization', `Bearer ${sessionToken()}`)
.set('Content-Type', 'application/json')
.set('Content-Length', String(2 * 1024 * 1024))

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.

This declares a length it never sends, so raw-body short-circuits on the header and the byte-counting path is never exercised; with the pass case at 900 KB, neither test sits at the boundary — a real ~1.05 MB body would cover both sides.

it('should target the fixed ai-query path with the ai-name of this deployment', async () => {
await makeClient().query(params());

expect(firstCall()[0]).toBe(`${FOREST_SERVER_URL}/api/ai-proxy/ai-query?ai-name=zendesk`);

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.

What about a POST /agent/v1/ai/query?ai-name=other case asserting the outgoing URL still carries zendesk? Both ctx.path comparisons would break silently on a refactor to ctx.url.

export const AI_QUERY_ROUTE = '/agent/v1/ai/query';

const JSON_CONTENT_TYPE = 'application/json';
const MAX_CAUSE_DEPTH = 3;

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.

No test walks a cause chain deeper than 3, so nothing proves the recursion actually stops.

'deployment runs without the OAuth configuration.',
});

export const AiQueryRequestSchema = z

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.

Nothing validates against this, yet the served contract now carries a BFF-authored shape nobody will keep in sync with routeArgsSchemaz.unknown(), as used for the 200, would match the passthrough decision.


describe('BFF_AI_TIMEOUT_MS', () => {
it('should default to 2 minutes when unset, since an AI generation is slow', () => {
expect(parseConfig({ ...VALID_ENV }).aiTimeoutMs).toBe(120_000);

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.

DEFAULT_AI_TIMEOUT_MS is already imported and used three cases below — using it here too keeps the default in one place.

});

it('should not mount the AI query route without the OAuth configuration, since it has no session', async () => {
const token = jsonwebtoken.sign(

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.

A sessionToken() helper already exists in the sibling describe — reusing it drops three copies of this block.

@Tonours
Tonours force-pushed the feat/prd-1033-ai-query-proxy branch from ad383e2 to 55fce99 Compare August 25, 2026 15:38
Comment thread packages/agent-bff/README.md
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