@capybara-hq/kernel 0.1.0-rc.12 → 0.1.0-rc.14

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 (267) hide show
  1. package/dist/admin/admin.schemas.d.ts +13 -0
  2. package/dist/agents/agent-lookup.d.ts +5 -0
  3. package/dist/agents/agent-lookup.js +15 -0
  4. package/dist/background/background.module.js +2 -2
  5. package/dist/background/launch-lock.d.ts +1 -1
  6. package/dist/background/launch-lock.js +1 -1
  7. package/dist/background/poll.job.d.ts +2 -2
  8. package/dist/background/poll.job.js +10 -10
  9. package/dist/background/stop.d.ts +1 -1
  10. package/dist/background/stop.js +3 -3
  11. package/dist/background/store.d.ts +5 -5
  12. package/dist/background/store.js +5 -5
  13. package/dist/background/tasks.service.d.ts +6 -6
  14. package/dist/background/tasks.service.js +13 -13
  15. package/dist/catalog/admin-catalog.controller.d.ts +13 -2
  16. package/dist/catalog/admin-catalog.controller.js +24 -3
  17. package/dist/catalog/admin-catalog.schemas.d.ts +10 -0
  18. package/dist/catalog/admin-catalog.schemas.js +7 -0
  19. package/dist/catalog/catalog-content.d.ts +9 -1
  20. package/dist/catalog/catalog-content.js +2 -1
  21. package/dist/catalog/catalog-files.d.ts +3 -2
  22. package/dist/catalog/catalog-files.js +8 -5
  23. package/dist/catalog/component-scope.service.d.ts +14 -1
  24. package/dist/catalog/component-scope.service.js +12 -2
  25. package/dist/catalog/marketplaces.service.d.ts +1 -1
  26. package/dist/catalog/org-catalog.controller.d.ts +5 -4
  27. package/dist/catalog/org-catalog.controller.js +5 -4
  28. package/dist/catalog/org-catalog.schemas.d.ts +18 -2
  29. package/dist/catalog/org-catalog.schemas.js +16 -2
  30. package/dist/catalog/org-catalog.service.js +8 -2
  31. package/dist/catalog/plugin-import.js +1 -1
  32. package/dist/catalog/plugin-package.js +37 -9
  33. package/dist/catalog/plugin-upgrade.d.ts +15 -1
  34. package/dist/catalog/plugin-upgrade.js +18 -5
  35. package/dist/channels/channels.module.js +2 -2
  36. package/dist/channels/connections.controller.d.ts +1 -1
  37. package/dist/channels/connections.controller.js +1 -1
  38. package/dist/channels/connections.service.d.ts +22 -5
  39. package/dist/channels/connections.service.js +56 -10
  40. package/dist/channels/inbound.d.ts +8 -8
  41. package/dist/channels/inbound.js +31 -31
  42. package/dist/channels/phrases.d.ts +27 -27
  43. package/dist/channels/phrases.js +27 -27
  44. package/dist/channels/webhook.d.ts +21 -1
  45. package/dist/channels/webhook.js +39 -4
  46. package/dist/config/docs.js +1 -1
  47. package/dist/config/schema.d.ts +2 -2
  48. package/dist/config/schema.js +21 -21
  49. package/dist/conversations/conversations.controller.d.ts +11 -11
  50. package/dist/conversations/conversations.controller.js +15 -15
  51. package/dist/conversations/conversations.module.d.ts +1 -1
  52. package/dist/conversations/conversations.module.js +7 -7
  53. package/dist/conversations/conversations.schemas.js +1 -1
  54. package/dist/conversations/conversations.service.d.ts +14 -14
  55. package/dist/conversations/conversations.service.js +19 -19
  56. package/dist/conversations/{environment-status.d.ts → device-status.d.ts} +9 -9
  57. package/dist/conversations/{environment-status.js → device-status.js} +18 -18
  58. package/dist/conversations/event-log.d.ts +1 -1
  59. package/dist/conversations/event-log.js +1 -1
  60. package/dist/conversations/events.d.ts +1 -1
  61. package/dist/conversations/events.js +2 -2
  62. package/dist/ctx/create-ctx.d.ts +26 -3
  63. package/dist/ctx/create-ctx.js +134 -26
  64. package/dist/db/kernel-schema.d.ts +37 -25
  65. package/dist/{environments → devices}/access.d.ts +19 -19
  66. package/dist/{environments → devices}/access.js +28 -28
  67. package/dist/devices/conversation-devices.d.ts +75 -0
  68. package/dist/{environments/conversation-environments.js → devices/conversation-devices.js} +71 -72
  69. package/dist/devices/devices.controller.d.ts +36 -0
  70. package/dist/{environments/environments.controller.js → devices/devices.controller.js} +37 -37
  71. package/dist/devices/devices.module.d.ts +5 -13
  72. package/dist/devices/devices.module.js +16 -29
  73. package/dist/devices/devices.schemas.d.ts +3 -0
  74. package/dist/devices/devices.schemas.js +14 -0
  75. package/dist/{environments/environments.service.d.ts → devices/devices.service.d.ts} +70 -70
  76. package/dist/{environments/environments.service.js → devices/devices.service.js} +122 -122
  77. package/dist/devices/dto.d.ts +13 -54
  78. package/dist/devices/dto.js +7 -39
  79. package/dist/devices/label.d.ts +11 -0
  80. package/dist/{environments → devices}/label.js +7 -7
  81. package/dist/{environments → devices}/legacy-cleanup.job.d.ts +3 -3
  82. package/dist/{environments → devices}/legacy-cleanup.job.js +3 -3
  83. package/dist/errors.d.ts +29 -25
  84. package/dist/errors.js +29 -25
  85. package/dist/evals/recorded-tools.d.ts +2 -2
  86. package/dist/evals/recorded-tools.js +6 -6
  87. package/dist/extensions/admin-ops.js +1 -0
  88. package/dist/extensions/admin-view.js +2 -0
  89. package/dist/extensions/effective-config.d.ts +6 -0
  90. package/dist/extensions/effective-config.js +18 -7
  91. package/dist/extensions/guide-places.d.ts +66 -0
  92. package/dist/extensions/guide-places.js +152 -0
  93. package/dist/extensions/host.d.ts +13 -2
  94. package/dist/extensions/host.js +27 -6
  95. package/dist/extensions/instance.d.ts +17 -2
  96. package/dist/extensions/instance.js +21 -0
  97. package/dist/extensions/ledger.d.ts +1 -1
  98. package/dist/extensions/permission-set.d.ts +16 -2
  99. package/dist/extensions/permission-set.js +17 -3
  100. package/dist/extensions/permission-view.d.ts +10 -1
  101. package/dist/extensions/plugin-guides.d.ts +207 -54
  102. package/dist/extensions/plugin-guides.js +436 -165
  103. package/dist/files/previewers.d.ts +1 -1
  104. package/dist/files/previewers.js +1 -1
  105. package/dist/http/api.module.js +16 -6
  106. package/dist/http/gateway-routes.d.ts +1 -1
  107. package/dist/http/gateway-routes.js +1 -1
  108. package/dist/identity/settings.d.ts +32 -0
  109. package/dist/identity/settings.js +26 -2
  110. package/dist/infra/config/kernel-config.service.js +4 -4
  111. package/dist/infra/lock/lock.service.d.ts +8 -0
  112. package/dist/infra/lock/lock.service.js +27 -0
  113. package/dist/infra/throttler/named-throttlers.d.ts +4 -1
  114. package/dist/infra/throttler/named-throttlers.js +16 -1
  115. package/dist/infra/throttler/throttler.guard.js +1 -1
  116. package/dist/kernel.module.js +3 -3
  117. package/dist/{devices → local-devices}/device-auth.controller.d.ts +6 -6
  118. package/dist/{devices → local-devices}/device-auth.controller.js +5 -5
  119. package/dist/{devices → local-devices}/device-auth.d.ts +20 -20
  120. package/dist/{devices → local-devices}/device-auth.js +51 -53
  121. package/dist/local-devices/device-auth.module.d.ts +7 -0
  122. package/dist/{devices → local-devices}/device-auth.module.js +5 -5
  123. package/dist/{devices → local-devices}/device-routes.d.ts +3 -3
  124. package/dist/{devices → local-devices}/device-routes.js +2 -2
  125. package/dist/local-devices/dto.d.ts +66 -0
  126. package/dist/local-devices/dto.js +44 -0
  127. package/dist/{devices → local-devices}/keys.d.ts +11 -11
  128. package/dist/{devices → local-devices}/keys.js +11 -11
  129. package/dist/{devices/device-gateway.d.ts → local-devices/local-device-gateway.d.ts} +14 -14
  130. package/dist/{devices/device-gateway.js → local-devices/local-device-gateway.js} +44 -44
  131. package/dist/{devices/device-provider.d.ts → local-devices/local-device-provider.d.ts} +15 -15
  132. package/dist/{devices/device-provider.js → local-devices/local-device-provider.js} +23 -23
  133. package/dist/local-devices/local-devices.module.d.ts +16 -0
  134. package/dist/local-devices/local-devices.module.js +44 -0
  135. package/dist/{devices → local-devices}/machines.d.ts +26 -26
  136. package/dist/{devices → local-devices}/machines.js +71 -71
  137. package/dist/{devices → local-devices}/relay.d.ts +15 -15
  138. package/dist/{devices → local-devices}/relay.js +55 -55
  139. package/dist/{devices → local-devices}/targets.d.ts +8 -8
  140. package/dist/{devices → local-devices}/targets.js +14 -14
  141. package/dist/orgs/offboarding.d.ts +6 -6
  142. package/dist/orgs/offboarding.js +12 -12
  143. package/dist/orgs/settings.service.d.ts +28 -0
  144. package/dist/orgs/settings.service.js +20 -1
  145. package/dist/overrides.d.ts +3 -1
  146. package/dist/overrides.js +2 -0
  147. package/dist/platform/exclusions.js +48 -8
  148. package/dist/platform/operation.js +10 -2
  149. package/dist/platform/ops/admin.ops.js +8 -2
  150. package/dist/platform/ops/connectors.ops.js +2 -0
  151. package/dist/platform/ops/conversations.ops.d.ts +3 -3
  152. package/dist/platform/ops/conversations.ops.js +9 -9
  153. package/dist/platform/ops/devices.ops.d.ts +26 -0
  154. package/dist/platform/ops/devices.ops.js +311 -0
  155. package/dist/platform/ops/org-settings.ops.js +2 -2
  156. package/dist/platform/ops/orgs.ops.js +1 -0
  157. package/dist/platform/ops/setup.ops.d.ts +24 -0
  158. package/dist/platform/ops/setup.ops.js +212 -0
  159. package/dist/platform/ops/ui.ops.d.ts +8 -0
  160. package/dist/platform/ops/ui.ops.js +41 -0
  161. package/dist/platform/platform-ops.module.d.ts +4 -2
  162. package/dist/platform/platform-ops.module.js +13 -5
  163. package/dist/platform/route-schemas.js +11 -3
  164. package/dist/roles/gateway-role.d.ts +3 -3
  165. package/dist/roles/gateway-role.js +4 -4
  166. package/dist/roles/gateway.module.d.ts +1 -1
  167. package/dist/roles/gateway.module.js +5 -5
  168. package/dist/roles/worker.module.js +1 -1
  169. package/dist/runtime/context/build.d.ts +1 -1
  170. package/dist/runtime/context/build.js +6 -6
  171. package/dist/runtime/context/compaction.js +1 -1
  172. package/dist/runtime/context/{environment-paths.d.ts → device-paths.d.ts} +11 -11
  173. package/dist/runtime/context/{environment-paths.js → device-paths.js} +13 -13
  174. package/dist/runtime/maintenance.d.ts +1 -1
  175. package/dist/sandbox/jobs.d.ts +13 -13
  176. package/dist/sandbox/jobs.js +20 -20
  177. package/dist/sandbox/leases.d.ts +9 -9
  178. package/dist/sandbox/leases.js +14 -14
  179. package/dist/sandbox/providers.d.ts +8 -8
  180. package/dist/sandbox/providers.js +8 -8
  181. package/dist/sandbox/sandbox.module.d.ts +1 -1
  182. package/dist/sandbox/sandbox.module.js +1 -1
  183. package/dist/sandbox/sandbox.service.d.ts +76 -76
  184. package/dist/sandbox/sandbox.service.js +230 -230
  185. package/dist/setup/kernel-checks.d.ts +91 -0
  186. package/dist/setup/kernel-checks.js +295 -0
  187. package/dist/setup/oauth-token-probe.d.ts +47 -0
  188. package/dist/setup/oauth-token-probe.js +108 -0
  189. package/dist/setup/redact-check.d.ts +18 -0
  190. package/dist/setup/redact-check.js +46 -0
  191. package/dist/setup/setup-check-registry.d.ts +86 -0
  192. package/dist/setup/setup-check-registry.js +75 -0
  193. package/dist/setup/setup-checks.controller.d.ts +28 -0
  194. package/dist/setup/setup-checks.controller.js +78 -0
  195. package/dist/setup/setup-checks.schemas.d.ts +38 -0
  196. package/dist/setup/setup-checks.schemas.js +43 -0
  197. package/dist/setup/setup-checks.service.d.ts +144 -0
  198. package/dist/setup/setup-checks.service.js +683 -0
  199. package/dist/setup/setup-guides.controller.d.ts +18 -0
  200. package/dist/setup/setup-guides.controller.js +48 -0
  201. package/dist/setup/setup-guides.schemas.d.ts +10 -0
  202. package/dist/setup/setup-guides.schemas.js +6 -0
  203. package/dist/setup/setup-guides.service.d.ts +126 -0
  204. package/dist/setup/setup-guides.service.js +315 -0
  205. package/dist/setup/setup.module.d.ts +8 -0
  206. package/dist/setup/setup.module.js +31 -0
  207. package/dist/tools/builtins/builtin-tools.d.ts +2 -2
  208. package/dist/tools/builtins/builtin-tools.js +5 -5
  209. package/dist/tools/builtins/platform.js +1 -1
  210. package/dist/tools/builtins/sandbox/bash.js +6 -6
  211. package/dist/tools/builtins/sandbox/common.d.ts +25 -25
  212. package/dist/tools/builtins/sandbox/common.js +51 -51
  213. package/dist/tools/builtins/sandbox/edit.js +2 -2
  214. package/dist/tools/builtins/sandbox/index.d.ts +1 -1
  215. package/dist/tools/builtins/sandbox/index.js +1 -1
  216. package/dist/tools/builtins/sandbox/task-output.d.ts +6 -6
  217. package/dist/tools/builtins/sandbox/task-output.js +10 -10
  218. package/dist/tools/builtins/sandbox/task-stop.js +2 -2
  219. package/dist/tools/builtins/sandbox/write.js +2 -2
  220. package/dist/tools/builtins/skill-load.d.ts +1 -1
  221. package/dist/tools/builtins/skill-load.js +2 -2
  222. package/dist/tools/connectors.controller.d.ts +28 -11
  223. package/dist/tools/connectors.controller.js +49 -14
  224. package/dist/tools/connectors.schemas.d.ts +4 -0
  225. package/dist/tools/connectors.schemas.js +4 -0
  226. package/dist/tools/connectors.service.d.ts +30 -2
  227. package/dist/tools/connectors.service.js +98 -3
  228. package/dist/tools/credentials.d.ts +20 -0
  229. package/dist/tools/credentials.js +90 -1
  230. package/dist/tools/oauth/tokens.d.ts +20 -0
  231. package/dist/tools/oauth/tokens.js +49 -24
  232. package/dist/tools/pipeline.js +4 -3
  233. package/dist/tools/registry.d.ts +2 -2
  234. package/dist/tools/tools.module.js +3 -3
  235. package/dist/ui/admin-ui.controller.d.ts +56 -0
  236. package/dist/ui/admin-ui.controller.js +192 -0
  237. package/dist/ui/arrangement.d.ts +74 -0
  238. package/dist/ui/arrangement.js +98 -0
  239. package/dist/ui/embed-token.d.ts +2 -2
  240. package/dist/ui/embed-token.js +30 -5
  241. package/dist/ui/registry.d.ts +29 -14
  242. package/dist/ui/registry.js +31 -10
  243. package/dist/ui/slots.controller.d.ts +11 -0
  244. package/dist/ui/slots.controller.js +25 -0
  245. package/dist/ui/ui-stream.d.ts +20 -3
  246. package/dist/ui/ui-stream.js +25 -7
  247. package/dist/ui/ui.schemas.d.ts +66 -0
  248. package/dist/ui/ui.schemas.js +65 -0
  249. package/dist/ui/views.controller.d.ts +35 -49
  250. package/dist/ui/views.controller.js +48 -70
  251. package/dist/ui/views.d.ts +84 -18
  252. package/dist/ui/views.js +201 -38
  253. package/package.json +3 -3
  254. package/src/migrations/0105_devices_rename.sql +136 -0
  255. package/src/migrations/0106_setup_guides_v2.sql +10 -0
  256. package/dist/devices/device-auth.module.d.ts +0 -7
  257. package/dist/environments/conversation-environments.d.ts +0 -75
  258. package/dist/environments/dto.d.ts +0 -25
  259. package/dist/environments/dto.js +0 -12
  260. package/dist/environments/environments.controller.d.ts +0 -36
  261. package/dist/environments/environments.module.d.ts +0 -8
  262. package/dist/environments/environments.module.js +0 -31
  263. package/dist/environments/environments.schemas.d.ts +0 -3
  264. package/dist/environments/environments.schemas.js +0 -14
  265. package/dist/environments/label.d.ts +0 -11
  266. package/dist/platform/ops/environments.ops.d.ts +0 -26
  267. package/dist/platform/ops/environments.ops.js +0 -311
