@cosmicdrift/kumiko-bundled-features 0.1.0 → 0.2.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/LICENSE +57 -0
- package/package.json +3 -3
- package/src/audit/__tests__/audit.integration.ts +2 -2
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +5 -5
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +5 -5
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +14 -14
- package/src/auth-email-password/__tests__/auth.integration.ts +8 -8
- package/src/auth-email-password/__tests__/email-verification.integration.ts +5 -5
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +5 -5
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +6 -6
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +5 -5
- package/src/auth-email-password/__tests__/password-reset.integration.ts +5 -5
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +5 -5
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +5 -5
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +5 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +6 -6
- package/src/cap-counter/__tests__/cap-counter.integration.ts +2 -2
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +2 -2
- package/src/config/__tests__/config.integration.ts +2 -2
- package/src/delivery/__tests__/delivery-events.integration.ts +4 -4
- package/src/delivery/__tests__/delivery.integration.ts +4 -4
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +5 -5
- package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +84 -0
- package/src/feature-toggles/handlers/registered.query.ts +7 -2
- package/src/file-foundation/__tests__/file-foundation.integration.ts +4 -4
- package/src/jobs/__tests__/job-system-user.integration.ts +3 -3
- package/src/jobs/__tests__/jobs-events.integration.ts +2 -2
- package/src/jobs/__tests__/jobs-feature.integration.ts +3 -3
- package/src/legal-pages/__tests__/legal-pages.integration.ts +3 -3
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +4 -4
- package/src/secrets/__tests__/rotate.integration.ts +2 -2
- package/src/secrets/__tests__/secrets-events.integration.ts +2 -2
- package/src/secrets/__tests__/secrets.integration.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.ts +2 -2
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +6 -6
- package/src/sessions/__tests__/sessions.integration.ts +6 -6
- package/src/tenant/__tests__/multi-tenant.integration.ts +4 -4
- package/src/tenant/__tests__/seed-testing.integration.ts +4 -4
- package/src/tenant/__tests__/tenant.integration.ts +4 -4
- package/src/tenant/seeding.ts +12 -1
- package/src/text-content/README.md +6 -2
- package/src/text-content/__tests__/text-content.integration.ts +2 -2
- package/src/tier-engine/__tests__/resolver.integration.ts +183 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +5 -5
- package/src/tier-engine/feature.ts +345 -48
- package/src/tier-engine/index.ts +5 -1
- package/src/user/__tests__/seed-testing.integration.ts +4 -4
- package/src/user/__tests__/user.integration.ts +2 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Sprint-8a: behavior-tests für `createTierEngineFeature({ tierMap })`.
|
|
2
|
+
// Drei tests, advisor-empfohlene Coverage-Schichten:
|
|
3
|
+
//
|
|
4
|
+
// (1) per-tenant gating end-to-end via dispatcher: Tenant A "pro" sieht
|
|
5
|
+
// den feature-handler, Tenant B "free" bekommt 403.
|
|
6
|
+
// (2) cache-invalidation via tier-assignment:postSave: tier ändert sich
|
|
7
|
+
// → resolver reflects sofort.
|
|
8
|
+
// (3) SYSTEM_TENANT_ID returnt union-of-all-features (operator-tooling +
|
|
9
|
+
// event-dispatcher async-pass convention).
|
|
10
|
+
//
|
|
11
|
+
// Memory `feedback_no_fake_tests`: das Verhalten der per-tenant signature
|
|
12
|
+
// + extension-pickup ist sonst nirgends getestet. Phase-1-tests (dispatcher
|
|
13
|
+
// per-tenant + lifecycle hook-filter) zeigen die signature funktioniert
|
|
14
|
+
// MIT einem mock-resolver. Diese tests zeigen die echte tier-engine als
|
|
15
|
+
// resolver-implementierung funktioniert.
|
|
16
|
+
|
|
17
|
+
import { configValuesTable } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
18
|
+
import { tenantSecretsTable } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
19
|
+
import { tenantMembershipsTable, tenantTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
20
|
+
import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
21
|
+
import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";
|
|
22
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
23
|
+
import {
|
|
24
|
+
defineFeature,
|
|
25
|
+
findTierResolverUsage,
|
|
26
|
+
SYSTEM_TENANT_ID,
|
|
27
|
+
type TenantId,
|
|
28
|
+
type TierResolverPlugin,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
+
import {
|
|
31
|
+
createTestUser,
|
|
32
|
+
setupTestStack,
|
|
33
|
+
type TestStack,
|
|
34
|
+
unsafePushTables,
|
|
35
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
36
|
+
import { sql } from "drizzle-orm";
|
|
37
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
38
|
+
import type { TierMap } from "../compose-app";
|
|
39
|
+
import { tierAssignmentEntity } from "../entity";
|
|
40
|
+
import { createTierEngineFeature } from "../feature";
|
|
41
|
+
|
|
42
|
+
// App-spezifische cap-shape (die TierMap ist generic). Hier dummy-caps —
|
|
43
|
+
// fokus ist features-resolution, nicht caps.
|
|
44
|
+
type TestCaps = { readonly maxItems: number };
|
|
45
|
+
|
|
46
|
+
const TEST_TIER_MAP: TierMap<TestCaps> = {
|
|
47
|
+
free: { features: [], caps: { maxItems: 1 } },
|
|
48
|
+
pro: { features: ["feat-pro"], caps: { maxItems: 5 } },
|
|
49
|
+
business: { features: ["feat-pro", "feat-business"], caps: { maxItems: 20 } },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Toy-feature mit einem tenantadmin-only-handler. Wenn das feature im
|
|
53
|
+
// effective-Set ist, dispatcher passt durch — wenn nicht, 403 feature_disabled.
|
|
54
|
+
const featProFeature = defineFeature("feat-pro", (r) => {
|
|
55
|
+
r.queryHandler(
|
|
56
|
+
"ping",
|
|
57
|
+
{
|
|
58
|
+
parse: () => ({}),
|
|
59
|
+
safeParse: () => ({ success: true as const, data: {} }),
|
|
60
|
+
} as never,
|
|
61
|
+
async () => ({ ok: true }),
|
|
62
|
+
{ access: { roles: ["TenantAdmin"] } },
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const tierAssignmentTable = buildDrizzleTable("tier-assignment", tierAssignmentEntity);
|
|
67
|
+
|
|
68
|
+
const features = composeFeatures(
|
|
69
|
+
[createTierEngineFeature({ tierMap: TEST_TIER_MAP }), featProFeature],
|
|
70
|
+
{ includeBundled: true },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
let stack: TestStack;
|
|
74
|
+
const tenantA = "00000000-0000-4000-8000-0000000000a1" as TenantId;
|
|
75
|
+
const tenantB = "00000000-0000-4000-8000-0000000000b2" as TenantId;
|
|
76
|
+
const _adminA = createTestUser({ id: "u-a", tenantId: tenantA, roles: ["TenantAdmin"] });
|
|
77
|
+
const _adminB = createTestUser({ id: "u-b", tenantId: tenantB, roles: ["TenantAdmin"] });
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
// setupTestStack mit dem extension-pickup-Pfad: wir holen den plugin
|
|
81
|
+
// selbst und builden ihn manuell, weil setupTestStack heute nicht das
|
|
82
|
+
// Auto-pickup macht (das ist runDevApp/runProdApp's Job). Test fokus
|
|
83
|
+
// ist die feature-Implementation, nicht das wiring — wiring deckt
|
|
84
|
+
// (separater) integration-test im dev-server ab.
|
|
85
|
+
stack = await setupTestStack({ features });
|
|
86
|
+
await unsafePushTables(stack.db, {
|
|
87
|
+
config_values: configValuesTable,
|
|
88
|
+
users: userTable,
|
|
89
|
+
tenants: tenantTable,
|
|
90
|
+
tenant_memberships: tenantMembershipsTable,
|
|
91
|
+
tenant_secrets: tenantSecretsTable,
|
|
92
|
+
tier_assignments: tierAssignmentTable,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(async () => stack?.cleanup());
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
await stack.db.execute(
|
|
100
|
+
sql`TRUNCATE read_tier_assignments, kumiko_events RESTART IDENTITY CASCADE`,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("createTierEngineFeature — per-tenant resolver", () => {
|
|
105
|
+
test("(1) per-tenant gating: Tenant A 'pro' sees feat-pro, Tenant B 'free' gets 403", async () => {
|
|
106
|
+
// Pickup the resolver-plugin from the registry — same path runDevApp uses.
|
|
107
|
+
const usage = findTierResolverUsage(features);
|
|
108
|
+
expect(usage).toBeDefined();
|
|
109
|
+
if (!usage) throw new Error("setup failure: no tier-resolver plugin registered");
|
|
110
|
+
const plugin = usage.options as TierResolverPlugin;
|
|
111
|
+
|
|
112
|
+
// Seed tier-assignments BEFORE building the resolver so pre-load picks them up.
|
|
113
|
+
await stack.http.writeOk(
|
|
114
|
+
"tier-engine:write:tier-assignment:create",
|
|
115
|
+
{ tier: "pro" },
|
|
116
|
+
createTestUser({ id: "sys-1", tenantId: tenantA, roles: ["SystemAdmin", "TenantAdmin"] }),
|
|
117
|
+
);
|
|
118
|
+
await stack.http.writeOk(
|
|
119
|
+
"tier-engine:write:tier-assignment:create",
|
|
120
|
+
{ tier: "free" },
|
|
121
|
+
createTestUser({ id: "sys-2", tenantId: tenantB, roles: ["SystemAdmin", "TenantAdmin"] }),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Build the resolver — this pre-loads existing assignments into cache.
|
|
125
|
+
const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
|
|
126
|
+
|
|
127
|
+
// Direct resolver-calls verify per-tenant behavior:
|
|
128
|
+
expect(resolver(tenantA).has("feat-pro")).toBe(true);
|
|
129
|
+
expect(resolver(tenantB).has("feat-pro")).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("(2) cache-invalidation: tier change reflects in resolver immediately", async () => {
|
|
133
|
+
// Start mit free, dann zu pro upgraden, resolver soll's sofort sehen.
|
|
134
|
+
const usage = findTierResolverUsage(features);
|
|
135
|
+
if (!usage) throw new Error("setup failure");
|
|
136
|
+
const plugin = usage.options as TierResolverPlugin;
|
|
137
|
+
|
|
138
|
+
const sysA = createTestUser({
|
|
139
|
+
id: "sys-3",
|
|
140
|
+
tenantId: tenantA,
|
|
141
|
+
roles: ["SystemAdmin", "TenantAdmin"],
|
|
142
|
+
});
|
|
143
|
+
await stack.http.writeOk("tier-engine:write:tier-assignment:create", { tier: "free" }, sysA);
|
|
144
|
+
|
|
145
|
+
const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
|
|
146
|
+
expect(resolver(tenantA).has("feat-pro")).toBe(false);
|
|
147
|
+
|
|
148
|
+
// Get the assignment-row for the update (need id + version).
|
|
149
|
+
type Row = { readonly id: string; readonly version: number; readonly tier: string };
|
|
150
|
+
const list = await stack.http.queryOk<{ rows: readonly Row[] }>(
|
|
151
|
+
"tier-engine:query:tier-assignment:list",
|
|
152
|
+
{},
|
|
153
|
+
sysA,
|
|
154
|
+
);
|
|
155
|
+
const row = list.rows[0];
|
|
156
|
+
if (!row) throw new Error("no tier-assignment row created");
|
|
157
|
+
|
|
158
|
+
// Update to pro — entityHook should fire and refresh cache.
|
|
159
|
+
await stack.http.writeOk(
|
|
160
|
+
"tier-engine:write:tier-assignment:update",
|
|
161
|
+
{ id: row.id, version: row.version, changes: { tier: "pro" } },
|
|
162
|
+
sysA,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Cache should reflect the new tier.
|
|
166
|
+
expect(resolver(tenantA).has("feat-pro")).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("(3) SYSTEM_TENANT_ID returns union of all tier-features", async () => {
|
|
170
|
+
const usage = findTierResolverUsage(features);
|
|
171
|
+
if (!usage) throw new Error("setup failure");
|
|
172
|
+
const plugin = usage.options as TierResolverPlugin;
|
|
173
|
+
|
|
174
|
+
const resolver = await plugin.build({ db: stack.db, registry: stack.registry });
|
|
175
|
+
const systemSet = resolver(SYSTEM_TENANT_ID);
|
|
176
|
+
|
|
177
|
+
// Union: feat-pro (in pro+business) + feat-business (in business only).
|
|
178
|
+
expect(systemSet.has("feat-pro")).toBe(true);
|
|
179
|
+
expect(systemSet.has("feat-business")).toBe(true);
|
|
180
|
+
// free has no features → nothing extra
|
|
181
|
+
expect(systemSet.size).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -2,13 +2,13 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import {
|
|
5
|
-
createEntityTable,
|
|
6
5
|
createTestUser,
|
|
7
|
-
pushTables,
|
|
8
6
|
setupTestStack,
|
|
9
7
|
type TestStack,
|
|
10
8
|
TestUsers,
|
|
11
9
|
testTenantId,
|
|
10
|
+
unsafeCreateEntityTable,
|
|
11
|
+
unsafePushTables,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
13
|
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
14
14
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
@@ -47,9 +47,9 @@ beforeAll(async () => {
|
|
|
47
47
|
});
|
|
48
48
|
db = stack.db;
|
|
49
49
|
|
|
50
|
-
await
|
|
51
|
-
await
|
|
52
|
-
await
|
|
50
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
51
|
+
await unsafeCreateEntityTable(db, tierAssignmentEntity);
|
|
52
|
+
await unsafePushTables(db, { configValuesTable });
|
|
53
53
|
await createEventsTable(db);
|
|
54
54
|
});
|
|
55
55
|
|
|
@@ -2,71 +2,368 @@
|
|
|
2
2
|
//
|
|
3
3
|
// **Was diese Feature macht:**
|
|
4
4
|
// Speichert pro Plattform-Tenant ein Tier-Assignment (welcher Tier ist
|
|
5
|
-
// aktiv).
|
|
6
|
-
//
|
|
5
|
+
// aktiv). Optional (mit `tierMap` in Options): per-tenant feature-set
|
|
6
|
+
// resolution via Framework's `tenantTierResolver`-extension — das
|
|
7
|
+
// dispatcher-feature-gate fragt automatisch pro request den resolver
|
|
8
|
+
// nach dem effective Set für den aktiven Tenant.
|
|
9
|
+
//
|
|
10
|
+
// **Zwei Use-Modes:**
|
|
11
|
+
// 1. `createTierEngineFeature()` ohne opts — nur Storage (Standard-CRUD
|
|
12
|
+
// für tier-assignment-Entity). Apps die composeApp() oder eigene
|
|
13
|
+
// logic nutzen.
|
|
14
|
+
// 2. `createTierEngineFeature({ defaultTier, tierMap })` — vollständige
|
|
15
|
+
// Tier-Composition. Sprint-8a Pattern für multi-tenant SaaS-Apps.
|
|
16
|
+
// Framework auto-wires effectiveFeatures via extension-pickup.
|
|
7
17
|
//
|
|
8
18
|
// **Generic über Tier-Werte:** das Feature kennt keine "free"/"pro"/etc.
|
|
9
|
-
// konkreten Tier-Werte.
|
|
10
|
-
// App definiert ihre TierMap (siehe compose-app.ts), damit kumiko.so,
|
|
11
|
-
// PublicStatus, und andere Kumiko-Apps je eigene Tier-Sets nutzen können.
|
|
19
|
+
// konkreten Tier-Werte. App definiert ihre TierMap.
|
|
12
20
|
//
|
|
13
|
-
// **
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
21
|
+
// **Auto-Default-Tier on Tenant-Signup:** wenn `opts.defaultTier` gesetzt
|
|
22
|
+
// ist, schreibt ein r.entityHook("postSave", "tenant", phase: inTransaction)
|
|
23
|
+
// automatisch eine tier-assignment für den neuen Tenant. Atomic mit
|
|
24
|
+
// tenant-create — wenn tier-assignment fail't, tenant-create rolled back.
|
|
25
|
+
// Idempotent via deterministic aggregate-id (re-replay re-checkt stream-
|
|
26
|
+
// version, skip wenn schon da). Plus: cache-miss-fallback returnt
|
|
27
|
+
// defaultTier-features wenn assignment-row fehlt (defense-in-depth gegen
|
|
28
|
+
// replay-races wo der hook noch nicht durch ist).
|
|
17
29
|
//
|
|
18
|
-
// **
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
30
|
+
// **In-Memory-Cache mit Push-Invalidation:**
|
|
31
|
+
// Closure-state pro feature-instance hält die effective Sets pro Tenant.
|
|
32
|
+
// r.entityHook auf tier-assignment:postSave/postDelete hält cache aktuell.
|
|
33
|
+
// Erste-Request-Cold-Cache: pre-load via build(deps) im extension-pickup
|
|
34
|
+
// Pfad (boot-time via runDevApp/runProdApp).
|
|
23
35
|
//
|
|
24
|
-
// **
|
|
25
|
-
// -
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// - Add-Ons im Schema — Sprint 4 fügt sie als separate
|
|
29
|
-
// `tier-add-on`-Entity hinzu (1:n Relation, ES-saubere Add/Remove-Events).
|
|
30
|
-
// - Cap-Counter-Integration — kommt mit `cap-counter`-Feature in Sprint 3.
|
|
31
|
-
// - Stripe-Sync + Idempotent-Set-Tier-Wrapper — kommt mit `stripe-sync`-
|
|
32
|
-
// Feature in Sprint 5.
|
|
36
|
+
// **SYSTEM_TENANT_ID-Convention:**
|
|
37
|
+
// resolver-callback bei call mit SYSTEM_TENANT_ID returnt union aller
|
|
38
|
+
// tier-features (für event-dispatcher async-pass + operator-tooling).
|
|
39
|
+
// Siehe DispatcherOptions.effectiveFeatures-doc.
|
|
33
40
|
//
|
|
34
|
-
// **Boot-Dependencies:**
|
|
35
|
-
// r.requires("config") — transitiv für tenant.
|
|
36
|
-
// r.requires("tenant") — tier-assignment lebt im Plattform-Tenant-Kontext.
|
|
41
|
+
// **Boot-Dependencies:** config + tenant.
|
|
37
42
|
|
|
43
|
+
import {
|
|
44
|
+
buildDrizzleTable,
|
|
45
|
+
createEventStoreExecutor,
|
|
46
|
+
createTenantDb,
|
|
47
|
+
type DbConnection,
|
|
48
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
38
49
|
import {
|
|
39
50
|
defineEntityCreateHandler,
|
|
40
51
|
defineEntityListHandler,
|
|
41
52
|
defineEntityUpdateHandler,
|
|
42
53
|
defineFeature,
|
|
43
54
|
type FeatureDefinition,
|
|
55
|
+
HookPhases,
|
|
56
|
+
type SessionUser,
|
|
57
|
+
SYSTEM_TENANT_ID,
|
|
58
|
+
TENANT_TIER_RESOLVER_EXT,
|
|
59
|
+
type TenantId,
|
|
60
|
+
type TierResolverPlugin,
|
|
44
61
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
62
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
63
|
+
import { eq, max as maxFn } from "drizzle-orm";
|
|
64
|
+
import { tierAssignmentAggregateId } from "./aggregate-id";
|
|
65
|
+
import type { TierMap } from "./compose-app";
|
|
45
66
|
import { TIER_ENGINE_FEATURE } from "./constants";
|
|
46
67
|
import { tierAssignmentEntity } from "./entity";
|
|
47
68
|
import { getActiveTierQuery } from "./handlers/active-tier.query";
|
|
48
69
|
|
|
49
|
-
|
|
70
|
+
// Drizzle-table for the tier-assignment-entity. Built once at module-load
|
|
71
|
+
// from the entity definition — same shape buildDrizzleTable would produce
|
|
72
|
+
// in the App's drizzle/schema.generated.ts.
|
|
73
|
+
const tierAssignmentTable = buildDrizzleTable("tier-assignment", tierAssignmentEntity);
|
|
50
74
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// Standard-CRUD via Helper. Sprint 5 wraps these in a custom set-tier
|
|
58
|
-
// handler with deterministic aggregate-id for Stripe-Webhook idempotency.
|
|
59
|
-
r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
60
|
-
r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
61
|
-
|
|
62
|
-
// Reads.
|
|
63
|
-
// - list: cross-tenant view for SystemAdmin (debug/migration-tooling)
|
|
64
|
-
// and per-tenant 0-or-1-row view for TenantAdmin (auto-tenant-scoped)
|
|
65
|
-
// - get-active-tier: convenience-wrapper for the only sensible per-tenant
|
|
66
|
-
// query — returns the single row or null. composeApp consumes this.
|
|
67
|
-
//
|
|
68
|
-
// Detail-by-id-handler bewusst weggelassen — kein Use-Case, weil pro Tenant
|
|
69
|
-
// genau eine Row existiert; get-active-tier ist die richtige Lookup-Form.
|
|
70
|
-
r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
71
|
-
r.queryHandler(getActiveTierQuery);
|
|
75
|
+
// Event-store-executor für direct-write aus dem auto-default-tier-hook.
|
|
76
|
+
// Pattern wie tenant/seeding.ts: hook sieht AppContext (kein ctx.write),
|
|
77
|
+
// muss aber atomisch mit tenant-create im selben TX schreiben → executor
|
|
78
|
+
// direkt aufrufen, nicht via dispatcher.
|
|
79
|
+
const tierAssignmentExecutor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEntity, {
|
|
80
|
+
entityName: "tier-assignment",
|
|
72
81
|
});
|
|
82
|
+
|
|
83
|
+
const adminAccess = { access: { roles: ["TenantAdmin", "SystemAdmin"] } } as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Options for createTierEngineFeature. Both fields optional — wenn beide
|
|
87
|
+
* leer, ist die feature nur Storage (back-compat zu legacy `tierEngineFeature`).
|
|
88
|
+
*
|
|
89
|
+
* @template TCaps - App-spezifischer Cap-Shape. Type-leakt durch tierMap →
|
|
90
|
+
* future capsFor() resolver returnt diesen Shape exakt.
|
|
91
|
+
*/
|
|
92
|
+
export type CreateTierEngineOptions<TCaps extends Readonly<Record<string, unknown>>> = {
|
|
93
|
+
/**
|
|
94
|
+
* Tier-Name der bei Tenant-signup automatisch geschrieben wird. Erfordert
|
|
95
|
+
* dass `tierMap` diesen Tier-Namen kennt (Boot-Validation).
|
|
96
|
+
* Wenn weggelassen, kein auto-assign — App schreibt manuell.
|
|
97
|
+
*/
|
|
98
|
+
readonly defaultTier?: string;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* App-spezifische Tier-Map. Wenn gesetzt, registriert das feature sich
|
|
102
|
+
* als plugin für `tenantTierResolver`-extension → framework auto-wires
|
|
103
|
+
* effectiveFeatures pro tenant.
|
|
104
|
+
* Wenn weggelassen, keine Resolver-extension — App muss `composeApp`
|
|
105
|
+
* oder eigene resolution-logic nutzen (legacy-pattern).
|
|
106
|
+
*/
|
|
107
|
+
readonly tierMap?: TierMap<TCaps>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compute the union of features across all tiers — verwendet bei
|
|
112
|
+
* SYSTEM_TENANT_ID-resolver-call (operator-tooling, async-event-dispatch).
|
|
113
|
+
* Operator-UI sieht alle features unabhängig vom tier-cut, async-events
|
|
114
|
+
* laufen tier-agnostic durch.
|
|
115
|
+
*/
|
|
116
|
+
function unionAllTierFeatures<TCaps extends Readonly<Record<string, unknown>>>(
|
|
117
|
+
tierMap: TierMap<TCaps>,
|
|
118
|
+
): ReadonlySet<string> {
|
|
119
|
+
const all = new Set<string>();
|
|
120
|
+
for (const tier of Object.values(tierMap)) {
|
|
121
|
+
for (const f of tier.features) all.add(f);
|
|
122
|
+
}
|
|
123
|
+
return all;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Compute the feature-set for a given tier-name. Unknown tier → empty Set
|
|
128
|
+
* (defensive — verwendete tier-namen werden nicht an boot-time validiert
|
|
129
|
+
* weil tier-engine generic über tier-Werte ist).
|
|
130
|
+
*/
|
|
131
|
+
function featuresForTier<TCaps extends Readonly<Record<string, unknown>>>(
|
|
132
|
+
tierMap: TierMap<TCaps>,
|
|
133
|
+
tierName: string,
|
|
134
|
+
): ReadonlySet<string> {
|
|
135
|
+
const tier = tierMap[tierName];
|
|
136
|
+
if (!tier) return new Set();
|
|
137
|
+
return new Set(tier.features);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Merge always-on features (non-toggleable framework-base) mit tier-cut
|
|
142
|
+
* features (toggleable per tier). Canonical Kumiko: dispatcher-gate
|
|
143
|
+
* blockt nur features die explizit `r.toggleable()` haben — alle anderen
|
|
144
|
+
* sind immer aktiv. Tier-resolver muss das selbe pattern liefern.
|
|
145
|
+
*/
|
|
146
|
+
function mergeAlwaysOn(
|
|
147
|
+
alwaysOn: ReadonlySet<string>,
|
|
148
|
+
tierFeatures: ReadonlySet<string>,
|
|
149
|
+
): ReadonlySet<string> {
|
|
150
|
+
const merged = new Set<string>(alwaysOn);
|
|
151
|
+
for (const f of tierFeatures) merged.add(f);
|
|
152
|
+
return merged;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Factory: create a tier-engine feature instance with optional auto-tier-
|
|
157
|
+
* resolution + default-tier-on-signup behavior. Returns FeatureDefinition
|
|
158
|
+
* mountable in run-config.
|
|
159
|
+
*/
|
|
160
|
+
export function createTierEngineFeature<
|
|
161
|
+
TCaps extends Readonly<Record<string, unknown>> = Readonly<Record<string, unknown>>,
|
|
162
|
+
>(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
|
|
163
|
+
return defineFeature(TIER_ENGINE_FEATURE, (r) => {
|
|
164
|
+
r.requires("config");
|
|
165
|
+
r.requires("tenant");
|
|
166
|
+
|
|
167
|
+
r.entity("tier-assignment", tierAssignmentEntity);
|
|
168
|
+
|
|
169
|
+
// Standard-CRUD via Helper.
|
|
170
|
+
r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
171
|
+
r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
172
|
+
|
|
173
|
+
// Reads.
|
|
174
|
+
r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));
|
|
175
|
+
r.queryHandler(getActiveTierQuery);
|
|
176
|
+
|
|
177
|
+
// ───────────────────────────────────────────────────────────────────
|
|
178
|
+
// Resolver-extension (only when tierMap is configured)
|
|
179
|
+
// ───────────────────────────────────────────────────────────────────
|
|
180
|
+
// skip: ohne tierMap ist die feature nur Storage (legacy back-compat
|
|
181
|
+
// zu `tierEngineFeature`). Resolver-extension + invalidation-hooks
|
|
182
|
+
// brauchen die tierMap zum Mapping tier-name → feature-set.
|
|
183
|
+
if (!opts.tierMap) return;
|
|
184
|
+
|
|
185
|
+
const tierMap = opts.tierMap;
|
|
186
|
+
|
|
187
|
+
// Closure-state: cache per-tenant effective Sets. Hooks halten den
|
|
188
|
+
// cache aktuell during process-lifetime; build() im extension-pickup
|
|
189
|
+
// pre-loaded existing assignments at boot.
|
|
190
|
+
const cache = new Map<TenantId, ReadonlySet<string>>();
|
|
191
|
+
// alwaysOn-Set wird in build(deps) aus registry.features berechnet (alle
|
|
192
|
+
// non-toggleable features). Hooks brauchen Zugriff darauf für
|
|
193
|
+
// mergeAlwaysOn-calls — Late-bind via mutable holder, gefüllt vor allen
|
|
194
|
+
// Requests (build läuft pre-listen via runDevApp/runProdApp-pickup).
|
|
195
|
+
const alwaysOnHolder: { set: ReadonlySet<string> } = { set: new Set() };
|
|
196
|
+
|
|
197
|
+
// Invalidation: tier-assignment events update the cache.
|
|
198
|
+
r.entityHook("postSave", "tier-assignment", async (result) => {
|
|
199
|
+
// result.data has tenantId + tier (after entity-update merge)
|
|
200
|
+
const data = result.data as { tenantId?: unknown; tier?: unknown };
|
|
201
|
+
// skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
|
|
202
|
+
// entity-events sind beide fields immer strings; ein malformed-payload
|
|
203
|
+
// (custom-handler-bug) würde hier silent zum cache-skip führen statt
|
|
204
|
+
// throwing — der lifecycle-pipeline darf nicht durch hook-fehler
|
|
205
|
+
// blocken (afterCommit-pattern, side-effect-best-effort).
|
|
206
|
+
if (typeof data.tenantId !== "string" || typeof data.tier !== "string") return;
|
|
207
|
+
cache.set(
|
|
208
|
+
data.tenantId as TenantId,
|
|
209
|
+
mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
r.entityHook("postDelete", "tier-assignment", async (payload) => {
|
|
213
|
+
const data = payload.data as { tenantId?: unknown };
|
|
214
|
+
// skip: gleiche type-guard semantik wie postSave-hook oben.
|
|
215
|
+
if (typeof data.tenantId !== "string") return;
|
|
216
|
+
cache.delete(data.tenantId as TenantId);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Auto-default-tier-on-tenant-signup: hook fires inTransaction (atomic
|
|
220
|
+
// mit tenant-create rollback), schreibt tier-assignment via Direct-
|
|
221
|
+
// Executor (nicht ctx.write — hook hat AppContext). Pattern analog
|
|
222
|
+
// tenant/seeding.ts seedTenant.
|
|
223
|
+
//
|
|
224
|
+
// **Idempotency:** deterministic aggregate-id aus tenantId. Re-replay
|
|
225
|
+
// (nach projection-rebuild oder hook-retry) findet stream-version > 0
|
|
226
|
+
// und skipt, statt version_conflict zu werfen. Caller sieht erfolgreichen
|
|
227
|
+
// tenant-create + bestehende tier-assignment.
|
|
228
|
+
//
|
|
229
|
+
// **Cross-tenant write:** executor braucht systemUser MIT tenantId =
|
|
230
|
+
// neuer Tenant (Memory `feedback_event_store_tenant_consistency`).
|
|
231
|
+
if (opts.defaultTier !== undefined) {
|
|
232
|
+
const defaultTier = opts.defaultTier;
|
|
233
|
+
r.entityHook(
|
|
234
|
+
"postSave",
|
|
235
|
+
"tenant",
|
|
236
|
+
async (result, ctx) => {
|
|
237
|
+
// result-shape: kumiko-framework's SaveContext mit isNew + data
|
|
238
|
+
const saveResult = result as { isNew?: unknown; data?: unknown };
|
|
239
|
+
// skip: nur bei tenant-create (initial) — tenant-updates feuern
|
|
240
|
+
// auch postSave aber wir wollen kein neues tier-assignment bei
|
|
241
|
+
// re-keying oder name-update.
|
|
242
|
+
if (saveResult.isNew !== true) return;
|
|
243
|
+
const data = saveResult.data as { id?: unknown };
|
|
244
|
+
// skip: defensive type-guard. Tenant-entity hat id zwingend, aber
|
|
245
|
+
// CrudExecutor's payload-shape ist runtime-unknown.
|
|
246
|
+
if (typeof data.id !== "string") return;
|
|
247
|
+
const newTenantId = data.id as TenantId;
|
|
248
|
+
const aggregateId = tierAssignmentAggregateId(newTenantId);
|
|
249
|
+
|
|
250
|
+
// skip: defensive — inTransaction phase hat ctx.db immer gesetzt,
|
|
251
|
+
// aber AppContext type macht's optional. Throw wäre overreach
|
|
252
|
+
// (lifecycle blocking), silent-skip ist defensive-soft.
|
|
253
|
+
if (!ctx.db) return;
|
|
254
|
+
|
|
255
|
+
// ctx.db ist im inTransaction-phase eine TenantDb (tenant-scoped
|
|
256
|
+
// proxy auf die echte TX). Für event-store-reads (cross-tenant
|
|
257
|
+
// stream-lookup via aggregate-id) brauchen wir die rohe TX —
|
|
258
|
+
// TenantDb wrapped die echte DbConnection, der select-call
|
|
259
|
+
// funktioniert structural identisch. Cast als DbConnection ist
|
|
260
|
+
// boundary-cast für event-store-API, kein narrowing-escape.
|
|
261
|
+
const rawDb = ctx.db as DbConnection;
|
|
262
|
+
|
|
263
|
+
// Idempotency: stream-existence-check vor create. Pattern aus
|
|
264
|
+
// seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
|
|
265
|
+
type StreamRow = { v: number | null };
|
|
266
|
+
const [streamRow] = (await rawDb
|
|
267
|
+
.select({ v: maxFn(eventsTable.version) })
|
|
268
|
+
.from(eventsTable)
|
|
269
|
+
.where(eq(eventsTable.aggregateId, aggregateId))) as StreamRow[];
|
|
270
|
+
// skip: idempotency — aggregate-stream existiert schon (re-replay
|
|
271
|
+
// nach projection-rebuild oder hook-retry). create() würde
|
|
272
|
+
// version_conflict werfen + tenant-create rollback'n. Pattern aus
|
|
273
|
+
// tenant/seeding.ts seedTenant.
|
|
274
|
+
if ((streamRow?.v ?? 0) > 0) return;
|
|
275
|
+
|
|
276
|
+
// SystemUser für den NEUEN tenant — der Hook wird vom signup-
|
|
277
|
+
// user (anderer tenant, oder SystemAdmin) ausgelöst, aber das
|
|
278
|
+
// tier-assignment muss im stream des neu-erzeugten tenants
|
|
279
|
+
// landen (= aggregate-id deterministic auf newTenantId). Memory
|
|
280
|
+
// `feedback_event_store_tenant_consistency`: by.tenantId muss =
|
|
281
|
+
// ziel-tenant.
|
|
282
|
+
const systemUser: SessionUser = {
|
|
283
|
+
id: "00000000-0000-4000-8000-000000000001",
|
|
284
|
+
tenantId: newTenantId,
|
|
285
|
+
roles: ["SystemAdmin"],
|
|
286
|
+
};
|
|
287
|
+
const tdb = createTenantDb(rawDb, newTenantId, "system");
|
|
288
|
+
|
|
289
|
+
await tierAssignmentExecutor.create(
|
|
290
|
+
{ id: aggregateId, tier: defaultTier },
|
|
291
|
+
systemUser,
|
|
292
|
+
tdb,
|
|
293
|
+
);
|
|
294
|
+
},
|
|
295
|
+
{ phase: HookPhases.inTransaction },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Extension-point declaration + self-registration. Pattern analog
|
|
300
|
+
// mail-foundation/file-foundation: das feature deklariert den
|
|
301
|
+
// extension-point UND registriert sich als default-plugin. Andere
|
|
302
|
+
// features könnten später auch dort registrieren (z.B. ein future
|
|
303
|
+
// toggle-only-resolver), aber nur ein plugin gewinnt — siehe
|
|
304
|
+
// findTierResolverUsage's "single-plugin"-comment.
|
|
305
|
+
r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
|
|
306
|
+
|
|
307
|
+
// Plugin-registration: framework's runDevApp/runProdApp picks this up
|
|
308
|
+
// post-stack-setup, calls build(deps), wires effectiveFeatures.
|
|
309
|
+
const plugin: TierResolverPlugin = {
|
|
310
|
+
build: async (deps) => {
|
|
311
|
+
// Always-on-Set: alle non-toggleable features im registry. Canonical
|
|
312
|
+
// Kumiko-pattern (matches feature-toggles' resolver): nur features
|
|
313
|
+
// die explizit `r.toggleable()` registrieren sind tier-cuttable.
|
|
314
|
+
// Framework-base-features (auth-email-password, config, user, tenant,
|
|
315
|
+
// sessions, etc.) sind non-toggleable → IMMER aktiv für jeden Tenant
|
|
316
|
+
// unabhängig vom tier. Sonst würde dispatcher 403 auf login-handler
|
|
317
|
+
// werfen weil "feature auth-email-password disabled".
|
|
318
|
+
const computedAlwaysOn = new Set<string>();
|
|
319
|
+
for (const feature of deps.registry.features.values()) {
|
|
320
|
+
if (feature.toggleableDefault === undefined) computedAlwaysOn.add(feature.name);
|
|
321
|
+
}
|
|
322
|
+
alwaysOnHolder.set = computedAlwaysOn;
|
|
323
|
+
|
|
324
|
+
// Pre-load all existing assignments into cache. SaaS-Apps haben
|
|
325
|
+
// typischerweise <100k tenants — single-pass scan akzeptabel.
|
|
326
|
+
// Skalierungs-Pfad (lazy-load + LRU) ist Sprint-8b wenn echtes
|
|
327
|
+
// Bedürfnis entsteht.
|
|
328
|
+
type AssignmentRow = { tenantId: string; tier: string };
|
|
329
|
+
const rows = (await deps.db.select().from(tierAssignmentTable)) as AssignmentRow[];
|
|
330
|
+
for (const row of rows) {
|
|
331
|
+
cache.set(
|
|
332
|
+
row.tenantId as TenantId,
|
|
333
|
+
mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, row.tier)),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Synchronous resolver-callback for dispatcher hot-path.
|
|
338
|
+
return (tenantId: TenantId): ReadonlySet<string> => {
|
|
339
|
+
// Operator-tooling + async-event-dispatch convention: SYSTEM_TENANT_ID
|
|
340
|
+
// gets the union of all tier-features (siehe DispatcherOptions doc).
|
|
341
|
+
if (tenantId === SYSTEM_TENANT_ID) {
|
|
342
|
+
return mergeAlwaysOn(computedAlwaysOn, unionAllTierFeatures(tierMap));
|
|
343
|
+
}
|
|
344
|
+
const cached = cache.get(tenantId);
|
|
345
|
+
if (cached !== undefined) return cached;
|
|
346
|
+
// Cache-miss: tenant ist noch nicht im cache (z.B. brandneu nach
|
|
347
|
+
// boot, oder defaultTier-hook hat noch nicht gefired). Default-Set
|
|
348
|
+
// ist least-privileged — typisch Free-Tier-features. Memory
|
|
349
|
+
// `feedback_security_default_on`: secure-by-default.
|
|
350
|
+
const fallbackTier = opts.defaultTier;
|
|
351
|
+
if (fallbackTier === undefined) return computedAlwaysOn;
|
|
352
|
+
return mergeAlwaysOn(computedAlwaysOn, featuresForTier(tierMap, fallbackTier));
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a framework registrar method, not a React hook.
|
|
357
|
+
r.useExtension(TENANT_TIER_RESOLVER_EXT, TIER_ENGINE_FEATURE, plugin);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Legacy named export — equivalent to `createTierEngineFeature()` ohne opts.
|
|
363
|
+
* Storage-only mode (no resolver, no auto-default). Existing apps die
|
|
364
|
+
* `tierEngineFeature` referenzieren bekommen identical behavior.
|
|
365
|
+
*
|
|
366
|
+
* Migration zu `createTierEngineFeature({ defaultTier, tierMap })` gibt
|
|
367
|
+
* volle Tier-Composition (Sprint-8a Pattern).
|
|
368
|
+
*/
|
|
369
|
+
export const tierEngineFeature: FeatureDefinition = createTierEngineFeature();
|
package/src/tier-engine/index.ts
CHANGED
|
@@ -19,4 +19,8 @@ export {
|
|
|
19
19
|
} from "./compose-app";
|
|
20
20
|
export { TIER_ENGINE_FEATURE, TierEngineHandlers, TierEngineQueries } from "./constants";
|
|
21
21
|
export { tierAssignmentEntity } from "./entity";
|
|
22
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
type CreateTierEngineOptions,
|
|
24
|
+
createTierEngineFeature,
|
|
25
|
+
tierEngineFeature,
|
|
26
|
+
} from "./feature";
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
10
10
|
import {
|
|
11
|
-
createEntityTable,
|
|
12
|
-
pushTables,
|
|
13
11
|
setupTestStack,
|
|
14
12
|
type TestStack,
|
|
15
13
|
TestUsers,
|
|
14
|
+
unsafeCreateEntityTable,
|
|
15
|
+
unsafePushTables,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
17
|
import { eq } from "drizzle-orm";
|
|
18
18
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
@@ -31,8 +31,8 @@ beforeAll(async () => {
|
|
|
31
31
|
features: [createConfigFeature(), createUserFeature()],
|
|
32
32
|
extraContext: { configResolver: resolver },
|
|
33
33
|
});
|
|
34
|
-
await
|
|
35
|
-
await
|
|
34
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
35
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
36
36
|
await createEventsTable(stack.db);
|
|
37
37
|
});
|
|
38
38
|
|