From 820f7049dfd07b1db1d882946e27ffc2da54baee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:46:00 +0000 Subject: [PATCH 01/11] feat(audit): every data export writes a run-level sys_audit_log row, and the `export` action comes back with its writer The REST export door (GET /data/:object/export) hands the `audit` slot one event per export it streamed; plugin-audit's new data-export writer turns it into an `export` row (record_id null, the object in object_name) carrying the format, row count, row limit, truncation, the filter as field names and operators only, whether a search term applied, and the outcome. The `export` value returns to sys_audit_log.action and to the config_changes view filter. Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP Co-authored-by: Claude --- .../plugins/plugin-audit/src/audit-plugin.ts | 7 + .../src/data-export-audit.test.ts | 271 ++++++++++ .../plugin-audit/src/data-export-audit.ts | 467 ++++++++++++++++++ packages/plugins/plugin-audit/src/index.ts | 17 + .../sys-audit-log-action-labels.test.ts | 5 +- .../sys-audit-log-retired-actions.test.ts | 17 +- .../src/objects/sys-audit-log.object.ts | 38 +- packages/rest/src/data-action-routes.ts | 146 +++++- packages/rest/src/rest-api-plugin.ts | 13 +- packages/rest/src/rest-server.ts | 36 ++ 10 files changed, 1000 insertions(+), 17 deletions(-) create mode 100644 packages/plugins/plugin-audit/src/data-export-audit.test.ts create mode 100644 packages/plugins/plugin-audit/src/data-export-audit.ts diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 8650c31030f..1465a543ff9 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -30,6 +30,7 @@ import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } import { installReadAuditWriter, type ReadAuditWriterHandle } from './read-audit.js'; import { createAuthEventAuditSink } from './auth-event-audit.js'; import { createActionInvocationAuditWriter } from './action-invocation-audit.js'; +import { createDataExportAuditWriter } from './data-export-audit.js'; import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js'; import { COMMENT_REACTION_REMOVE_ROUTE, @@ -318,6 +319,11 @@ export class AuditPlugin implements Plugin { // (`recordActionInvocation`): the two action doors in `@objectstack/runtime` // resolve it per request and hand it one event per invocation, naming the // door it came through (ruling 6513869839 on #27681, parameter 3). + // + // [#30955] And the data-export writer (`recordDataExport`): the REST + // export door resolves it per request and hands it one event per export + // it streamed, so every export of every object, this ledger included, + // leaves a run-level `export` row (`data-export-audit.ts`). const getLedgerEngine = (): IDataEngine | undefined => { try { return ctx.getService('objectql'); @@ -334,6 +340,7 @@ export class AuditPlugin implements Plugin { ctx.registerService('audit', { ...createAuthEventAuditSink({ getEngine: getLedgerEngine, logger: ctx.logger }), ...createActionInvocationAuditWriter({ getEngine: getLedgerEngine, logger: ctx.logger }), + ...createDataExportAuditWriter({ getEngine: getLedgerEngine, logger: ctx.logger }), }); // ADR-0029 D8 — contribute this plugin's object translations to the i18n diff --git a/packages/plugins/plugin-audit/src/data-export-audit.test.ts b/packages/plugins/plugin-audit/src/data-export-audit.test.ts new file mode 100644 index 00000000000..308a3905557 --- /dev/null +++ b/packages/plugins/plugin-audit/src/data-export-audit.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30955] The `export` writer stores a REAL, READ-BACK ledger row for each + * data export — and never an exported value. + * + * The slot cases boot the real composition this package ships in + * (`ObjectQLPlugin` + `AuditPlugin` on a sqlite driver) and reach the writer + * the way the REST export door does: through the `audit` service slot's + * `recordDataExport`. Every assertion reads the row back through the engine, + * for the reason `auth-event-audit.test.ts` records: every `sys_audit_log` + * field is `readonly` and `validateRecord` skips readonly fields, so "the call + * did not throw" is equally true when the row landed right, landed wrong, or + * was never attempted. The door side — that the export door hands this writer + * one event per export, and none for a refused request — is pinned in + * `@objectstack/rest`'s `export-integration.test.ts`; the two halves together + * on the showcase in the dogfood suite's `data-export-audit-row.dogfood.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { AuditPlugin } from './audit-plugin.js'; +import { + DATA_EXPORT_AUDIT_ACTION, + EXPORT_FILTER_SUMMARY_MAX_TERMS, + buildDataExportAuditRow, + createDataExportAuditWriter, + summarizeExportFilter, + type DataExportAuditEvent, + type DataExportAuditWriter, +} from './data-export-audit.js'; + +const LEDGER = 'sys_audit_log'; +const SYS = { isSystem: true } as const; + +/** + * A cell value no field name, operator or code in this file spells: the + * no-value pins seed it into every value position an export request has. + */ +const SECRET = 'Zq7-export-cell-value-4411'; + +/** An export as the door hands it, completed; each case overrides what it is about. */ +function exportEvent(overrides: Partial = {}): DataExportAuditEvent { + return { + context: { userId: 'u_exporter', tenantId: 'org_a' }, + objectName: 'dea_note', + format: 'csv', + rowCount: 2, + limit: 10_000, + requestedLimit: 10_000, + limitCap: 50_000, + truncated: false, + outcome: 'succeeded', + ...overrides, + }; +} + +const metadataOf = (row: Record): Record => JSON.parse(String(row.metadata)); + +describe('[#30955] the export writer on the real `audit` slot', () => { + let kernel: ObjectKernel; + let engine: ObjectQL; + let audit: DataExportAuditWriter; + + /** The export rows at rest, oldest first. */ + const exportRows = async (): Promise>> => + (await engine.find(LEDGER, { where: { action: DATA_EXPORT_AUDIT_ACTION }, orderBy: [{ field: 'created_at', order: 'asc' }], context: SYS } as any)) as any; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AuditPlugin()); + await kernel.bootstrap(); + engine = kernel.getService('objectql'); + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + engine.registerDriver(driver, true); + await engine.syncSchemas(); + audit = kernel.getService('audit'); + }, 120_000); + + afterAll(async () => { + if (kernel) await Promise.race([kernel.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); + }, 30_000); + + it('the slot carries the writer beside the other ledger ingresses', () => { + expect(typeof audit.recordDataExport).toBe('function'); + expect(typeof (audit as any).recordAuthEvent).toBe('function'); + expect(typeof (audit as any).recordActionInvocation).toBe('function'); + }); + + it('an export of an ordinary object writes ONE run-level row with its format and row count', async () => { + const before = (await exportRows()).length; + await audit.recordDataExport(exportEvent({ objectName: 'dea_note', format: 'xlsx', rowCount: 2 })); + const rows = await exportRows(); + expect(rows).toHaveLength(before + 1); + const row = rows[rows.length - 1]; + expect(row).toMatchObject({ + action: 'export', + object_name: 'dea_note', + record_id: null, + user_id: 'u_exporter', + actor: 'u_exporter', + tenant_id: 'org_a', + }); + expect(row.old_value ?? null).toBeNull(); + expect(row.new_value ?? null).toBeNull(); + expect(metadataOf(row)).toMatchObject({ + format: 'xlsx', + row_count: 2, + limit: 10_000, + requested_limit: 10_000, + limit_cap: 50_000, + limit_capped: false, + truncated: false, + search: false, + outcome: 'succeeded', + }); + }); + + it('an export of the ledger itself writes ONE row about the ledger', async () => { + const before = (await exportRows()).length; + await audit.recordDataExport(exportEvent({ objectName: LEDGER, format: 'json', rowCount: 7 })); + const rows = await exportRows(); + expect(rows).toHaveLength(before + 1); + expect(rows[rows.length - 1]).toMatchObject({ action: 'export', object_name: LEDGER, record_id: null }); + expect(metadataOf(rows[rows.length - 1])).toMatchObject({ format: 'json', row_count: 7, outcome: 'succeeded' }); + }); + + it('a truncated export records the truncation and that the 50k cap clipped the request', async () => { + await audit.recordDataExport(exportEvent({ rowCount: 50_000, limit: 50_000, requestedLimit: 80_000, truncated: true })); + const rows = await exportRows(); + expect(metadataOf(rows[rows.length - 1])).toMatchObject({ + row_count: 50_000, + limit: 50_000, + requested_limit: 80_000, + limit_cap: 50_000, + limit_capped: true, + truncated: true, + }); + }); + + it('a failed export records its outcome, status and machine code', async () => { + await audit.recordDataExport(exportEvent({ outcome: 'failed', status: 400, errorCode: 'INVALID_FIELD', rowCount: 0 })); + const rows = await exportRows(); + expect(metadataOf(rows[rows.length - 1])).toMatchObject({ + outcome: 'failed', + status: 400, + error_code: 'INVALID_FIELD', + row_count: 0, + }); + }); + + it('the row never carries an exported cell value: not the filter comparands, not the search term', async () => { + await audit.recordDataExport(exportEvent({ + query: { + filter: { + name: SECRET, + $or: [ + { code: { $in: [SECRET, `${SECRET}-2`] } }, + { owner: { email: { $contains: SECRET } } }, + { $not: { title: { $eq: SECRET } } }, + ], + }, + search: SECRET, + searchFields: ['name', 'code'], + }, + })); + const rows = await exportRows(); + const row = rows[rows.length - 1]; + // Every column of the stored row, the metadata included, as one string. + expect(JSON.stringify(row)).not.toContain(SECRET); + // …while what the request ASKED is recorded: which fields, which operators. + const meta = metadataOf(row); + expect(meta.filter).toEqual([ + { field: 'name', op: '$eq' }, + { field: 'code', op: '$in' }, + { field: 'owner.email', op: '$contains' }, + { field: 'title', op: '$eq' }, + ]); + expect(meta.search).toBe(true); + expect(meta.search_fields).toEqual(['name', 'code']); + }); +}); + +describe('[#30955] the filter summary keeps names and operators, never a comparand', () => { + it('reads the array dialect: comparison nodes, logic words and a list of conditions', () => { + expect(summarizeExportFilter(['and', ['status', '=', SECRET], ['or', ['amount', '>', 5], ['code', 'in', [SECRET]]]])).toEqual({ + terms: [ + { field: 'status', op: '=' }, + { field: 'amount', op: '>' }, + { field: 'code', op: 'in' }, + ], + omitted: 0, + }); + expect(summarizeExportFilter([['a', '=', SECRET], 'or', ['b', 'contains', SECRET]]).terms).toEqual([ + { field: 'a', op: '=' }, + { field: 'b', op: 'contains' }, + ]); + }); + + it('records a field position that is not a machine-name path, and an operator token of no operator shape, as `?`', () => { + const { terms } = summarizeExportFilter({ [SECRET]: 1, ok_field: { [`$${SECRET}`]: 1 } }); + expect(terms).toEqual([ + { field: '?', op: '$eq' }, + { field: 'ok_field', op: '?' }, + ]); + expect(JSON.stringify(terms)).not.toContain(SECRET); + }); + + it('is bounded: terms past the bound are counted, not recorded', () => { + const filter = Object.fromEntries(Array.from({ length: EXPORT_FILTER_SUMMARY_MAX_TERMS + 8 }, (_, i) => [`f_${i}`, SECRET])); + const { terms, omitted } = summarizeExportFilter(filter); + expect(terms).toHaveLength(EXPORT_FILTER_SUMMARY_MAX_TERMS); + expect(omitted).toBe(8); + const row = buildDataExportAuditRow(exportEvent({ query: { filter } })); + expect(metadataOf(row).filter_terms_omitted).toBe(8); + }); + + it('de-duplicates a repeated term', () => { + expect(summarizeExportFilter({ $or: [{ a: 1 }, { a: 2 }, { a: { $eq: 3 } }] }).terms).toEqual([{ field: 'a', op: '$eq' }]); + }); +}); + +describe('[#30955] the row attributes the export the way the other ledger rows do', () => { + it('an agent-performed export names the client and the delegating user', () => { + const row = buildDataExportAuditRow(exportEvent({ + context: { userId: 'u_human', tenantId: 'org_a', performedBy: { clientId: 'client_agent' } as any }, + })); + expect(metadataOf(row)).toMatchObject({ performed_by: 'client_agent', on_behalf_of: 'u_human' }); + }); + + it('a service principal with no user is the actor', () => { + const row = buildDataExportAuditRow(exportEvent({ context: { actor: 'svc:nightly', tenantId: 'org_a' } as any })); + expect(row).toMatchObject({ user_id: null, actor: 'svc:nightly' }); + }); + + it('a ledger with no `actor` column gets no `actor` key', () => { + const row = buildDataExportAuditRow(exportEvent(), () => false); + expect('actor' in row).toBe(false); + }); +}); + +describe('[#30955] a lost row is loud, once per cause, and never throws', () => { + it('reports a refused ledger write at `error`, then `debug` for the same cause', async () => { + const refusal = Object.assign(new Error('ledger refused'), { code: 'SQLITE_READONLY' }); + const engine = { insert: vi.fn(async () => { throw refusal; }) } as any; + const logger = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const writer = createDataExportAuditWriter({ getEngine: () => engine, logger }); + + await expect(writer.recordDataExport(exportEvent({ query: { filter: { name: SECRET }, search: SECRET } }))).resolves.toBeUndefined(); + await expect(writer.recordDataExport(exportEvent())).resolves.toBeUndefined(); + + expect(engine.insert).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(String(logger.error.mock.calls[0][0])).toMatch(/^Data-export audit write FAILED/); + expect(logger.debug).toHaveBeenCalledTimes(1); + // The report names the object and the cause — never the export's query. + expect(JSON.stringify([...logger.error.mock.calls, ...logger.debug.mock.calls])).not.toContain(SECRET); + }); + + it('writes nothing, and says nothing, with no engine to write to', async () => { + const logger = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const writer = createDataExportAuditWriter({ getEngine: () => undefined, logger }); + await expect(writer.recordDataExport(exportEvent())).resolves.toBeUndefined(); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-audit/src/data-export-audit.ts b/packages/plugins/plugin-audit/src/data-export-audit.ts new file mode 100644 index 00000000000..307d2c3064e --- /dev/null +++ b/packages/plugins/plugin-audit/src/data-export-audit.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `export` row in the compliance ledger: one durable, run-level row per + * data export the REST export door (`GET /data/:object/export`) streamed. + * + * ## The gap this closes + * + * `export` left `sys_audit_log.action` under ADR-0087 (#8147) because nothing + * wrote it: no export of any object, the ledger itself included, left a row. + * The rule that retired it (审计面宁窄勿谎, a value only together with its + * writer) is the rule that lets it back, with this writer, its tests and the + * view that lists its rows in the same change. + * + * ## Who calls it + * + * The export door in `@objectstack/rest` (`data-action-routes.ts`), once per + * export that cleared the door's gates and began reading, whether the stream + * then completed or failed. It resolves this writer through the `audit` service + * slot (beside `recordAuthEvent` and `recordActionInvocation`) and hands over + * an EVENT, never a row: the row shape is this package's, for the reason + * `auth-event-audit.ts` gives ("a second package hand-assembling ledger rows + * would be a second de-facto definition of that shape"). + * + * A request the door refused before reading (the object's `export` exposure, + * the caller's export permission `EXPORT_NOT_PERMITTED`, a malformed query + * parameter) is not an export and writes no row: no row of data left, and the + * caller was answered the refusal. The import template (`?template=true`) + * carries no record and writes none either. + * + * ## The row + * + * The incumbent run-level shape of plugin-auth's admin user-import row + * (`admin-import-users.ts`, `action: 'import'`): + * + * - `action: 'export'`, `record_id: null` (an export is about a set of rows, + * not one record), `object_name` = the exported object; + * - `user_id` / `actor` / `tenant_id` from the caller's envelope, with the + * CRUD mirror's rules (`actor` stays two-valued, ADR-0118 D1/D5); + * - no `old_value` / `new_value`: an export is not a field diff; + * - `metadata`: `format`, `row_count`, the row limit (`limit`, + * `requested_limit`, `limit_cap`, `limit_capped`), `truncated`, a bounded + * `filter` summary, `search` (whether a term was applied), `search_fields`, + * `outcome`, and on a failure the HTTP `status` and the error's machine code; + * `performed_by` / `on_behalf_of` when an agent performed the call and + * `impersonated_by` under an impersonation session, the pairs the CRUD + * mirror and the action-invocation row write. + * + * ## No exported value, ever + * + * The row records WHAT was asked, never what was in it: the filter is reduced + * to its field names and operators ({@link summarizeExportFilter}, every + * comparand dropped), the search term to the fact that one was applied, and a + * failure to its status and machine code (never the error's text, which a + * driver can fill with a comparand). Nothing here reads a row of the export. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { isMissingTableError } from '@objectstack/types'; +import { + auditFailureCauseKey, + auditFailureCauseSummary, + createFieldPresenceProbe, +} from './audit-writers.js'; +import type { AuthEventAuditLogger } from './auth-event-audit.js'; +import { IMPERSONATED_BY_KEY, impersonatorOf } from './impersonation-attribution.js'; + +/** The `sys_audit_log.action` value this writer emits. */ +export const DATA_EXPORT_AUDIT_ACTION = 'export' as const; + +/** How the export ended: the stream completed, or it failed after reading began. */ +export type DataExportOutcome = 'succeeded' | 'failed'; + +/** + * One export, in the vocabulary of what HAPPENED. The door builds it; this + * writer turns it into a row. + */ +export interface DataExportAuditEvent { + /** + * The caller's envelope. Read for `userId`, `actor`, `tenantId`, + * `performedBy` and `impersonatedBy` — nothing else. + */ + context: + | Pick + | undefined; + /** The exported object's name. */ + objectName: string; + /** The served format the file was written in (`csv`, `json`, `xlsx`). */ + format: string; + /** Rows written into the file. */ + rowCount: number; + /** The row limit the export ran under, after the door's cap. */ + limit: number; + /** The row limit the caller asked for (the door's default when it named none). */ + requestedLimit: number; + /** The door's hard row cap. */ + limitCap: number; + /** + * The file stopped at {@link limit} while the read did not say the result + * set ended there: rows that matched are not in the file. + */ + truncated: boolean; + /** + * The query as the door read it. Reduced to names and operators before it + * reaches the row: the comparands and the search term are never recorded. + */ + query?: { filter?: unknown; search?: unknown; searchFields?: unknown }; + outcome: DataExportOutcome; + /** On a failure: the HTTP status the door answered. */ + status?: number; + /** On a failure: the error's machine code, as the door answered it. */ + errorCode?: string; +} + +/** What {@link createDataExportAuditWriter} adds to the `audit` slot. */ +export interface DataExportAuditWriter { + /** + * Record one export in `sys_audit_log`. Never throws — an audit write must + * never change the file a caller downloads — and reports a lost row loudly, + * once per cause. + */ + recordDataExport(event: DataExportAuditEvent): Promise; +} + +export interface DataExportAuditWriterOptions { + /** Resolve the data engine at CALL time — see `AuthEventAuditSinkOptions.getEngine`. */ + getEngine(): IDataEngine | undefined; + logger?: AuthEventAuditLogger; +} + +/** The most `{ field, op }` terms a filter summary keeps. */ +export const EXPORT_FILTER_SUMMARY_MAX_TERMS = 32; + +/** The deepest a filter summary walks into nested logic. */ +const EXPORT_FILTER_SUMMARY_MAX_DEPTH = 16; + +/** The most `search_fields` names the row keeps. */ +const EXPORT_SEARCH_FIELDS_MAX = 32; + +/** + * A field path the summary records: machine-name segments joined by dots + * (a relation path), bounded. Anything else is caller text in a field + * position, recorded as `?` rather than verbatim. + */ +const FIELD_PATH = /^[A-Za-z_][A-Za-z0-9_]{0,63}(?:\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,3}$/; + +/** An operator the summary records: a `$`-key, an infix word or an infix symbol. */ +const OPERATOR = /^(?:\$[A-Za-z][A-Za-z0-9]{0,31}|[A-Za-z][A-Za-z_]{0,31}|=|==|!=|<>|>|>=|<|<=)$/; + +/** The logical keys of the object dialect; their values are sub-conditions, never comparands. */ +const LOGICAL_KEYS = new Set(['$and', '$or', '$not', '$nor']); + +/** The logical words of the array dialect (`['and', c1, c2]`, `[c1, 'or', c2]`). */ +const ARRAY_LOGIC_WORDS = new Set(['and', 'or', 'not']); + +/** One recorded term of a filter: which field it reads and how. Never the comparand. */ +export interface ExportFilterTerm { + field: string; + op: string; +} + +/** A filter reduced to its terms, and how many terms past the bound were left out. */ +export interface ExportFilterSummary { + terms: ExportFilterTerm[]; + omitted: number; +} + +function recordedField(path: string): string { + return FIELD_PATH.test(path) ? path : '?'; +} + +function recordedOperator(op: string): string { + return OPERATOR.test(op) ? op : '?'; +} + +function isPlainObject(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +/** + * Reduce an export's `filter` to the fields it reads and the operators it + * applies — the one place a filter crosses into the ledger, and the reason no + * comparand can: only KEYS and operator tokens are read, every value position + * is dropped unread. + * + * Both filter dialects the door accepts are walked: the object dialect + * (`{ field: value }`, `{ field: { $op: value } }`, `$and` / `$or` / `$not`, + * a nested relation path) and the array dialect (`[field, op, value]`, + * `['and', ...]`, `[c1, 'or', c2]`, a list of conditions). A field position + * that is not a machine-name path, or an operator token of no operator's + * shape, is recorded as `?`. Terms are de-duplicated in first-seen order and + * bounded at {@link EXPORT_FILTER_SUMMARY_MAX_TERMS}; the rest are counted in + * `omitted`. + */ +export function summarizeExportFilter(filter: unknown): ExportFilterSummary { + const terms: ExportFilterTerm[] = []; + const seen = new Set(); + let omitted = 0; + + const add = (field: string, op: string): void => { + const term = { field: recordedField(field), op: recordedOperator(op) }; + const key = JSON.stringify([term.field, term.op]); + if (seen.has(key)) return; + seen.add(key); + if (terms.length >= EXPORT_FILTER_SUMMARY_MAX_TERMS) { + omitted++; + return; + } + terms.push(term); + }; + + /** One field's condition: an operator map, a nested relation path, or a comparand (implicit equality). */ + const walkFieldCondition = (path: string, condition: unknown, depth: number): void => { + if (depth > EXPORT_FILTER_SUMMARY_MAX_DEPTH) return; + if (!isPlainObject(condition)) { + // A comparand: `{ field: value }` is equality. The value is not read. + add(path, '$eq'); + return; + } + const keys = Object.keys(condition); + if (keys.length === 0) { + add(path, '$eq'); + return; + } + for (const key of keys) { + if (key.startsWith('$')) { + if (key === '$not' && isPlainObject(condition[key])) { + // `{ field: { $not: { $op: value } } }` — the negated operators, under this field. + add(path, '$not'); + walkFieldCondition(path, condition[key], depth + 1); + } else { + add(path, key); + } + } else { + // A nested relation path: `{ owner: { name: … } }`. + walkFieldCondition(`${path}.${key}`, condition[key], depth + 1); + } + } + }; + + const walk = (node: unknown, depth: number): void => { + if (depth > EXPORT_FILTER_SUMMARY_MAX_DEPTH || node === null || node === undefined) return; + if (Array.isArray(node)) { + if (node.length === 3 && typeof node[0] === 'string' && typeof node[1] === 'string' + && !ARRAY_LOGIC_WORDS.has(node[0].toLowerCase())) { + // A comparison node: `[field, op, value]`. The value is not read. + add(node[0], node[1].toLowerCase()); + return; + } + for (const child of node) { + if (typeof child === 'string') continue; // a logic word between or before conditions + walk(child, depth + 1); + } + return; + } + if (!isPlainObject(node)) return; // a bare scalar is no condition + for (const [key, value] of Object.entries(node)) { + if (LOGICAL_KEYS.has(key)) { + if (Array.isArray(value)) for (const child of value) walk(child, depth + 1); + else walk(value, depth + 1); + } else if (key.startsWith('$')) { + // An operator with no field above it: record that it was applied, not its operand. + add('?', key); + } else { + walkFieldCondition(key, value, depth + 1); + } + } + }; + + walk(filter, 0); + return { terms, omitted }; +} + +/** JSON that cannot throw on a cyclic/odd value — same rule as the CRUD writer. */ +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function nonEmpty(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; +} + +function wholeNumber(v: unknown): number | undefined { + return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.trunc(v) : undefined; +} + +/** The `searchFields` override as names only, each a field path or `?`, bounded. */ +function searchFieldNames(v: unknown): string[] | undefined { + if (!Array.isArray(v)) return undefined; + return v + .filter((s): s is string => typeof s === 'string' && s.length > 0) + .slice(0, EXPORT_SEARCH_FIELDS_MAX) + .map(recordedField); +} + +/** + * The row for one export — exported so the shape is pinned without an engine. + * `objectHasField` answers whether the deployed ledger has the + * conditionally-present `actor` column (`createFieldPresenceProbe`). + */ +export function buildDataExportAuditRow( + event: DataExportAuditEvent, + objectHasField: (objectName: string, field: string) => boolean = () => true, +): Record { + const ctx = event.context ?? {}; + const userId = nonEmpty(ctx.userId); + const performedBy = nonEmpty(ctx.performedBy?.clientId); + const query = event.query ?? {}; + + const limit = wholeNumber(event.limit); + const requestedLimit = wholeNumber(event.requestedLimit); + const limitCap = wholeNumber(event.limitCap); + const metadata: Record = { + format: nonEmpty(event.format) ?? null, + row_count: wholeNumber(event.rowCount) ?? 0, + limit: limit ?? null, + requested_limit: requestedLimit ?? null, + limit_cap: limitCap ?? null, + // The request named more rows than the door's cap allows. + limit_capped: requestedLimit !== undefined && limitCap !== undefined && requestedLimit > limitCap, + truncated: event.truncated === true, + }; + if (query.filter !== undefined && query.filter !== null) { + const summary = summarizeExportFilter(query.filter); + metadata.filter = summary.terms; + if (summary.omitted > 0) metadata.filter_terms_omitted = summary.omitted; + } + // Whether a full-text term narrowed the file — never the term: it is text + // the caller expects to find IN the exported cells. + metadata.search = typeof query.search === 'string' && query.search.trim() !== ''; + const searchFields = metadata.search ? searchFieldNames(query.searchFields) : undefined; + if (searchFields) metadata.search_fields = searchFields; + metadata.outcome = event.outcome === 'failed' ? 'failed' : 'succeeded'; + if (event.outcome === 'failed') { + if (typeof event.status === 'number') metadata.status = event.status; + const errorCode = nonEmpty(event.errorCode); + if (errorCode) metadata.error_code = errorCode; + } + // [ADR-0090 D10 rule 4] The same pair, under the same names, the CRUD mirror + // writes for a delegated write — present only when an agent performed the + // call, so the two shapes stay distinguishable by absence. + if (performedBy) { + metadata.performed_by = performedBy; + metadata.on_behalf_of = userId ?? null; + } + // The impersonating administrator, under the key the CRUD mirror writes, + // present only when an impersonation session made the call. + const impersonatedBy = impersonatorOf(ctx); + if (impersonatedBy) metadata[IMPERSONATED_BY_KEY] = impersonatedBy; + + const row: Record = { + action: DATA_EXPORT_AUDIT_ACTION, + user_id: userId ?? null, + object_name: nonEmpty(event.objectName) ?? null, + // Run-level: an export is about a set of rows, so it names no record — + // the ledger's read gate serves it as a row about no record + // (`audit-log-read-visibility.ts`). + record_id: null, + old_value: null, + new_value: null, + // [ADR-0131 D7] The organization the export ran in — the attribution + // column the `sys_audit_log_org` row policy scopes readers on. + tenant_id: nonEmpty(ctx.tenantId) ?? null, + metadata: safeStringify(metadata), + }; + if (objectHasField('sys_audit_log', 'actor')) { + // ADR-0014 D2's principal label, by the CRUD mirror's rule: the user, else + // a service principal the host put on the envelope, else null. + row.actor = userId ?? nonEmpty(ctx.actor) ?? null; + } + return row; +} + +/** Create the data-export writer for the `audit` service slot. */ +export function createDataExportAuditWriter(opts: DataExportAuditWriterOptions): DataExportAuditWriter { + const { getEngine, logger } = opts; + let probe: ((objectName: string, field: string) => boolean) | undefined; + let probedEngine: IDataEngine | undefined; + + /** + * Report a lost row — once per CAUSE (`auditFailureCauseKey`), the rule the + * auth-event sink and the action-invocation writer follow. A durability + * degradation, so `error`, falling back to `warn`, never to silence. Names + * the object and the cause; never the event's query. + */ + const reportedCauses = new Set(); + const reportWriteFailure = (event: DataExportAuditEvent, err: unknown): void => { + const detail = String((err as any)?.message ?? err); + try { + const cause = auditFailureCauseKey('sys_audit_log', err); + if (reportedCauses.has(cause)) { + logger?.debug?.('Data-export audit write failed (already reported)', { + object: event.objectName, + err: detail, + cause, + }); + return; + } + reportedCauses.add(cause); + const message = + `Data-export audit write FAILED (${auditFailureCauseSummary(err, detail)}) — the compliance trail ` + + 'is now INCOMPLETE. The export itself was answered exactly as it would have been without the ' + + 'ledger: the caller holds the file, so nothing downstream looks broken; only the `sys_audit_log` ' + + 'row recording WHO exported WHICH object, how many rows and under which filter never landed, and ' + + 'nothing retries it. Every later export failing THIS WAY loses its row the same way (this CAUSE is ' + + 'reported ONCE — raise the log level to `debug` to see the rest; a DIFFERENT cause gets its own ' + + '`error` line). ' + + (isMissingTableError(err, 'sys_audit_log') + ? 'Fix: confirm `sys_audit_log` is reachable from the connection this write ran on — its ADR-0057 ' + + '§3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever one is registered, ' + + 'so a "no such table" here usually means the write ran against a DIFFERENT datasource than the ' + + 'one the table was created in. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on ' + + 'the primary datasource.' + : 'Fix: resolve the driver fault quoted at the head of this line on the connection this write ran ' + + 'on — every export that hits it loses its row until it is resolved.'); + if (logger?.error) { + logger.error(message, err instanceof Error ? err : new Error(detail), { object: event.objectName }); + } else { + logger?.warn?.(message, { object: event.objectName, err: detail }); + } + } catch { + /* logging must never break the export's answer */ + } + }; + + /** + * Write the ledger row. A NAMED callee so `pnpm check:durability-log-level` + * anchors on it (`DURABILITY_CRITICAL_CALLEES`): a later edit cannot walk + * the failure report above back down to `warn`. + */ + const persistDataExportAuditRow = async (engine: IDataEngine, row: Record): Promise => { + await engine.insert('sys_audit_log', row, { context: { isSystem: true } }); + }; + + return { + async recordDataExport(event: DataExportAuditEvent): Promise { + if (!event || typeof event.objectName !== 'string' || event.objectName === '') return; + const engine = getEngine(); + if (!engine) return; + if (probe === undefined || probedEngine !== engine) { + probe = createFieldPresenceProbe(engine); + probedEngine = engine; + } + let row: Record; + try { + row = buildDataExportAuditRow(event, probe); + } catch (err) { + // A row that cannot be built is a row that does not land: reported as one. + reportWriteFailure(event, err); + return; + } + try { + await persistDataExportAuditRow(engine, row); + } catch (err) { + // #5226's class on the export seam: a DURABILITY degradation — the + // file was served, the system looks normal, and the row that says who + // exported it is simply absent. + reportWriteFailure(event, err); + } + }, + }; +} diff --git a/packages/plugins/plugin-audit/src/index.ts b/packages/plugins/plugin-audit/src/index.ts index f592a3e24c1..b1688c4c67a 100644 --- a/packages/plugins/plugin-audit/src/index.ts +++ b/packages/plugins/plugin-audit/src/index.ts @@ -24,6 +24,23 @@ export type { ActionInvocationBranch, ActionInvocationOutcome, } from './action-invocation-audit.js'; +// [#30955] The run-level `export` row writer the REST export door reaches +// through the `audit` slot. +export { + buildDataExportAuditRow, + createDataExportAuditWriter, + DATA_EXPORT_AUDIT_ACTION, + EXPORT_FILTER_SUMMARY_MAX_TERMS, + summarizeExportFilter, +} from './data-export-audit.js'; +export type { + DataExportAuditEvent, + DataExportAuditWriter, + DataExportAuditWriterOptions, + DataExportOutcome, + ExportFilterSummary, + ExportFilterTerm, +} from './data-export-audit.js'; export { createReadAuditBatcher, extractDetailReadId, diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts index fc778921d34..c06ad01b77c 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-action-labels.test.ts @@ -25,7 +25,10 @@ describe('sys_audit_log.action option labels', () => { it('keeps the stored values', () => { expect(options.map((o) => o.value)).toEqual([ 'create', 'read', 'update', 'delete', 'login', 'logout', 'login_failed', 'account_locked', - 'config_change', 'import', 'platform_admin_standing_change', + 'config_change', 'import', + // #30955: `export` restored writer-first, beside `import`; no existing value moved. + 'export', + 'platform_admin_standing_change', // #27908: a new value, added writer-first; no existing value moved. 'action_invocation', ]); diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts index ce3eec298ac..8143efbedb4 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts @@ -8,6 +8,11 @@ import { SysAuditLog } from './index.js'; * `sys_audit_log.action` enum (maintainer ruling 2026-08-12 on #7675, ADR-0049 * enforce-or-remove, registered under ADR-0087 as `audit-log-action-enum-retired`). * + * #30955 — `export` came back WRITER-FIRST, by the rule the last paragraph of + * this docblock states: its writer is `data-export-audit.ts`, called by the + * REST export door, and its list view is `config_changes`. It moved from + * `RETIRED_ACTIONS` to `ACTIONS_WITH_WRITERS` in the same change. + * * #8315 — `restore` joins them, under the same ruling carried by triage and * registered as `audit-log-action-restore-retired`. It was the last unresolved * value from #7675's survey, and the least ambiguous of the three: `actionFor()` @@ -45,12 +50,6 @@ const RETIRED_ACTIONS: ReadonlyArray; +} + +/** What the last `findData` of an export said about the rows past its page. */ +interface ExportPageMarkers { + hasMore?: unknown; + total?: unknown; +} + +/** + * [#30955] Whether rows that matched are missing from the file: it stopped at + * its row limit and the last read did not say the result set ended there. + * A file that ran out before its limit is complete. At the limit, the read's + * own page markers decide — `hasMore` first, then `total` — and a read that + * answers neither (a protocol with no page metadata) cannot rule more rows out, + * so the file reads as truncated rather than as complete. + */ +function exportTruncatedAtLimit(rowCount: number, limit: number, lastPage: ExportPageMarkers | undefined): boolean { + if (rowCount < limit) return false; + if (lastPage?.hasMore === true) return true; + if (lastPage?.hasMore === false) return false; + if (typeof lastPage?.total === 'number') return lastPage.total > rowCount; + return true; +} + +/** + * [#30955] Hand one export to the `audit` slot. Never throws and never changes + * the door's answer — it runs after the response is complete, and the writer + * reports its own lost rows (`plugin-audit`). A host with no audit plugin has + * no ledger at all, so the call is skipped silently, as every other ledger row + * is on such a host. + */ +async function recordDataExport( + host: Pick, + environmentId: string | undefined, + req: unknown, + event: DataExportAuditEvent, +): Promise { + let sink: Partial | undefined; + try { + sink = (await host.resolveAuditService(environmentId, req)) as Partial | undefined; + } catch { + return; // no audit slot on this kernel: no ledger to write to + } + if (!sink || typeof sink.recordDataExport !== 'function') return; + try { + await sink.recordDataExport(event); + } catch { + // The writer never throws by contract; a host's own sink that does + // must not turn a served export into a failed request. + } +} + /** * Register object-specific action endpoints that don't fit the generic CRUD * shape — `RestServer#registerDataActionEndpoints`' body, called by that method @@ -1158,8 +1243,15 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: method: 'GET', path: `${dataPath}/:object/export`, handler: async (req: any, res: any) => { + // [#30955] The export this request is running, once it began + // reading: the ledger's `export` row is recorded from it after the + // stream ends, completed or failed. Unset for every request the + // door refuses first, which is no export and records nothing. + let exportRun: Omit | undefined; + // What the export's last read said about the rows past its page. + let exportLastPage: ExportPageMarkers | undefined; + const environmentId = isScoped ? req.params?.environmentId : undefined; try { - const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; @@ -1500,6 +1592,23 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: let skip = 0; let xlsx: Awaited> | null = null; + // [#30955] Every gate has passed and the read begins: from here + // on this request is an export, and the ledger records it. + exportRun = { + context, + objectName, + format, + rowCount: 0, + limit, + requestedLimit, + limitCap: HARD_CAP, + query: { + ...(filter !== undefined ? { filter } : {}), + ...(search ? { search } : {}), + ...(search && searchFields ? { searchFields } : {}), + }, + }; + while (exported < limit) { const take = Math.min(chunkSize, limit - exported); const findArgs: ServerScopedDataRequest = { @@ -1532,6 +1641,9 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: ...(context ? { context } : {}), }; const result: any = await p.findData(findArgs); + exportLastPage = result && typeof result === 'object' && !Array.isArray(result) + ? { hasMore: result.hasMore, total: result.total } + : undefined; // `findData` returns `{ object, records, total, hasMore }`; // accept the legacy `data` / `rows` aliases and a bare array // so test doubles and alternate protocols keep working. @@ -1621,6 +1733,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } firstChunk = false; exported += rows.length; + exportRun.rowCount = exported; skip += rows.length; if (rows.length < take) break; } @@ -1663,6 +1776,35 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: // client receives a truncated stream which signals failure. try { handleRouteError(res, error, String(req.params?.object || '')); } catch { try { res.end(); } catch { /* swallow */ } } + // [#30955] An export that failed after it began reading is + // still an export: rows may already have left in the part of + // the stream that was written. Its row carries the status and + // machine code the shared resolver reads off the error — + // never the error's text, which a driver can fill with a + // comparand. + if (exportRun) { + const { status, code } = resolveThrownHttpError(error, 500); + await recordDataExport(this, environmentId, req, { + ...exportRun, + truncated: exportTruncatedAtLimit(exportRun.rowCount, exportRun.limit, exportLastPage), + outcome: 'failed', + status, + ...(typeof code === 'string' && code.length > 0 ? { errorCode: code } : {}), + }); + } + return; + } + // [#30955] The file is complete and the response has ended: the + // ledger's `export` row is written now, so its write can neither + // delay nor alter the file the caller holds. A lost row is the + // writer's to report (`plugin-audit`'s `data-export-audit.ts`), + // loudly and once per cause. + if (exportRun) { + await recordDataExport(this, environmentId, req, { + ...exportRun, + truncated: exportTruncatedAtLimit(exportRun.rowCount, exportRun.limit, exportLastPage), + outcome: 'succeeded', + }); } }, metadata: { diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 03ff6c2f2b0..671f77284e0 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -537,6 +537,17 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // default permission set. const securityServiceProvider = environmentScopedProvider('security'); + // [#30955] Audit service resolver — used by the data export door, + // which records every export it streams as a run-level `export` row + // through the slot's `recordDataExport` (plugin-audit). Returns + // undefined when plugin-audit is not mounted: no ledger, no row. + // Environment-scoped like the seams above: plugin-audit's writers + // write the ledger of the kernel they are registered on, so an + // environment's exports are recorded in that environment's ledger. + // `unknown`, deliberately: the slot's members are read structurally + // by the one door that calls it (`data-action-routes.ts`). + const auditServiceProvider = environmentScopedProvider('audit'); + // Auth service resolver — used by RestServer.resolveExecCtx after // the request's environment kernel (and the default environment's) // answered no `auth`. [#30192] Environment-scoped: identity is @@ -577,7 +588,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // `RouteManager` in the first place. let restServer: RestServer | undefined; try { - restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, /* retired saved-report slot (#20102) */ undefined, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider, tenancyServiceProvider); + restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, /* retired saved-report slot (#20102) */ undefined, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider, requestEnvResolver, metadataServiceProvider, tenancyServiceProvider, auditServiceProvider); restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 61c42d34a8d..62efab032b5 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2093,6 +2093,12 @@ export class RestServer { * /security/suggested-bindings (D5/D9) and /security/explain (D6) * routes (plugin-security). */ private securityServiceProvider?: (environmentId?: string) => Promise; + /** + * [#30955] `audit` service resolver — the compliance ledger's writers + * (plugin-audit). Read by the export door, which hands the slot's + * `recordDataExport` one event per export it streams. + */ + private auditServiceProvider?: (environmentId?: string) => Promise; /** Sync probe: is a kernel service registered? Single-env path for nav * capability gates (ADR-0057 D10) — resolveExecCtx sets no kernel in * single-kernel deployments, so this prevents the gate failing open. */ @@ -2155,6 +2161,11 @@ export class RestServer { * providers would silently re-bind every positional argument after it. */ tenancyServiceProvider?: (environmentId?: string) => Promise, + /** + * [#30955] Appended LAST for the #15256 reason above: the `audit` slot + * the export door records each export through. + */ + auditServiceProvider?: (environmentId?: string) => Promise, ) { this.protocol = protocol; this.config = this.normalizeConfig(config); @@ -2176,6 +2187,7 @@ export class RestServer { this.requestEnvResolver = requestEnvResolver; this.metadataServiceProvider = metadataServiceProvider; this.tenancyServiceProvider = tenancyServiceProvider; + this.auditServiceProvider = auditServiceProvider; } /** @@ -5768,6 +5780,7 @@ export class RestServer { resolveMessageTranslator: (...a) => this.resolveMessageTranslator(...a), resolveSecurityService: (...a) => this.resolveSecurityService(...a), resolveI18nService: (...a) => this.resolveI18nService(...a), + resolveAuditService: (...a) => this.resolveAuditService(...a), extractLocale: (...a) => this.extractLocale(...a), answerImportTemplate: (...a) => this.answerImportTemplate(...a), }; @@ -5947,6 +5960,29 @@ export class RestServer { catch { return undefined; } } + /** + * [#30955] Resolve the environment's `audit` service — the compliance + * ledger's writers, which plugin-audit registers on the kernel that holds + * `sys_audit_log`. Asked for the REQUEST's environment, the shared + * resolver's answer, exactly as {@link resolveSecurityService} is. + * + * Returns `undefined` when no audit service is reachable: a deployment + * without plugin-audit has no ledger at all, so there is nothing to record + * an export in, as for every other ledger row on such a host. Never + * throws: recording an export must not change the export's answer. + */ + private async resolveAuditService( + environmentId?: string, + req?: any, + ): Promise { + if (!this.auditServiceProvider) return undefined; + try { + const envId = await this.resolveRequestEnvironmentId(environmentId, req); + return await this.auditServiceProvider(envId); + } + catch { return undefined; } + } + /** * Register global cross-object search endpoint (M10.5). * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5 From b2acde54a3930e825f247e08ea5a7a2fe89866a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:54:14 +0000 Subject: [PATCH 02/11] test(rest): the export door hands the ledger one event per streamed export, none for a refused one Pins the door half of #30955 on a real ObjectQL + sqlite boot: one event per csv/json/xlsx export, the ledger's own export, truncation evidence, a failed read after streaming began, and no event for EXPORT_NOT_PERMITTED, a 400 parse refusal or the template. A throwing or empty audit slot leaves the file as is. Also: the `audit` provider row in the slot-lookups pin, the persistDataExportAuditRow durability-critical callee, and the `export` option label in every plugin-audit locale bundle. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- .../src/translations/ar.objects.generated.ts | 1 + .../src/translations/de.objects.generated.ts | 1 + .../de.source-hashes.generated.ts | 1 + .../src/translations/en.objects.generated.ts | 1 + .../translations/es-ES.objects.generated.ts | 1 + .../src/translations/fr.objects.generated.ts | 1 + .../fr.source-hashes.generated.ts | 1 + .../translations/ja-JP.objects.generated.ts | 1 + .../src/translations/ko.objects.generated.ts | 1 + .../translations/pt-BR.objects.generated.ts | 1 + .../src/translations/ru.objects.generated.ts | 1 + .../translations/zh-CN.objects.generated.ts | 1 + .../translations/zh-Hant.objects.generated.ts | 1 + packages/rest/src/export-integration.test.ts | 189 ++++++++++++++++++ .../src/rest-api-plugin-slot-lookups.test.ts | 8 +- ...check-durability-degradation-log-level.mjs | 4 + 16 files changed, 212 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-audit/src/translations/ar.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ar.objects.generated.ts index 0589f9d51aa..7b1b4431874 100644 --- a/packages/plugins/plugin-audit/src/translations/ar.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ar.objects.generated.ts @@ -37,6 +37,7 @@ export const arObjects: NonNullable = { account_locked: "الحساب مقفل", config_change: "تغيير الإعدادات", import: "استيراد", + export: "تصدير", platform_admin_standing_change: "تغيير صلاحية مسؤول المنصّة", action_invocation: "استدعاء إجراء" } diff --git a/packages/plugins/plugin-audit/src/translations/de.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/de.objects.generated.ts index 363052bd9a4..033cf315a09 100644 --- a/packages/plugins/plugin-audit/src/translations/de.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/de.objects.generated.ts @@ -37,6 +37,7 @@ export const deObjects: NonNullable = { account_locked: "Konto gesperrt", config_change: "Konfigurationsänderung", import: "Import", + export: "Export", platform_admin_standing_change: "Änderung des Plattform-Admin-Status", action_invocation: "Aktionsaufruf" } diff --git a/packages/plugins/plugin-audit/src/translations/de.source-hashes.generated.ts b/packages/plugins/plugin-audit/src/translations/de.source-hashes.generated.ts index d68f687aa04..820ea881652 100644 --- a/packages/plugins/plugin-audit/src/translations/de.source-hashes.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/de.source-hashes.generated.ts @@ -19,6 +19,7 @@ export const deGeneratedSourceHashes: Readonly> = { "objects.sys_activity.fields.url.label": "2203b0b9f72534f7", + "objects.sys_audit_log.fields.action.options.export": "cdfbf4ace38f5a21", "objects.sys_audit_log.fields.action.options.import": "05c80d3a61ec3290", "objects.sys_comment.fields.thread_id.label": "7bf9fcadc2761a11", "objects.sys_comment_reaction.fields.emoji.label": "f281bddefc11042a", diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 810dfa59d2f..76db9d0c3e4 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -37,6 +37,7 @@ export const enObjects: NonNullable = { account_locked: "Account Locked", config_change: "Config Change", import: "Import", + export: "Export", platform_admin_standing_change: "Platform Admin Standing Change", action_invocation: "Action Invocation" } diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index 74682c682fa..67abde4addf 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -37,6 +37,7 @@ export const esESObjects: NonNullable = { account_locked: "Cuenta bloqueada", config_change: "Cambio de configuración", import: "Importar", + export: "Exportar", platform_admin_standing_change: "Cambio de acceso de administrador de plataforma", action_invocation: "Invocación de acción" } diff --git a/packages/plugins/plugin-audit/src/translations/fr.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/fr.objects.generated.ts index 530e28bd561..4abcd9c9a82 100644 --- a/packages/plugins/plugin-audit/src/translations/fr.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/fr.objects.generated.ts @@ -37,6 +37,7 @@ export const frObjects: NonNullable = { account_locked: "Compte verrouillé", config_change: "Modification de configuration", import: "Import", + export: "Export", platform_admin_standing_change: "Changement de statut d’administrateur de plateforme", action_invocation: "Appel d’action" } diff --git a/packages/plugins/plugin-audit/src/translations/fr.source-hashes.generated.ts b/packages/plugins/plugin-audit/src/translations/fr.source-hashes.generated.ts index 122315505bc..aee5ba0df8a 100644 --- a/packages/plugins/plugin-audit/src/translations/fr.source-hashes.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/fr.source-hashes.generated.ts @@ -21,6 +21,7 @@ export const frGeneratedSourceHashes: Readonly> = { "objects.sys_activity.fields.type.label": "901bb552898b2cab", "objects.sys_activity.fields.url.label": "2203b0b9f72534f7", "objects.sys_audit_log.fields.action.label": "a36bba313a7aa9ab", + "objects.sys_audit_log.fields.action.options.export": "cdfbf4ace38f5a21", "objects.sys_audit_log.fields.action.options.import": "05c80d3a61ec3290", "objects.sys_comment.fields.mentions.label": "4c16427f1faa3679", "objects.sys_comment_reaction.fields.emoji.label": "f281bddefc11042a", diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 67ac92a1168..801b4ab6550 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -37,6 +37,7 @@ export const jaJPObjects: NonNullable = { account_locked: "アカウントロック", config_change: "構成変更", import: "インポート", + export: "エクスポート", platform_admin_standing_change: "プラットフォーム管理者権限の変更", action_invocation: "アクション実行" } diff --git a/packages/plugins/plugin-audit/src/translations/ko.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ko.objects.generated.ts index d94303168e7..e220b1108e7 100644 --- a/packages/plugins/plugin-audit/src/translations/ko.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ko.objects.generated.ts @@ -37,6 +37,7 @@ export const koObjects: NonNullable = { account_locked: "계정 잠김", config_change: "구성 변경", import: "가져오기", + export: "내보내기", platform_admin_standing_change: "플랫폼 관리자 권한 변경", action_invocation: "작업 호출" } diff --git a/packages/plugins/plugin-audit/src/translations/pt-BR.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/pt-BR.objects.generated.ts index 4f8c895ab7d..de614d5d5d4 100644 --- a/packages/plugins/plugin-audit/src/translations/pt-BR.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/pt-BR.objects.generated.ts @@ -37,6 +37,7 @@ export const ptBRObjects: NonNullable = { account_locked: "Conta bloqueada", config_change: "Alteração de configuração", import: "Importação", + export: "Exportação", platform_admin_standing_change: "Alteração do status de administrador da plataforma", action_invocation: "Invocação de ação" } diff --git a/packages/plugins/plugin-audit/src/translations/ru.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ru.objects.generated.ts index 64ddfbc63c0..efbe560fe76 100644 --- a/packages/plugins/plugin-audit/src/translations/ru.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ru.objects.generated.ts @@ -37,6 +37,7 @@ export const ruObjects: NonNullable = { account_locked: "Учётная запись заблокирована", config_change: "Изменение конфигурации", import: "Импорт", + export: "Экспорт", platform_admin_standing_change: "Изменение статуса администратора платформы", action_invocation: "Вызов действия" } diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 4882979e304..5c16090a665 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -37,6 +37,7 @@ export const zhCNObjects: NonNullable = { account_locked: "账户锁定", config_change: "配置变更", import: "导入", + export: "导出", platform_admin_standing_change: "平台管理员权限变更", action_invocation: "动作调用" } diff --git a/packages/plugins/plugin-audit/src/translations/zh-Hant.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-Hant.objects.generated.ts index ddb813ff12c..454389155c2 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-Hant.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-Hant.objects.generated.ts @@ -37,6 +37,7 @@ export const zhHantObjects: NonNullable = { account_locked: "帳戶已鎖定", config_change: "設定變更", import: "匯入", + export: "匯出", platform_admin_standing_change: "平台管理員權限變更", action_invocation: "動作呼叫" } diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index b1b4652c27f..d13548d2196 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -694,3 +694,192 @@ describe('export route — partial masking parity (#8993)', () => { for (const row of arr) expect(row.title).toBe('写**'); }); }); + +// =========================================================================== +// [#30955] The export door records every export it streams in the compliance +// ledger: one event per export to the `audit` slot's `recordDataExport` +// (plugin-audit's `data-export-audit.ts` owns the row and reduces the query +// to names and operators — pinned there, and both halves together on the +// showcase in the dogfood suite's `data-export-audit-row.dogfood.test.ts`). +// Here: the real streaming door over the real engine, with the `audit` +// provider handed to the constructor exactly as `rest-api-plugin.ts` does. +// =========================================================================== +describe('[#30955] export route — the ledger hears of every export it streams', () => { + const LEDGER = 'sys_audit_log'; + + /** A stand-in for the ledger the door must NOT skip: same name, a few rows. */ + const LEDGER_OBJECT = { + name: LEDGER, + label: 'Audit Log', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + action: { name: 'action', type: 'text' as const }, + object_name: { name: 'object_name', type: 'text' as const }, + }, + }; + + async function bootAudited(opts: { + sink?: Record; + canExport?: (object: string, context?: any) => Promise; + } = {}) { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(makeSqliteDriver(), true); + await engine.init(); + engine.registry.registerObject(USER as any); + engine.registry.registerObject(TASK as any); + engine.registry.registerObject(LEDGER_OBJECT as any); + await engine.syncSchemas(); + await engine.insert('user', { id: 'u1', name: '张三' }); + await engine.insert('task', { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }); + await engine.insert('task', { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u1' }); + for (const id of ['a1', 'a2', 'a3']) await engine.insert(LEDGER, { id, action: 'create', object_name: 'task' }); + for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); + } + const protocol = new ObjectStackProtocolImplementation(engine as any); + const events: any[] = []; + // The writer's stand-in: it records the event and, like the real writer, + // inserts a row into the ledger — so exporting the ledger is measured + // against a write into it. + const sink = opts.sink ?? { + recordDataExport: async (event: any) => { + events.push(event); + await engine.insert(LEDGER, { action: 'export', object_name: event.objectName }); + }, + }; + const securityServiceProvider = opts.canExport ? async () => ({ canExport: opts.canExport }) : undefined; + const rest = new RestServer( + createMockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + undefined, // authServiceProvider + undefined, // objectQLProvider + undefined, // emailServiceProvider + undefined, // sharingServiceProvider + undefined, // retired reports slot + undefined, // approvalsServiceProvider + undefined, // sharingRulesServiceProvider + undefined, // i18nServiceProvider + undefined, // analyticsServiceProvider + undefined, // settingsServiceProvider + undefined, // serviceExistsProvider + securityServiceProvider, + undefined, // requestEnvResolver + undefined, // metadataServiceProvider + undefined, // tenancyServiceProvider + async () => sink, // auditServiceProvider + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user', tenantId: 'org_a' }); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + )!; + const run = async (object: string, query: Record) => { + const { res, chunks, getStatus, getJson } = makeRes(); + await route.handler({ params: { object }, query } as any, res); + return { status: getStatus(), json: getJson(), text: chunks.join('') }; + }; + return { engine, events, run }; + } + + it.each(['csv', 'json', 'xlsx'] as const)('a completed %s export of an ordinary object hands ONE event with its format and row count', async (format) => { + const { events, run } = await bootAudited(); + const answer = await run('task', { format }); + expect(answer.status).toBe(200); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + objectName: 'task', + format, + rowCount: 2, + limit: 10_000, + requestedLimit: 10_000, + limitCap: 50_000, + truncated: false, + outcome: 'succeeded', + query: {}, + }); + expect(events[0].context).toMatchObject({ userId: 'test-user', tenantId: 'org_a' }); + }); + + it('an export of the ledger itself hands ONE event, and its file holds the rows that were there before its own', async () => { + const { engine, events, run } = await bootAudited(); + const answer = await run(LEDGER, { format: 'json' }); + expect(answer.status).toBe(200); + expect(JSON.parse(answer.text)).toHaveLength(3); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ objectName: LEDGER, format: 'json', rowCount: 3, outcome: 'succeeded' }); + // The export's own row lands AFTER the stream, so the next export sees it. + expect(await engine.count(LEDGER, {})).toBe(4); + }); + + it('records truncation at the row limit from the read\'s own page markers, and the cap clipping the request', async () => { + const { events, run } = await bootAudited(); + await run('task', { format: 'csv', limit: '1' }); // 1 of 2 matching rows + await run('task', { format: 'csv', limit: '2' }); // exactly the 2 that match + await run('task', { format: 'csv', limit: '60000' }); // above the 50k cap + expect(events.map((e) => [e.rowCount, e.limit, e.requestedLimit, e.truncated])).toEqual([ + [1, 1, 1, true], + [2, 2, 2, false], + [2, 50_000, 60_000, false], + ]); + }); + + it('hands the query as read — the writer, not the door, reduces it to names and operators', async () => { + const { events, run } = await bootAudited(); + const filter = { done: true }; + await run('task', { format: 'csv', filter: JSON.stringify(filter), search: '代码', searchFields: 'title' }); + expect(events).toHaveLength(1); + expect(events[0].query).toEqual({ filter, search: '代码', searchFields: ['title'] }); + expect(events[0].rowCount).toBe(1); + }); + + it('a refused export (EXPORT_NOT_PERMITTED) is no export: 403, and no event', async () => { + const { events, run } = await bootAudited({ canExport: async () => false }); + const answer = await run('task', { format: 'csv' }); + expect(answer.status).toBe(403); + expect(answer.json?.code).toBe('EXPORT_NOT_PERMITTED'); + expect(events).toEqual([]); + }); + + it('a request refused before reading (an unknown query parameter, a malformed filter) hands no event', async () => { + const { events, run } = await bootAudited(); + expect((await run('task', { format: 'csv', bogus: '1' })).status).toBe(400); + expect((await run('task', { format: 'csv', filter: '{not json' })).status).toBe(400); + expect(events).toEqual([]); + }); + + it('the import template carries no record and hands no event', async () => { + const { events, run } = await bootAudited(); + expect((await run('task', { template: 'true' })).status).toBe(200); + expect(events).toEqual([]); + }); + + it('an export whose read is refused after the gates hands ONE failed event with the status and code it answered', async () => { + const { events, run } = await bootAudited(); + const answer = await run('task', { format: 'csv', filter: JSON.stringify({ no_such_field: 1 }) }); + expect(answer.status).toBe(400); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ objectName: 'task', outcome: 'failed', status: 400, rowCount: 0 }); + expect(answer.json?.code).toBe('INVALID_FIELD'); + expect(events[0].errorCode).toBe('INVALID_FIELD'); + }); + + it('a sink that throws changes nothing about the file the caller gets', async () => { + const { run } = await bootAudited({ sink: { recordDataExport: async () => { throw new Error('ledger down'); } } }); + const answer = await run('task', { format: 'csv' }); + expect(answer.status).toBe(200); + expect(parseCsv(answer.text).slice(1)).toHaveLength(2); + }); + + it('a kernel with no audit writer on the slot exports exactly as before', async () => { + const { run } = await bootAudited({ sink: {} }); + const answer = await run('task', { format: 'csv' }); + expect(answer.status).toBe(200); + expect(parseCsv(answer.text).slice(1)).toHaveLength(2); + }); +}); diff --git a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts index db1d1931bdf..0ba31bdb4c8 100644 --- a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts +++ b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts @@ -123,6 +123,9 @@ const PROVIDERS = [ // for, the set the last case compares could not contain it, and the guard // read green against ANY binding at this position. { index: 20, label: 'tenancyServiceProvider', slot: 'tenancy', scope: 'host' }, + // [#30955] Appended after it, by the same rule: the ledger's writers, which + // the export door records each export through. + { index: 21, label: 'auditServiceProvider', slot: 'audit', scope: 'environment' }, ] as const; /** @@ -421,10 +424,11 @@ describe('[#30011] a single-kernel deployment reads the environment-scoped slots const { args, ctx } = await boot(services); // [#30192] `auth`, `i18n`, `settings`, `security` and `metadata` joined - // the five #30011 scoped, and [#30218] `objectql`; `tenancy` stays on the - // host. + // the five #30011 scoped, [#30218] `objectql`, and [#30955] `audit`; + // `tenancy` stays on the host. expect(ENVIRONMENT_SCOPED.map((p) => p.slot)).toEqual([ 'auth', 'objectql', 'email', 'sharing', 'approvals', 'sharingRules', 'i18n', 'analytics', 'settings', 'security', 'metadata', + 'audit', ]); for (const { index, label, slot } of ENVIRONMENT_SCOPED) { const provider = args[index] as (environmentId?: string) => Promise; diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 8b5248ed67b..1396828d2f1 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -406,6 +406,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistActionInvocationAuditRow', 'The compliance audit row for a business-action invocation was never written — the action itself answered exactly as it would have without the ledger, so the API, the data and every counter read clean, while the `sys_audit_log` row recording WHICH DOOR (REST or MCP) invoked it, and who called it, is simply absent and nothing retries it. An administrator asking whether an agent or a person ran it finds no answer (#27908, the #5226 shape on the action seam).', ], + [ + 'persistDataExportAuditRow', + 'The compliance audit row for a data export was never written — the export itself was served and the caller holds the file, so the API, the download and every counter read clean, while the `sys_audit_log` row recording WHO exported WHICH object, how many rows and under which filter is simply absent and nothing retries it. The shipped `config_changes` list view and the Enterprise audit export read exactly those rows, so an auditor asking who took the data out finds no answer (#30955, the #5226 shape on the export seam).', + ], [ 'deleteMetaItemFromLoader', 'The metadata definition was never deleted from the authoritative store — `unregister()` still resolves and still announces `deleted`, the in-memory registry entry is gone, and the surviving row is read straight back out of storage by the very next `list()`/`get()`, so the "deleted" item reappears and survives every restart. Nothing retries it (#5259).', From bae425c0514bc2bb10f1c438c523b56fb9ef4d21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:59:11 +0000 Subject: [PATCH 03/11] test(dogfood): an export through the real door, gate and ledger leaves one row and no cell value Composes the #30955 halves on a real boot (SecurityPlugin + AuditPlugin): an ordinary export, the ledger's own export, a truncated export, a filtered and a searched export each leave one run-level row; the seeded value is in the file and in no row; EXPORT_NOT_PERMITTED leaves none. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- .../data-export-audit-row.dogfood.test.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 packages/qa/dogfood/test/data-export-audit-row.dogfood.test.ts diff --git a/packages/qa/dogfood/test/data-export-audit-row.dogfood.test.ts b/packages/qa/dogfood/test/data-export-audit-row.dogfood.test.ts new file mode 100644 index 00000000000..a3c5c4abecc --- /dev/null +++ b/packages/qa/dogfood/test/data-export-audit-row.dogfood.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#30955] Every data export the REST door streams leaves ONE run-level row in +// the compliance ledger — on a real boot, both halves composed: the door in +// `@objectstack/rest`'s `data-action-routes.ts` hands an export EVENT to the +// `audit` slot, and plugin-audit's `data-export-audit.ts` turns it into the +// row. Each half is pinned on its own (`export-integration.test.ts`, +// `data-export-audit.test.ts`); this file is the one place they meet with the +// real security gate, the real driver and the real ledger. +// +// ## The composition +// +// `bootStack` with the real `SecurityPlugin` and `AuditPlugin`. One synthetic +// object; one row of it carries a distinctive cell value. Two users: an +// EXPORTER whose set grants read + export on the object, and a READER whose +// set grants read only, so the export permission gate refuses it. The +// exporter's set also grants read + export on the ledger, for its own export. +// +// ## What is asserted +// +// - An ordinary export writes one row: `action: 'export'`, the object, no +// record id, the exporter, the format and the row count. +// - An export of `sys_audit_log` itself writes one row about the ledger. +// - A truncated export (`limit` below the matching rows) records the truncation. +// - The row never carries an exported cell value: the value is IN the file +// (filtered on, and searched for), and in no export row. +// - A refused export (`EXPORT_NOT_PERMITTED`) writes no row. +// +// The row is written after the file is finished, so a case reads the ledger +// until the row lands (bounded) instead of right after the response. +// +// Fixtures are synthetic. ⚠️ No test title states a value. +// `@objectstack/rest` and `@objectstack/plugin-audit` resolve through their +// BUILT output here, so a verdict on a change to either is a verdict on its +// last build. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const OBJ = 'dex_note'; +const LEDGER = 'sys_audit_log'; +const SYS = { context: { isSystem: true } } as const; + +/** A cell value no field name, operator or code in this file spells. */ +const SECRET = 'Dx9-export-cell-value-7316'; + +const Note = ObjectSchema.create({ + name: OBJ, + label: 'DEX Note', + pluralLabel: 'DEX Notes', + sharingModel: 'public_read_write', + fields: { + name: Field.text({ label: 'Name', required: true, searchable: true }), + body: Field.text({ label: 'Body' }), + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.data-export-audit-row', + namespace: 'dex', + version: '0.0.0', + type: 'app', + name: 'Data Export Audit Row Fixture', + description: 'One object exported through the REST door, recorded on the compliance ledger.', + }, + objects: [Note], +}); + +/** Read only: the export permission gate refuses this user. */ +const readerSet = PermissionSetSchema.parse({ + name: 'dex_reader', + label: 'DEX reader', + objects: { [OBJ]: { allowRead: true } }, +}); +/** Read and export on the fixture object, and on the ledger. */ +const exporterSet = PermissionSetSchema.parse({ + name: 'dex_exporter', + label: 'DEX exporter', + objects: { + [OBJ]: { allowRead: true, allowExport: true }, + [LEDGER]: { allowRead: true, allowExport: true }, + }, +}); + +type Row = Record; +const metadataOf = (row: Row): Record => JSON.parse(String(row.metadata)); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('[#30955] every export the REST door streams leaves one run-level ledger row', () => { + let stack: VerifyStack; + let ql: any; + const token: Record<'exporter' | 'reader', string> = { exporter: '', reader: '' }; + let exporterId = ''; + + const exportRows = async (): Promise => + ql.find(LEDGER, { where: { action: 'export' }, ...SYS }); + + /** + * Run one export and return the export rows it left. The row lands after the + * file is finished, so the ledger is read until `expect` rows are new (or + * the bound passes), then once more after a settle, so a second row would + * be counted too. + */ + const exportAndRead = async (who: keyof typeof token, path: string, expectNew: number) => { + const before = new Set((await exportRows()).map((r) => String(r.id))); + const res = await stack.apiAs(token[who], 'GET', path); + const text = await res.text(); + const fresh = async () => (await exportRows()).filter((r) => !before.has(String(r.id))); + for (let i = 0; i < 100 && (await fresh()).length < expectNew; i++) await sleep(20); + await sleep(150); + return { status: res.status, text, rows: await fresh() }; + }; + + beforeAll(async () => { + stack = await bootStack(fixtureStack as unknown as Parameters[0], { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, readerSet, exporterSet], + fallbackPermissionSet: readerSet.name, + }), + extraPlugins: [new AuditPlugin()], + }); + ql = await stack.kernel.getServiceAsync('objectql'); + token.reader = await stack.signUp('dex-reader@verify.test'); + token.exporter = await stack.signUp('dex-exporter@verify.test'); + exporterId = String((await ql.findOne('sys_user', { where: { email: 'dex-exporter@verify.test' }, ...SYS }))?.id ?? ''); + expect(exporterId).not.toBe(''); + expect(await catalogPermissionSet(ql, exporterSet.name), 'fixture set served by the security catalog').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: exporterId, permission_set: exporterSet.name }, SYS); + + await ql.insert(OBJ, { name: 'Note one', body: 'plain' }, SYS); + await ql.insert(OBJ, { name: 'Note two', body: 'plain' }, SYS); + await ql.insert(OBJ, { name: SECRET, body: 'carries the value' }, SYS); + }, 180_000); + + afterAll(async () => { + if (stack) await stack.stop(); + }); + + it('an export of an ordinary object writes ONE row naming the object, the exporter, the format and the row count', async () => { + const { status, text, rows } = await exportAndRead('exporter', `/data/${OBJ}/export?format=json`, 1); + expect(status, text).toBe(200); + expect(JSON.parse(text)).toHaveLength(3); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'export', object_name: OBJ, user_id: exporterId }); + expect(rows[0].record_id ?? null).toBeNull(); + expect(metadataOf(rows[0])).toMatchObject({ format: 'json', row_count: 3, truncated: false, outcome: 'succeeded' }); + }); + + it('a csv export writes ONE row with its own format', async () => { + const { status, rows } = await exportAndRead('exporter', `/data/${OBJ}/export?format=csv`, 1); + expect(status).toBe(200); + expect(rows).toHaveLength(1); + expect(metadataOf(rows[0])).toMatchObject({ format: 'csv', row_count: 3 }); + }); + + it('an export of the ledger itself writes ONE row about the ledger', async () => { + const { status, text, rows } = await exportAndRead('exporter', `/data/${LEDGER}/export?format=json`, 1); + expect(status, text).toBe(200); + // The file is the ledger, and it already holds the earlier cases' export + // rows (the json export renders the action by its option label). + const actions = (JSON.parse(text) as Row[]).map((r) => String(r.action).toLowerCase()); + expect(actions.filter((a) => a === 'export').length).toBeGreaterThanOrEqual(2); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'export', object_name: LEDGER }); + expect(rows[0].record_id ?? null).toBeNull(); + expect(metadataOf(rows[0])).toMatchObject({ format: 'json', outcome: 'succeeded' }); + }); + + it('a truncated export records the truncation', async () => { + const { status, text, rows } = await exportAndRead('exporter', `/data/${OBJ}/export?format=json&limit=1`, 1); + expect(status).toBe(200); + expect(JSON.parse(text)).toHaveLength(1); + expect(rows).toHaveLength(1); + expect(metadataOf(rows[0])).toMatchObject({ row_count: 1, limit: 1, truncated: true }); + }); + + it('the row never carries an exported cell value: filtered on, the value is in the file and in no row', async () => { + const filter = encodeURIComponent(JSON.stringify({ name: SECRET })); + const { status, text, rows } = await exportAndRead('exporter', `/data/${OBJ}/export?format=json&filter=${filter}`, 1); + expect(status, text).toBe(200); + expect(text, 'armed: the value was exported').toContain(SECRET); + expect(rows).toHaveLength(1); + expect(JSON.stringify(rows[0])).not.toContain(SECRET); + expect(metadataOf(rows[0])).toMatchObject({ row_count: 1, filter: [{ field: 'name', op: '$eq' }], search: false }); + }); + + it('the row never carries the search term: searched for, the value is in the file and the row says only that a search ran', async () => { + const { status, text, rows } = await exportAndRead('exporter', `/data/${OBJ}/export?format=json&search=${encodeURIComponent(SECRET)}`, 1); + expect(status, text).toBe(200); + expect(text, 'armed: the value was exported').toContain(SECRET); + expect(rows).toHaveLength(1); + expect(JSON.stringify(rows[0])).not.toContain(SECRET); + expect(metadataOf(rows[0])).toMatchObject({ row_count: 1, search: true }); + }); + + it('a refused export (no export permission) writes no row', async () => { + const { status, text, rows } = await exportAndRead('reader', `/data/${OBJ}/export?format=json`, 0); + expect(status, text).toBe(403); + expect(text).toContain('EXPORT_NOT_PERMITTED'); + expect(rows).toEqual([]); + // Control: the same door, the same object, an exporter — one row. + const control = await exportAndRead('exporter', `/data/${OBJ}/export?format=json`, 1); + expect(control.rows).toHaveLength(1); + }); + + it('no export row anywhere in the ledger carries the cell value', async () => { + expect(JSON.stringify(await exportRows())).not.toContain(SECRET); + }); +}); From ca7b952a30d8b9ed22b05aa1f74278020e21b430 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:11:36 +0000 Subject: [PATCH 04/11] =?UTF-8?q?chore(changeset):=20#30955=20export=20aud?= =?UTF-8?q?it=20row=20=E2=80=94=20plugin-audit=20and=20rest=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- .changeset/30955-data-export-audit-row.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/30955-data-export-audit-row.md diff --git a/.changeset/30955-data-export-audit-row.md b/.changeset/30955-data-export-audit-row.md new file mode 100644 index 00000000000..12d331e5610 --- /dev/null +++ b/.changeset/30955-data-export-audit-row.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-audit': patch +'@objectstack/rest': patch +--- + +feat(audit): every data export writes one run-level `sys_audit_log` row, and the `export` action comes back with its writer + +Clause-②: yes (widening) +Metadata-protocol: none + +`GET /data/:object/export` used to leave the compliance ledger nothing to say: a user could download up to 50,000 rows of any object they may export, `sys_audit_log` included, and no row recorded who, what or how much. Each export the door streams now writes one row with `action: "export"`, the object, `record_id: null` and the exporter (user, actor, tenant, and the agent or impersonator when there is one). Its `metadata` carries the format, the row count, the effective and requested limit and whether the 50,000 cap clipped it, whether the file was truncated at the limit, a bounded summary of the filter (field names and operators, at most 32 terms, never a comparand), whether a search term narrowed it (never the term) and the outcome. An export whose read fails after the checks records `outcome: "failed"` with the status and machine code it answered. + +A request refused before anything is read (no export permission, a bad parameter, an unparseable filter) writes no row; neither does the import template. The row is written after the file is finished, so the audit write never changes or delays the download; a ledger write that fails is logged at `error` once per cause and the export is unaffected. + +`export` is declared again on `sys_audit_log.action` (it was retired under #8147 because nothing wrote it) and is shown by the `config_changes` list view beside `config_change` and `import`. The writer lives on the `audit` service slot as `recordDataExport`, beside `recordAuthEvent` and `recordActionInvocation`; a kernel without the audit plugin exports exactly as before. From 2b5ee6c9695f92a7f4d52f5c4c7c0616f66c3556 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:15:11 +0000 Subject: [PATCH 05/11] docs(audit): the export enum note names which list views show the row Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- .../plugins/plugin-audit/src/objects/sys-audit-log.object.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index 1a5a335aa05..4dcf4866c0a 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -314,8 +314,9 @@ export const SysAuditLog = ObjectSchema.create({ // operators only, whether a search term applied (never the term) and the // outcome; never an exported value. A request the door refused writes no // row. Its shipped surface is the `config_changes` list view above, beside - // the `import` row of the same shape, and the unfiltered `recent` and - // `all_events` views. + // the `import` row of the same shape, and the `all_events` view; `recent` + // lists it too, except an export of an object that view leaves out + // (`AUDIT_LOG_DEFAULT_VIEW_EXCLUDED_OBJECTS`, the settings stores). // // ADR-0087 retirement (#8315, the same ruling carried by triage): `restore` // left this enum for the same reason and by the same measurement. It was From b6947992fc0010eabdda68a93e0e3592f191cc9e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:26:52 +0000 Subject: [PATCH 06/11] docs(census): the export writer is the 262nd tenant-audit write call site Regenerated by node scripts/tenant-audit-census.mjs --write; the page's hand-written figures move with it (261 -> 262, 184 -> 185, 144 -> 145). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- .../docs/permissions/tenant-audit-census.mdx | 36 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 21 +++++------ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index ff22ac4cded..536a65d4346 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A +forwarding shim cannot, and **55 of the 262 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **261** | +| 175 write call sites | quoted in the merged changeset | **262** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **184 of 261** decidable, **77** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 144 decidably elevated, 0 decidably not, 109 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **185 of 262** decidable, **77** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 109 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -217,11 +217,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -144 of 261 (55%) as decidably elevated, with 109 more whose elevation is a +145 of 262 (55%) as decidably elevated, with 109 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 262`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,28 +233,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **261** | -| …whose object name is statically decidable | 184 | +| write call sites on the application surface | **262** | +| …whose object name is statically decidable | 185 | | …whose object name is chosen at run time | 77 | -| …against an object with tenancy ENABLED | 178 | +| …against an object with tenancy ENABLED | 179 | | …against an object that declares tenancy off | 6 | -| threading a tenant context | 198 | +| threading a tenant context | 199 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 55 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 144 | +| threading a decidably ELEVATED (`isSystem`) context | 145 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 109 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 199 | +| receiver carried a readable engine type | 200 | | receiver erased, placed by the object NAME | 52 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 116 | +| object name spelled inline | 117 | | object name spelled through a `const` | 68 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 70 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `2b5ee6c96`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1037 | +| engine-shaped types recognised | 110 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 230 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index d05ca1acbbb..6f1055dddff 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 261 | -| Object name statically decidable | 184 | +| Write call sites | 262 | +| Object name statically decidable | 185 | | Object name chosen at run time | 77 | -| Against a tenancy-enabled object | 178 | +| Against a tenancy-enabled object | 179 | | Against an object declaring tenancy off | 6 | -| Threading a tenant context | 198 | +| Threading a tenant context | 199 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 55 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 144 | +| Threading a decidably elevated context | 145 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 109 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-11-05 at `e40e04854`. +Measured on 2026-11-06 at `2b5ee6c96`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 871 | -| engine-shaped types recognised | 105 | -| declared objects in the registry | 127 | -| same-named calls subtracted as non-engine | 229 | +| tracked non-test sources scanned | 1037 | +| engine-shaped types recognised | 110 | +| declared objects in the registry | 130 | +| same-named calls subtracted as non-engine | 230 | ## Every site @@ -120,6 +120,7 @@ Measured on 2026-11-05 at `e40e04854`. | `packages/plugins/plugin-audit/src/activity-changed-fields-backfill.ts` | `update` | `sys_activity` | enabled | elevated | 1 | | `packages/plugins/plugin-audit/src/auth-event-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 | | `packages/plugins/plugin-audit/src/comment-parent-pair-backfill.ts` | `update` | `sys_comment` | enabled | elevated | 1 | +| `packages/plugins/plugin-audit/src/data-export-audit.ts` | `insert` | `sys_audit_log` | enabled | elevated | 1 | | `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-audit/src/mail-log-body-migration.ts` | `update` | `object` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-audit/src/read-audit.ts` | `insert` | `sys_audit_log` | enabled | options unreadable | 1 | From d2f3695163f21227c58296746be3fd691a7d031e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:27:59 +0000 Subject: [PATCH 07/11] test(audit): the config_changes pin lists export beside import Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- .../plugin-audit/src/sys-audit-log-pointer-pair.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts b/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts index d6ddbc6e064..a7cf8125288 100644 --- a/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts +++ b/packages/plugins/plugin-audit/src/sys-audit-log-pointer-pair.test.ts @@ -194,7 +194,8 @@ describe('audit record pages and lists — the type half never draws as text (#2 it('control: config_changes is still declared with its filter, and the pair stays declared', () => { const view = SysAuditLog.listViews?.config_changes as { filter?: unknown; columns?: unknown } | undefined; - expect(view?.filter).toEqual([{ field: 'action', operator: 'in', value: ['config_change', 'import'] }]); + // [#30955] `export` joined `import` here: both are run-level rows with no `record_id`. + expect(view?.filter).toEqual([{ field: 'action', operator: 'in', value: ['config_change', 'import', 'export'] }]); expect(view?.columns).toEqual(['created_at', 'action', 'user_id']); expect((SysAuditLog.fields as Record>).record_id.referenceVia).toBe('object_name'); }); From 63e2ee7f56ad146bb06cfa9d213ef85c3928c6a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 16:20:13 +0000 Subject: [PATCH 08/11] test(audit): the export writer's test types its ledger read; the swallow census copies the new durability callee Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016WV4cxfxbKV82MKKQzizpP --- packages/plugins/plugin-audit/src/data-export-audit.test.ts | 2 +- scripts/measure-durability-swallow-family.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-audit/src/data-export-audit.test.ts b/packages/plugins/plugin-audit/src/data-export-audit.test.ts index 308a3905557..38df114eb38 100644 --- a/packages/plugins/plugin-audit/src/data-export-audit.test.ts +++ b/packages/plugins/plugin-audit/src/data-export-audit.test.ts @@ -67,7 +67,7 @@ describe('[#30955] the export writer on the real `audit` slot', () => { /** The export rows at rest, oldest first. */ const exportRows = async (): Promise>> => - (await engine.find(LEDGER, { where: { action: DATA_EXPORT_AUDIT_ACTION }, orderBy: [{ field: 'created_at', order: 'asc' }], context: SYS } as any)) as any; + (await engine.find(LEDGER, { where: { action: DATA_EXPORT_AUDIT_ACTION }, orderBy: [{ field: 'created_at', order: 'asc' }], context: SYS })) as Array>; beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } }); diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index 410238a447e..188cb7c9d73 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -328,6 +328,7 @@ const WRITE_SHAPED_CALLEES = new Map([ ['persistReadAuditRows', 'gate-vocabulary'], ['persistAuthEventAuditRow', 'gate-vocabulary'], ['persistActionInvocationAuditRow', 'gate-vocabulary'], + ['persistDataExportAuditRow', 'gate-vocabulary'], ['deleteMetaItemFromLoader', 'gate-vocabulary'], ['persistPackageCommitRow', 'gate-vocabulary'], ['persistSeedTenancyReceiptRow', 'gate-vocabulary'], From 6c9f359d330007ced3685eea83546ba62a16d39a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 20:36:06 +0000 Subject: [PATCH 09/11] fix(rest): the export audit event carries the query as read under readQuery, and two test files meet their gates The rest slot census (rest-server-canonical-query-ast.test.ts) counts every `query:` line in data-action-routes.ts as a server-built QueryAST slot. The export audit event's `query` member (its type and its literal) read as two new slots, 8 against the pinned 6, though neither reaches the engine. The member is now `readQuery` in the door, the plugin-audit writer and their tests; the census is back to 6 with nothing re-pinned. rest-api-plugin-slot-lookups.test.ts, edited by this branch, reaches `@objectstack/objectql` through two route handlers' dynamic imports; a module-top side-effect import pays that load during collection (check:test-source-alias, "The source reach"). The export of an object name the registry does not know, past an export gate that admitted it, is pinned as built: one failed event, 404 OBJECT_NOT_FOUND. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro --- .../src/data-export-audit.test.ts | 6 ++--- .../plugin-audit/src/data-export-audit.ts | 4 +-- packages/rest/src/data-action-routes.ts | 10 ++++--- packages/rest/src/export-integration.test.ts | 27 +++++++++++++++++-- .../src/rest-api-plugin-slot-lookups.test.ts | 8 ++++++ .../rest-server-canonical-query-ast.test.ts | 3 +++ 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/plugins/plugin-audit/src/data-export-audit.test.ts b/packages/plugins/plugin-audit/src/data-export-audit.test.ts index 38df114eb38..7dd9dc1b89c 100644 --- a/packages/plugins/plugin-audit/src/data-export-audit.test.ts +++ b/packages/plugins/plugin-audit/src/data-export-audit.test.ts @@ -156,7 +156,7 @@ describe('[#30955] the export writer on the real `audit` slot', () => { it('the row never carries an exported cell value: not the filter comparands, not the search term', async () => { await audit.recordDataExport(exportEvent({ - query: { + readQuery: { filter: { name: SECRET, $or: [ @@ -216,7 +216,7 @@ describe('[#30955] the filter summary keeps names and operators, never a compara const { terms, omitted } = summarizeExportFilter(filter); expect(terms).toHaveLength(EXPORT_FILTER_SUMMARY_MAX_TERMS); expect(omitted).toBe(8); - const row = buildDataExportAuditRow(exportEvent({ query: { filter } })); + const row = buildDataExportAuditRow(exportEvent({ readQuery: { filter } })); expect(metadataOf(row).filter_terms_omitted).toBe(8); }); @@ -251,7 +251,7 @@ describe('[#30955] a lost row is loud, once per cause, and never throws', () => const logger = { error: vi.fn(), warn: vi.fn(), debug: vi.fn() }; const writer = createDataExportAuditWriter({ getEngine: () => engine, logger }); - await expect(writer.recordDataExport(exportEvent({ query: { filter: { name: SECRET }, search: SECRET } }))).resolves.toBeUndefined(); + await expect(writer.recordDataExport(exportEvent({ readQuery: { filter: { name: SECRET }, search: SECRET } }))).resolves.toBeUndefined(); await expect(writer.recordDataExport(exportEvent())).resolves.toBeUndefined(); expect(engine.insert).toHaveBeenCalledTimes(2); diff --git a/packages/plugins/plugin-audit/src/data-export-audit.ts b/packages/plugins/plugin-audit/src/data-export-audit.ts index 307d2c3064e..39820af56f1 100644 --- a/packages/plugins/plugin-audit/src/data-export-audit.ts +++ b/packages/plugins/plugin-audit/src/data-export-audit.ts @@ -105,7 +105,7 @@ export interface DataExportAuditEvent { * The query as the door read it. Reduced to names and operators before it * reaches the row: the comparands and the search term are never recorded. */ - query?: { filter?: unknown; search?: unknown; searchFields?: unknown }; + readQuery?: { filter?: unknown; search?: unknown; searchFields?: unknown }; outcome: DataExportOutcome; /** On a failure: the HTTP status the door answered. */ status?: number; @@ -310,7 +310,7 @@ export function buildDataExportAuditRow( const ctx = event.context ?? {}; const userId = nonEmpty(ctx.userId); const performedBy = nonEmpty(ctx.performedBy?.clientId); - const query = event.query ?? {}; + const query = event.readQuery ?? {}; const limit = wholeNumber(event.limit); const requestedLimit = wholeNumber(event.requestedLimit); diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index d37f692f533..939f3d8cdc9 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -371,9 +371,11 @@ async function assertImportObjectExists(p: any, objectName: string, environmentI * * One event per export that cleared every gate and began reading, whether the * stream completed or failed; a refused request is no export and hands none. - * `query` is the query AS READ: the writer reduces it to field names and + * `readQuery` is the query AS READ: the writer reduces it to field names and * operators before anything reaches the ledger, and records only that a search - * term applied, never the term. + * term applied, never the term. It is not spelled `query` on purpose: it is no + * query handed to the engine, and `rest-server-canonical-query-ast.test.ts` + * censuses every `query:` line in this file as a server-built QueryAST slot. */ interface DataExportAuditEvent { context: unknown; @@ -384,7 +386,7 @@ interface DataExportAuditEvent { requestedLimit: number; limitCap: number; truncated: boolean; - query: { filter?: unknown; search?: string; searchFields?: string[] }; + readQuery: { filter?: unknown; search?: string; searchFields?: string[] }; outcome: 'succeeded' | 'failed'; status?: number; errorCode?: string; @@ -1602,7 +1604,7 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: limit, requestedLimit, limitCap: HARD_CAP, - query: { + readQuery: { ...(filter !== undefined ? { filter } : {}), ...(search ? { search } : {}), ...(search && searchFields ? { searchFields } : {}), diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index d13548d2196..0896dc23a81 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -801,7 +801,7 @@ describe('[#30955] export route — the ledger hears of every export it streams' limitCap: 50_000, truncated: false, outcome: 'succeeded', - query: {}, + readQuery: {}, }); expect(events[0].context).toMatchObject({ userId: 'test-user', tenantId: 'org_a' }); }); @@ -834,7 +834,7 @@ describe('[#30955] export route — the ledger hears of every export it streams' const filter = { done: true }; await run('task', { format: 'csv', filter: JSON.stringify(filter), search: '代码', searchFields: 'title' }); expect(events).toHaveLength(1); - expect(events[0].query).toEqual({ filter, search: '代码', searchFields: ['title'] }); + expect(events[0].readQuery).toEqual({ filter, search: '代码', searchFields: ['title'] }); expect(events[0].rowCount).toBe(1); }); @@ -869,6 +869,29 @@ describe('[#30955] export route — the ledger hears of every export it streams' expect(events[0].errorCode).toBe('INVALID_FIELD'); }); + it('an export of an object name the registry does not know, past an export gate that let it through, hands ONE failed event (404 OBJECT_NOT_FOUND)', async () => { + // Pinned as built, for the review to judge: the gates passed and the read + // began, so by the door's one rule this is an export that failed, not a + // refusal. Only a caller the export gate admits for that name reaches it. + const gateAsked: string[] = []; + const { events, run } = await bootAudited({ + canExport: async (object) => { gateAsked.push(object); return true; }, + }); + const answer = await run('no_such_object', { format: 'csv' }); + expect(answer.status).toBe(404); + expect(answer.json?.code ?? answer.json?.error?.code).toBe('OBJECT_NOT_FOUND'); + expect(gateAsked).toEqual(['no_such_object']); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + objectName: 'no_such_object', + outcome: 'failed', + status: 404, + errorCode: 'OBJECT_NOT_FOUND', + rowCount: 0, + truncated: false, + }); + }); + it('a sink that throws changes nothing about the file the caller gets', async () => { const { run } = await bootAudited({ sink: { recordDataExport: async () => { throw new Error('ledger down'); } } }); const answer = await run('task', { format: 'csv' }); diff --git a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts index 0ba31bdb4c8..9022f0390cc 100644 --- a/packages/rest/src/rest-api-plugin-slot-lookups.test.ts +++ b/packages/rest/src/rest-api-plugin-slot-lookups.test.ts @@ -60,6 +60,14 @@ */ import { describe, it, expect, vi } from 'vitest'; +// The real `RestServer` this file extends reaches `@objectstack/objectql` only +// through a dynamic import inside two route handlers (`meta-object-state-routes.ts`, +// `meta-record-currency-backfill-route.ts`). A module-top import pays that load +// during COLLECTION, which vitest clocks against nothing, so no case's timeout +// window can ever hold it (`scripts/check-test-source-alias.mjs`, "The source +// reach"). The source keeps its dynamic import; this only decides where the +// first load is paid. +import '@objectstack/objectql'; const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 8a744006f6f..49b9435cbf6 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -453,6 +453,9 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => { // [#27627] The sum unchanged by the app metadata bundle (no slot). // [#28470] The sum 5 → 6: the record page's stream reads (`readStream`). // [#30675] The sum unchanged by the record-currency code backfill (no slot). + // [#30955] The sum unchanged by the export door's audit event: it hands + // the plugin-audit writer the query as read under `readQuery`, which is + // not a QueryAST slot and reaches no engine. expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); From 4addb47fd1cf7ae72fbdbac1a508e068716391fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 21:22:33 +0000 Subject: [PATCH 10/11] test(rest): leave the slot-census test as main has it The history line added there brought the file into check:test-source-alias's source reach; the explanation for the `readQuery` spelling lives at the event's declaration in data-action-routes.ts instead. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro --- packages/rest/src/rest-server-canonical-query-ast.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index 49b9435cbf6..8a744006f6f 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -453,9 +453,6 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => { // [#27627] The sum unchanged by the app metadata bundle (no slot). // [#28470] The sum 5 → 6: the record page's stream reads (`readStream`). // [#30675] The sum unchanged by the record-currency code backfill (no slot). - // [#30955] The sum unchanged by the export door's audit event: it hands - // the plugin-audit writer the query as read under `readQuery`, which is - // not a QueryAST slot and reaches no engine. expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); From 53bec3a57f4dbc50f9b0e103214aa9bb1ada3dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 22:17:23 +0000 Subject: [PATCH 11/11] fix(rest): an export of an unknown object name is a refusal and writes no audit row Seat decision on the contract review of the export audit row: an object name the registry does not know means no object and no data moved, and the name is only the caller's path text, so the export catch hands no event for OBJECT_NOT_FOUND. The door pin flips to 404 with no event, beside a control export of a known object on the same boot. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwj6uu1L1hVh8hXHro --- packages/rest/src/data-action-routes.ts | 4 ++++ packages/rest/src/export-integration.test.ts | 21 +++++++++----------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 939f3d8cdc9..cc48b9986d3 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -1784,8 +1784,12 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: // machine code the shared resolver reads off the error — // never the error's text, which a driver can fill with a // comparand. + // An object name the registry does not know is the one read + // refusal that writes no row: no object, so no data moved, and + // the name is only the caller's path text, unverified. if (exportRun) { const { status, code } = resolveThrownHttpError(error, 500); + if (code === 'OBJECT_NOT_FOUND') return; await recordDataExport(this, environmentId, req, { ...exportRun, truncated: exportTruncatedAtLimit(exportRun.rowCount, exportRun.limit, exportLastPage), diff --git a/packages/rest/src/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 0896dc23a81..a045267bdfe 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -869,10 +869,11 @@ describe('[#30955] export route — the ledger hears of every export it streams' expect(events[0].errorCode).toBe('INVALID_FIELD'); }); - it('an export of an object name the registry does not know, past an export gate that let it through, hands ONE failed event (404 OBJECT_NOT_FOUND)', async () => { - // Pinned as built, for the review to judge: the gates passed and the read - // began, so by the door's one rule this is an export that failed, not a - // refusal. Only a caller the export gate admits for that name reaches it. + it('an export of an object name the registry does not know, past an export gate that let it through, is a refusal: 404 OBJECT_NOT_FOUND and no event', async () => { + // Seat decision on the contract review: no object, so no data moved, and + // the name is only the caller's path text — it is never written to the + // ledger as an `object_name`. Only a caller the export gate admits for + // that name reaches this branch. const gateAsked: string[] = []; const { events, run } = await bootAudited({ canExport: async (object) => { gateAsked.push(object); return true; }, @@ -881,15 +882,11 @@ describe('[#30955] export route — the ledger hears of every export it streams' expect(answer.status).toBe(404); expect(answer.json?.code ?? answer.json?.error?.code).toBe('OBJECT_NOT_FOUND'); expect(gateAsked).toEqual(['no_such_object']); + expect(events).toEqual([]); + // Control on the same boot: a known object still hands its event. + expect((await run('task', { format: 'csv' })).status).toBe(200); expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ - objectName: 'no_such_object', - outcome: 'failed', - status: 404, - errorCode: 'OBJECT_NOT_FOUND', - rowCount: 0, - truncated: false, - }); + expect(events[0]).toMatchObject({ objectName: 'task', outcome: 'succeeded' }); }); it('a sink that throws changes nothing about the file the caller gets', async () => {