@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.27.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",
@@ -15,6 +15,9 @@ import { listQuery } from "./handlers/list.query";
15
15
  // the framework).
16
16
  export function createAuditFeature(): FeatureDefinition {
17
17
  return defineFeature("audit", (r) => {
18
+ r.describe(
19
+ "Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection \u2014 the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range.",
20
+ );
18
21
  const queries = {
19
22
  list: r.queryHandler(listQuery),
20
23
  };
@@ -125,6 +125,9 @@ export function createAuthEmailPasswordFeature(
125
125
  opts.emailVerification !== undefined && (opts.emailVerification.mode ?? "strict") === "strict";
126
126
 
127
127
  return defineFeature("auth-email-password", (r) => {
128
+ r.describe(
129
+ "Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. Each opt-in flow uses HMAC-signed or opaque-random tokens delivered via callback (e.g. `sendResetEmail`) so the feature stays transport-agnostic. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
130
+ );
128
131
  r.requires("user");
129
132
  r.requires("tenant");
130
133
  r.envSchema(authEmailPasswordEnvSchema);
@@ -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).
@@ -71,6 +71,9 @@ import {
71
71
  } from "./projection";
72
72
 
73
73
  export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
74
+ r.describe(
75
+ "Plugin host for subscription billing \u2014 manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookHandler` factory for the `/api/subscription/webhook/:providerName` route. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
76
+ );
74
77
  // 5 fine-grained domain-events. Alle 5 nutzen denselben payload-
75
78
  // shape (= subscription-state-snapshot); der event-type taggt was
76
79
  // passiert ist. Future-consumer (billing-history, accounting)
@@ -7,12 +7,20 @@
7
7
  // `/api/subscription/webhook/paypal`. Eine Hono-Route, alle Plugins
8
8
  // gleichzeitig aktiv.
9
9
  //
10
- // Beispiel-Verwendung in bin/server.ts:
10
+ // Beispiel-Verwendung in bin/server.ts (runDevApp wie runProdApp liefern
11
+ // `registry` + `dispatchSystemWrite` in den extraRoutes-deps):
11
12
  //
12
13
  // await runDevApp({
13
14
  // features: APP_FEATURES,
14
15
  // extraRoutes: (app, deps) => {
15
- // const handler = createSubscriptionWebhookHandler(deps);
16
+ // const handler = createSubscriptionWebhookHandler({
17
+ // dispatchWrite: ({ handlerQn, payload, tenantId }) =>
18
+ // deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
19
+ // resolveProvider: (name) =>
20
+ // deps.registry.getExtensionUsages("subscriptionProvider")
21
+ // .find((u) => u.entityName === name)?.options as
22
+ // SubscriptionProviderPlugin | undefined,
23
+ // });
16
24
  // app.post("/api/subscription/webhook/:providerName", handler);
17
25
  // },
18
26
  // });
@@ -43,8 +51,9 @@ import {
43
51
  import type { SubscriptionProviderPlugin } from "./types";
44
52
 
45
53
  /**
46
- * Dependencies the App-Owner gibt dem webhook-handler. Identisch zum
47
- * `extraRoutes`-Callback-Argument-shape von runDevApp/runProdApp.
54
+ * Dependencies the App-Owner gibt dem webhook-handler beide direkt aus
55
+ * den `extraRoutes`-deps ableitbar (`dispatchSystemWrite` + `registry`),
56
+ * siehe Beispiel im Header.
48
57
  */
49
58
  export type SubscriptionWebhookDeps = {
50
59
  /** Schreibt durch den Standard-Dispatcher mit einem auto-konstruierten
@@ -60,6 +60,9 @@ import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
60
60
  const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
61
61
 
62
62
  export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
63
+ r.describe(
64
+ "Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.",
65
+ );
63
66
  r.entity("cap-counter", capCounterEntity);
64
67
 
65
68
  // Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
@@ -5,6 +5,9 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
5
5
  const channel = createEmailChannel(options);
6
6
 
7
7
  return defineFeature("channel-email", (r) => {
8
+ r.describe(
9
+ "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport`, a `renderer: NotificationRenderer` (e.g. backed by `renderer-simple`), and a `resolveEmail` function that maps a user ID to their email address.",
10
+ );
8
11
  r.requires("delivery");
9
12
 
10
13
  r.useExtension("deliveryChannel", "email", {
@@ -7,6 +7,9 @@ import { inAppChannel } from "./in-app-channel";
7
7
 
8
8
  export function createChannelInAppFeature(): FeatureDefinition {
9
9
  return defineFeature("channel-in-app", (r) => {
10
+ r.describe(
11
+ "Persists notifications to an in-app inbox table so users can retrieve them via `handlers.inbox` and track unread state with `handlers.markRead` / `handlers.markAllRead` and `queries.unreadCount`. Requires `delivery`; no external service needed \u2014 messages are stored in the app's own database.",
12
+ );
10
13
  r.requires("delivery");
11
14
 
12
15
  // Register as delivery channel via extension system
@@ -5,6 +5,9 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
5
5
  const channel = createPushChannel(options);
6
6
 
7
7
  return defineFeature("channel-push", (r) => {
8
+ r.describe(
9
+ "Delivers push notifications through a `PushTransport` (bring your own FCM/APNs adapter or use `createInMemoryPushTransport()` for tests) registered as the `push` channel in the delivery system. Requires `delivery`; supply a `PushChannelOptions` with a transport and a resolver that maps a user ID to their device token.",
10
+ );
8
11
  r.requires("delivery");
9
12
 
10
13
  r.useExtension("deliveryChannel", "push", {
@@ -27,6 +27,9 @@ export {
27
27
  // Begruendung in schema/profile-selection.ts.
28
28
  export function createComplianceProfilesFeature(): FeatureDefinition {
29
29
  return defineFeature("compliance-profiles", (r) => {
30
+ r.describe(
31
+ "Lets each tenant select a compliance regime (e.g. `eu-dsgvo`, `swiss-dsg`, `de-hr-dsgvo-hgb`) that bundles user-rights grace periods, breach-disclosure deadlines, sub-processor requirements, and audit-retention rules into a single named profile. Tenant admins call `compliance-profiles:write:set-profile` to choose a profile (with optional JSON override for edge cases); other features resolve the effective profile via the `compliance.forTenant` cross-feature API. Required by `user-data-rights` \u2014 mount this feature before it.",
32
+ );
30
33
  // Standalone — kein r.requires noetig: tenantId kommt aus dem User-
31
34
  // Context, Profile-Selection ist eigene Entity, sub-processor-Liste
32
35
  // sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
@@ -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";
@@ -24,6 +25,9 @@ export type ConfigContext = { readonly config: ConfigAccessor };
24
25
 
25
26
  export function createConfigFeature(): FeatureDefinition {
26
27
  return defineFeature("config", (r) => {
28
+ r.describe(
29
+ "Stores per-tenant (and optionally per-user) configuration values with a multi-layer cascade: user-row \u2192 tenant-row \u2192 system-row \u2192 app-override (deploy-time `AppConfigOverrides`) \u2192 computed \u2192 feature default. Access a value in handlers via `ctx.config(handle)`, declare keys with `r.config({ keys: { ... } })` inside a feature's registry callback, and optionally mark them `encrypted: true` to route storage through an `EncryptionProvider`. Use this feature whenever a tenant admin needs to customise behaviour at runtime without a code deploy.",
30
+ );
27
31
  r.systemScope();
28
32
 
29
33
  // One aggregate stream per (key, scope) pair — the executor handles the
@@ -42,6 +46,7 @@ export function createConfigFeature(): FeatureDefinition {
42
46
  cascade: r.queryHandler(cascadeQuery),
43
47
  values: r.queryHandler(valuesQuery),
44
48
  schema: r.queryHandler(schemaQuery),
49
+ readiness: r.queryHandler(readinessQuery),
45
50
  };
46
51
 
47
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";
@@ -84,6 +84,9 @@ function registerCustomFields(
84
84
  r: FeatureRegistrar<typeof CUSTOM_FIELDS_FEATURE_NAME>,
85
85
  defineTenantHandler: WriteHandlerDef,
86
86
  ) {
87
+ r.describe(
88
+ "Tenant- and system-scoped custom field definitions with generic value storage on any host entity. Registers the `field-definition` entity (event-sourced CRUD via `define-tenant-field`, `define-system-field`, `delete-tenant-field`, `delete-system-field`) and two value write-handlers (`set-custom-field`, `clear-custom-field`) that emit `custom-fields:event:custom-field-set` / `custom-fields:event:custom-field-cleared` events on the host aggregate's stream. To attach custom fields to your own entity, call `wireCustomFieldsFor(r, entityName, entityTable)` in the host feature — this wires the JSONB projection, `postQuery` flattening hook, and search-payload extension. The host entity must declare a `customFieldsField()` JSONB column.",
89
+ );
87
90
  r.entity("field-definition", fieldDefinitionEntity);
88
91
 
89
92
  // Event-types — qualified als "custom-fields:event:<short-name>".
@@ -44,6 +44,9 @@ export {
44
44
  // pflicht. Das Wiring kommt in Sprint 2.U5.
45
45
  export function createDataRetentionFeature(): FeatureDefinition {
46
46
  return defineFeature("data-retention", (r) => {
47
+ r.describe(
48
+ "Resolves the effective retention policy for any entity using a 3-layer stack: entity-level default \u2192 tenant preset (`dsgvo-basic`, `dsgvo-hgb`, `swiss-dsg`) \u2192 per-tenant override stored in `tenantRetentionOverride`. Other features query the resolved policy via the `retention.policyFor` cross-feature API \u2014 most notably `user-data-rights`, which uses it to decide whether to anonymize instead of hard-delete a record that is still within a mandatory retention window.",
49
+ );
47
50
  r.entity("tenant-retention-override", tenantRetentionOverrideEntity);
48
51
 
49
52
  // S2.D3: Cross-Feature-API fuer Forget-Flow + Cleanup-Job
@@ -14,6 +14,9 @@ import {
14
14
 
15
15
  export function createDeliveryFeature(): FeatureDefinition {
16
16
  return defineFeature("delivery", (r) => {
17
+ r.describe(
18
+ "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the `notification-preference` entity, logs every attempt to `read_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything.",
19
+ );
17
20
  r.systemScope();
18
21
  r.entity("notification-preference", notificationPreferenceEntity);
19
22
  r.unmanagedTable(deliveryAttemptsTableMeta, {
@@ -37,6 +37,9 @@ export function createFeatureTogglesFeature(
37
37
  options: FeatureTogglesOptions = {},
38
38
  ): FeatureDefinition {
39
39
  return defineFeature("feature-toggles", (r) => {
40
+ r.describe(
41
+ 'Persists per-feature enabled/disabled state in the `read_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "per-instance"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
42
+ );
40
43
  r.systemScope();
41
44
 
42
45
  // Toggle-change domain event. The event ends up in the events-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
 
@@ -90,6 +90,9 @@ export type FileProviderPlugin = {
90
90
  // =============================================================================
91
91
 
92
92
  export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
93
+ r.describe(
94
+ "Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` \u2014 use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
95
+ );
93
96
  r.requires("config");
94
97
 
95
98
  r.extendsRegistrar("fileProvider", {
@@ -108,6 +111,9 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
108
111
  }),
109
112
  },
110
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);
111
117
 
112
118
  return { configKeys };
113
119
  });
@@ -60,6 +60,9 @@ export function clearStorage(tenantId: string): void {
60
60
  // =============================================================================
61
61
 
62
62
  export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
63
+ r.describe(
64
+ 'Registers an in-process `"inmemory"` provider for `file-foundation` that stores file bytes per tenant in a module-level Map. Use `listKeys(tenantId)` and `clearStorage(tenantId)` in demo apps and tests; not for production (data is lost on restart and grows without bound).',
65
+ );
63
66
  // Kein r.requires("config") + kein r.requires("secrets") — der
64
67
  // In-Memory-Provider hat keine Config + kein Secret. Nur die
65
68
  // file-foundation muss da sein (Plugin-extension-point).