@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.
- package/package.json +2 -1
- package/src/audit/feature.ts +3 -0
- package/src/auth-email-password/feature.ts +3 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +20 -0
- package/src/auth-email-password/web/auth-client.ts +4 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +8 -2
- package/src/billing-foundation/feature.ts +3 -0
- package/src/billing-foundation/webhook-handler.ts +13 -4
- package/src/cap-counter/feature.ts +3 -0
- package/src/channel-email/feature.ts +3 -0
- package/src/channel-in-app/feature.ts +3 -0
- package/src/channel-push/feature.ts +3 -0
- package/src/compliance-profiles/feature.ts +3 -0
- package/src/config/__tests__/config.integration.test.ts +113 -0
- package/src/config/constants.ts +1 -0
- package/src/config/feature.ts +5 -0
- package/src/config/handlers/readiness.query.ts +96 -0
- package/src/config/index.ts +5 -0
- package/src/custom-fields/feature.ts +3 -0
- package/src/data-retention/feature.ts +3 -0
- package/src/delivery/feature.ts +3 -0
- package/src/feature-toggles/feature.ts +3 -0
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +12 -2
- package/src/file-foundation/feature.ts +6 -0
- package/src/file-provider-inmemory/feature.ts +3 -0
- package/src/file-provider-s3/feature.ts +11 -6
- package/src/foundation-shared/__tests__/config-helpers.test.ts +17 -0
- package/src/foundation-shared/config-helpers.ts +32 -6
- package/src/foundation-shared/index.ts +1 -1
- package/src/jobs/feature.ts +3 -0
- package/src/legal-pages/feature.ts +3 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +7 -1
- package/src/mail-foundation/feature.ts +6 -0
- package/src/mail-transport-inmemory/feature.ts +3 -0
- package/src/mail-transport-smtp/feature.ts +11 -6
- package/src/rate-limiting/feature.ts +3 -0
- package/src/readiness/__tests__/readiness.integration.test.ts +338 -0
- package/src/readiness/constants.ts +7 -0
- package/src/readiness/feature.ts +26 -0
- package/src/readiness/handlers/status.query.ts +48 -0
- package/src/readiness/index.ts +3 -0
- package/src/renderer-foundation/feature.ts +3 -0
- package/src/renderer-simple/feature.ts +3 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +1 -0
- package/src/secrets/feature.ts +5 -0
- package/src/secrets/secrets-context.ts +8 -0
- package/src/sessions/feature.ts +3 -0
- package/src/step-dispatcher/feature.ts +3 -0
- package/src/subscription-mollie/feature.ts +3 -0
- package/src/subscription-stripe/feature.ts +3 -0
- package/src/template-resolver/feature.ts +3 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +68 -0
- package/src/tenant/__tests__/tenant.integration.test.ts +16 -0
- package/src/tenant/constants.ts +1 -0
- package/src/tenant/feature.ts +5 -0
- package/src/tenant/handlers/enable.write.ts +20 -0
- package/src/tenant/handlers/memberships.query.ts +28 -5
- package/src/text-content/feature.ts +3 -0
- package/src/tier-engine/feature.ts +3 -0
- package/src/user/feature.ts +3 -0
- package/src/user-data-rights/feature.ts +3 -0
- 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.
|
|
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",
|
package/src/audit/feature.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
60
|
+
// Label-Priorität: App-eigener Resolver (Prop) > Server-geliefertes
|
|
61
|
+
// name/key aus /auth/tenants > UUID-Präfix. Letzteres ist nur noch der
|
|
62
|
+
// Notnagel — bei Seed-Tenants (00000000-…) ist es ununterscheidbar.
|
|
63
|
+
const nameOf = (tenantId: string): string => {
|
|
64
|
+
if (tenantName !== undefined) return tenantName(tenantId);
|
|
65
|
+
const membership = tenants.find((m) => m.tenantId === tenantId);
|
|
66
|
+
return membership?.name ?? membership?.key ?? tenantId.slice(0, 8);
|
|
67
|
+
};
|
|
62
68
|
|
|
63
69
|
// Rendering-Gate: kein User → nix; nur ein Tenant → auch nix
|
|
64
70
|
// (Single-Tenant-Apps brauchen keinen Switcher).
|
|
@@ -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(
|
|
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
|
|
47
|
-
* `extraRoutes`-
|
|
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", () => {
|
package/src/config/constants.ts
CHANGED
package/src/config/feature.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
15
|
import { cascadeQuery } from "./handlers/cascade.query";
|
|
16
|
+
import { readinessQuery } from "./handlers/readiness.query";
|
|
16
17
|
import { resetWrite } from "./handlers/reset.write";
|
|
17
18
|
import { schemaQuery } from "./handlers/schema.query";
|
|
18
19
|
import { setWrite } from "./handlers/set.write";
|
|
@@ -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
|
+
});
|
package/src/config/index.ts
CHANGED
|
@@ -15,6 +15,11 @@ export {
|
|
|
15
15
|
createConfigAccessorFactory,
|
|
16
16
|
createConfigFeature,
|
|
17
17
|
} from "./feature";
|
|
18
|
+
export type { ReadinessMissingKey, RequiredKeyGate } from "./handlers/readiness.query";
|
|
19
|
+
export {
|
|
20
|
+
buildProviderSelectionGate,
|
|
21
|
+
collectMissingRequiredConfig,
|
|
22
|
+
} from "./handlers/readiness.query";
|
|
18
23
|
export type { AppConfigOverrides, ConfigResolver } from "./resolver";
|
|
19
24
|
export { createConfigResolver, validateAppOverrides } from "./resolver";
|
|
20
25
|
export { configValuesTable } from "./table";
|
|
@@ -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
|
package/src/delivery/feature.ts
CHANGED
|
@@ -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 →
|
|
170
|
+
test("missing bucket → 422 unconfigured naming the key, not a cryptic SDK error", async () => {
|
|
171
171
|
const admin = adminFor(502);
|
|
172
172
|
|
|
173
173
|
await selectS3Provider(admin);
|
|
@@ -183,9 +183,13 @@ describe("scenario 2: validation errors", () => {
|
|
|
183
183
|
|
|
184
184
|
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
185
185
|
expect(JSON.stringify(error)).toMatch(/'bucket' is empty/);
|
|
186
|
+
expect(error.httpStatus).toBe(422);
|
|
187
|
+
expect(error.code).toBe("unconfigured");
|
|
188
|
+
expect(error.i18nKey).toBe("errors.unconfigured");
|
|
189
|
+
expect(error.details).toMatchObject({ feature: "file-provider-s3", key: "bucket" });
|
|
186
190
|
});
|
|
187
191
|
|
|
188
|
-
test("missing secret-access-key →
|
|
192
|
+
test("missing secret-access-key → 422 unconfigured naming the secret", async () => {
|
|
189
193
|
const admin = adminFor(503);
|
|
190
194
|
|
|
191
195
|
await selectS3Provider(admin);
|
|
@@ -196,6 +200,12 @@ describe("scenario 2: validation errors", () => {
|
|
|
196
200
|
|
|
197
201
|
const error = await stack.http.writeErr(TEST_HANDLER_QN, {}, admin);
|
|
198
202
|
expect(JSON.stringify(error)).toMatch(/s3-secret-access-key/);
|
|
203
|
+
expect(error.httpStatus).toBe(422);
|
|
204
|
+
expect(error.code).toBe("unconfigured");
|
|
205
|
+
expect(error.details).toMatchObject({
|
|
206
|
+
feature: "file-provider-s3",
|
|
207
|
+
key: S3_SECRET_ACCESS_KEY.name,
|
|
208
|
+
});
|
|
199
209
|
});
|
|
200
210
|
});
|
|
201
211
|
|
|
@@ -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).
|