@cosmicdrift/kumiko-bundled-features 0.28.0 → 0.31.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.
Files changed (32) hide show
  1. package/package.json +2 -1
  2. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +20 -0
  3. package/src/auth-email-password/web/auth-client.ts +4 -0
  4. package/src/auth-email-password/web/tenant-switcher.tsx +8 -2
  5. package/src/config/__tests__/config.integration.test.ts +113 -0
  6. package/src/config/constants.ts +1 -0
  7. package/src/config/feature.ts +2 -0
  8. package/src/config/handlers/readiness.query.ts +96 -0
  9. package/src/config/index.ts +5 -0
  10. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +12 -2
  11. package/src/file-foundation/feature.ts +3 -0
  12. package/src/file-provider-s3/feature.ts +8 -6
  13. package/src/foundation-shared/__tests__/config-helpers.test.ts +17 -0
  14. package/src/foundation-shared/config-helpers.ts +32 -6
  15. package/src/foundation-shared/index.ts +1 -1
  16. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +7 -1
  17. package/src/mail-foundation/feature.ts +3 -0
  18. package/src/mail-transport-smtp/feature.ts +8 -6
  19. package/src/readiness/__tests__/readiness.integration.test.ts +338 -0
  20. package/src/readiness/constants.ts +7 -0
  21. package/src/readiness/feature.ts +26 -0
  22. package/src/readiness/handlers/status.query.ts +48 -0
  23. package/src/readiness/index.ts +3 -0
  24. package/src/secrets/__tests__/require-secrets-context.test.ts +1 -0
  25. package/src/secrets/feature.ts +2 -0
  26. package/src/secrets/secrets-context.ts +8 -0
  27. package/src/tenant/__tests__/multi-tenant.integration.test.ts +68 -0
  28. package/src/tenant/__tests__/tenant.integration.test.ts +16 -0
  29. package/src/tenant/constants.ts +1 -0
  30. package/src/tenant/feature.ts +3 -1
  31. package/src/tenant/handlers/enable.write.ts +20 -0
  32. 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.28.0",
3
+ "version": "0.31.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>",
@@ -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
- const nameOf = (tenantId: string): string =>
61
- tenantName !== undefined ? tenantName(tenantId) : tenantId.slice(0, 8);
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", () => {
@@ -12,6 +12,7 @@ export const ConfigQueries = {
12
12
  cascade: "config:query:cascade",
13
13
  values: "config:query:values",
14
14
  schema: "config:query:schema",
15
+ readiness: "config:query:readiness",
15
16
  } as const;
16
17
 
17
18
  // Error codes
@@ -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
+ });
@@ -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 → factory throws with hint instead of cryptic SDK error", async () => {
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 → factory throws naming the secret", async () => {
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
- if (!branded) {
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 Error(
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 Error(
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 → factory throws naming the secret", async () => {
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
- if (!branded) {
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
  }