@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.
- package/package.json +7 -6
- package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
- package/src/agent-tools/index.ts +9 -0
- package/src/agent-tools/tool-catalog.ts +130 -0
- package/src/agent-tools/tool-dispatch.ts +82 -0
- package/src/agent-tools/types.ts +38 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-gate.tsx +14 -4
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
- package/src/auth-mfa/__tests__/totp.test.ts +2 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
- package/src/auth-mfa/constants.ts +4 -0
- package/src/auth-mfa/handlers/disable.write.ts +2 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
- package/src/auth-mfa/handlers/verify.write.ts +22 -12
- package/src/auth-mfa/recovery-codes.ts +2 -2
- package/src/auth-mfa/schema/user-mfa.ts +10 -0
- package/src/auth-mfa/totp.ts +18 -6
- package/src/auth-mfa/verify-factor.ts +30 -2
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
- package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +171 -0
- package/src/config/__tests__/write-helpers.test.ts +23 -0
- package/src/config/handlers/set.write.ts +47 -1
- package/src/config/resolver.ts +28 -0
- package/src/config/write-helpers.ts +22 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
- package/src/delivery/feature.ts +1 -1
- package/src/file-foundation/__tests__/feature.test.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/inbound-mail-foundation/feature.ts +3 -3
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
- package/src/jobs/__tests__/jobs-events.integration.test.ts +37 -1
- package/src/jobs/feature.ts +1 -1
- package/src/legal-pages/feature.ts +1 -1
- package/src/mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/page-render/branding.ts +4 -3
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +77 -0
- package/src/sessions/feature.ts +2 -2
- package/src/sessions/session-checker-fail-open.test.ts +77 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
- package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
- package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
- package/src/user-profile/__tests__/change-email.integration.test.ts +4 -0
- package/src/user-profile/feature.ts +4 -1
- 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
|
-
|
|
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
|
});
|
package/src/jobs/feature.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
38
38
|
recommended: false,
|
|
39
39
|
});
|
|
40
40
|
r.systemScope();
|
|
41
|
-
r.
|
|
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?:
|
|
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
|
|
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
|
|
95
|
+
const innerHtml = parts.join("\n");
|
|
95
96
|
if (isSafeHttpsUrl(tokens.siteUrl)) {
|
|
96
|
-
return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${
|
|
97
|
+
return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${innerHtml}</a></header>`;
|
|
97
98
|
}
|
|
98
|
-
return `<header class="brand-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.
|
|
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
|
package/src/sessions/feature.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
|
134
|
-
ctx,
|
|
135
|
-
|
|
136
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
188
|
-
ctx,
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
32
|
-
|
|
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
|
-
}
|