@capybara-hq/kernel 0.1.0-rc.10 → 0.1.0-rc.11

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 (79) hide show
  1. package/dist/admin/admin.schemas.d.ts +3 -0
  2. package/dist/catalog/catalog-content.d.ts +33 -1
  3. package/dist/catalog/catalog-content.js +65 -2
  4. package/dist/catalog/catalog-items.js +1 -0
  5. package/dist/catalog/catalog-scope.service.d.ts +29 -3
  6. package/dist/catalog/catalog-scope.service.js +82 -10
  7. package/dist/catalog/catalog.service.d.ts +7 -5
  8. package/dist/catalog/catalog.service.js +26 -5
  9. package/dist/catalog/claude/translate.js +3 -0
  10. package/dist/catalog/component-scope.schemas.d.ts +4 -0
  11. package/dist/catalog/component-scope.service.d.ts +48 -9
  12. package/dist/catalog/component-scope.service.js +152 -27
  13. package/dist/catalog/component-state.d.ts +12 -3
  14. package/dist/catalog/component-state.js +21 -3
  15. package/dist/catalog/marketplaces.service.d.ts +1 -1
  16. package/dist/catalog/org-catalog.service.js +3 -0
  17. package/dist/catalog/owned-items.js +7 -0
  18. package/dist/catalog/platform-components.d.ts +23 -3
  19. package/dist/catalog/platform-components.js +118 -9
  20. package/dist/catalog/platform-migration.js +2 -1
  21. package/dist/catalog/plugin-import.d.ts +24 -4
  22. package/dist/catalog/plugin-import.js +37 -6
  23. package/dist/catalog/plugin-install.d.ts +20 -1
  24. package/dist/catalog/plugin-install.js +71 -1
  25. package/dist/catalog/plugin-package.d.ts +41 -4
  26. package/dist/catalog/plugin-package.js +127 -7
  27. package/dist/catalog/plugin-upgrade.d.ts +13 -2
  28. package/dist/catalog/plugin-upgrade.js +124 -6
  29. package/dist/catalog/unshare.js +1 -0
  30. package/dist/db/kernel-schema.d.ts +41 -4
  31. package/dist/errors.d.ts +25 -0
  32. package/dist/errors.js +25 -0
  33. package/dist/http/api.module.js +7 -0
  34. package/dist/identity/accounts.d.ts +11 -4
  35. package/dist/identity/accounts.js +23 -10
  36. package/dist/identity/me.controller.d.ts +10 -10
  37. package/dist/identity/me.controller.js +8 -6
  38. package/dist/identity/me.schemas.d.ts +13 -3
  39. package/dist/identity/me.schemas.js +10 -2
  40. package/dist/identity/settings.d.ts +27 -0
  41. package/dist/identity/settings.js +64 -0
  42. package/dist/orgs/settings.service.d.ts +31 -0
  43. package/dist/orgs/settings.service.js +31 -1
  44. package/dist/platform/exclusions.js +16 -0
  45. package/dist/platform/operation.js +6 -2
  46. package/dist/platform/ops/admin.ops.d.ts +3 -1
  47. package/dist/platform/ops/admin.ops.js +25 -7
  48. package/dist/platform/ops/components.ops.js +1 -0
  49. package/dist/platform/ops/org-settings.ops.d.ts +3 -1
  50. package/dist/platform/ops/org-settings.ops.js +33 -8
  51. package/dist/platform/ops/orgs.ops.js +1 -0
  52. package/dist/platform/ops/platform.ops.d.ts +3 -1
  53. package/dist/platform/ops/platform.ops.js +43 -16
  54. package/dist/platform/ops/themes.ops.d.ts +14 -0
  55. package/dist/platform/ops/themes.ops.js +47 -0
  56. package/dist/platform/platform-ops.module.d.ts +2 -1
  57. package/dist/platform/platform-ops.module.js +5 -0
  58. package/dist/platform/route-schemas.js +3 -0
  59. package/dist/themes/render-css.d.ts +12 -0
  60. package/dist/themes/render-css.js +132 -0
  61. package/dist/themes/theme-delivery.service.d.ts +34 -0
  62. package/dist/themes/theme-delivery.service.js +111 -0
  63. package/dist/themes/theme-state.d.ts +95 -0
  64. package/dist/themes/theme-state.js +203 -0
  65. package/dist/themes/theme-usage.d.ts +61 -0
  66. package/dist/themes/theme-usage.js +98 -0
  67. package/dist/themes/themes-public.controller.d.ts +28 -0
  68. package/dist/themes/themes-public.controller.js +92 -0
  69. package/dist/themes/themes.controller.d.ts +13 -0
  70. package/dist/themes/themes.controller.js +38 -0
  71. package/dist/themes/themes.module.d.ts +3 -0
  72. package/dist/themes/themes.module.js +21 -0
  73. package/dist/themes/themes.schemas.d.ts +3 -0
  74. package/dist/themes/themes.schemas.js +2 -0
  75. package/dist/themes/themes.service.d.ts +50 -0
  76. package/dist/themes/themes.service.js +99 -0
  77. package/package.json +3 -3
  78. package/src/migrations/0102_themes.sql +64 -0
  79. package/src/migrations/0103_ui_theme_setting.sql +7 -0
