@cosmicdrift/kumiko-framework 0.325.2 → 0.326.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.325.2",
3
+ "version": "0.326.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.325.2",
206
- "@cosmicdrift/kumiko-types": "0.325.2",
205
+ "@cosmicdrift/kumiko-http": "0.326.1",
206
+ "@cosmicdrift/kumiko-types": "0.326.1",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.325.2",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.326.1",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
@@ -0,0 +1,395 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as z from "zod";
3
+ import { requiredKeysFromScreen } from "../../i18n/required-surface-keys";
4
+ import { SETTINGS_HUB_I18N } from "../../i18n/settings-hub-keys";
5
+ import { validateBoot } from "../boot-validator";
6
+ import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE } from "../build-config-feature-schema";
7
+ import { access, createSystemConfig, createTenantConfig } from "../config-helpers";
8
+ import { defineFeature } from "../define-feature";
9
+ import { SELECTED_EXTENSIONS_QUERY } from "../extension-selector-plugins";
10
+ import { createRegistry } from "../registry";
11
+ import type {
12
+ ConfigEditScreenDefinition,
13
+ FeatureDefinition,
14
+ ScreenDefinition,
15
+ SecretsEditScreenDefinition,
16
+ } from "../types";
17
+
18
+ const TENANT_ADMIN_WRITE = access.roles("TenantAdmin", "Admin");
19
+
20
+ function secretsFeature() {
21
+ return defineFeature("secrets", (r) => {
22
+ r.writeHandler(
23
+ "set",
24
+ z.object({ key: z.string(), value: z.string() }),
25
+ async () => ({ isSuccess: true, data: null }),
26
+ { access: { roles: ["TenantAdmin", "Admin", "SystemAdmin"] } },
27
+ );
28
+ });
29
+ }
30
+
31
+ function mailFoundation(extra?: (r: Parameters<Parameters<typeof defineFeature>[1]>[0]) => void) {
32
+ return defineFeature("mail-foundation", (r) => {
33
+ r.translations({
34
+ keys: {
35
+ "mail-foundation.settings": { en: "Mail", de: "Mail-Versand" },
36
+ "mail-foundation.settings.tenant": { en: "Mail (tenant)", de: "Mail (Mandant)" },
37
+ },
38
+ });
39
+ r.extendsRegistrar("mailTransport", { onRegister: () => undefined });
40
+ const keys = r.config({
41
+ keys: {
42
+ provider: createTenantConfig("text", {
43
+ default: "",
44
+ write: TENANT_ADMIN_WRITE,
45
+ mask: { title: "mail.provider", order: 1, icon: "mail" },
46
+ }),
47
+ },
48
+ });
49
+ r.extensionSelector("mailTransport", keys.provider);
50
+ extra?.(r);
51
+ });
52
+ }
53
+
54
+ const smtp = defineFeature("mail-smtp", (r) => {
55
+ r.requires("mail-foundation");
56
+ r.useExtension("mailTransport", "smtp");
57
+ r.config({
58
+ keys: {
59
+ host: createTenantConfig("text", {
60
+ default: "",
61
+ write: TENANT_ADMIN_WRITE,
62
+ mask: { title: "smtp.host" },
63
+ }),
64
+ relay: createSystemConfig("text", {
65
+ default: "",
66
+ write: access.systemAdmin,
67
+ mask: { title: "smtp.relay" },
68
+ }),
69
+ },
70
+ });
71
+ r.secret("password", { label: { en: "SMTP password" }, scope: "tenant", required: true });
72
+ });
73
+
74
+ const inmemory = defineFeature("mail-inmemory", (r) => {
75
+ r.requires("mail-foundation");
76
+ r.useExtension("mailTransport", "inmemory");
77
+ r.config({
78
+ keys: {
79
+ capture: createTenantConfig("boolean", {
80
+ write: TENANT_ADMIN_WRITE,
81
+ mask: { title: "inmemory.capture" },
82
+ }),
83
+ },
84
+ });
85
+ });
86
+
87
+ const stripe = defineFeature("stripe", (r) => {
88
+ r.secret("apiKey", { label: { en: "Stripe key" }, scope: "tenant" });
89
+ });
90
+
91
+ function build(features: FeatureDefinition[]) {
92
+ return buildConfigFeatureSchema(createRegistry(features));
93
+ }
94
+
95
+ function screenOf<T extends ScreenDefinition["type"]>(
96
+ schema: ReturnType<typeof buildConfigFeatureSchema>,
97
+ id: string,
98
+ type: T,
99
+ ): Extract<ScreenDefinition, { type: T }> {
100
+ const s = schema.screens.find((x) => x.id === id);
101
+ if (s === undefined || s.type !== type) throw new Error(`no ${type} screen "${id}"`);
102
+ return s as Extract<ScreenDefinition, { type: T }>; // @cast-boundary type checked on the line above
103
+ }
104
+
105
+ const gatedFeatures = () => [secretsFeature(), mailFoundation(), smtp, inmemory, stripe];
106
+
107
+ describe("buildConfigFeatureSchema — extension selector", () => {
108
+ test("selector key renders as select of plugin ids, at every scope it shows up", () => {
109
+ const schema = build(gatedFeatures());
110
+ const selection = screenOf(schema, "mail-foundation-tenant-selection", "configEdit");
111
+ expect(selection.fields["provider"]).toMatchObject({
112
+ type: "select",
113
+ options: ["inmemory", "smtp"],
114
+ });
115
+ });
116
+
117
+ test("selector without any plugin stays a text field", () => {
118
+ const schema = build([mailFoundation()]);
119
+ const screen = screenOf(schema, "mail-foundation-tenant", "configEdit");
120
+ expect(screen.fields["provider"]?.type).toBe("text");
121
+ });
122
+
123
+ test("owner tenant screen is a dashboard: selection, plugin config panels, plugin secrets panel", () => {
124
+ const schema = build(gatedFeatures());
125
+ const dashboard = screenOf(schema, "mail-foundation-tenant", "dashboard");
126
+ expect(dashboard.description).toBe("config.settings.extensionSelectorHint");
127
+ expect(dashboard.panels).toEqual([
128
+ { kind: "screen", id: "selection", screen: "mail-foundation-tenant-selection" },
129
+ {
130
+ kind: "screen",
131
+ id: "mail-inmemory-config",
132
+ screen: "mail-inmemory-tenant",
133
+ visibleWhen: { query: SELECTED_EXTENSIONS_QUERY, field: "mailTransport", eq: "inmemory" },
134
+ },
135
+ {
136
+ kind: "screen",
137
+ id: "mail-smtp-config",
138
+ screen: "mail-smtp-tenant",
139
+ visibleWhen: { query: SELECTED_EXTENSIONS_QUERY, field: "mailTransport", eq: "smtp" },
140
+ },
141
+ {
142
+ kind: "screen",
143
+ id: "mail-smtp-secrets",
144
+ screen: "mail-smtp-tenant-secrets",
145
+ visibleWhen: { query: SELECTED_EXTENSIONS_QUERY, field: "mailTransport", eq: "smtp" },
146
+ },
147
+ ]);
148
+ });
149
+
150
+ test("embedded screens are dormant and plugin features lose their tenant nav", () => {
151
+ const schema = build(gatedFeatures());
152
+ for (const id of [
153
+ "mail-foundation-tenant-selection",
154
+ "mail-smtp-tenant",
155
+ "mail-inmemory-tenant",
156
+ ]) {
157
+ expect(screenOf(schema, id, "configEdit").dormant).toBe(true);
158
+ }
159
+ expect(screenOf(schema, "mail-smtp-tenant-secrets", "secretsEdit").dormant).toBe(true);
160
+ expect(screenOf(schema, "mail-foundation-tenant", "dashboard").dormant).toBeUndefined();
161
+
162
+ const navIds = schema.navs.map((n) => n.id);
163
+ expect(navIds).toContain("mail-foundation-tenant");
164
+ expect(navIds).not.toContain("mail-smtp-tenant");
165
+ expect(navIds).not.toContain("mail-inmemory-tenant");
166
+ });
167
+
168
+ test("owner nav keeps id/label/screen and the dashboard access is the union incl. secrets", () => {
169
+ const schema = build(gatedFeatures());
170
+ const nav = schema.navs.find((n) => n.id === "mail-foundation-tenant");
171
+ expect(nav).toMatchObject({
172
+ screen: "mail-foundation-tenant",
173
+ label: "mail-foundation.settings.tenant",
174
+ parent: "audience-tenant",
175
+ icon: "mail",
176
+ });
177
+ const dashboard = screenOf(schema, "mail-foundation-tenant", "dashboard");
178
+ expect(dashboard.access).toEqual({ roles: ["TenantAdmin", "Admin", "SystemAdmin"] });
179
+ expect(nav?.access).toEqual(dashboard.access);
180
+ });
181
+
182
+ test("plugin panel keeps title source: the plugin screen still uses its own settings section", () => {
183
+ const schema = build(gatedFeatures());
184
+ const pluginScreen: ConfigEditScreenDefinition = screenOf(
185
+ schema,
186
+ "mail-smtp-tenant",
187
+ "configEdit",
188
+ );
189
+ expect(pluginScreen.configKeys).toEqual({ host: "mail-smtp:config:host" });
190
+ expect(pluginScreen.layout.sections[0]).toMatchObject({ title: "mail-smtp.settings" });
191
+ });
192
+
193
+ test("plugin secrets panel reuses the global secrets access object", () => {
194
+ const schema = build(gatedFeatures());
195
+ const panelScreen: SecretsEditScreenDefinition = screenOf(
196
+ schema,
197
+ "mail-smtp-tenant-secrets",
198
+ "secretsEdit",
199
+ );
200
+ const globalScreen = screenOf(schema, "secrets", "secretsEdit");
201
+ expect(panelScreen.access).toBe(globalScreen.access);
202
+ expect(panelScreen.requiredFields).toEqual(["mail-smtp-password"]);
203
+ expect(Object.keys(panelScreen.secretKeys)).toEqual(["mail-smtp-password"]);
204
+ });
205
+
206
+ test("global secrets screen drops gated secrets", () => {
207
+ const schema = build(gatedFeatures());
208
+ const globalScreen = screenOf(schema, "secrets", "secretsEdit");
209
+ expect(Object.keys(globalScreen.secretKeys)).toEqual(["stripe-api-key"]);
210
+ expect(schema.translations).toMatchObject({
211
+ "config.secret.mail-smtp.password.label": { en: "SMTP password" },
212
+ "config.secret.stripe.api-key.label": { en: "Stripe key" },
213
+ });
214
+ });
215
+
216
+ test("global secrets screen and nav vanish when only gated secrets exist; audience nav keeps secrets access", () => {
217
+ const schema = build([secretsFeature(), mailFoundation(), smtp, inmemory]);
218
+ expect(schema.screens.some((s) => s.id === "secrets")).toBe(false);
219
+ expect(schema.navs.some((n) => n.id === "secrets")).toBe(false);
220
+ const audience = schema.navs.find((n) => n.id === "audience-tenant");
221
+ expect(audience?.access).toEqual({ roles: ["TenantAdmin", "Admin", "SystemAdmin"] });
222
+ });
223
+
224
+ test("selection title translation is copied from the owner's tenant label, else its settings label", () => {
225
+ const withTenantLabel = build(gatedFeatures());
226
+ expect(withTenantLabel.translations?.["screen:mail-foundation-tenant-selection.title"]).toEqual(
227
+ {
228
+ en: "Mail (tenant)",
229
+ de: "Mail (Mandant)",
230
+ },
231
+ );
232
+
233
+ const plainOwner = defineFeature("mail-foundation", (r) => {
234
+ r.translations({ keys: { "mail-foundation.settings": { en: "Mail" } } });
235
+ r.extendsRegistrar("mailTransport", { onRegister: () => undefined });
236
+ const keys = r.config({
237
+ keys: {
238
+ provider: createTenantConfig("text", {
239
+ default: "",
240
+ write: TENANT_ADMIN_WRITE,
241
+ mask: { title: "mail.provider" },
242
+ }),
243
+ },
244
+ });
245
+ r.extensionSelector("mailTransport", keys.provider);
246
+ });
247
+ const schema = build([plainOwner, smtp, inmemory]);
248
+ expect(schema.translations?.["screen:mail-foundation-tenant-selection.title"]).toEqual({
249
+ en: "Mail",
250
+ });
251
+ });
252
+
253
+ test("plugin system-scope screen and nav stay ungated", () => {
254
+ const schema = build(gatedFeatures());
255
+ expect(screenOf(schema, "mail-smtp-system", "configEdit").dormant).toBeUndefined();
256
+ expect(schema.navs.find((n) => n.id === "mail-smtp-system")).toMatchObject({
257
+ parent: "audience-system",
258
+ screen: "mail-smtp-system",
259
+ });
260
+ });
261
+
262
+ test("plugin feature registered under two ids keeps today's behaviour", () => {
263
+ const twice = defineFeature("mail-smtp", (r) => {
264
+ r.useExtension("mailTransport", "smtp");
265
+ r.useExtension("mailTransport", "smtps");
266
+ r.config({
267
+ keys: {
268
+ host: createTenantConfig("text", {
269
+ default: "",
270
+ write: TENANT_ADMIN_WRITE,
271
+ mask: { title: "smtp.host" },
272
+ }),
273
+ },
274
+ });
275
+ r.secret("password", { label: { en: "SMTP password" }, scope: "tenant" });
276
+ });
277
+ const schema = build([secretsFeature(), mailFoundation(), twice, inmemory]);
278
+ expect(schema.navs.map((n) => n.id)).toContain("mail-smtp-tenant");
279
+ expect(screenOf(schema, "mail-smtp-tenant", "configEdit").dormant).toBeUndefined();
280
+ expect(Object.keys(screenOf(schema, "secrets", "secretsEdit").secretKeys)).toEqual([
281
+ "mail-smtp-password",
282
+ ]);
283
+ const dashboard = screenOf(schema, "mail-foundation-tenant", "dashboard");
284
+ expect(dashboard.panels.map((p) => p.id)).toEqual(["selection", "mail-inmemory-config"]);
285
+ });
286
+
287
+ test("plugin feature shared by two selectors keeps today's behaviour", () => {
288
+ const otherFoundation = defineFeature("sms-foundation", (r) => {
289
+ r.extendsRegistrar("smsTransport", { onRegister: () => undefined });
290
+ const keys = r.config({
291
+ keys: {
292
+ provider: createTenantConfig("text", {
293
+ default: "",
294
+ write: TENANT_ADMIN_WRITE,
295
+ mask: { title: "sms.provider" },
296
+ }),
297
+ },
298
+ });
299
+ r.extensionSelector("smsTransport", keys.provider);
300
+ });
301
+ const both = defineFeature("mail-smtp", (r) => {
302
+ r.useExtension("mailTransport", "smtp");
303
+ r.useExtension("smsTransport", "gateway");
304
+ r.config({
305
+ keys: {
306
+ host: createTenantConfig("text", {
307
+ default: "",
308
+ write: TENANT_ADMIN_WRITE,
309
+ mask: { title: "smtp.host" },
310
+ }),
311
+ },
312
+ });
313
+ });
314
+ const schema = build([mailFoundation(), otherFoundation, both]);
315
+ expect(schema.navs.map((n) => n.id)).toContain("mail-smtp-tenant");
316
+ expect(schema.screens.find((s) => s.id === "mail-foundation-tenant")?.type).toBe("configEdit");
317
+ });
318
+
319
+ test("selector key not visible at tenant scope: no dashboard, plugins untouched", () => {
320
+ const systemOnlyOwner = defineFeature("mail-foundation", (r) => {
321
+ r.extendsRegistrar("mailTransport", { onRegister: () => undefined });
322
+ const keys = r.config({
323
+ keys: {
324
+ provider: createSystemConfig("text", {
325
+ default: "",
326
+ write: access.systemAdmin,
327
+ mask: { title: "mail.provider" },
328
+ }),
329
+ },
330
+ });
331
+ r.extensionSelector("mailTransport", keys.provider);
332
+ });
333
+ const schema = build([systemOnlyOwner, smtp, inmemory]);
334
+ expect(schema.screens.some((s) => s.type === "dashboard")).toBe(false);
335
+ expect(schema.navs.map((n) => n.id)).toContain("mail-smtp-tenant");
336
+ expect(screenOf(schema, "mail-foundation-system", "configEdit").fields["provider"]?.type).toBe(
337
+ "select",
338
+ );
339
+ });
340
+
341
+ test("app without a masked selector is unchanged", () => {
342
+ const unmaskedOwner = defineFeature("mail-foundation", (r) => {
343
+ r.extendsRegistrar("mailTransport", { onRegister: () => undefined });
344
+ const keys = r.config({ keys: { provider: createTenantConfig("text", { default: "" }) } });
345
+ r.extensionSelector("mailTransport", keys.provider);
346
+ });
347
+ const schema = build([secretsFeature(), unmaskedOwner, smtp, inmemory, stripe]);
348
+ expect(schema.screens.some((s) => s.type === "dashboard")).toBe(false);
349
+ expect(schema.navs.map((n) => n.id)).toContain("mail-smtp-tenant");
350
+ expect(Object.keys(screenOf(schema, "secrets", "secretsEdit").secretKeys).sort()).toEqual([
351
+ "mail-smtp-password",
352
+ "stripe-api-key",
353
+ ]);
354
+ expect(screenOf(schema, "mail-smtp-tenant", "configEdit").dormant).toBeUndefined();
355
+ });
356
+ });
357
+
358
+ describe("generated selector dashboard — boot validation", () => {
359
+ // Re-registers the GENERATED hub screens on the config stand-in so
360
+ // validateBoot runs its real dashboard/screen-panel/query-ref checks on them.
361
+ function hubHostFeature(withQuery: boolean): FeatureDefinition {
362
+ const schema = build(gatedFeatures());
363
+ // i18n coverage of the hub is asserted in the i18n-keys boot test; here every
364
+ // required key is defined so only the screen/panel/query checks can fail.
365
+ const translationKeys = Object.fromEntries(
366
+ schema.screens
367
+ .flatMap((s) =>
368
+ requiredKeysFromScreen(SETTINGS_HUB_FEATURE, s, { treatDotFormAsKey: true }),
369
+ )
370
+ .map((key) => [key, { en: key }]),
371
+ );
372
+ return defineFeature("config", (r) => {
373
+ r.translations({ keys: { ...translationKeys, ...SETTINGS_HUB_I18N } });
374
+ if (withQuery) {
375
+ r.queryHandler({
376
+ name: "config-value:selected-extensions",
377
+ schema: z.object({}),
378
+ access: { openToAll: { reason: "test stand-in" } },
379
+ handler: async () => ({}),
380
+ });
381
+ }
382
+ for (const screen of schema.screens) r.screen(screen);
383
+ });
384
+ }
385
+
386
+ test("panel targets resolve to embeddable generated screens and the visibleWhen query is registered", () => {
387
+ expect(() => validateBoot([hubHostFeature(true), ...gatedFeatures()])).not.toThrow();
388
+ });
389
+
390
+ test("a visibleWhen query that is not a registered handler is rejected", () => {
391
+ expect(() => validateBoot([hubHostFeature(false), ...gatedFeatures()])).toThrow(
392
+ /visibleWhen query "config:query:config-value:selected-extensions"/,
393
+ );
394
+ });
395
+ });
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import * as z from "zod";
3
+ import { SETTINGS_HUB_I18N } from "../../../i18n/settings-hub-keys";
2
4
  import { buildConfigFeatureSchema } from "../../build-config-feature-schema";
