Skip to content

feat(audit-trail): cross-collection timeline route [PRD-1257] - #1911

Merged
bexchauveto merged 7 commits into
mainfrom
feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the
Oct 9, 2026
Merged

bexchauveto merged 7 commits into
mainfrom
feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the

Conversation

@bexchauveto

@bexchauveto bexchauveto commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

fixes PRD-1257. Stacked on #1910 → #1909.

GET /forest/_audit-trail serves every collection at once, newest first, so the project-level activity page can show before/after values rather than activity logs alone. Rows already carry collection and recordId, so the front can render "…on the Customer joe@ex.ample" and link it.

{ "data": [ /* rows */ ], "meta": { "cursor": { "before": "2026-01-05T00:00:00.000Z", "excludeIds": [12, 11] } } }

Values: no admin gate (changed on 8 October)

This PR first carried an admin gate on the timeline's values (restrictsProjectAuditValuesToAdmins, moved here from the withdrawn #1908). Commit 4bf169f7a drops it: a caller who can read a collection gets its rows with previousValues / newValues, whatever their permission level.

Why that holds: 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 (the "aggregation boundary, not a data barrier" framing of PRD-1259). Who can open the project's Activity Logs page at all is decided per team by Forest (featuresDeactivated / TeamDeactivatableFeature.activityLogs, enforced by the front and private-api). This route does not see that team setting; it only returns what the caller can read anyway.

The capability changes with it: canUseAuditTrailTimeline, true only when the store implements listTimeline. It replaces restrictsProjectAuditValuesToAdmins, which the front was using as a proxy for "this agent mounts the route". Nothing has shipped with the old flag, so there is no compatibility to keep.

Permissions

This is the first audit route with no single record to authorize against:

  • only 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 names, and the row alone would reveal that a record the caller cannot read exists and was touched.

Cost of that second rule: a caller scoped on every collection gets an empty timeline. Say the word if you'd rather have the rows with values blanked instead.

Cursor contract (Ruby implements against this)

Offset paging is wrong here: a project-wide feed grows at the head, so skip shifts rows across pages. page[size] still applies (default 20, cap 100).

param format effect
before ISO instant inclusive upper bound on timestamp
excludeIds comma-separated integers row ids already served at that boundary

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. A timestamp holding more rows than fit on one page accumulates its exclusions across pages. meta.cursor is null on the last page, and also when the walk cannot move past the current timestamp — see the nextCursor comment for the ceiling that guards (ids past 2^53, which fromRow rounds) and its upgrade path.

Filters

The record route's, minus fields (a column name means nothing across collections): userIds, operation, startDate/endDate/timezone, search — parsed by the same code, now in src/audit-trail/query-params.ts so the two routes cannot drift.

Store

listTimeline is optional on AuditStore: adding a required method would break anyone with their own store. A store that doesn't implement it doesn't get the route mounted.

Tests

16 route cases (permission sweep, scope exclusion, empty-without-store-call, filter forwarding, five cursor cases incl. the no-progress guard, a non-admin served the values, conditional mounting), 7 sql-store cases, capability-payload cases including canUseAuditTrailTimeline with and without listTimeline. 1675 passed, lint clean.

🤖 Generated with Claude Code

Note

Add cross-collection audit-trail timeline route with permission gate

  • Adds a project-level GET /_audit-trail route (audit-trail-timeline.ts) that returns audit rows across collections the caller can read without a record-level scope, with cursor pagination that handles timestamp ties by excluding already-served ids
  • Adds listTimeline as an optional AuditStore method with a SQL implementation in sql-store.ts, plus a new timestamp/id index migration (002) for existing audit tables
  • Moves user, operation, field, search, and date parsing into a shared module (query-params.ts) so per-record routes and the timeline route use the same rules
  • The route and the canUseAuditTrailTimeline capability flag are only exposed when the store implements listTimeline; stores without it keep working and skip mounting
  • Behavioral Change: audit migrations now include a tracked migration 002 that creates the timestamp/id index on existing tables, including tables that already applied migration 001

Macroscope summarized 6c72f5a.

