@replayio-app-building/netlify-recorder 0.11.0 → 0.12.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 +43 -1
- package/dist/index.js +111 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -421,4 +421,46 @@ 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 after each query,
|
|
446
|
+
* any new `audit_log` rows (with NULL `replay_request_id`) are tagged
|
|
447
|
+
* with the current request ID and an incrementing call index.
|
|
448
|
+
*
|
|
449
|
+
* The request ID is read from module-level state set by
|
|
450
|
+
* `createRecordingRequestHandler`. If no request is active, the
|
|
451
|
+
* wrapper passes calls through without tagging.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* ```ts
|
|
455
|
+
* import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
|
|
456
|
+
*
|
|
457
|
+
* const sql = createAuditedSql(getSql());
|
|
458
|
+
* await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
|
|
459
|
+
* // audit_log entries from this INSERT are now tagged with the request ID
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
declare function createAuditedSql(sql: SqlFunction): SqlFunction;
|
|
463
|
+
|
|
464
|
+
declare function getCurrentRequestId(): string | null;
|
|
465
|
+
|
|
466
|
+
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,107 @@ 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
|
+
BEGIN
|
|
1134
|
+
IF TG_OP = 'INSERT' THEN
|
|
1135
|
+
INSERT INTO audit_log (table_name, record_id, action, new_data)
|
|
1136
|
+
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW));
|
|
1137
|
+
RETURN NEW;
|
|
1138
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
1139
|
+
SELECT ARRAY_AGG(n.key) INTO changed_cols
|
|
1140
|
+
FROM jsonb_each_text(to_jsonb(NEW)) n
|
|
1141
|
+
LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
|
|
1142
|
+
WHERE o.value IS DISTINCT FROM n.value;
|
|
1143
|
+
|
|
1144
|
+
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields)
|
|
1145
|
+
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]));
|
|
1146
|
+
RETURN NEW;
|
|
1147
|
+
ELSIF TG_OP = 'DELETE' THEN
|
|
1148
|
+
INSERT INTO audit_log (table_name, record_id, action, old_data)
|
|
1149
|
+
VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD));
|
|
1150
|
+
RETURN OLD;
|
|
1151
|
+
END IF;
|
|
1152
|
+
RETURN NULL;
|
|
1153
|
+
END;
|
|
1154
|
+
$$ LANGUAGE plpgsql
|
|
1155
|
+
`;
|
|
1156
|
+
}
|
|
1157
|
+
async function databaseAuditMonitorTable(sql, tableName) {
|
|
1158
|
+
if (tableName === "audit_log") {
|
|
1159
|
+
throw new Error("Cannot monitor the audit_log table itself");
|
|
1160
|
+
}
|
|
1161
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
|
1162
|
+
throw new Error(`Invalid table name: ${tableName}`);
|
|
1163
|
+
}
|
|
1164
|
+
const triggerName = `audit_trigger_${tableName}`;
|
|
1165
|
+
await sql(
|
|
1166
|
+
`DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`,
|
|
1167
|
+
[]
|
|
1168
|
+
);
|
|
1169
|
+
await sql(
|
|
1170
|
+
`CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`,
|
|
1171
|
+
[]
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
async function databaseAuditDumpLogTable(sql) {
|
|
1175
|
+
const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
|
|
1176
|
+
return rows;
|
|
1177
|
+
}
|
|
1178
|
+
function createAuditedSql(sql) {
|
|
1179
|
+
let callIndex = 0;
|
|
1180
|
+
const requestId = getCurrentRequestId();
|
|
1181
|
+
const auditedSql = async (strings, ...values) => {
|
|
1182
|
+
const result = await sql(strings, ...values);
|
|
1183
|
+
callIndex++;
|
|
1184
|
+
if (requestId) {
|
|
1185
|
+
try {
|
|
1186
|
+
await sql`
|
|
1187
|
+
UPDATE audit_log
|
|
1188
|
+
SET replay_request_id = ${requestId}, replay_request_call_index = ${callIndex}
|
|
1189
|
+
WHERE replay_request_id IS NULL
|
|
1190
|
+
`;
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
console.warn("netlify-recorder: failed to update audit log:", err);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return result;
|
|
1196
|
+
};
|
|
1197
|
+
return auditedSql;
|
|
1198
|
+
}
|
|
1094
1199
|
export {
|
|
1200
|
+
createAuditedSql,
|
|
1095
1201
|
createRecordingRequestHandler,
|
|
1096
1202
|
createRequestRecording,
|
|
1203
|
+
databaseAuditDumpLogTable,
|
|
1204
|
+
databaseAuditEnsureLogTable,
|
|
1205
|
+
databaseAuditMonitorTable,
|
|
1097
1206
|
ensureRequestRecording,
|
|
1098
1207
|
finishRequest,
|
|
1208
|
+
getCurrentRequestId,
|
|
1099
1209
|
readInfraConfigFromEnv,
|
|
1100
1210
|
redactBlobData,
|
|
1101
1211
|
remoteCallbacks,
|