@cosmicdrift/kumiko-bundled-features 0.2.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/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.2.0",
3
+ "version": "0.2.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>",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
9
+ "url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
10
10
  "directory": "packages/bundled-features"
11
11
  },
12
12
  "bugs": {
13
- "url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
13
+ "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
14
14
  },
15
15
  "homepage": "https://kumiko.so",
16
16
  "type": "module",
@@ -0,0 +1,84 @@
1
+ // Sprint 8a recipe-test pin: registered.query nutzt SYSTEM_TENANT_ID
2
+ // statt event.user.tenantId. Operator-tooling muss PLATTFORM-truth
3
+ // sehen, nicht den eigenen tier-cut. Convention ist in DispatcherOptions.
4
+ // effectiveFeatures dokumentiert; dieser test pinst sie damit ein
5
+ // future-refactor (z.B. mechanisches sed oder copy-paste) sie nicht
6
+ // silent zurückdreht zu event.user.tenantId.
7
+ //
8
+ // Pure unit-test ist nicht möglich weil registered.query einen DB-select
9
+ // auf globalFeatureStateTable macht BEVOR der effectiveFeatures-call
10
+ // läuft. Wir mocken den ctx.db.select-pfad damit der handler komplett
11
+ // durchläuft. Die Convention-Pin ist die einzige Aussage des tests —
12
+ // echtes integration-Verhalten deckt feature-toggles.integration.ts ab.
13
+
14
+ import {
15
+ createEntity,
16
+ createRegistry,
17
+ createTextField,
18
+ defineFeature,
19
+ SYSTEM_TENANT_ID,
20
+ type TenantId,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { createDispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
23
+ import { createTestUser } from "@cosmicdrift/kumiko-framework/stack";
24
+ import { describe, expect, test } from "vitest";
25
+ import { createFeatureTogglesFeature } from "../feature";
26
+ import type { GlobalFeatureToggleRuntime } from "../toggle-runtime";
27
+
28
+ describe("Sprint 8a: registered.query SYSTEM_TENANT_ID convention", () => {
29
+ test("ruft effectiveFeatures mit SYSTEM_TENANT_ID, nicht mit caller-tenantId", async () => {
30
+ const observed: string[] = [];
31
+
32
+ const dummy = defineFeature("dummy", (r) => {
33
+ r.entity("widget", createEntity({ table: "Widgets", fields: { name: createTextField() } }));
34
+ });
35
+
36
+ const runtime: GlobalFeatureToggleRuntime | null = null;
37
+ const featureToggles = createFeatureTogglesFeature({
38
+ getRuntime: () => {
39
+ if (!runtime) throw new Error("runtime not initialized");
40
+ return runtime;
41
+ },
42
+ });
43
+
44
+ const registry = createRegistry([dummy, featureToggles]);
45
+
46
+ // Mock ctx.db.select-chain damit der handler durch den DB-Pfad
47
+ // kommt. Wir liefern leere overrides (.from() returnt []), das
48
+ // genügt — registered.query iteriert dann über registry.features
49
+ // und ruft ctx.effectiveFeatures, was unser observable ist.
50
+ const mockDb = {
51
+ select: () => ({ from: async () => [] as unknown[] }),
52
+ } as unknown as Parameters<typeof createDispatcher>[1]["db"];
53
+
54
+ const callerTenant = "00000000-0000-4000-8000-0000000000c1" as TenantId;
55
+
56
+ const dispatcher = createDispatcher(
57
+ registry,
58
+ { db: mockDb },
59
+ {
60
+ effectiveFeatures: (tenantId) => {
61
+ observed.push(tenantId);
62
+ return new Set(["dummy", "feature-toggles"]);
63
+ },
64
+ },
65
+ );
66
+
67
+ const admin = createTestUser({
68
+ id: "admin-1",
69
+ tenantId: callerTenant,
70
+ roles: ["SystemAdmin"],
71
+ });
72
+
73
+ await dispatcher.query("feature-toggles:query:registered", {}, admin);
74
+
75
+ // Pin: registered.query call führt zu MINDESTENS zwei effectiveFeatures-
76
+ // calls:
77
+ // 1. dispatcher's checkFeatureEnabled (mit user.tenantId = callerTenant)
78
+ // 2. registered.query handler-body (mit SYSTEM_TENANT_ID)
79
+ // Wenn ein future-refactor SYSTEM_TENANT_ID zu event.user.tenantId zurück-
80
+ // dreht, fehlt der zweite call und dieser test fail't.
81
+ expect(observed).toContain(callerTenant);
82
+ expect(observed).toContain(SYSTEM_TENANT_ID);
83
+ });
84
+ });
@@ -1,4 +1,4 @@
1
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
3
  import { globalFeatureStateTable } from "../global-feature-state-table";
4
4
 
@@ -24,7 +24,12 @@ export const registeredQuery = defineQueryHandler({
24
24
  .from(globalFeatureStateTable)) as OverrideRow[];
25
25
  const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
26
26
 
27
- const effective = ctx.effectiveFeatures?.();
27
+ // SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
28
+ // zeigen (alle features im Registry), nicht den eigenen tier-cut.
29
+ // Sprint-8a per-tenant signature → wir rufen mit SYSTEM_TENANT_ID,
30
+ // App-resolver returnt union-of-all-tier-features. Sentinel-Convention
31
+ // dokumentiert in DispatcherOptions.effectiveFeatures.
32
+ const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
28
33
 
29
34
  const items = [];
30
35
  for (const feature of ctx.registry.features.values()) {
@@ -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,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). composeApp-Helper liest diesen Stand und leitet daraus ab,
6
- // welche Features für den Tenant gemountet werden.
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. Es speichert nur den Tier-Namen als String. Die
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
- // **Standard-CRUD-Handler in Sprint 1:** Create/Update/List/Detail per
14
- // `defineEntityXxxHandler`. Idempotente set-tier-Logic (deterministic
15
- // aggregate-id, create-or-update-Routing) kommt im stripe-sync-Feature
16
- // in Sprint 5 als Wrapper darum.
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
- // **Tenant-Scope:** tier-engine ist tenant-scoped. Plattform-Tenant verwaltet
19
- // seinen eigenen Tier (Self-Service-Upgrade-UI). Stripe-Webhook (Sprint 5)
20
- // wird im stripe-sync-Feature die tenant-resolution machen und den
21
- // tier-assignment:create/update-Handler mit dem aufgelösten Context
22
- // aufrufen.
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
- // **Was Sprint 1 NICHT macht:**
25
- // - Custom Domain-Events (`tier-changed`) emittiert werden derzeit nur
26
- // die CRUD-Auto-Events. Sprint 4 (Add-On-Marketplace) erweitert das auf
27
- // semantische Domain-Events.
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
- const adminAccess = { access: { roles: ["TenantAdmin", "SystemAdmin"] } } as const;
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
- export const tierEngineFeature: FeatureDefinition = defineFeature(TIER_ENGINE_FEATURE, (r) => {
52
- r.requires("config");
53
- r.requires("tenant");
54
-
55
- r.entity("tier-assignment", tierAssignmentEntity);
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();
@@ -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 { tierEngineFeature } from "./feature";
22
+ export {
23
+ type CreateTierEngineOptions,
24
+ createTierEngineFeature,
25
+ tierEngineFeature,
26
+ } from "./feature";