From 0b14f8ac0874e411ef8f2b633cd2a53411e2dc5f Mon Sep 17 00:00:00 2001 From: Shohan RAHMAN Date: Thu, 18 Jun 2026 17:28:12 +0200 Subject: [PATCH 1/4] feat(agent): reconstruct a record state at a past timestamp from audit logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes `GET /_audit-trail/{collection}/{id}/state?timestamp=...` next to the existing history route. The handler reads the record's current value and replays every audit entry with `timestamp >= target` in reverse, applying `previousValues` to undo each change. Walking back stops on a terminal event: a `create` returns 404 (record did not exist yet at the target instant) and a `delete` adopts its `previousValues` snapshot and ignores anything older. Only the audited columns (writable + primary keys) are returned — read-only/computed fields are not captured by the audit log and can't be reconstructed. The revert logic lives next to the route in the agent rather than in the plugin so the agent runtime doesn't take a new dependency on `plugin-audit-trail` (the agent already locally re-declares the audit store interface). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/access/audit-trail-revert.ts | 78 ++++++ .../agent/src/routes/access/audit-trail.ts | 91 ++++++- .../routes/access/audit-trail-revert.test.ts | 132 ++++++++++ .../test/routes/access/audit-trail.test.ts | 244 ++++++++++++++++++ 4 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 packages/agent/src/routes/access/audit-trail-revert.ts create mode 100644 packages/agent/test/routes/access/audit-trail-revert.test.ts diff --git a/packages/agent/src/routes/access/audit-trail-revert.ts b/packages/agent/src/routes/access/audit-trail-revert.ts new file mode 100644 index 0000000000..25670a217e --- /dev/null +++ b/packages/agent/src/routes/access/audit-trail-revert.ts @@ -0,0 +1,78 @@ +type AuditEntry = { + operation: 'create' | 'update' | 'delete'; + previousValues: Record; + newValues: Record; +}; + +const isPlainObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); + +// Audit diffs are structural: when both `previous` and `next` are plain objects, the diff is nested +// (object keys or numeric array indexes) and we recurse; otherwise the leaf has been replaced +// wholesale and `previous` is the value to write back. +const revertValue = (current: unknown, previous: unknown, next: unknown): unknown => { + if (!isPlainObject(previous) || !isPlainObject(next)) return previous; + + if (Array.isArray(current)) { + const result = [...current]; + + for (const key of Object.keys(previous)) { + const index = Number(key); + result[index] = revertValue(result[index], previous[key], next[key]); + } + + return result; + } + + const base = isPlainObject(current) ? current : {}; + const result: Record = { ...base }; + + for (const key of Object.keys(previous)) { + result[key] = revertValue(result[key], previous[key], next[key]); + } + + return result; +}; + +const revertOne = ( + current: Record, + entry: AuditEntry, +): Record => { + const result = { ...current }; + + for (const column of Object.keys(entry.previousValues)) { + result[column] = revertValue( + result[column], + entry.previousValues[column], + entry.newValues[column], + ); + } + + return result; +}; + +/** + * Reconstruct the state of a record at a target instant by replaying audit entries in reverse. + * + * `current` is the record as it stands today (or `null` if it does not exist anymore); `entries` + * are the audit logs for that record with `timestamp >= target`, sorted newest-first. The walk + * stops on the first terminal event: a `create` returns `null` (the record did not exist yet at + * the target instant), a `delete` returns its `previousValues` snapshot and ignores anything + * older (the snapshot already contains everything that happened before the deletion). + */ +export default function revertRecord( + current: Record | null, + entries: AuditEntry[], +): Record | null { + let state = current; + + for (const entry of entries) { + if (entry.operation === 'create') return null; + + if (entry.operation === 'delete') return { ...entry.previousValues }; + + if (state) state = revertOne(state, entry); + } + + return state; +} diff --git a/packages/agent/src/routes/access/audit-trail.ts b/packages/agent/src/routes/access/audit-trail.ts index 29ee391b14..26c274e49f 100644 --- a/packages/agent/src/routes/access/audit-trail.ts +++ b/packages/agent/src/routes/access/audit-trail.ts @@ -1,9 +1,20 @@ +import type { CollectionSchema } from '@forestadmin/datasource-toolkit'; import type Router from '@koa/router'; import type { Context } from 'koa'; -import { ValidationError } from '@forestadmin/datasource-toolkit'; +import { + ConditionTreeFactory, + PaginatedFilter, + Projection, + SchemaUtils, + ValidationError, +} from '@forestadmin/datasource-toolkit'; import { DateTime } from 'luxon'; +import revertRecord from './audit-trail-revert'; +import { HttpCode } from '../../types'; +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}$/; @@ -22,6 +33,7 @@ type AuditHistoryFilters = { export default class AuditTrailRoute extends CollectionRoute { setupRoutes(router: Router): void { router.get(`/_audit-trail/${this.collectionUrlSlug}/:id`, this.handleHistory.bind(this)); + router.get(`/_audit-trail/${this.collectionUrlSlug}/:id/state`, this.handleStateAt.bind(this)); } public async handleHistory(context: Context): Promise { @@ -50,6 +62,83 @@ export default class AuditTrailRoute extends CollectionRoute { context.response.body = { data, meta: { count } }; } + // Rebuild the state of a record at a target instant by reading the current value and replaying + // every audit entry with `timestamp >= target` in reverse. Only the audited columns (writable + // columns and primary keys) are returned — read-only/computed fields are not captured in the + // audit log and can't be reconstructed. + public async handleStateAt(context: Context): Promise { + await this.services.authorization.assertCanRead(context, this.collection.name); + + const targetTimestamp = AuditTrailRoute.parseTargetTimestamp(context); + const auditedColumns = AuditTrailRoute.auditedColumns(this.collection.schema); + const current = await this.fetchCurrentRecord(context, auditedColumns); + + const { store } = this.options.auditTrail; + const entries = await store.listByRecord({ + collection: this.collection.name, + recordId: context.params.id, + startTimestamp: targetTimestamp, + order: 'desc', + }); + + const state = revertRecord(current, entries as Parameters[1]); + + if (!state) context.throw(HttpCode.NotFound, 'Record did not exist at this timestamp'); + + context.response.body = { data: state }; + } + + // Read the record through the regular collection API so authorization scopes are honored. Only + // the audited columns are projected; the primary keys come along so the row matches the id. + private async fetchCurrentRecord( + context: Context, + auditedColumns: string[], + ): Promise | null> { + const id = IdUtils.unpackId(this.collection.schema, context.params.id); + const filter = new PaginatedFilter({ + conditionTree: ConditionTreeFactory.intersect( + ConditionTreeFactory.matchIds(this.collection.schema, [id]), + await this.services.authorization.getScope(this.collection, context), + ), + }); + + const records = await this.collection.list( + QueryStringParser.parseCaller(context), + filter, + new Projection(...auditedColumns), + ); + + return records[0] ?? null; + } + + // Mirrors the column selection of the audit-trail plugin: writable columns plus the primary keys + // (audited so the record id can be packed even when a PK is read-only). + private static auditedColumns(schema: CollectionSchema): string[] { + const writable = Object.keys(schema.fields).filter(name => { + const field = schema.fields[name]; + + return field.type === 'Column' && !field.isReadOnly; + }); + + return [...new Set([...SchemaUtils.getPrimaryKeys(schema), ...writable])]; + } + + // `timestamp` is mandatory and accepts the same forms as the history route's `startDate`/`endDate` + // (`YYYY-MM-DD` or `YYYY-MM-DD[T| ]HH:mm[:ss]`). The bare-day form anchors at the start of day, + // matching the inclusive `>= T` semantics applied by the audit store. + private static parseTargetTimestamp(context: Context): string { + const query = context.request.query as Record; + const raw = query.timestamp?.toString(); + + if (!raw) throw new ValidationError('Missing timestamp'); + + const timezone = query.timezone?.toString() || 'UTC'; + const instant = AuditTrailRoute.parseDateBoundary(raw, timezone, 'start'); + + // parseDateBoundary returns undefined only when raw is empty, which we've already rejected. + return instant as string; + } + // JSON:API `sort`: `timestamp` → oldest first, `-timestamp` → newest first. Anything else // (absent or unsupported) defaults to newest first. private static parseSort(context: Context): 'asc' | 'desc' { diff --git a/packages/agent/test/routes/access/audit-trail-revert.test.ts b/packages/agent/test/routes/access/audit-trail-revert.test.ts new file mode 100644 index 0000000000..635d76bcec --- /dev/null +++ b/packages/agent/test/routes/access/audit-trail-revert.test.ts @@ -0,0 +1,132 @@ +import revertRecord from '../../../src/routes/access/audit-trail-revert'; + +const update = ( + previousValues: Record, + newValues: Record, + timestamp = '2026-06-18T12:00:00Z', +) => ({ operation: 'update' as const, previousValues, newValues, timestamp }); + +describe('revertRecord', () => { + test('returns the current record unchanged when no entries are provided', () => { + expect(revertRecord({ id: 1, status: 'closed' }, [])).toEqual({ id: 1, status: 'closed' }); + }); + + test('returns null when the current record does not exist and no entries are provided', () => { + expect(revertRecord(null, [])).toBeNull(); + }); + + test('reverts a single column update', () => { + const current = { id: 1, status: 'closed', name: 'Acme' }; + + const state = revertRecord(current, [update({ status: 'open' }, { status: 'closed' })]); + + expect(state).toEqual({ id: 1, status: 'open', name: 'Acme' }); + }); + + test('applies entries newest-first to walk back through multiple updates', () => { + // Audit entries arrive desc. Walking back: current (archived) → closed → open. + const current = { id: 1, status: 'archived' }; + const entries = [ + update({ status: 'closed' }, { status: 'archived' }, '2026-06-18T13:00:00Z'), + update({ status: 'open' }, { status: 'closed' }, '2026-06-18T12:00:00Z'), + ]; + + expect(revertRecord(current, entries)).toEqual({ id: 1, status: 'open' }); + }); + + test('reverts a nested-object diff at the changed leaf only', () => { + const current = { + id: 1, + payload: { theme: 'dark', layout: { sidebar: false, density: 'compact' } }, + }; + const entries = [ + update( + { payload: { layout: { sidebar: true } } }, + { payload: { layout: { sidebar: false } } }, + ), + ]; + + expect(revertRecord(current, entries)).toEqual({ + id: 1, + payload: { theme: 'dark', layout: { sidebar: true, density: 'compact' } }, + }); + }); + + test('reverts an array-of-objects diff at the changed index only', () => { + const current = { + id: 1, + payload: [ + { step: 'a', done: true }, + { step: 'b', done: true }, + ], + }; + const entries = [ + update({ payload: { 1: { done: false } } }, { payload: { 1: { done: true } } }), + ]; + + expect(revertRecord(current, entries)).toEqual({ + id: 1, + payload: [ + { step: 'a', done: true }, + { step: 'b', done: false }, + ], + }); + }); + + test('replaces a primitive-array column wholesale (no element-wise diff)', () => { + const current = { id: 1, tags: ['x', 'z'] }; + const entries = [update({ tags: ['x', 'y'] }, { tags: ['x', 'z'] })]; + + expect(revertRecord(current, entries)).toEqual({ id: 1, tags: ['x', 'y'] }); + }); + + test('returns null when walking back hits a create (record did not exist at target)', () => { + const current = { id: 1, status: 'open' }; + const entries = [ + { operation: 'create' as const, previousValues: {}, newValues: { id: 1, status: 'open' } }, + ]; + + expect(revertRecord(current, entries)).toBeNull(); + }); + + test('stops at a delete and returns its previousValues snapshot, ignoring older entries', () => { + // The record was deleted (current is null) and then we ignore anything older than the delete. + const entries = [ + { + operation: 'delete' as const, + previousValues: { id: 1, status: 'closed', name: 'Acme' }, + newValues: {}, + }, + update({ status: 'open' }, { status: 'closed' }), + ]; + + expect(revertRecord(null, entries)).toEqual({ id: 1, status: 'closed', name: 'Acme' }); + }); + + test('drops the accumulated reverts when walking back hits a delete (recreated record)', () => { + // Record currently exists (recreated). Walking back, we hit the delete from before the + // re-creation and adopt its previousValues as the snapshot for the target instant. + const current = { id: 1, status: 'fresh' }; + const entries = [ + update({ status: 'fresh-was' }, { status: 'fresh' }), + { + operation: 'delete' as const, + previousValues: { id: 1, status: 'old' }, + newValues: {}, + }, + ]; + + expect(revertRecord(current, entries)).toEqual({ id: 1, status: 'old' }); + }); + + test('does not mutate the current record passed in', () => { + const current = { id: 1, status: 'closed', payload: { a: 1 } }; + const snapshot = JSON.parse(JSON.stringify(current)); + + revertRecord(current, [ + update({ status: 'open', payload: { a: 0 } }, { status: 'closed', payload: { a: 1 } }), + ]); + + expect(current).toEqual(snapshot); + }); +}); diff --git a/packages/agent/test/routes/access/audit-trail.test.ts b/packages/agent/test/routes/access/audit-trail.test.ts index 72c2e6dab7..49a60e3a32 100644 --- a/packages/agent/test/routes/access/audit-trail.test.ts +++ b/packages/agent/test/routes/access/audit-trail.test.ts @@ -33,6 +33,15 @@ describe('AuditTrailRoute', () => { expect(router.get).toHaveBeenCalledWith('/_audit-trail/books/:id', expect.any(Function)); }); + test('registers the "/_audit-trail/books/:id/state" route', () => { + const { services, dataSource, options } = setup(); + const router = factories.router.mockAllMethods().build(); + + new AuditTrailRoute(services, options, dataSource, 'books').setupRoutes(router); + + expect(router.get).toHaveBeenCalledWith('/_audit-trail/books/:id/state', expect.any(Function)); + }); + test('returns the record history read from the store', async () => { const history = [{ operation: 'update', recordId: '2' }]; const { services, dataSource, options, store } = setup(history); @@ -450,6 +459,241 @@ describe('AuditTrailRoute', () => { expect(services.authorization.assertCanRead).toHaveBeenCalledWith(context, 'books'); }); + describe('handleStateAt', () => { + const setupBooks = (history: 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(), + status: factories.columnSchema.build({ columnType: 'String' }), + name: factories.columnSchema.build({ columnType: 'String' }), + // Read-only / computed columns are not audited, so they must be excluded from the + // projection used to read the current record. + displayName: factories.columnSchema.build({ columnType: 'String', isReadOnly: true }), + }, + }), + }), + ]); + const store = { + listByRecord: jest.fn().mockResolvedValue(history), + countByRecord: jest.fn(), + }; + const options = factories.forestAdminHttpDriverOptions.build({ auditTrail: { store } }); + const route = new AuditTrailRoute(services, options, dataSource, 'books'); + + return { services, dataSource, options, store, route }; + }; + + test('queries the store with the parsed UTC timestamp, desc order, no pagination', async () => { + const { dataSource, store, route } = setupBooks([]); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'Europe/Paris', timestamp: '2026-06-18T12:00' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(store.listByRecord).toHaveBeenCalledWith({ + collection: 'books', + recordId: '2', + // Paris is UTC+2 in June, so 12:00 local → 10:00 UTC. + startTimestamp: '2026-06-18T10:00:00.000Z', + order: 'desc', + }); + }); + + test('rejects a missing timestamp', async () => { + const { dataSource, route } = setupBooks(); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, + }); + + await expect(route.handleStateAt(context)).rejects.toThrow('Missing timestamp'); + }); + + test('rejects a malformed timestamp', async () => { + const { dataSource, route } = setupBooks(); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'Europe/Paris', timestamp: '17-06-2026' }, + params: { id: '2' }, + }, + }); + + await expect(route.handleStateAt(context)).rejects.toThrow( + 'Invalid date: "17-06-2026" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)', + ); + }); + + test('asserts the user can read the collection before returning state', async () => { + const { services, dataSource, route } = setupBooks(); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'Europe/Paris', timestamp: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(services.authorization.assertCanRead).toHaveBeenCalledWith(context, 'books'); + }); + + test('reads the current record projected on audited columns only (PKs + writable)', async () => { + const { dataSource, route } = setupBooks([]); + const list = jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'Europe/Paris', timestamp: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + // Read-only `displayName` is not in the projection — it can't be reconstructed. + expect(list).toHaveBeenCalledWith(expect.anything(), expect.anything(), [ + 'id', + 'status', + 'name', + ]); + }); + + test('returns the current record unchanged when there are no audit entries at or after T', async () => { + const { dataSource, route } = setupBooks([]); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', timestamp: '2026-06-18T12:00' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.response.body).toEqual({ + data: { id: 2, status: 'closed', name: 'Acme' }, + }); + }); + + test('reverts updates by walking entries newest-first', async () => { + const history = [ + { + operation: 'update', + previousValues: { status: 'closed' }, + newValues: { status: 'archived' }, + }, + { + operation: 'update', + previousValues: { status: 'open', name: 'Acme' }, + newValues: { status: 'closed', name: 'Acme Inc.' }, + }, + ]; + const { dataSource, route } = setupBooks(history); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'archived', name: 'Acme Inc.' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', timestamp: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.response.body).toEqual({ + data: { id: 2, status: 'open', name: 'Acme' }, + }); + }); + + test('returns 404 when the record does not exist now and the audit log is empty', async () => { + const { dataSource, route } = setupBooks([]); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', timestamp: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.throw).toHaveBeenCalledWith(404, 'Record did not exist at this timestamp'); + }); + + test('returns 404 when walking back hits a create (record did not exist yet at T)', async () => { + const history = [ + { operation: 'create', previousValues: {}, newValues: { id: 2, status: 'open' } }, + ]; + const { dataSource, route } = setupBooks(history); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'open', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', timestamp: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.throw).toHaveBeenCalledWith(404, 'Record did not exist at this timestamp'); + }); + + test('returns the delete snapshot when the record is currently gone', async () => { + const history = [ + { + operation: 'delete', + previousValues: { id: 2, status: 'closed', name: 'Acme' }, + newValues: {}, + }, + ]; + const { dataSource, route } = setupBooks(history); + jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'UTC', timestamp: '2026-06-18' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(context.response.body).toEqual({ + data: { id: 2, status: 'closed', name: 'Acme' }, + }); + }); + }); + describe('conditional mounting', () => { const buildDataSource = () => factories.dataSource.buildWithCollections([ From 44b2a5d3f6e9a47f1a152e9b01e43fb5e8cfa0c9 Mon Sep 17 00:00:00 2001 From: Shohan RAHMAN Date: Thu, 18 Jun 2026 17:42:45 +0200 Subject: [PATCH 2/4] chore(agent): drop non-essential comments from the record-state route Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/access/audit-trail-revert.ts | 15 +++------------ .../agent/src/routes/access/audit-trail.ts | 18 ++++-------------- .../routes/access/audit-trail-revert.test.ts | 4 ---- .../test/routes/access/audit-trail.test.ts | 4 ---- 4 files changed, 7 insertions(+), 34 deletions(-) diff --git a/packages/agent/src/routes/access/audit-trail-revert.ts b/packages/agent/src/routes/access/audit-trail-revert.ts index 25670a217e..06e7288cea 100644 --- a/packages/agent/src/routes/access/audit-trail-revert.ts +++ b/packages/agent/src/routes/access/audit-trail-revert.ts @@ -7,9 +7,7 @@ type AuditEntry = { const isPlainObject = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); -// Audit diffs are structural: when both `previous` and `next` are plain objects, the diff is nested -// (object keys or numeric array indexes) and we recurse; otherwise the leaf has been replaced -// wholesale and `previous` is the value to write back. +// Plain object on both sides ⇒ nested diff; otherwise `previous` is the leaf to write back. const revertValue = (current: unknown, previous: unknown, next: unknown): unknown => { if (!isPlainObject(previous) || !isPlainObject(next)) return previous; @@ -51,15 +49,8 @@ const revertOne = ( return result; }; -/** - * Reconstruct the state of a record at a target instant by replaying audit entries in reverse. - * - * `current` is the record as it stands today (or `null` if it does not exist anymore); `entries` - * are the audit logs for that record with `timestamp >= target`, sorted newest-first. The walk - * stops on the first terminal event: a `create` returns `null` (the record did not exist yet at - * the target instant), a `delete` returns its `previousValues` snapshot and ignores anything - * older (the snapshot already contains everything that happened before the deletion). - */ +// `entries` must arrive newest-first. Walking stops on the first terminal event: `create` ⇒ `null` +// (record did not exist yet), `delete` ⇒ its `previousValues` snapshot (older entries are ignored). export default function revertRecord( current: Record | null, entries: AuditEntry[], diff --git a/packages/agent/src/routes/access/audit-trail.ts b/packages/agent/src/routes/access/audit-trail.ts index 26c274e49f..a183ff4ef3 100644 --- a/packages/agent/src/routes/access/audit-trail.ts +++ b/packages/agent/src/routes/access/audit-trail.ts @@ -62,10 +62,7 @@ export default class AuditTrailRoute extends CollectionRoute { context.response.body = { data, meta: { count } }; } - // Rebuild the state of a record at a target instant by reading the current value and replaying - // every audit entry with `timestamp >= target` in reverse. Only the audited columns (writable - // columns and primary keys) are returned — read-only/computed fields are not captured in the - // audit log and can't be reconstructed. + // Only audited columns are returned; read-only/computed fields are not captured in the log. public async handleStateAt(context: Context): Promise { await this.services.authorization.assertCanRead(context, this.collection.name); @@ -88,8 +85,6 @@ export default class AuditTrailRoute extends CollectionRoute { context.response.body = { data: state }; } - // Read the record through the regular collection API so authorization scopes are honored. Only - // the audited columns are projected; the primary keys come along so the row matches the id. private async fetchCurrentRecord( context: Context, auditedColumns: string[], @@ -111,8 +106,8 @@ export default class AuditTrailRoute extends CollectionRoute { return records[0] ?? null; } - // Mirrors the column selection of the audit-trail plugin: writable columns plus the primary keys - // (audited so the record id can be packed even when a PK is read-only). + // Must mirror the audit-trail plugin's column selection (writable + primary keys, including + // read-only PKs) so the reconstructed record carries the same columns the log captured. private static auditedColumns(schema: CollectionSchema): string[] { const writable = Object.keys(schema.fields).filter(name => { const field = schema.fields[name]; @@ -123,9 +118,6 @@ export default class AuditTrailRoute extends CollectionRoute { return [...new Set([...SchemaUtils.getPrimaryKeys(schema), ...writable])]; } - // `timestamp` is mandatory and accepts the same forms as the history route's `startDate`/`endDate` - // (`YYYY-MM-DD` or `YYYY-MM-DD[T| ]HH:mm[:ss]`). The bare-day form anchors at the start of day, - // matching the inclusive `>= T` semantics applied by the audit store. private static parseTargetTimestamp(context: Context): string { const query = context.request.query as Record; const raw = query.timestamp?.toString(); @@ -133,10 +125,8 @@ export default class AuditTrailRoute extends CollectionRoute { if (!raw) throw new ValidationError('Missing timestamp'); const timezone = query.timezone?.toString() || 'UTC'; - const instant = AuditTrailRoute.parseDateBoundary(raw, timezone, 'start'); - // parseDateBoundary returns undefined only when raw is empty, which we've already rejected. - return instant as string; + return AuditTrailRoute.parseDateBoundary(raw, timezone, 'start') as string; } // JSON:API `sort`: `timestamp` → oldest first, `-timestamp` → newest first. Anything else diff --git a/packages/agent/test/routes/access/audit-trail-revert.test.ts b/packages/agent/test/routes/access/audit-trail-revert.test.ts index 635d76bcec..5ed56ef8fb 100644 --- a/packages/agent/test/routes/access/audit-trail-revert.test.ts +++ b/packages/agent/test/routes/access/audit-trail-revert.test.ts @@ -24,7 +24,6 @@ describe('revertRecord', () => { }); test('applies entries newest-first to walk back through multiple updates', () => { - // Audit entries arrive desc. Walking back: current (archived) → closed → open. const current = { id: 1, status: 'archived' }; const entries = [ update({ status: 'closed' }, { status: 'archived' }, '2026-06-18T13:00:00Z'), @@ -90,7 +89,6 @@ describe('revertRecord', () => { }); test('stops at a delete and returns its previousValues snapshot, ignoring older entries', () => { - // The record was deleted (current is null) and then we ignore anything older than the delete. const entries = [ { operation: 'delete' as const, @@ -104,8 +102,6 @@ describe('revertRecord', () => { }); test('drops the accumulated reverts when walking back hits a delete (recreated record)', () => { - // Record currently exists (recreated). Walking back, we hit the delete from before the - // re-creation and adopt its previousValues as the snapshot for the target instant. const current = { id: 1, status: 'fresh' }; const entries = [ update({ status: 'fresh-was' }, { status: 'fresh' }), diff --git a/packages/agent/test/routes/access/audit-trail.test.ts b/packages/agent/test/routes/access/audit-trail.test.ts index 49a60e3a32..8e20b69665 100644 --- a/packages/agent/test/routes/access/audit-trail.test.ts +++ b/packages/agent/test/routes/access/audit-trail.test.ts @@ -470,8 +470,6 @@ describe('AuditTrailRoute', () => { id: factories.columnSchema.numericPrimaryKey().build(), status: factories.columnSchema.build({ columnType: 'String' }), name: factories.columnSchema.build({ columnType: 'String' }), - // Read-only / computed columns are not audited, so they must be excluded from the - // projection used to read the current record. displayName: factories.columnSchema.build({ columnType: 'String', isReadOnly: true }), }, }), @@ -505,7 +503,6 @@ describe('AuditTrailRoute', () => { expect(store.listByRecord).toHaveBeenCalledWith({ collection: 'books', recordId: '2', - // Paris is UTC+2 in June, so 12:00 local → 10:00 UTC. startTimestamp: '2026-06-18T10:00:00.000Z', order: 'desc', }); @@ -571,7 +568,6 @@ describe('AuditTrailRoute', () => { await route.handleStateAt(context); - // Read-only `displayName` is not in the projection — it can't be reconstructed. expect(list).toHaveBeenCalledWith(expect.anything(), expect.anything(), [ 'id', 'status', From e6e977fe4ce86a0da8f83c55027c45295d0e925c Mon Sep 17 00:00:00 2001 From: Shohan RAHMAN Date: Fri, 19 Jun 2026 10:58:35 +0200 Subject: [PATCH 3/4] fix: format of query parameter --- .../agent/src/routes/access/audit-trail.ts | 20 ++++++++++++------- packages/agent/src/utils/query-string.ts | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/routes/access/audit-trail.ts b/packages/agent/src/routes/access/audit-trail.ts index a183ff4ef3..ac843a2350 100644 --- a/packages/agent/src/routes/access/audit-trail.ts +++ b/packages/agent/src/routes/access/audit-trail.ts @@ -20,6 +20,8 @@ import CollectionRoute from '../collection-route'; const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; // Date with a wall-clock time, `T` or space separator, seconds optional: `YYYY-MM-DD[T| ]HH:mm[:ss]`. 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; @@ -66,7 +68,7 @@ export default class AuditTrailRoute extends CollectionRoute { public async handleStateAt(context: Context): Promise { await this.services.authorization.assertCanRead(context, this.collection.name); - const targetTimestamp = AuditTrailRoute.parseTargetTimestamp(context); + const at = AuditTrailRoute.parseAt(context); const auditedColumns = AuditTrailRoute.auditedColumns(this.collection.schema); const current = await this.fetchCurrentRecord(context, auditedColumns); @@ -74,7 +76,7 @@ export default class AuditTrailRoute extends CollectionRoute { const entries = await store.listByRecord({ collection: this.collection.name, recordId: context.params.id, - startTimestamp: targetTimestamp, + startTimestamp: at, order: 'desc', }); @@ -98,7 +100,7 @@ export default class AuditTrailRoute extends CollectionRoute { }); const records = await this.collection.list( - QueryStringParser.parseCaller(context), + QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' }), filter, new Projection(...auditedColumns), ); @@ -118,11 +120,11 @@ export default class AuditTrailRoute extends CollectionRoute { return [...new Set([...SchemaUtils.getPrimaryKeys(schema), ...writable])]; } - private static parseTargetTimestamp(context: Context): string { + private static parseAt(context: Context): string { const query = context.request.query as Record; - const raw = query.timestamp?.toString(); + const raw = query.at?.toString(); - if (!raw) throw new ValidationError('Missing timestamp'); + if (!raw) throw new ValidationError('Missing "at" query parameter'); const timezone = query.timezone?.toString() || 'UTC'; @@ -205,7 +207,7 @@ export default class AuditTrailRoute extends CollectionRoute { throw new ValidationError( instant.invalidReason === 'unsupported zone' ? `Invalid timezone: "${timezone}"` - : `Invalid date: "${raw}" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)`, + : `Invalid date: "${raw}" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)`, ); } @@ -217,6 +219,10 @@ export default class AuditTrailRoute extends CollectionRoute { 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 }); diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index fcf5098cee..b29528cc30 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -134,8 +134,8 @@ export default class QueryStringParser { return { query: segmentQuery, connectionName }; } - static parseCaller(context: Context): Caller { - const timezone = context.request.query.timezone?.toString(); + static parseCaller(context: Context, options?: { defaultTimezone?: string }): Caller { + const timezone = context.request.query.timezone?.toString() || options?.defaultTimezone; const { ip } = context.request; if (!timezone) { From 6576c15f334ba9806621df1ac67394dcc029cb0d Mon Sep 17 00:00:00 2001 From: Shohan RAHMAN Date: Fri, 19 Jun 2026 13:52:27 +0200 Subject: [PATCH 4/4] fix: timezone missing --- .../test/routes/access/audit-trail.test.ts | 76 +++++++++++++++---- .../agent/test/utils/query-string.test.ts | 22 ++++++ 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/agent/test/routes/access/audit-trail.test.ts b/packages/agent/test/routes/access/audit-trail.test.ts index 8e20b69665..dafaa210da 100644 --- a/packages/agent/test/routes/access/audit-trail.test.ts +++ b/packages/agent/test/routes/access/audit-trail.test.ts @@ -331,7 +331,7 @@ describe('AuditTrailRoute', () => { }); await expect(route.handleHistory(context)).rejects.toThrow( - 'Invalid date: "17-06-2026" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)', + 'Invalid date: "17-06-2026" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)', ); expect(store.listByRecord).not.toHaveBeenCalled(); }); @@ -441,7 +441,7 @@ describe('AuditTrailRoute', () => { }); await expect(route.handleHistory(context)).rejects.toThrow( - 'Invalid date: "2026-06-18T11" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)', + 'Invalid date: "2026-06-18T11" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)', ); expect(store.listByRecord).not.toHaveBeenCalled(); }); @@ -493,7 +493,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'Europe/Paris', timestamp: '2026-06-18T12:00' }, + query: { timezone: 'Europe/Paris', at: '2026-06-18T12:00' }, params: { id: '2' }, }, }); @@ -508,7 +508,7 @@ describe('AuditTrailRoute', () => { }); }); - test('rejects a missing timestamp', async () => { + test('rejects a missing "at" query parameter', async () => { const { dataSource, route } = setupBooks(); jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); const context = createMockContext({ @@ -516,25 +516,71 @@ describe('AuditTrailRoute', () => { customProperties: { query: { timezone: 'Europe/Paris' }, params: { id: '2' } }, }); - await expect(route.handleStateAt(context)).rejects.toThrow('Missing timestamp'); + await expect(route.handleStateAt(context)).rejects.toThrow('Missing "at" query parameter'); }); - test('rejects a malformed timestamp', async () => { + test('rejects a malformed "at" value', async () => { const { dataSource, route } = setupBooks(); jest.spyOn(dataSource.getCollection('books'), 'list').mockResolvedValue([]); const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'Europe/Paris', timestamp: '17-06-2026' }, + query: { timezone: 'Europe/Paris', at: '17-06-2026' }, params: { id: '2' }, }, }); await expect(route.handleStateAt(context)).rejects.toThrow( - 'Invalid date: "17-06-2026" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)', + 'Invalid date: "17-06-2026" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)', ); }); + test('does not require a timezone query param when the timestamp is an ISO 8601 instant', async () => { + const { dataSource, store, route } = setupBooks([]); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { at: '2026-06-18T14:26:06.545Z' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(store.listByRecord).toHaveBeenCalledWith({ + collection: 'books', + recordId: '2', + startTimestamp: '2026-06-18T14:26:06.545Z', + order: 'desc', + }); + }); + + test('accepts an ISO 8601 instant and ignores the request timezone for it', async () => { + const { dataSource, store, route } = setupBooks([]); + jest + .spyOn(dataSource.getCollection('books'), 'list') + .mockResolvedValue([{ id: 2, status: 'closed', name: 'Acme' }]); + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { + query: { timezone: 'Europe/Paris', at: '2026-06-18T14:26:06.545Z' }, + params: { id: '2' }, + }, + }); + + await route.handleStateAt(context); + + expect(store.listByRecord).toHaveBeenCalledWith({ + collection: 'books', + recordId: '2', + startTimestamp: '2026-06-18T14:26:06.545Z', + order: 'desc', + }); + }); + test('asserts the user can read the collection before returning state', async () => { const { services, dataSource, route } = setupBooks(); jest @@ -543,7 +589,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'Europe/Paris', timestamp: '2026-06-18' }, + query: { timezone: 'Europe/Paris', at: '2026-06-18' }, params: { id: '2' }, }, }); @@ -561,7 +607,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'Europe/Paris', timestamp: '2026-06-18' }, + query: { timezone: 'Europe/Paris', at: '2026-06-18' }, params: { id: '2' }, }, }); @@ -583,7 +629,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'UTC', timestamp: '2026-06-18T12:00' }, + query: { timezone: 'UTC', at: '2026-06-18T12:00' }, params: { id: '2' }, }, }); @@ -615,7 +661,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'UTC', timestamp: '2026-06-18' }, + query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, }, }); @@ -633,7 +679,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'UTC', timestamp: '2026-06-18' }, + query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, }, }); @@ -654,7 +700,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'UTC', timestamp: '2026-06-18' }, + query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, }, }); @@ -677,7 +723,7 @@ describe('AuditTrailRoute', () => { const context = createMockContext({ state: { user: { email: 'john.doe@domain.com' } }, customProperties: { - query: { timezone: 'UTC', timestamp: '2026-06-18' }, + query: { timezone: 'UTC', at: '2026-06-18' }, params: { id: '2' }, }, }); diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index e457dc727d..c48094414c 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -509,6 +509,28 @@ describe('QueryStringParser', () => { expect(fn).toThrow('Missing timezone'); }); + test('falls back to the provided defaultTimezone when the timezone is missing', () => { + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: {} }, + }); + + expect(QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' })).toEqual( + expect.objectContaining({ timezone: 'UTC' }), + ); + }); + + test('keeps the request timezone when both query and defaultTimezone are set', () => { + const context = createMockContext({ + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'America/Los_Angeles' } }, + }); + + expect(QueryStringParser.parseCaller(context, { defaultTimezone: 'UTC' })).toEqual( + expect.objectContaining({ timezone: 'America/Los_Angeles' }), + ); + }); + test('should throw a ValidationError when the timezone is invalid', () => { const context = createMockContext({ customProperties: { query: { timezone: 'ThisTZ/Donotexist' } },