@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 +192 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +18 -2
- package/package.json +1 -1
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;
|