@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 +6 -6
- package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
- package/src/legal-pages/web/client-plugin.ts +9 -10
- package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
- package/src/text-content/web/client-plugin.tsx +16 -13
- package/src/tier-engine/__tests__/resolver.integration.test.ts +26 -27
- package/src/tier-engine/feature.ts +43 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
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.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
|
|
338
|
-
|
|
339
|
-
|
|
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(
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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.
|