@@ -28,10 +28,11 @@ import { OrgLock } from "../orgs/org-lock.js";
28
28
  import { packagePath } from "../packages/tgz.js";
29
29
  import { SkillsService, skillConflictError, skillConflicts, } from "../skills/skills.service.js";
30
30
  import { StorageService } from "../storage/storage.service.js";
31
+ import { assertNotPlatformDefault, themesPreview, } from "../themes/theme-usage.js";
31
32
  import { ConnectorsService } from "../tools/connectors.service.js";
32
33
  import { findConnectorIn, lockOrgForActions, readCatalogAgentTemplates, readCatalogFiles, readCatalogTemplates, requiredActions, templateConnectorInput, templatePluginProblem, } from "./catalog-content.js";
33
34
  import { versionUsable } from "./catalog-items.js";
34
- import { agentDefinitionRefs, componentOn, connectorTemplateState, connectorTemplatesOn, listedIn, loadComponents, loadScopeState, pluginFactsOf, referenceIds, switchOf, } from "./component-state.js";
35
+ import { agentDefinitionRefs, componentOn, connectorTemplateState, connectorTemplatesOn, listedIn, loadComponents, loadScopeState, pluginFactsOf, referenceIds, switchOf, themeIdClashes, } from "./component-state.js";
35
36
  import { assertItemInstallable, disabledPluginItems, pluginItemDisabled, resolveRequestsByInstall, } from "./request-rules.js";
36
37
  import { visibleTo } from "./scope.js";
37
38
  import { ScopeNoLongerWanted, ScopeStillWanted, wantedByScope } from "./scope-state.js";
