@cosmicdrift/kumiko-bundled-features 0.160.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 (47) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
  3. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
  4. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
  5. package/src/auth-email-password/constants.ts +6 -0
  6. package/src/auth-email-password/feature.ts +8 -1
  7. package/src/auth-email-password/handlers/login.write.ts +6 -3
  8. package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
  9. package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
  10. package/src/auth-email-password/i18n.ts +4 -0
  11. package/src/auth-email-password/index.ts +5 -1
  12. package/src/auth-email-password/self-registration-toggle.ts +25 -0
  13. package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
  14. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
  15. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
  16. package/src/auth-email-password/web/auth-client.ts +4 -3
  17. package/src/auth-email-password/web/auth-gate.tsx +44 -2
  18. package/src/auth-email-password/web/login-screen.tsx +11 -5
  19. package/src/auth-foundation/__tests__/feature.test.ts +16 -3
  20. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  21. package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
  22. package/src/auth-foundation/boot-checks.ts +54 -0
  23. package/src/auth-foundation/feature.ts +77 -23
  24. package/src/auth-foundation/index.ts +14 -0
  25. package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
  26. package/src/auth-foundation/types.ts +63 -0
  27. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
  28. package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
  29. package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
  30. package/src/auth-mfa/constants.ts +3 -0
  31. package/src/auth-mfa/feature.ts +15 -0
  32. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
  33. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
  34. package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
  35. package/src/auth-mfa/mfa-setup-token.ts +7 -1
  36. package/src/auth-mfa/mfa-status-checker.ts +13 -4
  37. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
  38. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
  39. package/src/auth-mfa/web/i18n.ts +14 -0
  40. package/src/auth-mfa/web/index.ts +8 -2
  41. package/src/auth-mfa/web/mfa-client.ts +113 -0
  42. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
  43. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
  44. package/src/feature-toggles/compose-tier-resolver.ts +41 -0
  45. package/src/feature-toggles/index.ts +1 -0
  46. package/src/feature-toggles/toggle-runtime.ts +14 -0
  47. package/src/sessions/feature.ts +8 -1
@@ -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);
@@ -42,6 +42,9 @@ export type SessionsFeatureOptions = {
42
42
  // only to supply a custom revoker — an explicit value wins over the
43
43
  // runtime binding.
44
44
  readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
45
+ // Session JWT / store_user_sessions TTL. Was previously auth.sessions.expiresInMs
46
+ // (#1372) — lives on the sessions feature now that sessionStore is the wiring.
47
+ readonly expiresInMs?: number;
45
48
  };
46
49
 
47
50
  export type BindAutoRevokeOnPasswordChange = (revoker: SessionMassRevoker) => void;
@@ -120,12 +123,16 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
120
123
  // via resolveSessionStore().
121
124
  r.useExtension(EXT_SESSION_STORE, "default", {
122
125
  build: (deps): SessionStore => {
123
- const callbacks = createSessionCallbacks({ db: deps.db });
126
+ const callbacks = createSessionCallbacks({
127
+ db: deps.db,
128
+ ...(options?.expiresInMs !== undefined && { expiresInMs: options.expiresInMs }),
129
+ });
124
130
  return {
125
131
  creator: callbacks.sessionCreator,
126
132
  revoker: callbacks.sessionRevoker,
127
133
  checker: callbacks.sessionChecker,
128
134
  massRevoker: callbacks.sessionMassRevoker,
135
+ revokeAllOthers: callbacks.sessionRevokeAllOthers,
129
136
  };
130
137
  },
131
138
  } satisfies SessionStoreProvider);