@replayio-app-building/netlify-recorder 0.15.5 → 0.15.7

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
@@ -31,8 +31,9 @@ The Netlify Recorder app (`https://netlify-recorder-bm4wmw.netlify.app`) provide
31
31
  | `REPLAY_REPOSITORY_URL` | Your app's git repository URL (e.g. `https://github.com/org/repo.git`) | Set in your deploy script or Netlify site settings |
32
32
  | `COMMIT_SHA` | The git commit hash of the deployed code | Set in your deploy script via `git rev-parse HEAD` |
33
33
  | `BRANCH_NAME` | The git branch of the deployed code | Set in your deploy script via `git rev-parse --abbrev-ref HEAD` |
34
+ | `NETLIFY_RECORDER_SECRET` | Secret string for access control — restricts who can view and act on your captured requests | Set in Netlify site environment variables or via `set-branch-secret` |
34
35
 
35
- **These are required.** `finishRequest` will throw an error if any are missing. Your deploy script should resolve them from git and set them on the Netlify site before deploying. Example:
36
+ The first three are **required** `finishRequest` will throw an error if any are missing. `NETLIFY_RECORDER_SECRET` is strongly recommended to prevent other apps from accessing your captured request data. Your deploy script should resolve the git values and set them on the Netlify site before deploying. Example:
36
37
 
37
38
  ```typescript
38
39
  // In your deploy script:
@@ -46,7 +47,7 @@ const repositoryUrl = execSync("git remote get-url origin", { encoding: "utf-8"
46
47
 
47
48
  ### 2. Wrap your Netlify function
48
49
 
49
- Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture.
50
+ Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture. Set `secret` to restrict access to captured requests — only API calls providing the same secret can view or act on them.
50
51
 
51
52
  **v1 handler** (Netlify Functions v1 — `event` with `httpMethod`, `path`, etc.):
52
53
 
@@ -71,6 +72,7 @@ const handler = createRecordingRequestHandler(
71
72
  {
72
73
  callbacks: remoteCallbacks(RECORDER_URL),
73
74
  handlerPath: "netlify/functions/my-handler",
75
+ secret: process.env.NETLIFY_RECORDER_SECRET,
74
76
  }
75
77
  );
76
78
 
@@ -102,6 +104,7 @@ export default createRecordingRequestHandler(
102
104
  {
103
105
  callbacks: remoteCallbacks(RECORDER_URL),
104
106
  handlerPath: "netlify/functions/my-handler",
107
+ secret: process.env.NETLIFY_RECORDER_SECRET,
105
108
  }
106
109
  );
107
110
  ```
@@ -262,6 +265,7 @@ const handler = createRecordingRequestHandler(
262
265
  };
263
266
  },
