@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,4 +1,4 @@
1
- import type { FileSystemHandle, SkillInvocation } from "@capybara-hq/plugin-sdk";
1
+ import type { FileSystemHandle, ParsedTheme, SkillInvocation } from "@capybara-hq/plugin-sdk";
2
2
  import type { ColumnType, Generated, Kysely } from "kysely";
3
3
  /** 带默认值的时间戳列:插入可省略,读出为 Date。 */
4
4
  type Ts = ColumnType<Date, Date | string | undefined, Date | string>;
@@ -20,6 +20,8 @@ export interface AccountsTable {
20
20
  time_zone: ColumnType<string | null, string | null | undefined, string | null>;
21
21
  /** 0090: the account must set its own password first (a generated initial password). */
22
22
  must_change_password: Generated<boolean>;
23
+ /** 0093: the person's theme id across all their orgs; null follows the org (themes spec §7). */
24
+ theme: ColumnType<string | null, string | null | undefined, string | null>;
23
25
  }
24
26
  /**
25
27
  * 0090: a generated initial password sealed for the person who approved the account's creation,
@@ -1195,6 +1197,8 @@ export interface KernelDatabase {
1195
1197
  platform_agent_templates: PlatformAgentTemplatesTable;
1196
1198
  platform_agent_template_versions: PlatformAgentTemplateVersionsTable;
1197
1199
  platform_connector_templates: PlatformConnectorTemplatesTable;
1200
+ platform_themes: PlatformThemesTable;
1201
+ platform_theme_versions: PlatformThemeVersionsTable;
1198
1202
  component_scope_orgs: ComponentScopeOrgsTable;
1199
1203
  component_org_switches: ComponentOrgSwitchesTable;
1200
1204
  plugin_installs: PluginInstallsTable;
@@ -1319,7 +1323,7 @@ export interface CatalogItemOrgSwitchesTable {
1319
1323
  }
1320
1324
  type ScopeMode = "follow" | "own";
1321
1325
  type ComponentScope = "all" | "optional" | "named";
1322
- type ComponentKind = "skill" | "agent_template" | "connector_template";
1326
+ type ComponentKind = "skill" | "agent_template" | "connector_template" | "theme";
1323
1327
  type Dflt<T> = ColumnType<T, T | undefined, T>;
1324
1328
  type NullableDflt<T> = ColumnType<T | null, T | null | undefined, T | null>;
1325
1329
  /** 0081: the scope columns every platform component carries (ruling C2). */
@@ -1409,6 +1413,39 @@ export interface PlatformConnectorTemplatesTable extends PlatformScopeColumns {
1409
1413
  updated_at: Ts;
1410
1414
  deleted_at: TsNullable;
1411
1415
  }
1416
+ /** 0093: a theme (themes spec §6); every theme comes from a plugin, owned by the platform or an org. */
1417
+ export interface PlatformThemesTable extends PlatformScopeColumns {
1418
+ id: string;
1419
+ /** The owning org (null = the platform). */
1420
+ owner_org_id: NullableDflt<string>;
1421
+ /** The org it was created in; never changed. */
1422
+ origin_org_id: NullableDflt<string>;
1423
+ /** The owner shares it with other orgs. */
1424
+ shareable: Dflt<boolean>;
1425
+ catalog_item_id: NullableDflt<string>;
1426
+ plugin_name: NullableDflt<string>;
1427
+ name: string;
1428
+ description: NullableDflt<string>;
1429
+ description_zh: NullableDflt<string>;
1430
+ current_version: number;
1431
+ created_at: Ts;
1432
+ updated_at: Ts;
1433
+ deleted_at: TsNullable;
1434
+ }
1435
+ /**
1436
+ * 0093: one version of a theme; `definition` is the parsed theme file, its fonts and logo live
1437
+ * under `object_prefix` in `deployment/` (null without assets).
1438
+ */
1439
+ export interface PlatformThemeVersionsTable {
1440
+ theme_id: string;
1441
+ version: number;
1442
+ definition: Json<ParsedTheme>;
1443
+ object_prefix: NullableDflt<string>;
1444
+ catalog_version: NullableDflt<string>;
1445
+ updated_on_own: Dflt<boolean>;
1446
+ created_by: NullableDflt<string>;
1447
+ created_at: Ts;
1448
+ }
1412
1449
  /** 0081: the orgs an own-scope `named` component is offered to. */
