@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,153 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ErrorBody } from "../error/wire";
|
|
6
|
+
import type { SocketVersions } from "../http/subprotocol";
|
|
7
|
+
import { PresenceActivity } from "./names";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* **Every frame on a presence room socket, as the Zod schema it is sent and parsed with.**
|
|
11
|
+
*
|
|
12
|
+
* Pure: zod and core's leaf modules. A browser imports it to type the socket, and the AsyncAPI document
|
|
13
|
+
* reads the same schemas through the declaring capability's `sockets`, so the document and the Durable
|
|
14
|
+
* Object cannot disagree.
|
|
15
|
+
*
|
|
16
|
+
* **How a client reads a frame, stated once** (`docs/SOCKETS.md`). A field added to a frame later is
|
|
17
|
+
* optional. A client ignores a `type` and a key it does not know. A change no client could ignore is a new
|
|
18
|
+
* subprotocol, and the Worker keeps serving the one before it.
|
|
19
|
+
*
|
|
20
|
+
* **The keepalive is plain text**, the one exception to JSON: a client sends `ping`, the Worker answers
|
|
21
|
+
* `pong`. The only JSON frame a client sends is `set_activity`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The subprotocols a room serves. There is no unversioned room: a ticket can only arrive in the offer. */
|
|
25
|
+
export const PRESENCE_ROOM_VERSIONS: SocketVersions = { current: "pithy.presence.v1", previous: null };
|
|
26
|
+
|
|
27
|
+
/** Which door a participant came in by. */
|
|
28
|
+
export const PresenceOrigin = z
|
|
29
|
+
.enum(["control-plane", "session"])
|
|
30
|
+
.describe(
|
|
31
|
+
"Which door the participant came in by: `control-plane`, a management client such as the dashboard, or `session`, a signed-in user of the app.",
|
|
32
|
+
);
|
|
33
|
+
export type PresenceOrigin = z.infer<typeof PresenceOrigin>;
|
|
34
|
+
|
|
35
|
+
/** Who a participant is, as the minting door states it. */
|
|
36
|
+
export const PresenceParticipantRef = z
|
|
37
|
+
.string()
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(128)
|
|
40
|
+
.describe(
|
|
41
|
+
"Who the participant is. A management client's own opaque reference, which it chose; or the signed-in user's id, which the Worker chose.",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** The longest participant name, in UTF-16 code units — what zod's `.max` counts. */
|
|
45
|
+
export const PRESENCE_PARTICIPANT_NAME_MAX = 80;
|
|
46
|
+
|
|
47
|
+
/** How the others see a participant. */
|
|
48
|
+
export const PresenceParticipantName = z
|
|
49
|
+
.string()
|
|
50
|
+
.trim()
|
|
51
|
+
.min(1)
|
|
52
|
+
.max(PRESENCE_PARTICIPANT_NAME_MAX)
|
|
53
|
+
.describe("How the other participants see this one. A label, never an identity — `ref` and `origin` are that.");
|
|
54
|
+
|
|
55
|
+
/** One participant in a room. */
|
|
56
|
+
export const PresenceParticipant = z
|
|
57
|
+
.object({
|
|
58
|
+
id: z
|
|
59
|
+
.string()
|
|
60
|
+
.uuid()
|
|
61
|
+
.describe("This participant's id in the room — one per socket, so one person in two tabs is two participants."),
|
|
62
|
+
ref: PresenceParticipantRef,
|
|
63
|
+
name: PresenceParticipantName,
|
|
64
|
+
origin: PresenceOrigin,
|
|
65
|
+
activity: PresenceActivity.describe("What they are doing now, one of the room kind's declared activities."),
|
|
66
|
+
joinedAt: z.iso.datetime().describe("When this socket joined, ISO-8601."),
|
|
67
|
+
})
|
|
68
|
+
.describe("One participant in a presence room: one open socket.");
|
|
69
|
+
export type PresenceParticipant = z.infer<typeof PresenceParticipant>;
|
|
70
|
+
|
|
71
|
+
/** The first frame on every room socket. */
|
|
72
|
+
export const PresenceRoomInitFrame = z
|
|
73
|
+
.object({
|
|
74
|
+
type: z.literal("init").describe("Always `init`."),
|
|
75
|
+
self: z.string().uuid().describe("This socket's own participant id, so a client can find itself in the list."),
|
|
76
|
+
participants: z
|
|
77
|
+
.array(PresenceParticipant)
|
|
78
|
+
.describe("Everyone in the room now, this socket included, in the order they joined."),
|
|
79
|
+
})
|
|
80
|
+
.describe("Who is in the room, sent once as soon as the socket opens.");
|
|
81
|
+
export type PresenceRoomInitFrame = z.infer<typeof PresenceRoomInitFrame>;
|
|
82
|
+
|
|
83
|
+
/** Someone joined. */
|
|
84
|
+
export const PresenceJoinedFrame = z
|
|
85
|
+
.object({
|
|
86
|
+
type: z.literal("joined").describe("Always `joined`."),
|
|
87
|
+
participant: PresenceParticipant.describe("Who joined."),
|
|
88
|
+
})
|
|
89
|
+
.describe("Another participant joined the room.");
|
|
90
|
+
export type PresenceJoinedFrame = z.infer<typeof PresenceJoinedFrame>;
|
|
91
|
+
|
|
92
|
+
/** Someone left, or their socket dropped. */
|
|
93
|
+
export const PresenceLeftFrame = z
|
|
94
|
+
.object({
|
|
95
|
+
type: z.literal("left").describe("Always `left`."),
|
|
96
|
+
participantId: z.string().uuid().describe("The participant who left."),
|
|
97
|
+
})
|
|
98
|
+
.describe("Another participant left the room, or their socket dropped.");
|
|
99
|
+
export type PresenceLeftFrame = z.infer<typeof PresenceLeftFrame>;
|
|
100
|
+
|
|
101
|
+
/** Someone changed what they are doing. */
|
|
102
|
+
export const PresenceActivityFrame = z
|
|
103
|
+
.object({
|
|
104
|
+
type: z.literal("activity").describe("Always `activity`."),
|
|
105
|
+
participantId: z.string().uuid().describe("The participant whose activity changed."),
|
|
106
|
+
activity: PresenceActivity.describe("What they are doing now."),
|
|
107
|
+
})
|
|
108
|
+
.describe("Another participant announced a different activity.");
|
|
109
|
+
export type PresenceActivityFrame = z.infer<typeof PresenceActivityFrame>;
|
|
110
|
+
|
|
111
|
+
/** A frame the room could not accept. */
|
|
112
|
+
export const PresenceRoomErrorFrame = z
|
|
113
|
+
.object({
|
|
114
|
+
type: z.literal("error").describe("Always `error`."),
|
|
115
|
+
error: ErrorBody.shape.error,
|
|
116
|
+
})
|
|
117
|
+
.describe(
|
|
118
|
+
"The room could not accept a frame — not one it reads, an activity the room kind does not declare, or past the size limit. The error is the one an HTTP response would carry.",
|
|
119
|
+
);
|
|
120
|
+
export type PresenceRoomErrorFrame = z.infer<typeof PresenceRoomErrorFrame>;
|
|
121
|
+
|
|
122
|
+
/** Every JSON frame the Worker sends on a room socket. `pong` is the plain-text exception. */
|
|
123
|
+
export const PresenceRoomServerFrame = z
|
|
124
|
+
.discriminatedUnion("type", [
|
|
125
|
+
PresenceRoomInitFrame,
|
|
126
|
+
PresenceJoinedFrame,
|
|
127
|
+
PresenceLeftFrame,
|
|
128
|
+
PresenceActivityFrame,
|
|
129
|
+
PresenceRoomErrorFrame,
|
|
130
|
+
])
|
|
131
|
+
.describe("A JSON frame the Worker sends on a presence room socket.");
|
|
132
|
+
export type PresenceRoomServerFrame = z.infer<typeof PresenceRoomServerFrame>;
|
|
133
|
+
|
|
134
|
+
/** Announce a different activity. */
|
|
135
|
+
export const PresenceSetActivityFrame = z
|
|
136
|
+
.object({
|
|
137
|
+
type: z.literal("set_activity").describe("Always `set_activity`."),
|
|
138
|
+
activity: PresenceActivity.describe("What this participant is doing now. One of the room kind's declared values."),
|
|
139
|
+
})
|
|
140
|
+
.describe("Announce what this participant is doing now.");
|
|
141
|
+
export type PresenceSetActivityFrame = z.infer<typeof PresenceSetActivityFrame>;
|
|
142
|
+
|
|
143
|
+
/** Every JSON frame a client may send on a room socket. `ping` is the plain-text exception. */
|
|
144
|
+
export const PresenceRoomClientFrame = z
|
|
145
|
+
.discriminatedUnion("type", [PresenceSetActivityFrame])
|
|
146
|
+
.describe("A JSON frame a client sends on a presence room socket.");
|
|
147
|
+
export type PresenceRoomClientFrame = z.infer<typeof PresenceRoomClientFrame>;
|
|
148
|
+
|
|
149
|
+
/** The keepalive a client sends, as plain text. */
|
|
150
|
+
export const PresenceRoomPing = z.literal("ping").describe("The plain-text keepalive a client sends.");
|
|
151
|
+
|
|
152
|
+
/** The Worker's answer to `ping`, as plain text. */
|
|
153
|
+
export const PresenceRoomPong = z.literal("pong").describe("The plain-text answer to `ping`.");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MiddlewareHandler } from "hono";
|
|
5
|
+
import type { PithyHonoEnv } from "../../capability/capability";
|
|
6
|
+
import { UnauthorizedError } from "../../error/pithyError";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Require a signed-in user of the app, over the `AuthContext` seam. Kept here rather than imported from
|
|
10
|
+
* `@pithy-sh/auth`, which core may not depend on — and without auth composed `c.var.auth` is null, so the
|
|
11
|
+
* session ticket route denies rather than stands open.
|
|
12
|
+
*/
|
|
13
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
14
|
+
return async (c, next) => {
|
|
15
|
+
if (!c.var.auth) {
|
|
16
|
+
throw new UnauthorizedError({
|
|
17
|
+
message: "Authentication required.",
|
|
18
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
await next();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ControlPlaneScope } from "../../controlPlane/scope/scope";
|
|
6
|
+
import { PRESENCE_ROOM_VERSIONS } from "../frames";
|
|
7
|
+
import { PresenceActivity, PresenceRecordId, PresenceRoomKindName } from "../names";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A minted presence ticket and where to spend it. Both ticket routes answer it.
|
|
11
|
+
*
|
|
12
|
+
* Read across a version boundary like every response schema, so a field added here later is `.optional()`.
|
|
13
|
+
*/
|
|
14
|
+
export const PresenceTicket = z
|
|
15
|
+
.object({
|
|
16
|
+
ticket: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.describe(
|
|
20
|
+
"The ticket. Offer it in `Sec-WebSocket-Protocol` as `pithy.ticket.<ticket>`, beside `protocol`. Single use.",
|
|
21
|
+
),
|
|
22
|
+
expiresAt: z.iso.datetime().describe("When the ticket stops being honored, ISO-8601 — 60 seconds after minting."),
|
|
23
|
+
kind: PresenceRoomKindName.describe("The room kind the ticket admits to."),
|
|
24
|
+
recordId: PresenceRecordId.describe("The record the room is about."),
|
|
25
|
+
socketPath: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.describe("The path to open the socket at, on this Worker — this room's, already filled in."),
|
|
29
|
+
protocol: z
|
|
30
|
+
.literal(PRESENCE_ROOM_VERSIONS.current)
|
|
31
|
+
.describe("The subprotocol to offer beside the ticket. The Worker answers with it."),
|
|
32
|
+
})
|
|
33
|
+
.describe("A single-use, 60-second ticket into one presence room, and where to open the socket with it.");
|
|
34
|
+
export type PresenceTicket = z.infer<typeof PresenceTicket>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One room kind, as `GET /control-plane/manifest` reports it under the capability that declares it. Built
|
|
38
|
+
* from the capability's resolved `basePath`, so a client never assumes where a socket is.
|
|
39
|
+
*/
|
|
40
|
+
export const PresenceRoomDescriptor = z
|
|
41
|
+
.object({
|
|
42
|
+
kind: PresenceRoomKindName.describe("The room kind — `support.thread`."),
|
|
43
|
+
scope: ControlPlaneScope.describe(
|
|
44
|
+
"The scope that reads the record, and so joins its room. A management client mints its ticket with exactly this scope.",
|
|
45
|
+
),
|
|
46
|
+
activities: z
|
|
47
|
+
.array(PresenceActivity)
|
|
48
|
+
.min(1)
|
|
49
|
+
.describe("What a participant may announce. The first is what everyone starts as."),
|
|
50
|
+
maxParticipants: z.number().int().positive().describe("How many participants the room admits at once."),
|
|
51
|
+
sessionTicketPath: z
|
|
52
|
+
.string()
|
|
53
|
+
.nullable()
|
|
54
|
+
.describe(
|
|
55
|
+
"Where a signed-in user of the app mints a ticket for this kind, or null when the kind admits management clients only.",
|
|
56
|
+
),
|
|
57
|
+
socketPath: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.describe("The socket route, with `:kind` and `:recordId` to fill in — the path as the Worker mounted it."),
|
|
61
|
+
})
|
|
62
|
+
.describe("One presence room kind this capability serves, and how to join one of its rooms.");
|
|
63
|
+
export type PresenceRoomDescriptor = z.infer<typeof PresenceRoomDescriptor>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { DurableObjectNamespace } from "@cloudflare/workers-types";
|
|
5
|
+
import type { Context, Hono } from "hono";
|
|
6
|
+
import type { PithyHonoEnv } from "../../capability/capability";
|
|
7
|
+
import { remedy } from "../../cli/command";
|
|
8
|
+
import { ForbiddenError, InternalError, NotFoundError, PresenceTicketInvalidError } from "../../error/pithyError";
|
|
9
|
+
import { describeRoute } from "../../http/routeSpec";
|
|
10
|
+
import { requireSameOrigin } from "../../http/sameOrigin";
|
|
11
|
+
import { SUBPROTOCOL_HEADER } from "../../http/subprotocol";
|
|
12
|
+
import { validate } from "../../http/validation";
|
|
13
|
+
import {
|
|
14
|
+
PRESENCE_ROOMS_BINDING,
|
|
15
|
+
type PresenceDeclaration,
|
|
16
|
+
type PresenceRoomKind,
|
|
17
|
+
participantCap,
|
|
18
|
+
presenceRoomPath,
|
|
19
|
+
presenceSocketPath,
|
|
20
|
+
presenceTicketPath,
|
|
21
|
+
resolveTicketKeys,
|
|
22
|
+
sessionParticipantName,
|
|
23
|
+
} from "../declaration";
|
|
24
|
+
import { PRESENCE_ADMISSION_HEADER, type PresenceAdmission } from "../room";
|
|
25
|
+
import { mintPresenceTicket, type PresenceTicketRequest, ticketFromOffer, verifyPresenceTicket } from "../ticket";
|
|
26
|
+
import { requireAuth } from "./guard";
|
|
27
|
+
import { type PresenceRoomDescriptor, PresenceTicket } from "./responses";
|
|
28
|
+
import { PresenceRoomParams, PresenceSessionTicketRequest } from "./schemas";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A capability's presence routes, mounted by `createBackend` under the `basePath` it declared:
|
|
32
|
+
*
|
|
33
|
+
* POST {base}/presence/tickets → a ticket for a signed-in user (bearer | session)
|
|
34
|
+
* GET {base}/presence/rooms/:kind/:recordId → the room's socket (a ticket)
|
|
35
|
+
*
|
|
36
|
+
* The control-plane door is the seam's own, `POST /control-plane/presence/tickets`, because one management
|
|
37
|
+
* client reaches every capability's rooms through it; both doors answer the same {@link PresenceTicket}.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Find a declared room kind, or the 404 an undeclared one is. */
|
|
41
|
+
export function roomKind(presence: PresenceDeclaration, kind: string): PresenceRoomKind {
|
|
42
|
+
const room = presence.rooms.find((candidate) => candidate.kind === kind);
|
|
43
|
+
if (!room) throw new NotFoundError({ message: "That room kind does not exist.", params: { kind } });
|
|
44
|
+
return room;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Mint the ticket a door answers with. */
|
|
48
|
+
export async function issuePresenceTicket(
|
|
49
|
+
c: Context<PithyHonoEnv>,
|
|
50
|
+
presence: PresenceDeclaration,
|
|
51
|
+
request: PresenceTicketRequest,
|
|
52
|
+
): Promise<PresenceTicket> {
|
|
53
|
+
const keys = await resolveTicketKeys(presence.ticketKey, c.env as Record<string, unknown>);
|
|
54
|
+
const { ticket, claims } = await mintPresenceTicket(request, keys);
|
|
55
|
+
return {
|
|
56
|
+
ticket,
|
|
57
|
+
expiresAt: new Date(claims.exp * 1000).toISOString(),
|
|
58
|
+
kind: claims.kind,
|
|
59
|
+
recordId: claims.recordId,
|
|
60
|
+
socketPath: presenceRoomPath(presence.basePath, claims.kind, claims.recordId),
|
|
61
|
+
protocol: "pithy.presence.v1",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The room kinds a declaration serves, as the manifest reports them. */
|
|
66
|
+
export function describePresenceRooms(presence: PresenceDeclaration): PresenceRoomDescriptor[] {
|
|
67
|
+
return presence.rooms.map((room) => ({
|
|
68
|
+
kind: room.kind,
|
|
69
|
+
scope: room.scope,
|
|
70
|
+
activities: [...room.activities],
|
|
71
|
+
maxParticipants: participantCap(room),
|
|
72
|
+
sessionTicketPath: room.session ? presenceTicketPath(presence.basePath) : null,
|
|
73
|
+
socketPath: presenceSocketPath(presence.basePath),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function registerPresenceRoutes(app: Hono<PithyHonoEnv>, presence: PresenceDeclaration): void {
|
|
78
|
+
app.post(
|
|
79
|
+
presenceTicketPath(presence.basePath),
|
|
80
|
+
describeRoute({
|
|
81
|
+
summary: "Mint a presence ticket for the signed-in user.",
|
|
82
|
+
description:
|
|
83
|
+
"Admits the caller to one room of a kind this capability declares, when the capability's own read permission admits them to the record. Single use, for 60 seconds. Open the socket at `socketPath`, offering `protocol` and `pithy.ticket.<ticket>` in `Sec-WebSocket-Protocol`.",
|
|
84
|
+
verification: ["bearer", "session"],
|
|
85
|
+
responses: { 201: PresenceTicket },
|
|
86
|
+
}),
|
|
87
|
+
requireAuth(),
|
|
88
|
+
requireSameOrigin(),
|
|
89
|
+
validate("json", PresenceSessionTicketRequest),
|
|
90
|
+
async (c) => {
|
|
91
|
+
const body = c.req.valid("json");
|
|
92
|
+
const room = roomKind(presence, body.kind);
|
|
93
|
+
const auth = c.var.auth;
|
|
94
|
+
// `requireAuth` ran; this is the type system catching up.
|
|
95
|
+
if (!auth) throw new InternalError({ detail: "presence session ticket reached without auth" });
|
|
96
|
+
const grant = room.session ? await room.session(c, body.recordId) : null;
|
|
97
|
+
if (!grant) {
|
|
98
|
+
throw new ForbiddenError({
|
|
99
|
+
message: "You cannot join that room.",
|
|
100
|
+
detail: room.session
|
|
101
|
+
? `session gate refused ${body.kind}/${body.recordId}`
|
|
102
|
+
: `${body.kind} admits management clients only`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const ticket = await issuePresenceTicket(c, presence, {
|
|
106
|
+
kind: room.kind,
|
|
107
|
+
recordId: body.recordId,
|
|
108
|
+
ref: auth.userId,
|
|
109
|
+
name: sessionParticipantName(grant.name, auth.userId),
|
|
110
|
+
origin: "session",
|
|
111
|
+
});
|
|
112
|
+
return c.json(ticket satisfies PresenceTicket, 201);
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
app.get(
|
|
117
|
+
presenceSocketPath(presence.basePath),
|
|
118
|
+
describeRoute({
|
|
119
|
+
summary: "Open a presence room's socket.",
|
|
120
|
+
description:
|
|
121
|
+
"Send `Upgrade: websocket` and offer `pithy.presence.v1` and `pithy.ticket.<ticket>` in `Sec-WebSocket-Protocol`; the ticket is the credential, minted by either ticket route for this room. The socket receives who is in the room, then joins, leaves and activity changes as they happen. The frames are in the AsyncAPI contract.",
|
|
122
|
+
verification: "public",
|
|
123
|
+
responses: {
|
|
124
|
+
101: { description: "Switches to a WebSocket. The room pushes presence frames as JSON text." },
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
validate("param", PresenceRoomParams),
|
|
128
|
+
async (c) => {
|
|
129
|
+
const { kind, recordId } = c.req.valid("param");
|
|
130
|
+
const room = roomKind(presence, kind);
|
|
131
|
+
const ticket = ticketFromOffer(c.req.header(SUBPROTOCOL_HEADER));
|
|
132
|
+
if (ticket === undefined) throw new PresenceTicketInvalidError({ detail: "no single ticket in the offer" });
|
|
133
|
+
const keys = await resolveTicketKeys(presence.ticketKey, c.env as Record<string, unknown>);
|
|
134
|
+
const claims = await verifyPresenceTicket(ticket, { kind, recordId }, keys);
|
|
135
|
+
|
|
136
|
+
const admission: PresenceAdmission = {
|
|
137
|
+
kind,
|
|
138
|
+
recordId,
|
|
139
|
+
ref: claims.ref,
|
|
140
|
+
name: claims.name,
|
|
141
|
+
origin: claims.origin,
|
|
142
|
+
ticket: { id: claims.id, exp: claims.exp },
|
|
143
|
+
activities: [...room.activities],
|
|
144
|
+
maxParticipants: participantCap(room),
|
|
145
|
+
};
|
|
146
|
+
// A fresh request, so the admission header is ours whatever the client sent under that name.
|
|
147
|
+
const request = new Request(c.req.url, c.req.raw);
|
|
148
|
+
request.headers.set(PRESENCE_ADMISSION_HEADER, JSON.stringify(admission));
|
|
149
|
+
return roomStub(c, kind, recordId).fetch(request);
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The one room for this record. */
|
|
155
|
+
function roomStub(c: Context<PithyHonoEnv>, kind: string, recordId: string) {
|
|
156
|
+
const ns = (c.env as Record<string, unknown>)[PRESENCE_ROOMS_BINDING] as DurableObjectNamespace | undefined;
|
|
157
|
+
if (!ns) {
|
|
158
|
+
throw new InternalError({
|
|
159
|
+
message: "Presence is not available right now.",
|
|
160
|
+
action: `Bind the ${PRESENCE_ROOMS_BINDING} Durable Object. ${remedy("upgrade", {}, { runtime: true }).say((command) => `${command} writes it.`)}`,
|
|
161
|
+
detail: `no ${PRESENCE_ROOMS_BINDING} binding`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return ns.get(ns.idFromName(`${kind}:${recordId}`)) as unknown as { fetch(request: Request): Promise<Response> };
|
|
165
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PresenceParticipantName, PresenceParticipantRef } from "../frames";
|
|
6
|
+
import { PresenceRecordId, PresenceRoomKindName } from "../names";
|
|
7
|
+
|
|
8
|
+
/** The room a ticket is for. */
|
|
9
|
+
const room = {
|
|
10
|
+
kind: PresenceRoomKindName.describe("The room kind, as the manifest lists it — `support.thread`."),
|
|
11
|
+
recordId: PresenceRecordId.describe("The record the room is about."),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** `POST {base}/presence/tickets` — a signed-in user asks to join a room. The participant is that user. */
|
|
15
|
+
export const PresenceSessionTicketRequest = z
|
|
16
|
+
.object(room)
|
|
17
|
+
.describe("The room a signed-in user asks a presence ticket for. The participant is the caller.");
|
|
18
|
+
export type PresenceSessionTicketRequest = z.infer<typeof PresenceSessionTicketRequest>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `POST /control-plane/presence/tickets` — a management client asks to join a room, naming its participant.
|
|
22
|
+
* The body is signed with the call, which is what makes the participant the client's own claim.
|
|
23
|
+
*/
|
|
24
|
+
export const ControlPlanePresenceTicketRequest = z
|
|
25
|
+
.object({
|
|
26
|
+
...room,
|
|
27
|
+
participant: z
|
|
28
|
+
.object({
|
|
29
|
+
ref: PresenceParticipantRef,
|
|
30
|
+
name: PresenceParticipantName,
|
|
31
|
+
})
|
|
32
|
+
.describe("Who joins: the client's own opaque reference for them, and the name the others see."),
|
|
33
|
+
})
|
|
34
|
+
.describe("The room a management client asks a presence ticket for, and who joins it.");
|
|
35
|
+
export type ControlPlanePresenceTicketRequest = z.infer<typeof ControlPlanePresenceTicketRequest>;
|
|
36
|
+
|
|
37
|
+
/** The socket route's path parameters. */
|
|
38
|
+
export const PresenceRoomParams = z.object(room).describe("The room a presence socket joins: its kind and its record.");
|
|
39
|
+
export type PresenceRoomParams = z.infer<typeof PresenceRoomParams>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The three names a presence room is made of, as Zod schemas and nothing else.
|
|
8
|
+
*
|
|
9
|
+
* A leaf on purpose: the manifest's room descriptor, the ticket responses and the frames all read these, and
|
|
10
|
+
* a management client holds those in a browser. So this module imports zod and no other thing — never the
|
|
11
|
+
* declaration, which names the Hono context a session gate runs in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A room kind's name: the declaring capability's name, a dot, and a noun — `support.thread`. */
|
|
15
|
+
export const PresenceRoomKindName = z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(
|
|
18
|
+
/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/,
|
|
19
|
+
"A room kind is the capability's name, a dot, and a lowercase noun — `support.thread`.",
|
|
20
|
+
)
|
|
21
|
+
.max(64)
|
|
22
|
+
.describe(
|
|
23
|
+
"A room kind: the declaring capability's name, a dot, and a noun — `support.thread`. The first segment is always the capability that declares it, so two capabilities cannot declare the same kind.",
|
|
24
|
+
);
|
|
25
|
+
export type PresenceRoomKindName = z.infer<typeof PresenceRoomKindName>;
|
|
26
|
+
|
|
27
|
+
/** One activity a participant may announce — `viewing`, `replying`. A closed vocabulary per room kind, never free text. */
|
|
28
|
+
export const PresenceActivity = z
|
|
29
|
+
.string()
|
|
30
|
+
.regex(/^[a-z][a-z0-9_]*$/, "An activity is one lowercase word — `viewing`, `replying`.")
|
|
31
|
+
.max(32)
|
|
32
|
+
.describe("What a participant is doing in the room — one of the values its room kind declares, never free text.");
|
|
33
|
+
export type PresenceActivity = z.infer<typeof PresenceActivity>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The record a room is about — the id the declaring capability's own routes take. Constrained to what fits a
|
|
37
|
+
* path segment and a Durable Object name without escaping.
|
|
38
|
+
*/
|
|
39
|
+
export const PresenceRecordId = z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.max(128)
|
|
43
|
+
.regex(/^[A-Za-z0-9._:-]+$/, "A record id is letters, digits, `.`, `_`, `:` and `-`.")
|
|
44
|
+
.describe("The record the room is about — the id the declaring capability's own routes use for it.");
|
|
45
|
+
export type PresenceRecordId = z.infer<typeof PresenceRecordId>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PresenceOrigin, PresenceParticipant, PresenceParticipantName, PresenceParticipantRef } from "./frames";
|
|
6
|
+
import { PresenceActivity, PresenceRecordId, PresenceRoomKindName } from "./names";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **What a room knows, and where it keeps it: on its sockets, and nowhere else.**
|
|
10
|
+
*
|
|
11
|
+
* A room is one Durable Object on the Hibernation API. When it hibernates its memory goes, so everything it
|
|
12
|
+
* must still know when it wakes rides in each socket's attachment, read back from `getWebSockets()`: the
|
|
13
|
+
* participant, the version the socket negotiated, the room kind's rules, and which tickets have been spent.
|
|
14
|
+
* Nothing is written to D1, KV or Durable Object storage. When the last socket closes, the room is gone.
|
|
15
|
+
*
|
|
16
|
+
* This module is the logic of that, pure, so it is tested without a runtime; `./durableObject.ts` is the
|
|
17
|
+
* thin part that reads and writes attachments.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A spent ticket: its id, and when it would have expired anyway — after which remembering it is pointless. */
|
|
21
|
+
export const SpentTicket = z
|
|
22
|
+
.object({
|
|
23
|
+
id: z.string().uuid().describe("The spent ticket's id."),
|
|
24
|
+
exp: z.number().int().describe("When the ticket would have expired, in seconds since the epoch."),
|
|
25
|
+
})
|
|
26
|
+
.describe("A ticket a room has redeemed, kept until it would have expired.");
|
|
27
|
+
export type SpentTicket = z.infer<typeof SpentTicket>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the Worker's socket route tells the room, on a header only the Worker can set: a verified ticket's
|
|
31
|
+
* participant, the ticket itself, and the room kind's rules. The room trusts this and nothing a client sent.
|
|
32
|
+
*/
|
|
33
|
+
export const PresenceAdmission = z
|
|
34
|
+
.object({
|
|
35
|
+
kind: PresenceRoomKindName.describe("The room kind."),
|
|
36
|
+
recordId: PresenceRecordId.describe("The record the room is about."),
|
|
37
|
+
ref: PresenceParticipantRef,
|
|
38
|
+
name: PresenceParticipantName,
|
|
39
|
+
origin: PresenceOrigin,
|
|
40
|
+
ticket: SpentTicket.describe("The ticket this socket is redeeming."),
|
|
41
|
+
activities: z
|
|
42
|
+
.array(PresenceActivity)
|
|
43
|
+
.min(1)
|
|
44
|
+
.describe("The room kind's declared activities. The first is what a participant starts as."),
|
|
45
|
+
maxParticipants: z.number().int().positive().describe("The room kind's participant cap."),
|
|
46
|
+
})
|
|
47
|
+
.describe("A verified admission, forwarded from the Worker's socket route to the room.");
|
|
48
|
+
export type PresenceAdmission = z.infer<typeof PresenceAdmission>;
|
|
49
|
+
|
|
50
|
+
/** The server-set header carrying a {@link PresenceAdmission} into the room. */
|
|
51
|
+
export const PRESENCE_ADMISSION_HEADER = "x-pithy-presence-admission";
|
|
52
|
+
|
|
53
|
+
/** What one socket carries through hibernation. Kept far under the 16,384-byte attachment ceiling. */
|
|
54
|
+
export const PresenceAttachment = z
|
|
55
|
+
.object({
|
|
56
|
+
participant: PresenceParticipant.describe("Who this socket is, and what they are doing."),
|
|
57
|
+
version: z.string().describe("The negotiated subprotocol."),
|
|
58
|
+
ticket: SpentTicket.describe("The ticket this socket redeemed."),
|
|
59
|
+
activities: z.array(PresenceActivity).min(1).describe("The room kind's declared activities."),
|
|
60
|
+
spent: z
|
|
61
|
+
.array(SpentTicket)
|
|
62
|
+
.describe("Tickets redeemed by sockets that have since closed, carried so a hibernated room still refuses them."),
|
|
63
|
+
})
|
|
64
|
+
.describe("The participant and room state a presence socket keeps across hibernation.");
|
|
65
|
+
export type PresenceAttachment = z.infer<typeof PresenceAttachment>;
|
|
66
|
+
|
|
67
|
+
/** Every ticket a room still remembers as spent, from its sockets' attachments and its own memory. */
|
|
68
|
+
export function spentTickets(
|
|
69
|
+
attachments: readonly PresenceAttachment[],
|
|
70
|
+
remembered: readonly SpentTicket[],
|
|
71
|
+
nowSeconds: number,
|
|
72
|
+
): Set<string> {
|
|
73
|
+
const spent = new Set<string>();
|
|
74
|
+
const add = (ticket: SpentTicket) => {
|
|
75
|
+
if (ticket.exp > nowSeconds) spent.add(ticket.id);
|
|
76
|
+
};
|
|
77
|
+
for (const attachment of attachments) {
|
|
78
|
+
add(attachment.ticket);
|
|
79
|
+
for (const ticket of attachment.spent) add(ticket);
|
|
80
|
+
}
|
|
81
|
+
for (const ticket of remembered) add(ticket);
|
|
82
|
+
return spent;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Why a room refuses an admission, or `null` to admit. */
|
|
86
|
+
export type AdmissionRefusal = "spent" | "full" | null;
|
|
87
|
+
|
|
88
|
+
/** Decide one admission against the room as it stands. */
|
|
89
|
+
export function admit(
|
|
90
|
+
admission: PresenceAdmission,
|
|
91
|
+
attachments: readonly PresenceAttachment[],
|
|
92
|
+
remembered: readonly SpentTicket[],
|
|
93
|
+
nowSeconds: number,
|
|
94
|
+
): AdmissionRefusal {
|
|
95
|
+
if (spentTickets(attachments, remembered, nowSeconds).has(admission.ticket.id)) return "spent";
|
|
96
|
+
if (attachments.length >= admission.maxParticipants) return "full";
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The most spent tickets one attachment carries. A ticket is honored for 60 seconds, so this is how many
|
|
102
|
+
* sockets a room may see redeemed and closed within a minute before it starts forgetting the soonest to
|
|
103
|
+
* expire; at about 60 bytes each it keeps an attachment far under its 16,384-byte ceiling.
|
|
104
|
+
*/
|
|
105
|
+
export const MAX_CARRIED_SPENT = 160;
|
|
106
|
+
|
|
107
|
+
/** Unexpired, once each, never `own`, and at most {@link MAX_CARRIED_SPENT} — the latest to expire kept. */
|
|
108
|
+
function liveSpent(tickets: readonly SpentTicket[], own: string, nowSeconds: number): SpentTicket[] {
|
|
109
|
+
const byId = new Map<string, SpentTicket>();
|
|
110
|
+
for (const ticket of tickets) {
|
|
111
|
+
if (ticket.exp > nowSeconds && ticket.id !== own) byId.set(ticket.id, ticket);
|
|
112
|
+
}
|
|
113
|
+
const live = [...byId.values()];
|
|
114
|
+
return live.length > MAX_CARRIED_SPENT ? live.sort((a, b) => b.exp - a.exp).slice(0, MAX_CARRIED_SPENT) : live;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The spent list a socket joining a room starts with: what the room remembers in memory, moved onto the
|
|
119
|
+
* socket, where it survives hibernation. A room's memory is only for the tickets of sockets that closed with
|
|
120
|
+
* no other socket left to carry them; the moment a socket is there again, it carries them.
|
|
121
|
+
*/
|
|
122
|
+
export function inheritSpent(remembered: readonly SpentTicket[], own: string, nowSeconds: number): SpentTicket[] {
|
|
123
|
+
return liveSpent(remembered, own, nowSeconds);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The spent list a surviving socket carries once another closes: its own, plus the closing socket's ticket
|
|
128
|
+
* and everything it carried, less what has expired. Returns `null` when nothing changed, so a room writes no
|
|
129
|
+
* attachment it does not need to.
|
|
130
|
+
*/
|
|
131
|
+
export function carrySpent(
|
|
132
|
+
survivor: PresenceAttachment,
|
|
133
|
+
closing: PresenceAttachment,
|
|
134
|
+
nowSeconds: number,
|
|
135
|
+
): SpentTicket[] | null {
|
|
136
|
+
const next = liveSpent([...survivor.spent, closing.ticket, ...closing.spent], survivor.ticket.id, nowSeconds);
|
|
137
|
+
const same =
|
|
138
|
+
next.length === survivor.spent.length && next.every((ticket, index) => survivor.spent[index]?.id === ticket.id);
|
|
139
|
+
return same ? null : next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether a client may announce this activity in this room. */
|
|
143
|
+
export function declaresActivity(attachment: PresenceAttachment, activity: string): boolean {
|
|
144
|
+
return attachment.activities.includes(activity);
|
|
145
|
+
}
|