@cosmicdrift/kumiko-bundled-features 0.161.0 → 0.162.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__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +8 -5
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/constants.ts +2 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pre-auth twin of MfaEnableScreen: a user blocked at login by MFA
|
|
3
|
+
// enforcement and not yet enrolled lands here straight out of the login
|
|
4
|
+
// flow — there is no session yet. Identity comes entirely from the
|
|
5
|
+
// preauthSetupToken login()'s mfa-setup-required result carries (see
|
|
6
|
+
// auth-client.ts's LoginResult). Calls startMfaSetupPreauth/confirmMfa-
|
|
7
|
+
// SetupPreauth directly via fetch (mfa-client.ts) — no useSession(), no
|
|
8
|
+
// useDispatcher(), both assume a real session that doesn't exist here.
|
|
9
|
+
//
|
|
10
|
+
// Session contract: a successful confirm mints cookie+JWT server-side, but
|
|
11
|
+
// THIS component does not refresh the session itself (unlike MfaVerify-
|
|
12
|
+
// Screen, which calls session.refresh() internally). onSuccess fires right
|
|
13
|
+
// after confirm — the caller (e.g. auth-gate's LoginRoute, which already
|
|
14
|
+
// has useSession() in scope) must call refresh() there, otherwise the app
|
|
15
|
+
// stays "unauthenticated" until the next reload.
|
|
16
|
+
|
|
17
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
18
|
+
// qrcode's package.json#browser remap avoids Node-only deps (yargs/pngjs)
|
|
19
|
+
// for bundlers that honor it — Metro doesn't, hence the explicit subpath.
|
|
20
|
+
import QRCode from "qrcode/lib/browser";
|
|
21
|
+
import { type FormEvent, type ReactNode, useState } from "react";
|
|
22
|
+
// kumiko-lint-ignore cross-feature-import client-only component, the feature's server barrel has no web/ re-export
|
|
23
|
+
import { AuthCard } from "../../auth-email-password/web";
|
|
24
|
+
import { confirmMfaSetupPreauth, startMfaSetupPreauth } from "./mfa-client";
|
|
25
|
+
|
|
26
|
+
type SetupState = {
|
|
27
|
+
readonly setupToken: string;
|
|
28
|
+
readonly secretParam: string;
|
|
29
|
+
readonly recoveryCodes: readonly string[];
|
|
30
|
+
readonly qrSvg: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// otpauth:// URIs put the secret in the query string — extract it for the
|
|
34
|
+
// manual-entry fallback (authenticator apps without a camera / desktop use).
|
|
35
|
+
function extractSecret(otpauthUri: string): string {
|
|
36
|
+
const query = otpauthUri.split("?")[1] ?? "";
|
|
37
|
+
return new URLSearchParams(query).get("secret") ?? "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function reasonToKey(reason: string): string {
|
|
41
|
+
switch (reason) {
|
|
42
|
+
case "invalid_totp_code":
|
|
43
|
+
return "auth.mfa.errors.invalidCode";
|
|
44
|
+
case "mfa_already_enabled":
|
|
45
|
+
return "auth.mfa.errors.mfaAlreadyEnabled";
|
|
46
|
+
case "invalid_setup_token":
|
|
47
|
+
return "auth.mfa.errors.invalidSetupToken";
|
|
48
|
+
case "invalid_challenge_token":
|
|
49
|
+
return "auth.mfa.errors.challengeExpired";
|
|
50
|
+
case "too_many_attempts":
|
|
51
|
+
return "auth.mfa.errors.tooManyAttempts";
|
|
52
|
+
case "rate_limited":
|
|
53
|
+
return "auth.errors.rateLimited";
|
|
54
|
+
default:
|
|
55
|
+
return "auth.mfa.errors.setupFailed";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MfaSetupPreauthScreenProps = {
|
|
60
|
+
readonly preauthSetupToken: string;
|
|
61
|
+
/** Account label for the otpauth:// URI (typically the email typed at
|
|
62
|
+
* login) — without a session there's no session.user.email to derive it from. */
|
|
63
|
+
readonly accountLabel: string;
|
|
64
|
+
readonly title?: string;
|
|
65
|
+
readonly subtitle?: ReactNode;
|
|
66
|
+
/** Fired once confirm succeeds — the caller owns session refresh, see the
|
|
67
|
+
* file-level comment above. */
|
|
68
|
+
readonly onSuccess?: () => void;
|
|
69
|
+
readonly onCancel?: () => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function MfaSetupPreauthScreen({
|
|
73
|
+
preauthSetupToken,
|
|
74
|
+
accountLabel,
|
|
75
|
+
title,
|
|
76
|
+
subtitle,
|
|
77
|
+
onSuccess,
|
|
78
|
+
onCancel,
|
|
79
|
+
}: MfaSetupPreauthScreenProps): ReactNode {
|
|
80
|
+
const t = useTranslation();
|
|
81
|
+
const { Button, Banner, Field, Form, Input, Section } = usePrimitives();
|
|
82
|
+
|
|
83
|
+
const [setup, setSetup] = useState<SetupState | null>(null);
|
|
84
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
85
|
+
const [code, setCode] = useState("");
|
|
86
|
+
const [busy, setBusy] = useState(false);
|
|
87
|
+
const [error, setError] = useState<string | null>(null);
|
|
88
|
+
|
|
89
|
+
const startSetup = async (): Promise<void> => {
|
|
90
|
+
setBusy(true);
|
|
91
|
+
setError(null);
|
|
92
|
+
try {
|
|
93
|
+
const res = await startMfaSetupPreauth(preauthSetupToken, accountLabel);
|
|
94
|
+
if (res.kind !== "success") {
|
|
95
|
+
setError(res.error.reason);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// errorCorrectionLevel "H" (~30% redundancy) — more resilient to
|
|
99
|
+
// camera/lighting issues than the default, no downside for a code
|
|
100
|
+
// this short-lived.
|
|
101
|
+
const qrSvg = await QRCode.toString(res.data.otpauthUri, {
|
|
102
|
+
type: "svg",
|
|
103
|
+
errorCorrectionLevel: "H",
|
|
104
|
+
});
|
|
105
|
+
setSetup({
|
|
106
|
+
setupToken: res.data.setupToken,
|
|
107
|
+
secretParam: extractSecret(res.data.otpauthUri),
|
|
108
|
+
recoveryCodes: res.data.recoveryCodes,
|
|
109
|
+
qrSvg,
|
|
110
|
+
});
|
|
111
|
+
setAcknowledged(false);
|
|
112
|
+
setCode("");
|
|
113
|
+
} catch {
|
|
114
|
+
setError("setup_failed");
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const confirmSetup = async (): Promise<void> => {
|
|
121
|
+
if (!setup) return;
|
|
122
|
+
setBusy(true);
|
|
123
|
+
setError(null);
|
|
124
|
+
try {
|
|
125
|
+
const res = await confirmMfaSetupPreauth(setup.setupToken, code);
|
|
126
|
+
if (res.kind !== "success") {
|
|
127
|
+
setError(res.error.reason);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
onSuccess?.();
|
|
131
|
+
} catch {
|
|
132
|
+
setError("setup_failed");
|
|
133
|
+
} finally {
|
|
134
|
+
setBusy(false);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onSubmit = (e?: FormEvent): void => {
|
|
139
|
+
e?.preventDefault();
|
|
140
|
+
void confirmSetup();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<AuthCard
|
|
145
|
+
title={title ?? t("auth.mfa.setup.title")}
|
|
146
|
+
subtitle={subtitle ?? t("auth.mfa.setup.subtitle")}
|
|
147
|
+
>
|
|
148
|
+
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
149
|
+
{error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
|
|
150
|
+
|
|
151
|
+
{!setup && (
|
|
152
|
+
<Section
|
|
153
|
+
testId="mfa-setup-preauth-intro"
|
|
154
|
+
actions={
|
|
155
|
+
<Button
|
|
156
|
+
variant="primary"
|
|
157
|
+
onClick={() => void startSetup()}
|
|
158
|
+
loading={busy}
|
|
159
|
+
disabled={busy}
|
|
160
|
+
>
|
|
161
|
+
{t("auth.mfa.setup.start")}
|
|
162
|
+
</Button>
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
|
|
166
|
+
</Section>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{setup && (
|
|
170
|
+
<Form onSubmit={onSubmit}>
|
|
171
|
+
<Section
|
|
172
|
+
testId="mfa-setup-preauth-setup"
|
|
173
|
+
actions={
|
|
174
|
+
<Button
|
|
175
|
+
type="submit"
|
|
176
|
+
variant="primary"
|
|
177
|
+
loading={busy}
|
|
178
|
+
disabled={busy || !acknowledged || code.length < 6}
|
|
179
|
+
>
|
|
180
|
+
{t("auth.mfa.setup.confirm")}
|
|
181
|
+
</Button>
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
185
|
+
<span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
|
|
186
|
+
{/* qrcode's own SVG string output, not user input — safe to inline */}
|
|
187
|
+
<div
|
|
188
|
+
className="h-40 w-40"
|
|
189
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
|
|
190
|
+
dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
|
|
191
|
+
/>
|
|
192
|
+
<span className="text-xs text-muted-foreground">
|
|
193
|
+
{t("auth.mfa.enable.manualEntry")}
|
|
194
|
+
</span>
|
|
195
|
+
<code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
196
|
+
{setup.secretParam}
|
|
197
|
+
</code>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="flex flex-col gap-2">
|
|
201
|
+
<span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
|
|
202
|
+
<span className="text-xs text-muted-foreground">
|
|
203
|
+
{t("auth.mfa.enable.recoveryHint")}
|
|
204
|
+
</span>
|
|
205
|
+
<code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
206
|
+
{setup.recoveryCodes.join("\n")}
|
|
207
|
+
</code>
|
|
208
|
+
<Field id="mfa-setup-preauth-ack" label={t("auth.mfa.enable.acknowledge")}>
|
|
209
|
+
<Input
|
|
210
|
+
kind="boolean"
|
|
211
|
+
id="mfa-setup-preauth-ack"
|
|
212
|
+
name="mfa-setup-preauth-ack"
|
|
213
|
+
value={acknowledged}
|
|
214
|
+
onChange={setAcknowledged}
|
|
215
|
+
/>
|
|
216
|
+
</Field>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
|
|
220
|
+
<Input
|
|
221
|
+
kind="text"
|
|
222
|
+
id="mfa-setup-preauth-code"
|
|
223
|
+
name="mfa-setup-preauth-code"
|
|
224
|
+
value={code}
|
|
225
|
+
onChange={setCode}
|
|
226
|
+
disabled={busy || !acknowledged}
|
|
227
|
+
autoComplete="one-time-code"
|
|
228
|
+
/>
|
|
229
|
+
</Field>
|
|
230
|
+
</Section>
|
|
231
|
+
</Form>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{onCancel ? (
|
|
235
|
+
<div className="flex justify-center">
|
|
236
|
+
<Button type="button" variant="secondary" onClick={onCancel} disabled={busy}>
|
|
237
|
+
{t("auth.mfa.verify.backToLogin")}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
</AuthCard>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Unit test (no DB) — the composition logic is pure given a tier resolver
|
|
2
|
+
// function + a GlobalFeatureToggleRuntime whose in-memory snapshot is set
|
|
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.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type {
|
|
11
|
+
EffectiveFeaturesResolver,
|
|
12
|
+
Registry,
|
|
13
|
+
TenantId,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { composeTierResolverWithGlobalToggles } from "../compose-tier-resolver";
|
|
16
|
+
import { GlobalFeatureToggleRuntime } from "../toggle-runtime";
|
|
17
|
+
|
|
18
|
+
// Never touched by .apply()/.readOverride() — the runtime only does I/O in
|
|
19
|
+
// .initialize()/.refresh(), which this test never calls.
|
|
20
|
+
const fakeDb = {} as unknown as DbConnection;
|
|
21
|
+
const fakeRegistry = {} as unknown as Registry;
|
|
22
|
+
|
|
23
|
+
function tierResolverGranting(...features: readonly string[]): EffectiveFeaturesResolver {
|
|
24
|
+
return ((_tenantId: TenantId) => new Set(features)) as EffectiveFeaturesResolver;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("composeTierResolverWithGlobalToggles", () => {
|
|
28
|
+
test("no override → tier's grant stands", () => {
|
|
29
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
|
|
30
|
+
const composed = composeTierResolverWithGlobalToggles(
|
|
31
|
+
tierResolverGranting("auth-self-registration", "personal-access-tokens"),
|
|
32
|
+
runtime,
|
|
33
|
+
);
|
|
34
|
+
expect([...composed("t1" as TenantId)].sort()).toEqual([
|
|
35
|
+
"auth-self-registration",
|
|
36
|
+
"personal-access-tokens",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("explicit override=false removes a tier-granted feature", () => {
|
|
41
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
|
|
42
|
+
runtime.apply("auth-self-registration", false);
|
|
43
|
+
const composed = composeTierResolverWithGlobalToggles(
|
|
44
|
+
tierResolverGranting("auth-self-registration", "personal-access-tokens"),
|
|
45
|
+
runtime,
|
|
46
|
+
);
|
|
47
|
+
expect([...composed("t1" as TenantId)]).toEqual(["personal-access-tokens"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
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
|
+
);
|
|
57
|
+
expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a tier-gated toggleable feature with NO override row stays on — no fallback to its own default", () => {
|
|
61
|
+
// Regression pin for the exact bug this composer exists to avoid:
|
|
62
|
+
// a Team-tier feature declared `toggleable({default:false})` must stay
|
|
63
|
+
// granted when the tier includes it and no operator has touched it —
|
|
64
|
+
// computeEffectiveFeatures' cascade would otherwise fall back to
|
|
65
|
+
// `false` the moment feature-toggles is composed in.
|
|
66
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
|
|
67
|
+
const composed = composeTierResolverWithGlobalToggles(
|
|
68
|
+
tierResolverGranting("personal-access-tokens"),
|
|
69
|
+
runtime,
|
|
70
|
+
);
|
|
71
|
+
expect([...composed("t1" as TenantId)]).toEqual(["personal-access-tokens"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("flip back on restores the tier's grant", () => {
|
|
75
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
|
|
76
|
+
runtime.apply("auth-self-registration", false);
|
|
77
|
+
const composed = composeTierResolverWithGlobalToggles(
|
|
78
|
+
tierResolverGranting("auth-self-registration"),
|
|
79
|
+
runtime,
|
|
80
|
+
);
|
|
81
|
+
expect([...composed("t1" as TenantId)]).toEqual([]);
|
|
82
|
+
|
|
83
|
+
runtime.apply("auth-self-registration", true);
|
|
84
|
+
expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("preserves the tier resolver's trialGate", async () => {
|
|
88
|
+
const runtime = new GlobalFeatureToggleRuntime(fakeDb, fakeRegistry);
|
|
89
|
+
const tierResolver = tierResolverGranting();
|
|
90
|
+
const trialGate = async (_tenantId: TenantId, _featureName: string) => true;
|
|
91
|
+
Object.assign(tierResolver, { trialGate });
|
|
92
|
+
|
|
93
|
+
const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime);
|
|
94
|
+
expect(composed.trialGate).toBe(trialGate);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { EffectiveFeaturesResolver } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
|
|
3
|
+
|
|
4
|
+
// Composes a tenantTierResolver (tier-engine) with a GlobalFeatureToggleRuntime
|
|
5
|
+
// (feature-toggles) into a single resolver an app can pass as
|
|
6
|
+
// runProdApp/runDevApp's `effectiveFeatures`.
|
|
7
|
+
//
|
|
8
|
+
// The framework only auto-wires ONE tenantTierResolver plugin (single-plugin
|
|
9
|
+
// assumption, see tier-resolver-extension.ts) — an app running BOTH
|
|
10
|
+
// tier-engine (per-tenant feature cuts) and feature-toggles (global runtime
|
|
11
|
+
// switches, e.g. an operator kill-switch not tied to any tier) needs to
|
|
12
|
+
// combine them itself. This is that combination, factored out here instead
|
|
13
|
+
// of duplicated per app.
|
|
14
|
+
//
|
|
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.
|
|
24
|
+
export function composeTierResolverWithGlobalToggles(
|
|
25
|
+
tierResolver: EffectiveFeaturesResolver,
|
|
26
|
+
toggleRuntime: GlobalFeatureToggleRuntime,
|
|
27
|
+
): EffectiveFeaturesResolver {
|
|
28
|
+
const composed = ((tenantId) => {
|
|
29
|
+
const tierSet = tierResolver(tenantId);
|
|
30
|
+
const result = new Set<string>();
|
|
31
|
+
for (const name of tierSet) {
|
|
32
|
+
if (toggleRuntime.readOverride(name) !== false) result.add(name);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}) as EffectiveFeaturesResolver;
|
|
36
|
+
|
|
37
|
+
if (tierResolver.trialGate) {
|
|
38
|
+
return Object.assign(composed, { trialGate: tierResolver.trialGate });
|
|
39
|
+
}
|
|
40
|
+
return composed;
|
|
41
|
+
}
|
|
@@ -47,6 +47,20 @@ export class GlobalFeatureToggleRuntime {
|
|
|
47
47
|
this.snapshot.set(featureName, enabled);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Raw per-feature override, bypassing the requires() cascade —
|
|
51
|
+
// `undefined` means "no explicit row, inherits toggleableDefault"
|
|
52
|
+
// (distinct from an explicit `false`). Used by composeTierResolver to
|
|
53
|
+
// layer a global kill-switch UNDER a tenantTierResolver: the composed
|
|
54
|
+
// resolver must only ever narrow what the tier already grants, never
|
|
55
|
+
// fall back to a toggleable feature's declared default the way
|
|
56
|
+
// effectiveFeatures()/computeEffectiveFeatures do — that default exists
|
|
57
|
+
// for apps with no tier resolver, and would otherwise turn tier-gated
|
|
58
|
+
// toggleable features (e.g. a Team-tier feature defaulting off) globally
|
|
59
|
+
// off the moment feature-toggles is composed in.
|
|
60
|
+
readOverride(featureName: string): boolean | undefined {
|
|
61
|
+
return this.snapshot.get(featureName);
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
// The callback shape the dispatcher expects. Computes the effective
|
|
51
65
|
// feature set from the current snapshot + the registry's requires()
|
|
52
66
|
// cascade every call. Cheap: the cascade is a DFS over O(features);
|