@cosmicdrift/kumiko-bundled-features 0.150.0 → 0.151.1

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.150.0",
3
+ "version": "0.151.1",
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>",
@@ -115,11 +115,11 @@
115
115
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
116
116
  },
117
117
  "dependencies": {
118
- "@cosmicdrift/kumiko-dispatcher-live": "0.150.0",
119
- "@cosmicdrift/kumiko-framework": "0.150.0",
120
- "@cosmicdrift/kumiko-headless": "0.150.0",
121
- "@cosmicdrift/kumiko-renderer": "0.150.0",
122
- "@cosmicdrift/kumiko-renderer-web": "0.150.0",
118
+ "@cosmicdrift/kumiko-dispatcher-live": "0.151.1",
119
+ "@cosmicdrift/kumiko-framework": "0.151.1",
120
+ "@cosmicdrift/kumiko-headless": "0.151.1",
121
+ "@cosmicdrift/kumiko-renderer": "0.151.1",
122
+ "@cosmicdrift/kumiko-renderer-web": "0.151.1",
123
123
  "@mollie/api-client": "^4.5.0",
124
124
  "imapflow": "^1.3.3",
125
125
  "mailparser": "^3.9.8",
@@ -9,7 +9,7 @@
9
9
  // (nur `{ children }`-Prop). Der Sample kann so einen eigenen Login-
10
10
  // Screen rein konfigurieren, ohne den Gate selbst ersetzen zu müssen.
11
11
 
12
- import { type ComponentType, type ReactNode, useState } from "react";
12
+ import { type ComponentType, type ReactNode, useEffect, useState } from "react";
13
13
  import { LoginScreen, type LoginScreenProps } from "./login-screen";
14
14
  import { SessionProvider, useSession } from "./session";
15
15
 
@@ -23,41 +23,84 @@ export type MfaVerifyComponentProps = {
23
23
  readonly onCancel?: () => void;
24
24
  };
25
25
 
26
- export function makeAuthGate(
27
- LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
28
- loginProps?: LoginScreenProps,
29
- MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
30
- ): ComponentType<{ children: ReactNode }> {
31
- function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
26
+ export type LoginRouteOptions = {
27
+ readonly loginScreen?: ComponentType<LoginScreenProps>;
28
+ readonly loginScreenProps?: LoginScreenProps;
29
+ readonly mfaVerifyScreen?: ComponentType<MfaVerifyComponentProps>;
30
+ /** Called once the session becomes authenticated. makeAuthGate ignores
31
+ * this (it renders `children` on its own authenticated branch instead);
32
+ * standalone routes — no parent gate, e.g. an anonymous apex/marketing
33
+ * surface — use it to redirect. */
34
+ readonly onAuthenticated?: () => void;
35
+ };
36
+
37
+ // The one sanctioned way to render a login flow that correctly completes
38
+ // an MFA challenge — makeAuthGate below is just this wrapped for the
39
+ // `{ children }` gate shape. Also exported directly for apps that render
40
+ // their own standalone login route outside any gate (createPublicSurface
41
+ // apex pages stack providers, not gates — see auth-mount.tsx recipes).
42
+ // Handing an app raw LoginScreen + telling it to hand-roll the challenge-
43
+ // token swap itself is exactly how kumiko-framework#266's login-time MFA
44
+ // step went missing in a real apex surface; this makes that mistake
45
+ // structurally unavailable — there is no lower-level piece left to misuse.
46
+ export function createLoginRoute(
47
+ opts: LoginRouteOptions = {},
48
+ ): ComponentType<Record<string, never>> {
49
+ const LoginComponent = opts.loginScreen ?? LoginScreen;
50
+ const MfaVerifyComponent = opts.mfaVerifyScreen;
51
+
52
+ function LoginRoute(): ReactNode {
32
53
  const { status } = useSession();
33
54
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
34
55
  // (not in SessionState) because it's a UI-only transition — the server
35
56
  // never considers this session authenticated until verify succeeds.
36
57
  const [challengeToken, setChallengeToken] = useState<string | null>(null);
37
58
 
59
+ useEffect(() => {
60
+ if (status === "authenticated") opts.onAuthenticated?.();
61
+ }, [status]);
62
+
38
63
  if (status === "loading") {
39
64
  return (
40
65
  <div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm" />
41
66
  );
42
67
  }
43
- if (status === "unauthenticated") {
44
- if (challengeToken !== null && MfaVerifyComponent) {
45
- return (
46
- <MfaVerifyComponent
47
- challengeToken={challengeToken}
48
- onSuccess={() => setChallengeToken(null)}
49
- onCancel={() => setChallengeToken(null)}
50
- />
51
- );
52
- }
68
+ if (status === "authenticated") return null;
69
+ if (challengeToken !== null && MfaVerifyComponent) {
53
70
  return (
54
- <LoginComponent
55
- {...loginProps}
56
- onMfaChallenge={MfaVerifyComponent ? setChallengeToken : loginProps?.onMfaChallenge}
71
+ <MfaVerifyComponent
72
+ challengeToken={challengeToken}
73
+ onSuccess={() => setChallengeToken(null)}
74
+ onCancel={() => setChallengeToken(null)}
57
75
  />
58
76
  );
59
77
  }
60
- return <>{children}</>;
78
+ return (
79
+ <LoginComponent
80
+ {...opts.loginScreenProps}
81
+ onMfaChallenge={
82
+ MfaVerifyComponent ? setChallengeToken : opts.loginScreenProps?.onMfaChallenge
83
+ }
84
+ />
85
+ );
86
+ }
87
+ return LoginRoute;
88
+ }
89
+
90
+ export function makeAuthGate(
91
+ LoginComponent: ComponentType<LoginScreenProps> = LoginScreen,
92
+ loginProps?: LoginScreenProps,
93
+ MfaVerifyComponent?: ComponentType<MfaVerifyComponentProps>,
94
+ ): ComponentType<{ children: ReactNode }> {
95
+ const LoginRoute = createLoginRoute({
96
+ loginScreen: LoginComponent,
97
+ loginScreenProps: loginProps,
98
+ mfaVerifyScreen: MfaVerifyComponent,
99
+ });
100
+ function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
101
+ const { status } = useSession();
102
+ if (status === "authenticated") return <>{children}</>;
103
+ return <LoginRoute />;
61
104
  }
62
105
  return AuthGate;
63
106
  }
@@ -27,8 +27,8 @@ export {
27
27
  } from "./auth-client";
28
28
  export type { AuthCardProps, AuthShellRenderer } from "./auth-form-primitives";
29
29
  export { AuthCard, AuthShellProvider, useAuthShell } from "./auth-form-primitives";
30
- export type { MfaVerifyComponentProps } from "./auth-gate";
31
- export { makeAuthGate, makeSessionAuthGate } from "./auth-gate";
30
+ export type { LoginRouteOptions, MfaVerifyComponentProps } from "./auth-gate";
31
+ export { createLoginRoute, makeAuthGate, makeSessionAuthGate } from "./auth-gate";
32
32
  export type {
33
33
  EmailPasswordClientFeature,
34
34
  EmailPasswordClientOptions,
@@ -40,8 +40,6 @@ export type { ForgotPasswordScreenProps } from "./forgot-password-screen";
40
40
  export { ForgotPasswordScreen } from "./forgot-password-screen";
41
41
  export type { InviteAcceptScreenProps } from "./invite-accept-screen";
42
42
  export { InviteAcceptScreen } from "./invite-accept-screen";
43
- export type { LoginScreenProps } from "./login-screen";
44
- export { LoginScreen } from "./login-screen";
45
43
  export type { ResetPasswordScreenProps } from "./reset-password-screen";
46
44
  export { ResetPasswordScreen } from "./reset-password-screen";
47
45
  export type { SessionApi, SessionState, SessionStatus } from "./session";
@@ -84,7 +84,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
84
84
  // the app boots clean and the first MFA login dies at runtime on an
85
85
  // unknown handler instead of failing at mount time.
86
86
  r.requires("tenant");
87
- r.config({ keys: { required: mfaRequiredConfigKey() } });
87
+ r.configKey("required", mfaRequiredConfigKey());
88
88
 
89
89
  // Dormant custom-screen — the client maps MFA_ENABLE_SCREEN_ID to
90
90
  // MfaEnableScreen (see personal-access-tokens/feature.ts for the same
@@ -9,6 +9,13 @@
9
9
 
10
10
  import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
11
11
  import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
+ // qrcode's package.json#browser remap avoids Node-only deps (yargs/pngjs)
13
+ // for bundlers that honor it — Metro doesn't, hence the explicit subpath.
14
+ // @types/qrcode only covers the main "qrcode" entry, not this subpath, and
15
+ // TypeScript can't auto-discover an ambient .d.ts sibling from inside a
16
+ // node_modules package (consuming apps typecheck this raw .tsx source).
17
+ // Apps mounting MfaEnableScreen need their own local ambient shim — see
18
+ // qrcode-browser.d.ts in this directory for the declaration to copy.
12
19
  import QRCode from "qrcode/lib/browser";
13
20
  import { type ReactNode, useState } from "react";
14
21
  // kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
@@ -131,17 +131,34 @@ const ordersFeature = defineFeature("orders", (r) => {
131
131
  });
132
132
  });
133
133
 
134
+ // r.configKey() shorthand — single-key sibling of r.config({keys}), same
135
+ // wiring end-to-end (registry → resolver → ctx.config). No seeds needed here.
136
+ const configKeyDemoFeature = defineFeature("configkeydemo", (r) => {
137
+ r.requires("config");
138
+ const betaEnabled = r.configKey(
139
+ "betaEnabled",
140
+ createTenantConfig("boolean", { default: false, write: access.roles("Admin") }),
141
+ );
142
+ return { betaEnabled };
143
+ });
144
+
134
145
  // Probe feature: a real writeHandler reads two configs through ctx.config
135
146
  // so the dispatcher-wiring path is exercised end-to-end (not just the
136
147
  // factory in isolation). Captures the resolved values for the test to assert.
137
- const probe: { orders: number | undefined; push: boolean | undefined } = {
148
+ const probe: {
149
+ orders: number | undefined;
150
+ push: boolean | undefined;
151
+ betaEnabled: boolean | undefined;
152
+ } = {
138
153
  orders: undefined,
139
154
  push: undefined,
155
+ betaEnabled: undefined,
140
156
  };
141
157
  const probeFeature = defineFeature("probe", (r) => {
142
158
  r.requires("config");
143
159
  r.requires("orders");
144
160
  r.requires("notifications");
161
+ r.requires("configkeydemo");
145
162
 
146
163
  r.writeHandler(
147
164
  "read-config",
@@ -150,7 +167,11 @@ const probeFeature = defineFeature("probe", (r) => {
150
167
  if (!ctx.config) throw new Error("ctx.config not wired — _configAccessorFactory missing");
151
168
  probe.orders = await ctx.config(ordersFeature.exports.maxOrderCount);
152
169
  probe.push = await ctx.config(notificationsFeature.exports.pushEnabled);
153
- return { isSuccess: true, data: { orders: probe.orders, push: probe.push } };
170
+ probe.betaEnabled = await ctx.config(configKeyDemoFeature.exports.betaEnabled);
171
+ return {
172
+ isSuccess: true,
173
+ data: { orders: probe.orders, push: probe.push, betaEnabled: probe.betaEnabled },
174
+ };
154
175
  },
155
176
  { access: { openToAll: true } },
156
177
  );
@@ -267,6 +288,7 @@ beforeAll(async () => {
267
288
  notificationsFeature,
268
289
  ordersFeature,
269
290
  integrationFeature,
291
+ configKeyDemoFeature,
270
292
  probeFeature,
271
293
  seedFeature,
272
294
  transportFeature,
@@ -781,12 +803,38 @@ describe("ctx.config() in handler context", () => {
781
803
  // Reset so prior test order doesn't pollute the assertion.
782
804
  probe.orders = undefined;
783
805
  probe.push = undefined;
806
+ probe.betaEnabled = undefined;
784
807
  await stack.http.writeOk("probe:write:read-config", {}, tenantAdmin);
785
808
  // The probe handler ran ctx.config(handle) for both keys — typeof
786
809
  // catches the regression where the handle path silently returns the
787
810
  // broad union instead of the narrowed primitive.
788
811
  expect(typeof probe.orders).toBe("number");
789
812
  expect(typeof probe.push).toBe("boolean");
813
+ // r.configKey()'s handle goes through the exact same ctx.config() path.
814
+ // (value-level check — true vs. false — is covered by the next test,
815
+ // which reads via configFn without the narrowing hazard of a plain
816
+ // `undefined`-initialized probe field.)
817
+ expect(typeof probe.betaEnabled).toBe("boolean");
818
+ });
819
+
820
+ test("r.configKey() value is settable/readable via ctx.config, same as r.config", async () => {
821
+ expect(configKeyDemoFeature.exports.betaEnabled.name).toBe("configkeydemo:config:beta-enabled");
822
+
823
+ const configFn = createConfigAccessor(
824
+ stack.registry,
825
+ resolver,
826
+ tenantAdmin.tenantId,
827
+ tenantAdmin.id,
828
+ db,
829
+ );
830
+ expect(await configFn(configKeyDemoFeature.exports.betaEnabled)).toBe(false);
831
+
832
+ await stack.http.writeOk(
833
+ ConfigHandlers.set,
834
+ { key: "configkeydemo:config:beta-enabled", value: true },
835
+ tenantAdmin,
836
+ );
837
+ expect(await configFn(configKeyDemoFeature.exports.betaEnabled)).toBe(true);
790
838
  });
791
839
  });
792
840
 
@@ -21,10 +21,10 @@ describe("fileFoundationFeature — shape", () => {
21
21
 
22
22
  describe("fileFoundationFeature.exports — typed handles", () => {
23
23
  test("exposes only the provider-selector config-key", () => {
24
- const keys = fileFoundationFeature.exports.configKeys;
25
- expect(keys.provider).toBeDefined();
26
- expect((keys as Record<string, unknown>)["bucket"]).toBeUndefined();
27
- expect((keys as Record<string, unknown>)["region"]).toBeUndefined();
24
+ expect(fileFoundationFeature.exports.providerConfigKey).toBeDefined();
25
+ expect(fileFoundationFeature.exports.providerConfigKey.name).toBe(
26
+ "file-foundation:config:provider",
27
+ );
28
28
  });
29
29
  });
30
30
 
@@ -54,18 +54,17 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
54
54
  },
55
55
  });
56
56
 
57
- const configKeys = r.config({
58
- keys: {
59
- provider: createTenantConfig("text", {
60
- default: "",
61
- write: access.roles("TenantAdmin", "SystemAdmin"),
62
- read: access.roles("TenantAdmin", "SystemAdmin", "User"),
63
- }),
64
- },
65
- });
57
+ const providerConfigKey = r.configKey(
58
+ "provider",
59
+ createTenantConfig("text", {
60
+ default: "",
61
+ write: access.roles("TenantAdmin", "SystemAdmin"),
62
+ read: access.roles("TenantAdmin", "SystemAdmin", "User"),
63
+ }),
64
+ );
66
65
  // Readiness gating: provider-plugins' required keys/secrets count only
67
66
  // while their plugin is the one this key selects.
68
- r.extensionSelector(EXT_FILE_PROVIDER, configKeys.provider);
67
+ r.extensionSelector(EXT_FILE_PROVIDER, providerConfigKey);
69
68
 
70
- return { configKeys };
69
+ return { providerConfigKey };
71
70
  });
@@ -29,11 +29,10 @@ describe("mailFoundationFeature — shape", () => {
29
29
 
30
30
  describe("mailFoundationFeature.exports — typed handles", () => {
31
31
  test("exposes only the provider-selector config-key", () => {
32
- const keys = mailFoundationFeature.exports.configKeys;
33
- expect(keys.provider).toBeDefined();
34
- // No host/port/from/authUser — those live in the provider-plugin.
35
- expect((keys as Record<string, unknown>)["host"]).toBeUndefined();
36
- expect((keys as Record<string, unknown>)["port"]).toBeUndefined();
32
+ expect(mailFoundationFeature.exports.providerConfigKey).toBeDefined();
33
+ expect(mailFoundationFeature.exports.providerConfigKey.name).toBe(
34
+ "mail-foundation:config:provider",
35
+ );
37
36
  });
38
37
  });
39
38
 
@@ -130,32 +130,31 @@ export const mailFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
130
130
  },
131
131
  });
132
132
 
133
- const configKeys = r.config({
134
- keys: {
135
- // Provider-selector. Default empty so the boot-validator throws
136
- // if a tenant tries to send mail without first picking + setting
137
- // up a provider — better than a silent fallback.
138
- // The actual list of valid values lives in the registered plugins,
139
- // not here — Designer-UI can render `getExtensionUsages
140
- // ("mailTransport").map(u => u.entityName)` as the option-list.
141
- provider: createTenantConfig("text", {
142
- default: "",
143
- // required: ohne gewählten Provider wirft createTransportForTenant —
144
- // readiness meldete vorher ready:true und der erste Mail-Send
145
- // lieferte den UnconfiguredError (280/1).
146
- required: true,
147
- write: access.roles("TenantAdmin", "SystemAdmin"),
148
- read: access.roles("TenantAdmin", "SystemAdmin", "User"),
149
- }),
150
- },
151
- });
133
+ // Provider-selector. Default empty so the boot-validator throws if a
134
+ // tenant tries to send mail without first picking + setting up a
135
+ // provider — better than a silent fallback. The actual list of valid
136
+ // values lives in the registered plugins, not here Designer-UI can
137
+ // render `getExtensionUsages("mailTransport").map(u => u.entityName)`
138
+ // as the option-list.
139
+ const providerConfigKey = r.configKey(
140
+ "provider",
141
+ createTenantConfig("text", {
142
+ default: "",
143
+ // required: ohne gewählten Provider wirft createTransportForTenant —
144
+ // readiness meldete vorher ready:true und der erste Mail-Send
145
+ // lieferte den UnconfiguredError (280/1).
146
+ required: true,
147
+ write: access.roles("TenantAdmin", "SystemAdmin"),
148
+ read: access.roles("TenantAdmin", "SystemAdmin", "User"),
149
+ }),
150
+ );
152
151
  // Readiness gating: transport-plugins' required keys/secrets count only
153
152
  // while their plugin is the one this key selects.
154
- r.extensionSelector("mailTransport", configKeys.provider);
153
+ r.extensionSelector("mailTransport", providerConfigKey);
155
154
 
156
155
  return {
157
156
  /** Config-key-handle for the provider-selector. */
158
- configKeys,
157
+ providerConfigKey,
159
158
  };
160
159
  });
161
160
 
@@ -190,7 +189,7 @@ export async function createTransportForTenant(
190
189
  }
191
190
 
192
191
  const provider = requireDefined(
193
- await ctxConfig(mailFoundationFeature.exports.configKeys.provider),
192
+ await ctxConfig(mailFoundationFeature.exports.providerConfigKey),
194
193
  FEATURE_NAME,
195
194
  "provider",
196
195
  ) as string; // @cast-boundary engine-payload
@@ -161,14 +161,13 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
161
161
  // "single-user" via appOverrides (TENANT_MODEL_CONFIG_KEY) so the forget
162
162
  // pipeline may erase the tenant's data as that user's personal data — still
163
163
  // gated by a runtime sole-member check in run-forget-cleanup.
164
- r.config({
165
- keys: {
166
- tenantModel: createSystemConfig("select", {
167
- default: "multi-user",
168
- options: ["single-user", "multi-user"],
169
- }),
170
- },
171
- });
164
+ r.configKey(
165
+ "tenantModel",
166
+ createSystemConfig("select", {
167
+ default: "multi-user",
168
+ options: ["single-user", "multi-user"],
169
+ }),
170
+ );
172
171
 
173
172
  // S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
174
173
  r.entity("export-job", exportJobEntity);