From 5c5b3dcfeb3d5ee45f45205e6a04703af3544767 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Tue, 22 Sep 2026 11:33:06 +0200 Subject: [PATCH 1/7] feat(audit-trail): withhold project-level audit values from non-admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only a caller whose permission level is `admin` — exactly that level, not a privileged set — gets `previousValues` and `newValues` on the project-level timeline. Every other caller gets each row with both objects empty, so operation, author and timestamp still read. A record's own history is deliberately *not* gated: someone who can read the collection can already read the record, so its before/after values tell them nothing the record itself doesn't. The project-wide feed is a different exposure — every collection at once, with no record to know in advance — and that aggregate is what stays admin-only. Advertised as `restrictsProjectAuditValuesToAdmins` in the capabilities payload, so the front can tell an agent that enforces the rule from an older one that merely hides the values in its UI. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/src/audit-trail/admin-gate.ts | 24 +++++++++++++++++++ packages/agent/src/routes/capabilities.ts | 5 ++++ .../agent/test/routes/capabilities.test.ts | 4 ++++ 3 files changed, 33 insertions(+) create mode 100644 packages/agent/src/audit-trail/admin-gate.ts diff --git a/packages/agent/src/audit-trail/admin-gate.ts b/packages/agent/src/audit-trail/admin-gate.ts new file mode 100644 index 0000000000..859a0b9b96 --- /dev/null +++ b/packages/agent/src/audit-trail/admin-gate.ts @@ -0,0 +1,24 @@ +import type { AuditRecord } from './types'; +import type { Context } from 'koa'; + +// `admin` exactly, not a privileged set (PRD-1259). The level comes from the Forest-signed JWT that +// `context.state.user` holds, so it needs no round-trip and cannot be forged by the caller. +function isAdmin(context: Context): boolean { + return context.state.user?.permissionLevel === 'admin'; +} + +// Applies to the project-level timeline only. A record's own history is not gated: someone who can +// read the collection can already read the record, so its before/after values tell them nothing the +// record itself doesn't. The project-wide feed is a different exposure — every collection at once, +// no record to know in advance — and that aggregate is what stays admin-only. +// +// The row itself always stays: operation, author and timestamp are what makes the timeline readable, +// and are not the values a non-admin isn't entitled to. +export default function withholdValuesFromNonAdmin( + entries: AuditRecord[], + context: Context, +): AuditRecord[] { + if (isAdmin(context)) return entries; + + return entries.map(entry => ({ ...entry, previousValues: {}, newValues: {} })); +} diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts index 6c51f72e60..669362b55b 100644 --- a/packages/agent/src/routes/capabilities.ts +++ b/packages/agent/src/routes/capabilities.ts @@ -46,6 +46,11 @@ export default class Capabilities extends BaseRoute { // `x-forest-correlation-id` is emitted on every response regardless — the frontend must // gate the History tab on this flag rather than inferring the feature from that header. canUseAuditTrail: this.options.auditTrail !== null, + // Constant, not config-driven: it states that this agent version withholds the detail values + // of the *project-level* timeline from a non-admin caller itself, so the front can stop + // treating its own hidden expander as the guarantee there. A record's own history is not + // gated. An older agent simply omits the flag. + restrictsProjectAuditValuesToAdmins: true, checksRelationReadPermissions: !this.options.skipRelationReadPermissions, }, collections: diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts index bf3fc8f643..f6530d7e4e 100644 --- a/packages/agent/test/routes/capabilities.test.ts +++ b/packages/agent/test/routes/capabilities.test.ts @@ -81,6 +81,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsProjectAuditValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [], @@ -107,6 +108,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsProjectAuditValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [], @@ -131,6 +133,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsProjectAuditValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [], @@ -205,6 +208,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, + restrictsProjectAuditValuesToAdmins: true, checksRelationReadPermissions: true, }, collections: [ From a9baee6cd398b8c60506db8fe5db767bac1fd0f5 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Mon, 21 Sep 2026 17:29:41 +0200 Subject: [PATCH 2/7] feat(audit-trail): cross-collection timeline route for the project-level page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /forest/_audit-trail` serves every collection at once, newest first, so the project-level page can show before/after values rather than activity logs alone. Each row already carries its `collection` and `recordId`, so the front can name and link the record it belongs to. Permissions are resolved per collection: only what the caller can read is queried, and a collection the caller sees only through a record-level scope is left out entirely — a scope can't be evaluated across a whole timeline without fetching every record it mentions, and the row alone would reveal that a record the caller cannot read exists and was touched. Paging is by cursor, not offset: a project-wide feed grows at the head, so an offset shifts rows across pages. `meta.cursor` carries an inclusive `before` bound plus the `excludeIds` already served at it, ties order by descending id, and a timestamp with more rows than fit on one page accumulates its exclusions rather than looping. The filter parsing the record route owns moves to `audit-trail/query-params` so both routes speak one contract. `listTimeline` is optional on `AuditStore`: a custom store written before this simply doesn't get the route mounted. Inherits the admin gate on detail values. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/src/audit-trail/README.md | 47 ++- .../agent/src/audit-trail/query-params.ts | 130 +++++++++ packages/agent/src/audit-trail/sql-store.ts | 54 ++++ packages/agent/src/audit-trail/types.ts | 29 ++ .../src/routes/access/audit-trail-timeline.ts | 115 ++++++++ .../agent/src/routes/access/audit-trail.ts | 156 +--------- packages/agent/src/routes/index.ts | 5 + .../agent/test/audit-trail/in-memory-store.ts | 23 ++ .../agent/test/audit-trail/sql-store.test.ts | 129 ++++++++ .../access/audit-trail-timeline.test.ts | 275 ++++++++++++++++++ 10 files changed, 822 insertions(+), 141 deletions(-) create mode 100644 packages/agent/src/audit-trail/query-params.ts create mode 100644 packages/agent/src/routes/access/audit-trail-timeline.ts create mode 100644 packages/agent/test/routes/access/audit-trail-timeline.test.ts diff --git a/packages/agent/src/audit-trail/README.md b/packages/agent/src/audit-trail/README.md index e2ff2e19a0..d4f3bbc38c 100644 --- a/packages/agent/src/audit-trail/README.md +++ b/packages/agent/src/audit-trail/README.md @@ -182,7 +182,7 @@ refuses an over-cap bulk write. ## HTTP routes -When `auditTrail` is set, the agent exposes three routes (all behind Forest's auth, gated by +When `auditTrail` is set, the agent exposes four routes (all behind Forest's auth, gated by `assertCanRead` on the target collection). When the caller's role has a record-level scope on that collection, every route below — including the correlation lookups — additionally requires the target id to currently exist and match that scope. A scope can't be evaluated against a record that @@ -334,6 +334,51 @@ to `20`, capped at `100`. Out-of-bound or non-numeric values fall back to the de Ties on equal timestamps fall back to insertion order (the auto-increment `id`), so the order is deterministic and stable across pages whatever the direction and filters. +### `GET /forest/_audit-trail` — cross-collection timeline + +The project-level feed: every collection at once, newest first, for the page that shows what +happened across the project rather than to one record. Each row carries its own `collection` and +`recordId`, so the client can name and link the record it belongs to. + +```json +{ "data": [ /* rows */ ], "meta": { "cursor": { "before": "…", "excludeIds": [12, 11] } } } +``` + +**Detail values are admin-only here, and only here.** `previousValues` and `newValues` reach a caller +whose permission level is `admin` — exactly that level, not a privileged set. Every other caller gets +each row with both objects empty: what happened, by whom and when stays readable, the values do not. +The per-record routes above are deliberately *not* gated — someone who can read the collection can +already read the record, so its before/after values tell them nothing the record itself doesn't. What +stays admin-only is the aggregate: every collection at once, with no record to know in advance. The +capabilities payload advertises this as `restrictsProjectAuditValuesToAdmins`, so a client can tell an +agent that enforces the rule from an older one that merely hides the values in its UI. + +**Permissions.** Only the collections the caller can read are queried. A collection the caller sees +only through a **record-level scope is left out entirely**: a scope can't be evaluated across a whole +timeline without fetching every record it mentions, and the row alone would already reveal that a +record the caller cannot read exists and was touched. A caller scoped on every collection therefore +gets an empty timeline. + +**Filters** are the per-record route's, minus `fields` (a column name means nothing across +collections): `userIds`, `operations`, `startDate` / `endDate` / `timezone`, `search`, parsed exactly +the same way — including the 400 on an unrecognized `operation`. + +**Paging is by cursor, not offset.** A project-wide feed keeps growing at the head, so an offset +silently shifts rows across pages. `page[size]` still applies (default `20`, capped at `100`), and +`meta.cursor` carries the next page's position — `null` once the last row has been served: + +| query param | format | effect | +| ------------- | ------------------------------- | --------------------------------------------------- | +| `before` | ISO instant | **inclusive** upper bound on `timestamp` | +| `excludeIds` | comma-separated integers | row ids already served at that boundary | + +The bound is inclusive on purpose: rows sharing the boundary timestamp must not fall between two +pages, so they come back and `excludeIds` drops the ones already sent. Ties order by descending `id` +(insertion order), and a timestamp holding more rows than fit on one page accumulates its exclusions +across pages rather than looping. Feed the two values back verbatim; don't synthesize them. + +A custom `AuditStore` that doesn't implement `listTimeline` simply doesn't get this route mounted. + ### `GET /forest/_audit-trail/correlation/{correlationKey}` Returns `{ data: AuditRecord[] }` — the operation(s) recorded under one `correlationKey` for a diff --git a/packages/agent/src/audit-trail/query-params.ts b/packages/agent/src/audit-trail/query-params.ts new file mode 100644 index 0000000000..e9452a12f9 --- /dev/null +++ b/packages/agent/src/audit-trail/query-params.ts @@ -0,0 +1,130 @@ +import type { AuditOperation } from './types'; + +import { ValidationError } from '@forestadmin/datasource-toolkit'; +import { DateTime } from 'luxon'; + +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; +const DATE_TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/; +// ISO 8601 instant: carries its own timezone designator (`Z` or `±HH:mm` / `±HHMM`). +const ISO_INSTANT = /[Zz]$|[+-]\d{2}:?\d{2}$/; + +export const DEFAULT_PAGE_SIZE = 20; +export const MAX_PAGE_SIZE = 100; + +export const AUDIT_OPERATIONS: readonly AuditOperation[] = [ + 'create', + 'update', + 'delete', + 'action', + 'action_failed', +]; + +// Comma-separated integer ids; non-numeric tokens are dropped. Empty after parsing → no filter. +export function parseUserIds(raw?: string): number[] | undefined { + if (!raw) return undefined; + + const ids = raw + .split(',') + .map(token => token.trim()) + .filter(token => /^\d+$/.test(token)) + .map(token => Number.parseInt(token, 10)); + + return ids.length > 0 ? ids : undefined; +} + +// Comma-separated, from a closed set. An unrecognized value is rejected rather than dropped: a +// silently ignored filter returns unfiltered rows into a list the caller believes is filtered, +// which is worse than an error. +export function parseOperations(raw?: string): AuditOperation[] | undefined { + if (!raw) return undefined; + + const tokens = raw + .split(',') + .map(token => token.trim()) + .filter(token => token.length > 0); + + const unknown = tokens.find(token => !AUDIT_OPERATIONS.includes(token as AuditOperation)); + + if (unknown) { + throw new ValidationError( + `Invalid operation: "${unknown}" (expected one of ${AUDIT_OPERATIONS.join(', ')})`, + ); + } + + return tokens.length > 0 ? (tokens as AuditOperation[]) : undefined; +} + +export function parseFields(raw?: string): string[] | undefined { + if (!raw) return undefined; + + const fields = raw + .split(',') + .map(token => token.trim()) + .filter(token => token.length > 0); + + return fields.length > 0 ? fields : undefined; +} + +// Trimmed; empty after trimming is treated the same as absent. +export function parseSearch(raw?: string): string | undefined { + const trimmed = raw?.trim(); + + return trimmed || undefined; +} + +// 1-based `page[size]` (default 20, capped at 100). Invalid values fall back to the default rather +// than erroring. +export function parsePageSize(raw?: string): number { + const size = Number.parseInt(raw ?? '', 10); + + if (Number.isNaN(size) || size < 1) return DEFAULT_PAGE_SIZE; + + return Math.min(size, MAX_PAGE_SIZE); +} + +function toLocalInstant(raw: string, timezone: string, boundary: 'start' | 'end'): DateTime { + // An embedded offset already pins the instant — the request timezone and start/end boundary + // don't apply. + if (ISO_INSTANT.test(raw)) return DateTime.fromISO(raw, { setZone: true }); + + if (DATE_ONLY.test(raw)) { + const day = DateTime.fromISO(raw, { zone: timezone }); + + return boundary === 'end' ? day.endOf('day') : day.startOf('day'); + } + + const match = DATE_TIME.exec(raw); + + if (!match) return DateTime.invalid('unparsable'); + + const [, date, hours, minutes, seconds] = match; + const base = DateTime.fromISO(`${date}T${hours}:${minutes}`, { zone: timezone }); + + if (seconds !== undefined) return base.set({ second: Number(seconds), millisecond: 0 }); + + // Minutes-only: end snaps to :59.999 to stay inclusive; start stays at :00.000. + return boundary === 'end' ? base.set({ second: 59, millisecond: 999 }) : base; +} + +// Bare day (`YYYY-MM-DD`) or wall-clock datetime (`YYYY-MM-DD[T| ]HH:mm[:ss]`), interpreted as +// local time in the request timezone and returned as a UTC instant so the store can compare it +// to stored timestamps. +export function parseDateBoundary( + raw: string | undefined, + timezone: string, + boundary: 'start' | 'end', +): string | undefined { + if (!raw) return undefined; + + const instant = toLocalInstant(raw, timezone, boundary); + + if (!instant.isValid) { + throw new ValidationError( + instant.invalidReason === 'unsupported zone' + ? `Invalid timezone: "${timezone}"` + : `Invalid date: "${raw}" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)`, + ); + } + + return instant.toUTC().toISO() ?? undefined; +} diff --git a/packages/agent/src/audit-trail/sql-store.ts b/packages/agent/src/audit-trail/sql-store.ts index 7e5d47554e..69e93846fc 100644 --- a/packages/agent/src/audit-trail/sql-store.ts +++ b/packages/agent/src/audit-trail/sql-store.ts @@ -4,6 +4,7 @@ import type { AuditStatus, AuditStorageOptions, AuditStore, + AuditTimelineQuery, PendingAuditRecord, } from './types'; import type { Model, ModelStatic } from 'sequelize'; @@ -232,6 +233,42 @@ function buildHistoryWhereClause( return where; } +function buildTimelineWhereClause( + { + collections, + before, + excludeIds, + userIds, + operations, + startTimestamp, + endTimestamp, + search, + }: AuditTimelineQuery, + sequelize: Sequelize, +): Record { + const where: Record = { collection: { [Op.in]: collections } }; + + if (userIds) where.userId = { [Op.in]: userIds }; + if (operations?.length) where.operation = { [Op.in]: operations }; + if (excludeIds?.length) where.id = { [Op.notIn]: excludeIds }; + + const timestampRange: Record = {}; + if (startTimestamp) timestampRange[Op.gte] = new Date(startTimestamp); + + // The cursor bound and the `endDate` filter are both inclusive upper bounds; the tighter wins. + const upperBounds = [endTimestamp, before].filter(Boolean).map(bound => new Date(bound)); + + if (upperBounds.length) { + timestampRange[Op.lte] = new Date(Math.min(...upperBounds.map(date => date.getTime()))); + } + + if (Object.getOwnPropertySymbols(timestampRange).length) where.timestamp = timestampRange; + + if (search) where[Op.and] = [searchCondition(sequelize, search)]; + + return where; +} + export function fromRow(row: Model): AuditRecord { const plain = row.get({ plain: true }) as Record; const { timestamp } = plain; @@ -363,6 +400,23 @@ export function createSqlAuditStore(options: AuditStorageOptions): { transaction: null, }); }, + async listTimeline(query) { + if (!query.collections.length) return []; + + const { model, connection } = await init(); + + const rows = await model.findAll({ + where: buildTimelineWhereClause(query, connection), + order: [ + ['timestamp', 'DESC'], + ['id', 'DESC'], + ], + limit: query.limit, + transaction: null, + }); + + return rows.map(fromRow); + }, async listDistinctUsers(query) { const { model, connection } = await init(); diff --git a/packages/agent/src/audit-trail/types.ts b/packages/agent/src/audit-trail/types.ts index 04a504aaf6..75c9b1c468 100644 --- a/packages/agent/src/audit-trail/types.ts +++ b/packages/agent/src/audit-trail/types.ts @@ -78,6 +78,30 @@ export type AuditHistoryQuery = { after?: Pick; }; +/** + * Cross-collection timeline, newest first. Paged by cursor rather than by offset: a project-wide + * feed keeps growing at the head, so an offset silently shifts rows across pages. + */ +export type AuditTimelineQuery = { + /** Collections the caller is allowed to see; an empty list must match nothing. */ + collections: string[]; + limit: number; + /** + * Inclusive upper bound on `timestamp`, from the previous page's cursor. Inclusive so rows + * sharing the boundary timestamp aren't skipped; `excludeIds` drops the ones already returned. + */ + before?: string; + excludeIds?: number[]; + userIds?: number[]; + operations?: AuditOperation[]; + /** Inclusive lower bound on `timestamp` as a UTC ISO instant. */ + startTimestamp?: string; + /** Inclusive upper bound on `timestamp` as a UTC ISO instant. */ + endTimestamp?: string; + /** Same free-text match as `AuditHistoryQuery.search`. */ + search?: string; +}; + export type AuditCorrelationQuery = { collection: string; recordId: string; @@ -111,6 +135,11 @@ export interface AuditStore { listByCorrelation(query: AuditCorrelationQuery): AuditRecord[] | Promise; /** Flat list of entries recorded under any of `correlationKeys` for a record, oldest first. */ listByCorrelations(query: AuditCorrelationsQuery): AuditRecord[] | Promise; + /** + * Cross-collection timeline, newest first (ties broken by descending `id`). Optional: a store + * written before this existed simply doesn't serve the project-level route. + */ + listTimeline?(query: AuditTimelineQuery): AuditRecord[] | Promise; /** Distinct authors matching the query filters, independent of pagination. */ listDistinctUsers( query: Omit, diff --git a/packages/agent/src/routes/access/audit-trail-timeline.ts b/packages/agent/src/routes/access/audit-trail-timeline.ts new file mode 100644 index 0000000000..561549ceb0 --- /dev/null +++ b/packages/agent/src/routes/access/audit-trail-timeline.ts @@ -0,0 +1,115 @@ +import type { AuditRecord } from '../../audit-trail'; +import type { ForestAdminHttpDriverServices } from '../../services'; +import type { AgentOptionsWithDefaults } from '../../types'; +import type { DataSource } from '@forestadmin/datasource-toolkit'; +import type Router from '@koa/router'; +import type { Context } from 'koa'; + +import withholdValuesFromNonAdmin from '../../audit-trail/admin-gate'; +import { + parseDateBoundary, + parseOperations, + parsePageSize, + parseSearch, + parseUserIds, +} from '../../audit-trail/query-params'; +import { RouteType } from '../../types'; +import BaseRoute from '../base-route'; + +type Cursor = { before?: string; excludeIds: number[] }; + +export default class AuditTrailTimelineRoute extends BaseRoute { + readonly type = RouteType.PrivateRoute; + + private readonly dataSource: DataSource; + + constructor( + services: ForestAdminHttpDriverServices, + options: AgentOptionsWithDefaults, + dataSource: DataSource, + ) { + super(services, options); + this.dataSource = dataSource; + } + + setupRoutes(router: Router): void { + router.get('/_audit-trail', this.handleTimeline.bind(this)); + } + + public async handleTimeline(context: Context): Promise { + const query = context.request.query as Record; + const timezone = query.timezone?.toString() || 'UTC'; + const limit = parsePageSize(query['page[size]']?.toString()); + const cursor = AuditTrailTimelineRoute.parseCursor(context); + + // Parsed before the permission sweep so a malformed filter reads as a 400 whatever the caller + // is allowed to see. + const filters = { + userIds: parseUserIds(query.userIds?.toString()), + operations: parseOperations(query.operations?.toString()), + startTimestamp: parseDateBoundary(query.startDate?.toString(), timezone, 'start'), + endTimestamp: parseDateBoundary(query.endDate?.toString(), timezone, 'end'), + search: parseSearch(query.search?.toString()), + }; + + const collections = await this.readableCollections(context); + const { store } = this.options.auditTrail; + + // One row over the page: the only way to know whether a further page exists without a count. + const fetched = collections.length + ? await store.listTimeline({ ...filters, ...cursor, collections, limit: limit + 1 }) + : []; + const page = fetched.slice(0, limit); + + context.response.body = { + data: withholdValuesFromNonAdmin(page, context), + meta: { + cursor: fetched.length > limit ? AuditTrailTimelineRoute.nextCursor(page, cursor) : null, + }, + }; + } + + // Collections whose rows this caller may see at all. A record-level scope can't be evaluated + // across a whole timeline without fetching every record it mentions, so a collection the caller + // only sees through a scope is left out entirely rather than surfaced unfiltered: the row alone + // would reveal that a record it cannot read exists and was touched. + private async readableCollections(context: Context): Promise { + const names = await Promise.all( + this.dataSource.collections.map(async collection => { + const readable = await this.services.authorization.canRead(context, collection.name); + + if (!readable) return null; + + return (await this.services.authorization.getScope(collection, context)) + ? null + : collection.name; + }), + ); + + return names.filter(Boolean); + } + + // `before` is an inclusive upper bound, so the rows sharing the last row's timestamp are carried + // over as `excludeIds` rather than being skipped — that is what keeps a tie from falling between + // two pages. When the previous page ended on the same timestamp its exclusions still apply, so a + // timestamp holding more rows than fit on one page walks forward instead of looping. + private static nextCursor(page: AuditRecord[], previous: Cursor): Cursor { + const last = page[page.length - 1]; + const carried = previous.before === last.timestamp ? previous.excludeIds : []; + const tied = page.filter(row => row.timestamp === last.timestamp).map(row => row.id); + + return { before: last.timestamp, excludeIds: [...new Set([...carried, ...tied])] }; + } + + private static parseCursor(context: Context): Cursor { + const query = context.request.query as Record; + const before = parseDateBoundary(query.before?.toString(), 'UTC', 'end'); + const excludeIds = (query.excludeIds?.toString() ?? '') + .split(',') + .map(token => token.trim()) + .filter(token => /^\d+$/.test(token)) + .map(token => Number.parseInt(token, 10)); + + return { before, excludeIds }; + } +} diff --git a/packages/agent/src/routes/access/audit-trail.ts b/packages/agent/src/routes/access/audit-trail.ts index a2b206c168..456ee5e331 100644 --- a/packages/agent/src/routes/access/audit-trail.ts +++ b/packages/agent/src/routes/access/audit-trail.ts @@ -15,9 +15,16 @@ import { SchemaUtils, ValidationError, } from '@forestadmin/datasource-toolkit'; -import { DateTime } from 'luxon'; import { revertRecord } from '../../audit-trail'; +import { + parseDateBoundary, + parseFields, + parseOperations, + parsePageSize, + parseSearch, + parseUserIds, +} from '../../audit-trail/query-params'; import checkRecordVisibility, { recheckRecordVisibility, recordExists, @@ -31,23 +38,8 @@ import IdUtils from '../../utils/id'; import QueryStringParser from '../../utils/query-string'; import CollectionRoute from '../collection-route'; -const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; -const DATE_TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/; -// ISO 8601 instant: carries its own timezone designator (`Z` or `±HH:mm` / `±HHMM`). -const ISO_INSTANT = /[Zz]$|[+-]\d{2}:?\d{2}$/; - -const DEFAULT_PAGE_SIZE = 20; -const MAX_PAGE_SIZE = 100; const SCAN_BATCH_SIZE = 500; -const AUDIT_OPERATIONS: readonly AuditOperation[] = [ - 'create', - 'update', - 'delete', - 'action', - 'action_failed', -]; - type AuditHistoryFilters = { userIds?: number[]; operations?: AuditOperation[]; @@ -437,7 +429,7 @@ export default class AuditTrailRoute extends CollectionRoute { const timezone = query.timezone?.toString() || 'UTC'; - return AuditTrailRoute.parseDateBoundary(raw, timezone, 'start') as string; + return parseDateBoundary(raw, timezone, 'start') as string; } // JSON:API `sort=timestamp` → oldest first, anything else (or absent) → newest first. @@ -451,20 +443,12 @@ export default class AuditTrailRoute extends CollectionRoute { // fall back to the defaults rather than erroring. private static parsePagination(context: Context): { skip: number; limit: number } { const query = context.request.query as Record; - const size = AuditTrailRoute.parsePageSize(query['page[size]']?.toString()); + const size = parsePageSize(query['page[size]']?.toString()); const number = AuditTrailRoute.parsePageNumber(query['page[number]']?.toString()); return { skip: (number - 1) * size, limit: size }; } - private static parsePageSize(raw?: string): number { - const size = Number.parseInt(raw ?? '', 10); - - if (Number.isNaN(size) || size < 1) return DEFAULT_PAGE_SIZE; - - return Math.min(size, MAX_PAGE_SIZE); - } - private static parsePageNumber(raw?: string): number { const number = Number.parseInt(raw ?? '', 10); @@ -476,120 +460,12 @@ export default class AuditTrailRoute extends CollectionRoute { const timezone = query.timezone?.toString() || 'UTC'; return { - userIds: AuditTrailRoute.parseUserIds(query.userIds?.toString()), - operations: AuditTrailRoute.parseOperations(query.operations?.toString()), - startTimestamp: AuditTrailRoute.parseDateBoundary( - query.startDate?.toString(), - timezone, - 'start', - ), - endTimestamp: AuditTrailRoute.parseDateBoundary(query.endDate?.toString(), timezone, 'end'), - fields: AuditTrailRoute.parseFields(query.fields?.toString()), - search: AuditTrailRoute.parseSearch(query.search?.toString()), + userIds: parseUserIds(query.userIds?.toString()), + operations: parseOperations(query.operations?.toString()), + startTimestamp: parseDateBoundary(query.startDate?.toString(), timezone, 'start'), + endTimestamp: parseDateBoundary(query.endDate?.toString(), timezone, 'end'), + fields: parseFields(query.fields?.toString()), + search: parseSearch(query.search?.toString()), }; } - - // Comma-separated integer ids; non-numeric tokens are dropped. Empty after parsing → no filter. - private static parseUserIds(raw?: string): number[] | undefined { - if (!raw) return undefined; - - const ids = raw - .split(',') - .map(token => token.trim()) - .filter(token => /^\d+$/.test(token)) - .map(token => Number.parseInt(token, 10)); - - return ids.length > 0 ? ids : undefined; - } - - // Comma-separated, from a closed set. An unrecognized value is rejected rather than dropped: a - // silently ignored filter returns unfiltered rows into a list the caller believes is filtered, - // which is worse than an error. - private static parseOperations(raw?: string): AuditOperation[] | undefined { - if (!raw) return undefined; - - const tokens = raw - .split(',') - .map(token => token.trim()) - .filter(token => token.length > 0); - - const unknown = tokens.find(token => !AUDIT_OPERATIONS.includes(token as AuditOperation)); - - if (unknown) { - throw new ValidationError( - `Invalid operation: "${unknown}" (expected one of ${AUDIT_OPERATIONS.join(', ')})`, - ); - } - - return tokens.length > 0 ? (tokens as AuditOperation[]) : undefined; - } - - private static parseFields(raw?: string): string[] | undefined { - if (!raw) return undefined; - - const fields = raw - .split(',') - .map(token => token.trim()) - .filter(token => token.length > 0); - - return fields.length > 0 ? fields : undefined; - } - - // Trimmed; empty after trimming is treated the same as absent. - private static parseSearch(raw?: string): string | undefined { - const trimmed = raw?.trim(); - - return trimmed || undefined; - } - - // Bare day (`YYYY-MM-DD`) or wall-clock datetime (`YYYY-MM-DD[T| ]HH:mm[:ss]`), interpreted as - // local time in the request timezone and returned as a UTC instant so the store can compare it - // to stored timestamps. - private static parseDateBoundary( - raw: string | undefined, - timezone: string, - boundary: 'start' | 'end', - ): string | undefined { - if (!raw) return undefined; - - const instant = AuditTrailRoute.toLocalInstant(raw, timezone, boundary); - - if (!instant.isValid) { - throw new ValidationError( - instant.invalidReason === 'unsupported zone' - ? `Invalid timezone: "${timezone}"` - : `Invalid date: "${raw}" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)`, - ); - } - - return instant.toUTC().toISO() ?? undefined; - } - - private static toLocalInstant( - raw: string, - timezone: string, - boundary: 'start' | 'end', - ): DateTime { - // An embedded offset already pins the instant — the request timezone and start/end boundary - // don't apply. - if (ISO_INSTANT.test(raw)) return DateTime.fromISO(raw, { setZone: true }); - - if (DATE_ONLY.test(raw)) { - const day = DateTime.fromISO(raw, { zone: timezone }); - - return boundary === 'end' ? day.endOf('day') : day.startOf('day'); - } - - const match = DATE_TIME.exec(raw); - - if (!match) return DateTime.invalid('unparsable'); - - const [, date, hours, minutes, seconds] = match; - const base = DateTime.fromISO(`${date}T${hours}:${minutes}`, { zone: timezone }); - - if (seconds !== undefined) return base.set({ second: Number(seconds), millisecond: 0 }); - - // Minutes-only: end snaps to :59.999 to stay inclusive; start stays at :00.000. - return boundary === 'end' ? base.set({ second: 59, millisecond: 999 }) : base; - } } diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index aebeb60cb6..9ddcf05871 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -7,6 +7,7 @@ import CollectionApiChartRoute from './access/api-chart-collection'; import DataSourceApiChartRoute from './access/api-chart-datasource'; import AuditTrailRoute from './access/audit-trail'; import AuditTrailCorrelationRoute from './access/audit-trail-correlation'; +import AuditTrailTimelineRoute from './access/audit-trail-timeline'; import Chart from './access/chart'; import Count from './access/count'; import CountRelated from './access/count-related'; @@ -184,6 +185,10 @@ function getAuditTrailRoutes( // Registered before the per-collection routes: if a collection is named "correlation", its // `/_audit-trail/correlation/:id` route would otherwise shadow this one. new AuditTrailCorrelationRoute(services, options, dataSource), + // A store written before the cross-collection timeline existed simply doesn't serve it. + ...(options.auditTrail.store.listTimeline + ? [new AuditTrailTimelineRoute(services, options, dataSource)] + : []), ...dataSource.collections.map( collection => new AuditTrailRoute(services, options, dataSource, collection.name), ), diff --git a/packages/agent/test/audit-trail/in-memory-store.ts b/packages/agent/test/audit-trail/in-memory-store.ts index a271d07ddb..2e06bbc95e 100644 --- a/packages/agent/test/audit-trail/in-memory-store.ts +++ b/packages/agent/test/audit-trail/in-memory-store.ts @@ -5,6 +5,7 @@ import type { AuditRecord, AuditRecordConfirmation, AuditStore, + AuditTimelineQuery, AuditUserSummary, PendingAuditRecord, } from '../../src/audit-trail/types'; @@ -89,6 +90,28 @@ export default class InMemoryAuditStore implements AuditStore { .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); } + listTimeline({ + collections, + limit, + before, + excludeIds, + userIds, + operations, + startTimestamp, + endTimestamp, + }: AuditTimelineQuery): AuditRecord[] { + return this.records + .filter(record => collections.includes(record.collection)) + .filter(record => !before || record.timestamp <= before) + .filter(record => !excludeIds?.includes(record.id)) + .filter(record => !userIds || userIds.includes(record.userId)) + .filter(record => !operations?.length || operations.includes(record.operation)) + .filter(record => !startTimestamp || record.timestamp >= startTimestamp) + .filter(record => !endTimestamp || record.timestamp <= endTimestamp) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp) || b.id - a.id) + .slice(0, limit); + } + listDistinctUsers( query: Omit, ): AuditUserSummary[] { diff --git a/packages/agent/test/audit-trail/sql-store.test.ts b/packages/agent/test/audit-trail/sql-store.test.ts index ed5d23e729..bb0a56f615 100644 --- a/packages/agent/test/audit-trail/sql-store.test.ts +++ b/packages/agent/test/audit-trail/sql-store.test.ts @@ -254,6 +254,135 @@ describe('createSqlAuditStore (sqlite round-trip)', () => { await close(); }); + describe('listTimeline', () => { + const seedAcross = async (store: AuditStore) => { + await seed( + store, + record({ collection: 'accounts', recordId: '1', timestamp: '2026-01-01T00:00:00.000Z' }), + ); + await seed( + store, + record({ collection: 'books', recordId: '7', timestamp: '2026-01-02T00:00:00.000Z' }), + ); + await seed( + store, + record({ collection: 'secrets', recordId: '9', timestamp: '2026-01-03T00:00:00.000Z' }), + ); + }; + + it('returns rows of the named collections only, newest first', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + await seedAcross(store); + + const timeline = await store.listTimeline({ + collections: ['accounts', 'books'], + limit: 10, + }); + + expect(timeline.map(entry => [entry.collection, entry.recordId])).toEqual([ + ['books', '7'], + ['accounts', '1'], + ]); + + await close(); + }); + + it('matches nothing when no collection is allowed', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + await seedAcross(store); + + expect(await store.listTimeline({ collections: [], limit: 10 })).toEqual([]); + + await close(); + }); + + it('treats the cursor bound as inclusive and skips the excluded ids', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + const boundary = '2026-01-02T00:00:00.000Z'; + const first = await seed(store, record({ recordId: '1', timestamp: boundary })); + await seed(store, record({ recordId: '2', timestamp: boundary })); + await seed(store, record({ recordId: '3', timestamp: '2026-01-03T00:00:00.000Z' })); + + const timeline = await store.listTimeline({ + collections: ['accounts'], + limit: 10, + before: boundary, + excludeIds: [first], + }); + + expect(timeline.map(entry => entry.recordId)).toEqual(['2']); + + await close(); + }); + + it('breaks a timestamp tie by descending id', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + const tied = '2026-01-02T00:00:00.000Z'; + await seed(store, record({ recordId: '1', timestamp: tied })); + await seed(store, record({ recordId: '2', timestamp: tied })); + await seed(store, record({ recordId: '3', timestamp: tied })); + + const timeline = await store.listTimeline({ collections: ['accounts'], limit: 10 }); + + expect(timeline.map(entry => entry.recordId)).toEqual(['3', '2', '1']); + + await close(); + }); + + it('keeps the tighter of the cursor bound and the endDate filter', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + await seed(store, record({ recordId: '1', timestamp: '2026-01-01T00:00:00.000Z' })); + await seed(store, record({ recordId: '2', timestamp: '2026-01-02T00:00:00.000Z' })); + + const timeline = await store.listTimeline({ + collections: ['accounts'], + limit: 10, + before: '2026-01-02T00:00:00.000Z', + endTimestamp: '2026-01-01T00:00:00.000Z', + }); + + expect(timeline.map(entry => entry.recordId)).toEqual(['1']); + + await close(); + }); + + it('applies the operation, user and search filters', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + await seed(store, record({ recordId: '1', operation: 'create', userId: 1 })); + await seed(store, record({ recordId: '2', operation: 'delete', userId: 1 })); + await seed(store, record({ recordId: '3', operation: 'create', userId: 2 })); + await seed( + store, + record({ recordId: '4', operation: 'create', userId: 1, newValues: { status: 'other' } }), + ); + + const timeline = await store.listTimeline({ + collections: ['accounts'], + limit: 10, + operations: ['create'], + userIds: [1], + search: 'closed', + }); + + expect(timeline.map(entry => entry.recordId)).toEqual(['1']); + + await close(); + }); + + it('honors the limit', async () => { + const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); + await seed(store, record({ recordId: '1', timestamp: '2026-01-01T00:00:00.000Z' })); + await seed(store, record({ recordId: '2', timestamp: '2026-01-02T00:00:00.000Z' })); + await seed(store, record({ recordId: '3', timestamp: '2026-01-03T00:00:00.000Z' })); + + const timeline = await store.listTimeline({ collections: ['accounts'], limit: 2 }); + + expect(timeline.map(entry => entry.recordId)).toEqual(['3', '2']); + + await close(); + }); + }); + it('filters by operation at the query level', async () => { const { store, close } = createSqlAuditStore({ connectionString: 'sqlite::memory:' }); diff --git a/packages/agent/test/routes/access/audit-trail-timeline.test.ts b/packages/agent/test/routes/access/audit-trail-timeline.test.ts new file mode 100644 index 0000000000..0c77ddc4b6 --- /dev/null +++ b/packages/agent/test/routes/access/audit-trail-timeline.test.ts @@ -0,0 +1,275 @@ +import { ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; +import { createMockContext } from '@shopify/jest-koa-mocks'; + +import makeRoutes from '../../../src/routes'; +import AuditTrailTimelineRoute from '../../../src/routes/access/audit-trail-timeline'; +import * as factories from '../../__factories__'; + +describe('AuditTrailTimelineRoute', () => { + const row = (id: number, patch: Record = {}) => ({ + id, + timestamp: `2026-01-0${id}T00:00:00.000Z`, + operation: 'update', + collection: 'books', + recordId: '2', + userId: 1, + previousValues: { title: 'Old' }, + newValues: { title: 'New' }, + ...patch, + }); + + const setup = (timeline: unknown[] = []) => { + const services = factories.forestAdminHttpDriverServices.build(); + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { id: factories.columnSchema.numericPrimaryKey().build() }, + }), + }), + factories.collection.build({ + name: 'authors', + schema: factories.collectionSchema.build({ + fields: { id: factories.columnSchema.numericPrimaryKey().build() }, + }), + }), + ]); + const store = { listTimeline: jest.fn().mockResolvedValue(timeline) }; + const options = factories.forestAdminHttpDriverOptions.build({ + auditTrail: { connectionString: 'sqlite::memory:', store } as never, + }); + const route = new AuditTrailTimelineRoute(services, options, dataSource); + + return { services, dataSource, options, store, route }; + }; + + const contextWith = (query: Record = {}, permissionLevel = 'admin') => + createMockContext({ + state: { user: { email: 'john.doe@domain.com', permissionLevel } }, + customProperties: { query: { timezone: 'Europe/Paris', ...query } }, + }); + + test('registers the "/_audit-trail" route', () => { + const { services, dataSource, options } = setup(); + const router = factories.router.mockAllMethods().build(); + + new AuditTrailTimelineRoute(services, options, dataSource).setupRoutes(router); + + expect(router.get).toHaveBeenCalledWith('/_audit-trail', expect.any(Function)); + }); + + test('reads every collection the caller can see, newest first', async () => { + const timeline = [row(3, { collection: 'authors' }), row(2), row(1)]; + const { store, route } = setup(timeline); + const context = contextWith(); + + await route.handleTimeline(context); + + expect(store.listTimeline).toHaveBeenCalledWith( + expect.objectContaining({ collections: ['books', 'authors'], limit: 21 }), + ); + expect((context.response.body as { data: unknown[] }).data).toEqual(timeline); + }); + + test('leaves out a collection the caller cannot read', async () => { + const { services, store, route } = setup(); + (services.authorization.canRead as jest.Mock).mockImplementation( + async (_context, name) => name === 'books', + ); + + await route.handleTimeline(contextWith()); + + expect(store.listTimeline).toHaveBeenCalledWith( + expect.objectContaining({ collections: ['books'] }), + ); + }); + + test('leaves out a collection the caller only sees through a record-level scope', async () => { + const { services, store, route } = setup(); + (services.authorization.getScope as jest.Mock).mockImplementation(async collection => + collection.name === 'authors' ? new ConditionTreeLeaf('id', 'Equal', 1) : null, + ); + + await route.handleTimeline(contextWith()); + + expect(store.listTimeline).toHaveBeenCalledWith( + expect.objectContaining({ collections: ['books'] }), + ); + }); + + test('returns an empty page without hitting the store when nothing is readable', async () => { + const { services, store, route } = setup(); + (services.authorization.canRead as jest.Mock).mockResolvedValue(false); + const context = contextWith(); + + await route.handleTimeline(context); + + expect(store.listTimeline).not.toHaveBeenCalled(); + expect(context.response.body).toEqual({ data: [], meta: { cursor: null } }); + }); + + test('forwards the filters and the page size to the store', async () => { + const { store, route } = setup(); + + await route.handleTimeline( + contextWith({ + 'page[size]': '5', + userIds: '3,7', + operations: 'create,delete', + startDate: '2026-01-01', + endDate: '2026-01-31', + search: ' jane ', + }), + ); + + expect(store.listTimeline).toHaveBeenCalledWith({ + collections: ['books', 'authors'], + limit: 6, + userIds: [3, 7], + operations: ['create', 'delete'], + startTimestamp: '2025-12-31T23:00:00.000Z', + endTimestamp: '2026-01-31T22:59:59.999Z', + search: 'jane', + before: undefined, + excludeIds: [], + }); + }); + + test('rejects an unrecognized operation before checking any permission', async () => { + const { services, store, route } = setup(); + + await expect(route.handleTimeline(contextWith({ operations: 'destroy' }))).rejects.toThrow( + 'Invalid operation: "destroy" (expected one of create, update, delete, action, action_failed)', + ); + expect(services.authorization.canRead).not.toHaveBeenCalled(); + expect(store.listTimeline).not.toHaveBeenCalled(); + }); + + describe('cursor', () => { + test('returns no cursor when the page is the last one', async () => { + const { route } = setup([row(2), row(1)]); + const context = contextWith({ 'page[size]': '2' }); + + await route.handleTimeline(context); + + expect(context.response.body).toEqual({ + data: [row(2), row(1)], + meta: { cursor: null }, + }); + }); + + test('drops the extra row and points the cursor at the last one returned', async () => { + const { route } = setup([row(3), row(2), row(1)]); + const context = contextWith({ 'page[size]': '2' }); + + await route.handleTimeline(context); + + expect(context.response.body).toEqual({ + data: [row(3), row(2)], + meta: { cursor: { before: '2026-01-02T00:00:00.000Z', excludeIds: [2] } }, + }); + }); + + test('carries every id sharing the boundary timestamp so a tie cannot fall between pages', async () => { + const tied = '2026-01-05T00:00:00.000Z'; + const { route } = setup([ + row(9, { timestamp: '2026-01-06T00:00:00.000Z' }), + row(8, { timestamp: tied }), + row(7, { timestamp: tied }), + row(6, { timestamp: tied }), + ]); + const context = contextWith({ 'page[size]': '3' }); + + await route.handleTimeline(context); + + expect((context.response.body as { meta: { cursor: unknown } }).meta.cursor).toEqual({ + before: tied, + excludeIds: [8, 7], + }); + }); + + test('keeps the previous exclusions when the next page ends on the same timestamp', async () => { + const tied = '2026-01-05T00:00:00.000Z'; + const { route } = setup([ + row(7, { timestamp: tied }), + row(6, { timestamp: tied }), + row(5, { timestamp: tied }), + ]); + const context = contextWith({ + 'page[size]': '2', + before: tied, + excludeIds: '9,8', + }); + + await route.handleTimeline(context); + + expect((context.response.body as { meta: { cursor: unknown } }).meta.cursor).toEqual({ + before: tied, + excludeIds: [9, 8, 7, 6], + }); + }); + + test('forwards the incoming cursor to the store as an inclusive bound', async () => { + const { store, route } = setup(); + + await route.handleTimeline( + contextWith({ before: '2026-01-05T00:00:00.000Z', excludeIds: '4, 5 ,x' }), + ); + + expect(store.listTimeline).toHaveBeenCalledWith( + expect.objectContaining({ + before: '2026-01-05T00:00:00.000Z', + excludeIds: [4, 5], + }), + ); + }); + }); + + test('blanks the values for a non-admin caller', async () => { + const { route } = setup([row(1)]); + const context = contextWith({}, 'editor'); + + await route.handleTimeline(context); + + expect((context.response.body as { data: unknown[] }).data).toEqual([ + { ...row(1), previousValues: {}, newValues: {} }, + ]); + }); + + describe('conditional mounting', () => { + const buildDataSource = () => + factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { id: factories.columnSchema.numericPrimaryKey().build() }, + }), + }), + ]); + + test('mounts the timeline route when the store serves it', () => { + const services = factories.forestAdminHttpDriverServices.build(); + const options = factories.forestAdminHttpDriverOptions.build({ + auditTrail: { + connectionString: 'sqlite::memory:', + store: { listTimeline: jest.fn() }, + } as never, + }); + + const routes = makeRoutes(buildDataSource(), options, services); + + expect(routes.filter(route => route instanceof AuditTrailTimelineRoute)).toHaveLength(1); + }); + + test('mounts no timeline route when the store does not serve it', () => { + const services = factories.forestAdminHttpDriverServices.build(); + const options = factories.forestAdminHttpDriverOptions.build({ + auditTrail: { connectionString: 'sqlite::memory:', store: {} } as never, + }); + + const routes = makeRoutes(buildDataSource(), options, services); + + expect(routes.filter(route => route instanceof AuditTrailTimelineRoute)).toHaveLength(0); + }); + }); +}); From 5fa8ffc02cb54f15a34f50f287dfcfc8cceba414 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Tue, 22 Sep 2026 09:54:39 +0200 Subject: [PATCH 3/7] fix(audit-trail): end the timeline walk when the cursor cannot move past a tie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: ids come back through `Number()` in the SQL store's `fromRow`, so an id past 2^53 rounds on the way out and the next page's `NOT IN` fails to exclude the row it names — the same page would then repeat forever. Staying on one timestamp must add at least the last row's id to the exclusions, so a cursor that would come back unchanged now reads as the end of the walk rather than another page. The upgrade path, if an audit table ever nears 9e15 rows, is to carry the id as a string through `AuditRecord`, the cursor and the SQL binding — a breaking change to a public type, not worth making for a ceiling nothing is near. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/src/audit-trail/README.md | 4 ++++ .../src/routes/access/audit-trail-timeline.ts | 18 +++++++++++++++--- .../access/audit-trail-timeline.test.ts | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/audit-trail/README.md b/packages/agent/src/audit-trail/README.md index d4f3bbc38c..179eed352d 100644 --- a/packages/agent/src/audit-trail/README.md +++ b/packages/agent/src/audit-trail/README.md @@ -377,6 +377,10 @@ pages, so they come back and `excludeIds` drops the ones already sent. Ties orde (insertion order), and a timestamp holding more rows than fit on one page accumulates its exclusions across pages rather than looping. Feed the two values back verbatim; don't synthesize them. +`meta.cursor` is also `null` if the walk cannot move past the current timestamp — staying on one +must always add at least the last row's id to the exclusions, so a cursor that would come back +unchanged ends the walk instead of repeating the page forever. + A custom `AuditStore` that doesn't implement `listTimeline` simply doesn't get this route mounted. ### `GET /forest/_audit-trail/correlation/{correlationKey}` diff --git a/packages/agent/src/routes/access/audit-trail-timeline.ts b/packages/agent/src/routes/access/audit-trail-timeline.ts index 561549ceb0..3905adf310 100644 --- a/packages/agent/src/routes/access/audit-trail-timeline.ts +++ b/packages/agent/src/routes/access/audit-trail-timeline.ts @@ -93,12 +93,24 @@ export default class AuditTrailTimelineRoute extends BaseRoute { // over as `excludeIds` rather than being skipped — that is what keeps a tie from falling between // two pages. When the previous page ended on the same timestamp its exclusions still apply, so a // timestamp holding more rows than fit on one page walks forward instead of looping. - private static nextCursor(page: AuditRecord[], previous: Cursor): Cursor { + // + // Null when that walk makes no progress. Staying on the same timestamp must add at least the last + // row's id to the exclusions; if it doesn't, the ids this page named are not the ones the store + // matched and the next page would repeat this one forever. The one way that happens today is an + // id past 2^53, which `fromRow` rounds on its way through `Number()` — an audit table would need + // ~9e15 rows to reach it. Ending the walk is the safe answer at that point; the upgrade, if such + // a table ever exists, is to carry the id as a string through `AuditRecord`, the cursor and the + // SQL binding. + private static nextCursor(page: AuditRecord[], previous: Cursor): Cursor | null { const last = page[page.length - 1]; - const carried = previous.before === last.timestamp ? previous.excludeIds : []; + const sameBoundary = previous.before === last.timestamp; + const carried = sameBoundary ? previous.excludeIds : []; const tied = page.filter(row => row.timestamp === last.timestamp).map(row => row.id); + const excludeIds = [...new Set([...carried, ...tied])]; - return { before: last.timestamp, excludeIds: [...new Set([...carried, ...tied])] }; + if (sameBoundary && excludeIds.length === carried.length) return null; + + return { before: last.timestamp, excludeIds }; } private static parseCursor(context: Context): Cursor { diff --git a/packages/agent/test/routes/access/audit-trail-timeline.test.ts b/packages/agent/test/routes/access/audit-trail-timeline.test.ts index 0c77ddc4b6..936eab74f1 100644 --- a/packages/agent/test/routes/access/audit-trail-timeline.test.ts +++ b/packages/agent/test/routes/access/audit-trail-timeline.test.ts @@ -209,6 +209,25 @@ describe('AuditTrailTimelineRoute', () => { }); }); + test('ends the walk rather than repeating a page the cursor cannot move past', async () => { + const tied = '2026-01-05T00:00:00.000Z'; + // Every row is one the cursor already named: the exclusions cannot grow, so a further page + // would return this same one forever. + const { route } = setup([ + row(7, { timestamp: tied }), + row(6, { timestamp: tied }), + row(5, { timestamp: tied }), + ]); + const context = contextWith({ 'page[size]': '2', before: tied, excludeIds: '7,6' }); + + await route.handleTimeline(context); + + expect(context.response.body).toEqual({ + data: [row(7, { timestamp: tied }), row(6, { timestamp: tied })], + meta: { cursor: null }, + }); + }); + test('forwards the incoming cursor to the store as an inclusive bound', async () => { const { store, route } = setup(); From 9f76388a312f16f0cf947ea1f8f3b2522c876043 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Wed, 23 Sep 2026 11:46:42 +0200 Subject: [PATCH 4/7] refactor(audit-trail): qualify the permission scope in the timeline route Follows the rename below it: the comments on `readableCollections` say which kind of scope excludes a collection from the project-level feed. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/src/routes/access/audit-trail-timeline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/routes/access/audit-trail-timeline.ts b/packages/agent/src/routes/access/audit-trail-timeline.ts index 3905adf310..89df06cdeb 100644 --- a/packages/agent/src/routes/access/audit-trail-timeline.ts +++ b/packages/agent/src/routes/access/audit-trail-timeline.ts @@ -69,9 +69,9 @@ export default class AuditTrailTimelineRoute extends BaseRoute { }; } - // Collections whose rows this caller may see at all. A record-level scope can't be evaluated + // Collections whose rows this caller may see at all. A record-level permission scope can't be evaluated // across a whole timeline without fetching every record it mentions, so a collection the caller - // only sees through a scope is left out entirely rather than surfaced unfiltered: the row alone + // only sees through a permission scope is left out entirely rather than surfaced unfiltered: the row alone // would reveal that a record it cannot read exists and was touched. private async readableCollections(context: Context): Promise { const names = await Promise.all( From 79333bb344eabcdac4593b23fa401f1979ae4166 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Wed, 23 Sep 2026 11:58:39 +0200 Subject: [PATCH 5/7] docs(audit-trail): the timeline route has no target collection to gate on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: the routes preamble said all four are gated by `assertCanRead` on the target collection, which the cross-collection timeline has none of — it checks `canRead` per collection and queries the ones that pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/agent/src/audit-trail/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/audit-trail/README.md b/packages/agent/src/audit-trail/README.md index 179eed352d..5a7d49fd0e 100644 --- a/packages/agent/src/audit-trail/README.md +++ b/packages/agent/src/audit-trail/README.md @@ -182,10 +182,12 @@ refuses an over-cap bulk write. ## HTTP routes -When `auditTrail` is set, the agent exposes four routes (all behind Forest's auth, gated by -`assertCanRead` on the target collection). When the caller's role has a record-level scope on that -collection, every route below — including the correlation lookups — additionally requires the -target id to currently exist and match that scope. A scope can't be evaluated against a record that +When `auditTrail` is set, the agent exposes four routes, all behind Forest's auth. The three +record-scoped ones are gated by `assertCanRead` on the collection they name; the cross-collection +timeline has no target collection, so it checks `canRead` per collection and queries only the ones +that pass. When the caller's role has a record-level scope on that collection, every record-scoped +route below — including the correlation lookups — additionally requires the target id to currently +exist and match that scope. A scope can't be evaluated against a record that no longer exists, so this only refuses a still-existing, out-of-scope id: once a record is genuinely deleted, anyone who can read the collection can see its history, reconstructed state, or correlated operations, scope aside — inspecting what was deleted is much of the point of an audit trail. From e7b7c0fd3c18aa6b03c787e2f96797468b83804f Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Thu, 8 Oct 2026 15:20:59 +0200 Subject: [PATCH 6/7] perf(audit-trail): index (timestamp, id) for the cross-collection timeline The timeline route has no record_id to narrow on, so each page sorted the whole table. Co-Authored-By: Claude Opus 5.5 --- packages/agent/src/audit-trail/migrations.ts | 42 ++++++++ .../agent/test/audit-trail/migrations.test.ts | 101 +++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/audit-trail/migrations.ts b/packages/agent/src/audit-trail/migrations.ts index f84afa0225..da82101bc6 100644 --- a/packages/agent/src/audit-trail/migrations.ts +++ b/packages/agent/src/audit-trail/migrations.ts @@ -29,6 +29,12 @@ function qualifiedMigrationName(schema: string | undefined, tableName: string): : `${tableName}:001-create-audit-logs`; } +function qualifiedMigrationName002(schema: string | undefined, tableName: string): string { + return schema + ? `${schema}.${tableName}:002-index-timestamp-id` + : `${tableName}:002-index-timestamp-id`; +} + // Every table name claimed by an audit-trail store configured in this process, keyed by // `schema\0name` — both its own data table and the migration table Umzug derives from it. // A second store's data table can otherwise land on the exact name a first store's migration @@ -198,6 +204,42 @@ function buildMigrations(schema: string | undefined, tableName: string) { ); }, }, + { + // Its own migration rather than an index added to 001: the table has shipped, so a database + // out there has already recorded 001 as applied. The timeline route reads across every + // record, ordered and paged by (timestamp, id): without this, each page sorts the whole table. + name: qualifiedMigrationName002(schema, tableName), + up: async ({ context }: { context: MigrationContext }) => { + const table = { tableName: context.tableName, schema: context.schema }; + const name = `${context.tableName}_timestamp_id`; + const hasIndex = () => + indexNames(context.queryInterface, table, context.transaction).then(names => + names.has(name), + ); + + // Idempotent for the same reason 001 is: a process losing a concurrent-boot race retries. + if (await hasIndex()) return; + + try { + await context.queryInterface.addIndex(table, { + fields: ['timestamp', 'id'], + name, + transaction: context.transaction, + }); + } catch (error) { + // Without Postgres's advisory lock, another agent booting at the same moment can add the + // index between the check above and this statement. Anything else is a real failure. + if (!(await hasIndex().catch(() => false))) throw error; + } + }, + down: async ({ context }: { context: MigrationContext }) => { + await context.queryInterface.removeIndex( + { tableName: context.tableName, schema: context.schema }, + `${context.tableName}_timestamp_id`, + { transaction: context.transaction }, + ); + }, + }, ]; } diff --git a/packages/agent/test/audit-trail/migrations.test.ts b/packages/agent/test/audit-trail/migrations.test.ts index 1f2779de03..10051ec769 100644 --- a/packages/agent/test/audit-trail/migrations.test.ts +++ b/packages/agent/test/audit-trail/migrations.test.ts @@ -68,7 +68,10 @@ describe('runAuditMigrations (sqlite)', () => { // sqlite has no real schema/catalog separation: Sequelize represents a schema-qualified table // as a single literal identifier joining schema and table name with a dot. const [applied] = await sequelize.query('SELECT name FROM "forest.audit_logs_migration"'); - expect(applied).toEqual([{ name: 'forest.audit_logs:001-create-audit-logs' }]); + expect(applied).toEqual([ + { name: 'forest.audit_logs:001-create-audit-logs' }, + { name: 'forest.audit_logs:002-index-timestamp-id' }, + ]); await sequelize.close(); }); @@ -79,7 +82,10 @@ describe('runAuditMigrations (sqlite)', () => { await runAuditMigrations(sequelize, { tableName: 'audit_logs' }); const [applied] = await sequelize.query('SELECT name FROM "audit_logs_migration"'); - expect(applied).toEqual([{ name: 'audit_logs:001-create-audit-logs' }]); + expect(applied).toEqual([ + { name: 'audit_logs:001-create-audit-logs' }, + { name: 'audit_logs:002-index-timestamp-id' }, + ]); await sequelize.close(); }); @@ -163,11 +169,100 @@ describe('runAuditMigrations (sqlite)', () => { ).resolves.toBeUndefined(); const [applied] = await sequelize.query('SELECT name FROM "audit_logs_migration"'); - expect(applied).toHaveLength(1); + expect(applied).toHaveLength(2); await sequelize.close(); }); + describe('migration 002: the (timestamp, id) index', () => { + const MIGRATION_002 = 'audit_logs:002-index-timestamp-id'; + + const indexOf = async (sequelize: Sequelize) => { + const indexes = (await sequelize.getQueryInterface().showIndex('audit_logs')) as Array<{ + name: string; + fields: Array<{ attribute: string }>; + }>; + + return indexes.find(index => index.name === 'audit_logs_timestamp_id'); + }; + + const shippedBefore002 = async () => { + const sequelize = new Sequelize('sqlite::memory:', { logging: false }); + await runAuditMigrations(sequelize, { tableName: 'audit_logs' }); + await sequelize.query('DROP INDEX "audit_logs_timestamp_id"'); + await sequelize.query(`DELETE FROM "audit_logs_migration" WHERE name = '${MIGRATION_002}'`); + + return sequelize; + }; + + it('indexes timestamp then id, the order the timeline pages in', async () => { + const sequelize = new Sequelize('sqlite::memory:', { logging: false }); + + await runAuditMigrations(sequelize, { tableName: 'audit_logs' }); + + expect((await indexOf(sequelize))?.fields.map(field => field.attribute)).toEqual([ + 'timestamp', + 'id', + ]); + + await sequelize.close(); + }); + + it('adds the index to a table that already applied 001', async () => { + const sequelize = await shippedBefore002(); + + await runAuditMigrations(sequelize, { tableName: 'audit_logs' }); + + expect(await indexOf(sequelize)).toBeDefined(); + + await sequelize.close(); + }); + + it('treats the index as added when a concurrent boot added it after the check', async () => { + const sequelize = await shippedBefore002(); + const queryInterface = sequelize.getQueryInterface(); + const addIndex = queryInterface.addIndex.bind(queryInterface); + jest.spyOn(queryInterface, 'addIndex').mockImplementationOnce(async (...args) => { + await (addIndex as (...a: unknown[]) => Promise)(...args); + throw new Error('index "audit_logs_timestamp_id" already exists'); + }); + + await expect( + runAuditMigrations(sequelize, { tableName: 'audit_logs' }), + ).resolves.toBeUndefined(); + + const [applied] = await sequelize.query('SELECT name FROM "audit_logs_migration"'); + expect(applied).toContainEqual({ name: MIGRATION_002 }); + + await sequelize.close(); + }); + + it('still fails when adding the index fails and the index is missing', async () => { + const sequelize = await shippedBefore002(); + jest + .spyOn(sequelize.getQueryInterface(), 'addIndex') + .mockRejectedValueOnce(new Error('disk full')); + + await expect(runAuditMigrations(sequelize, { tableName: 'audit_logs' })).rejects.toThrow( + 'disk full', + ); + + await sequelize.close(); + }); + + it('drops the index on down', async () => { + const sequelize = new Sequelize('sqlite::memory:', { logging: false }); + const umzug = buildUmzug(sequelize, { tableName: 'audit_logs' }); + await umzug.up(); + + await umzug.down(); + + expect(await indexOf(sequelize)).toBeUndefined(); + + await sequelize.close(); + }); + }); + it('rejects a pre-existing table sharing the name that is missing audit-trail columns', async () => { const sequelize = new Sequelize('sqlite::memory:', { logging: false }); await sequelize.getQueryInterface().createTable('audit_logs', { From 6c72f5a6deb2839679f970f5b1156acf254ce875 Mon Sep 17 00:00:00 2001 From: Olivier Bex-Chauvet Date: Thu, 8 Oct 2026 16:24:37 +0200 Subject: [PATCH 7/7] feat(audit-trail): serve the timeline's values to every reader, announce canUseAuditTrailTimeline [PRD-1257] Drops the admin gate on the cross-collection timeline. A caller who can read a collection already reads each record's history one at a time, so the feed serves the same values in aggregate. Who can open the project's Activity Logs is decided per team by Forest. restrictsProjectAuditValuesToAdmins goes with it. The front read it as "this agent mounts the timeline", which it now announces directly: canUseAuditTrailTimeline, true only when the store implements listTimeline. Co-Authored-By: Claude Opus 5.5 --- packages/agent/src/audit-trail/README.md | 15 ++++---- packages/agent/src/audit-trail/admin-gate.ts | 24 ------------- .../src/routes/access/audit-trail-timeline.ts | 3 +- packages/agent/src/routes/capabilities.ts | 9 +++-- .../access/audit-trail-timeline.test.ts | 6 ++-- .../agent/test/routes/capabilities.test.ts | 34 ++++++++++++++++--- 6 files changed, 44 insertions(+), 47 deletions(-) delete mode 100644 packages/agent/src/audit-trail/admin-gate.ts diff --git a/packages/agent/src/audit-trail/README.md b/packages/agent/src/audit-trail/README.md index 5a7d49fd0e..70d4b9f0d3 100644 --- a/packages/agent/src/audit-trail/README.md +++ b/packages/agent/src/audit-trail/README.md @@ -346,14 +346,13 @@ happened across the project rather than to one record. Each row carries its own { "data": [ /* rows */ ], "meta": { "cursor": { "before": "…", "excludeIds": [12, 11] } } } ``` -**Detail values are admin-only here, and only here.** `previousValues` and `newValues` reach a caller -whose permission level is `admin` — exactly that level, not a privileged set. Every other caller gets -each row with both objects empty: what happened, by whom and when stays readable, the values do not. -The per-record routes above are deliberately *not* gated — someone who can read the collection can -already read the record, so its before/after values tell them nothing the record itself doesn't. What -stays admin-only is the aggregate: every collection at once, with no record to know in advance. The -capabilities payload advertises this as `restrictsProjectAuditValuesToAdmins`, so a client can tell an -agent that enforces the rule from an older one that merely hides the values in its UI. +**Detail values follow the same rule as the per-record routes:** a caller who can read a collection +gets its rows with `previousValues` and `newValues`, whatever their permission level. Someone who can +read the collection can already read each record's history one at a time, so the feed serves the same +values in aggregate. Who can open the project's Activity Logs at all is decided per team by Forest +(the `activityLogs` feature a team can be deactivated for), not by this route. The capabilities +payload advertises the route as `canUseAuditTrailTimeline`, true only when the store implements +`listTimeline`. **Permissions.** Only the collections the caller can read are queried. A collection the caller sees only through a **record-level scope is left out entirely**: a scope can't be evaluated across a whole diff --git a/packages/agent/src/audit-trail/admin-gate.ts b/packages/agent/src/audit-trail/admin-gate.ts deleted file mode 100644 index 859a0b9b96..0000000000 --- a/packages/agent/src/audit-trail/admin-gate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AuditRecord } from './types'; -import type { Context } from 'koa'; - -// `admin` exactly, not a privileged set (PRD-1259). The level comes from the Forest-signed JWT that -// `context.state.user` holds, so it needs no round-trip and cannot be forged by the caller. -function isAdmin(context: Context): boolean { - return context.state.user?.permissionLevel === 'admin'; -} - -// Applies to the project-level timeline only. A record's own history is not gated: someone who can -// read the collection can already read the record, so its before/after values tell them nothing the -// record itself doesn't. The project-wide feed is a different exposure — every collection at once, -// no record to know in advance — and that aggregate is what stays admin-only. -// -// The row itself always stays: operation, author and timestamp are what makes the timeline readable, -// and are not the values a non-admin isn't entitled to. -export default function withholdValuesFromNonAdmin( - entries: AuditRecord[], - context: Context, -): AuditRecord[] { - if (isAdmin(context)) return entries; - - return entries.map(entry => ({ ...entry, previousValues: {}, newValues: {} })); -} diff --git a/packages/agent/src/routes/access/audit-trail-timeline.ts b/packages/agent/src/routes/access/audit-trail-timeline.ts index 89df06cdeb..d4aa88aa0f 100644 --- a/packages/agent/src/routes/access/audit-trail-timeline.ts +++ b/packages/agent/src/routes/access/audit-trail-timeline.ts @@ -5,7 +5,6 @@ import type { DataSource } from '@forestadmin/datasource-toolkit'; import type Router from '@koa/router'; import type { Context } from 'koa'; -import withholdValuesFromNonAdmin from '../../audit-trail/admin-gate'; import { parseDateBoundary, parseOperations, @@ -62,7 +61,7 @@ export default class AuditTrailTimelineRoute extends BaseRoute { const page = fetched.slice(0, limit); context.response.body = { - data: withholdValuesFromNonAdmin(page, context), + data: page, meta: { cursor: fetched.length > limit ? AuditTrailTimelineRoute.nextCursor(page, cursor) : null, }, diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts index 669362b55b..2f4db0eda5 100644 --- a/packages/agent/src/routes/capabilities.ts +++ b/packages/agent/src/routes/capabilities.ts @@ -46,11 +46,10 @@ export default class Capabilities extends BaseRoute { // `x-forest-correlation-id` is emitted on every response regardless — the frontend must // gate the History tab on this flag rather than inferring the feature from that header. canUseAuditTrail: this.options.auditTrail !== null, - // Constant, not config-driven: it states that this agent version withholds the detail values - // of the *project-level* timeline from a non-admin caller itself, so the front can stop - // treating its own hidden expander as the guarantee there. A record's own history is not - // gated. An older agent simply omits the flag. - restrictsProjectAuditValuesToAdmins: true, + // The cross-collection timeline is mounted only when the store can list it: an older agent, + // or a custom store without `listTimeline`, omits it or answers false, and the front keeps + // the project page on the activity logs instead of 404ing on every batch. + canUseAuditTrailTimeline: Boolean(this.options.auditTrail?.store?.listTimeline), checksRelationReadPermissions: !this.options.skipRelationReadPermissions, }, collections: diff --git a/packages/agent/test/routes/access/audit-trail-timeline.test.ts b/packages/agent/test/routes/access/audit-trail-timeline.test.ts index 936eab74f1..934c3ce00a 100644 --- a/packages/agent/test/routes/access/audit-trail-timeline.test.ts +++ b/packages/agent/test/routes/access/audit-trail-timeline.test.ts @@ -244,15 +244,13 @@ describe('AuditTrailTimelineRoute', () => { }); }); - test('blanks the values for a non-admin caller', async () => { + test('serves the values to a non-admin caller who can read the collection', async () => { const { route } = setup([row(1)]); const context = contextWith({}, 'editor'); await route.handleTimeline(context); - expect((context.response.body as { data: unknown[] }).data).toEqual([ - { ...row(1), previousValues: {}, newValues: {} }, - ]); + expect((context.response.body as { data: unknown[] }).data).toEqual([row(1)]); }); describe('conditional mounting', () => { diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts index f6530d7e4e..d3e6ae487c 100644 --- a/packages/agent/test/routes/capabilities.test.ts +++ b/packages/agent/test/routes/capabilities.test.ts @@ -81,7 +81,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, - restrictsProjectAuditValuesToAdmins: true, + canUseAuditTrailTimeline: false, checksRelationReadPermissions: true, }, collections: [], @@ -108,7 +108,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, - restrictsProjectAuditValuesToAdmins: true, + canUseAuditTrailTimeline: false, checksRelationReadPermissions: true, }, collections: [], @@ -133,7 +133,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, - restrictsProjectAuditValuesToAdmins: true, + canUseAuditTrailTimeline: false, checksRelationReadPermissions: true, }, collections: [], @@ -187,6 +187,32 @@ describe('Capabilities', () => { agentCapabilities: { canUseAuditTrail: true }, }); }); + + test('reports canUseAuditTrailTimeline only when the store can list the timeline', async () => { + const fetchTimelineCapability = async (store: object) => { + const timelineOptions = factories.forestAdminHttpDriverOptions.build({ + auditTrail: { store, close: jest.fn() } as never, + }); + const dataSource = factories.dataSource.buildWithCollection( + factories.collection.build({ name: 'books' }), + ); + const capabilities = new Capabilities(services, timelineOptions, dataSource); + const context = createMockContext({ + ...defaultContext, + requestBody: { collectionNames: [] }, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await capabilities.fetchCapabilities(context); + + return (context.response.body as { agentCapabilities: Record }) + .agentCapabilities.canUseAuditTrailTimeline; + }; + + expect(await fetchTimelineCapability({ listTimeline: jest.fn() })).toBe(true); + expect(await fetchTimelineCapability({})).toBe(false); + }); }); describe('when requesting a collection capabilities', () => { @@ -208,7 +234,7 @@ describe('Capabilities', () => { canUseProjectionViaHeaderOnList: true, canUseMultipleFieldsProjectionOnRelation: true, canUseAuditTrail: false, - restrictsProjectAuditValuesToAdmins: true, + canUseAuditTrailTimeline: false, checksRelationReadPermissions: true, }, collections: [