@huynle/hub 0.7.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.
Files changed (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -19,9 +19,8 @@ import type { Directory, DirectoryEntry, RoleView } from "./directory"
19
19
  import type { AgentActor } from "./actor"
20
20
  import { AgentError } from "./errors"
21
21
  import { AGENT_PREFIX, type AgentRoute } from "./routes"
22
- import { partyFromActor, partyKey } from "./envelope"
23
- import { launchedBy } from "./launch-routes"
24
- import { type LaunchSource, watchStream, type MessageSource, type WatchRegistry } from "./watch"
22
+ import { watchStream } from "./watch"
23
+ import type { EventsRouteDeps } from "./events-routes"
25
24
 
26
25
  /** Labels per session ID. */
27
26
  export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
@@ -46,14 +45,10 @@ export interface ReadDeps {
46
45
  readonly directory: Directory
47
46
  readonly registry: ReadRegistry
48
47
  readonly spokes?: SpokeRequester
49
- /** Agent messages for the watch stream's message events. */
50
- readonly feed?: MessageSource
51
48
  /** The labels of the user's launched sessions (none without it). */
52
49
  readonly labels?: (userID: string) => Promise<SessionLabels>
53
- /** Launch changes for the watch stream's launch events. */
54
- readonly launches?: LaunchSource
55
- /** What the watch stream of a user reads (cluster mode: status from every pod); the registry when absent. */
56
- readonly watchRegistry?: (userID: string) => WatchRegistry
50
+ /** The journal GET /api/agent/watch streams (no watch route without it). */
51
+ readonly events?: EventsRouteDeps
57
52
  /** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
58
53
  readonly heartbeatMs?: number
59
54
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
@@ -358,32 +353,33 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
358
353
  allow: READ,
359
354
  handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
360
355
  },
361
- {
362
- method: "GET",
363
- path: `${AGENT_PREFIX}/watch`,
364
- allow: READ,
365
- handle: ({ user, url, request, actor }) => {
366
- const addr = url.searchParams.get("session") || undefined
367
- // Message events: the user sees all; a session, or an app with messages:read, those it is a party to.
368
- const messageReader = actor.kind === "user" || actor.kind === "session" || actor.scopes.includes("messages:read")
369
- const messages = deps.feed && messageReader ? { feed: deps.feed, ...(actor.kind === "user" ? {} : { party: partyKey(partyFromActor(actor)) }) } : undefined
370
- const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
371
- const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
372
- // Launch events: the user sees all; an app or session those it started.
373
- const launches = deps.launches ? { feed: deps.launches, ...(actor.kind === "user" ? {} : { by: launchedBy(actor) }) } : undefined
374
- return watchStream({
375
- registry: deps.watchRegistry?.(user.id) ?? deps.registry,
376
- userID: user.id,
377
- signal: request.signal,
378
- blocked,
379
- ...(sessions ? { sessions } : {}),
380
- ...(messages ? { messages } : {}),
381
- ...(launches ? { launches } : {}),
382
- ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
383
- ...(deps.now ? { now: deps.now } : {}),
384
- ...(deps.draining ? { draining: deps.draining } : {}),
385
- })
386
- },
387
- },
356
+ ...(deps.events ? [watchRoute(deps, deps.events)] : []),
388
357
  ]
389
358
  }
359
+
360
+ /** GET /api/agent/watch: the journal's events live (watch.ts), with the events route's visibility and filters. */
361
+ function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
362
+ return {
363
+ method: "GET",
364
+ path: `${AGENT_PREFIX}/watch`,
365
+ allow: READ,
366
+ handle: ({ user, url, request, actor }) => {
367
+ const addr = url.searchParams.get("session") || undefined
368
+ const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
369
+ const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
370
+ const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
371
+ return watchStream({
372
+ events,
373
+ userID: user.id,
374
+ actor,
375
+ signal: request.signal,
376
+ ...(blocked ? { types: ["blocker.*"] } : {}),
377
+ ...(sessions ? { sessions } : {}),
378
+ ...(cursor ? { cursor } : {}),
379
+ ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
380
+ ...(deps.now ? { now: deps.now } : {}),
381
+ ...(deps.draining ? { draining: deps.draining } : {}),
382
+ })
383
+ },
384
+ }
385
+ }
@@ -6,6 +6,9 @@
6
6
  // requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
7
7
  // route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
8
8
  // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
+ import type { AgentEvents } from "./events"
10
+ import type { CursorKeys } from "./event-cursor"
11
+ import { eventsRoutes } from "./events-routes"
9
12
  import type { DeviceRecord, Store, UserRecord } from "../store"
10
13
  import type { Directory } from "./directory"
11
14
  import { directoryRoutes } from "./directory-routes"
@@ -25,7 +28,7 @@ import { type ActingSwitch, actingSwitchRoutes } from "./acting-switch"
25
28
  import { Acting, type ActingRegistry, type Escalate } from "./acting"
26
29
  import { actingRoutes } from "./acting-routes"
27
30
  import type { Actions } from "../actions"
28
- import { type LaunchFeed, launchRoutes, type RouteLaunches } from "./launch-routes"
31
+ import { launchRoutes, type RouteLaunches } from "./launch-routes"
29
32
  import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
30
33
 
31
34
  export const AGENT_PREFIX = "/api/agent"
@@ -87,8 +90,10 @@ export interface AgentDeps {
87
90
  readonly launches?: RouteLaunches
88
91
  /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
89
92
  readonly webhooks?: RouteWebhooks
90
- /** Launch changes: the watch stream's launch events. */
91
- readonly launchFeed?: Pick<LaunchFeed, "subscribe">
93
+ /** The canonical agent event journal: archive changes are appended to it. */
94
+ readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
95
+ /** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
96
+ readonly eventCursorKeys?: CursorKeys
92
97
  /** needs_user escalations of the act verbs (P3-T8 opens a Decision); without it needs_user is only answered. */
93
98
  readonly escalate?: Escalate
94
99
  /** The console's prompt limit, for the user's prompts on the act route. */
@@ -97,8 +102,6 @@ export interface AgentDeps {
97
102
  readonly feed?: MessageFeed
98
103
  /** Heartbeat of GET /api/agent/watch. */
99
104
  readonly heartbeatMs?: number
100
- /** What GET /api/agent/watch reads per user (cluster mode); the registry when absent. */
101
- readonly watchRegistry?: ReadDeps["watchRegistry"]
102
105
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
103
106
  readonly draining?: AbortSignal
104
107
  readonly now?: () => number
@@ -123,9 +126,10 @@ const userView = (user: UserRecord) => ({
123
126
  export function agentRoutes(deps: AgentDeps): AgentRoute[] {
124
127
  const feed = deps.feed ?? new MessageFeed()
125
128
  return [
126
- ...(deps.directory ? directoryRoutes(deps.directory) : []),
129
+ ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
130
+ ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
127
131
  ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
128
- ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.delivery ? { feed } : {}), ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.watchRegistry ? { watchRegistry: deps.watchRegistry } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
132
+ ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
129
133
  {
130
134
  method: "GET",
131
135
  path: `${AGENT_PREFIX}/whoami`,
@@ -144,9 +148,9 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
144
148
  ]
145
149
  }
146
150
 
147
- /** What the read routes show of launches: launched sessions' labels (the latest launch wins) and launch events. */
148
- function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels" | "launches"> {
149
- const { launches, launchFeed } = deps
151
+ /** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
152
+ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
153
+ const { launches } = deps
150
154
  return {
151
155
  ...(launches
152
156
  ? {
@@ -158,7 +162,6 @@ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels" | "launches"> {
158
162
  },
159
163
  }
160
164
  : {}),
161
- ...(launchFeed ? { launches: launchFeed } : {}),
162
165
  }
163
166
  }
164
167
 
@@ -1,220 +1,111 @@
1
- // GET /api/agent/watch (design §4): a lite SSE stream of compact JSON events about one user's sessions, for
2
- // orchestrator loops. Not the console's /api/events (cookie/device keyed, with replay and streamed text): this one
3
- // is per actor, has no replay and never carries text deltas.
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
4
  //
5
- // ?blocked=1 blocker events only
5
+ // ?blocked=1 blocker events only (blocker.*)
6
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
7
9
  //
8
- // Events (each `data:` line one JSON object with `type` and `at`):
9
- // {type: "ready"} first, once subscribed
10
- // {type: "state", sessionID, rootID, state, prev?} a session's own state changed
11
- // {type: "blocker.opened", sessionID, rootID, blocker: {kind, id, summary}}
12
- // {type: "blocker.closed", sessionID, rootID, id} answered, cancelled or gone
13
- // {type: "session.added", sessionID, title} / {type: "session.removed", sessionID} catalog changes
14
- // {type: "message", id, kind, state, from, to, sessionID?, senderID?, replyTo?, delivery?} an agent message was
15
- // stored (no text): sessionID is the recipient session, senderID the sending one. The user sees all of them,
16
- // a session or app (messages:read) only those it sent or received; ?session= matches either session.
17
- // {type: "launch", id, state, reason, outcome?, sessionID?, name?, labels} a launch changed (the user sees all of
18
- // theirs, an app or session those it started); ?session= matches its session.
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
19
15
  // When the hub drains (shutting down), the stream ends with `retry: 2000` (reconnect, to another pod, after it).
20
- // A `ping` event every heartbeat (default 20 s). Only status messages of the caller's tenant are read; catalog
21
- // changes are diffed against the caller's own sessions.
22
- import type { LiveStatus, RegistryMessage } from "../registry"
23
- import { partyKey } from "./envelope"
24
- import { addressOf, type Message } from "./messages"
25
- import type { Launch } from "./launch-store"
26
- import type { ReadRegistry } from "./read-routes"
16
+ import type { AgentActor } from "./actor"
17
+ import type { AgentEvent } from "./events"
18
+ import { read, RESET_REFRESH, type EventsPage, type EventsRouteDeps } from "./events-routes"
27
19
 
28
20
  export const WATCH_HEARTBEAT_MS = 20_000
29
21
  /** Draining: the `retry:` (ms) an ending watch stream sends. */
30
22
  export const WATCH_RETRY_MS = 2_000
31
23
  const RETRY = `retry: ${WATCH_RETRY_MS}\n\n`
32
24
  const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
33
-
34
- export interface WatchRegistry extends Pick<ReadRegistry, "catalog" | "sessionTenant"> {
35
- subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
36
- }
37
-
38
- /** Stored agent messages as they are sent (MessageFeed). */
39
- export interface MessageSource {
40
- subscribe(listener: (userID: string, message: Message) => void): () => void
41
- }
42
-
43
- /** Launch changes as the engine makes them (LaunchFeed). */
44
- export interface LaunchSource {
45
- subscribe(listener: (userID: string, launch: Launch) => void): () => void
46
- }
47
-
48
- /** The compact watch event of a launch change (never its prompt). */
49
- function launchEvent(launch: Launch): WatchEvent {
50
- return {
51
- type: "launch",
52
- id: launch.id,
53
- state: launch.state,
54
- reason: launch.reason,
55
- ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
56
- ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
57
- ...(launch.name !== undefined ? { name: launch.name } : {}),
58
- labels: launch.spec.labels,
59
- }
60
- }
25
+ const PAGE = 200
61
26
 
62
27
  export interface WatchOptions {
63
- readonly registry: WatchRegistry
28
+ readonly events: EventsRouteDeps
64
29
  readonly userID: string
30
+ readonly actor: AgentActor
65
31
  readonly signal: AbortSignal
66
- /** Only blocker events. */
67
- readonly blocked?: boolean
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[]
68
36
  /** Only events of these sessions or of trees rooted at them. */
69
37
  readonly sessions?: ReadonlySet<string>
70
- /** Message events: from this feed, only those of the party key (undefined: all of the user's). */
71
- readonly messages?: { readonly feed: MessageSource; readonly party?: string }
72
- /** Launch events: from this feed, only those launched by `by` (undefined: all of the user's). */
73
- readonly launches?: { readonly feed: LaunchSource; readonly by?: string }
74
38
  readonly heartbeatMs?: number
75
39
  readonly now?: () => number
76
40
  /** Aborted when the hub drains: the stream ends with a `retry:` hint. */
77
41
  readonly draining?: AbortSignal
78
42
  }
79
43
 
80
- /** The compact watch event of a message (never its text). */
81
- function messageEvent(message: Message): WatchEvent {
82
- const { id, kind, state, from, recipient, replyTo, delivery } = message
83
- return {
84
- type: "message",
85
- id,
86
- kind,
87
- state,
88
- from: addressOf(from),
89
- to: message.to,
90
- ...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
91
- ...(from.kind === "session" ? { senderID: from.sessionID } : {}),
92
- ...(replyTo ? { replyTo } : {}),
93
- ...(delivery ? { delivery } : {}),
94
- }
95
- }
96
-
97
- interface Known {
98
- state: string
99
- blockers: Set<string>
100
- }
101
-
102
- type WatchEvent = { type: string; sessionID?: string; rootID?: string; senderID?: string } & Record<string, unknown>
103
-
104
- export function watchStream(options: WatchOptions): Response {
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> {
105
46
  if (options.draining?.aborted) return new Response(RETRY, { headers: SSE_HEADERS })
106
- const { registry, userID } = options
47
+ const { userID, actor } = options
107
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 })
108
52
  const encoder = new TextEncoder()
109
- /** Per session (roots and subagents): its own state and open blocker IDs. */
110
- const known = new Map<string, Known>()
111
- /** Catalog sessions of the user, ID → title. */
112
- let catalog = new Map<string, string>()
53
+ const stop = new AbortController()
113
54
 
114
- const own = () => {
115
- const out = new Map<string, { title: string; status: LiveStatus[] }>()
116
- for (const machine of registry.catalog()) {
117
- for (const process of machine.processes) {
118
- for (const s of process.sessions) {
119
- if (!out.has(s.id) && registry.sessionTenant(s.id) === userID) out.set(s.id, { title: s.title, status: s.status })
120
- }
121
- }
122
- }
123
- return out
124
- }
125
- const remember = (entry: LiveStatus) => known.set(entry.id, { state: entry.state, blockers: new Set(entry.blockers.map((b) => b.id)) })
126
- const seed = () => {
127
- const sessions = own()
128
- for (const { status } of sessions.values()) for (const entry of status) if (!known.has(entry.id)) remember(entry)
129
- return sessions
130
- }
131
- catalog = new Map([...seed()].map(([id, s]) => [id, s.title]))
132
-
133
- const wanted = (event: WatchEvent) => {
134
- if (options.blocked && !event.type.startsWith("blocker.")) return false
135
- if (!options.sessions) return true
136
- return [event.sessionID, event.rootID, event.senderID].some((id) => id !== undefined && options.sessions!.has(id))
137
- }
138
-
139
- /** Events for a status tree (root first), against what was known. */
140
- const statusEvents = (rootID: string, sessions: readonly LiveStatus[]): WatchEvent[] => {
141
- const out: WatchEvent[] = []
142
- for (const entry of sessions) {
143
- const before = known.get(entry.id)
144
- const open = new Set(entry.blockers.map((b) => b.id))
145
- for (const b of entry.blockers) {
146
- if (!before?.blockers.has(b.id)) out.push({ type: "blocker.opened", sessionID: entry.id, rootID, blocker: { kind: b.kind, id: b.id, summary: b.summary } })
147
- }
148
- for (const id of before?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", sessionID: entry.id, rootID, id })
149
- if (before?.state !== entry.state) out.push({ type: "state", sessionID: entry.id, rootID, state: entry.state, ...(before ? { prev: before.state } : {}) })
150
- remember(entry)
151
- }
152
- return out
153
- }
154
-
155
- const catalogEvents = (): WatchEvent[] => {
156
- const sessions = seed()
157
- const out: WatchEvent[] = []
158
- for (const [id, s] of sessions) if (!catalog.has(id)) out.push({ type: "session.added", sessionID: id, title: s.title })
159
- for (const id of catalog.keys()) if (!sessions.has(id)) out.push({ type: "session.removed", sessionID: id })
160
- catalog = new Map([...sessions].map(([id, s]) => [id, s.title]))
161
- return out
162
- }
163
-
164
- let cleanup = () => {}
165
55
  const stream = new ReadableStream<Uint8Array>({
166
- start(controller) {
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
+ }
167
66
  const write = (chunk: string) => {
67
+ if (!open) return
168
68
  try {
169
69
  controller.enqueue(encoder.encode(chunk))
170
70
  } catch {
171
- cleanup()
71
+ close()
172
72
  }
173
73
  }
174
- const send = (event: WatchEvent) => write(`data: ${JSON.stringify({ ...event, at: now() })}\n\n`)
175
- const unsubscribe = registry.subscribe((message, tenant) => {
176
- let events: WatchEvent[] = []
177
- if (message.type === "catalog") events = catalogEvents()
178
- else if (message.type === "status" && tenant === userID) events = statusEvents(message.sessionID, message.sessions)
179
- for (const event of events) if (wanted(event)) send(event)
180
- })
181
- const messages = options.messages
182
- const unsubscribeMessages = messages?.feed.subscribe((user, message) => {
183
- if (user !== userID) return
184
- if (messages.party !== undefined && partyKey(message.from) !== messages.party && partyKey(message.recipient) !== messages.party) return
185
- const event = messageEvent(message)
186
- if (wanted(event)) send(event)
187
- })
188
- const launches = options.launches
189
- const unsubscribeLaunches = launches?.feed.subscribe((user, launch) => {
190
- if (user !== userID || (launches.by !== undefined && launch.by !== launches.by)) return
191
- const event = launchEvent(launch)
192
- if (wanted(event)) send(event)
193
- })
194
- // A named event (not a comment) so clients can tell a live stream from a dead one.
195
- const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), options.heartbeatMs ?? WATCH_HEARTBEAT_MS)
196
- cleanup = () => {
197
- clearInterval(heartbeat)
198
- unsubscribe()
199
- unsubscribeMessages?.()
200
- unsubscribeLaunches?.()
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))
201
79
  }
202
- options.signal.addEventListener("abort", () => cleanup(), { once: true })
203
- send({ type: "ready" })
80
+ options.signal.addEventListener("abort", () => close(), { once: true })
204
81
  options.draining?.addEventListener(
205
82
  "abort",
206
83
  () => {
207
84
  write(RETRY)
208
- cleanup()
209
- try {
210
- controller.close()
211
- } catch {}
85
+ close()
212
86
  },
213
87
  { once: true },
214
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()
215
106
  },
216
107
  cancel() {
217
- cleanup()
108
+ stop.abort()
218
109
  },
219
110
  })
220
111
  return new Response(stream, { headers: SSE_HEADERS })
@@ -13,13 +13,13 @@ import type { Store } from "../store"
13
13
  import type { AgentActor } from "./actor"
14
14
  import { AgentError } from "./errors"
15
15
  import type { AgentRoute } from "./routes"
16
- import type { WebhookOwner, Webhooks } from "./webhooks"
16
+ import { webhookEventScopes, type WebhookOwner, type Webhooks } from "./webhooks"
17
17
 
18
18
  // A literal: routes.ts imports this module.
19
19
  const PATH = "/api/agent/webhooks"
20
20
  const NO_STORE = { "cache-control": "no-store" }
21
21
  /** Users; apps with sessions:launch. */
22
- export const WEBHOOK_SCOPE = "sessions:launch"
22
+ export const WEBHOOK_SCOPE = "events:subscribe"
23
23
  const ALLOW = { scope: WEBHOOK_SCOPE } as const
24
24
 
25
25
  export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
@@ -29,6 +29,13 @@ const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind ===
29
29
 
30
30
  const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
31
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
+
32
39
  async function bodyOf(request: Request): Promise<Record<string, unknown>> {
33
40
  const raw = JSON.parse(await request.text()) as unknown
34
41
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
@@ -43,6 +50,7 @@ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audi
43
50
  allow: ALLOW,
44
51
  async handle({ request, actor, user }) {
45
52
  const input = await bodyOf(request)
53
+ requireEventScopes(actor, input.events)
46
54
  const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
47
55
  await store?.audit({ actor: user.id, action: "webhook.create", target: result.webhook.id, detail: new URL(result.webhook.url).host })
48
56
  return Response.json(result, { status: 201, headers: NO_STORE })
@@ -27,12 +27,20 @@ import {
27
27
  import type { Clock, Locks, StateStore, Timers } from "../backend/types"
28
28
  import { publicFetch, PublicURLError, publicURLProblem, type ResolveHost } from "../push-target"
29
29
  import { AgentError } from "./errors"
30
+ import type { AgentEvent, EventAudience, EventFamily } from "./events"
30
31
  import { LAUNCH_STATES, type Launch } from "./launch-store"
31
32
 
32
33
  export const WEBHOOKS = "webhooks"
33
34
  export const WEBHOOK_OUTBOX = "webhook-outbox"
34
35
  /** Every event type a webhook can receive (phase 4: launch changes). */
35
- export const WEBHOOK_EVENT_TYPES: readonly string[] = LAUNCH_STATES.map((state) => `launch.${state}`)
36
+ export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
37
+ "session.added", "session.removed", "session.changed", "session.archived", "session.named",
38
+ "session.pinned",
39
+ "blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
40
+ "message.changed", "launch.changed",
41
+ ]
42
+ /** Includes persisted pre-canonical launch registrations for rolling rollback compatibility. */
43
+ export const WEBHOOK_EVENT_TYPES: readonly string[] = [...CANONICAL_WEBHOOK_EVENT_TYPES, ...LAUNCH_STATES.map((state) => `launch.${state}`)]
36
44
  export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
37
45
  export const MAX_WEBHOOKS = 20
38
46
  export const MAX_WEBHOOK_URL = 2048
@@ -133,6 +141,8 @@ export interface WebhooksDeps {
133
141
  readonly fetch?: typeof globalThis.fetch
134
142
  /** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
135
143
  readonly appAlive?: (userID: string, tokenID: string) => boolean
144
+ /** Current scopes of an app token; absent preserves the legacy liveness-only check. */
145
+ readonly appScopes?: (userID: string, tokenID: string) => readonly string[] | undefined
136
146
  readonly backoffMs?: readonly number[]
137
147
  readonly timeoutMs?: number
138
148
  readonly maxResponseBytes?: number
@@ -165,14 +175,30 @@ function matches(pattern: string, type: string): boolean {
165
175
  function eventsOf(raw: unknown): string[] {
166
176
  if (raw === undefined) return [...DEFAULT_WEBHOOK_EVENTS]
167
177
  if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_WEBHOOK_EVENTS || !raw.every((e): e is string => typeof e === "string")) {
168
- throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*"]`)
178
+ throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*", "message.changed"]`)
169
179
  }
170
180
  for (const pattern of raw) {
171
- if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, `Use launch.* or one of ${WEBHOOK_EVENT_TYPES.join(", ")}`)
181
+ if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, "Use a canonical event type or family wildcard")
172
182
  }
173
183
  return [...new Set(raw)]
174
184
  }
175
185
 
186
+ export function webhookEventScopes(patterns: readonly string[]): string[] {
187
+ const scopes = new Set<string>()
188
+ if (patterns.some((pattern) => LAUNCH_STATES.some((state) => matches(pattern, `launch.${state}`)))) scopes.add("sessions:launch")
189
+ for (const pattern of patterns) for (const type of CANONICAL_WEBHOOK_EVENT_TYPES) if (matches(pattern, type)) {
190
+ if (type === "message.changed") scopes.add("messages:read")
191
+ else if (type === "launch.changed") scopes.add("sessions:launch")
192
+ else if (["session.archived", "session.named", "session.pinned", "role.claimed", "role.released"].includes(type)) scopes.add("sessions:manage")
193
+ else scopes.add("sessions:read")
194
+ }
195
+ return [...scopes]
196
+ }
197
+
198
+ export function canonicalWebhookEvent(source: string, userID: string, event: AgentEvent): WebhookEvent {
199
+ return { id: event.id, type: event.type, source, subject: event.subject.launchID ?? event.subject.messageID ?? event.subject.sessionID ?? event.subject.machineID ?? event.id, time: new Date(event.at).toISOString(), user: userID, schemaVersion: 1, data: { subject: event.subject, data: event.data } }
200
+ }
201
+
176
202
  const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
177
203
 
178
204
  const deliveryView = (entry: OutboxEntry): DeliveryView => ({
@@ -286,8 +312,9 @@ export class Webhooks {
286
312
  * only when `by` (who caused it) is that app. Resolves with how many deliveries were newly queued (an event
287
313
  * already queued for a registration is not queued again).
288
314
  */
289
- async publish(userID: string, event: WebhookEvent, options: { readonly by?: string } = {}): Promise<number> {
290
- const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by) && r.events.some((p) => matches(p, event.type)))
315
+ async publish(userID: string, event: WebhookEvent, options: { readonly by?: string; readonly owners?: readonly string[]; readonly matchTypes?: readonly string[] } = {}): Promise<number> {
316
+ const matchTypes = options.matchTypes ?? [event.type]
317
+ const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by || options.owners?.includes(r.by)) && r.events.some((p) => matchTypes.some((type) => matches(p, type))))
291
318
  if (!targets.length) return 0
292
319
  const max = this.deps.maxOutbox ?? WEBHOOK_OUTBOX_MAX
293
320
  let size = (await this.deps.state.list(userID, WEBHOOK_OUTBOX)).length
@@ -308,6 +335,15 @@ export class Webhooks {
308
335
  return queued
309
336
  }
310
337
 
338
+ publishCanonical(userID: string, event: AgentEvent, _family: EventFamily, audience?: EventAudience): Promise<number> {
339
+ const legacyLaunchType = event.type === "launch.changed" && typeof event.data.state === "string" ? `launch.${event.data.state}` : undefined
340
+ return this.publish(userID, canonicalWebhookEvent(this.deps.source, userID, event), {
341
+ ...(audience?.by ? { by: audience.by } : {}),
342
+ ...(audience?.parties ? { owners: audience.parties.filter((party) => party.startsWith("app:")) } : {}),
343
+ ...(legacyLaunchType ? { matchTypes: [event.type, legacyLaunchType] } : {}),
344
+ })
345
+ }
346
+
311
347
  /** A launch change (the LaunchFeed): `data` is the launch as the API shows it. */
312
348
  async onLaunch(userID: string, launch: Launch, data: unknown): Promise<number> {
313
349
  return this.publish(userID, launchEvent(this.deps.source, launch, data), launch.by !== undefined ? { by: launch.by } : {})
@@ -401,6 +437,14 @@ export class Webhooks {
401
437
  await this.remove(userID, registration.id)
402
438
  return
403
439
  }
440
+ if (registration.by !== "user" && this.deps.appScopes) {
441
+ const scopes = this.deps.appScopes(userID, registration.by.slice("app:".length))
442
+ const required = ["events:subscribe", ...webhookEventScopes(registration.events)]
443
+ if (!scopes || required.some((scope) => !scopes.includes(scope))) {
444
+ await this.remove(userID, registration.id)
445
+ return
446
+ }
447
+ }
404
448
  const { last, final } = await this.send(registration, entry, now, timeoutMs)
405
449
  const attempts = entry.attempts + 1
406
450
  const delivered = last.status !== undefined && last.status >= 200 && last.status < 300