Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"test": "jest"
},
"dependencies": {
"@forestadmin/agent-client": "1.10.1",
"@forestadmin/forestadmin-client": "1.40.4",
"@koa/bodyparser": "^6.1.0",
"jsonwebtoken": "^9.0.3",
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-bff/src/adapters/console-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Logger } from '../ports/logger-port';
import type { MetricTags, Metrics } from '../ports/metrics-port';

import createConsoleLogger from './console-logger';

export default function createConsoleMetrics(logger: Logger = createConsoleLogger()): Metrics {
return {
increment(name: string, tags?: MetricTags): void {
logger('Info', 'metric.increment', { metric: name, ...(tags ?? {}) });
},
gauge(name: string, value: number, tags?: MetricTags): void {
logger('Info', 'metric.gauge', { metric: name, value, ...(tags ?? {}) });
},
};
}
22 changes: 21 additions & 1 deletion packages/agent-bff/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import createAuthModeMiddleware from './auth/auth-mode-middleware';
import { parseConfig } from './config/env-config';
import createCorsMiddleware from './cors/cors-middleware';
import createPerKeyOriginMiddleware from './cors/per-key-origin';
import createDataRoutesMiddleware from './data/data-routes-middleware';
import { extractErrorMessage } from './errors';
import { unauthorized } from './http/bff-http-error';
import BFFHttpServer from './http/bff-http-server';
Expand All @@ -22,6 +23,7 @@ import ForestServerClient from './oauth/forest-server-client';
import createOAuthRoutes from './oauth/oauth-routes';
import createInMemorySessionStore from './oauth/session-store';
import createTokenCipher from './oauth/token-cipher';
import createReadModel from './read-model/create-read-model';
import createTimezoneMiddleware from './timezone/timezone-middleware';
import version from './version';

Expand Down Expand Up @@ -152,6 +154,24 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware |
return createApiKeyMiddleware({ authenticator, logger });
}

function buildDataRoutesMiddleware(config: BFFConfig, logger: Logger): Middleware {
const apiKeyConfig = resolveApiKeyConfig(config);

if (!apiKeyConfig || !config.agentUrl) {
logger('Warn', 'Data endpoints disabled: AGENT_URL or read-model configuration is missing');

return createAgentStubMiddleware();
}

const { store } = createReadModel({
forestServerUrl: apiKeyConfig.forestServerUrl,
envSecret: apiKeyConfig.forestEnvSecret,
logger,
});

return createDataRoutesMiddleware({ store, agentUrl: config.agentUrl, logger });
}

function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] {
const { forestAuthSecret, defaultTimezone } = config;

Expand All @@ -168,7 +188,7 @@ function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[]
apiKeyStep,
createPerKeyOriginMiddleware(),
createTimezoneMiddleware({ defaultTimezone }),
createAgentStubMiddleware(),
buildDataRoutesMiddleware(config, logger),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

];

return chain.map(agentScoped);
Expand Down
35 changes: 35 additions & 0 deletions packages/agent-bff/src/data/agent-data-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { HttpRequester } from '@forestadmin/agent-client';

export interface AgentDataClientOptions {
agentUrl: string;
token: string;
}

export interface AgentDataClient {
list(collection: string, query: Record<string, unknown>): Promise<Record<string, unknown>[]>;
countRaw(collection: string, query: Record<string, unknown>): Promise<unknown>;
}

/**
* Thin data client bound to a request's agent token. Uses the raw `HttpRequester` (not `Collection`)
* so the BFF controls the query params — notably the resolved `timezone` — and can read the count
* endpoint's raw payload, which `collection.count()` coerces through `Number()` and loses.
*/
export default function createAgentDataClient({
agentUrl,
token,
}: AgentDataClientOptions): AgentDataClient {
const requester = new HttpRequester(token, { url: agentUrl });

return {
list: (collection, query) =>
requester.query({ method: 'get', path: `/forest/${collection}`, query }),
countRaw: (collection, query) =>
requester.query({
method: 'get',
path: `/forest/${collection}/count`,
query,
skipDeserialization: true,
}),
};
}
119 changes: 119 additions & 0 deletions packages/agent-bff/src/data/agent-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { invalidRequest } from '../http/bff-local-errors';

export interface BffSortClause {
field: string;
direction?: 'asc' | 'desc';
}

export interface BffPage {
limit: number;
offset: number;
}

export interface ListRequestBody {
filter?: unknown;
projection?: string[];
sort?: BffSortClause[];
page?: BffPage;
}

export interface CountRequestBody {
filter?: unknown;
}

