@cosmicdrift/kumiko-bundled-features 0.155.1 → 0.156.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +7 -6
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
  4. package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
  5. package/src/agent-tools/index.ts +9 -0
  6. package/src/agent-tools/tool-catalog.ts +130 -0
  7. package/src/agent-tools/tool-dispatch.ts +82 -0
  8. package/src/agent-tools/types.ts +38 -0
  9. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
  10. package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +14 -4
  12. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
  13. package/src/auth-mfa/__tests__/totp.test.ts +2 -2
  14. package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
  15. package/src/auth-mfa/constants.ts +4 -0
  16. package/src/auth-mfa/handlers/disable.write.ts +2 -1
  17. package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
  18. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
  19. package/src/auth-mfa/handlers/verify.write.ts +22 -12
  20. package/src/auth-mfa/recovery-codes.ts +2 -2
  21. package/src/auth-mfa/schema/user-mfa.ts +10 -0
  22. package/src/auth-mfa/totp.ts +18 -6
  23. package/src/auth-mfa/verify-factor.ts +30 -2
  24. package/src/auth-mfa/web/i18n.ts +2 -0
  25. package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
  26. package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
  27. package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
  28. package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
  29. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +171 -0
  30. package/src/config/__tests__/write-helpers.test.ts +23 -0
  31. package/src/config/handlers/set.write.ts +47 -1
  32. package/src/config/resolver.ts +28 -0
  33. package/src/config/write-helpers.ts +22 -0
  34. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  35. package/src/delivery/feature.ts +1 -1
  36. package/src/file-foundation/__tests__/feature.test.ts +1 -1
  37. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
  38. package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
  39. package/src/inbound-mail-foundation/feature.ts +3 -3
  40. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
  41. package/src/jobs/__tests__/jobs-events.integration.test.ts +37 -1
  42. package/src/jobs/feature.ts +1 -1
  43. package/src/legal-pages/feature.ts +1 -1
  44. package/src/mail-foundation/__tests__/feature.test.ts +1 -1
  45. package/src/page-render/branding.ts +4 -3
  46. package/src/personal-access-tokens/feature.ts +1 -1
  47. package/src/sessions/__tests__/sessions.integration.test.ts +77 -0
  48. package/src/sessions/feature.ts +2 -2
  49. package/src/sessions/session-checker-fail-open.test.ts +77 -0
  50. package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
  51. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
  52. package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
  53. package/src/user-profile/__tests__/change-email.integration.test.ts +4 -0
  54. package/src/user-profile/feature.ts +4 -1
  55. package/src/user-data-rights/lib/tenant-file-provider.ts +0 -62
@@ -32,12 +32,13 @@ import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
32
32
 
33
33
  let testDb: TestDb;
34
34
  let testRedis: TestRedis;
35
+ let registry: ReturnType<typeof createRegistry>;
35
36
  let logger: ReturnType<typeof createJobRunLogger>;
36
37
 
