@cosmicdrift/kumiko-bundled-features 0.57.2 → 0.60.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 (53) hide show
  1. package/package.json +10 -7
  2. package/src/auth-email-password/i18n.ts +2 -0
  3. package/src/config/__tests__/app-override-visibility.integration.test.ts +6 -0
  4. package/src/config/__tests__/backing-secrets.integration.test.ts +38 -0
  5. package/src/config/__tests__/inherited-redaction.integration.test.ts +29 -0
  6. package/src/config/handlers/cascade.query.ts +1 -3
  7. package/src/config/handlers/readiness.query.ts +6 -0
  8. package/src/config/handlers/values.query.ts +1 -3
  9. package/src/config/read-redaction.ts +13 -2
  10. package/src/custom-fields/__tests__/feature.test.ts +57 -4
  11. package/src/custom-fields/feature.ts +19 -4
  12. package/src/files-provider-s3/__tests__/s3-provider.test.ts +61 -1
  13. package/src/files-provider-s3/s3-provider.ts +9 -3
  14. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +92 -1
  15. package/src/managed-pages/handlers/set.write.ts +14 -4
  16. package/src/subscription-stripe/__tests__/runtime.test.ts +59 -5
  17. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +105 -0
  18. package/src/subscription-stripe/feature.ts +2 -1
  19. package/src/tags/__tests__/drift.test.ts +46 -0
  20. package/src/tags/__tests__/feature.test.ts +155 -0
  21. package/src/tags/__tests__/tags.integration.test.ts +251 -0
  22. package/src/tags/aggregate-id.ts +23 -0
  23. package/src/tags/constants.ts +37 -0
  24. package/src/tags/entity.ts +35 -0
  25. package/src/tags/executor.ts +11 -0
  26. package/src/tags/feature.ts +75 -0
  27. package/src/tags/handlers/assign-tag.write.ts +48 -0
  28. package/src/tags/handlers/create-tag.write.ts +23 -0
  29. package/src/tags/handlers/remove-tag.write.ts +34 -0
  30. package/src/tags/index.ts +30 -0
  31. package/src/tags/schemas.ts +20 -0
  32. package/src/template-resolver/README.md +22 -0
  33. package/src/template-resolver/__tests__/conformance.integration.test.ts +79 -0
  34. package/src/template-resolver/testing.ts +192 -0
  35. package/src/tier-engine/__tests__/drift.test.ts +4 -0
  36. package/src/tier-engine/__tests__/resolver.integration.test.ts +30 -0
  37. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +118 -0
  38. package/src/tier-engine/constants.ts +13 -0
  39. package/src/tier-engine/entity.ts +5 -0
  40. package/src/tier-engine/feature.ts +51 -3
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +36 -0
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +99 -0
  43. package/src/tier-engine/i18n.ts +39 -0
  44. package/src/tier-engine/web/client-plugin.tsx +27 -0
  45. package/src/tier-engine/web/index.ts +8 -0
  46. package/src/tier-engine/web/tier-admin-screen.tsx +161 -0
  47. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  48. package/src/user-data-rights/deletion-token.ts +9 -3
  49. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +22 -3
  50. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +37 -43
  51. package/src/user-profile/__tests__/profile-screen.test.tsx +61 -3
  52. package/src/user-profile/i18n.ts +2 -3
  53. package/src/user-profile/web/profile-screen.tsx +29 -5
@@ -167,6 +167,36 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
167
167
  expect(resolver(tenantA).has("feat-pro")).toBe(true);
168
168
  });
169
169
 
