@cosmicdrift/kumiko-bundled-features 0.65.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.65.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.65.0",
88
- "@cosmicdrift/kumiko-framework": "0.65.0",
89
- "@cosmicdrift/kumiko-headless": "0.65.0",
90
- "@cosmicdrift/kumiko-renderer": "0.65.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.65.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",
@@ -98,6 +98,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
98
98
  // client can observe it through the HTTP surface.
99
99
  if (!user || user.isDeleted || !user.email || spec.extraSilentSkip(user)) {
100
100
  const data: TokenRequestData<TSuccessKind> = { kind: "no-op" };
101
+ // skip: silent no-op — uniform response prevents user-enumeration probing
101
102
  return { isSuccess: true, data };
102
103
  }
103
104
 
@@ -87,6 +87,7 @@ export async function collectMissingRequiredConfig(
87
87
  if (keyDef.required !== true) continue;
88
88
  if (!effectiveGate(qualifiedKey)) continue;
89
89
  if (options?.skipAccessFilter !== true && !hasConfigAccess(keyDef.access.read, user.roles)) {
90
+ // skip: key not visible to this user's roles (access-filtered listing)
90
91
  continue;
91
92
  }
92
93
  candidates.set(qualifiedKey, keyDef);
@@ -334,11 +334,13 @@ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
334
334
  </Wrapper>,
335
335
  );
336
336
 
337
- // boolean rendert als Checkbox Bestand steckt in checked, nicht value.
338
- const input = document.getElementById("custom-field-active") as HTMLInputElement;
339
- expect(input.checked).toBe(true);
337
+ // boolean = vendored Radix-Checkbox button[role=checkbox], Bestand steckt
338
+ // in aria-checked (kein natives .checked).
339
+ const checkbox = document.getElementById("custom-field-active");
340
+ if (checkbox === null) throw new Error("boolean checkbox not rendered");
341
+ expect(checkbox.getAttribute("aria-checked")).toBe("true");
340
342
 
341
- fireEvent.click(input);
343
+ fireEvent.click(checkbox);
342
344
  fireEvent.click(screen.getByTestId("custom-fields-form-save"));
343
345
  await Promise.resolve();
344
346
  await Promise.resolve();
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { legalPagesClient } from "../client-plugin";
4
+
5
+ // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach,
6
+ // no-leak ohne navId, und der Unwrap (Provider emittiert die slug-Folder
7
+ // direkt, NICHT mehr unter einem "Legal"-Wrapper — der App-r.nav-Knoten
8
+ // IST der Container). legal-pages ist fetch-frei → Provider direkt aufrufbar.
9
+
10
+ function collect(
11
+ provider: () => (emit: (n: readonly TreeNode[]) => void) => () => void,
12
+ ): readonly TreeNode[] {
13
+ let emitted: readonly TreeNode[] | undefined;
14
+ const unsub = provider()((nodes) => {
15
+ emitted = nodes;
16
+ });
17
+ unsub();
18
+ if (emitted === undefined) throw new Error("provider emitted nothing");
19
+ return emitted;
20
+ }
21
+
22
+ describe("legalPagesClient", () => {
23
+ test("ohne navId: kein navProvider (server-only-Consumer leaken keinen Node)", () => {
24
+ const def = legalPagesClient();
25
+ expect(def.name).toBe("legal-pages");
26
+ expect(def.navProviders).toBeUndefined();
27
+ });
28
+
29
+ test("mit navId: Provider hängt unter exakt dieser (pass-through) QN", () => {
30
+ const navId = "publicstatus:nav:legal";
31
+ const def = legalPagesClient({ navId });
32
+ expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
33
+ });
34
+
35
+ test("Provider unwrappt den Legal-Container: Top-Level sind die slug-Folder", () => {
36
+ const navId = "publicstatus:nav:legal";
37
+ const provider = legalPagesClient({ navId }).navProviders?.[navId];
38
+ if (provider === undefined) throw new Error("provider missing");
39
+ const emitted = collect(provider);
40
+
41
+ // Kein "Legal"-Wrapper mehr — der App-Knoten ist der Container.
42
+ expect(emitted.some((n) => n.label === "Legal")).toBe(false);
43
+ expect(emitted.length).toBeGreaterThan(0);
44
+
45
+ // Jeder Top-Level-Knoten ist ein slug-Folder mit lang-Leaves, die per
46
+ // Cross-Link auf text-content:edit zeigen.
47
+ const folder = emitted[0];
48
+ expect(Array.isArray(folder?.children)).toBe(true);
49
+ const langLeaf = Array.isArray(folder?.children) ? folder?.children[0] : undefined;
50
+ expect(langLeaf?.target?.featureId).toBe("text-content");
51
+ expect(langLeaf?.target?.action).toBe("edit");
52
+ });
53
+ });
@@ -63,20 +63,19 @@ const treeProvider: TreeChildrenSubscribe = () => (emit) => {
63
63
  });
64
64
  }
65
65
 
66
- emit([
67
- {
68
- label: "Legal",
69
- icon: "folder",
70
- state: "filled",
71
- children: slugFolders,
72
- },
73
- ]);
66
+ // Der App-seitige r.nav-Knoten IST der "Legal"-Container → der Provider
67
+ // emittiert die slug-Folder direkt darunter.
68
+ emit(slugFolders);
74
69
  return () => {};
75
70
  };
76
71
 
