@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 +3 -3
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +105 -0
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +68 -3
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.274.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.274.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.274.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEntityCreateHandler,
|
|
3
|
+
defineEntityListHandler,
|
|
4
|
+
defineEntityRestoreHandler,
|
|
5
|
+
registerEntityCrud,
|
|
6
|
+
} from "../../../engine/entity-handlers";
|
|
7
|
+
import { legacyReportEntity } from "./legacy-report.entity";
|
|
8
|
+
import { widgetEntity } from "./widget.entity";
|
|
9
|
+
|
|
10
|
+
const access = { roles: ["SystemAdmin"] };
|
|
11
|
+
const someFlag = process.env.WIDGET_CROSS_TENANT === "true";
|
|
12
|
+
|
|
13
|
+
const sysadminAccess = { access, crossTenant: true };
|
|
14
|
+
|
|
15
|
+
export function registerWidget(r) {
|
|
16
|
+
r.writeHandler(defineEntityCreateHandler("widget", widgetEntity, { ...sysadminAccess }));
|
|
17
|
+
|
|
18
|
+
registerEntityCrud(r, "widget", widgetEntity, {
|
|
19
|
+
write: { access, crossTenant: true },
|
|
20
|
+
read: { access },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
r.writeHandler(
|
|
24
|
+
defineEntityRestoreHandler("widget", widgetEntity, {
|
|
25
|
+
access,
|
|
26
|
+
crossTenant: true,
|
|
27
|
+
escapeHatch: { reason: "legacy grant, needs review" },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
r.queryHandler(
|
|
32
|
+
defineEntityListHandler("legacy-report", legacyReportEntity, {
|
|
33
|
+
access,
|
|
34
|
+
crossTenant: someFlag,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
r.queryHandler(
|
|
39
|
+
defineEntityListHandler("widget-summary", widgetEntity, {
|
|
40
|
+
access,
|
|
41
|
+
crossTenant: false,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEntityCreateHandler,
|
|
3
|
+
defineEntityListHandler,
|
|
4
|
+
defineEntityRestoreHandler,
|
|
5
|
+
registerEntityCrud,
|
|
6
|
+
} from "../../../engine/entity-handlers";
|
|
7
|
+
import { legacyReportEntity } from "./legacy-report.entity";
|
|
8
|
+
import { widgetEntity } from "./widget.entity";
|
|
9
|
+
|
|
10
|
+
const access = { roles: ["SystemAdmin"] };
|
|
11
|
+
const someFlag = process.env.WIDGET_CROSS_TENANT === "true";
|
|
12
|
+
|
|
13
|
+
const sysadminAccess = { access, crossTenant: true };
|
|
14
|
+
|
|
15
|
+
export function registerWidget(r) {
|
|
16
|
+
r.writeHandler(defineEntityCreateHandler("widget", widgetEntity, { ...sysadminAccess }));
|
|
17
|
+
|
|
18
|
+
registerEntityCrud(r, "widget", widgetEntity, {
|
|
19
|
+
write: { access, crossTenant: true },
|
|
20
|
+
read: { access },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
r.writeHandler(
|
|
24
|
+
defineEntityRestoreHandler("widget", widgetEntity, {
|
|
25
|
+
access,
|
|
26
|
+
crossTenant: true,
|
|
27
|
+
escapeHatch: { reason: "legacy grant, needs review" },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
r.queryHandler(
|
|
32
|
+
defineEntityListHandler("legacy-report", legacyReportEntity, {
|
|
33
|
+
access,
|
|
34
|
+
crossTenant: someFlag,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
r.queryHandler(
|
|
39
|
+
defineEntityListHandler("widget-summary", widgetEntity, {
|
|
40
|
+
access,
|
|
41
|
+
crossTenant: false,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEntityDetailHandler,
|
|
3
|
+
defineEntityListHandler,
|
|
4
|
+
defineEntityUpdateHandler,
|
|
5
|
+
defineEntityWriteHandler,
|
|
6
|
+
} from "../../../engine/entity-handlers";
|
|
7
|
+
import { exportJobEntity } from "./export-job.entity";
|
|
8
|
+
import { noteEntity } from "./note.entity";
|
|
9
|
+
|
|
10
|
+
const access = { roles: ["SystemAdmin"] };
|
|
11
|
+
|
|
12
|
+
export function registerExportJob(r) {
|
|
13
|
+
r.queryHandler(
|
|
14
|
+
defineEntityListHandler("export-job", exportJobEntity, {
|
|
15
|
+
access,
|
|
16
|
+
escapeHatch: { reason: "export-job:list reads export-job rows across every tenant (migrated from crossTenant: true; state the operator use case here)" },
|
|
17
|
+
description: "Lists export jobs across every tenant for operator support",
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
r.writeHandler(
|
|
22
|
+
defineEntityUpdateHandler("export-job", exportJobEntity, {
|
|
23
|
+
access,
|
|
24
|
+
escapeHatch: { reason: "export-job:update writes export-job rows across every tenant (migrated from crossTenant: true; state the operator use case here)" },
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
r.writeHandler(
|
|
29
|
+
defineEntityWriteHandler("note:delete", noteEntity, {
|
|
30
|
+
access,
|
|
31
|
+
escapeHatch: { reason: "note:delete writes note rows across every tenant (migrated from crossTenant: true; state the operator use case here)" },
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
r.queryHandler(
|
|
36
|
+
defineEntityDetailHandler("export-job", exportJobEntity, {
|
|
37
|
+
access,
|
|
38
|
+
escapeHatch: { reason: "export-job:detail reads export-job rows across every tenant (migrated from crossTenant: true; state the operator use case here)" },
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEntityDetailHandler,
|
|
3
|
+
defineEntityListHandler,
|
|
4
|
+
defineEntityUpdateHandler,
|
|
5
|
+
defineEntityWriteHandler,
|
|
6
|
+
} from "../../../engine/entity-handlers";
|
|
7
|
+
import { exportJobEntity } from "./export-job.entity";
|
|
8
|
+
import { noteEntity } from "./note.entity";
|
|
9
|
+
|
|
10
|
+
const access = { roles: ["SystemAdmin"] };
|
|
11
|
+
|
|
12
|
+
export function registerExportJob(r) {
|
|
13
|
+
r.queryHandler(
|
|
14
|
+
defineEntityListHandler("export-job", exportJobEntity, {
|
|
15
|
+
access,
|
|
16
|
+
crossTenant: true,
|
|
17
|
+
description: "Lists export jobs across every tenant for operator support",
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
r.writeHandler(
|
|
22
|
+
defineEntityUpdateHandler("export-job", exportJobEntity, {
|
|
23
|
+
access,
|
|
24
|
+
crossTenant: true,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
r.writeHandler(
|
|
29
|
+
defineEntityWriteHandler("note:delete", noteEntity, {
|
|
30
|
+
access,
|
|
31
|
+
crossTenant: true,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
r.queryHandler(
|
|
36
|
+
defineEntityDetailHandler("export-job", exportJobEntity, {
|
|
37
|
+
access,
|
|
38
|
+
crossTenant: true,
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { migrateCrossTenantSource } from "../scripts/codemod/migrate-cross-tenant";
|
|
5
|
+
|
|
6
|
+
const FIXTURES_DIR = join(import.meta.dir, "fixtures", "migrate-cross-tenant");
|
|
7
|
+
const PLACEHOLDER_PREFIXES = ["todo:", "fixme:", "tbd:"];
|
|
8
|
+
|
|
9
|
+
function readFixture(name: string): { input: string; expected: string } {
|
|
10
|
+
return {
|
|
11
|
+
input: readFileSync(join(FIXTURES_DIR, `${name}.input.ts`), "utf8"),
|
|
12
|
+
expected: readFileSync(join(FIXTURES_DIR, `${name}.expected.ts`), "utf8"),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("migrateCrossTenantSource", () => {
|
|
17
|
+
it("rewrites crossTenant: true to escapeHatch: { reason } for every recognized entity-handler call shape", () => {
|
|
18
|
+
const { input, expected } = readFixture("rewrite");
|
|
19
|
+
const result = migrateCrossTenantSource(input, "export-job.handlers.ts");
|
|
20
|
+
|
|
21
|
+
expect(result.output).toBe(expected);
|
|
22
|
+
expect(result.rewrites).toHaveLength(4);
|
|
23
|
+
expect(result.manual).toHaveLength(0);
|
|
24
|
+
|
|
25
|
+
const byLine = new Map(result.rewrites.map((r) => [r.line, r]));
|
|
26
|
+
|
|
27
|
+
expect(byLine.get(16)?.after).toBe(
|
|
28
|
+
'escapeHatch: { reason: "export-job:list reads export-job rows across every tenant (migrated from crossTenant: true; state the operator use case here)" }',
|
|
29
|
+
);
|
|
30
|
+
expect(byLine.get(24)?.after).toBe(
|
|
31
|
+
'escapeHatch: { reason: "export-job:update writes export-job rows across every tenant (migrated from crossTenant: true; state the operator use case here)" }',
|
|
32
|
+
);
|
|
33
|
+
expect(byLine.get(31)?.after).toBe(
|
|
34
|
+
'escapeHatch: { reason: "note:delete writes note rows across every tenant (migrated from crossTenant: true; state the operator use case here)" }',
|
|
35
|
+
);
|
|
36
|
+
expect(byLine.get(38)?.after).toBe(
|
|
37
|
+
'escapeHatch: { reason: "export-job:detail reads export-job rows across every tenant (migrated from crossTenant: true; state the operator use case here)" }',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("derives handlerName from the call for both the verb-in-name and verb-in-arg factory shapes", () => {
|
|
42
|
+
const { input } = readFixture("rewrite");
|
|
43
|
+
const result = migrateCrossTenantSource(input, "export-job.handlers.ts");
|
|
44
|
+
|
|
45
|
+
for (const rewrite of result.rewrites) {
|
|
46
|
+
expect(rewrite.after).toMatch(/^escapeHatch: \{ reason: "/);
|
|
47
|
+
}
|
|
48
|
+
expect(result.rewrites.map((r) => r.line)).toEqual([16, 24, 31, 38]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves a spread/shared access object, a registerEntityCrud write/read block, an already-declared escapeHatch, and a non-literal crossTenant for manual review", () => {
|
|
52
|
+
const { input, expected } = readFixture("manual-only");
|
|
53
|
+
const result = migrateCrossTenantSource(input, "widget.handlers.ts");
|
|
54
|
+
|
|
55
|
+
expect(result.output).toBe(expected);
|
|
56
|
+
expect(result.rewrites).toHaveLength(0);
|
|
57
|
+
expect(result.manual).toHaveLength(4);
|
|
58
|
+
|
|
59
|
+
const byLine = new Map(result.manual.map((m) => [m.line, m]));
|
|
60
|
+
|
|
61
|
+
expect(byLine.get(13)?.why).toBe(
|
|
62
|
+
"crossTenant is not set directly in a handler call (e.g. a spread/shared object) — give each consuming handler its own escapeHatch reason",
|
|
63
|
+
);
|
|
64
|
+
expect(byLine.get(13)?.handlerName).toBeUndefined();
|
|
65
|
+
|
|
66
|
+
expect(byLine.get(19)?.why).toBe(
|
|
67
|
+
'under registerEntityCrud("widget", ...) → write: multiple verbs share this crossTenant — set escapeHatch per verb manually, one reason each',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(byLine.get(26)?.why).toBe(
|
|
71
|
+
"this object already declares escapeHatch — merge the two manually and remove crossTenant",
|
|
72
|
+
);
|
|
73
|
+
expect(byLine.get(26)?.handlerName).toBe("widget");
|
|
74
|
+
|
|
75
|
+
expect(byLine.get(34)?.why).toBe(
|
|
76
|
+
"crossTenant is not a literal boolean (`someFlag`) — replace manually with escapeHatch: { reason }",
|
|
77
|
+
);
|
|
78
|
+
expect(byLine.get(34)?.handlerName).toBe("legacy-report");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("ignores crossTenant: false — no rewrite, no manual entry, output unchanged", () => {
|
|
82
|
+
const { input } = readFixture("manual-only");
|
|
83
|
+
const result = migrateCrossTenantSource(input, "widget.handlers.ts");
|
|
84
|
+
|
|
85
|
+
expect(input).toContain("crossTenant: false");
|
|
86
|
+
expect(result.manual.some((m) => m.line === 41)).toBe(false);
|
|
87
|
+
expect(result.rewrites.some((r) => r.line === 41)).toBe(false);
|
|
88
|
+
expect(result.output).toContain("crossTenant: false");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("never generates a placeholder reason and never leaves a rewritten reason empty", () => {
|
|
92
|
+
const { input } = readFixture("rewrite");
|
|
93
|
+
const result = migrateCrossTenantSource(input, "export-job.handlers.ts");
|
|
94
|
+
|
|
95
|
+
for (const rewrite of result.rewrites) {
|
|
96
|
+
const match = rewrite.after.match(/reason: "([^"]*)"/);
|
|
97
|
+
expect(match).not.toBeNull();
|
|
98
|
+
const reason = match?.[1] ?? "";
|
|
99
|
+
expect(reason.length).toBeGreaterThan(0);
|
|
100
|
+
const lower = reason.toLowerCase();
|
|
101
|
+
for (const prefix of PLACEHOLDER_PREFIXES) {
|
|
102
|
+
expect(lower.startsWith(prefix)).toBe(false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// A relatedList section with `parentFilter` sends the parent id as
|
|
2
|
+
// `payload.filter: { field, op: "eq", value: parentId }` instead of a
|
|
3
|
+
// bespoke top-level key (related-list-section.tsx) — proving it against the
|
|
4
|
+
// generic `<entity>:list` query end-to-end means proving `filter` and
|
|
5
|
+
// `search` combine correctly in the real executor, over real HTTP, exactly
|
|
6
|
+
// as the renderer's own request would look.
|
|
7
|
+
//
|
|
8
|
+
// Bun.SQL-only setup via setupTestStack.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
createEntity,
|
|
13
|
+
createTextField,
|
|
14
|
+
defineEntityCreateHandler,
|
|
15
|
+
defineEntityListHandler,
|
|
16
|
+
defineFeature,
|
|
17
|
+
} from "../engine";
|
|
18
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../stack";
|
|
19
|
+
|
|
20
|
+
const itemEntity = createEntity({
|
|
21
|
+
table: "akte_items",
|
|
22
|
+
fields: {
|
|
23
|
+
name: createTextField({
|
|
24
|
+
personal: false,
|
|
25
|
+
reason: "test_fixture",
|
|
26
|
+
required: true,
|
|
27
|
+
searchable: true,
|
|
28
|
+
}),
|
|
29
|
+
orderId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const ordersFeature = defineFeature("akteorders", (r) => {
|
|
34
|
+
r.entity("item", itemEntity);
|
|
35
|
+
r.writeHandler(defineEntityCreateHandler("item", itemEntity, { access: { roles: ["Admin"] } }));
|
|
36
|
+
r.queryHandler(defineEntityListHandler("item", itemEntity, { access: { roles: ["Admin"] } }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("relatedList parentFilter against the generic <entity>:list query", () => {
|
|
40
|
+
let stack: TestStack;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
stack = await setupTestStack({ features: [ordersFeature] });
|
|
44
|
+
await unsafeCreateEntityTable(stack.db, itemEntity);
|
|
45
|
+
|
|
46
|
+
await stack.http.writeOk(
|
|
47
|
+
"akteorders:write:item:create",
|
|
48
|
+
{ name: "Alice Corp", orderId: "order-1" },
|
|
49
|
+
TestUsers.admin,
|
|
50
|
+
);
|
|
51
|
+
await stack.http.writeOk(
|
|
52
|
+
"akteorders:write:item:create",
|
|
53
|
+
{ name: "Bob LLC", orderId: "order-1" },
|
|
54
|
+
TestUsers.admin,
|
|
55
|
+
);
|
|
56
|
+
await stack.http.writeOk(
|
|
57
|
+
"akteorders:write:item:create",
|
|
58
|
+
{ name: "Alice Two", orderId: "order-2" },
|
|
59
|
+
TestUsers.admin,
|
|
60
|
+
);
|
|
61
|
+
// Search indexing is async — drain the event dispatcher before any
|
|
62
|
+
// query below relies on `search` finding these rows.
|
|
63
|
+
await stack.eventDispatcher?.runOnce();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(() => stack.cleanup());
|
|
67
|
+
|
|
68
|
+
test("filter: eq on the parent id returns only that parent's rows", async () => {
|
|
69
|
+
const res = await stack.http.query(
|
|
70
|
+
"akteorders:query:item:list",
|
|
71
|
+
{ filter: { field: "orderId", op: "eq", value: "order-1" } },
|
|
72
|
+
TestUsers.admin,
|
|
73
|
+
);
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = (await res.json()) as { data: { rows: Array<Record<string, unknown>> } };
|
|
76
|
+
const names = body.data.rows.map((r) => r["name"]).sort();
|
|
77
|
+
expect(names).toEqual(["Alice Corp", "Bob LLC"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("filter combined with search narrows within the parent's own rows only", async () => {
|
|
81
|
+
const res = await stack.http.query(
|
|
82
|
+
"akteorders:query:item:list",
|
|
83
|
+
{ filter: { field: "orderId", op: "eq", value: "order-1" }, search: "alice" },
|
|
84
|
+
TestUsers.admin,
|
|
85
|
+
);
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
const body = (await res.json()) as { data: { rows: Array<Record<string, unknown>> } };
|
|
88
|
+
// "Alice Two" matches the search term but belongs to order-2 — the
|
|
89
|
+
// parent filter must exclude it even though search alone would find it.
|
|
90
|
+
expect(body.data.rows.map((r) => r["name"])).toEqual(["Alice Corp"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("search alone (no filter) would find both Alice rows across parents — proves the filter is what narrows them", async () => {
|
|
94
|
+
const res = await stack.http.query(
|
|
95
|
+
"akteorders:query:item:list",
|
|
96
|
+
{ search: "alice" },
|
|
97
|
+
TestUsers.admin,
|
|
98
|
+
);
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
const body = (await res.json()) as { data: { rows: Array<Record<string, unknown>> } };
|
|
101
|
+
expect(body.data.rows.map((r) => r["name"]).sort()).toEqual(["Alice Corp", "Alice Two"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// fw#2861 — nested-write parent-row ownership check. A custom (non-executor)
|
|
2
|
+
// `:create` handler can hand back an EXISTING row instead of inserting a
|
|
3
|
+
// fresh one (find-or-create, or a bug) — the framework must refuse to attach
|
|
4
|
+
// nested children to a parent row the caller does not own, both across
|
|
5
|
+
// tenants (role-only entity, no ownership field) and within a tenant (an
|
|
6
|
+
// ownership-bound field pointing at a different owner). Modelled on
|
|
7
|
+
// nested-write.integration.test.ts's project/task fixture. Real HTTP via
|
|
8
|
+
// setupTestStack + stack.http.write — never createTestDispatcher.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
13
|
+
import { executeRawQuery } from "../../db/queries/raw-sql";
|
|
14
|
+
import { asRawClient, selectMany } from "../../db/query";
|
|
15
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
16
|
+
import { createEntity, createTextField, defineFeature, from } from "../../engine";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
testTenantId,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
} from "../../stack";
|
|
24
|
+
|
|
25
|
+
// project2/task2: same-tenant ownership scenario (b/c). ownerId is an
|
|
26
|
+
// ownership-bound field — checkWriteFieldOwnership evaluates it.
|
|
27
|
+
const project2Entity = createEntity({
|
|
28
|
+
table: "nested_own_projects2",
|
|
29
|
+
fields: {
|
|
30
|
+
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
31
|
+
ownerId: createTextField({
|
|
32
|
+
personal: false,
|
|
33
|
+
reason: "test_fixture",
|
|
34
|
+
required: true,
|
|
35
|
+
access: { write: { User: from("user:id", "ownerId") } },
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const project2Table = buildEntityTable("project2", project2Entity);
|
|
40
|
+
|
|
41
|
+
const task2Entity = createEntity({
|
|
42
|
+
table: "nested_own_tasks2",
|
|
43
|
+
fields: {
|
|
44
|
+
projectId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
45
|
+
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const task2Table = buildEntityTable("task2", task2Entity);
|
|
49
|
+
|
|
50
|
+
// project3/task3: cross-tenant scenario (a). Role-only entity, no ownership
|
|
51
|
+
// field — checkWriteFieldOwnership is a no-op here; only the tenant check
|
|
52
|
+
// can catch it.
|
|
53
|
+
const project3Entity = createEntity({
|
|
54
|
+
table: "nested_own_projects3",
|
|
55
|
+
fields: {
|
|
56
|
+
name: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const project3Table = buildEntityTable("project3", project3Entity);
|
|
60
|
+
|
|
61
|
+
const task3Entity = createEntity({
|
|
62
|
+
table: "nested_own_tasks3",
|
|
63
|
+
fields: {
|
|
64
|
+
projectId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
65
|
+
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const task3Table = buildEntityTable("task3", task3Entity);
|
|
69
|
+
|
|
70
|
+
const nestedOwnershipFeature = defineFeature("nested-own", (r) => {
|
|
71
|
+
r.entity("project2", project2Entity);
|
|
72
|
+
r.entity("task2", task2Entity);
|
|
73
|
+
r.relation("project2", "tasks", {
|
|
74
|
+
type: "hasMany",
|
|
75
|
+
target: "task2",
|
|
76
|
+
foreignKey: "projectId",
|
|
77
|
+
nestedWrite: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
r.entity("project3", project3Entity);
|
|
81
|
+
r.entity("task3", task3Entity);
|
|
82
|
+
r.relation("project3", "tasks", {
|
|
83
|
+
type: "hasMany",
|
|
84
|
+
target: "task3",
|
|
85
|
+
foreignKey: "projectId",
|
|
86
|
+
nestedWrite: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Custom (non-executor-entry) find-or-create: same-tenant only (tenant-
|
|
90
|
+
// scoped fetchOne), simulating a handler that doesn't strictly insert a
|
|
91
|
+
// fresh row every time — only the "found" branch skips the executor.
|
|
92
|
+
// No escapeHatch needed — the lookup stays within the caller's own tenant.
|
|
93
|
+
r.writeHandler(
|
|
94
|
+
"project2:create",
|
|
95
|
+
z.object({ name: z.string().min(1), ownerId: z.string().uuid().optional() }),
|
|
96
|
+
async (event, ctx) => {
|
|
97
|
+
const existing = await ctx.db.fetchOne(project2Table, { name: event.payload.name });
|
|
98
|
+
if (existing) return { isSuccess: true as const, data: existing };
|
|
99
|
+
const crud = createEventStoreExecutor(project2Table, project2Entity, {
|
|
100
|
+
entityName: "project2",
|
|
101
|
+
});
|
|
102
|
+
return crud.create(
|
|
103
|
+
{ name: event.payload.name, ownerId: event.payload.ownerId ?? event.user.id },
|
|
104
|
+
event.user,
|
|
105
|
+
ctx.db,
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
r.writeHandler(
|
|
112
|
+
"task2:create",
|
|
113
|
+
z.object({ projectId: z.string().uuid(), title: z.string().min(1) }),
|
|
114
|
+
async (event, ctx) => {
|
|
115
|
+
const crud = createEventStoreExecutor(task2Table, task2Entity, { entityName: "task2" });
|
|
116
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
117
|
+
},
|
|
118
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Custom find-or-create for the cross-tenant scenario: an unfiltered raw
|
|
122
|
+
// lookup by name (declared escapeHatch) simulates a handler bug that finds
|
|
123
|
+
// a same-named row belonging to a DIFFERENT tenant and hands it back
|
|
124
|
+
// instead of inserting a fresh, tenant-scoped row.
|
|
125
|
+
r.writeHandler(
|
|
126
|
+
"project3:create",
|
|
127
|
+
z.object({ name: z.string().min(1) }),
|
|
128
|
+
async (event, ctx) => {
|
|
129
|
+
const runner = ctx.db.unsafeRaw(
|
|
130
|
+
"fw#2861 test fixture — simulate cross-tenant find-or-create bug",
|
|
131
|
+
);
|
|
132
|
+
const rows = await executeRawQuery<{
|
|
133
|
+
id: string;
|
|
134
|
+
name: string;
|
|
135
|
+
tenant_id: string;
|
|
136
|
+
}>(
|
|
137
|
+
runner,
|
|
138
|
+
`SELECT id, name, tenant_id FROM "${project3Table.tableName}" WHERE name = $1 LIMIT 1`,
|
|
139
|
+
[event.payload.name],
|
|
140
|
+
);
|
|
141
|
+
const existing = rows[0];
|
|
142
|
+
if (existing) {
|
|
143
|
+
return {
|
|
144
|
+
isSuccess: true as const,
|
|
145
|
+
data: { id: existing.id, name: existing.name, tenantId: existing.tenant_id },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const crud = createEventStoreExecutor(project3Table, project3Entity, {
|
|
149
|
+
entityName: "project3",
|
|
150
|
+
});
|
|
151
|
+
return crud.create({ name: event.payload.name }, event.user, ctx.db);
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
access: { roles: ["Admin", "User"] },
|
|
155
|
+
escapeHatch: { reason: "fw#2861 test fixture — simulate cross-tenant find-or-create bug" },
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
r.writeHandler(
|
|
160
|
+
"task3:create",
|
|
161
|
+
z.object({ projectId: z.string().uuid(), title: z.string().min(1) }),
|
|
162
|
+
async (event, ctx) => {
|
|
163
|
+
const crud = createEventStoreExecutor(task3Table, task3Entity, { entityName: "task3" });
|
|
164
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
165
|
+
},
|
|
166
|
+
{ access: { roles: ["Admin", "User"] } },
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
let stack: TestStack;
|
|
171
|
+
const TENANT_A = testTenantId(9101);
|
|
172
|
+
const TENANT_B = testTenantId(9102);
|
|
173
|
+
const ownerUser = createTestUser({ id: 9111, tenantId: TENANT_A, roles: ["User"] });
|
|
174
|
+
const attackerUser = createTestUser({ id: 9112, tenantId: TENANT_A, roles: ["User"] });
|
|
175
|
+
const tenantAOtherUser = createTestUser({ id: 9114, tenantId: TENANT_A, roles: ["User"] });
|
|
176
|
+
const tenantBUser = createTestUser({ id: 9113, tenantId: TENANT_B, roles: ["User"] });
|
|
177
|
+
|
|
178
|
+
beforeAll(async () => {
|
|
179
|
+
stack = await setupTestStack({ features: [nestedOwnershipFeature] });
|
|
180
|
+
await unsafeCreateEntityTable(stack.db, project2Entity, "project2");
|
|
181
|
+
await unsafeCreateEntityTable(stack.db, task2Entity, "task2");
|
|
182
|
+
await unsafeCreateEntityTable(stack.db, project3Entity, "project3");
|
|
183
|
+
await unsafeCreateEntityTable(stack.db, task3Entity, "task3");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterAll(async () => {
|
|
187
|
+
await stack.cleanup();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
beforeEach(async () => {
|
|
191
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${task2Table.tableName}"`);
|
|
192
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${task3Table.tableName}"`);
|
|
193
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${project2Table.tableName}"`);
|
|
194
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${project3Table.tableName}"`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("nested-write parent-row ownership check (fw#2861)", () => {
|
|
198
|
+
test("(a) cross-tenant: existing role-only parent row in tenant B, tenant-A caller -> access_denied, zero task rows", async () => {
|
|
199
|
+
// Seed the foreign row in tenant B.
|
|
200
|
+
const seedRes = await stack.http.write(
|
|
201
|
+
"nested-own:write:project3:create",
|
|
202
|
+
{ name: "cross-tenant" },
|
|
203
|
+
tenantBUser,
|
|
204
|
+
);
|
|
205
|
+
expect(seedRes.status).toBe(200);
|
|
206
|
+
|
|
207
|
+
const res = await stack.http.write(
|
|
208
|
+
"nested-own:write:project3:create",
|
|
209
|
+
{ name: "cross-tenant", tasks: [{ title: "t1" }] },
|
|
210
|
+
tenantAOtherUser,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
214
|
+
expect(res.status).toBeLessThan(500);
|
|
215
|
+
const body = await res.json();
|
|
216
|
+
expect(body.isSuccess).toBe(false);
|
|
217
|
+
expect(body.error.code).toBe("access_denied");
|
|
218
|
+
|
|
219
|
+
const dbTasks = await selectMany(stack.db, task3Table);
|
|
220
|
+
expect(dbTasks).toHaveLength(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("(b) same tenant, ownership-bound field, row owned by a different user -> access_denied, zero task rows", async () => {
|
|
224
|
+
const seedRes = await stack.http.write(
|
|
225
|
+
"nested-own:write:project2:create",
|
|
226
|
+
{ name: "owned-by-other" },
|
|
227
|
+
ownerUser,
|
|
228
|
+
);
|
|
229
|
+
expect(seedRes.status).toBe(200);
|
|
230
|
+
|
|
231
|
+
const res = await stack.http.write(
|
|
232
|
+
"nested-own:write:project2:create",
|
|
233
|
+
{ name: "owned-by-other", tasks: [{ title: "t1" }] },
|
|
234
|
+
attackerUser,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
238
|
+
expect(res.status).toBeLessThan(500);
|
|
239
|
+
const body = await res.json();
|
|
240
|
+
expect(body.isSuccess).toBe(false);
|
|
241
|
+
expect(body.error.code).toBe("access_denied");
|
|
242
|
+
|
|
243
|
+
const dbTasks = await selectMany(stack.db, task2Table);
|
|
244
|
+
expect(dbTasks).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("(c) caller owns the existing same-tenant row -> children attach (regression)", async () => {
|
|
248
|
+
const seedRes = await stack.http.write(
|
|
249
|
+
"nested-own:write:project2:create",
|
|
250
|
+
{ name: "owned-by-self" },
|
|
251
|
+
ownerUser,
|
|
252
|
+
);
|
|
253
|
+
expect(seedRes.status).toBe(200);
|
|
254
|
+
|
|
255
|
+
const res = await stack.http.write(
|
|
256
|
+
"nested-own:write:project2:create",
|
|
257
|
+
{ name: "owned-by-self", tasks: [{ title: "t1" }] },
|
|
258
|
+
ownerUser,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(res.status).toBe(200);
|
|
262
|
+
const body = await res.json();
|
|
263
|
+
expect(body.isSuccess).toBe(true);
|
|
264
|
+
|
|
265
|
+
const dbTasks = await selectMany(stack.db, task2Table);
|
|
266
|
+
expect(dbTasks).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
});
|