3
5
  import { access, createTenantConfig } from "../../config-helpers";
4
6
  import { defineFeature } from "../../define-feature";
@@ -265,3 +267,74 @@ describe("validateI18nSurfaceKeys — mask.title is unconditionally required (fw
265
267
  expect(() => validateBoot([configHub, feature])).not.toThrow();
266
268
  });
267
269
  });
270
+
271
+ describe("validateI18nSurfaceKeys — extension-selector settings dashboard", () => {
272
+ const secretsMount = defineFeature("secrets", (r) => {
273
+ r.writeHandler(
274
+ "set",
275
+ z.object({ key: z.string(), value: z.string() }),
276
+ async () => ({ isSuccess: true, data: null }),
277
+ { access: { roles: ["TenantAdmin"] } },
278
+ );
279
+ });
280
+ const hubWithHint = defineFeature("config", (r) => {
281
+ r.translations({ keys: SETTINGS_HUB_I18N });
282
+ });
283
+ const hubWithoutHint = defineFeature("config", (r) => {
284
+ const { "config.settings.extensionSelectorHint": _hint, ...withoutHint } = SETTINGS_HUB_I18N;
285
+ r.translations({ keys: withoutHint });
286
+ });
287
+
288
+ // Consumer-shaped: only the keys an app declared before the selector screen existed.
289
+ const owner = defineFeature("mail-foundation", (r) => {
290
+ r.translations({
291
+ keys: {
292
+ "mail-foundation.settings": { en: "Mail" },
293
+ "screen:mail-foundation-tenant.title": { en: "Mail" },
294
+ "mail.provider": { en: "Provider" },
295
+ },
296
+ });
297
+ r.extendsRegistrar("mailTransport", { onRegister: () => undefined });
298
+ const keys = r.config({
299
+ keys: {
300
+ provider: createTenantConfig("text", {
301
+ default: "",
302
+ write: access.roles("TenantAdmin"),
303
+ mask: { title: "mail.provider" },
304
+ }),
305
+ },
306
+ });
307
+ r.extensionSelector("mailTransport", keys.provider);
308
+ });
309
+ const smtp = defineFeature("mail-smtp", (r) => {
310
+ r.requires("mail-foundation");
311
+ r.translations({
312
+ keys: {
313
+ "mail-smtp.settings": { en: "SMTP" },
314
+ "screen:mail-smtp-tenant.title": { en: "SMTP" },
315
+ "smtp.host": { en: "Host" },
316
+ },
317
+ });
318
+ r.useExtension("mailTransport", "smtp");
319
+ r.config({
320
+ keys: {
321
+ host: createTenantConfig("text", {
322
+ default: "",
323
+ write: access.roles("TenantAdmin"),
324
+ mask: { title: "smtp.host" },
325
+ }),
326
+ },
327
+ });
328
+ r.secret("password", { label: { en: "SMTP password" }, scope: "tenant" });
329
+ });
330
+
331
+ test("an app declaring only today's keys boots with the selector dashboard", () => {
332
+ expect(() => validateBoot([hubWithHint, secretsMount, owner, smtp])).not.toThrow();
333
+ });
334
+
335
+ test("the dashboard hint key is required once a selector dashboard exists", () => {
336
+ expect(() => validateBoot([hubWithoutHint, secretsMount, owner, smtp])).toThrow(
337
+ /Settings-Hub: required translation key missing: "config\.settings\.extensionSelectorHint"/,
338
+ );
339
+ });
340
+ });
@@ -5,14 +5,20 @@ import {
5
5
  requiredKeysFromNav,
6
6
  requiredKeysFromScreen,
7
7
  requiredKeysFromWorkspace,
8
+ screenTitleKey,
8
9
  } from "../../i18n/required-surface-keys";
