@replayio-app-building/netlify-recorder 0.11.0 → 0.13.0
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/dist/index.d.ts +41 -1
- package/dist/index.js +120 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -421,4 +421,44 @@ interface SpawnRecordingContainerOptions {
|
|
|
421
421
|
*/
|
|
422
422
|
declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
|
|
423
423
|
|
|
424
|
-
|
|
424
|
+
type SqlFunction = (...args: any[]) => Promise<any[]>;
|
|
425
|
+
/**
|
|
426
|
+
* Creates the `audit_log` table and a generic PL/pgSQL trigger function
|
|
427
|
+
* (`audit_trigger_function`) that records INSERT, UPDATE, and DELETE
|
|
428
|
+
* operations on any monitored table.
|
|
429
|
+
*
|
|
430
|
+
* Call this once during schema initialization.
|
|
431
|
+
*/
|
|
432
|
+
declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
|
|
433
|
+
/**
|
|
434
|
+
* Creates a trigger on the specified table that calls
|
|
435
|
+
* `audit_trigger_function` for INSERT, UPDATE, and DELETE operations.
|
|
436
|
+
*
|
|
437
|
+
* Throws if `tableName` is `'audit_log'` (cannot monitor itself).
|
|
438
|
+
*/
|
|
439
|
+
declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string): Promise<void>;
|
|
440
|
+
/**
|
|
441
|
+
* Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
442
|
+
*/
|
|
443
|
+
declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
|
|
444
|
+
/**
|
|
445
|
+
* Wraps a Neon SQL tagged-template function so that before each query,
|
|
446
|
+
* `SET LOCAL` is used to inject the current request ID and call index
|
|
447
|
+
* into the PostgreSQL session. The `audit_trigger_function` reads these
|
|
448
|
+
* via `current_setting()` and stamps audit rows atomically at INSERT
|
|
449
|
+
* time inside the trigger — no separate UPDATE required.
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* ```ts
|
|
453
|
+
* import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
|
|
454
|
+
*
|
|
455
|
+
* const sql = createAuditedSql(getSql());
|
|
456
|
+
* await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
|
|
457
|
+
* // audit_log row created by the trigger already has the request ID
|
|
458
|
+
* ```
|
|
459
|
+
*/
|
|
460
|
+
declare function createAuditedSql(sql: SqlFunction): SqlFunction;
|
|
461
|
+
|
|
462
|
+
declare function getCurrentRequestId(): string | null;
|
|
463
|
+
|
|
464
|
+
export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createAuditedSql, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
|
package/dist/index.js
CHANGED
|
@@ -479,18 +479,32 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
479
479
|
|
|
480
480
|
// src/createRecordingRequestHandler.ts
|
|
481
481
|
import crypto from "crypto";
|
|
482
|
+
|
|
483
|
+
// src/requestState.ts
|
|
484
|
+
var _currentRequestId = null;
|
|
485
|
+
function setCurrentRequestId(id) {
|
|
486
|
+
_currentRequestId = id;
|
|
487
|
+
}
|
|
488
|
+
function getCurrentRequestId() {
|
|
489
|
+
return _currentRequestId;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/createRecordingRequestHandler.ts
|
|
482
493
|
function createRecordingRequestHandler(handler, options) {
|
|
483
494
|
return async (event, context) => {
|
|
495
|
+
const requestId = crypto.randomUUID();
|
|
496
|
+
setCurrentRequestId(requestId);
|
|
484
497
|
const reqContext = startRequest(event);
|
|
485
498
|
let response;
|
|
486
499
|
try {
|
|
487
500
|
response = await handler(event, context);
|
|
488
501
|
} catch (err) {
|
|
502
|
+
setCurrentRequestId(null);
|
|
489
503
|
reqContext.cleanup();
|
|
490
504
|
throw err;
|
|
491
505
|
}
|
|
492
506
|
reqContext.cleanup();
|
|
493
|
-
|
|
507
|
+
setCurrentRequestId(null);
|
|
494
508
|
const responseWithHeader = {
|
|
495
509
|
...response,
|
|
496
510
|
headers: {
|
|
@@ -1091,11 +1105,116 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
1091
1105
|
}
|
|
1092
1106
|
return result;
|
|
1093
1107
|
}
|
|
1108
|
+
|
|
1109
|
+
// src/databaseAudit.ts
|
|
1110
|
+
async function databaseAuditEnsureLogTable(sql) {
|
|
1111
|
+
await sql`
|
|
1112
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
1113
|
+
id BIGSERIAL PRIMARY KEY,
|
|
1114
|
+
table_name TEXT NOT NULL,
|
|
1115
|
+
record_id TEXT NOT NULL,
|
|
1116
|
+
action TEXT NOT NULL,
|
|
1117
|
+
old_data JSONB,
|
|
1118
|
+
new_data JSONB,
|
|
1119
|
+
changed_fields TEXT[],
|
|
1120
|
+
performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
1121
|
+
replay_request_id TEXT,
|
|
1122
|
+
replay_request_call_index INTEGER
|
|
1123
|
+
)
|
|
1124
|
+
`;
|
|
1125
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_table_name ON audit_log (table_name)`;
|
|
1126
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_replay_request_id ON audit_log (replay_request_id)`;
|
|
1127
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_performed_at ON audit_log (performed_at DESC)`;
|
|
1128
|
+
await sql`
|
|
1129
|
+
CREATE OR REPLACE FUNCTION audit_trigger_function()
|
|
1130
|
+
RETURNS TRIGGER AS $$
|
|
1131
|
+
DECLARE
|
|
1132
|
+
changed_cols TEXT[];
|
|
1133
|
+
req_id TEXT;
|
|
1134
|
+
call_idx INTEGER;
|
|
1135
|
+
BEGIN
|
|
1136
|
+
-- Read application context set via SET LOCAL by createAuditedSql
|
|
1137
|
+
req_id := COALESCE(current_setting('app.replay_request_id', true), '');
|
|
1138
|
+
IF req_id = '' THEN req_id := NULL; END IF;
|
|
1139
|
+
|
|
1140
|
+
BEGIN
|
|
1141
|
+
call_idx := current_setting('app.replay_call_index', true)::INTEGER;
|
|
1142
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1143
|
+
call_idx := NULL;
|
|
1144
|
+
END;
|
|
1145
|
+
|
|
1146
|
+
IF TG_OP = 'INSERT' THEN
|
|
1147
|
+
INSERT INTO audit_log (table_name, record_id, action, new_data, replay_request_id, replay_request_call_index)
|
|
1148
|
+
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW), req_id, call_idx);
|
|
1149
|
+
RETURN NEW;
|
|
1150
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
1151
|
+
SELECT ARRAY_AGG(n.key) INTO changed_cols
|
|
1152
|
+
FROM jsonb_each_text(to_jsonb(NEW)) n
|
|
1153
|
+
LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
|
|
1154
|
+
WHERE o.value IS DISTINCT FROM n.value;
|
|
1155
|
+
|
|
1156
|
+
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields, replay_request_id, replay_request_call_index)
|
|
1157
|
+
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
|
|
1158
|
+
RETURN NEW;
|
|
1159
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1160
|
+
INSERT INTO audit_log (table_name, record_id, action, old_data, replay_request_id, replay_request_call_index)
|
|
1161
|
+
VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD), req_id, call_idx);
|
|
1162
|
+
RETURN OLD;
|
|
1163
|
+
END IF;
|
|
1164
|
+
RETURN NULL;
|
|
1165
|
+
END;
|
|
1166
|
+
$$ LANGUAGE plpgsql
|
|
1167
|
+
`;
|
|
1168
|
+
}
|
|
1169
|
+
async function databaseAuditMonitorTable(sql, tableName) {
|
|
1170
|
+
if (tableName === "audit_log") {
|
|
1171
|
+
throw new Error("Cannot monitor the audit_log table itself");
|
|
1172
|
+
}
|
|
1173
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
|
1174
|
+
throw new Error(`Invalid table name: ${tableName}`);
|
|
1175
|
+
}
|
|
1176
|
+
const triggerName = `audit_trigger_${tableName}`;
|
|
1177
|
+
await sql(
|
|
1178
|
+
`DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`,
|
|
1179
|
+
[]
|
|
1180
|
+
);
|
|
1181
|
+
await sql(
|
|
1182
|
+
`CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`,
|
|
1183
|
+
[]
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
async function databaseAuditDumpLogTable(sql) {
|
|
1187
|
+
const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
|
|
1188
|
+
return rows;
|
|
1189
|
+
}
|
|
1190
|
+
function createAuditedSql(sql) {
|
|
1191
|
+
let callIndex = 0;
|
|
1192
|
+
const rawSql = sql;
|
|
1193
|
+
const auditedSql = async (strings, ...values) => {
|
|
1194
|
+
const requestId = getCurrentRequestId();
|
|
1195
|
+
callIndex++;
|
|
1196
|
+
if (requestId) {
|
|
1197
|
+
try {
|
|
1198
|
+
await rawSql(`SET LOCAL app.replay_request_id = '${requestId.replace(/'/g, "''")}'`);
|
|
1199
|
+
await rawSql(`SET LOCAL app.replay_call_index = '${callIndex}'`);
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
console.warn("netlify-recorder: failed to SET LOCAL audit context:", err);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return await sql(strings, ...values);
|
|
1205
|
+
};
|
|
1206
|
+
return auditedSql;
|
|
1207
|
+
}
|
|
1094
1208
|
export {
|
|
1209
|
+
createAuditedSql,
|
|
1095
1210
|
createRecordingRequestHandler,
|
|
1096
1211
|
createRequestRecording,
|
|
1212
|
+
databaseAuditDumpLogTable,
|
|
1213
|
+
databaseAuditEnsureLogTable,
|
|
1214
|
+
databaseAuditMonitorTable,
|
|
1097
1215
|
ensureRequestRecording,
|
|
1098
1216
|
finishRequest,
|
|
1217
|
+
getCurrentRequestId,
|
|
1099
1218
|
readInfraConfigFromEnv,
|
|
1100
1219
|
redactBlobData,
|
|
1101
1220
|
remoteCallbacks,
|