@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.
- package/dist/admin/admin.schemas.d.ts +13 -0
- package/dist/agents/agent-lookup.d.ts +5 -0
- package/dist/agents/agent-lookup.js +15 -0
- package/dist/background/background.module.js +2 -2
- package/dist/background/launch-lock.d.ts +1 -1
- package/dist/background/launch-lock.js +1 -1
- package/dist/background/poll.job.d.ts +2 -2
- package/dist/background/poll.job.js +10 -10
- package/dist/background/stop.d.ts +1 -1
- package/dist/background/stop.js +3 -3
- package/dist/background/store.d.ts +5 -5
- package/dist/background/store.js +5 -5
- package/dist/background/tasks.service.d.ts +6 -6
- package/dist/background/tasks.service.js +13 -13
- package/dist/catalog/admin-catalog.controller.d.ts +13 -2
- package/dist/catalog/admin-catalog.controller.js +24 -3
- package/dist/catalog/admin-catalog.schemas.d.ts +10 -0
- package/dist/catalog/admin-catalog.schemas.js +7 -0
- package/dist/catalog/catalog-content.d.ts +9 -1
- package/dist/catalog/catalog-content.js +2 -1
- package/dist/catalog/catalog-files.d.ts +3 -2
- package/dist/catalog/catalog-files.js +8 -5
- package/dist/catalog/component-scope.service.d.ts +14 -1
- package/dist/catalog/component-scope.service.js +12 -2
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/org-catalog.controller.d.ts +5 -4
- package/dist/catalog/org-catalog.controller.js +5 -4
- package/dist/catalog/org-catalog.schemas.d.ts +18 -2
- package/dist/catalog/org-catalog.schemas.js +16 -2
- package/dist/catalog/org-catalog.service.js +8 -2
- package/dist/catalog/plugin-import.js +1 -1
- package/dist/catalog/plugin-package.js +37 -9
- package/dist/catalog/plugin-upgrade.d.ts +15 -1
- package/dist/catalog/plugin-upgrade.js +18 -5
- package/dist/channels/channels.module.js +2 -2
- package/dist/channels/connections.controller.d.ts +1 -1
- package/dist/channels/connections.controller.js +1 -1
- package/dist/channels/connections.service.d.ts +22 -5
- package/dist/channels/connections.service.js +56 -10
- package/dist/channels/inbound.d.ts +8 -8
- package/dist/channels/inbound.js +31 -31
- package/dist/channels/phrases.d.ts +27 -27
- package/dist/channels/phrases.js +27 -27
- package/dist/channels/webhook.d.ts +21 -1
- package/dist/channels/webhook.js +39 -4
- package/dist/config/docs.js +1 -1
- package/dist/config/schema.d.ts +2 -2
- package/dist/config/schema.js +21 -21
- package/dist/conversations/conversations.controller.d.ts +11 -11
- package/dist/conversations/conversations.controller.js +15 -15
- package/dist/conversations/conversations.module.d.ts +1 -1
- package/dist/conversations/conversations.module.js +7 -7
- package/dist/conversations/conversations.schemas.js +1 -1
- package/dist/conversations/conversations.service.d.ts +14 -14
- package/dist/conversations/conversations.service.js +19 -19
- package/dist/conversations/{environment-status.d.ts → device-status.d.ts} +9 -9
- package/dist/conversations/{environment-status.js → device-status.js} +18 -18
- package/dist/conversations/event-log.d.ts +1 -1
- package/dist/conversations/event-log.js +1 -1
- package/dist/conversations/events.d.ts +1 -1
- package/dist/conversations/events.js +2 -2
- package/dist/ctx/create-ctx.d.ts +26 -3
- package/dist/ctx/create-ctx.js +134 -26
- package/dist/db/kernel-schema.d.ts +37 -25
- package/dist/{environments → devices}/access.d.ts +19 -19
- package/dist/{environments → devices}/access.js +28 -28
- package/dist/devices/conversation-devices.d.ts +75 -0
- package/dist/{environments/conversation-environments.js → devices/conversation-devices.js} +71 -72
- package/dist/devices/devices.controller.d.ts +36 -0
- package/dist/{environments/environments.controller.js → devices/devices.controller.js} +37 -37
- package/dist/devices/devices.module.d.ts +5 -13
- package/dist/devices/devices.module.js +16 -29
- package/dist/devices/devices.schemas.d.ts +3 -0
- package/dist/devices/devices.schemas.js +14 -0
- package/dist/{environments/environments.service.d.ts → devices/devices.service.d.ts} +70 -70
- package/dist/{environments/environments.service.js → devices/devices.service.js} +122 -122
- package/dist/devices/dto.d.ts +13 -54
- package/dist/devices/dto.js +7 -39
- package/dist/devices/label.d.ts +11 -0
- package/dist/{environments → devices}/label.js +7 -7
- package/dist/{environments → devices}/legacy-cleanup.job.d.ts +3 -3
- package/dist/{environments → devices}/legacy-cleanup.job.js +3 -3
- package/dist/errors.d.ts +29 -25
- package/dist/errors.js +29 -25
- package/dist/evals/recorded-tools.d.ts +2 -2
- package/dist/evals/recorded-tools.js +6 -6
- package/dist/extensions/admin-ops.js +1 -0
- package/dist/extensions/admin-view.js +2 -0
- package/dist/extensions/effective-config.d.ts +6 -0
- package/dist/extensions/effective-config.js +18 -7
- package/dist/extensions/guide-places.d.ts +66 -0
- package/dist/extensions/guide-places.js +152 -0
- package/dist/extensions/host.d.ts +13 -2
- package/dist/extensions/host.js +27 -6
- package/dist/extensions/instance.d.ts +17 -2
- package/dist/extensions/instance.js +21 -0
- package/dist/extensions/ledger.d.ts +1 -1
- package/dist/extensions/permission-set.d.ts +16 -2
- package/dist/extensions/permission-set.js +17 -3
- package/dist/extensions/permission-view.d.ts +10 -1
- package/dist/extensions/plugin-guides.d.ts +207 -54
- package/dist/extensions/plugin-guides.js +436 -165
- package/dist/files/previewers.d.ts +1 -1
- package/dist/files/previewers.js +1 -1
- package/dist/http/api.module.js +16 -6
- package/dist/http/gateway-routes.d.ts +1 -1
- package/dist/http/gateway-routes.js +1 -1
- package/dist/identity/settings.d.ts +32 -0
- package/dist/identity/settings.js +26 -2
- package/dist/infra/config/kernel-config.service.js +4 -4
- package/dist/infra/lock/lock.service.d.ts +8 -0
- package/dist/infra/lock/lock.service.js +27 -0
- package/dist/infra/throttler/named-throttlers.d.ts +4 -1
- package/dist/infra/throttler/named-throttlers.js +16 -1
- package/dist/infra/throttler/throttler.guard.js +1 -1
- package/dist/kernel.module.js +3 -3
- package/dist/{devices → local-devices}/device-auth.controller.d.ts +6 -6
- package/dist/{devices → local-devices}/device-auth.controller.js +5 -5
- package/dist/{devices → local-devices}/device-auth.d.ts +20 -20
- package/dist/{devices → local-devices}/device-auth.js +51 -53
- package/dist/local-devices/device-auth.module.d.ts +7 -0
- package/dist/{devices → local-devices}/device-auth.module.js +5 -5
- package/dist/{devices → local-devices}/device-routes.d.ts +3 -3
- package/dist/{devices → local-devices}/device-routes.js +2 -2
- package/dist/local-devices/dto.d.ts +66 -0
- package/dist/local-devices/dto.js +44 -0
- package/dist/{devices → local-devices}/keys.d.ts +11 -11
- package/dist/{devices → local-devices}/keys.js +11 -11
- package/dist/{devices/device-gateway.d.ts → local-devices/local-device-gateway.d.ts} +14 -14
- package/dist/{devices/device-gateway.js → local-devices/local-device-gateway.js} +44 -44
- package/dist/{devices/device-provider.d.ts → local-devices/local-device-provider.d.ts} +15 -15
- package/dist/{devices/device-provider.js → local-devices/local-device-provider.js} +23 -23
- package/dist/local-devices/local-devices.module.d.ts +16 -0
- package/dist/local-devices/local-devices.module.js +44 -0
- package/dist/{devices → local-devices}/machines.d.ts +26 -26
- package/dist/{devices → local-devices}/machines.js +71 -71
- package/dist/{devices → local-devices}/relay.d.ts +15 -15
- package/dist/{devices → local-devices}/relay.js +55 -55
- package/dist/{devices → local-devices}/targets.d.ts +8 -8
- package/dist/{devices → local-devices}/targets.js +14 -14
- package/dist/orgs/offboarding.d.ts +6 -6
- package/dist/orgs/offboarding.js +12 -12
- package/dist/orgs/settings.service.d.ts +28 -0
- package/dist/orgs/settings.service.js +20 -1
- package/dist/overrides.d.ts +3 -1
- package/dist/overrides.js +2 -0
- package/dist/platform/exclusions.js +48 -8
- package/dist/platform/operation.js +10 -2
- package/dist/platform/ops/admin.ops.js +8 -2
- package/dist/platform/ops/connectors.ops.js +2 -0
- package/dist/platform/ops/conversations.ops.d.ts +3 -3
- package/dist/platform/ops/conversations.ops.js +9 -9
- package/dist/platform/ops/devices.ops.d.ts +26 -0
- package/dist/platform/ops/devices.ops.js +311 -0
- package/dist/platform/ops/org-settings.ops.js +2 -2
- package/dist/platform/ops/orgs.ops.js +1 -0
- package/dist/platform/ops/setup.ops.d.ts +24 -0
- package/dist/platform/ops/setup.ops.js +212 -0
- package/dist/platform/ops/ui.ops.d.ts +8 -0
- package/dist/platform/ops/ui.ops.js +41 -0
- package/dist/platform/platform-ops.module.d.ts +4 -2
- package/dist/platform/platform-ops.module.js +13 -5
- package/dist/platform/route-schemas.js +11 -3
- package/dist/roles/gateway-role.d.ts +3 -3
- package/dist/roles/gateway-role.js +4 -4
- package/dist/roles/gateway.module.d.ts +1 -1
- package/dist/roles/gateway.module.js +5 -5
- package/dist/roles/worker.module.js +1 -1
- package/dist/runtime/context/build.d.ts +1 -1
- package/dist/runtime/context/build.js +6 -6
- package/dist/runtime/context/compaction.js +1 -1
- package/dist/runtime/context/{environment-paths.d.ts → device-paths.d.ts} +11 -11
- package/dist/runtime/context/{environment-paths.js → device-paths.js} +13 -13
- package/dist/runtime/maintenance.d.ts +1 -1
- package/dist/sandbox/jobs.d.ts +13 -13
- package/dist/sandbox/jobs.js +20 -20
- package/dist/sandbox/leases.d.ts +9 -9
- package/dist/sandbox/leases.js +14 -14
- package/dist/sandbox/providers.d.ts +8 -8
- package/dist/sandbox/providers.js +8 -8
- package/dist/sandbox/sandbox.module.d.ts +1 -1
- package/dist/sandbox/sandbox.module.js +1 -1
- package/dist/sandbox/sandbox.service.d.ts +76 -76
- package/dist/sandbox/sandbox.service.js +230 -230
- package/dist/setup/kernel-checks.d.ts +91 -0
- package/dist/setup/kernel-checks.js +295 -0
- package/dist/setup/oauth-token-probe.d.ts +47 -0
- package/dist/setup/oauth-token-probe.js +108 -0
- package/dist/setup/redact-check.d.ts +18 -0
- package/dist/setup/redact-check.js +46 -0
- package/dist/setup/setup-check-registry.d.ts +86 -0
- package/dist/setup/setup-check-registry.js +75 -0
- package/dist/setup/setup-checks.controller.d.ts +28 -0
- package/dist/setup/setup-checks.controller.js +78 -0
- package/dist/setup/setup-checks.schemas.d.ts +38 -0
- package/dist/setup/setup-checks.schemas.js +43 -0
- package/dist/setup/setup-checks.service.d.ts +144 -0
- package/dist/setup/setup-checks.service.js +683 -0
- package/dist/setup/setup-guides.controller.d.ts +18 -0
- package/dist/setup/setup-guides.controller.js +48 -0
- package/dist/setup/setup-guides.schemas.d.ts +10 -0
- package/dist/setup/setup-guides.schemas.js +6 -0
- package/dist/setup/setup-guides.service.d.ts +126 -0
- package/dist/setup/setup-guides.service.js +315 -0
- package/dist/setup/setup.module.d.ts +8 -0
- package/dist/setup/setup.module.js +31 -0
- package/dist/tools/builtins/builtin-tools.d.ts +2 -2
- package/dist/tools/builtins/builtin-tools.js +5 -5
- package/dist/tools/builtins/platform.js +1 -1
- package/dist/tools/builtins/sandbox/bash.js +6 -6
- package/dist/tools/builtins/sandbox/common.d.ts +25 -25
- package/dist/tools/builtins/sandbox/common.js +51 -51
- package/dist/tools/builtins/sandbox/edit.js +2 -2
- package/dist/tools/builtins/sandbox/index.d.ts +1 -1
- package/dist/tools/builtins/sandbox/index.js +1 -1
- package/dist/tools/builtins/sandbox/task-output.d.ts +6 -6
- package/dist/tools/builtins/sandbox/task-output.js +10 -10
- package/dist/tools/builtins/sandbox/task-stop.js +2 -2
- package/dist/tools/builtins/sandbox/write.js +2 -2
- package/dist/tools/builtins/skill-load.d.ts +1 -1
- package/dist/tools/builtins/skill-load.js +2 -2
- package/dist/tools/connectors.controller.d.ts +28 -11
- package/dist/tools/connectors.controller.js +49 -14
- package/dist/tools/connectors.schemas.d.ts +4 -0
- package/dist/tools/connectors.schemas.js +4 -0
- package/dist/tools/connectors.service.d.ts +30 -2
- package/dist/tools/connectors.service.js +98 -3
- package/dist/tools/credentials.d.ts +20 -0
- package/dist/tools/credentials.js +90 -1
- package/dist/tools/oauth/tokens.d.ts +20 -0
- package/dist/tools/oauth/tokens.js +49 -24
- package/dist/tools/pipeline.js +4 -3
- package/dist/tools/registry.d.ts +2 -2
- package/dist/tools/tools.module.js +3 -3
- package/dist/ui/admin-ui.controller.d.ts +56 -0
- package/dist/ui/admin-ui.controller.js +192 -0
- package/dist/ui/arrangement.d.ts +74 -0
- package/dist/ui/arrangement.js +98 -0
- package/dist/ui/embed-token.d.ts +2 -2
- package/dist/ui/embed-token.js +30 -5
- package/dist/ui/registry.d.ts +29 -14
- package/dist/ui/registry.js +31 -10
- package/dist/ui/slots.controller.d.ts +11 -0
- package/dist/ui/slots.controller.js +25 -0
- package/dist/ui/ui-stream.d.ts +20 -3
- package/dist/ui/ui-stream.js +25 -7
- package/dist/ui/ui.schemas.d.ts +66 -0
- package/dist/ui/ui.schemas.js +65 -0
- package/dist/ui/views.controller.d.ts +35 -49
- package/dist/ui/views.controller.js +48 -70
- package/dist/ui/views.d.ts +84 -18
- package/dist/ui/views.js +201 -38
- package/package.json +3 -3
- package/src/migrations/0105_devices_rename.sql +136 -0
- package/src/migrations/0106_setup_guides_v2.sql +10 -0
- package/dist/devices/device-auth.module.d.ts +0 -7
- package/dist/environments/conversation-environments.d.ts +0 -75
- package/dist/environments/dto.d.ts +0 -25
- package/dist/environments/dto.js +0 -12
- package/dist/environments/environments.controller.d.ts +0 -36
- package/dist/environments/environments.module.d.ts +0 -8
- package/dist/environments/environments.module.js +0 -31
- package/dist/environments/environments.schemas.d.ts +0 -3
- package/dist/environments/environments.schemas.js +0 -14
- package/dist/environments/label.d.ts +0 -11
- package/dist/platform/ops/environments.ops.d.ts +0 -26
- 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-
|
|
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, "
|
|
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-
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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">,
|
|
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 `/
|
|
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 (`
|
|
101
|
-
* provider switch answers to e.g. "Personal (Docker sandbox)"); the default
|
|
102
|
-
* answers to
|
|
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
|
|
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`
|
package/dist/channels/inbound.js
CHANGED
|
@@ -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 {
|
|
15
|
-
import {
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
220
|
-
if (
|
|
221
|
-
await this.
|
|
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
|
|
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 `/
|
|
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 (`
|
|
300
|
-
* provider switch answers to e.g. "Personal (Docker sandbox)"); the default
|
|
301
|
-
* answers to
|
|
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
|
|
304
|
-
const { items, defaultId } = await this.
|
|
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) =>
|
|
307
|
-
const current = (await this.
|
|
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("
|
|
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("
|
|
313
|
+
return replyText(phrase("deviceNone", locale));
|
|
314
314
|
if (name === null)
|
|
315
|
-
return replyText(fill("
|
|
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 &&
|
|
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("
|
|
325
|
+
return replyText(fill("deviceUnavailable", { name: labelOf(unavailable), list }));
|
|
326
326
|
}
|
|
327
|
-
return replyText(fill("
|
|
327
|
+
return replyText(fill("deviceUnknown", { name, list }));
|
|
328
328
|
}
|
|
329
329
|
try {
|
|
330
|
-
await this.
|
|
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("
|
|
335
|
-
return replyText(fill("
|
|
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("
|
|
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(
|
|
467
|
+
__param(12, Inject(DevicesService)),
|
|
468
468
|
__param(13, Inject(KERNEL_LOGGER))
|
|
469
469
|
], ChannelInbound);
|
|
470
470
|
export { ChannelInbound };
|
|
471
|
-
/** `/
|
|
472
|
-
function
|
|
473
|
-
const m = /^\/
|
|
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
|
-
/**
|
|
15
|
-
readonly
|
|
16
|
-
readonly zh: "
|
|
17
|
-
readonly en: "Your
|
|
18
|
-
};
|
|
19
|
-
/**
|
|
20
|
-
readonly
|
|
21
|
-
readonly zh: "
|
|
22
|
-
readonly en: "Your next new conversation will use the
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
readonly
|
|
26
|
-
readonly zh: "没有找到名为「{name}
|
|
27
|
-
readonly en: "No
|
|
28
|
-
};
|
|
29
|
-
/** `/
|
|
30
|
-
readonly
|
|
31
|
-
readonly zh: "
|
|
32
|
-
readonly en: "The
|
|
33
|
-
};
|
|
34
|
-
/**
|
|
35
|
-
readonly
|
|
36
|
-
readonly zh: "
|
|
37
|
-
readonly en: "You have no usable
|
|
38
|
-
};
|
|
39
|
-
/**
|
|
40
|
-
readonly
|
|
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
|
};
|
package/dist/channels/phrases.js
CHANGED
|
@@ -6,33 +6,33 @@
|
|
|
6
6
|
export const CHANNEL_PHRASES = {
|
|
7
7
|
/** 私聊 `/new` 之后。 */
|
|
8
8
|
newConversation: { zh: "已开始新对话", en: "Started a new conversation." },
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
zh: "
|
|
12
|
-
en: "Your
|
|
13
|
-
},
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
zh: "
|
|
17
|
-
en: "Your next new conversation will use the
|
|
18
|
-
},
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
zh: "没有找到名为「{name}
|
|
22
|
-
en: "No
|
|
23
|
-
},
|
|
24
|
-
/** `/
|
|
25
|
-
|
|
26
|
-
zh: "
|
|
27
|
-
en: "The
|
|
28
|
-
},
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
zh: "
|
|
32
|
-
en: "You have no usable
|
|
33
|
-
},
|
|
34
|
-
/**
|
|
35
|
-
|
|
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">,
|
|
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;
|
package/dist/channels/webhook.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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,
|
|
271
|
-
__param(
|
|
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). */
|
package/dist/config/docs.js
CHANGED
|
@@ -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` |
|
|
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");
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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
|
-
|
|
222
|
+
devices: z.ZodPrefault<z.ZodObject<{
|
|
223
223
|
maxPersonal: z.ZodDefault<z.ZodNumber>;
|
|
224
|
-
|
|
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>;
|