@cosmicdrift/kumiko-framework 0.174.0 → 0.174.1
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/api/__tests__/api.test.ts +11 -2
- package/src/crypto/ciphertext-pattern.ts +19 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +18 -0
- package/src/db/__tests__/migrate-runner.test.ts +16 -0
- package/src/db/blind-index-cleanup.ts +15 -24
- package/src/db/eagerload.ts +21 -5
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-write.ts +6 -1
- package/src/db/migrate-runner.ts +5 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +19 -0
- package/src/engine/__tests__/boot-validator.test.ts +51 -1
- package/src/engine/__tests__/ownership.test.ts +23 -0
- package/src/engine/__tests__/schema-builder.test.ts +64 -6
- package/src/engine/boot-validator/access-roles.ts +63 -21
- package/src/engine/boot-validator/entity-handler.ts +4 -0
- package/src/engine/boot-validator/index.ts +17 -2
- package/src/engine/boot-validator/pii-retention.ts +17 -10
- package/src/engine/boot-validator/screens.ts +10 -2
- package/src/engine/boot-validator.ts +1 -0
- package/src/engine/create-app.ts +4 -2
- package/src/engine/index.ts +2 -0
- package/src/engine/ownership.ts +19 -0
- package/src/engine/schema-builder.ts +42 -21
- package/src/entrypoint/index.ts +2 -5
- package/src/jobs/__tests__/scheduler-id.test.ts +13 -1
- package/src/jobs/job-runner.ts +7 -1
- package/src/pipeline/system-hooks.ts +16 -3
- package/src/schema-cli.ts +7 -5
- package/src/search/purge-subject.ts +2 -9
- package/src/secrets/derive-purpose-secret.ts +6 -16
- package/src/testing/__tests__/e2e-generator.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +2 -2
- package/src/testing/e2e-generator.ts +5 -0
- package/src/testing/shared-entities.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.174.
|
|
3
|
+
"version": "0.174.1",
|
|
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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.174.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.174.1",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.174.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.174.1",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -564,9 +564,18 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
564
564
|
// in-flight .next(), so cleanup only runs once the pending pull settles.
|
|
565
565
|
// Abort before heartbeatMs so the route hits the 499 branch; sleep then
|
|
566
566
|
// completes and the queued return drains the generator's finally.
|
|
567
|
+
//
|
|
568
|
+
// Abort is triggered off an entry signal, not a fixed sleep margin against
|
|
569
|
+
// the heartbeat timer — a stalled event loop could otherwise let the
|
|
570
|
+
// heartbeat fire first and flip the route onto the 200-SSE branch.
|
|
567
571
|
let cleanedUp = false;
|
|
572
|
+
let entered: () => void;
|
|
573
|
+
const atEntry = new Promise<void>((resolve) => {
|
|
574
|
+
entered = resolve;
|
|
575
|
+
});
|
|
568
576
|
const dispatcher = stubDispatcher(async function* () {
|
|
569
577
|
try {
|
|
578
|
+
entered();
|
|
570
579
|
await Bun.sleep(80);
|
|
571
580
|
yield { i: 0 };
|
|
572
581
|
} finally {
|
|
@@ -583,8 +592,8 @@ describe("POST /api/stream pre-pull race", () => {
|
|
|
583
592
|
signal: ac.signal,
|
|
584
593
|
}),
|
|
585
594
|
);
|
|
586
|
-
// Abort
|
|
587
|
-
await
|
|
595
|
+
// Abort as soon as the generator has been entered — no timing window left.
|
|
596
|
+
await atEntry;
|
|
588
597
|
ac.abort();
|
|
589
598
|
const res = await pending;
|
|
590
599
|
expect(res.status).toBe(499);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared SQL helpers for locating a subject's PII ciphertext by its inline
|
|
2
|
+
// subject-key prefix (kumiko-pii:v<version>:<subjectKey>:...). Used by both
|
|
3
|
+
// the blind-index sweep (db/blind-index-cleanup.ts) and the search-index
|
|
4
|
+
// purge (search/purge-subject.ts) — the two sweeps must stay in lockstep
|
|
5
|
+
// across ciphertext format versions.
|
|
6
|
+
|
|
7
|
+
export function quoteIdent(name: string): string {
|
|
8
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function escapeLikePattern(value: string): string {
|
|
12
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
|
|
16
|
+
// subject key placement is stable across versions.
|
|
17
|
+
export function subjectCiphertextLikePattern(subjectKey: string): string {
|
|
18
|
+
return `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
19
|
+
}
|
|
@@ -272,6 +272,24 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
|
|
|
272
272
|
);
|
|
273
273
|
expect(result.isSuccess).toBe(true);
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
// Review-fix (kumiko-framework#1685): a preSave hook that echoes `id`/
|
|
277
|
+
// `version` back in its return value must not have those leak into the
|
|
278
|
+
// persisted row — the framework-minted aggregateId stays authoritative.
|
|
279
|
+
test("create: preSave hook returning `id`/`version` does not override the minted aggregateId", async () => {
|
|
280
|
+
const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb, {
|
|
281
|
+
preSave: async (changes) => ({ ...changes, id: "hook-injected-id", version: 999 }),
|
|
282
|
+
});
|
|
283
|
+
expect(result.isSuccess).toBe(true);
|
|
284
|
+
if (!result.isSuccess) return;
|
|
285
|
+
expect(result.data.id).not.toBe("hook-injected-id");
|
|
286
|
+
|
|
287
|
+
const row = await asRawClient(testDb.db).unsafe(
|
|
288
|
+
`SELECT id FROM read_es_write_owned_field WHERE id = $1`,
|
|
289
|
+
[result.data.id],
|
|
290
|
+
);
|
|
291
|
+
expect(row.length).toBe(1);
|
|
292
|
+
});
|
|
275
293
|
});
|
|
276
294
|
|
|
277
295
|
// =============================================================================
|
|
@@ -99,4 +99,20 @@ describe("splitSqlStatements", () => {
|
|
|
99
99
|
'CREATE TABLE "a" ("id" uuid);',
|
|
100
100
|
]);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("throws fail-loud on a dollar-quoted body instead of splitting it in half", () => {
|
|
104
|
+
expect(() => splitSqlStatements("DO $$ BEGIN PERFORM 1; END $$;")).toThrow(
|
|
105
|
+
/unsupported dollar-quoted body/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("throws fail-loud on a tagged dollar-quoted body ($tag$...$tag$)", () => {
|
|
110
|
+
expect(() => splitSqlStatements("DO $tag$ BEGIN PERFORM 1; END $tag$;")).toThrow(
|
|
111
|
+
/unsupported dollar-quoted body/,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a bare $ not opening a dollar-tag does not false-positive (digit after $ is not a tag)", () => {
|
|
116
|
+
expect(splitSqlStatements("SELECT $1;")).toEqual(["SELECT $1;"]);
|
|
117
|
+
});
|
|
102
118
|
});
|
|
@@ -1,46 +1,37 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Immediate blind-index nulling after a subject erase (#818).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// bidx
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// (kumiko-pii:v1:<subjectKey>:...),
|
|
8
|
-
//
|
|
3
|
+
// After kms.eraseKey the ciphertext is unreadable, but the deterministic
|
|
4
|
+
// bidx column would stay matchable until the next write/rebuild — a
|
|
5
|
+
// linkage window ("does any row hold value X"). This sweep closes it right
|
|
6
|
+
// away: the ciphertext names its subject inline
|
|
7
|
+
// (kumiko-pii:v1:<subjectKey>:...), so a LIKE-prefix match finds exactly
|
|
8
|
+
// the erased subject's rows — one UPDATE per lookupable field.
|
|
9
9
|
//
|
|
10
|
-
// Rows
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Rows the forget run deletes/anonymizes via the executor anyway get their
|
|
11
|
+
// bidx recomputed automatically there; this sweep covers the rows left
|
|
12
|
+
// behind (foreign entities with userOwned fields).
|
|
13
13
|
|
|
14
14
|
import { collectLookupableFields } from "../crypto/blind-index";
|
|
15
|
+
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
15
16
|
import type { FeatureDefinition } from "../engine/types";
|
|
16
17
|
import { toSnakeCase } from "../utils/case";
|
|
17
18
|
import type { DbRunner } from "./connection";
|
|
18
19
|
import { resolveTableName } from "./entity-table-meta";
|
|
19
20
|
import { executeRawQuery } from "./queries/raw-sql";
|
|
20
21
|
|
|
21
|
-
function quoteIdent(name: string): string {
|
|
22
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function escapeLikePattern(value: string): string {
|
|
26
|
-
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
22
|
export async function nullBlindIndexesForSubject(
|
|
30
23
|
db: DbRunner,
|
|
31
24
|
features: ReadonlyMap<string, FeatureDefinition>,
|
|
32
25
|
subjectKey: string,
|
|
33
26
|
): Promise<void> {
|
|
34
|
-
|
|
35
|
-
// subject key placement is stable across versions.
|
|
36
|
-
const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
27
|
+
const likePattern = subjectCiphertextLikePattern(subjectKey);
|
|
37
28
|
for (const feature of features.values()) {
|
|
38
29
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
39
30
|
const lookupable = collectLookupableFields(entity);
|
|
40
31
|
if (lookupable.length === 0) continue;
|
|
41
|
-
//
|
|
42
|
-
// (buildEntityTable
|
|
43
|
-
//
|
|
32
|
+
// No featureName prefix — the dispatcher builds entity tables without
|
|
33
|
+
// one (buildEntityTable with no featureName option), the sweep has to
|
|
34
|
+
// hit the same names.
|
|
44
35
|
const tableName = resolveTableName(entityName, entity, undefined);
|
|
45
36
|
for (const fieldName of lookupable) {
|
|
46
37
|
const snake = toSnakeCase(fieldName);
|
package/src/db/eagerload.ts
CHANGED
|
@@ -118,9 +118,10 @@ function hasOwnershipScopedRead(refEntity: EntityDefinition): boolean {
|
|
|
118
118
|
async function decryptReferencedRow(
|
|
119
119
|
row: Record<string, unknown>,
|
|
120
120
|
refEntity: EntityDefinition,
|
|
121
|
+
piiFields: readonly string[],
|
|
122
|
+
encryptedFields: ReadonlySet<string>,
|
|
123
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
121
124
|
): Promise<Record<string, unknown>> {
|
|
122
|
-
const piiFields = collectPiiSubjectFields(refEntity);
|
|
123
|
-
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
124
125
|
if (hasOwnershipScopedRead(refEntity)) {
|
|
125
126
|
if (piiFields.length === 0 && encryptedFields.size === 0) return row;
|
|
126
127
|
const out = { ...row };
|
|
@@ -130,7 +131,6 @@ async function decryptReferencedRow(
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
let out = row;
|
|
133
|
-
const kms = configuredPiiSubjectKms();
|
|
134
134
|
if (piiFields.length > 0 && kms) {
|
|
135
135
|
out = await decryptPiiFieldValues(out, piiFields, kms, {
|
|
136
136
|
requestId: requestContext.get()?.requestId ?? "eagerload",
|
|
@@ -147,16 +147,22 @@ async function decryptReferencedRow(
|
|
|
147
147
|
// must not 500 the whole list request — the main rows the caller asked for
|
|
148
148
|
// are unrelated to this one broken reference. Drop just that row from the
|
|
149
149
|
// map; the renderer falls back to the raw UUID.
|
|
150
|
+
//
|
|
151
|
+
// piiFields/encryptedFields/kms are constant per refEntity (fw#1671) — the
|
|
152
|
+
// caller computes them once and passes them in instead of recomputing per row.
|
|
150
153
|
async function buildRefLookupMap(
|
|
151
154
|
rawRefRows: ReadonlyArray<Record<string, unknown>>,
|
|
152
155
|
refEntity: EntityDefinition,
|
|
153
156
|
refEntityName: string,
|
|
157
|
+
piiFields: readonly string[],
|
|
158
|
+
encryptedFields: ReadonlySet<string>,
|
|
159
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
154
160
|
): Promise<Map<string, Record<string, unknown>>> {
|
|
155
161
|
const map = new Map<string, Record<string, unknown>>();
|
|
156
162
|
for (const r of rawRefRows) {
|
|
157
163
|
let decrypted: Record<string, unknown>;
|
|
158
164
|
try {
|
|
159
|
-
decrypted = await decryptReferencedRow(r, refEntity);
|
|
165
|
+
decrypted = await decryptReferencedRow(r, refEntity, piiFields, encryptedFields, kms);
|
|
160
166
|
} catch (e) {
|
|
161
167
|
console.warn(
|
|
162
168
|
`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -214,7 +220,17 @@ export async function enrichWithReferences(
|
|
|
214
220
|
const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
|
|
215
221
|
Record<string, unknown>
|
|
216
222
|
>;
|
|
217
|
-
const
|
|
223
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
224
|
+
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
225
|
+
const kms = configuredPiiSubjectKms();
|
|
226
|
+
const map = await buildRefLookupMap(
|
|
227
|
+
rawRefRows,
|
|
228
|
+
refEntity,
|
|
229
|
+
rf.refEntityName,
|
|
230
|
+
piiFields,
|
|
231
|
+
encryptedFields,
|
|
232
|
+
kms,
|
|
233
|
+
);
|
|
218
234
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map };
|
|
219
235
|
}),
|
|
220
236
|
);
|
|
@@ -247,7 +247,7 @@ export function deriveEntityTableMeta(
|
|
|
247
247
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
248
248
|
const source = options?.source ?? "managed";
|
|
249
249
|
if (source === "unmanaged") {
|
|
250
|
-
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
250
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
|
|
251
251
|
}
|
|
252
252
|
const idType = entity.idType ?? "uuid";
|
|
253
253
|
|
|
@@ -424,6 +424,11 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
|
424
424
|
/**
|
|
425
425
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
426
426
|
* Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
|
|
427
|
+
*
|
|
428
|
+
* Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
|
|
429
|
+
* no softDelete — the app author owns tenant-scoping and retention for this
|
|
430
|
+
* table. Justify WHY in the call site; reviewers should scrutinize every new
|
|
431
|
+
* unmanaged table.
|
|
427
432
|
*/
|
|
428
433
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
429
434
|
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
@@ -69,7 +69,12 @@ async function runPreSave(
|
|
|
69
69
|
): Promise<{ readonly data: DbRow } | { readonly failure: ReturnType<typeof writeFailure> }> {
|
|
70
70
|
if (!preSave) return { data: changes as DbRow };
|
|
71
71
|
try {
|
|
72
|
-
|
|
72
|
+
const hookResult = await preSave(changes, previous, isNew);
|
|
73
|
+
// A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
|
|
74
|
+
// must not leak them into the persisted row — aggregateId already comes
|
|
75
|
+
// from generateId()/the loaded row, not from hook output (fw#1685).
|
|
76
|
+
const { id: _hookId, version: _hookVersion, ...safe } = hookResult as Record<string, unknown>;
|
|
77
|
+
return { data: safe as DbRow };
|
|
73
78
|
} catch (e) {
|
|
74
79
|
return {
|
|
75
80
|
failure: writeFailure(
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -160,6 +160,11 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
160
160
|
current += ch;
|
|
161
161
|
continue;
|
|
162
162
|
}
|
|
163
|
+
if (ch === "$" && /^\$([A-Za-z_]\w*)?\$/.test(sqlText.slice(i))) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"splitSqlStatements: unsupported dollar-quoted body — migration SQL is malformed, refusing to split",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
163
168
|
if (ch === ";") {
|
|
164
169
|
statements.push(current);
|
|
165
170
|
current = "";
|
|
@@ -626,6 +626,25 @@ describe("validateBoot — retention", () => {
|
|
|
626
626
|
expect(matchingWarn).toBeUndefined();
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
+
test("blockDelete with only a subjectRef-only field and no anonymize warns (#1645)", () => {
|
|
630
|
+
const feature = defineFeature("test", (r) => {
|
|
631
|
+
r.entity(
|
|
632
|
+
"lease",
|
|
633
|
+
createEntity({
|
|
634
|
+
fields: {
|
|
635
|
+
authorId: createTextField({ subjectRef: true }),
|
|
636
|
+
},
|
|
637
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
});
|
|
641
|
+
validateBoot([feature]);
|
|
642
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
643
|
+
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
644
|
+
);
|
|
645
|
+
expect(matchingWarn).toBeDefined();
|
|
646
|
+
});
|
|
647
|
+
|
|
629
648
|
test('retention.keepFor with invalid format "30days" warns', () => {
|
|
630
649
|
const feature = defineFeature("test", (r) => {
|
|
631
650
|
r.entity(
|
|
@@ -600,7 +600,7 @@ describe("boot-validator", () => {
|
|
|
600
600
|
});
|
|
601
601
|
}),
|
|
602
602
|
];
|
|
603
|
-
|
|
603
|
+
validateBootRaw(withBootValidatorFixture(features), { warnOnUniqueAccessRoles: true });
|
|
604
604
|
// Not toHaveBeenCalledTimes(1): this file's tests share the process-global
|
|
605
605
|
// console.warn and run with the default concurrency (bunfig.toml) — other
|
|
606
606
|
// concurrently-running tests' own "role used by one handler" warnings can
|
|
@@ -615,6 +615,27 @@ describe("boot-validator", () => {
|
|
|
615
615
|
}
|
|
616
616
|
});
|
|
617
617
|
|
|
618
|
+
test("does NOT warn on unique access roles by default — opt-in only (#1711)", () => {
|
|
619
|
+
const warnSpy = spyOn(console, "warn");
|
|
620
|
+
try {
|
|
621
|
+
const features = [
|
|
622
|
+
defineFeature("b", (r) => {
|
|
623
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
624
|
+
access: { roles: ["OnlyThereRole"] },
|
|
625
|
+
});
|
|
626
|
+
}),
|
|
627
|
+
];
|
|
628
|
+
validateBoot(features);
|
|
629
|
+
expect(
|
|
630
|
+
warnSpy.mock.calls.some((call) =>
|
|
631
|
+
(call[0] as string | undefined)?.includes("OnlyThereRole"),
|
|
632
|
+
),
|
|
633
|
+
).toBe(false);
|
|
634
|
+
} finally {
|
|
635
|
+
warnSpy.mockRestore();
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
618
639
|
test("throws when a stream handler has no access rule", () => {
|
|
619
640
|
const features = [
|
|
620
641
|
defineFeature("a", (r) => {
|
|
@@ -2886,6 +2907,35 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
2886
2907
|
);
|
|
2887
2908
|
});
|
|
2888
2909
|
|
|
2910
|
+
test("navigate with params targeting a cross-entity entityList screen → no throw (list screens read URL search params for filter-prefill, fw#1708)", () => {
|
|
2911
|
+
const feature = defineFeature("housing", (r) => {
|
|
2912
|
+
r.entity("unit", createEntity({ fields: { id: createTextField() } }));
|
|
2913
|
+
r.entity("contract", createEntity({ fields: { unitId: createTextField() } }));
|
|
2914
|
+
r.screen({
|
|
2915
|
+
id: "unit-list",
|
|
2916
|
+
type: "entityList",
|
|
2917
|
+
entity: "unit",
|
|
2918
|
+
columns: ["id"],
|
|
2919
|
+
rowActions: [
|
|
2920
|
+
{
|
|
2921
|
+
kind: "navigate",
|
|
2922
|
+
id: "view-contracts",
|
|
2923
|
+
label: "actions.viewContracts",
|
|
2924
|
+
screen: "contract-list",
|
|
2925
|
+
params: { map: { "housing:contract-list.f.unitId": "id" } },
|
|
2926
|
+
},
|
|
2927
|
+
],
|
|
2928
|
+
});
|
|
2929
|
+
r.screen({
|
|
2930
|
+
id: "contract-list",
|
|
2931
|
+
type: "entityList",
|
|
2932
|
+
entity: "contract",
|
|
2933
|
+
columns: ["unitId"],
|
|
2934
|
+
});
|
|
2935
|
+
});
|
|
2936
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
2937
|
+
});
|
|
2938
|
+
|
|
2889
2939
|
test("navigate with params targeting a custom screen → no throw (author owns the component, may read searchParams itself)", () => {
|
|
2890
2940
|
const feature = defineFeature("shop", (r) => {
|
|
2891
2941
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
@@ -195,6 +195,29 @@ describe("userCanReadFieldRow() — multi-role OR", () => {
|
|
|
195
195
|
// row with mismatched teamId — TeamMember would fail, Admin passes
|
|
196
196
|
expect(userCanReadFieldRow(user, accessMap, { teamId: "ops" })).toBe(true);
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
// fw#1700: matchesRule() throws on a where-rule (SQL-layer only, can't
|
|
200
|
+
// evaluate in-memory). Field-level access is boot-validator-rejected for
|
|
201
|
+
// where-rules, but this function is also reachable from hand-rolled
|
|
202
|
+
// entity-level reads — a role backed by a where-rule must fail closed
|
|
203
|
+
// (deny, no throw) instead of crashing the caller with an uncaught 500.
|
|
204
|
+
test("role backed by a where-rule → fails closed (deny), does not throw", () => {
|
|
205
|
+
const whereMap: OwnershipMap = {
|
|
206
|
+
Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
207
|
+
};
|
|
208
|
+
const user = mkUser({ roles: ["Support"] });
|
|
209
|
+
expect(() => userCanReadFieldRow(user, whereMap, { teamId: "ops" })).not.toThrow();
|
|
210
|
+
expect(userCanReadFieldRow(user, whereMap, { teamId: "ops" })).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("where-rule role does not block a later 'all' role in the same access map", () => {
|
|
214
|
+
const mixedMap: OwnershipMap = {
|
|
215
|
+
Support: { kind: "where", where: () => ({ sqlText: "1=1", params: [] }) },
|
|
216
|
+
Admin: "all",
|
|
217
|
+
};
|
|
218
|
+
const user = mkUser({ roles: ["Support", "Admin"] });
|
|
219
|
+
expect(userCanReadFieldRow(user, mixedMap, { teamId: "ops" })).toBe(true);
|
|
220
|
+
});
|
|
198
221
|
});
|
|
199
222
|
|
|
200
223
|
// --- userCanWriteFieldRow() — STRADDLE PREVENTION ---
|
|
@@ -450,6 +450,26 @@ describe("buildInsertSchema", () => {
|
|
|
450
450
|
}
|
|
451
451
|
});
|
|
452
452
|
|
|
453
|
+
// Review-fix (kumiko-framework#1712): an optional select WITHOUT a default
|
|
454
|
+
// normalizes an untouched <select> to null (see the "unset (null)" test
|
|
455
|
+
// above). A client that reuses that null against a since-defaulted field
|
|
456
|
+
// must fall back to the default too, not get rejected as an invalid enum
|
|
457
|
+
// value the way a bare `null` previously was.
|
|
458
|
+
test("optional select with default accepts null and falls back to the default", () => {
|
|
459
|
+
const entity = createEntity({
|
|
460
|
+
table: "Test",
|
|
461
|
+
fields: {
|
|
462
|
+
locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
const schema = buildInsertSchema(entity);
|
|
466
|
+
const result = schema.safeParse({ locale: null });
|
|
467
|
+
expect(result.success).toBe(true);
|
|
468
|
+
if (result.success) {
|
|
469
|
+
expect(result.data["locale"]).toBe("de");
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
453
473
|
test("optional select with default still validates a real value", () => {
|
|
454
474
|
const entity = createEntity({
|
|
455
475
|
table: "Test",
|
|
@@ -549,11 +569,13 @@ describe("buildUpdateSchema", () => {
|
|
|
549
569
|
}
|
|
550
570
|
});
|
|
551
571
|
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
572
|
+
// fw#1703: buildUpdateSchema never applies defaults for an OMITTED field —
|
|
573
|
+
// omitting a field must leave it untouched. But an explicit `""` from an
|
|
574
|
+
// untouched <select> is a submission, not an omission, and "a field with a
|
|
575
|
+
// default is never unset" (same invariant the insert path documents at
|
|
576
|
+
// #1702) — so "" must map to the field's default on update too, not clobber
|
|
577
|
+
// an existing value to null.
|
|
578
|
+
test("optional select with default on update: empty string falls back to the default", () => {
|
|
557
579
|
const entity = createEntity({
|
|
558
580
|
table: "Test",
|
|
559
581
|
fields: {
|
|
@@ -565,7 +587,43 @@ describe("buildUpdateSchema", () => {
|
|
|
565
587
|
const result = schema.safeParse({ locale: "" });
|
|
566
588
|
expect(result.success).toBe(true);
|
|
567
589
|
if (result.success) {
|
|
568
|
-
expect(result.data["locale"]).
|
|
590
|
+
expect(result.data["locale"]).toBe("de");
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("optional select with default on update: omitting the field leaves it untouched", () => {
|
|
595
|
+
const entity = createEntity({
|
|
596
|
+
table: "Test",
|
|
597
|
+
fields: {
|
|
598
|
+
locale: createSelectField({ options: ["de", "en"] as const, default: "de" }),
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const schema = buildUpdateSchema(entity);
|
|
603
|
+
const result = schema.safeParse({});
|
|
604
|
+
expect(result.success).toBe(true);
|
|
605
|
+
if (result.success) {
|
|
606
|
+
expect(Object.hasOwn(result.data, "locale")).toBe(false);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("required select with default on update: empty string falls back to the default instead of a required-error", () => {
|
|
611
|
+
const entity = createEntity({
|
|
612
|
+
table: "Test",
|
|
613
|
+
fields: {
|
|
614
|
+
locale: createSelectField({
|
|
615
|
+
options: ["de", "en"] as const,
|
|
616
|
+
default: "de",
|
|
617
|
+
required: true,
|
|
618
|
+
}),
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const schema = buildUpdateSchema(entity);
|
|
623
|
+
const result = schema.safeParse({ locale: "" });
|
|
624
|
+
expect(result.success).toBe(true);
|
|
625
|
+
if (result.success) {
|
|
626
|
+
expect(result.data["locale"]).toBe("de");
|
|
569
627
|
}
|
|
570
628
|
});
|
|
571
629
|
});
|
|
@@ -1,32 +1,74 @@
|
|
|
1
|
+
import { normalizeAccessEntry } from "../ownership";
|
|
1
2
|
import type { FeatureDefinition } from "../types";
|
|
2
3
|
|
|
3
4
|
const BUILTIN_ROLES = new Set(["all", "system"]);
|
|
4
5
|
|
|
6
|
+
function addRole(roleHandlers: Map<string, Set<string>>, role: string, identifier: string): void {
|
|
7
|
+
let handlers = roleHandlers.get(role);
|
|
8
|
+
if (!handlers) {
|
|
9
|
+
handlers = new Set();
|
|
10
|
+
roleHandlers.set(role, handlers);
|
|
11
|
+
}
|
|
12
|
+
handlers.add(identifier);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function addHandlerRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
|
|
16
|
+
const handlerGroups = [
|
|
17
|
+
{ type: "write", defs: f.writeHandlers },
|
|
18
|
+
{ type: "query", defs: f.queryHandlers },
|
|
19
|
+
{ type: "stream", defs: f.streamHandlers },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
for (const { type, defs } of handlerGroups) {
|
|
23
|
+
for (const [handlerName, def] of Object.entries(defs)) {
|
|
24
|
+
if (!def.access || !("roles" in def.access)) continue;
|
|
25
|
+
const identifier = `${f.name}:${type}:${handlerName}`;
|
|
26
|
+
for (const role of def.access.roles) {
|
|
27
|
+
addRole(roleHandlers, role, identifier);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function addConfigKeyRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
|
|
34
|
+
for (const [key, keyDef] of Object.entries(f.configKeys ?? {})) {
|
|
35
|
+
const identifier = `${f.name}:config:${key}`;
|
|
36
|
+
for (const role of [...keyDef.access.read, ...keyDef.access.write]) {
|
|
37
|
+
addRole(roleHandlers, role, identifier);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function addEntityFieldRoles(roleHandlers: Map<string, Set<string>>, f: FeatureDefinition): void {
|
|
43
|
+
for (const [entityName, entity] of Object.entries(f.entities ?? {})) {
|
|
44
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
45
|
+
const identifier = `${f.name}:entity:${entityName}.${fieldName}`;
|
|
46
|
+
const readRoles = Object.keys(normalizeAccessEntry(field.access?.read) ?? {});
|
|
47
|
+
const writeRoles = Object.keys(normalizeAccessEntry(field.access?.write) ?? {});
|
|
48
|
+
for (const role of [...readRoles, ...writeRoles]) {
|
|
49
|
+
addRole(roleHandlers, role, identifier);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A single "exactly one handler" heuristic over-counts by construction:
|
|
56
|
+
// legitimate fine-grained roles (a role scoped to one admin endpoint on
|
|
57
|
+
// purpose) are the normal case, not a typo — and until every access
|
|
58
|
+
// surface is scanned, a role can look unique here while it's really used
|
|
59
|
+
// elsewhere (configKeys / entity+field access), a false positive in the
|
|
60
|
+
// other direction. Both is why this stays opt-in (#1711) rather than a
|
|
61
|
+
// default-on prod warning.
|
|
5
62
|
export function warnOnUniqueAccessRoles(features: readonly FeatureDefinition[]): void {
|
|
6
|
-
// role → set of distinct
|
|
63
|
+
// role → set of distinct identifiers using it, across every access
|
|
64
|
+
// surface — write/query/stream handlers, config-key access, and
|
|
65
|
+
// entity/field-level access rules.
|
|
7
66
|
const roleHandlers = new Map<string, Set<string>>();
|
|
8
67
|
|
|
9
68
|
for (const f of features) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
{ type: "stream", defs: f.streamHandlers },
|
|
14
|
-
] as const;
|
|
15
|
-
|
|
16
|
-
for (const { type, defs } of handlerGroups) {
|
|
17
|
-
for (const [handlerName, def] of Object.entries(defs)) {
|
|
18
|
-
if (!def.access || !("roles" in def.access)) continue;
|
|
19
|
-
const identifier = `${f.name}:${type}:${handlerName}`;
|
|
20
|
-
for (const role of def.access.roles) {
|
|
21
|
-
let handlers = roleHandlers.get(role);
|
|
22
|
-
if (!handlers) {
|
|
23
|
-
handlers = new Set();
|
|
24
|
-
roleHandlers.set(role, handlers);
|
|
25
|
-
}
|
|
26
|
-
handlers.add(identifier);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
69
|
+
addHandlerRoles(roleHandlers, f);
|
|
70
|
+
addConfigKeyRoles(roleHandlers, f);
|
|
71
|
+
addEntityFieldRoles(roleHandlers, f);
|
|
30
72
|
}
|
|
31
73
|
|
|
32
74
|
for (const [role, handlers] of roleHandlers) {
|
|
@@ -61,9 +61,12 @@ export const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
|
61
61
|
export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
62
62
|
"authorid",
|
|
63
63
|
"assigneeid",
|
|
64
|
+
"assigneeuserid",
|
|
64
65
|
"ownerid",
|
|
66
|
+
"createdby",
|
|
65
67
|
"createdbyid",
|
|
66
68
|
"createdbyuserid",
|
|
69
|
+
"updatedby",
|
|
67
70
|
"updatedbyid",
|
|
68
71
|
"updatedbyuserid",
|
|
69
72
|
"invitedby",
|
|
@@ -72,6 +75,7 @@ export const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string> = new Set([
|
|
|
72
75
|
"uploadedby",
|
|
73
76
|
"assignedto",
|
|
74
77
|
"reportedby",
|
|
78
|
+
"memberid",
|
|
75
79
|
]);
|
|
76
80
|
|
|
77
81
|
// --- Extension preSave wiring validation ---
|
|
@@ -61,11 +61,24 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
|
|
|
61
61
|
// dieselbe Extraktionslogik.
|
|
62
62
|
export { collectWriteHandlerQns } from "./nav";
|
|
63
63
|
|
|
64
|
+
export type ValidateBootOptions = {
|
|
65
|
+
/** Warn when an access role is used by exactly one handler/config-key/
|
|
66
|
+
* field across the whole boot scan — often a typo, but also the normal
|
|
67
|
+
* shape of a legitimate fine-grained role (one role scoped to one admin
|
|
68
|
+
* endpoint on purpose). Opt-in (default false, #1711): a default-on
|
|
69
|
+
* prod warning that nobody can silence per-role isn't worth the noise
|
|
70
|
+
* it generates on every boot. */
|
|
71
|
+
readonly warnOnUniqueAccessRoles?: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
64
74
|
/**
|
|
65
75
|
* Validates all feature configurations at boot time.
|
|
66
76
|
* Throws on the first error found — fail fast.
|
|
67
77
|
*/
|
|
68
|
-
export function validateBoot(
|
|
78
|
+
export function validateBoot(
|
|
79
|
+
features: readonly FeatureDefinition[],
|
|
80
|
+
options?: ValidateBootOptions,
|
|
81
|
+
): void {
|
|
69
82
|
const featureMap = new Map<string, FeatureDefinition>();
|
|
70
83
|
for (const f of features) {
|
|
71
84
|
featureMap.set(f.name, f);
|
|
@@ -213,5 +226,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
213
226
|
|
|
214
227
|
validateConfigReads(features, allConfigKeys);
|
|
215
228
|
warnOnToggleableDependencies(features, featureMap);
|
|
216
|
-
warnOnUniqueAccessRoles
|
|
229
|
+
if (options?.warnOnUniqueAccessRoles === true) {
|
|
230
|
+
warnOnUniqueAccessRoles(features);
|
|
231
|
+
}
|
|
217
232
|
}
|
|
@@ -21,6 +21,13 @@ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
|
|
|
21
21
|
// werden statt erst beim ersten Cleanup-Run.
|
|
22
22
|
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
23
23
|
|
|
24
|
+
// A field carries a subject binding — pii/userOwned/tenantOwned mark
|
|
25
|
+
// annotated content, subjectRef marks a bare FK into `user` with no
|
|
26
|
+
// annotated content of its own but the same Art.17 obligations (#1645).
|
|
27
|
+
function hasSubjectAnnotation(annot: PiiAnnotations): boolean {
|
|
28
|
+
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
25
32
|
//
|
|
26
33
|
// Drei Klassen von Checks:
|
|
@@ -133,11 +140,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
133
140
|
);
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
//
|
|
137
|
-
// sortable +
|
|
138
|
-
// #1610
|
|
139
|
-
//
|
|
140
|
-
// sensitive + searchable
|
|
143
|
+
// Sorting reads the projection column — that stays ciphertext, so
|
|
144
|
+
// sortable + subject annotation stays a boot-fail. searchable has
|
|
145
|
+
// been allowed since #1610: the search consumer decrypts into the
|
|
146
|
+
// derived index and forget purges those docs (see
|
|
147
|
+
// createSearchEventConsumer). sensitive + searchable stays forbidden
|
|
148
|
+
// (nobody-may-read-back).
|
|
141
149
|
{
|
|
142
150
|
const flags = field as {
|
|
143
151
|
readonly searchable?: boolean;
|
|
@@ -215,7 +223,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
215
223
|
} else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
|
|
216
224
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
217
225
|
console.warn(
|
|
218
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true },
|
|
226
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no { subjectRef: true } annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { subjectRef: true } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) — without the hook the V3 boot guard throws. Or { userOwned: { ownerField: "${fieldName}" } } on the field it owns. If business data, set { allowPlaintext: "..." } to silence.`,
|
|
219
227
|
);
|
|
220
228
|
}
|
|
221
229
|
}
|
|
@@ -245,10 +253,9 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
245
253
|
if (retention.strategy === "blockDelete") {
|
|
246
254
|
// blockDelete on an entity with no subject field is the correct
|
|
247
255
|
// "never auto-delete" choice; User-Forget never reaches those rows (#1622).
|
|
248
|
-
const hasSubjectField = Object.values(fieldsByName).some(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
});
|
|
256
|
+
const hasSubjectField = Object.values(fieldsByName).some(
|
|
257
|
+
(f) => hasSubjectAnnotation(f as PiiAnnotations), // @cast-boundary schema-walk
|
|
258
|
+
);
|
|
252
259
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
253
260
|
const a = f as PiiAnnotations; // @cast-boundary schema-walk
|
|
254
261
|
return Boolean(a.anonymize);
|
|
@@ -36,8 +36,16 @@ function validateRowActionNavigateParams(
|
|
|
36
36
|
): void {
|
|
37
37
|
// skip: not a navigate-with-params action — nothing to validate here.
|
|
38
38
|
if (action.kind !== "navigate" || action.params === undefined) return;
|
|
39
|
-
//
|
|
40
|
-
|
|
39
|
+
// entityList/projectionList targets also read URL search params (Tier
|
|
40
|
+
// 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
|
|
41
|
+
// .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
|
|
42
|
+
const exemptTargetType =
|
|
43
|
+
target === undefined ||
|
|
44
|
+
target.screen.type === "custom" ||
|
|
45
|
+
target.screen.type === "entityList" ||
|
|
46
|
+
target.screen.type === "projectionList";
|
|
47
|
+
// skip: unresolvable/custom/list target already reported (or exempt) elsewhere.
|
|
48
|
+
if (exemptTargetType) return;
|
|
41
49
|
|
|
42
50
|
const isEntityEditUpdate =
|
|
43
51
|
target.screen.type === "entityEdit" &&
|
package/src/engine/create-app.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateBoot } from "./boot-validator";
|
|
1
|
+
import { type ValidateBootOptions, validateBoot } from "./boot-validator";
|
|
2
2
|
import { createRegistry } from "./registry";
|
|
3
3
|
import type { FeatureDefinition, Registry } from "./types";
|
|
4
4
|
import { DEFAULT_CURRENCIES } from "./types";
|
|
@@ -8,6 +8,8 @@ export type AppConfig = {
|
|
|
8
8
|
features: readonly FeatureDefinition[];
|
|
9
9
|
softDelete?: boolean; // Global default for all entities (default: true)
|
|
10
10
|
currencies?: readonly string[]; // Extends DEFAULT_CURRENCIES
|
|
11
|
+
/** Opt-in boot-validator warnings — see ValidateBootOptions. */
|
|
12
|
+
validateBootOptions?: ValidateBootOptions;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export type App = {
|
|
@@ -98,7 +100,7 @@ export function createApp(config: AppConfig): App {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
// Run boot-time validation before creating registry
|
|
101
|
-
validateBoot(config.features);
|
|
103
|
+
validateBoot(config.features, config.validateBootOptions);
|
|
102
104
|
|
|
103
105
|
return {
|
|
104
106
|
registry: createRegistry(config.features),
|
package/src/engine/index.ts
CHANGED
|
@@ -205,6 +205,8 @@ export type { OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule } from
|
|
|
205
205
|
export {
|
|
206
206
|
buildOwnershipClause,
|
|
207
207
|
from,
|
|
208
|
+
normalizeAccessEntry,
|
|
209
|
+
userCanCreateFieldRow,
|
|
208
210
|
userCanReadFieldRow,
|
|
209
211
|
userCanWriteFieldRow,
|
|
210
212
|
} from "./ownership";
|
package/src/engine/ownership.ts
CHANGED
|
@@ -158,6 +158,12 @@ export function userCanReadFieldRow(
|
|
|
158
158
|
for (const role of user.roles) {
|
|
159
159
|
const rule = accessMap[role];
|
|
160
160
|
if (!rule) continue;
|
|
161
|
+
// where-rules are entity-level SQL predicates (buildOwnershipClause);
|
|
162
|
+
// matchesRule can't evaluate them in-memory and throws. Field-level
|
|
163
|
+
// access is boot-validator-rejected for where-rules, but this function
|
|
164
|
+
// is also reachable from hand-rolled entity-level reads.
|
|
165
|
+
// skip: where-rules are SQL-layer only — fail closed instead of throwing.
|
|
166
|
+
if (rule !== "all" && rule.kind === "where") continue;
|
|
161
167
|
if (matchesRule(rule, user, row)) return true;
|
|
162
168
|
}
|
|
163
169
|
return false;
|
|
@@ -180,6 +186,8 @@ export function userCanWriteFieldRow(
|
|
|
180
186
|
const rule = accessMap[role];
|
|
181
187
|
if (!rule) continue;
|
|
182
188
|
if (rule === "all") return true;
|
|
189
|
+
// skip: where-rules are SQL-layer only — fail closed instead of throwing.
|
|
190
|
+
if (rule.kind === "where") continue;
|
|
183
191
|
if (matchesRule(rule, user, oldRow) && matchesRule(rule, user, newRow)) return true;
|
|
184
192
|
}
|
|
185
193
|
return false;
|
|
@@ -272,6 +280,17 @@ export function shiftParams(fragment: SqlFragment, shift: number): SqlFragment {
|
|
|
272
280
|
// SQL names via the kumiko:schema:Columns symbol. Unknown column on a from-rule
|
|
273
281
|
// is a boot-time misconfiguration; at request time we treat it as empty
|
|
274
282
|
// (safe default) rather than passing silently.
|
|
283
|
+
//
|
|
284
|
+
// Caller obligations (fw#1700) — this function returns ONLY the ownership
|
|
285
|
+
// fragment, not the full row-access contract. A raw-SQL caller (not going
|
|
286
|
+
// through `ctx.db`, which already applies all three) must additionally:
|
|
287
|
+
// 1. Pass `paramStart` as `params.length + 1` for its own already-bound
|
|
288
|
+
// params, or `$N` placeholders in the returned fragment silently splice
|
|
289
|
+
// into the wrong query params.
|
|
290
|
+
// 2. Apply tenant + soft-delete scoping itself (event-store-executor-read.ts
|
|
291
|
+
// does this outside `buildOwnershipClause`) — this function does not.
|
|
292
|
+
// 3. Treat `kind: "empty"` (see `OwnershipClause`) as a hard DENY, and
|
|
293
|
+
// `kind: "pass"` as an explicit bypass — not as "no additional filter".
|
|
275
294
|
export function buildOwnershipClause(
|
|
276
295
|
user: SessionUser,
|
|
277
296
|
accessMap: OwnershipMap | undefined,
|
|
@@ -55,7 +55,17 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export function fieldToZod(
|
|
58
|
+
export function fieldToZod(
|
|
59
|
+
field: FieldDefinition,
|
|
60
|
+
currencies: readonly string[],
|
|
61
|
+
opts: { readonly applyDefaults?: boolean } = {},
|
|
62
|
+
): z.ZodTypeAny {
|
|
63
|
+
// Insert callers want `.default(...)` applied so an omitted field falls
|
|
64
|
+
// back to it; buildUpdateSchema passes applyDefaults: false so an omitted
|
|
65
|
+
// field on update stays omitted (a `{ title }` patch must not clobber
|
|
66
|
+
// other columns with their defaults) while a field's own default value is
|
|
67
|
+
// still known here for "" → default mapping (select case below).
|
|
68
|
+
const applyDefaults = opts.applyDefaults ?? true;
|
|
59
69
|
switch (field.type) {
|
|
60
70
|
case "text": {
|
|
61
71
|
let schema = z.string();
|
|
@@ -63,7 +73,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
63
73
|
if (field.format === "email") schema = schema.email();
|
|
64
74
|
if (field.format === "url") schema = schema.url();
|
|
65
75
|
if (field.required) schema = schema.min(1);
|
|
66
|
-
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
76
|
+
return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
|
|
67
77
|
}
|
|
68
78
|
case "longText": {
|
|
69
79
|
// longText hat keine `format`-Variante (per type-design). Nur
|
|
@@ -71,24 +81,32 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
71
81
|
let schema = z.string();
|
|
72
82
|
if (field.maxLength) schema = schema.max(field.maxLength);
|
|
73
83
|
if (field.required) schema = schema.min(1);
|
|
74
|
-
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
84
|
+
return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
|
|
75
85
|
}
|
|
76
86
|
case "boolean": {
|
|
77
87
|
const schema = z.boolean();
|
|
78
|
-
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
88
|
+
return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
|
|
79
89
|
}
|
|
80
90
|
case "select": {
|
|
81
91
|
const [first, ...rest] = field.options;
|
|
82
92
|
if (!first) return z.string();
|
|
83
93
|
const enumSchema = z.enum([first, ...rest]);
|
|
84
|
-
if (field.default !== undefined)
|
|
94
|
+
if (field.default !== undefined) {
|
|
85
95
|
// Untouched <select> sends "" too; with a default that maps to the
|
|
86
96
|
// default (same semantics as undefined) instead of the invalid-value
|
|
87
|
-
// rejection from #1702. A field with a default is never "unset"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
// rejection from #1702. A field with a default is never "unset" —
|
|
98
|
+
// true on both insert AND update, so this branch (and its "" → default
|
|
99
|
+
// mapping) fires regardless of applyDefaults; only the `.default(...)`
|
|
100
|
+
// schema-level fallback for OMITTED input is update-gated below.
|
|
101
|
+
// `null` maps the same way: the no-default branch below normalizes
|
|
102
|
+
// an untouched select to null, and a client that reuses that value
|
|
103
|
+
// against a since-defaulted field must not get rejected either.
|
|
104
|
+
const mapped = z.preprocess(
|
|
105
|
+
(value) => (value === "" || value === null ? field.default : value),
|
|
106
|
+
enumSchema,
|
|
91
107
|
);
|
|
108
|
+
return applyDefaults ? mapped.default(field.default) : mapped;
|
|
109
|
+
}
|
|
92
110
|
if (field.required) return enumSchema;
|
|
93
111
|
// Optional select without a default: an untouched HTML <select> submits
|
|
94
112
|
// "" for its placeholder option. Treat that as "unset" (null) instead of
|
|
@@ -105,7 +123,9 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
105
123
|
// in buildInsertSchema kümmert sich um „darf fehlen".
|
|
106
124
|
let schema = z.array(z.enum([first, ...rest]));
|
|
107
125
|
if (field.required) schema = schema.min(1);
|
|
108
|
-
return field.default !== undefined
|
|
126
|
+
return field.default !== undefined && applyDefaults
|
|
127
|
+
? schema.default([...field.default])
|
|
128
|
+
: schema;
|
|
109
129
|
}
|
|
110
130
|
case "number": {
|
|
111
131
|
let schema = z.number();
|
|
@@ -115,7 +135,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
115
135
|
if (field.integer) schema = schema.int().min(-2147483648).max(2147483647);
|
|
116
136
|
if (field.min !== undefined) schema = schema.min(field.min);
|
|
117
137
|
if (field.max !== undefined) schema = schema.max(field.max);
|
|
118
|
-
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
138
|
+
return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
|
|
119
139
|
}
|
|
120
140
|
case "decimal": {
|
|
121
141
|
// Stored as numeric(precision, scale), surfaced as JS number. Bound the
|
|
@@ -129,7 +149,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
129
149
|
.refine((n) => isRepresentableAtScale(n, field.scale), {
|
|
130
150
|
message: `at most ${field.scale} decimal places`,
|
|
131
151
|
});
|
|
132
|
-
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
152
|
+
return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
|
|
133
153
|
}
|
|
134
154
|
case "bigInt": {
|
|
135
155
|
// JS-`number`-Round-trip via mode:"number"; sicher bis 2^53.
|
|
@@ -137,7 +157,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
137
157
|
// Float reinwirft (z.B. parseFloat-Bug), beim Insert sofort
|
|
138
158
|
// failed statt silent-Truncation zu kassieren.
|
|
139
159
|
const schema = z.number().int().safe();
|
|
140
|
-
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
160
|
+
return field.default !== undefined && applyDefaults ? schema.default(field.default) : schema;
|
|
141
161
|
}
|
|
142
162
|
case "money": {
|
|
143
163
|
const [first, ...rest] = currencies;
|
|
@@ -245,14 +265,15 @@ export function buildUpdateSchema(
|
|
|
245
265
|
const shape: Record<string, z.ZodTypeAny> = {};
|
|
246
266
|
|
|
247
267
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
248
|
-
// Update schemas never apply defaults — a user that
|
|
249
|
-
// `{ title }` means "only change title"; zod defaults would
|
|
250
|
-
// inject default values for every omitted field and clobber
|
|
251
|
-
// data via the event-store-executor's `changes` payload.
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
268
|
+
// Update schemas never apply defaults for OMITTED fields — a user that
|
|
269
|
+
// sends only `{ title }` means "only change title"; zod defaults would
|
|
270
|
+
// silently inject default values for every omitted field and clobber
|
|
271
|
+
// existing data via the event-store-executor's `changes` payload.
|
|
272
|
+
// The field is passed through un-stripped (unlike before fw#1703) so
|
|
273
|
+
// fieldToZod still knows the default for its "" → default mapping
|
|
274
|
+
// (e.g. select) — applyDefaults: false only suppresses the schema-level
|
|
275
|
+
// `.default(...)` fallback for a genuinely omitted key.
|
|
276
|
+
shape[name] = fieldToZod(field, currencies, { applyDefaults: false }).optional();
|
|
256
277
|
}
|
|
257
278
|
|
|
258
279
|
return z.object(shape);
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -142,11 +142,8 @@ export type WorkerEntrypoint = {
|
|
|
142
142
|
readonly eventDispatcher: EventDispatcher;
|
|
143
143
|
readonly jobRunner: JobRunner;
|
|
144
144
|
readonly observability: ObservabilityProvider;
|
|
145
|
-
// Same dispatcher the API process exposes
|
|
146
|
-
//
|
|
147
|
-
// and must persist their result need it: JobContext has no write/query
|
|
148
|
-
// (handlers.ts JobContext), so writing goes through dispatchSystemWrite,
|
|
149
|
-
// the pattern inbound-mail-foundation/watch-supervisor.ts established.
|
|
145
|
+
// Same dispatcher the API process exposes. Background components in the
|
|
146
|
+
// worker persist through the write-path — JobContext has no write/query.
|
|
150
147
|
readonly dispatcher: Dispatcher;
|
|
151
148
|
readonly mode: "worker";
|
|
152
149
|
// Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { schedulerIdForJobName } from "../job-runner";
|
|
2
|
+
import { bootJobIdForJobName, schedulerIdForJobName } from "../job-runner";
|
|
3
3
|
|
|
4
4
|
describe("schedulerIdForJobName", () => {
|
|
5
5
|
test("strips dots and colons so BullMQ job ids stay under the 5-segment legacy heuristic", () => {
|
|
@@ -16,3 +16,15 @@ describe("schedulerIdForJobName", () => {
|
|
|
16
16
|
expect(schedulerIdForJobName("app.job.tick")).toBe("scheduler-app-job-tick");
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
describe("bootJobIdForJobName", () => {
|
|
21
|
+
test("strips colons, same hazard as schedulerIdForJobName (fw#1604)", () => {
|
|
22
|
+
const id = bootJobIdForJobName("publicstatus:job:uptime-probe");
|
|
23
|
+
expect(id).toBe("boot-publicstatus-job-uptime-probe");
|
|
24
|
+
expect(id.includes(":")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("still collapses dotted QNs", () => {
|
|
28
|
+
expect(bootJobIdForJobName("app.job.tick")).toBe("boot-app-job-tick");
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -45,6 +45,12 @@ export function schedulerIdForJobName(jobName: string): string {
|
|
|
45
45
|
return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Same colon-in-BullMQ-id hazard as schedulerIdForJobName (fw#1603/#1604) —
|
|
49
|
+
// a QN like "publicstatus:job:uptime-probe" must not leave ":" in the id.
|
|
50
|
+
export function bootJobIdForJobName(jobName: string): string {
|
|
51
|
+
return `boot-${jobName.replace(/[.:]/g, "-")}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
// ponytail: migration shim, remove after fw#1603 deploy is everywhere.
|
|
49
55
|
function legacySchedulerIdForJobName(jobName: string): string {
|
|
50
56
|
return `scheduler-${jobName.replace(/\./g, "-")}`;
|
|
@@ -518,7 +524,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
518
524
|
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
519
525
|
if (jobDef.runOnBoot) {
|
|
520
526
|
const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
|
|
521
|
-
await consumerQueue.add(bootName, {}, { jobId:
|
|
527
|
+
await consumerQueue.add(bootName, {}, { jobId: bootJobIdForJobName(name) });
|
|
522
528
|
}
|
|
523
529
|
}
|
|
524
530
|
|
|
@@ -111,9 +111,22 @@ export async function decryptSearchableSubjectFields(
|
|
|
111
111
|
}
|
|
112
112
|
return out;
|
|
113
113
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
try {
|
|
115
|
+
return await decryptPiiFieldValues(state, fields, kms, {
|
|
116
|
+
requestId: "system:consumer:search",
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.warn(
|
|
120
|
+
`[kumiko:search] decryptSearchableSubjectFields failed for "${entityName}" — ` +
|
|
121
|
+
`dropping ciphertext fields for this document instead of wedging the consumer.`,
|
|
122
|
+
err,
|
|
123
|
+
);
|
|
124
|
+
const out = { ...state };
|
|
125
|
+
for (const name of fields) {
|
|
126
|
+
if (isPiiCiphertext(out[name])) delete out[name];
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
export function hasErasedSearchableSubjectField(
|
package/src/schema-cli.ts
CHANGED
|
@@ -289,11 +289,13 @@ export async function runSchemaCli(
|
|
|
289
289
|
}
|
|
290
290
|
if (mismatches.some((m) => m.kind === "unexpected-table")) {
|
|
291
291
|
out.err(
|
|
292
|
-
" Fix (unexpected-table):
|
|
293
|
-
"
|
|
294
|
-
"
|
|
295
|
-
"
|
|
296
|
-
"
|
|
292
|
+
" Fix (unexpected-table): build a meta via `defineUnmanagedTable()` " +
|
|
293
|
+
"from `@cosmicdrift/kumiko-framework/db`, then `r.storeTable(meta, { reason: ... })` " +
|
|
294
|
+
"inside a feature — this adds it to ENTITY_METAS immediately; the .snapshot.json " +
|
|
295
|
+
"only picks it up on the NEXT `kumiko-schema generate` run, which you still need " +
|
|
296
|
+
"to run and commit. `table()` returns a query handle, not a storeTable()-compatible " +
|
|
297
|
+
"meta — don't pass its result to storeTable(). See the bundled `jobs` feature's " +
|
|
298
|
+
"job-run-log store table for the pattern.",
|
|
297
299
|
);
|
|
298
300
|
}
|
|
299
301
|
if (mismatches.some((m) => m.kind !== "unexpected-table")) {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// 2. Ciphertext LIKE prefix (same as nullBlindIndexesForSubject) for rows
|
|
9
9
|
// that still carry the subject key in encrypted columns.
|
|
10
10
|
|
|
11
|
+
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
11
12
|
import type { SubjectId } from "../crypto/kms-adapter";
|
|
12
13
|
import { collectSearchableSubjectFields } from "../crypto/subject-resolver";
|
|
13
14
|
import type { DbRunner } from "../db/connection";
|
|
@@ -19,14 +20,6 @@ import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
|
19
20
|
import { toSnakeCase } from "../utils/case";
|
|
20
21
|
import type { SearchAdapter } from "./types";
|
|
21
22
|
|
|
22
|
-
function quoteIdent(name: string): string {
|
|
23
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function escapeLikePattern(value: string): string {
|
|
27
|
-
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
23
|
/** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
|
|
31
24
|
function ownershipPredicates(
|
|
32
25
|
entity: EntityDefinition,
|
|
@@ -76,7 +69,7 @@ export async function purgeSearchDocumentsForSubject(
|
|
|
76
69
|
/** When set, also match rows by ownership — needed after anonymize rewrites ciphertext. */
|
|
77
70
|
subject?: SubjectId,
|
|
78
71
|
): Promise<void> {
|
|
79
|
-
const likePattern =
|
|
72
|
+
const likePattern = subjectCiphertextLikePattern(subjectKey);
|
|
80
73
|
const byTenant = new Map<string, { entityType: string; entityId: EntityId }[]>();
|
|
81
74
|
const seen = new Set<string>();
|
|
82
75
|
|
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
import { hkdfSync } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// blast radius if the deploy is compromised), it is just more operations, and
|
|
10
|
-
// in practice one of them ends up unset in some environment.
|
|
11
|
-
//
|
|
12
|
-
// The purpose string is a domain separator and part of the contract: change it
|
|
13
|
-
// and every previously issued token for that purpose stops verifying. Version
|
|
14
|
-
// them ("mfa-setup-token-v1") so a single purpose can be rotated deliberately
|
|
15
|
-
// without touching the master or the other purposes.
|
|
16
|
-
//
|
|
17
|
-
// Lived copy-pasted in four apps before fw#1623 (money-horse, kumiko-studio,
|
|
18
|
-
// publicstatus, plus a stale worktree) — identical bodies, drifting comments.
|
|
3
|
+
// HKDF turns one master secret into an independent secret per purpose, so
|
|
4
|
+
// a token-signing key for MFA setup can't forge a deletion token and
|
|
5
|
+
// neither can be walked back to the master. `purpose` is a domain
|
|
6
|
+
// separator, not a label — changing it invalidates every previously
|
|
7
|
+
// issued token for that purpose; version it ("mfa-setup-token-v1") to
|
|
8
|
+
// rotate one purpose deliberately.
|
|
19
9
|
export function derivePurposeSecret(masterSecret: string, purpose: string): string {
|
|
20
10
|
if (!masterSecret) {
|
|
21
11
|
throw new Error("derivePurposeSecret: masterSecret must not be empty.");
|
|
@@ -259,4 +259,11 @@ describe("generateZodFixture", () => {
|
|
|
259
259
|
expect(() => generateZodFixture(z.object({}))).toThrow(/not supported yet/);
|
|
260
260
|
expect(() => generateZodFixture(z.array(z.string()))).toThrow(/not supported yet/);
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
test("pipe (select-with-default, same shape schema-builder's z.preprocess produces) unwraps to the underlying type's fixture (fw#1712)", () => {
|
|
264
|
+
const enumSchema = z.enum(["a", "b"]);
|
|
265
|
+
const pipe = z.preprocess((value) => (value === "" ? "a" : value), enumSchema);
|
|
266
|
+
expect(pipe._def.type).toBe("pipe");
|
|
267
|
+
expect(generateZodFixture(pipe)).toBe("a");
|
|
268
|
+
});
|
|
262
269
|
});
|
|
@@ -9,11 +9,11 @@ describe("waitFor", () => {
|
|
|
9
9
|
() => {
|
|
10
10
|
calls++;
|
|
11
11
|
},
|
|
12
|
-
{ delays: [
|
|
12
|
+
{ delays: [2000] },
|
|
13
13
|
);
|
|
14
14
|
expect(calls).toBe(1);
|
|
15
15
|
// try-first: must not burn the first delay when the condition already holds
|
|
16
|
-
expect(Date.now() - started).toBeLessThan(
|
|
16
|
+
expect(Date.now() - started).toBeLessThan(500);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test("retries on failure and succeeds once fn passes", async () => {
|
|
@@ -355,6 +355,7 @@ type ZodInternals = {
|
|
|
355
355
|
readonly format?: string;
|
|
356
356
|
readonly innerType?: z.ZodTypeAny;
|
|
357
357
|
readonly entries?: Record<string, string>;
|
|
358
|
+
readonly out?: z.ZodTypeAny;
|
|
358
359
|
};
|
|
359
360
|
|
|
360
361
|
function readZodInternals(schema: z.ZodTypeAny): ZodInternals | undefined {
|
|
@@ -384,6 +385,10 @@ export function generateZodFixture(schema: z.ZodTypeAny): unknown {
|
|
|
384
385
|
}
|
|
385
386
|
case "date":
|
|
386
387
|
return new Date("2026-01-01T00:00:00Z");
|
|
388
|
+
case "pipe": {
|
|
389
|
+
if (!def?.out) throw new Error("zod pipe without out");
|
|
390
|
+
return generateZodFixture(def.out);
|
|
391
|
+
}
|
|
387
392
|
default:
|
|
388
393
|
throw new Error(`generateZodFixture: not supported yet: ${typeName ?? "<unknown>"}`);
|
|
389
394
|
}
|
|
@@ -31,15 +31,15 @@ export const sharedUserEntity = createEntity({
|
|
|
31
31
|
required: true,
|
|
32
32
|
format: "email",
|
|
33
33
|
searchable: true,
|
|
34
|
-
allowPlaintext: "
|
|
34
|
+
allowPlaintext: "test-fixture",
|
|
35
35
|
}),
|
|
36
36
|
firstName: createTextField({
|
|
37
37
|
searchable: true,
|
|
38
|
-
allowPlaintext: "
|
|
38
|
+
allowPlaintext: "test-fixture",
|
|
39
39
|
}),
|
|
40
40
|
lastName: createTextField({
|
|
41
41
|
searchable: true,
|
|
42
|
-
allowPlaintext: "
|
|
42
|
+
allowPlaintext: "test-fixture",
|
|
43
43
|
}),
|
|
44
44
|
isEnabled: createBooleanField({ default: true }),
|
|
45
45
|
},
|