@cosmicdrift/kumiko-bundled-features 0.197.1 → 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.
Files changed (81) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
  3. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
  4. package/src/auth-email-password/feature.ts +9 -1
  5. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
  6. package/src/auth-email-password/handlers/login.write.ts +10 -5
  7. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
  8. package/src/auth-mfa/feature.ts +3 -0
  9. package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
  10. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
  11. package/src/compliance-profiles/feature.ts +18 -5
  12. package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
  13. package/src/compliance-profiles/index.ts +1 -0
  14. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
  15. package/src/config/__tests__/require-system-db.test.ts +46 -0
  16. package/src/config/feature.ts +28 -0
  17. package/src/config/handlers/cascade.query.ts +2 -2
  18. package/src/config/handlers/readiness.query.ts +19 -6
  19. package/src/config/handlers/reencrypt.job.ts +79 -21
  20. package/src/config/handlers/values.query.ts +2 -2
  21. package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
  22. package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
  23. package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
  24. package/src/delivery/feature.ts +13 -3
  25. package/src/delivery/handlers/log.query.ts +10 -4
  26. package/src/delivery/handlers/preferences.query.ts +14 -1
  27. package/src/delivery/handlers/set-preference.write.ts +12 -1
  28. package/src/delivery/index.ts +1 -1
  29. package/src/delivery/jobs.ts +20 -10
  30. package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
  31. package/src/derivatives-sharp/render.ts +55 -15
  32. package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
  33. package/src/feature-toggles/constants.ts +6 -0
  34. package/src/feature-toggles/handlers/list.query.ts +12 -1
  35. package/src/feature-toggles/handlers/registered.query.ts +12 -1
  36. package/src/feature-toggles/handlers/set.write.ts +18 -3
  37. package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
  38. package/src/file-derivatives/feature.ts +12 -13
  39. package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
  40. package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
  41. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
  42. package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
  43. package/src/form-draft/constants.ts +13 -0
  44. package/src/form-draft/db/queries/cleanup.ts +4 -1
  45. package/src/form-draft/db/queries/draft-count.ts +20 -0
  46. package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
  47. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
  48. package/src/form-draft/handlers/cleanup.job.ts +6 -0
  49. package/src/form-draft/handlers/discard.write.ts +6 -0
  50. package/src/form-draft/handlers/save.write.ts +15 -2
  51. package/src/form-draft/schemas.ts +16 -2
  52. package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
  53. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
  54. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
  55. package/src/jobs/handlers/detail.query.ts +7 -1
  56. package/src/jobs/handlers/list.query.ts +8 -1
  57. package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
  58. package/src/jobs/handlers/reindex-entity.job.ts +14 -10
  59. package/src/jobs/handlers/retry.write.ts +7 -1
  60. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
  61. package/src/personal-access-tokens/feature.ts +4 -1
  62. package/src/personal-access-tokens/roles.ts +11 -3
  63. package/src/readiness/handlers/status.query.ts +2 -1
  64. package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
  65. package/src/sessions/feature.ts +18 -10
  66. package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
  67. package/src/sessions/index.ts +1 -1
  68. package/src/sessions/session-callbacks.ts +7 -1
  69. package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
  70. package/src/tenant/handlers/remove-member.write.ts +23 -1
  71. package/src/tenant/handlers/update-member-roles.write.ts +23 -1
  72. package/src/tenant/handlers/update.write.ts +35 -2
  73. package/src/tenant/invitation-table.ts +8 -1
  74. package/src/user/__tests__/user.integration.test.ts +21 -0
  75. package/src/user/handlers/create.write.ts +10 -3
  76. package/src/user/handlers/find-for-auth.query.ts +9 -1
  77. package/src/user/handlers/me.query.ts +10 -1
  78. package/src/user/handlers/update.write.ts +12 -2
  79. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
  80. package/src/user-data-rights/feature.ts +10 -7
  81. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
