@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
@@ -53,19 +53,11 @@ export declare const DeploymentSettingsBody: z.ZodObject<{
53
53
  uiTheme: z.ZodOptional<z.ZodObject<{
54
54
  id: z.ZodNullable<z.ZodString>;
55
55
  }, z.core.$strict>>;
56
- uiArrangement: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
57
- "agent.detail.section": "agent.detail.section";
58
- "conversation.panel": "conversation.panel";
59
- "conversation.properties.section": "conversation.properties.section";
60
- "conversation.tool-result.card": "conversation.tool-result.card";
61
- "file.preview": "file.preview";
62
- "org.page": "org.page";
63
- "org.settings.section": "org.settings.section";
64
- "platform.page": "platform.page";
65
- }> & z.core.$partial, z.ZodNullable<z.ZodObject<{
66
- hidden: z.ZodArray<z.ZodString>;
67
- order: z.ZodArray<z.ZodString>;
68
- }, z.core.$strict>>>>;
56
+ uiArrangement: z.ZodOptional<z.ZodType<Partial<Record<"agent.detail.section" | "conversation.composer.frame" | "conversation.empty" | "conversation.panel" | "conversation.properties.section" | "conversation.tool-result.card" | "file.preview" | "org.page" | "org.settings.section" | "platform.page" | "shell.brand" | "shell.overlay", import("../ui/arrangement.js").UiArrangementEntry | null>>, unknown, z.core.$ZodTypeInternals<Partial<Record<"agent.detail.section" | "conversation.composer.frame" | "conversation.empty" | "conversation.panel" | "conversation.properties.section" | "conversation.tool-result.card" | "file.preview" | "org.page" | "org.settings.section" | "platform.page" | "shell.brand" | "shell.overlay", import("../ui/arrangement.js").UiArrangementEntry | null>>, unknown>>>;
57
+ clientExtensions: z.ZodOptional<z.ZodObject<{
58
+ enabled: z.ZodOptional<z.ZodBoolean>;
59
+ disabled: z.ZodOptional<z.ZodArray<z.ZodString>>;
60
+ }, z.core.$strict>>;
69
61
  }, z.core.$strict>;
