@replayio-app-building/netlify-recorder 0.15.2 → 0.15.4

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 CHANGED
@@ -155,6 +155,79 @@ const { status, recordingId } = await res.json();
155
155
  // recordingId: string | null
156
156
  ```
157
157
 
158
+ ### 4. Access control with secrets
159
+
160
+ You can restrict access to captured requests by setting a `secret` string. When a secret is set, the request is only accessible via API calls that provide the same secret value. This lets each app isolate its requests from other apps sharing the same Netlify Recorder service.
161
+
162
+ #### Setting a secret
163
+
164
+ Pass `secret` in the options when wrapping your handler:
165
+
166
+ ```typescript
167
+ export default createRecordingRequestHandler(
168
+ async (req) => {
169
+ const result = await myBusinessLogic();
170
+ return { statusCode: 200, body: JSON.stringify(result) };
171
+ },
172
+ {
173
+ callbacks: remoteCallbacks(RECORDER_URL),
174
+ handlerPath: "netlify/functions/my-handler",
175
+ secret: process.env.NETLIFY_RECORDER_SECRET,
176
+ }
177
+ );
178
+ ```
179
+
180
+ #### Listing requests by secret
181
+
182
+ Use the `list-by-secret` endpoint to retrieve all requests associated with a secret, with optional filtering by status, handler path, and time range:
183
+
184
+ ```typescript
185
+ const res = await fetch(
186
+ `${RECORDER_URL}/api/list-by-secret?secret=${secret}&status=recorded&handlerPath=netlify/functions/my-handler&after=2025-01-01T00:00:00Z&before=2025-12-31T23:59:59Z`
187
+ );
188
+ const { rows, total, page, limit } = await res.json();
189
+ ```
190
+
191
+ Query parameters:
192
+
193
+ | Parameter | Description |
194
+ |---|---|
195
+ | `secret` | **(required)** The secret string used when creating the requests |
196
+ | `status` | Filter by status: `captured`, `queued`, `processing`, `recorded`, `failed` |
197
+ | `handlerPath` | Filter by handler path (exact match) |
198
+ | `after` | Only include requests created at or after this ISO timestamp |
199
+ | `before` | Only include requests created at or before this ISO timestamp |
200
+ | `page` | Page number (default 1) |
201
+ | `limit` | Page size (default 20, max 100) |
202
+
203
+ #### Checking request status with a secret
204
+
205
+ When a request was created with a secret, you must include the secret when polling its status:
206
+
207
+ ```typescript
208
+ const res = await fetch(
209
+ `${RECORDER_URL}/api/get-request?requestId=${requestId}&secret=${secret}`
210
+ );
211
+ ```
212
+
213
+ Requests created without a secret remain accessible without one (backward compatible).
214
+
215
+ #### Managing your secret
216
+
217
+ Store your secret as a `NETLIFY_RECORDER_SECRET` environment variable. To keep it secure:
218
+
219
+ 1. **Netlify site environment:** Add `NETLIFY_RECORDER_SECRET` in your Netlify site's environment variables (Site settings → Environment variables). This makes it available to all deployed functions.
220
+
221
+ 2. **Branch-level secret** (for CI/development): If you're using the Netlify Recorder agent infrastructure, store the secret as a branch secret so deploy scripts and background agents can access it:
222
+
223
+ ```bash
224
+ set-branch-secret NETLIFY_RECORDER_SECRET "your-secret-value"
225
+ ```
226
+
227
+ 3. **Local development:** Add `NETLIFY_RECORDER_SECRET` to your `.env` file (make sure `.env` is in `.gitignore`).
228
+
229
+ Use a strong random string (e.g. `openssl rand -base64 32`) and rotate it if compromised. All requests created under the old secret remain accessible only with the old secret value — there is no migration mechanism, so plan rotations during low-traffic windows.
230
+
158
231
  ---
159
232
 
160
233
  ## Option B: Self-Hosted
@@ -305,6 +378,90 @@ Self-hosted recording requires these environment variables:
305
378
 
306
379
  ---
307
380
 
381
+ ## Audit Log Support
382
+
383
+ The package includes helpers that automatically track database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and link each change to the Replay request that caused it. This makes it easy to trace exactly which incoming request triggered a given database change.
384
+
385
+ ### Setup
386
+
387
+ #### 1. Create the audit log table
388
+
389
+ Call `databaseAuditEnsureLogTable` once during schema initialization. This creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function:
390
+
391
+ ```typescript
392
+ import { databaseAuditEnsureLogTable } from "@replayio-app-building/netlify-recorder";
393
+
394
+ await databaseAuditEnsureLogTable(sql);
395
+ ```
396
+
397
+ #### 2. Monitor tables
398
+
399
+ For each table you want to audit, call `databaseAuditMonitorTable`. This attaches a trigger that logs every INSERT, UPDATE, and DELETE:
400
+
401
+ ```typescript
402
+ import { databaseAuditMonitorTable } from "@replayio-app-building/netlify-recorder";
403
+
404
+ await databaseAuditMonitorTable(sql, "users");
405
+ await databaseAuditMonitorTable(sql, "orders");
406
+ ```
407
+
408
+ #### 3. Use audited SQL in your handlers
409
+
410
+ Wrap your Neon SQL function with `createAuditedSql` inside handlers that are wrapped with `createRecordingRequestHandler`. This ensures each audit log entry is stamped with the current Replay request ID and a per-request call index:
411
+
412
+ ```typescript
413
+ import {
414
+ createRecordingRequestHandler,
415
+ remoteCallbacks,
416
+ createAuditedSql,
417
+ } from "@replayio-app-building/netlify-recorder";
418
+
419
+ export default createRecordingRequestHandler(
420
+ async (req) => {
421
+ const auditedSql = createAuditedSql(sql);
422
+ // Queries through auditedSql automatically tag audit_log rows
423
+ // with the replay request ID — no extra code needed.
424
+ await auditedSql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
425
+
426
+ return { statusCode: 200, body: "OK" };
427
+ },
428
+ {
429
+ callbacks: remoteCallbacks(RECORDER_URL),
430
+ handlerPath: "netlify/functions/create-order",
431
+ }
432
+ );
433
+ ```
434
+
435
+ #### 4. Query the audit log
436
+
437
+ Use `databaseAuditDumpLogTable` to retrieve all audit entries (ordered by most recent first):
438
+
439
+ ```typescript
440
+ import { databaseAuditDumpLogTable } from "@replayio-app-building/netlify-recorder";
441
+
442
+ const entries = await databaseAuditDumpLogTable(sql);
443
+ ```
444
+
445
+ Each entry contains:
446
+
447
+ | Field | Description |
448
+ |---|---|
449
+ | `table_name` | The table where the change occurred |
450
+ | `record_id` | The `id` of the affected row |
451
+ | `action` | `INSERT`, `UPDATE`, or `DELETE` |
452
+ | `old_data` | Previous row data (UPDATE/DELETE only) |
453
+ | `new_data` | New row data (INSERT/UPDATE only) |
454
+ | `changed_fields` | Array of column names that changed (UPDATE only) |
455
+ | `performed_at` | Timestamp of the change |
456
+ | `replay_request_id` | The Replay request ID that caused the change (when using `createAuditedSql`) |
457
+ | `replay_request_call_index` | The sequential query index within the request |
458
+
459
+ ### How it works
460
+
461
+ `createAuditedSql` wraps each query in a transaction that first calls `SET LOCAL` to inject the current request ID and call index into PostgreSQL session variables. The trigger function reads these via `current_setting()` and stamps audit rows atomically — no separate UPDATE required. If no request context is available (e.g. queries outside a handler), the query executes normally without audit metadata.
462
+
463
+ ---
464
+
308
465
  ## API Reference
309
466
 
310
467
  ### `createRecordingRequestHandler(handler, options): Handler`
@@ -322,6 +479,7 @@ When `context.waitUntil` is not available (v1 handlers or missing context), the
322
479
  - `options.commitSha` — Override `COMMIT_SHA` env var
323
480
  - `options.branchName` — Override `BRANCH_NAME` env var
324
481
  - `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
