@pithy-sh/core 0.12.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/audit/composed.d.ts +18 -0
- package/dist/audit/composed.d.ts.map +1 -0
- package/dist/audit/composed.js +28 -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 +133 -15
- 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 +113 -4
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +138 -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 +434 -4
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +455 -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 +103 -7
- 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 +14 -1
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +19 -5
- package/dist/error/http.d.ts +216 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +432 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +144 -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/validation.d.ts +11 -2
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +20 -3
- 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 +126 -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/audit/composed.ts +30 -0
- package/src/capability/bindings.ts +61 -0
- package/src/capability/capability.ts +166 -18
- 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 +208 -8
- package/src/capability/settings.ts +7 -0
- package/src/capability/vectorIndex.ts +60 -0
- package/src/cli/commandCatalog.generated.ts +488 -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 +178 -10
- 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 +34 -2
- package/src/error/payload.ts +302 -0
- package/src/error/pithyError.ts +42 -0
- package/src/http/routeSpec.ts +75 -5
- package/src/http/validation.ts +35 -3
- 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,145 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { base64UrlDecode, base64UrlEncode } from "../controlPlane/token/base64url";
|
|
6
|
+
import { PresenceTicketInvalidError } from "../error/pithyError";
|
|
7
|
+
import { PRESENCE_TICKET_TTL_SECONDS } from "./declaration";
|
|
8
|
+
import { PresenceOrigin, PresenceParticipantName, PresenceParticipantRef } from "./frames";
|
|
9
|
+
import { PresenceRecordId, PresenceRoomKindName } from "./names";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* **A presence ticket: single use, 60 seconds, one room, one participant.**
|
|
13
|
+
*
|
|
14
|
+
* `<claims>.<signature>`, both unpadded base64url, the signature an HMAC-SHA256 over the claims segment with
|
|
15
|
+
* the Worker's `presence-ticket-key` secret. The alphabet and the dot are all token characters, so a
|
|
16
|
+
* ticket rides in `Sec-WebSocket-Protocol` as `pithy.ticket.<ticket>` untouched.
|
|
17
|
+
*
|
|
18
|
+
* What a ticket proves is that **this Worker** minted it, behind a gate, for these claims. Verifying it
|
|
19
|
+
* here checks the signature, the expiry and the room; spending it is the room's job, because only the room
|
|
20
|
+
* the ticket names can redeem it, which is what makes "single use" decidable without a write anywhere.
|
|
21
|
+
*
|
|
22
|
+
* Every failure is the one {@link PresenceTicketInvalidError}, with the step in `detail` for the log: a caller
|
|
23
|
+
* holding someone else's ticket learns that it does not work, never why.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The subprotocol prefix a ticket travels under, beside `pithy.presence.v1`. */
|
|
27
|
+
export const PRESENCE_TICKET_PROTOCOL_PREFIX = "pithy.ticket.";
|
|
28
|
+
|
|
29
|
+
/** What a ticket says, signed. */
|
|
30
|
+
export const PresenceTicketClaims = z
|
|
31
|
+
.object({
|
|
32
|
+
v: z.literal(1).describe("The ticket format. A different one is refused."),
|
|
33
|
+
id: z.string().uuid().describe("The ticket's own id — what the room records as spent."),
|
|
34
|
+
kind: PresenceRoomKindName.describe("The room kind the ticket admits to."),
|
|
35
|
+
recordId: PresenceRecordId.describe("The record the room is about."),
|
|
36
|
+
ref: PresenceParticipantRef.describe(
|
|
37
|
+
"Who the participant is: the client's opaque ref, or the signed-in user's id.",
|
|
38
|
+
),
|
|
39
|
+
name: PresenceParticipantName.describe("How other participants see them."),
|
|
40
|
+
origin: PresenceOrigin.describe("Which door the ticket was minted at."),
|
|
41
|
+
exp: z.number().int().positive().describe("When the ticket stops being honored, in seconds since the epoch."),
|
|
42
|
+
})
|
|
43
|
+
.describe("The claims a presence ticket carries, signed by the Worker that minted it.");
|
|
44
|
+
export type PresenceTicketClaims = z.infer<typeof PresenceTicketClaims>;
|
|
45
|
+
|
|
46
|
+
/** What a caller asks a ticket for: everything but the id and the expiry, which the minter decides. */
|
|
47
|
+
export type PresenceTicketRequest = Omit<PresenceTicketClaims, "v" | "id" | "exp">;
|
|
48
|
+
|
|
49
|
+
const encoder = new TextEncoder();
|
|
50
|
+
|
|
51
|
+
async function hmacKey(secret: string): Promise<CryptoKey> {
|
|
52
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
53
|
+
"sign",
|
|
54
|
+
"verify",
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mint a ticket. `keys[0]` signs. */
|
|
59
|
+
export async function mintPresenceTicket(
|
|
60
|
+
request: PresenceTicketRequest,
|
|
61
|
+
keys: readonly string[],
|
|
62
|
+
now: Date = new Date(),
|
|
63
|
+
): Promise<{ ticket: string; claims: PresenceTicketClaims }> {
|
|
64
|
+
const [signing] = keys;
|
|
65
|
+
if (signing === undefined) throw new PresenceTicketInvalidError({ detail: "no signing key" });
|
|
66
|
+
const claims = PresenceTicketClaims.parse({
|
|
67
|
+
v: 1,
|
|
68
|
+
id: crypto.randomUUID(),
|
|
69
|
+
...request,
|
|
70
|
+
exp: Math.floor(now.getTime() / 1000) + PRESENCE_TICKET_TTL_SECONDS,
|
|
71
|
+
});
|
|
72
|
+
const body = base64UrlEncode(encoder.encode(JSON.stringify(claims)));
|
|
73
|
+
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(signing), encoder.encode(body)));
|
|
74
|
+
return { ticket: `${body}.${base64UrlEncode(signature)}`, claims };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Decode a segment, mapping the control-plane decoder's refusal onto this one. */
|
|
78
|
+
function decode(segment: string, what: string): Uint8Array {
|
|
79
|
+
try {
|
|
80
|
+
return base64UrlDecode(segment);
|
|
81
|
+
} catch (cause) {
|
|
82
|
+
throw new PresenceTicketInvalidError({ detail: `${what} is not base64url` }, { cause });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Verify a ticket for one room: the signature under any honored key, the expiry, and that it names this
|
|
88
|
+
* kind and this record. Returns the claims; spending them is the room's.
|
|
89
|
+
*/
|
|
90
|
+
export async function verifyPresenceTicket(
|
|
91
|
+
ticket: string,
|
|
92
|
+
room: { kind: string; recordId: string },
|
|
93
|
+
keys: readonly string[],
|
|
94
|
+
now: Date = new Date(),
|
|
95
|
+
): Promise<PresenceTicketClaims> {
|
|
96
|
+
const parts = ticket.split(".");
|
|
97
|
+
const [body, signature] = parts;
|
|
98
|
+
if (parts.length !== 2 || !body || !signature) throw new PresenceTicketInvalidError({ detail: "not two segments" });
|
|
99
|
+
const signed = decode(signature, "signature");
|
|
100
|
+
let authentic = false;
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
if (await crypto.subtle.verify("HMAC", await hmacKey(key), signed, encoder.encode(body))) {
|
|
103
|
+
authentic = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!authentic) throw new PresenceTicketInvalidError({ detail: "signature does not verify under any honored key" });
|
|
108
|
+
|
|
109
|
+
let json: unknown;
|
|
110
|
+
try {
|
|
111
|
+
json = JSON.parse(new TextDecoder().decode(decode(body, "claims")));
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
if (cause instanceof PresenceTicketInvalidError) throw cause;
|
|
114
|
+
throw new PresenceTicketInvalidError({ detail: "claims are not JSON" }, { cause });
|
|
115
|
+
}
|
|
116
|
+
const parsed = PresenceTicketClaims.safeParse(json);
|
|
117
|
+
if (!parsed.success) throw new PresenceTicketInvalidError({ detail: "claims do not parse" });
|
|
118
|
+
const claims = parsed.data;
|
|
119
|
+
|
|
120
|
+
const seconds = Math.floor(now.getTime() / 1000);
|
|
121
|
+
if (claims.exp <= seconds) throw new PresenceTicketInvalidError({ detail: `expired at ${claims.exp}` });
|
|
122
|
+
// A ticket living past its stated life was not minted here, whatever signed it.
|
|
123
|
+
if (claims.exp > seconds + PRESENCE_TICKET_TTL_SECONDS) {
|
|
124
|
+
throw new PresenceTicketInvalidError({ detail: `expiry ${claims.exp} is beyond the ticket lifetime` });
|
|
125
|
+
}
|
|
126
|
+
if (claims.kind !== room.kind || claims.recordId !== room.recordId) {
|
|
127
|
+
throw new PresenceTicketInvalidError({
|
|
128
|
+
detail: `minted for ${claims.kind}/${claims.recordId}, presented at ${room.kind}/${room.recordId}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return claims;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The ticket from a subprotocol offer, or `undefined` when none rides in it. More than one is refused as
|
|
136
|
+
* none: an offer is one client's, and two tickets in it is not something a client means.
|
|
137
|
+
*/
|
|
138
|
+
export function ticketFromOffer(offered: string | null | undefined): string | undefined {
|
|
139
|
+
const tickets = (offered ?? "")
|
|
140
|
+
.split(",")
|
|
141
|
+
.map((protocol) => protocol.trim())
|
|
142
|
+
.filter((protocol) => protocol.startsWith(PRESENCE_TICKET_PROTOCOL_PREFIX))
|
|
143
|
+
.map((protocol) => protocol.slice(PRESENCE_TICKET_PROTOCOL_PREFIX.length));
|
|
144
|
+
return tickets.length === 1 ? tickets[0] : undefined;
|
|
145
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ConsentPurposeId } from "../consent/purpose";
|
|
6
|
+
import { fromZodError, ValidationError } from "../error/pithyError";
|
|
7
|
+
import { PrivacyClassName } from "../privacy/classes";
|
|
8
|
+
import { type BasisSource, LegalBasis } from "./vocabulary";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Processing activities: what a Worker does with personal data, why, and on what legal basis — the declarations the
|
|
12
|
+
* GDPR Article 30 record is derived from.
|
|
13
|
+
*
|
|
14
|
+
* ## Declared where the processing is
|
|
15
|
+
*
|
|
16
|
+
* A capability states the activities it performs, under its own `name` (`auth.account`), the rule consent purposes,
|
|
17
|
+
* tables and error codes follow. The adopter adds their own in the `processing` block of a Worker's
|
|
18
|
+
* `pithy.config.ts`, and may replace a capability's legal basis there.
|
|
19
|
+
*
|
|
20
|
+
* ## A missing basis is a gap, never a guess
|
|
21
|
+
*
|
|
22
|
+
* `legalBasis` is optional. An activity without one is reported as a gap in the record, and nothing fills it in.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** An activity id: `<domain>.<name>`, lowercase, the domain being the declaring capability's name. */
|
|
26
|
+
export const ProcessingActivityId = z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(
|
|
29
|
+
/^[a-z][a-z0-9-]*\.[a-z][a-z0-9_.-]*$/,
|
|
30
|
+
"A processing activity id is `<domain>.<name>`: lowercase, the domain being the declaring capability's name.",
|
|
31
|
+
)
|
|
32
|
+
.max(128)
|
|
33
|
+
.describe(
|
|
34
|
+
"A processing activity id, `<domain>.<name>` — `auth.account`. The domain is the declaring capability's name, or the adopter's own domain for an activity declared in `pithy.config.ts`.",
|
|
35
|
+
);
|
|
36
|
+
export type ProcessingActivityId = z.infer<typeof ProcessingActivityId>;
|
|
37
|
+
|
|
38
|
+
/** Someone outside the Worker's own code who receives personal data for an activity. */
|
|
39
|
+
export const ProcessingRecipient = z
|
|
40
|
+
.object({
|
|
41
|
+
name: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.max(120)
|
|
45
|
+
.describe("Who receives it, as an auditor would name them — `Stripe`, `Cloudflare Email Service`."),
|
|
46
|
+
receives: z
|
|
47
|
+
.string()
|
|
48
|
+
.min(1)
|
|
49
|
+
.max(500)
|
|
50
|
+
.describe("What they receive, in one sentence — `The buyer's email address and what they bought.`"),
|
|
51
|
+
classes: z
|
|
52
|
+
.array(PrivacyClassName)
|
|
53
|
+
.default([])
|
|
54
|
+
.describe("The data classes in what they receive — `pii`, `phi`, or an adopter's own."),
|
|
55
|
+
})
|
|
56
|
+
.describe("One recipient of personal data: who it is, what it receives, and the classes in it.");
|
|
57
|
+
export type ProcessingRecipient = z.output<typeof ProcessingRecipient>;
|
|
58
|
+
export type ProcessingRecipientInput = z.input<typeof ProcessingRecipient>;
|
|
59
|
+
|
|
60
|
+
/** A consent purpose belongs with a consent basis and nowhere else. */
|
|
61
|
+
function consentPurposeNeedsConsent(
|
|
62
|
+
value: { legalBasis?: LegalBasis | undefined; consentPurpose?: string | undefined },
|
|
63
|
+
ctx: z.RefinementCtx,
|
|
64
|
+
): void {
|
|
65
|
+
if (value.consentPurpose !== undefined && value.legalBasis !== "consent") {
|
|
66
|
+
ctx.addIssue({
|
|
67
|
+
code: "custom",
|
|
68
|
+
path: ["consentPurpose"],
|
|
69
|
+
message:
|
|
70
|
+
"`consentPurpose` names the purpose a `consent` basis rests on. Set `legalBasis` to `consent`, or drop it.",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One processing activity, as a capability or the adopter declares it. */
|
|
76
|
+
export const ProcessingActivity = z
|
|
77
|
+
.object({
|
|
78
|
+
id: ProcessingActivityId,
|
|
79
|
+
purpose: z
|
|
80
|
+
.string()
|
|
81
|
+
.min(1)
|
|
82
|
+
.max(500)
|
|
83
|
+
.describe(
|
|
84
|
+
"Why the data is processed, in one sentence an auditor reads — `Keep an account and sign its holder in.`",
|
|
85
|
+
),
|
|
86
|
+
classes: z
|
|
87
|
+
.array(PrivacyClassName)
|
|
88
|
+
.default([])
|
|
89
|
+
.describe(
|
|
90
|
+
"The data classes the activity uses — `pii`, `phi`, or an adopter's own. Cross-checked against the classes the Worker's schemas store.",
|
|
91
|
+
),
|
|
92
|
+
legalBasis: LegalBasis.optional().describe(
|
|
93
|
+
"The lawful basis it rests on. Absent is reported as a gap in the processing record, never filled in.",
|
|
94
|
+
),
|
|
95
|
+
consentPurpose: ConsentPurposeId.optional().describe(
|
|
96
|
+
"For a `consent` basis, the consent purpose the activity rests on — `email.marketing`. The record cites its grant counts.",
|
|
97
|
+
),
|
|
98
|
+
recipients: z
|
|
99
|
+
.array(ProcessingRecipient)
|
|
100
|
+
.default([])
|
|
101
|
+
.describe(
|
|
102
|
+
"Who receives the data beyond Cloudflare, which the record always lists — a payment rail, a mail provider. Built from the resolved config, so a rail that is off is not named.",
|
|
103
|
+
),
|
|
104
|
+
})
|
|
105
|
+
.superRefine(consentPurposeNeedsConsent)
|
|
106
|
+
.describe("One processing activity: its purpose, the data classes it uses, its legal basis and its recipients.");
|
|
107
|
+
export type ProcessingActivity = z.output<typeof ProcessingActivity>;
|
|
108
|
+
export type ProcessingActivityInput = z.input<typeof ProcessingActivity>;
|
|
109
|
+
|
|
110
|
+
/** The adopter's replacement for a capability's legal basis. */
|
|
111
|
+
export const ProcessingOverride = z
|
|
112
|
+
.object({
|
|
113
|
+
legalBasis: LegalBasis.describe("The basis that replaces the capability's own."),
|
|
114
|
+
consentPurpose: ConsentPurposeId.optional().describe(
|
|
115
|
+
"For a `consent` basis, the consent purpose it rests on. Left out, a capability's own purpose is kept.",
|
|
116
|
+
),
|
|
117
|
+
})
|
|
118
|
+
.strict()
|
|
119
|
+
.superRefine(consentPurposeNeedsConsent)
|
|
120
|
+
.describe("A legal basis the adopter states for a capability's activity, replacing the one it declares.");
|
|
121
|
+
export type ProcessingOverride = z.infer<typeof ProcessingOverride>;
|
|
122
|
+
|
|
123
|
+
/** The `processing` block, as a Worker's `pithy.config.ts` writes it. */
|
|
124
|
+
export const ProcessingPolicy = z
|
|
125
|
+
.object({
|
|
126
|
+
activities: z
|
|
127
|
+
.array(ProcessingActivity)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(
|
|
130
|
+
"Your own processing activities, beside the ones composed capabilities declare. Each under your own domain.",
|
|
131
|
+
),
|
|
132
|
+
overrides: z
|
|
133
|
+
.record(ProcessingActivityId, ProcessingOverride)
|
|
134
|
+
.optional()
|
|
135
|
+
.describe(
|
|
136
|
+
'A legal basis per capability activity, replacing the one it declares: `{ "auth.account": { legalBasis: "legitimate-interests" } }`.',
|
|
137
|
+
),
|
|
138
|
+
})
|
|
139
|
+
.strict()
|
|
140
|
+
.describe("What this Worker does with personal data beyond what its capabilities declare, and the bases you state.");
|
|
141
|
+
export type ProcessingPolicy = z.output<typeof ProcessingPolicy>;
|
|
142
|
+
export type ProcessingPolicyInput = z.input<typeof ProcessingPolicy>;
|
|
143
|
+
|
|
144
|
+
/** The domain half of an activity id: `auth` for `auth.account`. */
|
|
145
|
+
export function activityDomain(id: string): string {
|
|
146
|
+
const dot = id.indexOf(".");
|
|
147
|
+
return dot === -1 ? id : id.slice(0, dot);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse one declarer's activities and refuse what the record could not attribute: an invalid activity, one under a
|
|
152
|
+
* domain that is not the declarer's, or two with one id. `domain` is the declaring capability's name, or `null` for
|
|
153
|
+
* the adopter's own, which may use any domain but a reserved one.
|
|
154
|
+
*/
|
|
155
|
+
export function parseProcessingActivities(
|
|
156
|
+
declarer: string,
|
|
157
|
+
activities: readonly ProcessingActivityInput[],
|
|
158
|
+
domain: string | null,
|
|
159
|
+
reserved: ReadonlySet<string> = new Set(),
|
|
160
|
+
): ProcessingActivity[] {
|
|
161
|
+
const seen = new Set<string>();
|
|
162
|
+
return activities.map((input) => {
|
|
163
|
+
const parsed = ProcessingActivity.safeParse(input);
|
|
164
|
+
if (!parsed.success) {
|
|
165
|
+
throw new ValidationError({
|
|
166
|
+
message: `${declarer} declares a processing activity that is not valid: ${parsed.error.issues[0]?.message ?? "invalid"}.`,
|
|
167
|
+
action:
|
|
168
|
+
"Give it an id of the form `<domain>.<name>` and a purpose; name a consent purpose only under a consent basis.",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const activity = parsed.data;
|
|
172
|
+
const own = activityDomain(activity.id);
|
|
173
|
+
if (domain !== null && own !== domain) {
|
|
174
|
+
throw new ValidationError({
|
|
175
|
+
message: `The ${declarer} capability declares the processing activity "${activity.id}" outside its own domain.`,
|
|
176
|
+
action: `Name it under ${domain}. — a capability declares activities only under its own name.`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (domain === null && reserved.has(own)) {
|
|
180
|
+
throw new ValidationError({
|
|
181
|
+
message: `The processing activity "${activity.id}" is under ${own}., which a composed capability owns.`,
|
|
182
|
+
action: "Name your own activities under your own domain, such as your app's name.",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (seen.has(activity.id)) {
|
|
186
|
+
throw new ValidationError({
|
|
187
|
+
message: `${declarer} declares the processing activity "${activity.id}" twice.`,
|
|
188
|
+
action: "Declare each activity once.",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
seen.add(activity.id);
|
|
192
|
+
return activity;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Parse a `processing` block, or refuse it naming what is wrong. `undefined` is the empty policy. */
|
|
197
|
+
export function parseProcessingPolicy(input: unknown): ProcessingPolicy {
|
|
198
|
+
if (input === undefined) return {};
|
|
199
|
+
const parsed = ProcessingPolicy.safeParse(input);
|
|
200
|
+
if (!parsed.success) {
|
|
201
|
+
throw fromZodError(parsed.error, {
|
|
202
|
+
message: "The processing block in pithy.config.ts does not parse.",
|
|
203
|
+
action: "Fix the field named, then deploy again.",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return parsed.data;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One activity as it takes effect: who declared it, its basis after any override, and where that came from. */
|
|
210
|
+
export interface ResolvedActivity {
|
|
211
|
+
readonly id: string;
|
|
212
|
+
readonly purpose: string;
|
|
213
|
+
readonly classes: readonly string[];
|
|
214
|
+
readonly legalBasis: LegalBasis | null;
|
|
215
|
+
readonly basisSource: BasisSource | null;
|
|
216
|
+
readonly consentPurpose: string | null;
|
|
217
|
+
readonly recipients: readonly ProcessingRecipient[];
|
|
218
|
+
/** The capability that declared it, or `config` for the adopter's own. */
|
|
219
|
+
readonly declaredBy: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What resolving reads off a composed capability. */
|
|
223
|
+
interface ActivityDeclarer {
|
|
224
|
+
readonly name: string;
|
|
225
|
+
readonly processing?: readonly ProcessingActivity[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One declared activity, before any override. */
|
|
229
|
+
function resolved(activity: ProcessingActivity, declaredBy: string, source: BasisSource): ResolvedActivity {
|
|
230
|
+
return {
|
|
231
|
+
id: activity.id,
|
|
232
|
+
purpose: activity.purpose,
|
|
233
|
+
classes: [...activity.classes],
|
|
234
|
+
legalBasis: activity.legalBasis ?? null,
|
|
235
|
+
basisSource: activity.legalBasis === undefined ? null : source,
|
|
236
|
+
consentPurpose: activity.consentPurpose ?? null,
|
|
237
|
+
recipients: activity.recipients.map((recipient) => ({ ...recipient, classes: [...recipient.classes] })),
|
|
238
|
+
declaredBy,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every activity as it takes effect over a composition: each capability's, in composition order, then the adopter's,
|
|
244
|
+
* with the block's overrides applied. Refuses an adopter activity under a composed capability's domain, an id declared
|
|
245
|
+
* twice, and an override naming an activity no composed capability declares. Called when a Worker assembles.
|
|
246
|
+
*/
|
|
247
|
+
export function resolveProcessing(
|
|
248
|
+
policy: ProcessingPolicy,
|
|
249
|
+
capabilities: readonly ActivityDeclarer[],
|
|
250
|
+
): ResolvedActivity[] {
|
|
251
|
+
const reserved = new Set(capabilities.map((capability) => capability.name));
|
|
252
|
+
const own = parseProcessingActivities("pithy.config.ts", policy.activities ?? [], null, reserved);
|
|
253
|
+
const declared = capabilities.flatMap((capability) =>
|
|
254
|
+
(capability.processing ?? []).map((activity) => resolved(activity, capability.name, "capability")),
|
|
255
|
+
);
|
|
256
|
+
const ids = new Set<string>();
|
|
257
|
+
for (const activity of declared) {
|
|
258
|
+
if (ids.has(activity.id)) {
|
|
259
|
+
throw new ValidationError({
|
|
260
|
+
message: `The processing activity "${activity.id}" is declared twice.`,
|
|
261
|
+
action: "Declare each activity once.",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
ids.add(activity.id);
|
|
265
|
+
}
|
|
266
|
+
const overrides = policy.overrides ?? {};
|
|
267
|
+
for (const id of Object.keys(overrides)) {
|
|
268
|
+
if (own.some((activity) => activity.id === id)) {
|
|
269
|
+
throw new ValidationError({
|
|
270
|
+
message: `The processing block overrides "${id}", which is declared in the same block.`,
|
|
271
|
+
action: `State its legal basis on the activity itself.`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (!ids.has(id)) {
|
|
275
|
+
throw new ValidationError({
|
|
276
|
+
message: `The processing block overrides "${id}", which no composed capability declares.`,
|
|
277
|
+
action: "Name an activity a composed capability declares. `GET /control-plane/processing` lists them.",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const overridden = declared.map((activity): ResolvedActivity => {
|
|
282
|
+
const override = overrides[activity.id];
|
|
283
|
+
if (override === undefined) return activity;
|
|
284
|
+
const consentPurpose =
|
|
285
|
+
override.legalBasis === "consent" ? (override.consentPurpose ?? activity.consentPurpose) : null;
|
|
286
|
+
return { ...activity, legalBasis: override.legalBasis, basisSource: "config", consentPurpose };
|
|
287
|
+
});
|
|
288
|
+
return [...overridden, ...own.map((activity) => resolved(activity, "config", "config"))];
|
|
289
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
import { parseProcessingPolicy, type ResolvedActivity, resolveProcessing } from "./activity";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The processing activities as this Worker resolved them at assembly, reachable from module scope, so the control
|
|
9
|
+
* plane reads the same record `createBackend` refused or accepted. One Worker assembles one backend; a second assembly
|
|
10
|
+
* in one isolate replaces the first wholesale, as `../retention/composed.ts` does for the retention policy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Parse and resolve a Worker's `processing` block over what it composes, or refuse it. */
|
|
14
|
+
export function effectiveProcessing(block: unknown, capabilities: readonly Capability[]): ResolvedActivity[] {
|
|
15
|
+
return resolveProcessing(parseProcessingPolicy(block), capabilities);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let recorded: readonly ResolvedActivity[] | null = null;
|
|
19
|
+
|
|
20
|
+
/** Record the resolved activities. Called once by `createBackend`. */
|
|
21
|
+
export function recordProcessing(activities: readonly ResolvedActivity[]): void {
|
|
22
|
+
recorded = activities;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Forget them. For a test that needs the un-assembled state back. */
|
|
26
|
+
export function forgetProcessing(): void {
|
|
27
|
+
recorded = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The resolved activities, or `null` before any backend has been assembled in this isolate. */
|
|
31
|
+
export function composedProcessing(): readonly ResolvedActivity[] | null {
|
|
32
|
+
return recorded;
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/** The processing record's closed vocabularies, apart from the walk that fills them, so a browser may import them. */
|
|
7
|
+
|
|
8
|
+
/** What kind of gap the record names. */
|
|
9
|
+
export const PROCESSING_GAP_KINDS = [
|
|
10
|
+
"no-legal-basis",
|
|
11
|
+
"no-consent-purpose",
|
|
12
|
+
"class-not-stored",
|
|
13
|
+
"class-unclaimed",
|
|
14
|
+
"phi-not-covered",
|
|
15
|
+
"no-standing-grant",
|
|
16
|
+
] as const;
|
|
17
|
+
export type ProcessingGapKindId = (typeof PROCESSING_GAP_KINDS)[number];
|
|
18
|
+
|
|
19
|
+
/** Where a recipient came from. */
|
|
20
|
+
export const RECIPIENT_SOURCES = ["platform", "binding", "declared"] as const;
|
|
21
|
+
export type RecipientSource = (typeof RECIPIENT_SOURCES)[number];
|
|
22
|
+
|
|
23
|
+
/** Where a legal basis came from: the declaring capability, or the `processing` block. */
|
|
24
|
+
export const BASIS_SOURCES = ["capability", "config"] as const;
|
|
25
|
+
export type BasisSource = (typeof BASIS_SOURCES)[number];
|
|
26
|
+
|
|
27
|
+
/** The six lawful bases of GDPR Article 6(1), (a) to (f), in that order. */
|
|
28
|
+
export const LEGAL_BASES = [
|
|
29
|
+
"consent",
|
|
30
|
+
"contract",
|
|
31
|
+
"legal-obligation",
|
|
32
|
+
"vital-interests",
|
|
33
|
+
"public-task",
|
|
34
|
+
"legitimate-interests",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/** One lawful basis for processing. */
|
|
38
|
+
export const LegalBasis = z
|
|
39
|
+
.enum(LEGAL_BASES)
|
|
40
|
+
.describe(
|
|
41
|
+
"The lawful basis of GDPR Article 6(1): `consent` (a), `contract` (b), `legal-obligation` (c), `vital-interests` (d), `public-task` (e) or `legitimate-interests` (f). A `consent` basis names the consent purpose it rests on.",
|
|
42
|
+
);
|
|
43
|
+
export type LegalBasis = z.infer<typeof LegalBasis>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ExecutionContext } from "@cloudflare/workers-types";
|
|
5
|
+
import type { Logger } from "../logger/logger";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **A capability's queue consumer, and how one Worker's `queue` export finds it (#824).**
|
|
9
|
+
*
|
|
10
|
+
* A Worker has one `queue` handler for every queue it consumes, the way it has one `email` and one `scheduled`.
|
|
11
|
+
* Which capability a message belongs to cannot be read off the batch: `batch.queue` is the provisioned name,
|
|
12
|
+
* `<project>-<env>-<thing>`, which no capability knows at runtime and which a long name truncates. So the message
|
|
13
|
+
* says it: every body a capability sends carries its own name under {@link QUEUE_CAPABILITY_FIELD}, built with
|
|
14
|
+
* {@link queueBody}, and the dispatcher hands each capability exactly the messages that name it.
|
|
15
|
+
*
|
|
16
|
+
* **A message no composed capability claims is acknowledged and logged**, never retried. Retrying cannot make a
|
|
17
|
+
* capability appear, so a retry would only walk the message to its dead-letter queue and count it as a delivery
|
|
18
|
+
* failure it never was.
|
|
19
|
+
*
|
|
20
|
+
* **A handler that throws costs its own messages, and nothing else.** Every other capability's messages are still
|
|
21
|
+
* handed over; the first failure is rethrown after the pass, so the invocation is recorded as failed and the runtime
|
|
22
|
+
* retries what was neither acknowledged nor retried.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One delivered message, as much of the runtime's as a consumer touches. Stated here rather than imported, so the
|
|
27
|
+
* Workers types' `Message` — a word half the kit uses for other things — never becomes a name the kit reaches for.
|
|
28
|
+
*/
|
|
29
|
+
export interface QueueMessage {
|
|
30
|
+
/** The runtime's id for this delivery. */
|
|
31
|
+
readonly id: string;
|
|
32
|
+
/** The body as sent. */
|
|
33
|
+
readonly body: unknown;
|
|
34
|
+
/** How many times this message has been delivered, this one included. Starts at 1. */
|
|
35
|
+
readonly attempts: number;
|
|
36
|
+
/** Settle it: delivered, never to be seen again. */
|
|
37
|
+
ack(): void;
|
|
38
|
+
/** Settle it: deliver it again after the delay, or to the dead-letter queue once retries are spent. */
|
|
39
|
+
retry(options?: { delaySeconds?: number }): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One batch the runtime hands a Worker's `queue` export. */
|
|
43
|
+
export interface QueueBatch {
|
|
44
|
+
/** The provisioned queue's name. */
|
|
45
|
+
readonly queue: string;
|
|
46
|
+
/** Its messages. */
|
|
47
|
+
readonly messages: readonly QueueMessage[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The body field that names the capability a queue message belongs to. */
|
|
51
|
+
export const QUEUE_CAPABILITY_FIELD = "capability";
|
|
52
|
+
|
|
53
|
+
/** A capability's queue handler: the messages that name it, from one batch. */
|
|
54
|
+
export type CapabilityQueueHandler = (
|
|
55
|
+
messages: readonly QueueMessage[],
|
|
56
|
+
env: Record<string, unknown>,
|
|
57
|
+
ctx: ExecutionContext,
|
|
58
|
+
) => Promise<void>;
|
|
59
|
+
|
|
60
|
+
/** A message body, stamped with the capability it belongs to so the Worker's one `queue` export can route it. */
|
|
61
|
+
export function queueBody<const Capability extends string, const Body extends Record<string, unknown>>(
|
|
62
|
+
capability: Capability,
|
|
63
|
+
body: Body,
|
|
64
|
+
): Body & { capability: Capability } {
|
|
65
|
+
return { ...body, capability };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The capability a message body names, or null when it names none. */
|
|
69
|
+
export function queueMessageCapability(body: unknown): string | null {
|
|
70
|
+
if (typeof body !== "object" || body === null) return null;
|
|
71
|
+
const value = (body as Record<string, unknown>)[QUEUE_CAPABILITY_FIELD];
|
|
72
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Hand each composed capability the messages of one batch that name it. */
|
|
76
|
+
export async function dispatchQueueBatch(
|
|
77
|
+
batch: QueueBatch,
|
|
78
|
+
handlers: ReadonlyMap<string, CapabilityQueueHandler>,
|
|
79
|
+
env: Record<string, unknown>,
|
|
80
|
+
ctx: ExecutionContext,
|
|
81
|
+
log?: Pick<Logger, "warn">,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const claimed = new Map<string, QueueMessage[]>();
|
|
84
|
+
for (const message of batch.messages) {
|
|
85
|
+
const capability = queueMessageCapability(message.body);
|
|
86
|
+
if (capability === null || !handlers.has(capability)) {
|
|
87
|
+
log?.warn("queue message unclaimed", { queue: batch.queue, messageId: message.id, capability });
|
|
88
|
+
message.ack();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const list = claimed.get(capability) ?? [];
|
|
92
|
+
list.push(message);
|
|
93
|
+
claimed.set(capability, list);
|
|
94
|
+
}
|
|
95
|
+
const failures: unknown[] = [];
|
|
96
|
+
for (const [capability, messages] of claimed) {
|
|
97
|
+
try {
|
|
98
|
+
await handlers.get(capability)?.(messages, env, ctx);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failures.push(error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (failures[0] !== undefined) throw failures[0];
|
|
104
|
+
}
|