@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.
Files changed (81) hide show
  1. package/package.json +7 -6
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +1 -1
  3. package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
  4. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +1 -1
  5. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +1 -1
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +1 -1
  8. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  9. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +1 -1
  10. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +1 -1
  11. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
  12. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -1
  13. package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
  14. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +1 -1
  15. package/src/auth-email-password/handlers/change-password.write.ts +1 -1
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -2
  17. package/src/auth-email-password/handlers/login.write.ts +1 -1
  18. package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
  19. package/src/auth-email-password/i18n.ts +4 -0
  20. package/src/auth-email-password/index.ts +6 -1
  21. package/src/auth-email-password/seeding.ts +1 -1
  22. package/src/auth-email-password/web/auth-gate.tsx +65 -20
  23. package/src/auth-email-password/web/index.ts +2 -4
  24. package/src/auth-email-password/web/login-screen.tsx +2 -0
  25. package/src/auth-mfa/__tests__/base32.test.ts +28 -0
  26. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
  27. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
  28. package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
  29. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
  30. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
  31. package/src/auth-mfa/__tests__/totp.test.ts +11 -2
  32. package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
  33. package/src/auth-mfa/base32.ts +7 -1
  34. package/src/auth-mfa/constants.ts +14 -0
  35. package/src/auth-mfa/errors.ts +9 -16
  36. package/src/auth-mfa/feature.ts +5 -1
  37. package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
  38. package/src/auth-mfa/handlers/verify.write.ts +26 -3
  39. package/src/auth-mfa/index.ts +0 -1
  40. package/src/auth-mfa/recovery-codes.ts +12 -2
  41. package/src/auth-mfa/schema/user-mfa.ts +3 -0
  42. package/src/auth-mfa/testing.ts +5 -0
  43. package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
  44. package/src/auth-mfa/web/i18n.ts +2 -0
  45. package/src/auth-mfa/web/mfa-enable-screen.tsx +44 -28
  46. package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
  47. package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
  48. package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
  49. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
  50. package/src/config/__tests__/config.integration.test.ts +50 -2
  51. package/src/config/handlers/cascade.query.ts +2 -1
  52. package/src/config/handlers/values.query.ts +2 -1
  53. package/src/file-foundation/__tests__/feature.test.ts +4 -4
  54. package/src/file-foundation/feature.ts +10 -11
  55. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
  56. package/src/legal-pages/feature.ts +2 -1
  57. package/src/mail-foundation/__tests__/feature.test.ts +4 -5
  58. package/src/mail-foundation/feature.ts +21 -22
  59. package/src/managed-pages/feature.ts +1 -6
  60. package/src/seo/feature.ts +1 -6
  61. package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
  62. package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
  63. package/src/sessions/__tests__/test-helpers.ts +1 -1
  64. package/src/sessions/constants.ts +5 -0
  65. package/src/sessions/feature.ts +67 -1
  66. package/src/sessions/handlers/detail.query.ts +36 -0
  67. package/src/sessions/i18n.ts +23 -0
  68. package/src/shared/index.ts +1 -0
  69. package/src/shared/system-query.ts +5 -0
  70. package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
  71. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
  72. package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
  73. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
  74. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
  75. package/src/user-data-rights/feature.ts +7 -8
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
  78. package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
  79. package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
  80. package/src/user-profile/handlers/change-email.write.ts +2 -2
  81. 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
- import QRCode from "qrcode";
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
- const res = await dispatcher.write<EnableStartResponse>(AuthMfaHandlers.enableStart, {
69
- accountLabel: session.user?.email ?? "",
70
- });
71
- if (!res.isSuccess) {
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
- const res = await dispatcher.write(AuthMfaHandlers.enableConfirm, {
93
- setupToken: setup.setupToken,
94
- code,
95
- });
96
- setBusy(false);
97
- if (!res.isSuccess) {
98
- setError(res.error.code);
99
- return;
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 "invalid_totp_code":
14
+ case AuthMfaErrorCodes.invalidTotpCode:
13
15
  return "auth.mfa.errors.invalidCode";
14
- case "invalid_recovery_code":
16
+ case AuthMfaErrorCodes.invalidRecoveryCode:
15
17
  return "auth.mfa.errors.invalidRecoveryCode";
16
- case "mfa_already_enabled":
18
+ case AuthMfaErrorCodes.mfaAlreadyEnabled:
17
19
  return "auth.mfa.errors.mfaAlreadyEnabled";
18
- case "mfa_not_enabled":
20
+ case AuthMfaErrorCodes.mfaNotEnabled:
19
21
  return "auth.mfa.errors.mfaNotEnabled";
20
- case "invalid_setup_token":
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: { 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
 
@@ -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.encrypted || keyDef.backing === "secrets") {
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.encrypted || keyDef.backing === "secrets") {
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
- 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
  });
@@ -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.unsafeAppendEvent({
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
- 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
@@ -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 = {
@@ -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 "../../auth-email-password/password-hashing";
15
+ import { hashPassword } from "../../shared";
16
16
  import { seedTenantMembership } from "../../tenant/seeding";
17
17
  import { UserHandlers } from "../../user";
18
18