@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 +6 -6
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +25 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +17 -10
- package/src/auth-email-password/seeding.ts +16 -6
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +85 -28
- package/src/feature-toggles/compose-tier-resolver.ts +34 -10
- package/src/feature-toggles/feature.ts +5 -1
- package/src/feature-toggles/global-feature-state-table.ts +16 -0
- package/src/feature-toggles/index.ts +1 -0
- package/src/tenant/seeding.ts +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "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(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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,
|
|
49
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
|
|
30
50
|
const composed = composeTierResolverWithGlobalToggles(
|
|
31
|
-
tierResolverGranting("
|
|
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,
|
|
42
|
-
runtime.apply("
|
|
64
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
|
|
65
|
+
runtime.apply("personal-access-tokens", false);
|
|
43
66
|
const composed = composeTierResolverWithGlobalToggles(
|
|
44
|
-
tierResolverGranting("
|
|
67
|
+
tierResolverGranting("personal-access-tokens"),
|
|
45
68
|
runtime,
|
|
69
|
+
testRegistry,
|
|
46
70
|
);
|
|
47
|
-
expect([...composed("t1" as TenantId)]).toEqual(["
|
|
71
|
+
expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
|
|
48
72
|
});
|
|
49
73
|
|
|
50
|
-
test("explicit override=true
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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,
|
|
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([
|
|
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
|
|
75
|
-
const runtime = new GlobalFeatureToggleRuntime(fakeDb,
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
+
});
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -34,8 +34,15 @@ import {
|
|
|
34
34
|
createTenantDb,
|
|
35
35
|
type DbRunner,
|
|
36
36
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
37
|
-
import
|
|
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
|
|