@cosmicdrift/kumiko-bundled-features 0.63.0 → 0.65.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 +6 -6
  2. package/src/config/__tests__/write-helpers.test.ts +152 -0
  3. package/src/config/read-redaction.ts +0 -1
  4. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  5. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  6. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  7. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  8. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  9. package/src/custom-fields/db/queries/quota.ts +3 -1
  10. package/src/custom-fields/entity.ts +10 -3
  11. package/src/custom-fields/events.ts +4 -1
  12. package/src/custom-fields/feature.ts +1 -5
  13. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  14. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  15. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  16. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  17. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  18. package/src/custom-fields/wire-for-entity.ts +7 -0
  19. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  20. package/src/files-provider-s3/s3-provider.ts +2 -4
  21. package/src/managed-pages/handlers/set.write.ts +4 -11
  22. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  23. package/src/sessions/feature.ts +16 -3
  24. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  25. package/src/tags/entity.ts +8 -0
  26. package/src/tags/handlers/assign-tag.write.ts +20 -5
  27. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  28. package/src/tags/web/i18n.ts +6 -2
  29. package/src/tags/web/tag-section.tsx +87 -76
  30. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  31. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  32. package/src/tier-engine/entity.ts +8 -0
  33. package/src/tier-engine/feature.ts +49 -9
  34. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  35. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  36. package/src/tier-engine/index.ts +1 -0
  37. package/src/tier-engine/trial.ts +26 -0
  38. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  39. package/src/user-data-rights/constants.ts +48 -0
  40. package/src/user-data-rights/feature.ts +15 -0
  41. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  42. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  43. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  44. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  45. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  46. package/src/user-data-rights/index.ts +3 -0
  47. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  48. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  49. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  50. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  51. package/src/user-data-rights/web/i18n.ts +95 -0
  52. package/src/user-data-rights/web/index.ts +2 -0
  53. package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
- // TagSection — drop-in tag manager for ANY entity. Given an entityName +
3
- // entityId it shows the entity's current tags and lets the user attach an
4
- // existing tag, create-and-attach a new one, or detach a tag. Tag writes are
5
- // immediate (assign/remove are idempotent), so the section owns its own state
6
- // and refetches after each action — it is NOT part of a host form's save.
2
+ // TagSection — drop-in tag manager for ANY entity, GitLab-labels style: one
3
+ // searchable multi-combobox showing the entity's tags as chips, with a compact
4
+ // row below to create-and-attach a brand-new tag. Tag writes are immediate
5
+ // (assign/remove are idempotent), so the section owns its state and refetches
6
+ // after each action — it is NOT part of a host form's save.
7
7
  //
8
8
  // Two ways to mount (both need tagsClient() registered once, for i18n):
9
9
  // - standalone: <TagSection entityName="note" entityId={noteId} />
