@pithy-sh/core 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/audit/auditEvent.js +1 -1
- package/dist/audit/chain.d.ts +289 -0
- package/dist/audit/chain.d.ts.map +1 -0
- package/dist/audit/chain.js +400 -0
- package/dist/audit/citations.d.ts +52 -0
- package/dist/audit/citations.d.ts.map +1 -0
- package/dist/audit/citations.js +13 -0
- package/dist/capability/bindings.d.ts +22 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +32 -2
- package/dist/capability/capability.d.ts +123 -16
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +17 -2
- package/dist/capability/erasure.d.ts +75 -0
- package/dist/capability/erasure.d.ts.map +1 -0
- package/dist/capability/erasure.js +2 -0
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/personColumns.d.ts +28 -0
- package/dist/capability/personColumns.d.ts.map +1 -0
- package/dist/capability/personColumns.js +66 -0
- package/dist/capability/personRows.d.ts +62 -0
- package/dist/capability/personRows.d.ts.map +1 -0
- package/dist/capability/personRows.js +59 -0
- package/dist/capability/purge.d.ts +86 -19
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +83 -5
- package/dist/capability/settings.d.ts +10 -0
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/vectorIndex.d.ts +36 -0
- package/dist/capability/vectorIndex.d.ts.map +1 -0
- package/dist/capability/vectorIndex.js +25 -0
- package/dist/cli/commandCatalog.generated.d.ts +433 -3
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +449 -2
- package/dist/cli/commandFacts.d.ts +8 -5
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +12 -3
- package/dist/consent/purpose.d.ts +70 -0
- package/dist/consent/purpose.d.ts.map +1 -0
- package/dist/consent/purpose.js +97 -0
- package/dist/consent/reader.d.ts +99 -0
- package/dist/consent/reader.d.ts.map +1 -0
- package/dist/consent/reader.js +33 -0
- package/dist/controlPlane/audit/actions.d.ts +15 -4
- package/dist/controlPlane/audit/actions.d.ts.map +1 -1
- package/dist/controlPlane/audit/actions.js +16 -5
- package/dist/controlPlane/capability.d.ts +10 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +56 -8
- package/dist/controlPlane/context.d.ts +4 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -2
- package/dist/controlPlane/data/connection.d.ts +21 -2
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +13 -4
- package/dist/controlPlane/data/connections.d.ts +94 -0
- package/dist/controlPlane/data/connections.d.ts.map +1 -0
- package/dist/controlPlane/data/connections.js +151 -0
- package/dist/controlPlane/data/hold.d.ts +46 -0
- package/dist/controlPlane/data/hold.d.ts.map +1 -0
- package/dist/controlPlane/data/hold.js +36 -0
- package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
- package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
- package/dist/controlPlane/data/oidcTrust.js +173 -0
- package/dist/controlPlane/data/tables.d.ts +4 -0
- package/dist/controlPlane/data/tables.d.ts.map +1 -1
- package/dist/controlPlane/data/tables.js +9 -4
- package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
- package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
- package/dist/controlPlane/data/trustFirstCall.js +25 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +32 -8
- package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
- package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
- package/dist/controlPlane/discovery/routeInput.js +80 -0
- package/dist/controlPlane/discovery/toolName.d.ts +21 -0
- package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
- package/dist/controlPlane/discovery/toolName.js +25 -0
- package/dist/controlPlane/erasure/attest.d.ts +78 -0
- package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
- package/dist/controlPlane/erasure/attest.js +164 -0
- package/dist/controlPlane/erasure/read.d.ts +39 -0
- package/dist/controlPlane/erasure/read.d.ts.map +1 -0
- package/dist/controlPlane/erasure/read.js +140 -0
- package/dist/controlPlane/error/errors.d.ts +61 -2
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +13 -0
- package/dist/controlPlane/evidence/derive.d.ts +124 -0
- package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
- package/dist/controlPlane/evidence/derive.js +343 -0
- package/dist/controlPlane/evidence/mapping.d.ts +85 -0
- package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
- package/dist/controlPlane/evidence/mapping.js +114 -0
- package/dist/controlPlane/evidence/pack.d.ts +46 -0
- package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
- package/dist/controlPlane/evidence/pack.js +124 -0
- package/dist/controlPlane/http/connections.d.ts +96 -0
- package/dist/controlPlane/http/connections.d.ts.map +1 -0
- package/dist/controlPlane/http/connections.js +109 -0
- package/dist/controlPlane/http/guard.d.ts +23 -2
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +69 -12
- package/dist/controlPlane/http/handlers.d.ts +212 -10
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +117 -26
- package/dist/controlPlane/http/holds.d.ts +57 -0
- package/dist/controlPlane/http/holds.d.ts.map +1 -0
- package/dist/controlPlane/http/holds.js +98 -0
- package/dist/controlPlane/http/presence.d.ts +27 -0
- package/dist/controlPlane/http/presence.d.ts.map +1 -0
- package/dist/controlPlane/http/presence.js +67 -0
- package/dist/controlPlane/http/processing.d.ts +73 -0
- package/dist/controlPlane/http/processing.d.ts.map +1 -0
- package/dist/controlPlane/http/processing.js +17 -0
- package/dist/controlPlane/http/responses.d.ts +927 -3
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +283 -7
- package/dist/controlPlane/http/routes.d.ts +12 -2
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +182 -5
- package/dist/controlPlane/http/schemas.d.ts +63 -0
- package/dist/controlPlane/http/schemas.d.ts.map +1 -1
- package/dist/controlPlane/http/schemas.js +39 -1
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +13 -1
- package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
- package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
- package/dist/controlPlane/http/verifyTrust.js +75 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0005_holds.js +26 -0
- package/dist/controlPlane/processing/derive.d.ts +77 -0
- package/dist/controlPlane/processing/derive.d.ts.map +1 -0
- package/dist/controlPlane/processing/derive.js +206 -0
- package/dist/controlPlane/processing/read.d.ts +10 -0
- package/dist/controlPlane/processing/read.d.ts.map +1 -0
- package/dist/controlPlane/processing/read.js +89 -0
- package/dist/controlPlane/scope/kind.d.ts +46 -0
- package/dist/controlPlane/scope/kind.d.ts.map +1 -0
- package/dist/controlPlane/scope/kind.js +52 -0
- package/dist/controlPlane/scope/scope.d.ts +58 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +59 -4
- package/dist/controlPlane/token/jws.js +1 -1
- package/dist/controlPlane/token/mint.js +1 -1
- package/dist/controlPlane/wire.d.ts +23 -0
- package/dist/controlPlane/wire.d.ts.map +1 -1
- package/dist/controlPlane/wire.js +24 -1
- package/dist/createBackend.d.ts +13 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +11 -3
- package/dist/createEntrypoint.d.ts +7 -0
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +4 -0
- package/dist/error/http.d.ts +168 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +336 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +112 -0
- package/dist/error/pithyError.d.ts +22 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +31 -1
- package/dist/http/routeSpec.d.ts +46 -1
- package/dist/http/routeSpec.d.ts.map +1 -1
- package/dist/http/routeSpec.js +39 -5
- package/dist/http/verification.d.ts.map +1 -1
- package/dist/http/verification.js +1 -1
- package/dist/i18n/translator.d.ts +2 -1
- package/dist/i18n/translator.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/logger/record.d.ts +98 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/mcp/annotations.d.ts +20 -0
- package/dist/mcp/annotations.d.ts.map +1 -0
- package/dist/mcp/annotations.js +23 -0
- package/dist/mcp/tools.d.ts +75 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +118 -0
- package/dist/openapi/document.d.ts.map +1 -1
- package/dist/openapi/document.js +8 -26
- package/dist/openapi/operations.d.ts +35 -0
- package/dist/openapi/operations.d.ts.map +1 -0
- package/dist/openapi/operations.js +37 -0
- package/dist/presence/channel.d.ts +11 -0
- package/dist/presence/channel.d.ts.map +1 -0
- package/dist/presence/channel.js +65 -0
- package/dist/presence/declaration.d.ts +154 -0
- package/dist/presence/declaration.d.ts.map +1 -0
- package/dist/presence/declaration.js +164 -0
- package/dist/presence/durableObject.d.ts +61 -0
- package/dist/presence/durableObject.d.ts.map +1 -0
- package/dist/presence/durableObject.js +257 -0
- package/dist/presence/frames.d.ts +166 -0
- package/dist/presence/frames.d.ts.map +1 -0
- package/dist/presence/frames.js +90 -0
- package/dist/presence/http/guard.d.ts +11 -0
- package/dist/presence/http/guard.d.ts.map +1 -0
- package/dist/presence/http/guard.js +20 -0
- package/dist/presence/http/responses.d.ts +31 -0
- package/dist/presence/http/responses.d.ts.map +1 -0
- package/dist/presence/http/responses.js +34 -0
- package/dist/presence/http/routes.d.ts +24 -0
- package/dist/presence/http/routes.d.ts.map +1 -0
- package/dist/presence/http/routes.js +127 -0
- package/dist/presence/http/schemas.d.ts +29 -0
- package/dist/presence/http/schemas.d.ts.map +1 -0
- package/dist/presence/http/schemas.js +28 -0
- package/dist/presence/names.d.ts +23 -0
- package/dist/presence/names.d.ts.map +1 -0
- package/dist/presence/names.js +22 -0
- package/dist/presence/room.d.ts +95 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +105 -0
- package/dist/presence/ticket.d.ts +55 -0
- package/dist/presence/ticket.d.ts.map +1 -0
- package/dist/presence/ticket.js +109 -0
- package/dist/privacy/classify.js +1 -1
- package/dist/privacy/encrypted.js +1 -1
- package/dist/processing/activity.d.ts +134 -0
- package/dist/processing/activity.d.ts.map +1 -0
- package/dist/processing/activity.js +161 -0
- package/dist/processing/composed.d.ts +18 -0
- package/dist/processing/composed.d.ts.map +1 -0
- package/dist/processing/composed.js +28 -0
- package/dist/processing/vocabulary.d.ts +26 -0
- package/dist/processing/vocabulary.d.ts.map +1 -0
- package/dist/processing/vocabulary.js +35 -0
- package/dist/queue/consumer.d.ts +59 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +46 -0
- package/dist/retention/composed.d.ts +37 -0
- package/dist/retention/composed.d.ts.map +1 -0
- package/dist/retention/composed.js +66 -0
- package/dist/retention/holds.d.ts +61 -0
- package/dist/retention/holds.d.ts.map +1 -0
- package/dist/retention/holds.js +107 -0
- package/dist/retention/holdsPurge.d.ts +22 -0
- package/dist/retention/holdsPurge.d.ts.map +1 -0
- package/dist/retention/holdsPurge.js +50 -0
- package/dist/retention/policy.d.ts +132 -0
- package/dist/retention/policy.d.ts.map +1 -0
- package/dist/retention/policy.js +197 -0
- package/dist/session/rotated.d.ts +34 -0
- package/dist/session/rotated.d.ts.map +1 -0
- package/dist/session/rotated.js +25 -0
- package/dist/test-utils/connectionFixtures.d.ts +13 -0
- package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
- package/dist/test-utils/connectionFixtures.js +58 -0
- package/dist/test-utils/interleave.d.ts +12 -0
- package/dist/test-utils/interleave.d.ts.map +1 -0
- package/dist/test-utils/interleave.js +26 -0
- package/dist/testWorker.d.ts +8 -0
- package/dist/testWorker.d.ts.map +1 -0
- package/dist/testWorker.js +9 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/dispatchRoute.js +1 -1
- package/package.json +1 -1
- package/src/audit/auditEvent.ts +1 -1
- package/src/audit/chain.ts +551 -0
- package/src/audit/citations.ts +63 -0
- package/src/capability/bindings.ts +61 -0
- package/src/capability/capability.ts +156 -19
- package/src/capability/erasure.ts +82 -0
- package/src/capability/personColumns.ts +83 -0
- package/src/capability/personRows.ts +125 -0
- package/src/capability/purge.ts +158 -21
- package/src/capability/settings.ts +7 -0
- package/src/capability/vectorIndex.ts +60 -0
- package/src/cli/commandCatalog.generated.ts +482 -2
- package/src/cli/commandFacts.ts +12 -3
- package/src/cloudflare-test.d.ts +3 -1
- package/src/consent/purpose.ts +160 -0
- package/src/consent/reader.ts +134 -0
- package/src/controlPlane/audit/actions.ts +15 -4
- package/src/controlPlane/capability.ts +99 -20
- package/src/controlPlane/context.ts +10 -2
- package/src/controlPlane/data/connection.ts +16 -2
- package/src/controlPlane/data/connections.ts +231 -0
- package/src/controlPlane/data/hold.ts +52 -0
- package/src/controlPlane/data/oidcTrust.ts +249 -0
- package/src/controlPlane/data/tables.ts +7 -0
- package/src/controlPlane/data/trustFirstCall.ts +37 -0
- package/src/controlPlane/discovery/adminRoute.ts +71 -9
- package/src/controlPlane/discovery/routeInput.ts +86 -0
- package/src/controlPlane/discovery/toolName.ts +23 -0
- package/src/controlPlane/erasure/attest.ts +205 -0
- package/src/controlPlane/erasure/read.ts +169 -0
- package/src/controlPlane/error/errors.ts +69 -2
- package/src/controlPlane/evidence/derive.ts +426 -0
- package/src/controlPlane/evidence/mapping.ts +118 -0
- package/src/controlPlane/evidence/pack.ts +190 -0
- package/src/controlPlane/http/connections.ts +129 -0
- package/src/controlPlane/http/guard.ts +118 -9
- package/src/controlPlane/http/handlers.ts +172 -40
- package/src/controlPlane/http/holds.ts +108 -0
- package/src/controlPlane/http/presence.ts +71 -0
- package/src/controlPlane/http/processing.ts +19 -0
- package/src/controlPlane/http/responses.ts +590 -7
- package/src/controlPlane/http/routes.ts +302 -3
- package/src/controlPlane/http/schemas.ts +126 -0
- package/src/controlPlane/http/verify.ts +19 -2
- package/src/controlPlane/http/verifyTrust.ts +176 -0
- package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
- package/src/controlPlane/migrations/0005_holds.ts +40 -0
- package/src/controlPlane/processing/derive.ts +359 -0
- package/src/controlPlane/processing/read.ts +102 -0
- package/src/controlPlane/scope/kind.ts +63 -0
- package/src/controlPlane/scope/scope.ts +65 -3
- package/src/controlPlane/wire.ts +26 -0
- package/src/createBackend.ts +31 -6
- package/src/createEntrypoint.ts +17 -0
- package/src/error/payload.ts +234 -0
- package/src/error/pithyError.ts +42 -0
- package/src/http/routeSpec.ts +75 -5
- package/src/http/verification.ts +3 -1
- package/src/i18n/translator.ts +2 -1
- package/src/index.ts +2 -0
- package/src/mcp/annotations.ts +38 -0
- package/src/mcp/tools.ts +220 -0
- package/src/openapi/document.ts +8 -28
- package/src/openapi/operations.ts +62 -0
- package/src/presence/channel.ts +46 -0
- package/src/presence/declaration.ts +283 -0
- package/src/presence/durableObject.ts +287 -0
- package/src/presence/frames.ts +153 -0
- package/src/presence/http/guard.ts +23 -0
- package/src/presence/http/responses.ts +63 -0
- package/src/presence/http/routes.ts +165 -0
- package/src/presence/http/schemas.ts +39 -0
- package/src/presence/names.ts +45 -0
- package/src/presence/room.ts +145 -0
- package/src/presence/ticket.ts +145 -0
- package/src/processing/activity.ts +289 -0
- package/src/processing/composed.ts +33 -0
- package/src/processing/vocabulary.ts +43 -0
- package/src/queue/consumer.ts +104 -0
- package/src/retention/composed.ts +75 -0
- package/src/retention/holds.ts +175 -0
- package/src/retention/holdsPurge.ts +50 -0
- package/src/retention/policy.ts +305 -0
- package/src/session/rotated.ts +53 -0
- package/src/test-utils/connectionFixtures.ts +58 -0
- package/src/test-utils/interleave.ts +31 -0
- package/src/testWorker.ts +13 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import type { BindingSpecInput } from "../capability/bindings";
|
|
5
|
+
import type { PithyHonoEnv } from "../capability/capability";
|
|
6
|
+
/**
|
|
7
|
+
* **Presence rooms: who else has a record open, live, from the adopter's own Worker.**
|
|
8
|
+
*
|
|
9
|
+
* A capability declares the room kinds it serves beside its admin routes, on {@link Capability.presence}: a
|
|
10
|
+
* name (`support.thread`), the control-plane scope that reads the record (`support:threads:read`), and the
|
|
11
|
+
* activities a participant may announce (`viewing`, `replying`). A room is one kind plus one record id, and
|
|
12
|
+
* it lives in one hibernating Durable Object, `PresenceRoom`, that holds nothing but who is in it.
|
|
13
|
+
*
|
|
14
|
+
* Joining takes a **ticket**, because a browser's `WebSocket` sets no headers and so cannot carry either the
|
|
15
|
+
* control-plane token or, cross-origin, a session cookie. A ticket is minted by one of two routes — the
|
|
16
|
+
* control-plane one behind the kind's scope, or the capability's own session one behind its own read
|
|
17
|
+
* permission — and presented once, in the subprotocol offer, within 60 seconds.
|
|
18
|
+
*
|
|
19
|
+
* **Presence adds no scope of its own.** Anyone who may read a record may see who else has it open, so the
|
|
20
|
+
* room kind names the scope that already reads the record and the session gate the capability already has.
|
|
21
|
+
*
|
|
22
|
+
* This module is pure: zod, types and constants. The routes are in `./http/routes.ts`, the Durable Object in
|
|
23
|
+
* `./durableObject.ts`, and nothing here imports either.
|
|
24
|
+
*/
|
|
25
|
+
/** The Durable Object binding every room lives behind. One namespace for every room kind in a Worker. */
|
|
26
|
+
export declare const PRESENCE_ROOMS_BINDING = "PRESENCE_ROOMS";
|
|
27
|
+
/** The class that backs {@link PRESENCE_ROOMS_BINDING}, and the module the Worker's entry re-exports it from. */
|
|
28
|
+
export declare const PRESENCE_ROOM_CLASS = "PresenceRoom";
|
|
29
|
+
/** Its own module, never a package entry point, so `pithy.config.ts` can load the capability in Node. */
|
|
30
|
+
export declare const PRESENCE_ROOM_MODULE = "@pithy-sh/core/src/presence/durableObject";
|
|
31
|
+
/**
|
|
32
|
+
* The binding a capability that declares room kinds requires. `defineCapability` adds it, so a capability
|
|
33
|
+
* never spells it; a kit capability's `pithy.manifest.json` names it exactly as written here, which is what
|
|
34
|
+
* `pithy add` and `pithy upgrade` wire through the Durable Object path (#428).
|
|
35
|
+
*/
|
|
36
|
+
export declare const PRESENCE_ROOMS_BINDING_SPEC: BindingSpecInput;
|
|
37
|
+
/**
|
|
38
|
+
* The secret tickets are signed with: one per Worker environment, generated, never typed. A capability that
|
|
39
|
+
* declares room kinds declares this name in its `@pithy-sh/secrets` registry with {@link
|
|
40
|
+
* PRESENCE_TICKET_KEY_ENTRY} and reads it through `secretsStore`, and its manifest lists it under
|
|
41
|
+
* `devSecrets` so `pithy add` mints the local value. The name is the join key, so every capability that
|
|
42
|
+
* declares rooms in one Worker signs and verifies with the one key.
|
|
43
|
+
*/
|
|
44
|
+
export declare const PRESENCE_TICKET_KEY_SECRET = "presence-ticket-key";
|
|
45
|
+
/**
|
|
46
|
+
* The registry entry for {@link PRESENCE_TICKET_KEY_SECRET}, in the shape `defineSecretRegistry` takes. Its
|
|
47
|
+
* value is arbitrary — nothing outside the Worker has to agree with it — so it is `devValue: "random"`, and
|
|
48
|
+
* rotatable because {@link PresenceTicketKey} accepts every version still honored.
|
|
49
|
+
*/
|
|
50
|
+
export declare const PRESENCE_TICKET_KEY_ENTRY: {
|
|
51
|
+
readonly backend: "d1";
|
|
52
|
+
readonly scope: "environment";
|
|
53
|
+
readonly rotatable: true;
|
|
54
|
+
readonly valueType: "text";
|
|
55
|
+
readonly devValue: "random";
|
|
56
|
+
};
|
|
57
|
+
/** How long a minted ticket is honored, in seconds. Long enough to open a socket, short enough to be useless later. */
|
|
58
|
+
export declare const PRESENCE_TICKET_TTL_SECONDS = 60;
|
|
59
|
+
/** The participant cap a room kind gets when it declares none. */
|
|
60
|
+
export declare const DEFAULT_PRESENCE_PARTICIPANTS = 25;
|
|
61
|
+
/** The most participants any room kind may declare. A room is one Durable Object, and every frame fans out to all of them. */
|
|
62
|
+
export declare const MAX_PRESENCE_PARTICIPANTS = 100;
|
|
63
|
+
/** The largest frame a room accepts from a client, in bytes. The one frame a client sends is an activity name. */
|
|
64
|
+
export declare const PRESENCE_MAX_FRAME_BYTES = 1024;
|
|
65
|
+
/** The most activities one room kind may declare. */
|
|
66
|
+
export declare const MAX_PRESENCE_ACTIVITIES = 16;
|
|
67
|
+
/** What the session gate grants: the participant's display name, or the user id when it names none. */
|
|
68
|
+
export interface PresenceSessionGrant {
|
|
69
|
+
/**
|
|
70
|
+
* How other participants see this user. Trimmed and cut to 80 characters; the user id when it is missing
|
|
71
|
+
* or blank, so a gate can pass a stored display name through as it is.
|
|
72
|
+
*/
|
|
73
|
+
readonly name?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The name a session participant joins under: the gate's, trimmed and cut to fit, or the user id when the
|
|
77
|
+
* gate names none or a blank one. Always a name a ticket accepts — a display name is the user's own data, and
|
|
78
|
+
* one too long or empty must not lock them out of every room.
|
|
79
|
+
*/
|
|
80
|
+
export declare function sessionParticipantName(name: string | undefined, userId: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* The capability's own permission for reading one record, for a signed-in user of the app. Answer a grant
|
|
83
|
+
* to admit, `null` to refuse. `c.var.auth` is already set when it runs; it is the capability's job to say
|
|
84
|
+
* whether that user may read `recordId`, exactly as its own read route would.
|
|
85
|
+
*/
|
|
86
|
+
export type PresenceSessionGate = (c: Context<PithyHonoEnv>, recordId: string) => PresenceSessionGrant | null | Promise<PresenceSessionGrant | null>;
|
|
87
|
+
/** One room kind a capability serves. */
|
|
88
|
+
export interface PresenceRoomKind {
|
|
89
|
+
/** The kind's name — `support.thread`. Its first segment must be the declaring capability's name. */
|
|
90
|
+
readonly kind: string;
|
|
91
|
+
/** The control-plane scope that reads the record. A management client joins with exactly this scope. */
|
|
92
|
+
readonly scope: string;
|
|
93
|
+
/** The activities a participant may announce. The first is what a participant starts as. */
|
|
94
|
+
readonly activities: readonly [string, ...string[]];
|
|
95
|
+
/** The participant cap. Defaults to {@link DEFAULT_PRESENCE_PARTICIPANTS}, at most {@link MAX_PRESENCE_PARTICIPANTS}. */
|
|
96
|
+
readonly maxParticipants?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Who of the app's own signed-in users may join: the capability's read permission for the record. Absent,
|
|
99
|
+
* the kind admits management clients only and the session ticket route refuses it.
|
|
100
|
+
*/
|
|
101
|
+
readonly session?: PresenceSessionGate;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The secret tickets are signed with: the value, every value still honored during a rotation (the first
|
|
105
|
+
* signs), or a resolver reading it from the Worker env per request — which is the only form a secret read
|
|
106
|
+
* through `@pithy-sh/secrets` can take, since `env` exists only per request.
|
|
107
|
+
*/
|
|
108
|
+
export type PresenceTicketKey = string | readonly string[] | ((env: Record<string, unknown>) => string | readonly string[] | Promise<string | readonly string[]>);
|
|
109
|
+
/** A capability's presence declaration — see {@link Capability.presence}. */
|
|
110
|
+
export interface PresenceDeclaration {
|
|
111
|
+
/**
|
|
112
|
+
* Where this capability mounts its presence routes: `{basePath}/presence/tickets` and
|
|
113
|
+
* `{basePath}/presence/rooms/:kind/:recordId`. The capability's own resolved `basePath`, never its default.
|
|
114
|
+
*/
|
|
115
|
+
readonly basePath: string;
|
|
116
|
+
/** The room kinds it serves. */
|
|
117
|
+
readonly rooms: readonly PresenceRoomKind[];
|
|
118
|
+
/** The ticket signing key — {@link PRESENCE_TICKET_KEY_SECRET}, read through the capability's own secrets reader. */
|
|
119
|
+
readonly ticketKey: PresenceTicketKey;
|
|
120
|
+
}
|
|
121
|
+
/** The session ticket route, under a capability's base path. */
|
|
122
|
+
export declare function presenceTicketPath(basePath: string): string;
|
|
123
|
+
/** The socket route, under a capability's base path, as Hono registers it. */
|
|
124
|
+
export declare function presenceSocketPath(basePath: string): string;
|
|
125
|
+
/** The socket path for one room, ready to open. */
|
|
126
|
+
export declare function presenceRoomPath(basePath: string, kind: string, recordId: string): string;
|
|
127
|
+
/** The participant cap a room kind runs with. */
|
|
128
|
+
export declare function participantCap(room: PresenceRoomKind): number;
|
|
129
|
+
/** One of the capability's own admin routes, as far as the scope rule reads it: its method and the scope it requires. */
|
|
130
|
+
export interface PresenceGatingRoute {
|
|
131
|
+
method: string;
|
|
132
|
+
scope: string | null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Refuse a declaration no room could be served from, where it is written and naming the capability: a kind
|
|
136
|
+
* outside the capability's own name, a duplicate, a scope that is not one, an activity that is not one, a
|
|
137
|
+
* cap out of range. A declaration that reached a request would be refused there, by a caller, in a way only
|
|
138
|
+
* the operator can fix.
|
|
139
|
+
*
|
|
140
|
+
* **A room kind's scope is one the capability's own admin routes already require, and only `GET`s require
|
|
141
|
+
* it** — compared against `adminRoutes`, the same artifact `capabilityHealthSources` polices health keys
|
|
142
|
+
* with, and for the same reason. `pithy dashboard connect` offers the scopes it reads off `adminRoutes`, so
|
|
143
|
+
* a scope no route requires — one of presence's own, a near-miss, another capability's — is never offered
|
|
144
|
+
* and never in the default grant, and the room is unjoinable from every management client with no error to
|
|
145
|
+
* say why. And a scope that also gates a write would make joining a room need a mutation grant, which
|
|
146
|
+
* inverts the read rule `grant.ts` applies.
|
|
147
|
+
*/
|
|
148
|
+
export declare function assertPresenceDeclaration(capability: string, presence: PresenceDeclaration, adminRoutes: readonly PresenceGatingRoute[]): void;
|
|
149
|
+
/**
|
|
150
|
+
* The ticket signing keys, resolved and non-empty — the first signs, every one verifies. A blank entry is
|
|
151
|
+
* dropped rather than tried, since WebCrypto refuses a zero-length key with an error that names nothing.
|
|
152
|
+
*/
|
|
153
|
+
export declare function resolveTicketKeys(key: PresenceTicketKey, env: Record<string, unknown>): Promise<string[]>;
|
|
154
|
+
//# sourceMappingURL=declaration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"declaration.d.ts","sourceRoot":"","sources":["../../src/presence/declaration.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { InternalError } from "../error/pithyError.js";
|
|
4
|
+
import { remedy } from "../cli/command.js";
|
|
5
|
+
import { ControlPlaneScope } from "../controlPlane/scope/scope.js";
|
|
6
|
+
import { PresenceActivity, PresenceRoomKindName } from "./names.js";
|
|
7
|
+
import "./frames.js";
|
|
8
|
+
//#region src/presence/declaration.ts
|
|
9
|
+
/**
|
|
10
|
+
* **Presence rooms: who else has a record open, live, from the adopter's own Worker.**
|
|
11
|
+
*
|
|
12
|
+
* A capability declares the room kinds it serves beside its admin routes, on {@link Capability.presence}: a
|
|
13
|
+
* name (`support.thread`), the control-plane scope that reads the record (`support:threads:read`), and the
|
|
14
|
+
* activities a participant may announce (`viewing`, `replying`). A room is one kind plus one record id, and
|
|
15
|
+
* it lives in one hibernating Durable Object, `PresenceRoom`, that holds nothing but who is in it.
|
|
16
|
+
*
|
|
17
|
+
* Joining takes a **ticket**, because a browser's `WebSocket` sets no headers and so cannot carry either the
|
|
18
|
+
* control-plane token or, cross-origin, a session cookie. A ticket is minted by one of two routes — the
|
|
19
|
+
* control-plane one behind the kind's scope, or the capability's own session one behind its own read
|
|
20
|
+
* permission — and presented once, in the subprotocol offer, within 60 seconds.
|
|
21
|
+
*
|
|
22
|
+
* **Presence adds no scope of its own.** Anyone who may read a record may see who else has it open, so the
|
|
23
|
+
* room kind names the scope that already reads the record and the session gate the capability already has.
|
|
24
|
+
*
|
|
25
|
+
* This module is pure: zod, types and constants. The routes are in `./http/routes.ts`, the Durable Object in
|
|
26
|
+
* `./durableObject.ts`, and nothing here imports either.
|
|
27
|
+
*/
|
|
28
|
+
/** The Durable Object binding every room lives behind. One namespace for every room kind in a Worker. */
|
|
29
|
+
const PRESENCE_ROOMS_BINDING = "PRESENCE_ROOMS";
|
|
30
|
+
/** The class that backs {@link PRESENCE_ROOMS_BINDING}, and the module the Worker's entry re-exports it from. */
|
|
31
|
+
const PRESENCE_ROOM_CLASS = "PresenceRoom";
|
|
32
|
+
/** Its own module, never a package entry point, so `pithy.config.ts` can load the capability in Node. */
|
|
33
|
+
const PRESENCE_ROOM_MODULE = "@pithy-sh/core/src/presence/durableObject";
|
|
34
|
+
/**
|
|
35
|
+
* The binding a capability that declares room kinds requires. `defineCapability` adds it, so a capability
|
|
36
|
+
* never spells it; a kit capability's `pithy.manifest.json` names it exactly as written here, which is what
|
|
37
|
+
* `pithy add` and `pithy upgrade` wire through the Durable Object path (#428).
|
|
38
|
+
*/
|
|
39
|
+
const PRESENCE_ROOMS_BINDING_SPEC = {
|
|
40
|
+
type: "durable_object",
|
|
41
|
+
name: PRESENCE_ROOMS_BINDING,
|
|
42
|
+
className: PRESENCE_ROOM_CLASS,
|
|
43
|
+
classModule: PRESENCE_ROOM_MODULE
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The secret tickets are signed with: one per Worker environment, generated, never typed. A capability that
|
|
47
|
+
* declares room kinds declares this name in its `@pithy-sh/secrets` registry with {@link
|
|
48
|
+
* PRESENCE_TICKET_KEY_ENTRY} and reads it through `secretsStore`, and its manifest lists it under
|
|
49
|
+
* `devSecrets` so `pithy add` mints the local value. The name is the join key, so every capability that
|
|
50
|
+
* declares rooms in one Worker signs and verifies with the one key.
|
|
51
|
+
*/
|
|
52
|
+
const PRESENCE_TICKET_KEY_SECRET = "presence-ticket-key";
|
|
53
|
+
/**
|
|
54
|
+
* The registry entry for {@link PRESENCE_TICKET_KEY_SECRET}, in the shape `defineSecretRegistry` takes. Its
|
|
55
|
+
* value is arbitrary — nothing outside the Worker has to agree with it — so it is `devValue: "random"`, and
|
|
56
|
+
* rotatable because {@link PresenceTicketKey} accepts every version still honored.
|
|
57
|
+
*/
|
|
58
|
+
const PRESENCE_TICKET_KEY_ENTRY = {
|
|
59
|
+
backend: "d1",
|
|
60
|
+
scope: "environment",
|
|
61
|
+
rotatable: true,
|
|
62
|
+
valueType: "text",
|
|
63
|
+
devValue: "random"
|
|
64
|
+
};
|
|
65
|
+
/** How long a minted ticket is honored, in seconds. Long enough to open a socket, short enough to be useless later. */
|
|
66
|
+
const PRESENCE_TICKET_TTL_SECONDS = 60;
|
|
67
|
+
/** The participant cap a room kind gets when it declares none. */
|
|
68
|
+
const DEFAULT_PRESENCE_PARTICIPANTS = 25;
|
|
69
|
+
/** The most participants any room kind may declare. A room is one Durable Object, and every frame fans out to all of them. */
|
|
70
|
+
const MAX_PRESENCE_PARTICIPANTS = 100;
|
|
71
|
+
/** The largest frame a room accepts from a client, in bytes. The one frame a client sends is an activity name. */
|
|
72
|
+
const PRESENCE_MAX_FRAME_BYTES = 1024;
|
|
73
|
+
/** The most activities one room kind may declare. */
|
|
74
|
+
const MAX_PRESENCE_ACTIVITIES = 16;
|
|
75
|
+
/**
|
|
76
|
+
* The name a session participant joins under: the gate's, trimmed and cut to fit, or the user id when the
|
|
77
|
+
* gate names none or a blank one. Always a name a ticket accepts — a display name is the user's own data, and
|
|
78
|
+
* one too long or empty must not lock them out of every room.
|
|
79
|
+
*/
|
|
80
|
+
function sessionParticipantName(name, userId) {
|
|
81
|
+
return fitName(name ?? "") || fitName(userId) || "Participant";
|
|
82
|
+
}
|
|
83
|
+
/** Trim, then cut to {@link PRESENCE_PARTICIPANT_NAME_MAX} code units without splitting a character. */
|
|
84
|
+
function fitName(raw) {
|
|
85
|
+
let fitted = "";
|
|
86
|
+
for (const char of raw.trim()) {
|
|
87
|
+
if (fitted.length + char.length > 80) break;
|
|
88
|
+
fitted += char;
|
|
89
|
+
}
|
|
90
|
+
return fitted.trim();
|
|
91
|
+
}
|
|
92
|
+
/** The session ticket route, under a capability's base path. */
|
|
93
|
+
function presenceTicketPath(basePath) {
|
|
94
|
+
return `${basePath}/presence/tickets`;
|
|
95
|
+
}
|
|
96
|
+
/** The socket route, under a capability's base path, as Hono registers it. */
|
|
97
|
+
function presenceSocketPath(basePath) {
|
|
98
|
+
return `${basePath}/presence/rooms/:kind/:recordId`;
|
|
99
|
+
}
|
|
100
|
+
/** The socket path for one room, ready to open. */
|
|
101
|
+
function presenceRoomPath(basePath, kind, recordId) {
|
|
102
|
+
return `${basePath}/presence/rooms/${encodeURIComponent(kind)}/${encodeURIComponent(recordId)}`;
|
|
103
|
+
}
|
|
104
|
+
/** The participant cap a room kind runs with. */
|
|
105
|
+
function participantCap(room) {
|
|
106
|
+
return room.maxParticipants ?? 25;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Refuse a declaration no room could be served from, where it is written and naming the capability: a kind
|
|
110
|
+
* outside the capability's own name, a duplicate, a scope that is not one, an activity that is not one, a
|
|
111
|
+
* cap out of range. A declaration that reached a request would be refused there, by a caller, in a way only
|
|
112
|
+
* the operator can fix.
|
|
113
|
+
*
|
|
114
|
+
* **A room kind's scope is one the capability's own admin routes already require, and only `GET`s require
|
|
115
|
+
* it** — compared against `adminRoutes`, the same artifact `capabilityHealthSources` polices health keys
|
|
116
|
+
* with, and for the same reason. `pithy dashboard connect` offers the scopes it reads off `adminRoutes`, so
|
|
117
|
+
* a scope no route requires — one of presence's own, a near-miss, another capability's — is never offered
|
|
118
|
+
* and never in the default grant, and the room is unjoinable from every management client with no error to
|
|
119
|
+
* say why. And a scope that also gates a write would make joining a room need a mutation grant, which
|
|
120
|
+
* inverts the read rule `grant.ts` applies.
|
|
121
|
+
*/
|
|
122
|
+
function assertPresenceDeclaration(capability, presence, adminRoutes) {
|
|
123
|
+
const refuse = (detail) => {
|
|
124
|
+
throw new InternalError({
|
|
125
|
+
message: `${capability} declares presence rooms this Worker cannot serve.`,
|
|
126
|
+
action: `Fix the presence declaration on the ${capability} capability.`,
|
|
127
|
+
detail
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
if (!presence.basePath.startsWith("/") || presence.basePath.endsWith("/")) refuse(`presence basePath "${presence.basePath}" must start with "/" and not end with one`);
|
|
131
|
+
const seen = /* @__PURE__ */ new Set();
|
|
132
|
+
for (const room of presence.rooms) {
|
|
133
|
+
if (!PresenceRoomKindName.safeParse(room.kind).success) refuse(`room kind "${room.kind}" is not a kind name`);
|
|
134
|
+
if (!room.kind.startsWith(`${capability}.`)) refuse(`room kind "${room.kind}" must start with "${capability}." — a capability declares kinds under its own name`);
|
|
135
|
+
if (seen.has(room.kind)) refuse(`room kind "${room.kind}" is declared twice`);
|
|
136
|
+
seen.add(room.kind);
|
|
137
|
+
if (!ControlPlaneScope.safeParse(room.scope).success) refuse(`room kind "${room.kind}" scope "${room.scope}" is not a scope`);
|
|
138
|
+
const gating = adminRoutes.filter((route) => route.scope === room.scope);
|
|
139
|
+
if (gating.length === 0) refuse(`room kind "${room.kind}" scope "${room.scope}": no admin route of ${capability} requires it, so no grant would ever offer it`);
|
|
140
|
+
const write = gating.find((route) => route.method !== "GET");
|
|
141
|
+
if (write) refuse(`room kind "${room.kind}" scope "${room.scope}" gates a ${write.method}; a room joins with a read scope, one only GET routes require`);
|
|
142
|
+
if (room.activities.length === 0 || room.activities.length > 16) refuse(`room kind "${room.kind}" declares ${room.activities.length} activities; it needs 1 to 16`);
|
|
143
|
+
if (new Set(room.activities).size !== room.activities.length) refuse(`room kind "${room.kind}" declares an activity twice`);
|
|
144
|
+
for (const activity of room.activities) if (!PresenceActivity.safeParse(activity).success) refuse(`room kind "${room.kind}" activity "${activity}" is not one word`);
|
|
145
|
+
const cap = participantCap(room);
|
|
146
|
+
if (!Number.isInteger(cap) || cap < 1 || cap > 100) refuse(`room kind "${room.kind}" maxParticipants ${cap} is outside 1 to 100`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The ticket signing keys, resolved and non-empty — the first signs, every one verifies. A blank entry is
|
|
151
|
+
* dropped rather than tried, since WebCrypto refuses a zero-length key with an error that names nothing.
|
|
152
|
+
*/
|
|
153
|
+
async function resolveTicketKeys(key, env) {
|
|
154
|
+
const resolved = typeof key === "function" ? await key(env) : key;
|
|
155
|
+
const keys = (typeof resolved === "string" ? [resolved] : [...resolved]).filter((value) => value.length > 0);
|
|
156
|
+
if (keys.length === 0) throw new InternalError({
|
|
157
|
+
message: "Presence is not available right now.",
|
|
158
|
+
action: `Set ${PRESENCE_TICKET_KEY_SECRET}. ${remedy("secrets provision", {}, { runtime: true }).say((command) => `${command} mints it for each environment.`)}`,
|
|
159
|
+
detail: `${PRESENCE_TICKET_KEY_SECRET} resolved to no usable value`
|
|
160
|
+
});
|
|
161
|
+
return keys;
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
export { DEFAULT_PRESENCE_PARTICIPANTS, MAX_PRESENCE_ACTIVITIES, MAX_PRESENCE_PARTICIPANTS, PRESENCE_MAX_FRAME_BYTES, PRESENCE_ROOMS_BINDING, PRESENCE_ROOMS_BINDING_SPEC, PRESENCE_ROOM_CLASS, PRESENCE_ROOM_MODULE, PRESENCE_TICKET_KEY_ENTRY, PRESENCE_TICKET_KEY_SECRET, PRESENCE_TICKET_TTL_SECONDS, assertPresenceDeclaration, participantCap, presenceRoomPath, presenceSocketPath, presenceTicketPath, resolveTicketKeys, sessionParticipantName };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { DurableObject } from "cloudflare:workers";
|
|
4
|
+
/**
|
|
5
|
+
* **One presence room: who has one record open, and what they are doing.** Addressed by `<kind>:<recordId>`,
|
|
6
|
+
* so each record has its own object.
|
|
7
|
+
*
|
|
8
|
+
* The Hibernation API only: `ctx.acceptWebSocket`, never `ws.accept()`, and no registry of sockets in
|
|
9
|
+
* memory — the participants are read back from `getWebSockets()`, each from its own attachment, so a room
|
|
10
|
+
* that hibernates and wakes knows exactly who is in it. Nothing is written to Durable Object storage: the
|
|
11
|
+
* room holds nothing but who is here, and when the last socket closes it is gone.
|
|
12
|
+
*
|
|
13
|
+
* The upgrade is forwarded by the Worker's socket route, which has verified the ticket and states the
|
|
14
|
+
* admission on {@link PRESENCE_ADMISSION_HEADER}. The room trusts that header — a Durable Object is reached
|
|
15
|
+
* only through its binding — and decides the two things only it can: whether the ticket is already spent, and
|
|
16
|
+
* whether the room is full.
|
|
17
|
+
*/
|
|
18
|
+
export declare class PresenceRoom extends DurableObject<Record<string, unknown>> {
|
|
19
|
+
/**
|
|
20
|
+
* Tickets redeemed by sockets that closed while no other socket was left to carry them. Memory, not
|
|
21
|
+
* storage, and only while the room is empty: the next socket to join takes them into its attachment, and
|
|
22
|
+
* until one does, {@link awake} keeps the room from hibernating and losing them.
|
|
23
|
+
*/
|
|
24
|
+
private remembered;
|
|
25
|
+
/**
|
|
26
|
+
* The timer that holds an empty room awake until the last remembered ticket would expire. A Durable Object
|
|
27
|
+
* with no socket hibernates after about ten seconds idle and loses its memory, which would let a spent
|
|
28
|
+
* ticket in again; a pending timer is one of the things that stops it. With no timer, an idle object that
|
|
29
|
+
* cannot hibernate is evicted only after 70 seconds or more — past a 60-second ticket's life. A deploy or a
|
|
30
|
+
* restart still clears it, and drops every socket with it.
|
|
31
|
+
*/
|
|
32
|
+
private awake;
|
|
33
|
+
fetch(request: Request): Promise<Response>;
|
|
34
|
+
/**
|
|
35
|
+
* One frame from a participant: `ping`, or `set_activity` naming an activity the room kind declares. A frame
|
|
36
|
+
* past the size limit, one that does not parse, and an undeclared activity are each answered with an error
|
|
37
|
+
* frame and change nothing.
|
|
38
|
+
*/
|
|
39
|
+
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
|
|
40
|
+
webSocketClose(ws: WebSocket): Promise<void>;
|
|
41
|
+
webSocketError(ws: WebSocket): Promise<void>;
|
|
42
|
+
/** A socket leaves: everyone else hears it, and the ticket it spent stays spent. */
|
|
43
|
+
private leave;
|
|
44
|
+
/**
|
|
45
|
+
* Hold the room awake until the last remembered ticket would expire, or let it sleep when there is none.
|
|
46
|
+
* The timer wakes once that has passed and lets go.
|
|
47
|
+
*/
|
|
48
|
+
private stayAwake;
|
|
49
|
+
/** Send a frame to every participant but one. One dead socket never blocks the rest. */
|
|
50
|
+
private broadcast;
|
|
51
|
+
/** The open sockets the runtime holds for this room. */
|
|
52
|
+
private sockets;
|
|
53
|
+
/** The attachments of sockets the runtime still holds but that are no longer open. */
|
|
54
|
+
private leavingAttachments;
|
|
55
|
+
/** Every participant's attachment, read back from the runtime. */
|
|
56
|
+
private attachments;
|
|
57
|
+
private attachmentOf;
|
|
58
|
+
/** The admission the Worker forwarded. Missing or malformed is a request that did not come through the route. */
|
|
59
|
+
private admission;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=durableObject.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"durableObject.d.ts","sourceRoot":"","sources":["../../src/presence/durableObject.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AA8BnD;;;;;;;;;;;;;GAaG;AACH,qBAAa,YAAa,SAAQ,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtE;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAAqB;IAEvC;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,CAAwE;IAEtE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CA0DxD;IAED;;;;OAIG;IACY,gBAAgB,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CA2C3F;IAEc,cAAc,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1D;IAEc,cAAc,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAE1D;IAED,oFAAoF;IACpF,OAAO,CAAC,KAAK;IAyBb;;;OAGG;IACH,OAAO,CAAC,SAAS;IAiBjB,wFAAwF;IACxF,OAAO,CAAC,SAAS;IAYjB,wDAAwD;IACxD,OAAO,CAAC,OAAO;IAIf,sFAAsF;IACtF,OAAO,CAAC,kBAAkB;IAQ1B,kEAAkE;IAClE,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,YAAY;IAKpB,iHAAiH;IACjH,OAAO,CAAC,SAAS;CAYlB"}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { PithyError, PresenceRoomFullError, PresenceTicketInvalidError, ValidationError, fromZodError } from "../error/pithyError.js";
|
|
4
|
+
import { PRESENCE_ROOM_VERSIONS, PresenceRoomClientFrame, PresenceRoomPing, PresenceRoomPong } from "./frames.js";
|
|
5
|
+
import { PRESENCE_MAX_FRAME_BYTES } from "./declaration.js";
|
|
6
|
+
import { SUBPROTOCOL_HEADER, negotiateSubprotocol } from "../http/subprotocol.js";
|
|
7
|
+
import { PRESENCE_ADMISSION_HEADER, PresenceAdmission, PresenceAttachment, admit, carrySpent, declaresActivity, inheritSpent } from "./room.js";
|
|
8
|
+
import { clientError } from "../error/client.js";
|
|
9
|
+
import { DurableObject } from "cloudflare:workers";
|
|
10
|
+
//#region src/presence/durableObject.ts
|
|
11
|
+
/**
|
|
12
|
+
* **One presence room: who has one record open, and what they are doing.** Addressed by `<kind>:<recordId>`,
|
|
13
|
+
* so each record has its own object.
|
|
14
|
+
*
|
|
15
|
+
* The Hibernation API only: `ctx.acceptWebSocket`, never `ws.accept()`, and no registry of sockets in
|
|
16
|
+
* memory — the participants are read back from `getWebSockets()`, each from its own attachment, so a room
|
|
17
|
+
* that hibernates and wakes knows exactly who is in it. Nothing is written to Durable Object storage: the
|
|
18
|
+
* room holds nothing but who is here, and when the last socket closes it is gone.
|
|
19
|
+
*
|
|
20
|
+
* The upgrade is forwarded by the Worker's socket route, which has verified the ticket and states the
|
|
21
|
+
* admission on {@link PRESENCE_ADMISSION_HEADER}. The room trusts that header — a Durable Object is reached
|
|
22
|
+
* only through its binding — and decides the two things only it can: whether the ticket is already spent, and
|
|
23
|
+
* whether the room is full.
|
|
24
|
+
*/
|
|
25
|
+
var PresenceRoom = class extends DurableObject {
|
|
26
|
+
/**
|
|
27
|
+
* Tickets redeemed by sockets that closed while no other socket was left to carry them. Memory, not
|
|
28
|
+
* storage, and only while the room is empty: the next socket to join takes them into its attachment, and
|
|
29
|
+
* until one does, {@link awake} keeps the room from hibernating and losing them.
|
|
30
|
+
*/
|
|
31
|
+
remembered = [];
|
|
32
|
+
/**
|
|
33
|
+
* The timer that holds an empty room awake until the last remembered ticket would expire. A Durable Object
|
|
34
|
+
* with no socket hibernates after about ten seconds idle and loses its memory, which would let a spent
|
|
35
|
+
* ticket in again; a pending timer is one of the things that stops it. With no timer, an idle object that
|
|
36
|
+
* cannot hibernate is evicted only after 70 seconds or more — past a 60-second ticket's life. A deploy or a
|
|
37
|
+
* restart still clears it, and drops every socket with it.
|
|
38
|
+
*/
|
|
39
|
+
awake = null;
|
|
40
|
+
async fetch(request) {
|
|
41
|
+
if (request.headers.get("upgrade") !== "websocket") return new Response("Expected a WebSocket upgrade.", { status: 426 });
|
|
42
|
+
try {
|
|
43
|
+
const admission = this.admission(request);
|
|
44
|
+
const version = negotiateSubprotocol(request.headers.get(SUBPROTOCOL_HEADER), PRESENCE_ROOM_VERSIONS);
|
|
45
|
+
if (version === null) throw new ValidationError({ message: "Offer pithy.presence.v1." });
|
|
46
|
+
const now = /* @__PURE__ */ new Date();
|
|
47
|
+
const nowSeconds = Math.floor(now.getTime() / 1e3);
|
|
48
|
+
const present = this.attachments();
|
|
49
|
+
const leaving = this.leavingAttachments().flatMap((attachment) => [attachment.ticket, ...attachment.spent]);
|
|
50
|
+
const refusal = admit(admission, present, [...this.remembered, ...leaving], nowSeconds);
|
|
51
|
+
if (refusal === "spent") throw new PresenceTicketInvalidError({ detail: `ticket ${admission.ticket.id} is spent` });
|
|
52
|
+
if (refusal === "full") throw new PresenceRoomFullError({ detail: `${admission.kind}/${admission.recordId} at ${admission.maxParticipants}` });
|
|
53
|
+
const participant = {
|
|
54
|
+
id: crypto.randomUUID(),
|
|
55
|
+
ref: admission.ref,
|
|
56
|
+
name: admission.name,
|
|
57
|
+
origin: admission.origin,
|
|
58
|
+
activity: admission.activities[0],
|
|
59
|
+
joinedAt: now.toISOString()
|
|
60
|
+
};
|
|
61
|
+
const pair = new WebSocketPair();
|
|
62
|
+
const [client, server] = [pair[0], pair[1]];
|
|
63
|
+
this.ctx.acceptWebSocket(server);
|
|
64
|
+
server.serializeAttachment({
|
|
65
|
+
participant,
|
|
66
|
+
version,
|
|
67
|
+
ticket: admission.ticket,
|
|
68
|
+
activities: admission.activities,
|
|
69
|
+
spent: inheritSpent(this.remembered, admission.ticket.id, nowSeconds)
|
|
70
|
+
});
|
|
71
|
+
this.remembered = [];
|
|
72
|
+
this.stayAwake(nowSeconds);
|
|
73
|
+
const everyone = [...present.map((attachment) => attachment.participant), participant].sort((a, b) => a.joinedAt.localeCompare(b.joinedAt));
|
|
74
|
+
server.send(render({
|
|
75
|
+
type: "init",
|
|
76
|
+
self: participant.id,
|
|
77
|
+
participants: everyone
|
|
78
|
+
}));
|
|
79
|
+
this.broadcast({
|
|
80
|
+
type: "joined",
|
|
81
|
+
participant
|
|
82
|
+
}, server);
|
|
83
|
+
return new Response(null, {
|
|
84
|
+
status: 101,
|
|
85
|
+
webSocket: client,
|
|
86
|
+
headers: { [SUBPROTOCOL_HEADER]: version }
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!(error instanceof PithyError)) throw error;
|
|
90
|
+
return Response.json({ error: clientError(error.payload) }, { status: error.payload.status });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One frame from a participant: `ping`, or `set_activity` naming an activity the room kind declares. A frame
|
|
95
|
+
* past the size limit, one that does not parse, and an undeclared activity are each answered with an error
|
|
96
|
+
* frame and change nothing.
|
|
97
|
+
*/
|
|
98
|
+
async webSocketMessage(ws, message) {
|
|
99
|
+
const attachment = this.attachmentOf(ws);
|
|
100
|
+
if (!attachment) return;
|
|
101
|
+
const bytes = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
102
|
+
try {
|
|
103
|
+
if (bytes > 1024) throw new ValidationError({
|
|
104
|
+
message: `A frame is at most ${PRESENCE_MAX_FRAME_BYTES} bytes.`,
|
|
105
|
+
detail: `${bytes} bytes`
|
|
106
|
+
});
|
|
107
|
+
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
108
|
+
if (PresenceRoomPing.safeParse(text).success) {
|
|
109
|
+
ws.send(PresenceRoomPong.value);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let json;
|
|
113
|
+
try {
|
|
114
|
+
json = JSON.parse(text);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new ValidationError({ message: "This socket accepts `ping` and JSON frames." });
|
|
117
|
+
}
|
|
118
|
+
const parsed = PresenceRoomClientFrame.safeParse(json);
|
|
119
|
+
if (!parsed.success) throw fromZodError(parsed.error, { message: "This socket accepts `set_activity`." });
|
|
120
|
+
const frame = parsed.data;
|
|
121
|
+
if (!declaresActivity(attachment, frame.activity)) throw new ValidationError({
|
|
122
|
+
message: `This room's activities are ${attachment.activities.join(", ")}.`,
|
|
123
|
+
params: { activity: frame.activity }
|
|
124
|
+
});
|
|
125
|
+
if (frame.activity === attachment.participant.activity) return;
|
|
126
|
+
const participant = {
|
|
127
|
+
...attachment.participant,
|
|
128
|
+
activity: frame.activity
|
|
129
|
+
};
|
|
130
|
+
ws.serializeAttachment({
|
|
131
|
+
...attachment,
|
|
132
|
+
participant
|
|
133
|
+
});
|
|
134
|
+
this.broadcast({
|
|
135
|
+
type: "activity",
|
|
136
|
+
participantId: participant.id,
|
|
137
|
+
activity: frame.activity
|
|
138
|
+
}, ws);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof PithyError)) throw error;
|
|
141
|
+
try {
|
|
142
|
+
ws.send(render({
|
|
143
|
+
type: "error",
|
|
144
|
+
error: clientError(error.payload)
|
|
145
|
+
}));
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async webSocketClose(ws) {
|
|
150
|
+
this.leave(ws);
|
|
151
|
+
}
|
|
152
|
+
async webSocketError(ws) {
|
|
153
|
+
this.leave(ws);
|
|
154
|
+
}
|
|
155
|
+
/** A socket leaves: everyone else hears it, and the ticket it spent stays spent. */
|
|
156
|
+
leave(ws) {
|
|
157
|
+
const closing = this.attachmentOf(ws);
|
|
158
|
+
if (closing) {
|
|
159
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
160
|
+
const [carrier] = this.sockets().filter((other) => other !== ws);
|
|
161
|
+
const carried = carrier ? this.attachmentOf(carrier) : void 0;
|
|
162
|
+
if (carrier && carried) {
|
|
163
|
+
const spent = carrySpent(carried, closing, nowSeconds);
|
|
164
|
+
if (spent) carrier.serializeAttachment({
|
|
165
|
+
...carried,
|
|
166
|
+
spent
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
this.remembered = [
|
|
170
|
+
...this.remembered,
|
|
171
|
+
closing.ticket,
|
|
172
|
+
...closing.spent
|
|
173
|
+
];
|
|
174
|
+
this.stayAwake(nowSeconds);
|
|
175
|
+
}
|
|
176
|
+
ws.serializeAttachment(null);
|
|
177
|
+
this.broadcast({
|
|
178
|
+
type: "left",
|
|
179
|
+
participantId: closing.participant.id
|
|
180
|
+
}, ws);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
ws.close();
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Hold the room awake until the last remembered ticket would expire, or let it sleep when there is none.
|
|
188
|
+
* The timer wakes once that has passed and lets go.
|
|
189
|
+
*/
|
|
190
|
+
stayAwake(nowSeconds) {
|
|
191
|
+
this.remembered = this.remembered.filter((ticket) => ticket.exp > nowSeconds);
|
|
192
|
+
const until = Math.max(0, ...this.remembered.map((ticket) => ticket.exp));
|
|
193
|
+
if (this.awake && this.remembered.length > 0 && this.awake.until >= until) return;
|
|
194
|
+
if (this.awake) clearTimeout(this.awake.timer);
|
|
195
|
+
this.awake = null;
|
|
196
|
+
if (this.remembered.length === 0) return;
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
this.awake = null;
|
|
199
|
+
this.stayAwake(Math.floor(Date.now() / 1e3));
|
|
200
|
+
}, (until - nowSeconds + 1) * 1e3);
|
|
201
|
+
this.awake = {
|
|
202
|
+
until,
|
|
203
|
+
timer
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Send a frame to every participant but one. One dead socket never blocks the rest. */
|
|
207
|
+
broadcast(frame, except) {
|
|
208
|
+
const text = render(frame);
|
|
209
|
+
for (const ws of this.sockets()) {
|
|
210
|
+
if (ws === except || !this.attachmentOf(ws)) continue;
|
|
211
|
+
try {
|
|
212
|
+
ws.send(text);
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** The open sockets the runtime holds for this room. */
|
|
217
|
+
sockets() {
|
|
218
|
+
return this.ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN);
|
|
219
|
+
}
|
|
220
|
+
/** The attachments of sockets the runtime still holds but that are no longer open. */
|
|
221
|
+
leavingAttachments() {
|
|
222
|
+
return this.ctx.getWebSockets().flatMap((ws) => {
|
|
223
|
+
if (ws.readyState === WebSocket.OPEN) return [];
|
|
224
|
+
const attachment = this.attachmentOf(ws);
|
|
225
|
+
return attachment ? [attachment] : [];
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Every participant's attachment, read back from the runtime. */
|
|
229
|
+
attachments() {
|
|
230
|
+
return this.sockets().flatMap((ws) => {
|
|
231
|
+
const attachment = this.attachmentOf(ws);
|
|
232
|
+
return attachment ? [attachment] : [];
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
attachmentOf(ws) {
|
|
236
|
+
const parsed = PresenceAttachment.safeParse(ws.deserializeAttachment());
|
|
237
|
+
return parsed.success ? parsed.data : void 0;
|
|
238
|
+
}
|
|
239
|
+
/** The admission the Worker forwarded. Missing or malformed is a request that did not come through the route. */
|
|
240
|
+
admission(request) {
|
|
241
|
+
const raw = request.headers.get(PRESENCE_ADMISSION_HEADER);
|
|
242
|
+
let json;
|
|
243
|
+
try {
|
|
244
|
+
json = raw === null ? null : JSON.parse(raw);
|
|
245
|
+
} catch {
|
|
246
|
+
json = null;
|
|
247
|
+
}
|
|
248
|
+
const parsed = PresenceAdmission.safeParse(json);
|
|
249
|
+
if (!parsed.success) throw new PresenceTicketInvalidError({ detail: "no admission on the forwarded upgrade" });
|
|
250
|
+
return parsed.data;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function render(frame) {
|
|
254
|
+
return JSON.stringify(frame);
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
export { PresenceRoom };
|