pipework 0.52.2 → 0.54.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/CHANGELOG.md +8 -0
- package/REFERENCE.md +78 -0
- package/dist/REFERENCE.md +20 -1
- package/dist/audit/chain/index.d.ts +4 -0
- package/dist/audit/chain/index.d.ts.map +1 -0
- package/dist/audit/chain/index.js +3 -0
- package/dist/audit/chain/index.js.map +1 -0
- package/dist/audit/chain/install.d.ts +20 -0
- package/dist/audit/chain/install.d.ts.map +1 -0
- package/dist/audit/chain/install.js +82 -0
- package/dist/audit/chain/install.js.map +1 -0
- package/dist/audit/chain/retire.d.ts +13 -0
- package/dist/audit/chain/retire.d.ts.map +1 -0
- package/dist/audit/chain/retire.js +32 -0
- package/dist/audit/chain/retire.js.map +1 -0
- package/dist/audit/chain/verify.d.ts +45 -0
- package/dist/audit/chain/verify.d.ts.map +1 -0
- package/dist/audit/chain/verify.js +180 -0
- package/dist/audit/chain/verify.js.map +1 -0
- package/dist/audit/schema/audit-record-fields.d.ts +17 -0
- package/dist/audit/schema/audit-record-fields.d.ts.map +1 -1
- package/dist/audit/schema/audit-record-fields.js +8 -0
- package/dist/audit/schema/audit-record-fields.js.map +1 -1
- package/dist/audit/schema/audit-record.d.ts +15 -0
- package/dist/audit/schema/audit-record.d.ts.map +1 -1
- package/dist/core/config/load.d.ts +2 -0
- package/dist/core/config/load.d.ts.map +1 -1
- package/dist/core/config/load.js +1 -0
- package/dist/core/config/load.js.map +1 -1
- package/dist/core/config/namespace.d.ts +2 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +4 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +2 -0
- package/dist/core/config/schema.js.map +1 -1
- package/dist/data/migrate/provision-framework.d.ts.map +1 -1
- package/dist/data/migrate/provision-framework.js +3 -0
- package/dist/data/migrate/provision-framework.js.map +1 -1
- package/dist/data/write/audit/audit-columns.d.ts +2 -3
- package/dist/data/write/audit/audit-columns.d.ts.map +1 -1
- package/dist/data/write/audit/audit-columns.js +4 -3
- package/dist/data/write/audit/audit-columns.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/infra/audit/namespace.d.ts +5 -0
- package/dist/infra/audit/namespace.d.ts.map +1 -1
- package/dist/infra/audit/namespace.js +5 -0
- package/dist/infra/audit/namespace.js.map +1 -1
- package/dist/trace/entry/open-trace.d.ts.map +1 -1
- package/dist/trace/entry/open-trace.js +1 -0
- package/dist/trace/entry/open-trace.js.map +1 -1
- package/dist/trace/external/record.d.ts +11 -0
- package/dist/trace/external/record.d.ts.map +1 -0
- package/dist/trace/external/record.js +156 -0
- package/dist/trace/external/record.js.map +1 -0
- package/dist/trace/external/report.d.ts +34 -0
- package/dist/trace/external/report.d.ts.map +1 -0
- package/dist/trace/external/report.js +47 -0
- package/dist/trace/external/report.js.map +1 -0
- package/dist/trace/namespace.d.ts +11 -0
- package/dist/trace/namespace.d.ts.map +1 -0
- package/dist/trace/namespace.js +11 -0
- package/dist/trace/namespace.js.map +1 -0
- package/dist/trace/partition/startup-hook.d.ts +5 -1
- package/dist/trace/partition/startup-hook.d.ts.map +1 -1
- package/dist/trace/partition/startup-hook.js +8 -1
- package/dist/trace/partition/startup-hook.js.map +1 -1
- package/dist/trace/partition/sync.d.ts.map +1 -1
- package/dist/trace/partition/sync.js +10 -0
- package/dist/trace/partition/sync.js.map +1 -1
- package/dist/trace/runtime/step-buffer.d.ts +30 -0
- package/dist/trace/runtime/step-buffer.d.ts.map +1 -1
- package/dist/trace/runtime/step-buffer.js +30 -1
- package/dist/trace/runtime/step-buffer.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.54.0
|
|
4
|
+
|
|
5
|
+
- **New: steps that run outside the Node process join the same trace.** `trace.traceparent()` returns the W3C `traceparent` for the current trace and open step (`null` outside a traced flow), and `trace.env()` returns `{ TRACEPARENT }` to merge into a child process's environment; an HTTP service that receives the header opens its trace as a child of yours. `trace.record(output)` takes the JSON lines the process printed (one step per line: `v`, `id`, `parent`, `name`, `start`, `end`, `status`, `error`, `capture`, `touches`) and records them as `trace_step` rows under the step that is open, with microsecond timings. Lines that are not JSON objects are ignored, malformed reports are counted in `invalid` and never throw, and a reported `touches` writes `audit_record` rows like a `@touches` step. The format needs no pipework code on the Rust or Python side. These steps do not count against the structural step cap; a new `trace.maxExternalSteps` (default 1000) limits them per trace.
|
|
6
|
+
|
|
7
|
+
## 0.53.0
|
|
8
|
+
|
|
9
|
+
- **New: tamper-evident audit records.** Set `audit: { chain: true }` in the blueprint and every `audit_record` row carries `chain_seq`, `prev_hash` and `hash`: a SHA-256 hash chain per tenant, written by a database trigger. Concurrent writers cannot fork a chain, and `audit_record` becomes append-only (UPDATE and DELETE are rejected). `audit.verify({ fromSeq, toSeq, from, to, anchor })` walks the chain and returns `{ ok: true, checked, head }` or the first break (`hash-mismatch`, `prev-mismatch`, `missing`, `head-mismatch`, `anchor-mismatch`) with its seq and record id; `audit.chainHead()` returns the `{ seq, hash }` to store outside the database. When retention drops an audit partition, the chain keeps a checkpoint so the remaining records still verify. The trigger locks the tenant's chain head, so one tenant's audited writes serialize until their transaction commits; the option is off by default. The canonical form that is hashed is documented in the API reference so an external verifier can reproduce it.
|
|
10
|
+
|
|
3
11
|
## 0.52.2
|
|
4
12
|
|
|
5
13
|
- **Fix: test setup's connection probes now honor `PGCONNECT_TIMEOUT`.** The RLS-role probe and the queue-table check pinned a 5 s connect timeout (the template probe 10 s), which overrides `PGCONNECT_TIMEOUT` in postgres.js, so on a loaded server one slow connect failed a whole test file with `CONNECT_TIMEOUT`. They now leave the timeout to the driver: `PGCONNECT_TIMEOUT` seconds, else 30 s, the same as every other connection setup opens. When a probe still cannot connect, the error names the database and the step. Startup validation keeps its 5 s.
|
package/REFERENCE.md
CHANGED
|
@@ -1014,6 +1014,28 @@ Creates an Audit instance that writes audit records to a Postgres table.
|
|
|
1014
1014
|
|
|
1015
1015
|
Queries the framework-managed `audit_record` table. Tenant-scoped via the active Flow.
|
|
1016
1016
|
|
|
1017
|
+
### `audit.chainHead`
|
|
1018
|
+
|
|
1019
|
+
The newest record's `{ seq, hash }` in the tenant's audit hash chain, to store outside the database as an anchor; `null` when the chain is empty.
|
|
1020
|
+
|
|
1021
|
+
### `audit.verify`
|
|
1022
|
+
|
|
1023
|
+
Walks the tenant's audit hash chain and reports the first break, or `{ ok: true, checked, head }`.
|
|
1024
|
+
|
|
1025
|
+
#### Audit hash chain
|
|
1026
|
+
|
|
1027
|
+
Opt in with blueprint `audit: { chain: true }`. Every `audit_record` row then carries `chain_seq`, `prev_hash` and `hash` (lowercase hex SHA-256), filled by a database trigger; the application never supplies them. A tenant's rows form one chain, and rows with no tenant form another. Rows written before the chain was enabled have these columns null. `audit_record` becomes append-only: UPDATE and DELETE are rejected (dropping a partition is not).
|
|
1028
|
+
|
|
1029
|
+
**Lock cost.** The trigger locks the tenant's chain-head row, so one tenant's audited writes serialize from their first audit row until their transaction commits. Different tenants do not block each other. Keep transactions that write audit rows short.
|
|
1030
|
+
|
|
1031
|
+
**Canonical form.** A row's hash is `sha256(prev_hash_bytes || utf8(canonical))`, where `prev_hash_bytes` is the previous row's hash (32 zero bytes for `chain_seq` 1) and `canonical` is the text of this JSON array, as Postgres renders it: `[chain_seq, id, trace_day (YYYY-MM-DD), trace_id, trace_step_id, tenant_id, actor_id, entity, verb, before, after, captured_inputs, occurred_at]`. `occurred_at` is UTC, formatted `YYYY-MM-DDTHH:MI:SS.UUUUUUZ` (microseconds), so the result does not depend on the session TimeZone. `before`, `after` and `captured_inputs` are jsonb (keys in jsonb order). The SQL function `pipework_audit_record_canonical(audit_record)` returns this text.
|
|
1032
|
+
|
|
1033
|
+
**`audit.verify(options?)`** checks every seq in the range. Options: `fromSeq`, `toSeq`, `from` / `to` (`occurredAt >= from`, `< to`), and `anchor: { seq, hash }` (a pair saved from `audit.chainHead()`). Without an upper bound, the range ends at the chain head and its last record must equal the head, which catches deletion of the newest rows (reported as `missing`); rows committed after the head was read are not checked. Breaks, always the first in seq order: `hash-mismatch` (row edited), `prev-mismatch` (link to the previous row broken), `missing` (a seq has no row), `head-mismatch` (the chain head disagrees with the newest record, or is gone while records remain), `anchor-mismatch`. A tenant-scoped Flow verifies that tenant's chain; outside one, the chain of tenant-less rows. `seq` values are returned as numbers.
|
|
1034
|
+
|
|
1035
|
+
**Retention.** Before `syncTracePartitions` drops an `audit_record` partition it copies the rows' `(seq, hash)` into `pipework_audit_chain_retired`, keeping only the entry the oldest remaining row links to, so what remains still verifies. Anyone who must detect deletion of the oldest records should keep an external anchor.
|
|
1036
|
+
|
|
1037
|
+
The chain is installed (idempotently) when the framework tables are provisioned and by the trace partition startup hook when `audit.chain` is on.
|
|
1038
|
+
|
|
1017
1039
|
## cache — Caching
|
|
1018
1040
|
|
|
1019
1041
|
In-memory caching namespace — TTL-based with stats, preload, and tenant-aware variants.
|
|
@@ -1220,3 +1242,59 @@ Wraps `impl` so that, under an active traced Flow, each call records a `trace_st
|
|
|
1220
1242
|
```typescript
|
|
1221
1243
|
function __flowStep<F extends AnyFunction>(meta: FlowStepMeta, impl: F): F
|
|
1222
1244
|
```
|
|
1245
|
+
|
|
1246
|
+
### `trace.traceparent`
|
|
1247
|
+
|
|
1248
|
+
W3C `traceparent` for the current trace: `00-<trace id, 32 hex>-<span id>-01`. The span id is the tail of the open step's id (the trace id's when no step is open). Returns `null` outside a traced flow or once the trace is truncated. A pipework service that receives it over HTTP opens its trace with `parent_id` set to this trace.
|
|
1249
|
+
|
|
1250
|
+
```typescript
|
|
1251
|
+
function traceparent(): string | null
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
### `trace.env`
|
|
1255
|
+
|
|
1256
|
+
`{ TRACEPARENT: <traceparent> }` for a child process's environment, or `{}` when there is no traceparent. `TRACEPARENT` is the propagation variable the process should read and pass on.
|
|
1257
|
+
|
|
1258
|
+
```typescript
|
|
1259
|
+
function env(): Record<string, string>
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
### `trace.record`
|
|
1263
|
+
|
|
1264
|
+
Records steps reported by a process outside Node as `trace_step` rows under the step open at the call (root-level steps when none is open). They share the trace, show in it like any step, and a reported `touches` writes one `audit_record` per target. Synchronous; outside a traced flow it does nothing and returns zeros; it never throws on bad input. Reported steps do not count against the structural step cap; they are limited by `trace.maxExternalSteps` (default 1000), and reports over it are counted in `dropped`.
|
|
1265
|
+
|
|
1266
|
+
```typescript
|
|
1267
|
+
function record(input: string | Iterable<ExternalStepReport>): { recorded: number; invalid: number; dropped: number }
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
The report is one JSON object per line, so Rust or Python needs no pipework code. Blank lines and lines not starting with `{` are ignored (the process may log to the same stream); a malformed object, a duplicate `id`, `end` before `start`, or a `parent` chain that cycles is skipped and counted in `invalid`.
|
|
1271
|
+
|
|
1272
|
+
```json
|
|
1273
|
+
{"v":1,"id":"s1","parent":null,"name":"ocr.page","start":"2026-10-09T12:00:00.123456Z","end":"2026-10-09T12:00:00.623456Z","status":"ok","capture":{"page":3},"touches":["Document:read"]}
|
|
1274
|
+
{"v":1,"id":"s2","parent":"s1","name":"ocr.ink","start":"2026-10-09T12:00:00.2Z","end":"2026-10-09T12:00:00.3Z","status":"error","error":{"name":"Panic","message":"bad glyph","stack":"..."}}
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
| Field | Rule |
|
|
1278
|
+
|---|---|
|
|
1279
|
+
| `v` | required, `1` |
|
|
1280
|
+
| `id` | required, 1-128 chars, unique within the call |
|
|
1281
|
+
| `parent` | optional; an `id` from the same call. Absent, null, unknown or self means the calling step |
|
|
1282
|
+
| `name` | required, 1-200 chars; recorded as `flow_step_ref` `external:<name>` |
|
|
1283
|
+
| `start`, `end` | required RFC 3339 with offset, microsecond precision kept; `end >= start` |
|
|
1284
|
+
| `status` | required, `ok` or `error` |
|
|
1285
|
+
| `error` | optional `{ name?, message, stack? }`, kept only when `status` is `error` |
|
|
1286
|
+
| `capture` | optional JSON, bounded and redacted like any capture; dropped when `trace.captureInputs` is off |
|
|
1287
|
+
| `touches` | optional `["Entity:verb"]`; one `audit_record` per target when the step is `ok` |
|
|
1288
|
+
|
|
1289
|
+
Spawn and record, in a traced flow:
|
|
1290
|
+
|
|
1291
|
+
```typescript
|
|
1292
|
+
import { spawn } from 'node:child_process'
|
|
1293
|
+
import { trace } from 'pipework'
|
|
1294
|
+
|
|
1295
|
+
const child = spawn('./engine', args, { env: { ...process.env, ...trace.env() } }) // child reads TRACEPARENT
|
|
1296
|
+
let out = ''
|
|
1297
|
+
child.stdout.on('data', chunk => { out += chunk })
|
|
1298
|
+
await new Promise(resolve => child.on('close', resolve))
|
|
1299
|
+
trace.record(out) // the child's reported steps now sit under this step
|
|
1300
|
+
```
|
package/dist/REFERENCE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Pipework API Reference
|
|
2
2
|
|
|
3
3
|
> Auto-generated from source JSDoc. Do not edit manually.
|
|
4
|
-
> Generated: 2026-09
|
|
4
|
+
> Generated: 2026-10-09
|
|
5
5
|
|
|
6
6
|
## Contents
|
|
7
7
|
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
- [openapi — API Documentation](#openapi-api-documentation)
|
|
36
36
|
- [test](#test)
|
|
37
37
|
- [data](#data)
|
|
38
|
+
- [request](#request)
|
|
38
39
|
- [trace](#trace)
|
|
39
40
|
|
|
40
41
|
## Core
|
|
@@ -1029,6 +1030,14 @@ Creates an Audit instance that writes audit records to a Postgres table.
|
|
|
1029
1030
|
|
|
1030
1031
|
Queries the framework-managed `audit_record` table. Tenant-scoped via the active Flow.
|
|
1031
1032
|
|
|
1033
|
+
### `audit.chainHead`
|
|
1034
|
+
|
|
1035
|
+
The newest record's `{ seq, hash }` in the tenant's audit hash chain (blueprint `audit.chain`), to store outside the database; `null` when empty.
|
|
1036
|
+
|
|
1037
|
+
### `audit.verify`
|
|
1038
|
+
|
|
1039
|
+
Walks the tenant's audit hash chain and reports the first break (hash-mismatch, prev-mismatch, missing, head-mismatch, anchor-mismatch), or `{ ok: true }`.
|
|
1040
|
+
|
|
1032
1041
|
## cache — Caching
|
|
1033
1042
|
|
|
1034
1043
|
In-memory caching namespace — TTL-based with stats, preload, and tenant-aware variants.
|
|
@@ -1226,6 +1235,16 @@ lifecycle: always `await conn.close()`.
|
|
|
1226
1235
|
function tap(url: string, options?: ConnectOptions): ManagedConnection
|
|
1227
1236
|
```
|
|
1228
1237
|
|
|
1238
|
+
## request
|
|
1239
|
+
|
|
1240
|
+
### `createServer`
|
|
1241
|
+
|
|
1242
|
+
Creates a PipeworkServer — wraps Fastify with auto request IDs, CORS, rate limiting, and transaction-per-request.
|
|
1243
|
+
|
|
1244
|
+
```typescript
|
|
1245
|
+
function createServer(instance: Manifold, config: ServerConfig = if (instance.config.environment === 'production')
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1229
1248
|
## trace
|
|
1230
1249
|
|
|
1231
1250
|
### `__flowStep`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/audit/chain/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAC/C,YAAY,EACV,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,aAAa,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/audit/chain/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DB } from '../../data/db/index.js';
|
|
2
|
+
/** Chain key of rows with no tenant — they form their own chain. */
|
|
3
|
+
export declare const NULL_TENANT_CHAIN_KEY = "00000000-0000-0000-0000-000000000000";
|
|
4
|
+
export declare const CHAIN_HEAD_TABLE = "pipework_audit_chain_head";
|
|
5
|
+
export declare const CHAIN_RETIRED_TABLE = "pipework_audit_chain_retired";
|
|
6
|
+
/** SQL expression for a row's chain key; the chain index is built on exactly this. */
|
|
7
|
+
export declare const CHAIN_KEY_SQL = "coalesce(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid)";
|
|
8
|
+
/**
|
|
9
|
+
* The text hashed for one row. `jsonb_build_array(...)::text` of, in order:
|
|
10
|
+
* chain_seq, id, trace_day (YYYY-MM-DD), trace_id, trace_step_id, tenant_id,
|
|
11
|
+
* actor_id, entity, verb, before, after, captured_inputs, occurred_at (UTC,
|
|
12
|
+
* `YYYY-MM-DD"T"HH24:MI:SS.US"Z"`). Independent of the session TimeZone.
|
|
13
|
+
*/
|
|
14
|
+
export declare const CANONICAL_FUNCTION_SQL = "\nCREATE OR REPLACE FUNCTION pipework_audit_record_canonical(r audit_record) RETURNS text\nLANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$\n SELECT jsonb_build_array(\n r.chain_seq, r.id, to_char(r.trace_day, 'YYYY-MM-DD'), r.trace_id, r.trace_step_id,\n r.tenant_id, r.actor_id, r.entity, r.verb, r.before, r.after, r.captured_inputs,\n to_char(r.occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"')\n )::text\n$$";
|
|
15
|
+
/**
|
|
16
|
+
* Install the audit hash chain on `audit_record`. The table must exist.
|
|
17
|
+
* Idempotent.
|
|
18
|
+
*/
|
|
19
|
+
export declare function installAuditChain(db: DB): Promise<void>;
|
|
20
|
+
//# sourceMappingURL=install.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../../src/audit/chain/install.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,wBAAwB,CAAA;AAGhD,oEAAoE;AACpE,eAAO,MAAM,qBAAqB,yCAAyC,CAAA;AAE3E,eAAO,MAAM,gBAAgB,8BAA8B,CAAA;AAC3D,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AAEjE,sFAAsF;AACtF,eAAO,MAAM,aAAa,sEAA0D,CAAA;AAIpF;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,wcAQhC,CAAA;AAgCH;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B7D"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Installs the audit hash chain: head/retired bookkeeping tables, the
|
|
2
|
+
// canonical-form function, and the triggers on `audit_record`. Everything is
|
|
3
|
+
// CREATE OR REPLACE / IF NOT EXISTS, so installing twice is a no-op.
|
|
4
|
+
//
|
|
5
|
+
// The chain is written only by the BEFORE INSERT trigger: it locks the tenant's
|
|
6
|
+
// head row, so concurrent writers to one chain queue behind each other and the
|
|
7
|
+
// sequence is gapless (a rolled-back transaction releases the lock and leaves
|
|
8
|
+
// no gap). A second trigger rejects UPDATE and DELETE; partition DROP, which
|
|
9
|
+
// is how retention works, does not fire row triggers.
|
|
10
|
+
import { sql } from '../../data/db/sql.js';
|
|
11
|
+
import { quoteIdentifier } from '../../data/db/identifiers.js';
|
|
12
|
+
/** Chain key of rows with no tenant — they form their own chain. */
|
|
13
|
+
export const NULL_TENANT_CHAIN_KEY = '00000000-0000-0000-0000-000000000000';
|
|
14
|
+
export const CHAIN_HEAD_TABLE = 'pipework_audit_chain_head';
|
|
15
|
+
export const CHAIN_RETIRED_TABLE = 'pipework_audit_chain_retired';
|
|
16
|
+
/** SQL expression for a row's chain key; the chain index is built on exactly this. */
|
|
17
|
+
export const CHAIN_KEY_SQL = `coalesce(tenant_id, '${NULL_TENANT_CHAIN_KEY}'::uuid)`;
|
|
18
|
+
const GENESIS_HASH_HEX = '00'.repeat(32);
|
|
19
|
+
/**
|
|
20
|
+
* The text hashed for one row. `jsonb_build_array(...)::text` of, in order:
|
|
21
|
+
* chain_seq, id, trace_day (YYYY-MM-DD), trace_id, trace_step_id, tenant_id,
|
|
22
|
+
* actor_id, entity, verb, before, after, captured_inputs, occurred_at (UTC,
|
|
23
|
+
* `YYYY-MM-DD"T"HH24:MI:SS.US"Z"`). Independent of the session TimeZone.
|
|
24
|
+
*/
|
|
25
|
+
export const CANONICAL_FUNCTION_SQL = `
|
|
26
|
+
CREATE OR REPLACE FUNCTION pipework_audit_record_canonical(r audit_record) RETURNS text
|
|
27
|
+
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
|
|
28
|
+
SELECT jsonb_build_array(
|
|
29
|
+
r.chain_seq, r.id, to_char(r.trace_day, 'YYYY-MM-DD'), r.trace_id, r.trace_step_id,
|
|
30
|
+
r.tenant_id, r.actor_id, r.entity, r.verb, r.before, r.after, r.captured_inputs,
|
|
31
|
+
to_char(r.occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')
|
|
32
|
+
)::text
|
|
33
|
+
$$`;
|
|
34
|
+
function insertTriggerFunctionSql(schema) {
|
|
35
|
+
return `
|
|
36
|
+
CREATE OR REPLACE FUNCTION pipework_audit_chain_before_insert() RETURNS trigger
|
|
37
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, ${schema} AS $$
|
|
38
|
+
DECLARE
|
|
39
|
+
k uuid := coalesce(NEW.tenant_id, '${NULL_TENANT_CHAIN_KEY}'::uuid);
|
|
40
|
+
h record;
|
|
41
|
+
BEGIN
|
|
42
|
+
INSERT INTO ${CHAIN_HEAD_TABLE} (chain_key, seq, hash)
|
|
43
|
+
VALUES (k, 0, decode('${GENESIS_HASH_HEX}', 'hex'))
|
|
44
|
+
ON CONFLICT DO NOTHING;
|
|
45
|
+
SELECT seq, hash INTO h FROM ${CHAIN_HEAD_TABLE} WHERE chain_key = k FOR UPDATE;
|
|
46
|
+
NEW.chain_seq := h.seq + 1;
|
|
47
|
+
NEW.prev_hash := encode(h.hash, 'hex');
|
|
48
|
+
NEW.hash := encode(sha256(h.hash || convert_to(pipework_audit_record_canonical(NEW), 'UTF8')), 'hex');
|
|
49
|
+
UPDATE ${CHAIN_HEAD_TABLE} SET seq = NEW.chain_seq, hash = decode(NEW.hash, 'hex') WHERE chain_key = k;
|
|
50
|
+
RETURN NEW;
|
|
51
|
+
END
|
|
52
|
+
$$`;
|
|
53
|
+
}
|
|
54
|
+
const APPEND_ONLY_FUNCTION_SQL = `
|
|
55
|
+
CREATE OR REPLACE FUNCTION pipework_audit_chain_append_only() RETURNS trigger
|
|
56
|
+
LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
|
57
|
+
BEGIN
|
|
58
|
+
RAISE EXCEPTION 'audit_record is append-only: % is not allowed', TG_OP
|
|
59
|
+
USING ERRCODE = 'restrict_violation';
|
|
60
|
+
END
|
|
61
|
+
$$`;
|
|
62
|
+
/**
|
|
63
|
+
* Install the audit hash chain on `audit_record`. The table must exist.
|
|
64
|
+
* Idempotent.
|
|
65
|
+
*/
|
|
66
|
+
export async function installAuditChain(db) {
|
|
67
|
+
const schemaRows = await db.execute(sql `SELECT current_schema() AS schema`);
|
|
68
|
+
const schema = quoteIdentifier(String(schemaRows[0]['schema']), 'schema');
|
|
69
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${CHAIN_HEAD_TABLE} (` +
|
|
70
|
+
`chain_key uuid PRIMARY KEY, seq bigint NOT NULL, hash bytea NOT NULL)`));
|
|
71
|
+
await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${CHAIN_RETIRED_TABLE} (` +
|
|
72
|
+
`chain_key uuid NOT NULL, seq bigint NOT NULL, hash bytea NOT NULL, PRIMARY KEY (chain_key, seq))`));
|
|
73
|
+
await db.execute(sql.raw(CANONICAL_FUNCTION_SQL));
|
|
74
|
+
await db.execute(sql.raw(insertTriggerFunctionSql(schema)));
|
|
75
|
+
await db.execute(sql.raw(APPEND_ONLY_FUNCTION_SQL));
|
|
76
|
+
await db.execute(sql.raw(`CREATE OR REPLACE TRIGGER pipework_audit_chain_insert BEFORE INSERT ON audit_record ` +
|
|
77
|
+
`FOR EACH ROW EXECUTE FUNCTION pipework_audit_chain_before_insert()`));
|
|
78
|
+
await db.execute(sql.raw(`CREATE OR REPLACE TRIGGER pipework_audit_chain_append_only BEFORE UPDATE OR DELETE ON audit_record ` +
|
|
79
|
+
`FOR EACH ROW EXECUTE FUNCTION pipework_audit_chain_append_only()`));
|
|
80
|
+
await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS audit_record_chain_idx ON audit_record ((${CHAIN_KEY_SQL}), chain_seq)`));
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=install.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install.js","sourceRoot":"","sources":["../../../src/audit/chain/install.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,6EAA6E;AAC7E,qEAAqE;AACrE,EAAE;AACF,gFAAgF;AAChF,+EAA+E;AAC/E,8EAA8E;AAC9E,6EAA6E;AAC7E,sDAAsD;AAEtD,OAAO,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAA;AAE1C,OAAO,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAA;AAE9D,oEAAoE;AACpE,MAAM,CAAC,MAAM,qBAAqB,GAAG,sCAAsC,CAAA;AAE3E,MAAM,CAAC,MAAM,gBAAgB,GAAG,2BAA2B,CAAA;AAC3D,MAAM,CAAC,MAAM,mBAAmB,GAAG,8BAA8B,CAAA;AAEjE,sFAAsF;AACtF,MAAM,CAAC,MAAM,aAAa,GAAG,wBAAwB,qBAAqB,UAAU,CAAA;AAEpF,MAAM,gBAAgB,GAAG,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAExC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG;;;;;;;;GAQnC,CAAA;AAEH,SAAS,wBAAwB,CAAC,MAAc;IAC9C,OAAO;;kEAEyD,MAAM;;uCAEjC,qBAAqB;;;gBAG5C,gBAAgB;4BACJ,gBAAgB;;iCAEX,gBAAgB;;;;WAItC,gBAAgB;;;GAGxB,CAAA;AACH,CAAC;AAED,MAAM,wBAAwB,GAAG;;;;;;;GAO9B,CAAA;AAEH;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,EAAM;IAC5C,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAA,mCAAmC,CAAC,CAAA;IAC3E,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAA;IAE1E,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,8BAA8B,gBAAgB,IAAI;QAClD,uEAAuE,CACxE,CAAC,CAAA;IACF,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,8BAA8B,mBAAmB,IAAI;QACrD,kGAAkG,CACnG,CAAC,CAAA;IACF,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC,CAAA;IACjD,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,wBAAwB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAC3D,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC,CAAA;IACnD,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,sFAAsF;QACtF,oEAAoE,CACrE,CAAC,CAAA;IACF,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,qGAAqG;QACrG,kEAAkE,CACnE,CAAC,CAAA;IACF,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,uEAAuE,aAAa,eAAe,CACpG,CAAC,CAAA;AACJ,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DB } from '../../data/db/index.js';
|
|
2
|
+
/** True when the chain tables exist, i.e. the chain was installed on this database. */
|
|
3
|
+
export declare function auditChainInstalled(db: DB): Promise<boolean>;
|
|
4
|
+
/**
|
|
5
|
+
* Retire and drop one audit partition. Run inside a transaction. `child` is the
|
|
6
|
+
* already-quoted partition name.
|
|
7
|
+
*
|
|
8
|
+
* After the drop, a chain keeps only the retired entries at or above
|
|
9
|
+
* `(lowest remaining seq) - 1` — the one entry the oldest remaining row links
|
|
10
|
+
* to. A chain with no rows left keeps just its highest retired entry.
|
|
11
|
+
*/
|
|
12
|
+
export declare function retireAuditPartition(tx: DB, child: string): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=retire.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retire.d.ts","sourceRoot":"","sources":["../../../src/audit/chain/retire.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,wBAAwB,CAAA;AAGhD,uFAAuF;AACvF,wBAAsB,mBAAmB,CAAC,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAMlE;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAa/E"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Retention for the audit chain: before an `audit_record` day partition is
|
|
2
|
+
// dropped, its (seq, hash) pairs move to the retired table so the rows that
|
|
3
|
+
// remain still link to the ones that went.
|
|
4
|
+
import { sql } from '../../data/db/sql.js';
|
|
5
|
+
import { CHAIN_HEAD_TABLE, CHAIN_RETIRED_TABLE, CHAIN_KEY_SQL } from './install.js';
|
|
6
|
+
/** True when the chain tables exist, i.e. the chain was installed on this database. */
|
|
7
|
+
export async function auditChainInstalled(db) {
|
|
8
|
+
const rows = await db.execute(sql `
|
|
9
|
+
SELECT to_regclass(${CHAIN_HEAD_TABLE}) IS NOT NULL
|
|
10
|
+
AND to_regclass(${CHAIN_RETIRED_TABLE}) IS NOT NULL AS installed
|
|
11
|
+
`);
|
|
12
|
+
return rows[0]?.['installed'] === true;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Retire and drop one audit partition. Run inside a transaction. `child` is the
|
|
16
|
+
* already-quoted partition name.
|
|
17
|
+
*
|
|
18
|
+
* After the drop, a chain keeps only the retired entries at or above
|
|
19
|
+
* `(lowest remaining seq) - 1` — the one entry the oldest remaining row links
|
|
20
|
+
* to. A chain with no rows left keeps just its highest retired entry.
|
|
21
|
+
*/
|
|
22
|
+
export async function retireAuditPartition(tx, child) {
|
|
23
|
+
await tx.execute(sql.raw(`INSERT INTO ${CHAIN_RETIRED_TABLE} (chain_key, seq, hash) ` +
|
|
24
|
+
`SELECT ${CHAIN_KEY_SQL}, chain_seq, decode(hash, 'hex') FROM ${child} ` +
|
|
25
|
+
`WHERE chain_seq IS NOT NULL ON CONFLICT DO NOTHING`));
|
|
26
|
+
await tx.execute(sql.raw(`DROP TABLE IF EXISTS ${child}`));
|
|
27
|
+
await tx.execute(sql.raw(`DELETE FROM ${CHAIN_RETIRED_TABLE} r WHERE r.seq < coalesce(` +
|
|
28
|
+
`(SELECT min(a.chain_seq) - 1 FROM audit_record a ` +
|
|
29
|
+
`WHERE ${CHAIN_KEY_SQL.replace('tenant_id', 'a.tenant_id')} = r.chain_key AND a.chain_seq IS NOT NULL), ` +
|
|
30
|
+
`(SELECT max(x.seq) FROM ${CHAIN_RETIRED_TABLE} x WHERE x.chain_key = r.chain_key))`));
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=retire.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retire.js","sourceRoot":"","sources":["../../../src/audit/chain/retire.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,4EAA4E;AAC5E,2CAA2C;AAE3C,OAAO,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAA;AAE1C,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAEnF,uFAAuF;AACvF,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAM;IAC9C,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAA;yBACV,gBAAgB;yBAChB,mBAAmB;GACzC,CAAC,CAAA;IACF,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,WAAW,CAAC,KAAK,IAAI,CAAA;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,EAAM,EAAE,KAAa;IAC9D,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,eAAe,mBAAmB,0BAA0B;QAC5D,UAAU,aAAa,yCAAyC,KAAK,GAAG;QACxE,oDAAoD,CACrD,CAAC,CAAA;IACF,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,wBAAwB,KAAK,EAAE,CAAC,CAAC,CAAA;IAC1D,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CACtB,eAAe,mBAAmB,4BAA4B;QAC9D,mDAAmD;QACnD,SAAS,aAAa,CAAC,OAAO,CAAC,WAAW,EAAE,aAAa,CAAC,+CAA+C;QACzG,2BAA2B,mBAAmB,sCAAsC,CACrF,CAAC,CAAA;AACJ,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** A chain position: the sequence number and the record's lowercase-hex SHA-256. */
|
|
2
|
+
export interface AuditChainHead {
|
|
3
|
+
readonly seq: number;
|
|
4
|
+
readonly hash: string;
|
|
5
|
+
}
|
|
6
|
+
export type AuditChainBreakReason = 'hash-mismatch' | 'prev-mismatch' | 'missing' | 'head-mismatch' | 'anchor-mismatch';
|
|
7
|
+
export interface AuditChainBreak {
|
|
8
|
+
readonly seq: number;
|
|
9
|
+
/** The record at `seq`, or null when none exists. */
|
|
10
|
+
readonly recordId: string | null;
|
|
11
|
+
readonly reason: AuditChainBreakReason;
|
|
12
|
+
}
|
|
13
|
+
export interface AuditVerifyOptions {
|
|
14
|
+
/** Inclusive lower bound on the chain sequence. */
|
|
15
|
+
readonly fromSeq?: number;
|
|
16
|
+
/** Inclusive upper bound. With any upper bound, the chain head is not compared. */
|
|
17
|
+
readonly toSeq?: number;
|
|
18
|
+
/** Start at the first record with `occurredAt >= from`. */
|
|
19
|
+
readonly from?: Date;
|
|
20
|
+
/** End at the last record with `occurredAt < to`. */
|
|
21
|
+
readonly to?: Date;
|
|
22
|
+
/** A (seq, hash) pair the caller stored earlier; the chain must still hold it. */
|
|
23
|
+
readonly anchor?: AuditChainHead;
|
|
24
|
+
}
|
|
25
|
+
export type AuditVerifyResult = {
|
|
26
|
+
readonly ok: true;
|
|
27
|
+
readonly checked: number;
|
|
28
|
+
readonly head: AuditChainHead | null;
|
|
29
|
+
} | {
|
|
30
|
+
readonly ok: false;
|
|
31
|
+
readonly checked: number;
|
|
32
|
+
readonly break: AuditChainBreak;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The newest record's position in the tenant's chain, for the app to store
|
|
36
|
+
* outside the database. `null` when the chain has no records yet.
|
|
37
|
+
*/
|
|
38
|
+
export declare function chainHead(): Promise<AuditChainHead | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Walks the tenant's chain and reports the first break. Every seq in the
|
|
41
|
+
* checked range is examined: a record is recomputed from its fields, linked to
|
|
42
|
+
* the one before it, and a missing seq is a break.
|
|
43
|
+
*/
|
|
44
|
+
export declare function verify(options?: AuditVerifyOptions): Promise<AuditVerifyResult>;
|
|
45
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../../src/audit/chain/verify.ts"],"names":[],"mappings":"AAoBA,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,MAAM,qBAAqB,GAC7B,eAAe,GACf,eAAe,GACf,SAAS,GACT,eAAe,GACf,iBAAiB,CAAA;AAErB,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAA;CACvC;AAED,MAAM,WAAW,kBAAkB;IACjC,mDAAmD;IACnD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,mFAAmF;IACnF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,2DAA2D;IAC3D,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAA;IACpB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,CAAC,EAAE,IAAI,CAAA;IAClB,kFAAkF;IAClF,QAAQ,CAAC,MAAM,CAAC,EAAE,cAAc,CAAA;CACjC;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAAA;CAAE,GACrF;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAA;CAAE,CAAA;AAsBrF;;;GAGG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAEhE;AAED;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAgIzF"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `audit.chainHead` and `audit.verify` — the read side of the audit hash chain.
|
|
2
|
+
//
|
|
3
|
+
// Both act on the active Flow's tenant chain; outside a tenant scope they act
|
|
4
|
+
// on the chain of rows with no tenant. The recompute runs in SQL
|
|
5
|
+
// (`pipework_audit_record_canonical`), so what is checked is exactly what the
|
|
6
|
+
// trigger hashed.
|
|
7
|
+
import { auditRecord } from '../schema/audit-record.js';
|
|
8
|
+
import { pipeRaw } from '../../data/db/db.js';
|
|
9
|
+
import { sql } from '../../data/db/sql.js';
|
|
10
|
+
import { getContext } from '../../request/context/store.js';
|
|
11
|
+
import { ValidationError } from '../../core/errors/index.js';
|
|
12
|
+
import { CHAIN_HEAD_TABLE, CHAIN_RETIRED_TABLE, CHAIN_KEY_SQL, NULL_TENANT_CHAIN_KEY, } from './install.js';
|
|
13
|
+
const BATCH_SIZE = 1000;
|
|
14
|
+
const GENESIS_HASH = '0'.repeat(64);
|
|
15
|
+
function chainKey() {
|
|
16
|
+
return getContext()?.tenant ?? NULL_TENANT_CHAIN_KEY;
|
|
17
|
+
}
|
|
18
|
+
function toSeq(value) {
|
|
19
|
+
return Number(value);
|
|
20
|
+
}
|
|
21
|
+
async function readHead(db, key) {
|
|
22
|
+
const rows = await db.execute(sql `
|
|
23
|
+
SELECT seq, encode(hash, 'hex') AS hash FROM ${sql.raw(CHAIN_HEAD_TABLE)}
|
|
24
|
+
WHERE chain_key = ${key}::uuid AND seq > 0
|
|
25
|
+
`);
|
|
26
|
+
const row = rows[0];
|
|
27
|
+
return row === undefined ? null : { seq: toSeq(row['seq']), hash: row['hash'] };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The newest record's position in the tenant's chain, for the app to store
|
|
31
|
+
* outside the database. `null` when the chain has no records yet.
|
|
32
|
+
*/
|
|
33
|
+
export async function chainHead() {
|
|
34
|
+
return readHead(pipeRaw(auditRecord.$options.database), chainKey());
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walks the tenant's chain and reports the first break. Every seq in the
|
|
38
|
+
* checked range is examined: a record is recomputed from its fields, linked to
|
|
39
|
+
* the one before it, and a missing seq is a break.
|
|
40
|
+
*/
|
|
41
|
+
export async function verify(options = {}) {
|
|
42
|
+
for (const [name, value] of [['fromSeq', options.fromSeq], ['toSeq', options.toSeq]]) {
|
|
43
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
|
|
44
|
+
throw new ValidationError(`audit.verify: ${name} must be a positive integer (got ${String(value)}).`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const db = pipeRaw(auditRecord.$options.database);
|
|
48
|
+
const key = chainKey();
|
|
49
|
+
const keyMatch = sql `${sql.raw(CHAIN_KEY_SQL)} = ${key}::uuid`;
|
|
50
|
+
const bounded = options.toSeq !== undefined || options.to !== undefined;
|
|
51
|
+
const head = await readHead(db, key);
|
|
52
|
+
// Resolve the range [lo, hi].
|
|
53
|
+
let lo = options.fromSeq ?? 1;
|
|
54
|
+
if (options.fromSeq === undefined && options.from === undefined) {
|
|
55
|
+
const r = await db.execute(sql `
|
|
56
|
+
SELECT min(seq) AS seq FROM ${sql.raw(CHAIN_RETIRED_TABLE)} WHERE chain_key = ${key}::uuid
|
|
57
|
+
`);
|
|
58
|
+
if (r[0]?.['seq'] !== null && r[0]?.['seq'] !== undefined)
|
|
59
|
+
lo = toSeq(r[0]['seq']);
|
|
60
|
+
}
|
|
61
|
+
const maxRow = await db.execute(sql `
|
|
62
|
+
SELECT max(chain_seq) AS seq FROM audit_record
|
|
63
|
+
WHERE ${keyMatch} AND chain_seq IS NOT NULL
|
|
64
|
+
`);
|
|
65
|
+
// Stop at the head read above: a row committed since then must not read as a head mismatch.
|
|
66
|
+
let hi = head !== null
|
|
67
|
+
? head.seq
|
|
68
|
+
: maxRow[0]?.['seq'] === null || maxRow[0]?.['seq'] === undefined ? 0 : toSeq(maxRow[0]['seq']);
|
|
69
|
+
if (options.from !== undefined) {
|
|
70
|
+
const r = await db.execute(sql `
|
|
71
|
+
SELECT min(chain_seq) AS seq FROM audit_record
|
|
72
|
+
WHERE ${keyMatch} AND chain_seq IS NOT NULL AND occurred_at >= ${options.from.toISOString()}::timestamptz
|
|
73
|
+
`);
|
|
74
|
+
if (r[0]?.['seq'] === null || r[0]?.['seq'] === undefined)
|
|
75
|
+
return { ok: true, checked: 0, head };
|
|
76
|
+
lo = Math.max(lo, toSeq(r[0]['seq']));
|
|
77
|
+
}
|
|
78
|
+
if (options.to !== undefined) {
|
|
79
|
+
const r = await db.execute(sql `
|
|
80
|
+
SELECT max(chain_seq) AS seq FROM audit_record
|
|
81
|
+
WHERE ${keyMatch} AND chain_seq IS NOT NULL AND occurred_at < ${options.to.toISOString()}::timestamptz
|
|
82
|
+
`);
|
|
83
|
+
if (r[0]?.['seq'] === null || r[0]?.['seq'] === undefined)
|
|
84
|
+
return { ok: true, checked: 0, head };
|
|
85
|
+
hi = Math.min(hi, toSeq(r[0]['seq']));
|
|
86
|
+
}
|
|
87
|
+
if (options.toSeq !== undefined)
|
|
88
|
+
hi = options.toSeq;
|
|
89
|
+
// Retired entries inside the range count as present; the one just below it is the first link.
|
|
90
|
+
const retiredRows = await db.execute(sql `
|
|
91
|
+
SELECT seq, encode(hash, 'hex') AS hash FROM ${sql.raw(CHAIN_RETIRED_TABLE)}
|
|
92
|
+
WHERE chain_key = ${key}::uuid AND seq >= ${lo - 1} AND seq <= ${hi}
|
|
93
|
+
`);
|
|
94
|
+
const retired = new Map(retiredRows.map(r => [toSeq(r['seq']), r['hash']]));
|
|
95
|
+
let last;
|
|
96
|
+
if (lo === 1)
|
|
97
|
+
last = GENESIS_HASH;
|
|
98
|
+
else {
|
|
99
|
+
last = retired.get(lo - 1);
|
|
100
|
+
if (last === undefined) {
|
|
101
|
+
const r = await db.execute(sql `
|
|
102
|
+
SELECT hash FROM audit_record WHERE ${keyMatch} AND chain_seq = ${lo - 1}
|
|
103
|
+
`);
|
|
104
|
+
last = r[0]?.['hash'];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let checked = 0;
|
|
108
|
+
let expected = lo;
|
|
109
|
+
let tail = null;
|
|
110
|
+
const fail = (seq, recordId, reason) => ({ ok: false, checked, break: { seq, recordId, reason } });
|
|
111
|
+
while (expected <= hi) {
|
|
112
|
+
const batch = await db.execute(sql `
|
|
113
|
+
SELECT a.chain_seq AS seq, a.id, a.prev_hash, a.hash,
|
|
114
|
+
encode(sha256(decode(a.prev_hash, 'hex') || convert_to(pipework_audit_record_canonical(a), 'UTF8')), 'hex') = a.hash AS intact
|
|
115
|
+
FROM audit_record a
|
|
116
|
+
WHERE ${keyMatch} AND a.chain_seq >= ${expected} AND a.chain_seq <= ${hi}
|
|
117
|
+
ORDER BY a.chain_seq, a.id
|
|
118
|
+
LIMIT ${BATCH_SIZE}
|
|
119
|
+
`);
|
|
120
|
+
if (batch.length === 0) {
|
|
121
|
+
for (; expected <= hi; expected++) {
|
|
122
|
+
const hash = retired.get(expected);
|
|
123
|
+
if (hash === undefined)
|
|
124
|
+
return fail(expected, null, 'missing');
|
|
125
|
+
last = hash;
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
for (const row of batch) {
|
|
130
|
+
const seq = toSeq(row['seq']);
|
|
131
|
+
const id = row['id'];
|
|
132
|
+
for (; expected < seq; expected++) {
|
|
133
|
+
const hash = retired.get(expected);
|
|
134
|
+
if (hash === undefined)
|
|
135
|
+
return fail(expected, null, 'missing');
|
|
136
|
+
last = hash;
|
|
137
|
+
}
|
|
138
|
+
if (seq < expected)
|
|
139
|
+
return fail(seq, id, 'hash-mismatch');
|
|
140
|
+
if (row['intact'] !== true)
|
|
141
|
+
return fail(seq, id, 'hash-mismatch');
|
|
142
|
+
if (last === undefined)
|
|
143
|
+
return fail(seq - 1, null, 'missing');
|
|
144
|
+
if (row['prev_hash'] !== last)
|
|
145
|
+
return fail(seq, id, 'prev-mismatch');
|
|
146
|
+
last = row['hash'];
|
|
147
|
+
tail = { seq, hash: last };
|
|
148
|
+
checked++;
|
|
149
|
+
expected = seq + 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!bounded) {
|
|
153
|
+
const top = tail ?? await highestRetired(db, key);
|
|
154
|
+
const expectedHead = head;
|
|
155
|
+
if ((top === null) !== (expectedHead === null) || (top !== null && expectedHead !== null &&
|
|
156
|
+
(top.seq !== expectedHead.seq || top.hash !== expectedHead.hash))) {
|
|
157
|
+
return fail(expectedHead?.seq ?? top?.seq ?? 0, null, 'head-mismatch');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (options.anchor !== undefined) {
|
|
161
|
+
const { seq, hash } = options.anchor;
|
|
162
|
+
const r = await db.execute(sql `
|
|
163
|
+
SELECT hash FROM audit_record WHERE ${keyMatch} AND chain_seq = ${seq}
|
|
164
|
+
UNION ALL
|
|
165
|
+
SELECT encode(hash, 'hex') FROM ${sql.raw(CHAIN_RETIRED_TABLE)} WHERE chain_key = ${key}::uuid AND seq = ${seq}
|
|
166
|
+
`);
|
|
167
|
+
if (!r.some(row => row['hash'] === hash))
|
|
168
|
+
return fail(seq, null, 'anchor-mismatch');
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, checked, head };
|
|
171
|
+
}
|
|
172
|
+
async function highestRetired(db, key) {
|
|
173
|
+
const rows = await db.execute(sql `
|
|
174
|
+
SELECT seq, encode(hash, 'hex') AS hash FROM ${sql.raw(CHAIN_RETIRED_TABLE)}
|
|
175
|
+
WHERE chain_key = ${key}::uuid ORDER BY seq DESC LIMIT 1
|
|
176
|
+
`);
|
|
177
|
+
const row = rows[0];
|
|
178
|
+
return row === undefined ? null : { seq: toSeq(row['seq']), hash: row['hash'] };
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=verify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../../../src/audit/chain/verify.ts"],"names":[],"mappings":"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"}
|
|
@@ -37,5 +37,22 @@ export declare const auditRecordFields: {
|
|
|
37
37
|
nullable: true;
|
|
38
38
|
}>;
|
|
39
39
|
occurredAt: import("../../data/domain/types.js").FieldDescriptor<Date, {}> & import("../../data/domain/field.js").FieldModifiers<Date, {}>;
|
|
40
|
+
chainSeq: import("../../data/domain/types.js").FieldDescriptor<bigint | null, {
|
|
41
|
+
nullable: true;
|
|
42
|
+
}> & import("../../data/domain/field.js").FieldModifiers<bigint | null, {
|
|
43
|
+
nullable: true;
|
|
44
|
+
}>;
|
|
45
|
+
prevHash: import("../../data/domain/types.js").FieldDescriptor<string | null, {
|
|
46
|
+
nullable: true;
|
|
47
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string | null, {
|
|
48
|
+
nullable: true;
|
|
49
|
+
}>;
|
|
50
|
+
hash: import("../../data/domain/types.js").FieldDescriptor<string | null, {
|
|
51
|
+
nullable: true;
|
|
52
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string | null, {
|
|
53
|
+
nullable: true;
|
|
54
|
+
}>;
|
|
40
55
|
};
|
|
56
|
+
/** Fields the chain trigger owns — never part of an application INSERT's column list. */
|
|
57
|
+
export declare const AUDIT_CHAIN_FIELD_KEYS: ReadonlySet<string>;
|
|
41
58
|
//# sourceMappingURL=audit-record-fields.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-record-fields.d.ts","sourceRoot":"","sources":["../../../src/audit/schema/audit-record-fields.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,iBAAiB
|
|
1
|
+
{"version":3,"file":"audit-record-fields.d.ts","sourceRoot":"","sources":["../../../src/audit/schema/audit-record-fields.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuC7B,CAAA;AAED,yFAAyF;AACzF,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAA6C,CAAA"}
|
|
@@ -49,5 +49,13 @@ export const auditRecordFields = {
|
|
|
49
49
|
// or `captureInputs: false`).
|
|
50
50
|
capturedInputs: field.jsonb(schema.check.json()).nullable(),
|
|
51
51
|
occurredAt: field.timestamp(),
|
|
52
|
+
// Hash chain (opt-in, blueprint `audit.chain`). Written only by the
|
|
53
|
+
// `audit_record` insert trigger; null on rows written before chaining was
|
|
54
|
+
// enabled. `prevHash` / `hash` are lowercase hex SHA-256.
|
|
55
|
+
chainSeq: field.bigint().nullable(),
|
|
56
|
+
prevHash: field.text().nullable(),
|
|
57
|
+
hash: field.text().nullable(),
|
|
52
58
|
};
|
|
59
|
+
/** Fields the chain trigger owns — never part of an application INSERT's column list. */
|
|
60
|
+
export const AUDIT_CHAIN_FIELD_KEYS = new Set(['chainSeq', 'prevHash', 'hash']);
|
|
53
61
|
//# sourceMappingURL=audit-record-fields.js.map
|