@@ -0,0 +1,112 @@
1
+ // Proves the two DoS-shaped caps on form-draft save (#1900/2): an unbounded
2
+ // `values` blob and unbounded drafts-per-owner both grow the append-only
3
+ // event stream without limit. Real Postgres, real dispatch — not hand-fed
4
+ // context.
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
+ import {
10
+ createTestUser,
11
+ setupTestStack,
12
+ type TestStack,
13
+ unsafeCreateEntityTable,
14
+ } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { createConfigFeature } from "../../config";
16
+ import {
17
+ FORM_DRAFT_MAX_PER_OWNER,
18
+ FORM_DRAFT_VALUES_MAX_BYTES,
19
+ FormDraftHandlers,
20
+ } from "../constants";
21
+ import { formDraftEntity } from "../entity";
22
+ import { formDraftFeature } from "../feature";
23
+
24
+ let stack: TestStack;
25
+
26
+ const owner = createTestUser({ id: 1, roles: ["TenantMember"] });
27
+
28
+ beforeAll(async () => {
29
+ stack = await setupTestStack({ features: [formDraftFeature, createConfigFeature()] });
30
+ await unsafeCreateEntityTable(stack.db, formDraftEntity);
31
+ await createEventsTable(stack.db);
32
+ });
33
+
34
+ afterAll(async () => {
35
+ await stack.cleanup();
36
+ });
37
+
38
+ beforeEach(async () => {
39
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
40
+ await asRawClient(stack.db).unsafe("DELETE FROM read_form_drafts");
41
+ });
42
+
43
+ async function saveDraft(draftKey: string, values: Record<string, unknown>) {
44
+ return stack.http.write(FormDraftHandlers.save, { draftKey, values, stepIndex: 0 }, owner);
45
+ }
46
+
47
+ describe("form-draft — values byte cap", () => {
48
+ test(`values at or under ${FORM_DRAFT_VALUES_MAX_BYTES} bytes is accepted`, async () => {
49
+ // Padding value sized so the whole payload lands just under the cap.
50
+ const padding = "x".repeat(FORM_DRAFT_VALUES_MAX_BYTES - 200);
51
+ const res = await saveDraft("wizard:within-cap", { note: padding });
52
+ expect(res.status).toBe(200);
53
+ });
54
+
55
+ test(`values exceeding ${FORM_DRAFT_VALUES_MAX_BYTES} bytes is rejected, not silently truncated`, async () => {
56
+ const padding = "x".repeat(FORM_DRAFT_VALUES_MAX_BYTES + 1);
57
+ const res = await saveDraft("wizard:over-cap", { note: padding });
58
+ expect(res.status).toBe(400);
59
+
60
+ const rows = await asRawClient(stack.db).unsafe(
61
+ "SELECT count(*)::int AS n FROM read_form_drafts WHERE draft_key = $1",
62
+ ["wizard:over-cap"],
63
+ );
64
+ expect((rows as Array<{ n: number }>)[0]?.n).toBe(0);
65
+ });
66
+ });
67
+
68
+ describe("form-draft — drafts-per-owner cap", () => {
69
+ test(`an owner can create up to ${FORM_DRAFT_MAX_PER_OWNER} drafts, then the next create is rejected`, async () => {
70
+ for (let i = 0; i < FORM_DRAFT_MAX_PER_OWNER; i++) {
71
+ const res = await saveDraft(`wizard:draft-${i}`, { note: "x" });
72
+ expect(res.status).toBe(200);
73
+ }
74
+
75
+ const overflow = await saveDraft("wizard:draft-overflow", { note: "x" });
76
+ expect(overflow.status).toBe(422);
77
+ const body = (await overflow.json()) as { error: { details: Record<string, unknown> } };
78
+ expect(body.error.details).toMatchObject({
79
+ reason: "draft_limit_reached",
80
+ limit: FORM_DRAFT_MAX_PER_OWNER,
81
+ });
82
+
83
+ const rows = await asRawClient(stack.db).unsafe(
84
+ "SELECT count(*)::int AS n FROM read_form_drafts WHERE owner_id = $1",
85
+ [String(owner.id)],
86
+ );
87
+ expect((rows as Array<{ n: number }>)[0]?.n).toBe(FORM_DRAFT_MAX_PER_OWNER);
88
+ });
89
+
90
+ test("updating an existing draft is never blocked by the cap, even at the limit", async () => {
91
+ for (let i = 0; i < FORM_DRAFT_MAX_PER_OWNER; i++) {
92
+ await saveDraft(`wizard:draft-${i}`, { note: "x" });
93
+ }
94
+
95
+ const res = await saveDraft("wizard:draft-0", { note: "updated" });
96
+ expect(res.status).toBe(200);
97
+ });
98
+
99
+ test("the cap is per-owner, not per-tenant — a different owner is unaffected", async () => {
100
+ for (let i = 0; i < FORM_DRAFT_MAX_PER_OWNER; i++) {
101
+ await saveDraft(`wizard:draft-${i}`, { note: "x" });
102
+ }
103
+
104
+ const otherOwner = createTestUser({ id: 2, roles: ["TenantMember"] });
105
+ const res = await stack.http.write(
106
+ FormDraftHandlers.save,
107
+ { draftKey: "wizard:first", values: { note: "x" }, stepIndex: 0 },
108
+ otherOwner,
109
+ );
110
+ expect(res.status).toBe(200);
111
+ });
112
+ });
@@ -29,3 +29,16 @@ export const FORM_DRAFT_ACCESS: AccessRule = { openToAll: true };
29
29
  export const FORM_DRAFT_UNIQUE_KEY_CONSTRAINT = "read_form_drafts_tenant_owner_key_uniq";
