@cosmicdrift/kumiko-bundled-features 0.66.0 → 0.67.1
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.
|
|
3
|
+
"version": "0.67.1",
|
|
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.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.67.1",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.67.1",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.67.1",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.67.1",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.67.1",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// authMutedLinkClass — Subtle-Link-Style.
|
|
16
16
|
// parseUrlToken — URL-Param-Helper (window.location.search).
|
|
17
17
|
|
|
18
|
-
import { cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
18
|
+
import { BareFormProvider, cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
19
19
|
import { createContext, type ReactNode, useContext } from "react";
|
|
20
20
|
|
|
21
21
|
// Wrappt die zentrierte Auth-Card in ihre Umgebung. Default = Fullscreen-
|
|
@@ -60,7 +60,7 @@ export function AuthCard({ title, subtitle, children }: AuthCardProps): ReactNod
|
|
|
60
60
|
{subtitle !== undefined && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
|
61
61
|
</div>
|
|
62
62
|
)}
|
|
63
|
-
{children}
|
|
63
|
+
<BareFormProvider>{children}</BareFormProvider>
|
|
64
64
|
</div>
|
|
65
65
|
);
|
|
66
66
|
return shell(card);
|
|
@@ -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
|
-
|
|
232
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
270
|
+
expect(await resolver.trialGate?.(tenantB, "feat-pro")).toBe(false);
|
|
267
271
|
});
|
|
268
272
|
|
|
269
|
-
test("ohne Trial-Option
|
|
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
|
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
434
|
+
return (
|
|
440
435
|
cache.get(tenantId) ??
|
|
441
436
|
(fallbackTier === undefined
|
|
442
437
|
? computedAlwaysOn
|
|
443
|
-
: mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier)))
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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.
|