@cosmicdrift/kumiko-bundled-features 0.28.0 → 0.31.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 +2 -1
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +20 -0
- package/src/auth-email-password/web/auth-client.ts +4 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +8 -2
- package/src/config/__tests__/config.integration.test.ts +113 -0
- package/src/config/constants.ts +1 -0
- package/src/config/feature.ts +2 -0
- package/src/config/handlers/readiness.query.ts +96 -0
- package/src/config/index.ts +5 -0
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +12 -2
- package/src/file-foundation/feature.ts +3 -0
- package/src/file-provider-s3/feature.ts +8 -6
- package/src/foundation-shared/__tests__/config-helpers.test.ts +17 -0
- package/src/foundation-shared/config-helpers.ts +32 -6
- package/src/foundation-shared/index.ts +1 -1
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +7 -1
- package/src/mail-foundation/feature.ts +3 -0
- package/src/mail-transport-smtp/feature.ts +8 -6
- package/src/readiness/__tests__/readiness.integration.test.ts +338 -0
- package/src/readiness/constants.ts +7 -0
- package/src/readiness/feature.ts +26 -0
- package/src/readiness/handlers/status.query.ts +48 -0
- package/src/readiness/index.ts +3 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +1 -0
- package/src/secrets/feature.ts +2 -0
- package/src/secrets/secrets-context.ts +8 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +68 -0
- package/src/tenant/__tests__/tenant.integration.test.ts +16 -0
- package/src/tenant/constants.ts +1 -0
- package/src/tenant/feature.ts +3 -1
- package/src/tenant/handlers/enable.write.ts +20 -0
- package/src/tenant/handlers/memberships.query.ts +28 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.1",
|
|
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>",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./compliance-profiles": "./src/compliance-profiles/index.ts",
|
|
23
23
|
"./config": "./src/config/index.ts",
|
|
24
24
|
"./data-retention": "./src/data-retention/index.ts",
|
|
25
|
+
"./readiness": "./src/readiness/index.ts",
|
|
25
26
|
"./jobs": "./src/jobs/index.ts",
|
|
26
27
|
"./tier-engine": "./src/tier-engine/index.ts",
|
|
27
28
|
"./cap-counter": "./src/cap-counter/index.ts",
|
|
@@ -31,6 +31,26 @@ describe("TenantSwitcher", () => {
|
|
|
31
31
|
// tenantName-Resolver liefert "Tenant tenant-a" als Trigger-Label
|
|
32
32
|
expect(screen.getByText("Tenant tenant-a")).toBeTruthy();
|
|
33
33
|
});
|
|
34
|
+
test("renders server-provided membership name without tenantName prop", () => {
|
|
35
|
+
// /auth/tenants liefert name/key mit — der Switcher braucht keinen
|
|
36
|
+
// App-Resolver mehr. Fallback-Kette: name > key > UUID-Präfix.
|
|
37
|
+
const session = makeSessionApi({
|
|
38
|
+
activeTenantId: "00000000-0000-4000-8000-000000000001",
|
|
39
|
+
tenants: [
|
|
40
|
+
{
|
|
41
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
42
|
+
roles: ["Admin"],
|
|
43
|
+
name: "Status",
|
|
44
|
+
key: "status",
|
|
45
|
+
},
|
|
46
|
+
{ tenantId: "00000000-0000-4000-8000-000000000002", roles: ["Admin"], key: "demo" },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
renderWithProviders(<TenantSwitcher />, { session });
|
|
50
|
+
// Ohne den Fix wären beide Labels das identische UUID-Präfix "00000000".
|
|
51
|
+
expect(screen.getByText("Status")).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
34
54
|
test("opens dropdown showing all memberships with roles", async () => {
|
|
35
55
|
const user = userEvent.setup();
|
|
36
56
|
const session = makeSessionApi({
|
|
@@ -14,6 +14,10 @@ import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-
|
|
|
14
14
|
export type TenantSummary = {
|
|
15
15
|
readonly tenantId: string;
|
|
16
16
|
readonly roles: readonly string[];
|
|
17
|
+
/** Display-Name aus /auth/tenants — fehlt nur bei App-eigenen
|
|
18
|
+
* membership-Queries ohne tenantName-Anreicherung. */
|
|
19
|
+
readonly name?: string;
|
|
20
|
+
readonly key?: string;
|
|
17
21
|
};
|
|
18
22
|
|
|
19
23
|
export type LoginRequest = {
|
|
@@ -57,8 +57,14 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
|
|
|
57
57
|
[activeTenantId, switchTenant],
|
|
58
58
|
);
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Label-Priorität: App-eigener Resolver (Prop) > Server-geliefertes
|
|
61
|
+
// name/key aus /auth/tenants > UUID-Präfix. Letzteres ist nur noch der
|
|
62
|
+
// Notnagel — bei Seed-Tenants (00000000-…) ist es ununterscheidbar.
|
|
63
|
+
const nameOf = (tenantId: string): string => {
|
|
64
|
+
if (tenantName !== undefined) return tenantName(tenantId);
|
|
65
|
+
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
66
|
+
return membership?.name ?? membership?.key ?? tenantId.slice(0, 8);
|
|
67
|
+
};
|
|
62
68
|
|
|
63
69
|
// Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
|
|
64
70
|
// (Single-Tenant-Apps brauchen keinen Switcher).
|
|
@@ -196,6 +196,37 @@ const seedFeature = defineFeature("seeddemo", (r) => {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
// Readiness-Scenario: required keys — feature is unusable until the tenant
|
|
200
|
+
// sets real values (mirrors mail-transport-smtp / file-provider-s3).
|
|
201
|
+
const transportFeature = defineFeature("transport", (r) => {
|
|
202
|
+
r.requires("config");
|
|
203
|
+
return r.config({
|
|
204
|
+
keys: {
|
|
205
|
+
smtpHost: createTenantConfig("text", {
|
|
206
|
+
required: true,
|
|
207
|
+
default: "",
|
|
208
|
+
write: access.roles("Admin"),
|
|
209
|
+
read: access.admin,
|
|
210
|
+
}),
|
|
211
|
+
apiUrl: createTenantConfig("text", {
|
|
212
|
+
required: true,
|
|
213
|
+
default: "",
|
|
214
|
+
write: access.roles("Admin"),
|
|
215
|
+
}),
|
|
216
|
+
// Stays unset for the whole suite — read-access tests rely on it.
|
|
217
|
+
webhookUrl: createTenantConfig("text", {
|
|
218
|
+
required: true,
|
|
219
|
+
default: "",
|
|
220
|
+
write: access.roles("Admin"),
|
|
221
|
+
}),
|
|
222
|
+
timeout: createTenantConfig("number", {
|
|
223
|
+
required: true,
|
|
224
|
+
write: access.roles("Admin"),
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
199
230
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
200
231
|
|
|
201
232
|
beforeAll(async () => {
|
|
@@ -212,6 +243,7 @@ beforeAll(async () => {
|
|
|
212
243
|
integrationFeature,
|
|
213
244
|
probeFeature,
|
|
214
245
|
seedFeature,
|
|
246
|
+
transportFeature,
|
|
215
247
|
],
|
|
216
248
|
// Wire `ctx.config()` for real handlers: pass the resolver-bound factory
|
|
217
249
|
// so the dispatcher can mint a per-user accessor inside buildHandlerContext.
|
|
@@ -690,6 +722,87 @@ describe("config.schema query handler", () => {
|
|
|
690
722
|
});
|
|
691
723
|
});
|
|
692
724
|
|
|
725
|
+
// --- config.readiness query ---
|
|
726
|
+
|
|
727
|
+
describe("config.readiness query handler", () => {
|
|
728
|
+
type Missing = { missing: Array<{ key: string; scope: string; type: string }> };
|
|
729
|
+
|
|
730
|
+
test("lists required keys without a usable value — and only those", async () => {
|
|
731
|
+
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, tenantAdmin);
|
|
732
|
+
|
|
733
|
+
const keys = missing.map((m) => m.key);
|
|
734
|
+
expect(keys).toContain("transport:config:smtp-host");
|
|
735
|
+
expect(keys).toContain("transport:config:api-url");
|
|
736
|
+
expect(keys).toContain("transport:config:timeout");
|
|
737
|
+
// Non-required keys never show up, configured or not.
|
|
738
|
+
expect(keys).not.toContain("app:config:mail-server");
|
|
739
|
+
expect(keys).not.toContain("orders:config:max-order-count");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("whitespace-only text value still counts as missing (requireNonEmpty-Parität)", async () => {
|
|
743
|
+
await stack.http.writeOk(
|
|
744
|
+
ConfigHandlers.set,
|
|
745
|
+
{ key: "transport:config:api-url", value: " " },
|
|
746
|
+
tenantAdmin,
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, tenantAdmin);
|
|
750
|
+
expect(missing.map((m) => m.key)).toContain("transport:config:api-url");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("a real value clears the key from the missing list", async () => {
|
|
754
|
+
await stack.http.writeOk(
|
|
755
|
+
ConfigHandlers.set,
|
|
756
|
+
{ key: "transport:config:api-url", value: "https://api.example.com" },
|
|
757
|
+
tenantAdmin,
|
|
758
|
+
);
|
|
759
|
+
await stack.http.writeOk(
|
|
760
|
+
ConfigHandlers.set,
|
|
761
|
+
{ key: "transport:config:timeout", value: 30 },
|
|
762
|
+
tenantAdmin,
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, tenantAdmin);
|
|
766
|
+
const keys = missing.map((m) => m.key);
|
|
767
|
+
expect(keys).not.toContain("transport:config:api-url");
|
|
768
|
+
expect(keys).not.toContain("transport:config:timeout");
|
|
769
|
+
// Untouched required keys stay missing.
|
|
770
|
+
expect(keys).toContain("transport:config:smtp-host");
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("filters by read access", async () => {
|
|
774
|
+
const { missing } = await stack.http.queryOk<Missing>(ConfigQueries.readiness, {}, normalUser);
|
|
775
|
+
|
|
776
|
+
const keys = missing.map((m) => m.key);
|
|
777
|
+
// read: all (tenant-scope default) → visible to a plain User
|
|
778
|
+
expect(keys).toContain("transport:config:webhook-url");
|
|
779
|
+
// read: admin-only → hidden from a plain User even though unset
|
|
780
|
+
expect(keys).not.toContain("transport:config:smtp-host");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("readiness is per-tenant: another tenant still sees the keys as missing", async () => {
|
|
784
|
+
const { missing } = await stack.http.queryOk<Missing>(
|
|
785
|
+
ConfigQueries.readiness,
|
|
786
|
+
{},
|
|
787
|
+
otherTenantAdmin,
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
const keys = missing.map((m) => m.key);
|
|
791
|
+
expect(keys).toContain("transport:config:api-url");
|
|
792
|
+
expect(keys).toContain("transport:config:timeout");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("schema query exposes the required flag for UI rendering", async () => {
|
|
796
|
+
const schema = await stack.http.queryOk<Record<string, { required?: boolean }>>(
|
|
797
|
+
ConfigQueries.schema,
|
|
798
|
+
{},
|
|
799
|
+
tenantAdmin,
|
|
800
|
+
);
|
|
801
|
+
expect(schema["transport:config:smtp-host"]?.required).toBe(true);
|
|
802
|
+
expect(schema["app:config:mail-server"]?.required).toBeUndefined();
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
693
806
|
// --- Type validation ---
|
|
694
807
|
|
|
695
808
|
describe("type validation", () => {
|
package/src/config/constants.ts
CHANGED
package/src/config/feature.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
15
|
import { cascadeQuery } from "./handlers/cascade.query";
|
|
16
|
+
import { readinessQuery } from "./handlers/readiness.query";
|
|
16
17
|
import { resetWrite } from "./handlers/reset.write";
|
|
17
18
|
import { schemaQuery } from "./handlers/schema.query";
|
|
18
19
|
import { setWrite } from "./handlers/set.write";
|
|
@@ -45,6 +46,7 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
45
46
|
cascade: r.queryHandler(cascadeQuery),
|
|
46
47
|
values: r.queryHandler(valuesQuery),
|
|
47
48
|
schema: r.queryHandler(schemaQuery),
|
|
49
|
+
readiness: r.queryHandler(readinessQuery),
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
return { handlers, queries };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConfigKeyType,
|
|
3
|
+
type ConfigScope,
|
|
4
|
+
defineQueryHandler,
|
|
5
|
+
type HandlerContext,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
toKebab,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { requireConfigResolver } from "../feature";
|
|
11
|
+
import { hasConfigAccess } from "../write-helpers";
|
|
12
|
+
|
|
13
|
+
export type ReadinessMissingKey = {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly scope: ConfigScope;
|
|
16
|
+
readonly type: ConfigKeyType;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Mirrors requireNonEmpty in foundation-shared/config-helpers.ts: required
|
|
20
|
+
// text keys ship default "" and count as unset while empty/whitespace.
|
|
21
|
+
function isUnset(value: string | number | boolean | undefined, type: ConfigKeyType): boolean {
|
|
22
|
+
if (value === undefined) return true;
|
|
23
|
+
return type === "text" && typeof value === "string" && value.trim().length === 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Whether a required key/secret counts for THIS tenant right now. Keys of
|
|
27
|
+
// provider-features under a selector-declared extension point count only
|
|
28
|
+
// while their provider is the selected one — an SMTP password is no gap
|
|
29
|
+
// for a tenant running the inmemory transport.
|
|
30
|
+
export type RequiredKeyGate = (qualifiedName: string) => boolean;
|
|
31
|
+
|
|
32
|
+
// Builds the per-tenant gate from r.extensionSelector declarations: resolve
|
|
33
|
+
// each selector through the config cascade, mark every provider-feature
|
|
34
|
+
// registered under that point as counted/uncounted. Features without a
|
|
35
|
+
// selector-gated registration always count.
|
|
36
|
+
export async function buildProviderSelectionGate(
|
|
37
|
+
ctx: HandlerContext,
|
|
38
|
+
callerQn: string,
|
|
39
|
+
user: SessionUser,
|
|
40
|
+
): Promise<RequiredKeyGate> {
|
|
41
|
+
const resolver = requireConfigResolver(ctx, callerQn);
|
|
42
|
+
const countsByFeature = new Map<string, boolean>();
|
|
43
|
+
for (const [extensionName, selectorKey] of ctx.registry.getAllExtensionSelectors()) {
|
|
44
|
+
const keyDef = ctx.registry.getConfigKey(selectorKey);
|
|
45
|
+
if (!keyDef) continue; // registry-build already failed this; defensive
|
|
46
|
+
const value = await resolver.get(selectorKey, keyDef, user.tenantId, user.id, ctx.db);
|
|
47
|
+
const selected = typeof value === "string" ? value.trim() : "";
|
|
48
|
+
for (const usage of ctx.registry.getExtensionUsages(extensionName)) {
|
|
49
|
+
if (usage.featureName === undefined) continue;
|
|
50
|
+
const owner = toKebab(usage.featureName);
|
|
51
|
+
const isSelected = usage.entityName === selected;
|
|
52
|
+
countsByFeature.set(owner, (countsByFeature.get(owner) ?? false) || isSelected);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return (qualifiedName) => countsByFeature.get(qualifiedName.split(":")[0] ?? "") ?? true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Core of config:query:readiness, exported through the config barrel so the
|
|
59
|
+
// readiness rollup-feature reuses the exact same cascade + access filter.
|
|
60
|
+
// Resolved through the same cascade as ctx.config() so readiness can never
|
|
61
|
+
// drift from what the owning feature's build-fn will see. Note:
|
|
62
|
+
// required+encrypted assumes encryption is wired — resolver.get decrypts
|
|
63
|
+
// stored values, same prerequisite as any ctx.config() read of that key.
|
|
64
|
+
export async function collectMissingRequiredConfig(
|
|
65
|
+
ctx: HandlerContext,
|
|
66
|
+
callerQn: string,
|
|
67
|
+
user: SessionUser,
|
|
68
|
+
gate?: RequiredKeyGate,
|
|
69
|
+
): Promise<ReadinessMissingKey[]> {
|
|
70
|
+
const resolver = requireConfigResolver(ctx, callerQn);
|
|
71
|
+
const effectiveGate = gate ?? (await buildProviderSelectionGate(ctx, callerQn, user));
|
|
72
|
+
const missing: ReadinessMissingKey[] = [];
|
|
73
|
+
for (const [qualifiedKey, keyDef] of ctx.registry.getAllConfigKeys()) {
|
|
74
|
+
if (keyDef.required !== true) continue;
|
|
75
|
+
if (!effectiveGate(qualifiedKey)) continue;
|
|
76
|
+
if (!hasConfigAccess(keyDef.access.read, user.roles)) continue;
|
|
77
|
+
const value = await resolver.get(qualifiedKey, keyDef, user.tenantId, user.id, ctx.db);
|
|
78
|
+
if (isUnset(value, keyDef.type)) {
|
|
79
|
+
missing.push({ key: qualifiedKey, scope: keyDef.scope, type: keyDef.type });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return missing;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// No boolean "ready" verdict on purpose — config readiness says nothing
|
|
86
|
+
// about secrets. readiness:query:status (readiness feature) rolls both up.
|
|
87
|
+
export const readinessQuery = defineQueryHandler({
|
|
88
|
+
name: "readiness",
|
|
89
|
+
schema: z.object({}),
|
|
90
|
+
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
91
|
+
access: { openToAll: true },
|
|
92
|
+
handler: async (query, ctx) => {
|
|
93
|
+
const missing = await collectMissingRequiredConfig(ctx, "config:query:readiness", query.user);
|
|
94
|
+
return { missing };
|
|
95
|
+
},
|
|
96
|
+
});
|
package/src/config/index.ts
CHANGED
|
@@ -15,6 +15,11 @@ export {
|
|
|
15
15
|
createConfigAccessorFactory,
|
|
16
16
|
createConfigFeature,
|
|
17
17
|
} from "./feature";
|
|
18
|
+
export type { ReadinessMissingKey, RequiredKeyGate } from "./handlers/readiness.query";
|
|
19
|
+
export {
|
|
20
|
+
buildProviderSelectionGate,
|
|
21
|
+
collectMissingRequiredConfig,
|
|
22
|
+
} from "./handlers/readiness.query";
|
|
18
23
|
export type { AppConfigOverrides, ConfigResolver } from "./resolver";
|
|
19
24
|
export { createConfigResolver, validateAppOverrides } from "./resolver";
|
|
20
25
|
export { configValuesTable } from "./table";
|
|
@@ -167,7 +167,7 @@ describe("scenario 1: happy path", () => {
|
|
|
167
167
|
// --- Scenario 2: validation errors ---
|
|
168
168
|
|
|
169
169
|
describe("scenario 2: validation errors", () => {
|
|
170
|
-
test("missing bucket →
|
|
170
|
+
test("missing bucket → 422 unconfigured naming the key, not a cryptic SDK error", async () => {
|
|
171
171
|
const admin = adminFor(502);
|
|
172
172
|
|
|
173
173
|
await selectS3Provider(admin);
|
|
@@ -183,9 +183,13 @@ describe("scenario 2: validation errors", () => {
|
|
|
183
183
|
|
|
184
184
|
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
185
185
|
expect(JSON.stringify(error)).toMatch(/'bucket' is empty/);
|
|
186
|
+
expect(error.httpStatus).toBe(422);
|
|
187
|
+
expect(error.code).toBe("unconfigured");
|
|
188
|
+
expect(error.i18nKey).toBe("errors.unconfigured");
|
|
189
|
+
expect(error.details).toMatchObject({ feature: "file-provider-s3", key: "bucket" });
|
|
186
190
|
});
|
|
187
191
|
|
|
188
|
-
test("missing secret-access-key →
|
|
192
|
+
test("missing secret-access-key → 422 unconfigured naming the secret", async () => {
|
|
189
193
|
const admin = adminFor(503);
|
|
190
194
|
|
|
191
195
|
await selectS3Provider(admin);
|
|
@@ -196,6 +200,12 @@ describe("scenario 2: validation errors", () => {
|
|
|
196
200
|
|
|
197
201
|
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
198
202
|
expect(JSON.stringify(error)).toMatch(/s3-secret-access-key/);
|
|
203
|
+
expect(error.httpStatus).toBe(422);
|
|
204
|
+
expect(error.code).toBe("unconfigured");
|
|
205
|
+
expect(error.details).toMatchObject({
|
|
206
|
+
feature: "file-provider-s3",
|
|
207
|
+
key: S3_SECRET_ACCESS_KEY.name,
|
|
208
|
+
});
|
|
199
209
|
});
|
|
200
210
|
});
|
|
201
211
|
|
|
@@ -111,6 +111,9 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
111
111
|
}),
|
|
112
112
|
},
|
|
113
113
|
});
|
|
114
|
+
// Readiness gating: provider-plugins' required keys/secrets count only
|
|
115
|
+
// while their plugin is the one this key selects.
|
|
116
|
+
r.extensionSelector("fileProvider", configKeys.provider);
|
|
114
117
|
|
|
115
118
|
return { configKeys };
|
|
116
119
|
});
|
|
@@ -26,6 +26,7 @@ import { createS3Provider } from "@cosmicdrift/kumiko-bundled-features/files-pro
|
|
|
26
26
|
import {
|
|
27
27
|
requireDefined,
|
|
28
28
|
requireNonEmpty,
|
|
29
|
+
requireSecretSet,
|
|
29
30
|
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
30
31
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
31
32
|
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -56,16 +57,21 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
56
57
|
return `${plaintext.slice(0, 4)}...${plaintext.slice(-4)}`;
|
|
57
58
|
},
|
|
58
59
|
scope: "tenant",
|
|
60
|
+
// required: true ↔ the missing-secret throw in readSecretAccessKey — keep in sync.
|
|
61
|
+
required: true,
|
|
59
62
|
});
|
|
60
63
|
|
|
64
|
+
// required: true ↔ the requireNonEmpty calls in buildS3Provider — keep in sync.
|
|
61
65
|
const configKeys = r.config({
|
|
62
66
|
keys: {
|
|
63
67
|
bucket: createTenantConfig("text", {
|
|
68
|
+
required: true,
|
|
64
69
|
default: "",
|
|
65
70
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
66
71
|
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
67
72
|
}),
|
|
68
73
|
region: createTenantConfig("text", {
|
|
74
|
+
required: true,
|
|
69
75
|
default: "",
|
|
70
76
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
71
77
|
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
@@ -80,6 +86,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
80
86
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
81
87
|
}),
|
|
82
88
|
accessKeyId: createTenantConfig("text", {
|
|
89
|
+
required: true,
|
|
83
90
|
default: "",
|
|
84
91
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
85
92
|
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
@@ -161,10 +168,5 @@ async function buildS3Provider(
|
|
|
161
168
|
async function readSecretAccessKey(ctx: FileProviderContext, tenantId: string): Promise<string> {
|
|
162
169
|
const secrets = requireSecretsContext(ctx, FEATURE_NAME);
|
|
163
170
|
const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
|
|
164
|
-
|
|
165
|
-
throw new Error(
|
|
166
|
-
`${FEATURE_NAME}: ${S3_SECRET_ACCESS_KEY.name} not set for tenant ${tenantId} — Tenant-Admin must set it via /api/write/secrets:write:set`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
return branded.reveal();
|
|
171
|
+
return requireSecretSet(branded, FEATURE_NAME, S3_SECRET_ACCESS_KEY.name).reveal();
|
|
170
172
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// unterschieden werden.
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { InternalError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
11
|
import { requireDefined, requireNonEmpty } from "../config-helpers";
|
|
11
12
|
|
|
12
13
|
describe("requireDefined", () => {
|
|
@@ -16,6 +17,10 @@ describe("requireDefined", () => {
|
|
|
16
17
|
);
|
|
17
18
|
});
|
|
18
19
|
|
|
20
|
+
test("undefined → wirft InternalError (500): Dev-Bug, kein Tenant-Gap", () => {
|
|
21
|
+
expect(() => requireDefined(undefined, "ai-foundation", "apiKey")).toThrow(InternalError);
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
test("defined Wert → unverändert zurück", () => {
|
|
20
25
|
expect(requireDefined("sk-123", "ai-foundation", "apiKey")).toBe("sk-123");
|
|
21
26
|
});
|
|
@@ -48,6 +53,18 @@ describe("requireNonEmpty", () => {
|
|
|
48
53
|
);
|
|
49
54
|
});
|
|
50
55
|
|
|
56
|
+
test("leerer String → wirft UnconfiguredError (422, code unconfigured, typed details)", () => {
|
|
57
|
+
try {
|
|
58
|
+
requireNonEmpty("", "file-foundation", "bucket");
|
|
59
|
+
throw new Error("expected requireNonEmpty to throw");
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!(err instanceof UnconfiguredError)) throw err;
|
|
62
|
+
expect(err.code).toBe("unconfigured");
|
|
63
|
+
expect(err.httpStatus).toBe(422);
|
|
64
|
+
expect(err.details).toMatchObject({ feature: "file-foundation", key: "bucket" });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
51
68
|
test("leerer String mit custom uiHint → Hint landet in der Message", () => {
|
|
52
69
|
expect(() =>
|
|
53
70
|
requireNonEmpty("", "ai-foundation", "model", "Choose a model in Settings → AI."),
|
|
@@ -19,11 +19,16 @@
|
|
|
19
19
|
// across foundations live here. Per-foundation transport/provider-
|
|
20
20
|
// factories stay in their own package.
|
|
21
21
|
|
|
22
|
+
import { InternalError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
25
|
* Narrow `value | undefined` → `value` with a clear message that names
|
|
24
26
|
* which config key resolved to nothing. Use for keys whose `undefined`
|
|
25
27
|
* means a registry misconfiguration (no value + no default).
|
|
26
28
|
*
|
|
29
|
+
* Throws InternalError (500): `undefined` here is a developer bug, not a
|
|
30
|
+
* tenant-configuration gap — contrast requireNonEmpty.
|
|
31
|
+
*
|
|
27
32
|
* Foundation-Pattern: this is the wrap-helper around `await ctx.config(
|
|
28
33
|
* featureFoundationFeature.exports.configKeys.someKey)` — the call-site
|
|
29
34
|
* stays a single line per key.
|
|
@@ -34,9 +39,9 @@
|
|
|
34
39
|
*/
|
|
35
40
|
export function requireDefined<T>(value: T | undefined, featureName: string, label: string): T {
|
|
36
41
|
if (value === undefined) {
|
|
37
|
-
throw new
|
|
38
|
-
`${featureName}: '${label}' config key resolved to undefined — registry misconfigured (no value + no default)`,
|
|
39
|
-
);
|
|
42
|
+
throw new InternalError({
|
|
43
|
+
message: `${featureName}: '${label}' config key resolved to undefined — registry misconfigured (no value + no default)`,
|
|
44
|
+
});
|
|
40
45
|
}
|
|
41
46
|
return value;
|
|
42
47
|
}
|
|
@@ -54,6 +59,9 @@ export function requireDefined<T>(value: T | undefined, featureName: string, lab
|
|
|
54
59
|
* Whitespace is trimmed: a whitespace-only value counts as empty, and the
|
|
55
60
|
* returned string has surrounding whitespace removed — so a stray " host "
|
|
56
61
|
* never reaches the SDK as-is.
|
|
62
|
+
*
|
|
63
|
+
* Throws UnconfiguredError (422, code "unconfigured") so clients can route
|
|
64
|
+
* the user to the settings screen instead of a generic 500.
|
|
57
65
|
*/
|
|
58
66
|
export function requireNonEmpty(
|
|
59
67
|
value: string | undefined,
|
|
@@ -63,9 +71,27 @@ export function requireNonEmpty(
|
|
|
63
71
|
): string {
|
|
64
72
|
const trimmed = requireDefined(value, featureName, label).trim();
|
|
65
73
|
if (trimmed.length === 0) {
|
|
66
|
-
throw new
|
|
67
|
-
`${featureName}: '${label}' is empty — tenant must configure it before use. ${uiHint}`,
|
|
68
|
-
);
|
|
74
|
+
throw new UnconfiguredError({ feature: featureName, key: label, hint: uiHint });
|
|
69
75
|
}
|
|
70
76
|
return trimmed;
|
|
71
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Narrow a `ctx.secrets.get()` result → set secret. The secrets-counterpart
|
|
81
|
+
* to requireNonEmpty: a missing secret is a tenant-configuration gap, not a
|
|
82
|
+
* crash — same 422 contract, so clients route to the settings screen.
|
|
83
|
+
*
|
|
84
|
+
* `key` is the secret's qualified name (`HANDLE.name`) so the error names
|
|
85
|
+
* exactly what `secrets:write:set` expects.
|
|
86
|
+
*/
|
|
87
|
+
export function requireSecretSet<T>(
|
|
88
|
+
value: T | undefined,
|
|
89
|
+
featureName: string,
|
|
90
|
+
key: string,
|
|
91
|
+
uiHint = "Set via secrets:write:set or the tenant-admin UI.",
|
|
92
|
+
): T {
|
|
93
|
+
if (value === undefined) {
|
|
94
|
+
throw new UnconfiguredError({ feature: featureName, key, hint: uiHint });
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Public API of foundation-shared — utilities consumed by the per-tenant
|
|
2
2
|
// Foundation packages (ai-foundation, mail-foundation, file-foundation).
|
|
3
3
|
|
|
4
|
-
export { requireDefined, requireNonEmpty } from "./config-helpers";
|
|
4
|
+
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers";
|
|
@@ -188,7 +188,7 @@ describe("scenario 2: validation errors", () => {
|
|
|
188
188
|
expect(JSON.stringify(error)).toMatch(/'host' is empty/);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
-
test("missing password secret →
|
|
191
|
+
test("missing password secret → 422 unconfigured naming the secret", async () => {
|
|
192
192
|
const admin = adminFor(403);
|
|
193
193
|
|
|
194
194
|
await selectSmtpProvider(admin);
|
|
@@ -201,6 +201,12 @@ describe("scenario 2: validation errors", () => {
|
|
|
201
201
|
|
|
202
202
|
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
203
203
|
expect(JSON.stringify(error)).toMatch(/smtp-password/);
|
|
204
|
+
expect(error.httpStatus).toBe(422);
|
|
205
|
+
expect(error.code).toBe("unconfigured");
|
|
206
|
+
expect(error.details).toMatchObject({
|
|
207
|
+
feature: "mail-transport-smtp",
|
|
208
|
+
key: SMTP_PASSWORD.name,
|
|
209
|
+
});
|
|
204
210
|
});
|
|
205
211
|
});
|
|
206
212
|
|
|
@@ -96,6 +96,9 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
96
96
|
}),
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
|
+
// Readiness gating: transport-plugins' required keys/secrets count only
|
|
100
|
+
// while their plugin is the one this key selects.
|
|
101
|
+
r.extensionSelector("mailTransport", configKeys.provider);
|
|
99
102
|
|
|
100
103
|
return {
|
|
101
104
|
/** Config-key-handle for the provider-selector. */
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
requireDefined,
|
|
34
34
|
requireNonEmpty,
|
|
35
|
+
requireSecretSet,
|
|
35
36
|
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
36
37
|
import type { MailTransportPlugin } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
37
38
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
@@ -68,11 +69,15 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
68
69
|
return `${plaintext.slice(0, 3)}...${plaintext.slice(-2)}`;
|
|
69
70
|
},
|
|
70
71
|
scope: "tenant",
|
|
72
|
+
// required: true ↔ the missing-secret throw in readPassword — keep in sync.
|
|
73
|
+
required: true,
|
|
71
74
|
});
|
|
72
75
|
|
|
76
|
+
// required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
|
|
73
77
|
const configKeys = r.config({
|
|
74
78
|
keys: {
|
|
75
79
|
host: createTenantConfig("text", {
|
|
80
|
+
required: true,
|
|
76
81
|
default: "",
|
|
77
82
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
78
83
|
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
@@ -87,11 +92,13 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
87
92
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
88
93
|
}),
|
|
89
94
|
from: createTenantConfig("text", {
|
|
95
|
+
required: true,
|
|
90
96
|
default: "",
|
|
91
97
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
92
98
|
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
93
99
|
}),
|
|
94
100
|
authUser: createTenantConfig("text", {
|
|
101
|
+
required: true,
|
|
95
102
|
default: "",
|
|
96
103
|
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
97
104
|
read: access.roles("TenantAdmin", "SystemAdmin"),
|
|
@@ -176,10 +183,5 @@ async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promis
|
|
|
176
183
|
async function readPassword(ctx: HandlerContext, tenantId: string): Promise<string> {
|
|
177
184
|
const secrets = requireSecretsContext(ctx, FEATURE_NAME);
|
|
178
185
|
const branded = await secrets.get(tenantId, SMTP_PASSWORD);
|
|
179
|
-
|
|
180
|
-
throw new Error(
|
|
181
|
-
`${FEATURE_NAME}: ${SMTP_PASSWORD.name} not set for tenant ${tenantId} — Tenant-Admin must set it via /api/write/secrets:write:set`,
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
return branded.reveal();
|
|
186
|
+
return requireSecretSet(branded, FEATURE_NAME, SMTP_PASSWORD.name).reveal();
|
|
185
187
|
}
|