@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.150.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 (74) 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 +2 -0
  23. package/src/auth-email-password/web/login-screen.tsx +2 -0
  24. package/src/auth-mfa/__tests__/base32.test.ts +28 -0
  25. package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
  26. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
  27. package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
  28. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
  29. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
  30. package/src/auth-mfa/__tests__/totp.test.ts +11 -2
  31. package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
  32. package/src/auth-mfa/base32.ts +7 -1
  33. package/src/auth-mfa/constants.ts +14 -0
  34. package/src/auth-mfa/errors.ts +9 -16
  35. package/src/auth-mfa/feature.ts +4 -0
  36. package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
  37. package/src/auth-mfa/handlers/verify.write.ts +26 -3
  38. package/src/auth-mfa/index.ts +0 -1
  39. package/src/auth-mfa/recovery-codes.ts +12 -2
  40. package/src/auth-mfa/schema/user-mfa.ts +3 -0
  41. package/src/auth-mfa/testing.ts +5 -0
  42. package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
  43. package/src/auth-mfa/web/i18n.ts +2 -0
  44. package/src/auth-mfa/web/mfa-enable-screen.tsx +37 -28
  45. package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
  46. package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
  47. package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
  48. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
  49. package/src/config/handlers/cascade.query.ts +2 -1
  50. package/src/config/handlers/values.query.ts +2 -1
  51. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
  52. package/src/legal-pages/feature.ts +2 -1
  53. package/src/managed-pages/feature.ts +1 -6
  54. package/src/seo/feature.ts +1 -6
  55. package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
  56. package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
  57. package/src/sessions/__tests__/test-helpers.ts +1 -1
  58. package/src/sessions/constants.ts +5 -0
  59. package/src/sessions/feature.ts +67 -1
  60. package/src/sessions/handlers/detail.query.ts +36 -0
  61. package/src/sessions/i18n.ts +23 -0
  62. package/src/shared/index.ts +1 -0
  63. package/src/shared/system-query.ts +5 -0
  64. package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
  65. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
  66. package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
  67. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
  68. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
  69. package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
  70. package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
  71. package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
  72. package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
  73. package/src/user-profile/handlers/change-email.write.ts +2 -2
  74. package/src/auth-email-password/password-hashing.ts +0 -4
@@ -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
 
@@ -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
 
@@ -17,6 +17,8 @@ export const SessionQueries = {
17
17
  // Admin-scoped: all sessions in the caller's tenant (live + revoked).
18
18
  // Tenant isolation comes from ctx.db; access-gate is admin-or-higher.
19
19
  list: "sessions:query:user-session:list",
20
+ // Admin-scoped single-session inspector, backs the session-detail screen.
21
+ detail: "sessions:query:user-session:detail",
20
22
  } as const;
21
23
 
22
24
  export const SessionErrors = {
@@ -45,3 +47,6 @@ export const DEFAULT_SESSION_CACHE_TTL_MS = 60_000;
45
47
  // longer so refresh can rotate the token without requiring a new password).
46
48
  // MVP ships a single window; per-app overrides can come later.
47
49
  export const DEFAULT_SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
50
+
51
+ export const SESSION_LIST_SCREEN_ID = "session-list" as const;
52
+ export const SESSION_DETAIL_SCREEN_ID = "session-detail" as const;
@@ -1,11 +1,18 @@
1
1
  import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ access,
4
+ defineFeature,
5
+ type FeatureDefinition,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
3
8
  import { cleanupJob } from "./handlers/cleanup.job";
9
+ import { detailQuery } from "./handlers/detail.query";
4
10
  import { listQuery } from "./handlers/list.query";
5
11
  import { mineQuery } from "./handlers/mine.query";
6
12
  import { revokeWrite } from "./handlers/revoke.write";
7
13
  import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
8
14
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
15
+ import { SESSIONS_I18N } from "./i18n";
9
16
  import { userSessionEntity } from "./schema/user-session";
10
17
  import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
11
18
 
@@ -105,6 +112,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
105
112
  const queries = {
106
113
  mine: r.queryHandler(mineQuery),
107
114
  list: r.queryHandler(listQuery),
115
+ detail: r.queryHandler(detailQuery),
108
116
  };
109
117
 
110
118
  // Retention: chunked DELETE of expired/revoked rows. Manual trigger
@@ -143,6 +151,64 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
143
151
  autoRevoke ??= revoker;
144
152
  };
145
153
 