@@ -336,12 +337,14 @@ let PluginInstaller = class PluginInstaller {
336
337
  async preflight(orgId, actor, itemId, version) {
337
338
  const plan = await this.plan(orgId, actor, itemId, version, null, "", undefined);
338
339
  await this.assertNoSkillConflicts(orgId, plan);
340
+ await this.assertNoThemeClashes(orgId, itemId);
339
341
  return { actions: planActions(plan) };
340
342
  }
341
343
  async install(orgId, actor, itemId, version, input, opts = {}) {
342
344
  const { switchOn, via, ...actorOpts } = opts;
343
345
  const plan = await this.plan(orgId, actor, itemId, version, input, "", undefined);
344
346
  await this.assertNoSkillConflicts(orgId, plan);
347
+ await this.assertNoThemeClashes(orgId, itemId);
345
348
  let installId;
346
349
  try {
347
350
  installId = await this.execute(orgId, actor, plan, actorOpts, {
@@ -386,6 +389,39 @@ let PluginInstaller = class PluginInstaller {
386
389
  if (conflicts.length > 0)
387
390
  throw skillConflictError(conflicts);
388
391
  }
392
+ /**
393
+ * Refuses installing a plugin whose themes would come on in the org (they follow it) while
394
+ * another theme with the same id is on there (themes spec §6, rule 10): 409 `theme.id_taken`,
395
+ * details `[{ theme, orgId, themes, plugins }]`, before anything is written.
396
+ */
397
+ async assertNoThemeClashes(orgId, itemId) {
398
+ const themes = await loadComponents(this.db, "theme", { itemId });
399
+ if (themes.length === 0)
400
+ return;
401
+ const st = await loadScopeState(this.db, "theme", themes);
402
+ const conflicts = [];
403
+ for (const c of themes) {
404
+ const p = pluginFactsOf(st, c.itemId, orgId);
405
+ const listed = listedIn(st, c.id, orgId);
406
+ const sw = switchOf(st, c.id, orgId);
407
+ if (componentOn(c, p, listed, sw) ||
408
+ !componentOn(c, p && { ...p, installed: true }, listed, sw)) {
409
+ continue;
410
+ }
411
+ const clash = await themeIdClashes(this.db, orgId, c, c.name);
412
+ if (clash.length > 0) {
413
+ conflicts.push({
414
+ theme: c.name,
415
+ orgId,
416
+ themes: clash.map((x) => x.id),
417
+ plugins: clash.map((x) => x.plugin ?? "platform"),
418
+ });
419
+ }
420
+ }
421
+ if (conflicts.length > 0) {
422
+ throw new ApiError("theme.id_taken", `The plugin's themes would come on next to themes with the same id already on in this organization (${conflicts.map((c) => c.theme).join(", ")}); switch those themes off first`, conflicts);
423
+ }
424
+ }
389
425
  /** The connector checks of templates against what this replica loads right now. */
390
426
  templateDeps() {
391
427
  const versions = new Map(this.registry.manifests().map((m) => [m.name, m.version]));
@@ -1108,6 +1144,10 @@ let PluginInstaller = class PluginInstaller {
1108
1144
  }
1109
1145
  }
1110
1146
  }
1147
+ // An org's own uninstall never takes the platform default theme away from it (themes spec §7;
1148
+ // a scope sync uninstall follows a scope change or disable that was guarded already)
1149
+ if (via !== "scope")
1150
+ await this.assertKeepsPlatformDefault(orgId, installId);
1111
1151
  const outcome = await this.uninstallIn(orgId, actor, installId, {
1112
1152
  report,
1113
1153
  removing: new Set(),
@@ -1131,6 +1171,24 @@ let PluginInstaller = class PluginInstaller {
1131
1171
  });
1132
1172
  return report;
1133
1173
  }
1174
+ /**
1175
+ * 409 `theme.platform_default` when uninstalling `installId` would switch the platform default
1176
+ * theme off in the org (one of the plugin's `follow` themes on there through it, themes spec §7),
1177
+ * in the caller's transaction before anything is written. A missing install passes (the
1178
+ * uninstall reports it).
1179
+ */
1180
+ async assertKeepsPlatformDefault(orgId, installId) {
1181
+ const found = await this.db
1182
+ .selectFrom("plugin_installs")
1183
+ .select("id")
1184
+ .where("id", "=", installId)
1185
+ .where("org_id", "=", orgId)
1186
+ .executeTakeFirst();
1187
+ if (!found)
1188
+ return;
1189
+ const p = await this.previewUninstall(orgId, installId);
1190
+ await assertNotPlatformDefault(this.db, (p.themes ?? []).map((t) => t.id), () => "narrower");
1191
+ }
1134
1192
  /**
1135
1193
  * The oldest other live install (not in `removing`) that has component `kind` / `ref`, or
1136
1194
  * undefined.
@@ -1883,8 +1941,19 @@ let PluginInstaller = class PluginInstaller {
1883
1941
  }
1884
1942
  }
1885
1943
  }
1944
+ const themes = await loadComponents(t, "theme", { itemId });
1945
+ const tst = await loadScopeState(t, "theme", themes);
1946
+ for (const c of themes) {
1947
+ if (c.scopeMode === "follow" &&
1948
+ componentOn(c, pluginFactsOf(tst, c.itemId, orgId), listedIn(tst, c.id, orgId), switchOf(tst, c.id, orgId))) {
1949
+ lostThemes.set(c.id, new Set([orgId]));
1950
+ }
1951
+ }
1886
1952
  };
1953
+ /** The themes that go off in the org with the install (and the dependencies it removes). */
1954
+ const lostThemes = new Map();
1887
1955
  await visit(row.item_id, comps, true, new Set([installId]));
1956
+ const themes = await themesPreview(t, lostThemes);
1888
1957
  return {
1889
1958
  installId,
1890
1959
  name: row.name,
@@ -1894,6 +1963,7 @@ let PluginInstaller = class PluginInstaller {
1894
1963
  agents: [...used.values()]
1895
1964
  .map((a) => ({ ...a, uses: a.uses.sort((x, y) => x.ref.localeCompare(y.ref)) }))
1896
1965
  .sort((x, y) => x.id.localeCompare(y.id)),
1966
+ ...(themes !== undefined && { themes }),
1897
1967
  };
1898
1968
  }
1899
1969
  /**
@@ -1,4 +1,5 @@
1
- import { type AgentTemplate, type ConnectorTemplateFile, type PluginManifest } from "@capybara-hq/plugin-sdk";
1
+ import { type AgentTemplate, type ConnectorTemplateFile, type ParsedTheme, type PluginManifest } from "@capybara-hq/plugin-sdk";
2
+ import { ApiError } from "../errors.js";
2
3
  import { LEGACY_FORMAT_MESSAGE, type PackageTgzLimits, packagePath } from "../packages/tgz.js";
3
4
  export { LEGACY_FORMAT_MESSAGE, packagePath };
4
5
  /**
@@ -13,13 +14,24 @@ export { LEGACY_FORMAT_MESSAGE, packagePath };
13
14
  * plugin.json) is rejected first, with {@link LEGACY_FORMAT_MESSAGE}: there is no reader for the
14
15
  * old formats.
15
16
  */
16
- /** Codes of {@link PluginPackageError}. */
17
- export type PluginPackageErrorCode = "catalog.invalid_manifest" | "catalog.legacy_format";
17
+ /**
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).
20
+ */
21
+ export type PluginPackageErrorCode = "catalog.invalid_manifest" | "catalog.legacy_format" | "theme.invalid" | "theme.unreadable";
22
+ /** The problems of one theme of a refused plugin (English; `unreadable:` lines for readability). */
23
+ export interface PluginThemeProblem {
24
+ /** The theme id (the file's base name). */
25
+ theme: string;
26
+ problems: string[];
27
+ }
18
28
  /** The plugin package is invalid; `issues` lists the problems (English, shown to the admin). */
19
29
  export declare class PluginPackageError extends Error {
20
30
  readonly code: PluginPackageErrorCode;
21
31
  readonly issues: string[];
22
- constructor(code: PluginPackageErrorCode, issues: string[]);
32
+ /** The failing themes of a `theme.*` code. */
33
+ readonly themes?: PluginThemeProblem[];
34
+ constructor(code: PluginPackageErrorCode, issues: string[], themes?: PluginThemeProblem[]);
23
35
  }
24
36
  /** A skill of the plugin. `dir` is relative to the package root; `files` are relative to `dir`. */
25
37
  export interface PluginPackageSkill {
@@ -42,6 +54,18 @@ export interface PluginPackageAgentTemplate {
42
54
  path: string;
43
55
  template: AgentTemplate;
44
56
  }
57
+ /** A theme of the plugin (themes spec §4); `id` is the file's base name. */
58
+ export interface PluginPackageTheme {
59
+ id: string;
60
+ /** Relative to the package root (no leading `./`). */
61
+ path: string;
62
+ theme: ParsedTheme;
63
+ /**
64
+ * The theme's fonts and logo files after `validateThemeAssets` (the SVGs sanitized, the rest
65
+ * unchanged), keyed by their path relative to the package root (no leading `./`).
66
+ */
67
+ assets: Map<string, Uint8Array>;
68
+ }
45
69
  /** A validated plugin file tree. Paths are relative to the plugin root (no `package/`). */
46
70
  export interface PluginContents {
47
71
  /** `{}` when a git plugin has no package.json. */
@@ -52,6 +76,8 @@ export interface PluginContents {
52
76
  connectorTemplates: PluginPackageConnectorTemplate[];
53
77
  /** Agent templates (listed in plugin.json `agents`, else scanned from `agents/*.md`). */
54
78
  agentTemplates: PluginPackageAgentTemplate[];
79
+ /** Themes (listed in plugin.json `themes`, else scanned from `themes/*.json`). */
80
+ themes: PluginPackageTheme[];
55
81
  /** {@link contentDigest} of `files` (the same for a tgz and a git tree of the same files). */
56
82
  contentDigest: string;
57
83
  }
@@ -141,3 +167,14 @@ export interface ReadPluginFilesOptions {
141
167
  * {@link PluginPackageError} like {@link readPluginPackage}.
142
168
  */
143
169
  export declare function readPluginFiles(files: Map<string, Uint8Array>, opts: ReadPluginFilesOptions): PluginContents;
170
+ /**
171
+ * The refusal of a plugin whose only problems are in its themes: `theme.invalid` when any theme
172
+ * has a problem other than readability (those themes listed), else `theme.unreadable` (every
173
+ * failing theme listed with its failing pairs).
174
+ */
175
+ export declare function pluginThemeError(problems: PluginThemeProblem[]): PluginPackageError;
176
+ /**
177
+ * The API error of a `theme.*` {@link PluginPackageError}: 400 `theme.invalid` with details
178
+ * `[{ theme, errors }]`, or 400 `theme.unreadable` with details `[{ theme, issues }]`.
179
+ */
180
+ export declare function themeApiError(e: PluginPackageError): ApiError;
@@ -1,18 +1,25 @@
1
1
  import { createHash } from "node:crypto";
2
- import { AGENT_TEMPLATE_LIMITS, CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, hostSdkProblem, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, parseSkillMd, SKILL_ENTRY_FILE, setupGuideFileProblem, validatePluginPackageJsonFor, } from "@capybara-hq/plugin-sdk";
2
+ import { AGENT_TEMPLATE_LIMITS, CATALOG_ITEM_NAME_RE, CONNECTOR_TEMPLATE_LIMITS, hostSdkProblem, PLUGIN_MANIFEST_FILE, PLUGIN_MANIFEST_LIMITS, parseAgentTemplate, parseConnectorTemplateFile, parsePluginManifest, parseSkillMd, parseThemeFile, SKILL_ENTRY_FILE, setupGuideFileProblem, THEME_LIMITS, themeIdOf, validatePluginPackageJsonFor, validateThemeAssets, } from "@capybara-hq/plugin-sdk";
3
+ import { ApiError } from "../errors.js";
3
4
  import { HOST_ZOD_VERSION, LEGACY_FORMAT_MESSAGE, PackageTgzError, packagePath, parsePackageJsonFile, unpackPackageTgz, } from "../packages/tgz.js";
4
5
  export { LEGACY_FORMAT_MESSAGE, packagePath };
5
6
  /** The plugin package is invalid; `issues` lists the problems (English, shown to the admin). */
6
7
  export class PluginPackageError extends Error {
7
8
  code;
8
9
  issues;
9
- constructor(code, issues) {
10
+ /** The failing themes of a `theme.*` code. */
11
+ themes;
12
+ constructor(code, issues, themes) {
10
13
  super(code === "catalog.legacy_format"
11
14
  ? (issues[0] ?? LEGACY_FORMAT_MESSAGE)
12
- : `Invalid plugin package: ${issues[0] ?? "unknown problem"}`);
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"}`);
13
18
  this.name = "PluginPackageError";
14
19
  this.code = code;
15
20
  this.issues = issues;
21
+ if (themes !== undefined)
22
+ this.themes = themes;
16
23
  }
17
24
  }
18
25
  /**
@@ -345,6 +352,7 @@ export function readPluginFiles(files, opts) {
345
352
  agentNames.set(r.template.name, path);
346
353
  agentTemplates.push({ name: r.template.name, path, template: r.template });
347
354
  }
355
+ const { themes, themePaths, themeProblems } = readThemes(files, manifest);
348
356
  if (manifest.icon !== undefined) {
349
357
  const icon = files.get(packagePath(manifest.icon));
350
358
  const problem = icon === undefined
@@ -378,11 +386,21 @@ export function readPluginFiles(files, opts) {
378
386
  }
379
387
  }
380
388
  }
381
- else if (skillDirs.length === 0 && templatePaths.length === 0 && agentPaths.length === 0) {
382
- issues.push("a plugin must contain an extension, a skill, a connector template or an agent template");
389
+ else if (skillDirs.length === 0 &&
390
+ templatePaths.length === 0 &&
391
+ agentPaths.length === 0 &&
392
+ themePaths.length === 0) {
393
+ issues.push("a plugin must contain an extension, a skill, a connector template, an agent template or a theme");
383
394
  }
384
- if (issues.length > 0)
385
- invalid(issues);
395
+ if (issues.length > 0) {
396
+ // Other problems first: the theme problems are listed with them as plain issues
397
+ invalid([
398
+ ...issues,
399
+ ...themeProblems.flatMap((t) => t.problems.map((p) => `themes/${t.theme}: ${p}`)),
400
+ ]);
401
+ }
402
+ if (themeProblems.length > 0)
403
+ throw pluginThemeError(themeProblems);
386
404
  return {
387
405
  packageJson: (packageJson ?? {}),
388
406
  manifest,
@@ -390,6 +408,108 @@ export function readPluginFiles(files, opts) {
390
408
  skills,
391
409
  connectorTemplates,
392
410
  agentTemplates,
411
+ themes,
393
412
  contentDigest: contentDigest(files),
394
413
  };
395
414
  }
415
+ const UNREADABLE = "unreadable:";
416
+ /**
417
+ * The refusal of a plugin whose only problems are in its themes: `theme.invalid` when any theme
418
+ * has a problem other than readability (those themes listed), else `theme.unreadable` (every
419
+ * failing theme listed with its failing pairs).
420
+ */
421
+ export function pluginThemeError(problems) {
422
+ const invalidOnes = problems.filter((t) => t.problems.some((p) => !p.startsWith(UNREADABLE)));
423
+ const code = invalidOnes.length > 0 ? "theme.invalid" : "theme.unreadable";
424
+ const listed = invalidOnes.length > 0 ? invalidOnes : problems;
425
+ return new PluginPackageError(code, listed.flatMap((t) => t.problems), listed);
426
+ }
427
+ /**
428
+ * The API error of a `theme.*` {@link PluginPackageError}: 400 `theme.invalid` with details
429
+ * `[{ theme, errors }]`, or 400 `theme.unreadable` with details `[{ theme, issues }]`.
430
+ */
431
+ export function themeApiError(e) {
432
+ const code = e.code === "theme.unreadable" ? "theme.unreadable" : "theme.invalid";
433
+ const key = code === "theme.invalid" ? "errors" : "issues";
434
+ return new ApiError(code, e.message, (e.themes ?? []).map((t) => ({ theme: t.theme, [key]: t.problems })));
435
+ }
436
+ /**
437
+ * The plugin's themes (themes spec §4-§5): listed in `manifest.themes`, else scanned from
438
+ * `themes/*.json` (at most {@link THEME_LIMITS}`.perPlugin`); each parsed by `parseThemeFile`
439
+ * (shape, token grammar, readability) and its assets checked by `validateThemeAssets`. The asset
440
+ * limit is per plugin, so the sizes of the distinct asset paths are also summed over all themes.
441
+ * Problems are returned per theme, never thrown.
442
+ */
443
+ function readThemes(files, manifest) {
444
+ const themeProblems = [];
445
+ const problem = (theme, problems) => {
446
+ const t = themeProblems.find((x) => x.theme === theme);
447
+ if (t)
448
+ t.problems.push(...problems);
449
+ else
450
+ themeProblems.push({ theme, problems: [...problems] });
451
+ };
452
+ const themePaths = [];
453
+ if (manifest.themes !== undefined) {
454
+ for (const t of manifest.themes) {
455
+ const path = packagePath(t.path);
456
+ if (!files.has(path))
457
+ problem(themeIdOf(path), [`${t.path} not found in the package`]);
458
+ else if (!themePaths.includes(path))
459
+ themePaths.push(path);
460
+ }
461
+ }
462
+ else {
463
+ for (const p of [...files.keys()].sort()) {
464
+ if (/^themes\/[^/]+\.json$/.test(p))
465
+ themePaths.push(p);
466
+ }
467
+ if (themePaths.length > THEME_LIMITS.perPlugin) {
468
+ problem(themeIdOf(themePaths[THEME_LIMITS.perPlugin]), [
469
+ `themes/ holds ${themePaths.length} themes; a plugin may have at most ${THEME_LIMITS.perPlugin}`,
470
+ ]);
471
+ }
472
+ }
473
+ const themes = [];
474
+ const sizes = new Map();
475
+ for (const path of themePaths) {
476
+ const id = themeIdOf(path);
477
+ if (themes.some((t) => t.id === id)) {
478
+ problem(id, [`${path}: another theme of the plugin has the id "${id}"`]);
479
+ continue;
480
+ }
481
+ const bytes = files.get(path);
482
+ if (bytes.byteLength > THEME_LIMITS.fileBytes) {
483
+ problem(id, [`${path}: the theme file is larger than ${THEME_LIMITS.fileBytes} bytes`]);
484
+ continue;
485
+ }
486
+ const json = decode(bytes);
487
+ if (json === undefined) {
488
+ problem(id, [`${path}: not valid UTF-8 text`]);
489
+ continue;
490
+ }
491
+ const parsed = parseThemeFile(id, json);
492
+ if ("errors" in parsed) {
493
+ problem(id, parsed.errors);
494
+ continue;
495
+ }
496
+ const { assets, errors } = validateThemeAssets(parsed, files);
497
+ if (errors.length > 0) {
498
+ problem(id, errors);
499
+ continue;
500
+ }
501
+ let total = 0;
502
+ for (const p of assets.keys())
503
+ sizes.set(p, files.get(p).byteLength);
504
+ for (const n of sizes.values())
505
+ total += n;
506
+ if (total > THEME_LIMITS.assetsBytes) {
507
+ problem(id, [
508
+ `the plugin's theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${total}); the limit covers all of the plugin's themes`,
509
+ ]);
510
+ continue;
511
+ }
512
+ themes.push({ id, path, theme: parsed, assets });
513
+ }
514
+ return { themes, themePaths, themeProblems };
515
+ }
@@ -11,6 +11,7 @@ import { LockService } from "../infra/lock/lock.service.js";
11
11
  import type { KernelLogger } from "../log.js";
12
12
  import { OrgLock } from "../orgs/org-lock.js";
13
13
  import { StorageService } from "../storage/storage.service.js";
14
+ import { type ThemePreview } from "../themes/theme-usage.js";
14
15
  import { ConnectorsService } from "../tools/connectors.service.js";
15
16
  import { type CatalogVersionFiles } from "./catalog-content.js";
16
17
  import { type ComponentKind } from "./component-state.js";
@@ -25,7 +26,7 @@ export interface PlatformUpdateComponent {
25
26
  kind: ComponentKind;
26
27
  /** The platform component (null: added by the target). */
27
28
  id: string | null;
28
- /** Skill or agent template name; a connector template's id. */
29
+ /** Skill or agent template name; a connector template's or a theme's id. */
29
30
  name: string;
30
31
  change: UpdateChange;
31
32
  /** The catalog version of the component's current version (null: added, or no catalog version). */
@@ -109,6 +110,11 @@ export interface PlatformUpdatePreview {
109
110
  needsApproval: boolean;
110
111
  } | null;
111
112
  connectorOrgs: PlatformUpdateConnectorOrg[];
113
+ /**
114
+ * The themes the update removes that are on in some org (themes spec §6): the orgs whose default
115
+ * each is and how many people picked it. Absent when no such theme is removed.
116
+ */
117
+ themes?: ThemePreview[];
112
118
  /**
113
119
  * An owner's update preview only ({@link PlatformPluginUpdater.previewForOwner}): the name of
114
120
  * every org the preview mentions, by id. An org admin cannot read other orgs, and these names
@@ -306,7 +312,10 @@ export declare class PlatformPluginUpdater {
306
312
  * extension removes it.
307
313
  */
308
314
  private diff;
309
- /** The target's skill names, agent templates and connector templates, from object storage. */
315
+ /**
316
+ * The target's skill names, agent templates, connector templates and themes, from object storage
317
+ * (a theme failing its checks -> 400 `theme.invalid`, themes spec §6).
318
+ */
310
319
  private readContents;
311
320
  /**
312
321
  * Whether the target's extension package asks for permissions the approved snapshot does not
@@ -328,6 +337,8 @@ export declare class PlatformPluginUpdater {
328
337
  private checkDependents;
329
338
  /** The plugin's live platform components with their current versions. */
330
339
  private liveComponents;
340
+ /** The `themes` block of the preview: the orgs each of the removed themes `ids` is on in. */
341
+ private themesLost;
331
342
  /** The orgs where the plugin is on: a live install (builtin: every live org). */
332
343
  private pluginOrgs;
333
344
  private liveOrgs;
@@ -34,11 +34,12 @@ import { KERNEL_LOGGER } from "../infra/tokens.js";
34
34
  import { OrgLock } from "../orgs/org-lock.js";
35
35
  import { packagePath } from "../packages/tgz.js";
36
36
  import { StorageService } from "../storage/storage.service.js";
37
+ import { assertNotPlatformDefault, pickedByIn, themesPreview, } from "../themes/theme-usage.js";
37
38
  import { canonicalJson } from "../tools/canonical-json.js";
38
39
  import { ConnectorsService } from "../tools/connectors.service.js";
39
- import { dirDigest, getPath, readCatalogAgentTemplates, readCatalogTemplates, setPath, skillFilesDigest, templatePluginProblem, } from "./catalog-content.js";
40
+ import { dirDigest, getPath, readCatalogAgentTemplates, readCatalogTemplates, readCatalogThemes, setPath, skillFilesDigest, templatePluginProblem, themeAssetsDigest, } from "./catalog-content.js";
40
41
  import { markVersionApplied, versionUsable } from "./catalog-items.js";
41
- import { agentsUsing, clashingOrgSkills, clashingPlatformSkills, componentOn, connectorsMadeFrom, connectorTemplatesOn, listedIn, loadScopeState, pluginFactsOf, switchOf, } from "./component-state.js";
42
+ import { agentsUsing, clashingOrgSkills, clashingPlatformSkills, componentOn, connectorsMadeFrom, connectorTemplatesOn, listedIn, loadComponents, loadScopeState, pluginFactsOf, switchOf, themeIdClashes, } from "./component-state.js";
42
43
  import { CatalogItemLock } from "./locks.js";
43
44
  import { itemPendingError, itemRemovedError } from "./ownership.js";
44
45
  import { PlanStale, PlatformComponents } from "./platform-components.js";
@@ -58,6 +59,7 @@ const KIND_ORDER = {
58
59
  skill: 0,
59
60
  agent_template: 1,
60
61
  connector_template: 2,
62
+ theme: 3,
61
63
  };
62
64
  /** How long one update holds its item (renewed while it runs). */
63
65
  const UPDATE_LOCK_TTL_MS = 60_000;
@@ -188,7 +190,17 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
188
190
  owner: { orgId },
189
191
  ...(staged !== undefined && { target: staged }),
190
192
  }));
191
- return { ...preview, orgNames: await this.orgNamesOf(preview) };
193
+ // The owner sees the names of the orgs whose default a removed theme is (named like the other
194
+ // orgs the preview mentions, P3-14 / P3-19), but people only of its own org: other orgs'
195
+ // member counts were never disclosed to it. Display only: `diffHash` covers the full preview.
196
+ const themes = preview.themes === undefined
197
+ ? undefined
198
+ : await Promise.all(preview.themes.map(async (t) => ({
199
+ ...t,
200
+ pickedBy: await pickedByIn(this.db, t.id, new Set([orgId])),
201
+ })));
202
+ const owned = { ...preview, ...(themes !== undefined && { themes }) };
203
+ return { ...owned, orgNames: await this.orgNamesOf(owned) };
192
204
  }
193
205
  /**
194
206
  * Locks, in sorted order and before the item (ruling P3-50), every live org the update may
@@ -234,6 +246,7 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
234
246
  ...(p.extension?.orgs ?? []),
235
247
  ...p.connectorOrgs.map((c) => c.orgId),
236
248
  ...p.components.flatMap((c) => [...c.pinnedOrgs, ...(c.kept ?? []).map((k) => k.orgId)]),
249
+ ...(p.themes ?? []).flatMap((t) => t.orgDefaults.map((o) => o.orgId)),
237
250
  ]);
238
251
  if (ids.size === 0)
239
252
  return {};
@@ -517,7 +530,9 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
517
530
  ? "platform_skills"
518
531
  : c.kind === "agent_template"
519
532
  ? "platform_agent_templates"
520
- : "platform_connector_templates";
533
+ : c.kind === "theme"
534
+ ? "platform_themes"
535
+ : "platform_connector_templates";
521
536
  await t
522
537
  .updateTable(table)
523
538
  .set({ deleted_at: now, updated_at: now })
@@ -984,6 +999,43 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
984
999
  pinnedOrgs: [],
985
1000
  });
986
1001
  }
1002
+ // Themes (by id, canonical definition and their assets' digest)
1003
+ const toThemes = contents.themes;
1004
+ for (const th of toThemes) {
1005
+ const l = live.themes.find((x) => x.name === th.id);
1006
+ if (!l) {
1007
+ components.push(added("theme", th.id));
1008
+ continue;
1009
+ }
1010
+ const same = canonicalJson(l.definition) === canonicalJson(th.theme) &&
1011
+ l.assetsDigest === th.assetsDigest;
1012
+ components.push({
1013
+ kind: "theme",
1014
+ id: l.id,
1015
+ name: th.id,
1016
+ change: same ? "same" : direction(l.catalogVersion),
1017
+ from: l.catalogVersion,
1018
+ to: to.version,
1019
+ updatedOnOwn: false,
1020
+ ownScope: l.ownScope,
1021
+ pinnedOrgs: [],
1022
+ });
1023
+ }
1024
+ for (const l of live.themes) {
1025
+ if (toThemes.some((x) => x.id === l.name))
1026
+ continue;
1027
+ components.push({
1028
+ kind: "theme",
1029
+ id: l.id,
1030
+ name: l.name,
1031
+ change: "removed",
1032
+ from: l.catalogVersion,
1033
+ to: null,
1034
+ updatedOnOwn: false,
1035
+ ownScope: l.ownScope,
1036
+ pinnedOrgs: [],
1037
+ });
1038
+ }
987
1039
  // The orgs where the plugin is on (a live install; builtin: every org)
988
1040
  const onOrgs = await this.pluginOrgs(itemId, item.builtin);
989
1041
  if (extension)
@@ -1005,6 +1057,32 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
1005
1057
  if (conflicts.length > 0) {
1006
1058
  throw new ApiError("plugin.skill_conflict", `The update adds skills whose names organizations already use where the plugin is on (${[...new Set(conflicts.map((c) => c.skill))].join(", ")}); remove or rename those skills first`, conflicts);
1007
1059
  }
1060
+ // Themes spec §7: an update never removes the platform default theme; rule 10 (§6): an added
1061
+ // theme must not take an id already on where the plugin is on
1062
+ const removedThemes = components
1063
+ .filter((c) => c.kind === "theme" && c.change === "removed" && c.id !== null)
1064
+ .map((c) => c.id);
1065
+ await assertNotPlatformDefault(db, removedThemes, () => "gone");
1066
+ const themeConflicts = [];
1067
+ for (const c of components) {
1068
+ if (c.kind !== "theme" || c.change !== "added")
1069
+ continue;
1070
+ for (const orgId of onOrgs) {
1071
+ const clash = await themeIdClashes(db, orgId, null, c.name);
1072
+ if (clash.length > 0) {
1073
+ themeConflicts.push({
1074
+ theme: c.name,
1075
+ orgId,
1076
+ themes: clash.map((x) => x.id),
1077
+ plugins: clash.map((x) => x.plugin ?? "platform"),
1078
+ });
1079
+ }
1080
+ }
1081
+ }
1082
+ if (themeConflicts.length > 0) {
1083
+ throw new ApiError("theme.id_taken", `The update adds themes whose ids are already on in organizations where the plugin is on (${[...new Set(themeConflicts.map((c) => c.theme))].join(", ")}); switch those themes off there first`, themeConflicts);
1084
+ }
1085
+ const themes = await this.themesLost(removedThemes);
1008
1086
  // Pinned copies (in orgs where the component is on) and removed components agents use
1009
1087
  for (const kind of ["skill", "agent_template"]) {
1010
1088
  const mine = components.filter((c) => c.kind === kind && c.id !== null);
@@ -1145,6 +1223,8 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
1145
1223
  reset: c.credentialsReset,
1146
1224
  })),
1147
1225
  connectorOrgs,
1226
+ // only when present: a diff without themes keeps the hash it had before themes
1227
+ ...(themes !== undefined && { themes }),
1148
1228
  }));
1149
1229
  return {
1150
1230
  itemId,
@@ -1155,18 +1235,23 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
1155
1235
  extension,
1156
1236
  connectors,
1157
1237
  connectorOrgs,
1238
+ ...(themes !== undefined && { themes }),
1158
1239
  suggest,
1159
1240
  contents,
1160
1241
  dependencies,
1161
1242
  diffHash,
1162
1243
  };
1163
1244
  }
1164
- /** The target's skill names, agent templates and connector templates, from object storage. */
1245
+ /**
1246
+ * The target's skill names, agent templates, connector templates and themes, from object storage
1247
+ * (a theme failing its checks -> 400 `theme.invalid`, themes spec §6).
1248
+ */
1165
1249
  async readContents(to) {
1166
1250
  return {
1167
1251
  skillNames: await catalogSkillNames(this.storage, to, to.manifest),
1168
1252
  agents: await readCatalogAgentTemplates(this.storage, to),
1169
1253
  templates: await readCatalogTemplates(this.storage, to),
1254
+ themes: await readCatalogThemes(this.storage, to),
1170
1255
  };
1171
1256
  }
1172
1257
  /**
@@ -1331,7 +1416,39 @@ let PlatformPluginUpdater = class PlatformPluginUpdater {
1331
1416
  file: r.file,
1332
1417
  ownScope: r.scope_mode === "own",
1333
1418
  }));
1334
- return { skills, agentTemplates, templates };
1419
+ const themes = (await db
1420
+ .selectFrom("platform_themes as p")
1421
+ .innerJoin("platform_theme_versions as v", (j) => j.onRef("v.theme_id", "=", "p.id").onRef("v.version", "=", "p.current_version"))
1422
+ .leftJoin("catalog_item_versions as cv", (j) => j
1423
+ .onRef("cv.item_id", "=", "p.catalog_item_id")
1424
+ .onRef("cv.version", "=", "v.catalog_version"))
1425
+ .select(["p.id", "p.name", "p.scope_mode", "v.definition", "v.catalog_version", "cv.files"])
1426
+ .where("p.catalog_item_id", "=", itemId)
1427
+ .where("p.deleted_at", "is", null)
1428
+ .execute()).map((r) => ({
1429
+ id: r.id,
1430
+ name: r.name,
1431
+ definition: r.definition,
1432
+ catalogVersion: r.catalog_version,
1433
+ // no recorded files (should not happen): never equal, the update writes a new version
1434
+ assetsDigest: r.files === null ? null : themeAssetsDigest(r.files, r.definition),
1435
+ ownScope: r.scope_mode === "own",
1436
+ }));
1437
+ return { skills, agentTemplates, templates, themes };
1438
+ }
1439
+ /** The `themes` block of the preview: the orgs each of the removed themes `ids` is on in. */
1440
+ async themesLost(ids) {
1441
+ if (ids.length === 0)
1442
+ return undefined;
1443
+ const comps = await loadComponents(this.db, "theme", { ids });
1444
+ const st = await loadScopeState(this.db, "theme", comps);
1445
+ const orgs = await this.liveOrgs();
1446
+ const lost = new Map();
1447
+ for (const c of comps) {
1448
+ const on = orgs.filter((orgId) => componentOn(c, pluginFactsOf(st, c.itemId, orgId), listedIn(st, c.id, orgId), switchOf(st, c.id, orgId)));
1449
+ lost.set(c.id, new Set(on));
1450
+ }
1451
+ return themesPreview(this.db, lost);
1335
1452
  }
1336
1453
  /** The orgs where the plugin is on: a live install (builtin: every live org). */
1337
1454
  async pluginOrgs(itemId, builtin) {
@@ -1398,6 +1515,7 @@ function previewOf(d) {
1398
1515
  components: d.components,
1399
1516
  extension: extensionView(d.extension),
1400
1517
  connectorOrgs: d.connectorOrgs,
1518
+ ...(d.themes !== undefined && { themes: d.themes }),
1401
1519
  diffHash: d.diffHash,
1402
1520
  };
1403
1521
  }
@@ -115,4 +115,5 @@ const TABLE = {
115
115
  skill: "platform_skills",
116
116
  agent_template: "platform_agent_templates",
117
117
  connector_template: "platform_connector_templates",
118
+ theme: "platform_themes",
118
119
  };