@cosmicdrift/kumiko-bundled-features 0.27.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 (62) hide show
  1. package/package.json +2 -1
  2. package/src/audit/feature.ts +3 -0
  3. package/src/auth-email-password/feature.ts +3 -0
  4. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +20 -0
  5. package/src/auth-email-password/web/auth-client.ts +4 -0
  6. package/src/auth-email-password/web/tenant-switcher.tsx +8 -2
  7. package/src/billing-foundation/feature.ts +3 -0
  8. package/src/billing-foundation/webhook-handler.ts +13 -4
  9. package/src/cap-counter/feature.ts +3 -0
  10. package/src/channel-email/feature.ts +3 -0
  11. package/src/channel-in-app/feature.ts +3 -0
  12. package/src/channel-push/feature.ts +3 -0
  13. package/src/compliance-profiles/feature.ts +3 -0
  14. package/src/config/__tests__/config.integration.test.ts +113 -0
  15. package/src/config/constants.ts +1 -0
  16. package/src/config/feature.ts +5 -0
  17. package/src/config/handlers/readiness.query.ts +96 -0
  18. package/src/config/index.ts +5 -0
  19. package/src/custom-fields/feature.ts +3 -0
  20. package/src/data-retention/feature.ts +3 -0
  21. package/src/delivery/feature.ts +3 -0
  22. package/src/feature-toggles/feature.ts +3 -0
  23. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +12 -2
  24. package/src/file-foundation/feature.ts +6 -0
  25. package/src/file-provider-inmemory/feature.ts +3 -0
  26. package/src/file-provider-s3/feature.ts +11 -6
  27. package/src/foundation-shared/__tests__/config-helpers.test.ts +17 -0
  28. package/src/foundation-shared/config-helpers.ts +32 -6
  29. package/src/foundation-shared/index.ts +1 -1
  30. package/src/jobs/feature.ts +3 -0
  31. package/src/legal-pages/feature.ts +3 -0
  32. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +7 -1
  33. package/src/mail-foundation/feature.ts +6 -0
  34. package/src/mail-transport-inmemory/feature.ts +3 -0
  35. package/src/mail-transport-smtp/feature.ts +11 -6
  36. package/src/rate-limiting/feature.ts +3 -0
  37. package/src/readiness/__tests__/readiness.integration.test.ts +338 -0
  38. package/src/readiness/constants.ts +7 -0
  39. package/src/readiness/feature.ts +26 -0
  40. package/src/readiness/handlers/status.query.ts +48 -0
  41. package/src/readiness/index.ts +3 -0
  42. package/src/renderer-foundation/feature.ts +3 -0
  43. package/src/renderer-simple/feature.ts +3 -0
  44. package/src/secrets/__tests__/require-secrets-context.test.ts +1 -0
  45. package/src/secrets/feature.ts +5 -0
  46. package/src/secrets/secrets-context.ts +8 -0
  47. package/src/sessions/feature.ts +3 -0
  48. package/src/step-dispatcher/feature.ts +3 -0
  49. package/src/subscription-mollie/feature.ts +3 -0
  50. package/src/subscription-stripe/feature.ts +3 -0
  51. package/src/template-resolver/feature.ts +3 -0
  52. package/src/tenant/__tests__/multi-tenant.integration.test.ts +68 -0
  53. package/src/tenant/__tests__/tenant.integration.test.ts +16 -0
  54. package/src/tenant/constants.ts +1 -0
  55. package/src/tenant/feature.ts +5 -0
  56. package/src/tenant/handlers/enable.write.ts +20 -0
  57. package/src/tenant/handlers/memberships.query.ts +28 -5
  58. package/src/text-content/feature.ts +3 -0
  59. package/src/tier-engine/feature.ts +3 -0
  60. package/src/user/feature.ts +3 -0
  61. package/src/user-data-rights/feature.ts +3 -0
  62. package/src/user-data-rights-defaults/feature.ts +3 -0