154
+ r.translations({ keys: SESSIONS_I18N });
155
+
156
+ const listAccess = { roles: access.admin };
157
+
158
+ r.screen({
159
+ id: SESSION_LIST_SCREEN_ID,
160
+ type: "projectionList",
161
+ query: SessionQueries.list,
162
+ columns: [
163
+ { field: "id", label: "sessions.list.col.id" },
164
+ { field: "userId", label: "sessions.list.col.userId" },
165
+ { field: "createdAt", label: "sessions.list.col.createdAt" },
166
+ { field: "expiresAt", label: "sessions.list.col.expiresAt" },
167
+ { field: "revokedAt", label: "sessions.list.col.revokedAt" },
168
+ ],
169
+ rowActions: [
170
+ {
171
+ kind: "navigate",
172
+ id: "open",
173
+ label: "sessions.list.action.open",
174
+ screen: SESSION_DETAIL_SCREEN_ID,
175
+ entityId: "id",
176
+ rowClick: true,
177
+ },
178
+ ],
179
+ access: listAccess,
180
+ });
181
+ r.screen({
182
+ id: SESSION_DETAIL_SCREEN_ID,
183
+ type: "projectionDetail",
184
+ query: SessionQueries.detail,
185
+ listScreenId: SESSION_LIST_SCREEN_ID,
186
+ layout: {
187
+ sections: [
188
+ {
189
+ fields: ["id", "userId", "createdAt", "expiresAt", "revokedAt", "ip", "userAgent"],
190
+ },
191
+ ],
192
+ },
193
+ fieldLabels: {
194
+ id: "sessions.detail.field.id",
195
+ userId: "sessions.detail.field.userId",
196
+ createdAt: "sessions.detail.field.createdAt",
197
+ expiresAt: "sessions.detail.field.expiresAt",
198
+ revokedAt: "sessions.detail.field.revokedAt",
199
+ ip: "sessions.detail.field.ip",
200
+ userAgent: "sessions.detail.field.userAgent",
201
+ },
202
+ access: listAccess,
203
+ });
204
+ r.nav({
205
+ id: "session-list",
206
+ label: "sessions:nav.sessionList",
207
+ icon: "list",
208
+ screen: "sessions:screen:session-list",
209
+ order: 10,
210
+ });
211
+
146
212
  return { handlers, queries, bindAutoRevokeOnPasswordChange };
147
213
  });
148
214
  }
@@ -0,0 +1,36 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { decryptStoredPii } from "../../shared";
5
+ import { userSessionTable } from "../schema/user-session";
6
+
7
+ // Admin single-session inspector — mirrors list.query's decrypt handling for
8
+ // the one-row case. ctx.db (TenantDb) applies tenant-scoping automatically.
9
+ export const detailQuery = defineQueryHandler({
10
+ name: "user-session:detail",
11
+ schema: z.object({ id: z.uuid() }),
12
+ access: { roles: access.admin },
13
+ handler: async (query, ctx) => {
14
+ const row = await fetchOne<{
15
+ id: string;
16
+ userId: string;
17
+ createdAt: unknown;
18
+ expiresAt: unknown;
19
+ revokedAt: unknown;
20
+ ip: string | null;
21
+ userAgent: string | null;
22
+ }>(ctx.db, userSessionTable, { id: query.payload.id });
23
+ if (!row) return null;
24
+ return {
25
+ id: row.id,
26
+ userId: row.userId,
27
+ createdAt: row.createdAt,
28
+ expiresAt: row.expiresAt,
29
+ revokedAt: row.revokedAt,
30
+ ip: row.ip ? await decryptStoredPii(row.ip, "sessions:detail") : row.ip,
31
+ userAgent: row.userAgent
32
+ ? await decryptStoredPii(row.userAgent, "sessions:detail")
33
+ : row.userAgent,
34
+ };
35
+ },
36
+ });
@@ -0,0 +1,23 @@
1
+ // @runtime client
2
+ // Server + client i18n for the sessions operator screens.
3
+
4
+ type LocalizedString = { readonly de: string; readonly en: string };
5
+
6
+ export const SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
7
+ "screen:session-list.title": { de: "Sitzungen", en: "Sessions" },
8
+ "screen:session-detail.title": { de: "Sitzung", en: "Session" },
9
+ "sessions:nav.sessionList": { de: "Sitzungen", en: "Sessions" },
10
+ "sessions.list.col.id": { de: "ID", en: "ID" },
11
+ "sessions.list.col.userId": { de: "User-ID", en: "User ID" },
12
+ "sessions.list.col.createdAt": { de: "Erstellt", en: "Created" },
13
+ "sessions.list.col.expiresAt": { de: "Läuft ab", en: "Expires" },
14
+ "sessions.list.col.revokedAt": { de: "Widerrufen", en: "Revoked" },
15
+ "sessions.list.action.open": { de: "Details", en: "Details" },
16
+ "sessions.detail.field.id": { de: "ID", en: "ID" },
17
+ "sessions.detail.field.userId": { de: "User-ID", en: "User ID" },
18
+ "sessions.detail.field.createdAt": { de: "Erstellt", en: "Created" },
19
+ "sessions.detail.field.expiresAt": { de: "Läuft ab", en: "Expires" },
20
+ "sessions.detail.field.revokedAt": { de: "Widerrufen", en: "Revoked" },
21
+ "sessions.detail.field.ip": { de: "IP-Adresse", en: "IP address" },
22
+ "sessions.detail.field.userAgent": { de: "User-Agent", en: "User agent" },
23
+ };
@@ -9,4 +9,5 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
9
9
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
10
  export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
