@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,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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { base64UrlDecode, base64UrlEncode } from "../controlPlane/token/base64url";
|
|
6
|
+
import { PresenceTicketInvalidError } from "../error/pithyError";
|
|
7
|
+
import { PRESENCE_TICKET_TTL_SECONDS } from "./declaration";
|
|
8
|
+
import { PresenceOrigin, PresenceParticipantName, PresenceParticipantRef } from "./frames";
|
|
9
|
+
import { PresenceRecordId, PresenceRoomKindName } from "./names";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* **A presence ticket: single use, 60 seconds, one room, one participant.**
|
|
13
|
+
*
|
|
14
|
+
* `<claims>.<signature>`, both unpadded base64url, the signature an HMAC-SHA256 over the claims segment with
|
|
15
|
+
* the Worker's `presence-ticket-key` secret. The alphabet and the dot are all token characters, so a
|
|
16
|
+
* ticket rides in `Sec-WebSocket-Protocol` as `pithy.ticket.<ticket>` untouched.
|
|
17
|
+
*
|
|
18
|
+
* What a ticket proves is that **this Worker** minted it, behind a gate, for these claims. Verifying it
|
|
19
|
+
* here checks the signature, the expiry and the room; spending it is the room's job, because only the room
|
|
20
|
+
* the ticket names can redeem it, which is what makes "single use" decidable without a write anywhere.
|
|
21
|
+
*
|
|
22
|
+
* Every failure is the one {@link PresenceTicketInvalidError}, with the step in `detail` for the log: a caller
|
|
23
|
+
* holding someone else's ticket learns that it does not work, never why.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The subprotocol prefix a ticket travels under, beside `pithy.presence.v1`. */
|
|
27
|
+
export const PRESENCE_TICKET_PROTOCOL_PREFIX = "pithy.ticket.";
|
|
28
|
+
|
|
29
|
+
/** What a ticket says, signed. */
|
|
30
|
+
export const PresenceTicketClaims = z
|
|
31
|
+
.object({
|
|
32
|
+
v: z.literal(1).describe("The ticket format. A different one is refused."),
|
|
33
|
+
id: z.string().uuid().describe("The ticket's own id — what the room records as spent."),
|
|
34
|
+
kind: PresenceRoomKindName.describe("The room kind the ticket admits to."),
|
|
35
|
+
recordId: PresenceRecordId.describe("The record the room is about."),
|
|
36
|
+
ref: PresenceParticipantRef.describe(
|
|
37
|
+
"Who the participant is: the client's opaque ref, or the signed-in user's id.",
|
|
38
|
+
),
|
|
39
|
+
name: PresenceParticipantName.describe("How other participants see them."),
|
|
40
|
+
origin: PresenceOrigin.describe("Which door the ticket was minted at."),
|
|
41
|
+
exp: z.number().int().positive().describe("When the ticket stops being honored, in seconds since the epoch."),
|
|
42
|
+
})
|
|
43
|
+
.describe("The claims a presence ticket carries, signed by the Worker that minted it.");
|
|
44
|
+
export type PresenceTicketClaims = z.infer<typeof PresenceTicketClaims>;
|
|
45
|
+
|
|
46
|
+
/** What a caller asks a ticket for: everything but the id and the expiry, which the minter decides. */
|
|
47
|
+
export type PresenceTicketRequest = Omit<PresenceTicketClaims, "v" | "id" | "exp">;
|
|
48
|
+
|
|
49
|
+
const encoder = new TextEncoder();
|
|
50
|
+
|
|
51
|
+
async function hmacKey(secret: string): Promise<CryptoKey> {
|
|
52
|
+
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
53
|
+
"sign",
|
|
54
|
+
"verify",
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mint a ticket. `keys[0]` signs. */
|
|
59
|
+
export async function mintPresenceTicket(
|
|
60
|
+
request: PresenceTicketRequest,
|
|
61
|
+
keys: readonly string[],
|
|
62
|
+
now: Date = new Date(),
|
|
63
|
+
): Promise<{ ticket: string; claims: PresenceTicketClaims }> {
|
|
64
|
+
const [signing] = keys;
|
|
65
|
+
if (signing === undefined) throw new PresenceTicketInvalidError({ detail: "no signing key" });
|
|
66
|
+
const claims = PresenceTicketClaims.parse({
|
|
67
|
+
v: 1,
|
|
68
|
+
id: crypto.randomUUID(),
|
|
69
|
+
...request,
|
|
70
|
+
exp: Math.floor(now.getTime() / 1000) + PRESENCE_TICKET_TTL_SECONDS,
|
|
71
|
+
});
|
|
72
|
+
const body = base64UrlEncode(encoder.encode(JSON.stringify(claims)));
|
|
73
|
+
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", await hmacKey(signing), encoder.encode(body)));
|
|
74
|
+
return { ticket: `${body}.${base64UrlEncode(signature)}`, claims };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Decode a segment, mapping the control-plane decoder's refusal onto this one. */
|
|
78
|
+
function decode(segment: string, what: string): Uint8Array {
|
|
79
|
+
try {
|
|
80
|
+
return base64UrlDecode(segment);
|
|
81
|
+
} catch (cause) {
|
|
82
|
+
throw new PresenceTicketInvalidError({ detail: `${what} is not base64url` }, { cause });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Verify a ticket for one room: the signature under any honored key, the expiry, and that it names this
|
|
88
|
+
* kind and this record. Returns the claims; spending them is the room's.
|
|
89
|
+
*/
|
|
90
|
+
export async function verifyPresenceTicket(
|
|
91
|
+
ticket: string,
|
|
92
|
+
room: { kind: string; recordId: string },
|
|
93
|
+
keys: readonly string[],
|
|
94
|
+
now: Date = new Date(),
|
|
95
|
+
): Promise<PresenceTicketClaims> {
|
|
96
|
+
const parts = ticket.split(".");
|
|
97
|
+
const [body, signature] = parts;
|
|
98
|
+
if (parts.length !== 2 || !body || !signature) throw new PresenceTicketInvalidError({ detail: "not two segments" });
|
|
99
|
+
const signed = decode(signature, "signature");
|
|
100
|
+
let authentic = false;
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
if (await crypto.subtle.verify("HMAC", await hmacKey(key), signed, encoder.encode(body))) {
|
|
103
|
+
authentic = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!authentic) throw new PresenceTicketInvalidError({ detail: "signature does not verify under any honored key" });
|
|
108
|
+
|
|
109
|
+
let json: unknown;
|
|
110
|
+
try {
|
|
111
|
+
json = JSON.parse(new TextDecoder().decode(decode(body, "claims")));
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
if (cause instanceof PresenceTicketInvalidError) throw cause;
|
|
114
|
+
throw new PresenceTicketInvalidError({ detail: "claims are not JSON" }, { cause });
|
|
115
|
+
}
|
|
116
|
+
const parsed = PresenceTicketClaims.safeParse(json);
|
|
117
|
+
if (!parsed.success) throw new PresenceTicketInvalidError({ detail: "claims do not parse" });
|
|
118
|
+
const claims = parsed.data;
|
|
119
|
+
|
|
120
|
+
const seconds = Math.floor(now.getTime() / 1000);
|
|
121
|
+
if (claims.exp <= seconds) throw new PresenceTicketInvalidError({ detail: `expired at ${claims.exp}` });
|
|
122
|
+
// A ticket living past its stated life was not minted here, whatever signed it.
|
|
123
|
+
if (claims.exp > seconds + PRESENCE_TICKET_TTL_SECONDS) {
|
|
124
|
+
throw new PresenceTicketInvalidError({ detail: `expiry ${claims.exp} is beyond the ticket lifetime` });
|
|
125
|
+
}
|
|
126
|
+
if (claims.kind !== room.kind || claims.recordId !== room.recordId) {
|
|
127
|
+
throw new PresenceTicketInvalidError({
|
|
128
|
+
detail: `minted for ${claims.kind}/${claims.recordId}, presented at ${room.kind}/${room.recordId}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return claims;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The ticket from a subprotocol offer, or `undefined` when none rides in it. More than one is refused as
|
|
136
|
+
* none: an offer is one client's, and two tickets in it is not something a client means.
|
|
137
|
+
*/
|
|
138
|
+
export function ticketFromOffer(offered: string | null | undefined): string | undefined {
|
|
139
|
+
const tickets = (offered ?? "")
|
|
140
|
+
.split(",")
|
|
141
|
+
.map((protocol) => protocol.trim())
|
|
142
|
+
.filter((protocol) => protocol.startsWith(PRESENCE_TICKET_PROTOCOL_PREFIX))
|
|
143
|
+
.map((protocol) => protocol.slice(PRESENCE_TICKET_PROTOCOL_PREFIX.length));
|
|
144
|
+
return tickets.length === 1 ? tickets[0] : undefined;
|
|
145
|
+
}
|