9
10
  import {
10
11
  buildConfigFeatureSchema,
11
12
  type ConfigFeatureSchema,
12
13
  SETTINGS_HUB_FEATURE,
13
14
  } from "../build-config-feature-schema";
15
+ import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins";
14
16
  import { createRegistry } from "../registry";
15
- import type { FeatureDefinition } from "../types";
17
+ import type { FeatureDefinition, ScreenDefinition } from "../types";
18
+
19
+ function isDormantSecretsScreen(screen: ScreenDefinition): boolean {
20
+ return screen.type === "secretsEdit" && screen.dormant === true;
21
+ }
16
22
 
17
23
  function requiredKeysFromGeneratedConfigHub(schema: ConfigFeatureSchema): readonly string[] {
18
24
  if (schema.navs.length === 0) return [];
@@ -25,9 +31,12 @@ function requiredKeysFromGeneratedConfigHub(schema: ConfigFeatureSchema): readon
25
31
  for (const key of requiredKeysFromScreen(SETTINGS_HUB_FEATURE, screen, {
26
32
  treatDotFormAsKey: true,
27
33
  })) {
34
+ // Embedded selector-plugin secrets panels render no title of their own.
35
+ if (isDormantSecretsScreen(screen) && key === screenTitleKey(screen.id)) continue;
28
36
  out.add(key);
29
37
  }
30
38
  }