170
+ test("(4) set-tenant-tier reflects in resolver — effective gating, not just projection", async () => {
171
+ // Kern-Zweck von #434: ein manueller Grant muss das EFFEKTIVE Feature-Set
172
+ // ändern (Resolver-Cache), nicht nur die Projektion. set-tenant-tier
173
+ // schreibt direkt über den Executor — feuert das den postSave-Hook, der
174
+ // den Cache aktualisiert? Stale-Upgrade free→pro deckt den Fall ab, den
175
+ // der cache-miss-Fallback NICHT rettet.
176
+ const usage = findTierResolverUsage(features);
177
+ if (!usage) throw new Error("setup failure");
178
+ const plugin = usage.options as TierResolverPlugin;
179
+
180
+ const sysA = createTestUser({
181
+ id: "sys-4",
182
+ tenantId: tenantA,
183
+ roles: ["SystemAdmin", "TenantAdmin"],
184
+ });
185
+ await stack.http.writeOk("tier-engine:write:tier-assignment:create", { tier: "free" }, sysA);
186
+
187
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
188
+ expect(resolver(tenantA).has("feat-pro")).toBe(false);
189
+
190
+ // Manueller Grant via set-tenant-tier (cross-tenant-fähig, hier eigener Tenant).
191
+ await stack.http.writeOk(
192
+ "tier-engine:write:set-tenant-tier",
193
+ { tenantId: tenantA, tier: "pro" },
194
+ sysA,
195
+ );
196
+
197
+ expect(resolver(tenantA).has("feat-pro")).toBe(true);
198
+ });
199
+
170
200
  test("(3) SYSTEM_TENANT_ID returns union of all tier-features", async () => {
171
201
  const usage = findTierResolverUsage(features);
172
202
  if (!usage) throw new Error("setup failure");
@@ -279,6 +279,7 @@ describe("scenario 6: access control", () => {
279
279
  });
280
280
 
281
281
  test("query handlers carry the admin-only access rule (config-level check)", () => {
282
+ // (siehe Scenario 7 für die set-tenant-tier/get-tenant-tier Reads)
282
283
  // Read-access is enforced by the same role-rule set on the query handler.
283
284
  // We assert the rule is registered correctly — covers regression when
284
285
  // someone changes adminAccess to openToAll without noticing.
@@ -294,3 +295,120 @@ describe("scenario 6: access control", () => {
294
295
  expect(JSON.stringify(activeTierRule)).toMatch(/SystemAdmin/);
295
296
  });
296
297
  });
298
+
299
+ // --- Scenario 7: manueller cross-tenant Tier-Grant (set-tenant-tier) ---
300
+ //
301
+ // Kern-Sicherheitsgrenze von #434: ein SystemAdmin sitzt in seinem eigenen
302
+ // Tenant, setzt aber das Tier eines FREMDEN Tenants — ohne Billing-Kauf.
303
+ // Der Event muss im Stream des Ziel-Tenants landen (nicht im Admin-Tenant),
304
+ // `source: "manual"` tragen und nur für SystemAdmin erreichbar sein.
305
+
306
+ type SetTenantTierResult = { tenantId: string; tier: string; isNew: boolean };
307
+
308
+ describe("scenario 7: cross-tenant manual grant", () => {
309
+ test("SystemAdmin sets a FOREIGN tenant's tier — lands in the target stream, source=manual", async () => {
310
+ const adminTenant = testTenantId(401);
311
+ const targetTenant = testTenantId(402);
312
+ const sysadmin = createTestUser({ id: 401, tenantId: adminTenant, roles: ["SystemAdmin"] });
313
+
314
+ const result = await stack.http.writeOk<SetTenantTierResult>(
315
+ TierEngineHandlers.setTenantTier,
316
+ { tenantId: targetTenant, tier: "pro" },
317
+ sysadmin,
318
+ );
319
+ expect(result.tenantId).toBe(targetTenant);
320
+ expect(result.tier).toBe("pro");
321
+ expect(result.isNew).toBe(true);
322
+
323
+ // Beweis, dass der Event im Ziel-Stream liegt: ein Admin IM Ziel-Tenant
324
+ // liest sein eigenes get-active-tier (own-tenant-scoped) und sieht "pro".
325
+ const targetAdmin = createTestUser({ id: 402, tenantId: targetTenant, roles: ["TenantAdmin"] });
326
+ const seenByTarget = await stack.http.queryOk<Record<string, unknown> | null>(
327
+ TierEngineQueries.getActiveTier,
328
+ {},
329
+ targetAdmin,
330
+ );
331
+ expect(seenByTarget!["tier"]).toBe("pro");
332
+
333
+ // get-tenant-tier (cross-tenant Read, SystemAdmin) liefert source=manual.
334
+ const grant = await stack.http.queryOk<Record<string, unknown> | null>(
335
+ TierEngineQueries.getTenantTier,
336
+ { tenantId: targetTenant },
337
+ sysadmin,
338
+ );
339
+ expect(grant!["tier"]).toBe("pro");
340
+ expect(grant!["source"]).toBe("manual");
341
+
342
+ // Der Admin-eigene Tenant bleibt unberührt — kein Tier dort geleakt.
343
+ const seenByAdmin = await stack.http.queryOk<Record<string, unknown> | null>(
344
+ TierEngineQueries.getActiveTier,
345
+ {},
346
+ sysadmin,
347
+ );
348
+ expect(seenByAdmin).toBeNull();
349
+ });
350
+
351
+ test("upsert is idempotent — second set updates the same aggregate (isNew:false)", async () => {
352
+ const sysadmin = createTestUser({
353
+ id: 410,
354
+ tenantId: testTenantId(410),
355
+ roles: ["SystemAdmin"],
356
+ });
357
+ const target = testTenantId(411);
358
+
359
+ const first = await stack.http.writeOk<SetTenantTierResult>(
360
+ TierEngineHandlers.setTenantTier,
361
+ { tenantId: target, tier: "pro" },
362
+ sysadmin,
363
+ );
364
+ expect(first.isNew).toBe(true);
365
+
366
+ const second = await stack.http.writeOk<SetTenantTierResult>(
367
+ TierEngineHandlers.setTenantTier,
368
+ { tenantId: target, tier: "business" },
369
+ sysadmin,
370
+ );
371
+ expect(second.isNew).toBe(false);
372
+ expect(second.tier).toBe("business");
373
+
374
+ const grant = await stack.http.queryOk<Record<string, unknown> | null>(
375
+ TierEngineQueries.getTenantTier,
376
+ { tenantId: target },
377
+ sysadmin,
378
+ );
379
+ expect(grant!["tier"]).toBe("business");
380
+ });
381
+
382
+ test("TenantAdmin cannot set a foreign tenant's tier — fail-closed", async () => {
383
+ const tenantAdmin = createTestUser({
384
+ id: 420,
385
+ tenantId: testTenantId(420),
386
+ roles: ["TenantAdmin"],
387
+ });
388
+
389
+ const error = await stack.http.writeErr(
390
+ TierEngineHandlers.setTenantTier,
391
+ { tenantId: testTenantId(421), tier: "pro" },
392
+ tenantAdmin,
393
+ );
394
+ expectErrorIncludes(error, "access_denied");
395
+ });
396
+
397
+ test("normal User cannot set a tier and cannot read get-tenant-tier", async () => {
398
+ const normalUser = TestUsers.user;
399
+
400
+ const writeError = await stack.http.writeErr(
401
+ TierEngineHandlers.setTenantTier,
402
+ { tenantId: testTenantId(431), tier: "pro" },
403
+ normalUser,
404
+ );
405
+ expectErrorIncludes(writeError, "access_denied");
406
+
407
+ const res = await stack.http.query(
408
+ TierEngineQueries.getTenantTier,
409
+ { tenantId: testTenantId(431) },
410
+ normalUser,
411
+ );
412
+ expect(res.status).toBe(403);
413
+ });
414
+ });
@@ -1,3 +1,9 @@
1
+ // @runtime client
2
+ // Pure string-literal QNs/ids — von Server (feature.ts) UND Client
3
+ // (web/tier-admin-screen, client-plugin) importiert. Als `client` markiert,
4
+ // die im kumiko-Isolation-Modell permissivste Kategorie (runtime darf
5
+ // client importieren, client nur client) — sonst wirft der Runtime-
6
+ // Isolation-Guard auf den Client-Imports. Muster wie text-content/constants.
1
7
  // Feature name
2
8
  export const TIER_ENGINE_FEATURE = "tier-engine" as const;
3
9
 
@@ -6,10 +12,17 @@ export const TIER_ENGINE_FEATURE = "tier-engine" as const;
6
12
  export const TierEngineHandlers = {
7
13
  create: "tier-engine:write:tier-assignment:create",
8
14
  update: "tier-engine:write:tier-assignment:update",
15
+ setTenantTier: "tier-engine:write:set-tenant-tier",
9
16
  } as const;
10
17
 
11
18
  // Qualified query handler names.
12
19
  export const TierEngineQueries = {
13
20
  list: "tier-engine:query:tier-assignment:list",
14
21
  getActiveTier: "tier-engine:query:get-active-tier",
22
+ getTenantTier: "tier-engine:query:get-tenant-tier",
23
+ tierOptions: "tier-engine:query:tier-options",
15
24
  } as const;
25
+
26
+ // Screen-id für den manuellen Tier-Grant-Screen. Apps referenzieren ihn
27
+ // qualifiziert in r.nav: `tier-engine:screen:tier-admin`.
28
+ export const TIER_ADMIN_SCREEN_ID = "tier-admin" as const;
@@ -26,5 +26,10 @@ export const tierAssignmentEntity = createEntity({
26
26
  table: "read_tier_assignments",
27
27
  fields: {
28
28
  tier: createTextField({ required: true, maxLength: 50 }),
29
+ // Woher das Assignment stammt: "manual" (Admin-Grant via tier-admin-Screen),
30
+ // "stripe" (future Billing-Sync), "default" (auto-default-on-signup-Hook).
31
+ // Optional für Back-Compat zu bestehenden Rows ohne source. Schützt manuelle
32
+ // Grants davor, von einem späteren Stripe→Tier-Sync geplättet zu werden.
33
+ source: createTextField({ required: false, maxLength: 20 }),
29
34
  },
30
35
  });
@@ -52,6 +52,7 @@ import {
52
52
  defineEntityListHandler,
53
53
  defineEntityUpdateHandler,
54
54
  defineFeature,
55
+ defineQueryHandler,
55
56
  type FeatureDefinition,
56
57
  HookPhases,
57
58
  type SessionUser,
@@ -61,11 +62,14 @@ import {
61
62
  type TierResolverPlugin,
62
63
  } from "@cosmicdrift/kumiko-framework/engine";
63
64
  import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
65
+ import { z } from "zod";
64
66
  import { tierAssignmentAggregateId } from "./aggregate-id";
65
67
  import type { TierMap } from "./compose-app";
66
- import { TIER_ENGINE_FEATURE } from "./constants";
68
+ import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "./constants";
67
69
  import { tierAssignmentEntity } from "./entity";
68
70
  import { getActiveTierQuery } from "./handlers/active-tier.query";
71
+ import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
72
+ import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
69
73
 
70
74
  // Drizzle-table for the tier-assignment-entity. Built once at module-load
71
75
  // from the entity definition — same shape buildEntityTable would produce
@@ -168,7 +172,7 @@ export function createTierEngineFeature<
168
172
  >(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
169
173
  return defineFeature(TIER_ENGINE_FEATURE, (r) => {
170
174
  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`.",
175
+ '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`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen.',
172
176
  );
173
177
  r.requires("config");
174
178
  r.requires("tenant");
@@ -183,6 +187,41 @@ export function createTierEngineFeature<
183
187
  r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));
184
188
  r.queryHandler(getActiveTierQuery);
185
189
 
190
+ // \u2500\u2500 Manueller Tier-Grant (SystemAdmin, ohne Billing) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
191
+ // Cross-tenant set + read f\u00fcr den tier-admin-Screen. tier-options liefert
192
+ // dem Client die App-Tier-Namen aus der tierMap-Closure (sonst hartkodiert).
193
+ //
194
+ // onAssigned h\u00e4lt den Resolver-Cache nach einem direkten Executor-Write
195
+ // warm (der den postSave-Hook NICHT feuert). Late-bind via Holder: ohne
196
+ // tierMap bleibt es no-op (kein Resolver), im tierMap-Block unten wird
197
+ // die echte Cache-Update-Funktion eingeh\u00e4ngt \u2014 analog alwaysOnHolder.
198
+ const onTierAssigned: { fn: (tenantId: TenantId, tier: string) => void } = { fn: () => {} };
199
+ r.writeHandler(
200
+ createSetTenantTierWrite({
201
+ onAssigned: (tenantId, tier) => onTierAssigned.fn(tenantId, tier),
202
+ }),
203
+ );
204
+ r.queryHandler(getTenantTierQuery);
205
+ r.queryHandler(
206
+ defineQueryHandler({
207
+ name: "tier-options",
208
+ schema: z.object({}),
209
+ access: { roles: ["SystemAdmin"] },
210
+ handler: async () => ({ tiers: opts.tierMap ? Object.keys(opts.tierMap) : [] }),
211
+ }),
212
+ );
213
+
214
+ // Custom React-Screen für den manuellen Grant. SystemAdmin-only fest
215
+ // verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
216
+ // platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
217
+ // Komponente liefert tierEngineClient() aus dem ./web-subpath.
218
+ r.screen({
219
+ id: TIER_ADMIN_SCREEN_ID,
220
+ type: "custom",
221
+ renderer: { react: { __component: "TierAdminScreen" } },
222
+ access: { roles: ["SystemAdmin"] },
223
+ });
224
+
186
225
  // ───────────────────────────────────────────────────────────────────
187
226
  // Resolver-extension (only when tierMap is configured)
188
227
  // ───────────────────────────────────────────────────────────────────
@@ -203,6 +242,15 @@ export function createTierEngineFeature<
203
242
  // Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
204
243
  const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
205
244
 
245
+ // set-tenant-tier schreibt direkt über den Executor → der postSave-Hook
246
+ // unten feuert dabei NICHT. Diese Funktion repliziert den Cache-Update
247
+ // des Hooks, damit ein manueller Grant das effektive Feature-Set sofort
248
+ // ändert (nicht nur die Projektion). Selber Cache, selbe mergeAlwaysOn-
249
+ // Semantik wie der Hook.
250
+ onTierAssigned.fn = (tenantId, tier) => {
251
+ cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
252
+ };
253
+
206
254
  // Invalidation: tier-assignment events update the cache.
207
255
  r.entityHook("postSave", "tier-assignment", async (result) => {
208
256
  // result.data has tenantId + tier (after entity-update merge)
@@ -299,7 +347,7 @@ export function createTierEngineFeature<
299
347
  const tdb = createTenantDb(rawDb, newTenantId, "system");
300
348
 
301
349
  await tierAssignmentExecutor.create(
302
- { id: aggregateId, tier: defaultTier },
350
+ { id: aggregateId, tier: defaultTier, source: "default" },
303
351
  systemUser,
304
352
  tdb,
305
353
  );
@@ -0,0 +1,36 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ buildEntityTable,
4
+ createTenantDb,
5
+ type DbConnection,
6
+ } from "@cosmicdrift/kumiko-framework/db";
7
+ import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { z } from "zod";
9
+ import { tierAssignmentEntity } from "../entity";
10
+
11
+ // Liest das Tier-Assignment eines BELIEBIGEN Tenants (cross-tenant) für den
12
+ // tier-admin-Screen. SystemAdmin-only. get-active-tier liest nur den eigenen
13
+ // Tenant — hier ein "system"-mode TenantDb auf den Ziel-Tenant (kein Filter),
14
+ // damit der Admin das Tier fremder Tenants sehen kann. null wenn noch keins.
15
+
16
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
17
+
18
+ type TierAssignmentRow = {
19
+ readonly id: string;
20
+ readonly version: number;
21
+ readonly tier: string;
22
+ readonly source: string | null;
23
+ readonly tenantId: string;
24
+ };
25
+
26
+ export const getTenantTierQuery = defineQueryHandler({
27
+ name: "get-tenant-tier",
28
+ schema: z.object({ tenantId: z.string().min(1) }),
29
+ access: { roles: ["SystemAdmin"] },
30
+ handler: async (query, ctx) => {
31
+ const tenantId = query.payload.tenantId as TenantId; // @cast-boundary engine-bridge
32
+ const tdb = createTenantDb(ctx.db.raw as DbConnection, tenantId, "system"); // @cast-boundary db-runner
33
+ const row = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
34
+ return row ?? null;
35
+ },
36
+ });
@@ -0,0 +1,99 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ buildEntityTable,
4
+ createEventStoreExecutor,
5
+ createTenantDb,
6
+ type DbConnection,
7
+ } from "@cosmicdrift/kumiko-framework/db";
8
+ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { z } from "zod";
10
+ import { tierAssignmentAggregateId } from "../aggregate-id";
11
+ import { tierAssignmentEntity } from "../entity";
12
+
13
+ // SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
14
+ // Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
15
+ // Gratis-Self-Upgrade).
16
+ //
17
+ // **Cross-tenant-Mechanik:** ein "system"-mode TenantDb auf den Ziel-Tenant legt
18
+ // KEINEN Tenant-Filter an (tenant-db.ts:141 — mode==="system" überspringt ihn);
19
+ // der executor-user wird ebenfalls auf den Ziel-Tenant gestellt, sonst landet das
20
+ // Event im Stream des Admins (Memory feedback_event_store_tenant_consistency).
21
+ // Das set.write-"override-user"-Muster trägt NICHT für beliebige Tenants — es
22
+ // funktioniert nur für SYSTEM_TENANT_ID (immer im IN-Filter). Dies ist das
23
+ // auto-default-Hook-Muster (feature.ts), generalisiert auf einen Request-Handler.
24
+ //
25
+ // `source: "manual"` markiert den Grant, damit ein späterer Stripe→Tier-Sync ihn
26
+ // nicht plättet. Upsert: ein Aggregat pro Tenant (deterministische aggregate-id).
27
+ //
28
+ // **Effective-Set-Invalidation (kritisch):** der Executor-Write feuert NICHT
29
+ // den `tier-assignment:postSave`-entityHook (Hooks laufen nur im Entity-
30
+ // Handler-Pfad, nicht bei direktem executor.create/update). Ohne Cache-Update
31
+ // bliebe das Feature-Gate auf dem alten Tier hängen — die Projektion zeigt
32
+ // "pro", das Gate verhält sich weiter wie "free", bis der Prozess neu startet.
33
+ // Daher ruft der Handler nach erfolgreichem Write `opts.onAssigned(tenantId,
34
+ // tier)`; feature.ts verdrahtet das auf denselben Cache-Update wie der Hook
35
+ // (storage-only ohne tierMap = no-op).
36
+
37
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
38
+ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEntity, {
39
+ entityName: "tier-assignment",
40
+ });
41
+
42
+ type TierAssignmentRow = {
43
+ readonly id: string;
44
+ readonly version: number;
45
+ readonly tier: string;
46
+ readonly source: string | null;
47
+ readonly tenantId: string;
48
+ };
49
+
50
+ export type SetTenantTierOptions = {
51
+ /** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
52
+ * Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
53
+ * nicht). Ohne tierMap kein Resolver → no-op. */
54
+ readonly onAssigned?: (tenantId: TenantId, tier: string) => void;
55
+ };
56
+
57
+ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
58
+ return defineWriteHandler({
59
+ name: "set-tenant-tier",
60
+ schema: z.object({
61
+ tenantId: z.string().min(1),
62
+ tier: z.string().min(1).max(50),
63
+ }),
64
+ access: { roles: ["SystemAdmin"] },
65
+ handler: async (event, ctx) => {
66
+ const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
67
+ const rawDb = ctx.db.raw as DbConnection; // @cast-boundary db-runner
68
+ const tdb = createTenantDb(rawDb, tenantId, "system");
69
+ const systemUser = { ...event.user, tenantId };
70
+ const tier = event.payload.tier;
71
+
72
+ const existing = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
73
+
74
+ if (existing) {
75
+ const result = await executor.update(
76
+ {
77
+ id: existing.id,
78
+ version: existing.version,
79
+ changes: { tier, source: "manual" },
80
+ },
81
+ systemUser,
82
+ tdb,
83
+ );
84
+ if (!result.isSuccess) return result;
85
+ opts.onAssigned?.(tenantId, tier);
86
+ return { isSuccess: true as const, data: { tenantId, tier, isNew: false } };
87
+ }
88
+
89
+ const result = await executor.create(
90
+ { id: tierAssignmentAggregateId(tenantId), tier, source: "manual", tenantId },
91
+ systemUser,
92
+ tdb,
93
+ );
94
+ if (!result.isSuccess) return result;
95
+ opts.onAssigned?.(tenantId, tier);
96
+ return { isSuccess: true as const, data: { tenantId, tier, isNew: true } };
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,39 @@
1
+ // @runtime client
2
+ // Default-Bundles für den TierAdminScreen. Werden vom tierEngineClient()
3
+ // als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
4
+ // einzelne Keys via `tierEngineClient({ translations: { de: { … } } })`.
5
+
6
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
+
8
+ export const defaultTranslations: TranslationsByLocale = {
9
+ de: {
10
+ "tier-admin.title": "Tier manuell zuweisen",
11
+ "tier-admin.explainer":
12
+ "Weise einem Tenant ein Tier ohne Kauf zu. Der Grant wird als „manuell“ markiert und von einem späteren Billing-Sync nicht überschrieben.",
13
+ "tier-admin.tenant.label": "Tenant",
14
+ "tier-admin.current.label": "Aktuelles Tier",
15
+ "tier-admin.current.none": "— noch keins —",
16
+ "tier-admin.tier.label": "Neues Tier",
17
+ "tier-admin.submit": "Tier zuweisen",
18
+ "tier-admin.success": "Tier „{tier}“ zugewiesen.",
19
+ "tier-admin.error.generic": "Konnte das Tier nicht zuweisen.",
20
+ "tier-admin.error.load": "Tenants konnten nicht geladen werden.",
21
+ "tier-admin.error.noTiers":
22
+ "Diese App hat keine TierMap konfiguriert — es gibt keine zuweisbaren Tiers.",
23
+ },
24
+ en: {
25
+ "tier-admin.title": "Assign tier manually",
26
+ "tier-admin.explainer":
27
+ "Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
28
+ "tier-admin.tenant.label": "Tenant",
29
+ "tier-admin.current.label": "Current tier",
30
+ "tier-admin.current.none": "— none yet —",
31
+ "tier-admin.tier.label": "New tier",
32
+ "tier-admin.submit": "Assign tier",
33
+ "tier-admin.success": "Assigned tier “{tier}”.",
34
+ "tier-admin.error.generic": "Could not assign the tier.",
35
+ "tier-admin.error.load": "Failed to load tenants.",
36
+ "tier-admin.error.noTiers":
37
+ "This app has no TierMap configured — there are no assignable tiers.",
38
+ },
39
+ };
@@ -0,0 +1,27 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für tier-engine. Liefert den TierAdminScreen
3
+ // (gemappt auf die Screen-id "tier-admin") + Default-Translations. Apps
4
+ // hängen es in createKumikoApp({ clientFeatures: [tierEngineClient()] }) ein;
5
+ // der Screen selbst wird server-seitig vom Feature als custom-Screen
6
+ // registriert (r.screen), die App platziert ihn nur via r.nav.
7
+
8
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
10
+ import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "../constants";
11
+ import { defaultTranslations } from "../i18n";
12
+ import { TierAdminScreen } from "./tier-admin-screen";
13
+
14
+ export type TierEngineClientOptions = {
15
+ /** Key-weise Overrides über die Default-Bundles (de/en). */
16
+ readonly translations?: TranslationsByLocale;
17
+ };
18
+
19
+ export function tierEngineClient(options?: TierEngineClientOptions): ClientFeatureDefinition {
20
+ return {
21
+ name: TIER_ENGINE_FEATURE,
22
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
23
+ components: {
24
+ [TIER_ADMIN_SCREEN_ID]: TierAdminScreen,
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des tier-engine Features. Konsumiert
3
+ // über `@cosmicdrift/kumiko-bundled-features/tier-engine/web` — die
4
+ // Server-Seite (createTierEngineFeature) lebt unter
5
+ // `@cosmicdrift/kumiko-bundled-features/tier-engine` und hat keine React-Deps.
6
+
7
+ export { type TierEngineClientOptions, tierEngineClient } from "./client-plugin";
8
+ export { TierAdminScreen } from "./tier-admin-screen";