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

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 (107) hide show
  1. package/dist/admin/admin.schemas.d.ts +5 -13
  2. package/dist/catalog/admin-catalog.controller.d.ts +27 -4
  3. package/dist/catalog/admin-catalog.controller.js +53 -5
  4. package/dist/catalog/appearance.schemas.d.ts +10 -0
  5. package/dist/catalog/appearance.schemas.js +20 -0
  6. package/dist/catalog/appearance.service.d.ts +105 -0
  7. package/dist/catalog/appearance.service.js +306 -0
  8. package/dist/catalog/catalog-import.module.js +3 -0
  9. package/dist/catalog/catalog-items.d.ts +11 -0
  10. package/dist/catalog/catalog-items.js +21 -0
  11. package/dist/catalog/catalog-scope.service.d.ts +7 -0
  12. package/dist/catalog/catalog-scope.service.js +55 -15
  13. package/dist/catalog/catalog.service.js +10 -2
  14. package/dist/catalog/cli.js +3 -0
  15. package/dist/catalog/marketplaces.service.d.ts +1 -1
  16. package/dist/catalog/org-extension.controller.d.ts +27 -7
  17. package/dist/catalog/org-extension.controller.js +48 -6
  18. package/dist/catalog/plugin-import.d.ts +28 -4
  19. package/dist/catalog/plugin-import.js +95 -19
  20. package/dist/catalog/plugin-package.d.ts +4 -2
  21. package/dist/catalog/plugin-package.js +29 -3
  22. package/dist/channels/channels.schemas.d.ts +4 -0
  23. package/dist/channels/channels.schemas.js +2 -0
  24. package/dist/channels/connections.controller.d.ts +5 -3
  25. package/dist/channels/connections.controller.js +6 -4
  26. package/dist/channels/connections.service.d.ts +26 -0
  27. package/dist/channels/connections.service.js +79 -6
  28. package/dist/client/client-access.service.d.ts +101 -0
  29. package/dist/client/client-access.service.js +294 -0
  30. package/dist/client/client-builds.service.d.ts +109 -0
  31. package/dist/client/client-builds.service.js +229 -0
  32. package/dist/client/client-delivery.controller.d.ts +25 -0
  33. package/dist/client/client-delivery.controller.js +66 -0
  34. package/dist/client/client-files.d.ts +48 -0
  35. package/dist/client/client-files.js +136 -0
  36. package/dist/client/client-reports.cron.d.ts +13 -0
  37. package/dist/client/client-reports.cron.js +42 -0
  38. package/dist/client/client-reports.service.d.ts +30 -0
  39. package/dist/client/client-reports.service.js +102 -0
  40. package/dist/client/client.controller.d.ts +45 -0
  41. package/dist/client/client.controller.js +121 -0
  42. package/dist/client/client.module.d.ts +7 -0
  43. package/dist/client/client.module.js +26 -0
  44. package/dist/client/client.schemas.d.ts +55 -0
  45. package/dist/client/client.schemas.js +27 -0
  46. package/dist/ctx/create-ctx.d.ts +4 -3
  47. package/dist/ctx/create-ctx.js +4 -3
  48. package/dist/db/kernel-schema.d.ts +47 -0
  49. package/dist/errors.d.ts +29 -0
  50. package/dist/errors.js +29 -0
  51. package/dist/extensions/admin-ops.d.ts +4 -1
  52. package/dist/extensions/admin-ops.js +22 -1
  53. package/dist/extensions/admin-view.d.ts +8 -0
  54. package/dist/extensions/admin-view.js +78 -0
  55. package/dist/extensions/blobs.d.ts +14 -1
  56. package/dist/extensions/blobs.js +48 -5
  57. package/dist/extensions/guide-places.d.ts +3 -1
  58. package/dist/extensions/guide-places.js +1 -1
  59. package/dist/extensions/host.d.ts +6 -1
  60. package/dist/extensions/host.js +20 -9
  61. package/dist/extensions/package.d.ts +4 -3
  62. package/dist/extensions/package.js +5 -1
  63. package/dist/extensions/permission-set.d.ts +19 -3
  64. package/dist/extensions/permission-set.js +19 -2
  65. package/dist/extensions/permission-view.d.ts +38 -7
  66. package/dist/extensions/permission-view.js +23 -7
  67. package/dist/extensions/sources.d.ts +6 -0
  68. package/dist/extensions/sources.js +2 -0
  69. package/dist/extensions/state.module.js +9 -1
  70. package/dist/http/api.module.js +10 -0
  71. package/dist/identity/settings.d.ts +41 -26
  72. package/dist/identity/settings.js +87 -4
  73. package/dist/infra/throttler/named-throttlers.d.ts +4 -0
  74. package/dist/infra/throttler/named-throttlers.js +14 -0
  75. package/dist/kernel.module.js +3 -0
  76. package/dist/orgs/settings.service.d.ts +2 -26
  77. package/dist/platform/exclusions.js +27 -1
  78. package/dist/platform/ops/admin.ops.js +17 -2
  79. package/dist/platform/ops/catalog-admin.ops.d.ts +19 -1
  80. package/dist/platform/ops/catalog-admin.ops.js +85 -2
  81. package/dist/platform/ops/org-plugins.ops.d.ts +7 -1
  82. package/dist/platform/ops/org-plugins.ops.js +63 -4
  83. package/dist/platform/ops/org-settings.ops.js +2 -2
  84. package/dist/platform/ops/orgs.ops.js +1 -0
  85. package/dist/platform/ops/ui.ops.js +1 -1
  86. package/dist/platform/platform-ops.module.js +2 -0
  87. package/dist/platform/route-schemas.js +3 -0
  88. package/dist/roles/worker.module.js +4 -0
  89. package/dist/themes/render-css.d.ts +2 -1
  90. package/dist/themes/render-css.js +5 -2
  91. package/dist/themes/theme-delivery.service.d.ts +4 -2
  92. package/dist/themes/theme-delivery.service.js +8 -3
  93. package/dist/themes/theme-state.d.ts +28 -7
  94. package/dist/themes/theme-state.js +24 -6
  95. package/dist/themes/themes-public.controller.d.ts +7 -2
  96. package/dist/themes/themes-public.controller.js +11 -5
  97. package/dist/themes/themes.service.js +2 -2
  98. package/dist/tools/pipeline.d.ts +3 -2
  99. package/dist/ui/arrangement.d.ts +41 -50
  100. package/dist/ui/arrangement.js +88 -15
  101. package/dist/ui/registry.d.ts +13 -3
  102. package/dist/ui/registry.js +15 -4
  103. package/dist/ui/ui.schemas.d.ts +4 -0
  104. package/dist/ui/views.d.ts +36 -9
  105. package/dist/ui/views.js +58 -28
  106. package/package.json +3 -3
  107. package/src/migrations/0107_extension_client_builds.sql +50 -0