@@ -29,12 +29,20 @@ type AssignmentRow = {
29
29
  type TagListResponse = { readonly rows: readonly TagRow[] };
30
30
  type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
31
31
 
32
- // Structural shape of a dispatcher write result for the generic action wrapper.
33
- // The real WriteResult (a discriminated union) is assignable to this; narrowing
34
- // on `isSuccess` reaches `error.i18nKey` without importing server-side types.
35
- type ActionResult =
36
- | { readonly isSuccess: true }
37
- | { readonly isSuccess: false; readonly error: { readonly i18nKey: string } };
32
+ // What changed between the entity's current tags and the combobox's new
33
+ // selection. A single combobox toggle yields one add or one remove; the diff
34
+ // stays correct for a batch selection too.
35
+ export function tagSelectionDelta(
36
+ prev: readonly string[],
37
+ next: readonly string[],
38
+ ): { readonly added: readonly string[]; readonly removed: readonly string[] } {
39
+ const prevSet = new Set(prev);
40
+ const nextSet = new Set(next);
41
+ return {
42
+ added: next.filter((id) => !prevSet.has(id)),
43
+ removed: prev.filter((id) => !nextSet.has(id)),
44
+ };
45
+ }
38
46
 
39
47
  export function TagSection({
40
48
  entityName,
@@ -84,98 +92,101 @@ export function TagSection({
84
92
  }
85
93
 
86
94
  const catalogTags = catalog.data?.rows ?? [];
87
- const byId = new Map(catalogTags.map((tg) => [tg.id, tg]));
88
- const assignedRows = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
89
- const assignedIds = new Set(assignedRows.map((r) => r.tagId));
90
- const assignedTags = assignedRows.map((r) => byId.get(r.tagId) ?? { id: r.tagId, name: r.tagId });
91
- const available = catalogTags.filter((tg) => !assignedIds.has(tg.id));
95
+ const assignedIds = (assignments.data?.rows ?? [])
96
+ .filter((r) => r.entityType === entityName)
97
+ .map((r) => r.tagId);
98
+ // Catalog drives the options; an assigned tag missing from the catalog (none
99
+ // in v1 — no delete-tag yet) is appended so it stays removable.
100
+ const nameById = new Map(catalogTags.map((tg) => [tg.id, tg.name]));
101
+ const options = [...new Set([...catalogTags.map((tg) => tg.id), ...assignedIds])].map((id) => ({
102
+ value: id,
103
+ label: nameById.get(id) ?? id,
104
+ }));
92
105
 
93
106
  const refetch = async (): Promise<void> => {
94
107
  await Promise.all([catalog.refetch(), assignments.refetch()]);
95
108
  };
96
109
 
97
- const run = async (action: () => Promise<ActionResult>): Promise<void> => {
110
+ // Runs a write-sequence (each step returns false + sets errorKey on failure,
111
+ // stopping the sequence) and refetches to server-truth when it completes.
112
+ const apply = async (writes: () => Promise<boolean>): Promise<void> => {
98
113
  setBusy(true);
99
114
  setErrorKey(null);
100
115
  try {
101
- const result = await action();
102
- if (!result.isSuccess) {
103
- setErrorKey(result.error.i18nKey);
104
- return;
105
- }
106
- await refetch();
116
+ if (await writes()) await refetch();
107
117
  } finally {
108
118
  setBusy(false);
109
119
  }
110
120
  };
111
121
 
112
- const assign = (tagId: string): Promise<void> =>
113
- run(() =>
114
- dispatcher.write(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId }),
115
- );
122
+ const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
123
+ const result = await dispatcher.write(type, payload);
124
+ if (!result.isSuccess) {
125
+ setErrorKey(result.error.i18nKey);
126
+ return false;
127
+ }
128
+ return true;
129
+ };
116
130
 
117
- const detach = (tagId: string): Promise<void> =>
118
- run(() =>
119
- dispatcher.write(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId }),
120
- );
131
+ const onSelectionChange = (next: readonly string[]): void => {
132
+ const { added, removed } = tagSelectionDelta(assignedIds, next);
133
+ if (added.length === 0 && removed.length === 0) return;
134
+ void apply(async () => {
135
+ for (const tagId of added) {
136
+ if (!(await writeOk(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId })))
137
+ return false;
138
+ }
139
+ for (const tagId of removed) {
140
+ if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
141
+ return false;
142
+ }
143
+ return true;
144
+ });
145
+ };
121
146
 
122
- const createAndAssign = async (): Promise<void> => {
147
+ const createAndAssign = (): void => {
123
148
  const name = newName.trim();
124
149
  if (name === "") return;
125
- setBusy(true);
126
- setErrorKey(null);
127
- try {
150
+ void apply(async () => {
128
151
  const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, { name });
129
152
  if (!created.isSuccess) {
130
153
  setErrorKey(created.error.i18nKey);
131
- return;
154
+ return false;
132
155
  }
133
- const assigned = await dispatcher.write(TagsHandlers.assignTag, {
134
- tagId: created.data.id,
135
- entityType: entityName,
136
- entityId,
137
- });
138
- if (!assigned.isSuccess) {
139
- setErrorKey(assigned.error.i18nKey);
140
- return;
156
+ if (
157
+ !(await writeOk(TagsHandlers.assignTag, {
158
+ tagId: created.data.id,
159
+ entityType: entityName,
160
+ entityId,
161
+ }))
162
+ ) {
163
+ return false;
141
164
  }
142
165
  setNewName("");
143
- await refetch();
144
- } finally {
145
- setBusy(false);
146
- }
166
+ return true;
167
+ });
147
168
  };
148
169
 
149
170
  return (
150
171
  <div data-testid="tags-section">
151
- {assignedTags.length === 0 ? (
152
- <Text>{t("tags.section.none")}</Text>
153
- ) : (
154
- assignedTags.map((tg) => (
155
- <Button
156
- key={tg.id}
157
- variant="secondary"
158
- disabled={busy}
159
- onClick={() => void detach(tg.id)}
160
- testId={`tags-section-remove-${tg.id}`}
161
- >
162
- {`${tg.name} ✕`}
163
- </Button>
164
- ))
165
- )}
166
-
167
- {available.map((tg) => (
168
- <Button
169
- key={tg.id}
170
- variant="secondary"
172
+ <Field id="tags-section-select" label={t("tags.section.label")}>
173
+ <Input
174
+ kind="combobox"
175
+ multiple
176
+ id="tags-section-select"
177
+ name="tags"
178
+ options={options}
179
+ value={assignedIds}
180
+ onChange={onSelectionChange}
171
181
  disabled={busy}
172
- onClick={() => void assign(tg.id)}
173
- testId={`tags-section-assign-${tg.id}`}
174
- >
175
- {`+ ${tg.name}`}
176
- </Button>
177
- ))}
182
+ placeholder={t("tags.section.placeholder")}
183
+ emptyText={t("tags.section.empty")}
184
+ />
185
+ </Field>
178
186
 
187
+ {/* ponytail: separate create row — the shared combobox has no create-on-type
188
+ affordance. Fold create into the dropdown's Command.Empty if/when the
189
+ renderer-web combobox grows a freeSolo/onCreate prop. */}
179
190
  <Field id="tags-section-new" label={t("tags.section.newLabel")}>
180
191
  <Input
181
192
  kind="text"
@@ -186,9 +197,9 @@ export function TagSection({
186
197
  />
187
198
  </Field>
188
199
  <Button
189
- variant="primary"
200
+ variant="secondary"
190
201
  disabled={busy || newName.trim() === ""}
191
- onClick={() => void createAndAssign()}
202
+ onClick={() => createAndAssign()}
192
203
  testId="tags-section-create"
193
204
  >
194
205
  {busy ? t("tags.section.working") : t("tags.section.create")}
@@ -71,6 +71,20 @@ const features = composeFeatures(
71
71
  { includeBundled: true },
72
72
  );
73
73
 
74
+ // Zweite Komposition MIT Trial-Option: jeder Tenant bekommt 30 Tage ab
75
+ // inserted_at die "pro"-Features (feat-pro), unabhängig vom gespeicherten Tier.
76
+ const TRIAL_HOURS = 30 * 24;
77
+ const featuresWithTrial = composeFeatures(
78
+ [
79
+ createTierEngineFeature({
80
+ tierMap: TEST_TIER_MAP,
81
+ trial: { tier: "pro", durationHours: TRIAL_HOURS },
82
+ }),
83
+ featProFeature,
84
+ ],
85
+ { includeBundled: true },
86
+ );
87
+
74
88
  let stack: TestStack;
75
89
  const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
76
90
  const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
@@ -212,3 +226,56 @@ describe("createTierEngineFeature — per-tenant resolver", () => {
212
226
  expect(systemSet.size).toBeGreaterThanOrEqual(2);
213
227
  });
214
228
  });
229
+
230
+ describe("createTierEngineFeature — Trial-Phase (zeit-abgeleitet)", () => {
231
+ function sysUser(tenantId: TenantId, id: string) {
232
+ return createTestUser({ id, tenantId, roles: ["SystemAdmin", "TenantAdmin"] });
233
+ }
234
+
235
+ test("neuer 'free'-Tenant sieht im Fenster die Trial-Features (feat-pro)", async () => {
236
+ const usage = findTierResolverUsage(featuresWithTrial);
237
+ if (!usage) throw new Error("setup failure: no trial resolver");
238
+ const plugin = usage.options as TierResolverPlugin;
239
+
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
+ );
246
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
247
+ expect(resolver(tenantA).has("feat-pro")).toBe(true);
248
+ });
249
+
250
+ test("Tenant außerhalb des Fensters (inserted_at > 30 Tage) fällt auf free zurück", async () => {
251
+ const usage = findTierResolverUsage(featuresWithTrial);
252
+ if (!usage) throw new Error("setup failure: no trial resolver");
253
+ const plugin = usage.options as TierResolverPlugin;
254
+
255
+ await stack.http.writeOk(
256
+ "tier-engine:write:tier-assignment:create",
257
+ { tier: "free" },
258
+ sysUser(tenantB, "trial-sys-2"),
259
+ );
260
+ // Anlage-Datum künstlich 31 Tage zurückdrehen → Trial abgelaufen. tenantB ist
261
+ // eine fixe Test-UUID (kein User-Input) → inline-Interpolation unkritisch.
262
+ await asRawClient(stack.db).unsafe(
263
+ `UPDATE read_tier_assignments SET inserted_at = now() - interval '31 days' WHERE tenant_id = '${tenantB}'::uuid`,
264
+ );
265
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
266
+ expect(resolver(tenantB).has("feat-pro")).toBe(false);
267
+ });
268
+
269
+ test("ohne Trial-Option ist der Resolver unverändert (free = keine feat-pro)", async () => {
270
+ const usage = findTierResolverUsage(features);
271
+ if (!usage) throw new Error("setup failure");
272
+ 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
+ const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
279
+ expect(resolver(tenantA).has("feat-pro")).toBe(false);
280
+ });
281
+ });
@@ -0,0 +1,27 @@
1
+ // Trial-Fenster: reine epochMs-Arithmetik, Rand inklusive.
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { isTrialActive } from "../trial";
5
+
6
+ const HOUR_MS = 3_600_000;
7
+ const start = 1_700_000_000_000;
8
+
9
+ describe("isTrialActive", () => {
10
+ test("innerhalb des Fensters → aktiv", () => {
11
+ expect(isTrialActive(start, start + 10 * 24 * HOUR_MS, 720)).toBe(true);
12
+ expect(isTrialActive(start, start, 720)).toBe(true);
13
+ });
14
+
15
+ test("exakt am Fenster-Ende → nicht mehr aktiv (halb-offen)", () => {
16
+ expect(isTrialActive(start, start + 720 * HOUR_MS, 720)).toBe(false);
17
+ });
18
+
19
+ test("nach dem Fenster → inaktiv", () => {
20
+ expect(isTrialActive(start, start + 721 * HOUR_MS, 720)).toBe(false);
21
+ expect(isTrialActive(start, start + 31 * 24 * HOUR_MS, 720)).toBe(false);
22
+ });
23
+
24
+ test("Dauer 0 → nie aktiv", () => {
25
+ expect(isTrialActive(start, start, 0)).toBe(false);
26
+ });
27
+ });
@@ -33,3 +33,11 @@ export const tierAssignmentEntity = createEntity({
33
33
  source: createTextField({ required: false, maxLength: 20 }),
34
34
  },
35
35
  });
36
+
37
+ export type TierAssignmentRow = {
38
+ readonly id: string;
39
+ readonly version: number;
40
+ readonly tier: string;
41
+ readonly source: string | null;
42
+ readonly tenantId: string;
43
+ };
@@ -62,6 +62,7 @@ import {
62
62
  type TierResolverPlugin,
63
63
  } from "@cosmicdrift/kumiko-framework/engine";
64
64
  import { getAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
65
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
65
66
  import { z } from "zod";
66
67
  import { tierAssignmentAggregateId } from "./aggregate-id";
67
68
  import type { TierMap } from "./compose-app";
@@ -70,6 +71,7 @@ import { tierAssignmentEntity } from "./entity";
70
71
  import { getActiveTierQuery } from "./handlers/active-tier.query";
71
72
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
72
73
  import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
74
+ import { isTrialActive, type TrialPolicy } from "./trial";
73
75
 
74
76
  // Drizzle-table for the tier-assignment-entity. Built once at module-load
75
77
  // from the entity definition — same shape buildEntityTable would produce
@@ -115,6 +117,15 @@ export type CreateTierEngineOptions<TCaps extends Readonly<Record<string, unknow
115
117
  * oder eigene resolution-logic nutzen (legacy-pattern).
116
118
  */
117
119
  readonly tierMap?: TierMap<TCaps>;
120
+
121
+ /**
122
+ * Optionale Trial-Phase: jeder Tenant bekommt für `durationHours` ab seinem
123
+ * Anlage-Datum zusätzlich die Features von `trial.tier` freigeschaltet,
124
+ * danach fällt er automatisch auf sein gespeichertes Tier zurück. Erfordert
125
+ * `tierMap` (der Trial-Tier muss ein Key sein). Zeit-abgeleitet aus
126
+ * inserted_at — kein Stored-Flag, kein Scheduler.
127
+ */
128
+ readonly trial?: TrialPolicy;
118
129
  };
119
130
 
120
131
  /**
@@ -242,6 +253,17 @@ export function createTierEngineFeature<
242
253
  // Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
243
254
  const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
244
255
 
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>();
262
+ const trialFeatures: ReadonlySet<string> = opts.trial
263
+ ? featuresForTier(tierMap, opts.trial.tier)
264
+ : new Set();
265
+ const nowMs = (): number => getTemporal().Now.instant().epochMilliseconds;
266
+
245
267
  // set-tenant-tier schreibt direkt über den Executor → der postSave-Hook
246
268
  // unten feuert dabei NICHT. Diese Funktion repliziert den Cache-Update
247
269
  // des Hooks, damit ein manueller Grant das effektive Feature-Set sofort
@@ -249,6 +271,9 @@ export function createTierEngineFeature<
249
271
  // Semantik wie der Hook.
250
272
  onTierAssigned.fn = (tenantId, tier) => {
251
273
  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());
252
277
  };
253
278
 
254
279
  // Invalidation: tier-assignment events update the cache.
@@ -261,10 +286,12 @@ export function createTierEngineFeature<
261
286
  // throwing — der lifecycle-pipeline darf nicht durch hook-fehler
262
287
  // blocken (afterCommit-pattern, side-effect-best-effort).
263
288
  if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
264
- cache.set(
265
- data.tenantId as TenantId,
266
- mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)),
267
- );
289
+ const tenantId = data.tenantId as TenantId;
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());
268
295
  });
269
296
  r.entityHook("postDelete", "tier-assignment", async (payload) => {
270
297
  const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
@@ -385,15 +412,18 @@ export function createTierEngineFeature<
385
412
  // typischerweise <100k tenants — single-pass scan akzeptabel.
386
413
  // Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
387
414
  // Bedürfnis entsteht.
388
- type AssignmentRow = { tenantId: string; tier: string };
415
+ type AssignmentRow = { tenantId: string; tier: string; insertedAt: Temporal.Instant };
389
416
  const rows = await selectMany<AssignmentRow>(deps.db, tierAssignmentTable);
390
417
  for (const row of rows) {
391
418
  cache.set(
392
419
  row.tenantId as TenantId,
393
420
  mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
394
421
  );
422
+ trialClock.set(row.tenantId as TenantId, row.insertedAt.epochMilliseconds);
395
423
  }
396
424
 
425
+ const trial = opts.trial;
426
+
397
427
  // Synchronous resolver-callback for dispatcher hot-path.
398
428
  return (tenantId: TenantId): ReadonlySet<string> => {
399
429
  // Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
@@ -401,15 +431,25 @@ export function createTierEngineFeature<
401
431
  if (tenantId === SYSTEM_TENANT_ID) {
402
432
  return mergeAlwaysOn(computedAlwaysOn, unionAllTierFeatures(tierMap));
403
433
  }
404
- const cached = cache.get(tenantId);
405
- if (cached !== undefined) return cached;
406
434
  // Cache-miss: tenant ist noch nicht im cache (z.B. brandneu nach
407
435
  // boot, oder defaultTier-hook hat noch nicht gefired). Default-Set
408
436
  // ist least-privileged — typisch Free-Tier-features. Memory
409
437
  // `feedback_security_default_on`: secure-by-default.
410
438
  const fallbackTier = opts.defaultTier;
411
- if (fallbackTier === undefined) return computedAlwaysOn;
412
- return mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier));
439
+ const base =
440
+ cache.get(tenantId) ??
441
+ (fallbackTier === undefined
442
+ ? 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
+ }
451
+ }
452
+ return base;
413
453
  };
414
454
  },
415
455
  };
@@ -6,7 +6,7 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/db";
7
7
  import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { z } from "zod";
9
- import { tierAssignmentEntity } from "../entity";
9
+ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
10
10
 
11
11
  // Liest das Tier-Assignment eines BELIEBIGEN Tenants (cross-tenant) für den
12
12
  // tier-admin-Screen. SystemAdmin-only. get-active-tier liest nur den eigenen
@@ -15,14 +15,6 @@ import { tierAssignmentEntity } from "../entity";
15
15
 
16
16
  const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
17
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
18
  export const getTenantTierQuery = defineQueryHandler({
27
19
  name: "get-tenant-tier",
28
20
  schema: z.object({ tenantId: z.string().min(1) }),
@@ -8,7 +8,7 @@ import {
8
8
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { z } from "zod";
10
10
  import { tierAssignmentAggregateId } from "../aggregate-id";
11
- import { tierAssignmentEntity } from "../entity";
11
+ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
12
12
 
13
13
  // SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
14
14
  // Billing. Cross-tenant, daher SystemAdmin-only (kein TenantAdmin: sonst
@@ -39,14 +39,6 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
39
39
  entityName: "tier-assignment",
40
40
  });
41
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
42
  export type SetTenantTierOptions = {
51
43
  /** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
52
44
  * Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
@@ -24,3 +24,4 @@ export {
24
24
  createTierEngineFeature,
25
25
  tierEngineFeature,
26
26
  } from "./feature";
27
+ export { isTrialActive, type TrialPolicy } from "./trial";
@@ -0,0 +1,26 @@
1
+ // Trial-Phase: ein neuer Tenant bekommt für eine Karenzzeit ab seinem
2
+ // Anlage-Datum (inserted_at der tier-assignment-Row — rebuild-stabil aus dem
3
+ // Create-Event) zusätzlich die Features eines höheren Tiers, unabhängig vom
4
+ // gespeicherten Tier. Rein zeit-abgeleitet: kein Stored-Flag, kein Scheduler,
5
+ // automatischer Ablauf. Die App definiert die Policy (welcher Tier, wie lange);
6
+ // die tier-engine wendet sie im Resolver an.
7
+
8
+ export interface TrialPolicy {
9
+ // Tier, dessen Features während der Trial-Phase zusätzlich freigeschaltet
10
+ // werden (muss ein Key der tierMap sein, sonst greift kein Feature).
11
+ readonly tier: string;
12
+ // Länge der Trial-Phase ab inserted_at, in Stunden (720 = 30 Tage). Stunden
13
+ // statt Tage: Temporal.Instant kennt keine Kalender-Tage, 720h ist die
14
+ // ehrliche, DST-unabhängige Dauer.
15
+ readonly durationHours: number;
16
+ }
17
+
18
+ // Reine Millis-Arithmetik auf epochMilliseconds (die beide Seiten — Projektions-
19
+ // Row und Now — liefern). Kein Date, keine TZ.
20
+ export function isTrialActive(
21
+ startedAtEpochMs: number,
22
+ nowEpochMs: number,
23
+ durationHours: number,
24
+ ): boolean {
25
+ return nowEpochMs < startedAtEpochMs + durationHours * 3_600_000;
26
+ }