1413
1450
  export interface ComponentScopeOrgsTable {
1414
1451
  kind: ComponentKind;
@@ -1628,8 +1665,8 @@ export interface PluginInstallsTable {
1628
1665
  */
1629
1666
  export interface PluginInstallComponentsTable {
1630
1667
  install_id: string;
1631
- /** 0053: 'agent_template', ref = the agent_templates id. */
1632
- kind: "extension" | "connector" | "skill" | "plugin" | "agent_template";
1668
+ /** 0053: 'agent_template', ref = the agent_templates id; 0093: 'theme', ref = the platform_themes id. */
1669
+ kind: "extension" | "connector" | "skill" | "plugin" | "agent_template" | "theme";
1633
1670
  ref: string;
1634
1671
  created: boolean;
1635
1672
  skill_version: NullableCol<number>;
package/dist/errors.d.ts CHANGED
@@ -108,6 +108,31 @@ export declare const ERROR_CODES: {
108
108
  /** What deleting an org's own plugin does changed since its preview (P3-19). */
109
109
  readonly "catalog.preview_stale": 409;
110
110
  readonly "catalog.name_conflict": 409;
111
+ /** A theme of an imported plugin fails its file or asset checks (themes spec §4, §5). */
112
+ readonly "theme.invalid": 400;
113
+ /** A theme of an imported plugin fails the readability checks of its merged schemes (§5). */
114
+ readonly "theme.unreadable": 400;
115
+ /**
116
+ * A settings write names a theme that is not available where it is set (§7, §9): an org default
117
+ * off in the org, a platform default that is not a platform theme with scope `all`, or a
118
+ * person's pick on in none of their orgs; details `[{ theme }]`.
119
+ */
120
+ readonly "theme.not_available": 400;
121
+ /**
122
+ * The change would take the platform default theme (`uiTheme`) away from an org: narrowing,
123
+ * disabling, removing it or its plugin (§7); details `[{ theme, setting: "uiTheme" }]`.
124
+ */
125
+ readonly "theme.platform_default": 409;
126
+ /**
127
+ * Another theme with the same id is already on in an org the change would turn this one on in
128
+ * (§6, rule 10); details `[{ theme, orgId, themes, plugins }]`.
129
+ */
130
+ readonly "theme.id_taken": 409;
131
+ /**
132
+ * A theme delivery URL names no stored theme version, or a file that version does not name (§8);
133
+ * a removed theme's versions are still served.
134
+ */
135
+ readonly "theme.not_found": 404;
111
136
  readonly "directory.not_configured": 409;
112
137
  readonly "catalog.item_disabled": 403;
113
138
  readonly "plugin.disabled": 409;
package/dist/errors.js CHANGED
@@ -108,6 +108,31 @@ export const ERROR_CODES = {
108
108
  /** What deleting an org's own plugin does changed since its preview (P3-19). */
109
109
  "catalog.preview_stale": 409,
110
110
  "catalog.name_conflict": 409,
111
+ /** A theme of an imported plugin fails its file or asset checks (themes spec §4, §5). */
112
+ "theme.invalid": 400,
113
+ /** A theme of an imported plugin fails the readability checks of its merged schemes (§5). */
114
+ "theme.unreadable": 400,
115
+ /**
116
+ * A settings write names a theme that is not available where it is set (§7, §9): an org default
117
+ * off in the org, a platform default that is not a platform theme with scope `all`, or a
118
+ * person's pick on in none of their orgs; details `[{ theme }]`.
119
+ */
120
+ "theme.not_available": 400,
121
+ /**
122
+ * The change would take the platform default theme (`uiTheme`) away from an org: narrowing,
123
+ * disabling, removing it or its plugin (§7); details `[{ theme, setting: "uiTheme" }]`.
124
+ */
125
+ "theme.platform_default": 409,
126
+ /**
127
+ * Another theme with the same id is already on in an org the change would turn this one on in
128
+ * (§6, rule 10); details `[{ theme, orgId, themes, plugins }]`.
129
+ */
130
+ "theme.id_taken": 409,
131
+ /**
132
+ * A theme delivery URL names no stored theme version, or a file that version does not name (§8);
133
+ * a removed theme's versions are still served.
134
+ */
135
+ "theme.not_found": 404,
111
136
  "directory.not_configured": 409,
112
137
  "catalog.item_disabled": 403,
113
138
  "plugin.disabled": 409,
@@ -65,6 +65,9 @@ import { InvocableSkillsController } from "../skills/invocable.controller.js";
65
65
  import { SkillsController } from "../skills/skills.controller.js";
66
66
  import { SkillsModule } from "../skills/skills.module.js";
67
67
  import { StorageModule } from "../storage/storage.module.js";
68
+ import { OrgThemesController } from "../themes/themes.controller.js";
69
+ import { ThemesModule } from "../themes/themes.module.js";
70
+ import { PlatformThemeController, ThemeDeliveryController, } from "../themes/themes-public.controller.js";
68
71
  import { ApprovalsController, ToolGrantsController } from "../tools/approvals.controller.js";
69
72
  import { ConnectorContentController } from "../tools/connector-content.controller.js";
70
73
  import { ConnectorsController } from "../tools/connectors.controller.js";
@@ -135,6 +138,9 @@ const CONTROLLERS = [
135
138
  OrgOwnedPluginsController,
136
139
  OrgComponentsController,
137
140
  AdminCatalogRequestsController,
141
+ OrgThemesController,
142
+ ThemeDeliveryController,
143
+ PlatformThemeController,
138
144
  DeviceAuthController,
139
145
  ];
140
146
  /**
@@ -168,6 +174,7 @@ ApiModule = __decorate([
168
174
  FilesModule,
169
175
  UiModule,
170
176
  InboxModule,
177
+ ThemesModule,
171
178
  ],
172
179
  controllers: CONTROLLERS,
173
180
  providers: [{ provide: APP_GUARD, useClass: AuthGuard }],
@@ -100,17 +100,24 @@ export declare class AccountsService {
100
100
  verifyPassword(accountId: string, password: string): Promise<boolean>;
101
101
  get(id: string): Promise<Account | undefined>;
102
102
  /**
103
- * A person's preferences (run context spec §3.4): `timeZone` is their time zone setting (IANA),
104
- * null = follow the browser. Missing account → null.
103
+ * A person's preferences: `timeZone` is their time zone setting (IANA), null = follow the browser
104
+ * (run context spec §3.4); `theme` their theme (a `platform_themes` id), null = follow the org
105
+ * (themes spec §7). Missing account -> nulls.
105
106
  */
106
107
  preferences(accountId: string): Promise<{
107
108
  timeZone: string | null;
109
+ theme: string | null;
108
110
  }>;
109
- /** Sets a person's preferences (the caller validated the time zone). */
111
+ /**
112
+ * Updates the preferences given (a field left out keeps its value; the caller validated the time
113
+ * zone). A theme must be on in at least one of the person's orgs, else 400 `theme.not_available`.
114
+ */
110
115
  setPreferences(accountId: string, prefs: {
111
- timeZone: string | null;
116
+ timeZone?: string | null | undefined;
117
+ theme?: string | null | undefined;
112
118
  }): Promise<{
113
119
  timeZone: string | null;
120
+ theme: string | null;
114
121
  }>;
115
122
  /** 按 id 升序分页,`after` 为上一页最后一个 id。 */
116
123
  list(opts: {
@@ -15,6 +15,7 @@ import { ApiError } from "../errors.js";
15
15
  import { KernelDb } from "../infra/database/kernel-db.js";
16
16
  import { TransactionHost } from "../infra/database/transaction-host.js";
17
17
  import { Transactional } from "../infra/database/transactional.decorator.js";
18
+ import { assertThemeOnForAccount } from "../themes/theme-state.js";
18
19
  import { audit } from "./audit.js";
19
20
  import { MAX_PASSWORD_LENGTH, normalizeLogin, validatePassword } from "./login.js";
20
21
  import { DUMMY_PASSWORD_HASH, hashPassword, verifyPassword } from "./passwords.js";
@@ -237,24 +238,36 @@ let AccountsService = class AccountsService {
237
238
  return row && toAccount(row);
238
239
  }
239
240
  /**
240
- * A person's preferences (run context spec §3.4): `timeZone` is their time zone setting (IANA),
241
- * null = follow the browser. Missing account → null.
241
+ * A person's preferences: `timeZone` is their time zone setting (IANA), null = follow the browser
242
+ * (run context spec §3.4); `theme` their theme (a `platform_themes` id), null = follow the org
243
+ * (themes spec §7). Missing account -> nulls.
242
244
  */
243
245
  async preferences(accountId) {
244
246
  const row = await this.db
245
247
  .selectFrom("accounts")
246
- .select("time_zone")
248
+ .select(["time_zone", "theme"])
247
249
  .where("id", "=", accountId)
248
250
  .executeTakeFirst();
249
- return { timeZone: row?.time_zone ?? null };
251
+ return { timeZone: row?.time_zone ?? null, theme: row?.theme ?? null };
250
252
  }
251
- /** Sets a person's preferences (the caller validated the time zone). */
253
+ /**
254
+ * Updates the preferences given (a field left out keeps its value; the caller validated the time
255
+ * zone). A theme must be on in at least one of the person's orgs, else 400 `theme.not_available`.
256
+ */
252
257
  async setPreferences(accountId, prefs) {
253
- await this.db
254
- .updateTable("accounts")
255
- .set({ time_zone: prefs.timeZone, updated_at: new Date() })
256
- .where("id", "=", accountId)
257
- .execute();
258
+ if (prefs.theme != null)
259
+ await assertThemeOnForAccount(this.db.db, accountId, prefs.theme);
260
+ const set = {
261
+ ...(prefs.timeZone !== undefined && { time_zone: prefs.timeZone }),
262
+ ...(prefs.theme !== undefined && { theme: prefs.theme }),
263
+ };
264
+ if (Object.keys(set).length > 0) {
265
+ await this.db
266
+ .updateTable("accounts")
267
+ .set({ ...set, updated_at: new Date() })
268
+ .where("id", "=", accountId)
269
+ .execute();
270
+ }
258
271
  return this.preferences(accountId);
259
272
  }
260
273
  /** 按 id 升序分页,`after` 为上一页最后一个 id。 */
@@ -6,7 +6,7 @@ import { OrgsService } from "../orgs/orgs.service.js";
6
6
  import type { OrgRole } from "../orgs/roles.js";
7
7
  import { type Account, AccountsService } from "./accounts.js";
8
8
  import { type ApiTokenRecord, ApiTokenService } from "./api-tokens.js";
9
- import { type PreferencesSchema } from "./me.schemas.js";
9
+ import { type Preferences, type PreferencesSchema } from "./me.schemas.js";
10
10
  import { type Principal } from "./principal.js";
11
11
  import { SessionService } from "./sessions.js";
12
12
  import { SettingsService } from "./settings.js";
@@ -46,16 +46,16 @@ export declare class MeController {
46
46
  canCreateOrg: boolean;
47
47
  }>;
48
48
  /**
49
- * The person's preferences (run context spec §3.4): `timeZone` null = follow the browser's time
50
- * zone sent with each message; a set zone overrides it for the messages sent afterwards.
49
+ * The person's preferences: `timeZone` null = follow the browser's time zone sent with each
50
+ * message, a set zone overrides it for the messages sent afterwards (run context spec §3.4);
51
+ * `theme` null = follow the org (themes spec §7).
51
52
  */
52
- preferences(principal: Principal): Promise<{
53
- timeZone: string | null;
54
- }>;
55
- /** Replaces the person's preferences; an unknown time zone → 400 `validation_failed`. */
56
- setPreferences(principal: Principal, body: z.infer<typeof PreferencesSchema>): Promise<{
57
- timeZone: string | null;
58
- }>;
53
+ preferences(principal: Principal): Promise<Preferences>;
54
+ /**
55
+ * Updates the preferences given; a field left out keeps its value. An unknown time zone → 400
56
+ * `validation_failed`; a theme on in none of the person's orgs → 400 `theme.not_available`.
57
+ */
58
+ setPreferences(principal: Principal, body: z.infer<typeof PreferencesSchema>): Promise<Preferences>;
59
59
  /** 切换当前组织(仅 Web 会话);`null` 清空。非成员或已软删除的组织 → `org.not_found`。 */
60
60
  setCurrentOrg(principal: Principal, body: z.infer<typeof CurrentOrgSchema>): Promise<void>;
61
61
  /**
@@ -74,17 +74,19 @@ let MeController = class MeController {
74
74
  return { account: p.account, via: p.via, currentOrgId: current, memberships, canCreateOrg };
75
75
  }
76
76
  /**
77
- * The person's preferences (run context spec §3.4): `timeZone` null = follow the browser's time
78
- * zone sent with each message; a set zone overrides it for the messages sent afterwards.
77
+ * The person's preferences: `timeZone` null = follow the browser's time zone sent with each
78
+ * message, a set zone overrides it for the messages sent afterwards (run context spec §3.4);
79
+ * `theme` null = follow the org (themes spec §7).
79
80
  */
80
81
  async preferences(principal) {
81
82
  return this.accounts.preferences(requireUser(principal).account.id);
82
83
  }
83
- /** Replaces the person's preferences; an unknown time zone → 400 `validation_failed`. */
84
+ /**
85
+ * Updates the preferences given; a field left out keeps its value. An unknown time zone → 400
86
+ * `validation_failed`; a theme on in none of the person's orgs → 400 `theme.not_available`.
87
+ */
84
88
  async setPreferences(principal, body) {
85
- return this.accounts.setPreferences(requireUser(principal).account.id, {
86
- timeZone: body.timeZone,
87
- });
89
+ return this.accounts.setPreferences(requireUser(principal).account.id, body);
88
90
  }
89
91
  /** 切换当前组织(仅 Web 会话);`null` 清空。非成员或已软删除的组织 → `org.not_found`。 */
90
92
  async setCurrentOrg(principal, body) {
@@ -1,11 +1,21 @@
1
1
  import { z } from "zod";
2
2
  import type { RouteSchemas } from "../platform/route-schemas.js";
3
- /** `PUT /me/preferences`: the person's preferences (run context spec §3.4). */
3
+ /**
4
+ * `PUT /me/preferences`: a partial update of the person's preferences; a field left out keeps its
5
+ * stored value (themes spec §9).
6
+ */
4
7
  export declare const PreferencesSchema: z.ZodObject<{
5
- timeZone: z.ZodNullable<z.ZodString>;
8
+ timeZone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
9
+ theme: z.ZodOptional<z.ZodNullable<z.ZodString>>;
6
10
  }, z.core.$strict>;
11
+ /** The person's preferences as `GET` / `PUT /me/preferences` return them. */
12
+ export interface Preferences {
13
+ timeZone: string | null;
14
+ theme: string | null;
15
+ }
7
16
  declare const PreferencesBody_base: import("../infra/validation/create-zod-dto.js").ZodDtoClass<z.ZodObject<{
8
- timeZone: z.ZodNullable<z.ZodString>;
17
+ timeZone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
18
+ theme: z.ZodOptional<z.ZodNullable<z.ZodString>>;
9
19
  }, z.core.$strict>>;
10
20
  export declare class PreferencesBody extends PreferencesBody_base {
11
21
  }
@@ -1,11 +1,19 @@
1
1
  import { z } from "zod";
2
2
  import { createZodDto } from "../infra/validation/create-zod-dto.js";
3
3
  import { IanaTimeZone } from "../time-zone.js";
4
- /** `PUT /me/preferences`: the person's preferences (run context spec §3.4). */
4
+ /**
5
+ * `PUT /me/preferences`: a partial update of the person's preferences; a field left out keeps its
6
+ * stored value (themes spec §9).
7
+ */
5
8
  export const PreferencesSchema = z
6
9
  .object({
7
10
  /** The person's time zone (IANA); null = follow the browser (run context spec §3.4). */
8
- timeZone: IanaTimeZone.nullable(),
11
+ timeZone: IanaTimeZone.nullable().optional(),
12
+ /**
13
+ * The person's theme (a `platform_themes` id on in at least one of their orgs, else 400
14
+ * `theme.not_available`); null = follow the org (themes spec §7).
15
+ */
16
+ theme: z.string().min(1).max(200).nullable().optional(),
9
17
  })
10
18
  .strict();
11
19
  export class PreferencesBody extends createZodDto(PreferencesSchema, "PreferencesBody") {
@@ -31,6 +31,18 @@ export declare const SUBAGENT_LIMIT_OVERRIDE: z.ZodObject<{
31
31
  maxTokensPerRun: z.ZodOptional<z.ZodNumber>;
32
32
  }, z.core.$strict>;
33
33
  export type SubagentLimitOverride = z.infer<typeof SUBAGENT_LIMIT_OVERRIDE>;
34
+ /** The deployment setting key of the platform default theme (themes spec §7). */
35
+ export declare const UI_THEME_SETTING = "uiTheme";
36
+ /**
37
+ * The platform default theme (themes spec §7): a `platform_themes` id, or null for the built-in
38
+ * theme. Only a platform-owned theme whose effective scope is `all` can be set (checked by
39
+ * {@link SettingsService.update}); every reader goes through {@link readUiTheme} or
40
+ * {@link SettingsService.get}, both parsing with this schema.
41
+ */
42
+ export declare const UiThemeSetting: z.ZodObject<{
43
+ id: z.ZodNullable<z.ZodString>;
44
+ }, z.core.$strip>;
45
+ export type UiTheme = z.infer<typeof UiThemeSetting>;
34
46
  export declare const DEPLOYMENT_SETTINGS: z.ZodObject<{
35
47
  orgCreation: z.ZodDefault<z.ZodEnum<{
36
48
  anyone: "anyone";
@@ -57,6 +69,9 @@ export declare const DEPLOYMENT_SETTINGS: z.ZodObject<{
57
69
  deny: "deny";
58
70
  }>>;
59
71
  orgMarketplaceBrowse: z.ZodDefault<z.ZodBoolean>;
72
+ uiTheme: z.ZodDefault<z.ZodObject<{
73
+ id: z.ZodNullable<z.ZodString>;
74
+ }, z.core.$strip>>;
60
75
  }, z.core.$strip>;
61
76
  export type DeploymentSettings = z.infer<typeof DEPLOYMENT_SETTINGS>;
62
77
  /**
@@ -93,6 +108,9 @@ export declare const DEPLOYMENT_SETTINGS_PATCH: z.ZodObject<{
93
108
  deny: "deny";
94
109
  }>>;
95
110
  orgMarketplaceBrowse: z.ZodOptional<z.ZodBoolean>;
111
+ uiTheme: z.ZodOptional<z.ZodObject<{
112
+ id: z.ZodNullable<z.ZodString>;
113
+ }, z.core.$strict>>;
96
114
  }, z.core.$strict>;
97
115
  export type DeploymentSettingsPatch = z.infer<typeof DEPLOYMENT_SETTINGS_PATCH>;
98
116
  /**
@@ -118,6 +136,15 @@ export declare function assertCustomAllowed(db: KernelSchemaDb, key: "customConn
118
136
  * when there is one); missing or invalid -> the default, true.
119
137
  */
120
138
  export declare function readOrgMarketplaceBrowse(db: KernelSchemaDb): Promise<boolean>;
139
+ /**
140
+ * The platform default theme setting, read live from PG (in the caller's transaction when there is
141
+ * one) and parsed with {@link UiThemeSetting}: missing or invalid -> `{ id: null }`. `lock`: the
142
+ * row is read FOR SHARE, so a concurrent write of the setting (which holds it FOR UPDATE) waits for
143
+ * the caller's transaction, and the reverse.
144
+ */
145
+ export declare function readUiTheme(db: KernelSchemaDb, opts?: {
146
+ lock?: boolean;
147
+ }): Promise<UiTheme>;
121
148
  /** Deployment settings: one row per key, defaults for missing keys. */
122
149
  export declare class SettingsService {
123
150
  private readonly db;
@@ -14,6 +14,7 @@ import { KernelDb } from "../infra/database/kernel-db.js";
14
14
  import { TransactionHost } from "../infra/database/transaction-host.js";
15
15
  import { Transactional } from "../infra/database/transactional.decorator.js";
16
16
  import { KERNEL_LOGGER } from "../infra/tokens.js";
17
+ import { assertPlatformDefaultEligible, lockThemeShared } from "../themes/theme-state.js";
17
18
  import { audit } from "./audit.js";
18
19
  import { SuperAdminLock } from "./super-admin.js";
19
20
  /** 币种代码:ISO 4217 的三位大写字母(如 USD、CNY)。 */
@@ -63,6 +64,15 @@ export const SUBAGENT_LIMIT_OVERRIDE = z
63
64
  });
64
65
  /** 组织能否自建连接器 / 技能(C22):`deny` 时只能从平台目录安装(T26 执行)。 */
65
66
  const CustomPolicy = z.enum(["allow", "deny"]);
67
+ /** The deployment setting key of the platform default theme (themes spec §7). */
68
+ export const UI_THEME_SETTING = "uiTheme";
69
+ /**
70
+ * The platform default theme (themes spec §7): a `platform_themes` id, or null for the built-in
71
+ * theme. Only a platform-owned theme whose effective scope is `all` can be set (checked by
72
+ * {@link SettingsService.update}); every reader goes through {@link readUiTheme} or
73
+ * {@link SettingsService.get}, both parsing with this schema.
74
+ */
75
+ export const UiThemeSetting = z.object({ id: z.string().min(1).max(200).nullable() });
66
76
  export const DEPLOYMENT_SETTINGS = z.object({
67
77
  orgCreation: z.enum(["anyone", "super_admin"]).default("anyone"),
68
78
  /** 按组织覆盖运行并发上限(orgId → 上限);未列出的组织用 `runtime.orgConcurrency`。 */
@@ -88,6 +98,8 @@ export const DEPLOYMENT_SETTINGS = z.object({
88
98
  * scope spec §5.1 rule 3). false hides the browse route and refuses those requests.
89
99
  */
90
100
  orgMarketplaceBrowse: z.boolean().default(true),
101
+ /** The platform default theme (themes spec §7); `{ id: null }`: the built-in theme. */
102
+ uiTheme: UiThemeSetting.default({ id: null }),
91
103
  });
92
104
  const DEFAULTS = DEPLOYMENT_SETTINGS.parse({});
93
105
  /**
@@ -114,6 +126,11 @@ export const DEPLOYMENT_SETTINGS_PATCH = z
114
126
  customConnectors: CustomPolicy.optional(),
115
127
  customSkills: CustomPolicy.optional(),
116
128
  orgMarketplaceBrowse: z.boolean().optional(),
129
+ /**
130
+ * The platform default theme: a platform-owned theme whose effective scope is `all`, not
131
+ * disabled (else 400 `theme.not_available`), or `{ id: null }` for the built-in theme.
132
+ */
133
+ uiTheme: UiThemeSetting.strict().optional(),
117
134
  })
118
135
  .strict();
119
136
  /**
@@ -191,6 +208,38 @@ export async function readOrgMarketplaceBrowse(db) {
191
208
  const parsed = z.boolean().safeParse(row.value);
192
209
  return parsed.success ? parsed.data : true;
193
210
  }
211
+ /**
212
+ * The platform default theme setting, read live from PG (in the caller's transaction when there is
213
+ * one) and parsed with {@link UiThemeSetting}: missing or invalid -> `{ id: null }`. `lock`: the
214
+ * row is read FOR SHARE, so a concurrent write of the setting (which holds it FOR UPDATE) waits for
215
+ * the caller's transaction, and the reverse.
216
+ */
217
+ export async function readUiTheme(db, opts = {}) {
218
+ let q = db.selectFrom("deployment_settings").select("value").where("key", "=", UI_THEME_SETTING);
219
+ if (opts.lock)
220
+ q = q.forShare();
221
+ const row = await q.executeTakeFirst();
222
+ const parsed = UiThemeSetting.safeParse(row?.value);
223
+ return parsed.success ? parsed.data : { id: null };
224
+ }
225
+ /**
226
+ * Locks the `uiTheme` row FOR UPDATE in the caller's transaction, creating it as `{ id: null }`
227
+ * first when it is missing (migration 0094 seeds it; a test may have truncated the table), so the
228
+ * FOR SHARE readers always find a row to wait on.
229
+ */
230
+ async function lockUiThemeRow(db) {
231
+ await db
232
+ .insertInto("deployment_settings")
233
+ .values({ key: UI_THEME_SETTING, value: JSON.stringify({ id: null }) })
234
+ .onConflict((oc) => oc.column("key").doNothing())
235
+ .execute();
236
+ await db
237
+ .selectFrom("deployment_settings")
238
+ .select("key")
239
+ .where("key", "=", UI_THEME_SETTING)
240
+ .forUpdate()
241
+ .execute();
242
+ }
194
243
  /** 按组织合并覆盖:值为 null 删除该组织,其余整项替换;未列出的组织不变。 */
195
244
  function mergeByOrg(current, patch) {
196
245
  const merged = { ...current };
@@ -267,6 +316,21 @@ let SettingsService = class SettingsService {
267
316
  if (patch.orgMarketplaceBrowse !== undefined) {
268
317
  next.orgMarketplaceBrowse = patch.orgMarketplaceBrowse;
269
318
  }
319
+ if (patch.uiTheme !== undefined) {
320
+ // The one theme lock order (themes spec §7), inside the super admin lock above: the chosen
321
+ // theme's catalog item, then its `platform_themes` row FOR SHARE, then the `uiTheme` row FOR
322
+ // UPDATE, and only then the eligibility check (on in every live org). The writers that take
323
+ // a theme away or switch it off in an org (scope, disable, removal, the org switch and
324
+ // uninstall) hold the item or theme row before they read `uiTheme` FOR SHARE, so they and
325
+ // this write wait for each other in the same order and never deadlock.
326
+ if (patch.uiTheme.id !== null)
327
+ await lockThemeShared(this.db.db, patch.uiTheme.id);
328
+ await lockUiThemeRow(this.db.db);
329
+ if (patch.uiTheme.id !== null) {
330
+ await assertPlatformDefaultEligible(this.db.db, patch.uiTheme.id);
331
+ }
332
+ next.uiTheme = { id: patch.uiTheme.id };
333
+ }
270
334
  const { runConcurrencyOverrides: runPatch, sandboxLimitOverrides: sandboxPatch, subagentLimitOverrides: subagentPatch, } = patch;
271
335
  if (runPatch !== undefined || sandboxPatch !== undefined || subagentPatch !== undefined) {
272
336
  const current = await this.get();
@@ -19,6 +19,8 @@ import { OrgLock } from "./org-lock.js";
19
19
  * `read-only` (writes listed as unavailable) or `off` (default `on`).
20
20
  * - `timeZone`: the org's default time zone (IANA; absent = UTC) for senders without one of their
21
21
  * own (run context spec §3.3); an invalid stored value reads as absent.
22
+ * - `theme`: the org's default theme and whether members may pick their own (themes spec §7);
23
+ * absent (or an invalid stored value) = follow the platform, personal picks allowed.
22
24
  */
23
25
  /** 组织沙箱出站白名单的主机数上限。 */
24
26
  export declare const ORG_SANDBOX_ALLOW_HOSTS_MAX = 200;
@@ -34,6 +36,21 @@ export declare const SandboxEgressSettingSchema: z.ZodObject<{
34
36
  allowHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
37
  }, z.core.$strict>;
36
38
  export type SandboxEgressSetting = z.infer<typeof SandboxEgressSettingSchema>;
39
+ /**
40
+ * The org's theme setting (themes spec §7): `id` a theme on in the org (a `platform_themes` id),
41
+ * null = follow the platform's default; `personal: "locked"` hides the personal picker in the org
42
+ * and resolution ignores personal picks there.
43
+ */
44
+ export declare const OrgThemeSettingSchema: z.ZodObject<{
45
+ id: z.ZodNullable<z.ZodString>;
46
+ personal: z.ZodEnum<{
47
+ allowed: "allowed";
48
+ locked: "locked";
49
+ }>;
50
+ }, z.core.$strict>;
51
+ export type OrgThemeSetting = z.infer<typeof OrgThemeSettingSchema>;
52
+ /** The theme setting of an org that has none: follow the platform, personal picks allowed. */
53
+ export declare const DEFAULT_ORG_THEME: OrgThemeSetting;
37
54
  export declare const OrgSettingsSchema: z.ZodObject<{
38
55
  toolSearch: z.ZodCatch<z.ZodDefault<z.ZodEnum<{
39
56
  off: "off";
@@ -61,6 +78,13 @@ export declare const OrgSettingsSchema: z.ZodObject<{
61
78
  }, z.core.$strict>>>;
62
79
  wakeOnBackgroundTask: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
63
80
  timeZone: z.ZodCatch<z.ZodOptional<z.ZodString>>;
81
+ theme: z.ZodCatch<z.ZodOptional<z.ZodObject<{
82
+ id: z.ZodNullable<z.ZodString>;
83
+ personal: z.ZodEnum<{
84
+ allowed: "allowed";
85
+ locked: "locked";
86
+ }>;
87
+ }, z.core.$strict>>>;
64
88
  }, z.core.$strip>;
65
89
  export type OrgSettings = z.infer<typeof OrgSettingsSchema>;
66
90
  /** PATCH 体:只改给出的字段;未知字段 → 400。 */
@@ -91,6 +115,13 @@ export declare const OrgSettingsPatchSchema: z.ZodObject<{
91
115
  allowHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
92
116
  }, z.core.$strict>>>;
93
117
  timeZone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
118
+ theme: z.ZodOptional<z.ZodNullable<z.ZodObject<{
119
+ id: z.ZodNullable<z.ZodString>;
120
+ personal: z.ZodEnum<{
121
+ allowed: "allowed";
122
+ locked: "locked";
123
+ }>;
124
+ }, z.core.$strict>>>;
94
125
  }, z.core.$strict>;
95
126
  export type OrgSettingsPatch = z.infer<typeof OrgSettingsPatchSchema>;
96
127
  /** 部署的沙箱出站(`sandbox.limits.egress` + `allowHosts`)。 */