11
11
  export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
12
+ export type { SystemQueryFn } from "./system-query";
12
13
  export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
@@ -0,0 +1,5 @@
1
+ // Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
2
+ // http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
3
+ // isn't part of the engine's public surface (only the httpRoute-
4
+ // registration types are).
5
+ export type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
@@ -14,7 +14,6 @@ import {
14
14
  import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
15
15
  import { AuthHandlers } from "../../auth-email-password/constants";
16
16
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
17
- import { hashPassword } from "../../auth-email-password/password-hashing";
18
17
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
19
18
  import { createConfigFeature } from "../../config";
20
19
  import { createConfigResolver } from "../../config/resolver";
@@ -23,6 +22,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
23
22
  import { notificationPreferencesTable } from "../../delivery/tables";
24
23
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
25
24
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
25
+ import { hashPassword } from "../../shared";
26
26
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
27
27
  import { createUserFeature } from "../../user/feature";
28
28
  import { userEntity, userTable } from "../../user/schema/user";
@@ -0,0 +1,163 @@
1
+ // #1057 regression: anonymous GDPR magic-link download under
2
+ // `resolverTrust: "authoritative"` WITHOUT `defaultTenantId` (publicstatus's
3
+ // real prod config — Host-Resolver-only, no single-tenant fallback).
4
+ //
5
+ // The tenantResolver derives the ambient tenant from the request Host — here
6
+ // it always resolves to `hostTenant`, DIFFERENT from the tenant the export
7
+ // job actually belongs to (`jobTenant`). Before the fix, `download-by-token`
8
+ // resolved the file-storage-provider *selection* via `ctx.config`, which is
9
+ // bound to the ambient (resolved) tenant — `hostTenant` has no provider
10
+ // configured at all here, so the download 500s even though the token/job
11
+ // themselves are perfectly valid. The fix resolves the provider explicitly
12
+ // for `jobRow.requestedFromTenantId` instead.
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { randomBytes } from "node:crypto";
15
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createInMemoryFileProvider,
19
+ type FileStorageProvider,
20
+ } from "@cosmicdrift/kumiko-framework/files";
21
+ import {
22
+ createTestUser,
23
+ setupTestStack,
24
+ type TestStack,
25
+ testTenantId,
26
+ unsafeCreateEntityTable,
27
+ unsafePushTables,
28
+ } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
30
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
31
+ import {
32
+ createComplianceProfilesFeature,
33
+ tenantComplianceProfileEntity,
34
+ } from "../../compliance-profiles";
35
+ import { createConfigFeature } from "../../config";
36
+ import { ConfigHandlers } from "../../config/constants";
37
+ import { createConfigAccessorFactory } from "../../config/feature";
38
+ import { createConfigResolver } from "../../config/resolver";
39
+ import { configValuesTable } from "../../config/table";
40
+ import { createDataRetentionFeature } from "../../data-retention";
41
+ import { fileFoundationFeature } from "../../file-foundation";
42
+ import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
43
+ import { createSessionsFeature } from "../../sessions";
44
+ import { createUserFeature } from "../../user";
45
+ import { createUserDataRightsFeature } from "../feature";
46
+ import { runExportJobs } from "../run-export-jobs";
47
+ import { exportDownloadTokenEntity } from "../schema/download-token";
48
+ import { exportJobEntity } from "../schema/export-job";
49
+
50
+ const jobTenant = testTenantId(1);
51
+ const hostTenant = testTenantId(2);
52
+ const jobTenantAdmin = createTestUser({ id: 99, tenantId: jobTenant, roles: ["TenantAdmin"] });
53
+ const aliceUser = createTestUser({ id: 42, tenantId: jobTenant, roles: ["Member"] });
54
+
55
+ const providerPerTenant = new Map<string, ReturnType<typeof createInMemoryFileProvider>>();
56
+ function buildProvider(tenantId: string): Promise<FileStorageProvider> {
57
+ let p = providerPerTenant.get(tenantId);
58
+ if (!p) {
59
+ p = createInMemoryFileProvider();
60
+ providerPerTenant.set(tenantId, p);
61
+ }
62
+ return Promise.resolve(p);
63
+ }
64
+
65
+ let stack: TestStack;
66
+
67
+ beforeAll(async () => {
68
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
69
+ const resolver = createConfigResolver({ cipher: encryption });
70
+
71
+ stack = await setupTestStack({
72
+ features: [
73
+ createConfigFeature(),
74
+ createUserFeature(),
75
+ createDataRetentionFeature(),
76
+ createComplianceProfilesFeature(),
77
+ fileFoundationFeature,
78
+ fileProviderInMemoryFeature,
79
+ createSessionsFeature(),
80
+ createUserDataRightsFeature(),
81
+ ],
82
+ extraContext: ({ registry }) => ({
83
+ configResolver: resolver,
84
+ configEncryption: encryption,
85
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
86
+ }),
87
+ // publicstatus prod: Host-Resolver-only, NO defaultTenantId. Resolver
88
+ // always answers `hostTenant` here — jobTenant never appears as the
89
+ // ambient/resolved tenant, only ever as jobRow.requestedFromTenantId.
90
+ anonymousAccess: {
91
+ tenantResolver: () => hostTenant,
92
+ resolverTrust: "authoritative",
93
+ tenantExists: async (id) => id === jobTenant || id === hostTenant,
94
+ },
95
+ });
96
+ await unsafeCreateEntityTable(stack.db, exportJobEntity);
97
+ await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
98
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
99
+ await unsafePushTables(stack.db, { configValuesTable });
100
+ await createEventsTable(stack.db);
101
+ await asRawClient(stack.db).unsafe(`
102
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
103
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
104
+ tenant_id UUID NOT NULL,
105
+ user_id TEXT NOT NULL,
106
+ version INTEGER NOT NULL DEFAULT 0,
107
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
108
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
109
+ inserted_by_id TEXT,
110
+ modified_by_id TEXT,
111
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
112
+ deleted_at TIMESTAMPTZ,
113
+ deleted_by_id TEXT,
114
+ roles TEXT NOT NULL DEFAULT '[]',
115
+ UNIQUE(user_id, tenant_id)
116
+ )
117
+ `);
118
+
119
+ // Only jobTenant gets a provider configured — hostTenant deliberately has
120
+ // NONE, so a pre-fix ambient-tenant read would hard-fail with "no provider
121
+ // selected", not just pick the wrong one.
122
+ await stack.http.writeOk(
123
+ ConfigHandlers.set,
124
+ { key: "file-foundation:config:provider", value: "inmemory" },
125
+ jobTenantAdmin,
126
+ );
127
+ });
128
+
129
+ afterAll(async () => {
130
+ await stack.cleanup();
131
+ });
132
+
133
+ describe("download-by-token under resolverTrust: authoritative, no defaultTenantId", () => {
134
+ test("magic-link download succeeds via the job's own tenant, not the Host-resolved ambient tenant", async () => {
135
+ const requestRes = await stack.http.writeOk<{ jobId: string }>(
136
+ "user-data-rights:write:request-export",
137
+ {},
138
+ aliceUser,
139
+ );
140
+ const jobId = requestRes.jobId;
141
+ const provider = await buildProvider(jobTenant);
142
+ await provider.write(`${jobTenant}/exports/${jobId}.zip`, new Uint8Array([1, 2, 3]));
143
+
144
+ const result = await runExportJobs({
145
+ db: stack.db,
146
+ registry: stack.registry,
147
+ buildStorageProvider: buildProvider,
148
+ now: getTemporal().Now.instant(),
149
+ });
150
+ const plainToken = result.tokenByJobId.get(jobId);
151
+ if (!plainToken) {
152
+ throw new Error("token-create failed in worker run");
153
+ }
154
+
155
+ const res = await stack.app.fetch(
156
+ new Request(`http://test/user-export/by-token?token=${plainToken}`, { method: "GET" }),
157
+ );
158
+ expect(res.status).toBe(302);
159
+ const location = res.headers.get("location");
160
+ expect(location).toMatch(/^memory:\/\//);
161
+ expect(location).toContain(`${jobTenant}/exports/${jobId}.zip`);
162
+ });
163
+ });
@@ -559,4 +559,43 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
559
559
  );
