@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
@@ -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";
@@ -161,14 +161,13 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
161
161
  // "single-user" via appOverrides (TENANT_MODEL_CONFIG_KEY) so the forget
162
162
  // pipeline may erase the tenant's data as that user's personal data — still
163
163
  // gated by a runtime sole-member check in run-forget-cleanup.
164
- r.config({
165
- keys: {
166
- tenantModel: createSystemConfig("select", {
167
- default: "multi-user",
168
- options: ["single-user", "multi-user"],
169
- }),
170
- },
171
- });
164
+ r.configKey(
165
+ "tenantModel",
166
+ createSystemConfig("select", {
167
+ default: "multi-user",
168
+ options: ["single-user", "multi-user"],
169
+ }),
170
+ );
172
171
 
173
172
  // S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
174
173
  r.entity("export-job", exportJobEntity);
@@ -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",
@@ -31,8 +31,8 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
31
31
  import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
32
32
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
33
33
  import { z } from "zod";
34
- import { createFileProviderForTenant } from "../../file-foundation";
35
34
  import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
35
+ import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
36
36
  import { exportDownloadTokensTable } from "../schema/download-token";
37
37
  import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
38
38
  import { hashDownloadToken } from "../token-helpers";
@@ -181,9 +181,10 @@ export const downloadByTokenQuery = defineQueryHandler({
181
181
  });
182
182
  }
183
183
 
184
- // Step 5: signed-URL via provider. createFileProviderForTenant nutzt
185
- // requestedFromTenantId (gleicher Tenant wie beim Worker-Storage-Write).
186
- const provider = await createFileProviderForTenant(
184
+ // Step 5: signed-URL via provider, explicitly bound to the job's
185
+ // tenant NOT the ambient request tenant (anonymous magic-link path
186
+ // has none under resolverTrust: "authoritative").
187
+ const provider = await resolveTenantFileProvider(
187
188
  ctx,
188
189
  jobRow.requestedFromTenantId,
189
190
  "user-data-rights:query:download-by-token",
@@ -0,0 +1,62 @@
1
+ // Resolves the GDPR-download file-storage provider explicitly for
2
+ // `jobRow.requestedFromTenantId` instead of the ambient request tenant.
3
+ //
4
+ // Both download handlers (by-token, by-job) used to call
5
+ // `createFileProviderForTenant(ctx, jobRow.requestedFromTenantId, ...)`,
6
+ // which reads the PROVIDER SELECTION from `ctx.config` — bound to the
7
+ // caller's own ambient tenant, not the job's tenant:
8
+ // - by-token (anonymous magic-link, httpRoute): under
9
+ // `resolverTrust: "authoritative"` without `defaultTenantId` there is
10
+ // no ambient tenant at all — `ctx.config` throws, 500s the whole
11
+ // GDPR download flow.
12
+ // - by-job (session-authed): a user can own jobs across tenants
13
+ // (cross-tenant-same-user); the ambient session tenant silently
14
+ // picks the WRONG tenant's provider config for a job requested from
15
+ // a different tenant.
16
+ //
17
+ // Fix: build a fresh config accessor bound explicitly to the given
18
+ // tenantId (same construction as `makeTenantStorageProviderResolver`,
19
+ // which the export/forget crons already use), instead of the ambient
20
+ // `ctx.config`. Deliberately NOT `ctx._fileProviderResolver` — that
21
+ // resolver is boot-built and per-tenant-cached for the process lifetime,
22
+ // so an operator switching `file-foundation:config:provider` mid-session
23
+ // wouldn't take effect until the cache evicts.
24
+ import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
25
+ import type { ConfigResolver, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
27
+ import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
28
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
29
+ import { createConfigAccessor } from "../../config";
30
+ import { createFileProviderForTenant } from "../../file-foundation";
31
+
32
+ export interface TenantFileProviderCtx {
33
+ readonly registry?: Registry;
34
+ readonly configResolver?: ConfigResolver;
35
+ readonly secrets?: SecretsContext;
36
+ readonly db: { readonly raw: DbConnection | TenantDb };
37
+ }
38
+
39
+ export async function resolveTenantFileProvider(
40
+ ctx: TenantFileProviderCtx,
41
+ tenantId: string,
42
+ handlerName: string,
43
+ ): Promise<FileStorageProvider> {
44
+ if (!ctx.registry || !ctx.configResolver) {
45
+ throw new Error(
46
+ `${handlerName}: ctx.registry/ctx.configResolver missing — cannot resolve the file provider for tenant ${tenantId}`,
47
+ );
48
+ }
49
+ const config = createConfigAccessor(
50
+ ctx.registry,
51
+ ctx.configResolver,
52
+ tenantId as TenantId, // @cast-boundary engine-payload: TenantId brand
53
+ SYSTEM_USER_ID,
54
+ ctx.db.raw,
55
+ ctx.secrets,
56
+ );
57
+ return createFileProviderForTenant(
58
+ { config, registry: ctx.registry, secrets: ctx.secrets, _userId: SYSTEM_USER_ID },
59
+ tenantId,
60
+ handlerName,
61
+ );
62
+ }
@@ -18,7 +18,6 @@ import {
18
18
  import { expectErrorIncludes, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
19
19
  import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
20
20
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
21
- import { hashPassword } from "../../auth-email-password/password-hashing";
22
21
  import {
23
22
  createComplianceProfilesFeature,
24
23
  tenantComplianceProfileEntity,
@@ -28,6 +27,7 @@ import { createConfigFeature } from "../../config";
28
27
  import { configValuesTable } from "../../config/table";
29
28
  import { createDataRetentionFeature } from "../../data-retention";
30
29
  import { createSessionsFeature } from "../../sessions";
30
+ import { hashPassword } from "../../shared";
31
31
  import { createTenantFeature } from "../../tenant";
32
32
  import { tenantMembershipsTable } from "../../tenant/membership-table";
33
33
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -6,8 +6,8 @@ import {
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { z } from "zod";
9
- import { AuthErrors, verifyPassword } from "../../auth-email-password";
10
- import { decryptStoredPii } from "../../shared";
9
+ import { AuthErrors } from "../../auth-email-password";
10
+ import { decryptStoredPii, verifyPassword } from "../../shared";
11
11
  import { UserErrors, UserHandlers, UserQueries } from "../../user";
12
12
  import { UserProfileErrors } from "../constants";
13
13
 
@@ -1,4 +0,0 @@
1
- // Moved to shared/ — reused across admin-shell, audit, sessions, tenant,
2
- // user-data-rights, user-profile, and now auth-mfa. Re-exported here for the
3
- // existing call sites within this feature that import via "./password-hashing".
4
- export { hashPassword, verifyDummyPassword, verifyPassword } from "../shared/password-hashing";