@cosmicdrift/kumiko-bundled-features 0.325.1 → 0.326.0
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 +9 -9
- package/src/config/__tests__/selected-extensions-query-name.test.ts +18 -0
- package/src/config/__tests__/selected-extensions.integration.test.ts +247 -0
- package/src/config/changes.json +7 -0
- package/src/config/constants.ts +2 -0
- package/src/config/feature.ts +2 -0
- package/src/config/handlers/selected-extensions.query.ts +71 -0
- package/src/config/handlers/set.write.ts +8 -0
- package/src/config/web/i18n.ts +3 -0
- package/src/config/write-helpers.ts +19 -0
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.326.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.326.0",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.326.0",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.326.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.326.0",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.326.0",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.326.0",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
@@ -168,8 +168,8 @@
|
|
|
168
168
|
],
|
|
169
169
|
"devDependencies": {
|
|
170
170
|
"@testing-library/user-event": "^14.6.1",
|
|
171
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
172
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
171
|
+
"@cosmicdrift/kumiko-locale-de": "0.326.0",
|
|
172
|
+
"@cosmicdrift/kumiko-locale-es": "0.326.0",
|
|
173
173
|
"jsqr": "^1.4.0"
|
|
174
174
|
}
|
|
175
175
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRegistry, SELECTED_EXTENSIONS_QUERY } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { ConfigQueries } from "../constants";
|
|
4
|
+
import { createConfigFeature } from "../feature";
|
|
5
|
+
|
|
6
|
+
// The framework's generated settings dashboard points visibleWhen at this QN but
|
|
7
|
+
// cannot import bundled-features; if the handler is renamed the panels would
|
|
8
|
+
// silently never show.
|
|
9
|
+
describe("selected-extensions query-name pin", () => {
|
|
10
|
+
test("framework's SELECTED_EXTENSIONS_QUERY equals the config feature's constant", () => {
|
|
11
|
+
expect(SELECTED_EXTENSIONS_QUERY).toBe(ConfigQueries.selectedExtensions);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("the QN is a query handler registered by the config feature", () => {
|
|
15
|
+
const registry = createRegistry([createConfigFeature()]);
|
|
16
|
+
expect(registry.getAllQueryHandlers().has(SELECTED_EXTENSIONS_QUERY)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
createTenantConfig,
|
|
6
|
+
defineFeature,
|
|
7
|
+
selectablePluginIds,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
createTestUser,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
testTenantId,
|
|
14
|
+
unsafePushTables,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
+
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
18
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
19
|
+
import { createConfigResolver } from "../resolver";
|
|
20
|
+
import { configValuesTable } from "../table";
|
|
21
|
+
|
|
22
|
+
const SELECTOR_KEY = "probe-transport:config:provider";
|
|
23
|
+
const OPEN_SELECTOR_KEY = "probe-open-transport:config:provider";
|
|
24
|
+
|
|
25
|
+
const transportFoundation = defineFeature("probe-transport", (r) => {
|
|
26
|
+
r.requires("config");
|
|
27
|
+
r.extendsRegistrar("probeTransport", { onRegister: () => undefined });
|
|
28
|
+
const keys = r.config({
|
|
29
|
+
keys: {
|
|
30
|
+
provider: createTenantConfig("text", {
|
|
31
|
+
default: "",
|
|
32
|
+
write: access.roles("TenantAdmin"),
|
|
33
|
+
read: access.roles("TenantAdmin"),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
r.extensionSelector("probeTransport", keys.provider);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const openFoundation = defineFeature("probe-open-transport", (r) => {
|
|
41
|
+
r.requires("config");
|
|
42
|
+
r.extendsRegistrar("probeOpenTransport", { onRegister: () => undefined });
|
|
43
|
+
const keys = r.config({
|
|
44
|
+
keys: {
|
|
45
|
+
provider: createTenantConfig("text", {
|
|
46
|
+
default: "",
|
|
47
|
+
write: access.roles("TenantAdmin"),
|
|
48
|
+
read: access.all,
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
r.extensionSelector("probeOpenTransport", keys.provider);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const platformFoundation = defineFeature("probe-platform-transport", (r) => {
|
|
56
|
+
r.requires("config");
|
|
57
|
+
r.extendsRegistrar("probePlatformTransport", { onRegister: () => undefined });
|
|
58
|
+
const keys = r.config({
|
|
59
|
+
keys: {
|
|
60
|
+
provider: createTenantConfig("text", {
|
|
61
|
+
default: "",
|
|
62
|
+
inheritedToTenant: false,
|
|
63
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
64
|
+
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
r.extensionSelector("probePlatformTransport", keys.provider);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const platformPlugin = defineFeature("probe-platform-plugin", (r) => {
|
|
72
|
+
r.useExtension("probePlatformTransport", "platform-smtp");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const pluginX = defineFeature("probe-plugin-x", (r) => {
|
|
76
|
+
r.useExtension("probeTransport", "x");
|
|
77
|
+
});
|
|
78
|
+
const pluginY = defineFeature("probe-plugin-y", (r) => {
|
|
79
|
+
r.useExtension("probeTransport", "y");
|
|
80
|
+
});
|
|
81
|
+
const openPlugin = defineFeature("probe-open-plugin", (r) => {
|
|
82
|
+
r.useExtension("probeOpenTransport", "open");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let stack: TestStack;
|
|
86
|
+
|
|
87
|
+
beforeAll(async () => {
|
|
88
|
+
const resolver = createConfigResolver({
|
|
89
|
+
cipher: createTestEnvelopeCipher(randomBytes(32).toString("base64")),
|
|
90
|
+
});
|
|
91
|
+
stack = await setupTestStack({
|
|
92
|
+
features: [
|
|
93
|
+
createConfigFeature(),
|
|
94
|
+
transportFoundation,
|
|
95
|
+
openFoundation,
|
|
96
|
+
platformFoundation,
|
|
97
|
+
platformPlugin,
|
|
98
|
+
pluginX,
|
|
99
|
+
pluginY,
|
|
100
|
+
openPlugin,
|
|
101
|
+
],
|
|
102
|
+
extraContext: ({ registry }) => ({
|
|
103
|
+
configResolver: resolver,
|
|
104
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
await stack.cleanup();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function tenantAdmin(tenantNumber: number) {
|
|
115
|
+
return createTestUser({
|
|
116
|
+
id: tenantNumber,
|
|
117
|
+
tenantId: testTenantId(tenantNumber),
|
|
118
|
+
roles: ["TenantAdmin"],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function plainUser(tenantNumber: number) {
|
|
123
|
+
return createTestUser({
|
|
124
|
+
id: 100 + tenantNumber,
|
|
125
|
+
tenantId: testTenantId(tenantNumber),
|
|
126
|
+
roles: ["User"],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("config:write:set — extension selector validation", () => {
|
|
131
|
+
test("registry lists the mounted plugin ids the write is validated against", () => {
|
|
132
|
+
expect(selectablePluginIds(stack.registry, "probeTransport")).toEqual(["x", "y"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("unknown plugin id is rejected with 422 and its i18n key", async () => {
|
|
136
|
+
const err = await stack.http.writeErr(
|
|
137
|
+
ConfigHandlers.set,
|
|
138
|
+
{ key: SELECTOR_KEY, value: "nope" },
|
|
139
|
+
tenantAdmin(701),
|
|
140
|
+
);
|
|
141
|
+
expect(err).toMatchObject({
|
|
142
|
+
httpStatus: 422,
|
|
143
|
+
code: "unprocessable",
|
|
144
|
+
i18nKey: "config.errors.unknownExtensionPlugin",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("mounted plugin id and the empty string are accepted", async () => {
|
|
149
|
+
const admin = tenantAdmin(702);
|
|
150
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: SELECTOR_KEY, value: "x" }, admin);
|
|
151
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: SELECTOR_KEY, value: "" }, admin);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("config:query:config-value:selected-extensions", () => {
|
|
156
|
+
test("returns the caller's own tenant selection, never another tenant's", async () => {
|
|
157
|
+
const adminA = tenantAdmin(711);
|
|
158
|
+
const adminB = tenantAdmin(712);
|
|
159
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: SELECTOR_KEY, value: "x" }, adminA);
|
|
160
|
+
|
|
161
|
+
const seenByA = await stack.http.queryOk<Record<string, string>>(
|
|
162
|
+
ConfigQueries.selectedExtensions,
|
|
163
|
+
{},
|
|
164
|
+
adminA,
|
|
165
|
+
);
|
|
166
|
+
const seenByB = await stack.http.queryOk<Record<string, string>>(
|
|
167
|
+
ConfigQueries.selectedExtensions,
|
|
168
|
+
{},
|
|
169
|
+
adminB,
|
|
170
|
+
);
|
|
171
|
+
expect(seenByA).toEqual({ probeTransport: "x" });
|
|
172
|
+
expect(seenByB).toEqual({});
|
|
173
|
+
|
|
174
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: SELECTOR_KEY, value: "y" }, adminB);
|
|
175
|
+
const afterB = await stack.http.queryOk<Record<string, string>>(
|
|
176
|
+
ConfigQueries.selectedExtensions,
|
|
177
|
+
{},
|
|
178
|
+
adminB,
|
|
179
|
+
);
|
|
180
|
+
expect(afterB).toEqual({ probeTransport: "y" });
|
|
181
|
+
expect(
|
|
182
|
+
await stack.http.queryOk<Record<string, string>>(
|
|
183
|
+
ConfigQueries.selectedExtensions,
|
|
184
|
+
{},
|
|
185
|
+
adminA,
|
|
186
|
+
),
|
|
187
|
+
).toEqual({
|
|
188
|
+
probeTransport: "x",
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("omits selectors the caller may not read", async () => {
|
|
193
|
+
const admin = tenantAdmin(713);
|
|
194
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: SELECTOR_KEY, value: "x" }, admin);
|
|
195
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: OPEN_SELECTOR_KEY, value: "open" }, admin);
|
|
196
|
+
|
|
197
|
+
const seenByAdmin = await stack.http.queryOk<Record<string, string>>(
|
|
198
|
+
ConfigQueries.selectedExtensions,
|
|
199
|
+
{},
|
|
200
|
+
admin,
|
|
201
|
+
);
|
|
202
|
+
expect(seenByAdmin).toEqual({ probeTransport: "x", probeOpenTransport: "open" });
|
|
203
|
+
|
|
204
|
+
const seenByUser = await stack.http.queryOk<Record<string, string>>(
|
|
205
|
+
ConfigQueries.selectedExtensions,
|
|
206
|
+
{},
|
|
207
|
+
plainUser(713),
|
|
208
|
+
);
|
|
209
|
+
expect(seenByUser).toEqual({ probeOpenTransport: "open" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("hides an inheritedToTenant:false platform selection from tenant-side callers", async () => {
|
|
213
|
+
const systemAdmin = createTestUser({
|
|
214
|
+
id: 715,
|
|
215
|
+
tenantId: testTenantId(715),
|
|
216
|
+
roles: ["SystemAdmin"],
|
|
217
|
+
});
|
|
218
|
+
await stack.http.writeOk(
|
|
219
|
+
ConfigHandlers.set,
|
|
220
|
+
{ key: "probe-platform-transport:config:provider", value: "platform-smtp", scope: "system" },
|
|
221
|
+
systemAdmin,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const seenByTenantAdmin = await stack.http.queryOk<Record<string, string>>(
|
|
225
|
+
ConfigQueries.selectedExtensions,
|
|
226
|
+
{},
|
|
227
|
+
tenantAdmin(715),
|
|
228
|
+
);
|
|
229
|
+
expect(seenByTenantAdmin).toEqual({});
|
|
230
|
+
|
|
231
|
+
const seenBySystemAdmin = await stack.http.queryOk<Record<string, string>>(
|
|
232
|
+
ConfigQueries.selectedExtensions,
|
|
233
|
+
{},
|
|
234
|
+
systemAdmin,
|
|
235
|
+
);
|
|
236
|
+
expect(seenBySystemAdmin["probePlatformTransport"]).toBe("platform-smtp");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("rejects a payload with extra fields such as tenantId", async () => {
|
|
240
|
+
const err = await stack.http.queryErr(
|
|
241
|
+
ConfigQueries.selectedExtensions,
|
|
242
|
+
{ tenantId: testTenantId(999) },
|
|
243
|
+
tenantAdmin(714),
|
|
244
|
+
);
|
|
245
|
+
expect(err.httpStatus).toBe(400);
|
|
246
|
+
});
|
|
247
|
+
});
|
package/src/config/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.326.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Extension-selector settings render as one tenant screen",
|
|
6
|
+
"detail": "BREAKING: an extension-selector key now renders as a select of the mounted plugin ids, and\n`config:write:set` rejects every value except a mounted plugin id or \"\" with 422\n`config.errors.unknownExtensionPlugin`.\n\nThe owner's tenant screen `<owner>-tenant` is now a dashboard; its former configEdit moved\nto `<owner>-tenant-selection`. Plugin features under a masked selector lose their tenant nav\nentry: their `<plugin>-tenant` screen stays as a nav-less embedded panel (a direct deep link\nstill resolves), and their secrets move from the global `secrets` screen into\n`<plugin>-tenant-secrets` panels. A plugin feature registered under more than one selector\nregistration keeps the previous behaviour.\n\nGenerated hub screens need the new key `config.settings.extensionSelectorHint` (shipped in\nthe config and secrets bundles, en/de/es).",
|
|
7
|
+
"migration": "Links, tests or screenshots that open `<plugin>-tenant` through the nav or expect a configEdit\nat `<owner>-tenant` should open `<owner>-tenant` (now the dashboard) and look for the plugin\npanels there. Tenant rows or scripts that write a selector value naming no mounted plugin must\nwrite a mounted plugin id or \"\" instead. No new translation keys are required from apps."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.271.0",
|
|
4
11
|
"type": "improvement",
|
package/src/config/constants.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const ConfigQueries = {
|
|
|
15
15
|
values: "config:query:values",
|
|
16
16
|
schema: "config:query:schema",
|
|
17
17
|
readiness: "config:query:readiness",
|
|
18
|
+
selectedExtensions: "config:query:config-value:selected-extensions",
|
|
18
19
|
} as const;
|
|
19
20
|
|
|
20
21
|
// Error codes
|
|
@@ -25,4 +26,5 @@ export const ConfigErrors = {
|
|
|
25
26
|
systemScopeWriteDenied: "system_scope_write_denied",
|
|
26
27
|
typeError: "type_error",
|
|
27
28
|
invalidOption: "invalid_option",
|
|
29
|
+
unknownExtensionPlugin: "unknown_extension_plugin",
|
|
28
30
|
} as const;
|
package/src/config/feature.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { readinessQuery } from "./handlers/readiness.query";
|
|
|
19
19
|
import { reencryptJob } from "./handlers/reencrypt.job";
|
|
20
20
|
import { resetWrite } from "./handlers/reset.write";
|
|
21
21
|
import { schemaQuery } from "./handlers/schema.query";
|
|
22
|
+
import { selectedExtensionsQuery } from "./handlers/selected-extensions.query";
|
|
22
23
|
import { setWrite } from "./handlers/set.write";
|
|
23
24
|
import { valuesQuery } from "./handlers/values.query";
|
|
24
25
|
import { CONFIG_FEATURE_I18N } from "./i18n";
|
|
@@ -56,6 +57,7 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
56
57
|
values: r.queryHandler(valuesQuery),
|
|
57
58
|
schema: r.queryHandler(schemaQuery),
|
|
58
59
|
readiness: r.queryHandler(readinessQuery),
|
|
60
|
+
selectedExtensions: r.queryHandler(selectedExtensionsQuery),
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
// KEK-rotation for encrypted values. Manual trigger — ops runs it once
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigKeyDefinition,
|
|
3
|
+
defineQueryHandler,
|
|
4
|
+
isEncryptedAtRest,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { requireConfigResolver, requireSystemDb } from "../feature";
|
|
8
|
+
import { redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
|
|
9
|
+
import { hasConfigAccess } from "../write-helpers";
|
|
10
|
+
|
|
11
|
+
// Flat `{ [extensionName]: selectedPluginId }` so a settings dashboard can show
|
|
12
|
+
// panels per selected provider (visibleWhen field = extension name).
|
|
13
|
+
export const selectedExtensionsQuery = defineQueryHandler({
|
|
14
|
+
name: "config-value:selected-extensions",
|
|
15
|
+
description:
|
|
16
|
+
"Returns, per extension point with a config selector, the plugin id the caller's tenant has selected; use it to show only the settings of the active provider.",
|
|
17
|
+
schema: z.object({}).strict(),
|
|
18
|
+
// UI plumbing for panel visibility; config:query:values already gives the agent these values.
|
|
19
|
+
agent: { expose: false },
|
|
20
|
+
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
21
|
+
access: {
|
|
22
|
+
openToAll: {
|
|
23
|
+
reason:
|
|
24
|
+
"any signed-in user may call selected-extensions; the handler only returns " +
|
|
25
|
+
"selectors whose config key the caller's roles may read, resolved for the caller's own tenant",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
rateLimit: {
|
|
29
|
+
disabled: true,
|
|
30
|
+
reason:
|
|
31
|
+
"self-scoped read of the caller's own selector values, hit on every settings page load; " +
|
|
32
|
+
"per-tenant bucket would throttle the whole tenant, L1 IP limit still applies",
|
|
33
|
+
},
|
|
34
|
+
handler: async (query, ctx) => {
|
|
35
|
+
const db = requireSystemDb(
|
|
36
|
+
ctx,
|
|
37
|
+
"config:query:config-value:selected-extensions",
|
|
38
|
+
query.user.tenantId,
|
|
39
|
+
);
|
|
40
|
+
const resolver = requireConfigResolver(ctx, "config:query:config-value:selected-extensions");
|
|
41
|
+
const readableSelectors = new Map<string, ConfigKeyDefinition>();
|
|
42
|
+
const extensionBySelectorKey = new Map<string, string>();
|
|
43
|
+
for (const [extensionName, selectorKey] of ctx.registry.getAllExtensionSelectors()) {
|
|
44
|
+
const keyDef = ctx.registry.getConfigKey(selectorKey);
|
|
45
|
+
if (!keyDef || !hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
|
|
46
|
+
if (isEncryptedAtRest(keyDef)) continue;
|
|
47
|
+
readableSelectors.set(selectorKey, keyDef);
|
|
48
|
+
extensionBySelectorKey.set(selectorKey, extensionName);
|
|
49
|
+
}
|
|
50
|
+
const cascades = await resolver.getCascadeBatch(
|
|
51
|
+
[...readableSelectors.keys()],
|
|
52
|
+
readableSelectors,
|
|
53
|
+
query.user.tenantId,
|
|
54
|
+
query.user.id,
|
|
55
|
+
db,
|
|
56
|
+
ctx.secrets,
|
|
57
|
+
);
|
|
58
|
+
const selected: Record<string, string> = {};
|
|
59
|
+
for (const [selectorKey, keyDef] of readableSelectors) {
|
|
60
|
+
const rawCascade = cascades.get(selectorKey);
|
|
61
|
+
if (!rawCascade) continue;
|
|
62
|
+
const cascade = shouldRedactInherited(keyDef, query.user.roles)
|
|
63
|
+
? redactInheritedCascade(rawCascade)
|
|
64
|
+
: rawCascade;
|
|
65
|
+
const pluginId = typeof cascade.value === "string" ? cascade.value.trim() : "";
|
|
66
|
+
const extensionName = extensionBySelectorKey.get(selectorKey);
|
|
67
|
+
if (pluginId !== "" && extensionName !== undefined) selected[extensionName] = pluginId;
|
|
68
|
+
}
|
|
69
|
+
return selected;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
prepareConfigWrite,
|
|
24
24
|
resolvePiiSubject,
|
|
25
25
|
validateBounds,
|
|
26
|
+
validateExtensionPlugin,
|
|
26
27
|
validatePattern,
|
|
27
28
|
validateScope,
|
|
28
29
|
validateType,
|
|
@@ -82,6 +83,13 @@ export const setWrite = defineWriteHandler({
|
|
|
82
83
|
const typeError = validateType(event.payload.value, keyDef);
|
|
83
84
|
if (typeError) return writeFailure(typeError);
|
|
84
85
|
|
|
86
|
+
const pluginError = validateExtensionPlugin(
|
|
87
|
+
ctx.registry,
|
|
88
|
+
event.payload.key,
|
|
89
|
+
event.payload.value,
|
|
90
|
+
);
|
|
91
|
+
if (pluginError) return writeFailure(pluginError);
|
|
92
|
+
|
|
85
93
|
// Bounds enforcement: hard-reject (not silent-clamp). A caller that
|
|
86
94
|
// sends 9999 for a bounds.max=1000 key should see a 422 and fix their
|
|
87
95
|
// input — silent clamping would make `get` return a different value
|
package/src/config/web/i18n.ts
CHANGED
|
@@ -25,6 +25,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
25
25
|
"config.errors.systemOnly": "This value can only be set by the system.",
|
|
26
26
|
"config.errors.invalidScope": "This scope is not allowed for this key.",
|
|
27
27
|
"config.errors.unknownKey": "Unknown configuration key.",
|
|
28
|
+
"config.errors.unknownExtensionPlugin": "This provider is not available.",
|
|
29
|
+
"config.settings.extensionSelectorHint":
|
|
30
|
+
"Save the provider selection to show its settings below.",
|
|
28
31
|
// Required by every generated screen (screenTitleKey, required-surface-keys.ts) —
|
|
29
32
|
// the secrets screen has a fixed id ("secrets"), so the framework ships its
|
|
30
33
|
// title translation directly instead of asking every app to declare it.
|
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
type ConfigKeyDefinition,
|
|
10
10
|
type ConfigScope,
|
|
11
11
|
ConfigScopes,
|
|
12
|
+
extensionSelectorTargets,
|
|
12
13
|
type Registry,
|
|
13
14
|
type SessionUser,
|
|
14
15
|
SYSTEM_ROLE,
|
|
15
16
|
SYSTEM_TENANT_ID,
|
|
17
|
+
selectablePluginIds,
|
|
16
18
|
type TenantId,
|
|
17
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
20
|
import {
|
|
@@ -220,6 +222,23 @@ export function resolvePiiSubject(
|
|
|
220
222
|
return { kind: "tenant", tenantId };
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
// An extension-selector key may only name a mounted plugin (or "" to clear) —
|
|
226
|
+
// otherwise the settings screen would show no panel for a typo'd value.
|
|
227
|
+
export function validateExtensionPlugin(
|
|
228
|
+
registry: Registry,
|
|
229
|
+
key: string,
|
|
230
|
+
value: string | number | boolean,
|
|
231
|
+
): KumikoError | null {
|
|
232
|
+
const extensionName = extensionSelectorTargets(registry).get(key);
|
|
233
|
+
if (extensionName === undefined || value === "") return null;
|
|
234
|
+
const allowed = selectablePluginIds(registry, extensionName);
|
|
235
|
+
if (typeof value === "string" && allowed.includes(value)) return null;
|
|
236
|
+
return new UnprocessableError(ConfigErrors.unknownExtensionPlugin, {
|
|
237
|
+
i18nKey: "config.errors.unknownExtensionPlugin",
|
|
238
|
+
details: { key, value, allowed },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
223
242
|
export function validateType(
|
|
224
243
|
value: string | number | boolean,
|
|
225
244
|
keyDef: ConfigKeyDefinition,
|
|
@@ -97,6 +97,7 @@ const testProbeFeature = defineFeature("file-test", (r) => {
|
|
|
97
97
|
let stack: TestStack;
|
|
98
98
|
let db: DbConnection;
|
|
99
99
|
let resolver: ConfigResolver;
|
|
100
|
+
let envOverrides: Map<string, string | number | boolean>;
|
|
100
101
|
let providerRef: MutableMasterKeyProvider;
|
|
101
102
|
|
|
102
103
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
@@ -125,13 +126,14 @@ beforeAll(async () => {
|
|
|
125
126
|
],
|
|
126
127
|
masterKeyProvider: providerRef,
|
|
127
128
|
extraContext: ({ db, registry }) => {
|
|
129
|
+
envOverrides = new Map(
|
|
130
|
+
buildEnvConfigOverrides(registry, { [FILE_STORAGE_PROVIDER_ENV]: ENV_SELECTED_PROVIDER }),
|
|
131
|
+
);
|
|
128
132
|
// No app-side resolver rebuild: the provider key declares
|
|
129
133
|
// env: FILE_STORAGE_PROVIDER, so the generic ENV bridge selects it.
|
|
130
134
|
resolver = createConfigResolver({
|
|
131
135
|
cipher: encryption,
|
|
132
|
-
appOverrides:
|
|
133
|
-
[FILE_STORAGE_PROVIDER_ENV]: ENV_SELECTED_PROVIDER,
|
|
134
|
-
}),
|
|
136
|
+
appOverrides: envOverrides,
|
|
135
137
|
});
|
|
136
138
|
return {
|
|
137
139
|
configResolver: resolver,
|
|
@@ -384,9 +386,19 @@ describe("scenario 5: ENV-bridged provider selection", () => {
|
|
|
384
386
|
).toBe(FILE_STORAGE_PROVIDER_BOOT_SENTINEL);
|
|
385
387
|
|
|
386
388
|
const admin = adminFor(508);
|
|
387
|
-
|
|
389
|
+
// The sentinel names no mounted plugin, so a tenant row can no longer hold it;
|
|
390
|
+
// it reaches the cascade the way production delivers it, as an app override.
|
|
391
|
+
const rejected = await stack.http.writeErr(
|
|
392
|
+
"config:write:set",
|
|
393
|
+
{ key: FILE_PROVIDER_CONFIG_KEY, value: FILE_STORAGE_PROVIDER_BOOT_SENTINEL },
|
|
394
|
+
admin,
|
|
395
|
+
);
|
|
396
|
+
expect(rejected.i18nKey).toBe("config.errors.unknownExtensionPlugin");
|
|
397
|
+
envOverrides.set(FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL);
|
|
388
398
|
|
|
389
|
-
const error = await stack.http
|
|
399
|
+
const error = await stack.http
|
|
400
|
+
.writeErr(TEST_HANDLER_QN, {}, admin)
|
|
401
|
+
.finally(() => envOverrides.set(FILE_PROVIDER_CONFIG_KEY, ENV_SELECTED_PROVIDER));
|
|
390
402
|
// The factory throw surfaces as internal_error; the original message sits
|
|
391
403
|
// in details.causeMessage.
|
|
392
404
|
expect(JSON.stringify(error)).toMatch(
|