30
30
 
31
31
  export const FORM_DRAFT_KEY_MAX_LENGTH = 256;
32
+
33
+ // `values` is unbounded, caller-controlled JSON (wizard-form fields) — an
34
+ // autosaving frontend or a malicious authenticated caller could otherwise
35
+ // grow a single draft row without limit. Each save appends an event to the
36
+ // append-only event store, so an oversized draft is also a permanent
37
+ // storage cost, not just a transient one.
38
+ export const FORM_DRAFT_VALUES_MAX_BYTES = 64 * 1024;
39
+
40
+ // Cap on concurrent drafts per (tenant, ownerId) — without it, an
41
+ // autosaving frontend generating a new create-mode draftId per abandoned
42
+ // wizard session grows the event stream without bound (every save is an
43
+ // append-only event).
44
+ export const FORM_DRAFT_MAX_PER_OWNER = 50;
@@ -8,6 +8,7 @@ export type StaleDraftRow = {
8
8
  readonly id: string;
9
9
  readonly tenantId: TenantId;
10
10
  readonly ownerId: string;
11
+ readonly draftKey: string;
11
12
  readonly draft: FormDraftBlob;
12
13
  readonly insertedAt: Temporal.Instant;
13
14
  };
@@ -29,7 +30,7 @@ export async function selectStaleDraftsBatch(
29
30
  batchSize: number,
30
31
  ): Promise<readonly StaleDraftRow[]> {
31
32
  const rows = (await asRawClient(db).unsafe(
32
- `SELECT "id", "tenant_id", "owner_id", "draft", "inserted_at" FROM "read_form_drafts"
33
+ `SELECT "id", "tenant_id", "owner_id", "draft_key", "draft", "inserted_at" FROM "read_form_drafts"
33
34
  WHERE COALESCE("modified_at", "inserted_at") < now() - ($1::int * interval '1 day')
34
35
  ORDER BY COALESCE("modified_at", "inserted_at") ASC
35
36
  LIMIT $2`,
@@ -38,6 +39,7 @@ export async function selectStaleDraftsBatch(
38
39
  id: string;
39
40
  tenant_id: string;
40
41
  owner_id: string;
42
+ draft_key: string;
41
43
  draft: FormDraftBlob;
42
44
  inserted_at: Date;
43
45
  }[];
@@ -45,6 +47,7 @@ export async function selectStaleDraftsBatch(
45
47
  id: row.id,
46
48
  tenantId: row.tenant_id as TenantId, // @cast-boundary db-row
47
49
  ownerId: row.owner_id,
50
+ draftKey: row.draft_key,
48
51
  draft: row.draft,
49
52
  insertedAt: Temporal.Instant.fromEpochMilliseconds(+row.inserted_at), // @cast-boundary db-row
50
53
  }));
@@ -0,0 +1,20 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+
5
+ // COUNT(*), not selectMany(...).length — save.write.ts checks this on every
6
+ // create, and a draft's `values` blob can be up to FORM_DRAFT_VALUES_MAX_BYTES
7
+ // (64KB); loading up to FORM_DRAFT_MAX_PER_OWNER full rows just to count them
8
+ // would be several MB of wasted transfer per save.
9
+ export async function countDraftsByOwner(
10
+ db: DbRunner,
11
+ tenantId: TenantId,
12
+ ownerId: string,
13
+ ): Promise<number> {
14
+ const rows = (await asRawClient(db).unsafe(
15
+ `SELECT count(*)::int AS "count" FROM "read_form_drafts"
16
+ WHERE "tenant_id" = $1 AND "owner_id" = $2`,
17
+ [tenantId, ownerId],
18
+ )) as readonly { count: number }[];
19
+ return rows[0]?.count ?? 0;
20
+ }
@@ -18,14 +18,30 @@ import type { Temporal } from "temporal-polyfill";
18
18
  // entity) but must survive a discard/cleanup sweep because the live entity
19
19
  // still references them. Only uploads that happened during THIS draft's
20
20
  // lifetime (inserted after the draft row) are release-eligible.
21
+ //
22
+ // `isCreateMode` bypasses that timestamp filter entirely: a create-mode
23
+ // draftKey (`${screenId}:new:${draftId}`) mints its draftId lazily on the
24
+ // first step-change, so a file uploaded on step 0 gets a file_refs row
25
+ // timestamped BEFORE the draft row exists. There is no pre-filled entity in
26
+ // create-mode — every FileRef the draft references is draft-owned — so the
27
+ // filter would otherwise permanently exclude that key from every future
28
+ // discard/cleanup, leaking it as an unreleasable storage object.
21
29
  export async function filterOwnedStorageKeys(
22
30
  db: DbRunner,
23
31
  tenantId: TenantId,
24
32
  ownerId: string,
25
33
  candidateKeys: readonly string[],
26
34
  draftInsertedAt: Temporal.Instant,
35
+ isCreateMode: boolean,
27
36
  ): Promise<readonly string[]> {
28
- const rows = await filterOwnedFileRefs(db, tenantId, ownerId, candidateKeys, draftInsertedAt);
37
+ const rows = await filterOwnedFileRefs(
38
+ db,
39
+ tenantId,
40
+ ownerId,
41
+ candidateKeys,
42
+ draftInsertedAt,
43
+ isCreateMode,
44
+ );
29
45
  return rows.map((row) => row.storageKey);
30
46
  }
31
47
 
@@ -43,14 +59,15 @@ export async function filterOwnedFileRefs(
43
59
  ownerId: string,
44
60
  candidateKeys: readonly string[],
45
61
  draftInsertedAt: Temporal.Instant,
62
+ isCreateMode: boolean,
46
63
  ): Promise<readonly OwnedFileRef[]> {
47
64
  if (candidateKeys.length === 0) return [];
48
65
  const rows = (await asRawClient(db).unsafe(
49
66
  `SELECT "id", "storage_key" FROM "file_refs"
50
67
  WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
51
68
  AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
52
- AND "inserted_at" > $4::timestamptz`,
53
- [tenantId, ownerId, candidateKeys, draftInsertedAt.toString()],
69
+ AND ($5::boolean OR "inserted_at" > $4::timestamptz)`,
70
+ [tenantId, ownerId, candidateKeys, draftInsertedAt.toString(), isCreateMode],
54
71
  )) as readonly { id: string; storage_key: string }[];
55
72
  return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
56
73
  }