560
560
  expect(result.url).toMatch(/^memory:\/\//);
561
561
  });
562
+
563
+ test("cross-tenant same-user: provider is resolved for the job's tenant, not the ambient session tenant", async () => {
564
+ // #1057 regression: createFileProviderForTenant used to read the
565
+ // provider *selection* from the ambient session's ctx.config — here
566
+ // Tenant B (Alice's session tenant) — instead of the job's own
567
+ // Tenant A. Give Tenant A a provider WITHOUT getSignedUrl while
568
+ // Tenant B keeps "inmemory" (default from beforeEach): if the bug
569
+ // resurfaces, the wrong (Tenant B) provider is picked and the
570
+ // request succeeds with 200 instead of 422.
571
+ const { jobId } = await seedDoneJobWithToken();
572
+ await stack.http.writeOk(
573
+ ConfigHandlers.set,
574
+ { key: "file-foundation:config:provider", value: "no-signed-url" },
575
+ tenantAdmin, // Tenant A admin
576
+ );
577
+ const aliceFromTenantB = createTestUser({
578
+ id: 42,
579
+ tenantId: tenantB,
580
+ roles: ["Member"],
581
+ });
582
+ await asRawClient(stack.db).unsafe(
583
+ `
584
+ INSERT INTO read_tenant_memberships (tenant_id, user_id)
585
+ VALUES ($1, $2)
586
+ ON CONFLICT (user_id, tenant_id) DO NOTHING
587
+ `,
588
+ [tenantB, String(aliceUser.id)],
589
+ );
590
+
591
+ const res = await stack.http.query(
592
+ "user-data-rights:query:download-by-job",
593
+ { jobId },
594
+ aliceFromTenantB,
595
+ );
596
+ expect(res.status).toBe(422);
597
+ const body = (await res.json()) as { error: { code: string; i18nKey: string } };
598
+ expect(body.error.code).toBe("unprocessable");
599
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.signedUrlNotSupported");
600
+ });
562
601
  });
