@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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// readiness — one-call tenant-onboarding rollup above config + secrets.
|
|
4
|
+
//
|
|
5
|
+
// `config:query:readiness` lists required config keys without a usable
|
|
6
|
+
// value; `secrets:query:list` lists set secrets. Neither can verdict
|
|
7
|
+
// "tenant is ready" alone. This feature requires both, so its status
|
|
8
|
+
// query may roll up missing config + missing required secrets + a single
|
|
9
|
+
// `ready` boolean — the settings-checklist call for admin UIs.
|
|
10
|
+
|
|
11
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { statusQuery } from "./handlers/status.query";
|
|
13
|
+
|
|
14
|
+
export const readinessFeature = defineFeature("readiness", (r) => {
|
|
15
|
+
r.describe(
|
|
16
|
+
"One-call tenant-onboarding probe: `readiness:query:status` rolls up every config key and secret declared `required: true` across all mounted features and reports which still lack a usable value for the calling tenant, plus a single `ready` boolean. Provider-features under an `r.extensionSelector`-declared extension point count only while their provider is the selected one — a tenant on the inmemory mail transport is not blocked by unset SMTP keys. Mount it (together with `config` and `secrets`) when an admin UI needs a settings checklist before the first mail-send or file-write; the per-concern lists stay available via `config:query:readiness` and `secrets:query:list`.",
|
|
17
|
+
);
|
|
18
|
+
r.requires("config");
|
|
19
|
+
r.requires("secrets");
|
|
20
|
+
|
|
21
|
+
const queries = {
|
|
22
|
+
status: r.queryHandler(statusQuery),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return { queries };
|
|
26
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildProviderSelectionGate,
|
|
3
|
+
collectMissingRequiredConfig,
|
|
4
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
5
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
6
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { ReadinessQueries } from "../constants";
|
|
9
|
+
|
|
10
|
+
export type ReadinessMissingSecret = { readonly key: string };
|
|
11
|
+
|
|
12
|
+
// The one-call rollup config:query:readiness deliberately refused: that
|
|
13
|
+
// query can't see secrets, this feature requires both — so it may verdict.
|
|
14
|
+
export const statusQuery = defineQueryHandler({
|
|
15
|
+
name: "status",
|
|
16
|
+
schema: z.object({}),
|
|
17
|
+
// Same gate as secrets:query:list — the response names missing secrets.
|
|
18
|
+
access: { roles: ["TenantAdmin"] },
|
|
19
|
+
handler: async (query, ctx) => {
|
|
20
|
+
// One gate for both halves: required keys/secrets of provider-features
|
|
21
|
+
// count only while their provider is the selected one (r.extensionSelector).
|
|
22
|
+
const gate = await buildProviderSelectionGate(ctx, ReadinessQueries.status, query.user);
|
|
23
|
+
const missingConfig = await collectMissingRequiredConfig(
|
|
24
|
+
ctx,
|
|
25
|
+
ReadinessQueries.status,
|
|
26
|
+
query.user,
|
|
27
|
+
gate,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// has() is metadata-only: no decryption, no read-audit event — a
|
|
31
|
+
// readiness probe must not pollute the credential-read trail.
|
|
32
|
+
const secrets = requireSecretsContext(ctx, ReadinessQueries.status);
|
|
33
|
+
const missingSecrets: ReadinessMissingSecret[] = [];
|
|
34
|
+
for (const [qualifiedName, keyDef] of ctx.registry.getAllSecretKeys()) {
|
|
35
|
+
if (keyDef.required !== true) continue;
|
|
36
|
+
if (!gate(qualifiedName)) continue;
|
|
37
|
+
if (!(await secrets.has(query.user.tenantId, qualifiedName))) {
|
|
38
|
+
missingSecrets.push({ key: qualifiedName });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
missingConfig,
|
|
44
|
+
missingSecrets,
|
|
45
|
+
ready: missingConfig.length === 0 && missingSecrets.length === 0,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -11,6 +11,9 @@ import type { RendererPlugin } from "./types";
|
|
|
11
11
|
// Konsumenten holen sich Plugin runtime via createRendererForTenant.
|
|
12
12
|
export function createRendererFoundationFeature() {
|
|
13
13
|
return defineFeature("renderer-foundation", (r) => {
|
|
14
|
+
r.describe(
|
|
15
|
+
'Plugin registry for content rendering (notification HTML, mail HTML, PDF, images): call `foundation.createRendererForTenant({ tenantId, kind })` at render time to get the right renderer plugin selected by kind, with tenant-level overrides via the `rendererPluginByKind` config key. Requires `template-resolver` (declared via `r.requires`). Low-level building block \u2014 add `renderer-simple` (or write a custom plugin via `r.useExtension("renderer", name, { kinds, render })`) rather than using this feature alone.',
|
|
16
|
+
);
|
|
14
17
|
r.requires("template-resolver");
|
|
15
18
|
|
|
16
19
|
r.extendsRegistrar("renderer", {
|
|
@@ -27,6 +27,9 @@ export async function adaptToFoundation(req: RenderRequest): Promise<RenderRespo
|
|
|
27
27
|
|
|
28
28
|
export function createRendererSimpleFeature(): FeatureDefinition {
|
|
29
29
|
return defineFeature("renderer-simple", (r) => {
|
|
30
|
+
r.describe(
|
|
31
|
+
'Default renderer plugin for `kind="notification"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails \u2014 swap it for `renderer-mail-html` if you need MJML/Markdown layouts.',
|
|
32
|
+
);
|
|
30
33
|
r.requires("renderer-foundation");
|
|
31
34
|
|
|
32
35
|
r.useExtension("renderer", "simple", {
|
package/src/secrets/feature.ts
CHANGED
|
@@ -75,6 +75,8 @@ export function requireSecretsContext(
|
|
|
75
75
|
return {
|
|
76
76
|
get: (tenantId, key, overrideAudit) =>
|
|
77
77
|
raw.get(tenantId, key, overrideAudit ?? { userId, handlerName }),
|
|
78
|
+
// No audit injection: has() is metadata-only and never logs a read.
|
|
79
|
+
has: raw.has.bind(raw),
|
|
78
80
|
set: raw.set.bind(raw),
|
|
79
81
|
delete: raw.delete.bind(raw),
|
|
80
82
|
};
|
|
@@ -82,6 +84,9 @@ export function requireSecretsContext(
|
|
|
82
84
|
|
|
83
85
|
export function createSecretsFeature(): FeatureDefinition {
|
|
84
86
|
return defineFeature("secrets", (r) => {
|
|
87
|
+
r.describe(
|
|
88
|
+
"Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump.",
|
|
89
|
+
);
|
|
85
90
|
r.envSchema(secretsEnvSchema);
|
|
86
91
|
|
|
87
92
|
// ES entity: set/delete go through the executor, `tenantSecret.created/
|
|
@@ -179,6 +179,14 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
|
|
|
179
179
|
return createSecret(plaintext);
|
|
180
180
|
},
|
|
181
181
|
|
|
182
|
+
async has(tenantId, keyOrHandle) {
|
|
183
|
+
// Row-existence only — no decrypt, no DEK unwrap, no read-audit
|
|
184
|
+
// event. The audit table logs credential reads; a readiness probe
|
|
185
|
+
// never sees the value, so logging it would dilute the trail.
|
|
186
|
+
const existing = await lookup(tenantId, resolveKey(keyOrHandle));
|
|
187
|
+
return existing !== undefined;
|
|
188
|
+
},
|
|
189
|
+
|
|
182
190
|
async set(tenantId, keyOrHandle, value, setOpts = {}) {
|
|
183
191
|
const key = resolveKey(keyOrHandle);
|
|
184
192
|
const envelope = await encryptValue(value, masterKeyProvider);
|
package/src/sessions/feature.ts
CHANGED
|
@@ -38,6 +38,9 @@ export type SessionsFeatureOptions = {
|
|
|
38
38
|
// see rows in the caller's active tenant.
|
|
39
39
|
export function createSessionsFeature(options?: SessionsFeatureOptions): FeatureDefinition {
|
|
40
40
|
return defineFeature("sessions", (r) => {
|
|
41
|
+
r.describe(
|
|
42
|
+
"Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
43
|
+
);
|
|
41
44
|
r.entity("user-session", userSessionEntity);
|
|
42
45
|
|
|
43
46
|
const handlers = {
|
|
@@ -29,6 +29,9 @@ type DispatchRequestedPayload =
|
|
|
29
29
|
|
|
30
30
|
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
31
31
|
return defineFeature("step-dispatcher", (r) => {
|
|
32
|
+
r.describe(
|
|
33
|
+
"Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`.",
|
|
34
|
+
);
|
|
32
35
|
r.systemScope();
|
|
33
36
|
|
|
34
37
|
r.multiStreamProjection({
|
|
@@ -146,6 +146,9 @@ export function createSubscriptionMollieFeature(
|
|
|
146
146
|
);
|
|
147
147
|
|
|
148
148
|
return defineFeature(SUBSCRIPTION_MOLLIE_FEATURE, (r) => {
|
|
149
|
+
r.describe(
|
|
150
|
+
'Mollie payment provider plugin for `billing-foundation`, covering the DACH/EU mid-market use case. Mount via `createSubscriptionMollieFeature({ apiKey, webhookUrl, priceToTier, priceToConfig })` \u2014 the factory validates that `priceToTier` and `priceToConfig` keys are identical at boot time. Implements `verifyAndParseWebhook` (lazy Mollie-API fetch + heuristic event-type mapping) and `createCheckoutSession` (customer + first-payment with `sequenceType="first"`); `createPortalSession` and `cancelSubscription` are not available because Mollie has no customer portal and requires a `customerId` that the plugin contract does not carry.',
|
|
151
|
+
);
|
|
149
152
|
r.requires("billing-foundation");
|
|
150
153
|
r.envSchema(subscriptionMollieEnvSchema);
|
|
151
154
|
|
|
@@ -124,6 +124,9 @@ export function createSubscriptionStripeFeature(
|
|
|
124
124
|
const cancel = createStripeCancelSubscription(stripe);
|
|
125
125
|
|
|
126
126
|
return defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
|
|
127
|
+
r.describe(
|
|
128
|
+
"Stripe payment provider plugin for `billing-foundation`. Mount via `createSubscriptionStripeFeature({ webhookSecret, apiKey, priceToTier })` \u2014 a factory function that holds the app-wide credentials in a closure for use before tenant resolution. Implements all four provider methods: `verifyAndParseWebhook` (HMAC signature verification), `createCheckoutSession`, `createPortalSession`, and `cancelSubscription`. The `priceToTier` map connects Stripe price IDs to your tier names.",
|
|
129
|
+
);
|
|
127
130
|
// Hard-deps: subscription-foundation als plugin-host. KEIN
|
|
128
131
|
// `r.requires("config", "secrets")` — der Plugin nutzt weder
|
|
129
132
|
// tenant-config noch tenant-secrets (alles app-wide via factory-
|
|
@@ -17,6 +17,9 @@ import { templateResourceEntity } from "./table";
|
|
|
17
17
|
// - Cross-Feature: requireTemplateResolver(ctx, callerName) — Pattern wie requireTextContent
|
|
18
18
|
export function createTemplateResolverFeature() {
|
|
19
19
|
return defineFeature("template-resolver", (r) => {
|
|
20
|
+
r.describe(
|
|
21
|
+
"Stores notification and mail templates in the database with a 4-level fallback: tenant+locale \u2192 system+locale \u2192 tenant+fallback-locale \u2192 system+fallback-locale. Call `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })` at render time; manage templates via the `upsertSystem`, `upsertTenant`, `publish`, and `archive` write handlers. Tenants can override system-default templates without touching application code.",
|
|
22
|
+
);
|
|
20
23
|
r.entity("template-resource", templateResourceEntity);
|
|
21
24
|
|
|
22
25
|
const handlers = {
|
|
@@ -205,6 +205,14 @@ describe("multi-tenant user", () => {
|
|
|
205
205
|
const tenantIds = memberships.map((m: Record<string, unknown>) => m["tenantId"]);
|
|
206
206
|
expect(tenantIds).toContain(testTenantId(1));
|
|
207
207
|
expect(tenantIds).toContain(testTenantId(2));
|
|
208
|
+
|
|
209
|
+
// tenantName/tenantKey machen die Memberships im UI unterscheidbar
|
|
210
|
+
// (Tenant-Switcher) — die Query reichert sie aus der tenants-Tabelle an.
|
|
211
|
+
const acme = memberships.find(
|
|
212
|
+
(m: Record<string, unknown>) => m["tenantId"] === testTenantId(1),
|
|
213
|
+
);
|
|
214
|
+
expect(acme["tenantName"]).toBe("ACME");
|
|
215
|
+
expect(acme["tenantKey"]).toBe("acme");
|
|
208
216
|
});
|
|
209
217
|
|
|
210
218
|
test("user has different roles per tenant", async () => {
|
|
@@ -231,6 +239,14 @@ describe("multi-tenant user", () => {
|
|
|
231
239
|
const body = await res.json();
|
|
232
240
|
expect(body.tenants.length).toBe(2);
|
|
233
241
|
expect(body.activeTenantId).toBe(testTenantId(1));
|
|
242
|
+
|
|
243
|
+
// name/key kommen bis in die HTTP-Response durch (Tenant-Switcher-Label).
|
|
244
|
+
const names = body.tenants.map((t: Record<string, unknown>) => t["name"]);
|
|
245
|
+
expect(names).toContain("ACME");
|
|
246
|
+
expect(names).toContain("Beta Inc");
|
|
247
|
+
const keys = body.tenants.map((t: Record<string, unknown>) => t["key"]);
|
|
248
|
+
expect(keys).toContain("acme");
|
|
249
|
+
expect(keys).toContain("beta");
|
|
234
250
|
});
|
|
235
251
|
|
|
236
252
|
test("POST /auth/switch-tenant issues new JWT with different tenant", async () => {
|
|
@@ -276,3 +292,55 @@ describe("perTenant jobs", () => {
|
|
|
276
292
|
}
|
|
277
293
|
});
|
|
278
294
|
});
|
|
295
|
+
|
|
296
|
+
// --- Scenario 6: disabled tenant verschwindet aus allen Auth-Flächen ---
|
|
297
|
+
//
|
|
298
|
+
// tenant:write:disable legt den Tenant still: memberships-Query filtert ihn,
|
|
299
|
+
// damit listet /auth/tenants ihn nicht mehr (Switcher), switch-tenant lehnt
|
|
300
|
+
// ab (not_a_member) und der Login wählt ihn nicht. active-tenant-ids lässt
|
|
301
|
+
// ihn ebenfalls aus (perTenant-Jobs). enable macht alles rückgängig.
|
|
302
|
+
// Läuft bewusst NACH den perTenant-Job-Tests — die erwarten 2 aktive Tenants.
|
|
303
|
+
|
|
304
|
+
describe("disabled tenant", () => {
|
|
305
|
+
const user = createTestUser({ id: 10 });
|
|
306
|
+
|
|
307
|
+
test("disable removes the tenant from memberships, /auth/tenants and switch-tenant", async () => {
|
|
308
|
+
const r = await writeApi(systemAdmin, TenantHandlers.disable, { id: testTenantId(2) });
|
|
309
|
+
expect(r.isSuccess).toBe(true);
|
|
310
|
+
|
|
311
|
+
const result = await queryApi(systemAdmin, TenantQueries.memberships, {
|
|
312
|
+
userId: "11111111-0000-4000-8000-000000000010",
|
|
313
|
+
});
|
|
314
|
+
const tenantIds = result.data.map((m: Record<string, unknown>) => m["tenantId"]);
|
|
315
|
+
expect(tenantIds).toEqual([testTenantId(1)]);
|
|
316
|
+
|
|
317
|
+
const res = await getApi(user, "/auth/tenants");
|
|
318
|
+
expect(res.status).toBe(200);
|
|
319
|
+
const body = await res.json();
|
|
320
|
+
expect(body.tenants.map((t: Record<string, unknown>) => t["tenantId"])).toEqual([
|
|
321
|
+
testTenantId(1),
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
const switchRes = await postApi(user, "/auth/switch-tenant", { tenantId: testTenantId(2) });
|
|
325
|
+
expect(switchRes.status).toBe(403);
|
|
326
|
+
|
|
327
|
+
const activeIds = await queryApi(systemAdmin, TenantQueries.activeTenantIds, {});
|
|
328
|
+
expect(activeIds.data).toEqual([testTenantId(1)]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("enable restores membership, switcher and active-tenant-ids", async () => {
|
|
332
|
+
const r = await writeApi(systemAdmin, TenantHandlers.enable, { id: testTenantId(2) });
|
|
333
|
+
expect(r.isSuccess).toBe(true);
|
|
334
|
+
|
|
335
|
+
const result = await queryApi(systemAdmin, TenantQueries.memberships, {
|
|
336
|
+
userId: "11111111-0000-4000-8000-000000000010",
|
|
337
|
+
});
|
|
338
|
+
expect(result.data.length).toBe(2);
|
|
339
|
+
|
|
340
|
+
const switchRes = await postApi(user, "/auth/switch-tenant", { tenantId: testTenantId(2) });
|
|
341
|
+
expect(switchRes.status).toBe(200);
|
|
342
|
+
|
|
343
|
+
const activeIds = await queryApi(systemAdmin, TenantQueries.activeTenantIds, {});
|
|
344
|
+
expect(activeIds.data).toContain(testTenantId(2));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -145,6 +145,22 @@ describe("scenario 4: tenant.disable", () => {
|
|
|
145
145
|
"SystemAdmin",
|
|
146
146
|
]);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
test("SystemAdmin can re-enable a disabled tenant", async () => {
|
|
150
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(TenantQueries.me, {}, systemAdmin);
|
|
151
|
+
const tenantId = me["id"] as string;
|
|
152
|
+
|
|
153
|
+
const data = await stack.http.writeOk(TenantHandlers.enable, { id: tenantId }, systemAdmin);
|
|
154
|
+
expect(data!["data"]).toMatchObject({
|
|
155
|
+
isEnabled: true,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("enable handler requires SystemAdmin role", async () => {
|
|
160
|
+
expect(rolesOf(stack.registry.getWriteHandler(TenantHandlers.enable)?.access)).toEqual([
|
|
161
|
+
"SystemAdmin",
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
148
164
|
});
|
|
149
165
|
|
|
150
166
|
// --- Scenario 5: tenant.list ---
|
package/src/tenant/constants.ts
CHANGED
|
@@ -12,6 +12,7 @@ export const TenantHandlers = {
|
|
|
12
12
|
create: "tenant:write:create",
|
|
13
13
|
update: "tenant:write:update",
|
|
14
14
|
disable: "tenant:write:disable",
|
|
15
|
+
enable: "tenant:write:enable",
|
|
15
16
|
addMember: "tenant:write:add-member",
|
|
16
17
|
removeMember: "tenant:write:remove-member",
|
|
17
18
|
updateMemberRoles: "tenant:write:update-member-roles",
|
package/src/tenant/feature.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { addMemberWrite } from "./handlers/add-member.write";
|
|
|
10
10
|
import { cancelInvitationWrite } from "./handlers/cancel-invitation.write";
|
|
11
11
|
import { createWrite } from "./handlers/create.write";
|
|
12
12
|
import { disableWrite } from "./handlers/disable.write";
|
|
13
|
+
import { enableWrite } from "./handlers/enable.write";
|
|
13
14
|
import { invitationsQuery } from "./handlers/invitations.query";
|
|
14
15
|
import { listQuery } from "./handlers/list.query";
|
|
15
16
|
import { meQuery } from "./handlers/me.query";
|
|
@@ -29,6 +30,9 @@ export { tenantEntity, tenantTable } from "./schema/tenant";
|
|
|
29
30
|
|
|
30
31
|
export function createTenantFeature(): FeatureDefinition {
|
|
31
32
|
return defineFeature("tenant", (r) => {
|
|
33
|
+
r.describe(
|
|
34
|
+
"Registers the three core multi-tenancy entities \u2014 `tenant`, `tenant-membership`, and `tenant-invitation` (DB tables `read_tenants`, `read_tenant_memberships`, and `read_tenant_invitations`) \u2014 along with write handlers for create/update/disable/enable/addMember/removeMember/updateMemberRoles and the matching queries. It also declares a set of per-tenant config keys (companyName, timezone, locale, SMTP credentials) and system-only keys (priceModel, maxUsers) via `r.config({ keys: { ... } })`. Use this feature in every multi-tenant app; membership resolution and invitation flows depend on it, and `auth-email-password` requires it.",
|
|
35
|
+
);
|
|
32
36
|
r.systemScope();
|
|
33
37
|
r.requires("config");
|
|
34
38
|
r.entity("tenant", tenantEntity);
|
|
@@ -87,6 +91,7 @@ export function createTenantFeature(): FeatureDefinition {
|
|
|
87
91
|
create: r.writeHandler(createWrite),
|
|
88
92
|
update: r.writeHandler(updateWrite),
|
|
89
93
|
disable: r.writeHandler(disableWrite),
|
|
94
|
+
enable: r.writeHandler(enableWrite),
|
|
90
95
|
addMember: r.writeHandler(addMemberWrite),
|
|
91
96
|
removeMember: r.writeHandler(removeMemberWrite),
|
|
92
97
|
updateMemberRoles: r.writeHandler(updateMemberRolesWrite),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { tenantEntity, tenantTable } from "../schema/tenant";
|
|
5
|
+
|
|
6
|
+
const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
7
|
+
|
|
8
|
+
// Recovery-Gegenstück zu disable — ohne enable wäre ein Fehlklick des
|
|
9
|
+
// Operators nur per Event-Hack reversibel.
|
|
10
|
+
export const enableWrite = defineWriteHandler({
|
|
11
|
+
name: "enable",
|
|
12
|
+
schema: z.object({ id: z.uuid() }),
|
|
13
|
+
access: { roles: ["SystemAdmin"] },
|
|
14
|
+
// Admin flip: last-writer-wins is fine. SystemAdmin is the only caller and
|
|
15
|
+
// there's no meaningful concurrent-edit race on this single boolean.
|
|
16
|
+
handler: async (event, ctx) =>
|
|
17
|
+
crud.update({ id: event.payload.id, changes: { isEnabled: true } }, event.user, ctx.db, {
|
|
18
|
+
skipOptimisticLock: true,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler, SYSTEM_ROLE } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { tenantMembershipsTable } from "../membership-table";
|
|
6
|
+
import { tenantTable } from "../schema/tenant";
|
|
6
7
|
|
|
7
8
|
export const membershipsQuery = defineQueryHandler({
|
|
8
9
|
name: "memberships",
|
|
@@ -13,9 +14,31 @@ export const membershipsQuery = defineQueryHandler({
|
|
|
13
14
|
handler: async (query, ctx) => {
|
|
14
15
|
const rows = await selectMany(ctx.db, tenantMembershipsTable, { userId: query.payload.userId });
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// tenantName/tenantKey machen Memberships in der UI unterscheidbar
|
|
18
|
+
// (Tenant-Switcher zeigte sonst nur das UUID-Präfix — bei Seed-Tenants
|
|
19
|
+
// mit 00000000-…-Präfix sind die ununterscheidbar). Eine Handvoll
|
|
20
|
+
// Memberships pro User → Einzel-Fetches sind ok.
|
|
21
|
+
const enriched = await Promise.all(
|
|
22
|
+
rows.map(async (row) => {
|
|
23
|
+
const tenant = await fetchOne<{ name?: unknown; key?: unknown; isEnabled?: unknown }>(
|
|
24
|
+
ctx.db,
|
|
25
|
+
tenantTable,
|
|
26
|
+
{ id: row["tenantId"] },
|
|
27
|
+
);
|
|
28
|
+
// Disabled Tenants (tenant:write:disable) zählen nicht als Membership:
|
|
29
|
+
// Login wählt sie nicht, /auth/tenants listet sie nicht, switch-tenant
|
|
30
|
+
// antwortet not_a_member. Nur das explizite false filtert — eine
|
|
31
|
+
// fehlende tenant-Row (Projektions-Drift) soll keinen Login-Lockout
|
|
32
|
+
// aller Member auslösen.
|
|
33
|
+
if (tenant !== undefined && tenant.isEnabled === false) return null;
|
|
34
|
+
return {
|
|
35
|
+
...row,
|
|
36
|
+
roles: parseRoles(row["roles"]),
|
|
37
|
+
...(typeof tenant?.name === "string" && { tenantName: tenant.name }),
|
|
38
|
+
...(typeof tenant?.key === "string" && { tenantKey: tenant.key }),
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
return enriched.filter((m) => m !== null);
|
|
20
43
|
},
|
|
21
44
|
});
|
|
@@ -23,6 +23,9 @@ import { textBlockEntity } from "./table";
|
|
|
23
23
|
// Target nutzt. Der Client-side TreeProvider lebt in `web/client-plugin.ts`.
|
|
24
24
|
export function createTextContentFeature() {
|
|
25
25
|
return defineFeature("text-content", (r) => {
|
|
26
|
+
r.describe(
|
|
27
|
+
"Generic Markdown text store keyed by `(tenantId, slug, lang)` \u2014 one row per combination in the `read_text_blocks` entity table. Provides `text-content:write:set` (TenantAdmin upsert) and `text-content:query:by-slug` (anonymous-capable read); use `SYSTEM_TENANT_ID` as the tenant for app-wide texts such as imprint, privacy policy, or FAQ. Other features (e.g. `legal-pages`) read blocks without a direct code import via the `createTextContentApi` / `requireTextContent` extraContext pattern.",
|
|
28
|
+
);
|
|
26
29
|
r.entity("text-block", textBlockEntity);
|
|
27
30
|
|
|
28
31
|
const handlers = {
|
|
@@ -167,6 +167,9 @@ export function createTierEngineFeature<
|
|
|
167
167
|
TCaps extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
|
|
168
168
|
>(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
|
|
169
169
|
return defineFeature(TIER_ENGINE_FEATURE, (r) => {
|
|
170
|
+
r.describe(
|
|
171
|
+
"Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`.",
|
|
172
|
+
);
|
|
170
173
|
r.requires("config");
|
|
171
174
|
r.requires("tenant");
|
|
172
175
|
|
package/src/user/feature.ts
CHANGED
|
@@ -12,6 +12,9 @@ import { userEntity } from "./schema/user";
|
|
|
12
12
|
// Membership + tenant-specific roles live in the tenant feature.
|
|
13
13
|
export function createUserFeature(): FeatureDefinition {
|
|
14
14
|
return defineFeature("user", (r) => {
|
|
15
|
+
r.describe(
|
|
16
|
+
"Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` \u2014 membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.",
|
|
17
|
+
);
|
|
15
18
|
r.systemScope();
|
|
16
19
|
r.entity("user", userEntity);
|
|
17
20
|
|
|
@@ -84,6 +84,9 @@ export type UserDataRightsOptions = {
|
|
|
84
84
|
|
|
85
85
|
export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
|
|
86
86
|
return defineFeature("user-data-rights", (r) => {
|
|
87
|
+
r.describe(
|
|
88
|
+
'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion` + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
|
|
89
|
+
);
|
|
87
90
|
r.requires("user", "data-retention", "compliance-profiles", "sessions");
|
|
88
91
|
r.usesApi("compliance.forTenant");
|
|
89
92
|
r.usesApi("retention.policyFor");
|
|
@@ -39,6 +39,9 @@ export function createUserDataRightsDefaultsFeature(
|
|
|
39
39
|
): FeatureDefinition {
|
|
40
40
|
const fileRefDeleteHook = createFileRefDeleteHook(options.storageProvider);
|
|
41
41
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
42
|
+
r.describe(
|
|
43
|
+
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the two core entities: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`) and `fileRef` (delete removes both the DB row and the storage binary). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
44
|
+
);
|
|
42
45
|
r.requires("user", "files", "user-data-rights");
|
|
43
46
|
|
|
44
47
|
r.useExtension(EXT_USER_DATA, "user", {
|