@@ -19,6 +19,7 @@ function row(id: string, tenantId = TENANT_A): StaleDraftRow {
19
19
  id,
20
20
  tenantId,
21
21
  ownerId: "owner-1",
22
+ draftKey: "wizard:draft",
22
23
  draft: { values: {}, stepIndex: 0, savedAt: "" },
23
24
  insertedAt: Temporal.Instant.fromEpochMilliseconds(0),
24
25
  };
@@ -63,12 +63,18 @@ async function releaseRowFileRefs(
63
63
  // db/queries/owned-file-refs.ts). Not wrapped in try/catch: a query
64
64
  // failure here (pool exhaustion, missing table) is a real error, not the
65
65
  // "no provider resolvable" case below — let it propagate so the job retries.
66
+ //
67
+ // Create-mode draftKey (`${screenId}:new:${draftId}`) mints its draftId
68
+ // lazily on the first step-change — a file uploaded before that exists
69
+ // predates the draft row, so the insertedAt filter must not apply.
70
+ const isCreateMode = row.draftKey.includes(":new:");
66
71
  const ownedKeys = await filterOwnedStorageKeys(
67
72
  db,
68
73
  row.tenantId,
69
74
  row.ownerId,
70
75
  keys,
71
76
  row.insertedAt,
77
+ isCreateMode,
72
78
  );
73
79
 
74
80
  try {
@@ -53,12 +53,18 @@ export const discardDraftWrite = defineWriteHandler({
53
53
  // releasable — `values` is free-form JSON the caller controls, so an
54
54
  // unverified key could target someone else's file (see
55
55
  // db/queries/owned-file-refs.ts).
56
+ //
57
+ // Create-mode draftKey (`${screenId}:new:${draftId}`) mints its draftId
58
+ // lazily on the first step-change — a file uploaded before that exists
59
+ // predates the draft row, so the insertedAt filter must not apply.
60
+ const isCreateMode = event.payload.draftKey.includes(":new:");
56
61
  const ownedRefs = await filterOwnedFileRefs(
57
62
  ctx.db.raw,
58
63
  event.user.tenantId,
59
64
  ownerId,
60
65
  candidateKeys,
61
66
  existing.insertedAt,
67
+ isCreateMode,
62
68
  );
63
69
  const refsByStorageKey = new Map(ownedRefs.map((ref) => [ref.storageKey, ref]));
64
70
  await releaseDraftFileRefs(
@@ -1,7 +1,12 @@
1
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { failUnprocessable, type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
4
- import { FORM_DRAFT_ACCESS, FORM_DRAFT_UNIQUE_KEY_CONSTRAINT } from "../constants";
4
+ import {
5
+ FORM_DRAFT_ACCESS,
6
+ FORM_DRAFT_MAX_PER_OWNER,
7
+ FORM_DRAFT_UNIQUE_KEY_CONSTRAINT,
8
+ } from "../constants";
9
+ import { countDraftsByOwner } from "../db/queries/draft-count";
5
10
  import { formDraftExecutor } from "../executor";
6
11
  import { lookupDraft } from "../lookup";
7
12
  import { saveDraftPayloadSchema } from "../schemas";
@@ -45,6 +50,14 @@ export const saveDraftWrite = defineWriteHandler({
45
50
  );
46
51
  }
47
52
 
53
+ // Cap check only on the create path — an update never grows the number
54
+ // of drafts this owner has, so a user already at the limit can still
55
+ // keep saving their existing drafts.
56
+ const draftCount = await countDraftsByOwner(ctx.db.raw, event.user.tenantId, ownerId);
57
+ if (draftCount >= FORM_DRAFT_MAX_PER_OWNER) {
58
+ return failUnprocessable("draft_limit_reached", { limit: FORM_DRAFT_MAX_PER_OWNER });
59
+ }
60
+
48
61
  const result = await formDraftExecutor.create({ ownerId, draftKey, draft }, event.user, ctx.db);
49
62
  if (result.isSuccess) return result;
50
63
  if (!isDraftKeyConflict(result)) return result;
@@ -1,8 +1,22 @@
1
1
  import { z } from "zod";
2
- import { FORM_DRAFT_KEY_MAX_LENGTH } from "./constants";
2
+ import { FORM_DRAFT_KEY_MAX_LENGTH, FORM_DRAFT_VALUES_MAX_BYTES } from "./constants";
3
3
 
4
4
  const draftKeySchema = z.string().trim().min(1).max(FORM_DRAFT_KEY_MAX_LENGTH);
5
5
 
6
+ // UTF-8 byte length, not string.length — `values` is arbitrary JSON that can
7
+ // hold multi-byte characters, and the event-store row this ends up in is
8
+ // bounded by bytes, not JS string units.
9
+ function jsonByteLength(value: unknown): number {
10
+ return new TextEncoder().encode(JSON.stringify(value)).length;
11
+ }
12
+
13
+ const draftValuesSchema = z
14
+ .record(z.string(), z.unknown())
15
+ .refine(
16
+ (values) => jsonByteLength(values) <= FORM_DRAFT_VALUES_MAX_BYTES,
17
+ `values exceeds the ${FORM_DRAFT_VALUES_MAX_BYTES}-byte limit`,
18
+ );
19
+
6
20
  // The draft blob shape — fixed by issue #1889 here, not left for consumers
7
21
  // to invent per-app. `stepIndex` is in from the start so wizard-mode resume
8
22
  // (kumiko-framework#1884) doesn't need a later migration.
@@ -17,7 +31,7 @@ export type FormDraftBlob = z.infer<typeof formDraftBlobSchema>;
17
31
  // accepted from the caller.
18
32
  export const saveDraftPayloadSchema = z.object({
19
33
  draftKey: draftKeySchema,
20
- values: z.record(z.string(), z.unknown()),
34
+ values: draftValuesSchema,
21
35
  stepIndex: z.number().int().min(0),
22
36
  });
23
37
  export type SaveDraftPayload = z.infer<typeof saveDraftPayloadSchema>;
@@ -15,6 +15,7 @@ import {
15
15
  type TestDb,
16
16
  type TestRedis,
17
17
  TestUsers,
18
+ testTenantId,
18
19
  unsafePushTables,
19
20
  } from "@cosmicdrift/kumiko-framework/stack";
20
21
  import { sleep } from "@cosmicdrift/kumiko-framework/testing";
@@ -35,6 +36,14 @@ let jobRunner: JobRunner;
35
36
 
36
37
  const systemAdmin = TestUsers.systemAdmin;
37
38
  const normalUser = createTestUser({ id: 2, roles: ["User"] });
39
+ // jobs is platform-wide, cross-tenant-by-design monitoring (fw#2077) — this
40
+ // SystemAdmin lives under a different tenant than `systemAdmin` above, so
41
+ // scenario 6 can prove list/details/retry see runs across tenant boundaries.
42
+ const otherTenantSystemAdmin = createTestUser({
43
+ id: 20,
44
+ tenantId: testTenantId(2),
45
+ roles: ["SystemAdmin"],
46
+ });
38
47
  const JWT_SECRET = "jobs-feature-test-secret-minimum-32-chars!!";
39
48
 
40
49
  // Track job executions
@@ -321,6 +330,54 @@ describe("scenario 5: retry failed job", () => {
321
330
  });
322
331
  });
323
332
 
333
+ // --- Scenario 6: cross-tenant visibility (ctx.systemDb.acknowledgeCrossTenant) ---
334
+
335
+ describe("scenario 6: cross-tenant job monitoring", () => {
336
+ test("SystemAdmin from a different tenant sees runs triggered under another tenant", async () => {
337
+ const triggerResult = await write(systemAdmin, JobHandlers.trigger, {
338
+ jobName: "app:job:sync-data",
339
+ payload: { source: "cross-tenant-check" },
340
+ });
341
+ expect(triggerResult.isSuccess).toBe(true);
342
+ const bullJobId = triggerResult.data.bullJobId;
343
+ await sleep(1000);
344
+
345
+ const listResult = await query(otherTenantSystemAdmin, JobQueries.list, {
346
+ jobName: "app:job:sync-data",
347
+ });
348
+ const run = listResult.data.rows.find(
349
+ (r: Record<string, unknown>) => r["bullJobId"] === bullJobId,
350
+ );
351
+ expect(run).toBeDefined();
352
+
353
+ const detailResult = await query(otherTenantSystemAdmin, JobQueries.details, {
354
+ runId: run.id,
355
+ });
356
+ expect(detailResult.data).not.toBeNull();
357
+ expect(detailResult.data.id).toBe(run.id);
358
+ });
359
+
360
+ test("SystemAdmin from a different tenant can retry a failed run triggered under another tenant", async () => {
361
+ const triggerResult = await write(systemAdmin, JobHandlers.trigger, {
362
+ jobName: "app:job:failing-import",
363
+ });
364
+ expect(triggerResult.isSuccess).toBe(true);
365
+ await sleep(1500);
366
+
367
+ const listResult = await query(otherTenantSystemAdmin, JobQueries.list, {
368
+ jobName: "app:job:failing-import",
369
+ status: "failed",
370
+ });
371
+ const failedRunId = listResult.data.rows[0].id;
372
+
373
+ const retryResult = await write(otherTenantSystemAdmin, JobHandlers.retry, {
374
+ runId: failedRunId,
375
+ });
376
+ expect(retryResult.isSuccess).toBe(true);
377
+ expect(retryResult.data.retriedFromRunId).toBe(failedRunId);
378
+ });
379
+ });
380
+
324
381
  // --- Access control ---
325
382
 
326
383
  describe("access control", () => {
@@ -153,9 +153,18 @@ describe("projection-rebuild job (jobs feature composed)", () => {
153
153
  for (let i = 0; i < 40 && (await getCount()) !== 2; i++) await sleep(200);
154
154
  expect(await getCount()).toBe(2);
155
155
 
156
- const runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
157
- jobName: PROJECTION_REBUILD_JOB,
158
- });
156
+ // getCount()==2 only proves rebuildProjection's own writes landed — the
157
+ // run-completed event (job-run-logger's onJobComplete) is a separate
158
+ // async append that starts only after the handler returns, so it can
159
+ // still be in flight here. Poll status too instead of racing it.
160
+ let runs: readonly { jobName: string; status: string }[] = [];
161
+ for (let i = 0; i < 40; i++) {
162
+ runs = await selectMany<{ jobName: string; status: string }>(db, jobRunsTable, {
163
+ jobName: PROJECTION_REBUILD_JOB,
164
+ });
165
+ if (runs.some((r) => r.status === "completed")) break;
166
+ await sleep(200);
167
+ }
159
168
  expect(runs.length).toBeGreaterThanOrEqual(1);
160
169
  expect(runs.some((r) => r.status === "completed")).toBe(true);
161
170
  }, 30000);
@@ -11,6 +11,7 @@ import {
11
11
  buildEntityTable,
12
12
  createEventStoreExecutor,
13
13
  createTenantDb,
14
+ createUncheckedSystemDb,
14
15
  } from "@cosmicdrift/kumiko-framework/db";
15
16
  import {
16
17
  createEntity,
@@ -18,6 +19,7 @@ import {
18
19
  defineFeature,
19
20
  type JobContext,
20
21
  } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
21
23
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
22
24
  import {
23
25
  setupTestStack,
@@ -97,6 +99,7 @@ describe("reindexEntityJob", () => {
97
99
  registry: stack.registry,
98
100
  searchAdapter: stack.search,
99
101
  systemUser: admin,
102
+ systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
100
103
  log: noopLogger,
101
104
  triggeredBy: null,
102
105
  ...bridgeStub(),
@@ -137,6 +140,7 @@ describe("reindexEntityJob", () => {
137
140
  registry: stack.registry,
138
141
  searchAdapter: stack.search,
139
142
  systemUser: admin,
143
+ systemDb: createUncheckedSystemDb(createTenantDb(stack.db, admin.tenantId, "system")),
140
144
  log: noopLogger,
141
145
  triggeredBy: null,
142
146
  ...bridgeStub(),
@@ -153,7 +157,12 @@ describe("reindexEntityJob", () => {
153
157
  await expect(
154
158
  reindexEntityJob(
155
159
  { entity: "widget" },
156
- { db: stack.db, registry: stack.registry, searchAdapter: stack.search },
160
+ // @cast-boundary test-seam — deliberately no systemUser: proves the no-tenant fan-out-misfire path
161
+ {
162
+ db: stack.db,
163
+ registry: stack.registry,
164
+ searchAdapter: stack.search,
165
+ } as unknown as JobContext,
157
166
  ),
158
167
  ).resolves.toBeUndefined();
159
168
  });
@@ -162,8 +171,44 @@ describe("reindexEntityJob", () => {
162
171
  await expect(
163
172
  reindexEntityJob(
164
173
  { entity: "widget" },
165
- { db: stack.db, registry: stack.registry, systemUser: admin },
174
+ // @cast-boundary test-seam — deliberately no searchAdapter: proves the missing-adapter throw
175
+ { db: stack.db, registry: stack.registry, systemUser: admin } as unknown as JobContext,
166
176
  ),
167
177
  ).rejects.toThrow(/searchAdapter/);
168
178
  });
179
+
180
+ test("throws when ctx.systemDb is missing", async () => {
181
+ await expect(
182
+ reindexEntityJob(
183
+ { entity: "widget" },
184
+ // @cast-boundary test-seam — deliberately no systemDb: proves the missing-systemDb throw
185
+ {
186
+ db: stack.db,
187
+ registry: stack.registry,
188
+ searchAdapter: stack.search,
189
+ systemUser: admin,
190
+ } as unknown as JobContext,
191
+ ),
192
+ ).rejects.toThrow(/systemDb/);
193
+ });
194
+
195
+ // Honest scope: in production job-runner.ts derives both ctx.systemDb's
196
+ // tenantId and ctx.systemUser from the same per-dispatch value, so this
197
+ // mismatch can't occur live — it only proves the self-check is wired to
198
+ // the tenant that was actually resolved, catching future drift between
199
+ // the two, not a real cross-tenant hole.
200
+ test("fails closed when ctx.systemDb is scoped to a different tenant than ctx.systemUser", async () => {
201
+ const otherTenantId = TestUsers.otherTenant.tenantId;
202
+ const ctx: JobContext = {
203
+ db: stack.db,
204
+ registry: stack.registry,
205
+ searchAdapter: stack.search,
206
+ systemUser: admin,
207
+ systemDb: createUncheckedSystemDb(createTenantDb(stack.db, otherTenantId, "system")),
208
+ log: noopLogger,
209
+ triggeredBy: null,
210
+ ...bridgeStub(),
211
+ };
212
+ await expect(reindexEntityJob({ entity: "widget" }, ctx)).rejects.toThrow(AccessDeniedError);
213
+ });
169
214
  });
@@ -1,5 +1,6 @@
1
1
  import { fetchOne, 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 { decryptStoredPii } from "../../shared";
5
6
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
@@ -13,7 +14,12 @@ export const detailQuery = defineQueryHandler({
13
14
  schema: z.object({ runId: z.uuid() }),
14
15
  access: { roles: ["SystemAdmin"] },
15
16
  handler: async (query, ctx) => {
16
- const db = ctx.db;
17
+ if (!ctx.systemDb) {
18
+ throw new InternalError({
19
+ message: "jobs:query:details requires ctx.systemDb (feature must declare r.systemScope())",
20
+ });
21
+ }
22
+ const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
17
23
 
18
24
  const row = await fetchOne(db, jobRunsTable, { id: query.payload.runId });
19
25
 
@@ -1,5 +1,6 @@
1
1
  import { selectMany, type WhereObject } 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 { decryptStoredPii } from "../../shared";
5
6
  import { jobRunsTable } from "../job-run-table";
@@ -18,10 +19,16 @@ export const listQuery = defineQueryHandler({
18
19
  }),
19
20
  access: { roles: ["SystemAdmin"] },
20
21
  handler: async (query, ctx) => {
22
+ if (!ctx.systemDb) {
23
+ throw new InternalError({
24
+ message: "jobs:query:list requires ctx.systemDb (feature must declare r.systemScope())",
25
+ });
26
+ }
27
+ const db = ctx.systemDb.acknowledgeCrossTenant("cross-tenant job monitoring");
21
28
  const where: WhereObject = {};
22
29
  if (query.payload.jobName) where["jobName"] = query.payload.jobName;
23
30
  if (query.payload.status) where["status"] = query.payload.status;
24
- const rows = await selectMany(ctx.db, jobRunsTable, where, {
31
+ const rows = await selectMany(db, jobRunsTable, where, {
25
32
  orderBy: { col: "id", direction: "desc" },
26
33
  limit: query.payload.limit ?? 50,
27
34
  });
@@ -15,10 +15,10 @@ export const projectionRebuildPayloadSchema = z.object({
15
15
 
16
16
  export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
17
17
  const { projection, skipApplyErrors } = projectionRebuildPayloadSchema.parse(rawPayload);
18
- if (!ctx.db) {
18
+ if (!ctx.systemDb) {
19
19
  throw new InternalError({
20
20
  message:
21
- "[jobs:projection-rebuild] ctx.db missing — job context requires a database connection.",
21
+ "[jobs:projection-rebuild] ctx.systemDb missing — is r.systemScope() still set on the jobs feature?",
22
22
  });
23
23
  }
24
24
  if (!ctx.registry) {
@@ -27,7 +27,10 @@ export const projectionRebuildJob: JobHandlerFn = async (rawPayload, ctx): Promi
27
27
  "[jobs:projection-rebuild] ctx.registry missing — job context requires the registry.",
28
28
  });
29
29
  }
30
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
30
+ // Global rebuild by design (streams every tenant's events, not just one) —
31
+ // acknowledgeCrossTenant documents that instead of the previous implicit
32
+ // ctx.db cast. .raw is the same DbConnection ctx.db carried before.
33
+ const db = ctx.systemDb.acknowledgeCrossTenant("global projection rebuild").raw as DbConnection; // @cast-boundary db-operator
31
34
  const result = await rebuildProjection(projection, {
32
35
  db,
33
36
  registry: ctx.registry,
@@ -4,7 +4,6 @@
4
4
  // just cron: one `jobs:write:trigger` call with { entity } re-runs once per
5
5
  // active tenant.
6
6
 
7
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
7
  import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
9
8
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
9
  import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
@@ -16,11 +15,6 @@ export const reindexEntityPayloadSchema = z.object({
16
15
 
17
16
  export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
18
17
  const { entity } = reindexEntityPayloadSchema.parse(rawPayload);
19
- if (!ctx.db) {
20
- throw new InternalError({
21
- message: "[jobs:reindex-entity] ctx.db missing — job context requires a database connection.",
22
- });
23
- }
24
18
  if (!ctx.registry) {
25
19
  throw new InternalError({
26
20
  message: "[jobs:reindex-entity] ctx.registry missing — job context requires the registry.",
@@ -33,14 +27,24 @@ export const reindexEntityJob: JobHandlerFn = async (rawPayload, ctx): Promise<v
33
27
  });
34
28
  }
35
29
  // perTenant fan-out — the job-runner enqueues one child run per active
36
- // tenant with _tenantId set; a run without one (e.g. misconfigured
37
- // manual dispatch outside the fan-out) has nothing scoped to reindex.
38
- const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
30
+ // tenant with ctx.systemUser scoped to it; a run without one (e.g.
31
+ // misconfigured manual dispatch outside the fan-out) has nothing scoped
32
+ // to reindex. ctx._tenantId is never populated per-dispatch (only
33
+ // ctx.systemUser is), so it is not a valid fallback here.
34
+ const tenantId = ctx.systemUser?.tenantId;
39
35
  if (tenantId === undefined) {
40
36
  // skip: fired without a perTenant fan-out tenant — nothing scoped to reindex
41
37
  return;
42
38
  }
43
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
39
+ if (!ctx.systemDb) {
40
+ throw new InternalError({
41
+ message:
42
+ "[jobs:reindex-entity] ctx.systemDb missing — the owning feature must declare r.systemScope().",
43
+ });
44
+ }
45
+ // .raw bypasses tenant filtering, safe here only because reindexEntity
46
+ // filters `WHERE tenant_id = $1` itself with this same tenantId.
47
+ const db = ctx.systemDb.assertTenantMatch(tenantId).raw;
44
48
  const result = await reindexEntity(db, ctx.registry, ctx.searchAdapter, entity, tenantId);
45
49
  ctx.log?.info?.(
46
50
  `[jobs:reindex-entity] tenant=${tenantId} reindexed ${entity}: ${result.indexedRows}/${result.scannedRows} rows indexed` +