@cosmicdrift/kumiko-bundled-features 0.66.0 → 0.67.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.66.0",
3
+ "version": "0.67.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>",
@@ -84,11 +84,11 @@
84
84
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
88
- "@cosmicdrift/kumiko-framework": "0.66.0",
89
- "@cosmicdrift/kumiko-headless": "0.66.0",
90
- "@cosmicdrift/kumiko-renderer": "0.66.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.66.0",
87
+ "@cosmicdrift/kumiko-dispatcher-live": "0.67.0",
88
+ "@cosmicdrift/kumiko-framework": "0.67.0",
89
+ "@cosmicdrift/kumiko-headless": "0.67.0",
90
+ "@cosmicdrift/kumiko-renderer": "0.67.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.67.0",
92
92
  "@mollie/api-client": "^4.5.0",
93
93
  "@node-rs/argon2": "^2.0.2",
94
94
  "@types/nodemailer": "^8.0.0",
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
18
18
  import { configValuesTable } from "@cosmicdrift/kumiko-bundled-features/config";
19
19
  import { tenantSecretsTable } from "@cosmicdrift/kumiko-bundled-features/secrets";
20
20
  import { tenantMembershipsTable, tenantTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
21
+ import { seedTenant } from "@cosmicdrift/kumiko-bundled-features/tenant/seeding";
21
22
  import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
22
23
  import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";
23
24
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -112,7 +113,7 @@ afterAll(async () => stack?.cleanup());
112
113
 
113
114
  beforeEach(async () => {
114
115
  await asRawClient(stack.db).unsafe(
115
- `TRUNCATE read_tier_assignments, kumiko_events RESTART IDENTITY CASCADE`,
116
+ `TRUNCATE read_tier_assignments, read_tenants, kumiko_events RESTART IDENTITY CASCADE`,
116
117
  );
117
118
  });
118
119
 
@@ -227,55 +228,53 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
227
228
  });
228
229
  });
229
230
 