39
+ if (schema.screens.some((s) => s.type === "dashboard")) out.add(EXTENSION_SELECTOR_HINT_KEY);
31
40
  for (const nav of schema.navs) {
32
41
  for (const key of requiredKeysFromNav(nav, { treatDotFormAsKey: true })) out.add(key);
33
42
  }
@@ -17,14 +17,19 @@
17
17
 
18
18
  import type { WorkspaceSchema } from "../ui-types";
19
19
  import type { ConfigScope } from "./constants";
20
+ import {
21
+ EXTENSION_SELECTOR_HINT_KEY,
22
+ SELECTED_EXTENSIONS_QUERY,
23
+ selectablePluginIds,
24
+ } from "./extension-selector-plugins";
20
25
  import {
21
26
  createBooleanField,
22
27
  createNumberField,
23
28
  createSelectField,
24
29
  createTextField,
25
30
  } from "./factories";
26
- import { isKebabSegment } from "./qualified-name";
27
- import type { ConfigKeyDefinition, TranslationKeys } from "./types/config";
31
+ import { isKebabSegment, toKebab } from "./qualified-name";
32
+ import type { ConfigKeyDefinition, TranslationEntry, TranslationKeys } from "./types/config";
28
33
  import type { Registry, SecretKeyDefinition } from "./types/feature";
29
34
  import type { FieldDefinition } from "./types/fields";
30
35
  import type { AccessRule } from "./types/handlers";
@@ -32,6 +37,8 @@ import { isOpenToAllGranted } from "./types/handlers";
32
37
  import type { NavDefinition, NavIconKey } from "./types/nav";