export type AgentQuery = Record<string, unknown> & { timezone: string };

interface ConditionTreeBranch {
aggregator: string;
conditions: unknown[];
}

interface ConditionTreeLeaf {
field: string;
}

function isBranch(node: unknown): node is ConditionTreeBranch {
return (
typeof node === 'object' &&
node !== null &&
Array.isArray((node as { conditions?: unknown }).conditions)
);
}

function isLeaf(node: unknown): node is ConditionTreeLeaf {
return (
typeof node === 'object' &&
node !== null &&
typeof (node as { field?: unknown }).field === 'string'
);
}

function collectFilterFields(filter: unknown, acc: string[]): void {
if (isBranch(filter)) {
filter.conditions.forEach(condition => collectFilterFields(condition, acc));
} else if (isLeaf(filter)) {
acc.push(filter.field);
}
}

function serializeSort(sort: BffSortClause[]): string {
return sort.map(({ field, direction }) => (direction === 'desc' ? `-${field}` : field)).join(',');
}

function serializePage(page: BffPage): Record<string, number> {
const { limit, offset } = page;

if (!Number.isInteger(limit) || limit <= 0) {
throw invalidRequest(`Invalid page.limit: ${limit}`);
}

if (!Number.isInteger(offset) || offset < 0) {
throw invalidRequest(`Invalid page.offset: ${offset}`);
}

// The agent paginates by page number/size, so an arbitrary offset that is not a whole multiple
// of the limit cannot be expressed. Reject it rather than silently return a shifted window.
if (offset % limit !== 0) {
throw invalidRequest(`page.offset (${offset}) must be a multiple of page.limit (${limit})`);
}

return { 'page[size]': limit, 'page[number]': offset / limit + 1 };
}

export function buildListAgentQuery(
collection: string,
timezone: string,
body: ListRequestBody,
): AgentQuery {
const query: AgentQuery = { timezone };

if (body.filter !== undefined) query.filters = JSON.stringify(body.filter);
if (body.projection?.length) query[`fields[${collection}]`] = body.projection.join(',');
if (body.sort?.length) query.sort = serializeSort(body.sort);
if (body.page) Object.assign(query, serializePage(body.page));

return query;
}

export function buildCountAgentQuery(timezone: string, body: CountRequestBody): AgentQuery {
const query: AgentQuery = { timezone };

if (body.filter !== undefined) query.filters = JSON.stringify(body.filter);

return query;
}

export function collectListFieldPaths(body: ListRequestBody): string[] {
const paths: string[] = [...(body.projection ?? [])];
collectFilterFields(body.filter, paths);
(body.sort ?? []).forEach(({ field }) => paths.push(field));

return paths;
}

export function collectCountFieldPaths(body: CountRequestBody): string[] {
const paths: string[] = [];
collectFilterFields(body.filter, paths);

return paths;
}
103 changes: 103 additions & 0 deletions packages/agent-bff/src/data/data-routes-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { AgentDataClient, AgentDataClientOptions } from './agent-data-client';
import type { CountRequestBody, ListRequestBody } from './agent-query';
import type { Logger } from '../ports/logger-port';
import type ReadModelStore from '../read-model/read-model-store';
import type { Middleware } from 'koa';

import defaultCreateAgentDataClient from './agent-data-client';
import {
buildCountAgentQuery,
buildListAgentQuery,
collectCountFieldPaths,
collectListFieldPaths,
} from './agent-query';
import { mapCountResponse, mapListResponse } from './response-mappers';
import { mapAgentError } from '../http/agent-error-mapper';
import { schemaUnavailable, unknownCollection } from '../http/bff-local-errors';
import SchemaUnavailableError from '../read-model/errors';
import assertNoRelationFieldPaths from '../validation/relation-field-guard';

const DATA_ROUTE = /^\/agent\/v1\/([^/]+)\/(list|count)$/;

export interface DataRoutesMiddlewareOptions {
store: ReadModelStore;
agentUrl: string;
logger: Logger;
createClient?: (options: AgentDataClientOptions) => AgentDataClient;
}

async function resolveReadModel(store: ReadModelStore) {
try {
return await store.getReadModel();
} catch (error) {
if (error instanceof SchemaUnavailableError) throw schemaUnavailable();
throw error;
}
}