230
- describe("createTierEngineFeature — Trial-Phase (zeit-abgeleitet)", () => {
231
- function sysUser(tenantId: TenantId, id: string) {
232
- return createTestUser({ id, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
231
+ describe("createTierEngineFeature — Trial-Phase (zeit-abgeleitet, Live-Gate)", () => {
232
+ // Der Trial lebt NICHT mehr im Resolver-Feature-Set (sync/boot-cached sieht
233
+ // weder frische Signups noch den Zeitablauf), sondern als async trialGate,
234
+ // der tenant.inserted_at LIVE liest. Diese Tenants entstehen über den ECHTEN
235
+ // seedTenant-Pfad (= auth-signup) — OHNE tier-assignment-Row. Genau dieser
236
+ // Pfad war der Prod-Bug: die alte gecachte Trial-Uhr sah seedTenant-Signups
237
+ // nie (seedTenant umgeht den dispatcher-postSave-Hook).
238
+ async function seedSignup(tenantId: TenantId, key: string) {
239
+ await seedTenant(stack.db, { id: tenantId, key, name: key });
233
240
  }
234
241
 
235
- test("neuer 'free'-Tenant sieht im Fenster die Trial-Features (feat-pro)", async () => {
242
+ test("seedTenant-Signup ohne tier-assignment: trialGate schaltet feat-pro im Fenster frei", async () => {
236
243
  const usage = findTierResolverUsage(featuresWithTrial);
237
244
  if (!usage) throw new Error("setup failure: no trial resolver");
238
245
  const plugin = usage.options as TierResolverPlugin;
239
246
 
240
- // Gespeicherter Tier ist free (keine feat-pro), inserted_at = jetzt → Trial aktiv.
241
- await stack.http.writeOk(
242
- "tier-engine:write:tier-assignment:create",
243
- { tier: "free" },
244
- sysUser(tenantA, "trial-sys-1"),
245
- );
247
+ await seedSignup(tenantA, "trial-a");
246
248
  const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
247
- expect(resolver(tenantA).has("feat-pro")).toBe(true);
249
+
250
+ // Trial sitzt am Gate, nicht im Resolver-Feature-Set.
251
+ expect(resolver(tenantA).has("feat-pro")).toBe(false);
252
+ expect(resolver.trialGate).toBeDefined();
253
+ expect(await resolver.trialGate?.(tenantA, "feat-pro")).toBe(true);
254
+ // Feature außerhalb des Trial-Tiers ("business") bleibt zu.
255
+ expect(await resolver.trialGate?.(tenantA, "feat-business")).toBe(false);
248
256
  });
249
257
 
250
- test("Tenant außerhalb des Fensters (inserted_at > 30 Tage) fällt auf free zurück", async () => {
258
+ test("inserted_at > 30 Tage: trialGate schließt", async () => {
251
259
  const usage = findTierResolverUsage(featuresWithTrial);
252
260
  if (!usage) throw new Error("setup failure: no trial resolver");
253
261
  const plugin = usage.options as TierResolverPlugin;
254
262
 
255
- await stack.http.writeOk(
256
- "tier-engine:write:tier-assignment:create",
257
- { tier: "free" },
258
- sysUser(tenantB, "trial-sys-2"),
259
- );
263
+ await seedSignup(tenantB, "trial-b");
260
264
  // Anlage-Datum künstlich 31 Tage zurückdrehen → Trial abgelaufen. tenantB ist
261
265
  // eine fixe Test-UUID (kein User-Input) → inline-Interpolation unkritisch.
262
266
  await asRawClient(stack.db).unsafe(
263
- `UPDATE read_tier_assignments SET inserted_at = now() - interval '31 days' WHERE tenant_id = '${tenantB}'::uuid`,
267
+ `UPDATE read_tenants SET inserted_at = now() - interval '31 days' WHERE id = '${tenantB}'::uuid`,
264
268
  );
265
269
  const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
266
- expect(resolver(tenantB).has("feat-pro")).toBe(false);
270
+ expect(await resolver.trialGate?.(tenantB, "feat-pro")).toBe(false);
267
271
  });
268
272
 
269
- test("ohne Trial-Option ist der Resolver unverändert (free = keine feat-pro)", async () => {
273
+ test("ohne Trial-Option gibt es keinen trialGate", async () => {
270
274
  const usage = findTierResolverUsage(features);
271
275
  if (!usage) throw new Error("setup failure");
272
276
  const plugin = usage.options as TierResolverPlugin;
273
- await stack.http.writeOk(
274
- "tier-engine:write:tier-assignment:create",
275
- { tier: "free" },
276
- sysUser(tenantA, "no-trial-sys"),
277
- );
278
277
  const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
279
- expect(resolver(tenantA).has("feat-pro")).toBe(false);
278
+ expect(resolver.trialGate).toBeUndefined();
280
279
  });
281
280
  });
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // **Boot-Dependencies:** config + tenant.
42
42
 
43
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
43
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
44
44
  import {
45
45
  buildEntityTable,
46
46
  createEventStoreExecutor,
@@ -60,10 +60,12 @@ import {
60
60
  TENANT_TIER_RESOLVER_EXT,
61
61
  type TenantId,
62
62
  type TierResolverPlugin,
63
+ type TrialGate,
63
64
  } from "@cosmicdrift/kumiko-framework/engine";
64
65
  import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
65
66
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
66
67
  import { z } from "zod";
68
+ import { tenantTable } from "../tenant";
67
69
  import { tierAssignmentAggregateId } from "./aggregate-id";
68
70
  import type { TierMap } from "./compose-app";
69
71
  import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "./constants";
@@ -253,12 +255,13 @@ export function createTierEngineFeature<
253
255
  // Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
254
256
  const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
255
257
 
256
- // Trial-State: tenantId inserted_at als epochMilliseconds (Anlage-Datum
257
- // der Assignment Signup, rebuild-stabil). Trial wird at-resolve-time aus
258
- // (jetzt vs startedAt + durationHours) berechnet, NICHT gecacht anders als
259
- // das Feature-Set ändert sich der Trial-Status mit der Zeit. trialFeatures
260
- // ist die fixe Feature-Menge des Trial-Tiers (einmal aufgelöst).
261
- const trialClock = new Map<TenantId, number>();
258
+ // Trial: zeit-abgeleitet aus tenant.inserted_at (≈ Signup), live am
259
+ // Feature-Gate geprüft NICHT im Resolver-Cache. Der Resolver ist
260
+ // boot-cached + synchron und sieht weder frische Signups noch den
261
+ // Zeitablauf; der Trial-Status ändert sich aber mit der Zeit und gilt ab
262
+ // Sekunde 1 nach Signup. Darum lebt er als async trialGate (unten, am
263
+ // build-Ende angehängt), den der dispatcher nur auf dem disabled-Pfad
264
+ // konsultiert. trialFeatures = fixe Feature-Menge des Trial-Tiers.
262
265
  const trialFeatures: ReadonlySet<string> = opts.trial
263
266
  ? featuresForTier(tierMap, opts.trial.tier)
264
267
  : new Set();
@@ -271,9 +274,6 @@ export function createTierEngineFeature<
271
274
  // Semantik wie der Hook.
272
275
  onTierAssigned.fn = (tenantId, tier) => {
273
276
  cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, tier)));
274
- // Trial-Uhr nur setzen, wenn unbekannt: ein manueller Grant ändert nicht
275
- // das Signup-Datum eines bestehenden Tenants (build() hat es bereits).
276
- if (!trialClock.has(tenantId)) trialClock.set(tenantId, nowMs());
277
277
  };
278
278
 
279
279
  // Invalidation: tier-assignment events update the cache.
@@ -288,10 +288,6 @@ export function createTierEngineFeature<
288
288
  if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
289
289
  const tenantId = data.tenantId as TenantId;
290
290
  cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
291
- // Erstes Assignment eines Tenants = Signup → Trial-Uhr startet jetzt
292
- // (inserted_at der frisch erzeugten Row ≈ now). Spätere Tier-Wechsel
293
- // lassen die Uhr unberührt, sonst würde ein Upgrade das Fenster verlängern.
294
- if (!trialClock.has(tenantId)) trialClock.set(tenantId, nowMs());
295
291
  });
296
292
  r.entityHook("postDelete", "tier-assignment", async (payload) => {
297
293
  const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
@@ -412,20 +408,17 @@ export function createTierEngineFeature<
412
408
  // typischerweise <100k tenants — single-pass scan akzeptabel.
413
409
  // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
414
410
  // Bedürfnis entsteht.
415
- type AssignmentRow = { tenantId: string; tier: string; insertedAt: Temporal.Instant };
411
+ type AssignmentRow = { tenantId: string; tier: string };
416
412
  const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
417
413
  for (const row of rows) {
418
414
  cache.set(
419
415
  row.tenantId as TenantId,
420
416
  mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
421
417
  );
422
- trialClock.set(row.tenantId as TenantId, row.insertedAt.epochMilliseconds);
423
418
  }
424
419
 
425
- const trial = opts.trial;
426
-
427
420
  // Synchronous resolver-callback for dispatcher hot-path.
428
- return (tenantId: TenantId): ReadonlySet<string> => {
421
+ const resolver = (tenantId: TenantId): ReadonlySet<string> => {
429
422
  // Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
430
423
  // gets the union of all tier-features (siehe DispatcherOptions doc).
431
424
  if (tenantId === SYSTEM_TENANT_ID) {
@@ -434,23 +427,43 @@ export function createTierEngineFeature<
434
427
  // Cache-miss: tenant ist noch nicht im cache (z.B. brandneu nach
435
428
  // boot, oder defaultTier-hook hat noch nicht gefired). Default-Set
436
429
  // ist least-privileged — typisch Free-Tier-features. Memory
437
- // `feedback_security_default_on`: secure-by-default.
430
+ // `feedback_security_default_on`: secure-by-default. Der Trial wird
431
+ // hier NICHT addiert (sync/boot-cached sieht den Zeitablauf nicht) —
432
+ // das macht der trialGate unten am disabled-Gate-Pfad.
438
433
  const fallbackTier = opts.defaultTier;
439
- const base =
434
+ return (
440
435
  cache.get(tenantId) ??
441
436
  (fallbackTier === undefined
442
437
  ? computedAlwaysOn
443
- : mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)));
444
- // Trial: innerhalb des Fensters ab Signup zusätzlich die Trial-Tier-
445
- // Features. Zeit-abgeleitet → pro Request geprüft, nie gecacht.
446
- if (trial !== undefined) {
447
- const startedMs = trialClock.get(tenantId);
448
- if (startedMs !== undefined && isTrialActive(startedMs, nowMs(), trial.durationHours)) {
449
- return mergeAlwaysOn(base, trialFeatures);
450
- }
438
+ : mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)))
439
+ );
440
+ };
441
+
442
+ const trial = opts.trial;
443
+ if (trial === undefined) return resolver;
444
+
445
+ // Live-Trial-Gate: liest tenant.inserted_at (≈ Signup, existiert für
446
+ // JEDEN Tenant inkl. auth-signup via seedTenant — anders als die
447
+ // tier-assignment-Row, die der seed-Pfad nicht anlegt) und prüft das
448
+ // Fenster gegen die aktuelle Zeit. Nur auf dem disabled-Gate-Pfad
449
+ // konsultiert. inserted_at ist immutable → pro Tenant einmal lesen.
450
+ const startedMemo = new Map<TenantId, number>();
451
+ const trialGate: TrialGate = async (tenantId, featureName) => {
452
+ if (!trialFeatures.has(featureName)) return false;
453
+ let startedMs = startedMemo.get(tenantId);
454
+ if (startedMs === undefined) {
455
+ const row = await fetchOne<{ insertedAt?: Temporal.Instant }>(deps.db, tenantTable, {
456
+ id: tenantId,
457
+ });
458
+ // Tenant-Row noch nicht projiziert (Replay-Race) → keinen Miss
459
+ // memoizen, beim nächsten Request neu lesen.
460
+ if (row?.insertedAt === undefined) return false;
461
+ startedMs = row.insertedAt.epochMilliseconds;
462
+ startedMemo.set(tenantId, startedMs);
451
463
  }
452
- return base;
464
+ return isTrialActive(startedMs, nowMs(), trial.durationHours);
453
465
  };
466
+ return Object.assign(resolver, { trialGate });
454
467
  },
455
468
  };
456
469
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a framework registrar method, not a React hook.