70
62
  export declare const PurposeParams: z.ZodObject<{
71
63
  purpose: z.ZodString;
@@ -1,9 +1,13 @@
1
+ import type { SuggestedAppearance } from "@capybara-hq/plugin-sdk";
1
2
  import type { FastifyReply } from "fastify";
2
3
  import { z } from "zod";
4
+ import { type ClientBuildSummary, ClientBuilds } from "../client/client-builds.service.js";
3
5
  import { PluginGuides, type RenderedSetupGuide } from "../extensions/plugin-guides.js";
4
6
  import { PluginIcons } from "../extensions/plugin-icons.js";
5
7
  import { type Principal } from "../identity/principal.js";
6
8
  import { AdminCatalogGuideQuery, CatalogItemsQuery, CatalogUpdateBody, CatalogUpdatePreviewBody, CatalogDisabledBody as DisabledBody, CatalogGitImportBody as GitBody, CatalogNpmImportBody as NpmBody, CatalogScopeBody as ScopeBody, CatalogScopePreviewBody as ScopePreviewBody, CatalogShownBody as ShownBody } from "./admin-catalog.schemas.js";
9
+ import { AppearanceApplyBody } from "./appearance.schemas.js";
10
+ import { type AppearanceResult, AppearanceService } from "./appearance.service.js";
7
11
  import { type CatalogItemDetail, type CatalogItemView, CatalogService, type PermissionsMismatch } from "./catalog.service.js";
8
12
  import { CatalogScopeService, type ScopePreview } from "./catalog-scope.service.js";
9
13
  import { OrgCatalogService } from "./org-catalog.service.js";
@@ -39,8 +43,10 @@ export declare class AdminCatalogController {
39
43
  private readonly scope;
40
44
  private readonly updater;
41
45
  private readonly icons;
46
+ private readonly appearance;
47
+ private readonly clientBuilds;
42
48
  private readonly guides;
43
- constructor(catalog: CatalogService, pluginImport: PluginImport, orgCatalog: OrgCatalogService, scope: CatalogScopeService, updater: PlatformPluginUpdater, icons: PluginIcons, guides: PluginGuides);
49
+ constructor(catalog: CatalogService, pluginImport: PluginImport, orgCatalog: OrgCatalogService, scope: CatalogScopeService, updater: PlatformPluginUpdater, icons: PluginIcons, appearance: AppearanceService, clientBuilds: ClientBuilds, guides: PluginGuides);
44
50
  /**
45
51
  * Prepares the import of an uploaded plugin (an npm-pack tgz as the raw body,
46
52
  * `application/octet-stream`, at most `packages.maxBytes`): reads and checks the whole plugin
@@ -115,12 +121,29 @@ export declare class AdminCatalogController {
115
121
  nextOffset: number | null;
116
122
  }>;
117
123
  /**
118
- * 条目详情与各版本,另给出 `hasIcon`(最新版本有图标,`GET items/:id/icon` 可取);不存在 → 404
119
- * `catalog.item_not_found`。
124
+ * The item with its versions, plus `hasIcon` (the latest version has an icon, served by `GET
125
+ * items/:id/icon`) and `suggestedAppearance` (the platform version's, null without one: the page
126
+ * offers 应用此插件的外观 when set). Each version carries `client`: its extension package's
127
+ * client build (files with sizes, total, client API version; null without one), what the
128
+ * permission confirmation of an org's upload shows in its 客户端代码 group (client extensions spec
129
+ * §5.4). Unknown -> 404 `catalog.item_not_found`.
120
130
  */
121
- get(id: string): Promise<CatalogItemDetail & {
131
+ get(id: string): Promise<Omit<CatalogItemDetail, "versions"> & {
132
+ versions: (CatalogItemDetail["versions"][number] & {
133
+ client: ClientBuildSummary | null;
134
+ })[];
122
135
  hasIcon: boolean;
136
+ suggestedAppearance: SuggestedAppearance | null;
123
137
  }>;
138
+ /**
139
+ * 应用此插件的外观 (client extensions spec §6.5): sets the plugin's suggested theme as the platform
140
+ * default theme and each suggested replacement as the platform arrangement's `use` of its slot, in
141
+ * one transaction. `dryRun: true` answers the same changes and writes nothing. A theme that cannot
142
+ * be the platform default -> 400 `theme.not_available` (nothing written); a replacement not
143
+ * available on the platform is skipped. No such item, or no suggestion -> 404
144
+ * `catalog.item_not_found`.
145
+ */
146
+ applyAppearance(principal: Principal, id: string, body: z.infer<typeof AppearanceApplyBody>): Promise<AppearanceResult>;
124
147
  /**
125
148
  * The item's icon (disabled items included; same response headers as the org side, see
126
149
  * `sendCatalogIcon`). No icon -> 404 `catalog.icon_not_found`.
@@ -9,6 +9,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
9
9
  };
10
10
  import { Controller, Delete, Get, HttpCode, Inject, Param, Post, Put, Req, Res, } from "@nestjs/common";
11
11
  import { z } from "zod";
12
+ import { ClientBuilds } from "../client/client-builds.service.js";
12
13
  import { ApiError } from "../errors.js";
13
14
  import { PluginGuides } from "../extensions/plugin-guides.js";
14
15
  import { PluginIcons } from "../extensions/plugin-icons.js";
@@ -16,6 +17,8 @@ import { CurrentPrincipal, patTokenId, requireUser, SuperAdmin, } from "../ident
16
17
  import { createZodDto } from "../infra/validation/create-zod-dto.js";
17
18
  import { ZBody, ZQuery } from "../infra/validation/zod-params.js";
18
19
  import { AdminCatalogGuideQuery, CatalogItemsQuery, CatalogUpdateBody, CatalogUpdatePreviewBody, CatalogDisabledBody as DisabledBody, CatalogGitImportBody as GitBody, CatalogHash as Hash, CatalogNpmImportBody as NpmBody, CatalogScopeOrgs as Orgs, CatalogScope as Scope, CatalogScopeBody as ScopeBody, CatalogScopePreviewBody as ScopePreviewBody, CatalogShownBody as ShownBody, } from "./admin-catalog.schemas.js";
20
+ import { AppearanceApplyBody } from "./appearance.schemas.js";
21
+ import { AppearanceService, suggestedAppearanceOf, } from "./appearance.service.js";
19
22
  import { CatalogService, } from "./catalog.service.js";
20
23
  import { CatalogScopeService } from "./catalog-scope.service.js";
21
24
  import { sendCatalogIcon } from "./org-catalog.controller.js";
@@ -65,6 +68,8 @@ class CatalogUpdatePreviewBodyDto extends createZodDto(CatalogUpdatePreviewBody,
65
68
  }
66
69
  class CatalogUpdateBodyDto extends createZodDto(CatalogUpdateBody, "CatalogUpdateBody") {
67
70
  }
71
+ class AppearanceApplyBodyDto extends createZodDto(AppearanceApplyBody, "AppearanceApplyBody") {
72
+ }
68
73
  /**
69
74
  * The platform catalog (`/api/v1/admin/catalog/*`, super admins): plugin import (upload / npm
70
75
  * prepare, then confirm, which installs the plugin's extension), the item list and detail, scope and
@@ -78,14 +83,18 @@ let AdminCatalogController = class AdminCatalogController {
78
83
  scope;
79
84
  updater;
80
85
  icons;
86
+ appearance;
87
+ clientBuilds;
81
88
  guides;
82
- constructor(catalog, pluginImport, orgCatalog, scope, updater, icons, guides) {
89
+ constructor(catalog, pluginImport, orgCatalog, scope, updater, icons, appearance, clientBuilds, guides) {
83
90
  this.catalog = catalog;
84
91
  this.pluginImport = pluginImport;
85
92
  this.orgCatalog = orgCatalog;
86
93
  this.scope = scope;
87
94
  this.updater = updater;
88
95
  this.icons = icons;
96
+ this.appearance = appearance;
97
+ this.clientBuilds = clientBuilds;
89
98
  this.guides = guides;
90
99
  }
91
100
  /**
@@ -180,12 +189,42 @@ let AdminCatalogController = class AdminCatalogController {
180
189
  return { ...page, items: page.items.map((i) => ({ ...i, icon: icons.get(i.id) ?? null })) };
181
190
  }
182
191
  /**
183
- * 条目详情与各版本,另给出 `hasIcon`(最新版本有图标,`GET items/:id/icon` 可取);不存在 → 404
184
- * `catalog.item_not_found`。
192
+ * The item with its versions, plus `hasIcon` (the latest version has an icon, served by `GET
193
+ * items/:id/icon`) and `suggestedAppearance` (the platform version's, null without one: the page
194
+ * offers 应用此插件的外观 when set). Each version carries `client`: its extension package's
195
+ * client build (files with sizes, total, client API version; null without one), what the
196
+ * permission confirmation of an org's upload shows in its 客户端代码 group (client extensions spec
197
+ * §5.4). Unknown -> 404 `catalog.item_not_found`.
185
198
  */
186
199
  async get(id) {
187
200
  const detail = await this.catalog.get(id);
188
- return { ...detail, hasIcon: await this.orgCatalog.hasIcon(id) };
201
+ const platform = detail.versions.find((v) => v.version === detail.item.platformVersion);
202
+ const [hasIcon, builds] = await Promise.all([
203
+ this.orgCatalog.hasIcon(id),
204
+ this.clientBuilds.summaries(detail.versions.flatMap((v) => (v.extensionSha256 === null ? [] : [v.extensionSha256]))),
205
+ ]);
206
+ return {
207
+ ...detail,
208
+ versions: detail.versions.map((v) => ({
209
+ ...v,
210
+ client: v.extensionSha256 === null ? null : (builds.get(v.extensionSha256) ?? null),
211
+ })),
212
+ hasIcon,
213
+ suggestedAppearance: suggestedAppearanceOf(platform?.manifest),
214
+ };
215
+ }
216
+ /**
217
+ * 应用此插件的外观 (client extensions spec §6.5): sets the plugin's suggested theme as the platform
218
+ * default theme and each suggested replacement as the platform arrangement's `use` of its slot, in
219
+ * one transaction. `dryRun: true` answers the same changes and writes nothing. A theme that cannot
220
+ * be the platform default -> 400 `theme.not_available` (nothing written); a replacement not
221
+ * available on the platform is skipped. No such item, or no suggestion -> 404
222
+ * `catalog.item_not_found`.
223
+ */
224
+ applyAppearance(principal, id, body) {
225
+ const actor = requireUser(principal);
226
+ const tokenId = patTokenId(actor);
227
+ return this.appearance.apply({ platform: true }, id, { accountId: actor.account.id, ...(tokenId !== undefined && { tokenId }) }, body.dryRun === true);
189
228
  }
190
229
  /**
191
230
  * The item's icon (disabled items included; same response headers as the org side, see
@@ -340,6 +379,13 @@ __decorate([
340
379
  Get("items/:id"),
341
380
  __param(0, Param("id"))
342
381
  ], AdminCatalogController.prototype, "get", null);
382
+ __decorate([
383
+ Post("items/:id/appearance"),
384
+ HttpCode(200),
385
+ __param(0, CurrentPrincipal()),
386
+ __param(1, Param("id")),
387
+ __param(2, ZBody(AppearanceApplyBodyDto))
388
+ ], AdminCatalogController.prototype, "applyAppearance", null);
343
389
  __decorate([
344
390
  Get("items/:id/icon"),
345
391
  __param(0, Param("id")),
@@ -416,6 +462,8 @@ AdminCatalogController = __decorate([
416
462
  __param(3, Inject(CatalogScopeService)),
417
463
  __param(4, Inject(PlatformPluginUpdater)),
418
464
  __param(5, Inject(PluginIcons)),
419
- __param(6, Inject(PluginGuides))
465
+ __param(6, Inject(AppearanceService)),
466
+ __param(7, Inject(ClientBuilds)),
467
+ __param(8, Inject(PluginGuides))
420
468
  ], AdminCatalogController);
421
469
  export { AdminCatalogController };
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import type { RouteSchemas } from "../platform/route-schemas.js";
3
+ /**
4
+ * The body of both "apply this plugin's appearance" routes (client extensions spec §6.5):
5
+ * `dryRun: true` answers what would change and writes nothing (the confirm dialog's preview).
6
+ */
7
+ export declare const AppearanceApplyBody: z.ZodObject<{
8
+ dryRun: z.ZodOptional<z.ZodBoolean>;
9
+ }, z.core.$strict>;
10
+ export declare const APPEARANCE_ROUTES: readonly RouteSchemas[];
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { CatalogIdPathParams } from "./admin-catalog.schemas.js";
3
+ import { OrgExtensionParams } from "./org-extension.schemas.js";
4
+ /**
5
+ * The body of both "apply this plugin's appearance" routes (client extensions spec §6.5):
6
+ * `dryRun: true` answers what would change and writes nothing (the confirm dialog's preview).
7
+ */
8
+ export const AppearanceApplyBody = z.object({ dryRun: z.boolean().optional() }).strict();
9
+ export const APPEARANCE_ROUTES = [
10
+ {
11
+ route: "POST /api/v1/admin/catalog/items/:id/appearance",
12
+ params: CatalogIdPathParams,
13
+ body: AppearanceApplyBody,
14
+ },
15
+ {
16
+ route: "POST /api/v1/orgs/:orgId/extensions/:extension/appearance",
17
+ params: OrgExtensionParams,
18
+ body: AppearanceApplyBody,
19
+ },
20
+ ];
@@ -0,0 +1,105 @@
1
+ import { type SuggestedAppearance } from "@capybara-hq/plugin-sdk";
2
+ import { ExtensionHost } from "../extensions/host.js";
3
+ import { SettingsService } from "../identity/settings.js";
4
+ import { SuperAdminLock } from "../identity/super-admin.js";
5
+ import { KernelDb } from "../infra/database/kernel-db.js";
6
+ import { TransactionHost } from "../infra/database/transaction-host.js";
7
+ import { OrgLock } from "../orgs/org-lock.js";
8
+ import { OrgSettingsService } from "../orgs/settings.service.js";
9
+ /** Where the suggestion is applied: the platform defaults, or one org's. */
10
+ export type AppearanceLevel = {
11
+ platform: true;
12
+ } | {
13
+ orgId: string;
14
+ };
15
+ /** Why a replacement of the suggestion was left out (client extensions spec §6.5). */
16
+ export type AppearanceSkipReason = "unknown-slot" | "not-available" | "not-eligible";
17
+ /**
18
+ * What applying a suggestion changes at its level: the default theme (`from`: the theme in effect,
19
+ * null for none; `to`: the `platform_themes` id; null when the plugin suggests no theme or it is
20
+ * in effect already), each replacement slot's `use` (`to`: the contribution key
21
+ * `<extension>/<contribution>`; `from`: the key in effect, null for the built-in; a slot that
22
+ * already uses it is not listed) and what was skipped, in plugin.json order. At org level `from`
23
+ * is the org's own entry, else the platform's: `followsPlatform` says which (absent at platform
24
+ * level), and when the org follows a platform that already uses the suggestion nothing is pinned.
25
+ */
26
+ export interface AppearanceResult {
27
+ theme: {
28
+ from: string | null;
29
+ to: string;
30
+ followsPlatform?: boolean;
31
+ } | null;
32
+ replacements: {
33
+ slot: string;
34
+ from: string | null;
35
+ to: string;
36
+ followsPlatform?: boolean;
37
+ }[];
38
+ skipped: {
39
+ what: string;
40
+ reason: AppearanceSkipReason;
41
+ }[];
42
+ }
43
+ /** Who applies it: the account, the PAT when one made the call, and extra audit detail. */
44
+ export interface AppearanceActor {
45
+ accountId: string;
46
+ tokenId?: string;
47
+ /** Merged into the settings writer's audit detail (the Platform tool's `via: "agent"`). */
48
+ auditDetail?: Record<string, unknown>;
49
+ }
50
+ /** The suggestion of a plugin's catalog version, or null (none, or not an object). */
51
+ export declare function suggestedAppearanceOf(manifest: unknown): SuggestedAppearance | null;
52
+ /**
53
+ * "Apply this plugin's appearance" (client extensions spec §6.5): one step that sets the plugin's
54
+ * suggested theme as the level's default theme and each suggested replacement as the level's `use`
55
+ * of its single slot. The writes go through the settings writers (`SettingsService.update` for the
56
+ * platform, `OrgSettingsService.update` for an org), so their validation, theme checks, lock order
57
+ * (the level's lock, then catalog item, theme row, `uiTheme` row) and audit apply, both settings in
58
+ * one call and one transaction: a theme that cannot be set (`theme.not_available`) refuses the
59
+ * whole call; a replacement that cannot be used where it is set is skipped, never refused (the
60
+ * arrangement keeps unknown keys anyway). A dry run computes the same answer and writes nothing.
61
+ */
62
+ export declare class AppearanceService {
63
+ private readonly db;
64
+ readonly txHost: TransactionHost;
65
+ private readonly settings;
66
+ private readonly orgSettings;
67
+ private readonly adminLock;
68
+ private readonly orgLock;
69
+ private readonly host;
70
+ constructor(db: KernelDb, txHost: TransactionHost, settings: SettingsService, orgSettings: OrgSettingsService, adminLock: SuperAdminLock, orgLock: OrgLock, host: ExtensionHost);
71
+ /**
72
+ * The suggestions of the live plugins named `names`, from the version their components come from
73
+ * (`platform_synced_version`, else the latest); a plugin without one maps to null.
74
+ */
75
+ suggestions(names: readonly string[]): Promise<Map<string, SuggestedAppearance | null>>;
76
+ /** The live catalog item `itemId` with its suggestion; else `catalog.item_not_found`. */
77
+ private platformItem;
78
+ /**
79
+ * The live plugin of extension `extension`, which must be on in the org, with its suggestion;
80
+ * else `extension.not_found`.
81
+ */
82
+ private orgItem;
83
+ /**
84
+ * Applies (or, with `dryRun`, previews) the suggestion of `item` at `level`: for the platform,
85
+ * `item` is the catalog item id (`POST /api/v1/admin/catalog/items/:id/appearance`, super
86
+ * admin); for an org, the extension name (`POST /api/v1/orgs/:orgId/extensions/:extension/
87
+ * appearance`, `org.update`). The writers re-check the actor under their lock.
88
+ */
89
+ apply(level: AppearanceLevel, item: string, actor: AppearanceActor, dryRun: boolean): Promise<AppearanceResult>;
90
+ /** Refuses (`theme.not_available`) a theme the level's writer would refuse; reads only. */
91
+ private assertThemeSettable;
92
+ private applyPlatform;
93
+ private applyOrg;
94
+ /**
95
+ * What applying `found` at `level` changes, from the stored settings (no writes): the level's
96
+ * own, and at org level the platform's it follows where it stores nothing.
97
+ */
98
+ private plan;
99
+ }
100
+ /**
101
+ * The suggested appearance action: its own module, since it writes through the org settings
102
+ * writer (`OrgsModule`, which imports `CatalogModule`) and the deployment settings writer.
103
+ */
104
+ export declare class AppearanceModule {
105
+ }
@@ -0,0 +1,306 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { parseSlotRef, UI_ARRANGEABLE_SLOTS, uiSlot, } from "@capybara-hq/plugin-sdk";
11
+ import { Inject, Injectable, Module } from "@nestjs/common";
12
+ import { ApiError } from "../errors.js";
13
+ import { ExtensionsModule } from "../extensions/extensions.module.js";
14
+ import { ExtensionHost } from "../extensions/host.js";
15
+ import { IdentityModule } from "../identity/identity.module.js";
16
+ import { readUiArrangement, readUiTheme, SettingsService } from "../identity/settings.js";
17
+ import { SuperAdminLock, SuperAdminLockModule } from "../identity/super-admin.js";
18
+ import { KernelDb } from "../infra/database/kernel-db.js";
19
+ import { TransactionHost } from "../infra/database/transaction-host.js";
20
+ import { Transactional } from "../infra/database/transactional.decorator.js";
21
+ import { OrgLock, OrgLockModule } from "../orgs/org-lock.js";
22
+ import { OrgsModule } from "../orgs/orgs.module.js";
23
+ import { OrgSettingsService, readOrgSettings } from "../orgs/settings.service.js";
24
+ import { assertPlatformDefaultEligible, eligiblePlatformDefault, themeNotAvailable, themeOnIn, } from "../themes/theme-state.js";
25
+ import { ORG_ARRANGEABLE } from "../ui/arrangement.js";
26
+ import { inAuthorOrder } from "./catalog-items.js";
27
+ /** The suggestion of a plugin's catalog version, or null (none, or not an object). */
28
+ export function suggestedAppearanceOf(manifest) {
29
+ const sa = manifest?.suggestedAppearance;
30
+ return sa !== null && typeof sa === "object" ? sa : null;
31
+ }
32
+ const itemNotFound = () => new ApiError("catalog.item_not_found", "No such plugin, or it suggests no appearance");
33
+ const extensionNotFound = () => new ApiError("extension.not_found", "No such extension on in this organization, or its plugin suggests no appearance");
34
+ const useOf = (e) => e !== undefined && "use" in e ? e.use : null;
35
+ /**
36
+ * "Apply this plugin's appearance" (client extensions spec §6.5): one step that sets the plugin's
37
+ * suggested theme as the level's default theme and each suggested replacement as the level's `use`
38
+ * of its single slot. The writes go through the settings writers (`SettingsService.update` for the
39
+ * platform, `OrgSettingsService.update` for an org), so their validation, theme checks, lock order
40
+ * (the level's lock, then catalog item, theme row, `uiTheme` row) and audit apply, both settings in
41
+ * one call and one transaction: a theme that cannot be set (`theme.not_available`) refuses the
42
+ * whole call; a replacement that cannot be used where it is set is skipped, never refused (the
43
+ * arrangement keeps unknown keys anyway). A dry run computes the same answer and writes nothing.
44
+ */
45
+ let AppearanceService = class AppearanceService {
46
+ db;
47
+ txHost;
48
+ settings;
49
+ orgSettings;
50
+ adminLock;
51
+ orgLock;
52
+ host;
53
+ constructor(db, txHost, settings, orgSettings, adminLock, orgLock, host) {
54
+ this.db = db;
55
+ this.txHost = txHost;
56
+ this.settings = settings;
57
+ this.orgSettings = orgSettings;
58
+ this.adminLock = adminLock;
59
+ this.orgLock = orgLock;
60
+ this.host = host;
61
+ }
62
+ /**
63
+ * The suggestions of the live plugins named `names`, from the version their components come from
64
+ * (`platform_synced_version`, else the latest); a plugin without one maps to null.
65
+ */
66
+ async suggestions(names) {
67
+ const out = new Map(names.map((n) => [n, null]));
68
+ if (names.length === 0)
69
+ return out;
70
+ const rows = await this.db
71
+ .selectFrom("catalog_items as i")
72
+ .innerJoin("catalog_item_versions as v", (j) => j
73
+ .onRef("v.item_id", "=", "i.id")
74
+ .on((eb) => eb("v.version", "=", eb.fn.coalesce("i.platform_synced_version", "i.latest_version"))))
75
+ .select(["i.name", "v.manifest", "v.appearance_order"])
76
+ .where("i.name", "in", [...names])
77
+ .where("i.deleted_at", "is", null)
78
+ .execute();
79
+ for (const r of rows) {
80
+ out.set(r.name, suggestedAppearanceOf(inAuthorOrder(r.manifest, r.appearance_order)));
81
+ }
82
+ return out;
83
+ }
84
+ /** The live catalog item `itemId` with its suggestion; else `catalog.item_not_found`. */
85
+ async platformItem(itemId) {
86
+ if (!/^[0-9a-f-]{36}$/i.test(itemId))
87
+ throw itemNotFound();
88
+ const row = await this.db
89
+ .selectFrom("catalog_items")
90
+ .select(["id", "name"])
91
+ .where("id", "=", itemId)
92
+ .where("deleted_at", "is", null)
93
+ .executeTakeFirst();
94
+ const suggestion = row && (await this.suggestions([row.name])).get(row.name);
95
+ if (!row || !suggestion)
96
+ throw itemNotFound();
97
+ return { id: row.id, name: row.name, suggestion };
98
+ }
99
+ /**
100
+ * The live plugin of extension `extension`, which must be on in the org, with its suggestion;
101
+ * else `extension.not_found`.
102
+ */
103
+ async orgItem(orgId, extension) {
104
+ if (!(await this.host.enabledPlugins(orgId)).has(extension))
105
+ throw extensionNotFound();
106
+ const row = await this.db
107
+ .selectFrom("catalog_items")
108
+ .select(["id", "name"])
109
+ .where("name", "=", extension)
110
+ .where("deleted_at", "is", null)
111
+ .executeTakeFirst();
112
+ const suggestion = row && (await this.suggestions([row.name])).get(row.name);
113
+ if (!row || !suggestion)
114
+ throw extensionNotFound();
115
+ return { id: row.id, name: row.name, suggestion };
116
+ }
117
+ /**
118
+ * Applies (or, with `dryRun`, previews) the suggestion of `item` at `level`: for the platform,
119
+ * `item` is the catalog item id (`POST /api/v1/admin/catalog/items/:id/appearance`, super
120
+ * admin); for an org, the extension name (`POST /api/v1/orgs/:orgId/extensions/:extension/
121
+ * appearance`, `org.update`). The writers re-check the actor under their lock.
122
+ */
123
+ async apply(level, item, actor, dryRun) {
124
+ if (dryRun) {
125
+ const found = "orgId" in level ? await this.orgItem(level.orgId, item) : await this.platformItem(item);
126
+ const plan = await this.plan(level, found);
127
+ if (plan.theme !== undefined)
128
+ await this.assertThemeSettable(level, plan.theme);
129
+ return plan.result;
130
+ }
131
+ return "orgId" in level
132
+ ? this.applyOrg(level.orgId, item, actor)
133
+ : this.applyPlatform(item, actor);
134
+ }
135
+ /** Refuses (`theme.not_available`) a theme the level's writer would refuse; reads only. */
136
+ async assertThemeSettable(level, themeId) {
137
+ if ("orgId" in level) {
138
+ if (!(await themeOnIn(this.db.db, level.orgId, themeId))) {
139
+ throw themeNotAvailable(themeId, "in this organization");
140
+ }
141
+ }
142
+ else {
143
+ await assertPlatformDefaultEligible(this.db.db, themeId);
144
+ }
145
+ }
146
+ async applyPlatform(itemId, actor) {
147
+ // The writer's own lock first, so what the plan reads (the stored theme and arrangement) is
148
+ // what the write replaces; the writer takes it again (re-entrant) and then the theme rows
149
+ await this.adminLock.lockAndAuthorize(actor.accountId);
150
+ const found = await this.platformItem(itemId);
151
+ const plan = await this.plan({ platform: true }, found);
152
+ if (plan.theme === undefined && Object.keys(plan.arrangement).length === 0)
153
+ return plan.result;
154
+ await this.settings.update({
155
+ ...(plan.theme !== undefined && { uiTheme: { id: plan.theme } }),
156
+ ...(Object.keys(plan.arrangement).length > 0 && { uiArrangement: plan.arrangement }),
157
+ }, actor.accountId, actor.tokenId, { ...actor.auditDetail, appearance: { itemId: found.id, plugin: found.name } });
158
+ return plan.result;
159
+ }
160
+ async applyOrg(orgId, extension, actor) {
161
+ // The org lock first (as the writer takes it): `personal` and the stored arrangement are read
162
+ // under it, so the write keeps the lock setting it read
163
+ await this.orgLock.lockAndAuthorize(orgId, actor.accountId, "org.update");
164
+ const found = await this.orgItem(orgId, extension);
165
+ const plan = await this.plan({ orgId }, found);
166
+ if (plan.theme === undefined && Object.keys(plan.arrangement).length === 0)
167
+ return plan.result;
168
+ const stored = await readOrgSettings(this.db.db, orgId);
169
+ await this.orgSettings.update(orgId, {
170
+ ...(plan.theme !== undefined && {
171
+ theme: { id: plan.theme, personal: stored.theme?.personal ?? "allowed" },
172
+ }),
173
+ ...(Object.keys(plan.arrangement).length > 0 && { uiArrangement: plan.arrangement }),
174
+ }, actor.accountId, actor.tokenId, { ...actor.auditDetail, appearance: { itemId: found.id, plugin: found.name } });
175
+ return plan.result;
176
+ }
177
+ /**
178
+ * What applying `found` at `level` changes, from the stored settings (no writes): the level's
179
+ * own, and at org level the platform's it follows where it stores nothing.
180
+ */
181
+ async plan(level, found) {
182
+ const { suggestion } = found;
183
+ const [platformTheme, platformArrangement] = await Promise.all([
184
+ readUiTheme(this.db.db).then((t) => t.id),
185
+ readUiArrangement(this.db.db),
186
+ ]);
187
+ const org = "orgId" in level ? await readOrgSettings(this.db.db, level.orgId) : null;
188
+ // the theme in effect: an org without its own theme (id null) follows the platform default,
189
+ // which renders only while it is eligible (else the org sees the built-in, as ThemesService
190
+ // resolves it)
191
+ const orgTheme = org === null ? undefined : (org.theme?.id ?? null);
192
+ const themeFollows = orgTheme === null;
193
+ const followed = themeFollows && platformTheme !== null
194
+ ? (await eligiblePlatformDefault(this.db.db, platformTheme)) !== null
195
+ ? platformTheme
196
+ : null
197
+ : platformTheme;
198
+ const themeInEffect = orgTheme ?? followed;
199
+ /** The `use` in effect for a single slot, and whether an org follows the platform's. */
200
+ const inEffect = (slot) => {
201
+ if (org === null)
202
+ return { use: useOf(platformArrangement[slot]) };
203
+ const own = org.uiArrangement?.[slot];
204
+ return own !== undefined
205
+ ? { use: useOf(own), follows: false }
206
+ : { use: useOf(platformArrangement[slot]), follows: true };
207
+ };
208
+ const follows = (f) => (f === undefined ? {} : { followsPlatform: f });
209
+ const result = { theme: null, replacements: [], skipped: [] };
210
+ const plan = { result, arrangement: {} };
211
+ if (suggestion.theme !== undefined) {
212
+ const row = await this.db
213
+ .selectFrom("platform_themes")
214
+ .select("id")
215
+ .where("catalog_item_id", "=", found.id)
216
+ .where("name", "=", suggestion.theme)
217
+ .where("deleted_at", "is", null)
218
+ .executeTakeFirst();
219
+ if (!row) {
220
+ throw themeNotAvailable(suggestion.theme, "orgId" in level ? "in this organization" : "as the platform default");
221
+ }
222
+ if (row.id !== themeInEffect) {
223
+ plan.theme = row.id;
224
+ result.theme = {
225
+ from: themeInEffect,
226
+ to: row.id,
227
+ ...follows(org === null ? undefined : themeFollows),
228
+ };
229
+ }
230
+ }
231
+ // in plugin.json order (`suggestions` restores it from `appearance_order`)
232
+ const entries = Object.entries(suggestion.replacements ?? {});
233
+ if (entries.length === 0)
234
+ return plan;
235
+ const enabled = "orgId" in level ? await this.host.enabledPlugins(level.orgId) : null;
236
+ const loaded = this.host
237
+ .uiContributions()
238
+ .filter((c) => c.plugin === found.name &&
239
+ c.contribution.aliasOf === undefined &&
240
+ (enabled === null || enabled.has(c.plugin)));
241
+ const arrangeable = "orgId" in level ? ORG_ARRANGEABLE : UI_ARRANGEABLE_SLOTS;
242
+ const planned = new Set();
243
+ for (const [ref, id] of entries) {
244
+ const parsed = parseSlotRef(ref);
245
+ const known = parsed === null ? undefined : uiSlot(parsed.id);
246
+ if (parsed === null || known === undefined || parsed.version > known.version) {
247
+ result.skipped.push({ what: ref, reason: "unknown-slot" });
248
+ continue;
249
+ }
250
+ const slot = known.id;
251
+ // a second ref to one slot (`shell.brand` and `shell.brand@1`; the SDK refuses it at parse)
252
+ if (known.cardinality !== "single" || !arrangeable.includes(slot) || planned.has(slot)) {
253
+ result.skipped.push({ what: ref, reason: "not-eligible" });
254
+ continue;
255
+ }
256
+ const c = loaded.find((l) => l.slot.id === slot && l.contribution.id === id);
257
+ if (c === undefined) {
258
+ result.skipped.push({ what: ref, reason: "not-available" });
259
+ continue;
260
+ }
261
+ if (c.contribution.kind !== "component") {
262
+ result.skipped.push({ what: ref, reason: "not-eligible" });
263
+ continue;
264
+ }
265
+ planned.add(slot);
266
+ const key = `${found.name}/${id}`;
267
+ const now = inEffect(slot);
268
+ if (now.use === key)
269
+ continue;
270
+ plan.arrangement[slot] = { use: key };
271
+ result.replacements.push({ slot, from: now.use, to: key, ...follows(now.follows) });
272
+ }
273
+ return plan;
274
+ }
275
+ };
276
+ __decorate([
277
+ Transactional()
278
+ ], AppearanceService.prototype, "applyPlatform", null);
279
+ __decorate([
280
+ Transactional()
281
+ ], AppearanceService.prototype, "applyOrg", null);
282
+ AppearanceService = __decorate([
283
+ Injectable(),
284
+ __param(0, Inject(KernelDb)),
285
+ __param(1, Inject(TransactionHost)),
286
+ __param(2, Inject(SettingsService)),
287
+ __param(3, Inject(OrgSettingsService)),
288
+ __param(4, Inject(SuperAdminLock)),
289
+ __param(5, Inject(OrgLock)),
290
+ __param(6, Inject(ExtensionHost))
291
+ ], AppearanceService);
292
+ export { AppearanceService };
293
+ /**
294
+ * The suggested appearance action: its own module, since it writes through the org settings
295
+ * writer (`OrgsModule`, which imports `CatalogModule`) and the deployment settings writer.
296
+ */
297
+ let AppearanceModule = class AppearanceModule {
298
+ };
299
+ AppearanceModule = __decorate([
300
+ Module({
301
+ imports: [IdentityModule, OrgsModule, SuperAdminLockModule, OrgLockModule, ExtensionsModule],
302
+ providers: [AppearanceService],
303
+ exports: [AppearanceService],
304
+ })
305
+ ], AppearanceModule);
306
+ export { AppearanceModule };