37
38
  beforeAll(async () => {
38
39
  testDb = await createTestDb();
39
40
  testRedis = await createTestRedis();
40
- const registry = createRegistry([createJobsFeature()]);
41
+ registry = createRegistry([createJobsFeature()]);
41
42
  await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
42
43
  await createEventsTable(testDb.db);
43
44
  logger = createJobRunLogger({ db: testDb.db, registry });
@@ -127,4 +128,39 @@ describe("jobRun event shapes", () => {
127
128
  const ids = new Set(events.map((e) => e.aggregateId));
128
129
  expect(ids.size).toBe(1);
129
130
  });
131
+
132
+ test("complete/fail without a prior start skips — does not forge a jobRun stream", async () => {
133
+ // State-loss path: worker restart with empty cache AND no projection row for
134
+ // this bullJobId. Dropping the terminal event is intentional — forging an
135
+ // aggregate from scratch would invent a run that never started.
136
+ await logger.onJobComplete?.("example:job:orphan", "bull-orphan-complete", 50, []);
137
+ await logger.onJobFailed?.("example:job:orphan", "bull-orphan-failed", "boom", []);
138
+
139
+ const completed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_COMPLETED_EVENT });
140
+ const failed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_FAILED_EVENT });
141
+ const runs = await selectMany(testDb.db, jobRunsTable);
142
+
143
+ expect(completed).toHaveLength(0);
144
+ expect(failed).toHaveLength(0);
145
+ expect(runs).toHaveLength(0);
146
+ });
147
+
148
+ test("complete after cache loss recovers runId from the projection (same stream)", async () => {
149
+ // Simulates worker process restart: in-memory bullJobId→runId cache is gone,
150
+ // but the run-started projection row still has bull_job_id. A fresh logger
151
+ // must DB-lookup and append onto the original aggregate — not mint a second.
152
+ await logger.onJobStart?.("example:job:restart", "bull-restart-1", {});
153
+ const started = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
154
+ expect(started).toHaveLength(1);
155
+ const originalAggregateId = started[0]?.aggregateId;
156
+ expect(originalAggregateId).toBeTruthy();
157
+
158
+ const coldLogger = createJobRunLogger({ db: testDb.db, registry });
159
+ await coldLogger.onJobComplete?.("example:job:restart", "bull-restart-1", 42, []);
160
+
161
+ const all = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
162
+ expect(all).toHaveLength(2);
163
+ expect(new Set(all.map((e) => e.aggregateId))).toEqual(new Set([originalAggregateId]));
164
+ expect(all.some((e) => e.type === JOB_RUN_COMPLETED_EVENT)).toBe(true);
165
+ });
130
166
  });
@@ -38,7 +38,7 @@ export function createJobsFeature(): FeatureDefinition {
38
38
  recommended: false,
39
39
  });
40
40
  r.systemScope();