77
- export function legalPagesClient(): ClientFeatureDefinition {
72
+ // `navId` = QN des r.nav({ provider: true })-Knotens den die App registriert.
73
+ // Statisch (kein Fetch, keine Entities) → kein SSE-Refresh nötig. Ohne navId:
74
+ // kein Sidebar-Knoten (server-only-Consumer leaken nichts).
75
+ export function legalPagesClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
76
+ const navId = opts?.navId;
78
77
  return {
79
78
  name: "legal-pages",
80
- treeProvider,
79
+ ...(navId !== undefined && { navProviders: { [navId]: treeProvider } }),
81
80
  };
82
81
  }
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { textContentClient } from "../client-plugin";
4
+
5
+ // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach + SSE-
6
+ // Entities, no-leak ohne navId (conditional-spread), und der Unwrap (Provider
7
+ // emittiert die Folder/Leaves direkt, NICHT unter dem "Content"-Wrapper).
8
+ // Der Provider fetcht → fetch wird gemockt.
9
+
10
+ describe("textContentClient — shape", () => {
11
+ test("ohne navId: kein navProvider/navEntities (no-leak), aber Resolver bleibt", () => {
12
+ const def = textContentClient();
13
+ expect(def.name).toBe("text-content");
14
+ expect(def.navProviders).toBeUndefined();
15
+ expect(def.navEntities).toBeUndefined();
16
+ expect(def.resolvers?.["text-content:edit"]).toBeDefined();
17
+ });
18
+
19
+ test("mit navId: Provider + SSE-Entities unter exakt dieser QN", () => {
20
+ const navId = "publicstatus:nav:content";
21
+ const def = textContentClient({ navId });
22
+ expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
23
+ expect(def.navEntities?.[navId]).toEqual(["text-block"]);
24
+ });
25
+ });
26
+
27
+ describe("textContentClient — Provider unwrappt den Content-Container", () => {
28
+ const origFetch = globalThis.fetch;
29
+ afterEach(() => {
30
+ globalThis.fetch = origFetch;
31
+ });
32
+
33
+ test("emittiert Folder/Leaves direkt, kein 'Content'-Wrapper-Knoten", async () => {
34
+ const blocks = [
35
+ { slug: "imprint", lang: "de", title: "Imprint", body: "x", folder: null, updatedAt: "" },
36
+ { slug: "hero", lang: "de", title: "Hero", body: null, folder: "page", updatedAt: "" },
37
+ ];
38
+ // Test-Mock-Grenze: bun-Mock deckt nicht die volle fetch-Signatur
39
+ // (preconnect etc.) — Double-Cast bewusst, nur dieser Test ruft fetch.
40
+ globalThis.fetch = mock(
41
+ async () =>
42
+ new Response(JSON.stringify({ data: { blocks } }), {
43
+ status: 200,
44
+ headers: { "content-type": "application/json" },
45
+ }),
46
+ ) as unknown as typeof fetch;
47
+
48
+ const navId = "x:nav:content";
49
+ const provider = textContentClient({ navId }).navProviders?.[navId];
50
+ if (provider === undefined) throw new Error("provider missing");
51
+
52
+ let emitted: readonly TreeNode[] | undefined;
53
+ provider()((nodes) => {
54
+ emitted = nodes;
55
+ });
56
+ // fetch().then(...) ist async → eine Makrotask abwarten bis emit lief.
57
+ await new Promise((r) => setTimeout(r, 0));
58
+
59
+ expect(emitted).toBeDefined();
60
+ const labels = (emitted ?? []).map((n) => n.label).sort();
61
+ // Kein "Content"-Wrapper — root-leaf "Imprint" + folder "page" direkt.
62
+ expect(labels).not.toContain("Content");
63
+ expect(labels).toEqual(["Imprint", "page"]);
64
+ });
65
+ });
@@ -157,8 +157,10 @@ const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
157
157
  return r.json();
158
158
  })
159
159
  .then((data: ByTenantResponse) => {
160
- const nodes = groupBlocksByFolder(data.data.blocks);
161
- emit(nodes);
160
+ // Der App-seitige r.nav-Knoten IST der "Content"-Container → die
161
+ // Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
162
+ const content = groupBlocksByFolder(data.data.blocks)[0];
163
+ emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
162
164
  })
163
165
  .catch((e) => {
164
166
  // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
@@ -346,19 +348,20 @@ function TextContentEditor({
346
348
  );
347
349
  }
348
350
 
349
- export function textContentClient(): ClientFeatureDefinition {
351
+ // `navId` = die QN des r.nav({ provider: true })-Knotens, den die App für den
352
+ // Content-Tree registriert (z.B. "publicstatus:nav:content"). Die App besitzt
353
+ // Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
354
+ // liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
355
+ // Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
356
+ export function textContentClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
357
+ const navId = opts?.navId;
350
358
  return {
351
359
  name: "text-content",
352
- treeProvider,
353
- // V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
354
- // (created/updated/deleted) ruft ProviderBranch den treeProvider
355
- // neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
356
- treeEntities: ["text-block"],
357
- treeActions: {
358
- edit: { args: { slug: "" as string, lang: "" as string } },
359
- list: {},
360
- create: { args: { folder: "" as string } },
361
- },
360
+ ...(navId !== undefined && {
361
+ navProviders: { [navId]: treeProvider },
362
+ // SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
363
+ navEntities: { [navId]: ["text-block"] },
364
+ }),
362
365
  resolvers: {
363
366
  "text-content:edit": TextContentEditor,
364
367
  },
@@ -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.