@cosmicdrift/kumiko-bundled-features 0.162.0 → 0.163.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.162.0",
3
+ "version": "0.163.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>",
@@ -117,11 +117,11 @@
117
117
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
118
118
  },
119
119
  "dependencies": {
120
- "@cosmicdrift/kumiko-dispatcher-live": "0.162.0",
121
- "@cosmicdrift/kumiko-framework": "0.162.0",
122
- "@cosmicdrift/kumiko-headless": "0.162.0",
123
- "@cosmicdrift/kumiko-renderer": "0.162.0",
124
- "@cosmicdrift/kumiko-renderer-web": "0.162.0",
120
+ "@cosmicdrift/kumiko-dispatcher-live": "0.163.0",
121
+ "@cosmicdrift/kumiko-framework": "0.163.0",
122
+ "@cosmicdrift/kumiko-headless": "0.163.0",
123
+ "@cosmicdrift/kumiko-renderer": "0.163.0",
124
+ "@cosmicdrift/kumiko-renderer-web": "0.163.0",
125
125
  "@mollie/api-client": "^4.5.0",
126
126
  "imapflow": "^1.3.3",
127
127
  "mailparser": "^3.9.8",
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
28
28
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
29
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
29
30
  import {
30
31
  setupTestStack,
31
32
  type TestStack,
@@ -44,11 +45,18 @@ import { createTemplateResolverFeature } from "../../template-resolver/feature";
44
45
  import { createTenantFeature } from "../../tenant";
45
46
  import { tenantMembershipsTable } from "../../tenant/membership-table";
46
47
  import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
48
+ // kumiko-lint-ignore cross-feature-import regression-proof for #1463: seedTenant
49
+ // must fire tier-engine's entity postSave hook on self-signup, not just on the
50
+ // TenantHandlers.create HTTP path.
51
+ import { tierAssignmentEntity } from "../../tier-engine/entity";
52
+ import { createTierEngineFeature } from "../../tier-engine/feature";
47
53
  import { createUserFeature } from "../../user/feature";
48
54
  import { userEntity, userTable } from "../../user/schema/user";
49
55
  import { AuthErrors, AuthHandlers } from "../constants";
50
56
  import { createAuthEmailPasswordFeature } from "../feature";
51
57
 
58
+ const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
59
+
52
60
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
53
61
 
54
62
  // Activation mails now go through delivery (ctx.notify → channel-email). The
@@ -78,6 +86,12 @@ beforeAll(async () => {
78
86
  createAuthEmailPasswordFeature({
79
87
  signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
80
88
  }),
89
+ // Regression-proof for #1463: seedTenant must fire the entity postSave
90
+ // hook on self-signup too, not just on the TenantHandlers.create path.
91
+ createTierEngineFeature({
92
+ defaultTier: "free",
93
+ tierMap: { free: { features: [], caps: {} } },
94
+ }),
81
95
  ],
82
96
  extraContext: (deps) => ({
83
97
  ...createDeliveryTestContext(deps),
@@ -102,6 +116,7 @@ beforeAll(async () => {
102
116
  configValuesTable,
103
117
  tenantMembershipsTable,
104
118
  notificationPreferencesTable,
119
+ tierAssignmentTable,
105
120
  });
106
121
  });
107
122
 
@@ -112,6 +127,7 @@ afterAll(async () => {
112
127
  beforeEach(async () => {
113
128
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
114
129
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
130
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tierAssignmentTable.tableName}"`);
115
131
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
116
132
  emailTransport.sent.length = 0;
117
133
  // Redis-cleanup damit Resend-Tests keine state-leaks haben.
@@ -220,6 +236,15 @@ describe("POST /api/auth/signup-confirm", () => {
220
236
  expect(JSON.parse(rolesRaw) as string[]).toContain("Admin");
221
237
  }
222
238
 
239
+ // #1463 regression: seedTenant fires the tenant entity's postSave
240
+ // hooks — tier-engine's auto-default-tier hook must run on self-signup
241
+ // exactly like it does on the regular TenantHandlers.create HTTP path.
242
+ const tierRows = await selectMany(stack.db, tierAssignmentTable, {
243
+ tenantId: body.user?.tenantId ?? "",
244
+ });
245
+ expect(tierRows).toHaveLength(1);
246
+ expect(tierRows[0]?.["tier"]).toBe("free");
247
+
223
248
  // Authority-Beweis: Login mit dem gesetzten Password funktioniert.
224
249
  const loginRes = await postLogin(email, password);
225
250
  expect(loginRes.status).toBe(200);
@@ -109,16 +109,23 @@ export function createSignupConfirmHandler() {
109
109
 
110
110
  let provisioned: { readonly userId: string; readonly tenantId: TenantId };
111
111
  try {
112
- provisioned = await provisionSignupAccount(dbConn, {
113
- email,
114
- password: event.payload.password,
115
- displayName,
116
- tenantId,
117
- tenantKey,
118
- // Tenant-Display-Name als Default = Email. User wechselt das im
119
- // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
120
- tenantName: email,
121
- });
112
+ provisioned = await provisionSignupAccount(
113
+ dbConn,
114
+ {
115
+ email,
116
+ password: event.payload.password,
117
+ displayName,
118
+ tenantId,
119
+ tenantKey,
120
+ // Tenant-Display-Name als Default = Email. User wechselt das im
121
+ // Settings-Screen. Konzept "Tenant" leakt nicht in die Signup-UI.
122
+ tenantName: email,
123
+ },
124
+ // #1463: seedTenant's postSave hooks (tier-engine's auto-default-
125
+ // tier, an app's auto-default-compliance) must fire on self-signup
126
+ // exactly like they do on the regular TenantHandlers.create path.
127
+ { registry: ctx.registry, context: ctx },
128
+ );
122
129
  } catch (err) {
123
130
  // Email hat bereits ein Konto — provisionSignupAccount ist create-only
124
131
  // (#365): sauberer User-Fehler, KEINE Session für den fremden Account.
@@ -19,7 +19,7 @@ import { ConflictError } from "@cosmicdrift/kumiko-framework/errors";
19
19
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
20
20
  import { hashPassword } from "../shared";
21
21
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
22
- import { seedTenant, seedTenantMembership } from "../tenant/seeding";
22
+ import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenant/seeding";
23
23
  // kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
24
24
  import { userTable } from "../user/schema/user";
25
25
  // kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
@@ -115,6 +115,12 @@ export type ProvisionSignupAccountOptions = {
115
115
  export async function provisionSignupAccount(
116
116
  db: DbConnection,
117
117
  options: ProvisionSignupAccountOptions,
118
+ // Optional, append-only — existing callers (tests, seed scripts) that
119
+ // don't pass hooks keep today's behavior. The real signup-confirm
120
+ // handler DOES pass it (#1463) so seedTenant's postSave hooks
121
+ // (tier-engine's auto-default-tier, app auto-default-compliance) fire
122
+ // on self-signup, same as they do on the regular tenant-create path.
123
+ hooks?: SeedTenantHooks,
118
124
  ): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
119
125
  // Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
120
126
  // abbrechen, sonst entstünde ein verwaister Tenant und seedUser (idempotent
@@ -124,11 +130,15 @@ export async function provisionSignupAccount(
124
130
  if (existingUser) {
125
131
  throw new ConflictError({ message: "signup: email already registered" });
126
132
  }
127
- await seedTenant(db, {
128
- id: options.tenantId,
129
- key: options.tenantKey,
130
- name: options.tenantName,
131
- });
133
+ await seedTenant(
134
+ db,
135
+ {
136
+ id: options.tenantId,
137
+ key: options.tenantKey,
138
+ name: options.tenantName,
139
+ },
140
+ hooks,
141
+ );
132
142
  const { id: userId } = await seedUserWithPassword(db, {
133
143
  email: options.email,
134
144
  password: options.password,
@@ -1,16 +1,25 @@
1
1
  // Unit test (no DB) — the composition logic is pure given a tier resolver
2
2
  // function + a GlobalFeatureToggleRuntime whose in-memory snapshot is set
3
3
  // directly via .apply(). Pins the semantics documented in
4
- // compose-tier-resolver.ts: the global layer only NARROWS what the tier
5
- // grants (explicit `false` removes it), it never falls back to a
6
- // toggleable feature's own default and never widens beyond the tier.
4
+ // compose-tier-resolver.ts:
5
+ // - tier-managed toggleables (in SOME tier's `features` list, per the
6
+ // tierResolver(SYSTEM_TENANT_ID) union convention): the global layer
7
+ // only NARROWS what the tier grants — an explicit `false` removes it,
8
+ // no row / `true` leaves the tier's grant untouched, it can never widen
9
+ // beyond what the tenant's own tier includes.
10
+ // - tier-unaware toggleables (in NO tier's `features` list at all, e.g. a
11
+ // pure operator kill-switch like auth-self-registration): membership
12
+ // comes entirely from computeEffectiveFeatures' normal cascade
13
+ // (override ?? toggleableDefault) since no tier ever votes on them.
7
14
 
8
15
  import { describe, expect, test } from "bun:test";
9
16
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
- import type {
11
- EffectiveFeaturesResolver,
12
- Registry,
13
- TenantId,
17
+ import {
18
+ createRegistry,
19
+ defineFeature,
20
+ type EffectiveFeaturesResolver,
21
+ SYSTEM_TENANT_ID,
22
+ type TenantId,
14
23
  } from "@cosmicdrift/kumiko-framework/engine";
15
24
  import { composeTierResolverWithGlobalToggles } from "../compose-tier-resolver";
16
25
  import { GlobalFeatureToggleRuntime } from "../toggle-runtime";
@@ -18,7 +27,18 @@ import { GlobalFeatureToggleRuntime } from "../toggle-runtime";
18
27
  // Never touched by .apply()/.readOverride() — the runtime only does I/O in
19
28
  // .initialize()/.refresh(), which this test never calls.
20
29
  const fakeDb = {} as unknown as DbConnection;
21
- const fakeRegistry = {} as unknown as Registry;
30
+
31
+ // Mirrors publicstatus's real mix: "personal-access-tokens" is Team-tier-
32
+ // gated (toggleable, default false), "auth-self-registration" is a global
33
+ // kill-switch no tier ever lists (toggleable, default true).
34
+ const testRegistry = createRegistry([
35
+ defineFeature("personal-access-tokens", (r) => {
36
+ r.toggleable({ default: false });
37
+ }),
38
+ defineFeature("auth-self-registration", (r) => {
39
+ r.toggleable({ default: true });
40
+ }),
41
+ ]);
22
42
 
23
43
  function tierResolverGranting(...features: readonly string[]): EffectiveFeaturesResolver {
24
44
  return ((_tenantId: TenantId) => new Set(features)) as EffectiveFeaturesResolver;
@@ -26,11 +46,14 @@ function tierResolverGranting(...features: readonly string[]): EffectiveFeatures
26
46
 
27
47
  describe("composeTierResolverWithGlobalToggles", () => {
28
48
  test("no override → tier's grant stands", () => {
29
- const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
49
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
30
50
  const composed = composeTierResolverWithGlobalToggles(
31
- tierResolverGranting("auth-self-registration", "personal-access-tokens"),
51
+ tierResolverGranting("personal-access-tokens"),
32
52
  runtime,
53
+ testRegistry,
33
54
  );
55
+ // auth-self-registration joins via the tier-unaware cascade (default
56
+ // true, no override) — see the dedicated tier-unaware tests below.
34
57
  expect([...composed("t1" as TenantId)].sort()).toEqual([
35
58
  "auth-self-registration",
36
59
  "personal-access-tokens",
@@ -38,22 +61,30 @@ describe("composeTierResolverWithGlobalToggles", () => {
38
61
  });
39
62
 
40
63
  test("explicit override=false removes a tier-granted feature", () => {
41
- const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
42
- runtime.apply("auth-self-registration", false);
64
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
65
+ runtime.apply("personal-access-tokens", false);
43
66
  const composed = composeTierResolverWithGlobalToggles(
44
- tierResolverGranting("auth-self-registration", "personal-access-tokens"),
67
+ tierResolverGranting("personal-access-tokens"),
45
68
  runtime,
69
+ testRegistry,
46
70
  );
47
- expect([...composed("t1" as TenantId)]).toEqual(["personal-access-tokens"]);
71
+ expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
48
72
  });
49
73
 
50
- test("explicit override=true is a no-op cannot widen beyond the tier's grant", () => {
51
- const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
52
- runtime.apply("some-other-feature", true);
53
- const composed = composeTierResolverWithGlobalToggles(
54
- tierResolverGranting("auth-self-registration"),
55
- runtime,
56
- );
74
+ test("explicit override=true cannot widen a tier-managed feature beyond the tenant's own tier", () => {
75
+ // "personal-access-tokens" IS tier-managed (it's in SOME tier's list,
76
+ // per the SYSTEM_TENANT_ID union), but THIS tenant's own tier grants
77
+ // nothing (e.g. Free). An operator flipping the global row to true
78
+ // must not leak it in — that decision belongs to the tier, not the
79
+ // toggle.
80
+ const tierResolver = ((tenantId: TenantId) =>
81
+ tenantId === SYSTEM_TENANT_ID
82
+ ? new Set(["personal-access-tokens"]) // union of all tiers' features
83
+ : new Set<string>()) as EffectiveFeaturesResolver; // this tenant's (Free) tier grants nothing
84
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
85
+ runtime.apply("personal-access-tokens", true);
86
+ const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, testRegistry);
87
+ // auth-self-registration still joins via the tier-unaware cascade.
57
88
  expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
58
89
  });
59
90
 
@@ -63,20 +94,46 @@ describe("composeTierResolverWithGlobalToggles", () => {
63
94
  // granted when the tier includes it and no operator has touched it —
64
95
  // computeEffectiveFeatures' cascade would otherwise fall back to
65
96
  // `false` the moment feature-toggles is composed in.
66
- const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
97
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
67
98
  const composed = composeTierResolverWithGlobalToggles(
68
99
  tierResolverGranting("personal-access-tokens"),
69
100
  runtime,
101
+ testRegistry,
70
102
  );
71
- expect([...composed("t1" as TenantId)]).toEqual(["personal-access-tokens"]);
103
+ expect([...composed("t1" as TenantId)].sort()).toEqual([
104
+ "auth-self-registration",
105
+ "personal-access-tokens",
106
+ ]);
107
+ });
108
+
109
+ test("tier-unaware toggleable (in no tier's feature list) defaults on via the global cascade", () => {
110
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
111
+ const composed = composeTierResolverWithGlobalToggles(
112
+ tierResolverGranting(), // no tier grants anything
113
+ runtime,
114
+ testRegistry,
115
+ );
116
+ expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
117
+ });
118
+
119
+ test("tier-unaware toggleable — explicit override=false turns it off for every tenant", () => {
120
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
121
+ runtime.apply("auth-self-registration", false);
122
+ const composed = composeTierResolverWithGlobalToggles(
123
+ tierResolverGranting(),
124
+ runtime,
125
+ testRegistry,
126
+ );
127
+ expect([...composed("t1" as TenantId)]).toEqual([]);
72
128
  });
73
129
 
74
- test("flip back on restores the tier's grant", () => {
75
- const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
130
+ test("flip back on restores a tier-unaware feature's default", () => {
131
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
76
132
  runtime.apply("auth-self-registration", false);
77
133
  const composed = composeTierResolverWithGlobalToggles(
78
- tierResolverGranting("auth-self-registration"),
134
+ tierResolverGranting(),
79
135
  runtime,
136
+ testRegistry,
80
137
  );
81
138
  expect([...composed("t1" as TenantId)]).toEqual([]);
82
139
 
@@ -85,12 +142,12 @@ describe("composeTierResolverWithGlobalToggles", () => {
85
142
  });
86
143
 
87
144
  test("preserves the tier resolver's trialGate", async () => {
88
- const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
145
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
89
146
  const tierResolver = tierResolverGranting();
90
147
  const trialGate = async (_tenantId: TenantId, _featureName: string) => true;
91
148
  Object.assign(tierResolver, { trialGate });
92
149
 
93
- const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime);
150
+ const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, testRegistry);
94
151
  expect(composed.trialGate).toBe(trialGate);
95
152
  });
96
153
  });
@@ -1,4 +1,9 @@
1
- import type { EffectiveFeaturesResolver } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ computeEffectiveFeatures,
3
+ type EffectiveFeaturesResolver,
4
+ type Registry,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
2
7
  import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
3
8
 
4
9
  // Composes a tenantTierResolver (tier-engine) with a GlobalFeatureToggleRuntime
@@ -12,25 +17,44 @@ import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
12
17
  // combine them itself. This is that combination, factored out here instead
13
18
  // of duplicated per app.
14
19
  //
15
- // Semantics: the global toggle layer can only NARROW what the tier grants,
16
- // never widen it an explicit `enabled: false` row removes a feature the
17
- // tier would otherwise include; no row, or an explicit `true` row, leaves
18
- // the tier's grant untouched. Deliberately NOT computeEffectiveFeatures'
19
- // cascade (which falls back to a toggleable feature's declared default):
20
- // under tier-engine, a toggleable feature with no override row is governed
21
- // by the TIER, not by its own default — falling back to the default would
22
- // silently kill every tier-gated toggleable feature (e.g. a Team-tier
23
- // feature defaulting `false`) the moment feature-toggles gets composed in.
20
+ // Two disjoint classes of toggleable feature exist once both are mounted:
21
+ // - tier-managed (e.g. a Team-tier perk like personal-access-tokens): the
22
+ // TIER decides membership, not the feature's own default a toggleable
23
+ // feature with no override row must stay governed by its tier, or every
24
+ // tier-gated toggleable would silently leak/vanish the moment
25
+ // feature-toggles is composed in. The global layer may only NARROW this
26
+ // (`enabled: false` removes it; no row / `true` leaves the tier's grant
27
+ // untouched).
28
+ // - tier-unaware (e.g. a pure operator kill-switch with no tier
29
+ // differentiation at all, such as auth-self-registration): no tier's
30
+ // `features` list ever mentions it, so it would never appear in ANY
31
+ // tenant's tier-set — narrowing alone can only remove features, never
32
+ // grant this one. Its membership must come from computeEffectiveFeatures'
33
+ // normal cascade (override ?? toggleableDefault) instead.
34
+ //
35
+ // `tierResolver(SYSTEM_TENANT_ID)` is tier-engine's documented convention for
36
+ // "union of every tier's features" (operator-tooling/async-dispatch
37
+ // convention) — used here purely to classify which toggleable names are
38
+ // tier-managed, without needing the app's TierMap as a separate parameter.
24
39
  export function composeTierResolverWithGlobalToggles(
25
40
  tierResolver: EffectiveFeaturesResolver,
26
41
  toggleRuntime: GlobalFeatureToggleRuntime,
42
+ registry: Registry,
27
43
  ): EffectiveFeaturesResolver {
44
+ const tierManaged = tierResolver(SYSTEM_TENANT_ID);
45
+
28
46
  const composed = ((tenantId) => {
29
47
  const tierSet = tierResolver(tenantId);
30
48
  const result = new Set<string>();
31
49
  for (const name of tierSet) {
32
50
  if (toggleRuntime.readOverride(name) !== false) result.add(name);
33
51
  }
52
+ const globalCascade = computeEffectiveFeatures(registry, (name) =>
53
+ toggleRuntime.readOverride(name),
54
+ );
55
+ for (const name of globalCascade) {
56
+ if (!tierManaged.has(name)) result.add(name);
57
+ }
34
58
  return result;
35
59
  }) as EffectiveFeaturesResolver;
36
60
 
@@ -1,6 +1,7 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { FEATURE_TOGGLE_SET_EVENT_NAME, TOGGLE_ADMIN_SCREEN_ID } from "./constants";
3
3
  import { featureToggleSetSchema } from "./events";
4
+ import { globalFeatureStateTableMeta } from "./global-feature-state-table";
4
5
  import { listQuery } from "./handlers/list.query";
5
6
  import { registeredQuery } from "./handlers/registered.query";
6
7
  import { createSetWriteHandler } from "./handlers/set.write";
@@ -47,6 +48,9 @@ export function createFeatureTogglesFeature(
47
48
  recommended: false,
48
49
  });
49
50
  r.systemScope();
51
+ r.storeTable(globalFeatureStateTableMeta, {
52
+ reason: "feature_toggles.global_override_snapshot",
53
+ });
50
54
 
51
55
  // Toggle-change domain event. The event ends up in the events-table
52
56
  // alongside every other write — audit.list picks it up automatically,
@@ -127,7 +131,7 @@ export function createFeatureTogglesFeature(
127
131
  }
128
132
 
129
133
  export { FEATURE_TOGGLE_SET_EVENT_NAME, FeatureToggleErrors } from "./constants";
130
- export { globalFeatureStateTable } from "./global-feature-state-table";
134
+ export { globalFeatureStateTable, globalFeatureStateTableMeta } from "./global-feature-state-table";
131
135
  // Re-export the runtime factory + class so app-boot code has a single
132
136
  // import path: "@cosmicdrift/kumiko-bundled-features/feature-toggles".
133
137
  export {
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  boolean,
3
+ defineUnmanagedTable,
4
+ type EntityTableMeta,
3
5
  instant,
4
6
  integer,
5
7
  table as pgTable,
@@ -26,3 +28,17 @@ export const globalFeatureStateTable = pgTable("store_global_feature_state", {
26
28
  // UserId (text — SessionUser.id is a uuid string post-ES).
27
29
  updatedBy: text("updated_by"),
28
30
  });
31
+
32
+ // r.storeTable meta — without this, collectTableMetas(FEATURES) never
33
+ // sees the table, so `kumiko schema generate` reports no changes and no
34
+ // app ever gets a migration for it (framework gap, not app-local).
35
+ export const globalFeatureStateTableMeta: EntityTableMeta = defineUnmanagedTable({
36
+ tableName: "store_global_feature_state",
37
+ columns: [
38
+ { name: "feature_name", pgType: "text", notNull: true, primaryKey: true },
39
+ { name: "enabled", pgType: "boolean", notNull: true },
40
+ { name: "version", pgType: "integer", notNull: true, defaultSql: "1" },
41
+ { name: "updated_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
42
+ { name: "updated_by", pgType: "text", notNull: false },
43
+ ],
44
+ });
@@ -13,4 +13,5 @@ export {
13
13
  type FeatureTogglesOptions,
14
14
  GlobalFeatureToggleRuntime,
15
15
  globalFeatureStateTable,
16
+ globalFeatureStateTableMeta,
16
17
  } from "./feature";
@@ -34,8 +34,15 @@ import {
34
34
  createTenantDb,
35
35
  type DbRunner,
36
36
  } from "@cosmicdrift/kumiko-framework/db";
37
- import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
37
+ import {
38
+ type AppContext,
39
+ HookPhases,
40
+ type Registry,
41
+ type SessionUser,
42
+ type TenantId,
43
+ } from "@cosmicdrift/kumiko-framework/engine";
38
44
  import { getUnscopedAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
45
+ import { createLifecycleHooks } from "@cosmicdrift/kumiko-framework/pipeline";
39
46
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
40
47
  import { assertAssignableMembershipRoles } from "./membership-roles";
41
48
  import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
@@ -74,6 +81,19 @@ export type SeedTenantOptions = {
74
81
  readonly by?: SessionUser;
75
82
  };
76
83
 
84
+ // seedTenant runs through the raw event-store executor, which fires no
85
+ // postSave hooks (#1463) — feature-registered entity hooks on "tenant"
86
+ // (e.g. tier-engine's auto-default-tier, an app's auto-default-compliance)
87
+ // silently never run for tenants created this way. Pass `hooks` to fire
88
+ // them explicitly, same lookup as the dispatcher (registry.getEntityPostSaveHooks
89
+ // by result.entityName), just without the full dispatch roundtrip. Omit it
90
+ // to keep today's hook-less behavior (existing fixture call-sites that don't
91
+ // care about hooks need no changes).
92
+ export type SeedTenantHooks = {
93
+ readonly registry: Registry;
94
+ readonly context: AppContext;
95
+ };
96
+
77
97
  /**
78
98
  * Seed a tenant through the event-store executor. Idempotent add-only:
79
99
  * a second call for the same `id` is a no-op (no update path). Same
@@ -83,6 +103,7 @@ export type SeedTenantOptions = {
83
103
  export async function seedTenant(
84
104
  db: DbRunner,
85
105
  options: SeedTenantOptions,
106
+ hooks?: SeedTenantHooks,
86
107
  ): Promise<{ id: TenantId }> {
87
108
  const by = options.by ?? TestUsers.systemAdmin;
88
109
  // executor.create erwartet eine TenantDb (mit .insert()-API), nicht
@@ -112,6 +133,24 @@ export async function seedTenant(
112
133
  `seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
113
134
  );
114
135
  }
136
+
137
+ if (hooks) {
138
+ // Fresh instance per call, no systemHooks — only feature-registered
139
+ // entity/handler hooks fire (search-index/SSE system hooks are wired
140
+ // at server-boot and out of reach here, same as before this fix).
141
+ // "tenant:seed" matches no handler-scoped hook, only entity-scoped ones
142
+ // (keyed by result.entityName === "tenant") — that's exactly what
143
+ // tier-engine's `r.hook("postSave", { allOf: "tenant" }, ...)` needs.
144
+ const lifecycle = createLifecycleHooks(hooks.registry);
145
+ await lifecycle.runPostSave(
146
+ "tenant:seed",
147
+ result.data,
148
+ hooks.context,
149
+ HookPhases.inTransaction,
150
+ );
151
+ await lifecycle.runPostSave("tenant:seed", result.data, hooks.context, HookPhases.afterCommit);
152
+ }
153
+
115
154
  return { id: options.id };
116
155
  }
117
156