33
38
  import type {
34
39
  ConfigEditScreenDefinition,
40
+ DashboardPanelDefinition,
41
+ DashboardScreenDefinition,
35
42
  EditFieldsSection,
36
43
  ScreenDefinition,
37
44
  SecretsEditScreenDefinition,
@@ -117,51 +124,293 @@ type ScopeResult = {
117
124
  readonly translations?: TranslationKeys;
118
125
  };
119
126
 
127
+ // Its tenant keys/secrets become panels of the selector owner's dashboard, not own navs.
128
+ type GatedPlugin = {
129
+ readonly feature: string;
130
+ readonly extension: string;
131
+ readonly pluginId: string;
132
+ readonly scopedKeys: readonly ScopedKey[];
133
+ readonly secrets: readonly DeclaredSecret[];
134
+ };
135
+
136
+ type SelectorGating = {
137
+ readonly pluginsByOwnerGroup: ReadonlyMap<string, readonly GatedPlugin[]>;
138
+ readonly pluginFeatures: ReadonlySet<string>;
139
+ readonly secretQns: ReadonlySet<string>;
140
+ };
141
+
142
+ const NO_SELECTOR_GATING: SelectorGating = {
143
+ pluginsByOwnerGroup: new Map(),
144
+ pluginFeatures: new Set(),
145
+ secretQns: new Set(),
146
+ };
147
+
148
+ type HubContext = {
149
+ readonly declaredTranslationKeys: ReadonlySet<string>;
150
+ readonly selectorOptions: ReadonlyMap<string, readonly string[]>;
151
+ readonly translationsByKey: ReadonlyMap<string, TranslationEntry>;
152
+ };
153
+
120
154
  // Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
121
155
  // (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
122
- function collectDeclaredTranslationKeys(registry: Registry): Set<string> {
123
- const declaredTranslationKeys = new Set<string>();
156
+ function collectDeclaredTranslations(registry: Registry): ReadonlyMap<string, TranslationEntry> {
157
+ const declared = new Map<string, TranslationEntry>();
124
158
  for (const f of registry.features.values()) {
125
- for (const key of Object.keys(f.translations ?? {})) declaredTranslationKeys.add(key);
159
+ for (const [key, values] of Object.entries(f.translations ?? {})) {
160
+ if (!declared.has(key)) declared.set(key, values);
161
+ }
162
+ }
163
+ return declared;
164
+ }
165
+
166
+ function collectSelectorOptions(registry: Registry): Map<string, readonly string[]> {
167
+ const options = new Map<string, readonly string[]>();
168
+ for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
169
+ const pluginIds = selectablePluginIds(registry, extensionName);
170
+ if (pluginIds.length > 0) options.set(selectorKey, pluginIds);
171
+ }
172
+ return options;
173
+ }
174
+
175
+ function collectVisibleSelectorOwners(
176
+ registry: Registry,
177
+ visible: readonly ScopedKey[],
178
+ ): { ownerGroupByExtension: Map<string, string>; selectorFeatures: Set<string> } {
179
+ const ownerGroupByExtension = new Map<string, string>();
180
+ const selectorFeatures = new Set<string>();
181
+ for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
182
+ const selector = visible.find((v) => v.key.qn === selectorKey);
183
+ if (selector === undefined) continue;
184
+ ownerGroupByExtension.set(extensionName, selector.key.feature);
185
+ selectorFeatures.add(selector.key.feature);
186
+ selectorFeatures.add(selector.key.ownerFeature);
187
+ }
188
+ return { ownerGroupByExtension, selectorFeatures };
189
+ }
190
+
191
+ function countSelectorUsagesByFeature(registry: Registry): Map<string, number> {
192
+ const usageCountByFeature = new Map<string, number>();
193
+ for (const [extensionName] of registry.getAllExtensionSelectors()) {
194
+ for (const usage of registry.getExtensionUsages(extensionName)) {
195
+ if (usage.featureName === undefined) continue;
196
+ const feature = toKebab(usage.featureName);
197
+ usageCountByFeature.set(feature, (usageCountByFeature.get(feature) ?? 0) + 1);
198
+ }
199
+ }
200
+ return usageCountByFeature;
201
+ }
202
+
203
+ // A plugin feature with several selector-gated registrations can't sit under one
204
+ // selector's panel, so it stays ungated (own nav, secrets in the global screen).
205
+ function planSelectorGating(
206
+ registry: Registry,
207
+ visible: readonly ScopedKey[],
208
+ declaredSecrets: readonly DeclaredSecret[],
209
+ secretsEnabled: boolean,
210
+ ): SelectorGating {
211
+ const { ownerGroupByExtension, selectorFeatures } = collectVisibleSelectorOwners(
212
+ registry,
213
+ visible,
214
+ );
215
+ if (ownerGroupByExtension.size === 0) return NO_SELECTOR_GATING;
216
+ const usageCountByFeature = countSelectorUsagesByFeature(registry);
217
+
218
+ const pluginsByOwnerGroup = new Map<string, GatedPlugin[]>();
219
+ const pluginFeatures = new Set<string>();
220
+ const secretQns = new Set<string>();
221
+ for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) =>
222
+ a.localeCompare(b),
223
+ )) {
224
+ const usages = [...registry.getExtensionUsages(extension)].sort((a, b) =>
225
+ (a.featureName ?? "").localeCompare(b.featureName ?? ""),
226
+ );
227
+ for (const usage of usages) {
228
+ if (usage.featureName === undefined) continue;
229
+ const feature = toKebab(usage.featureName);
230
+ if (usageCountByFeature.get(feature) !== 1 || selectorFeatures.has(feature)) continue;
231
+ const scopedKeys = visible.filter((v) => v.key.ownerFeature === feature);
232
+ const secrets = secretsEnabled ? declaredSecrets.filter((s) => s.feature === feature) : [];
233
+ if (scopedKeys.length === 0 && secrets.length === 0) continue;
234
+ const plugins = pluginsByOwnerGroup.get(ownerGroup) ?? [];
235
+ plugins.push({ feature, extension, pluginId: usage.entityName, scopedKeys, secrets });
236
+ pluginsByOwnerGroup.set(ownerGroup, plugins);
237
+ pluginFeatures.add(feature);
238
+ for (const s of secrets) secretQns.add(s.qn);
239
+ }
126
240
  }
