@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,400 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/audit/chain.ts
|
|
5
|
+
/**
|
|
6
|
+
* **The audit trail's hash chain (#822): its canonical form, its commitments, and its verifier.**
|
|
7
|
+
*
|
|
8
|
+
* Every event `@pithy-sh/audit` records after the upgrade carries the hash of the event before it in its tenant's
|
|
9
|
+
* chain, computed over a canonical serialization. Editing, deleting or inserting a row then breaks a link, and a
|
|
10
|
+
* chain rewritten end to end is caught against a checkpoint written outside the database.
|
|
11
|
+
*
|
|
12
|
+
* **It lives in core, not in the audit package**, because three readers need the same bytes: the recorder that
|
|
13
|
+
* writes a link, the Worker's verify read, and `pithy audit verify`, which an auditor runs on an export with no
|
|
14
|
+
* project and no `@pithy-sh/audit` installed. A second copy of the canonical form is a second definition of the
|
|
15
|
+
* evidence, and the first time they differ every export reads as tampered.
|
|
16
|
+
*
|
|
17
|
+
* **Personal fields are committed, not included.** An event's hash covers a salted hash of its IP, user-agent and
|
|
18
|
+
* metadata rather than the values, so an export that leaves them out still verifies. The salt is per event and
|
|
19
|
+
* travels only with the values, so a commitment cannot be reversed by hashing every IPv4 address.
|
|
20
|
+
*
|
|
21
|
+
* **Nothing is signed.** The chain proves the trail is whole against its checkpoints; it adds no key and no way
|
|
22
|
+
* to change the trail.
|
|
23
|
+
*/
|
|
24
|
+
/** The canonical form's version, the first element of every serialization. A new form is a new string. */
|
|
25
|
+
const AUDIT_CHAIN_FORMAT = "pithy-audit-chain/1";
|
|
26
|
+
/** A lowercase hex SHA-256 digest. */
|
|
27
|
+
const Sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "A SHA-256 digest is 64 lowercase hex characters.").describe("A SHA-256 digest, 64 lowercase hex characters.");
|
|
28
|
+
/**
|
|
29
|
+
* Every field of an event its hash covers, as the row stores it. Loose strings, never the taxonomy's enums: a
|
|
30
|
+
* verifier reads an export somebody may have altered, and an altered value must reach the hash check and be
|
|
31
|
+
* named as a break rather than fail to parse.
|
|
32
|
+
*/
|
|
33
|
+
const ChainedEventFields = z.object({
|
|
34
|
+
eventId: z.string().describe("The event's stable public id."),
|
|
35
|
+
occurredAt: z.number().int().describe("When it happened, ms since the epoch, exactly as stored."),
|
|
36
|
+
action: z.string().describe("The `domain/reason` action code."),
|
|
37
|
+
outcome: z.string().describe("Whether it succeeded, failed, or was denied."),
|
|
38
|
+
severity: z.string().describe("How serious it is."),
|
|
39
|
+
actorType: z.string().describe("The kind of principal that acted."),
|
|
40
|
+
actorId: z.string().nullable().describe("The acting principal's id, or null."),
|
|
41
|
+
actorKind: z.string().nullable().describe("For a control-plane actor, the kind of client, or null."),
|
|
42
|
+
sessionId: z.string().nullable().describe("The session the action belongs to, or null."),
|
|
43
|
+
resourceType: z.string().nullable().describe("The kind of thing acted on, or null."),
|
|
44
|
+
resourceId: z.string().nullable().describe("The thing acted on, or null."),
|
|
45
|
+
requestId: z.string().nullable().describe("The request correlation id, or null."),
|
|
46
|
+
tenant: z.string().nullable().describe("The tenant whose chain this event is in, or null for the untenanted chain."),
|
|
47
|
+
project: z.string().nullable().describe("The project the recorder stamped, or null."),
|
|
48
|
+
environment: z.string().nullable().describe("The environment the recorder stamped, or null."),
|
|
49
|
+
worker: z.string().nullable().describe("The Worker that recorded it, or null."),
|
|
50
|
+
version: z.string().nullable().describe("The Cloudflare build id that recorded it, or null."),
|
|
51
|
+
ipCommitment: Sha256Hex.nullable().describe("The salted hash of the client IP, or null when there was none."),
|
|
52
|
+
userAgentCommitment: Sha256Hex.nullable().describe("The salted hash of the user-agent, or null when there was none."),
|
|
53
|
+
metadataCommitment: Sha256Hex.nullable().describe("The salted hash of the metadata's stored JSON text, or null when there was none.")
|
|
54
|
+
}).describe("Every field of an audit event its chain hash covers, personal values committed by hash.");
|
|
55
|
+
/** A link a range starts from or ends at: a position and the hash of the event there. */
|
|
56
|
+
const ChainLink = z.object({
|
|
57
|
+
seq: z.number().int().min(1).describe("The 1-based position in the tenant's chain."),
|
|
58
|
+
hash: Sha256Hex.describe("The hash of the event at that position.")
|
|
59
|
+
}).describe("One position in a tenant's chain and the hash of the event there.");
|
|
60
|
+
/**
|
|
61
|
+
* One chained event as an export carries it: the hashed fields, its link, and — only with the detail scope — the
|
|
62
|
+
* personal values and the salt their commitments were made under.
|
|
63
|
+
*/
|
|
64
|
+
const ChainedEvent = ChainedEventFields.extend({
|
|
65
|
+
seq: z.number().int().min(1).describe("The 1-based position in the tenant's chain."),
|
|
66
|
+
prev: Sha256Hex.nullable().describe("The hash of the event before it, or null for the first event."),
|
|
67
|
+
hash: Sha256Hex.describe("This event's hash, over its canonical serialization."),
|
|
68
|
+
salt: z.string().optional().describe("The per-event salt the commitments were made under. Present only with the personal values."),
|
|
69
|
+
ip: z.string().nullable().optional().describe("The client IP. Present only with the detail scope."),
|
|
70
|
+
userAgent: z.string().nullable().optional().describe("The user-agent. Present only with the detail scope."),
|
|
71
|
+
metadata: z.string().nullable().optional().describe("The metadata's stored JSON text, byte for byte. Present only with the detail scope.")
|
|
72
|
+
}).describe("One chained audit event as an export carries it.");
|
|
73
|
+
/** The format string of a checkpoint object. */
|
|
74
|
+
const AUDIT_CHECKPOINT_FORMAT = "pithy-audit-checkpoint/1";
|
|
75
|
+
/** One checkpoint: a tenant chain's length and head, written outside the database on a schedule. */
|
|
76
|
+
const AuditCheckpoint = z.object({
|
|
77
|
+
format: z.literal(AUDIT_CHECKPOINT_FORMAT).describe("The checkpoint format."),
|
|
78
|
+
tenant: z.string().nullable().describe("The tenant whose chain this is, or null for the untenanted chain."),
|
|
79
|
+
count: z.number().int().min(1).describe("How many events the chain held: the head's position."),
|
|
80
|
+
head: Sha256Hex.describe("The hash of the event at that position."),
|
|
81
|
+
takenAt: z.iso.datetime().describe("When the checkpoint was written, ISO-8601.")
|
|
82
|
+
}).describe("A tenant chain's length and head, written to append-only object storage on a schedule.");
|
|
83
|
+
/** The format string of a tombstone object. */
|
|
84
|
+
const AUDIT_TOMBSTONE_FORMAT = "pithy-audit-tombstone/1";
|
|
85
|
+
/**
|
|
86
|
+
* A pruned run of one chain: which positions the retention prune removed, the link before them, and the hashes at
|
|
87
|
+
* either end. Written to the checkpoint bucket before the rows are deleted, never overwritten, so a verify bridges
|
|
88
|
+
* the gap from the event before it to the event after it and reads the chain as whole across it.
|
|
89
|
+
*/
|
|
90
|
+
const AuditTombstone = z.object({
|
|
91
|
+
format: z.literal(AUDIT_TOMBSTONE_FORMAT).describe("The tombstone format."),
|
|
92
|
+
tenant: z.string().nullable().describe("The tenant whose chain was pruned, or null for the untenanted chain."),
|
|
93
|
+
fromSeq: z.number().int().min(1).describe("The first position the prune removed."),
|
|
94
|
+
throughSeq: z.number().int().min(1).describe("The last position the prune removed."),
|
|
95
|
+
count: z.number().int().min(1).describe("How many events the prune removed: every position from first to last."),
|
|
96
|
+
prior: Sha256Hex.nullable().describe("The hash of the event before the first one removed, or null when the run began the chain."),
|
|
97
|
+
first: Sha256Hex.describe("The hash of the first event removed."),
|
|
98
|
+
last: Sha256Hex.describe("The hash of the last event removed, which the event after the gap names as its link."),
|
|
99
|
+
prunedAt: z.iso.datetime().describe("When the run was pruned, ISO-8601.")
|
|
100
|
+
}).describe("A run of a tenant chain the retention prune removed, written to append-only object storage.");
|
|
101
|
+
/** The format string of an export document. */
|
|
102
|
+
const AUDIT_EXPORT_FORMAT = "pithy-audit-export/1";
|
|
103
|
+
/** An export: one tenant's events in a range, the link before it, and the checkpoints covering it. */
|
|
104
|
+
const AuditChainExport = z.object({
|
|
105
|
+
format: z.literal(AUDIT_EXPORT_FORMAT).describe("The export format."),
|
|
106
|
+
chain: z.literal(AUDIT_CHAIN_FORMAT).describe("The canonical form every hash in it was computed over."),
|
|
107
|
+
tenant: z.string().nullable().describe("The tenant whose chain this is, or null for the untenanted chain."),
|
|
108
|
+
since: z.iso.datetime().nullable().describe("The range's inclusive start as asked, or null for the chain's start."),
|
|
109
|
+
until: z.iso.datetime().nullable().describe("The range's inclusive end as asked, or null for the chain's head."),
|
|
110
|
+
exportedAt: z.iso.datetime().describe("When the export was taken."),
|
|
111
|
+
detail: z.boolean().describe("Whether the personal values travel with their commitments. Only with `audit:events:read_detail`."),
|
|
112
|
+
prior: ChainLink.nullable().describe("The link before the first event, or null when the range starts the chain."),
|
|
113
|
+
events: z.array(ChainedEvent).describe("The range's chained events, in chain order."),
|
|
114
|
+
checkpoints: z.array(AuditCheckpoint).describe("Every checkpoint covering the range, and the latest one after it."),
|
|
115
|
+
tombstones: z.array(AuditTombstone).optional().describe("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."),
|
|
116
|
+
complete: z.boolean().describe("Whether the events run to the chain's head, so a checkpoint past the last event is a truncation."),
|
|
117
|
+
beforeChain: z.number().int().nonnegative().describe("How many of the tenant's events in the range were written before the chain, and are not in it."),
|
|
118
|
+
outsideChain: z.number().int().nonnegative().describe("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."),
|
|
119
|
+
nextCursor: z.string().nullable().describe("Where the next page resumes, or null at the end of the range.")
|
|
120
|
+
}).describe("One tenant's chained audit events over a range, verifiable offline against its checkpoints.");
|
|
121
|
+
/** Why a chain is not whole. */
|
|
122
|
+
const ChainBreakReason = z.enum([
|
|
123
|
+
"edited",
|
|
124
|
+
"commitment",
|
|
125
|
+
"missing",
|
|
126
|
+
"inserted",
|
|
127
|
+
"unlinked",
|
|
128
|
+
"checkpoint",
|
|
129
|
+
"truncated"
|
|
130
|
+
]).describe("`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.");
|
|
131
|
+
/** The first place a chain is not whole. */
|
|
132
|
+
const ChainBreak = z.object({
|
|
133
|
+
eventId: z.string().nullable().describe("The event at the break, or null where the break is an absence the trail no longer holds."),
|
|
134
|
+
position: z.number().int().min(1).describe("The position in the tenant's chain where the break is."),
|
|
135
|
+
reason: ChainBreakReason,
|
|
136
|
+
detail: z.string().describe("The break in one sentence.")
|
|
137
|
+
}).describe("The first break in a chain: which event, at which position, and why.");
|
|
138
|
+
/** The canonical serialization: a JSON array in one fixed order, so no key order or whitespace can vary it. */
|
|
139
|
+
function canonicalEvent(position, fields) {
|
|
140
|
+
return JSON.stringify([
|
|
141
|
+
AUDIT_CHAIN_FORMAT,
|
|
142
|
+
position.seq,
|
|
143
|
+
position.prev,
|
|
144
|
+
fields.eventId,
|
|
145
|
+
fields.occurredAt,
|
|
146
|
+
fields.action,
|
|
147
|
+
fields.outcome,
|
|
148
|
+
fields.severity,
|
|
149
|
+
fields.actorType,
|
|
150
|
+
fields.actorId,
|
|
151
|
+
fields.actorKind,
|
|
152
|
+
fields.sessionId,
|
|
153
|
+
fields.resourceType,
|
|
154
|
+
fields.resourceId,
|
|
155
|
+
fields.requestId,
|
|
156
|
+
fields.tenant,
|
|
157
|
+
fields.project,
|
|
158
|
+
fields.environment,
|
|
159
|
+
fields.worker,
|
|
160
|
+
fields.version,
|
|
161
|
+
fields.ipCommitment,
|
|
162
|
+
fields.userAgentCommitment,
|
|
163
|
+
fields.metadataCommitment
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
/** Lowercase hex SHA-256 of a UTF-8 string. */
|
|
167
|
+
async function sha256Hex(text) {
|
|
168
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
169
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
170
|
+
}
|
|
171
|
+
/** A fresh per-event salt: 16 random bytes, hex. */
|
|
172
|
+
function newChainSalt() {
|
|
173
|
+
return [...crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
174
|
+
}
|
|
175
|
+
/** The salted commitment to one personal value, or null when there is no value. */
|
|
176
|
+
async function commitment(salt, value) {
|
|
177
|
+
return value === null ? null : sha256Hex(JSON.stringify([salt, value]));
|
|
178
|
+
}
|
|
179
|
+
/** An event's chain hash: SHA-256 over its canonical serialization. */
|
|
180
|
+
async function chainHash(position, fields) {
|
|
181
|
+
return sha256Hex(canonicalEvent(position, fields));
|
|
182
|
+
}
|
|
183
|
+
/** The hashed fields of an exported event, without its link or its personal values. */
|
|
184
|
+
function hashedFields(event) {
|
|
185
|
+
return {
|
|
186
|
+
eventId: event.eventId,
|
|
187
|
+
occurredAt: event.occurredAt,
|
|
188
|
+
action: event.action,
|
|
189
|
+
outcome: event.outcome,
|
|
190
|
+
severity: event.severity,
|
|
191
|
+
actorType: event.actorType,
|
|
192
|
+
actorId: event.actorId,
|
|
193
|
+
actorKind: event.actorKind,
|
|
194
|
+
sessionId: event.sessionId,
|
|
195
|
+
resourceType: event.resourceType,
|
|
196
|
+
resourceId: event.resourceId,
|
|
197
|
+
requestId: event.requestId,
|
|
198
|
+
tenant: event.tenant,
|
|
199
|
+
project: event.project,
|
|
200
|
+
environment: event.environment,
|
|
201
|
+
worker: event.worker,
|
|
202
|
+
version: event.version,
|
|
203
|
+
ipCommitment: event.ipCommitment,
|
|
204
|
+
userAgentCommitment: event.userAgentCommitment,
|
|
205
|
+
metadataCommitment: event.metadataCommitment
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** The personal value an event carries for one commitment, when it carries the values at all. */
|
|
209
|
+
const COMMITTED = [
|
|
210
|
+
[
|
|
211
|
+
"ip",
|
|
212
|
+
"ipCommitment",
|
|
213
|
+
"IP"
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
"userAgent",
|
|
217
|
+
"userAgentCommitment",
|
|
218
|
+
"user-agent"
|
|
219
|
+
],
|
|
220
|
+
[
|
|
221
|
+
"metadata",
|
|
222
|
+
"metadataCommitment",
|
|
223
|
+
"metadata"
|
|
224
|
+
]
|
|
225
|
+
];
|
|
226
|
+
/**
|
|
227
|
+
* Walk one tenant's chain in order. Each event is checked for its tenant, its position, its link, its commitments
|
|
228
|
+
* (when it carries its values), its hash, and the checkpoint at its position — in that order, so the reason names
|
|
229
|
+
* the first thing wrong.
|
|
230
|
+
*/
|
|
231
|
+
function chainVerifier(options) {
|
|
232
|
+
const own = options.checkpoints.filter((checkpoint) => checkpoint.tenant === options.tenant);
|
|
233
|
+
const byCount = /* @__PURE__ */ new Map();
|
|
234
|
+
for (const checkpoint of own) byCount.set(checkpoint.count, checkpoint);
|
|
235
|
+
const tombstones = (options.tombstones ?? []).filter((tombstone) => tombstone.tenant === options.tenant);
|
|
236
|
+
let head = options.prior;
|
|
237
|
+
let checked = 0;
|
|
238
|
+
let pruned = 0;
|
|
239
|
+
/**
|
|
240
|
+
* Cross every pruned run that starts where the walk stands and links to its head, before the event at `seq`. A
|
|
241
|
+
* tombstone that does not link, or that a checkpoint at either end disagrees with, bridges nothing.
|
|
242
|
+
*/
|
|
243
|
+
const bridge = (seq) => {
|
|
244
|
+
for (;;) {
|
|
245
|
+
const expected = (head?.seq ?? 0) + 1;
|
|
246
|
+
if (seq <= expected) return null;
|
|
247
|
+
const run = tombstones.find((tombstone) => tombstone.fromSeq === expected && tombstone.throughSeq < seq && tombstone.count === tombstone.throughSeq - tombstone.fromSeq + 1 && tombstone.prior === (head?.hash ?? null));
|
|
248
|
+
if (!run) return null;
|
|
249
|
+
for (const [position, hash] of [[run.fromSeq, run.first], [run.throughSeq, run.last]]) {
|
|
250
|
+
const checkpoint = byCount.get(position);
|
|
251
|
+
if (checkpoint && checkpoint.head !== hash) return {
|
|
252
|
+
eventId: null,
|
|
253
|
+
position,
|
|
254
|
+
reason: "checkpoint",
|
|
255
|
+
detail: `The pruned run ${run.fromSeq} to ${run.throughSeq} differs from the checkpoint taken at ${checkpoint.takenAt}.`
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
head = {
|
|
259
|
+
seq: run.throughSeq,
|
|
260
|
+
hash: run.last
|
|
261
|
+
};
|
|
262
|
+
pruned += run.count;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
return {
|
|
266
|
+
get head() {
|
|
267
|
+
return head;
|
|
268
|
+
},
|
|
269
|
+
get checked() {
|
|
270
|
+
return checked;
|
|
271
|
+
},
|
|
272
|
+
get checkpoints() {
|
|
273
|
+
return own.length;
|
|
274
|
+
},
|
|
275
|
+
get pruned() {
|
|
276
|
+
return pruned;
|
|
277
|
+
},
|
|
278
|
+
async push(event) {
|
|
279
|
+
if (event.tenant === options.tenant) {
|
|
280
|
+
const bridged = bridge(event.seq);
|
|
281
|
+
if (bridged) return bridged;
|
|
282
|
+
}
|
|
283
|
+
const expected = (head?.seq ?? 0) + 1;
|
|
284
|
+
if (event.tenant !== options.tenant) return {
|
|
285
|
+
eventId: event.eventId,
|
|
286
|
+
position: expected,
|
|
287
|
+
reason: "inserted",
|
|
288
|
+
detail: `Event ${event.eventId} belongs to another tenant's chain.`
|
|
289
|
+
};
|
|
290
|
+
if (event.seq > expected) return {
|
|
291
|
+
eventId: event.eventId,
|
|
292
|
+
position: expected,
|
|
293
|
+
reason: "missing",
|
|
294
|
+
detail: event.seq - expected === 1 ? `The event at position ${expected} is missing.` : `The events at positions ${expected} to ${event.seq - 1} are missing.`
|
|
295
|
+
};
|
|
296
|
+
if (event.seq < expected) return {
|
|
297
|
+
eventId: event.eventId,
|
|
298
|
+
position: expected,
|
|
299
|
+
reason: "inserted",
|
|
300
|
+
detail: `Event ${event.eventId} claims position ${event.seq}, which the chain has already passed.`
|
|
301
|
+
};
|
|
302
|
+
if (event.prev !== (head?.hash ?? null)) return {
|
|
303
|
+
eventId: event.eventId,
|
|
304
|
+
position: event.seq,
|
|
305
|
+
reason: "unlinked",
|
|
306
|
+
detail: `Event ${event.eventId} does not name the event before it.`
|
|
307
|
+
};
|
|
308
|
+
if (event.salt === void 0) {
|
|
309
|
+
const carried = COMMITTED.find(([valueKey]) => event[valueKey] !== void 0);
|
|
310
|
+
if (carried || options.values) return {
|
|
311
|
+
eventId: event.eventId,
|
|
312
|
+
position: event.seq,
|
|
313
|
+
reason: "commitment",
|
|
314
|
+
detail: carried ? `Event ${event.eventId} carries its ${carried[2]} without the salt its commitment needs.` : `Event ${event.eventId} carries no salt, though the export says it carries the values.`
|
|
315
|
+
};
|
|
316
|
+
} else for (const [valueKey, commitmentKey, label] of COMMITTED) {
|
|
317
|
+
const value = event[valueKey];
|
|
318
|
+
if (value === void 0) {
|
|
319
|
+
if (!options.values) continue;
|
|
320
|
+
return {
|
|
321
|
+
eventId: event.eventId,
|
|
322
|
+
position: event.seq,
|
|
323
|
+
reason: "commitment",
|
|
324
|
+
detail: `Event ${event.eventId} carries no ${label}, though the export says it carries the values.`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (await commitment(event.salt, value) !== event[commitmentKey]) return {
|
|
328
|
+
eventId: event.eventId,
|
|
329
|
+
position: event.seq,
|
|
330
|
+
reason: "commitment",
|
|
331
|
+
detail: `Event ${event.eventId}'s ${label} does not match its commitment.`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (await chainHash({
|
|
335
|
+
seq: event.seq,
|
|
336
|
+
prev: event.prev
|
|
337
|
+
}, hashedFields(event)) !== event.hash) return {
|
|
338
|
+
eventId: event.eventId,
|
|
339
|
+
position: event.seq,
|
|
340
|
+
reason: "edited",
|
|
341
|
+
detail: `Event ${event.eventId} does not hash to the value it carries.`
|
|
342
|
+
};
|
|
343
|
+
const checkpoint = byCount.get(event.seq);
|
|
344
|
+
if (checkpoint && checkpoint.head !== event.hash) return {
|
|
345
|
+
eventId: event.eventId,
|
|
346
|
+
position: event.seq,
|
|
347
|
+
reason: "checkpoint",
|
|
348
|
+
detail: `Event ${event.eventId} differs from the checkpoint taken at ${checkpoint.takenAt}.`
|
|
349
|
+
};
|
|
350
|
+
head = {
|
|
351
|
+
seq: event.seq,
|
|
352
|
+
hash: event.hash
|
|
353
|
+
};
|
|
354
|
+
checked++;
|
|
355
|
+
return null;
|
|
356
|
+
},
|
|
357
|
+
finish(complete) {
|
|
358
|
+
if (!complete) return null;
|
|
359
|
+
const end = head?.seq ?? 0;
|
|
360
|
+
const beyond = own.filter((checkpoint) => checkpoint.count > end).sort((a, b) => b.count - a.count)[0];
|
|
361
|
+
if (!beyond) return null;
|
|
362
|
+
return {
|
|
363
|
+
eventId: null,
|
|
364
|
+
position: end + 1,
|
|
365
|
+
reason: "truncated",
|
|
366
|
+
detail: `The chain ends at position ${end}, and the checkpoint taken at ${beyond.takenAt} recorded ${beyond.count}.`
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/** Verify an export offline: every link, every commitment it can check, every checkpoint it carries. */
|
|
372
|
+
async function verifyChainExport(document) {
|
|
373
|
+
const verifier = chainVerifier({
|
|
374
|
+
tenant: document.tenant,
|
|
375
|
+
prior: document.prior,
|
|
376
|
+
checkpoints: document.checkpoints,
|
|
377
|
+
tombstones: document.tombstones ?? [],
|
|
378
|
+
values: document.detail
|
|
379
|
+
});
|
|
380
|
+
for (const event of document.events) {
|
|
381
|
+
const found = await verifier.push(event);
|
|
382
|
+
if (found) return {
|
|
383
|
+
intact: false,
|
|
384
|
+
checked: verifier.checked,
|
|
385
|
+
checkpoints: verifier.checkpoints,
|
|
386
|
+
pruned: verifier.pruned,
|
|
387
|
+
break: found
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const truncated = verifier.finish(document.complete);
|
|
391
|
+
return {
|
|
392
|
+
intact: truncated === null,
|
|
393
|
+
checked: verifier.checked,
|
|
394
|
+
checkpoints: verifier.checkpoints,
|
|
395
|
+
pruned: verifier.pruned,
|
|
396
|
+
break: truncated
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
export { AUDIT_CHAIN_FORMAT, AUDIT_CHECKPOINT_FORMAT, AUDIT_EXPORT_FORMAT, AUDIT_TOMBSTONE_FORMAT, AuditChainExport, AuditCheckpoint, AuditTombstone, ChainBreak, ChainBreakReason, ChainLink, ChainedEvent, ChainedEventFields, Sha256Hex, canonicalEvent, chainHash, chainVerifier, commitment, newChainSalt, sha256Hex, verifyChainExport };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { Capability } from "../capability/capability";
|
|
4
|
+
/**
|
|
5
|
+
* **Citing the trail (#823): which recorded events name a resource, and where each sits in its chain.**
|
|
6
|
+
*
|
|
7
|
+
* An erasure attestation proves a purge happened by pointing at the audit events the purge wrote, by event id and
|
|
8
|
+
* chain position. The chain (#822) is what makes those events tamper-evident, so the attestation adds no second
|
|
9
|
+
* integrity mechanism: it cites, and an auditor checks the citation with `pithy audit verify`.
|
|
10
|
+
*
|
|
11
|
+
* Reading the trail is `@pithy-sh/audit`'s, and nothing may import it (principle 4). So, like
|
|
12
|
+
* {@link Capability.auditRecorder}, the audit capability declares a reader built from the env, and
|
|
13
|
+
* {@link composedAuditCitations} finds it among the composed capabilities. With no audit composed there is no
|
|
14
|
+
* trail to cite, and the answer is `null` rather than an empty list: *nothing recorded* and *no recorder* are two
|
|
15
|
+
* different facts, and an attestation must say which.
|
|
16
|
+
*/
|
|
17
|
+
/** Which events to cite: one kind of resource, some of its ids, and the actions that matter. */
|
|
18
|
+
export interface AuditCitationQuery {
|
|
19
|
+
/** The `resourceType` the events were recorded against — `user`, `organization`. */
|
|
20
|
+
readonly resourceType: string;
|
|
21
|
+
/** The resource ids. Every event for any of them, among {@link actions}. */
|
|
22
|
+
readonly resourceIds: readonly string[];
|
|
23
|
+
/** The `domain/reason` actions to cite. Every other action on the resource is left out. */
|
|
24
|
+
readonly actions: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/** One recorded event, as an attestation cites it: never its personal values, only where it is. */
|
|
27
|
+
export interface AuditCitation {
|
|
28
|
+
/** The event's stable public id. */
|
|
29
|
+
readonly eventId: string;
|
|
30
|
+
/** The `domain/reason` action. */
|
|
31
|
+
readonly action: string;
|
|
32
|
+
/** The resource it was recorded against. */
|
|
33
|
+
readonly resourceId: string;
|
|
34
|
+
/** When it happened. */
|
|
35
|
+
readonly occurredAt: Date;
|
|
36
|
+
/** The tenant whose chain it is in, or null for the untenanted chain. */
|
|
37
|
+
readonly tenant: string | null;
|
|
38
|
+
/** Its 1-based position in that chain, or null for an event recorded before the chain began. */
|
|
39
|
+
readonly seq: number | null;
|
|
40
|
+
/** Its chain hash, or null before the chain. */
|
|
41
|
+
readonly hash: string | null;
|
|
42
|
+
/** The `step` its metadata names, when it names one as a string — which purge step the event records. */
|
|
43
|
+
readonly step: string | null;
|
|
44
|
+
}
|
|
45
|
+
/** Read the citations for one query, oldest first. */
|
|
46
|
+
export type AuditCitationReader = (query: AuditCitationQuery) => Promise<AuditCitation[]>;
|
|
47
|
+
/**
|
|
48
|
+
* The composed audit capability's citation reader over this env, or `null` when no capability provides one — no
|
|
49
|
+
* audit is composed, so there is no trail to cite.
|
|
50
|
+
*/
|
|
51
|
+
export declare function composedAuditCitations(capabilities: readonly Capability[], env: Record<string, unknown>): AuditCitationReader | null;
|
|
52
|
+
//# sourceMappingURL=citations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"citations.d.ts","sourceRoot":"","sources":["../../src/audit/citations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D;;;;;;;;;;;;GAYG;AAEH,gGAAgG;AAChG,MAAM,WAAW,kBAAkB;IACjC,oFAAoF;IACpF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,2FAA2F;IAC3F,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,mGAAmG;AACnG,MAAM,WAAW,aAAa;IAC5B,oCAAoC;IACpC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,kCAAkC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4CAA4C;IAC5C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,wBAAwB;IACxB,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,gGAAgG;IAChG,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,yGAAyG;IACzG,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,sDAAsD;AACtD,MAAM,MAAM,mBAAmB,GAAG,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC;AAE1F;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,SAAS,UAAU,EAAE,EACnC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,mBAAmB,GAAG,IAAI,CAG5B"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/audit/citations.ts
|
|
4
|
+
/**
|
|
5
|
+
* The composed audit capability's citation reader over this env, or `null` when no capability provides one — no
|
|
6
|
+
* audit is composed, so there is no trail to cite.
|
|
7
|
+
*/
|
|
8
|
+
function composedAuditCitations(capabilities, env) {
|
|
9
|
+
const provider = capabilities.find((capability) => capability.auditCitations !== void 0);
|
|
10
|
+
return provider?.auditCitations ? provider.auditCitations(env) : null;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { composedAuditCitations };
|
|
@@ -88,6 +88,23 @@ export declare const TARGET_KINDS: Readonly<Partial<Record<BindingType, {
|
|
|
88
88
|
field: string;
|
|
89
89
|
required: boolean;
|
|
90
90
|
}>>>;
|
|
91
|
+
/**
|
|
92
|
+
* **How a Worker consumes a queue it also produces to (#824).**
|
|
93
|
+
*
|
|
94
|
+
* A consumer is half of the queue's own configuration — Cloudflare reads it off the `queues.consumers` entry, not
|
|
95
|
+
* off the code — so it is declared beside the producer binding and written with it, by the same writer. The
|
|
96
|
+
* dead-letter queue is named by **binding**, never by queue name: the name is `<project>-<env>-<thing>`, composed
|
|
97
|
+
* per environment by the same namer that names the producer's, so a spec that stated it would be one environment's.
|
|
98
|
+
*
|
|
99
|
+
* `maxRetries` is bounded by what Cloudflare Queues accepts (0 to 100), and the capability's handler reads the same
|
|
100
|
+
* number to know a message's last attempt, which is the moment it can count one as dead-lettered.
|
|
101
|
+
*/
|
|
102
|
+
export declare const QueueConsumerSpec: z.ZodObject<{
|
|
103
|
+
deadLetter: z.ZodString;
|
|
104
|
+
maxRetries: z.ZodNumber;
|
|
105
|
+
maxBatchSize: z.ZodOptional<z.ZodNumber>;
|
|
106
|
+
}, z.core.$strip>;
|
|
107
|
+
export type QueueConsumerSpec = z.infer<typeof QueueConsumerSpec>;
|
|
91
108
|
/**
|
|
92
109
|
* Declares a Cloudflare binding a capability requires in the Worker env. The authoring
|
|
93
110
|
* shape lets `optional` be omitted (defaults false); `createBackend` normalizes via parse.
|
|
@@ -107,6 +124,11 @@ export declare const BindingSpec: z.ZodObject<{
|
|
|
107
124
|
global: "global";
|
|
108
125
|
}>>;
|
|
109
126
|
resource: z.ZodOptional<z.ZodString>;
|
|
127
|
+
consumer: z.ZodOptional<z.ZodObject<{
|
|
128
|
+
deadLetter: z.ZodString;
|
|
129
|
+
maxRetries: z.ZodNumber;
|
|
130
|
+
maxBatchSize: z.ZodOptional<z.ZodNumber>;
|
|
131
|
+
}, z.core.$strip>>;
|
|
110
132
|
}, z.core.$strip>;
|
|
111
133
|
export type BindingSpec = z.infer<typeof BindingSpec>;
|
|
112
134
|
/** Authoring shape for a capability's `requiredBindings`: `optional` may be omitted. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../../src/capability/bindings.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,WAAW,4iBAuCuC,CAAC;AAChE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,QAAQ,CAAC;AA8B1D,mFAAmF;AACnF,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,CAExD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE3D;AAsBD;;;GAGG;AACH,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,CAMrG,CAAC;AAqCF;;;GAGG;AACH,eAAO,MAAM,WAAW
|
|
1
|
+
{"version":3,"file":"bindings.d.ts","sourceRoot":"","sources":["../../src/capability/bindings.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,WAAW,4iBAuCuC,CAAC;AAChE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,QAAQ,CAAC;AA8B1D,mFAAmF;AACnF,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,CAExD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAE3D;AAsBD;;;GAGG;AACH,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,CAMrG,CAAC;AAqCF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB;;;;iBAyByF,CAAC;AACxH,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE;;;GAGG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;iBA6KpB,CAAC;AACL,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC"}
|
|
@@ -189,6 +189,22 @@ const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
189
189
|
*/
|
|
190
190
|
const NAMED_RESOURCE_KINDS = new Set(FEATURE_RESOURCE_KINDS);
|
|
191
191
|
/**
|
|
192
|
+
* **How a Worker consumes a queue it also produces to (#824).**
|
|
193
|
+
*
|
|
194
|
+
* A consumer is half of the queue's own configuration — Cloudflare reads it off the `queues.consumers` entry, not
|
|
195
|
+
* off the code — so it is declared beside the producer binding and written with it, by the same writer. The
|
|
196
|
+
* dead-letter queue is named by **binding**, never by queue name: the name is `<project>-<env>-<thing>`, composed
|
|
197
|
+
* per environment by the same namer that names the producer's, so a spec that stated it would be one environment's.
|
|
198
|
+
*
|
|
199
|
+
* `maxRetries` is bounded by what Cloudflare Queues accepts (0 to 100), and the capability's handler reads the same
|
|
200
|
+
* number to know a message's last attempt, which is the moment it can count one as dead-lettered.
|
|
201
|
+
*/
|
|
202
|
+
const QueueConsumerSpec = z.object({
|
|
203
|
+
deadLetter: z.string().min(1).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "A dead-letter queue is named by its binding: letters, digits and `_`.").describe("The binding of the queue a message goes to once its retries are spent. Declared as a `queue` binding of its own, so `pithy provision` creates it; its queue name is composed the same way as this one's."),
|
|
204
|
+
maxRetries: z.number().int().min(0).max(100).describe("How many times a failed message is retried before it is dead-lettered. Cloudflare allows 0 to 100."),
|
|
205
|
+
maxBatchSize: z.number().int().min(1).max(100).optional().describe("The most messages one invocation of the handler receives. Absent leaves Cloudflare's default.")
|
|
206
|
+
}).describe("How a Worker consumes a queue it produces to: where exhausted messages go, and how often one is retried.");
|
|
207
|
+
/**
|
|
192
208
|
* Declares a Cloudflare binding a capability requires in the Worker env. The authoring
|
|
193
209
|
* shape lets `optional` be omitted (defaults false); `createBackend` normalizes via parse.
|
|
194
210
|
*/
|
|
@@ -203,7 +219,8 @@ const BindingSpec = z.object({
|
|
|
203
219
|
target: z.string().min(1).regex(TARGET, "A target is a resource id or name: letters, digits, `.`, `_` and `-`.").optional().describe("The account-level resource this binding addresses, which the adopter creates themselves: a Hyperdrive config id, an mTLS certificate id, a Pipelines stream, a dispatch namespace, or an Analytics Engine dataset. Written into the field wrangler names for it (`id`, `certificate_id`, `stream`, `namespace`, `dataset`), and carried whole into every environment, because the resource is the account's. Required for `hyperdrive`, `mtls_certificates`, `pipelines` and `dispatch_namespaces`; optional for `analytics_engine_datasets`. Refused on every other kind."),
|
|
204
220
|
remote: z.boolean().optional().describe("Reach the real Cloudflare resource during local development instead of a local emulation. Set it for a binding that has none — Vectorize and Workers AI both lack local simulation — so `wrangler dev` and any Workflow host, which always runs locally, still work. Left unset rather than defaulting to false, so a spec that does not care emits no flag at all. Ignored in a deployed Worker."),
|
|
205
221
|
scope: z.enum(["environment", "global"]).optional().describe("Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding."),
|
|
206
|
-
resource: z.string().min(1).regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.").optional().describe("The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding.")
|
|
222
|
+
resource: z.string().min(1).regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.").optional().describe("The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv`, `r2` or `queue` binding."),
|
|
223
|
+
consumer: QueueConsumerSpec.optional().describe("That this Worker also **consumes** the queue it produces to, and how: the queue its exhausted messages go to and how many times one is retried first. Written as the `queues.consumers` entry beside the producer, so the queue's handler is the Worker's own `queue` export (`Capability.queue`). Absent, the Worker only sends. Meaningful only for a `queue` binding.")
|
|
207
224
|
}).describe("Declares a Cloudflare binding a capability requires in the Worker env.").check((ctx) => {
|
|
208
225
|
if (ctx.value.type === "durable_object" && ctx.value.className === void 0) ctx.issues.push({
|
|
209
226
|
code: "custom",
|
|
@@ -239,6 +256,19 @@ const BindingSpec = z.object({
|
|
|
239
256
|
path: ["target"],
|
|
240
257
|
message: `Binding "${ctx.value.name}" needs a target — the ${ctx.value.type} resource it addresses, written as \`${target.field}\`.`
|
|
241
258
|
});
|
|
259
|
+
const consumer = ctx.value.consumer;
|
|
260
|
+
if (consumer !== void 0 && ctx.value.type !== "queue") ctx.issues.push({
|
|
261
|
+
code: "custom",
|
|
262
|
+
input: ctx.value,
|
|
263
|
+
path: ["consumer"],
|
|
264
|
+
message: `Binding "${ctx.value.name}" declares a consumer, which only a queue binding has — this one is a ${ctx.value.type}.`
|
|
265
|
+
});
|
|
266
|
+
if (consumer !== void 0 && consumer.deadLetter === ctx.value.name) ctx.issues.push({
|
|
267
|
+
code: "custom",
|
|
268
|
+
input: ctx.value,
|
|
269
|
+
path: ["consumer", "deadLetter"],
|
|
270
|
+
message: `Queue "${ctx.value.name}" names itself as its dead-letter queue. Declare a second queue binding for it.`
|
|
271
|
+
});
|
|
242
272
|
if (target === void 0 && ctx.value.target !== void 0) ctx.issues.push({
|
|
243
273
|
code: "custom",
|
|
244
274
|
input: ctx.value,
|
|
@@ -247,4 +277,4 @@ const BindingSpec = z.object({
|
|
|
247
277
|
});
|
|
248
278
|
});
|
|
249
279
|
//#endregion
|
|
250
|
-
export { BindingSpec, BindingType, TARGET_KINDS, isProvisionedBinding, isWrittenBinding, seedPolicy };
|
|
280
|
+
export { BindingSpec, BindingType, QueueConsumerSpec, TARGET_KINDS, isProvisionedBinding, isWrittenBinding, seedPolicy };
|