@linear-code

linear-code Bot commented Sep 21, 2026

Copy link
Copy Markdown

PRD-1257

@qltysh

qltysh Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 5): toLocalInstant 2

Comment thread packages/agent/src/routes/access/audit-trail-timeline.ts Outdated
@qltysh

qltysh Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (6)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent/src/audit-trail/migrations.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/audit-trail.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/audit-trail/sql-store.ts100.0%
New file Coverage rating: A
packages/agent/src/routes/access/audit-trail-timeline.ts100.0%
New file Coverage rating: A
packages/agent/src/audit-trail/query-params.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@bexchauveto
bexchauveto added this pull request to stack #1913 September 22, 2026 07:15
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from 829575d to 830de40 Compare September 22, 2026 07:17
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from 830de40 to 70cbd1a Compare September 22, 2026 07:55
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from 70cbd1a to e3913cb Compare September 22, 2026 07:56
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from e3913cb to 7e558a9 Compare September 22, 2026 08:19
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch 2 times, most recently from 7e558a9 to f0dd096 Compare September 22, 2026 09:35
@bexchauveto bexchauveto changed the title feat(audit-trail): cross-collection timeline route for the project-level page [PRD-1257] feat(audit-trail): cross-collection timeline route + admin gate on its values [PRD-1257] Sep 22, 2026
@bexchauveto
bexchauveto removed this pull request from stack #1913 September 22, 2026 12:05
@bexchauveto
bexchauveto added this pull request to stack #1915 September 22, 2026 12:06
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch 2 times, most recently from 14b7a63 to c3e9b71 Compare September 23, 2026 09:14
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from c3e9b71 to b4ae8f0 Compare September 23, 2026 09:36
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from b4ae8f0 to 13e290f Compare September 23, 2026 09:37
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from 13e290f to 4dc16ee Compare September 23, 2026 09:47
Comment thread packages/agent/src/audit-trail/README.md Outdated
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch 3 times, most recently from 04f1eab to b8ec83e Compare September 30, 2026 15:23
Base automatically changed from feature/prd-1256-agent-nodejs-operation-filter-on-the-audit-trail-route to main September 30, 2026 15:34
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from b8ec83e to 7eab810 Compare September 30, 2026 15:34
Comment thread packages/agent/src/routes/access/audit-trail.ts
Comment thread packages/agent/src/audit-trail/types.ts
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch 6 times, most recently from f1d3ed3 to f1ae943 Compare October 7, 2026 09:31
@arnaud-moncel
arnaud-moncel self-requested a review October 7, 2026 12:56
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from f1ae943 to cc2e1de Compare October 7, 2026 15:00
@bexchauveto bexchauveto changed the title feat(audit-trail): cross-collection timeline route + admin gate on its values [PRD-1257] feat(audit-trail): cross-collection timeline route [PRD-1257] Oct 8, 2026
bexchauveto and others added 7 commits October 9, 2026 09:06
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) <noreply@anthropic.com>
…vel page

`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) <noreply@anthropic.com>
…ast a tie

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) <noreply@anthropic.com>
…oute

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) <noreply@anthropic.com>
…e on

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) <noreply@anthropic.com>
…eline

The timeline route has no record_id to narrow on, so each page sorted the whole table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nce 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 <noreply@anthropic.com>
@bexchauveto
bexchauveto force-pushed the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch from 4bf169f to 6c72f5a Compare October 9, 2026 07:06

@arnaud-moncel arnaud-moncel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@bexchauveto
bexchauveto merged commit 811559f into main Oct 9, 2026
32 checks passed
@bexchauveto
bexchauveto deleted the feature/prd-1257-agent-nodejs-cross-collection-audit-trail-route-for-the branch October 9, 2026 12:33
forest-bot added a commit that referenced this pull request Oct 9, 2026
# @forestadmin/agent [1.108.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.107.3...@forestadmin/agent@1.108.0) (2026-10-09)

### Features

* **audit-trail:** cross-collection timeline route [PRD-1257] ([#1911](#1911)) ([811559f](811559f))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants