@cosmicdrift/kumiko-bundled-features 0.198.0 → 0.199.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.
- package/package.json +7 -7
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/feature.ts +2 -2
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +94 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +39 -0
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/tenant/handlers/update.write.ts +30 -5
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
|
@@ -15,15 +15,28 @@
|
|
|
15
15
|
// so each rotation appends a normal `.updated` event whose payload carries
|
|
16
16
|
// the NEW envelope: after a full run even a from-scratch rebuild only
|
|
17
17
|
// ever sees the current-KEK envelope.
|
|
18
|
+
//
|
|
19
|
+
// systemScope() fail-closed pattern (framework#2056): the initial scan is
|
|
20
|
+
// intentionally cross-tenant (system-wide key rotation), acknowledged via
|
|
21
|
+
// UncheckedSystemDb.acknowledgeCrossTenant(). Rows are bucketed by tenant,
|
|
22
|
+
// and assertRowsTenant() re-verifies each row against its bucket's tenant
|
|
23
|
+
// right before the write — both checks are built locally from the exported
|
|
24
|
+
// createUncheckedSystemDb().
|
|
18
25
|
|
|
19
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
26
|
import {
|
|
21
27
|
createEventStoreExecutor,
|
|
22
28
|
createTenantDb,
|
|
29
|
+
createUncheckedSystemDb,
|
|
23
30
|
type DbConnection,
|
|
24
31
|
type TenantDb,
|
|
32
|
+
type UncheckedSystemDb,
|
|
25
33
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
type JobHandlerFn,
|
|
36
|
+
type SessionUser,
|
|
37
|
+
SYSTEM_TENANT_ID,
|
|
38
|
+
type TenantId,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
27
40
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
28
41
|
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
42
|
import {
|
|
@@ -104,6 +117,22 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
104
117
|
return existing;
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
const sdbCache = new Map<TenantId, UncheckedSystemDb>();
|
|
121
|
+
function sdbFor(tenantId: TenantId): UncheckedSystemDb {
|
|
122
|
+
let existing = sdbCache.get(tenantId);
|
|
123
|
+
if (!existing) {
|
|
124
|
+
existing = createUncheckedSystemDb(tdbFor(tenantId));
|
|
125
|
+
sdbCache.set(tenantId, existing);
|
|
126
|
+
}
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Standalone from sdbCache/tdbCache on purpose: this one is only ever used
|
|
131
|
+
// to acknowledge the cross-tenant scan below, never bound to a real
|
|
132
|
+
// tenant's writes — sharing it with sdbFor(SYSTEM_TENANT_ID) would make a
|
|
133
|
+
// write-path cache entry double as the scan's ack gate.
|
|
134
|
+
const scanDb = createUncheckedSystemDb(createTenantDb(db, SYSTEM_TENANT_ID, "system"));
|
|
135
|
+
|
|
107
136
|
type ConfigRow = {
|
|
108
137
|
id: string;
|
|
109
138
|
key: string;
|
|
@@ -115,27 +144,51 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
115
144
|
let alreadyCurrent = 0;
|
|
116
145
|
const targetVersion = provider.currentVersion();
|
|
117
146
|
|
|
147
|
+
type BucketedRow = { tenantId: TenantId; row: ConfigRow };
|
|
148
|
+
|
|
118
149
|
// ponytail: one full candidate scan — config rows are operator-scale
|
|
119
150
|
// (tenants × encrypted keys), cursor pagination when that ever changes.
|
|
120
|
-
//
|
|
121
|
-
// checks
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
// Bucketed by tenant so each row keeps its bucket's tenant attached
|
|
152
|
+
// (assertRowsTenant below re-checks that against the row's own field
|
|
153
|
+
// right before the write) and the shared loop's deadline/signal/failure
|
|
154
|
+
// checks still run between chunks, not only once.
|
|
155
|
+
let buckets: Map<TenantId, ConfigRow[]> | undefined;
|
|
156
|
+
async function loadBuckets(): Promise<Map<TenantId, ConfigRow[]>> {
|
|
157
|
+
if (buckets) return buckets;
|
|
158
|
+
const grouped = new Map<TenantId, ConfigRow[]>();
|
|
159
|
+
if (encryptedKeys.length > 0) {
|
|
160
|
+
const scanned = await scanDb
|
|
161
|
+
.acknowledgeCrossTenant("system-wide key rotation")
|
|
162
|
+
.selectMany<ConfigRow>(configValuesTable, { key: { in: encryptedKeys } });
|
|
163
|
+
for (const row of scanned) {
|
|
164
|
+
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
165
|
+
const bucket = grouped.get(tenantId);
|
|
166
|
+
if (bucket) bucket.push(row);
|
|
167
|
+
else grouped.set(tenantId, [row]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
buckets = grouped;
|
|
171
|
+
return buckets;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function nextBatch(): Promise<readonly BucketedRow[]> {
|
|
175
|
+
const grouped = await loadBuckets();
|
|
176
|
+
for (const [tenantId, rows] of grouped) {
|
|
177
|
+
if (rows.length === 0) {
|
|
178
|
+
grouped.delete(tenantId);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const slice = rows.splice(0, SCAN_SLICE_SIZE);
|
|
182
|
+
if (rows.length === 0) grouped.delete(tenantId);
|
|
183
|
+
return slice.map((row) => ({ tenantId, row }));
|
|
133
184
|
}
|
|
134
|
-
|
|
135
|
-
return slice.splice(0, SCAN_SLICE_SIZE);
|
|
185
|
+
return [];
|
|
136
186
|
}
|
|
137
187
|
|
|
138
|
-
async function migrateRow(
|
|
188
|
+
async function migrateRow({
|
|
189
|
+
tenantId,
|
|
190
|
+
row,
|
|
191
|
+
}: BucketedRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
139
192
|
if (row.value === null || row.value === undefined) return "skipped";
|
|
140
193
|
const classification = classifyStoredEnvelope(row.value, targetVersion);
|
|
141
194
|
if (classification === "current") {
|
|
@@ -154,7 +207,12 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
154
207
|
return "failed";
|
|
155
208
|
}
|
|
156
209
|
|
|
157
|
-
|
|
210
|
+
// Re-verify the row's own tenantId against the bucket it was scanned
|
|
211
|
+
// into, right before the write (framework#2072) — a mismatch here
|
|
212
|
+
// fails this row only (via onRowError below), it does not abort the
|
|
213
|
+
// whole job.
|
|
214
|
+
sdbFor(tenantId).assertRowsTenant([row], "tenantId");
|
|
215
|
+
|
|
158
216
|
const plaintext = await cipher.decrypt(row.value, { tenantId });
|
|
159
217
|
const reencrypted = await cipher.encrypt(plaintext, { tenantId });
|
|
160
218
|
|
|
@@ -177,13 +235,13 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
177
235
|
return "migrated";
|
|
178
236
|
}
|
|
179
237
|
|
|
180
|
-
const outcome = await runChunkedMigration<
|
|
238
|
+
const outcome = await runChunkedMigration<BucketedRow>({
|
|
181
239
|
nextBatch,
|
|
182
240
|
migrateRow,
|
|
183
241
|
maxFailures,
|
|
184
242
|
deadlineAt: deadline,
|
|
185
243
|
signal: ctx.signal,
|
|
186
|
-
onRowError: (row, err) => {
|
|
244
|
+
onRowError: ({ row }, err) => {
|
|
187
245
|
ctx.log?.warn?.(`[config:reencrypt] failed to re-encrypt row ${row.id}`, { err });
|
|
188
246
|
},
|
|
189
247
|
});
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
isEncryptedAtRest,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { requireConfigResolver } from "../feature";
|
|
9
|
+
import { requireConfigResolver, requireSystemDb } from "../feature";
|
|
10
10
|
import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction";
|
|
11
11
|
import { hasConfigAccess } from "../write-helpers";
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
16
16
|
// Per-key read access enforced via hasConfigAccess inside the handler.
|
|
17
17
|
access: { openToAll: true },
|
|
18
18
|
handler: async (query, ctx) => {
|
|
19
|
-
const db = ctx.
|
|
19
|
+
const db = requireSystemDb(ctx, "config:query:values", query.user.tenantId);
|
|
20
20
|
const registry = ctx.registry;
|
|
21
21
|
const resolver = requireConfigResolver(ctx, "config:query:values");
|
|
22
22
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
unsafePushTables,
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
11
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
-
import { DELIVERY_LOG_SCREEN_ID, DeliveryQueries } from "../constants";
|
|
12
|
+
import { DELIVERY_LOG_SCREEN_ID, DeliveryHandlers, DeliveryQueries } from "../constants";
|
|
13
13
|
import { createDeliveryFeature } from "../feature";
|
|
14
14
|
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
15
15
|
|
|
@@ -104,3 +104,46 @@ describe("delivery log tenant isolation", () => {
|
|
|
104
104
|
expect(res.rows.some((r) => r.tenantId === tenantB)).toBe(false);
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("delivery preferences tenant isolation", () => {
|
|
109
|
+
// A userId is not tenant-unique — the same person can be a member of
|
|
110
|
+
// several tenants (tenant:write:add-member takes {userId, tenantId}).
|
|
111
|
+
// delivery runs in system-scope, so the preferences query handler must
|
|
112
|
+
// filter by tenantId too — otherwise setting a preference in tenant A
|
|
113
|
+
// leaks into (or gets read from) tenant B for the same userId.
|
|
114
|
+
test("user only sees their own tenant's preference for a shared userId", async () => {
|
|
115
|
+
const tenantA = testTenantId(31);
|
|
116
|
+
const tenantB = testTenantId(32);
|
|
117
|
+
const sharedUser = 41;
|
|
118
|
+
const userInTenantA = createTestUser({ id: sharedUser, roles: ["User"], tenantId: tenantA });
|
|
119
|
+
const userInTenantB = createTestUser({ id: sharedUser, roles: ["User"], tenantId: tenantB });
|
|
120
|
+
expect(userInTenantA.id).toBe(userInTenantB.id);
|
|
121
|
+
|
|
122
|
+
await stack.http.writeOk(
|
|
123
|
+
DeliveryHandlers.setPreference,
|
|
124
|
+
{ notificationType: "welcome", channel: "email", enabled: false },
|
|
125
|
+
userInTenantA,
|
|
126
|
+
);
|
|
127
|
+
await stack.http.writeOk(
|
|
128
|
+
DeliveryHandlers.setPreference,
|
|
129
|
+
{ notificationType: "welcome", channel: "push", enabled: false },
|
|
130
|
+
userInTenantB,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const resA = await stack.http.queryOk<{ rows: readonly { channel: string }[] }>(
|
|
134
|
+
DeliveryQueries.preferences,
|
|
135
|
+
{},
|
|
136
|
+
userInTenantA,
|
|
137
|
+
);
|
|
138
|
+
expect(resA.rows.length).toBeGreaterThan(0);
|
|
139
|
+
expect(resA.rows.every((r) => r.channel === "email")).toBe(true);
|
|
140
|
+
|
|
141
|
+
const resB = await stack.http.queryOk<{ rows: readonly { channel: string }[] }>(
|
|
142
|
+
DeliveryQueries.preferences,
|
|
143
|
+
{},
|
|
144
|
+
userInTenantB,
|
|
145
|
+
);
|
|
146
|
+
expect(resB.rows.length).toBeGreaterThan(0);
|
|
147
|
+
expect(resB.rows.every((r) => r.channel === "push")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
3
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildEntityTable,
|
|
6
|
+
createEventStoreExecutor,
|
|
7
|
+
createTenantDb,
|
|
8
|
+
createUncheckedSystemDb,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
5
10
|
import {
|
|
6
11
|
createEntity,
|
|
7
12
|
createTextField,
|
|
@@ -10,6 +15,7 @@ import {
|
|
|
10
15
|
type JobContext,
|
|
11
16
|
type NotifyFn,
|
|
12
17
|
qn,
|
|
18
|
+
type TenantId,
|
|
13
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
20
|
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
15
21
|
import {
|
|
@@ -17,6 +23,7 @@ import {
|
|
|
17
23
|
setupTestStack,
|
|
18
24
|
type TestStack,
|
|
19
25
|
TestUsers,
|
|
26
|
+
testTenantId,
|
|
20
27
|
unsafePushTables,
|
|
21
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
29
|
import { z } from "zod";
|
|
@@ -40,7 +47,7 @@ import { tenantEntity } from "../../tenant/schema/tenant";
|
|
|
40
47
|
import { DeliveryHandlers, DeliveryJobs, DeliveryQueries } from "../constants";
|
|
41
48
|
import { collectChannels, createDeliveryService } from "../delivery-service";
|
|
42
49
|
import { createDeliveryFeature } from "../feature";
|
|
43
|
-
import { deliveryRenderJob } from "../jobs";
|
|
50
|
+
import { deliveryRenderJob, deliverySendJob } from "../jobs";
|
|
44
51
|
import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
|
|
45
52
|
import { createDeliveryTestContext } from "../testing";
|
|
46
53
|
import type { DeliveryService } from "../types";
|
|
@@ -1502,6 +1509,14 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1502
1509
|
expect(emailRows.some((r) => r["status"] === "sent")).toBe(true);
|
|
1503
1510
|
});
|
|
1504
1511
|
|
|
1512
|
+
// Mirrors what job-runner.ts's handleJob really builds for a
|
|
1513
|
+
// r.systemScope()'d job (framework#2105) — a real TenantDb wrapped by
|
|
1514
|
+
// createUncheckedSystemDb, not a mock, so requireTenantScopedDeps'
|
|
1515
|
+
// assertTenantMatch() call exercises the actual production code path.
|
|
1516
|
+
function makeSystemDb(tenantId: TenantId) {
|
|
1517
|
+
return createUncheckedSystemDb(createTenantDb(db, tenantId, "system"));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1505
1520
|
test("delivery.render on a channel without a render step records failed and does not dispatch send", async () => {
|
|
1506
1521
|
const { runner, dispatched } = makeStubRunner();
|
|
1507
1522
|
const attemptId = "00000000-0000-4000-8000-0000000000aa";
|
|
@@ -1518,7 +1533,12 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1518
1533
|
message: { notificationType: "app:notify:render-isolation", title: "X" },
|
|
1519
1534
|
},
|
|
1520
1535
|
// @cast-boundary test-seam — job throws before touching systemUser/log/write/queryAs
|
|
1521
|
-
{
|
|
1536
|
+
{
|
|
1537
|
+
db,
|
|
1538
|
+
registry: stack.registry,
|
|
1539
|
+
jobRunner: runner,
|
|
1540
|
+
systemDb: makeSystemDb(admin.tenantId),
|
|
1541
|
+
} as unknown as JobContext,
|
|
1522
1542
|
),
|
|
1523
1543
|
).rejects.toThrow(/no render step/);
|
|
1524
1544
|
|
|
@@ -1529,6 +1549,77 @@ describe("flow 17: async render→send pipeline", () => {
|
|
|
1529
1549
|
});
|
|
1530
1550
|
expect(rows.some((r) => r["status"] === "failed")).toBe(true);
|
|
1531
1551
|
});
|
|
1552
|
+
|
|
1553
|
+
// The regression this diff exists to prevent: a payload.tenantId that
|
|
1554
|
+
// doesn't match the systemDb's own tenant must be rejected, not silently
|
|
1555
|
+
// used to read/write another tenant's stream (what ctx.db without
|
|
1556
|
+
// assertTenantMatch would have allowed).
|
|
1557
|
+
test("job handlers throw when payload.tenantId doesn't match ctx.systemDb's tenant", async () => {
|
|
1558
|
+
const { runner, dispatched } = makeStubRunner();
|
|
1559
|
+
const foreignTenantId = testTenantId(999);
|
|
1560
|
+
const attemptId = "00000000-0000-4000-8000-0000000000cc";
|
|
1561
|
+
// @cast-boundary test-seam — mirrors job-runner.ts's real systemDb shape
|
|
1562
|
+
const ctxWithMismatchedSystemDb = {
|
|
1563
|
+
db,
|
|
1564
|
+
registry: stack.registry,
|
|
1565
|
+
jobRunner: runner,
|
|
1566
|
+
systemDb: makeSystemDb(admin.tenantId),
|
|
1567
|
+
} as unknown as JobContext;
|
|
1568
|
+
|
|
1569
|
+
await expect(
|
|
1570
|
+
deliveryRenderJob(
|
|
1571
|
+
{
|
|
1572
|
+
channelName: "email",
|
|
1573
|
+
address: testEmail(asyncRecipient),
|
|
1574
|
+
tenantId: foreignTenantId,
|
|
1575
|
+
recipientId: String(asyncRecipient),
|
|
1576
|
+
notificationType: "app:notify:cross-tenant",
|
|
1577
|
+
deliveryAttemptId: attemptId,
|
|
1578
|
+
priority: "normal",
|
|
1579
|
+
message: { notificationType: "app:notify:cross-tenant", title: "X" },
|
|
1580
|
+
},
|
|
1581
|
+
ctxWithMismatchedSystemDb,
|
|
1582
|
+
),
|
|
1583
|
+
).rejects.toThrow(/tenant self-check failed/);
|
|
1584
|
+
|
|
1585
|
+
expect(dispatched).toHaveLength(0);
|
|
1586
|
+
const rows = await selectMany(db, deliveryAttemptsTable, {
|
|
1587
|
+
notificationType: "app:notify:cross-tenant",
|
|
1588
|
+
});
|
|
1589
|
+
expect(rows).toHaveLength(0);
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
// Unreachable via real dispatch (job-runner.ts only omits systemDb for jobs
|
|
1593
|
+
// whose feature is NOT r.systemScope()'d, and delivery always is) — this
|
|
1594
|
+
// exercises requireTenantScopedDeps' own defensive guard directly, the
|
|
1595
|
+
// same way the missing-render-step case above exercises resolveChannel's.
|
|
1596
|
+
test("job handlers throw when ctx.systemDb is missing on a system-scoped job", async () => {
|
|
1597
|
+
const { runner } = makeStubRunner();
|
|
1598
|
+
const attemptId = "00000000-0000-4000-8000-0000000000bb";
|
|
1599
|
+
const payload = {
|
|
1600
|
+
channelName: "email",
|
|
1601
|
+
address: testEmail(asyncRecipient),
|
|
1602
|
+
tenantId: admin.tenantId,
|
|
1603
|
+
recipientId: String(asyncRecipient),
|
|
1604
|
+
notificationType: "app:notify:missing-systemdb",
|
|
1605
|
+
deliveryAttemptId: attemptId,
|
|
1606
|
+
priority: "normal" as const,
|
|
1607
|
+
message: { notificationType: "app:notify:missing-systemdb", title: "X" },
|
|
1608
|
+
};
|
|
1609
|
+
// @cast-boundary test-seam — no systemDb, unlike what job-runner.ts actually wires
|
|
1610
|
+
const ctxWithoutSystemDb = {
|
|
1611
|
+
db,
|
|
1612
|
+
registry: stack.registry,
|
|
1613
|
+
jobRunner: runner,
|
|
1614
|
+
} as unknown as JobContext;
|
|
1615
|
+
|
|
1616
|
+
await expect(deliveryRenderJob(payload, ctxWithoutSystemDb)).rejects.toThrow(
|
|
1617
|
+
/ctx\.systemDb missing/,
|
|
1618
|
+
);
|
|
1619
|
+
await expect(deliverySendJob(payload, ctxWithoutSystemDb)).rejects.toThrow(
|
|
1620
|
+
/ctx\.systemDb missing/,
|
|
1621
|
+
);
|
|
1622
|
+
});
|
|
1532
1623
|
});
|
|
1533
1624
|
|
|
1534
1625
|
// --- Flow 18: priority maps onto the BullMQ job priority ---
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { decryptStoredPii } from "../../shared";
|
|
5
6
|
import { deliveryAttemptsTable } from "../tables";
|
|
@@ -11,11 +12,16 @@ export const logQuery = defineQueryHandler({
|
|
|
11
12
|
}),
|
|
12
13
|
access: { roles: access.admin },
|
|
13
14
|
handler: async (query, ctx) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
if (!ctx.systemDb) {
|
|
16
|
+
throw new InternalError({ message: "delivery log handler requires ctx.systemDb" });
|
|
17
|
+
}
|
|
18
|
+
// delivery is r.systemScope()'d, so the TenantDb is system-mode (reads
|
|
19
|
+
// unfiltered). assertTenantMatch is a self-check on the caller's own
|
|
20
|
+
// tenantId, not a query filter — it returns that same unfiltered db, so
|
|
21
|
+
// the explicit `where` below still does the actual tenant scoping.
|
|
22
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
17
23
|
const rows = await selectMany(
|
|
18
|
-
|
|
24
|
+
db,
|
|
19
25
|
deliveryAttemptsTable,
|
|
20
26
|
{ tenantId: query.user.tenantId },
|
|
21
27
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { notificationPreferencesTable } from "../tables";
|
|
5
6
|
|
|
@@ -8,7 +9,19 @@ export const preferencesQuery = defineQueryHandler({
|
|
|
8
9
|
schema: z.object({}),
|
|
9
10
|
access: { openToAll: true },
|
|
10
11
|
handler: async (query, ctx) => {
|
|
11
|
-
|
|
12
|
+
// delivery runs in system-scope, so ctx.db is not auto-tenant-filtered —
|
|
13
|
+
// userId alone is not tenant-unique, so a userId-only filter here leaked preferences cross-tenant.
|
|
14
|
+
if (!ctx.systemDb) {
|
|
15
|
+
throw new InternalError({
|
|
16
|
+
message: "preferences: ctx.systemDb missing on a system-scoped handler",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
20
|
+
|
|
21
|
+
const rows = await selectMany(db, notificationPreferencesTable, {
|
|
22
|
+
tenantId: query.user.tenantId,
|
|
23
|
+
userId: query.user.id,
|
|
24
|
+
});
|
|
12
25
|
|
|
13
26
|
return { rows };
|
|
14
27
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { upsertPreference } from "../upsert-preference";
|
|
4
5
|
|
|
@@ -15,7 +16,17 @@ export const setPreferenceWrite = defineWriteHandler({
|
|
|
15
16
|
const { notificationType, channel, enabled } = event.payload;
|
|
16
17
|
const { id: userId, tenantId } = event.user;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// delivery runs in system-scope (feature.ts r.systemScope()) — ctx.systemDb
|
|
20
|
+
// is guaranteed there. No client-supplied userId exists on this payload;
|
|
21
|
+
// assertTenantMatch is the enforcement path that keeps it that way.
|
|
22
|
+
if (!ctx.systemDb) {
|
|
23
|
+
throw new InternalError({
|
|
24
|
+
message: "setPreference: ctx.systemDb missing on a system-scoped handler",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const db = ctx.systemDb.assertTenantMatch(tenantId);
|
|
28
|
+
|
|
29
|
+
const result = await upsertPreference(db, event.user, {
|
|
19
30
|
tenantId,
|
|
20
31
|
userId,
|
|
21
32
|
notificationType,
|
package/src/delivery/jobs.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
9
|
import type {
|
|
10
|
-
|
|
10
|
+
JobContext,
|
|
11
11
|
JobHandlerFn,
|
|
12
12
|
Registry,
|
|
13
13
|
TenantId,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
16
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
import { appendAttemptEvent } from "./attempt-log";
|
|
@@ -44,13 +45,22 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
|
44
45
|
|
|
45
46
|
type RenderJobPayload = z.infer<typeof renderJobPayloadSchema>;
|
|
46
47
|
|
|
47
|
-
function
|
|
48
|
+
function requireTenantScopedDeps(
|
|
49
|
+
ctx: JobContext,
|
|
50
|
+
tenantId: TenantId,
|
|
51
|
+
): { db: DbConnection; registry: Registry } {
|
|
48
52
|
const registry = ctx.registry;
|
|
49
53
|
if (!registry) throw new Error("delivery job: missing registry in job context");
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// delivery is r.systemScope()'d (feature.ts), so JobContext.systemDb is
|
|
55
|
+
// guaranteed here. assertTenantMatch(tenantId).raw hands appendAttemptEvent/
|
|
56
|
+
// buildChannelContext the underlying DbConnection — safe because both
|
|
57
|
+
// filter by the same tenantId themselves (see JobContext.systemDb doc).
|
|
58
|
+
if (!ctx.systemDb) {
|
|
59
|
+
throw new InternalError({
|
|
60
|
+
message: "delivery job: ctx.systemDb missing on a system-scoped job",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const db = ctx.systemDb.assertTenantMatch(tenantId).raw as DbConnection; // @cast-boundary db-operator — TenantDb.raw is DbRunner, jobs never run inside a DbTx
|
|
54
64
|
return { db, registry };
|
|
55
65
|
}
|
|
56
66
|
|
|
@@ -96,9 +106,9 @@ function messageOf(err: unknown): string {
|
|
|
96
106
|
// the send step is never reached, so a stuck render can't half-send.
|
|
97
107
|
export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
98
108
|
const p = renderJobPayloadSchema.parse(payload);
|
|
99
|
-
const { db, registry } = requireDeps(ctx);
|
|
100
|
-
const channel = resolveChannel(registry, p.channelName);
|
|
101
109
|
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
110
|
+
const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
|
|
111
|
+
const channel = resolveChannel(registry, p.channelName);
|
|
102
112
|
const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
|
|
103
113
|
|
|
104
114
|
try {
|
|
@@ -128,9 +138,9 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
|
|
|
128
138
|
// the same already-rendered HTML — no re-render needed.
|
|
129
139
|
export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
|
|
130
140
|
const p = sendJobPayloadSchema.parse(payload);
|
|
131
|
-
const { db, registry } = requireDeps(ctx);
|
|
132
|
-
const channel = resolveChannel(registry, p.channelName);
|
|
133
141
|
const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
|
|
142
|
+
const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
|
|
143
|
+
const channel = resolveChannel(registry, p.channelName);
|
|
134
144
|
const channelCtx = buildChannelContext(db, registry, undefined, tenantId);
|
|
135
145
|
|
|
136
146
|
try {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// framework#2076: set.write / registered.query / list.query all read/write
|
|
2
|
+
// globalFeatureStateTable via ctx.systemDb.acknowledgeCrossTenant(...) instead
|
|
3
|
+
// of ctx.db directly. That only resolves correctly as long as the
|
|
4
|
+
// feature-toggles feature stays r.systemScope() (feature.ts) — buildHandlerContext
|
|
5
|
+
// only populates ctx.systemDb for system-scoped handlers (dispatch-shared.ts).
|
|
6
|
+
// This test pins that coupling so a future refactor that drops r.systemScope()
|
|
7
|
+
// fails loudly here instead of surfacing as "undefined.acknowledgeCrossTenant"
|
|
8
|
+
// deep inside a handler.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { FeatureToggleHandlers, FeatureToggleQueries } from "../constants";
|
|
13
|
+
import { createFeatureTogglesFeature } from "../feature";
|
|
14
|
+
|
|
15
|
+
describe("feature-toggles handlers stay r.systemScope()-coupled", () => {
|
|
16
|
+
const registry = createRegistry([createFeatureTogglesFeature()]);
|
|
17
|
+
|
|
18
|
+
test("set/list/registered are all system-scoped", () => {
|
|
19
|
+
expect(registry.isHandlerSystemScoped(FeatureToggleHandlers.set)).toBe(true);
|
|
20
|
+
expect(registry.isHandlerSystemScoped(FeatureToggleQueries.list)).toBe(true);
|
|
21
|
+
expect(registry.isHandlerSystemScoped(FeatureToggleQueries.registered)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -10,6 +10,12 @@ export const FEATURE_TOGGLE_SET_EVENT_NAME = "feature-toggles:event:toggle-set";
|
|
|
10
10
|
// "feature-toggles" in its aggregate_type column.
|
|
11
11
|
export const FEATURE_TOGGLE_AGGREGATE_TYPE = "feature-toggles";
|
|
12
12
|
|
|
13
|
+
// Shared acknowledgeCrossTenant() reason across all three handlers that read
|
|
14
|
+
// or write globalFeatureStateTable — one string so the three call sites
|
|
15
|
+
// can't drift (framework#2076).
|
|
16
|
+
export const FEATURE_TOGGLE_CROSS_TENANT_REASON =
|
|
17
|
+
"global feature-toggle state, no tenant scope by design";
|
|
18
|
+
|
|
13
19
|
// Error reasons surfaced from feature-toggle handlers. Scoped to the
|
|
14
20
|
// feature's namespace per the framework's reason-convention.
|
|
15
21
|
export const FeatureToggleErrors = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
|
|
4
5
|
import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
5
6
|
|
|
6
7
|
// List every row in the global_feature_state table — i.e. every feature
|
|
@@ -12,6 +13,16 @@ export const listQuery = defineQueryHandler({
|
|
|
12
13
|
schema: z.object({}),
|
|
13
14
|
access: { roles: ["SystemAdmin"] },
|
|
14
15
|
handler: async (_event, ctx) => {
|
|
16
|
+
// ctx.systemDb is populated for r.systemScope() handlers only (feature.ts
|
|
17
|
+
// declares it) — guarded instead of asserted, see set.write.ts Guard 0.5.
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"[feature-toggles] list-query requires ctx.systemDb — the feature-toggles " +
|
|
21
|
+
"feature must stay r.systemScope() (see feature.ts).",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
|
|
25
|
+
|
|
15
26
|
type Row = {
|
|
16
27
|
featureName: string;
|
|
17
28
|
enabled: boolean;
|
|
@@ -19,7 +30,7 @@ export const listQuery = defineQueryHandler({
|
|
|
19
30
|
updatedAt: Temporal.Instant;
|
|
20
31
|
updatedBy: string;
|
|
21
32
|
};
|
|
22
|
-
const rows = await selectMany<Row>(
|
|
33
|
+
const rows = await selectMany<Row>(db.raw, globalFeatureStateTable);
|
|
23
34
|
return {
|
|
24
35
|
items: rows.map((r) => ({
|
|
25
36
|
featureName: r.featureName,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { FEATURE_TOGGLE_CROSS_TENANT_REASON } from "../constants";
|
|
4
5
|
import { globalFeatureStateTable } from "../global-feature-state-table";
|
|
5
6
|
|
|
6
7
|
// Inventory of every registered feature, annotated with toggle metadata
|
|
@@ -16,8 +17,18 @@ export const registeredQuery = defineQueryHandler({
|
|
|
16
17
|
schema: z.object({}),
|
|
17
18
|
access: { roles: ["SystemAdmin"] },
|
|
18
19
|
handler: async (_event, ctx) => {
|
|
20
|
+
// ctx.systemDb is populated for r.systemScope() handlers only (feature.ts
|
|
21
|
+
// declares it) — guarded instead of asserted, see set.write.ts Guard 0.5.
|
|
22
|
+
if (!ctx.systemDb) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"[feature-toggles] registered-query requires ctx.systemDb — the feature-toggles " +
|
|
25
|
+
"feature must stay r.systemScope() (see feature.ts).",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(FEATURE_TOGGLE_CROSS_TENANT_REASON);
|
|
29
|
+
|
|
19
30
|
type OverrideRow = { featureName: string; enabled: boolean };
|
|
20
|
-
const overrideRows = await selectMany<OverrideRow>(
|
|
31
|
+
const overrideRows = await selectMany<OverrideRow>(db.raw, globalFeatureStateTable);
|
|
21
32
|
const overrides = new Map(overrideRows.map((r) => [r.featureName, r.enabled]));
|
|
22
33
|
|
|
23
34
|
// SystemAdmin operator-tooling: das listing soll die PLATTFORM-truth
|