@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
@@ -1,8 +1,11 @@
1
+ import type { LocalizedName, ThemeScheme } from "@capybara-hq/plugin-sdk";
1
2
  import { ApiError } from "../errors.js";
2
3
  import { SuperAdminLock } from "../identity/super-admin.js";
3
4
  import { KernelDb } from "../infra/database/kernel-db.js";
4
5
  import { TransactionHost } from "../infra/database/transaction-host.js";
5
6
  import { OrgLock } from "../orgs/org-lock.js";
7
+ import { type ThemeSwatch } from "../themes/theme-state.js";
8
+ import { type ThemePreview } from "../themes/theme-usage.js";
6
9
  import type { ActorOpts } from "./catalog.service.js";
7
10
  import { ComponentPromotion } from "./component-promotion.js";
8
11
  import { type ComponentKind } from "./component-state.js";
@@ -38,7 +41,14 @@ export interface ComponentScopePreview {
38
41
  plugins: string[];
39
42
  /** An agent template's: the owner of each clashing template, in `skills`' order. */
40
43
  owners?: Owner[];
44
+ /** A theme's: the clashing themes on in the org (`skills` then empty). */
45
+ themes?: string[];
41
46
  }[];
47
+ /**
48
+ * A theme the change takes away from some orgs (themes spec §6): the orgs among them whose
49
+ * default it is and how many people picked it. Absent when no theme is taken away.
50
+ */
51
+ themes?: ThemePreview[];
42
52
  scopeHash: string;
43
53
  }
44
54
  /** A component's own scope (`orgs` only with `named`), a disable, or following its plugin again. */
@@ -91,6 +101,11 @@ export interface ComponentView {
91
101
  * other scopes, builtin plugins and orgs' own rows.
92
102
  */
93
103
  offCount: number;
104
+ /** A theme: its name and swatches from its current version (absent for other kinds). */
105
+ theme?: {
106
+ name: LocalizedName;
107
+ swatches: Partial<Record<ThemeScheme, ThemeSwatch>>;
108
+ };
94
109
  }
95
110
  /** One version of a platform component (`GET admin/components/:kind/:id/versions`). */
96
111
  export interface ComponentVersionView {
@@ -111,7 +126,10 @@ export interface OrgComponentView {
111
126
  /** The org's switch (null: never switched). */
112
127
  switch: boolean | null;
113
128
  on: boolean;
114
- /** Of a builtin plugin: it cannot be switched off (an `all` one can, layering spec §4). */
129
+ /**
130
+ * Of a builtin plugin, or the platform default theme (themes spec §7): it cannot be switched off
131
+ * (an `all` one can, layering spec §4).
132
+ */
115
133
  locked: boolean;
116
134
  }
117
135
  /**
@@ -157,6 +175,11 @@ export type ComponentSwitchResult = {
157
175
  refs: string[];
158
176
  }[];
159
177
  connectorsKept: number;
178
+ /**
179
+ * A theme: whether it is the org's default and how many of its members picked it (themes
180
+ * spec §6). Absent for other kinds and for a theme nobody there chose.
181
+ */
182
+ themes?: ThemePreview[];
160
183
  };
161
184
  confirm: string;
162
185
  };
@@ -166,6 +189,8 @@ type AuditOpts = ActorOpts & {
166
189
  };
167
190
  /**
168
191
  * 409 `plugin.skill_conflict` listing the orgs whose own skills use a component's name (rule 10);
192
+ * for a theme 409 `theme.id_taken` listing the orgs where another theme with its id is on (details
193
+ * `[{ theme, orgId, themes, plugins }]`);
169
194
  * for an agent template 409 `agent_template.name_taken` listing the orgs that already use its
170
195
  * roster type (ruling P4-13; details `[{ orgId, type, templateId, owner }]`, one per clashing
171
196
  * template, `owner` as save-as names it).
@@ -181,10 +206,11 @@ export declare function nameConflictError(conflicts: ComponentScopePreview["conf
181
206
  * An org admin switches one component on or off in the org (only that component, rule 5); one of a
182
207
  * builtin plugin -> 409 `plugin.scope_locked` (an `all` one may go off, layering spec §4); switching
183
208
  * off something in use is previewed with its agents first.
184
- * Name clashes are checked for skills only: an org agent template always carries its plugin's
185
- * type, so one with a platform template's type is a copy of it. Components are resolved at read
186
- * time (skills/usable.ts, agents/templates.ts), so nothing is enqueued. Stateless, shared by every
187
- * replica.
209
+ * Name clashes are checked for skills, agent templates by their roster type, and themes by their
210
+ * id among the themes on in an org (themes spec §6). A change that would take the platform default
211
+ * theme away from an org is refused (409 `theme.platform_default`, themes spec §7). Components are
212
+ * resolved at read time (skills/usable.ts, agents/templates.ts), so nothing is enqueued.
213
+ * Stateless, shared by every replica.
188
214
  */