@@ -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";
@@ -38,6 +39,9 @@ const FEATURE_NAME = "file-provider-s3";
38
39
  // =============================================================================
39
40
 
40
41
  export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
42
+ r.describe(
43
+ 'Registers itself as the `"s3"` provider for `file-foundation` and owns the per-tenant config keys (`bucket`, `region`, `endpoint`, `forcePathStyle`, `accessKeyId`) and the encrypted `s3.secretAccessKey` secret. Compatible with any S3-compatible object store (AWS S3, Hetzner Object Storage); set credentials via the admin UI or a seed handler before the first file operation.',
44
+ );
41
45
  r.requires("config");
42
46
  r.requires("secrets");
43
47
  r.requires("file-foundation");
@@ -53,16 +57,21 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
53
57
  return `${plaintext.slice(0, 4)}...${plaintext.slice(-4)}`;
54
58
  },
55
59
  scope: "tenant",
60
+ // required: true ↔ the missing-secret throw in readSecretAccessKey — keep in sync.
61
+ required: true,
56
62
  });
57
63
 
64
+ // required: true ↔ the requireNonEmpty calls in buildS3Provider — keep in sync.
58
65
  const configKeys = r.config({
59
66
  keys: {
60
67
  bucket: createTenantConfig("text", {
68
+ required: true,
61
69
  default: "",
62
70
  write: access.roles("TenantAdmin", "SystemAdmin"),
63
71
  read: access.roles("TenantAdmin", "SystemAdmin"),
64
72
  }),
65
73
  region: createTenantConfig("text", {
74
+ required: true,
66
75
  default: "",
67
76
  write: access.roles("TenantAdmin", "SystemAdmin"),
68
77
  read: access.roles("TenantAdmin", "SystemAdmin"),
@@ -77,6 +86,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
77
86
  write: access.roles("TenantAdmin", "SystemAdmin"),
78
87
  }),
79
88
  accessKeyId: createTenantConfig("text", {
89
+ required: true,
80
90
  default: "",
81
91
  write: access.roles("TenantAdmin", "SystemAdmin"),
82
92
  read: access.roles("TenantAdmin", "SystemAdmin"),
@@ -158,10 +168,5 @@ async function buildS3Provider(
158
168
  async function readSecretAccessKey(ctx: FileProviderContext, tenantId: string): Promise<string> {
159
169
  const secrets = requireSecretsContext(ctx, FEATURE_NAME);
160
170
  const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
161
- if (!branded) {
162
- throw new Error(
163
- `${FEATURE_NAME}: ${S3_SECRET_ACCESS_KEY.name} not set for tenant ${tenantId} — Tenant-Admin must set it via /api/write/secrets:write:set`,
164
- );
165
- }
166
- return branded.reveal();
171
+ return requireSecretSet(branded, FEATURE_NAME, S3_SECRET_ACCESS_KEY.name).reveal();
167
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";
@@ -23,6 +23,9 @@ import { jobRunLogsTable, jobRunLogsTableMeta, jobRunsTable } from "./job-run-ta
23
23
 
24
24
  export function createJobsFeature(): FeatureDefinition {
25
25
  return defineFeature("jobs", (r) => {
26
+ r.describe(
27
+ "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution appends `run-started`, `run-completed`, and `run-failed` events to the `jobRun` aggregate stream, which two inline projections materialize into `read_job_runs` (current status + duration) and `read_job_run_logs` (per-line log rows). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list` and `jobs:query:details` for the operator UI.",
28
+ );
26
29
  r.systemScope();
27
30
  r.unmanagedTable(jobRunLogsTableMeta, {
28
31
  reason: "read_side.job_run_logs",
@@ -60,6 +60,9 @@ export type LegalPagesOptions = {
60
60
  export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDefinition {
61
61
  const wrapLayout = opts.wrapLayout ?? wrapInLayout;
62
62
  return defineFeature("legal-pages", (r) => {
63
+ r.describe(
64
+ "Opt-in wrapper around `text-content` that registers four public HTML routes (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`, `/legal/privacy`) with Markdown-to-HTML rendering and a boot-time job that hard-fails in production when the required DE blocks (`imprint/de`, `privacy/de`) are not seeded in `SYSTEM_TENANT`. Requires `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` and `extraContext.textContent` to be wired at app bootstrap; for per-tenant imprints or a custom layout call `text-content:query:by-slug` directly.",
65
+ );
63
66
  r.requires("text-content");
64
67
 
65
68
  // 4 Public-HTML-Routes
@@ -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
 
@@ -65,6 +65,9 @@ export type MailTransportPlugin = {
65
65
  // =============================================================================
66
66
 
67
67
  export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
68
+ r.describe(
69
+ "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending \u2014 use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences.",
70
+ );
68
71
  r.requires("config");
69
72
 
70
73
  // Plugin extension-point. Provider-features register here. The
@@ -93,6 +96,9 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
93
96
  }),
94
97
  },
95
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);
96
102
 
97
103
  return {
98
104
  /** Config-key-handle for the provider-selector. */
@@ -73,6 +73,9 @@ export function clearInbox(tenantId: string): void {
73
73
  // =============================================================================
74
74
 
75
75
  export const mailTransportInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
76
+ r.describe(
77
+ 'Registers an in-process `"inmemory"` provider for `mail-foundation` that buffers sent mails per tenant instead of contacting an SMTP server. Use `getInbox(tenantId)` and `clearInbox(tenantId)` in demo apps and tests; not for production (buffer is process-memory, lost on restart).',
78
+ );
76
79
  // Kein r.requires("config") + kein r.requires("secrets") — der
77
80
  // In-Memory-Transport hat keine Config (nichts zu konfigurieren) und
78
81
  // kein Secret. Der einzige hard-require ist mail-foundation, das den
@@ -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";
@@ -49,6 +50,9 @@ const FEATURE_NAME = "mail-transport-smtp";
49
50
  // =============================================================================
50
51
 
51
52
  export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
53
+ r.describe(
54
+ 'Registers itself as the `"smtp"` provider for `mail-foundation` and owns the per-tenant config keys (`host`, `port`, `secure`, `from`, `authUser`) and the encrypted `smtp.password` secret. Tenants set `mail-foundation`\'s `provider` config key to `"smtp"` to activate it; set the SMTP credentials via the admin UI or a seed handler before sending the first mail.',
55
+ );
52
56
  r.requires("config");
53
57
  r.requires("secrets");
54
58
  r.requires("mail-foundation");
@@ -65,11 +69,15 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
65
69
  return `${plaintext.slice(0, 3)}...${plaintext.slice(-2)}`;
66
70
  },
67
71
  scope: "tenant",
72
+ // required: true ↔ the missing-secret throw in readPassword — keep in sync.
73
+ required: true,
68
74
  });
69
75
 
76
+ // required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
70
77
  const configKeys = r.config({
71
78
  keys: {
72
79
  host: createTenantConfig("text", {
80
+ required: true,
73
81
  default: "",
74
82
  write: access.roles("TenantAdmin", "SystemAdmin"),
75
83
  read: access.roles("TenantAdmin", "SystemAdmin"),
@@ -84,11 +92,13 @@ export const mailTransportSmtpFeature = defineFeature(FEATURE_NAME, (r) => {
84
92
  write: access.roles("TenantAdmin", "SystemAdmin"),
85
93
  }),
86
94
  from: createTenantConfig("text", {
95
+ required: true,
87
96
  default: "",
88
97
  write: access.roles("TenantAdmin", "SystemAdmin"),
89
98
  read: access.roles("TenantAdmin", "SystemAdmin"),
90
99
  }),
91
100
  authUser: createTenantConfig("text", {
101
+ required: true,
92
102
  default: "",
93
103
  write: access.roles("TenantAdmin", "SystemAdmin"),
94
104
  read: access.roles("TenantAdmin", "SystemAdmin"),
@@ -173,10 +183,5 @@ async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promis
173
183
  async function readPassword(ctx: HandlerContext, tenantId: string): Promise<string> {
174
184
  const secrets = requireSecretsContext(ctx, FEATURE_NAME);
175
185
  const branded = await secrets.get(tenantId, SMTP_PASSWORD);
176
- if (!branded) {
177
- throw new Error(
178
- `${FEATURE_NAME}: ${SMTP_PASSWORD.name} not set for tenant ${tenantId} — Tenant-Admin must set it via /api/write/secrets:write:set`,
179
- );
180
- }
181
- return branded.reveal();
186
+ return requireSecretSet(branded, FEATURE_NAME, SMTP_PASSWORD.name).reveal();
182
187
  }
@@ -11,6 +11,9 @@ import { rateLimitStatus } from "./handlers/status.query";
11
11
  // skip this feature entirely — the resolver still runs.
12
12
  export function createRateLimitingFeature() {
13
13
  return defineFeature("rate-limiting", (r) => {
14
+ r.describe(
15
+ "Adds an ops-side `rate-limiting:query:status` query handler for inspecting current bucket state; the actual request throttling is wired automatically by the dispatcher when any handler declares a `rateLimit` option (e.g. `{ per: 'user', limit: 3, windowSeconds: 60 }`) or when you pass `context.rateLimit` to `buildServer`. Loading this feature is optional if you only need L3 per-handler rate limits and have no need for ops introspection.",
16
+ );
14
17
  r.queryHandler(rateLimitStatus);
15
18
  });
16
19
  }
@@ -0,0 +1,338 @@
1
+ // Full-stack integration test for the readiness rollup. Drives
2
+ // readiness:query:status through the dispatcher so the real config-cascade
3
+ // + secrets-metadata-lookup are exercised — including the no-read-audit
4
+ // guarantee of the has() probe.
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { randomBytes } from "node:crypto";
8
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
+ import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
12
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
13
+ import {
14
+ createTestUser,
15
+ setupTestStack,
16
+ type TestStack,
17
+ testTenantId,
18
+ unsafeCreateEntityTable,
19
+ unsafePushTables,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { createConfigFeature } from "../../config";
22
+ import { createConfigAccessorFactory } from "../../config/feature";
23
+ import { createConfigResolver } from "../../config/resolver";
24
+ import { configValuesTable } from "../../config/table";
25
+ import {
26
+ createSecretsContext,
27
+ createSecretsFeature,
28
+ TENANT_SECRET_READ_EVENT,
29
+ tenantSecretsTable,
30
+ } from "../../secrets";
31
+ import { createTenantFeature } from "../../tenant/feature";
32
+ import { tenantEntity } from "../../tenant/schema/tenant";
33
+ import { ReadinessQueries } from "../constants";
34
+ import { readinessFeature } from "../feature";
35
+
36
+ // Probe-feature: one required + one optional config key, one required +
37
+ // one optional secret — the rollup must list exactly the required gaps.
38
+ const probeFeature = defineFeature("readiness-probe", (r) => {
39
+ r.requires("config");
40
+ r.requires("secrets");
41
+
42
+ r.config({
43
+ keys: {
44
+ apiUrl: createTenantConfig("text", {
45
+ required: true,
46
+ default: "",
47
+ write: access.roles("TenantAdmin", "SystemAdmin"),
48
+ read: access.roles("TenantAdmin", "SystemAdmin"),
49
+ }),
50
+ timeout: createTenantConfig("number", {
51
+ default: 30,
52
+ write: access.roles("TenantAdmin", "SystemAdmin"),
53
+ }),
54
+ },
55
+ });
56
+
57
+ r.secret("probe.apiToken", {
58
+ label: { de: "API-Token", en: "API token" },
59
+ scope: "tenant",
60
+ required: true,
61
+ });
62
+ r.secret("probe.optionalToken", {
63
+ label: { de: "Optionales Token", en: "Optional token" },
64
+ scope: "tenant",
65
+ });
66
+ });
67
+
68
+ // Provider-gating fixture: foundation declares the selector, two providers
69
+ // register under the point. The smtp-ish one carries required key + secret —
70
+ // they must count ONLY while "smtp" is the selected provider.
71
+ const probeMailFoundation = defineFeature("probe-mail-foundation", (r) => {
72
+ r.requires("config");
73
+ r.extendsRegistrar("probeMailTransport", { onRegister: () => undefined });
74
+ const configKeys = r.config({
75
+ keys: {
76
+ provider: createTenantConfig("text", {
77
+ default: "",
78
+ write: access.roles("TenantAdmin", "SystemAdmin"),
79
+ }),
80
+ },
81
+ });
82
+ r.extensionSelector("probeMailTransport", configKeys.provider);
83
+ return { configKeys };
84
+ });
85
+
86
+ const probeSmtpProvider = defineFeature("probe-smtp", (r) => {
87
+ r.requires("config");
88
+ r.requires("secrets");
89
+ r.useExtension("probeMailTransport", "smtp");
90
+ r.config({
91
+ keys: {
92
+ host: createTenantConfig("text", {
93
+ required: true,
94
+ default: "",
95
+ write: access.roles("TenantAdmin", "SystemAdmin"),
96
+ read: access.roles("TenantAdmin", "SystemAdmin"),
97
+ }),
98
+ },
99
+ });
100
+ r.secret("smtp.password", {
101
+ label: { de: "SMTP-Passwort", en: "SMTP password" },
102
+ scope: "tenant",
103
+ required: true,
104
+ });
105
+ });
106
+
107
+ const probeInMemoryProvider = defineFeature("probe-inmemory", (r) => {
108
+ r.useExtension("probeMailTransport", "inmemory");
109
+ });
110
+
111
+ const REQUIRED_CONFIG_KEY = "readiness-probe:config:api-url";
112
+ const REQUIRED_SECRET_KEY = "readiness-probe:secret:probe-api-token";
113
+ const PROVIDER_SELECTOR_KEY = "probe-mail-foundation:config:provider";
114
+ const GATED_CONFIG_KEY = "probe-smtp:config:host";
115
+ const GATED_SECRET_KEY = "probe-smtp:secret:smtp-password";
116
+
117
+ type StatusResult = {
118
+ missingConfig: ReadonlyArray<{ key: string; scope: string; type: string }>;
119
+ missingSecrets: ReadonlyArray<{ key: string }>;
120
+ ready: boolean;
121
+ };
122
+
123
+ let stack: TestStack;
124
+ let db: DbConnection;
125
+
126
+ beforeAll(async () => {
127
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
128
+ const resolver = createConfigResolver({ encryption });
129
+ const masterKeyProvider = createEnvMasterKeyProvider({
130
+ env: {
131
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
132
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
133
+ },
134
+ });
135
+
136
+ stack = await setupTestStack({
137
+ features: [
138
+ createConfigFeature(),
139
+ createTenantFeature(),
140
+ createSecretsFeature(),
141
+ readinessFeature,
142
+ probeFeature,
143
+ probeMailFoundation,
144
+ probeSmtpProvider,
145
+ probeInMemoryProvider,
146
+ ],
147
+ extraContext: ({ db, registry }) => ({
148
+ configResolver: resolver,
149
+ configEncryption: encryption,
150
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
151
+ secrets: createSecretsContext({ db, masterKeyProvider }),
152
+ }),
153
+ });
154
+ db = stack.db;
155
+ await unsafeCreateEntityTable(db, tenantEntity);
156
+ await unsafePushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
157
+ await createEventsTable(db);
158
+ });
159
+
160
+ afterAll(async () => {
161
+ await stack.cleanup();
162
+ });
163
+
164
+ function adminFor(tenantNumber: number) {
165
+ return createTestUser({
166
+ id: tenantNumber,
167
+ tenantId: testTenantId(tenantNumber),
168
+ roles: ["TenantAdmin"],
169
+ });
170
+ }
171
+
172
+ async function statusFor(admin: ReturnType<typeof adminFor>): Promise<StatusResult> {
173
+ return stack.http.queryOk<StatusResult>(ReadinessQueries.status, {}, admin);
174
+ }
175
+
176
+ describe("readiness:query:status", () => {
177
+ test("fresh tenant → required config + secret listed as missing, ready false", async () => {
178
+ const admin = adminFor(601);
179
+
180
+ const status = await statusFor(admin);
181
+
182
+ expect(status.ready).toBe(false);
183
+ expect(status.missingConfig).toContainEqual({
184
+ key: REQUIRED_CONFIG_KEY,
185
+ scope: "tenant",
186
+ type: "text",
187
+ });
188
+ expect(status.missingSecrets).toContainEqual({ key: REQUIRED_SECRET_KEY });
189
+ // Optional keys must not appear — they have usable defaults / aren't required.
190
+ expect(status.missingConfig.map((k) => k.key)).not.toContain("readiness-probe:config:timeout");
191
+ expect(status.missingSecrets.map((s) => s.key)).not.toContain(
192
+ "readiness-probe:secret:probe-optional-token",
193
+ );
194
+ });
195
+
196
+ test("setting required config + secret flips ready to true", async () => {
197
+ const admin = adminFor(602);
198
+
199
+ await stack.http.writeOk(
200
+ "config:write:set",
201
+ { key: REQUIRED_CONFIG_KEY, value: "https://api.example.test" },
202
+ admin,
203
+ );
204
+ await stack.http.writeOk(
205
+ "secrets:write:set",
206
+ { key: REQUIRED_SECRET_KEY, value: "token-xyz" },
207
+ admin,
208
+ );
209
+
210
+ const status = await statusFor(admin);
211
+ expect(status.missingConfig.map((k) => k.key)).not.toContain(REQUIRED_CONFIG_KEY);
212
+ expect(status.missingSecrets).toEqual([]);
213
+ expect(status.ready).toBe(true);
214
+ });
215
+
216
+ test("tenant isolation: tenant A's values don't make tenant B ready", async () => {
217
+ const adminA = adminFor(603);
218
+ const adminB = adminFor(604);
219
+
220
+ await stack.http.writeOk(
221
+ "config:write:set",
222
+ { key: REQUIRED_CONFIG_KEY, value: "https://a.example.test" },
223
+ adminA,
224
+ );
225
+ await stack.http.writeOk(
226
+ "secrets:write:set",
227
+ { key: REQUIRED_SECRET_KEY, value: "token-a" },
228
+ adminA,
229
+ );
230
+
231
+ expect((await statusFor(adminA)).ready).toBe(true);
232
+ const statusB = await statusFor(adminB);
233
+ expect(statusB.ready).toBe(false);
234
+ expect(statusB.missingSecrets).toContainEqual({ key: REQUIRED_SECRET_KEY });
235
+ });
236
+
237
+ test("non-TenantAdmin → access denied (same gate as secrets:query:list)", async () => {
238
+ const member = createTestUser({
239
+ id: 605,
240
+ tenantId: testTenantId(605),
241
+ roles: ["Member"],
242
+ });
243
+
244
+ const res = await stack.http.query(ReadinessQueries.status, {}, member);
245
+ expect(res.status).toBe(403);
246
+ });
247
+
248
+ test("provider-gated keys don't count while no provider is selected", async () => {
249
+ const admin = adminFor(607);
250
+
251
+ const status = await statusFor(admin);
252
+ expect(status.missingConfig.map((k) => k.key)).not.toContain(GATED_CONFIG_KEY);
253
+ expect(status.missingSecrets.map((s) => s.key)).not.toContain(GATED_SECRET_KEY);
254
+ });
255
+
256
+ test("selecting the provider pulls its required key + secret into missing", async () => {
257
+ const admin = adminFor(608);
258
+
259
+ await stack.http.writeOk(
260
+ "config:write:set",
261
+ { key: PROVIDER_SELECTOR_KEY, value: "smtp" },
262
+ admin,
263
+ );
264
+
265
+ const status = await statusFor(admin);
266
+ expect(status.missingConfig.map((k) => k.key)).toContain(GATED_CONFIG_KEY);
267
+ expect(status.missingSecrets).toContainEqual({ key: GATED_SECRET_KEY });
268
+ expect(status.ready).toBe(false);
269
+ });
270
+
271
+ test("tenant on the inmemory provider is ready despite unset smtp keys", async () => {
272
+ const admin = adminFor(609);
273
+
274
+ // The advisor scenario: smtp + inmemory both mounted, tenant runs
275
+ // inmemory — unset smtp keys must not block ready.
276
+ await stack.http.writeOk(
277
+ "config:write:set",
278
+ { key: PROVIDER_SELECTOR_KEY, value: "inmemory" },
279
+ admin,
280
+ );
281
+ await stack.http.writeOk(
282
+ "config:write:set",
283
+ { key: REQUIRED_CONFIG_KEY, value: "https://api.example.test" },
284
+ admin,
285
+ );
286
+ await stack.http.writeOk(
287
+ "secrets:write:set",
288
+ { key: REQUIRED_SECRET_KEY, value: "token-609" },
289
+ admin,
290
+ );
291
+
292
+ const status = await statusFor(admin);
293
+ expect(status.missingConfig).toEqual([]);
294
+ expect(status.missingSecrets).toEqual([]);
295
+ expect(status.ready).toBe(true);
296
+ });
297
+
298
+ test("config:query:readiness applies the same provider gating", async () => {
299
+ const admin = adminFor(610);
300
+
301
+ const before = await stack.http.queryOk<{ missing: ReadonlyArray<{ key: string }> }>(
302
+ "config:query:readiness",
303
+ {},
304
+ admin,
305
+ );
306
+ expect(before.missing.map((k) => k.key)).not.toContain(GATED_CONFIG_KEY);
307
+
308
+ await stack.http.writeOk(
309
+ "config:write:set",
310
+ { key: PROVIDER_SELECTOR_KEY, value: "smtp" },
311
+ admin,
312
+ );
313
+ const after = await stack.http.queryOk<{ missing: ReadonlyArray<{ key: string }> }>(
314
+ "config:query:readiness",
315
+ {},
316
+ admin,
317
+ );
318
+ expect(after.missing.map((k) => k.key)).toContain(GATED_CONFIG_KEY);
319
+ });
320
+
321
+ test("status probe writes NO secret-read audit events", async () => {
322
+ const admin = adminFor(606);
323
+ await stack.http.writeOk(
324
+ "secrets:write:set",
325
+ { key: REQUIRED_SECRET_KEY, value: "token-606" },
326
+ admin,
327
+ );
328
+ await asRawClient(db).unsafe(
329
+ `DELETE FROM "${eventsTable.tableName}" WHERE type = '${TENANT_SECRET_READ_EVENT}'`,
330
+ );
331
+
332
+ // Probes both branches: set secret (has → true) + missing optional.
333
+ await statusFor(admin);
334
+
335
+ const readEvents = await selectMany(db, eventsTable, { type: TENANT_SECRET_READ_EVENT });
336
+ expect(readEvents).toEqual([]);
337
+ });
338
+ });
@@ -0,0 +1,7 @@
1
+ // Feature name
2
+ export const READINESS_FEATURE = "readiness" as const;
3
+
4
+ // Qualified query handler names (QN format: scope:type:name)
5
+ export const ReadinessQueries = {
6
+ status: "readiness:query:status",
7
+ } as const;