@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.
- package/package.json +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- 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-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -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/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- 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/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- 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 +47 -2
- 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/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- 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
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- 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(
|
|
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
|
}
|
|
@@ -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
|
|
2
|
+
import { failUnprocessable, type WriteFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
4
|
-
import {
|
|
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:
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
18
|
+
if (!ctx.systemDb) {
|
|
19
19
|
throw new InternalError({
|
|
20
20
|
message:
|
|
21
|
-
"[jobs:projection-rebuild] ctx.
|
|
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
|
-
|
|
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
|
|
37
|
-
// manual dispatch outside the fan-out) has nothing scoped
|
|
38
|
-
|
|
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
|
-
|
|
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` +
|