export default function createDataRoutesMiddleware({
store,
agentUrl,
logger,
createClient = defaultCreateAgentDataClient,
}: DataRoutesMiddlewareOptions): Middleware {
return async function dataRoutesMiddleware(ctx, next) {
const match = DATA_ROUTE.exec(ctx.path);

if (!match || ctx.method !== 'POST') {
await next();

return;
}

const collection = decodeURIComponent(match[1]);
const operation = match[2] as 'list' | 'count';

const readModel = await resolveReadModel(store);

if (!readModel.isCollectionAllowed(collection)) {
// TODO(PRD-671): the read-model exposes a single allow-list (the exposed collections), so it
// cannot tell an unknown collection from a known-but-disallowed one. Every absent name maps
// to 404 here; `collection_not_allowed` (403) has no local trigger until a distinct exposure
// source exists. Escalated on the ticket — revisit when Anthony answers.
throw unknownCollection(`Unknown collection: ${collection}`);
}

const timezone = ctx.state.timezone as string;
const token = ctx.state.agentToken as string;
const client = createClient({ agentUrl, token });
const body = (ctx.request.body ?? {}) as ListRequestBody & CountRequestBody;

if (operation === 'list') {
assertNoRelationFieldPaths(collectListFieldPaths(body));

const query = buildListAgentQuery(collection, timezone, body);
let records: Record<string, unknown>[];

try {
records = await client.list(collection, query);
} catch (error) {
throw mapAgentError(error, { logger });
}

ctx.status = 200;
ctx.body = mapListResponse(collection, records, readModel.getPrimaryKeys(collection));

return;
}

assertNoRelationFieldPaths(collectCountFieldPaths(body));

const query = buildCountAgentQuery(timezone, body);
let raw: unknown;

try {
raw = await client.countRaw(collection, query);
} catch (error) {
throw mapAgentError(error, { logger });
}

ctx.status = 200;
ctx.body = mapCountResponse(raw);
};
}
37 changes: 37 additions & 0 deletions packages/agent-bff/src/data/pack-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { PrimaryKeyField } from '../read-model/read-model';

import { mappingError } from '../http/bff-local-errors';

const PACKED_ID_SEPARATOR = '|';

/**
* Rebuild the structured primary key of a record from its opaque packed id, mirroring the agent's
* `IdUtils.packId`/`unpackId` (`|`-joined values, `Number` columns cast back to numbers). Returns a
* `{ pkField: value }` map for `__forest.primaryKey`. Throws a mapping error rather than emitting a
* malformed key when the schema lacks key metadata or the packed id shape does not match it.
*/
export default function unpackPrimaryKey(
packedId: string,
primaryKeys: PrimaryKeyField[],
): Record<string, string | number> {
if (primaryKeys.length === 0) {
throw mappingError('Cannot build primary key: the collection exposes no key metadata');
}

const values = packedId.split(PACKED_ID_SEPARATOR);

if (values.length !== primaryKeys.length) {
throw mappingError(
`Cannot build primary key: expected ${primaryKeys.length} values, found ${values.length}`,
);
}

const result: Record<string, string | number> = {};

primaryKeys.forEach(({ name, type }, index) => {
const value = values[index];
result[name] = type === 'Number' ? Number(value) : value;
});
Comment on lines +31 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium data/pack-id.ts:31

unpackPrimaryKey casts Number-typed key segments with Number(value) but never validates the result. When the agent returns an invalid packed numeric id such as 'abc' for a Number primary key, this produces { id: NaN } in the returned primary key instead of throwing a mapping error, so callers receive a malformed key silently. Consider checking Number.isNaN(result[name]) for Number fields and throwing mappingError when the cast fails, mirroring the agent's IdUtils.unpackId validation.

Suggested change
primaryKeys.forEach(({ name, type }, index) => {
const value = values[index];
result[name] = type === 'Number' ? Number(value) : value;
});
primaryKeys.forEach(({ name, type }, index) => {
const value = values[index];
if (type === 'Number') {
const numericValue = Number(value);
if (Number.isNaN(numericValue)) {
throw mappingError(
`Cannot build primary key: invalid numeric value "${value}" for field "${name}"`,
);
}
result[name] = numericValue;
} else {
result[name] = value;
}
});
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/data/pack-id.ts around lines 31-34:

`unpackPrimaryKey` casts `Number`-typed key segments with `Number(value)` but never validates the result. When the agent returns an invalid packed numeric id such as `'abc'` for a `Number` primary key, this produces `{ id: NaN }` in the returned primary key instead of throwing a mapping error, so callers receive a malformed key silently. Consider checking `Number.isNaN(result[name])` for `Number` fields and throwing `mappingError` when the cast fails, mirroring the agent's `IdUtils.unpackId` validation.


return result;
}
Loading
Loading