@cosmicdrift/kumiko-framework 0.289.0 → 0.290.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 +4 -4
- package/src/__tests__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/request-context.ts +28 -0
- package/src/api/server.ts +30 -4
- package/src/changes.json +18 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/screens.ts +4 -12
- package/src/engine/index.ts +1 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/event-store.ts +23 -2
- package/src/jobs/job-runner.ts +10 -2
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +17 -5
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- package/src/ui-types/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.290.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,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.290.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.290.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.290.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
|
@@ -26,10 +26,14 @@ import {
|
|
|
26
26
|
const employeeEntity = createEntity({
|
|
27
27
|
table: "fa_employees",
|
|
28
28
|
fields: {
|
|
29
|
-
email: createTextField({ required: true }),
|
|
30
|
-
firstName: createTextField(),
|
|
29
|
+
email: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
30
|
+
firstName: createTextField({ personal: false, reason: "test_fixture" }),
|
|
31
31
|
salary: createNumberField({ access: { read: ["Admin", "Accounting"], write: ["Admin"] } }),
|
|
32
|
-
notes: createTextField({
|
|
32
|
+
notes: createTextField({
|
|
33
|
+
personal: false,
|
|
34
|
+
reason: "test_fixture",
|
|
35
|
+
access: { read: ["Admin"], write: ["Admin"] },
|
|
36
|
+
}),
|
|
33
37
|
},
|
|
34
38
|
});
|
|
35
39
|
|
|
@@ -45,7 +45,7 @@ const memoEntity = createEntity({
|
|
|
45
45
|
table: "fw2626_memos",
|
|
46
46
|
softDelete: true,
|
|
47
47
|
fields: {
|
|
48
|
-
ownerId: createTextField({ required: true }),
|
|
48
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
49
49
|
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
50
50
|
},
|
|
51
51
|
access: {
|
|
@@ -37,7 +37,7 @@ const contractEntity = createEntity({
|
|
|
37
37
|
softDelete: true,
|
|
38
38
|
fields: {
|
|
39
39
|
teamId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
40
|
-
assigneeId: createTextField(),
|
|
40
|
+
assigneeId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
41
41
|
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
42
42
|
// propA: public on read + write
|
|
43
43
|
propA: createTextField({ personal: false, reason: "test_fixture" }),
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
defineFeature,
|
|
11
11
|
defineQueryHandler,
|
|
12
12
|
EXT_PRINCIPAL_STATUS,
|
|
13
|
+
EXT_TENANT_LIFECYCLE_STATUS,
|
|
13
14
|
} from "../../engine";
|
|
14
15
|
import { createInMemorySearchAdapter } from "../../search";
|
|
15
16
|
import { buildServer } from "../server";
|
|
@@ -263,3 +264,44 @@ describe("buildServer — auth membershipQuery requires a principalStatus provid
|
|
|
263
264
|
).not.toThrow();
|
|
264
265
|
});
|
|
265
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("buildServer — tenant-lifecycle gate derivation", () => {
|
|
269
|
+
const lifecyclePlugin = { resolveStatus: async () => null };
|
|
270
|
+
const providerFeature = defineFeature("lifecycle-provider", (r) => {
|
|
271
|
+
r.extendsRegistrar(EXT_TENANT_LIFECYCLE_STATUS, {});
|
|
272
|
+
r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider", lifecyclePlugin);
|
|
273
|
+
});
|
|
274
|
+
const secondProviderFeature = defineFeature("lifecycle-provider-2", (r) => {
|
|
275
|
+
r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider-2", lifecyclePlugin);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("throws when a lifecycle provider is mounted but context.db is missing", () => {
|
|
279
|
+
expect(() =>
|
|
280
|
+
buildServer({
|
|
281
|
+
registry: createRegistry([providerFeature]),
|
|
282
|
+
context: {},
|
|
283
|
+
jwtSecret: JWT_SECRET,
|
|
284
|
+
}),
|
|
285
|
+
).toThrow(/tenantLifecycleStatus provider is mounted .* but context\.db is missing/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("throws when two lifecycle providers are registered", () => {
|
|
289
|
+
expect(() =>
|
|
290
|
+
buildServer({
|
|
291
|
+
registry: createRegistry([providerFeature, secondProviderFeature]),
|
|
292
|
+
context: {},
|
|
293
|
+
jwtSecret: JWT_SECRET,
|
|
294
|
+
}),
|
|
295
|
+
).toThrow(/multiple "tenantLifecycleStatus" providers registered/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("no provider mounted leaves the gate unwired", () => {
|
|
299
|
+
expect(() =>
|
|
300
|
+
buildServer({
|
|
301
|
+
registry: createRegistry([]),
|
|
302
|
+
context: {},
|
|
303
|
+
jwtSecret: JWT_SECRET,
|
|
304
|
+
}),
|
|
305
|
+
).not.toThrow();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -38,6 +38,12 @@ export type RequestContextData = {
|
|
|
38
38
|
// request-locale.ts. Undefined when neither header carried a valid tag;
|
|
39
39
|
// callers fall back further (dispatch-shared.ts's ctx.locale chain).
|
|
40
40
|
readonly locale?: string;
|
|
41
|
+
// Attribution of the currently executing scope (#3043): the feature that
|
|
42
|
+
// owns it and the qualified name of the handler / MSP-consumer / job
|
|
43
|
+
// inside it. event-store.append() reads both and stamps them onto every
|
|
44
|
+
// event written under this scope.
|
|
45
|
+
readonly feature?: string;
|
|
46
|
+
readonly handler?: string;
|
|
41
47
|
};
|
|
42
48
|
|
|
43
49
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -55,3 +61,25 @@ export const requestContext = {
|
|
|
55
61
|
return generateId();
|
|
56
62
|
},
|
|
57
63
|
};
|
|
64
|
+
|
|
65
|
+
// Enter a scope that attributes every event written inside it. Keeps the
|
|
66
|
+
// surrounding request's ids so correlation survives, and mints fresh ones
|
|
67
|
+
// when there is no surrounding request (job-runner, event-dispatcher) —
|
|
68
|
+
// requestId/correlationId are mandatory, `get()` may be undefined.
|
|
69
|
+
export function runWithOrigin<T>(
|
|
70
|
+
origin: { readonly feature?: string; readonly handler?: string },
|
|
71
|
+
fn: () => T,
|
|
72
|
+
): T {
|
|
73
|
+
const current = requestContext.get();
|
|
74
|
+
const requestId = current?.requestId ?? requestContext.generateId();
|
|
75
|
+
return requestContext.run(
|
|
76
|
+
{
|
|
77
|
+
...current,
|
|
78
|
+
requestId,
|
|
79
|
+
correlationId: current?.correlationId ?? requestId,
|
|
80
|
+
feature: origin.feature,
|
|
81
|
+
handler: origin.handler,
|
|
82
|
+
},
|
|
83
|
+
fn,
|
|
84
|
+
);
|
|
85
|
+
}
|
package/src/api/server.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
registerStandardMetrics,
|
|
20
20
|
wrapRedisClient,
|
|
21
21
|
} from "../observability";
|
|
22
|
+
import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership";
|
|
22
23
|
import type { DispatcherOptions } from "../pipeline/dispatcher";
|
|
23
24
|
import { createDispatcher, type Dispatcher } from "../pipeline/dispatcher";
|
|
24
25
|
import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
|
|
@@ -43,7 +44,12 @@ import {
|
|
|
43
44
|
import type { SearchAdapter } from "../search/types";
|
|
44
45
|
import { assertUnreachable, generateId } from "../utils";
|
|
45
46
|
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
|
|
46
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
type AnonymousAccessResolved,
|
|
49
|
+
authMiddleware,
|
|
50
|
+
getUser,
|
|
51
|
+
type TenantLifecycleStatusResolver,
|
|
52
|
+
} from "./auth-middleware";
|
|
47
53
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
48
54
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
49
55
|
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
@@ -670,12 +676,16 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
670
676
|
// a token (or, when anonymousAccess is wired, falls through as anonymous).
|
|
671
677
|
// A session-checker is forwarded when the auth-config wires one, so the
|
|
672
678
|
// middleware can reject revoked sids on every request.
|
|
679
|
+
// Mounting a tenantLifecycleStatus provider is what turns the 410 on, so no
|
|
680
|
+
// entrypoint can forget the wiring; `??` short-circuits, so an explicit
|
|
681
|
+
// auth.resolveTenantLifecycleStatus remains the override.
|
|
682
|
+
const tenantLifecycleResolver =
|
|
683
|
+
options.auth?.resolveTenantLifecycleStatus ??
|
|
684
|
+
deriveTenantLifecycleResolver(options.registry, baseDb);
|
|
673
685
|
const jwtGuard = authMiddleware(jwt, {
|
|
674
686
|
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
675
687
|
...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
|
|
676
|
-
...(
|
|
677
|
-
? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
|
|
678
|
-
: {}),
|
|
688
|
+
...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
|
|
679
689
|
...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
|
|
680
690
|
});
|
|
681
691
|
app.use("/api/*", async (c, next) => {
|
|
@@ -890,6 +900,22 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
890
900
|
};
|
|
891
901
|
}
|
|
892
902
|
|
|
903
|
+
function deriveTenantLifecycleResolver(
|
|
904
|
+
registry: Registry,
|
|
905
|
+
db: DbConnection | undefined,
|
|
906
|
+
): TenantLifecycleStatusResolver | undefined {
|
|
907
|
+
const plugin = resolveTenantLifecyclePlugin(registry, "buildServer");
|
|
908
|
+
if (!plugin) return undefined;
|
|
909
|
+
if (!db) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
"[kumiko] a tenantLifecycleStatus provider is mounted (tenant-lifecycle) but context.db is " +
|
|
912
|
+
"missing — the request-level teardown gate needs a DbConnection. Pass context.db, or wire " +
|
|
913
|
+
"auth.resolveTenantLifecycleStatus explicitly.",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
return (tenantId) => plugin.resolveStatus(tenantId, { db });
|
|
917
|
+
}
|
|
918
|
+
|
|
893
919
|
// Scans every feature's entities for a file/image/files/images field. Short-
|
|
894
920
|
// circuits on the first hit — no need to build a full inventory, we only want
|
|
895
921
|
// the yes/no answer for the boot check.
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.290.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Event-Attribution: metadata.feature + metadata.handler auf jedem Event",
|
|
6
|
+
"detail": "event-store.append() stempelt Feature und Handler-Namen aus dem Ausfuehrungsscope (requestContext) auf jedes geschriebene Event. Dispatch-Handler, Entity-Executor-Writes, MSP-Applies und Jobs sind damit ohne Signaturaenderung attribuiert; Appends ausserhalb eines Scopes tragen UNATTRIBUTED_ORIGIN. appendRaw/appendRawBatch bleiben unveraendert."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.290.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Section readers see groups[].fields",
|
|
12
|
+
"detail": "Boot-guard and E2E-generator now flatten section.groups via sectionFieldSpecs instead of iterating section.fields only: a function renderer inside groups[].fields fails at boot, and generated E2E specs cover group fields (required fields, fill ops, text assertions)."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.290.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "The tenant-teardown 410 gate is derived from the mounted tenantLifecycleStatus provider (fw#2881).",
|
|
18
|
+
"detail": "buildServer now resolves the EXT_TENANT_LIFECYCLE_STATUS extension point itself, so every server with tenant-lifecycle mounted rejects requests against a tenant in teardown with 410 tenant_unavailable — prod, dev and test stacks can no longer drift because one entrypoint forgot the wiring. An explicit auth.resolveTenantLifecycleStatus still takes precedence and stays the escape hatch. A mounted provider without context.db is a boot error instead of a silently skipped gate."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.289.0",
|
|
4
22
|
"type": "breaking",
|
|
@@ -25,7 +25,7 @@ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
|
|
|
25
25
|
const userLikeEntity = createEntity({
|
|
26
26
|
fields: {
|
|
27
27
|
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
28
|
-
role: createTextField(),
|
|
28
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
29
29
|
},
|
|
30
30
|
table: "bidx_users",
|
|
31
31
|
});
|
|
@@ -28,7 +28,7 @@ const ENTITY_NAME = "pii-entity";
|
|
|
28
28
|
const userLikeEntity = createEntity({
|
|
29
29
|
fields: {
|
|
30
30
|
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
31
|
-
role: createTextField(),
|
|
31
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
32
32
|
},
|
|
33
33
|
table: "pii_users",
|
|
34
34
|
});
|
|
@@ -39,7 +39,7 @@ const commentEntity = createEntity({
|
|
|
39
39
|
personal: { of: "authorId" },
|
|
40
40
|
find: "none",
|
|
41
41
|
}),
|
|
42
|
-
authorId: createTextField({ required: true }),
|
|
42
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
43
43
|
},
|
|
44
44
|
table: "pii_comments",
|
|
45
45
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
const userLikeEntity = createEntity({
|
|
12
12
|
fields: {
|
|
13
13
|
email: createTextField({ required: true, personal: "self", find: "none" }),
|
|
14
|
-
role: createTextField(),
|
|
14
|
+
role: createTextField({ personal: false, reason: "test_fixture" }),
|
|
15
15
|
},
|
|
16
16
|
table: "resolver_users",
|
|
17
17
|
idType: "uuid",
|
|
@@ -23,7 +23,7 @@ const commentEntity = createEntity({
|
|
|
23
23
|
personal: { of: "authorId" },
|
|
24
24
|
find: "none",
|
|
25
25
|
}),
|
|
26
|
-
authorId: createTextField({ required: true }),
|
|
26
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
27
27
|
},
|
|
28
28
|
table: "resolver_comments",
|
|
29
29
|
});
|
|
@@ -37,7 +37,7 @@ const personEntity = createEntity({
|
|
|
37
37
|
table: "read_bidx_persons",
|
|
38
38
|
fields: {
|
|
39
39
|
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
40
|
-
firstName: createTextField(),
|
|
40
|
+
firstName: createTextField({ personal: false, reason: "test_fixture" }),
|
|
41
41
|
},
|
|
42
42
|
});
|
|
43
43
|
const personFeature = defineFeature("bidxtest", (r) => {
|
|
@@ -50,7 +50,12 @@ const contactEntity = createEntity({
|
|
|
50
50
|
fields: {
|
|
51
51
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
52
52
|
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
53
|
-
iban: createTextField({
|
|
53
|
+
iban: createTextField({
|
|
54
|
+
personal: false,
|
|
55
|
+
reason: "test_fixture",
|
|
56
|
+
required: true,
|
|
57
|
+
encrypted: true,
|
|
58
|
+
}),
|
|
54
59
|
},
|
|
55
60
|
});
|
|
56
61
|
const leadEntity = createEntity({
|
|
@@ -72,7 +77,12 @@ const ownedContactEntity = createEntity({
|
|
|
72
77
|
fields: {
|
|
73
78
|
name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
74
79
|
email: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
75
|
-
iban: createTextField({
|
|
80
|
+
iban: createTextField({
|
|
81
|
+
personal: false,
|
|
82
|
+
reason: "test_fixture",
|
|
83
|
+
required: true,
|
|
84
|
+
encrypted: true,
|
|
85
|
+
}),
|
|
76
86
|
},
|
|
77
87
|
access: { read: { admin: from("user:id", "ownerId") } },
|
|
78
88
|
});
|
|
@@ -13,8 +13,8 @@ describe("entity-field-encryption", () => {
|
|
|
13
13
|
const entity = createEntity({
|
|
14
14
|
table: "read_enc_test",
|
|
15
15
|
fields: {
|
|
16
|
-
email: createTextField({ required: true }),
|
|
17
|
-
secretNote: createTextField({ encrypted: true }),
|
|
16
|
+
email: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
17
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
18
18
|
},
|
|
19
19
|
});
|
|
20
20
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
@@ -16,7 +16,7 @@ describe("event-store-executor-context — encryptForStorage/decryptForRead laye
|
|
|
16
16
|
const entity = createEntity({
|
|
17
17
|
table: "pii_roundtrip_test",
|
|
18
18
|
fields: {
|
|
19
|
-
userId: createTextField({ required: true }),
|
|
19
|
+
userId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
20
20
|
// Both markers at once — the auth-mfa.totpSecret/recoveryCodes shape
|
|
21
21
|
// that first surfaced the ordering bug (pii-subject-encryption
|
|
22
22
|
// integration test).
|
|
@@ -34,9 +34,14 @@ const cipher = createTestEnvelopeCipher(TEST_KEY);
|
|
|
34
34
|
const entity = createEntity({
|
|
35
35
|
table: "read_money_orders",
|
|
36
36
|
fields: {
|
|
37
|
-
ownerId: createTextField({ required: true }),
|
|
37
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
38
38
|
grossTotal: createMoneyField(),
|
|
39
|
-
billingIban: createTextField({
|
|
39
|
+
billingIban: createTextField({
|
|
40
|
+
personal: false,
|
|
41
|
+
reason: "test_fixture",
|
|
42
|
+
required: true,
|
|
43
|
+
encrypted: true,
|
|
44
|
+
}),
|
|
40
45
|
},
|
|
41
46
|
// A non-"all" read rule forces buildOwnershipClause into the
|
|
42
47
|
// ownership.kind==="sql" raw-SQL branch that list()/detail() read through.
|
|
@@ -227,8 +227,10 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
|
|
|
227
227
|
const ownedFieldEntity = createEntity({
|
|
228
228
|
table: "read_es_write_owned_field",
|
|
229
229
|
fields: {
|
|
230
|
-
authorId: createTextField({ required: true }),
|
|
230
|
+
authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
231
231
|
note: createTextField({
|
|
232
|
+
personal: false,
|
|
233
|
+
reason: "test_fixture",
|
|
232
234
|
access: { write: { Admin: "all", User: from("user:id", "authorId") } },
|
|
233
235
|
}),
|
|
234
236
|
},
|
|
@@ -139,8 +139,18 @@ const sensitiveEntity = createEntity({
|
|
|
139
139
|
table: "read_es_exec_sensitive",
|
|
140
140
|
fields: {
|
|
141
141
|
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
142
|
-
passwordHash: createTextField({
|
|
143
|
-
|
|
142
|
+
passwordHash: createTextField({
|
|
143
|
+
personal: false,
|
|
144
|
+
reason: "test_fixture",
|
|
145
|
+
sensitive: true,
|
|
146
|
+
encrypted: true,
|
|
147
|
+
}),
|
|
148
|
+
apiToken: createTextField({
|
|
149
|
+
personal: false,
|
|
150
|
+
reason: "test_fixture",
|
|
151
|
+
sensitive: true,
|
|
152
|
+
encrypted: true,
|
|
153
|
+
}),
|
|
144
154
|
},
|
|
145
155
|
softDelete: true,
|
|
146
156
|
});
|
|
@@ -343,7 +353,7 @@ const encryptedEntity = createEntity({
|
|
|
343
353
|
table: "read_es_exec_encrypted",
|
|
344
354
|
fields: {
|
|
345
355
|
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
346
|
-
secretNote: createTextField({ encrypted: true }),
|
|
356
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
347
357
|
},
|
|
348
358
|
});
|
|
349
359
|
const encryptedTable = buildEntityTable("esExecEncrypted", encryptedEntity);
|
|
@@ -352,7 +362,7 @@ const encryptedSoftDeleteEntity = createEntity({
|
|
|
352
362
|
table: "read_es_exec_enc_soft",
|
|
353
363
|
fields: {
|
|
354
364
|
email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
355
|
-
secretNote: createTextField({ encrypted: true }),
|
|
365
|
+
secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
|
|
356
366
|
},
|
|
357
367
|
softDelete: true,
|
|
358
368
|
});
|
|
@@ -703,7 +713,7 @@ const piiEntity = createEntity({
|
|
|
703
713
|
personal: { of: "authorId" },
|
|
704
714
|
find: "none",
|
|
705
715
|
}),
|
|
706
|
-
authorId: createTextField(),
|
|
716
|
+
authorId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
707
717
|
plain: createTextField({ personal: false, reason: "test_fixture" }),
|
|
708
718
|
},
|
|
709
719
|
});
|
|
@@ -17,14 +17,18 @@ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } fr
|
|
|
17
17
|
const filterNoteEntity = createEntity({
|
|
18
18
|
table: "fa_filter_notes",
|
|
19
19
|
fields: {
|
|
20
|
-
title: createTextField({ required: true }),
|
|
21
|
-
ownerId: createTextField({ required: true }),
|
|
20
|
+
title: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
21
|
+
ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
22
22
|
secret: createTextField({
|
|
23
|
+
personal: false,
|
|
24
|
+
reason: "test_fixture",
|
|
23
25
|
filterable: true,
|
|
24
26
|
sortable: true,
|
|
25
27
|
access: { read: { Admin: "all" } },
|
|
26
28
|
}),
|
|
27
29
|
ownedNote: createTextField({
|
|
30
|
+
personal: false,
|
|
31
|
+
reason: "test_fixture",
|
|
28
32
|
filterable: true,
|
|
29
33
|
access: { read: { Admin: "all", User: from("user:id", "ownerId") } },
|
|
30
34
|
}),
|
|
@@ -229,6 +229,38 @@ describe("validateBoot — action wiring (no function values)", () => {
|
|
|
229
229
|
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
test("entityEdit field renderer as function inside groups → Throw", () => {
|
|
233
|
+
const feature = defineFeature("shop", (r) => {
|
|
234
|
+
r.entity(
|
|
235
|
+
"product",
|
|
236
|
+
createEntity({
|
|
237
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
r.screen({
|
|
241
|
+
id: "product-edit",
|
|
242
|
+
type: "entityEdit",
|
|
243
|
+
entity: "product",
|
|
244
|
+
layout: {
|
|
245
|
+
sections: [
|
|
246
|
+
{
|
|
247
|
+
columns: 1,
|
|
248
|
+
fields: [],
|
|
249
|
+
groups: [
|
|
250
|
+
{
|
|
251
|
+
title: "Basis",
|
|
252
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
253
|
+
fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
262
|
+
});
|
|
263
|
+
|
|
232
264
|
test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
|
|
233
265
|
const feature = defineFeature("shop", (r) => {
|
|
234
266
|
r.entity(
|
|
@@ -62,7 +62,7 @@ describe("r.bootCheck / validateFeatureBootChecks", () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("no PII field → boot succeeds even without the companion", () => {
|
|
65
|
-
const noPiiFields = { text: createTextField() };
|
|
65
|
+
const noPiiFields = { text: createTextField({ personal: false, reason: "test_fixture" }) };
|
|
66
66
|
const noPii = defineFeature("prompt-store-no-pii", (r) => {
|
|
67
67
|
r.entity("note", createEntity({ fields: noPiiFields }));
|
|
68
68
|
r.bootCheck(({ features }) => {
|
|
@@ -40,6 +40,17 @@ import type { LongTextFieldDef, TextFieldDef } from "../types";
|
|
|
40
40
|
const rawField = <T>(f: T) => f as unknown as TextFieldDef;
|
|
41
41
|
const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
|
|
42
42
|
|
|
43
|
+
// The heuristics under test only fire without a personal stance; after #2810
|
|
44
|
+
// the factories can no longer produce that shape.
|
|
45
|
+
const unannotatedText: TextFieldDef = {
|
|
46
|
+
type: "text",
|
|
47
|
+
maxLength: 200,
|
|
48
|
+
required: false,
|
|
49
|
+
searchable: false,
|
|
50
|
+
sortable: false,
|
|
51
|
+
};
|
|
52
|
+
const unannotatedLongText: LongTextFieldDef = { type: "longText", required: false };
|
|
53
|
+
|
|
43
54
|
// Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
|
|
44
55
|
// Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
|
|
45
56
|
// wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
|
|
@@ -136,7 +147,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
136
147
|
createEntity({
|
|
137
148
|
fields: {
|
|
138
149
|
confused: rawField({
|
|
139
|
-
...
|
|
150
|
+
...unannotatedText,
|
|
140
151
|
pii: true,
|
|
141
152
|
tenantOwned: true,
|
|
142
153
|
}),
|
|
@@ -153,7 +164,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
153
164
|
"vault",
|
|
154
165
|
createEntity({
|
|
155
166
|
fields: {
|
|
156
|
-
apiToken:
|
|
167
|
+
apiToken: { ...unannotatedText, sensitive: true },
|
|
157
168
|
},
|
|
158
169
|
}),
|
|
159
170
|
);
|
|
@@ -188,7 +199,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
188
199
|
"vault",
|
|
189
200
|
createEntity({
|
|
190
201
|
fields: {
|
|
191
|
-
apiToken:
|
|
202
|
+
apiToken: { ...unannotatedText, sensitive: true, encrypted: true },
|
|
192
203
|
},
|
|
193
204
|
}),
|
|
194
205
|
);
|
|
@@ -228,7 +239,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
228
239
|
personal: { of: "authorId" },
|
|
229
240
|
find: "none",
|
|
230
241
|
}),
|
|
231
|
-
authorId:
|
|
242
|
+
authorId: { ...unannotatedText },
|
|
232
243
|
},
|
|
233
244
|
}),
|
|
234
245
|
);
|
|
@@ -258,7 +269,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
258
269
|
|
|
259
270
|
test("userOwned.ownerField referencing non-user entity warns", () => {
|
|
260
271
|
const feature = defineFeature("test", (r) => {
|
|
261
|
-
r.entity("employee", createEntity({ fields: { name:
|
|
272
|
+
r.entity("employee", createEntity({ fields: { name: { ...unannotatedText } } }));
|
|
262
273
|
stubListHandler(r, "employee");
|
|
263
274
|
r.entity(
|
|
264
275
|
"personalNote",
|
|
@@ -286,7 +297,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
286
297
|
"thing",
|
|
287
298
|
createEntity({
|
|
288
299
|
fields: {
|
|
289
|
-
email:
|
|
300
|
+
email: { ...unannotatedText },
|
|
290
301
|
},
|
|
291
302
|
}),
|
|
292
303
|
);
|
|
@@ -304,7 +315,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
304
315
|
"thing",
|
|
305
316
|
createEntity({
|
|
306
317
|
fields: {
|
|
307
|
-
body:
|
|
318
|
+
body: { ...unannotatedLongText },
|
|
308
319
|
},
|
|
309
320
|
}),
|
|
310
321
|
);
|
|
@@ -322,7 +333,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
322
333
|
"thing",
|
|
323
334
|
createEntity({
|
|
324
335
|
fields: {
|
|
325
|
-
authorId:
|
|
336
|
+
authorId: { ...unannotatedText },
|
|
326
337
|
},
|
|
327
338
|
}),
|
|
328
339
|
);
|
|
@@ -563,7 +574,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
563
574
|
"product",
|
|
564
575
|
createEntity({
|
|
565
576
|
fields: {
|
|
566
|
-
name:
|
|
577
|
+
name: { ...unannotatedText },
|
|
567
578
|
},
|
|
568
579
|
}),
|
|
569
580
|
);
|
|
@@ -581,7 +592,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
581
592
|
"person",
|
|
582
593
|
createEntity({
|
|
583
594
|
fields: {
|
|
584
|
-
displayName:
|
|
595
|
+
displayName: { ...unannotatedText },
|
|
585
596
|
},
|
|
586
597
|
}),
|
|
587
598
|
);
|
|
@@ -626,7 +637,7 @@ describe("validateBoot — retention", () => {
|
|
|
626
637
|
"auditEvent",
|
|
627
638
|
createEntity({
|
|
628
639
|
fields: {
|
|
629
|
-
note:
|
|
640
|
+
note: { ...unannotatedText },
|
|
630
641
|
},
|
|
631
642
|
retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
632
643
|
}),
|
|
@@ -641,7 +652,7 @@ describe("validateBoot — retention", () => {
|
|
|
641
652
|
"thing",
|
|
642
653
|
createEntity({
|
|
643
654
|
fields: {
|
|
644
|
-
note:
|
|
655
|
+
note: { ...unannotatedText },
|
|
645
656
|
},
|
|
646
657
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
|
|
647
658
|
}),
|
|
@@ -852,7 +863,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
852
863
|
createEntity({
|
|
853
864
|
fields: {
|
|
854
865
|
slug: rawField({
|
|
855
|
-
...
|
|
866
|
+
...unannotatedText,
|
|
856
867
|
lookupable: true,
|
|
857
868
|
}),
|
|
858
869
|
},
|
|
@@ -872,7 +883,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
872
883
|
...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
|
|
873
884
|
lookupable: true,
|
|
874
885
|
}),
|
|
875
|
-
ownerId:
|
|
886
|
+
ownerId: { ...unannotatedText, required: true },
|
|
876
887
|
},
|
|
877
888
|
}),
|
|
878
889
|
);
|
|
@@ -922,7 +933,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
922
933
|
createEntity({
|
|
923
934
|
fields: {
|
|
924
935
|
passwordHash: rawField({
|
|
925
|
-
...
|
|
936
|
+
...unannotatedText,
|
|
926
937
|
pii: true,
|
|
927
938
|
sensitive: true,
|
|
928
939
|
searchable: true,
|