@@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto";
11
11
  import { CHANNEL_EXTENSION_POINT, } from "@capybara-hq/plugin-sdk";
12
12
  import { Inject, Injectable } from "@nestjs/common";
13
13
  import { sql } from "kysely";
14
- import { configFields, getPath, secretValues } from "../config/fields.js";
14
+ import { configFields, getPath, secretScalars, secretValues } from "../config/fields.js";
15
15
  import { queryEnabledPlugins } from "../ctx/create-ctx.js";
16
16
  import { createSecrets } from "../ctx/secrets.js";
17
17
  import { ApiError } from "../errors.js";
@@ -135,7 +135,7 @@ let ChannelConnections = class ChannelConnections {
135
135
  config, events,
136
136
  /** Secret values handled here (written or decrypted) are redacted from the logs (`addOrgSecrets`). */
137
137
  log, txHost,
138
- /** The setup guide acks (spec 2026-11-08-plugin-setup-guides-design.md §6). */
138
+ /** The setup guide acks (spec 2026-11-09-setup-guides-v2-design.md §11). */
139
139
  guides) {
140
140
  this.db = db;
141
141
  this.orgLock = orgLock;
@@ -336,7 +336,9 @@ let ChannelConnections = class ChannelConnections {
336
336
  const id = randomUUID();
337
337
  const { plain, secrets } = split(schema, full);
338
338
  // the guide the 新建渠道 page showed: the plugin's current one, for the default mode
339
- const guideAck = await this.guides.currentAck(input.plugin, "long_connection");
339
+ const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", {
340
+ mode: "long_connection",
341
+ });
340
342
  const row = await this.insertIn({
341
343
  orgId,
342
344
  id,
@@ -440,11 +442,13 @@ let ChannelConnections = class ChannelConnections {
440
442
  // A mode switch sets the platform app up for the new mode: the guide of that mode is the one
441
443
  // on the page. Only an ack without drift moves on: a drifted one stays until the person
442
444
  // confirms through guide-ack, so switching modes never clears the update notice (spec
443
- // 2026-11-08-plugin-setup-guides-design.md §6)
445
+ // 2026-11-09-setup-guides-v2-design.md §11)
444
446
  const guideAck = modeChanged &&
445
447
  patch.inboundMode !== undefined &&
446
- !(await this.guides.ackDrifted(current.plugin, current.guide_ack ?? null))
447
- ? await this.guides.currentAck(current.plugin, patch.inboundMode)
448
+ !(await this.guides.ackDrifted(current.plugin, "channel-connection", current.guide_ack ?? null))
449
+ ? await this.guides.currentAck(current.plugin, "channel-connection", {
450
+ mode: patch.inboundMode,
451
+ })
448
452
  : undefined;
449
453
  const row = await this.updateIn({
450
454
  orgId,
@@ -533,15 +537,21 @@ let ChannelConnections = class ChannelConnections {
533
537
  }
534
538
  /**
535
539
  * 「我已更新应用」: records the plugin's current channel-connection guide for the connection's
536
- * mode as the one it is set up with. `hash` is the template hash of the guide the person was
540
+ * mode as the one it is set up with. `hash` is the drift hash of the guide the person was
537
541
  * shown; when the plugin has moved on since, 409 `channel.guide_changed` (show the new guide
538
- * first). A plugin without a guide -> 404 `catalog.guide_not_found`.
542
+ * first). A plugin without a guide, or whose item the org's catalog does not show -> 404
543
+ * `catalog.guide_not_found`.
539
544
  */
540
- async ackGuide(orgId, id, hash, actorAccountId, actorTokenId) {
545
+ async ackGuide(org, id, hash, actorAccountId, actorTokenId) {
546
+ const { orgId } = org;
541
547
  const current = await this.live(orgId).where("id", "=", id).executeTakeFirst();
542
548
  if (!current)
543
549
  throw notFound();
544
- const ack = await this.guides.currentAck(current.plugin, current.inbound_mode);
550
+ // the guide read's visibility: an item the org's catalog does not show has no guide to ack
551
+ const ack = await this.guides.currentAck(current.plugin, "channel-connection", {
552
+ mode: current.inbound_mode,
553
+ org,
554
+ });
545
555
  if (ack === null) {
546
556
  throw new ApiError("catalog.guide_not_found", "The plugin has no channel setup guide");
547
557
  }
@@ -671,6 +681,42 @@ let ChannelConnections = class ChannelConnections {
671
681
  for (const r of rows)
672
682
  await this.changed(r.id, true);
673
683
  }
684
+ /**
685
+ * For setup checks (setup guides v2 §10.4): the input revision of a live connection of the org
686
+ * on `plugin`: the `updated_at` of its row and of its secrets row (ISO), never a value. Missing,
687
+ * deleted, of another org or plugin -> `channel.not_found`.
688
+ */
689
+ async checkRevision(orgId, plugin, id) {
690
+ const row = await this.live(orgId)
691
+ .where("id", "=", id)
692
+ .where("plugin", "=", plugin)
693
+ .executeTakeFirst();
694
+ if (!row)
695
+ throw notFound();
696
+ const secretRow = await this.db
697
+ .selectFrom("secrets")
698
+ .select("updated_at")
699
+ .where("namespace", "=", SECRETS_NAMESPACE)
700
+ .where("org_id", "=", orgId)
701
+ .where("key", "=", connectionSecretKey(id))
702
+ .executeTakeFirst();
703
+ const iso = (d) => new Date(d).toISOString();
704
+ return `${iso(row.updated_at)}|${secretRow ? iso(secretRow.updated_at) : "-"}`;
705
+ }
706
+ /**
707
+ * For setup checks (setup guides v2 §10.8): the stored secret values of a live connection of the
708
+ * org on `plugin` (every secret field, decrypted), what a check's result is redacted by. Missing,
709
+ * deleted, of another org or plugin -> `channel.not_found`. Never echo them.
710
+ */
711
+ async checkSecrets(orgId, plugin, id) {
712
+ const row = await this.live(orgId)
713
+ .where("id", "=", id)
714
+ .where("plugin", "=", plugin)
715
+ .executeTakeFirst();
716
+ if (!row)
717
+ throw notFound();
718
+ return secretScalars(Object.values(await this.readSecrets(orgId, id)));
719
+ }
674
720
  /**
675
721
  * The extension implementation and full config a connection needs to run (decrypted secrets
676
722
  * included, validated against `connectionSchema`). Connection missing / deleted ->
@@ -1,7 +1,7 @@
1
1
  import type { ChannelCard, InboundEvent, InboundSender, Logger } from "@capybara-hq/plugin-sdk";
2
2
  import type { Redis } from "ioredis";
3
3
  import { ConversationsService } from "../conversations/conversations.service.js";
4
- import { EnvironmentsService } from "../environments/environments.service.js";
4
+ import { DevicesService } from "../devices/devices.service.js";
5
5
  import { IdentityBinding } from "../identity/binding.js";
6
6
  import { ExternalIdentities } from "../identity/external.js";
7
7
  import { KernelDb } from "../infra/database/kernel-db.js";
@@ -63,7 +63,7 @@ export declare class ChannelInbound {
63
63
  /** The channel queue: inbound jobs (`onEvent` only dedupes and enqueues) and deliveries. */
64
64
  private readonly jobs;
65
65
  private readonly guests;
66
- private readonly environments;
66
+ private readonly devices;
67
67
  private readonly log;
68
68
  private readonly actions;
69
69
  constructor(kdb: KernelDb, redis: Pick<Redis, "set">,
@@ -74,7 +74,7 @@ export declare class ChannelInbound {
74
74
  /** The channel queue: inbound jobs (`onEvent` only dedupes and enqueues) and deliveries. */
75
75
  jobs: Pick<ChannelJobs, "enqueue" | "enqueueInbound">,
76
76
  /** 卡片操作:审批决定与提问回答(授权由这两个服务按点击者映射到的账号判定)。 */
77
- approvals: Pick<ApprovalsService, "decide">, questions: Pick<QuestionsService, "answer">, guests: Pick<AnonymousGuests, "ensure">, environments: Pick<EnvironmentsService, "list" | "imDefault" | "setImDefault">, log: Pick<Logger, "warn" | "info">);
77
+ approvals: Pick<ApprovalsService, "decide">, questions: Pick<QuestionsService, "answer">, guests: Pick<AnonymousGuests, "ensure">, devices: Pick<DevicesService, "list" | "imDefault" | "setImDefault">, log: Pick<Logger, "warn" | "info">);
78
78
  /**
79
79
  * 插件 `onEvent`:去重并入队后返回(见类注释)。返回后插件才向平台确认;抛错时插件不确认。
80
80
  */
@@ -95,13 +95,13 @@ export declare class ChannelInbound {
95
95
  handle(connectionId: string, e: InboundEvent): Promise<void>;
96
96
  private message;
97
97
  /**
98
- * Direct-message `/env` (environments spec §4.2 item 8): lists the person's usable environments,
98
+ * Direct-message `/device` (environments spec §4.2 item 8): lists the person's usable devices,
99
99
  * or with a name sets the IM default their next new conversation is bound to. The name matches
100
- * case-insensitively against each label (`environmentLabel`: a former default demoted by a
101
- * provider switch answers to e.g. "Personal (Docker sandbox)"); the default environment also
102
- * answers to 个人环境 / Personal.
100
+ * case-insensitively against each label (`deviceLabel`: a former default demoted by a
101
+ * provider switch answers to e.g. "Personal (Docker sandbox)"); the default device also
102
+ * answers to 个人设备 / Personal.
103
103
  */
104
- private envCommand;
104
+ private deviceCommand;
105
105
  /**
106
106
  * Posts the person's message: `/name args` as a skill invocation when the kernel accepts it, else
107
107
  * as typed (phase D spec §9). A retry of the job finds the first write by `clientMessageId`
@@ -11,8 +11,8 @@ import { randomUUID } from "node:crypto";
11
11
  import { Inject, Injectable } from "@nestjs/common";
12
12
  import { ConversationsService } from "../conversations/conversations.service.js";
13
13
  import { imSenderContext, recipientLocale, } from "../conversations/message-context.js";
14
- import { EnvironmentsService } from "../environments/environments.service.js";
15
- import { DEFAULT_ENVIRONMENT_ALIASES, environmentLabel } from "../environments/label.js";
14
+ import { DevicesService } from "../devices/devices.service.js";
15
+ import { DEFAULT_DEVICE_ALIASES, deviceLabel } from "../devices/label.js";
16
16
  import { ApiError } from "../errors.js";
17
17
  import { IdentityBinding } from "../identity/binding.js";
18
18
  import { ExternalIdentities } from "../identity/external.js";
@@ -75,7 +75,7 @@ let ChannelInbound = class ChannelInbound {
75
75
  directorySync;
76
76
  jobs;
77
77
  guests;
78
- environments;
78
+ devices;
79
79
  log;
80
80
  actions;
81
81
  constructor(kdb, redis,
@@ -86,7 +86,7 @@ let ChannelInbound = class ChannelInbound {
86
86
  /** The channel queue: inbound jobs (`onEvent` only dedupes and enqueues) and deliveries. */
87
87
  jobs,
88
88
  /** 卡片操作:审批决定与提问回答(授权由这两个服务按点击者映射到的账号判定)。 */
89
- approvals, questions, guests, environments, log) {
89
+ approvals, questions, guests, devices, log) {
90
90
  this.kdb = kdb;
91
91
  this.redis = redis;
92
92
  this.locks = locks;
@@ -97,7 +97,7 @@ let ChannelInbound = class ChannelInbound {
97
97
  this.directorySync = directorySync;
98
98
  this.jobs = jobs;
99
99
  this.guests = guests;
100
- this.environments = environments;
100
+ this.devices = devices;
101
101
  this.log = log;
102
102
  this.actions = new ChannelCardActions({
103
103
  get db() {
@@ -216,16 +216,16 @@ let ChannelInbound = class ChannelInbound {
216
216
  await reply("newConversation");
217
217
  return;
218
218
  }
219
- const envCommand = !isGroup && threadAccount !== null ? parseEnvCommand(e.text) : null;
220
- if (envCommand !== null) {
221
- await this.envCommand(row.orgId, accountId, envCommand.name, locale, replyText);
219
+ const deviceCommand = !isGroup && threadAccount !== null ? parseDeviceCommand(e.text) : null;
220
+ if (deviceCommand !== null) {
221
+ await this.deviceCommand(row.orgId, accountId, deviceCommand.name, locale, replyText);
222
222
  return;
223
223
  }
224
224
  let conversationId = await findThread(db, k);
225
225
  if (conversationId === undefined) {
226
226
  let created;
227
227
  try {
228
- // bound to the person's IM default environment, else their default (environments spec
228
+ // bound to the person's IM default device, else their default (environments spec
229
229
  // §4.2 item 8); an anonymous sender's stays unbound (no sandbox)
230
230
  created = (await this.conversations.create(row.orgId, accountId, row.agentId, { im: true }))
231
231
  .id;
@@ -294,49 +294,49 @@ let ChannelInbound = class ChannelInbound {
294
294
  await this.jobs.enqueue(conversationId);
295
295
  }
296
296
  /**
297
- * Direct-message `/env` (environments spec §4.2 item 8): lists the person's usable environments,
297
+ * Direct-message `/device` (environments spec §4.2 item 8): lists the person's usable devices,
298
298
  * or with a name sets the IM default their next new conversation is bound to. The name matches
299
- * case-insensitively against each label (`environmentLabel`: a former default demoted by a
300
- * provider switch answers to e.g. "Personal (Docker sandbox)"); the default environment also
301
- * answers to 个人环境 / Personal.
299
+ * case-insensitively against each label (`deviceLabel`: a former default demoted by a
300
+ * provider switch answers to e.g. "Personal (Docker sandbox)"); the default device also
301
+ * answers to 个人设备 / Personal.
302
302
  */
303
- async envCommand(orgId, accountId, name, locale, replyText) {
304
- const { items, defaultId } = await this.environments.list(orgId, accountId);
303
+ async deviceCommand(orgId, accountId, name, locale, replyText) {
304
+ const { items, defaultId } = await this.devices.list(orgId, accountId);
305
305
  const usable = items.filter((v) => v.available);
306
- const labelOf = (v) => environmentLabel(v, locale);
307
- const current = (await this.environments.imDefault(orgId, accountId)) ?? defaultId;
306
+ const labelOf = (v) => deviceLabel(v, locale);
307
+ const current = (await this.devices.imDefault(orgId, accountId)) ?? defaultId;
308
308
  const list = usable
309
- .map((v) => `- ${labelOf(v)}${v.id === current ? phrase("envCurrent", locale) : ""}`)
309
+ .map((v) => `- ${labelOf(v)}${v.id === current ? phrase("deviceCurrent", locale) : ""}`)
310
310
  .join("\n");
311
311
  const fill = (key, vars) => phrase(key, locale).replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m);
312
312
  if (usable.length === 0)
313
- return replyText(phrase("envNone", locale));
313
+ return replyText(phrase("deviceNone", locale));
314
314
  if (name === null)
315
- return replyText(fill("envList", { list }));
315
+ return replyText(fill("deviceList", { list }));
316
316
  const wanted = name.toLowerCase();
317
317
  const is = (v) => labelOf(v).toLowerCase() === wanted;
318
318
  // a name the person gave wins over a generated label, and both over the default's aliases
319
319
  const hit = usable.find((v) => v.name !== null && is(v)) ??
320
320
  usable.find(is) ??
321
- usable.find((v) => v.isDefault && DEFAULT_ENVIRONMENT_ALIASES.includes(wanted));
321
+ usable.find((v) => v.isDefault && DEFAULT_DEVICE_ALIASES.includes(wanted));
322
322
  if (hit === undefined) {
323
323
  const unavailable = items.find((v) => !v.available && is(v));
324
324
  if (unavailable !== undefined) {
325
- return replyText(fill("envUnavailable", { name: labelOf(unavailable), list }));
325
+ return replyText(fill("deviceUnavailable", { name: labelOf(unavailable), list }));
326
326
  }
327
- return replyText(fill("envUnknown", { name, list }));
327
+ return replyText(fill("deviceUnknown", { name, list }));
328
328
  }
329
329
  try {
330
- await this.environments.setImDefault(orgId, accountId, hit.id);
330
+ await this.devices.setImDefault(orgId, accountId, hit.id);
331
331
  }
332
332
  catch (err) {
333
333
  // deleted or made unusable since the list was read
334
- if (err instanceof ApiError && err.code.startsWith("environment.")) {
335
- return replyText(fill("envUnknown", { name, list }));
334
+ if (err instanceof ApiError && err.code.startsWith("device.")) {
335
+ return replyText(fill("deviceUnknown", { name, list }));
336
336
  }
337
337
  throw err;
338
338
  }
339
- return replyText(fill("envSwitched", { name: labelOf(hit) }));
339
+ return replyText(fill("deviceSwitched", { name: labelOf(hit) }));
340
340
  }
341
341
  /**
342
342
  * Posts the person's message: `/name args` as a skill invocation when the kernel accepts it, else
@@ -464,13 +464,13 @@ ChannelInbound = __decorate([
464
464
  __param(9, Inject(ApprovalsService)),
465
465
  __param(10, Inject(QuestionsService)),
466
466
  __param(11, Inject(AnonymousGuests)),
467
- __param(12, Inject(EnvironmentsService)),
467
+ __param(12, Inject(DevicesService)),
468
468
  __param(13, Inject(KERNEL_LOGGER))
469
469
  ], ChannelInbound);
470
470
  export { ChannelInbound };
471
- /** `/env` or `/env <name>` (the exact command word, case-insensitive); anything else is null. */
472
- function parseEnvCommand(text) {
473
- const m = /^\/env(?:\s+([\s\S]*))?$/i.exec(text.trim());
471
+ /** `/device` or `/device <name>` (the exact command word, case-insensitive); anything else is null. */
472
+ function parseDeviceCommand(text) {
473
+ const m = /^\/device(?:\s+([\s\S]*))?$/i.exec(text.trim());
474
474
  if (m === null)
475
475
  return null;
476
476
  const name = m[1]?.trim();
@@ -11,33 +11,33 @@ export declare const CHANNEL_PHRASES: {
11
11
  readonly zh: "已开始新对话";
12
12
  readonly en: "Started a new conversation.";
13
13
  };
14
- /** 私聊 `/env`:列出可用环境(`{list}` 每行一个,当前的带标记)。 */
15
- readonly envList: {
16
- readonly zh: "你的环境:\n{list}\n发送 /env <名称> 选择下一个新对话使用的环境。";
17
- readonly en: "Your environments:\n{list}\nSend /env <name> to choose the one your next new conversation uses.";
18
- };
19
- /** 私聊 `/env <名称>` 之后(`{name}` 是环境名)。 */
20
- readonly envSwitched: {
21
- readonly zh: "下一个新对话将使用环境「{name}」,发送 /new 开始。";
22
- readonly en: "Your next new conversation will use the environment “{name}”; send /new to start one.";
23
- };
24
- /** 私聊 `/env <名称>` 没有匹配的可用环境(`{list}` 同 envList)。 */
25
- readonly envUnknown: {
26
- readonly zh: "没有找到名为「{name}」的环境。你的环境:\n{list}";
27
- readonly en: "No environment named “{name}”. Your environments:\n{list}";
28
- };
29
- /** `/env <name>` names an existing environment that is unavailable now (`{list}` as in envList). */
30
- readonly envUnavailable: {
31
- readonly zh: "环境「{name}」当前不可用(它的文件由未启用的沙箱提供方保存)。你的环境:\n{list}";
32
- readonly en: "The environment “{name}” is unavailable: its files are kept by a sandbox provider that is not active. Your environments:\n{list}";
33
- };
34
- /** 私聊 `/env` 时此人没有可用环境(部署没有云沙箱)。 */
35
- readonly envNone: {
36
- readonly zh: "你目前没有可用的环境。";
37
- readonly en: "You have no usable environments.";
38
- };
39
- /** 环境列表里当前 IM 默认环境的标记。 */
40
- readonly envCurrent: {
14
+ /** Direct message `/device`: lists the usable devices (`{list}` one per line, the current one marked). */
15
+ readonly deviceList: {
16
+ readonly zh: "你的设备:\n{list}\n发送 /device <名称> 选择下一个新对话使用的设备。";
17
+ readonly en: "Your devices:\n{list}\nSend /device <name> to choose the one your next new conversation uses.";
18
+ };
19
+ /** After a direct message `/device <name>` (`{name}` is the device's name). */
20
+ readonly deviceSwitched: {
21
+ readonly zh: "下一个新对话将使用设备「{name}」,发送 /new 开始。";
22
+ readonly en: "Your next new conversation will use the device “{name}”; send /new to start one.";
23
+ };
24
+ /** `/device <name>` matches no usable device (`{list}` as in deviceList). */
25
+ readonly deviceUnknown: {
26
+ readonly zh: "没有找到名为「{name}」的设备。你的设备:\n{list}";
27
+ readonly en: "No device named “{name}”. Your devices:\n{list}";
28
+ };
29
+ /** `/device <name>` names an existing device that is unavailable now (`{list}` as in deviceList). */
30
+ readonly deviceUnavailable: {
31
+ readonly zh: "设备「{name}」当前不可用(它的文件由未启用的沙箱提供方保存)。你的设备:\n{list}";
32
+ readonly en: "The device “{name}” is unavailable: its files are kept by a sandbox provider that is not active. Your devices:\n{list}";
33
+ };
34
+ /** Direct message `/device` when the person has no usable device (no cloud sandbox in the deployment). */
35
+ readonly deviceNone: {
36
+ readonly zh: "你目前没有可用的设备。";
37
+ readonly en: "You have no usable devices.";
38
+ };
39
+ /** The mark of the current IM default device in the device list. */
40
+ readonly deviceCurrent: {
41
41
  readonly zh: "(当前)";
42
42
  readonly en: " (current)";
43
43
  };
@@ -6,33 +6,33 @@
6
6
  export const CHANNEL_PHRASES = {
7
7
  /** 私聊 `/new` 之后。 */
8
8
  newConversation: { zh: "已开始新对话", en: "Started a new conversation." },
9
- /** 私聊 `/env`:列出可用环境(`{list}` 每行一个,当前的带标记)。 */
10
- envList: {
11
- zh: "你的环境:\n{list}\n发送 /env <名称> 选择下一个新对话使用的环境。",
12
- en: "Your environments:\n{list}\nSend /env <name> to choose the one your next new conversation uses.",
13
- },
14
- /** 私聊 `/env <名称>` 之后(`{name}` 是环境名)。 */
15
- envSwitched: {
16
- zh: "下一个新对话将使用环境「{name}」,发送 /new 开始。",
17
- en: "Your next new conversation will use the environment \u201c{name}\u201d; send /new to start one.",
18
- },
19
- /** 私聊 `/env <名称>` 没有匹配的可用环境(`{list}` 同 envList)。 */
20
- envUnknown: {
21
- zh: "没有找到名为「{name}」的环境。你的环境:\n{list}",
22
- en: "No environment named \u201c{name}\u201d. Your environments:\n{list}",
23
- },
24
- /** `/env <name>` names an existing environment that is unavailable now (`{list}` as in envList). */
25
- envUnavailable: {
26
- zh: "环境「{name}」当前不可用(它的文件由未启用的沙箱提供方保存)。你的环境:\n{list}",
27
- en: "The environment \u201c{name}\u201d is unavailable: its files are kept by a sandbox provider that is not active. Your environments:\n{list}",
28
- },
29
- /** 私聊 `/env` 时此人没有可用环境(部署没有云沙箱)。 */
30
- envNone: {
31
- zh: "你目前没有可用的环境。",
32
- en: "You have no usable environments.",
33
- },
34
- /** 环境列表里当前 IM 默认环境的标记。 */
35
- envCurrent: { zh: "(当前)", en: " (current)" },
9
+ /** Direct message `/device`: lists the usable devices (`{list}` one per line, the current one marked). */
10
+ deviceList: {
11
+ zh: "你的设备:\n{list}\n发送 /device <名称> 选择下一个新对话使用的设备。",
12
+ en: "Your devices:\n{list}\nSend /device <name> to choose the one your next new conversation uses.",
13
+ },
14
+ /** After a direct message `/device <name>` (`{name}` is the device's name). */
15
+ deviceSwitched: {
16
+ zh: "下一个新对话将使用设备「{name}」,发送 /new 开始。",
17
+ en: "Your next new conversation will use the device \u201c{name}\u201d; send /new to start one.",
18
+ },
19
+ /** `/device <name>` matches no usable device (`{list}` as in deviceList). */
20
+ deviceUnknown: {
21
+ zh: "没有找到名为「{name}」的设备。你的设备:\n{list}",
22
+ en: "No device named \u201c{name}\u201d. Your devices:\n{list}",
23
+ },
24
+ /** `/device <name>` names an existing device that is unavailable now (`{list}` as in deviceList). */
25
+ deviceUnavailable: {
26
+ zh: "设备「{name}」当前不可用(它的文件由未启用的沙箱提供方保存)。你的设备:\n{list}",
27
+ en: "The device \u201c{name}\u201d is unavailable: its files are kept by a sandbox provider that is not active. Your devices:\n{list}",
28
+ },
29
+ /** Direct message `/device` when the person has no usable device (no cloud sandbox in the deployment). */
30
+ deviceNone: {
31
+ zh: "你目前没有可用的设备。",
32
+ en: "You have no usable devices.",
33
+ },
34
+ /** The mark of the current IM default device in the device list. */
35
+ deviceCurrent: { zh: "(当前)", en: " (current)" },
36
36
  /** 未绑定的人在私聊里发消息:附绑定卡片。 */
37
37
  bindPrompt: {
38
38
  zh: "请先绑定你的 Capybara 账号,再与我对话。",
@@ -1,4 +1,6 @@
1
1
  import type { ChannelWebhookRequest, ChannelWebhookResponse, Logger } from "@capybara-hq/plugin-sdk";
2
+ import type { Redis } from "ioredis";
3
+ import { LockService } from "../infra/lock/lock.service.js";
2
4
  import { ChannelConnections } from "./connections.service.js";
3
5
  import { ChannelInbound } from "./inbound.js";
4
6
  /**
@@ -19,6 +21,10 @@ export interface ChannelWebhookTimings {
19
21
  export declare const CHANNEL_WEBHOOK_TIMINGS: unique symbol;
20
22
  /** Most connections whose runtime is cached in one process. */
21
23
  export declare const WEBHOOK_RUNTIME_CACHE_MAX = 1000;
24
+ /** How long a connection's last accepted webhook is remembered (`capybara:webhook-received`). */
25
+ export declare const WEBHOOK_SEEN_TTL_SECONDS: number;
26
+ /** When the gateway last accepted a verified webhook for the connection (ISO time, TTL 7 days). */
27
+ export declare const webhookSeenKey: (connectionId: string) => string;
22
28
  /**
23
29
  * Headers every webhook response carries; a plugin cannot override them (the body is never meant to
24
30
  * be rendered or sniffed by a browser).
@@ -49,6 +55,10 @@ export declare const WEBHOOK_SECURITY_HEADERS: Readonly<Record<string, string>>;
49
55
  * for it (`extensionHost.drainSeconds`) before `dispose`; past that the call is aborted → 503
50
56
  * `channel.webhook_retry`.
51
57
  * Every response carries {@link WEBHOOK_SECURITY_HEADERS}.
58
+ * - An accepted `events` result (every event handed to `onEvent`) sets the marker
59
+ * {@link webhookSeenKey} (the setup check `capybara:webhook-received`), at most once a minute per
60
+ * connection (`LockService.lease`, `SET NX` with a 60 s TTL, on a minute key), in Redis with a TTL, never in PG; a Redis error
61
+ * never fails the request.
52
62
  *
53
63
  * The decrypted runtime (plugin implementation + config) is cached per connection for a few seconds
54
64
  * (bounded, process local), so unauthenticated requests stay cheap before the signature check. It is
@@ -60,6 +70,9 @@ export declare class ChannelWebhooks {
60
70
  private readonly connections;
61
71
  private readonly inbound;
62
72
  private readonly log;
73
+ /** The minute gate of the seen marker (`SET NX` with a TTL). */
74
+ private readonly locks;
75
+ private readonly redis;
63
76
  private readonly runtimes;
64
77
  /** Bumped by {@link invalidateAll}: a fetch started before the bump is not stored. */
65
78
  private generation;
@@ -67,12 +80,19 @@ export declare class ChannelWebhooks {
67
80
  private readonly handleTimeoutMs;
68
81
  /** How long a connection's runtime is reused; 0 disables the cache. */
69
82
  private readonly runtimeCacheMs;
70
- constructor(connections: Pick<ChannelConnections, "row" | "runtime">, inbound: Pick<ChannelInbound, "onEvent">, log: Pick<Logger, "warn">, timings?: Partial<ChannelWebhookTimings>);
83
+ constructor(connections: Pick<ChannelConnections, "row" | "runtime">, inbound: Pick<ChannelInbound, "onEvent">, log: Pick<Logger, "warn">,
84
+ /** The minute gate of the seen marker (`SET NX` with a TTL). */
85
+ locks: Pick<LockService, "lease">, redis: Pick<Redis, "set">, timings?: Partial<ChannelWebhookTimings>);
71
86
  /** Drops the cached runtime of one connection (on `channel.changed`). */
72
87
  invalidate(connectionId: string): void;
73
88
  /** Drops every cached runtime (a plugin was replaced, revoked or reloaded). */
74
89
  invalidateAll(): void;
75
90
  handle(connectionId: string, request: ChannelWebhookRequest, signal: AbortSignal): Promise<ChannelWebhookResponse>;
91
+ /**
92
+ * Records that a verified webhook arrived for the connection: the minute gate first (`NX`), then
93
+ * the marker, both with a TTL. Failures are logged and swallowed (the platform got its ack).
94
+ */
95
+ private markSeen;
76
96
  private runtime;
77
97
  private respond;
78
98
  private respondBefore;
@@ -10,7 +10,8 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
10
10
  import { Inject, Injectable, Optional } from "@nestjs/common";
11
11
  import { ApiError, errorBody } from "../errors.js";
12
12
  import { ExtensionUnloadedError } from "../extensions/inflight.js";
13
- import { KERNEL_LOGGER } from "../infra/tokens.js";
13
+ import { LockService } from "../infra/lock/lock.service.js";
14
+ import { KERNEL_LOGGER, REDIS } from "../infra/tokens.js";
14
15
  import { ChannelConnections } from "./connections.service.js";
15
16
  import { ChannelInbound } from "./inbound.js";
16
17
  /**
@@ -24,6 +25,12 @@ export const WEBHOOK_RUNTIME_CACHE_MS = 5_000;
24
25
  export const CHANNEL_WEBHOOK_TIMINGS = Symbol("CHANNEL_WEBHOOK_TIMINGS");
25
26
  /** Most connections whose runtime is cached in one process. */
26
27
  export const WEBHOOK_RUNTIME_CACHE_MAX = 1_000;
28
+ /** How long a connection's last accepted webhook is remembered (`capybara:webhook-received`). */
29
+ export const WEBHOOK_SEEN_TTL_SECONDS = 7 * 24 * 3600;
30
+ /** When the gateway last accepted a verified webhook for the connection (ISO time, TTL 7 days). */
31
+ export const webhookSeenKey = (connectionId) => `capk:channel-webhook-seen:${connectionId}`;
32
+ /** The minute gate of {@link webhookSeenKey}: the marker is written at most once a minute. */
33
+ const webhookSeenMinuteKey = (connectionId, minute) => `capk:channel-webhook-seen-min:${connectionId}:${minute}`;
27
34
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
35
  /**
29
36
  * Headers every webhook response carries; a plugin cannot override them (the body is never meant to
@@ -99,6 +106,10 @@ function raceAbort(p, signal) {
99
106
  * for it (`extensionHost.drainSeconds`) before `dispose`; past that the call is aborted → 503
100
107
  * `channel.webhook_retry`.
101
108
  * Every response carries {@link WEBHOOK_SECURITY_HEADERS}.
109
+ * - An accepted `events` result (every event handed to `onEvent`) sets the marker
110
+ * {@link webhookSeenKey} (the setup check `capybara:webhook-received`), at most once a minute per
111
+ * connection (`LockService.lease`, `SET NX` with a 60 s TTL, on a minute key), in Redis with a TTL, never in PG; a Redis error
112
+ * never fails the request.
102
113
  *
103
114
  * The decrypted runtime (plugin implementation + config) is cached per connection for a few seconds
104
115
  * (bounded, process local), so unauthenticated requests stay cheap before the signature check. It is
@@ -110,6 +121,8 @@ let ChannelWebhooks = class ChannelWebhooks {
110
121
  connections;
111
122
  inbound;
112
123
  log;
124
+ locks;
125
+ redis;
113
126
  runtimes = new Map();
114
127
  /** Bumped by {@link invalidateAll}: a fetch started before the bump is not stored. */
115
128
  generation = 0;
@@ -117,10 +130,14 @@ let ChannelWebhooks = class ChannelWebhooks {
117
130
  handleTimeoutMs;
118
131
  /** How long a connection's runtime is reused; 0 disables the cache. */
119
132
  runtimeCacheMs;
120
- constructor(connections, inbound, log, timings = {}) {
133
+ constructor(connections, inbound, log,
134
+ /** The minute gate of the seen marker (`SET NX` with a TTL). */
135
+ locks, redis, timings = {}) {
121
136
  this.connections = connections;
122
137
  this.inbound = inbound;
123
138
  this.log = log;
139
+ this.locks = locks;
140
+ this.redis = redis;
124
141
  this.handleTimeoutMs = timings.handleTimeoutMs ?? WEBHOOK_HANDLE_TIMEOUT_MS;
125
142
  this.runtimeCacheMs = timings.runtimeCacheMs ?? WEBHOOK_RUNTIME_CACHE_MS;
126
143
  }
@@ -136,6 +153,21 @@ let ChannelWebhooks = class ChannelWebhooks {
136
153
  async handle(connectionId, request, signal) {
137
154
  return secure(await this.respond(connectionId, request, signal));
138
155
  }
156
+ /**
157
+ * Records that a verified webhook arrived for the connection: the minute gate first (`NX`), then
158
+ * the marker, both with a TTL. Failures are logged and swallowed (the platform got its ack).
159
+ */
160
+ async markSeen(connectionId) {
161
+ try {
162
+ const minute = Math.floor(Date.now() / 60_000);
163
+ if (!(await this.locks.lease(webhookSeenMinuteKey(connectionId, minute), "1", 60_000)))
164
+ return;
165
+ await this.redis.set(webhookSeenKey(connectionId), new Date().toISOString(), "EX", WEBHOOK_SEEN_TTL_SECONDS);
166
+ }
167
+ catch (err) {
168
+ this.log.warn("cannot record the channel webhook marker", { connectionId, err });
169
+ }
170
+ }
139
171
  async runtime(connectionId, deadline) {
140
172
  const ttl = this.runtimeCacheMs;
141
173
  const now = Date.now();
@@ -255,6 +287,7 @@ let ChannelWebhooks = class ChannelWebhooks {
255
287
  return retryLater();
256
288
  }
257
289
  }
290
+ await this.markSeen(connectionId);
258
291
  return sanitize(result.ack);
259
292
  }
260
293
  }
@@ -267,8 +300,10 @@ ChannelWebhooks = __decorate([
267
300
  __param(0, Inject(ChannelConnections)),
268
301
  __param(1, Inject(ChannelInbound)),
269
302
  __param(2, Inject(KERNEL_LOGGER)),
270
- __param(3, Optional()),
271
- __param(3, Inject(CHANNEL_WEBHOOK_TIMINGS))
303
+ __param(3, Inject(LockService)),
304
+ __param(4, Inject(REDIS)),
305
+ __param(5, Optional()),
306
+ __param(5, Inject(CHANNEL_WEBHOOK_TIMINGS))
272
307
  ], ChannelWebhooks);
273
308
  export { ChannelWebhooks };
274
309
  /** Generic codes for the statuses a plugin may reject with (same as the HTTP error filter's). */
@@ -82,7 +82,7 @@ export function renderConfigDocs(schema) {
82
82
  "| `CAPYBARA_WEB_OBJECT_STORE_URL` | Object storage on the web origin: the object store's address as web reaches it (e.g. `http://seaweedfs-s3:8333`). When set, web proxies `/<bucket>/**` (and `/<bucket>` itself for POST only) there, so browsers upload attachments (presigned POST) and download attachments and artifacts (presigned GET) through web; set the object-store extension's `publicEndpoint` to the web origin with `forcePathStyle: true`. Only GET, HEAD and POST; the path is forwarded unchanged; cookies and authorization are not forwarded; responses carry `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`; uploads are capped at `CAPYBARA_WEB_MAX_PACKAGE_BYTES`. Unset (default): off |",
83
83
  "| `CAPYBARA_WEB_OBJECT_STORE_BUCKET` | With `CAPYBARA_WEB_OBJECT_STORE_URL`: the bucket, equal to the object-store extension's `bucket` (it is the first path segment web proxies) |",
84
84
  "| `CAPYBARA_WEB_OBJECT_STORE_PUBLIC_HOST` | With `CAPYBARA_WEB_OBJECT_STORE_URL`: the host of the extension's `publicEndpoint` (a host such as `capybara.example.com`, or that URL). Sent upstream as `Host` instead of the incoming one, because presigned URLs sign the host they were issued for and front proxies rewrite it |",
85
- "| `CAPYBARA_WEB_GATEWAY_URL` | Connected machines on the web origin: a `gateway` replica's address as web reaches it (an http(s) URL, e.g. `http://gateway:8080`). WebSocket upgrades on exactly `/device/connect` are forwarded to it (`capybara-device` connects at `<http.publicUrl>/device/connect`). Unset, they go to `CAPYBARA_KERNEL_URL` (the kernel runs every role unless the deployment splits them), so only deployments with separate gateway replicas set it (compose does). A malformed value stops the web tier at startup |",
85
+ "| `CAPYBARA_WEB_GATEWAY_URL` | Local devices on the web origin: a `gateway` replica's address as web reaches it (an http(s) URL, e.g. `http://gateway:8080`). WebSocket upgrades on exactly `/device/connect` are forwarded to it (`capybara-device` connects at `<http.publicUrl>/device/connect`). Unset, they go to `CAPYBARA_KERNEL_URL` (the kernel runs every role unless the deployment splits them), so only deployments with separate gateway replicas set it (compose does). A malformed value stops the web tier at startup |",
86
86
  "| `CAPYBARA_DEV_UI` | When set to `1`, production builds also serve the `/dev/ui` component catalog page (off by default) |",
87
87
  "",
88
88
  ].join("\n");
@@ -219,9 +219,9 @@ export declare const KernelConfigSchema: z.ZodObject<{
219
219
  allowHosts: z.ZodDefault<z.ZodArray<z.ZodString>>;
220
220
  }, z.core.$strip>>;
221
221
  }, z.core.$strip>>;
222
- environments: z.ZodPrefault<z.ZodObject<{
222
+ devices: z.ZodPrefault<z.ZodObject<{
223
223
  maxPersonal: z.ZodDefault<z.ZodNumber>;
224
- device: z.ZodPrefault<z.ZodObject<{
224
+ local: z.ZodPrefault<z.ZodObject<{
225
225
  reconnectGraceSeconds: z.ZodDefault<z.ZodNumber>;
226
226
  leaseTtlMs: z.ZodDefault<z.ZodNumber>;
227
227
  authCodeTtlSeconds: z.ZodDefault<z.ZodNumber>;