@xano/cli 1.3.5-beta.1 → 1.3.5-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/base-command.js +3 -0
- package/dist/commands/table/audit/index.d.ts +35 -0
- package/dist/commands/table/audit/index.js +189 -0
- package/dist/utils/multidoc-push.js +6 -0
- package/dist/utils/table-audit.d.ts +72 -0
- package/dist/utils/table-audit.js +90 -0
- package/oclif.manifest.json +4418 -4266
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -447,6 +447,42 @@ when the function returns an error status.
|
|
|
447
447
|
> denied with an access error — grant run/debug to the role (or use a token whose role has
|
|
448
448
|
> it). `function list`/`get`/`create`/`edit` only need the Function permission.
|
|
449
449
|
|
|
450
|
+
### Tables
|
|
451
|
+
|
|
452
|
+
```bash
|
|
453
|
+
# Export a table's audit trail (newest first; by table name or ID)
|
|
454
|
+
xano table audit users # the 50 newest events, as JSON
|
|
455
|
+
xano table audit users --all -o csv -f users_audit.csv # every event, as CSV, to a file
|
|
456
|
+
xano table audit 42 --record 17 --op update --all # every update of record 17
|
|
457
|
+
xano table audit users --limit 100 --before 98765 # the page after cursor 98765
|
|
458
|
+
xano table audit users --datasource test --all # the trail on the 'test' data source
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
`table audit` is read-only: it exports what the database recorded for auditors and scripts.
|
|
462
|
+
Auditing itself is turned on and off in the table's XanoScript `audit` block
|
|
463
|
+
(`audit = {enabled: true, mask: [...], exclude: [...]}`) and goes through `workspace push`/`pull`.
|
|
464
|
+
|
|
465
|
+
| Flag | Meaning |
|
|
466
|
+
| --- | --- |
|
|
467
|
+
| `--record <id>` | only the events of this record |
|
|
468
|
+
| `--op <op>` | only `insert`, `update`, `delete`, `truncate` or `system` events |
|
|
469
|
+
| `--limit <n>` | events per request (page size), 1-100, default 50. Without `--all` it is the number of events returned |
|
|
470
|
+
| `--before <cursor>` | start after this cursor (the `next` value of a previous page) |
|
|
471
|
+
| `--all` | follow the `next` cursor until every matching event is fetched |
|
|
472
|
+
| `--datasource <label>` | read the trail of a non-live data source (sends `X-Data-Source`; default `live`) |
|
|
473
|
+
| `-o, --output json\|csv` | `json` (default): the route's response (`enabled`, `available`, `log_table`, `items`, `next`), with every page's `items` joined under `--all`. `csv`: a header and one row per event |
|
|
474
|
+
| `-f, --file <path>` | write the export to a file instead of stdout |
|
|
475
|
+
|
|
476
|
+
CSV columns, in order: `id, created_at, tx_id, op, record_id, changed, old, new, context, db_user,
|
|
477
|
+
app_name, client_addr`. `created_at` is ISO 8601 UTC with milliseconds; `changed`, `old`, `new` and
|
|
478
|
+
`context` (who made the change: source, user, endpoint) are JSON strings. Without `--all`, when more
|
|
479
|
+
events remain the command prints `More events: rerun with --before <next>` on stderr. When auditing is
|
|
480
|
+
off for the table it says so on stderr (events recorded while it was on are still exported).
|
|
481
|
+
|
|
482
|
+
On a plan without Content Record Audit, a push that would turn a table's auditing on is refused, and
|
|
483
|
+
the CLI prints `Content Record Audit is not included in this plan. Upgrade to Pro or above to audit a
|
|
484
|
+
table.`
|
|
485
|
+
|
|
450
486
|
### Knowledge
|
|
451
487
|
|
|
452
488
|
```bash
|
package/dist/base-command.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { Agent } from 'undici';
|
|
7
7
|
import { checkForUpdate } from './update-check.js';
|
|
8
8
|
import { applyLocalOverrides, findLocalProfilePath, formatLocalProfileBanner, parseLocalProfile, resolveProfileSelection, } from './utils/local-config.js';
|
|
9
|
+
import { auditPlanRequiredMessage } from './utils/table-audit.js';
|
|
9
10
|
export function buildUserAgent(version) {
|
|
10
11
|
return `xano-cli/${version} (${process.platform}; ${process.arch}) node/${process.version}`;
|
|
11
12
|
}
|
|
@@ -308,6 +309,8 @@ export default class BaseCommand extends Command {
|
|
|
308
309
|
if (errorJson.message) {
|
|
309
310
|
message = errorJson.message;
|
|
310
311
|
}
|
|
312
|
+
// turning on table audit on a plan without Content Record Audit (403, payload.code audit_plan_required)
|
|
313
|
+
message = auditPlanRequiredMessage(errorJson) ?? message;
|
|
311
314
|
}
|
|
312
315
|
catch {
|
|
313
316
|
if (errorText) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
/** True when the raw argv names an output file, so the data is not what stdout carries. */
|
|
3
|
+
export declare function argvWritesFile(argv: string[]): boolean;
|
|
4
|
+
export default class TableAudit extends BaseCommand {
|
|
5
|
+
static args: {
|
|
6
|
+
table: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
static description: string;
|
|
9
|
+
static examples: string[];
|
|
10
|
+
static flags: {
|
|
11
|
+
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
before: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
datasource: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
op: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
record: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
21
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
22
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
23
|
+
};
|
|
24
|
+
/** stdout carries the export unless it goes to a file: keep banners and verbose logs off it. */
|
|
25
|
+
protected isJsonOutput(): boolean;
|
|
26
|
+
run(): Promise<void>;
|
|
27
|
+
/** Say on stderr when the table is not being audited, so an empty export is not a mystery. */
|
|
28
|
+
private explainState;
|
|
29
|
+
private fetchPage;
|
|
30
|
+
/**
|
|
31
|
+
* A table ID as given, or the ID of the table with exactly this name (falling back to a single
|
|
32
|
+
* case-insensitive match). An all-digit argument is always an ID.
|
|
33
|
+
*/
|
|
34
|
+
private resolveTableId;
|
|
35
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import BaseCommand from '../../../base-command.js';
|
|
5
|
+
import { normalizeListResponse } from '../../../utils/paging.js';
|
|
6
|
+
import { AUDIT_DEFAULT_LIMIT, AUDIT_MAX_LIMIT, AUDIT_OPS, AUDIT_PLAN_REQUIRED_MESSAGE, auditEventsToCsv, buildAuditQuery, normalizeAuditPage, } from '../../../utils/table-audit.js';
|
|
7
|
+
/** True when the raw argv names an output file, so the data is not what stdout carries. */
|
|
8
|
+
export function argvWritesFile(argv) {
|
|
9
|
+
return argv.some((arg) => arg === '-f' || arg === '--file' || arg.startsWith('--file=') || arg.startsWith('-f='));
|
|
10
|
+
}
|
|
11
|
+
export default class TableAudit extends BaseCommand {
|
|
12
|
+
static args = {
|
|
13
|
+
table: Args.string({
|
|
14
|
+
description: 'Table name or ID',
|
|
15
|
+
required: true,
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
static description = "Export a table's audit trail (read-only), newest first. Auditing itself is turned on and off in the table's XanoScript `audit` block and goes through push/pull.";
|
|
19
|
+
static examples = [
|
|
20
|
+
`$ xano table audit users
|
|
21
|
+
# The 50 newest events of the users table, as JSON`,
|
|
22
|
+
`$ xano table audit users --all -o csv -f users_audit.csv
|
|
23
|
+
Saved 1234 events to /path/to/users_audit.csv`,
|
|
24
|
+
`$ xano table audit 42 --record 17 --op update --all
|
|
25
|
+
# Every update of record 17 in table 42`,
|
|
26
|
+
`$ xano table audit users --limit 100 --before 98765
|
|
27
|
+
# The page after the one whose next cursor was 98765`,
|
|
28
|
+
`$ xano table audit users --datasource test --all -o csv
|
|
29
|
+
# The trail recorded on the 'test' data source`,
|
|
30
|
+
];
|
|
31
|
+
static flags = {
|
|
32
|
+
...BaseCommand.baseFlags,
|
|
33
|
+
all: Flags.boolean({
|
|
34
|
+
default: false,
|
|
35
|
+
description: 'Follow the next cursor until every matching event is fetched',
|
|
36
|
+
required: false,
|
|
37
|
+
}),
|
|
38
|
+
before: Flags.integer({
|
|
39
|
+
description: 'Start after this cursor (the `next` value of a previous page)',
|
|
40
|
+
min: 1,
|
|
41
|
+
required: false,
|
|
42
|
+
}),
|
|
43
|
+
datasource: Flags.string({
|
|
44
|
+
description: 'Data source whose trail to read, e.g. test (defaults to live)',
|
|
45
|
+
required: false,
|
|
46
|
+
}),
|
|
47
|
+
file: Flags.string({
|
|
48
|
+
char: 'f',
|
|
49
|
+
description: 'Write the export to this file instead of stdout',
|
|
50
|
+
required: false,
|
|
51
|
+
}),
|
|
52
|
+
limit: Flags.integer({
|
|
53
|
+
default: AUDIT_DEFAULT_LIMIT,
|
|
54
|
+
description: `Events per request (page size), 1-${AUDIT_MAX_LIMIT}. Without --all this is the number of events returned`,
|
|
55
|
+
max: AUDIT_MAX_LIMIT,
|
|
56
|
+
min: 1,
|
|
57
|
+
required: false,
|
|
58
|
+
}),
|
|
59
|
+
op: Flags.string({
|
|
60
|
+
description: 'Only events of this operation',
|
|
61
|
+
options: [...AUDIT_OPS],
|
|
62
|
+
required: false,
|
|
63
|
+
}),
|
|
64
|
+
output: Flags.string({
|
|
65
|
+
char: 'o',
|
|
66
|
+
default: 'json',
|
|
67
|
+
description: 'Output format: json (the trail as the API returns it) or csv (one row per event)',
|
|
68
|
+
options: ['json', 'csv'],
|
|
69
|
+
required: false,
|
|
70
|
+
}),
|
|
71
|
+
record: Flags.string({
|
|
72
|
+
description: 'Only the events of this record ID',
|
|
73
|
+
required: false,
|
|
74
|
+
}),
|
|
75
|
+
workspace: Flags.string({
|
|
76
|
+
char: 'w',
|
|
77
|
+
description: 'Workspace ID (optional if set in profile)',
|
|
78
|
+
required: false,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
/** stdout carries the export unless it goes to a file: keep banners and verbose logs off it. */
|
|
82
|
+
isJsonOutput() {
|
|
83
|
+
return !argvWritesFile(this.argv);
|
|
84
|
+
}
|
|
85
|
+
async run() {
|
|
86
|
+
const { args, flags } = await this.parse(TableAudit);
|
|
87
|
+
const { profile, profileName } = this.resolveProfile(flags);
|
|
88
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
89
|
+
if (!workspaceId) {
|
|
90
|
+
this.error(`Workspace ID is required. Either:\n` +
|
|
91
|
+
` 1. Provide it as a flag: xano table audit <table> -w <workspace_id>\n` +
|
|
92
|
+
` 2. Set it in your profile using: xano profile:edit ${profileName} -w <workspace_id>`);
|
|
93
|
+
}
|
|
94
|
+
const api = { profile, verbose: flags.verbose, workspaceId };
|
|
95
|
+
const tableId = await this.resolveTableId(api, args.table);
|
|
96
|
+
const dataSource = flags.datasource?.trim();
|
|
97
|
+
const items = [];
|
|
98
|
+
let first;
|
|
99
|
+
let { before } = flags;
|
|
100
|
+
let next = null;
|
|
101
|
+
/* eslint-disable no-await-in-loop -- each page's cursor comes from the page before */
|
|
102
|
+
while (true) {
|
|
103
|
+
const page = await this.fetchPage(api, tableId, {
|
|
104
|
+
before,
|
|
105
|
+
dataSource,
|
|
106
|
+
limit: flags.limit,
|
|
107
|
+
op: flags.op,
|
|
108
|
+
record: flags.record,
|
|
109
|
+
});
|
|
110
|
+
first ??= page;
|
|
111
|
+
items.push(...page.items);
|
|
112
|
+
next = page.next ?? null;
|
|
113
|
+
// a cursor that does not move would loop forever; stop rather than refetch the same page
|
|
114
|
+
if (!flags.all || next === null || next === before)
|
|
115
|
+
break;
|
|
116
|
+
before = next;
|
|
117
|
+
}
|
|
118
|
+
/* eslint-enable no-await-in-loop */
|
|
119
|
+
const trail = { ...first, items, next: flags.all ? null : next };
|
|
120
|
+
this.explainState(trail, args.table);
|
|
121
|
+
const content = flags.output === 'csv' ? auditEventsToCsv(items) : `${JSON.stringify(trail, null, 2)}\n`;
|
|
122
|
+
if (flags.file) {
|
|
123
|
+
const filePath = path.resolve(flags.file);
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.error(`Failed to write ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
}
|
|
130
|
+
this.log(`Saved ${items.length} event${items.length === 1 ? '' : 's'} to ${filePath}`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.log(content.replace(/\n$/, ''));
|
|
134
|
+
}
|
|
135
|
+
if (!flags.all && next !== null) {
|
|
136
|
+
this.logToStderr(`More events: rerun with --before ${next}, or with --all for every event.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Say on stderr when the table is not being audited, so an empty export is not a mystery. */
|
|
140
|
+
explainState(trail, table) {
|
|
141
|
+
if (trail.enabled !== false)
|
|
142
|
+
return;
|
|
143
|
+
const plan = trail.available === false ? ` ${AUDIT_PLAN_REQUIRED_MESSAGE}` : '';
|
|
144
|
+
this.warn(`Auditing is off for table '${table}'; any events listed were recorded while it was on. ` +
|
|
145
|
+
`Turn it on with the table's XanoScript audit block.${plan}`);
|
|
146
|
+
}
|
|
147
|
+
async fetchPage(api, tableId, query) {
|
|
148
|
+
const params = buildAuditQuery(query);
|
|
149
|
+
const url = `${api.profile.instance_origin}/api:meta/workspace/${api.workspaceId}/table/${tableId}/audit?${params.toString()}`;
|
|
150
|
+
const headers = {
|
|
151
|
+
accept: 'application/json',
|
|
152
|
+
Authorization: `Bearer ${api.profile.access_token}`,
|
|
153
|
+
};
|
|
154
|
+
// The route reads the trail recorded on the data source named by X-Data-Source (default live).
|
|
155
|
+
if (query.dataSource)
|
|
156
|
+
headers['X-Data-Source'] = query.dataSource;
|
|
157
|
+
const response = await this.verboseFetch(url, { headers, method: 'GET' }, api.verbose, api.profile.access_token);
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
this.error(await this.parseApiError(response, 'Failed to fetch the audit trail'));
|
|
160
|
+
}
|
|
161
|
+
return normalizeAuditPage(await response.json());
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A table ID as given, or the ID of the table with exactly this name (falling back to a single
|
|
165
|
+
* case-insensitive match). An all-digit argument is always an ID.
|
|
166
|
+
*/
|
|
167
|
+
async resolveTableId(api, table) {
|
|
168
|
+
const trimmed = table.trim();
|
|
169
|
+
if (/^\d+$/.test(trimmed))
|
|
170
|
+
return Number(trimmed);
|
|
171
|
+
const params = new URLSearchParams({ page: '1', per_page: '10000', search: trimmed });
|
|
172
|
+
const url = `${api.profile.instance_origin}/api:meta/workspace/${api.workspaceId}/table?${params.toString()}`;
|
|
173
|
+
const response = await this.verboseFetch(url, { headers: { accept: 'application/json', Authorization: `Bearer ${api.profile.access_token}` }, method: 'GET' }, api.verbose, api.profile.access_token);
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
this.error(await this.parseApiError(response, 'Failed to look up the table'));
|
|
176
|
+
}
|
|
177
|
+
const { items } = normalizeListResponse(await response.json());
|
|
178
|
+
const exact = items.filter((item) => item.name === trimmed);
|
|
179
|
+
const matches = exact.length > 0 ? exact : items.filter((item) => item.name?.toLowerCase() === trimmed.toLowerCase());
|
|
180
|
+
if (matches.length === 0) {
|
|
181
|
+
this.error(`Table '${trimmed}' not found in workspace ${api.workspaceId}.`);
|
|
182
|
+
}
|
|
183
|
+
if (matches.length > 1) {
|
|
184
|
+
const ids = matches.map((item) => item.id).join(', ');
|
|
185
|
+
this.error(`More than one table matches '${trimmed}' (IDs ${ids}). Pass the table ID instead.`);
|
|
186
|
+
}
|
|
187
|
+
return matches[0].id;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -11,6 +11,7 @@ import { PushPolicyGateError } from './policy/push-gate.js';
|
|
|
11
11
|
import { checkReferences, checkTableIndexes } from './reference-checker.js';
|
|
12
12
|
import { invalidateSandboxMarkerForOrigin, readMarkerToken, sandboxMarkerExists, writeMarkerToken, } from './sandbox-marker.js';
|
|
13
13
|
import { hashBytes, hashFile, instanceHost, loadSyncState, recordEntriesForContents, resolveTarget, sandboxTargetIdentity, saveSyncState, syncStateExists, toRelPath, } from './sync-state.js';
|
|
14
|
+
import { auditPlanRequiredMessage } from './table-audit.js';
|
|
14
15
|
/**
|
|
15
16
|
* A push that failed after its multidoc import landed. The import is not rolled back, so the error
|
|
16
17
|
* carries what it did for the calling command to report beside the failure.
|
|
@@ -1828,6 +1829,11 @@ function refuseIfExplained(command, target, status, body) {
|
|
|
1828
1829
|
// push itself would be refused the same way, so the preview stops here rather than skipping.
|
|
1829
1830
|
if (status === 403 && refusal?.code === 'policy_plan_limit')
|
|
1830
1831
|
command.error(`Push refused (${status}): ${message}`);
|
|
1832
|
+
// A table whose audit block turns auditing on, on a plan without Content Record Audit: the push
|
|
1833
|
+
// would be refused the same way, so the preview stops here too.
|
|
1834
|
+
const auditPlan = auditPlanRequiredMessage({ payload });
|
|
1835
|
+
if (auditPlan)
|
|
1836
|
+
command.error(`Push refused (${status}): ${auditPlan}`);
|
|
1831
1837
|
if (refusal?.code === 'policy_gate_transaction_required') {
|
|
1832
1838
|
command.error('Push refused: the live branch policy gate requires a transaction. Remove --no-transaction and retry. Nothing was imported.');
|
|
1833
1839
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for `xano table audit`: the audit-trail route's shapes, its query string, the CSV
|
|
3
|
+
* rendering of its events, and the plan refusal a push or an API call can carry.
|
|
4
|
+
*
|
|
5
|
+
* The route is `GET /api:meta/workspace/{workspace_id}/table/{table_id}/audit` (MCP tool
|
|
6
|
+
* `getTableAuditTrail`). It pages newest first with a cursor: `next` is the id to pass as `before`
|
|
7
|
+
* for the page after, and is null on the last page.
|
|
8
|
+
*/
|
|
9
|
+
/** The `op` values the route accepts. */
|
|
10
|
+
export declare const AUDIT_OPS: readonly ["insert", "update", "delete", "truncate", "system"];
|
|
11
|
+
/** The route's largest page. */
|
|
12
|
+
export declare const AUDIT_MAX_LIMIT = 100;
|
|
13
|
+
/** The route's default page. */
|
|
14
|
+
export declare const AUDIT_DEFAULT_LIMIT = 50;
|
|
15
|
+
/** One recorded event, as the route returns it. */
|
|
16
|
+
export interface AuditEvent {
|
|
17
|
+
app_name?: null | string;
|
|
18
|
+
changed?: null | string[];
|
|
19
|
+
client_addr?: null | string;
|
|
20
|
+
/** Who made the change, as Xano stamped it (source, user, endpoint, ...). */
|
|
21
|
+
context?: unknown;
|
|
22
|
+
/** Milliseconds since the epoch. */
|
|
23
|
+
created_at?: number;
|
|
24
|
+
db_user?: null | string;
|
|
25
|
+
id: number;
|
|
26
|
+
new?: unknown;
|
|
27
|
+
old?: unknown;
|
|
28
|
+
op?: string;
|
|
29
|
+
record_id?: null | string;
|
|
30
|
+
tx_id?: number;
|
|
31
|
+
}
|
|
32
|
+
/** One page of the route. */
|
|
33
|
+
export interface AuditTrailPage {
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
/** Whether the plan lets a table start being audited. Absent on an older instance. */
|
|
36
|
+
available?: boolean;
|
|
37
|
+
/** Whether auditing is on for this table. */
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
items: AuditEvent[];
|
|
40
|
+
/** The table's read-only audit log table, or null. */
|
|
41
|
+
log_table?: unknown;
|
|
42
|
+
/** The `before` cursor of the next page; null or absent on the last page. */
|
|
43
|
+
next?: null | number;
|
|
44
|
+
}
|
|
45
|
+
export interface AuditQuery {
|
|
46
|
+
before?: number;
|
|
47
|
+
limit?: number;
|
|
48
|
+
op?: string;
|
|
49
|
+
record?: string;
|
|
50
|
+
}
|
|
51
|
+
/** The route's query string. Only the filters that were given are sent. */
|
|
52
|
+
export declare function buildAuditQuery(query: AuditQuery): URLSearchParams;
|
|
53
|
+
/** The route's response, normalized: a missing items list is an empty one, a missing next is null. */
|
|
54
|
+
export declare function normalizeAuditPage(data: unknown): AuditTrailPage;
|
|
55
|
+
/** The CSV columns, in order. Stable: scripts and auditors key on them. */
|
|
56
|
+
export declare const AUDIT_CSV_COLUMNS: readonly ["id", "created_at", "tx_id", "op", "record_id", "changed", "old", "new", "context", "db_user", "app_name", "client_addr"];
|
|
57
|
+
/** One CSV field (RFC 4180): quoted when it holds a comma, a quote or a line break. */
|
|
58
|
+
export declare function csvField(value: unknown): string;
|
|
59
|
+
/**
|
|
60
|
+
* Events as CSV: a header row, then one row per event. old, new, changed and context are JSON
|
|
61
|
+
* strings. Values are written as recorded, without spreadsheet-formula escaping, so the export
|
|
62
|
+
* stays a faithful copy of the trail.
|
|
63
|
+
*/
|
|
64
|
+
export declare function auditEventsToCsv(events: AuditEvent[]): string;
|
|
65
|
+
/** The refusal's `payload.code` when a plan does not include Content Record Audit. */
|
|
66
|
+
export declare const AUDIT_PLAN_REQUIRED_CODE = "audit_plan_required";
|
|
67
|
+
export declare const AUDIT_PLAN_REQUIRED_MESSAGE = "Content Record Audit is not included in this plan. Upgrade to Pro or above to audit a table.";
|
|
68
|
+
/**
|
|
69
|
+
* The plan message when an API error body is the "turning on table audit needs a higher plan"
|
|
70
|
+
* refusal (a 403 with `payload.code` `audit_plan_required`), else undefined.
|
|
71
|
+
*/
|
|
72
|
+
export declare function auditPlanRequiredMessage(body: unknown): string | undefined;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for `xano table audit`: the audit-trail route's shapes, its query string, the CSV
|
|
3
|
+
* rendering of its events, and the plan refusal a push or an API call can carry.
|
|
4
|
+
*
|
|
5
|
+
* The route is `GET /api:meta/workspace/{workspace_id}/table/{table_id}/audit` (MCP tool
|
|
6
|
+
* `getTableAuditTrail`). It pages newest first with a cursor: `next` is the id to pass as `before`
|
|
7
|
+
* for the page after, and is null on the last page.
|
|
8
|
+
*/
|
|
9
|
+
/** The `op` values the route accepts. */
|
|
10
|
+
export const AUDIT_OPS = ['insert', 'update', 'delete', 'truncate', 'system'];
|
|
11
|
+
/** The route's largest page. */
|
|
12
|
+
export const AUDIT_MAX_LIMIT = 100;
|
|
13
|
+
/** The route's default page. */
|
|
14
|
+
export const AUDIT_DEFAULT_LIMIT = 50;
|
|
15
|
+
/** The route's query string. Only the filters that were given are sent. */
|
|
16
|
+
export function buildAuditQuery(query) {
|
|
17
|
+
const params = new URLSearchParams();
|
|
18
|
+
if (query.record !== undefined && query.record !== '')
|
|
19
|
+
params.set('record_id', query.record);
|
|
20
|
+
if (query.op)
|
|
21
|
+
params.set('op', query.op);
|
|
22
|
+
if (query.before !== undefined)
|
|
23
|
+
params.set('before', String(query.before));
|
|
24
|
+
if (query.limit !== undefined)
|
|
25
|
+
params.set('limit', String(query.limit));
|
|
26
|
+
return params;
|
|
27
|
+
}
|
|
28
|
+
/** The route's response, normalized: a missing items list is an empty one, a missing next is null. */
|
|
29
|
+
export function normalizeAuditPage(data) {
|
|
30
|
+
const record = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
31
|
+
const items = Array.isArray(record.items) ? record.items : [];
|
|
32
|
+
const next = typeof record.next === 'number' ? record.next : null;
|
|
33
|
+
return { ...record, items, next };
|
|
34
|
+
}
|
|
35
|
+
/** The CSV columns, in order. Stable: scripts and auditors key on them. */
|
|
36
|
+
export const AUDIT_CSV_COLUMNS = [
|
|
37
|
+
'id',
|
|
38
|
+
'created_at',
|
|
39
|
+
'tx_id',
|
|
40
|
+
'op',
|
|
41
|
+
'record_id',
|
|
42
|
+
'changed',
|
|
43
|
+
'old',
|
|
44
|
+
'new',
|
|
45
|
+
'context',
|
|
46
|
+
'db_user',
|
|
47
|
+
'app_name',
|
|
48
|
+
'client_addr',
|
|
49
|
+
];
|
|
50
|
+
/** One CSV field (RFC 4180): quoted when it holds a comma, a quote or a line break. */
|
|
51
|
+
export function csvField(value) {
|
|
52
|
+
if (value === null || value === undefined)
|
|
53
|
+
return '';
|
|
54
|
+
const text = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
55
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
56
|
+
}
|
|
57
|
+
function csvValue(event, column) {
|
|
58
|
+
if (column === 'created_at') {
|
|
59
|
+
// milliseconds since the epoch, written as ISO 8601 UTC (lossless: it keeps the milliseconds)
|
|
60
|
+
return typeof event.created_at === 'number' ? new Date(event.created_at).toISOString() : event.created_at;
|
|
61
|
+
}
|
|
62
|
+
return event[column];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Events as CSV: a header row, then one row per event. old, new, changed and context are JSON
|
|
66
|
+
* strings. Values are written as recorded, without spreadsheet-formula escaping, so the export
|
|
67
|
+
* stays a faithful copy of the trail.
|
|
68
|
+
*/
|
|
69
|
+
export function auditEventsToCsv(events) {
|
|
70
|
+
const rows = [AUDIT_CSV_COLUMNS.join(',')];
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
rows.push(AUDIT_CSV_COLUMNS.map((column) => csvField(csvValue(event, column))).join(','));
|
|
73
|
+
}
|
|
74
|
+
return `${rows.join('\n')}\n`;
|
|
75
|
+
}
|
|
76
|
+
/** The refusal's `payload.code` when a plan does not include Content Record Audit. */
|
|
77
|
+
export const AUDIT_PLAN_REQUIRED_CODE = 'audit_plan_required';
|
|
78
|
+
export const AUDIT_PLAN_REQUIRED_MESSAGE = 'Content Record Audit is not included in this plan. Upgrade to Pro or above to audit a table.';
|
|
79
|
+
/**
|
|
80
|
+
* The plan message when an API error body is the "turning on table audit needs a higher plan"
|
|
81
|
+
* refusal (a 403 with `payload.code` `audit_plan_required`), else undefined.
|
|
82
|
+
*/
|
|
83
|
+
export function auditPlanRequiredMessage(body) {
|
|
84
|
+
if (!body || typeof body !== 'object')
|
|
85
|
+
return undefined;
|
|
86
|
+
const { payload } = body;
|
|
87
|
+
if (!payload || typeof payload !== 'object')
|
|
88
|
+
return undefined;
|
|
89
|
+
return payload.code === AUDIT_PLAN_REQUIRED_CODE ? AUDIT_PLAN_REQUIRED_MESSAGE : undefined;
|
|
90
|
+
}
|