189
215
  export declare class ComponentScopeService {
190
216
  private readonly kdb;
@@ -203,7 +229,7 @@ export declare class ComponentScopeService {
203
229
  /**
204
230
  * What the admin routes act on (ruling P3-23): the platform component `id`, an org row the org
205
231
  * promoted already (its platform row), or an org's own row not promoted yet. A connector
206
- * template has no org rows; anything else -> 404 `component.not_found`.
232
+ * template or a theme has no org rows; anything else -> 404 `component.not_found`.
207
233
  */
208
234
  private resolve;
209
235
  /**
@@ -224,12 +250,13 @@ export declare class ComponentScopeService {
224
250
  /**
225
251
  * The org's copies of platform skills or agent templates linked without a pin (ruling B2), by
226
252
  * platform id: they resolve to it, so agents that select them use it (none for connector
227
- * templates).
253
+ * templates and themes).
228
254
  */
229
255
  private linkedCopies;
230
256
  /**
231
257
  * The orgs' own skills (org scope) and agent templates, not linked to a platform component
232
- * (ruling P2-11): listed read-only, on in their org only. Connector templates have none.
258
+ * (ruling P2-11): listed read-only, on in their org only. Connector templates have none; an
259
+ * org's own theme is a platform_themes row with its org as owner, listed with the platform ones.
233
260
  */
234
261
  private orgOwned;
235
262
  /** The preview of a scope change, a disable or a return to following the plugin (nothing written). */
@@ -257,7 +284,8 @@ export declare class ComponentScopeService {
257
284
  * The org admin's switch of one component (`PUT orgs/:orgId/components/:kind/:id/switch`): only
258
285
  * this component (rule 5). A component not offered to the org -> 404. On: refused while the
259
286
  * component or its plugin is disabled (`plugin.disabled`) or an own skill of the org has its name
260
- * (`plugin.skill_conflict`, rule 10). Off: of a builtin plugin -> `plugin.scope_locked`; while it
287
+ * (`plugin.skill_conflict`, rule 10; a theme: `theme.id_taken`). Off: the platform default theme
288
+ * while it is on -> `theme.platform_default`; of a builtin plugin -> `plugin.scope_locked`; while it
261
289
  * is on, without `confirm` the preview (agents that use it) and its hash are returned, with the
262
290
  * current hash the switch is written (a changed preview -> `plugin.switch_stale`).
263
291
  */
@@ -314,6 +342,17 @@ export declare class ComponentScopeService {
314
342
  * (an org row not promoted yet: `[owner]`), else read.
315
343
  */
316
344
  private targetOf;
345
+ /**
346
+ * 409 `theme.platform_default` when theme `c` is the platform default and `t` would leave it
347
+ * narrower than `all` or gone (themes spec §7), in the caller's transaction, before any write.
348
+ */
349
+ private assertKeepsPlatformDefault;
350
+ /**
351
+ * Theme `c`'s availability under `t`: gone when disabled (or its plugin is disabled or deleted
352
+ * while it follows it), else `all` when its effective scope (its own, or its plugin's; builtin:
353
+ * `all`) is `all`, else narrower.
354
+ */
355
+ private themeAfter;
317
356
  /** Live orgs (`only`: that one), sorted by id. */
318
357
  private liveOrgs;
319
358
  private on;
@@ -19,9 +19,11 @@ import { KernelDb } from "../infra/database/kernel-db.js";
19
19
  import { TransactionHost } from "../infra/database/transaction-host.js";
20
20
  import { Transactional } from "../infra/database/transactional.decorator.js";
21
21
  import { OrgLock } from "../orgs/org-lock.js";
22
+ import { themeDefinitions, themeSwatches } from "../themes/theme-state.js";
23
+ import { assertNotPlatformDefault, platformDefaultThemeId, themesPreview, } from "../themes/theme-usage.js";
22
24
  import { assertOrgsExist } from "./catalog-items.js";
23
25
  import { ComponentPromotion, notOwnedError, PROMOTION_ATTEMPTS, PromotionStale, promotionStaleError, } from "./component-promotion.js";
24
- import { agentsUsing, COMPONENT_KINDS, componentLocked, componentOffered, componentOn, componentPluginOn, connectorsMadeFrom, listedIn, loadComponents, loadScopeState, pluginFactsOf, skillNameClashes, switchOf, } from "./component-state.js";
26
+ import { agentsUsing, COMPONENT_KINDS, componentLocked, componentOffered, componentOn, componentPluginOn, connectorsMadeFrom, listedIn, loadComponents, loadScopeState, pluginFactsOf, skillNameClashes, switchOf, themeIdClashes, } from "./component-state.js";
25
27
  import { assertOwnerListed, assertShareable, assertShareableTarget, newlySharedWith, ownerOf, ownersOf, } from "./ownership.js";
26
28
  const notFound = () => new ApiError("component.not_found", "Component not found");
27
29
  const staleError = () => new ApiError("plugin.scope_stale", "What this change affects has changed since the preview; preview it again and confirm the new hash");
@@ -31,14 +33,27 @@ const ACTION = {
31
33
  skill: "skills.manage",
32
34
  agent_template: "agents.manage",
33
35
  connector_template: "connectors.manage",
36
+ theme: "org.update",
34
37
  };
35
38
  /**
36
39
  * 409 `plugin.skill_conflict` listing the orgs whose own skills use a component's name (rule 10);
40
+ * for a theme 409 `theme.id_taken` listing the orgs where another theme with its id is on (details
41
+ * `[{ theme, orgId, themes, plugins }]`);
37
42
  * for an agent template 409 `agent_template.name_taken` listing the orgs that already use its
38
43
  * roster type (ruling P4-13; details `[{ orgId, type, templateId, owner }]`, one per clashing
39
44
  * template, `owner` as save-as names it).
40
45
  */
41
46
  export function nameConflictError(conflicts, kind = "skill") {
47
+ if (kind === "theme") {
48
+ return new ApiError("theme.id_taken", conflicts
49
+ .map((c) => `A theme with id ${c.name} is already on in organization ${c.orgName} (plugin "${c.plugins.join('", "')}"); one theme id cannot be on there twice. Switch that theme off there first.`)
50
+ .join("\n"), conflicts.map((c) => ({
51
+ theme: c.name,
52
+ orgId: c.orgId,
53
+ themes: c.themes ?? [],
54
+ plugins: c.plugins,
55
+ })));
56
+ }
42
57
  if (kind === "agent_template") {
43
58
  // the save-as wording (ruling P4-9), naming each clashing template's owner
44
59
  const whose = (o, orgId) => o === undefined || o.kind === "platform"
@@ -76,6 +91,8 @@ function previewHash(c, target, p) {
76
91
  .sort((a, b) => a.orgId.localeCompare(b.orgId))
77
92
  .map((a) => ({ ...a, agents: [...a.agents].sort((x, y) => x.id.localeCompare(y.id)) })),
78
93
  conflicts: [...p.conflicts].sort((a, b) => a.orgId.localeCompare(b.orgId)),
94
+ // only when present: a preview without themes keeps the hash it had before themes
95
+ ...(p.themes !== undefined && { themes: p.themes }),
79
96
  }))
80
97
  .digest("hex");
81
98
  }
@@ -95,10 +112,11 @@ function switchHash(orgId, c, preview) {
95
112
  * An org admin switches one component on or off in the org (only that component, rule 5); one of a
96
113
  * builtin plugin -> 409 `plugin.scope_locked` (an `all` one may go off, layering spec §4); switching
97
114
  * off something in use is previewed with its agents first.
98
- * Name clashes are checked for skills only: an org agent template always carries its plugin's
99
- * type, so one with a platform template's type is a copy of it. Components are resolved at read
100
- * time (skills/usable.ts, agents/templates.ts), so nothing is enqueued. Stateless, shared by every
101
- * replica.
115
+ * Name clashes are checked for skills, agent templates by their roster type, and themes by their
116
+ * id among the themes on in an org (themes spec §6). A change that would take the platform default
117
+ * theme away from an org is refused (409 `theme.platform_default`, themes spec §7). Components are
118
+ * resolved at read time (skills/usable.ts, agents/templates.ts), so nothing is enqueued.
119
+ * Stateless, shared by every replica.
102
120
  */
103
121
  let ComponentScopeService = class ComponentScopeService {
104
122
  kdb;
@@ -123,13 +141,13 @@ let ComponentScopeService = class ComponentScopeService {
123
141
  /**
124
142
  * What the admin routes act on (ruling P3-23): the platform component `id`, an org row the org
125
143
  * promoted already (its platform row), or an org's own row not promoted yet. A connector
126
- * template has no org rows; anything else -> 404 `component.not_found`.
144
+ * template or a theme has no org rows; anything else -> 404 `component.not_found`.
127
145
  */
128
146
  async resolve(kind, id) {
129
147
  const [c] = await loadComponents(this.db, kind, { ids: [id] });
130
148
  if (c)
131
149
  return { c };
132
- if (kind === "connector_template")
150
+ if (kind === "connector_template" || kind === "theme")
133
151
  throw notFound();
134
152
  const org = await this.promotion.orgRow(kind, id);
135
153
  if (!org)
@@ -166,7 +184,12 @@ let ComponentScopeService = class ComponentScopeService {
166
184
  const st = await loadScopeState(this.db, kind, comps);
167
185
  const orgs = await this.liveOrgs();
168
186
  const owners = await ownersOf(this.db, comps.flatMap((c) => (c.ownerOrgId === null ? [] : [c.ownerOrgId])));
187
+ // a theme's name and swatches come from its current definition (the admin pickers, §10)
188
+ const defs = kind === "theme"
189
+ ? await themeDefinitions(this.db, comps.map((c) => c.id))
190
+ : null;
169
191
  const platform = comps.map((c) => {
192
+ const def = defs?.get(c.id)?.definition;
170
193
  const plugin = c.itemId === null ? undefined : st.plugins.get(c.itemId);
171
194
  const follow = c.scopeMode === "follow" && c.itemId !== null;
172
195
  const scope = follow
@@ -202,6 +225,7 @@ let ComponentScopeService = class ComponentScopeService {
202
225
  offCount: scope === "all" && plugin?.builtin !== true
203
226
  ? orgs.filter((o) => switchOf(st, c.id, o.id) === false).length
204
227
  : 0,
228
+ ...(def !== undefined && { theme: { name: def.name, swatches: themeSwatches(def) } }),
205
229
  };
206
230
  });
207
231
  const owned = await this.orgOwned(kind, orgs);
@@ -213,6 +237,8 @@ let ComponentScopeService = class ComponentScopeService {
213
237
  */
214
238
  async orgList(orgId, filter) {
215
239
  const out = [];
240
+ // the platform default theme is locked on in every org (themes spec §7)
241
+ const uiTheme = await platformDefaultThemeId(this.db);
216
242
  for (const kind of filter.kind !== undefined ? [filter.kind] : COMPONENT_KINDS) {
217
243
  const comps = await loadComponents(this.db, kind, filter.itemId !== undefined ? { itemId: filter.itemId } : {});
218
244
  const st = await loadScopeState(this.db, kind, comps);
@@ -226,7 +252,7 @@ let ComponentScopeService = class ComponentScopeService {
226
252
  const on = componentOn(c, p, listed, sw);
227
253
  if (!on && !componentOffered(c, p, listed))
228
254
  continue;
229
- const locked = componentLocked(c, p);
255
+ const locked = componentLocked(c, p) || isUiTheme(c, uiTheme);
230
256
  const pluginOn = componentPluginOn(c, p);
231
257
  rows.push({
232
258
  kind,
@@ -255,11 +281,11 @@ let ComponentScopeService = class ComponentScopeService {
255
281
  /**
256
282
  * The org's copies of platform skills or agent templates linked without a pin (ruling B2), by
257
283
  * platform id: they resolve to it, so agents that select them use it (none for connector
258
- * templates).
284
+ * templates and themes).
259
285
  */
260
286
  async linkedCopies(orgId, kind) {
261
287
  const out = new Map();
262
- if (kind === "connector_template")
288
+ if (kind === "connector_template" || kind === "theme")
263
289
  return out;
264
290
  const rows = kind === "skill"
265
291
  ? await this.db
@@ -289,10 +315,11 @@ let ComponentScopeService = class ComponentScopeService {
289
315
  }
290
316
  /**
291
317
  * The orgs' own skills (org scope) and agent templates, not linked to a platform component
292
- * (ruling P2-11): listed read-only, on in their org only. Connector templates have none.
318
+ * (ruling P2-11): listed read-only, on in their org only. Connector templates have none; an
319
+ * org's own theme is a platform_themes row with its org as owner, listed with the platform ones.
293
320
  */
294
321
  async orgOwned(kind, orgs) {
295
- if (kind === "connector_template")
322
+ if (kind === "connector_template" || kind === "theme")
296
323
  return [];
297
324
  const live = new Set(orgs.map((o) => o.id));
298
325
  const rows = kind === "skill"
@@ -428,7 +455,8 @@ let ComponentScopeService = class ComponentScopeService {
428
455
  * The org admin's switch of one component (`PUT orgs/:orgId/components/:kind/:id/switch`): only
429
456
  * this component (rule 5). A component not offered to the org -> 404. On: refused while the
430
457
  * component or its plugin is disabled (`plugin.disabled`) or an own skill of the org has its name
431
- * (`plugin.skill_conflict`, rule 10). Off: of a builtin plugin -> `plugin.scope_locked`; while it
458
+ * (`plugin.skill_conflict`, rule 10; a theme: `theme.id_taken`). Off: the platform default theme
459
+ * while it is on -> `theme.platform_default`; of a builtin plugin -> `plugin.scope_locked`; while it
432
460
  * is on, without `confirm` the preview (agents that use it) and its hash are returned, with the
433
461
  * current hash the switch is written (a changed preview -> `plugin.switch_stale`).
434
462
  */
@@ -459,7 +487,26 @@ let ComponentScopeService = class ComponentScopeService {
459
487
  throw new ApiError("plugin.disabled", "This component is disabled by the super admin and cannot be switched on");
460
488
  }
461
489
  if (body.enabled) {
462
- if (kind !== "connector_template" && !on) {
490
+ if (kind === "theme") {
491
+ // theme ids are unique among the themes on in the org (themes spec §6)
492
+ if (!on && componentOn(c, p, listed, true)) {
493
+ const clash = await themeIdClashes(this.db, orgId, c, c.name);
494
+ if (clash.length > 0) {
495
+ const [org] = await this.liveOrgs(orgId);
496
+ throw nameConflictError([
497
+ {
498
+ orgId,
499
+ orgName: org?.name ?? orgId,
500
+ name: c.name,
501
+ skills: [],
502
+ themes: clash.map((x) => x.id),
503
+ plugins: clash.map((x) => x.plugin ?? "platform"),
504
+ },
505
+ ], kind);
506
+ }
507
+ }
508
+ }
509
+ else if (kind !== "connector_template" && !on) {
463
510
  const clash = kind === "skill"
464
511
  ? await skillNameClashes(this.db, orgId, c, c.name)
465
512
  : await templateTypeClashes(this.db, orgId, c);
@@ -471,13 +518,22 @@ let ComponentScopeService = class ComponentScopeService {
471
518
  }
472
519
  }
473
520
  else {
521
+ // The platform default theme is locked on in every org (themes spec §7): refused before the
522
+ // preview, inside this transaction
523
+ if (kind === "theme" && on)
524
+ await assertNotPlatformDefault(this.db, [c.id], () => "narrower");
474
525
  if (componentLocked(c, p)) {
475
526
  throw new ApiError("plugin.scope_locked", "This component belongs to a built-in plugin: it is on for every organization and cannot be switched off here");
476
527
  }
477
528
  if (on) {
529
+ const themes = kind === "theme"
530
+ ? await themesPreview(this.db, new Map([[c.id, new Set([orgId])]]))
531
+ : undefined;
478
532
  const preview = {
479
533
  agents: await agentsUsing(this.db, orgId, kind, [c.id]),
480
534
  connectorsKept: kind === "connector_template" ? await connectorsMadeFrom(this.db, orgId, c.id) : 0,
535
+ // a theme only: other kinds keep the preview (and the hash) they had before themes
536
+ ...(themes !== undefined && { themes }),
481
537
  };
482
538
  const confirm = switchHash(orgId, c, preview);
483
539
  if (body.confirm === undefined)
@@ -528,7 +584,7 @@ let ComponentScopeService = class ComponentScopeService {
528
584
  plugin: c.plugin,
529
585
  switch: body.enabled,
530
586
  on: componentOn(c, p, listed, body.enabled),
531
- locked: componentLocked(c, p),
587
+ locked: componentLocked(c, p) || isUiTheme(c, await platformDefaultThemeId(this.db)),
532
588
  },
533
589
  };
534
590
  }
@@ -551,7 +607,7 @@ let ComponentScopeService = class ComponentScopeService {
551
607
  const offered = componentOffered({ ...c, disabled: false }, p && { ...p, disabled: false }, listed);
552
608
  if (!on && !offered)
553
609
  throw notFound();
554
- const locked = componentLocked(c, p);
610
+ const locked = componentLocked(c, p) || isUiTheme(c, await platformDefaultThemeId(this.db));
555
611
  let preview = null;
556
612
  let confirm = null;
557
613
  if (on && !locked) {
@@ -578,6 +634,8 @@ let ComponentScopeService = class ComponentScopeService {
578
634
  id = await this.promotion.promote(x.promote, actor);
579
635
  const c = await this.component(kind, id, true);
580
636
  const t = await this.targetOf(c, target);
637
+ if (kind === "theme")
638
+ await this.assertKeepsPlatformDefault(c, t);
581
639
  const from = await this.listed(kind, id);
582
640
  // Its own scope: the owner stays listed (P3-17), widening goes through the share gate (P3-16);
583
641
  // following the plugin again takes the plugin's scope, which its own writer gates
@@ -615,8 +673,9 @@ let ComponentScopeService = class ComponentScopeService {
615
673
  .values(t.orgs.map((org_id) => ({ kind, component_id: id, org_id })))
616
674
  .execute();
617
675
  }
618
- // An own scope that newly shows an org-owned component tells its owner (P3-22)
619
- if (t.scopeMode === "own" && kind !== "connector_template") {
676
+ // An own scope that newly shows an org-owned component tells its owner (P3-22); not a theme:
677
+ // the `item_shared` notice names plugins, skills and agent templates only
678
+ if (t.scopeMode === "own" && kind !== "connector_template" && kind !== "theme") {
620
679
  const shared = newlySharedWith(before, t);
621
680
  if (shared === "all_orgs" || shared.length > 0) {
622
681
  await notifyItemShared(this.db, c.ownerOrgId, { kind, id }, c.name, shared);
@@ -643,7 +702,10 @@ let ComponentScopeService = class ComponentScopeService {
643
702
  const c = await this.component(kind, id, true);
644
703
  if (c.disabled === disabled)
645
704
  return;
646
- const p = await this.compute(c, await this.targetOf(c, { disabled }));
705
+ const t = await this.targetOf(c, { disabled });
706
+ if (kind === "theme")
707
+ await this.assertKeepsPlatformDefault(c, t);
708
+ const p = await this.compute(c, t);
647
709
  if (disabled && p.scopeHash !== hash)
648
710
  throw staleError();
649
711
  if (p.conflicts.length > 0)
@@ -737,12 +799,19 @@ let ComponentScopeService = class ComponentScopeService {
737
799
  .where("skill_id", "=", id)
738
800
  .orderBy("version", "desc")
739
801
  .execute()
740
- : await this.db
741
- .selectFrom("platform_agent_template_versions")
742
- .select(["version", "catalog_version", "updated_on_own", "created_at"])
743
- .where("template_id", "=", id)
744
- .orderBy("version", "desc")
745
- .execute();
802
+ : kind === "theme"
803
+ ? await this.db
804
+ .selectFrom("platform_theme_versions")
805
+ .select(["version", "catalog_version", "updated_on_own", "created_at"])
806
+ .where("theme_id", "=", id)
807
+ .orderBy("version", "desc")
808
+ .execute()
809
+ : await this.db
810
+ .selectFrom("platform_agent_template_versions")
811
+ .select(["version", "catalog_version", "updated_on_own", "created_at"])
812
+ .where("template_id", "=", id)
813
+ .orderBy("version", "desc")
814
+ .execute();
746
815
  return rows.map((r) => ({
747
816
  version: String(r.version),
748
817
  catalogVersion: r.catalog_version,
@@ -860,6 +929,36 @@ let ComponentScopeService = class ComponentScopeService {
860
929
  await assertOrgsExist(this.db, orgs);
861
930
  return { scopeMode: "own", scope: target.scope, orgs, disabled: c.disabled };
862
931
  }
932
+ /**
933
+ * 409 `theme.platform_default` when theme `c` is the platform default and `t` would leave it
934
+ * narrower than `all` or gone (themes spec §7), in the caller's transaction, before any write.
935
+ */
936
+ async assertKeepsPlatformDefault(c, t) {
937
+ const after = await this.themeAfter(c, t);
938
+ await assertNotPlatformDefault(this.db, [c.id], () => after);
939
+ }
940
+ /**
941
+ * Theme `c`'s availability under `t`: gone when disabled (or its plugin is disabled or deleted
942
+ * while it follows it), else `all` when its effective scope (its own, or its plugin's; builtin:
943
+ * `all`) is `all`, else narrower.
944
+ */
945
+ async themeAfter(c, t) {
946
+ if (t.disabled)
947
+ return "gone";
948
+ if (t.scopeMode === "own")
949
+ return t.scope === "all" ? "all" : "narrower";
950
+ if (c.itemId === null)
951
+ return "gone";
952
+ const item = await this.db
953
+ .selectFrom("catalog_items")
954
+ .select(["scope", "builtin", "disabled"])
955
+ .where("id", "=", c.itemId)
956
+ .where("deleted_at", "is", null)
957
+ .executeTakeFirst();
958
+ if (!item || item.disabled)
959
+ return "gone";
960
+ return item.builtin || item.scope === "all" ? "all" : "narrower";
961
+ }
863
962
  /** Live orgs (`only`: that one), sorted by id. */
864
963
  async liveOrgs(only) {
865
964
  let q = this.db
@@ -894,11 +993,16 @@ let ComponentScopeService = class ComponentScopeService {
894
993
  const listedAfter = new Set(target.orgs);
895
994
  const affected = [];
896
995
  const conflicts = [];
996
+ /** A theme: the orgs it is on in now and would be off in. */
997
+ const lost = new Set();
897
998
  for (const org of await this.liveOrgs()) {
898
999
  const p = pluginFactsOf(st, c.itemId, org.id);
899
1000
  const listed = listedIn(st, c.id, org.id);
900
1001
  const sw = switchOf(st, c.id, org.id);
901
- if (componentOn(c, p, listed, sw) && !componentOn(after, p, listedAfter.has(org.id), sw)) {
1002
+ const onNow = componentOn(c, p, listed, sw);
1003
+ const onAfter = componentOn(after, p, listedAfter.has(org.id), sw);
1004
+ if (onNow && !onAfter) {
1005
+ lost.add(org.id);
902
1006
  const agents = await agentsUsing(this.db, org.id, c.kind, [c.id]);
903
1007
  affected.push({
904
1008
  orgId: org.id,
@@ -907,6 +1011,23 @@ let ComponentScopeService = class ComponentScopeService {
907
1011
  connectorsKept: c.kind === "connector_template" ? await connectorsMadeFrom(this.db, org.id, c.id) : 0,
908
1012
  });
909
1013
  }
1014
+ if (c.kind === "theme") {
1015
+ // theme ids are unique among the themes on in an org (themes spec §6)
1016
+ if (onAfter && !onNow) {
1017
+ const clash = await themeIdClashes(this.db, org.id, c, c.name);
1018
+ if (clash.length > 0) {
1019
+ conflicts.push({
1020
+ orgId: org.id,
1021
+ orgName: org.name,
1022
+ name: c.name,
1023
+ skills: [],
1024
+ themes: clash.map((x) => x.id),
1025
+ plugins: clash.map((x) => x.plugin ?? "platform"),
1026
+ });
1027
+ }
1028
+ }
1029
+ continue;
1030
+ }
910
1031
  if (c.kind !== "connector_template" &&
911
1032
  componentOffered(after, p, listedAfter.has(org.id)) &&
912
1033
  !componentOffered(c, p, listed)) {
@@ -920,7 +1041,8 @@ let ComponentScopeService = class ComponentScopeService {
920
1041
  }
921
1042
  }
922
1043
  }
923
- const p = { affected, conflicts };
1044
+ const themes = c.kind === "theme" ? await themesPreview(this.db, new Map([[c.id, lost]])) : undefined;
1045
+ const p = { affected, conflicts, ...(themes !== undefined && { themes }) };
924
1046
  return { ...p, scopeHash: previewHash({ kind: c.kind, id: x.hashId ?? c.id }, target, p) };
925
1047
  }
926
1048
  };
@@ -945,6 +1067,8 @@ ComponentScopeService = __decorate([
945
1067
  __param(4, Inject(TransactionHost))
946
1068
  ], ComponentScopeService);
947
1069
  export { ComponentScopeService };
1070
+ /** Whether `c` is the platform default theme `uiTheme` (locked on in every org, themes spec §7). */
1071
+ const isUiTheme = (c, uiTheme) => c.kind === "theme" && c.id === uiTheme;
948
1072
  /** The plugin row FOR SHARE (exclusive with its scope writers and an unshare, which hold it FOR UPDATE). */
949
1073
  async function lockItemShared(db, itemId) {
950
1074
  await db.selectFrom("catalog_items").select("id").where("id", "=", itemId).forShare().execute();
@@ -987,4 +1111,5 @@ const TABLE = {
987
1111
  skill: "platform_skills",
988
1112
  agent_template: "platform_agent_templates",
989
1113
  connector_template: "platform_connector_templates",
1114
+ theme: "platform_themes",
990
1115
  };
@@ -1,7 +1,8 @@
1
1
  import type { KernelSchemaDb } from "../db/kernel-schema.js";
2
2
  import { type PluginScope } from "./scope.js";
3
3
  /** The kinds of platform components. */
4
- export type ComponentKind = "skill" | "agent_template" | "connector_template";
4
+ export type ComponentKind = "skill" | "agent_template" | "connector_template" | "theme";
5
+ /** The kinds the scope, switch, ownership and preview flows (and the component routes) walk. */
5
6
  export declare const COMPONENT_KINDS: readonly ComponentKind[];
6
7
  /** A component's own scope settings (ruling C2). */
7
8
  export interface ComponentScopeFacts {
@@ -70,7 +71,7 @@ export interface ComponentRow extends ComponentScopeFacts {
70
71
  shownToOthers: boolean;
71
72
  /** The owning org (null: the platform; ruling P3-1). */
72
73
  ownerOrgId: string | null;
73
- /** The org it was created in (skills, agent templates; null: the platform, F27). */
74
+ /** The org it was created in (skills, agent templates, themes; null: the platform, F27). */
74
75
  originOrgId: string | null;
75
76
  /** Its owner shares it with other orgs (the share gate, P3-1). */
76
77
  shareable: boolean;
@@ -130,7 +131,8 @@ export declare function agentDefinitionRefs(db: KernelSchemaDb, orgId: string):
130
131
  /**
131
132
  * The ids an org's agents may reference a platform component by: its own id and the org's copies
132
133
  * linked to it (ruling B2: unpinned ones resolve to it; ruling P2-18: pinned ones follow its
133
- * availability). Connector templates: none (connectors made from one stay the org's own).
134
+ * availability). Connector templates: none (connectors made from one stay the org's own); themes:
135
+ * none (no agent references a theme, and a theme has no org copies).
134
136
  */
135
137
  export declare function referenceIds(db: KernelSchemaDb, orgId: string, kind: ComponentKind, ids: readonly string[]): Promise<Map<string, string>>;
136
138
  /**
@@ -167,6 +169,13 @@ export declare function skillNameClashes(db: KernelSchemaDb, orgId: string, comp
167
169
  skills: string[];
168
170
  plugins: string[];
169
171
  }>;
172
+ /**
173
+ * The live themes with id `name` that are on in the org (themes spec §6: theme ids are unique in an
174
+ * org's usable set, rule 10), except `self` (null: a theme with no row yet, e.g. one an update adds).
175
+ */
176
+ export declare function themeIdClashes(db: KernelSchemaDb, orgId: string, self: {
177
+ id: string;
178
+ } | null, name: string): Promise<ComponentRow[]>;
170
179
  /** A connector template's id from its manifest path (`./connectors/<id>.json`). */
171
180
  export declare const templateIdOfPath: (path: string) => string;
172
181
  /**
@@ -1,8 +1,10 @@
1
1
  import { visibleTo, wantedOn } from "./scope.js";
2
+ /** The kinds the scope, switch, ownership and preview flows (and the component routes) walk. */
2
3
  export const COMPONENT_KINDS = [
3
4
  "skill",
4
5
  "agent_template",
5
6
  "connector_template",
7
+ "theme",
6
8
  ];
7
9
  /** The component or its plugin stops it everywhere: disabled, or the plugin disabled or deleted. */
8
10
  function stopped(c, p) {
@@ -115,7 +117,11 @@ export async function loadComponents(db, kind, filter = {}) {
115
117
  originOrgId: null,
116
118
  }));
117
119
  }
118
- const table = kind === "skill" ? "platform_skills" : "platform_agent_templates";
120
+ const table = kind === "skill"
121
+ ? "platform_skills"
122
+ : kind === "theme"
123
+ ? "platform_themes"
124
+ : "platform_agent_templates";
119
125
  let q = db
120
126
  .selectFrom(`${table} as p`)
121
127
  .select([
@@ -308,11 +314,12 @@ export async function agentDefinitionRefs(db, orgId) {
308
314
  /**
309
315
  * The ids an org's agents may reference a platform component by: its own id and the org's copies
310
316
  * linked to it (ruling B2: unpinned ones resolve to it; ruling P2-18: pinned ones follow its
311
- * availability). Connector templates: none (connectors made from one stay the org's own).
317
+ * availability). Connector templates: none (connectors made from one stay the org's own); themes:
318
+ * none (no agent references a theme, and a theme has no org copies).
312
319
  */
313
320
  export async function referenceIds(db, orgId, kind, ids) {
314
321
  const out = new Map();
315
- if (kind === "connector_template" || ids.length === 0)
322
+ if (kind === "connector_template" || kind === "theme" || ids.length === 0)
316
323
  return out;
317
324
  for (const id of ids)
318
325
  out.set(id, id);
@@ -402,6 +409,17 @@ export async function skillNameClashes(db, orgId, component, name) {
402
409
  plugins: platform.map((c) => c.plugin ?? "platform"),
403
410
  };
404
411
  }
412
+ /**
413
+ * The live themes with id `name` that are on in the org (themes spec §6: theme ids are unique in an
414
+ * org's usable set, rule 10), except `self` (null: a theme with no row yet, e.g. one an update adds).
415
+ */
416
+ export async function themeIdClashes(db, orgId, self, name) {
417
+ const others = (await loadComponents(db, "theme", { name })).filter((c) => c.id !== self?.id);
418
+ if (others.length === 0)
419
+ return [];
420
+ const st = await loadScopeState(db, "theme", others);
421
+ return others.filter((c) => componentOn(c, pluginFactsOf(st, c.itemId, orgId), listedIn(st, c.id, orgId), switchOf(st, c.id, orgId)));
422
+ }
405
423
  /** A connector template's id from its manifest path (`./connectors/<id>.json`). */
406
424
  export const templateIdOfPath = (path) => (path.split("/").pop() ?? path).replace(/\.json$/, "");
407
425
  /**
@@ -188,7 +188,7 @@ export declare function headsToResolve(entries: readonly MarketplaceEntry[], imp
188
188
  /** The host versions (an entry's `minKernel` is judged against them). */
189
189
  export declare const HOST_VERSIONS: {
190
190
  readonly kernel: string;
191
- readonly sdk: "0.1.0-rc.7";
191
+ readonly sdk: "0.1.0-rc.8";
192
192
  };
193
193
  /** Whether this deployment satisfies the entry's `minKernel`. */
194
194
  export declare const entryCompatible: (e: Pick<MarketplaceEntry, "minKernel">) => boolean;
@@ -479,6 +479,9 @@ let OrgCatalogService = class OrgCatalogService {
479
479
  await this.recordSwitch(orgId, actor, itemId, false);
480
480
  return { kind: "done", item: await this.itemOf(orgId, actor, itemId) };
481
481
  }
482
+ // The platform default theme is locked on (themes spec §7): refused at the preview already;
483
+ // the uninstall's transaction checks again before it writes
484
+ await this.pluginInstaller.assertKeepsPlatformDefault(orgId, live.id);
482
485
  const preview = await this.pluginInstaller.previewUninstall(orgId, live.id);
483
486
  const confirm = uninstallPreviewHash(preview);
484
487
  if (body.confirm === undefined)