@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.
Files changed (29) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  3. package/src/auth-email-password/constants.ts +6 -0
  4. package/src/auth-email-password/feature.ts +8 -1
  5. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  6. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  7. package/src/auth-email-password/index.ts +5 -1
  8. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  9. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  10. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +2 -2
  11. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  12. package/src/auth-email-password/web/login-screen.tsx +8 -5
  13. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  14. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  15. package/src/auth-mfa/constants.ts +2 -0
  16. package/src/auth-mfa/feature.ts +15 -0
  17. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  18. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  19. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  20. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  21. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  22. package/src/auth-mfa/web/i18n.ts +14 -0
  23. package/src/auth-mfa/web/index.ts +8 -2
  24. package/src/auth-mfa/web/mfa-client.ts +113 -0
  25. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  26. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  27. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  28. package/src/feature-toggles/index.ts +1 -0
  29. 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
+ }
@@ -1,3 +1,4 @@
1
+ export { composeTierResolverWithGlobalToggles } from "./compose-tier-resolver";
1
2
  export {
2
3
  FEATURE_TOGGLES_FEATURE,
3
4
  FeatureToggleHandlers,
@@ -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);