@cosmicdrift/kumiko-framework 0.174.0 → 0.176.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +11 -2
- package/src/crypto/ciphertext-pattern.ts +19 -0
- package/src/db/__tests__/entity-table-from-registry.test.ts +73 -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-from-registry.ts +31 -0
- package/src/db/entity-table-meta.ts +6 -1
- package/src/db/event-store-executor-write.ts +6 -1
- package/src/db/index.ts +1 -0
- 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
|
@@ -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 () => {
|