@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 +6 -6
- package/src/auth-email-password/web/auth-gate.tsx +64 -21
- package/src/auth-email-password/web/index.ts +2 -4
- package/src/auth-mfa/feature.ts +1 -1
- package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -0
- package/src/config/__tests__/config.integration.test.ts +50 -2
- package/src/file-foundation/__tests__/feature.test.ts +4 -4
- package/src/file-foundation/feature.ts +10 -11
- package/src/mail-foundation/__tests__/feature.test.ts +4 -5
- package/src/mail-foundation/feature.ts +21 -22
- package/src/user-data-rights/feature.ts +7 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
119
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
120
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer-web": "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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 === "
|
|
44
|
-
|
|
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
|
-
<
|
|
55
|
-
{
|
|
56
|
-
|
|
71
|
+
<MfaVerifyComponent
|
|
72
|
+
challengeToken={challengeToken}
|
|
73
|
+
onSuccess={() => setChallengeToken(null)}
|
|
74
|
+
onCancel={() => setChallengeToken(null)}
|
|
57
75
|
/>
|
|
58
76
|
);
|
|
59
77
|
}
|
|
60
|
-
return
|
|
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";
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -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.
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
expect(
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
67
|
+
r.extensionSelector(EXT_FILE_PROVIDER, providerConfigKey);
|
|
69
68
|
|
|
70
|
-
return {
|
|
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
|
-
|
|
33
|
-
expect(
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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",
|
|
153
|
+
r.extensionSelector("mailTransport", providerConfigKey);
|
|
155
154
|
|
|
156
155
|
return {
|
|
157
156
|
/** Config-key-handle for the provider-selector. */
|
|
158
|
-
|
|
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.
|
|
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.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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);
|