@huynle/hub 0.6.1 → 0.7.2
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/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Hub-minted session tokens (design §1.3): a bearer credential for one session of one machine, which the hub
|
|
2
|
+
// pushes to the spoke reporting the session (sessionTokens op, see ../session-token-push.ts) and the spoke hands
|
|
3
|
+
// to the session's shells.
|
|
4
|
+
//
|
|
5
|
+
// Format: `hst1.<b64url(payload JSON)>.<b64url(HMAC-SHA256(secret of kid, "hst1." + payload part))>`, where the
|
|
6
|
+
// payload is {typ, uid, mid, sid, exp (ms since the epoch), kid, ue (user epoch), me (machine epoch), aud (the
|
|
7
|
+
// hub's public URL)}. One algorithm, one prefix; anything else is not a session token.
|
|
8
|
+
//
|
|
9
|
+
// typ "session" is the shell token (HUB_SESSION_TOKEN; any code in the session's shells can read it): its actor
|
|
10
|
+
// is via "shell" and can never act. typ "act" (phase 3, decision #1) is the act token, pushed only to spokes
|
|
11
|
+
// announcing "actTokens" (actTokens op), which never put it in any environment; its actor is via "act", the only
|
|
12
|
+
// session actor act verbs may admit (canActAsSession). The signature covers typ, so one cannot be turned into the other.
|
|
13
|
+
//
|
|
14
|
+
// A token is accepted while all of this holds: signature (constant-time) under a known kid, audience, expiry,
|
|
15
|
+
// the user's and machine's current token epochs, the machine exists, is not revoked and still belongs to uid,
|
|
16
|
+
// the user exists (the resolver refuses disabled users), and that machine still reports the session for uid.
|
|
17
|
+
// Tokens and secrets are never logged and never appear in this object's JSON or inspect output.
|
|
18
|
+
import { createHmac, timingSafeEqual } from "node:crypto"
|
|
19
|
+
import { SESSION_TOKEN_MAX_CHARS } from "../../protocol"
|
|
20
|
+
import type { Store } from "../store"
|
|
21
|
+
import type { TokenKeys } from "../token-keys"
|
|
22
|
+
import type { BearerVerifier, SessionActor, VerifyResult } from "./actor"
|
|
23
|
+
|
|
24
|
+
export const SESSION_TOKEN_PREFIX = "hst1"
|
|
25
|
+
/** How long a session token is valid. */
|
|
26
|
+
export const SESSION_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
|
|
27
|
+
|
|
28
|
+
/** What the verifier needs to know about live sessions (implemented by the registry). */
|
|
29
|
+
export interface SessionLiveness {
|
|
30
|
+
/** Tenant (user ID) of a reported session (Registry.sessionTenant). */
|
|
31
|
+
sessionTenant(sessionID: string): string | undefined
|
|
32
|
+
/** Whether a connected process of the machine reports the session (for the machine's tenant). */
|
|
33
|
+
machineReports(machineID: string, sessionID: string): boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionTokensOptions {
|
|
37
|
+
readonly keys: TokenKeys
|
|
38
|
+
readonly store: Store
|
|
39
|
+
readonly sessions: SessionLiveness
|
|
40
|
+
/** The hub's public URL: the tokens' audience. */
|
|
41
|
+
readonly publicURL: string
|
|
42
|
+
readonly now?: () => number
|
|
43
|
+
readonly lifetimeMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MintedSessionToken {
|
|
47
|
+
readonly token: string
|
|
48
|
+
/** Expiry, ms since the epoch. */
|
|
49
|
+
readonly exp: number
|
|
50
|
+
/** The machine's stamp when minted (see SessionTokens.stamp). */
|
|
51
|
+
readonly stamp: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Token types: "session" for shells, "act" for act verbs (the hub tool). */
|
|
55
|
+
export type SessionTokenType = "session" | "act"
|
|
56
|
+
|
|
57
|
+
interface Claims {
|
|
58
|
+
readonly typ: SessionTokenType
|
|
59
|
+
readonly uid: string
|
|
60
|
+
readonly mid: string
|
|
61
|
+
readonly sid: string
|
|
62
|
+
readonly exp: number
|
|
63
|
+
readonly kid: string
|
|
64
|
+
readonly ue: number
|
|
65
|
+
readonly me: number
|
|
66
|
+
readonly aud: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CLAIM_KEYS = ["typ", "uid", "mid", "sid", "exp", "kid", "ue", "me", "aud"].sort().join()
|
|
70
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
71
|
+
/** HMAC-SHA256 output length. */
|
|
72
|
+
const SIG_BYTES = 32
|
|
73
|
+
|
|
74
|
+
const text = (v: unknown): v is string => typeof v === "string" && v.length > 0
|
|
75
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
|
|
76
|
+
|
|
77
|
+
/** Canonical base64url only (no padding, no other alphabet, no non-zero trailing bits). */
|
|
78
|
+
function decode(part: string): Buffer | undefined {
|
|
79
|
+
if (!B64URL.test(part)) return undefined
|
|
80
|
+
const bytes = Buffer.from(part, "base64url")
|
|
81
|
+
return bytes.toString("base64url") === part ? bytes : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseClaims(bytes: Buffer): Claims | undefined {
|
|
85
|
+
let raw: unknown
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(bytes.toString("utf8"))
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
|
|
92
|
+
const c = raw as Record<string, unknown>
|
|
93
|
+
if (Object.keys(c).sort().join() !== CLAIM_KEYS) return undefined
|
|
94
|
+
if ((c.typ !== "session" && c.typ !== "act") || !text(c.uid) || !text(c.mid) || !text(c.sid) || !text(c.kid) || !text(c.aud)) return undefined
|
|
95
|
+
if (!count(c.exp) || !count(c.ue) || !count(c.me)) return undefined
|
|
96
|
+
return c as unknown as Claims
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sign = (secret: Buffer, payload: string): Buffer => createHmac("sha256", secret).update(`${SESSION_TOKEN_PREFIX}.${payload}`).digest()
|
|
100
|
+
|
|
101
|
+
export class SessionTokens {
|
|
102
|
+
readonly lifetimeMs: number
|
|
103
|
+
readonly verifier: BearerVerifier
|
|
104
|
+
readonly #keys: TokenKeys
|
|
105
|
+
readonly #store: Store
|
|
106
|
+
readonly #sessions: SessionLiveness
|
|
107
|
+
readonly #aud: string
|
|
108
|
+
readonly #now: () => number
|
|
109
|
+
|
|
110
|
+
constructor(options: SessionTokensOptions) {
|
|
111
|
+
this.#keys = options.keys
|
|
112
|
+
this.#store = options.store
|
|
113
|
+
this.#sessions = options.sessions
|
|
114
|
+
this.#aud = options.publicURL
|
|
115
|
+
this.#now = options.now ?? Date.now
|
|
116
|
+
this.lifetimeMs = options.lifetimeMs ?? SESSION_TOKEN_LIFETIME_MS
|
|
117
|
+
this.verifier = { kind: "session", verify: (token) => this.verifyFresh(token) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The owner of an enrolled, unrevoked machine whose user exists and is enabled. */
|
|
121
|
+
#owner(machineID: string): string | undefined {
|
|
122
|
+
const machine = this.#store.machines().find((m) => m.id === machineID)
|
|
123
|
+
if (!machine || machine.revoked) return undefined
|
|
124
|
+
const user = this.#store.user(machine.ownerID)
|
|
125
|
+
return user && !user.disabled ? user.id : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a machine's tokens depend on besides the session: owner, active kid, user and machine epochs. Tokens
|
|
130
|
+
* minted under another stamp are stale (resend them); undefined when the machine cannot get tokens.
|
|
131
|
+
*/
|
|
132
|
+
stamp(machineID: string): string | undefined {
|
|
133
|
+
const uid = this.#owner(machineID)
|
|
134
|
+
if (!uid) return undefined
|
|
135
|
+
return JSON.stringify([uid, this.#keys.active().kid, this.#store.userEpoch(uid), this.#store.machineEpoch(machineID)])
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A token for a session of the machine's owner (undefined when the machine is unknown, revoked or its owner
|
|
140
|
+
* disabled, or the session belongs to another tenant or to nobody).
|
|
141
|
+
*/
|
|
142
|
+
mint(machineID: string, sessionID: string): MintedSessionToken | undefined {
|
|
143
|
+
return this.#mint("session", machineID, sessionID)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** An act token (typ "act") for the session, under the same rules as mint. Only for the actTokens op. */
|
|
147
|
+
mintAct(machineID: string, sessionID: string): MintedSessionToken | undefined {
|
|
148
|
+
return this.#mint("act", machineID, sessionID)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#mint(typ: SessionTokenType, machineID: string, sessionID: string): MintedSessionToken | undefined {
|
|
152
|
+
const uid = this.#owner(machineID)
|
|
153
|
+
if (!uid || this.#sessions.sessionTenant(sessionID) !== uid) return undefined
|
|
154
|
+
const { kid, secret } = this.#keys.active()
|
|
155
|
+
const exp = this.#now() + this.lifetimeMs
|
|
156
|
+
const claims: Claims = {
|
|
157
|
+
typ,
|
|
158
|
+
uid,
|
|
159
|
+
mid: machineID,
|
|
160
|
+
sid: sessionID,
|
|
161
|
+
exp,
|
|
162
|
+
kid,
|
|
163
|
+
ue: this.#store.userEpoch(uid),
|
|
164
|
+
me: this.#store.machineEpoch(machineID),
|
|
165
|
+
aud: this.#aud,
|
|
166
|
+
}
|
|
167
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
|
|
168
|
+
const stamp = JSON.stringify([uid, kid, claims.ue, claims.me])
|
|
169
|
+
return { token: `${SESSION_TOKEN_PREFIX}.${payload}.${sign(secret, payload).toString("base64url")}`, exp, stamp }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The session actor of a valid token, "expired" for a genuine token past its expiry, else undefined (from memory). */
|
|
173
|
+
verify(token: string): VerifyResult {
|
|
174
|
+
const claims = this.#genuine(token)
|
|
175
|
+
return claims && claims !== "expired" ? this.#current(claims) : claims
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* verify, after reading the token's user and machine again (a shared store's security re-check: a revocation,
|
|
180
|
+
* epoch bump or disabled user on another pod applies at once). The bearer verifier uses this.
|
|
181
|
+
*/
|
|
182
|
+
async verifyFresh(token: string): Promise<VerifyResult> {
|
|
183
|
+
const claims = this.#genuine(token)
|
|
184
|
+
if (!claims || claims === "expired") return claims
|
|
185
|
+
await this.#store.recheck({ users: [claims.uid], machines: [claims.mid] })
|
|
186
|
+
return this.#current(claims)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The claims of a token signed with a known key for this hub, "expired" past its expiry, else undefined. */
|
|
190
|
+
#genuine(token: string): Claims | "expired" | undefined {
|
|
191
|
+
if (typeof token !== "string" || token.length > SESSION_TOKEN_MAX_CHARS) return undefined
|
|
192
|
+
const parts = token.split(".")
|
|
193
|
+
if (parts.length !== 3 || parts[0] !== SESSION_TOKEN_PREFIX) return undefined
|
|
194
|
+
const [, payload, sigPart] = parts as [string, string, string]
|
|
195
|
+
const body = decode(payload)
|
|
196
|
+
const sig = decode(sigPart)
|
|
197
|
+
if (!body || !sig || sig.length !== SIG_BYTES) return undefined
|
|
198
|
+
const claims = parseClaims(body)
|
|
199
|
+
if (!claims) return undefined
|
|
200
|
+
const secret = this.#keys.key(claims.kid)
|
|
201
|
+
if (!secret) return undefined
|
|
202
|
+
const expected = sign(secret, payload)
|
|
203
|
+
if (expected.length !== sig.length || !timingSafeEqual(expected, sig)) return undefined
|
|
204
|
+
if (claims.aud !== this.#aud) return undefined
|
|
205
|
+
if (this.#now() >= claims.exp) return "expired"
|
|
206
|
+
return claims
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The session actor while the token's epochs, machine, user and session are all still current. */
|
|
210
|
+
#current(claims: Claims): VerifyResult {
|
|
211
|
+
if (claims.ue !== this.#store.userEpoch(claims.uid) || claims.me !== this.#store.machineEpoch(claims.mid)) return undefined
|
|
212
|
+
const machine = this.#store.machines().find((m) => m.id === claims.mid)
|
|
213
|
+
if (!machine || machine.revoked || machine.ownerID !== claims.uid) return undefined
|
|
214
|
+
if (!this.#store.user(claims.uid)) return undefined
|
|
215
|
+
if (this.#sessions.sessionTenant(claims.sid) !== claims.uid || !this.#sessions.machineReports(claims.mid, claims.sid)) return undefined
|
|
216
|
+
const actor: SessionActor = { kind: "session", userID: claims.uid, machineID: claims.mid, sessionID: claims.sid, via: claims.typ === "act" ? "act" : "shell" }
|
|
217
|
+
return actor
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
toJSON(): { audience: string; lifetimeMs: number } {
|
|
221
|
+
return { audience: this.#aud, lifetimeMs: this.lifetimeMs }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
225
|
+
return `SessionTokens(${this.#aud})`
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Read-modify-write over a StateStore key with optimistic versions: retried when another writer got there first.
|
|
2
|
+
import type { StateStore } from "../backend/types"
|
|
3
|
+
|
|
4
|
+
/** Attempts before a contended update gives up (each one re-reads the key). */
|
|
5
|
+
const ATTEMPTS = 16
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Applies `change` to the key's current value (undefined: missing) and writes the result with ifVersion.
|
|
9
|
+
* `change` returning undefined leaves the key alone. Resolves with the written value (or undefined).
|
|
10
|
+
*/
|
|
11
|
+
export async function updateVersioned<T>(
|
|
12
|
+
state: StateStore,
|
|
13
|
+
userID: string,
|
|
14
|
+
collection: string,
|
|
15
|
+
key: string,
|
|
16
|
+
change: (current: T | undefined) => T | undefined,
|
|
17
|
+
): Promise<T | undefined> {
|
|
18
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
19
|
+
const current = await state.get<T>(userID, collection, key)
|
|
20
|
+
const next = change(current?.value)
|
|
21
|
+
if (next === undefined) return undefined
|
|
22
|
+
const result = await state.put(userID, collection, key, next, { ifVersion: current?.version ?? 0 })
|
|
23
|
+
if (result.ok) return next
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`state update of ${collection}/${key} kept conflicting`)
|
|
26
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// GET /api/agent/watch (design §4; lifecycle design "Events"): a live SSE view of the canonical agent event journal
|
|
2
|
+
// (events.ts), for orchestrator loops. The same events, visibility and filters as GET /api/agent/events, read with
|
|
3
|
+
// the same read() (events-routes.ts), so the two never define events differently. Not the console's /api/events.
|
|
4
|
+
//
|
|
5
|
+
// ?blocked=1 blocker events only (blocker.*)
|
|
6
|
+
// ?session=<addr> only that session's tree (an address resolved for the caller; several for a @@role)
|
|
7
|
+
// ?cursor=… or Last-Event-ID: resume after a cursor this stream (or GET /api/agent/events) gave; a bad one is
|
|
8
|
+
// 400 invalid_cursor before the stream starts
|
|
9
|
+
//
|
|
10
|
+
// Frames (each `data:` line one JSON object; `id:` lines carry the resumable cursor):
|
|
11
|
+
// {type: "ready", at} first, once subscribed
|
|
12
|
+
// {id: "evt_…", at, type, subject, data} a journal event (no free text)
|
|
13
|
+
// {type: "reset", at, refresh: [...]} the cursor was no longer covered: re-read those
|
|
14
|
+
// event: ping every heartbeat while idle (default 20 s), with the cursor
|
|
15
|
+
// When the hub drains (shutting down), the stream ends with `retry: 2000` (reconnect, to another pod, after it).
|
|
16
|
+
import type { AgentActor } from "./actor"
|
|
17
|
+
import type { AgentEvent } from "./events"
|
|
18
|
+
import { read, RESET_REFRESH, type EventsPage, type EventsRouteDeps } from "./events-routes"
|
|
19
|
+
|
|
20
|
+
export const WATCH_HEARTBEAT_MS = 20_000
|
|
21
|
+
/** Draining: the `retry:` (ms) an ending watch stream sends. */
|
|
22
|
+
export const WATCH_RETRY_MS = 2_000
|
|
23
|
+
const RETRY = `retry: ${WATCH_RETRY_MS}\n\n`
|
|
24
|
+
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
|
|
25
|
+
const PAGE = 200
|
|
26
|
+
|
|
27
|
+
export interface WatchOptions {
|
|
28
|
+
readonly events: EventsRouteDeps
|
|
29
|
+
readonly userID: string
|
|
30
|
+
readonly actor: AgentActor
|
|
31
|
+
readonly signal: AbortSignal
|
|
32
|
+
/** Resume after this cursor (none: from now). */
|
|
33
|
+
readonly cursor?: string
|
|
34
|
+
/** Only these event types (exact, or `blocker.*`). */
|
|
35
|
+
readonly types?: readonly string[]
|
|
36
|
+
/** Only events of these sessions or of trees rooted at them. */
|
|
37
|
+
readonly sessions?: ReadonlySet<string>
|
|
38
|
+
readonly heartbeatMs?: number
|
|
39
|
+
readonly now?: () => number
|
|
40
|
+
/** Aborted when the hub drains: the stream ends with a `retry:` hint. */
|
|
41
|
+
readonly draining?: AbortSignal
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The watch stream; throws invalid_cursor (before streaming) for a cursor this hub did not give this user. */
|
|
45
|
+
export async function watchStream(options: WatchOptions): Promise<Response> {
|
|
46
|
+
if (options.draining?.aborted) return new Response(RETRY, { headers: SSE_HEADERS })
|
|
47
|
+
const { userID, actor } = options
|
|
48
|
+
const now = options.now ?? Date.now
|
|
49
|
+
const heartbeatMs = options.heartbeatMs ?? WATCH_HEARTBEAT_MS
|
|
50
|
+
const filters = { limit: PAGE, types: options.types ?? [], ...(options.sessions ? { sessions: options.sessions } : {}) }
|
|
51
|
+
const first = await read(options.events, userID, actor, { ...filters, ...(options.cursor ? { cursor: options.cursor } : {}), wait: 0 })
|
|
52
|
+
const encoder = new TextEncoder()
|
|
53
|
+
const stop = new AbortController()
|
|
54
|
+
|
|
55
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
56
|
+
async start(controller) {
|
|
57
|
+
let open = true
|
|
58
|
+
const close = () => {
|
|
59
|
+
if (!open) return
|
|
60
|
+
open = false
|
|
61
|
+
stop.abort()
|
|
62
|
+
try {
|
|
63
|
+
controller.close()
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
const write = (chunk: string) => {
|
|
67
|
+
if (!open) return
|
|
68
|
+
try {
|
|
69
|
+
controller.enqueue(encoder.encode(chunk))
|
|
70
|
+
} catch {
|
|
71
|
+
close()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const frame = (data: object, id?: string) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(data)}\n\n`)
|
|
75
|
+
const emit = (page: EventsPage) => {
|
|
76
|
+
if (page.reset) return frame({ type: "reset", at: now(), refresh: page.refresh ?? RESET_REFRESH }, page.cursor)
|
|
77
|
+
if (page.events.length === 0) return write(`event: ping\nid: ${page.cursor}\ndata: 0\n\n`)
|
|
78
|
+
page.events.forEach((event: AgentEvent, i) => frame(event, i === page.events.length - 1 ? page.cursor : undefined))
|
|
79
|
+
}
|
|
80
|
+
options.signal.addEventListener("abort", () => close(), { once: true })
|
|
81
|
+
options.draining?.addEventListener(
|
|
82
|
+
"abort",
|
|
83
|
+
() => {
|
|
84
|
+
write(RETRY)
|
|
85
|
+
close()
|
|
86
|
+
},
|
|
87
|
+
{ once: true },
|
|
88
|
+
)
|
|
89
|
+
frame({ type: "ready", at: now() }, options.cursor && !first.reset ? options.cursor : first.cursor)
|
|
90
|
+
if (first.reset || first.events.length) emit(first)
|
|
91
|
+
let cursor = first.cursor
|
|
92
|
+
while (open && !stop.signal.aborted) {
|
|
93
|
+
let page: EventsPage
|
|
94
|
+
try {
|
|
95
|
+
page = await read(options.events, userID, actor, { ...filters, cursor, wait: heartbeatMs / 1000 }, stop.signal)
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
console.error(`[hub] agent watch read failed: ${(cause as Error).message}`)
|
|
98
|
+
write(RETRY)
|
|
99
|
+
return close()
|
|
100
|
+
}
|
|
101
|
+
if (stop.signal.aborted) break
|
|
102
|
+
emit(page)
|
|
103
|
+
cursor = page.cursor
|
|
104
|
+
}
|
|
105
|
+
close()
|
|
106
|
+
},
|
|
107
|
+
cancel() {
|
|
108
|
+
stop.abort()
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
return new Response(stream, { headers: SSE_HEADERS })
|
|
112
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The webhook API on /api/agent (P4-T10; decision znbg8xa5 #8), over Webhooks (webhooks.ts):
|
|
2
|
+
//
|
|
3
|
+
// POST /api/agent/webhooks {url, events?, label?} → 201 {webhook, secret} (the secret only here)
|
|
4
|
+
// GET /api/agent/webhooks → {webhooks}
|
|
5
|
+
// GET /api/agent/webhooks/:id → {webhook, deliveries} (recent deliveries, oldest first)
|
|
6
|
+
// DELETE /api/agent/webhooks/:id → 204 (its pending deliveries are dropped)
|
|
7
|
+
//
|
|
8
|
+
// Who: users (all of their registrations, their own and their app tokens'); app tokens with sessions:launch (the
|
|
9
|
+
// scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
|
|
10
|
+
// the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
|
|
11
|
+
// one the token did not make, is 404 not_found.
|
|
12
|
+
import type { Store } from "../store"
|
|
13
|
+
import type { AgentActor } from "./actor"
|
|
14
|
+
import { AgentError } from "./errors"
|
|
15
|
+
import type { AgentRoute } from "./routes"
|
|
16
|
+
import { webhookEventScopes, type WebhookOwner, type Webhooks } from "./webhooks"
|
|
17
|
+
|
|
18
|
+
// A literal: routes.ts imports this module.
|
|
19
|
+
const PATH = "/api/agent/webhooks"
|
|
20
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
21
|
+
/** Users; apps with sessions:launch. */
|
|
22
|
+
export const WEBHOOK_SCOPE = "events:subscribe"
|
|
23
|
+
const ALLOW = { scope: WEBHOOK_SCOPE } as const
|
|
24
|
+
|
|
25
|
+
export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
|
|
26
|
+
|
|
27
|
+
/** The registrations an actor manages: an app token its own, a user all (undefined). */
|
|
28
|
+
const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind === "app" ? `app:${actor.tokenID}` : undefined)
|
|
29
|
+
|
|
30
|
+
const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
|
|
31
|
+
|
|
32
|
+
const requireEventScopes = (actor: AgentActor, events: unknown) => {
|
|
33
|
+
if (actor.kind !== "app") return
|
|
34
|
+
const patterns = events === undefined ? ["launch.*"] : Array.isArray(events) && events.every((e) => typeof e === "string") ? events : []
|
|
35
|
+
const missing = webhookEventScopes(patterns).filter((scope) => !actor.scopes.includes(scope))
|
|
36
|
+
if (missing.length) throw new AgentError("forbidden", `Webhook events need scope ${missing.join(", ")}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
40
|
+
const raw = JSON.parse(await request.text()) as unknown
|
|
41
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
42
|
+
return raw as Record<string, unknown>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audit">): AgentRoute[] {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
method: "POST",
|
|
49
|
+
path: PATH,
|
|
50
|
+
allow: ALLOW,
|
|
51
|
+
async handle({ request, actor, user }) {
|
|
52
|
+
const input = await bodyOf(request)
|
|
53
|
+
requireEventScopes(actor, input.events)
|
|
54
|
+
const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
|
|
55
|
+
await store?.audit({ actor: user.id, action: "webhook.create", target: result.webhook.id, detail: new URL(result.webhook.url).host })
|
|
56
|
+
return Response.json(result, { status: 201, headers: NO_STORE })
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
method: "GET",
|
|
61
|
+
path: PATH,
|
|
62
|
+
allow: ALLOW,
|
|
63
|
+
handle: async ({ actor, user }) => Response.json({ webhooks: await webhooks.list(user.id, ownerOf(actor)) }, { headers: NO_STORE }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
method: "GET",
|
|
67
|
+
path: `${PATH}/:id`,
|
|
68
|
+
allow: ALLOW,
|
|
69
|
+
async handle({ params, actor, user }) {
|
|
70
|
+
const webhook = await webhooks.get(user.id, params.id!, ownerOf(actor))
|
|
71
|
+
if (!webhook) throw notFound(params.id!)
|
|
72
|
+
return Response.json({ webhook, deliveries: await webhooks.deliveries(user.id, webhook.id) }, { headers: NO_STORE })
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
method: "DELETE",
|
|
77
|
+
path: `${PATH}/:id`,
|
|
78
|
+
allow: ALLOW,
|
|
79
|
+
async handle({ params, actor, user }) {
|
|
80
|
+
if (!(await webhooks.remove(user.id, params.id!, ownerOf(actor)))) throw notFound(params.id!)
|
|
81
|
+
await store?.audit({ actor: user.id, action: "webhook.delete", target: params.id! })
|
|
82
|
+
return new Response(null, { status: 204, headers: NO_STORE })
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
}
|