127
- return declaredTranslationKeys;
241
+ return { pluginsByOwnerGroup, pluginFeatures, secretQns };
242
+ }
243
+
244
+ type FeatureScreens = {
245
+ screens: ScreenDefinition[];
246
+ navs: NavDefinition[];
247
+ translations: Record<string, TranslationEntry>;
248
+ };
249
+
250
+ // A tenant/user-home key with an elevated write role (SystemAdmin on a
251
+ // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
252
+ // audience navs (cascade-default screen + home screen) — both would
253
+ // otherwise carry the identical `${feature}.settings` label. Opt-in
254
+ // scoped override (`${feature}.settings.${scope}`) disambiguates only
255
+ // where a feature actually declares one; every single-scope feature
256
+ // keeps the plain key unchanged.
257
+ function buildFeatureNav(
258
+ scope: ConfigScope,
259
+ feature: string,
260
+ ordered: readonly MaskedKey[],
261
+ access: AccessRule,
262
+ declaredTranslationKeys: ReadonlySet<string>,
263
+ ): NavDefinition {
264
+ const shortId = `${feature}-${scope}`;
265
+ const scopedLabel = `${feature}.settings.${scope}`;
266
+ return {
267
+ id: shortId,
268
+ label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
269
+ parent: audienceNavShortId(scope),
270
+ screen: shortId,
271
+ icon: ordered[0]?.def.mask?.icon ?? "settings",
272
+ order: minMaskOrder(ordered),
273
+ access,
274
+ };
128
275
  }
129
276
 
130
- // Per-feature configEdit screen + child-nav, one pair per feature present in
131
- // `visible` at this scope.
277
+ // One configEdit screen + child-nav per feature; a tenant-scope selector owner
278
+ // becomes a dashboard instead and its gated plugins get no entries of their own.
132
279
  function buildFeatureScreensAndNavs(
133
280
  scope: ConfigScope,
134
281
  visible: readonly ScopedKey[],
135
- declaredTranslationKeys: ReadonlySet<string>,
136
- ): { screens: ScreenDefinition[]; navs: NavDefinition[] } {
137
- const screens: ScreenDefinition[] = [];
138
- const navs: NavDefinition[] = [];
139
- for (const feature of featuresPresent(visible.map((v) => v.key))) {
140
- const group = visible.filter((v) => v.key.feature === feature);
282
+ hub: HubContext,
283
+ gating: SelectorGating,
284
+ secretsAccess: AccessRule | undefined,
285
+ ): FeatureScreens {
286
+ const out: FeatureScreens = { screens: [], navs: [], translations: {} };
287
+ const ungated = visible.filter((v) => !gating.pluginFeatures.has(v.key.ownerFeature));
288
+ for (const feature of featuresPresent(ungated.map((v) => v.key))) {
289
+ const group = ungated.filter((v) => v.key.feature === feature);
141
290
  const ordered = sortByMaskOrder(group.map((v) => v.key));
142
291
  const access = rolesToAccess(group.flatMap((v) => v.roles));
143
292
  const shortId = `${feature}-${scope}`;
293
+ const plugins = scope === "tenant" ? gating.pluginsByOwnerGroup.get(feature) : undefined;
144
294
 
145
- screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
146
- // A tenant/user-home key with an elevated write role (SystemAdmin on a
147
- // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
148
- // audience navs (cascade-default screen + home screen) — both would
149
- // otherwise carry the identical `${feature}.settings` label. Opt-in
150
- // scoped override (`${feature}.settings.${scope}`) disambiguates only
151
- // where a feature actually declares one; every single-scope feature
152
- // keeps the plain key unchanged.
153
- const scopedLabel = `${feature}.settings.${scope}`;
154
- navs.push({
155
- id: shortId,
156
- label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
157
- parent: audienceNavShortId(scope),
158
- screen: shortId,
159
- icon: ordered[0]?.def.mask?.icon ?? "settings",
160
- order: minMaskOrder(ordered),
295
+ if (plugins === undefined) {
296
+ out.screens.push(buildScreen(shortId, scope, feature, ordered, access, hub));
297
+ out.navs.push(buildFeatureNav(scope, feature, ordered, access, hub.declaredTranslationKeys));
298
+ continue;
299
+ }
300
+ const ownerHub = buildSelectorOwnerDashboard(
301
+ feature,
302
+ ordered,
161
303
  access,
162
- });
304
+ plugins,
305
+ hub,
306
+ secretsAccess,
307
+ );
308
+ out.screens.push(...ownerHub.screens);
309
+ out.navs.push(
310
+ buildFeatureNav(scope, feature, ordered, ownerHub.access, hub.declaredTranslationKeys),
311
+ );
312
+ Object.assign(out.translations, ownerHub.translations);
313
+ }
314
+ return out;
315
+ }
316
+
317
+ function pluginTenantScreenId(plugin: GatedPlugin): string {
318
+ return `${plugin.feature}-tenant`;
319
+ }
320
+
321
+ // Plugin screens keep their `${F}-tenant` ids so consumer title keys stay valid;
322
+ // they are dormant because only this dashboard embeds them.
323
+ function buildSelectorOwnerDashboard(
324
+ ownerGroup: string,
325
+ ownerKeys: readonly MaskedKey[],
326
+ ownerAccess: AccessRule,
327
+ plugins: readonly GatedPlugin[],
328
+ hub: HubContext,
329
+ secretsAccess: AccessRule | undefined,
330
+ ): {
331
+ screens: ScreenDefinition[];
332
+ access: AccessRule;
333
+ translations: Record<string, TranslationEntry>;
334
+ } {
335
+ const selectionScreenId = `${ownerGroup}-tenant-selection`;
336
+ const selectionScreen: ConfigEditScreenDefinition = {
337
+ ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub),
338
+ dormant: true,
339
+ };
340
+ const screens: ScreenDefinition[] = [selectionScreen];
341
+ const configPanels: DashboardPanelDefinition[] = [];
342
+ const secretsPanels: DashboardPanelDefinition[] = [];
343
+ const accessRules: (AccessRule | undefined)[] = [ownerAccess];
344
+ const translations: Record<string, TranslationEntry> = {};
345
+
346
+ const titleSource = [`${ownerGroup}.settings.tenant`, `${ownerGroup}.settings`].find((key) =>
347
+ hub.translationsByKey.has(key),
348
+ );
349
+ const titleValues =
350
+ titleSource === undefined ? undefined : hub.translationsByKey.get(titleSource);
351
+ if (titleValues !== undefined) translations[`screen:${selectionScreenId}.title`] = titleValues;
352
+
353
+ for (const plugin of plugins) {
354
+ const visibleWhen = {
355
+ query: SELECTED_EXTENSIONS_QUERY,
356
+ field: plugin.extension,
357
+ eq: plugin.pluginId,
358
+ };
359
+ if (plugin.scopedKeys.length > 0) {
360
+ const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
361
+ const ordered = sortByMaskOrder(plugin.scopedKeys.map((v) => v.key));
362
+ screens.push({
363
+ ...buildScreen(
364
+ pluginTenantScreenId(plugin),
365
+ "tenant",
366
+ plugin.feature,
367
+ ordered,
368
+ pluginAccess,
369
+ hub,
370
+ ),
371
+ dormant: true,
372
+ });
373
+ accessRules.push(pluginAccess);
374
+ configPanels.push({
375
+ kind: "screen",
376
+ id: `${plugin.feature}-config`,
377
+ screen: pluginTenantScreenId(plugin),
378
+ visibleWhen,
379
+ });
380
+ }
381
+ if (plugin.secrets.length > 0) {
382
+ const secretsScreenId = `${plugin.feature}-tenant-secrets`;
383
+ const generated = buildSecretsEditScreen(
384
+ secretsScreenId,
385
+ plugin.secrets,
386
+ secretsAccess,
387
+ hub.declaredTranslationKeys,
388
+ );
389
+ screens.push({ ...generated.screen, dormant: true });
390
+ Object.assign(translations, generated.translations);
391
+ accessRules.push(secretsAccess);
392
+ secretsPanels.push({
393
+ kind: "screen",
394
+ id: `${plugin.feature}-secrets`,
395
+ screen: secretsScreenId,
396
+ visibleWhen,
397
+ });
398
+ }
163
399
  }
164
- return { screens, navs };
400
+
401
+ const access = unionAccessRules(accessRules);
402
+ const dashboard: DashboardScreenDefinition = {
403
+ id: `${ownerGroup}-tenant`,
404
+ type: "dashboard",
405
+ description: EXTENSION_SELECTOR_HINT_KEY,
406
+ panels: [
407
+ { kind: "screen", id: "selection", screen: selectionScreenId },
408
+ ...configPanels,
409
+ ...secretsPanels,
410
+ ],
411
+ access,
412
+ };
413
+ return { screens: [dashboard, ...screens], access, translations };
165
414
  }