482
+ - `options.secret` — Optional secret string. When set, the stored request is only accessible via API calls that provide the same secret value.
325
483
 
326
484
  **Returns:** A wrapped handler function with the same signature.
327
485
 
@@ -359,6 +517,7 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
359
517
  - `options.branchName` — Override `BRANCH_NAME` env var
360
518
  - `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
361
519
  - `options.requestId` — Pre-generated request ID (used by `createRecordingRequestHandler` in the `waitUntil` flow)
520
+ - `options.secret` — Optional secret string. When set, the stored request is only accessible via API calls that provide the same secret value.
362
521
 
363
522
  ### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
364
523
 
@@ -386,6 +545,39 @@ Called inside a container running under `replay-node`. Downloads the captured da
386
545
  - `handlerPath` — Path to the handler module to execute
387
546
  - `requestInfo` — The original request info to replay
388
547
 
548
+ ### `databaseAuditEnsureLogTable(sql): Promise<void>`
549
+
550
+ Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function (`audit_trigger_function`). Call once during schema initialization.
551
+
552
+ **Parameters:**
553
+ - `sql` — A Neon SQL tagged-template function
554
+
555
+ ### `databaseAuditMonitorTable(sql, tableName): Promise<void>`
556
+
557
+ Attaches a trigger to the specified table that logs INSERT, UPDATE, and DELETE operations to the `audit_log` table. Throws if `tableName` is `'audit_log'`.
558
+
559
+ **Parameters:**
560
+ - `sql` — A Neon SQL tagged-template function
561
+ - `tableName` — Name of the table to monitor (must match `^[a-zA-Z_][a-zA-Z0-9_]*$`)
562
+
563
+ ### `databaseAuditDumpLogTable(sql): Promise<Record<string, unknown>[]>`
564
+
565
+ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
566
+
567
+ **Parameters:**
568
+ - `sql` — A Neon SQL tagged-template function
569
+
570
+ ### `createAuditedSql(sql): SqlFunction`
571
+
572
+ Wraps a Neon SQL tagged-template function so that each query runs inside a transaction that injects the current Replay request ID and call index via `SET LOCAL`. The audit trigger reads these values and stamps audit rows automatically.
573
+
574
+ Falls back to non-audited execution when no request context is active or if the transaction fails.
575
+
576
+ **Parameters:**
577
+ - `sql` — A Neon SQL tagged-template function (must have a `.transaction()` method, as provided by the Neon HTTP driver)
578
+
579
+ **Returns:** A wrapped SQL function with the same tagged-template interface.
580
+
389
581
  ## Environment Variables
390
582
 
391
583
  ### Required for all setups (read by `finishRequest`)
package/dist/index.d.ts CHANGED
@@ -111,6 +111,8 @@ interface FinishRequestCallbacks {
111
111
  handlerPath: string;
112
112
  /** Pre-generated request ID. Use as the row ID when provided. */
113
113
  requestId?: string;
114
+ /** Optional secret that restricts access to this request. */
115
+ secret?: string;
114
116
  }) => Promise<string>;
115
117
  }
116
118
  /**
@@ -217,6 +219,11 @@ interface FinishRequestOptions {
217
219
  * the response is returned before `finishRequest` runs.
218
220
  */
219
221
  requestId?: string;
222
+ /**
223
+ * Optional secret string. When set, the stored request is only
224
+ * accessible via API calls that provide the same secret value.
225
+ */
226
+ secret?: string;
220
227
  }
221
228
  /**
222
229
  * Called at the end of the handler execution.
package/dist/index.js CHANGED
@@ -126,6 +126,10 @@ function installNetworkInterceptor(mode, calls) {
126
126
  function installEnvironmentInterceptor(mode, reads) {
127
127
  const originalEnv = process.env;
128
128
  if (mode === "capture") {
129
+ const now = Date.now();
130
+ for (const key of Object.keys(originalEnv)) {
131
+ reads.push({ key, value: originalEnv[key], timestamp: now });
132
+ }
129
133
  process.env = new Proxy(originalEnv, {
130
134
  get(target, prop) {
131
135
  const value = target[prop];
@@ -454,7 +458,8 @@ async function finishRequest(requestContext, callbacks, response, options) {
454
458
  branchName,
455
459
  repositoryUrl,
456
460
  handlerPath,
457
- requestId: options?.requestId
461
+ requestId: options?.requestId,
462
+ secret: options?.secret
458
463
  });
459
464
  const storeDuration = Date.now() - storeStart;
460
465
  if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
@@ -560,7 +565,8 @@ function remoteCallbacks(serviceUrl) {
560
565
  commitSha: metadata.commitSha,
561
566
  branchName: metadata.branchName,
562
567
  repositoryUrl: metadata.repositoryUrl,
563
- requestId: metadata.requestId
568
+ requestId: metadata.requestId,
569
+ secret: metadata.secret
564
570
  })
565
571
  }
566
572
  );
@@ -862,6 +868,16 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
862
868
  };
863
869
  g.File = FileShim;
864
870
  }
871
+ if (typeof g.crypto === "undefined") {
872
+ try {
873
+ const nodeCrypto = __require("crypto");
874
+ g.crypto = {
875
+ randomUUID: () => nodeCrypto.randomUUID(),
876
+ getRandomValues: (buf) => nodeCrypto.getRandomValues(buf)
877
+ };
878
+ } catch {
879
+ }
880
+ }
865
881
  if (typeof g.Headers === "undefined") {
866
882
  const HeadersShim = class Headers {
867
883
  _map;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {