@capybara-hq/kernel 0.1.0-rc.13 → 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 (91) hide show
  1. package/dist/catalog/admin-catalog.controller.d.ts +13 -2
  2. package/dist/catalog/admin-catalog.controller.js +24 -3
  3. package/dist/catalog/admin-catalog.schemas.d.ts +10 -0
  4. package/dist/catalog/admin-catalog.schemas.js +7 -0
  5. package/dist/catalog/catalog-content.d.ts +9 -1
  6. package/dist/catalog/catalog-content.js +2 -1
  7. package/dist/catalog/catalog-files.d.ts +3 -2
  8. package/dist/catalog/catalog-files.js +8 -5
  9. package/dist/catalog/component-scope.service.d.ts +14 -1
  10. package/dist/catalog/component-scope.service.js +12 -2
  11. package/dist/catalog/marketplaces.service.d.ts +1 -1
  12. package/dist/catalog/org-catalog.controller.d.ts +5 -4
  13. package/dist/catalog/org-catalog.controller.js +5 -4
  14. package/dist/catalog/org-catalog.schemas.d.ts +18 -2
  15. package/dist/catalog/org-catalog.schemas.js +16 -2
  16. package/dist/catalog/org-catalog.service.js +8 -2
  17. package/dist/catalog/plugin-import.js +1 -1
  18. package/dist/catalog/plugin-package.js +37 -9
  19. package/dist/catalog/plugin-upgrade.d.ts +15 -1
  20. package/dist/catalog/plugin-upgrade.js +18 -5
  21. package/dist/channels/connections.controller.d.ts +1 -1
  22. package/dist/channels/connections.controller.js +1 -1
  23. package/dist/channels/connections.service.d.ts +22 -5
  24. package/dist/channels/connections.service.js +56 -10
  25. package/dist/channels/webhook.d.ts +21 -1
  26. package/dist/channels/webhook.js +39 -4
  27. package/dist/ctx/create-ctx.d.ts +17 -3
  28. package/dist/ctx/create-ctx.js +49 -2
  29. package/dist/db/kernel-schema.d.ts +11 -2
  30. package/dist/errors.d.ts +4 -0
  31. package/dist/errors.js +4 -0
  32. package/dist/extensions/guide-places.d.ts +66 -0
  33. package/dist/extensions/guide-places.js +152 -0
  34. package/dist/extensions/host.js +3 -3
  35. package/dist/extensions/instance.d.ts +5 -1
  36. package/dist/extensions/instance.js +8 -0
  37. package/dist/extensions/ledger.d.ts +1 -1
  38. package/dist/extensions/permission-set.d.ts +16 -2
  39. package/dist/extensions/permission-set.js +17 -3
  40. package/dist/extensions/plugin-guides.d.ts +207 -54
  41. package/dist/extensions/plugin-guides.js +436 -165
  42. package/dist/http/api.module.js +6 -0
  43. package/dist/infra/lock/lock.service.d.ts +8 -0
  44. package/dist/infra/lock/lock.service.js +27 -0
  45. package/dist/infra/throttler/named-throttlers.d.ts +4 -1
  46. package/dist/infra/throttler/named-throttlers.js +16 -1
  47. package/dist/infra/throttler/throttler.guard.js +1 -1
  48. package/dist/overrides.d.ts +3 -1
  49. package/dist/overrides.js +2 -0
  50. package/dist/platform/exclusions.js +10 -5
  51. package/dist/platform/operation.js +4 -0
  52. package/dist/platform/ops/connectors.ops.js +2 -0
  53. package/dist/platform/ops/setup.ops.d.ts +24 -0
  54. package/dist/platform/ops/setup.ops.js +212 -0
  55. package/dist/platform/platform-ops.module.d.ts +2 -1
  56. package/dist/platform/platform-ops.module.js +5 -0
  57. package/dist/platform/route-schemas.js +5 -0
  58. package/dist/setup/kernel-checks.d.ts +91 -0
  59. package/dist/setup/kernel-checks.js +295 -0
  60. package/dist/setup/oauth-token-probe.d.ts +47 -0
  61. package/dist/setup/oauth-token-probe.js +108 -0
  62. package/dist/setup/redact-check.d.ts +18 -0
  63. package/dist/setup/redact-check.js +46 -0
  64. package/dist/setup/setup-check-registry.d.ts +86 -0
  65. package/dist/setup/setup-check-registry.js +75 -0
  66. package/dist/setup/setup-checks.controller.d.ts +28 -0
  67. package/dist/setup/setup-checks.controller.js +78 -0
  68. package/dist/setup/setup-checks.schemas.d.ts +38 -0
  69. package/dist/setup/setup-checks.schemas.js +43 -0
  70. package/dist/setup/setup-checks.service.d.ts +144 -0
  71. package/dist/setup/setup-checks.service.js +683 -0
  72. package/dist/setup/setup-guides.controller.d.ts +18 -0
  73. package/dist/setup/setup-guides.controller.js +48 -0
  74. package/dist/setup/setup-guides.schemas.d.ts +10 -0
  75. package/dist/setup/setup-guides.schemas.js +6 -0
  76. package/dist/setup/setup-guides.service.d.ts +126 -0
  77. package/dist/setup/setup-guides.service.js +315 -0
  78. package/dist/setup/setup.module.d.ts +8 -0
  79. package/dist/setup/setup.module.js +31 -0
  80. package/dist/tools/connectors.controller.d.ts +28 -11
  81. package/dist/tools/connectors.controller.js +49 -14
  82. package/dist/tools/connectors.schemas.d.ts +4 -0
  83. package/dist/tools/connectors.schemas.js +4 -0
  84. package/dist/tools/connectors.service.d.ts +30 -2
  85. package/dist/tools/connectors.service.js +98 -3
  86. package/dist/tools/credentials.d.ts +20 -0
  87. package/dist/tools/credentials.js +90 -1
  88. package/dist/tools/oauth/tokens.d.ts +20 -0
  89. package/dist/tools/oauth/tokens.js +49 -24
  90. package/package.json +3 -3
  91. package/src/migrations/0106_setup_guides_v2.sql +10 -0
@@ -20,6 +20,7 @@ import { ExtensionAdminOps } from "../extensions/admin-ops.js";
20
20
  import { ExtensionBlobs } from "../extensions/blobs.js";
21
21
  import { permissionSetOfPackage, readExtensionPackage } from "../extensions/package.js";
22
22
  import { blockingKeys, diffPermissions } from "../extensions/permission-set.js";
23
+ import { PluginGuides } from "../extensions/plugin-guides.js";
23
24
  import { ExtensionRegistry } from "../extensions/registry.js";
24
25
  import { audit } from "../identity/audit.js";
25
26
  import { SuperAdminLock } from "../identity/super-admin.js";
@@ -143,11 +144,14 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
143
144
  connectors;
144
145
  sync;
145
146
  log;
147
+ guides;
146
148
  constructor(kdb, txHost, adminLock, itemLock, orgLock, locks, storage, registry, ops,
147
149
  /** Reads the target's extension package (its permissions, for `needsApproval`). */
148
150
  blobs, config, platform,
149
151
  /** Template dependencies (the deployed connector kinds). */
150
- installer, connectors, sync, log) {
152
+ installer, connectors, sync, log,
153
+ /** The connector template guides an update changes (the preview's `guideChanges`). */
154
+ guides) {
151
155
  this.kdb = kdb;
152
156
  this.txHost = txHost;
153
157
  this.adminLock = adminLock;
@@ -164,13 +168,21 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
164
168
  this.connectors = connectors;
165
169
  this.sync = sync;
166
170
  this.log = log;
171
+ this.guides = guides;
167
172
  }
168
173
  get db() {
169
174
  return this.kdb.db;
170
175
  }
171
176
  /** The update preview (see the class comment); writes nothing. */
172
177
  async preview(itemId, version) {
173
- return previewOf(await this.diff(itemId, version));
178
+ return this.withGuideChanges(await this.diff(itemId, version));
179
+ }
180
+ /** The preview of a diff with the template guides it changes. */
181
+ async withGuideChanges(d) {
182
+ return {
183
+ ...previewOf(d),
184
+ guideChanges: await this.guides.templateGuideChanges(d.itemId, d.from, d.to),
185
+ };
174
186
  }
175
187
  /**
176
188
  * The update preview of a new version of an org's own plugin (ruling P3-12): the item must be
@@ -186,7 +198,7 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
186
198
  if (staged !== undefined && staged.version !== version) {
187
199
  throw new Error(`internal: previewForOwner got the staged target of version ${staged.version} for version ${version}`);
188
200
  }
189
- const preview = previewOf(await this.diff(itemId, version, {
201
+ const preview = await this.withGuideChanges(await this.diff(itemId, version, {
190
202
  owner: { orgId },
191
203
  ...(staged !== undefined && { target: staged }),
192
204
  }));
@@ -1502,10 +1514,11 @@ PlatformPluginUpdater = __decorate([
1502
1514
  __param(12, Inject(PluginInstaller)),
1503
1515
  __param(13, Inject(ConnectorsService)),
1504
1516
  __param(14, Inject(PluginScopeSync)),
1505
- __param(15, Inject(KERNEL_LOGGER))
1517
+ __param(15, Inject(KERNEL_LOGGER)),
1518
+ __param(16, Inject(PluginGuides))
1506
1519
  ], PlatformPluginUpdater);
1507
1520
  export { PlatformPluginUpdater };
1508
- /** The preview of a diff. */
1521
+ /** The preview of a diff (without `guideChanges`, which reads PG). */
1509
1522
  function previewOf(d) {
1510
1523
  return {
1511
1524
  itemId: d.itemId,
@@ -13,7 +13,7 @@ import { ChannelConnections, type ChannelConnectionView } from "./connections.se
13
13
  * the URL of its plugin's icon on the org's catalog route (null without one, or when the org may
14
14
  * not see the plugin's catalog item; maintainer decision 2026-11-08), and its plugin's setup guide
15
15
  * with the drift since the connection was set up (`setupGuide`, `guideDrift`; spec
16
- * 2026-11-08-plugin-setup-guides-design.md §6).
16
+ * 2026-11-09-setup-guides-v2-design.md §11).
17
17
  */
18
18
  export type ChannelConnectionPage = ChannelConnectionView & ConnectionGuideState & {
19
19
  pluginDisplayName: LocalizedName | null;
@@ -93,7 +93,7 @@ let ChannelConnectionsController = class ChannelConnectionsController {
93
93
  */
94
94
  async ackGuide(principal, org, id, body) {
95
95
  const p = requireUser(principal);
96
- const view = await this.channels.ackGuide(org.orgId, id, body.hash, p.account.id, patTokenId(p));
96
+ const view = await this.channels.ackGuide({ orgId: org.orgId, role: org.role }, id, body.hash, p.account.id, patTokenId(p));
97
97
  return { channel: await this.withPlugin(org, view) };
98
98
  }
99
99
  async remove(principal, org, id) {
@@ -12,6 +12,7 @@ import { TransactionHost } from "../infra/database/transaction-host.js";
12
12
  import { KernelEvents } from "../infra/events/kernel-events.js";
13
13
  import { type KernelLogger } from "../log.js";
14
14
  import { OrgLock } from "../orgs/org-lock.js";
15
+ import type { OrgRole } from "../orgs/roles.js";
15
16
  import { ChannelJobs } from "./jobs.js";
16
17
  import type { ChannelLocale } from "./phrases.js";
17
18
  /** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
@@ -112,7 +113,7 @@ export declare class ChannelConnections implements OnApplicationShutdown {
112
113
  /** Secret values handled here (written or decrypted) are redacted from the logs (`addOrgSecrets`). */
113
114
  private readonly log;
114
115
  readonly txHost: TransactionHost;
115
- /** The setup guide acks (spec 2026-11-08-plugin-setup-guides-design.md §6). */
116
+ /** The setup guide acks (spec 2026-11-09-setup-guides-v2-design.md §11). */
116
117
  private readonly guides;
117
118
  private readonly unsubscribe;
118
119
  /** `<plugin>@<version>` whose invalid connection hints were warned about (a log dedupe only). */
@@ -126,7 +127,7 @@ export declare class ChannelConnections implements OnApplicationShutdown {
126
127
  config: Pick<KernelConfigService, "get">, events: Pick<KernelEvents, "on">,
127
128
  /** Secret values handled here (written or decrypted) are redacted from the logs (`addOrgSecrets`). */
128
129
  log: Pick<KernelLogger, "warn" | "addOrgSecrets">, txHost: TransactionHost,
129
- /** The setup guide acks (spec 2026-11-08-plugin-setup-guides-design.md §6). */
130
+ /** The setup guide acks (spec 2026-11-09-setup-guides-v2-design.md §11). */
130
131
  guides: Pick<PluginGuides, "currentAck" | "ackDrifted">);
131
132
  /** Unsubscribes after the stop phases: events emitted while they drain are still handled. */
132
133
  onApplicationShutdown(): void;
@@ -186,11 +187,15 @@ export declare class ChannelConnections implements OnApplicationShutdown {
186
187
  private updateIn;
187
188
  /**
188
189
  * 「我已更新应用」: records the plugin's current channel-connection guide for the connection's
189
- * mode as the one it is set up with. `hash` is the template hash of the guide the person was
190
+ * mode as the one it is set up with. `hash` is the drift hash of the guide the person was
190
191
  * shown; when the plugin has moved on since, 409 `channel.guide_changed` (show the new guide
191
- * first). A plugin without a guide -> 404 `catalog.guide_not_found`.
192
+ * first). A plugin without a guide, or whose item the org's catalog does not show -> 404
193
+ * `catalog.guide_not_found`.
192
194
  */
193
- ackGuide(orgId: string, id: string, hash: string, actorAccountId: string, actorTokenId?: string): Promise<ChannelConnectionView>;
195
+ ackGuide(org: {
196
+ orgId: string;
197
+ role: OrgRole;
198
+ }, id: string, hash: string, actorAccountId: string, actorTokenId?: string): Promise<ChannelConnectionView>;
194
199
  /** The row of {@link ackGuide}, in its own transaction. */
195
200
  private ackIn;
196
201
  /** 删除(软删除,同一事务删掉密钥);持有长连接的副本收到广播后断开。 */
@@ -211,6 +216,18 @@ export declare class ChannelConnections implements OnApplicationShutdown {
211
216
  * 持有长连接的副本立即断开(扫描复核只是兜底)。只广播,失败只记日志。
212
217
  */
213
218
  notifyOrgChanged(orgId: string, plugin?: string): Promise<void>;
219
+ /**
220
+ * For setup checks (setup guides v2 §10.4): the input revision of a live connection of the org
221
+ * on `plugin`: the `updated_at` of its row and of its secrets row (ISO), never a value. Missing,
222
+ * deleted, of another org or plugin -> `channel.not_found`.
223
+ */
224
+ checkRevision(orgId: string, plugin: string, id: string): Promise<string>;
225
+ /**
226
+ * For setup checks (setup guides v2 §10.8): the stored secret values of a live connection of the
227
+ * org on `plugin` (every secret field, decrypted), what a check's result is redacted by. Missing,
228
+ * deleted, of another org or plugin -> `channel.not_found`. Never echo them.
229
+ */
230
+ checkSecrets(orgId: string, plugin: string, id: string): Promise<string[]>;
214
231
  /**
215
232
  * The extension implementation and full config a connection needs to run (decrypted secrets
216
233
  * included, validated against `connectionSchema`). Connection missing / deleted ->
@@ -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,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). */
@@ -1,4 +1,4 @@
1
- import { type ExtensionContext, type ExtensionManifest, type ExtensionPointRef, type Logger, resolveUiSlots, type ToolHandler, type ToolSpec } from "@capybara-hq/plugin-sdk";
1
+ import { type ExtensionContext, type ExtensionManifest, type ExtensionPointRef, type Logger, resolveUiSlots, type SetupCheckArgs, type SetupGuidePlace, type ToolHandler, type ToolSpec } from "@capybara-hq/plugin-sdk";
2
2
  import type { Redis } from "ioredis";
3
3
  import { Kysely } from "kysely";
4
4
  import { type RegisteredHook } from "../extensions/hooks.js";
@@ -52,6 +52,15 @@ export type RegisteredTool = {
52
52
  spec: ToolSpec;
53
53
  handler: ToolHandler;
54
54
  };
55
+ /**
56
+ * A setup check registered through `ctx.setup.check`: `place` and `run` read once from the
57
+ * definition at registration and frozen, so the extension cannot swap them afterwards (a getter
58
+ * on the definition runs once, at registration, never when a check runs).
59
+ */
60
+ export type RegisteredSetupCheck = Readonly<{
61
+ place: SetupGuidePlace;
62
+ run: (args: SetupCheckArgs) => Promise<unknown>;
63
+ }>;
55
64
  /** 插件经 `ctx.extensionPoints` 取用其他插件能力时,内核注入的访问接口。 */
56
65
  export interface ExtensionPointAccess {
57
66
  /** 已加载(setup 完成)的提供方,按加载顺序;按完整引用(含主版本)精确匹配。 */
@@ -105,7 +114,11 @@ export type KernelExtensionContext = ExtensionContext & {
105
114
  uiViews: ReadonlyMap<string, UiRender>;
106
115
  /** 经 `ctx.ui.action` 注册的动作处理函数(按贡献 id)。 */
107
116
  uiActions: ReadonlyMap<string, UiActionHandler>;
108
- /** setup 结束后调用:此后 `tools.register`、`ui.view`、`ui.action`、`hooks.on` 抛错。 */
117
+ /** Setup checks registered through `ctx.setup.check`, by id. */
118
+ setupChecks: ReadonlyMap<string, RegisteredSetupCheck>;
119
+ /**
120
+ * setup 结束后调用:此后 `tools.register`、`ui.view`、`ui.action`、`hooks.on`、`setup.check` 抛错。
121
+ */
109
122
  sealTools(): void;
110
123
  /** 中止 signal、封账并逐项撤销仍在账上的登记、关闭队列连接(幂等)。 */
111
124
  close(): Promise<void>;
@@ -128,7 +141,8 @@ export declare class LiveConfig {
128
141
  * only with `permissions.models` (and only its `embedding` purposes, through `deps.models`); `http.route`
129
142
  * only the method + path declared in `permissions.http`; `tools.register` only names declared in
130
143
  * `permissions.tools`; `ui.view` / `ui.action` only ids declared in `permissions.ui.views` (during
131
- * setup only); `ui.invalidate` sends a refresh over the kernel bus (namespace `kernel`), across
144
+ * setup only); `setup.check` only ids declared in `permissions.setupChecks` (during setup only,
145
+ * `place` and `run` read once and frozen); `ui.invalidate` sends a refresh over the kernel bus (namespace `kernel`), across
132
146
  * replicas; `ui.verifyEmbedToken` checks an embed token the kernel signed: `claims.plugin` must be
133
147
  * this extension, the extension still enabled in that org and the caller still an active member of
134
148
  * the org (org not deleted); returns the current role;
@@ -1,4 +1,4 @@
1
- import { EXTENSION_POINTS, ExtensionPermissionError, ExtensionPointUnavailableError, FILE_PREVIEW_EXTENSION_POINT, OBJECT_STORE_EXTENSION_POINT, parseExtensionPoint, resolveUiSlots, validateFilePreviewProvider, validateToolSpec, } from "@capybara-hq/plugin-sdk";
1
+ import { EXTENSION_POINTS, ExtensionPermissionError, ExtensionPointUnavailableError, FILE_PREVIEW_EXTENSION_POINT, OBJECT_STORE_EXTENSION_POINT, parseExtensionPoint, resolveUiSlots, SETUP_CHECK_ID_RE, SETUP_GUIDE_PLACES, validateFilePreviewProvider, validateToolSpec, } from "@capybara-hq/plugin-sdk";
2
2
  import { Command, ConnectionBuilder, ControlledTransactionBuilder, Kysely, TransactionBuilder, } from "kysely";
3
3
  import { conversationTopic } from "../conversations/event-log.js";
4
4
  import { kernelDb } from "../db/kernel-schema.js";
@@ -182,7 +182,8 @@ export class LiveConfig {
182
182
  * only with `permissions.models` (and only its `embedding` purposes, through `deps.models`); `http.route`
183
183
  * only the method + path declared in `permissions.http`; `tools.register` only names declared in
184
184
  * `permissions.tools`; `ui.view` / `ui.action` only ids declared in `permissions.ui.views` (during
185
- * setup only); `ui.invalidate` sends a refresh over the kernel bus (namespace `kernel`), across
185
+ * setup only); `setup.check` only ids declared in `permissions.setupChecks` (during setup only,
186
+ * `place` and `run` read once and frozen); `ui.invalidate` sends a refresh over the kernel bus (namespace `kernel`), across
186
187
  * replicas; `ui.verifyEmbedToken` checks an embed token the kernel signed: `claims.plugin` must be
187
188
  * this extension, the extension still enabled in that org and the caller still an active member of
188
189
  * the org (org not deleted); returns the current role;
@@ -261,6 +262,8 @@ export function createExtensionContext(deps, manifest, deploymentConfig, extensi
261
262
  ...(manifest.optional ?? []),
262
263
  ]);
263
264
  const uiReg = new PluginUiRegistration(manifest, deny);
265
+ const declaredSetupChecks = new Set(perms.setupChecks ?? []);
266
+ const setupChecks = new Map();
264
267
  const embedTokens = deps.embedTokens;
265
268
  const uiResolution = resolveUiSlots(manifest.ui);
266
269
  const uiSlots = new Map(uiResolution.resolved.map((r) => [r.contribution.id, r.slot]));
@@ -445,6 +448,49 @@ export function createExtensionContext(deps, manifest, deploymentConfig, extensi
445
448
  });
446
449
  },
447
450
  },
451
+ // Setup checks (setup guides v2 §10.2): only during setup, only ids declared in
452
+ // permissions.setupChecks (an undeclared or malformed id is a denial, audited); the kernel's
453
+ // SetupChecksService runs them (bounded, cached, redacted).
454
+ setup: {
455
+ check(id, def) {
456
+ if (toolsSealed) {
457
+ throw new Error(`plugin ${name}: setup.check is only allowed during setup; setup has completed`);
458
+ }
459
+ if (typeof id !== "string" || !SETUP_CHECK_ID_RE.test(id) || !declaredSetupChecks.has(id)) {
460
+ // the item names the id only when it has the id shape (an audit row never carries
461
+ // arbitrary extension text)
462
+ deny(`permissions.setupChecks ${typeof id === "string" && SETUP_CHECK_ID_RE.test(id) ? id : "(invalid id)"}`);
463
+ }
464
+ if (setupChecks.has(id)) {
465
+ throw new Error(`plugin ${name}: duplicate setup check "${id}"`);
466
+ }
467
+ if (typeof def !== "object" || def === null) {
468
+ throw new Error(`plugin ${name}: setup check "${id}" needs a definition object`);
469
+ }
470
+ // read once: what runs later is exactly what was checked here
471
+ const place = def.place;
472
+ const run = def.run;
473
+ if (!SETUP_GUIDE_PLACES.includes(place)) {
474
+ throw new Error(`plugin ${name}: setup check "${id}" place must be one of ${SETUP_GUIDE_PLACES.join(", ")}`);
475
+ }
476
+ if (typeof run !== "function") {
477
+ throw new Error(`plugin ${name}: setup check "${id}" run must be a function`);
478
+ }
479
+ const check = Object.freeze({
480
+ place: place,
481
+ run: run,
482
+ });
483
+ setupChecks.set(id, check);
484
+ ledger.add({
485
+ kind: "setup.check",
486
+ key: id,
487
+ dispose: async () => {
488
+ if (setupChecks.get(id) === check)
489
+ setupChecks.delete(id);
490
+ },
491
+ });
492
+ },
493
+ },
448
494
  hooks: {
449
495
  on(mount, handler, opts) {
450
496
  if (toolsSealed) {
@@ -585,6 +631,7 @@ export function createExtensionContext(deps, manifest, deploymentConfig, extensi
585
631
  uiResolution,
586
632
  uiViews: uiReg.views,
587
633
  uiActions: uiReg.actions,
634
+ setupChecks,
588
635
  ledger,
589
636
  async beginDispose(deadline) {
590
637
  drainDeadline = deadline;
@@ -419,6 +419,11 @@ export interface ConnectorsTable {
419
419
  template_tool_policies: ColumnType<Record<string, "allow" | "ask" | "deny"> | null, string | null | undefined, string | null>;
420
420
  /** 0081: the platform connector template this connector was made from (null = none). */
421
421
  platform_template_id: ColumnType<string | null, string | null | undefined, string | null>;
422
+ /**
423
+ * 0106: the setup guide of its connector template it was last set up with (`{ guideId, version,
424
+ * hash, mode: null }`); null when unknown (custom connectors, connectors before 0106).
425
+ */
426
+ guide_ack: ColumnType<GuideAckJson | null, string | null | undefined, string | null>;
422
427
  }
423
428
  /**
424
429
  * 0104: a member's own policy for one tool of a personal-credential connector, for calls made with
@@ -567,12 +572,16 @@ export interface LgCheckpointWritesTable {
567
572
  task_path: Generated<string>;
568
573
  }
569
574
  /** 0018_channels.sql:组织的渠道连接(非密钥配置;密钥在 secrets `channels:<id>:config`)。 */
570
- /** `channel_connections.guide_ack` (0099): the setup guide a connection was set up with. */
575
+ /**
576
+ * `channel_connections.guide_ack` (0099) and `connectors.guide_ack` (0106): the setup guide a
577
+ * target was set up with; `hash` is the v2 drift hash (`setupGuideHash`, admin audience) of
578
+ * `mode` (null for a place without modes).
579
+ */
571
580
  export interface GuideAckJson {
572
581
  guideId: string;
573
582
  version: string;
574
583
  hash: string;
575
- mode: string;
584
+ mode: string | null;
576
585
  }
577
586
  export interface ChannelConnectionsTable {
578
587
  id: string;
package/dist/errors.d.ts CHANGED
@@ -159,6 +159,8 @@ export declare const ERROR_CODES: {
159
159
  readonly "catalog.not_deployed": 409;
160
160
  readonly "catalog.icon_not_found": 404;
161
161
  readonly "catalog.guide_not_found": 404;
162
+ /** The check is not in the setup guide rendered for that mode and audience (setup guides v2 §10.3). */
163
+ readonly "catalog.check_not_found": 404;
162
164
  readonly "marketplace.not_found": 404;
163
165
  readonly "marketplace.entry_not_found": 404;
164
166
  readonly "marketplace.fetch_failed": 502;
@@ -246,6 +248,8 @@ export declare const ERROR_CODES: {
246
248
  readonly "background_task.output_unavailable": 409;
247
249
  readonly "connector.not_found": 404;
248
250
  readonly "connector.slug_taken": 409;
251
+ /** The connector template's setup guide moved on since the person was shown it (guide-ack). */
252
+ readonly "connector.guide_changed": 409;
249
253
  readonly "connector.kind_unavailable": 409;
250
254
  readonly "connector.connect_failed": 502;
251
255
  readonly "connector.tool_not_found": 404;
package/dist/errors.js CHANGED
@@ -159,6 +159,8 @@ export const ERROR_CODES = {
159
159
  "catalog.not_deployed": 409,
160
160
  "catalog.icon_not_found": 404,
161
161
  "catalog.guide_not_found": 404,
162
+ /** The check is not in the setup guide rendered for that mode and audience (setup guides v2 §10.3). */
163
+ "catalog.check_not_found": 404,
162
164
  "marketplace.not_found": 404,
163
165
  "marketplace.entry_not_found": 404,
164
166
  "marketplace.fetch_failed": 502,
@@ -246,6 +248,8 @@ export const ERROR_CODES = {
246
248
  "background_task.output_unavailable": 409,
247
249
  "connector.not_found": 404,
248
250
  "connector.slug_taken": 409,
251
+ /** The connector template's setup guide moved on since the person was shown it (guide-ack). */
252
+ "connector.guide_changed": 409,
249
253
  "connector.kind_unavailable": 409,
250
254
  "connector.connect_failed": 502,
251
255
  "connector.tool_not_found": 404,