@@ -8,11 +8,13 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { createHash, randomUUID } from "node:crypto";
11
- import { PLUGIN_MANIFEST_FILE, parseSkillMd, pluginDescription, SDK_VERSION, SKILL_ENTRY_FILE, } from "@capybara-hq/plugin-sdk";
11
+ import { appearanceIssues, PLUGIN_MANIFEST_FILE, parseSkillMd, pluginDescription, SDK_VERSION, SKILL_ENTRY_FILE, } from "@capybara-hq/plugin-sdk";
12
12
  import { getQueueToken } from "@nestjs/bullmq";
13
13
  import { Inject, Injectable } from "@nestjs/common";
14
14
  import { sql } from "kysely";
15
15
  import semver from "semver";
16
+ import { ClientBuilds } from "../client/client-builds.service.js";
17
+ import { checkClientFiles } from "../client/client-files.js";
16
18
  import { ApiError } from "../errors.js";
17
19
  import { ExtensionAdminOps } from "../extensions/admin-ops.js";
18
20
  import { ExtensionAdmin } from "../extensions/admin-view.js";
@@ -112,14 +114,17 @@ const reviewStale = (review) => new ApiError("plugin.review_stale", `The review
112
114
  const mismatch = (what) => new ApiError("marketplace.package_mismatch", `The downloaded package does not match the marketplace entry: ${what}`);
113
115
  /**
114
116
  * The API error of a plugin package that cannot be read in a catalog path: the pre-0.1.0 format ->
115
- * `catalog.legacy_format`; a failing theme (themes spec §5) -> `theme.invalid` (details
116
- * `[{ theme, errors }]`) or `theme.unreadable` (details `[{ theme, issues }]`, the failing pairs);
117
- * anything else -> `catalog.invalid_manifest` (issues as details).
117
+ * `catalog.legacy_format`; undeclared client code -> `client.undeclared`; a failing theme (themes
118
+ * spec §5) -> `theme.invalid` (details `[{ theme, errors }]`) or `theme.unreadable` (details
119
+ * `[{ theme, issues }]`, the failing pairs); anything else -> `catalog.invalid_manifest` (issues as
120
+ * details).
118
121
  */
119
122
  export function catalogPackageError(e) {
120
123
  const legacy = e.code === "catalog.legacy_format" || e.code === "extension.legacy_format";
121
124
  if (legacy)
122
125
  return new ApiError("catalog.legacy_format", e.message, e.issues);
126
+ if (e.code === "client.undeclared")
127
+ return new ApiError("client.undeclared", e.message);
123
128
  if (e.code === "theme.invalid" || e.code === "theme.unreadable") {
124
129
  return themeApiError(e);
125
130
  }
@@ -160,6 +165,7 @@ let PluginImport = class PluginImport {
160
165
  txHost;
161
166
  platform;
162
167
  maintenance;
168
+ clientBuilds;
163
169
  /** Where plugin files go (the kernel's object storage; the CLI's own, see {@link useCliStorage}). */
164
170
  files;
165
171
  /**
@@ -180,7 +186,9 @@ let PluginImport = class PluginImport {
180
186
  /** The platform rows of a new item's components, written after the confirm's commit. */
181
187
  platform,
182
188
  /** A failed platform sync enqueues a run of the migration job here (it heals the item). */
183
- maintenance) {
189
+ maintenance,
190
+ /** The client build of a dropped org upload's package is deleted with it. */
191
+ clientBuilds) {
184
192
  this.kdb = kdb;
185
193
  this.adminLock = adminLock;
186
194
  this.orgLock = orgLock;
@@ -197,6 +205,7 @@ let PluginImport = class PluginImport {
197
205
  this.txHost = txHost;
198
206
  this.platform = platform;
199
207
  this.maintenance = maintenance;
208
+ this.clientBuilds = clientBuilds;
200
209
  this.files = storage;
201
210
  }
202
211
  /** The kernel-schema handle of the current context (the transaction, else the pool). */
@@ -341,6 +350,22 @@ let PluginImport = class PluginImport {
341
350
  ? await readingPackage(() => readExtensionPackage(bytes, { maxBytes, hostSdkVersion: SDK_VERSION }))
342
351
  : undefined;
343
352
  const content = this.checkContent(pkg, format);
353
+ // suggestedAppearance.theme against the themes the scan found (the manifest may not list them)
354
+ const appearance = appearanceIssues(m, pkg.themes.map((t) => t.id));
355
+ if (appearance.length > 0)
356
+ throw invalid(appearance);
357
+ // Client code (client extensions spec §9.1): the section re-checked and every file against it
358
+ // (keys, listed = present, sizes, sha384 recomputed, the CSS layer wrap); delivered from object
359
+ // storage, so refused without one. The store checks the bytes it writes again. A bootstrap
360
+ // plugin never carries it: a bootstrap run may have no object store (it may be a bootstrap
361
+ // plugin itself), and a required entry failing on that would stop the start.
362
+ if (ext?.section.client !== undefined) {
363
+ if (source.kind === "bootstrap") {
364
+ throw new ApiError("client.invalid", `Bootstrap plugin ${m.name} ships client code (extension.client), which a bootstrap plugin may not carry: its files are kept in object storage, which a bootstrap run may not have (the object store may be a bootstrap plugin itself); take ${m.name} out of bootstrap.json and import it into the catalog instead`);
365
+ }
366
+ checkClientFiles(ext.section.client, pkg.files);
367
+ this.files.assertAvailable();
368
+ }
344
369
  // A name live for another owner (an org's for the super admin; the platform's or another
345
370
  // org's for an org) -> 409 plugin.name_taken naming it (P3-14); the same owner adds a version
346
371
  await assertNameFree(this.db, m.name, owner?.orgId ?? null);
@@ -475,13 +500,16 @@ let PluginImport = class PluginImport {
475
500
  .sort()
476
501
  : [];
477
502
  const mismatch = permissionsMismatch(opts.expect?.marketplace?.permissions, ext ? keys : undefined);
503
+ const manifest = normalizedManifest(pkg);
504
+ const replacements = manifest.suggestedAppearance?.replacements;
478
505
  const staged = {
479
506
  itemId,
480
507
  packageName,
481
508
  version,
482
509
  contentDigest: pkg.contentDigest,
483
510
  size: tgz.size,
484
- manifest: normalizedManifest(pkg),
511
+ manifest,
512
+ ...(replacements !== undefined && { appearanceOrder: Object.keys(replacements) }),
485
513
  files: content.files.map(({ path, size, sha256 }) => ({ path, size, sha256 })),
486
514
  ...(mismatch !== null && { permissionsMismatch: mismatch }),
487
515
  sourceFormat: format,
@@ -785,6 +813,22 @@ let PluginImport = class PluginImport {
785
813
  // Checked again before this upload claims anything (ruling P3-51)
786
814
  if (owner !== undefined)
787
815
  await assertPackageFree(t, staged.packageName, owner.orgId);
816
+ // The item's name lock and its live row before the extension's install lock: the global lock
817
+ // order is the item, then the install lock (ruling P3-50; ExtensionAdminOps.install locks the
818
+ // source item row before the install lock, an org deletion its items before it). Taken after
819
+ // the install lock instead, a confirmation of an org upload racing a replacement upload
820
+ // deadlocks (40P01).
821
+ const m = manifest;
822
+ await this.itemLock.lock(m.name);
823
+ // The scope the audit records: the review's for a new item, the item's own for a new version
824
+ // of a live item (which keeps its scope)
825
+ const existing = await t
826
+ .selectFrom("catalog_items")
827
+ .select(["id", "scope", "awaiting_confirmation", "package_name"])
828
+ .where("name", "=", m.name)
829
+ .where("deleted_at", "is", null)
830
+ .forUpdate()
831
+ .executeTakeFirst();
788
832
  let now;
789
833
  let previous;
790
834
  if (reviewed) {
@@ -817,8 +861,6 @@ let PluginImport = class PluginImport {
817
861
  previous = await this.approve(actor, review, staged, now, stored, opts);
818
862
  }
819
863
  }
820
- const m = manifest;
821
- await this.itemLock.lock(m.name);
822
864
  // Under the name's lock: the first confirm of a name wins, a later one of another owner is
823
865
  // refused naming it, before anything of it is written (P3-14)
824
866
  await assertNameFree(t, m.name, owner?.orgId ?? null);
@@ -826,15 +868,6 @@ let PluginImport = class PluginImport {
826
868
  await assertNameNotReserved(t, m.name);
827
869
  await this.resolveDependencies(m, owner);
828
870
  }
829
- // The scope the audit records: the review's for a new item, the item's own for a new version
830
- // of a live item (which keeps its scope)
831
- const existing = await t
832
- .selectFrom("catalog_items")
833
- .select(["id", "scope", "awaiting_confirmation", "package_name"])
834
- .where("name", "=", m.name)
835
- .where("deleted_at", "is", null)
836
- .forUpdate()
837
- .executeTakeFirst();
838
871
  const actual = existing === undefined
839
872
  ? scope
840
873
  : {
@@ -879,6 +912,7 @@ let PluginImport = class PluginImport {
879
912
  version: staged.version,
880
913
  contentDigest: staged.contentDigest,
881
914
  manifest: m,
915
+ appearanceOrder: staged.appearanceOrder ?? null,
882
916
  listing: {
883
917
  publisher: m.publisher,
884
918
  description: pluginDescription(m.description).en,
@@ -1307,8 +1341,27 @@ let PluginImport = class PluginImport {
1307
1341
  .executeTakeFirst());
1308
1342
  if (used !== undefined)
1309
1343
  return;
1310
- // extension_blobs (a PG-stored package) goes with the row (ON DELETE CASCADE)
1344
+ const build = await t
1345
+ .selectFrom("extension_client_builds")
1346
+ .select("object_prefix")
1347
+ .where("package_sha256", "=", sha)
1348
+ .executeTakeFirst();
1349
+ // extension_blobs (a PG-stored package) and extension_client_builds go with the row (ON
1350
+ // DELETE CASCADE)
1311
1351
  await t.deleteFrom("extension_packages").where("sha256", "=", sha).execute();
1352
+ if (build !== undefined) {
1353
+ afterCommit(async () => {
1354
+ try {
1355
+ await this.clientBuilds.deleteFiles(sha, build.object_prefix);
1356
+ }
1357
+ catch (err) {
1358
+ this.log.warn("cannot delete an expired org upload's client build objects", {
1359
+ sha256: sha,
1360
+ err,
1361
+ });
1362
+ }
1363
+ });
1364
+ }
1312
1365
  // The object only once the row is gone for good: a failure leaves an unreferenced object
1313
1366
  // (harmless; a later upload of the same bytes rewrites it), never a row without its bytes
1314
1367
  if (pkg.blob_store === "object") {
@@ -1588,6 +1641,7 @@ let PluginImport = class PluginImport {
1588
1641
  warnings,
1589
1642
  reviewHash: extensionReviewHash(set, warnings),
1590
1643
  sensitive: permissions.some((g) => g.sensitive) || warnings.length > 0,
1644
+ ...(pkg.section.client !== undefined && { client: reviewClient(pkg) }),
1591
1645
  };
1592
1646
  }
1593
1647
  async readStored(sha256) {
@@ -1654,7 +1708,8 @@ PluginImport = __decorate([
1654
1708
  __param(13, Inject(KERNEL_LOGGER)),
1655
1709
  __param(14, Inject(TransactionHost)),
1656
1710
  __param(15, Inject(PlatformComponents)),
1657
- __param(16, Inject(getQueueToken(MAINTENANCE_QUEUE_NAME)))
1711
+ __param(16, Inject(getQueueToken(MAINTENANCE_QUEUE_NAME))),
1712
+ __param(17, Inject(ClientBuilds))
1658
1713
  ], PluginImport);
1659
1714
  export { PluginImport };
1660
1715
  /**
@@ -1751,6 +1806,27 @@ export function pluginReviewHash(r, files) {
1751
1806
  ]);
1752
1807
  return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
1753
1808
  }
1809
+ /** The client code of a read extension package for its review (sizes from the package's files). */
1810
+ function reviewClient(pkg) {
1811
+ const c = pkg.section.client;
1812
+ const sizes = new Map(pkg.files.map((f) => [f.path, f.size]));
1813
+ const files = [
1814
+ ...(c.entry !== undefined ? [c.entry] : []),
1815
+ ...(c.styles ?? []),
1816
+ ...(c.assets ?? []),
1817
+ ].map((path) => {
1818
+ // checkClientFiles ran first: every listed file is in the package
1819
+ const size = sizes.get(path.slice(2));
1820
+ if (size === undefined)
1821
+ throw new Error(`client file ${path} is not in the package`);
1822
+ return { path, size };
1823
+ });
1824
+ return {
1825
+ files,
1826
+ totalBytes: files.reduce((n, f) => n + f.size, 0),
1827
+ apiVersion: c.apiVersion,
1828
+ };
1829
+ }
1754
1830
  /**
1755
1831
  * A version change that is a downgrade (the imported version below the installed one) whose
1756
1832
  * version has migrations not run on this deployment -> `extension.downgrade_unsupported` (the same
@@ -16,9 +16,11 @@ export { LEGACY_FORMAT_MESSAGE, packagePath };
16
16
  */
17
17
  /**
18
18
  * Codes of {@link PluginPackageError}: `theme.invalid` / `theme.unreadable` when the package's only
19
- * problems are in its themes (themes spec §5: a failing theme refuses the whole plugin).
19
+ * problems are in its themes (themes spec §5: a failing theme refuses the whole plugin);
20
+ * `client.undeclared` for an `extension.client` section whose manifest lacks
21
+ * `permissions.client: true` (client extensions spec §5.4: client code is always reviewed).
20
22
  */
21
- export type PluginPackageErrorCode = "catalog.invalid_manifest" | "catalog.legacy_format" | "theme.invalid" | "theme.unreadable";
23
+ export type PluginPackageErrorCode = "catalog.invalid_manifest" | "catalog.legacy_format" | "theme.invalid" | "theme.unreadable" | "client.undeclared";
22
24
  /** The problems of one theme of a refused plugin (English; `unreadable:` lines for readability). */
23
25
  export interface PluginThemeProblem {
24
26
  /** The theme id (the file's base name). */
@@ -12,9 +12,11 @@ export class PluginPackageError extends Error {
12
12
  constructor(code, issues, themes) {
13
13
  super(code === "catalog.legacy_format"
14
14
  ? (issues[0] ?? LEGACY_FORMAT_MESSAGE)
15
- : code === "theme.invalid" || code === "theme.unreadable"
16
- ? `Theme ${themes?.[0]?.theme ?? "?"} ${code === "theme.invalid" ? "is invalid" : "is not readable"}: ${issues[0] ?? "unknown problem"}`
17
- : `Invalid plugin package: ${issues[0] ?? "unknown problem"}`);
15
+ : code === "client.undeclared"
16
+ ? (issues[0] ?? code)
17
+ : code === "theme.invalid" || code === "theme.unreadable"
18
+ ? `Theme ${themes?.[0]?.theme ?? "?"} ${code === "theme.invalid" ? "is invalid" : "is not readable"}: ${issues[0] ?? "unknown problem"}`
19
+ : `Invalid plugin package: ${issues[0] ?? "unknown problem"}`);
18
20
  this.name = "PluginPackageError";
19
21
  this.code = code;
20
22
  this.issues = issues;
@@ -78,6 +80,27 @@ export function iconProblem(path, data) {
78
80
  return /<svg[\s>/]/i.test(text) ? undefined : `${path} is not an SVG document`;
79
81
  }
80
82
  /** package.json parsed, or undefined when it is not valid (only used to name a legacy package). */
83
+ /**
84
+ * Client code the extension manifest does not declare (an `extension.client` section without
85
+ * `permissions.client: true`), read from the raw plugin.json before it is parsed, so the refusal
86
+ * names it (`client.undeclared`) instead of being one parse problem among others.
87
+ */
88
+ function clientUndeclared(text) {
89
+ let raw;
90
+ try {
91
+ raw = JSON.parse(text);
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ const ext = isObj(raw) ? raw.extension : undefined;
97
+ if (!isObj(ext) || ext.client === undefined)
98
+ return undefined;
99
+ const perms = isObj(ext.manifest) ? ext.manifest.permissions : undefined;
100
+ if (isObj(perms) && perms.client === true)
101
+ return undefined;
102
+ return `${PLUGIN_MANIFEST_FILE}: extension.client ships client code, but the extension manifest does not declare permissions.client: true; client code must be declared so it is reviewed (capybara-plugin pack writes both)`;
103
+ }
81
104
  const parsedOrUndefined = (data) => {
82
105
  try {
83
106
  return parsePackageJsonFile({ data });
@@ -201,6 +224,9 @@ export function readPluginFiles(files, opts) {
201
224
  const text = decode(manifestBytes);
202
225
  if (text === undefined)
203
226
  return invalid([`${PLUGIN_MANIFEST_FILE} is not valid UTF-8 text`]);
227
+ const undeclared = clientUndeclared(text);
228
+ if (undeclared !== undefined)
229
+ throw new PluginPackageError("client.undeclared", [undeclared]);
204
230
  const parsed = parsePluginManifest(text);
205
231
  if ("errors" in parsed) {
206
232
  return invalid(parsed.errors.map((e) => `${PLUGIN_MANIFEST_FILE}: ${e}`));
@@ -9,6 +9,10 @@ export declare const ChannelCreateSchema: z.ZodObject<{
9
9
  anonymous: "anonymous";
10
10
  bind: "bind";
11
11
  }>>;
12
+ inboundMode: z.ZodOptional<z.ZodEnum<{
13
+ long_connection: "long_connection";
14
+ webhook: "webhook";
15
+ }>>;
12
16
  }, z.core.$strict>;
13
17
  export declare const ChannelPatchSchema: z.ZodObject<{
14
18
  name: z.ZodOptional<z.ZodString>;
@@ -8,6 +8,8 @@ export const ChannelCreateSchema = z
8
8
  agentId: z.string().min(1).max(100),
9
9
  config: Config,
10
10
  unboundPolicy: Policy.optional(),
11
+ // default long_connection; webhook needs plugin support and channels.webhookBaseUrl (409)
12
+ inboundMode: z.enum(["long_connection", "webhook"]).optional(),
11
13
  })
12
14
  .strict();
13
15
  export const ChannelPatchSchema = z
@@ -7,7 +7,7 @@ import { PluginIcons } from "../extensions/plugin-icons.js";
7
7
  import { type Principal } from "../identity/principal.js";
8
8
  import { type OrgContext } from "../orgs/org.guard.js";
9
9
  import { ChannelCreateSchema as CreateBody, ChannelGuideAckSchema as GuideAckBody, ChannelPatchSchema as PatchBody } from "./channels.schemas.js";
10
- import { ChannelConnections, type ChannelConnectionView } from "./connections.service.js";
10
+ import { ChannelConnections, type ChannelConnectionView, type ChannelInboundInfo } from "./connections.service.js";
11
11
  /**
12
12
  * A connection as the org's pages show it: its plugin's display name (null when it has none) and
13
13
  * the URL of its plugin's icon on the org's catalog route (null without one, or when the org may
@@ -36,13 +36,15 @@ export declare class ChannelConnectionsController {
36
36
  channels: ChannelConnectionPage[];
37
37
  }>;
38
38
  /**
39
- * The connection form's JSON Schema, and where the plugin's setup guide is (`guide`, null when
40
- * the plugin has none the org may see): 新建渠道 shows it once the plugin is picked.
39
+ * The connection form's JSON Schema, where the plugin's setup guide is (`guide`, null when the
40
+ * plugin has none the org may see): 新建渠道 shows it once the plugin is picked; and whether its
41
+ * connections can use webhook inbound (`inbound`, channel inbound mode fields spec §4).
41
42
  */
42
43
  schema(org: OrgContext, plugin: string): Promise<{
43
44
  plugin: string;
44
45
  schema: JsonSchema;
45
46
  guide: Omit<SetupGuideRef, "version"> | null;
47
+ inbound: ChannelInboundInfo;
46
48
  }>;
47
49
  create(principal: Principal, org: OrgContext, body: z.infer<typeof CreateBody>): Promise<{
48
50
  channel: ChannelConnectionPage;
@@ -16,7 +16,7 @@ import { createZodDto } from "../infra/validation/create-zod-dto.js";
16
16
  import { ZBody } from "../infra/validation/zod-params.js";
17
17
  import { CurrentOrg, OrgAction } from "../orgs/org.guard.js";
18
18
  import { ChannelCreateSchema as CreateBody, ChannelGuideAckSchema as GuideAckBody, ChannelPatchSchema as PatchBody, } from "./channels.schemas.js";
19
- import { ChannelConnections } from "./connections.service.js";
19
+ import { ChannelConnections, } from "./connections.service.js";
20
20
  class ChannelCreateBody extends createZodDto(CreateBody, "ChannelCreateBody") {
21
21
  }
22
22
  class ChannelPatchBody extends createZodDto(PatchBody, "ChannelPatchBody") {
@@ -62,13 +62,15 @@ let ChannelConnectionsController = class ChannelConnectionsController {
62
62
  return { channels: await this.withPlugins(org, await this.channels.list(org.orgId)) };
63
63
  }
64
64
  /**
65
- * The connection form's JSON Schema, and where the plugin's setup guide is (`guide`, null when
66
- * the plugin has none the org may see): 新建渠道 shows it once the plugin is picked.
65
+ * The connection form's JSON Schema, where the plugin's setup guide is (`guide`, null when the
66
+ * plugin has none the org may see): 新建渠道 shows it once the plugin is picked; and whether its
67
+ * connections can use webhook inbound (`inbound`, channel inbound mode fields spec §4).
67
68
  */
68
69
  async schema(org, plugin) {
69
70
  const schema = this.channels.jsonSchema(plugin);
71
+ const inbound = this.channels.inboundInfo(plugin);
70
72
  const g = (await this.guides.channelGuides([plugin], org)).get(plugin);
71
- return { plugin, schema, guide: g ? { itemId: g.itemId, guideId: g.guideId } : null };
73
+ return { plugin, schema, guide: g ? { itemId: g.itemId, guideId: g.guideId } : null, inbound };
72
74
  }
73
75
  async create(principal, org, body) {
74
76
  const p = requireUser(principal);
@@ -15,6 +15,11 @@ import { OrgLock } from "../orgs/org-lock.js";
15
15
  import type { OrgRole } from "../orgs/roles.js";
16
16
  import { ChannelJobs } from "./jobs.js";
17
17
  import type { ChannelLocale } from "./phrases.js";
18
+ /**
19
+ * A public https address: https, and the host is not loopback, a private / link-local / reserved
20
+ * IP, `localhost`, `*.localhost`, `*.local`, `*.internal` or a single label (no DNS lookup).
21
+ */
22
+ export declare function isPublicHttpsUrl(url: string): boolean;
18
23
  /** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
19
24
  export declare const connectionSecretKey: (connectionId: string) => string;
20
25
  /**
@@ -82,6 +87,18 @@ export interface CreateChannelInput {
82
87
  agentId: string;
83
88
  config: Record<string, unknown>;
84
89
  unboundPolicy?: UnboundPolicy | undefined;
90
+ /** Default `long_connection`; `webhook` needs plugin support and `channels.webhookBaseUrl`. */
91
+ inboundMode?: InboundMode | undefined;
92
+ }
93
+ /**
94
+ * Whether a channel plugin's connections can use webhook inbound (channel inbound mode fields spec
95
+ * §4): the plugin implements `Channel.webhook`; the kernel accepts webhook mode (a usable
96
+ * `channels.webhookBaseUrl`); and that base URL is a public https address.
97
+ */
98
+ export interface ChannelInboundInfo {
99
+ webhookSupported: boolean;
100
+ webhookAvailable: boolean;
101
+ webhookPublic: boolean;
85
102
  }
86
103
  /** `config` 里的密钥字段:缺省 = 保留原值、`null` = 清除、其他值 = 新值(同组织插件配置)。 */
87
104
  export interface UpdateChannelInput {
@@ -163,6 +180,15 @@ export declare class ChannelConnections implements OnApplicationShutdown {
163
180
  * `channels.webhookBaseUrl` (otherwise there is no URL to give the platform): 409 otherwise.
164
181
  */
165
182
  private assertWebhookAvailable;
183
+ /**
184
+ * The fields the inbound mode requires (`x-ui.inboundModes`, channel inbound mode fields spec
185
+ * §3) must have a value in `full` (the parsed config, stored secrets included): 400
186
+ * `extension.config_invalid` with one issue per missing field otherwise. A plugin without the
187
+ * hint passes.
188
+ */
189
+ private assertModeFields;
190
+ /** Whether the plugin's connections can use webhook inbound (spec §4); `extension.not_found`. */
191
+ inboundInfo(plugin: string): ChannelInboundInfo;
166
192
  private viewOf;
167
193
  private live;
168
194
  list(orgId: string): Promise<ChannelConnectionView[]>;
@@ -8,7 +8,8 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
- import { CHANNEL_EXTENSION_POINT, } from "@capybara-hq/plugin-sdk";
11
+ import { isIP } from "node:net";
12
+ import { CHANNEL_EXTENSION_POINT, missingInboundModeFields, } from "@capybara-hq/plugin-sdk";
12
13
  import { Inject, Injectable } from "@nestjs/common";
13
14
  import { sql } from "kysely";
14
15
  import { configFields, getPath, secretScalars, secretValues } from "../config/fields.js";
@@ -28,11 +29,35 @@ import { Transactional } from "../infra/database/transactional.decorator.js";
28
29
  import { KernelEvents } from "../infra/events/kernel-events.js";
29
30
  import { KERNEL_BUS, KERNEL_LOGGER, KEYRING, REDIS } from "../infra/tokens.js";
30
31
  import { createRedactor } from "../log.js";
32
+ import { isForbiddenAddress, isLoopbackHost, normalizeHost } from "../net/public-address.js";
31
33
  import { OrgLock } from "../orgs/org-lock.js";
32
34
  import { readOrgSettings } from "../orgs/settings.service.js";
33
35
  import { adaptToFeatures } from "./features.js";
34
36
  import { ChannelJobs } from "./jobs.js";
35
37
  import { webhookBase, webhookUrlOf } from "./webhook-url.js";
38
+ /**
39
+ * A public https address: https, and the host is not loopback, a private / link-local / reserved
40
+ * IP, `localhost`, `*.localhost`, `*.local`, `*.internal` or a single label (no DNS lookup).
41
+ */
42
+ export function isPublicHttpsUrl(url) {
43
+ let u;
44
+ try {
45
+ u = new URL(url);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ if (u.protocol !== "https:")
51
+ return false;
52
+ const host = normalizeHost(u.hostname);
53
+ if (isLoopbackHost(host))
54
+ return false;
55
+ if (isIP(host) !== 0)
56
+ return !isForbiddenAddress(host);
57
+ if (!host.includes("."))
58
+ return false;
59
+ return ![".localhost", ".local", ".internal"].some((s) => host.endsWith(s));
60
+ }
36
61
  /** 内核 secrets 命名空间(插件名不能取 `kernel`)。 */
37
62
  const SECRETS_NAMESPACE = "kernel";
38
63
  /** 连接配置里全部密钥字段存成一行:`{ 点路径: 值 }` 的 JSON。 */
@@ -305,6 +330,38 @@ let ChannelConnections = class ChannelConnections {
305
330
  throw new ApiError("channel.webhook_unavailable", "Webhook inbound needs channels.webhookBaseUrl to be configured");
306
331
  }
307
332
  }
333
+ /**
334
+ * The fields the inbound mode requires (`x-ui.inboundModes`, channel inbound mode fields spec
335
+ * §3) must have a value in `full` (the parsed config, stored secrets included): 400
336
+ * `extension.config_invalid` with one issue per missing field otherwise. A plugin without the
337
+ * hint passes.
338
+ */
339
+ assertModeFields(plugin, schema, mode, full) {
340
+ const js = this.formSchema(plugin, this.versionOf(plugin), schema)?.schema;
341
+ if (js === undefined)
342
+ return;
343
+ const missing = missingInboundModeFields(js, mode, (field) => {
344
+ const v = getPath(full, field);
345
+ return v !== undefined && v !== null && v !== "";
346
+ });
347
+ if (missing.length === 0)
348
+ return;
349
+ throw new ApiError("extension.config_invalid", `Invalid channel connection config for extension ${plugin}`, missing.map((field) => ({
350
+ path: field,
351
+ segments: [field],
352
+ message: `required in ${mode} mode`,
353
+ })));
354
+ }
355
+ /** Whether the plugin's connections can use webhook inbound (spec §4); `extension.not_found`. */
356
+ inboundInfo(plugin) {
357
+ const webhookSupported = this.channel(plugin).webhook !== undefined;
358
+ const base = this.webhookBase();
359
+ return {
360
+ webhookSupported,
361
+ webhookAvailable: webhookSupported && base !== undefined,
362
+ webhookPublic: webhookSupported && base !== undefined && isPublicHttpsUrl(base),
363
+ };
364
+ }
308
365
  async viewOf(row) {
309
366
  return this.view(row, new Set(Object.keys(await this.readSecrets(row.org_id, row.id))));
310
367
  }
@@ -331,14 +388,16 @@ let ChannelConnections = class ChannelConnections {
331
388
  await this.assertEnabled(orgId, input.plugin);
332
389
  const schema = channel.connectionSchema;
333
390
  const full = this.parse(input.plugin, schema, input.config);
391
+ const mode = input.inboundMode ?? "long_connection";
392
+ if (mode === "webhook")
393
+ this.assertWebhookAvailable(input.plugin);
394
+ this.assertModeFields(input.plugin, schema, mode, full);
334
395
  await this.assertAgent(orgId, input.agentId);
335
396
  const verified = await this.verify(channel, full);
336
397
  const id = randomUUID();
337
398
  const { plain, secrets } = split(schema, full);
338
- // the guide the 新建渠道 page showed: the plugin's current one, for the default mode
339
- const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", {
340
- mode: "long_connection",
341
- });
399
+ // the guide the 新建渠道 page showed: the plugin's current one, for the mode picked there
400
+ const guideAck = await this.guides.currentAck(input.plugin, "channel-connection", { mode });
342
401
  const row = await this.insertIn({
343
402
  orgId,
344
403
  id,
@@ -372,6 +431,7 @@ let ChannelConnections = class ChannelConnections {
372
431
  agent_id: input.agentId,
373
432
  config: JSON.stringify(a.plain),
374
433
  unbound_policy: input.unboundPolicy ?? "bind",
434
+ inbound_mode: input.inboundMode ?? "long_connection",
375
435
  bot_user_id: a.verified.botUserId,
376
436
  tenant_id: a.verified.tenantId,
377
437
  guide_ack: a.guideAck === null ? null : JSON.stringify(a.guideAck),
@@ -385,7 +445,11 @@ let ChannelConnections = class ChannelConnections {
385
445
  orgId,
386
446
  action: "channel.created",
387
447
  target: id,
388
- detail: { plugin: input.plugin, agentId: input.agentId },
448
+ detail: {
449
+ plugin: input.plugin,
450
+ agentId: input.agentId,
451
+ inboundMode: input.inboundMode ?? "long_connection",
452
+ },
389
453
  });
390
454
  return inserted;
391
455
  }
@@ -437,6 +501,15 @@ let ChannelConnections = class ChannelConnections {
437
501
  const modeChanged = patch.inboundMode !== undefined && patch.inboundMode !== current.inbound_mode;
438
502
  if (modeChanged && patch.inboundMode === "webhook")
439
503
  this.assertWebhookAvailable(current.plugin);
504
+ if (rotated || modeChanged) {
505
+ // the fields the effective mode requires (spec §3): the new config, else the stored one
506
+ const schema = this.channel(current.plugin).connectionSchema;
507
+ const full = rotated?.full ?? {
508
+ ...(current.config ?? {}),
509
+ ...(await this.readSecrets(orgId, id)),
510
+ };
511
+ this.assertModeFields(current.plugin, schema, patch.inboundMode ?? current.inbound_mode, full);
512
+ }
440
513
  if (patch.agentId !== undefined)
441
514
  await this.assertAgent(orgId, patch.agentId);
442
515
  // A mode switch sets the platform app up for the new mode: the guide of that mode is the one