@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.
- package/dist/admin/admin.schemas.d.ts +5 -13
- package/dist/catalog/admin-catalog.controller.d.ts +27 -4
- package/dist/catalog/admin-catalog.controller.js +53 -5
- package/dist/catalog/appearance.schemas.d.ts +10 -0
- package/dist/catalog/appearance.schemas.js +20 -0
- package/dist/catalog/appearance.service.d.ts +105 -0
- package/dist/catalog/appearance.service.js +306 -0
- package/dist/catalog/catalog-import.module.js +3 -0
- package/dist/catalog/catalog-items.d.ts +11 -0
- package/dist/catalog/catalog-items.js +21 -0
- package/dist/catalog/catalog-scope.service.d.ts +7 -0
- package/dist/catalog/catalog-scope.service.js +55 -15
- package/dist/catalog/catalog.service.js +10 -2
- package/dist/catalog/cli.js +3 -0
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/org-extension.controller.d.ts +27 -7
- package/dist/catalog/org-extension.controller.js +48 -6
- package/dist/catalog/plugin-import.d.ts +28 -4
- package/dist/catalog/plugin-import.js +95 -19
- package/dist/catalog/plugin-package.d.ts +4 -2
- package/dist/catalog/plugin-package.js +29 -3
- package/dist/channels/channels.schemas.d.ts +4 -0
- package/dist/channels/channels.schemas.js +2 -0
- package/dist/channels/connections.controller.d.ts +5 -3
- package/dist/channels/connections.controller.js +6 -4
- package/dist/channels/connections.service.d.ts +26 -0
- package/dist/channels/connections.service.js +79 -6
- package/dist/client/client-access.service.d.ts +101 -0
- package/dist/client/client-access.service.js +294 -0
- package/dist/client/client-builds.service.d.ts +109 -0
- package/dist/client/client-builds.service.js +229 -0
- package/dist/client/client-delivery.controller.d.ts +25 -0
- package/dist/client/client-delivery.controller.js +66 -0
- package/dist/client/client-files.d.ts +48 -0
- package/dist/client/client-files.js +136 -0
- package/dist/client/client-reports.cron.d.ts +13 -0
- package/dist/client/client-reports.cron.js +42 -0
- package/dist/client/client-reports.service.d.ts +30 -0
- package/dist/client/client-reports.service.js +102 -0
- package/dist/client/client.controller.d.ts +45 -0
- package/dist/client/client.controller.js +121 -0
- package/dist/client/client.module.d.ts +7 -0
- package/dist/client/client.module.js +26 -0
- package/dist/client/client.schemas.d.ts +55 -0
- package/dist/client/client.schemas.js +27 -0
- package/dist/ctx/create-ctx.d.ts +4 -3
- package/dist/ctx/create-ctx.js +4 -3
- package/dist/db/kernel-schema.d.ts +47 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +29 -0
- package/dist/extensions/admin-ops.d.ts +4 -1
- package/dist/extensions/admin-ops.js +22 -1
- package/dist/extensions/admin-view.d.ts +8 -0
- package/dist/extensions/admin-view.js +78 -0
- package/dist/extensions/blobs.d.ts +14 -1
- package/dist/extensions/blobs.js +48 -5
- package/dist/extensions/guide-places.d.ts +3 -1
- package/dist/extensions/guide-places.js +1 -1
- package/dist/extensions/host.d.ts +6 -1
- package/dist/extensions/host.js +20 -9
- package/dist/extensions/package.d.ts +4 -3
- package/dist/extensions/package.js +5 -1
- package/dist/extensions/permission-set.d.ts +19 -3
- package/dist/extensions/permission-set.js +19 -2
- package/dist/extensions/permission-view.d.ts +38 -7
- package/dist/extensions/permission-view.js +23 -7
- package/dist/extensions/sources.d.ts +6 -0
- package/dist/extensions/sources.js +2 -0
- package/dist/extensions/state.module.js +9 -1
- package/dist/http/api.module.js +10 -0
- package/dist/identity/settings.d.ts +41 -26
- package/dist/identity/settings.js +87 -4
- package/dist/infra/throttler/named-throttlers.d.ts +4 -0
- package/dist/infra/throttler/named-throttlers.js +14 -0
- package/dist/kernel.module.js +3 -0
- package/dist/orgs/settings.service.d.ts +2 -26
- package/dist/platform/exclusions.js +27 -1
- package/dist/platform/ops/admin.ops.js +17 -2
- package/dist/platform/ops/catalog-admin.ops.d.ts +19 -1
- package/dist/platform/ops/catalog-admin.ops.js +85 -2
- package/dist/platform/ops/org-plugins.ops.d.ts +7 -1
- package/dist/platform/ops/org-plugins.ops.js +63 -4
- package/dist/platform/ops/org-settings.ops.js +2 -2
- package/dist/platform/ops/orgs.ops.js +1 -0
- package/dist/platform/ops/ui.ops.js +1 -1
- package/dist/platform/platform-ops.module.js +2 -0
- package/dist/platform/route-schemas.js +3 -0
- package/dist/roles/worker.module.js +4 -0
- package/dist/themes/render-css.d.ts +2 -1
- package/dist/themes/render-css.js +5 -2
- package/dist/themes/theme-delivery.service.d.ts +4 -2
- package/dist/themes/theme-delivery.service.js +8 -3
- package/dist/themes/theme-state.d.ts +28 -7
- package/dist/themes/theme-state.js +24 -6
- package/dist/themes/themes-public.controller.d.ts +7 -2
- package/dist/themes/themes-public.controller.js +11 -5
- package/dist/themes/themes.service.js +2 -2
- package/dist/tools/pipeline.d.ts +3 -2
- package/dist/ui/arrangement.d.ts +41 -50
- package/dist/ui/arrangement.js +88 -15
- package/dist/ui/registry.d.ts +13 -3
- package/dist/ui/registry.js +15 -4
- package/dist/ui/ui.schemas.d.ts +4 -0
- package/dist/ui/views.d.ts +36 -9
- package/dist/ui/views.js +58 -28
- package/package.json +3 -3
- 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`;
|
|
116
|
-
*
|
|
117
|
-
* anything else -> `catalog.invalid_manifest` (issues as
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
16
|
-
?
|
|
17
|
-
:
|
|
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,
|
|
40
|
-
*
|
|
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,
|
|
66
|
-
*
|
|
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 {
|
|
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
|
|
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: {
|
|
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
|