166
415
 
167
416
  // Everything generated for one audience scope: the audience-parent nav, the
@@ -173,7 +422,8 @@ function buildScopeResult(
173
422
  secretsEnabled: boolean,
174
423
  secretsWriteHandler: ReturnType<Registry["getWriteHandler"]>,
175
424
  declaredSecrets: readonly DeclaredSecret[],
176
- declaredTranslationKeys: ReadonlySet<string>,
425
+ hub: HubContext,
426
+ registry: Registry,
177
427
  ): ScopeResult | null {
178
428
  const visible = scopedKeysAt(masked, scope);
179
429
  // Secrets attach to the tenant-audience nav regardless of whether any
@@ -183,6 +433,10 @@ function buildScopeResult(
183
433
 
184
434
  const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
185
435
  const secretsAccess = secretsWriteHandler?.access;
436
+ const gating =
437
+ scope === "tenant"
438
+ ? planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
439
+ : NO_SELECTOR_GATING;
186
440
  const screens: ScreenDefinition[] = [];
187
441
  const navs: NavDefinition[] = [];
188
442
  // Audience-Parent: Gruppierungs-Knoten ohne Screen.
@@ -194,19 +448,26 @@ function buildScopeResult(
194
448
  access: includeSecrets ? unionAccessRules([configAccess, secretsAccess]) : configAccess,
195
449
  });
196
450
 
197
- const perFeature = buildFeatureScreensAndNavs(scope, visible, declaredTranslationKeys);
451
+ const perFeature = buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess);
198
452
  screens.push(...perFeature.screens);
199
453
  navs.push(...perFeature.navs);
200
454
 
201
- let translations: TranslationKeys | undefined;
202
- if (includeSecrets) {
203
- const generated = buildSecretsScreen(declaredSecrets, secretsAccess, declaredTranslationKeys);
455
+ const translations: Record<string, TranslationEntry> = {
456
+ ...perFeature.translations,
457
+ };
458
+ const globalSecrets = declaredSecrets.filter((s) => !gating.secretQns.has(s.qn));
459
+ if (includeSecrets && globalSecrets.length > 0) {
460
+ const generated = buildSecretsScreen(globalSecrets, secretsAccess, hub.declaredTranslationKeys);
204
461
  screens.push(generated.screen);
205
462
  navs.push(generated.nav);
206
- translations = generated.translations;
463
+ Object.assign(translations, generated.translations);
207
464
  }
208
465
 
209
- return { screens, navs, ...(translations !== undefined && { translations }) };
466
+ return {
467
+ screens,
468
+ navs,
469
+ ...(Object.keys(translations).length > 0 && { translations }),
470
+ };
210
471
  }
211
472
 
212
473
  export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
@@ -218,7 +479,12 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
218
479
  const secretsEnabled = secretsWriteHandler !== undefined && declaredSecrets.length > 0;
219
480
  if (masked.length === 0 && !secretsEnabled) return { screens: [], navs: [] };
220
481
 
221
- const declaredTranslationKeys = collectDeclaredTranslationKeys(registry);
482
+ const translationsByKey = collectDeclaredTranslations(registry);
483
+ const hub: HubContext = {
484
+ declaredTranslationKeys: new Set(translationsByKey.keys()),
485
+ selectorOptions: collectSelectorOptions(registry),
486
+ translationsByKey,
487
+ };
222
488
 
223
489
  const screens: ScreenDefinition[] = [];
224
490
  const navs: NavDefinition[] = [];
@@ -231,12 +497,15 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
231
497
  secretsEnabled,
232
498
  secretsWriteHandler,
233
499
  declaredSecrets,
234
- declaredTranslationKeys,
500
+ hub,
501
+ registry,
235
502
  );
236
503
  if (result === null) continue;
237
504
  screens.push(...result.screens);
238
505
  navs.push(...result.navs);
239
- if (result.translations !== undefined) translations = result.translations;
506
+ if (result.translations !== undefined) {
507
+ translations = { ...translations, ...result.translations };
508
+ }
240
509
  }
241
510
 
242
511
  // Every masked key machine-only and no secrets → no human hub, and no
