@pithy-sh/core 0.13.0 → 0.14.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/README.md +54 -0
- package/dist/audit/auditEvent.js +1 -1
- package/dist/audit/chain.d.ts +289 -0
- package/dist/audit/chain.d.ts.map +1 -0
- package/dist/audit/chain.js +400 -0
- package/dist/audit/citations.d.ts +52 -0
- package/dist/audit/citations.d.ts.map +1 -0
- package/dist/audit/citations.js +13 -0
- package/dist/capability/bindings.d.ts +22 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +32 -2
- package/dist/capability/capability.d.ts +123 -16
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +17 -2
- package/dist/capability/erasure.d.ts +75 -0
- package/dist/capability/erasure.d.ts.map +1 -0
- package/dist/capability/erasure.js +2 -0
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/personColumns.d.ts +28 -0
- package/dist/capability/personColumns.d.ts.map +1 -0
- package/dist/capability/personColumns.js +66 -0
- package/dist/capability/personRows.d.ts +62 -0
- package/dist/capability/personRows.d.ts.map +1 -0
- package/dist/capability/personRows.js +59 -0
- package/dist/capability/purge.d.ts +86 -19
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +83 -5
- package/dist/capability/settings.d.ts +10 -0
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/vectorIndex.d.ts +36 -0
- package/dist/capability/vectorIndex.d.ts.map +1 -0
- package/dist/capability/vectorIndex.js +25 -0
- package/dist/cli/commandCatalog.generated.d.ts +433 -3
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +449 -2
- package/dist/cli/commandFacts.d.ts +8 -5
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +12 -3
- package/dist/consent/purpose.d.ts +70 -0
- package/dist/consent/purpose.d.ts.map +1 -0
- package/dist/consent/purpose.js +97 -0
- package/dist/consent/reader.d.ts +99 -0
- package/dist/consent/reader.d.ts.map +1 -0
- package/dist/consent/reader.js +33 -0
- package/dist/controlPlane/audit/actions.d.ts +15 -4
- package/dist/controlPlane/audit/actions.d.ts.map +1 -1
- package/dist/controlPlane/audit/actions.js +16 -5
- package/dist/controlPlane/capability.d.ts +10 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +56 -8
- package/dist/controlPlane/context.d.ts +4 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -2
- package/dist/controlPlane/data/connection.d.ts +21 -2
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +13 -4
- package/dist/controlPlane/data/connections.d.ts +94 -0
- package/dist/controlPlane/data/connections.d.ts.map +1 -0
- package/dist/controlPlane/data/connections.js +151 -0
- package/dist/controlPlane/data/hold.d.ts +46 -0
- package/dist/controlPlane/data/hold.d.ts.map +1 -0
- package/dist/controlPlane/data/hold.js +36 -0
- package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
- package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
- package/dist/controlPlane/data/oidcTrust.js +173 -0
- package/dist/controlPlane/data/tables.d.ts +4 -0
- package/dist/controlPlane/data/tables.d.ts.map +1 -1
- package/dist/controlPlane/data/tables.js +9 -4
- package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
- package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
- package/dist/controlPlane/data/trustFirstCall.js +25 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +32 -8
- package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
- package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
- package/dist/controlPlane/discovery/routeInput.js +80 -0
- package/dist/controlPlane/discovery/toolName.d.ts +21 -0
- package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
- package/dist/controlPlane/discovery/toolName.js +25 -0
- package/dist/controlPlane/erasure/attest.d.ts +78 -0
- package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
- package/dist/controlPlane/erasure/attest.js +164 -0
- package/dist/controlPlane/erasure/read.d.ts +39 -0
- package/dist/controlPlane/erasure/read.d.ts.map +1 -0
- package/dist/controlPlane/erasure/read.js +140 -0
- package/dist/controlPlane/error/errors.d.ts +61 -2
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +13 -0
- package/dist/controlPlane/evidence/derive.d.ts +124 -0
- package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
- package/dist/controlPlane/evidence/derive.js +343 -0
- package/dist/controlPlane/evidence/mapping.d.ts +85 -0
- package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
- package/dist/controlPlane/evidence/mapping.js +114 -0
- package/dist/controlPlane/evidence/pack.d.ts +46 -0
- package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
- package/dist/controlPlane/evidence/pack.js +124 -0
- package/dist/controlPlane/http/connections.d.ts +96 -0
- package/dist/controlPlane/http/connections.d.ts.map +1 -0
- package/dist/controlPlane/http/connections.js +109 -0
- package/dist/controlPlane/http/guard.d.ts +23 -2
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +69 -12
- package/dist/controlPlane/http/handlers.d.ts +212 -10
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +117 -26
- package/dist/controlPlane/http/holds.d.ts +57 -0
- package/dist/controlPlane/http/holds.d.ts.map +1 -0
- package/dist/controlPlane/http/holds.js +98 -0
- package/dist/controlPlane/http/presence.d.ts +27 -0
- package/dist/controlPlane/http/presence.d.ts.map +1 -0
- package/dist/controlPlane/http/presence.js +67 -0
- package/dist/controlPlane/http/processing.d.ts +73 -0
- package/dist/controlPlane/http/processing.d.ts.map +1 -0
- package/dist/controlPlane/http/processing.js +17 -0
- package/dist/controlPlane/http/responses.d.ts +927 -3
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +283 -7
- package/dist/controlPlane/http/routes.d.ts +12 -2
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +182 -5
- package/dist/controlPlane/http/schemas.d.ts +63 -0
- package/dist/controlPlane/http/schemas.d.ts.map +1 -1
- package/dist/controlPlane/http/schemas.js +39 -1
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +13 -1
- package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
- package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
- package/dist/controlPlane/http/verifyTrust.js +75 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0005_holds.js +26 -0
- package/dist/controlPlane/processing/derive.d.ts +77 -0
- package/dist/controlPlane/processing/derive.d.ts.map +1 -0
- package/dist/controlPlane/processing/derive.js +206 -0
- package/dist/controlPlane/processing/read.d.ts +10 -0
- package/dist/controlPlane/processing/read.d.ts.map +1 -0
- package/dist/controlPlane/processing/read.js +89 -0
- package/dist/controlPlane/scope/kind.d.ts +46 -0
- package/dist/controlPlane/scope/kind.d.ts.map +1 -0
- package/dist/controlPlane/scope/kind.js +52 -0
- package/dist/controlPlane/scope/scope.d.ts +58 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +59 -4
- package/dist/controlPlane/token/jws.js +1 -1
- package/dist/controlPlane/token/mint.js +1 -1
- package/dist/controlPlane/wire.d.ts +23 -0
- package/dist/controlPlane/wire.d.ts.map +1 -1
- package/dist/controlPlane/wire.js +24 -1
- package/dist/createBackend.d.ts +13 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +11 -3
- package/dist/createEntrypoint.d.ts +7 -0
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +4 -0
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +112 -0
- package/dist/error/pithyError.d.ts +22 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +31 -1
- package/dist/http/routeSpec.d.ts +46 -1
- package/dist/http/routeSpec.d.ts.map +1 -1
- package/dist/http/routeSpec.js +39 -5
- package/dist/http/verification.d.ts.map +1 -1
- package/dist/http/verification.js +1 -1
- package/dist/i18n/translator.d.ts +2 -1
- package/dist/i18n/translator.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/logger/record.d.ts +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/mcp/annotations.d.ts +20 -0
- package/dist/mcp/annotations.d.ts.map +1 -0
- package/dist/mcp/annotations.js +23 -0
- package/dist/mcp/tools.d.ts +75 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +118 -0
- package/dist/openapi/document.d.ts.map +1 -1
- package/dist/openapi/document.js +8 -26
- package/dist/openapi/operations.d.ts +35 -0
- package/dist/openapi/operations.d.ts.map +1 -0
- package/dist/openapi/operations.js +37 -0
- package/dist/presence/channel.d.ts +11 -0
- package/dist/presence/channel.d.ts.map +1 -0
- package/dist/presence/channel.js +65 -0
- package/dist/presence/declaration.d.ts +154 -0
- package/dist/presence/declaration.d.ts.map +1 -0
- package/dist/presence/declaration.js +164 -0
- package/dist/presence/durableObject.d.ts +61 -0
- package/dist/presence/durableObject.d.ts.map +1 -0
- package/dist/presence/durableObject.js +257 -0
- package/dist/presence/frames.d.ts +166 -0
- package/dist/presence/frames.d.ts.map +1 -0
- package/dist/presence/frames.js +90 -0
- package/dist/presence/http/guard.d.ts +11 -0
- package/dist/presence/http/guard.d.ts.map +1 -0
- package/dist/presence/http/guard.js +20 -0
- package/dist/presence/http/responses.d.ts +31 -0
- package/dist/presence/http/responses.d.ts.map +1 -0
- package/dist/presence/http/responses.js +34 -0
- package/dist/presence/http/routes.d.ts +24 -0
- package/dist/presence/http/routes.d.ts.map +1 -0
- package/dist/presence/http/routes.js +127 -0
- package/dist/presence/http/schemas.d.ts +29 -0
- package/dist/presence/http/schemas.d.ts.map +1 -0
- package/dist/presence/http/schemas.js +28 -0
- package/dist/presence/names.d.ts +23 -0
- package/dist/presence/names.d.ts.map +1 -0
- package/dist/presence/names.js +22 -0
- package/dist/presence/room.d.ts +95 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +105 -0
- package/dist/presence/ticket.d.ts +55 -0
- package/dist/presence/ticket.d.ts.map +1 -0
- package/dist/presence/ticket.js +109 -0
- package/dist/privacy/classify.js +1 -1
- package/dist/privacy/encrypted.js +1 -1
- package/dist/processing/activity.d.ts +134 -0
- package/dist/processing/activity.d.ts.map +1 -0
- package/dist/processing/activity.js +161 -0
- package/dist/processing/composed.d.ts +18 -0
- package/dist/processing/composed.d.ts.map +1 -0
- package/dist/processing/composed.js +28 -0
- package/dist/processing/vocabulary.d.ts +26 -0
- package/dist/processing/vocabulary.d.ts.map +1 -0
- package/dist/processing/vocabulary.js +35 -0
- package/dist/queue/consumer.d.ts +59 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +46 -0
- package/dist/retention/composed.d.ts +37 -0
- package/dist/retention/composed.d.ts.map +1 -0
- package/dist/retention/composed.js +66 -0
- package/dist/retention/holds.d.ts +61 -0
- package/dist/retention/holds.d.ts.map +1 -0
- package/dist/retention/holds.js +107 -0
- package/dist/retention/holdsPurge.d.ts +22 -0
- package/dist/retention/holdsPurge.d.ts.map +1 -0
- package/dist/retention/holdsPurge.js +50 -0
- package/dist/retention/policy.d.ts +132 -0
- package/dist/retention/policy.d.ts.map +1 -0
- package/dist/retention/policy.js +197 -0
- package/dist/session/rotated.d.ts +34 -0
- package/dist/session/rotated.d.ts.map +1 -0
- package/dist/session/rotated.js +25 -0
- package/dist/test-utils/connectionFixtures.d.ts +13 -0
- package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
- package/dist/test-utils/connectionFixtures.js +58 -0
- package/dist/test-utils/interleave.d.ts +12 -0
- package/dist/test-utils/interleave.d.ts.map +1 -0
- package/dist/test-utils/interleave.js +26 -0
- package/dist/testWorker.d.ts +8 -0
- package/dist/testWorker.d.ts.map +1 -0
- package/dist/testWorker.js +9 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/dispatchRoute.js +1 -1
- package/package.json +1 -1
- package/src/audit/auditEvent.ts +1 -1
- package/src/audit/chain.ts +551 -0
- package/src/audit/citations.ts +63 -0
- package/src/capability/bindings.ts +61 -0
- package/src/capability/capability.ts +156 -19
- package/src/capability/erasure.ts +82 -0
- package/src/capability/personColumns.ts +83 -0
- package/src/capability/personRows.ts +125 -0
- package/src/capability/purge.ts +158 -21
- package/src/capability/settings.ts +7 -0
- package/src/capability/vectorIndex.ts +60 -0
- package/src/cli/commandCatalog.generated.ts +482 -2
- package/src/cli/commandFacts.ts +12 -3
- package/src/cloudflare-test.d.ts +3 -1
- package/src/consent/purpose.ts +160 -0
- package/src/consent/reader.ts +134 -0
- package/src/controlPlane/audit/actions.ts +15 -4
- package/src/controlPlane/capability.ts +99 -20
- package/src/controlPlane/context.ts +10 -2
- package/src/controlPlane/data/connection.ts +16 -2
- package/src/controlPlane/data/connections.ts +231 -0
- package/src/controlPlane/data/hold.ts +52 -0
- package/src/controlPlane/data/oidcTrust.ts +249 -0
- package/src/controlPlane/data/tables.ts +7 -0
- package/src/controlPlane/data/trustFirstCall.ts +37 -0
- package/src/controlPlane/discovery/adminRoute.ts +71 -9
- package/src/controlPlane/discovery/routeInput.ts +86 -0
- package/src/controlPlane/discovery/toolName.ts +23 -0
- package/src/controlPlane/erasure/attest.ts +205 -0
- package/src/controlPlane/erasure/read.ts +169 -0
- package/src/controlPlane/error/errors.ts +69 -2
- package/src/controlPlane/evidence/derive.ts +426 -0
- package/src/controlPlane/evidence/mapping.ts +118 -0
- package/src/controlPlane/evidence/pack.ts +190 -0
- package/src/controlPlane/http/connections.ts +129 -0
- package/src/controlPlane/http/guard.ts +118 -9
- package/src/controlPlane/http/handlers.ts +172 -40
- package/src/controlPlane/http/holds.ts +108 -0
- package/src/controlPlane/http/presence.ts +71 -0
- package/src/controlPlane/http/processing.ts +19 -0
- package/src/controlPlane/http/responses.ts +590 -7
- package/src/controlPlane/http/routes.ts +302 -3
- package/src/controlPlane/http/schemas.ts +126 -0
- package/src/controlPlane/http/verify.ts +19 -2
- package/src/controlPlane/http/verifyTrust.ts +176 -0
- package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
- package/src/controlPlane/migrations/0005_holds.ts +40 -0
- package/src/controlPlane/processing/derive.ts +359 -0
- package/src/controlPlane/processing/read.ts +102 -0
- package/src/controlPlane/scope/kind.ts +63 -0
- package/src/controlPlane/scope/scope.ts +65 -3
- package/src/controlPlane/wire.ts +26 -0
- package/src/createBackend.ts +31 -6
- package/src/createEntrypoint.ts +17 -0
- package/src/error/payload.ts +234 -0
- package/src/error/pithyError.ts +42 -0
- package/src/http/routeSpec.ts +75 -5
- package/src/http/verification.ts +3 -1
- package/src/i18n/translator.ts +2 -1
- package/src/index.ts +2 -0
- package/src/mcp/annotations.ts +38 -0
- package/src/mcp/tools.ts +220 -0
- package/src/openapi/document.ts +8 -28
- package/src/openapi/operations.ts +62 -0
- package/src/presence/channel.ts +46 -0
- package/src/presence/declaration.ts +283 -0
- package/src/presence/durableObject.ts +287 -0
- package/src/presence/frames.ts +153 -0
- package/src/presence/http/guard.ts +23 -0
- package/src/presence/http/responses.ts +63 -0
- package/src/presence/http/routes.ts +165 -0
- package/src/presence/http/schemas.ts +39 -0
- package/src/presence/names.ts +45 -0
- package/src/presence/room.ts +145 -0
- package/src/presence/ticket.ts +145 -0
- package/src/processing/activity.ts +289 -0
- package/src/processing/composed.ts +33 -0
- package/src/processing/vocabulary.ts +43 -0
- package/src/queue/consumer.ts +104 -0
- package/src/retention/composed.ts +75 -0
- package/src/retention/holds.ts +175 -0
- package/src/retention/holdsPurge.ts +50 -0
- package/src/retention/policy.ts +305 -0
- package/src/session/rotated.ts +53 -0
- package/src/test-utils/connectionFixtures.ts +58 -0
- package/src/test-utils/interleave.ts +31 -0
- package/src/testWorker.ts +13 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **The audit trail's hash chain (#822): its canonical form, its commitments, and its verifier.**
|
|
8
|
+
*
|
|
9
|
+
* Every event `@pithy-sh/audit` records after the upgrade carries the hash of the event before it in its tenant's
|
|
10
|
+
* chain, computed over a canonical serialization. Editing, deleting or inserting a row then breaks a link, and a
|
|
11
|
+
* chain rewritten end to end is caught against a checkpoint written outside the database.
|
|
12
|
+
*
|
|
13
|
+
* **It lives in core, not in the audit package**, because three readers need the same bytes: the recorder that
|
|
14
|
+
* writes a link, the Worker's verify read, and `pithy audit verify`, which an auditor runs on an export with no
|
|
15
|
+
* project and no `@pithy-sh/audit` installed. A second copy of the canonical form is a second definition of the
|
|
16
|
+
* evidence, and the first time they differ every export reads as tampered.
|
|
17
|
+
*
|
|
18
|
+
* **Personal fields are committed, not included.** An event's hash covers a salted hash of its IP, user-agent and
|
|
19
|
+
* metadata rather than the values, so an export that leaves them out still verifies. The salt is per event and
|
|
20
|
+
* travels only with the values, so a commitment cannot be reversed by hashing every IPv4 address.
|
|
21
|
+
*
|
|
22
|
+
* **Nothing is signed.** The chain proves the trail is whole against its checkpoints; it adds no key and no way
|
|
23
|
+
* to change the trail.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The canonical form's version, the first element of every serialization. A new form is a new string. */
|
|
27
|
+
export const AUDIT_CHAIN_FORMAT = "pithy-audit-chain/1";
|
|
28
|
+
|
|
29
|
+
/** A lowercase hex SHA-256 digest. */
|
|
30
|
+
export const Sha256Hex = z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^[0-9a-f]{64}$/, "A SHA-256 digest is 64 lowercase hex characters.")
|
|
33
|
+
.describe("A SHA-256 digest, 64 lowercase hex characters.");
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every field of an event its hash covers, as the row stores it. Loose strings, never the taxonomy's enums: a
|
|
37
|
+
* verifier reads an export somebody may have altered, and an altered value must reach the hash check and be
|
|
38
|
+
* named as a break rather than fail to parse.
|
|
39
|
+
*/
|
|
40
|
+
export const ChainedEventFields = z
|
|
41
|
+
.object({
|
|
42
|
+
eventId: z.string().describe("The event's stable public id."),
|
|
43
|
+
occurredAt: z.number().int().describe("When it happened, ms since the epoch, exactly as stored."),
|
|
44
|
+
action: z.string().describe("The `domain/reason` action code."),
|
|
45
|
+
outcome: z.string().describe("Whether it succeeded, failed, or was denied."),
|
|
46
|
+
severity: z.string().describe("How serious it is."),
|
|
47
|
+
actorType: z.string().describe("The kind of principal that acted."),
|
|
48
|
+
actorId: z.string().nullable().describe("The acting principal's id, or null."),
|
|
49
|
+
actorKind: z.string().nullable().describe("For a control-plane actor, the kind of client, or null."),
|
|
50
|
+
sessionId: z.string().nullable().describe("The session the action belongs to, or null."),
|
|
51
|
+
resourceType: z.string().nullable().describe("The kind of thing acted on, or null."),
|
|
52
|
+
resourceId: z.string().nullable().describe("The thing acted on, or null."),
|
|
53
|
+
requestId: z.string().nullable().describe("The request correlation id, or null."),
|
|
54
|
+
tenant: z
|
|
55
|
+
.string()
|
|
56
|
+
.nullable()
|
|
57
|
+
.describe("The tenant whose chain this event is in, or null for the untenanted chain."),
|
|
58
|
+
project: z.string().nullable().describe("The project the recorder stamped, or null."),
|
|
59
|
+
environment: z.string().nullable().describe("The environment the recorder stamped, or null."),
|
|
60
|
+
worker: z.string().nullable().describe("The Worker that recorded it, or null."),
|
|
61
|
+
version: z.string().nullable().describe("The Cloudflare build id that recorded it, or null."),
|
|
62
|
+
ipCommitment: Sha256Hex.nullable().describe("The salted hash of the client IP, or null when there was none."),
|
|
63
|
+
userAgentCommitment: Sha256Hex.nullable().describe(
|
|
64
|
+
"The salted hash of the user-agent, or null when there was none.",
|
|
65
|
+
),
|
|
66
|
+
metadataCommitment: Sha256Hex.nullable().describe(
|
|
67
|
+
"The salted hash of the metadata's stored JSON text, or null when there was none.",
|
|
68
|
+
),
|
|
69
|
+
})
|
|
70
|
+
.describe("Every field of an audit event its chain hash covers, personal values committed by hash.");
|
|
71
|
+
export type ChainedEventFields = z.infer<typeof ChainedEventFields>;
|
|
72
|
+
|
|
73
|
+
/** One event's place in its chain: its position and the hash of the event before it. */
|
|
74
|
+
export interface ChainPosition {
|
|
75
|
+
/** The 1-based position in the tenant's chain. */
|
|
76
|
+
seq: number;
|
|
77
|
+
/** The previous event's hash, or null for the first event. */
|
|
78
|
+
prev: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A link a range starts from or ends at: a position and the hash of the event there. */
|
|
82
|
+
export const ChainLink = z
|
|
83
|
+
.object({
|
|
84
|
+
seq: z.number().int().min(1).describe("The 1-based position in the tenant's chain."),
|
|
85
|
+
hash: Sha256Hex.describe("The hash of the event at that position."),
|
|
86
|
+
})
|
|
87
|
+
.describe("One position in a tenant's chain and the hash of the event there.");
|
|
88
|
+
export type ChainLink = z.infer<typeof ChainLink>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One chained event as an export carries it: the hashed fields, its link, and — only with the detail scope — the
|
|
92
|
+
* personal values and the salt their commitments were made under.
|
|
93
|
+
*/
|
|
94
|
+
export const ChainedEvent = ChainedEventFields.extend({
|
|
95
|
+
seq: z.number().int().min(1).describe("The 1-based position in the tenant's chain."),
|
|
96
|
+
prev: Sha256Hex.nullable().describe("The hash of the event before it, or null for the first event."),
|
|
97
|
+
hash: Sha256Hex.describe("This event's hash, over its canonical serialization."),
|
|
98
|
+
salt: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe("The per-event salt the commitments were made under. Present only with the personal values."),
|
|
102
|
+
ip: z.string().nullable().optional().describe("The client IP. Present only with the detail scope."),
|
|
103
|
+
userAgent: z.string().nullable().optional().describe("The user-agent. Present only with the detail scope."),
|
|
104
|
+
metadata: z
|
|
105
|
+
.string()
|
|
106
|
+
.nullable()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("The metadata's stored JSON text, byte for byte. Present only with the detail scope."),
|
|
109
|
+
}).describe("One chained audit event as an export carries it.");
|
|
110
|
+
export type ChainedEvent = z.infer<typeof ChainedEvent>;
|
|
111
|
+
|
|
112
|
+
/** The format string of a checkpoint object. */
|
|
113
|
+
export const AUDIT_CHECKPOINT_FORMAT = "pithy-audit-checkpoint/1";
|
|
114
|
+
|
|
115
|
+
/** One checkpoint: a tenant chain's length and head, written outside the database on a schedule. */
|
|
116
|
+
export const AuditCheckpoint = z
|
|
117
|
+
.object({
|
|
118
|
+
format: z.literal(AUDIT_CHECKPOINT_FORMAT).describe("The checkpoint format."),
|
|
119
|
+
tenant: z.string().nullable().describe("The tenant whose chain this is, or null for the untenanted chain."),
|
|
120
|
+
count: z.number().int().min(1).describe("How many events the chain held: the head's position."),
|
|
121
|
+
head: Sha256Hex.describe("The hash of the event at that position."),
|
|
122
|
+
takenAt: z.iso.datetime().describe("When the checkpoint was written, ISO-8601."),
|
|
123
|
+
})
|
|
124
|
+
.describe("A tenant chain's length and head, written to append-only object storage on a schedule.");
|
|
125
|
+
export type AuditCheckpoint = z.infer<typeof AuditCheckpoint>;
|
|
126
|
+
|
|
127
|
+
/** The format string of a tombstone object. */
|
|
128
|
+
export const AUDIT_TOMBSTONE_FORMAT = "pithy-audit-tombstone/1";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A pruned run of one chain: which positions the retention prune removed, the link before them, and the hashes at
|
|
132
|
+
* either end. Written to the checkpoint bucket before the rows are deleted, never overwritten, so a verify bridges
|
|
133
|
+
* the gap from the event before it to the event after it and reads the chain as whole across it.
|
|
134
|
+
*/
|
|
135
|
+
export const AuditTombstone = z
|
|
136
|
+
.object({
|
|
137
|
+
format: z.literal(AUDIT_TOMBSTONE_FORMAT).describe("The tombstone format."),
|
|
138
|
+
tenant: z.string().nullable().describe("The tenant whose chain was pruned, or null for the untenanted chain."),
|
|
139
|
+
fromSeq: z.number().int().min(1).describe("The first position the prune removed."),
|
|
140
|
+
throughSeq: z.number().int().min(1).describe("The last position the prune removed."),
|
|
141
|
+
count: z.number().int().min(1).describe("How many events the prune removed: every position from first to last."),
|
|
142
|
+
prior: Sha256Hex.nullable().describe(
|
|
143
|
+
"The hash of the event before the first one removed, or null when the run began the chain.",
|
|
144
|
+
),
|
|
145
|
+
first: Sha256Hex.describe("The hash of the first event removed."),
|
|
146
|
+
last: Sha256Hex.describe("The hash of the last event removed, which the event after the gap names as its link."),
|
|
147
|
+
prunedAt: z.iso.datetime().describe("When the run was pruned, ISO-8601."),
|
|
148
|
+
})
|
|
149
|
+
.describe("A run of a tenant chain the retention prune removed, written to append-only object storage.");
|
|
150
|
+
export type AuditTombstone = z.infer<typeof AuditTombstone>;
|
|
151
|
+
|
|
152
|
+
/** The format string of an export document. */
|
|
153
|
+
export const AUDIT_EXPORT_FORMAT = "pithy-audit-export/1";
|
|
154
|
+
|
|
155
|
+
/** An export: one tenant's events in a range, the link before it, and the checkpoints covering it. */
|
|
156
|
+
export const AuditChainExport = z
|
|
157
|
+
.object({
|
|
158
|
+
format: z.literal(AUDIT_EXPORT_FORMAT).describe("The export format."),
|
|
159
|
+
chain: z.literal(AUDIT_CHAIN_FORMAT).describe("The canonical form every hash in it was computed over."),
|
|
160
|
+
tenant: z.string().nullable().describe("The tenant whose chain this is, or null for the untenanted chain."),
|
|
161
|
+
since: z.iso.datetime().nullable().describe("The range's inclusive start as asked, or null for the chain's start."),
|
|
162
|
+
until: z.iso.datetime().nullable().describe("The range's inclusive end as asked, or null for the chain's head."),
|
|
163
|
+
exportedAt: z.iso.datetime().describe("When the export was taken."),
|
|
164
|
+
detail: z
|
|
165
|
+
.boolean()
|
|
166
|
+
.describe("Whether the personal values travel with their commitments. Only with `audit:events:read_detail`."),
|
|
167
|
+
prior: ChainLink.nullable().describe("The link before the first event, or null when the range starts the chain."),
|
|
168
|
+
events: z.array(ChainedEvent).describe("The range's chained events, in chain order."),
|
|
169
|
+
checkpoints: z.array(AuditCheckpoint).describe("Every checkpoint covering the range, and the latest one after it."),
|
|
170
|
+
tombstones: z
|
|
171
|
+
.array(AuditTombstone)
|
|
172
|
+
.optional()
|
|
173
|
+
.describe(
|
|
174
|
+
"Every pruned run of the chain, so a verify bridges each gap the retention prune left. Absent from a Worker that has never pruned one.",
|
|
175
|
+
),
|
|
176
|
+
complete: z
|
|
177
|
+
.boolean()
|
|
178
|
+
.describe("Whether the events run to the chain's head, so a checkpoint past the last event is a truncation."),
|
|
179
|
+
beforeChain: z
|
|
180
|
+
.number()
|
|
181
|
+
.int()
|
|
182
|
+
.nonnegative()
|
|
183
|
+
.describe("How many of the tenant's events in the range were written before the chain, and are not in it."),
|
|
184
|
+
outsideChain: z
|
|
185
|
+
.number()
|
|
186
|
+
.int()
|
|
187
|
+
.nonnegative()
|
|
188
|
+
.describe(
|
|
189
|
+
"How many rows in the range carry no chain though written after it began — an older recorder's during an upgrade or a rollback. Not in the chain, and not a break.",
|
|
190
|
+
),
|
|
191
|
+
nextCursor: z.string().nullable().describe("Where the next page resumes, or null at the end of the range."),
|
|
192
|
+
})
|
|
193
|
+
.describe("One tenant's chained audit events over a range, verifiable offline against its checkpoints.");
|
|
194
|
+
export type AuditChainExport = z.infer<typeof AuditChainExport>;
|
|
195
|
+
|
|
196
|
+
/** Why a chain is not whole. */
|
|
197
|
+
export const ChainBreakReason = z
|
|
198
|
+
.enum(["edited", "commitment", "missing", "inserted", "unlinked", "checkpoint", "truncated"])
|
|
199
|
+
.describe(
|
|
200
|
+
"`edited` — the event's hash does not match its fields. `commitment` — a personal value does not match its commitment. `missing` — events before it are gone. `inserted` — it is not where the chain says it should be. `unlinked` — it does not name the event before it. `checkpoint` — its hash differs from the checkpoint at its position. `truncated` — the chain ends before a checkpoint says it reached.",
|
|
201
|
+
);
|
|
202
|
+
export type ChainBreakReason = z.infer<typeof ChainBreakReason>;
|
|
203
|
+
|
|
204
|
+
/** The first place a chain is not whole. */
|
|
205
|
+
export const ChainBreak = z
|
|
206
|
+
.object({
|
|
207
|
+
eventId: z
|
|
208
|
+
.string()
|
|
209
|
+
.nullable()
|
|
210
|
+
.describe("The event at the break, or null where the break is an absence the trail no longer holds."),
|
|
211
|
+
position: z.number().int().min(1).describe("The position in the tenant's chain where the break is."),
|
|
212
|
+
reason: ChainBreakReason,
|
|
213
|
+
detail: z.string().describe("The break in one sentence."),
|
|
214
|
+
})
|
|
215
|
+
.describe("The first break in a chain: which event, at which position, and why.");
|
|
216
|
+
export type ChainBreak = z.infer<typeof ChainBreak>;
|
|
217
|
+
|
|
218
|
+
/** The canonical serialization: a JSON array in one fixed order, so no key order or whitespace can vary it. */
|
|
219
|
+
export function canonicalEvent(position: ChainPosition, fields: ChainedEventFields): string {
|
|
220
|
+
return JSON.stringify([
|
|
221
|
+
AUDIT_CHAIN_FORMAT,
|
|
222
|
+
position.seq,
|
|
223
|
+
position.prev,
|
|
224
|
+
fields.eventId,
|
|
225
|
+
fields.occurredAt,
|
|
226
|
+
fields.action,
|
|
227
|
+
fields.outcome,
|
|
228
|
+
fields.severity,
|
|
229
|
+
fields.actorType,
|
|
230
|
+
fields.actorId,
|
|
231
|
+
fields.actorKind,
|
|
232
|
+
fields.sessionId,
|
|
233
|
+
fields.resourceType,
|
|
234
|
+
fields.resourceId,
|
|
235
|
+
fields.requestId,
|
|
236
|
+
fields.tenant,
|
|
237
|
+
fields.project,
|
|
238
|
+
fields.environment,
|
|
239
|
+
fields.worker,
|
|
240
|
+
fields.version,
|
|
241
|
+
fields.ipCommitment,
|
|
242
|
+
fields.userAgentCommitment,
|
|
243
|
+
fields.metadataCommitment,
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Lowercase hex SHA-256 of a UTF-8 string. */
|
|
248
|
+
export async function sha256Hex(text: string): Promise<string> {
|
|
249
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
250
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A fresh per-event salt: 16 random bytes, hex. */
|
|
254
|
+
export function newChainSalt(): string {
|
|
255
|
+
return [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The salted commitment to one personal value, or null when there is no value. */
|
|
259
|
+
export async function commitment(salt: string, value: string | null): Promise<string | null> {
|
|
260
|
+
return value === null ? null : sha256Hex(JSON.stringify([salt, value]));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** An event's chain hash: SHA-256 over its canonical serialization. */
|
|
264
|
+
export async function chainHash(position: ChainPosition, fields: ChainedEventFields): Promise<string> {
|
|
265
|
+
return sha256Hex(canonicalEvent(position, fields));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The hashed fields of an exported event, without its link or its personal values. */
|
|
269
|
+
function hashedFields(event: ChainedEvent): ChainedEventFields {
|
|
270
|
+
return {
|
|
271
|
+
eventId: event.eventId,
|
|
272
|
+
occurredAt: event.occurredAt,
|
|
273
|
+
action: event.action,
|
|
274
|
+
outcome: event.outcome,
|
|
275
|
+
severity: event.severity,
|
|
276
|
+
actorType: event.actorType,
|
|
277
|
+
actorId: event.actorId,
|
|
278
|
+
actorKind: event.actorKind,
|
|
279
|
+
sessionId: event.sessionId,
|
|
280
|
+
resourceType: event.resourceType,
|
|
281
|
+
resourceId: event.resourceId,
|
|
282
|
+
requestId: event.requestId,
|
|
283
|
+
tenant: event.tenant,
|
|
284
|
+
project: event.project,
|
|
285
|
+
environment: event.environment,
|
|
286
|
+
worker: event.worker,
|
|
287
|
+
version: event.version,
|
|
288
|
+
ipCommitment: event.ipCommitment,
|
|
289
|
+
userAgentCommitment: event.userAgentCommitment,
|
|
290
|
+
metadataCommitment: event.metadataCommitment,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The personal value an event carries for one commitment, when it carries the values at all. */
|
|
295
|
+
const COMMITTED = [
|
|
296
|
+
["ip", "ipCommitment", "IP"],
|
|
297
|
+
["userAgent", "userAgentCommitment", "user-agent"],
|
|
298
|
+
["metadata", "metadataCommitment", "metadata"],
|
|
299
|
+
] as const;
|
|
300
|
+
|
|
301
|
+
/** What {@link chainVerifier} needs: whose chain, where the range starts, and the checkpoints to hold it to. */
|
|
302
|
+
export interface ChainVerifierOptions {
|
|
303
|
+
/** The tenant whose chain is walked. An event from any other chain is a break. */
|
|
304
|
+
tenant: string | null;
|
|
305
|
+
/** The link before the first event, or null when the walk starts the chain. */
|
|
306
|
+
prior: ChainLink | null;
|
|
307
|
+
/** Checkpoints to hold the walk to. Another tenant's are ignored. */
|
|
308
|
+
checkpoints: readonly AuditCheckpoint[];
|
|
309
|
+
/** The chain's pruned runs, each bridged where the walk reaches it. Another tenant's are ignored. */
|
|
310
|
+
tombstones?: readonly AuditTombstone[];
|
|
311
|
+
/**
|
|
312
|
+
* Whether every event must carry its personal values and their salt, so every commitment is checked: an export
|
|
313
|
+
* taken with the detail says it does. Omitted, an event without them is one whose commitments stand unopened.
|
|
314
|
+
*/
|
|
315
|
+
values?: boolean;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A walk over one chain, fed in order, page by page. */
|
|
319
|
+
export interface ChainVerifier {
|
|
320
|
+
/** Check the next event. Returns the break it is, or null. Stop at the first break. */
|
|
321
|
+
push(event: ChainedEvent): Promise<ChainBreak | null>;
|
|
322
|
+
/** End the walk. With `complete`, a checkpoint past the last event is a truncation. */
|
|
323
|
+
finish(complete: boolean): ChainBreak | null;
|
|
324
|
+
/** The last link walked, or the prior when none was. */
|
|
325
|
+
readonly head: ChainLink | null;
|
|
326
|
+
/** How many events were checked. */
|
|
327
|
+
readonly checked: number;
|
|
328
|
+
/** How many of the tenant's checkpoints applied. */
|
|
329
|
+
readonly checkpoints: number;
|
|
330
|
+
/** How many events the walk crossed as pruned, bridged by a tombstone rather than checked. */
|
|
331
|
+
readonly pruned: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Walk one tenant's chain in order. Each event is checked for its tenant, its position, its link, its commitments
|
|
336
|
+
* (when it carries its values), its hash, and the checkpoint at its position — in that order, so the reason names
|
|
337
|
+
* the first thing wrong.
|
|
338
|
+
*/
|
|
339
|
+
export function chainVerifier(options: ChainVerifierOptions): ChainVerifier {
|
|
340
|
+
const own = options.checkpoints.filter((checkpoint) => checkpoint.tenant === options.tenant);
|
|
341
|
+
const byCount = new Map<number, AuditCheckpoint>();
|
|
342
|
+
for (const checkpoint of own) byCount.set(checkpoint.count, checkpoint);
|
|
343
|
+
const tombstones = (options.tombstones ?? []).filter((tombstone) => tombstone.tenant === options.tenant);
|
|
344
|
+
let head = options.prior;
|
|
345
|
+
let checked = 0;
|
|
346
|
+
let pruned = 0;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Cross every pruned run that starts where the walk stands and links to its head, before the event at `seq`. A
|
|
350
|
+
* tombstone that does not link, or that a checkpoint at either end disagrees with, bridges nothing.
|
|
351
|
+
*/
|
|
352
|
+
const bridge = (seq: number): ChainBreak | null => {
|
|
353
|
+
for (;;) {
|
|
354
|
+
const expected = (head?.seq ?? 0) + 1;
|
|
355
|
+
if (seq <= expected) return null;
|
|
356
|
+
const run = tombstones.find(
|
|
357
|
+
(tombstone) =>
|
|
358
|
+
tombstone.fromSeq === expected &&
|
|
359
|
+
tombstone.throughSeq < seq &&
|
|
360
|
+
tombstone.count === tombstone.throughSeq - tombstone.fromSeq + 1 &&
|
|
361
|
+
tombstone.prior === (head?.hash ?? null),
|
|
362
|
+
);
|
|
363
|
+
if (!run) return null;
|
|
364
|
+
for (const [position, hash] of [
|
|
365
|
+
[run.fromSeq, run.first],
|
|
366
|
+
[run.throughSeq, run.last],
|
|
367
|
+
] as const) {
|
|
368
|
+
const checkpoint = byCount.get(position);
|
|
369
|
+
if (checkpoint && checkpoint.head !== hash) {
|
|
370
|
+
return {
|
|
371
|
+
eventId: null,
|
|
372
|
+
position,
|
|
373
|
+
reason: "checkpoint",
|
|
374
|
+
detail: `The pruned run ${run.fromSeq} to ${run.throughSeq} differs from the checkpoint taken at ${checkpoint.takenAt}.`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
head = { seq: run.throughSeq, hash: run.last };
|
|
379
|
+
pruned += run.count;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
get head() {
|
|
385
|
+
return head;
|
|
386
|
+
},
|
|
387
|
+
get checked() {
|
|
388
|
+
return checked;
|
|
389
|
+
},
|
|
390
|
+
get checkpoints() {
|
|
391
|
+
return own.length;
|
|
392
|
+
},
|
|
393
|
+
get pruned() {
|
|
394
|
+
return pruned;
|
|
395
|
+
},
|
|
396
|
+
async push(event) {
|
|
397
|
+
if (event.tenant === options.tenant) {
|
|
398
|
+
const bridged = bridge(event.seq);
|
|
399
|
+
if (bridged) return bridged;
|
|
400
|
+
}
|
|
401
|
+
const expected = (head?.seq ?? 0) + 1;
|
|
402
|
+
if (event.tenant !== options.tenant) {
|
|
403
|
+
return {
|
|
404
|
+
eventId: event.eventId,
|
|
405
|
+
position: expected,
|
|
406
|
+
reason: "inserted",
|
|
407
|
+
detail: `Event ${event.eventId} belongs to another tenant's chain.`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (event.seq > expected) {
|
|
411
|
+
return {
|
|
412
|
+
eventId: event.eventId,
|
|
413
|
+
position: expected,
|
|
414
|
+
reason: "missing",
|
|
415
|
+
detail:
|
|
416
|
+
event.seq - expected === 1
|
|
417
|
+
? `The event at position ${expected} is missing.`
|
|
418
|
+
: `The events at positions ${expected} to ${event.seq - 1} are missing.`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
if (event.seq < expected) {
|
|
422
|
+
return {
|
|
423
|
+
eventId: event.eventId,
|
|
424
|
+
position: expected,
|
|
425
|
+
reason: "inserted",
|
|
426
|
+
detail: `Event ${event.eventId} claims position ${event.seq}, which the chain has already passed.`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (event.prev !== (head?.hash ?? null)) {
|
|
430
|
+
return {
|
|
431
|
+
eventId: event.eventId,
|
|
432
|
+
position: event.seq,
|
|
433
|
+
reason: "unlinked",
|
|
434
|
+
detail: `Event ${event.eventId} does not name the event before it.`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
// A value is not in the hash, only its commitment is, so a value no salt opens is a value nothing checked.
|
|
438
|
+
if (event.salt === undefined) {
|
|
439
|
+
const carried = COMMITTED.find(([valueKey]) => event[valueKey] !== undefined);
|
|
440
|
+
if (carried || options.values) {
|
|
441
|
+
return {
|
|
442
|
+
eventId: event.eventId,
|
|
443
|
+
position: event.seq,
|
|
444
|
+
reason: "commitment",
|
|
445
|
+
detail: carried
|
|
446
|
+
? `Event ${event.eventId} carries its ${carried[2]} without the salt its commitment needs.`
|
|
447
|
+
: `Event ${event.eventId} carries no salt, though the export says it carries the values.`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
for (const [valueKey, commitmentKey, label] of COMMITTED) {
|
|
452
|
+
const value = event[valueKey];
|
|
453
|
+
if (value === undefined) {
|
|
454
|
+
if (!options.values) continue;
|
|
455
|
+
return {
|
|
456
|
+
eventId: event.eventId,
|
|
457
|
+
position: event.seq,
|
|
458
|
+
reason: "commitment",
|
|
459
|
+
detail: `Event ${event.eventId} carries no ${label}, though the export says it carries the values.`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if ((await commitment(event.salt, value)) !== event[commitmentKey]) {
|
|
463
|
+
return {
|
|
464
|
+
eventId: event.eventId,
|
|
465
|
+
position: event.seq,
|
|
466
|
+
reason: "commitment",
|
|
467
|
+
detail: `Event ${event.eventId}'s ${label} does not match its commitment.`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if ((await chainHash({ seq: event.seq, prev: event.prev }, hashedFields(event))) !== event.hash) {
|
|
473
|
+
return {
|
|
474
|
+
eventId: event.eventId,
|
|
475
|
+
position: event.seq,
|
|
476
|
+
reason: "edited",
|
|
477
|
+
detail: `Event ${event.eventId} does not hash to the value it carries.`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const checkpoint = byCount.get(event.seq);
|
|
481
|
+
if (checkpoint && checkpoint.head !== event.hash) {
|
|
482
|
+
return {
|
|
483
|
+
eventId: event.eventId,
|
|
484
|
+
position: event.seq,
|
|
485
|
+
reason: "checkpoint",
|
|
486
|
+
detail: `Event ${event.eventId} differs from the checkpoint taken at ${checkpoint.takenAt}.`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
head = { seq: event.seq, hash: event.hash };
|
|
490
|
+
checked++;
|
|
491
|
+
return null;
|
|
492
|
+
},
|
|
493
|
+
finish(complete) {
|
|
494
|
+
if (!complete) return null;
|
|
495
|
+
const end = head?.seq ?? 0;
|
|
496
|
+
const beyond = own.filter((checkpoint) => checkpoint.count > end).sort((a, b) => b.count - a.count)[0];
|
|
497
|
+
if (!beyond) return null;
|
|
498
|
+
return {
|
|
499
|
+
eventId: null,
|
|
500
|
+
position: end + 1,
|
|
501
|
+
reason: "truncated",
|
|
502
|
+
detail: `The chain ends at position ${end}, and the checkpoint taken at ${beyond.takenAt} recorded ${beyond.count}.`,
|
|
503
|
+
};
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** What verifying one export found. */
|
|
509
|
+
export interface ChainVerification {
|
|
510
|
+
/** Whether the export's chain is whole. */
|
|
511
|
+
intact: boolean;
|
|
512
|
+
/** How many events were checked before the first break, or all of them. */
|
|
513
|
+
checked: number;
|
|
514
|
+
/** How many of the tenant's checkpoints the export carried. */
|
|
515
|
+
checkpoints: number;
|
|
516
|
+
/** How many events the walk crossed as pruned, each run bridged by its tombstone. */
|
|
517
|
+
pruned: number;
|
|
518
|
+
/** The first break, or null. */
|
|
519
|
+
break: ChainBreak | null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Verify an export offline: every link, every commitment it can check, every checkpoint it carries. */
|
|
523
|
+
export async function verifyChainExport(document: AuditChainExport): Promise<ChainVerification> {
|
|
524
|
+
const verifier = chainVerifier({
|
|
525
|
+
tenant: document.tenant,
|
|
526
|
+
prior: document.prior,
|
|
527
|
+
checkpoints: document.checkpoints,
|
|
528
|
+
tombstones: document.tombstones ?? [],
|
|
529
|
+
values: document.detail,
|
|
530
|
+
});
|
|
531
|
+
for (const event of document.events) {
|
|
532
|
+
const found = await verifier.push(event);
|
|
533
|
+
if (found) {
|
|
534
|
+
return {
|
|
535
|
+
intact: false,
|
|
536
|
+
checked: verifier.checked,
|
|
537
|
+
checkpoints: verifier.checkpoints,
|
|
538
|
+
pruned: verifier.pruned,
|
|
539
|
+
break: found,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const truncated = verifier.finish(document.complete);
|
|
544
|
+
return {
|
|
545
|
+
intact: truncated === null,
|
|
546
|
+
checked: verifier.checked,
|
|
547
|
+
checkpoints: verifier.checkpoints,
|
|
548
|
+
pruned: verifier.pruned,
|
|
549
|
+
break: truncated,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **Citing the trail (#823): which recorded events name a resource, and where each sits in its chain.**
|
|
8
|
+
*
|
|
9
|
+
* An erasure attestation proves a purge happened by pointing at the audit events the purge wrote, by event id and
|
|
10
|
+
* chain position. The chain (#822) is what makes those events tamper-evident, so the attestation adds no second
|
|
11
|
+
* integrity mechanism: it cites, and an auditor checks the citation with `pithy audit verify`.
|
|
12
|
+
*
|
|
13
|
+
* Reading the trail is `@pithy-sh/audit`'s, and nothing may import it (principle 4). So, like
|
|
14
|
+
* {@link Capability.auditRecorder}, the audit capability declares a reader built from the env, and
|
|
15
|
+
* {@link composedAuditCitations} finds it among the composed capabilities. With no audit composed there is no
|
|
16
|
+
* trail to cite, and the answer is `null` rather than an empty list: *nothing recorded* and *no recorder* are two
|
|
17
|
+
* different facts, and an attestation must say which.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Which events to cite: one kind of resource, some of its ids, and the actions that matter. */
|
|
21
|
+
export interface AuditCitationQuery {
|
|
22
|
+
/** The `resourceType` the events were recorded against — `user`, `organization`. */
|
|
23
|
+
readonly resourceType: string;
|
|
24
|
+
/** The resource ids. Every event for any of them, among {@link actions}. */
|
|
25
|
+
readonly resourceIds: readonly string[];
|
|
26
|
+
/** The `domain/reason` actions to cite. Every other action on the resource is left out. */
|
|
27
|
+
readonly actions: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One recorded event, as an attestation cites it: never its personal values, only where it is. */
|
|
31
|
+
export interface AuditCitation {
|
|
32
|
+
/** The event's stable public id. */
|
|
33
|
+
readonly eventId: string;
|
|
34
|
+
/** The `domain/reason` action. */
|
|
35
|
+
readonly action: string;
|
|
36
|
+
/** The resource it was recorded against. */
|
|
37
|
+
readonly resourceId: string;
|
|
38
|
+
/** When it happened. */
|
|
39
|
+
readonly occurredAt: Date;
|
|
40
|
+
/** The tenant whose chain it is in, or null for the untenanted chain. */
|
|
41
|
+
readonly tenant: string | null;
|
|
42
|
+
/** Its 1-based position in that chain, or null for an event recorded before the chain began. */
|
|
43
|
+
readonly seq: number | null;
|
|
44
|
+
/** Its chain hash, or null before the chain. */
|
|
45
|
+
readonly hash: string | null;
|
|
46
|
+
/** The `step` its metadata names, when it names one as a string — which purge step the event records. */
|
|
47
|
+
readonly step: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read the citations for one query, oldest first. */
|
|
51
|
+
export type AuditCitationReader = (query: AuditCitationQuery) => Promise<AuditCitation[]>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The composed audit capability's citation reader over this env, or `null` when no capability provides one — no
|
|
55
|
+
* audit is composed, so there is no trail to cite.
|
|
56
|
+
*/
|
|
57
|
+
export function composedAuditCitations(
|
|
58
|
+
capabilities: readonly Capability[],
|
|
59
|
+
env: Record<string, unknown>,
|
|
60
|
+
): AuditCitationReader | null {
|
|
61
|
+
const provider = capabilities.find((capability) => capability.auditCitations !== undefined);
|
|
62
|
+
return provider?.auditCitations ? provider.auditCitations(env) : null;
|
|
63
|
+
}
|