@@ -46,7 +46,6 @@ import {
46
46
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
47
47
  import { AuthHandlers } from "../../auth-email-password/constants";
48
48
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
49
- import { hashPassword } from "../../auth-email-password/password-hashing";
50
49
  import {
51
50
  createComplianceProfilesFeature,
52
51
  tenantComplianceProfileEntity,
@@ -60,6 +59,7 @@ import { createSessionsFeature } from "../../sessions";
60
59
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
61
60
  import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
62
61
  import { sessionCallbacksFromLateBound } from "../../sessions/testing";
62
+ import { hashPassword } from "../../shared";
63
63
  import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
64
64
  import { tenantEntity } from "../../tenant/schema/tenant";
65
65
  import { seedTenantMembership } from "../../tenant/seeding";
@@ -31,7 +31,6 @@ import {
31
31
  } from "@cosmicdrift/kumiko-framework/testing";
32
32
  import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
33
33
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
34
- import { hashPassword } from "../../auth-email-password/password-hashing";
35
34
  import {
36
35
  createComplianceProfilesFeature,
37
36
  tenantComplianceProfileEntity,
@@ -46,6 +45,7 @@ import { SessionHandlers } from "../../sessions/constants";
46
45
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
47
46
  import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
48
47
  import { sessionCallbacksFromLateBound } from "../../sessions/testing";
48
+ import { hashPassword } from "../../shared";
49
49
  import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
50
50
  import { tenantEntity } from "../../tenant/schema/tenant";
51
51
  import { seedTenantMembership } from "../../tenant/seeding";
@@ -29,8 +29,8 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
30
30
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
31
31
  import { z } from "zod";
32
- import { createFileProviderForTenant } from "../../file-foundation";
33
32
  import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
33
+ import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
34
34
  import { exportDownloadTokensTable } from "../schema/download-token";
35
35
  import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
36
36
 
@@ -127,7 +127,10 @@ export const downloadByJobQuery = defineQueryHandler({
127
127
  });
128
128
  }
129
129
 
130
- const provider = await createFileProviderForTenant(
130
+ // Provider bound explicitly to the job's tenant — NOT the ambient
131
+ // session tenant (cross-tenant-same-user: job.requestedFromTenantId
132
+ // can differ from query.user.tenantId).
133
+ const provider = await resolveTenantFileProvider(
131
134
  ctx,
132
135
  jobRow.requestedFromTenantId,
133
136
  "user-data-rights:query:download-by-job",