@@ -283,6 +552,30 @@ function buildSecretsScreen(
283
552
  access: AccessRule | undefined,
284
553
  declaredTranslationKeys: ReadonlySet<string>,
285
554
  ): { screen: SecretsEditScreenDefinition; nav: NavDefinition; translations: TranslationKeys } {
555
+ const { screen, translations } = buildSecretsEditScreen(
556
+ "secrets",
557
+ secrets,
558
+ access,
559
+ declaredTranslationKeys,
560
+ );
561
+ const nav: NavDefinition = {
562
+ id: "secrets",
563
+ label: "config.secrets.title",
564
+ parent: audienceNavShortId("tenant"),
565
+ screen: "secrets",
566
+ icon: "key",
567
+ order: 900,
568
+ ...(access !== undefined && { access }),
569
+ };
570
+ return { screen, nav, translations };
571
+ }
572
+
573
+ function buildSecretsEditScreen(
574
+ screenId: string,
575
+ secrets: readonly DeclaredSecret[],
576
+ access: AccessRule | undefined,
577
+ declaredTranslationKeys: ReadonlySet<string>,
578
+ ): { screen: SecretsEditScreenDefinition; translations: TranslationKeys } {
286
579
  const secretKeys: Record<string, string> = {};
287
580
  const fieldLabels: Record<string, string> = {};
288
581
  const fieldHints: Record<string, string> = {};
@@ -290,7 +583,7 @@ function buildSecretsScreen(
290
583
  // Mutable outer record — TranslationKeys' Readonly<Record<...>> index
291
584
  // signature only permits reading, so the top-level assignments below
292
585
  // need a writable local type; each entry is still a fresh, never-mutated object.
293
- const translations: Record<string, Readonly<Record<string, string>>> = {};
586
+ const translations: Record<string, TranslationEntry> = {};
294
587
  const sections: SecretsEditSection[] = [];
295
588
 
296
589
  for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
@@ -317,7 +610,7 @@ function buildSecretsScreen(
317
610
  }
318
611
 
319
612
  const screen: SecretsEditScreenDefinition = {
320
- id: "secrets",
613
+ id: screenId,
321
614
  type: "secretsEdit",
322
615
  secretKeys,
323
616
  fieldLabels,
@@ -326,16 +619,7 @@ function buildSecretsScreen(
326
619
  sections,
327
620
  ...(access !== undefined && { access }),
328
621
  };
329
- const nav: NavDefinition = {
330
- id: "secrets",
331
- label: "config.secrets.title",
332
- parent: audienceNavShortId("tenant"),
333
- screen: "secrets",
334
- icon: "key",
335
- order: 900,
336
- ...(access !== undefined && { access }),
337
- };
338
- return { screen, nav, translations };
622
+ return { screen, translations };
339
623
  }
340
624
 
341
625
  // Keys visible at `scope`, paired with their effective write roles AT that
@@ -389,7 +673,7 @@ function buildScreen(
389
673
  feature: string,
390
674
  keys: readonly MaskedKey[],
391
675
  access: AccessRule,
392
- declaredTranslationKeys: ReadonlySet<string>,
676
+ hub: HubContext,
393
677
  ): ConfigEditScreenDefinition {
394
678
  const configKeys: Record<string, string> = {};
395
679
  const fields: Record<string, FieldDefinition> = {};
@@ -411,7 +695,7 @@ function buildScreen(
411
695
  }
412
696
  seenFieldIds.set(id, k.qn);
413
697
  configKeys[id] = k.qn;
414
- fields[id] = deriveField(k.def);
698
+ fields[id] = deriveField(k.def, hub.selectorOptions.get(k.qn));
415
699
  // mask is the visibility gate, so collectMaskedKeys guarantees it here.
416
700
  if (k.def.mask) fieldLabels[id] = k.def.mask.title;
417
701
  }
@@ -419,7 +703,7 @@ function buildScreen(
419
703
  const descriptionKey = `${feature}.settings.description`;
420
704
  const section: EditFieldsSection = {
421
705
  title: `${feature}.settings`,
422
- ...(declaredTranslationKeys.has(descriptionKey) && { description: descriptionKey }),
706
+ ...(hub.declaredTranslationKeys.has(descriptionKey) && { description: descriptionKey }),
423
707
  fields: keys.map(fieldId),
424
708
  };
425
709
  return {
@@ -434,7 +718,11 @@ function buildScreen(
434
718
  };
435
719
  }
436
720
 
437
- function deriveField(def: ConfigKeyDefinition): FieldDefinition {
721
+ function deriveField(
722
+ def: ConfigKeyDefinition,
723
+ selectorPluginIds: readonly string[] | undefined,
724
+ ): FieldDefinition {
725
+ if (selectorPluginIds !== undefined) return createSelectField({ options: selectorPluginIds });
438
726
  switch (def.type) {
439
727
  case "number":
440
728
  return createNumberField();
@@ -0,0 +1,24 @@
1
+ import type { Registry } from "./types/feature";
2
+
3
+ // Pinned QN of config's selected-extensions query — framework cannot import
4
+ // bundled-features, so a pin test bundled-side keeps it equal to the handler.
5
+ export const SELECTED_EXTENSIONS_QUERY = "config:query:config-value:selected-extensions";
6
+
7
+ export const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHint";
8
+
9
+ export function selectablePluginIds(
10
+ registry: Pick<Registry, "getExtensionUsages">,
11
+ extensionName: string,
12
+ ): string[] {
13
+ return [...new Set(registry.getExtensionUsages(extensionName).map((u) => u.entityName))].sort();
14
+ }
15
+
16
+ export function extensionSelectorTargets(
17
+ registry: Pick<Registry, "getAllExtensionSelectors">,
18
+ ): ReadonlyMap<string, string> {
19
+ const extensionByKey = new Map<string, string>();
20
+ for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
21
+ extensionByKey.set(selectorKey, extensionName);
22
+ }
23
+ return extensionByKey;
24
+ }
@@ -118,6 +118,12 @@ export {
118
118
  FILE_STORAGE_PROVIDER_ENV,
119
119
  TENANT_MEMBERSHIPS_QUERY,
120
120
  } from "./extension-names";
121
+ export {
122
+ EXTENSION_SELECTOR_HINT_KEY,
123
+ extensionSelectorTargets,
124
+ SELECTED_EXTENSIONS_QUERY,
125
+ selectablePluginIds,
126
+ } from "./extension-selector-plugins";
121
127
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage";
122
128
  export type {
123
129
  StorageProviderDestroyTenantHook,
@@ -17,6 +17,9 @@ export const SETTINGS_HUB_I18N: TranslationKeys = {
17
17
  "config.settings.system": { en: "Platform" },
18
18
  "config.settings.tenant": { en: "Tenant" },
19
19
  "config.settings.user": { en: "Personal" },
20
+ "config.settings.extensionSelectorHint": {
21
+ en: "Save the provider selection to show its settings below.",
22
+ },
20
23
  "config.secrets.title": { en: "Secrets" },
21
24
  "config.secrets.notSet": { en: "Not set" },
22
25
  "config.secrets.set": { en: "Set" },