264
267
  {
268
+ secret: process.env.NETLIFY_RECORDER_SECRET,
265
269
  callbacks: {
266
270
  uploadBlob: async (data) => {
267
271
  // Upload the JSON string to your blob storage (S3, R2, etc.)
@@ -272,10 +276,10 @@ const handler = createRecordingRequestHandler(
272
276
  const { url } = await res.json();
273
277
  return url;
274
278
  },
275
- storeRequestData: async ({ blobUrl, commitSha, branchName, repositoryUrl, handlerPath }) => {
279
+ storeRequestData: async ({ blobUrl, commitSha, branchName, repositoryUrl, handlerPath, secret }) => {
276
280
  const [row] = await sql`
277
- INSERT INTO requests (blob_url, commit_sha, branch_name, repository_url, handler_path, status)
278
- VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${repositoryUrl}, ${handlerPath}, 'captured')
281
+ INSERT INTO requests (blob_url, commit_sha, branch_name, repository_url, handler_path, secret, status)
282
+ VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${repositoryUrl}, ${handlerPath}, ${secret}, 'captured')
279
283
  RETURNING id
280
284
  `;
281
285
  return row.id;
@@ -297,12 +301,15 @@ CREATE TABLE IF NOT EXISTS requests (
297
301
  branch_name TEXT,
298
302
  repository_url TEXT,
299
303
  handler_path TEXT,
304
+ secret TEXT,
300
305
  recording_id TEXT,
301
306
  status TEXT NOT NULL DEFAULT 'captured'
302
307
  CHECK (status IN ('captured', 'processing', 'recorded', 'failed')),
303
308
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
304
309
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
305
310
  );
311
+
312
+ CREATE INDEX IF NOT EXISTS idx_requests_secret ON requests (secret) WHERE secret IS NOT NULL;
306
313
  ```
307
314
 
308
315
  ### 4. Create a background function to produce recordings
@@ -386,7 +393,7 @@ Self-hosted recording requires these environment variables:
386
393
 
387
394
  ## Audit Log Support
388
395
 
389
- 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.
396
+ The package automatically tracks database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and links each change to the Replay request that caused it. When your handler is wrapped with `createRecordingRequestHandler`, all Neon SQL queries are automatically tagged with the request ID no changes to your SQL code required.
390
397
 
391
398
  ### Setup
392
399
 
@@ -411,29 +418,26 @@ await databaseAuditMonitorTable(sql, "users");
411
418
  await databaseAuditMonitorTable(sql, "orders");
412
419
  ```
413
420
 
414
- #### 3. Use audited SQL in your handlers
421
+ #### 3. Use SQL normally in your handlers
415
422
 
416
- 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:
423
+ No special SQL wrapper is needed. Any Neon SQL query inside a handler wrapped with `createRecordingRequestHandler` is automatically tagged with the replay request ID:
417
424
 
418
425
  ```typescript
419
426
  import {
420
427
  createRecordingRequestHandler,
421
428
  remoteCallbacks,
422
- createAuditedSql,
423
429
  } from "@replayio-app-building/netlify-recorder";
424
430
 
425
431
  export default createRecordingRequestHandler(
426
432
  async (req) => {
427
- const auditedSql = createAuditedSql(sql);
428
- // Queries through auditedSql automatically tag audit_log rows
429
- // with the replay request ID — no extra code needed.
430
- await auditedSql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
433
+ await sql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
431
434
 
432
435
  return { statusCode: 200, body: "OK" };
433
436
  },
434
437
  {
435
438
  callbacks: remoteCallbacks(RECORDER_URL),
436
439
  handlerPath: "netlify/functions/create-order",
440
+ secret: process.env.NETLIFY_RECORDER_SECRET,
437
441
  }
438
442
  );
439
443
  ```
@@ -459,12 +463,12 @@ Each entry contains:
459
463
  | `new_data` | New row data (INSERT/UPDATE only) |
460
464
  | `changed_fields` | Array of column names that changed (UPDATE only) |
461
465
  | `performed_at` | Timestamp of the change |
462
- | `replay_request_id` | The Replay request ID that caused the change (when using `createAuditedSql`) |
466
+ | `replay_request_id` | The Replay request ID that caused the change |
463
467
  | `replay_request_call_index` | The sequential query index within the request |
464
468
 
465
469
  ### How it works
466
470
 
467
- `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.
471
+ The network interceptor detects Neon SQL HTTP requests (which use `fetch` internally) and automatically wraps each query in a transaction with `SET LOCAL` statements that inject the current request ID and call index. The PostgreSQL trigger function reads these via `current_setting()` and stamps audit rows atomically. Outside a handler context, queries execute normally without audit metadata.
468
472
 
469
473
  ---
470
474
 
@@ -573,17 +577,6 @@ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
573
577
  **Parameters:**
574
578
  - `sql` — A Neon SQL tagged-template function
575
579
 
576
- ### `createAuditedSql(sql): SqlFunction`
577
-
578
- 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.
579
-
580
- Falls back to non-audited execution when no request context is active or if the transaction fails.
581
-
582
- **Parameters:**
583
- - `sql` — A Neon SQL tagged-template function (must have a `.transaction()` method, as provided by the Neon HTTP driver)
584
-
585
- **Returns:** A wrapped SQL function with the same tagged-template interface.
586
-
587
580
  ## Environment Variables
588
581
 
589
582
  ### Required for all setups (read by `finishRequest`)
@@ -595,6 +588,7 @@ These must be set on your Netlify site. Your deploy script should resolve them f
595
588
  | `COMMIT_SHA` | Git commit hash of the deployed code | `git rev-parse HEAD` |
596
589
  | `BRANCH_NAME` | Git branch of the deployed code | `git rev-parse --abbrev-ref HEAD` |
597
590
  | `REPLAY_REPOSITORY_URL` | Git repository URL (no embedded credentials) | `git remote get-url origin` (strip tokens) |
591
+ | `NETLIFY_RECORDER_SECRET` | Secret for access control (strongly recommended) | `openssl rand -base64 32` — store in Netlify site env vars |
598
592
 
599
593
  ### Required for self-hosted recording (Option B)
600
594
 
package/dist/index.d.ts CHANGED
@@ -448,27 +448,7 @@ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string):
448
448
  * Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
449
449
  */
450
450
  declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
451
- /**
452
- * Wraps a Neon SQL tagged-template function so that each query runs
453
- * inside a transaction that first calls `set_config()` to inject the
454
- * current request ID and call index. The `audit_trigger_function`
455
- * reads these via `current_setting()` and stamps audit rows atomically
456
- * — no separate UPDATE required.
457
- *
458
- * Requires the Neon HTTP driver's `.transaction()` method so that
459
- * `set_config` and the user's query share one transaction.
460
- *
461
- * @example
462
- * ```ts
463
- * import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
464
- *
465
- * const sql = createAuditedSql(getSql());
466
- * await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
467
- * // audit_log row created by the trigger already has the request ID
468
- * ```
469
- */
470
- declare function createAuditedSql(sql: SqlFunction): SqlFunction;
471
451
 
472
452
  declare function getCurrentRequestId(): string | null;
473
453
 
474
- 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 };
454
+ 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, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
package/dist/index.js CHANGED
@@ -5,7 +5,41 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
5
5
  throw Error('Dynamic require of "' + x + '" is not supported');
6
6
  });
7
7
 
8
+ // src/requestState.ts
9
+ var _currentRequestId = null;
10
+ var _callIndex = 0;
11
+ function setCurrentRequestId(id) {
12
+ _currentRequestId = id;
13
+ _callIndex = 0;
14
+ }
15
+ function getCurrentRequestId() {
16
+ return _currentRequestId;
17
+ }
18
+ function incrementCallIndex() {
19
+ return ++_callIndex;
20
+ }
21
+
8
22
  // src/interceptors/network.ts
23
+ function isNeonQuery(obj) {
24
+ return typeof obj === "object" && obj !== null && "query" in obj && "params" in obj;
25
+ }
26
+ function isNeonSqlRequest(url, body) {
27
+ if (!body) return false;
28
+ try {
29
+ const u = new URL(url);
30
+ if (!u.pathname.endsWith("/sql")) return false;
31
+ const parsed = JSON.parse(body);
32
+ return isNeonQuery(parsed) || Array.isArray(parsed) && parsed.length > 0 && isNeonQuery(parsed[0]);
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ function buildSetConfigQueries(requestId, callIndex) {
38
+ return [
39
+ { query: "SELECT set_config('app.replay_request_id', $1, true)", params: [requestId] },
40
+ { query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
41
+ ];
42
+ }
9
43
  function installNetworkInterceptor(mode, calls) {
10
44
  const originalFetch = globalThis.fetch;
11
45
  const consumed = /* @__PURE__ */ new Set();
@@ -20,6 +54,20 @@ function installNetworkInterceptor(mode, calls) {
20
54
  });
21
55
  }
22
56
  const requestBody = typeof init?.body === "string" ? init.body : void 0;
57
+ const requestId = getCurrentRequestId();
58
+ if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
59
+ return await handleNeonSqlRequest(
60
+ originalFetch,
61
+ input,
62
+ init,
63
+ url,
64
+ method,
65
+ requestHeaders,
66
+ requestBody,
67
+ requestId,
68
+ calls
69
+ );
70
+ }
23
71
  const response = await originalFetch(input, init);
24
72
  const responseBody = await response.clone().text();
25
73
  const responseHeaders = {};
@@ -121,6 +169,50 @@ function installNetworkInterceptor(mode, calls) {
121
169
  }
122
170
  };
123
171
  }
172
+ async function handleNeonSqlRequest(originalFetch, input, init, url, method, requestHeaders, requestBody, requestId, calls) {
173
+ const parsed = JSON.parse(requestBody);
174
+ const wasTransaction = Array.isArray(parsed);
175
+ const callIndex = incrementCallIndex();
176
+ const setConfigs = buildSetConfigQueries(requestId, callIndex);
177
+ const txBody = wasTransaction ? [...setConfigs, ...parsed] : [...setConfigs, parsed];
178
+ const modifiedInit = {
179
+ ...init,
180
+ body: JSON.stringify(txBody)
181
+ };
182
+ const response = await originalFetch(input, modifiedInit);
183
+ const rawBody = await response.clone().text();
184
+ const responseHeaders = {};
185
+ response.headers.forEach((v, k) => {
186
+ responseHeaders[k] = v;
187
+ });
188
+ let recordedBody = rawBody;
189
+ if (response.ok) {
190
+ try {
191
+ const json = JSON.parse(rawBody);
192
+ if (json.results && Array.isArray(json.results)) {
193
+ const stripped = json.results.slice(setConfigs.length);
194
+ const unwrapped = wasTransaction ? { results: stripped } : stripped[0] ?? json;
195
+ recordedBody = JSON.stringify(unwrapped);
196
+ }
197
+ } catch {
198
+ }
199
+ }
200
+ calls.push({
201
+ url,
202
+ method,
203
+ requestHeaders,
204
+ requestBody,
205
+ responseStatus: response.status,
206
+ responseHeaders,
207
+ responseBody: recordedBody,
208
+ timestamp: Date.now()
209
+ });
210
+ return new Response(recordedBody, {
211
+ status: response.status,
212
+ statusText: response.statusText,
213
+ headers: responseHeaders
214
+ });
215
+ }
124
216
 
125
217
  // src/interceptors/environment.ts
126
218
  function installEnvironmentInterceptor(mode, reads) {
@@ -484,17 +576,6 @@ async function finishRequest(requestContext, callbacks, response, options) {
484
576
 
485
577
  // src/createRecordingRequestHandler.ts
486
578
  import crypto from "crypto";
487
-
488
- // src/requestState.ts
489
- var _currentRequestId = null;
490
- function setCurrentRequestId(id) {
491
- _currentRequestId = id;
492
- }
493
- function getCurrentRequestId() {
494
- return _currentRequestId;
495
- }
496
-
497
- // src/createRecordingRequestHandler.ts
498
579
  function createRecordingRequestHandler(handler, options) {
499
580
  return async (event, context) => {
500
581
  const requestId = crypto.randomUUID();
@@ -1149,7 +1230,7 @@ async function databaseAuditEnsureLogTable(sql) {
1149
1230
  req_id TEXT;
1150
1231
  call_idx INTEGER;
1151
1232
  BEGIN
1152
- -- Read application context set via SET LOCAL by createAuditedSql
1233
+ -- Read application context injected by the network interceptor
1153
1234
  req_id := COALESCE(current_setting('app.replay_request_id', true), '');
1154
1235
  IF req_id = '' THEN req_id := NULL; END IF;
1155
1236
 
@@ -1201,30 +1282,7 @@ async function databaseAuditDumpLogTable(sql) {
1201
1282
  const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
1202
1283
  return rows;
1203
1284
  }
1204
- function createAuditedSql(sql) {
1205
- let callIndex = 0;
1206
- const sqlWithTx = sql;
1207
- const auditedSql = async (strings, ...values) => {
1208
- const requestId = getCurrentRequestId();
1209
- callIndex++;
1210
- if (requestId && typeof sqlWithTx.transaction === "function") {
1211
- try {
1212
- const results = await sqlWithTx.transaction([
1213
- sql`SELECT set_config('app.replay_request_id', ${requestId}, true)`,
1214
- sql`SELECT set_config('app.replay_call_index', ${String(callIndex)}, true)`,
1215
- sql(strings, ...values)
1216
- ]);
1217
- return results[2];
1218
- } catch (err) {
1219
- console.warn("netlify-recorder: audited transaction failed, falling back:", err);
1220
- }
1221
- }
1222
- return await sql(strings, ...values);
1223
- };
1224
- return auditedSql;
1225
- }
1226
1285
  export {
1227
- createAuditedSql,
1228
1286
  createRecordingRequestHandler,
1229
1287
  createRequestRecording,
1230
1288
  databaseAuditDumpLogTable,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {