@pithy-sh/core 0.13.0 → 0.15.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 +438 -3
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +454 -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 +487 -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
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { AdminOperation } from "../controlPlane/discovery/adminRoute";
|
|
5
|
+
import { inputOf } from "../controlPlane/discovery/routeInput";
|
|
6
|
+
import { controlPlaneToolName } from "../controlPlane/discovery/toolName";
|
|
7
|
+
import { ValidationError } from "../error/pithyError";
|
|
8
|
+
import { type RouteSpec, routeSpecOf, type ToolEffect } from "../http/routeSpec";
|
|
9
|
+
import type { RouteGroup } from "../openapi/document";
|
|
10
|
+
import { isMiddlewareOnly, operationsOf } from "../openapi/operations";
|
|
11
|
+
import { KIND_SENTENCES, kindAnnotations, type ToolAnnotations } from "./annotations";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The app's own MCP tools, read off the composed router (`docs/AGENT-ACCESS.md` §4.1, §5).
|
|
15
|
+
*
|
|
16
|
+
* **The route declarations are already a tool list.** A route that opts in with `describeRoute({ tool })` carries
|
|
17
|
+
* everything a tool definition needs: the `summary` an agent chooses on, a `description` for what the schemas do not
|
|
18
|
+
* say, the `power` that gates it, the `scopes` a caller must carry, and an `operation` that names it. Its input is
|
|
19
|
+
* the route's own `validate(...)` schemas, in the same `{ path, query, body }` shape the control-plane server's tools
|
|
20
|
+
* take. Nothing is written twice: a route that changes its body, its power or its summary changes its tool on the
|
|
21
|
+
* same commit.
|
|
22
|
+
*
|
|
23
|
+
* **A tool inherits its route's power. That is the whole mapping**, and there is no tool-to-role table to maintain.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One route, as a tool. */
|
|
27
|
+
export interface AppTool {
|
|
28
|
+
/** `<capability>_<operation>`, through the control-plane server's normalizer. */
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** The capability that registered the route — the unit of consent. */
|
|
31
|
+
readonly capability: string;
|
|
32
|
+
/** The route's declared operation. */
|
|
33
|
+
readonly operation: string;
|
|
34
|
+
/** The HTTP method a call dispatches with. */
|
|
35
|
+
readonly method: string;
|
|
36
|
+
/** The mounted path a call dispatches to, Hono's spelling. */
|
|
37
|
+
readonly path: string;
|
|
38
|
+
/** The route's summary, in its capability's own words. What the consent screen lists. */
|
|
39
|
+
readonly summary: string;
|
|
40
|
+
/** What an agent reads: the summary, the route's description, and its effect said aloud. */
|
|
41
|
+
readonly description: string;
|
|
42
|
+
/** The route's input as JSON Schema. */
|
|
43
|
+
readonly inputSchema: Record<string, unknown>;
|
|
44
|
+
/** What the tool does beyond reading, or `null` for a read. */
|
|
45
|
+
readonly effect: ToolEffect | null;
|
|
46
|
+
/** The MCP annotations its effect sets. */
|
|
47
|
+
readonly annotations: ToolAnnotations;
|
|
48
|
+
/** The power the caller's role must hold, or `undefined` when the route declares none. */
|
|
49
|
+
readonly power: string | undefined;
|
|
50
|
+
/** The attestation scopes a caller must carry, from `describeRoute({ scopes })`. */
|
|
51
|
+
readonly scopes: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What each effect says in a tool's description and on the consent screen. One sentence each. */
|
|
55
|
+
export const EFFECT_SENTENCES: Readonly<Record<ToolEffect, string>> = {
|
|
56
|
+
writes: KIND_SENTENCES.writes,
|
|
57
|
+
sends: KIND_SENTENCES.sends,
|
|
58
|
+
charges: KIND_SENTENCES.charges,
|
|
59
|
+
deletes: KIND_SENTENCES.deletes,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The MCP annotations for an effect. `null` is a read. */
|
|
63
|
+
export function toolAnnotations(effect: ToolEffect | null): ToolAnnotations {
|
|
64
|
+
return kindAnnotations(effect ?? "reads");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One described route that opted in, before it is a tool. */
|
|
68
|
+
interface Declared {
|
|
69
|
+
readonly capability: string;
|
|
70
|
+
readonly method: string;
|
|
71
|
+
readonly path: string;
|
|
72
|
+
readonly spec: RouteSpec;
|
|
73
|
+
readonly handlers: readonly unknown[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every described route in these groups, with the method it is mounted under. */
|
|
77
|
+
function described(groups: readonly RouteGroup[]): Declared[] {
|
|
78
|
+
return groups.flatMap((group) =>
|
|
79
|
+
operationsOf(group.routes)
|
|
80
|
+
.filter((operation) => !isMiddlewareOnly(operation))
|
|
81
|
+
.flatMap((operation) => {
|
|
82
|
+
const spec = operation.handlers.map(routeSpecOf).find((candidate) => candidate !== undefined);
|
|
83
|
+
return spec === undefined
|
|
84
|
+
? []
|
|
85
|
+
: [
|
|
86
|
+
{
|
|
87
|
+
capability: group.tag,
|
|
88
|
+
method: operation.method.toUpperCase(),
|
|
89
|
+
path: operation.path,
|
|
90
|
+
spec,
|
|
91
|
+
handlers: operation.handlers,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function refuse(message: string, detail: string): never {
|
|
99
|
+
throw new ValidationError({
|
|
100
|
+
message,
|
|
101
|
+
action:
|
|
102
|
+
"Fix the route's describeRoute({ operation, tool }). A GET opts in with `tool: true`; any other method declares `tool: { effect }`.",
|
|
103
|
+
detail,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Refuse a composition whose tool declarations are wrong, naming the route.
|
|
109
|
+
*
|
|
110
|
+
* **This is where "it does not compile" is enforced.** `describeRoute` cannot see the method it is mounted under, so
|
|
111
|
+
* a `POST` saying `tool: true` type-checks; `createBackend` calls this at assembly, so the Worker refuses to boot
|
|
112
|
+
* and the kit's own composition fails its tests. Five checks: a non-`GET` opting in without an effect, a `GET`
|
|
113
|
+
* declaring one, a tool with no operation, an operation outside the published alphabet, and two routes sharing an
|
|
114
|
+
* operation within a capability — or two capabilities whose names normalize to one tool.
|
|
115
|
+
*/
|
|
116
|
+
export function assertToolDeclarations(groups: readonly RouteGroup[]): void {
|
|
117
|
+
const operations = new Map<string, string>();
|
|
118
|
+
const names = new Map<string, string>();
|
|
119
|
+
for (const route of described(groups)) {
|
|
120
|
+
const where = `${route.method} ${route.path}`;
|
|
121
|
+
const { operation, tool } = route.spec;
|
|
122
|
+
if (operation !== undefined) {
|
|
123
|
+
if (!AdminOperation.safeParse(operation).success) {
|
|
124
|
+
refuse(
|
|
125
|
+
`The operation "${operation}" on ${where} is not a valid name.`,
|
|
126
|
+
`capability ${route.capability} route ${where} declares operation "${operation}"; an operation is lowercase words joined by ".", "_" or "-"`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const key = `${route.capability} ${operation}`;
|
|
130
|
+
const earlier = operations.get(key);
|
|
131
|
+
if (earlier !== undefined) {
|
|
132
|
+
refuse(
|
|
133
|
+
`Two ${route.capability} routes declare the operation "${operation}".`,
|
|
134
|
+
`${earlier} and ${where} both declare operation "${operation}" in capability ${route.capability}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
operations.set(key, where);
|
|
138
|
+
}
|
|
139
|
+
if (tool === undefined) continue;
|
|
140
|
+
if (operation === undefined) {
|
|
141
|
+
refuse(
|
|
142
|
+
`${where} opts in as a tool with no operation.`,
|
|
143
|
+
`capability ${route.capability} route ${where} declares tool but no operation, so its tool has no name`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (route.method !== "GET" && tool === true) {
|
|
147
|
+
refuse(
|
|
148
|
+
`${where} opts in as a tool without an effect.`,
|
|
149
|
+
`capability ${route.capability} route ${where} is not a GET, so it must declare tool: { effect } (writes, sends, charges or deletes)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (route.method === "GET" && tool !== true) {
|
|
153
|
+
refuse(
|
|
154
|
+
`${where} is a read and declares an effect.`,
|
|
155
|
+
`capability ${route.capability} route ${where} is a GET; a GET opts in with tool: true`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const name = controlPlaneToolName(route.capability, operation);
|
|
159
|
+
const clash = names.get(name);
|
|
160
|
+
if (clash !== undefined) {
|
|
161
|
+
refuse(`Two routes are both the tool ${name}.`, `${clash} and ${where} both normalize to the tool name ${name}`);
|
|
162
|
+
}
|
|
163
|
+
names.set(name, where);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The description an agent reads: the summary, the route's own description, and its effect. */
|
|
168
|
+
function descriptionOf(spec: RouteSpec, effect: ToolEffect | null): string {
|
|
169
|
+
return [spec.summary, spec.description, effect === null ? undefined : EFFECT_SENTENCES[effect]]
|
|
170
|
+
.filter((part): part is string => part !== undefined && part.length > 0)
|
|
171
|
+
.join(" ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Every tool these groups declare, in registration order. Refuses a wrong declaration first. */
|
|
175
|
+
export function appTools(groups: readonly RouteGroup[]): AppTool[] {
|
|
176
|
+
assertToolDeclarations(groups);
|
|
177
|
+
return described(groups).flatMap((route): AppTool[] => {
|
|
178
|
+
const { spec } = route;
|
|
179
|
+
if (spec.tool === undefined || spec.operation === undefined) return [];
|
|
180
|
+
const effect = spec.tool === true ? null : spec.tool.effect;
|
|
181
|
+
return [
|
|
182
|
+
{
|
|
183
|
+
name: controlPlaneToolName(route.capability, spec.operation),
|
|
184
|
+
capability: route.capability,
|
|
185
|
+
operation: spec.operation,
|
|
186
|
+
method: route.method,
|
|
187
|
+
path: route.path,
|
|
188
|
+
summary: spec.summary,
|
|
189
|
+
description: descriptionOf(spec, effect),
|
|
190
|
+
inputSchema: inputOf(route.handlers),
|
|
191
|
+
effect,
|
|
192
|
+
annotations: toolAnnotations(effect),
|
|
193
|
+
power: spec.power,
|
|
194
|
+
scopes: spec.scopes ?? [],
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What a caller holds, for {@link offeredTools}. */
|
|
201
|
+
export interface ToolCaller {
|
|
202
|
+
/** Whether the caller's role holds a power, or `null` when no organization is in force — nothing is held. */
|
|
203
|
+
readonly roleAllows: ((power: string) => boolean) | null;
|
|
204
|
+
/** The attestation scopes the caller carries. */
|
|
205
|
+
readonly scopes: readonly string[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether this caller may call this tool, by exactly the checks its route makes. */
|
|
209
|
+
export function mayCall(tool: AppTool, caller: ToolCaller): boolean {
|
|
210
|
+
if (tool.power !== undefined && (caller.roleAllows === null || !caller.roleAllows(tool.power))) return false;
|
|
211
|
+
return tool.scopes.every((scope) => caller.scopes.includes(scope));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The tools this caller may call. **A tool a role withholds is absent, not locked**: offering it discloses that it
|
|
216
|
+
* exists and spends a call to learn what the answer would be (`docs/AGENT-ACCESS.md` §4.1).
|
|
217
|
+
*/
|
|
218
|
+
export function offeredTools(tools: readonly AppTool[], caller: ToolCaller): AppTool[] {
|
|
219
|
+
return tools.filter((tool) => mayCall(tool, caller));
|
|
220
|
+
}
|
package/src/openapi/document.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { pathParams } from "../http/routeContract";
|
|
|
9
9
|
import { type DocumentedMethod, type RouteResponse, type RouteSpec, routeSpecOf } from "../http/routeSpec";
|
|
10
10
|
import { type RequestContract, requestContractOf } from "../http/validation";
|
|
11
11
|
import type { VerificationStrategy } from "../http/verification";
|
|
12
|
+
import { isMiddlewareOnly, type Operation, operationsOf } from "./operations";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* **The OpenAPI contract, read off a composed app.**
|
|
@@ -76,7 +77,8 @@ const SECURITY_SCHEMES: Record<string, unknown> = {
|
|
|
76
77
|
bearer: {
|
|
77
78
|
type: "http",
|
|
78
79
|
scheme: "bearer",
|
|
79
|
-
description:
|
|
80
|
+
description:
|
|
81
|
+
"A session token from `@pithy-sh/auth`, sent as `Authorization: Bearer <token>`: the one sign-in returns, then the `refreshToken` each `/token/rotate` answers with. The rotation's `accessToken` is a JWT for services outside the Worker, and these routes refuse it.",
|
|
80
82
|
},
|
|
81
83
|
session: {
|
|
82
84
|
type: "apiKey",
|
|
@@ -134,33 +136,6 @@ export function jsonSchema(schema: z.ZodType, io: "input" | "output"): JsonSchem
|
|
|
134
136
|
return rest;
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
/** One route's handlers, grouped by method and path the way Hono stores them: one entry per handler. */
|
|
138
|
-
interface Operation {
|
|
139
|
-
readonly method: string;
|
|
140
|
-
readonly path: string;
|
|
141
|
-
readonly handlers: unknown[];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function operationsOf(routes: readonly RouterRoute[]): Operation[] {
|
|
145
|
-
const byKey = new Map<string, Operation>();
|
|
146
|
-
for (const route of routes) {
|
|
147
|
-
const key = `${route.method} ${route.path}`;
|
|
148
|
-
const entry = byKey.get(key) ?? { method: route.method, path: route.path, handlers: [] };
|
|
149
|
-
entry.handlers.push(route.handler);
|
|
150
|
-
byKey.set(key, entry);
|
|
151
|
-
}
|
|
152
|
-
return [...byKey.values()];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* An `ALL` entry with no spec is `app.use` — middleware, not a route — because Hono records both as
|
|
157
|
-
* `ALL`. An `app.all` route that forgot its `describeRoute` is therefore invisible to the coverage
|
|
158
|
-
* check, which is the one blind spot here; the kit has one such route and it is described.
|
|
159
|
-
*/
|
|
160
|
-
function isMiddlewareOnly(operation: Operation): boolean {
|
|
161
|
-
return operation.method === "ALL" && !operation.handlers.some((handler) => routeSpecOf(handler));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
139
|
/** Every route in these groups that registered no `describeRoute`. Empty is the contract being met. */
|
|
165
140
|
export function undescribedRoutes(groups: readonly RouteGroup[]): UndescribedRoute[] {
|
|
166
141
|
return groups.flatMap((group) =>
|
|
@@ -274,6 +249,11 @@ function operationOf(tag: string, method: string, operation: Operation, spec: Ro
|
|
|
274
249
|
responses,
|
|
275
250
|
...(strategies.length === 0 ? {} : { security, "x-pithy-verification": strategies }),
|
|
276
251
|
...(spec.scopes === undefined || spec.scopes.length === 0 ? {} : { "x-pithy-scopes": spec.scopes }),
|
|
252
|
+
// What the route declared for the app's MCP endpoint: the power its gate installs, its permanent name,
|
|
253
|
+
// and its effect when it is a tool. A client generated from the contract reads the gate it will meet.
|
|
254
|
+
...(spec.power === undefined ? {} : { "x-pithy-power": spec.power }),
|
|
255
|
+
...(spec.operation === undefined ? {} : { "x-pithy-operation": spec.operation }),
|
|
256
|
+
...(spec.tool === undefined ? {} : { "x-pithy-tool": spec.tool === true ? { effect: null } : spec.tool }),
|
|
277
257
|
};
|
|
278
258
|
}
|
|
279
259
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { type RouteSpec, routeSpecOf } from "../http/routeSpec";
|
|
5
|
+
import type { RouteGroup, RouterRoute } from "./document";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The composed route table as operations: one per method and path, with the spec its `describeRoute` declared.
|
|
9
|
+
*
|
|
10
|
+
* Hono keeps one entry per handler, so a route with a `describeRoute` ahead of its handler appears twice in
|
|
11
|
+
* `app.routes`. Two readers need the grouped view: the OpenAPI document, which turns each operation into a path
|
|
12
|
+
* item, and the evidence pack (#821), which counts how every route verifies its caller. They group the same way
|
|
13
|
+
* because they group here, and the pack never loads the JSON Schema conversion the document needs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One route's handlers, grouped by method and path the way Hono stores them: one entry per handler. */
|
|
17
|
+
export interface Operation {
|
|
18
|
+
readonly method: string;
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly handlers: unknown[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Group a route table into operations, in registration order. */
|
|
24
|
+
export function operationsOf(routes: readonly RouterRoute[]): Operation[] {
|
|
25
|
+
const byKey = new Map<string, Operation>();
|
|
26
|
+
for (const route of routes) {
|
|
27
|
+
const key = `${route.method} ${route.path}`;
|
|
28
|
+
const entry = byKey.get(key) ?? { method: route.method, path: route.path, handlers: [] };
|
|
29
|
+
entry.handlers.push(route.handler);
|
|
30
|
+
byKey.set(key, entry);
|
|
31
|
+
}
|
|
32
|
+
return [...byKey.values()];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An `ALL` entry with no spec is `app.use` — middleware, not a route — because Hono records both as
|
|
37
|
+
* `ALL`. An `app.all` route that forgot its `describeRoute` is therefore invisible to the coverage
|
|
38
|
+
* check, which is the one blind spot here; the kit has one such route and it is described.
|
|
39
|
+
*/
|
|
40
|
+
export function isMiddlewareOnly(operation: Operation): boolean {
|
|
41
|
+
return operation.method === "ALL" && !operation.handlers.some((handler) => routeSpecOf(handler));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One route, and the spec it declared — `undefined` for a route that registered no `describeRoute`. */
|
|
45
|
+
export interface DescribedOperation {
|
|
46
|
+
readonly method: string;
|
|
47
|
+
readonly path: string;
|
|
48
|
+
readonly spec: RouteSpec | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every route in these groups, middleware left out, each with the spec it declared or `undefined`. */
|
|
52
|
+
export function describedOperations(groups: readonly RouteGroup[]): DescribedOperation[] {
|
|
53
|
+
return groups.flatMap((group) =>
|
|
54
|
+
operationsOf(group.routes)
|
|
55
|
+
.filter((operation) => !isMiddlewareOnly(operation))
|
|
56
|
+
.map((operation) => ({
|
|
57
|
+
method: operation.method,
|
|
58
|
+
path: operation.path,
|
|
59
|
+
spec: operation.handlers.map(routeSpecOf).find((candidate) => candidate !== undefined),
|
|
60
|
+
})),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SocketChannel } from "../http/socket";
|
|
5
|
+
import { type PresenceDeclaration, presenceSocketPath } from "./declaration";
|
|
6
|
+
import {
|
|
7
|
+
PRESENCE_ROOM_VERSIONS,
|
|
8
|
+
PresenceActivityFrame,
|
|
9
|
+
PresenceJoinedFrame,
|
|
10
|
+
PresenceLeftFrame,
|
|
11
|
+
PresenceRoomErrorFrame,
|
|
12
|
+
PresenceRoomInitFrame,
|
|
13
|
+
PresenceRoomPing,
|
|
14
|
+
PresenceRoomPong,
|
|
15
|
+
PresenceSetActivityFrame,
|
|
16
|
+
} from "./frames";
|
|
17
|
+
import { PresenceRoomParams } from "./http/schemas";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A capability's presence room socket, declared for the AsyncAPI contract. `defineCapability` adds it to the
|
|
21
|
+
* capability's `sockets` whenever it declares room kinds, from the same `basePath` the routes mount on, so a
|
|
22
|
+
* declared socket and a mounted one cannot be two different things.
|
|
23
|
+
*/
|
|
24
|
+
export function presenceRoomsSocket(capability: string, presence: PresenceDeclaration): SocketChannel {
|
|
25
|
+
return {
|
|
26
|
+
name: `${capability}PresenceRooms`,
|
|
27
|
+
path: presenceSocketPath(presence.basePath),
|
|
28
|
+
summary: `Who else has a ${capability} record open, and what they are doing, live.`,
|
|
29
|
+
description: `Mint a ticket first — \`POST /control-plane/presence/tickets\` for a management client, \`POST ${presence.basePath}/presence/tickets\` for a signed-in user — then offer \`pithy.presence.v1\` and \`pithy.ticket.<ticket>\` in \`Sec-WebSocket-Protocol\`. The ticket is the credential: single use, 60 seconds, this room only. The room sends \`init\` first, then \`joined\`, \`left\` and \`activity\` as they happen. Send \`set_activity\` to announce one of the kind's declared activities, and \`ping\` as plain text to get \`pong\`. Room kinds: ${presence.rooms.map((room) => `\`${room.kind}\``).join(", ")}.`,
|
|
30
|
+
verification: "public",
|
|
31
|
+
params: PresenceRoomParams,
|
|
32
|
+
versions: PRESENCE_ROOM_VERSIONS,
|
|
33
|
+
send: [
|
|
34
|
+
{ name: "init", summary: "Everyone in the room, sent once on connect.", schema: PresenceRoomInitFrame },
|
|
35
|
+
{ name: "joined", summary: "Another participant joined.", schema: PresenceJoinedFrame },
|
|
36
|
+
{ name: "left", summary: "Another participant left, or their socket dropped.", schema: PresenceLeftFrame },
|
|
37
|
+
{ name: "activity", summary: "Another participant changed what they are doing.", schema: PresenceActivityFrame },
|
|
38
|
+
{ name: "error", summary: "A frame the room could not accept.", schema: PresenceRoomErrorFrame },
|
|
39
|
+
{ name: "pong", summary: "The answer to `ping`, as plain text.", text: PresenceRoomPong.value },
|
|
40
|
+
],
|
|
41
|
+
receive: [
|
|
42
|
+
{ name: "set_activity", summary: "Announce a declared activity.", schema: PresenceSetActivityFrame },
|
|
43
|
+
{ name: "ping", summary: "The keepalive, as plain text.", text: PresenceRoomPing.value },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import type { BindingSpecInput } from "../capability/bindings";
|
|
6
|
+
import type { PithyHonoEnv } from "../capability/capability";
|
|
7
|
+
import { remedy } from "../cli/command";
|
|
8
|
+
import { ControlPlaneScope } from "../controlPlane/scope/scope";
|
|
9
|
+
import { InternalError } from "../error/pithyError";
|
|
10
|
+
import { PRESENCE_PARTICIPANT_NAME_MAX } from "./frames";
|
|
11
|
+
import { PresenceActivity, PresenceRoomKindName } from "./names";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* **Presence rooms: who else has a record open, live, from the adopter's own Worker.**
|
|
15
|
+
*
|
|
16
|
+
* A capability declares the room kinds it serves beside its admin routes, on {@link Capability.presence}: a
|
|
17
|
+
* name (`support.thread`), the control-plane scope that reads the record (`support:threads:read`), and the
|
|
18
|
+
* activities a participant may announce (`viewing`, `replying`). A room is one kind plus one record id, and
|
|
19
|
+
* it lives in one hibernating Durable Object, `PresenceRoom`, that holds nothing but who is in it.
|
|
20
|
+
*
|
|
21
|
+
* Joining takes a **ticket**, because a browser's `WebSocket` sets no headers and so cannot carry either the
|
|
22
|
+
* control-plane token or, cross-origin, a session cookie. A ticket is minted by one of two routes — the
|
|
23
|
+
* control-plane one behind the kind's scope, or the capability's own session one behind its own read
|
|
24
|
+
* permission — and presented once, in the subprotocol offer, within 60 seconds.
|
|
25
|
+
*
|
|
26
|
+
* **Presence adds no scope of its own.** Anyone who may read a record may see who else has it open, so the
|
|
27
|
+
* room kind names the scope that already reads the record and the session gate the capability already has.
|
|
28
|
+
*
|
|
29
|
+
* This module is pure: zod, types and constants. The routes are in `./http/routes.ts`, the Durable Object in
|
|
30
|
+
* `./durableObject.ts`, and nothing here imports either.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The Durable Object binding every room lives behind. One namespace for every room kind in a Worker. */
|
|
34
|
+
export const PRESENCE_ROOMS_BINDING = "PRESENCE_ROOMS";
|
|
35
|
+
|
|
36
|
+
/** The class that backs {@link PRESENCE_ROOMS_BINDING}, and the module the Worker's entry re-exports it from. */
|
|
37
|
+
export const PRESENCE_ROOM_CLASS = "PresenceRoom";
|
|
38
|
+
|
|
39
|
+
/** Its own module, never a package entry point, so `pithy.config.ts` can load the capability in Node. */
|
|
40
|
+
export const PRESENCE_ROOM_MODULE = "@pithy-sh/core/src/presence/durableObject";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The binding a capability that declares room kinds requires. `defineCapability` adds it, so a capability
|
|
44
|
+
* never spells it; a kit capability's `pithy.manifest.json` names it exactly as written here, which is what
|
|
45
|
+
* `pithy add` and `pithy upgrade` wire through the Durable Object path (#428).
|
|
46
|
+
*/
|
|
47
|
+
export const PRESENCE_ROOMS_BINDING_SPEC: BindingSpecInput = {
|
|
48
|
+
type: "durable_object",
|
|
49
|
+
name: PRESENCE_ROOMS_BINDING,
|
|
50
|
+
className: PRESENCE_ROOM_CLASS,
|
|
51
|
+
classModule: PRESENCE_ROOM_MODULE,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The secret tickets are signed with: one per Worker environment, generated, never typed. A capability that
|
|
56
|
+
* declares room kinds declares this name in its `@pithy-sh/secrets` registry with {@link
|
|
57
|
+
* PRESENCE_TICKET_KEY_ENTRY} and reads it through `secretsStore`, and its manifest lists it under
|
|
58
|
+
* `devSecrets` so `pithy add` mints the local value. The name is the join key, so every capability that
|
|
59
|
+
* declares rooms in one Worker signs and verifies with the one key.
|
|
60
|
+
*/
|
|
61
|
+
export const PRESENCE_TICKET_KEY_SECRET = "presence-ticket-key";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The registry entry for {@link PRESENCE_TICKET_KEY_SECRET}, in the shape `defineSecretRegistry` takes. Its
|
|
65
|
+
* value is arbitrary — nothing outside the Worker has to agree with it — so it is `devValue: "random"`, and
|
|
66
|
+
* rotatable because {@link PresenceTicketKey} accepts every version still honored.
|
|
67
|
+
*/
|
|
68
|
+
export const PRESENCE_TICKET_KEY_ENTRY = {
|
|
69
|
+
backend: "d1",
|
|
70
|
+
scope: "environment",
|
|
71
|
+
rotatable: true,
|
|
72
|
+
valueType: "text",
|
|
73
|
+
devValue: "random",
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
/** How long a minted ticket is honored, in seconds. Long enough to open a socket, short enough to be useless later. */
|
|
77
|
+
export const PRESENCE_TICKET_TTL_SECONDS = 60;
|
|
78
|
+
|
|
79
|
+
/** The participant cap a room kind gets when it declares none. */
|
|
80
|
+
export const DEFAULT_PRESENCE_PARTICIPANTS = 25;
|
|
81
|
+
|
|
82
|
+
/** The most participants any room kind may declare. A room is one Durable Object, and every frame fans out to all of them. */
|
|
83
|
+
export const MAX_PRESENCE_PARTICIPANTS = 100;
|
|
84
|
+
|
|
85
|
+
/** The largest frame a room accepts from a client, in bytes. The one frame a client sends is an activity name. */
|
|
86
|
+
export const PRESENCE_MAX_FRAME_BYTES = 1024;
|
|
87
|
+
|
|
88
|
+
/** The most activities one room kind may declare. */
|
|
89
|
+
export const MAX_PRESENCE_ACTIVITIES = 16;
|
|
90
|
+
|
|
91
|
+
/** What the session gate grants: the participant's display name, or the user id when it names none. */
|
|
92
|
+
export interface PresenceSessionGrant {
|
|
93
|
+
/**
|
|
94
|
+
* How other participants see this user. Trimmed and cut to 80 characters; the user id when it is missing
|
|
95
|
+
* or blank, so a gate can pass a stored display name through as it is.
|
|
96
|
+
*/
|
|
97
|
+
readonly name?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The name a session participant joins under: the gate's, trimmed and cut to fit, or the user id when the
|
|
102
|
+
* gate names none or a blank one. Always a name a ticket accepts — a display name is the user's own data, and
|
|
103
|
+
* one too long or empty must not lock them out of every room.
|
|
104
|
+
*/
|
|
105
|
+
export function sessionParticipantName(name: string | undefined, userId: string): string {
|
|
106
|
+
return fitName(name ?? "") || fitName(userId) || "Participant";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Trim, then cut to {@link PRESENCE_PARTICIPANT_NAME_MAX} code units without splitting a character. */
|
|
110
|
+
function fitName(raw: string): string {
|
|
111
|
+
let fitted = "";
|
|
112
|
+
for (const char of raw.trim()) {
|
|
113
|
+
if (fitted.length + char.length > PRESENCE_PARTICIPANT_NAME_MAX) break;
|
|
114
|
+
fitted += char;
|
|
115
|
+
}
|
|
116
|
+
return fitted.trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The capability's own permission for reading one record, for a signed-in user of the app. Answer a grant
|
|
121
|
+
* to admit, `null` to refuse. `c.var.auth` is already set when it runs; it is the capability's job to say
|
|
122
|
+
* whether that user may read `recordId`, exactly as its own read route would.
|
|
123
|
+
*/
|
|
124
|
+
export type PresenceSessionGate = (
|
|
125
|
+
c: Context<PithyHonoEnv>,
|
|
126
|
+
recordId: string,
|
|
127
|
+
) => PresenceSessionGrant | null | Promise<PresenceSessionGrant | null>;
|
|
128
|
+
|
|
129
|
+
/** One room kind a capability serves. */
|
|
130
|
+
export interface PresenceRoomKind {
|
|
131
|
+
/** The kind's name — `support.thread`. Its first segment must be the declaring capability's name. */
|
|
132
|
+
readonly kind: string;
|
|
133
|
+
/** The control-plane scope that reads the record. A management client joins with exactly this scope. */
|
|
134
|
+
readonly scope: string;
|
|
135
|
+
/** The activities a participant may announce. The first is what a participant starts as. */
|
|
136
|
+
readonly activities: readonly [string, ...string[]];
|
|
137
|
+
/** The participant cap. Defaults to {@link DEFAULT_PRESENCE_PARTICIPANTS}, at most {@link MAX_PRESENCE_PARTICIPANTS}. */
|
|
138
|
+
readonly maxParticipants?: number;
|
|
139
|
+
/**
|
|
140
|
+
* Who of the app's own signed-in users may join: the capability's read permission for the record. Absent,
|
|
141
|
+
* the kind admits management clients only and the session ticket route refuses it.
|
|
142
|
+
*/
|
|
143
|
+
readonly session?: PresenceSessionGate;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The secret tickets are signed with: the value, every value still honored during a rotation (the first
|
|
148
|
+
* signs), or a resolver reading it from the Worker env per request — which is the only form a secret read
|
|
149
|
+
* through `@pithy-sh/secrets` can take, since `env` exists only per request.
|
|
150
|
+
*/
|
|
151
|
+
export type PresenceTicketKey =
|
|
152
|
+
| string
|
|
153
|
+
| readonly string[]
|
|
154
|
+
| ((env: Record<string, unknown>) => string | readonly string[] | Promise<string | readonly string[]>);
|
|
155
|
+
|
|
156
|
+
/** A capability's presence declaration — see {@link Capability.presence}. */
|
|
157
|
+
export interface PresenceDeclaration {
|
|
158
|
+
/**
|
|
159
|
+
* Where this capability mounts its presence routes: `{basePath}/presence/tickets` and
|
|
160
|
+
* `{basePath}/presence/rooms/:kind/:recordId`. The capability's own resolved `basePath`, never its default.
|
|
161
|
+
*/
|
|
162
|
+
readonly basePath: string;
|
|
163
|
+
/** The room kinds it serves. */
|
|
164
|
+
readonly rooms: readonly PresenceRoomKind[];
|
|
165
|
+
/** The ticket signing key — {@link PRESENCE_TICKET_KEY_SECRET}, read through the capability's own secrets reader. */
|
|
166
|
+
readonly ticketKey: PresenceTicketKey;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The session ticket route, under a capability's base path. */
|
|
170
|
+
export function presenceTicketPath(basePath: string): string {
|
|
171
|
+
return `${basePath}/presence/tickets`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The socket route, under a capability's base path, as Hono registers it. */
|
|
175
|
+
export function presenceSocketPath(basePath: string): string {
|
|
176
|
+
return `${basePath}/presence/rooms/:kind/:recordId`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The socket path for one room, ready to open. */
|
|
180
|
+
export function presenceRoomPath(basePath: string, kind: string, recordId: string): string {
|
|
181
|
+
return `${basePath}/presence/rooms/${encodeURIComponent(kind)}/${encodeURIComponent(recordId)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The participant cap a room kind runs with. */
|
|
185
|
+
export function participantCap(room: PresenceRoomKind): number {
|
|
186
|
+
return room.maxParticipants ?? DEFAULT_PRESENCE_PARTICIPANTS;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One of the capability's own admin routes, as far as the scope rule reads it: its method and the scope it requires. */
|
|
190
|
+
export interface PresenceGatingRoute {
|
|
191
|
+
method: string;
|
|
192
|
+
scope: string | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Refuse a declaration no room could be served from, where it is written and naming the capability: a kind
|
|
197
|
+
* outside the capability's own name, a duplicate, a scope that is not one, an activity that is not one, a
|
|
198
|
+
* cap out of range. A declaration that reached a request would be refused there, by a caller, in a way only
|
|
199
|
+
* the operator can fix.
|
|
200
|
+
*
|
|
201
|
+
* **A room kind's scope is one the capability's own admin routes already require, and only `GET`s require
|
|
202
|
+
* it** — compared against `adminRoutes`, the same artifact `capabilityHealthSources` polices health keys
|
|
203
|
+
* with, and for the same reason. `pithy dashboard connect` offers the scopes it reads off `adminRoutes`, so
|
|
204
|
+
* a scope no route requires — one of presence's own, a near-miss, another capability's — is never offered
|
|
205
|
+
* and never in the default grant, and the room is unjoinable from every management client with no error to
|
|
206
|
+
* say why. And a scope that also gates a write would make joining a room need a mutation grant, which
|
|
207
|
+
* inverts the read rule `grant.ts` applies.
|
|
208
|
+
*/
|
|
209
|
+
export function assertPresenceDeclaration(
|
|
210
|
+
capability: string,
|
|
211
|
+
presence: PresenceDeclaration,
|
|
212
|
+
adminRoutes: readonly PresenceGatingRoute[],
|
|
213
|
+
): void {
|
|
214
|
+
const refuse = (detail: string): never => {
|
|
215
|
+
throw new InternalError({
|
|
216
|
+
message: `${capability} declares presence rooms this Worker cannot serve.`,
|
|
217
|
+
action: `Fix the presence declaration on the ${capability} capability.`,
|
|
218
|
+
detail,
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
if (!presence.basePath.startsWith("/") || presence.basePath.endsWith("/")) {
|
|
222
|
+
refuse(`presence basePath "${presence.basePath}" must start with "/" and not end with one`);
|
|
223
|
+
}
|
|
224
|
+
const seen = new Set<string>();
|
|
225
|
+
for (const room of presence.rooms) {
|
|
226
|
+
if (!PresenceRoomKindName.safeParse(room.kind).success) refuse(`room kind "${room.kind}" is not a kind name`);
|
|
227
|
+
if (!room.kind.startsWith(`${capability}.`)) {
|
|
228
|
+
refuse(
|
|
229
|
+
`room kind "${room.kind}" must start with "${capability}." — a capability declares kinds under its own name`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (seen.has(room.kind)) refuse(`room kind "${room.kind}" is declared twice`);
|
|
233
|
+
seen.add(room.kind);
|
|
234
|
+
if (!ControlPlaneScope.safeParse(room.scope).success)
|
|
235
|
+
refuse(`room kind "${room.kind}" scope "${room.scope}" is not a scope`);
|
|
236
|
+
// Inherited, never invented: the scope a room joins with is one this capability already gates a read with.
|
|
237
|
+
const gating = adminRoutes.filter((route) => route.scope === room.scope);
|
|
238
|
+
if (gating.length === 0) {
|
|
239
|
+
refuse(
|
|
240
|
+
`room kind "${room.kind}" scope "${room.scope}": no admin route of ${capability} requires it, so no grant would ever offer it`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const write = gating.find((route) => route.method !== "GET");
|
|
244
|
+
if (write) {
|
|
245
|
+
refuse(
|
|
246
|
+
`room kind "${room.kind}" scope "${room.scope}" gates a ${write.method}; a room joins with a read scope, one only GET routes require`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (room.activities.length === 0 || room.activities.length > MAX_PRESENCE_ACTIVITIES) {
|
|
250
|
+
refuse(
|
|
251
|
+
`room kind "${room.kind}" declares ${room.activities.length} activities; it needs 1 to ${MAX_PRESENCE_ACTIVITIES}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (new Set(room.activities).size !== room.activities.length) {
|
|
255
|
+
refuse(`room kind "${room.kind}" declares an activity twice`);
|
|
256
|
+
}
|
|
257
|
+
for (const activity of room.activities) {
|
|
258
|
+
if (!PresenceActivity.safeParse(activity).success)
|
|
259
|
+
refuse(`room kind "${room.kind}" activity "${activity}" is not one word`);
|
|
260
|
+
}
|
|
261
|
+
const cap = participantCap(room);
|
|
262
|
+
if (!Number.isInteger(cap) || cap < 1 || cap > MAX_PRESENCE_PARTICIPANTS) {
|
|
263
|
+
refuse(`room kind "${room.kind}" maxParticipants ${cap} is outside 1 to ${MAX_PRESENCE_PARTICIPANTS}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The ticket signing keys, resolved and non-empty — the first signs, every one verifies. A blank entry is
|
|
270
|
+
* dropped rather than tried, since WebCrypto refuses a zero-length key with an error that names nothing.
|
|
271
|
+
*/
|
|
272
|
+
export async function resolveTicketKeys(key: PresenceTicketKey, env: Record<string, unknown>): Promise<string[]> {
|
|
273
|
+
const resolved = typeof key === "function" ? await key(env) : key;
|
|
274
|
+
const keys = (typeof resolved === "string" ? [resolved] : [...resolved]).filter((value) => value.length > 0);
|
|
275
|
+
if (keys.length === 0) {
|
|
276
|
+
throw new InternalError({
|
|
277
|
+
message: "Presence is not available right now.",
|
|
278
|
+
action: `Set ${PRESENCE_TICKET_KEY_SECRET}. ${remedy("secrets provision", {}, { runtime: true }).say((command) => `${command} mints it for each environment.`)}`,
|
|
279
|
+
detail: `${PRESENCE_TICKET_KEY_SECRET} resolved to no usable value`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return keys;
|
|
283
|
+
}
|