@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.151.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 +7 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +1 -1
- package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +1 -1
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -2
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +6 -1
- package/src/auth-email-password/seeding.ts +1 -1
- package/src/auth-email-password/web/auth-gate.tsx +65 -20
- package/src/auth-email-password/web/index.ts +2 -4
- package/src/auth-email-password/web/login-screen.tsx +2 -0
- package/src/auth-mfa/__tests__/base32.test.ts +28 -0
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
- package/src/auth-mfa/__tests__/totp.test.ts +11 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
- package/src/auth-mfa/base32.ts +7 -1
- package/src/auth-mfa/constants.ts +14 -0
- package/src/auth-mfa/errors.ts +9 -16
- package/src/auth-mfa/feature.ts +5 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
- package/src/auth-mfa/handlers/verify.write.ts +26 -3
- package/src/auth-mfa/index.ts +0 -1
- package/src/auth-mfa/recovery-codes.ts +12 -2
- package/src/auth-mfa/schema/user-mfa.ts +3 -0
- package/src/auth-mfa/testing.ts +5 -0
- package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +44 -28
- package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
- package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
- package/src/config/__tests__/config.integration.test.ts +50 -2
- package/src/config/handlers/cascade.query.ts +2 -1
- package/src/config/handlers/values.query.ts +2 -1
- package/src/file-foundation/__tests__/feature.test.ts +4 -4
- package/src/file-foundation/feature.ts +10 -11
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
- package/src/legal-pages/feature.ts +2 -1
- package/src/mail-foundation/__tests__/feature.test.ts +4 -5
- package/src/mail-foundation/feature.ts +21 -22
- package/src/managed-pages/feature.ts +1 -6
- package/src/seo/feature.ts +1 -6
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
- package/src/sessions/__tests__/test-helpers.ts +1 -1
- package/src/sessions/constants.ts +5 -0
- package/src/sessions/feature.ts +67 -1
- package/src/sessions/handlers/detail.query.ts +36 -0
- package/src/sessions/i18n.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/system-query.ts +5 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
- package/src/user-data-rights/feature.ts +7 -8
- package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
- package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
- package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
- package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
- package/src/user-profile/handlers/change-email.write.ts +2 -2
- package/src/auth-email-password/password-hashing.ts +0 -4
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
|
-
|
|
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.
|
|
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
|
|
15
22
|
import { useSession } from "../../auth-email-password/web";
|
|
@@ -65,42 +72,51 @@ export function MfaEnableScreen({
|
|
|
65
72
|
const startSetup = async (): Promise<void> => {
|
|
66
73
|
setBusy(true);
|
|
67
74
|
setError(null);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
try {
|
|
76
|
+
const res = await dispatcher.write<EnableStartResponse>(AuthMfaHandlers.enableStart, {
|
|
77
|
+
accountLabel: session.user?.email ?? "",
|
|
78
|
+
});
|
|
79
|
+
if (!res.isSuccess) {
|
|
80
|
+
setError(res.error.code);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
|
|
84
|
+
setSetup({
|
|
85
|
+
setupToken: res.data.setupToken,
|
|
86
|
+
secretParam: extractSecret(res.data.otpauthUri),
|
|
87
|
+
recoveryCodes: res.data.recoveryCodes,
|
|
88
|
+
qrSvg,
|
|
89
|
+
});
|
|
90
|
+
setAcknowledged(false);
|
|
91
|
+
setCode("");
|
|
92
|
+
} catch {
|
|
93
|
+
setError("setup_failed");
|
|
94
|
+
} finally {
|
|
72
95
|
setBusy(false);
|
|
73
|
-
setError(res.error.code);
|
|
74
|
-
return;
|
|
75
96
|
}
|
|
76
|
-
const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
|
|
77
|
-
setBusy(false);
|
|
78
|
-
setSetup({
|
|
79
|
-
setupToken: res.data.setupToken,
|
|
80
|
-
secretParam: extractSecret(res.data.otpauthUri),
|
|
81
|
-
recoveryCodes: res.data.recoveryCodes,
|
|
82
|
-
qrSvg,
|
|
83
|
-
});
|
|
84
|
-
setAcknowledged(false);
|
|
85
|
-
setCode("");
|
|
86
97
|
};
|
|
87
98
|
|
|
88
99
|
const confirmSetup = async (): Promise<void> => {
|
|
89
100
|
if (!setup) return;
|
|
90
101
|
setBusy(true);
|
|
91
102
|
setError(null);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
try {
|
|
104
|
+
const res = await dispatcher.write(AuthMfaHandlers.enableConfirm, {
|
|
105
|
+
setupToken: setup.setupToken,
|
|
106
|
+
code,
|
|
107
|
+
});
|
|
108
|
+
if (!res.isSuccess) {
|
|
109
|
+
setError(res.error.code);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setEnabled(true);
|
|
113
|
+
setSetup(null);
|
|
114
|
+
onEnabled?.();
|
|
115
|
+
} catch {
|
|
116
|
+
setError("setup_failed");
|
|
117
|
+
} finally {
|
|
118
|
+
setBusy(false);
|
|
100
119
|
}
|
|
101
|
-
setEnabled(true);
|
|
102
|
-
setSetup(null);
|
|
103
|
-
onEnabled?.();
|
|
104
120
|
};
|
|
105
121
|
|
|
106
122
|
const content = (
|
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
// each re-deriving the key ad hoc (enable-screen used to template-string
|
|
8
8
|
// the raw snake_case code straight into the key, which never matched the
|
|
9
9
|
// camelCase keys actually registered below).
|
|
10
|
+
import { AuthMfaErrorCodes } from "../constants";
|
|
11
|
+
|
|
10
12
|
export function mfaManageErrorKey(code: string): string {
|
|
11
13
|
switch (code) {
|
|
12
|
-
case
|
|
14
|
+
case AuthMfaErrorCodes.invalidTotpCode:
|
|
13
15
|
return "auth.mfa.errors.invalidCode";
|
|
14
|
-
case
|
|
16
|
+
case AuthMfaErrorCodes.invalidRecoveryCode:
|
|
15
17
|
return "auth.mfa.errors.invalidRecoveryCode";
|
|
16
|
-
case
|
|
18
|
+
case AuthMfaErrorCodes.mfaAlreadyEnabled:
|
|
17
19
|
return "auth.mfa.errors.mfaAlreadyEnabled";
|
|
18
|
-
case
|
|
20
|
+
case AuthMfaErrorCodes.mfaNotEnabled:
|
|
19
21
|
return "auth.mfa.errors.mfaNotEnabled";
|
|
20
|
-
case
|
|
22
|
+
case AuthMfaErrorCodes.invalidSetupToken:
|
|
21
23
|
return "auth.mfa.errors.invalidSetupToken";
|
|
22
24
|
default:
|
|
23
25
|
return "auth.mfa.errors.verifyFailed";
|
|
@@ -21,6 +21,11 @@ export type MfaVerifyScreenProps = {
|
|
|
21
21
|
* refreshed. Optional — apps that swap this screen back out purely on
|
|
22
22
|
* session.status changing to "authenticated" don't need it. */
|
|
23
23
|
readonly onSuccess?: () => void;
|
|
24
|
+
// Some failures (challenge_expired, too_many_attempts) have no retry path —
|
|
25
|
+
// the challengeToken is dead. Without this, the host has no way back to
|
|
26
|
+
// login short of a full page reload. Optional: apps that always mount
|
|
27
|
+
// this screen fresh per challenge can omit it.
|
|
28
|
+
readonly onCancel?: () => void;
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
function reasonToKey(reason: string): string {
|
|
@@ -47,6 +52,7 @@ export function MfaVerifyScreen({
|
|
|
47
52
|
subtitle,
|
|
48
53
|
submitLabel,
|
|
49
54
|
onSuccess,
|
|
55
|
+
onCancel,
|
|
50
56
|
}: MfaVerifyScreenProps): ReactNode {
|
|
51
57
|
const t = useTranslation();
|
|
52
58
|
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
@@ -99,6 +105,11 @@ export function MfaVerifyScreen({
|
|
|
99
105
|
? t("auth.mfa.verify.submitting")
|
|
100
106
|
: (submitLabel ?? t("auth.mfa.verify.submit"))}
|
|
101
107
|
</Button>
|
|
108
|
+
{onCancel ? (
|
|
109
|
+
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
|
110
|
+
{t("auth.mfa.verify.backToLogin")}
|
|
111
|
+
</Button>
|
|
112
|
+
) : null}
|
|
102
113
|
</Form>
|
|
103
114
|
</div>
|
|
104
115
|
</AuthCard>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @types/qrcode only declares the main "qrcode" entry, not the browser-only
|
|
2
|
+
// subpath (see mfa-enable-screen.tsx's import — the main entry pulls in
|
|
3
|
+
// Node-only deps like yargs/pngjs via qrcode's own package.json#browser
|
|
4
|
+
// remap, which Metro doesn't honor). Same runtime shape, just re-typed.
|
|
5
|
+
declare module "qrcode/lib/browser" {
|
|
6
|
+
export * from "qrcode";
|
|
7
|
+
}
|
|
@@ -20,6 +20,7 @@ import { currentTotpCode } from "../../auth-mfa/totp";
|
|
|
20
20
|
import { createConfigFeature } from "../../config";
|
|
21
21
|
import { createConfigResolver } from "../../config/resolver";
|
|
22
22
|
import { configValuesTable } from "../../config/table";
|
|
23
|
+
import { createTenantFeature } from "../../tenant";
|
|
23
24
|
import { createUserFeature } from "../../user/feature";
|
|
24
25
|
import { userEntity } from "../../user/schema/user";
|
|
25
26
|
import { userMfaDeleteHook, userMfaExportHook } from "../hooks";
|
|
@@ -36,6 +37,7 @@ beforeAll(async () => {
|
|
|
36
37
|
features: [
|
|
37
38
|
createConfigFeature(),
|
|
38
39
|
createUserFeature(),
|
|
40
|
+
createTenantFeature(),
|
|
39
41
|
createAuthMfaFeature({
|
|
40
42
|
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
41
43
|
issuer: "Kumiko Test",
|
|
@@ -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
|
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type ConfigCascade,
|
|
3
3
|
type ConfigCascadeLevel,
|
|
4
4
|
defineQueryHandler,
|
|
5
|
+
isEncryptedAtRest,
|
|
5
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { requireConfigResolver } from "../feature";
|
|
@@ -59,7 +60,7 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
59
60
|
|
|
60
61
|
// backing="secrets" is masked like encrypted — the resolver revealed the
|
|
61
62
|
// plaintext for internal reads, but it must never reach the cascade UI.
|
|
62
|
-
if (keyDef
|
|
63
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
63
64
|
const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
|
|
64
65
|
...l,
|
|
65
66
|
value: l.hasValue ? MASKED : l.value,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type ConfigScope,
|
|
4
4
|
type ConfigValueSource,
|
|
5
5
|
defineQueryHandler,
|
|
6
|
+
isEncryptedAtRest,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { requireConfigResolver } from "../feature";
|
|
@@ -63,7 +64,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
63
64
|
// key so the plaintext (which the resolver revealed for internal reads)
|
|
64
65
|
// never reaches the UI response.
|
|
65
66
|
let value: string | number | boolean | undefined;
|
|
66
|
-
if (keyDef
|
|
67
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
67
68
|
value = cascade.value !== undefined ? MASKED : undefined;
|
|
68
69
|
} else {
|
|
69
70
|
value = cascade.value;
|
|
@@ -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
|
});
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// 3. PII-Encrypt (from/to/cc/subject/snippet) VOR dem append —
|
|
10
10
|
// Event-Log UND Projection tragen Ciphertext (#800-Muster).
|
|
11
11
|
// 4. inbound-message-received-append (Inline-Projection schreibt
|
|
12
|
-
// read_inbound_messages in derselben TX).
|
|
12
|
+
// read_inbound_messages in derselben TX). Savepoint-scoped
|
|
13
|
+
// (ctx.tryAppendEvent) — a losing concurrent ingest for the same
|
|
14
|
+
// messageAggId returns { duplicate: true } instead of failing the TX.
|
|
13
15
|
// 5. Thread-Rollup: mail-thread-updated-append mit neu berechnetem
|
|
14
16
|
// Snapshot (count+1, max(lastMessageAt)) — die apply ist ein
|
|
15
17
|
// dummer UPSERT.
|
|
@@ -199,13 +201,24 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
199
201
|
providerName: payload.providerName,
|
|
200
202
|
providerCursor: payload.providerCursor,
|
|
201
203
|
};
|
|
202
|
-
await ctx.
|
|
204
|
+
const messageAppend = await ctx.tryAppendEvent({
|
|
203
205
|
aggregateId: messageAggId,
|
|
204
206
|
aggregateType: INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
205
207
|
type: INBOUND_MESSAGE_RECEIVED_EVENT_QN,
|
|
206
208
|
payload: messageEventPayload,
|
|
207
209
|
headers: messageHeaders,
|
|
208
210
|
});
|
|
211
|
+
if (!messageAppend.ok) {
|
|
212
|
+
// Lost the race against a concurrent ingest for the same messageAggId
|
|
213
|
+
// (Watch-Push vs. Poll-Reconciliation overlap). The winner already
|
|
214
|
+
// writes thread-rollup + seen-anchor for this message — steps 5+6
|
|
215
|
+
// skip here. tryAppendEvent's savepoint keeps this TX usable despite
|
|
216
|
+
// the caught VersionConflictError (see ctx.tryAppendEvent doc).
|
|
217
|
+
return {
|
|
218
|
+
isSuccess: true as const,
|
|
219
|
+
data: { duplicate: true as const, inboundMessageAggregateId: messageAggId },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
209
222
|
|
|
210
223
|
// ---------------------------------------------------------------
|
|
211
224
|
// 5. Thread-Rollup. Der Handler berechnet den NEUEN Snapshot
|
|
@@ -97,7 +97,8 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
|
|
|
97
97
|
{ slug: route.slug, lang: route.lang },
|
|
98
98
|
SYSTEM_TENANT_ID,
|
|
99
99
|
)) as TextBlockQueryResult;
|
|
100
|
-
} catch {
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(`legal-pages: text-content query failed for slug="${route.slug}"`, err);
|
|
101
102
|
return c.text("legal page unavailable", 503);
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -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
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
renderSafeMarkdown,
|
|
16
16
|
wrapInLayout,
|
|
17
17
|
} from "../page-render";
|
|
18
|
+
import type { SystemQueryFn } from "../shared";
|
|
18
19
|
import { BRANDING_KEYS, BRANDING_QUERY_QN, CUSTOM_CSS_KEY, coerceBranding } from "./branding";
|
|
19
20
|
import { createBrandingQuery } from "./handlers/branding.query";
|
|
20
21
|
import { bySlugQuery } from "./handlers/by-slug.query";
|
|
@@ -38,12 +39,6 @@ const PUBLIC_PAGE_CACHE = { kind: "revalidate", maxAgeSeconds: 60 } as const;
|
|
|
38
39
|
// Handlers, symmetrisch zum legal-pages-Muster).
|
|
39
40
|
const BY_SLUG_QN = "managed-pages:query:by-slug";
|
|
40
41
|
|
|
41
|
-
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
|
|
42
|
-
// http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
|
|
43
|
-
// isn't part of the engine's public surface (only the httpRoute-
|
|
44
|
-
// registration types are; same convention as seo/feature.ts's FetchApp).
|
|
45
|
-
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
46
|
-
|
|
47
42
|
// Raw Handler-Return von bySlugQuery (published-only) — systemQuery dispatcht
|
|
48
43
|
// direkt gegen den Handler, keine `{data}`-Envelope wie am /api/query-Wire.
|
|
49
44
|
type BySlugQueryResult = {
|
package/src/seo/feature.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { LEGAL_ROUTES } from "../legal-pages";
|
|
8
8
|
import { cachedSecurePageResponse } from "../page-render";
|
|
9
|
+
import type { SystemQueryFn } from "../shared";
|
|
9
10
|
import { SEO_CONFIG_KEYS, SEO_DEFAULT_PATHS } from "./constants";
|
|
10
11
|
import { seoConfigQuery } from "./handlers/seo-config.query";
|
|
11
12
|
import { buildLlmsTxt } from "./llms-txt";
|
|
@@ -19,12 +20,6 @@ const SEO_CACHE = { kind: "revalidate", maxAgeSeconds: 300 } as const;
|
|
|
19
20
|
const MANAGED_PAGES_BY_TENANT_PUBLISHED_QN = "managed-pages:query:by-tenant-published";
|
|
20
21
|
const SEO_CONFIG_QUERY_QN = "seo:query:config";
|
|
21
22
|
|
|
22
|
-
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — just
|
|
23
|
-
// enough to force a specific tenant in-process. Not importing
|
|
24
|
-
// HttpRouteHandlerDeps itself: it isn't part of the engine's public surface
|
|
25
|
-
// (only the httpRoute-registration types are).
|
|
26
|
-
type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
27
|
-
|
|
28
23
|
export type ManagedPagesDiscoveryOptions = {
|
|
29
24
|
/** Same per-host tenant resolver the app already passes to
|
|
30
25
|
* createManagedPagesFeature — needed to X-Tenant-scope the anonymous
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
|
+
import { createConfigFeature } from "../../config/feature";
|
|
5
|
+
import { createTenantFeature } from "../../tenant";
|
|
6
|
+
import { createUserFeature } from "../../user/feature";
|
|
7
|
+
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID } from "../constants";
|
|
8
|
+
import { createSessionsFeature } from "../feature";
|
|
9
|
+
|
|
10
|
+
describe("sessions screens + query access alignment (kumiko-framework#255)", () => {
|
|
11
|
+
const features = [
|
|
12
|
+
createConfigFeature(),
|
|
13
|
+
createUserFeature(),
|
|
14
|
+
createTenantFeature(),
|
|
15
|
+
createSessionsFeature(),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
test("boot-validates with session-list/session-detail screens registered", () => {
|
|
19
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("session-list is projectionList, session-detail is projectionDetail, both admin-gated", () => {
|
|
23
|
+
const sessions = createSessionsFeature();
|
|
24
|
+
const list = sessions.screens[SESSION_LIST_SCREEN_ID];
|
|
25
|
+
expect(list?.type).toBe("projectionList");
|
|
26
|
+
const detail = sessions.screens[SESSION_DETAIL_SCREEN_ID];
|
|
27
|
+
expect(detail?.type).toBe("projectionDetail");
|
|
28
|
+
for (const screen of [list, detail]) {
|
|
29
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
30
|
+
expect(screen.access.roles).toEqual(["TenantAdmin", "Admin", "SystemAdmin"]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("session-list row-action navigates to session-detail with entityId 'id'", () => {
|
|
36
|
+
const sessions = createSessionsFeature();
|
|
37
|
+
const list = sessions.screens[SESSION_LIST_SCREEN_ID];
|
|
38
|
+
if (list?.type !== "projectionList") throw new Error("expected projectionList");
|
|
39
|
+
const openAction = list.rowActions?.find((a) => a.id === "open");
|
|
40
|
+
if (openAction?.kind !== "navigate") throw new Error("expected a navigate rowAction");
|
|
41
|
+
expect(openAction.screen).toBe(SESSION_DETAIL_SCREEN_ID);
|
|
42
|
+
expect(openAction.entityId).toBe("id");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("sessions queries share the admin-or-higher access rule", () => {
|
|
46
|
+
const sessions = createSessionsFeature();
|
|
47
|
+
const roles = ["TenantAdmin", "Admin", "SystemAdmin"];
|
|
48
|
+
expect(rolesOf(sessions.queryHandlers["user-session:list"]?.access)).toEqual(roles);
|
|
49
|
+
expect(rolesOf(sessions.queryHandlers["user-session:detail"]?.access)).toEqual(roles);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -467,6 +467,50 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
467
467
|
// silent orderBy removal.
|
|
468
468
|
expect(body.data[0]?.id).toBe(aliceAsAdmin.sid);
|
|
469
469
|
});
|
|
470
|
+
|
|
471
|
+
// Single-row inspector backing the session-detail screen (kumiko-framework#255).
|
|
472
|
+
// Same access-gate as session:list (admin-or-higher); verifies field-shape,
|
|
473
|
+
// decryption of ip/userAgent, and the "unknown id" not-found path.
|
|
474
|
+
test("session:detail returns one decrypted row for admins, null for an unknown id", async () => {
|
|
475
|
+
const { userId: aliceId } = await h.seedUser("alice3@example.com", "pw-long-enough");
|
|
476
|
+
|
|
477
|
+
await updateRows(
|
|
478
|
+
stack.db,
|
|
479
|
+
tenantMembershipsTable,
|
|
480
|
+
{ roles: JSON.stringify(["Admin"]) },
|
|
481
|
+
{ userId: aliceId, tenantId: TENANT },
|
|
482
|
+
);
|
|
483
|
+
const aliceAsAdmin = await h.login("alice3@example.com", "pw-long-enough");
|
|
484
|
+
|
|
485
|
+
const res = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
486
|
+
type: SessionQueries.detail,
|
|
487
|
+
payload: { id: aliceAsAdmin.sid },
|
|
488
|
+
});
|
|
489
|
+
expect(res.status).toBe(200);
|
|
490
|
+
const body = (await res.json()) as {
|
|
491
|
+
data: { id: string; userId: string; revokedAt: string | null } | null;
|
|
492
|
+
};
|
|
493
|
+
expect(body.data?.id).toBe(aliceAsAdmin.sid);
|
|
494
|
+
expect(body.data?.userId).toBe(aliceId);
|
|
495
|
+
expect(body.data?.revokedAt).toBeNull();
|
|
496
|
+
|
|
497
|
+
const missing = await h.authedPost("/api/query", aliceAsAdmin.token, {
|
|
498
|
+
type: SessionQueries.detail,
|
|
499
|
+
payload: { id: "00000000-0000-4000-8000-0000deadbeef" },
|
|
500
|
+
});
|
|
501
|
+
expect(missing.status).toBe(200);
|
|
502
|
+
const missingBody = (await missing.json()) as { data: unknown };
|
|
503
|
+
expect(missingBody.data).toBeNull();
|
|
504
|
+
|
|
505
|
+
// Plain User gets the same 403 as session:list — same access-gate.
|
|
506
|
+
await h.seedUser("bob3@example.com", "pw-long-enough");
|
|
507
|
+
const bob = await h.login("bob3@example.com", "pw-long-enough");
|
|
508
|
+
const asUser = await h.authedPost("/api/query", bob.token, {
|
|
509
|
+
type: SessionQueries.detail,
|
|
510
|
+
payload: { id: aliceAsAdmin.sid },
|
|
511
|
+
});
|
|
512
|
+
expect(asUser.status).toBe(403);
|
|
513
|
+
});
|
|
470
514
|
});
|
|
471
515
|
|
|
472
516
|
// Defense-in-depth: the sessionChecker refuses a live sid once the user it
|
|
@@ -12,7 +12,7 @@ import { expect } from "bun:test";
|
|
|
12
12
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
13
|
import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
14
14
|
import * as jose from "jose";
|
|
15
|
-
import { hashPassword } from "../../
|
|
15
|
+
import { hashPassword } from "../../shared";
|
|
16
16
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
17
17
|
import { UserHandlers } from "../../user";
|
|
18
18
|
|