41
- r.unmanagedTable(jobRunLogsTableMeta, {
41
+ r.rawTable(jobRunLogsTableMeta, {
42
42
  reason: "read_side.job_run_logs",
43
43
  });
44
44
  // Events-only aggregate: "jobRun" has no r.entity registration, because
@@ -50,7 +50,7 @@ export type LegalPagesWrapLayout = (opts: {
50
50
  readonly title: string;
51
51
  readonly bodyHtml: string;
52
52
  readonly lang: string;
53
- readonly slug?: "imprint" | "privacy" | "terms";
53
+ readonly slug?: (typeof LEGAL_ROUTES)[number]["slug"];
54
54
  }) => string;
55
55
 
56
56
  export type LegalPagesOptions = {
@@ -28,7 +28,7 @@ describe("mailFoundationFeature — shape", () => {
28
28
  });
29
29
 
30
30
  describe("mailFoundationFeature.exports — typed handles", () => {
31
- test("exposes only the provider-selector config-key", () => {
31
+ test("exposes the provider-selector handle", () => {
32
32
  expect(mailFoundationFeature.exports.providerConfigKey).toBeDefined();
33
33
  expect(mailFoundationFeature.exports.providerConfigKey.name).toBe(
34
34
  "mail-foundation:config:provider",
@@ -73,6 +73,7 @@ export function brandingStyleBlock(tokens: BrandingTokens): string {
73
73
  decls.push(`--accent:${tokens.accentColor}`);
74
74
  }
75
75
  decls.push(`--page-max-width:${layoutMaxWidth(tokens.layoutPreset)}`);
76
+ // html-ok: decls are CSS custom-property declarations (regex-validated hex color or fixed table lookup), never raw user text.
76
77
  return `<style id="tenant-theme">:root{${decls.join(";")}}</style>`;
77
78
  }
78
79
 
@@ -91,9 +92,9 @@ export function brandingHeaderHtml(tokens: BrandingTokens): string {
91
92
  }
92
93
  if (parts.length === 0) return "";
93
94
 
94
- const inner = parts.join("\n");
95
+ const innerHtml = parts.join("\n");
95
96
  if (isSafeHttpsUrl(tokens.siteUrl)) {
96
- return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${inner}</a></header>`;
97
+ return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${innerHtml}</a></header>`;
97
98
  }
98
- return `<header class="brand-header">${inner}</header>`;
99
+ return `<header class="brand-header">${innerHtml}</header>`;
99
100
  }
@@ -53,7 +53,7 @@ export function createPersonalAccessTokensFeature(
53
53
  // Direct-write store like read_user_sessions: create/revoke write it, the
54
54
  // resolver point-reads it. r.entity would make it a rebuildable projection
55
55
  // whose replay (no token events) would wipe every live token (#498/#494).
56
- r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
56
+ r.rawTable(buildEntityTableMeta("api-token", apiTokenEntity), {
57
57
  reason: "read_side.api_tokens_direct_write",
58
58
  // create.write encrypts `name` via encryptForDirectWrite (#820).
59
59
  piiEncryptedOnWrite: true,
@@ -391,6 +391,41 @@ describe("sessions feature — login → check → revoke → rejected", () => {
391
391
  expect(body.error?.details?.reason).toBe("missing");
392
392
  });
393
393
 
394
+ // Cross-user check: a valid JWT for Alice paired with Bob's live sid must
395
+ // look identical to a forged/missing sid — otherwise the checker would be
396
+ // an existence oracle on other users' sessions.
397
+ test("sid belonging to another user → 401 reason=missing (no existence oracle)", async () => {
398
+ const alice = await h.seedUser("alice-xuser@example.com", "pw-long-enough");
399
+ const bob = await h.seedUser("bob-xuser@example.com", "pw-long-enough");
400
+ const bobLogin = await h.login("bob-xuser@example.com", "pw-long-enough");
401
+
402
+ // Direct invariant: Bob's sid + Alice's userId → missing (not live/revoked).
403
+ expect(await callbacks.get().sessionChecker(bobLogin.sid, alice.userId)).toBe("missing");
404
+ expect(await callbacks.get().sessionChecker(bobLogin.sid, bob.userId)).toBe("live");
405
+
406
+ // HTTP path: mint Alice's identity onto Bob's sid (stolen-sid scenario).
407
+ const forged = await stack.jwt.sign({
408
+ id: alice.userId,
409
+ tenantId: TENANT,
410
+ roles: ["User"],
411
+ sid: bobLogin.sid,
412
+ });
413
+ const res = await h.authedPost("/api/query", forged, {
414
+ type: "user:query:user:me",
415
+ payload: {},
416
+ });
417
+ expect(res.status).toBe(401);
418
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
419
+ expect(body.error?.details?.reason).toBe("missing");
420
+
421
+ // Control: Bob's own JWT still authenticates — the sid itself is fine.
422
+ const bobOk = await h.authedPost("/api/query", bobLogin.token, {
423
+ type: "user:query:user:me",
424
+ payload: {},
425
+ });
426
+ expect(bobOk.status).toBe(200);
427
+ });
428
+
394
429
  test("expired session row → 401 with reason=expired", async () => {
395
430
  await h.seedUser("stale@example.com", "pw-long-enough");
396
431
  const { token, sid } = await h.login("stale@example.com", "pw-long-enough");
@@ -511,6 +546,48 @@ describe("sessions feature — login → check → revoke → rejected", () => {
511
546
  });
512
547
  expect(asUser.status).toBe(403);
513
548
  });
549
+
550
+ // IDOR guard: session:detail/session:list are scoped by ctx.db (TenantDb)
551
+ // alone, no explicit tenant predicate in the handler — an admin from a
552
+ // different tenant must not be able to read another tenant's session rows.
553
+ test("session:detail and session:list never leak sessions across tenants", async () => {
554
+ const TENANT_2 = testTenantId(2);
555
+ const h2 = makeSessionHelpers(stack, TENANT_2);
556
+
557
+ const { userId: carolId } = await h.seedUser("carol4@example.com", "pw-long-enough");
558
+ await updateRows(
559
+ stack.db,
560
+ tenantMembershipsTable,
561
+ { roles: JSON.stringify(["Admin"]) },
562
+ { userId: carolId, tenantId: TENANT },
563
+ );
564
+ const carolAsAdmin = await h.login("carol4@example.com", "pw-long-enough");
565
+
566
+ const { userId: daveId } = await h2.seedUser("dave4@example.com", "pw-long-enough");
567
+ await updateRows(
568
+ stack.db,
569
+ tenantMembershipsTable,
570
+ { roles: JSON.stringify(["Admin"]) },
571
+ { userId: daveId, tenantId: TENANT_2 },
572
+ );
573
+ const daveAsAdmin = await h2.login("dave4@example.com", "pw-long-enough");
574
+
575
+ const detailRes = await h2.authedPost("/api/query", daveAsAdmin.token, {
576
+ type: SessionQueries.detail,
577
+ payload: { id: carolAsAdmin.sid },
578
+ });
579
+ expect(detailRes.status).toBe(200);
580
+ const detailBody = (await detailRes.json()) as { data: unknown };
581
+ expect(detailBody.data).toBeNull();
582
+
583
+ const listRes = await h2.authedPost("/api/query", daveAsAdmin.token, {
584
+ type: SessionQueries.list,
585
+ payload: {},
586
+ });
587
+ expect(listRes.status).toBe(200);
588
+ const listBody = (await listRes.json()) as { data: Array<{ id: string }> };
589
+ expect(listBody.data.map((r) => r.id)).not.toContain(carolAsAdmin.sid);
590
+ });
514
591
  });
515
592
 
516
593
  // Defense-in-depth: the sessionChecker refuses a live sid once the user it
@@ -93,10 +93,10 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
93
93
  // r.entity would make it a rebuildable implicit projection whose replay
94
94
  // finds zero session events and swaps an empty shadow over the live
95
95
  // table — wiping every active session on the next projection rebuild
96
- // (#498/#494). r.unmanagedTable keeps the migration DDL but opts the
96
+ // (#498/#494). r.rawTable keeps the migration DDL but opts the
97
97
  // table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
98
98
  // which are direct-write stores too.
99
- r.unmanagedTable(buildEntityTableMeta("user-session", userSessionEntity), {
99
+ r.rawTable(buildEntityTableMeta("user-session", userSessionEntity), {
100
100
  reason: "read_side.user_sessions_direct_write",
101
101
  // sessionCreator encrypts ip/userAgent via encryptForDirectWrite (#820).
102
102
  piiEncryptedOnWrite: true,
@@ -0,0 +1,77 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import * as bunDb from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
+ import { Temporal } from "temporal-polyfill";
5
+ import { USER_STATUS, userTable } from "../user/schema/user";
6
+ import { userSessionTable } from "./schema/user-session";
7
+ import { createSessionCallbacks } from "./session-callbacks";
8
+
9
+ // Unit-only: the fail-open-on-throw branch cannot be provoked through real
10
+ // Postgres without faking infrastructure failure. Integration tests forbid
11
+ // mocks, so this lives next to the source as a plain *.test.ts.
12
+
13
+ type FetchOne = typeof bunDb.fetchOne;
14
+
15
+ describe("sessionChecker fail-open on user-lookup throw", () => {
16
+ test("read_users THROW → live (not 500 / not blocked)", async () => {
17
+ const db = {} as DbConnection;
18
+ const cbs = createSessionCallbacks({ db });
19
+ const sid = "00000000-0000-4000-8000-00000000sid1";
20
+ const userId = "00000000-0000-4000-8000-00000000user";
21
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
22
+
23
+ const originalFetchOne = bunDb.fetchOne;
24
+ const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
25
+ if (table === userSessionTable) {
26
+ return {
27
+ userId,
28
+ revokedAt: null,
29
+ expiresAt: { epochMilliseconds: farFutureMs },
30
+ };
31
+ }
32
+ if (table === userTable) {
33
+ throw new Error("simulated pool exhaustion");
34
+ }
35
+ throw new Error("unexpected table in sessionChecker spy");
36
+ }) as FetchOne);
37
+
38
+ try {
39
+ expect(await cbs.sessionChecker(sid, userId)).toBe("live");
40
+ } finally {
41
+ spy.mockRestore();
42
+ // Sanity: restore must put the real export back (guards against spy leak).
43
+ expect(bunDb.fetchOne).toBe(originalFetchOne);
44
+ }
45
+ });
46
+
47
+ test("control: Restricted user without throw → blocked (spy must hit userTable)", async () => {
48
+ // If the spy somehow skipped the userTable branch, Restricted would still
49
+ // be "live" via fail-open-on-null — this control pins that the throw path
50
+ // is what we exercise above, not an accidental miss.
51
+ const db = {} as DbConnection;
52
+ const cbs = createSessionCallbacks({ db });
53
+ const sid = "00000000-0000-4000-8000-00000000sid2";
54
+ const userId = "00000000-0000-4000-8000-00000000usr2";
55
+ const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
56
+
57
+ const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
58
+ if (table === userSessionTable) {
59
+ return {
60
+ userId,
61
+ revokedAt: null,
62
+ expiresAt: { epochMilliseconds: farFutureMs },
63
+ };
64
+ }
65
+ if (table === userTable) {
66
+ return { status: USER_STATUS.Restricted };
67
+ }
68
+ throw new Error("unexpected table in sessionChecker spy");
69
+ }) as FetchOne);
70
+
71
+ try {
72
+ expect(await cbs.sessionChecker(sid, userId)).toBe("blocked");
73
+ } finally {
74
+ spy.mockRestore();
75
+ }
76
+ });
77
+ });
@@ -25,12 +25,16 @@
25
25
 
26
26
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
27
27
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
28
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
28
+ import {
29
+ defineQueryHandler,
30
+ SYSTEM_USER_ID,
31
+ type TenantId,
32
+ } from "@cosmicdrift/kumiko-framework/engine";
29
33
  import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
30
34
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
31
35
  import { z } from "zod";
32
36
  import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
33
- import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
37
+ import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
34
38
  import { exportDownloadTokensTable } from "../schema/download-token";
35
39
  import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
36
40
 
@@ -130,11 +134,14 @@ export const downloadByJobQuery = defineQueryHandler({
130
134
  // Provider bound explicitly to the job's tenant — NOT the ambient
131
135
  // session tenant (cross-tenant-same-user: job.requestedFromTenantId
132
136
  // can differ from query.user.tenantId).
133
- const provider = await resolveTenantFileProvider(
134
- ctx,
135
- jobRow.requestedFromTenantId,
136
- "user-data-rights:query:download-by-job",
137
- );
137
+ const provider = await makeTenantStorageProviderResolver({
138
+ registry: ctx.registry,
139
+ configResolver: ctx.configResolver,
140
+ secrets: ctx.secrets,
141
+ db: ctx.db.raw,
142
+ userId: SYSTEM_USER_ID,
143
+ handlerName: "user-data-rights:query:download-by-job",
144
+ })(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
138
145
  if (!provider.getSignedUrl) {
139
146
  await recordInvalidAttempt({
140
147
  db: ctx.db.raw,
@@ -27,12 +27,16 @@
27
27
  // startet. Dieser query-handler liefert nur das JSON.
28
28
 
29
29
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
30
- import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
30
+ import {
31
+ defineQueryHandler,
32
+ SYSTEM_USER_ID,
33
+ type TenantId,
34
+ } from "@cosmicdrift/kumiko-framework/engine";
31
35
  import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
32
36
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
33
37
  import { z } from "zod";
34
38
  import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
35
- import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
39
+ import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
36
40
  import { exportDownloadTokensTable } from "../schema/download-token";
37
41
  import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
38
42
  import { hashDownloadToken } from "../token-helpers";
@@ -184,11 +188,14 @@ export const downloadByTokenQuery = defineQueryHandler({
184
188
  // Step 5: signed-URL via provider, explicitly bound to the job's
185
189
  // tenant — NOT the ambient request tenant (anonymous magic-link path
186
190
  // has none under resolverTrust: "authoritative").
187
- const provider = await resolveTenantFileProvider(
188
- ctx,
189
- jobRow.requestedFromTenantId,
190
- "user-data-rights:query:download-by-token",
191
- );
191
+ const provider = await makeTenantStorageProviderResolver({
192
+ registry: ctx.registry,
193
+ configResolver: ctx.configResolver,
194
+ secrets: ctx.secrets,
195
+ db: ctx.db.raw,
196
+ userId: SYSTEM_USER_ID,
197
+ handlerName: "user-data-rights:query:download-by-token",
198
+ })(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
192
199
  if (!provider.getSignedUrl) {
193
200
  await recordInvalidAttempt({
194
201
  db: ctx.db.raw,
@@ -4,8 +4,9 @@
4
4
  // construction). Extracted from the export cron so both crons + the manual
5
5
  // forget handler share one construction site instead of inlining it three times.
6
6
 
7
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
8
8
  import type { ConfigResolver, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
10
  import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
10
11
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
11
12
  import { createConfigAccessor } from "../../config";
@@ -18,7 +19,7 @@ export interface TenantStorageResolverCtx {
18
19
  // Undefined → the returned resolver throws (callers decide fail-loud vs skip).
19
20
  readonly configResolver: ConfigResolver | undefined;
20
21
  readonly secrets: SecretsContext | undefined;
21
- readonly db: DbConnection;
22
+ readonly db: DbConnection | TenantDb;
22
23
  readonly userId: string;
23
24
  readonly handlerName: string;
24
25
  }
@@ -28,9 +29,9 @@ export function makeTenantStorageProviderResolver(
28
29
  ): (tenantId: TenantId) => Promise<FileStorageProvider> {
29
30
  return async (tenantId) => {
30
31
  if (!ctx.configResolver) {
31
- throw new Error(
32
- `${ctx.handlerName}: ctx.configResolver missing — cannot resolve the file provider for tenant ${tenantId}`,
33
- );
32
+ throw new InternalError({
33
+ message: `[${ctx.handlerName}] ctx.configResolver missing — cannot resolve the file provider for tenant ${tenantId}`,
34
+ });
34
35
  }
35
36
  const config = createConfigAccessor(
36
37
  ctx.registry,
@@ -26,6 +26,7 @@ import {
26
26
  import { createConfigFeature } from "../../config";
27
27
  import { configValuesTable } from "../../config/table";
28
28
  import { createDataRetentionFeature } from "../../data-retention";
29
+ import { createFilesFeature } from "../../files";
29
30
  import { createSessionsFeature } from "../../sessions";
30
31
  import { hashPassword } from "../../shared";
31
32
  import { createTenantFeature } from "../../tenant";
@@ -36,6 +37,7 @@ import { UserErrors, UserHandlers, UserQueries } from "../../user";
36
37
  import { createUserFeature } from "../../user/feature";
37
38
  import { userEntity, userTable } from "../../user/schema/user";
38
39
  import { createUserDataRightsFeature } from "../../user-data-rights";
40
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
39
41
  import {
40
42
  UserDataRightsHandlers,
41
43
  UserProfileErrors,
@@ -59,7 +61,9 @@ beforeAll(async () => {
59
61
  createDataRetentionFeature(),
60
62
  createComplianceProfilesFeature(),
61
63
  createSessionsFeature(),
64
+ createFilesFeature(),
62
65
  createUserDataRightsFeature(),
66
+ createUserDataRightsDefaultsFeature(),
63
67
  createUserProfileFeature(),
64
68
  ],
65
69
  authConfig: {
@@ -11,7 +11,9 @@ export function createUserProfileFeature(): FeatureDefinition {
11
11
  "change-email and account deletion (user-data-rights request/cancel with " +
12
12
  "grace period) into one screen. Apps register the screen as " +
13
13
  '`type: "custom"` with `__component: "UserProfileScreen"`. Requires `user`, ' +
14
- "`auth-email-password`, and `user-data-rights`.",
14
+ "`auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` " +
15
+ "(so the GDPR boot-validator finds export/delete hooks for `user`'s PII " +
16
+ "fields once user-data-rights is mounted).",
15
17
  );
16
18
  r.uiHints({
17
19
  displayLabel: "User Profile · Self-Service",
@@ -21,6 +23,7 @@ export function createUserProfileFeature(): FeatureDefinition {
21
23
  r.requires("user");
22
24
  r.requires("auth-email-password");
23
25
  r.requires("user-data-rights");
26
+ r.requires("user-data-rights-defaults");
24
27
 
25
28
  const handlers = {
26
29
  changeEmail: r.writeHandler(changeEmailWrite),
@@ -1,62 +0,0 @@
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
- }