@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 +4 -4
- package/src/engine/__tests__/build-config-feature-schema-extension-selector.test.ts +395 -0
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +73 -0
- package/src/engine/boot-validator/i18n-keys.ts +10 -1
- package/src/engine/build-config-feature-schema.ts +346 -58
- package/src/engine/extension-selector-plugins.ts +24 -0
- package/src/engine/index.ts +6 -0
- package/src/i18n/settings-hub-keys.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
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.
|
|
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
|
|
123
|
-
const
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
131
|
-
//
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
451
|
+
const perFeature = buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess);
|
|
198
452
|
screens.push(...perFeature.screens);
|
|
199
453
|
navs.push(...perFeature.navs);
|
|
200
454
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
463
|
+
Object.assign(translations, generated.translations);
|
|
207
464
|
}
|
|
208
465
|
|
|
209
|
-
return {
|
|
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
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -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" },
|