@cosmicdrift/kumiko-framework 0.155.1 → 0.156.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/README.md +9 -38
- package/package.json +2 -2
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/raw-table.integration.test.ts +17 -40
- package/src/crypto/__tests__/pii-field-encryption.test.ts +105 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +19 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
- package/src/db/__tests__/blind-index.integration.test.ts +16 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/event-store-executor.integration.test.ts +75 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/event-store-executor-write.ts +23 -2
- package/src/db/event-store-executor.ts +1 -1
- package/src/db/feature-table-sources.ts +5 -11
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/queries/shadow-swap.ts +85 -3
- package/src/db/tenant-db.ts +8 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/raw-table.test.ts +134 -84
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -2
- package/src/engine/boot-validator/config-deps.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/pii-retention.ts +35 -3
- package/src/engine/build-config-feature-schema.ts +3 -3
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +2 -6
- package/src/engine/entity-handlers.ts +11 -5
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +222 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +32 -24
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-ast/render.ts +16 -11
- package/src/engine/feature-builder-state.ts +0 -3
- package/src/engine/feature-config-events-jobs.ts +3 -6
- package/src/engine/feature-entity-handlers.ts +9 -1
- package/src/engine/feature-ui-extensions.ts +10 -40
- package/src/engine/field-access.ts +13 -2
- package/src/engine/object-form.ts +15 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/registry-ingest.ts +19 -29
- package/src/engine/registry-state.ts +1 -7
- package/src/engine/registry-validate.ts +5 -28
- package/src/engine/registry.ts +0 -2
- package/src/engine/types/config.ts +7 -0
- package/src/engine/types/feature.ts +40 -68
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +0 -3
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +29 -0
- package/src/pipeline/msp-rebuild.ts +4 -0
- package/src/pipeline/projection-rebuild.ts +35 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
- package/src/stack/test-stack.ts +17 -2
- package/src/ui-types/index.ts +1 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
- package/src/engine/feature-ast/extractors/round4.ts +0 -1227
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
SearchPayloadContributorFn,
|
|
20
20
|
TreeActionDef,
|
|
21
21
|
TreeActionsHandle,
|
|
22
|
-
UnmanagedTableOptions,
|
|
23
22
|
ValidationHookFn,
|
|
24
23
|
} from "./types";
|
|
25
24
|
import { HookPhases } from "./types";
|
|
@@ -417,39 +416,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
417
416
|
}
|
|
418
417
|
state.httpRoutes[key] = definition;
|
|
419
418
|
},
|
|
420
|
-
rawTable(
|
|
421
|
-
// Same kebab guard as r.projection / r.screen / r.nav so authoring-time
|
|
422
|
-
// mistakes surface at the feature file, not deep in registry boot.
|
|
423
|
-
if (!isKebabSegment(rawTableName)) {
|
|
424
|
-
throw new Error(
|
|
425
|
-
`[Feature ${name}] Raw-table name "${rawTableName}" must be kebab-case ` +
|
|
426
|
-
`(lowercase letters, digits, dashes; start with a letter). ` +
|
|
427
|
-
`Got "${rawTableName}" — try "${toKebab(rawTableName).replace(/_/g, "-")}".`,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
if (state.rawTables[rawTableName]) {
|
|
431
|
-
throw new Error(
|
|
432
|
-
`[Feature ${name}] r.rawTable("${rawTableName}") already registered. ` +
|
|
433
|
-
`Raw-table names must be unique per feature.`,
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
// The `reason` is the marker that justifies the bypass — empty
|
|
437
|
-
// strings would defeat the audit trail. Reject early so the
|
|
438
|
-
// failure points at the feature file.
|
|
439
|
-
if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
|
|
440
|
-
throw new Error(
|
|
441
|
-
`[Feature ${name}] r.rawTable("${rawTableName}"): options.reason must be a ` +
|
|
442
|
-
`non-empty string. The reason is the marker that justifies the bypass — ` +
|
|
443
|
-
`if you can't write one, declare data via r.entity() instead.`,
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
state.rawTables[rawTableName] = {
|
|
447
|
-
name: rawTableName,
|
|
448
|
-
table,
|
|
449
|
-
reason: options.reason,
|
|
450
|
-
};
|
|
451
|
-
},
|
|
452
|
-
unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void {
|
|
419
|
+
rawTable(meta: EntityTableMeta, options: RawTableOptions): void {
|
|
453
420
|
// Name comes from the meta itself — apps already give the table a
|
|
454
421
|
// name when calling defineUnmanagedTable, no need to repeat it.
|
|
455
422
|
const tableName = meta.tableName;
|
|
@@ -457,24 +424,27 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
457
424
|
// EntityTableMeta uses snake_case for tableName (matches Postgres
|
|
458
425
|
// convention); we just guard against truly broken input.
|
|
459
426
|
throw new Error(
|
|
460
|
-
`[Feature ${name}]
|
|
427
|
+
`[Feature ${name}] Raw-table name "${tableName}" must be a ` +
|
|
461
428
|
`valid identifier (lowercase letters, digits, underscores; start with a letter).`,
|
|
462
429
|
);
|
|
463
430
|
}
|
|
464
|
-
if (state.
|
|
431
|
+
if (state.rawTables[tableName]) {
|
|
465
432
|
throw new Error(
|
|
466
|
-
`[Feature ${name}] r.
|
|
467
|
-
`
|
|
433
|
+
`[Feature ${name}] r.rawTable("${tableName}") already registered. ` +
|
|
434
|
+
`Raw-table names must be unique per feature.`,
|
|
468
435
|
);
|
|
469
436
|
}
|
|
437
|
+
// The `reason` is the marker that justifies the bypass — empty
|
|
438
|
+
// strings would defeat the audit trail. Reject early so the
|
|
439
|
+
// failure points at the feature file.
|
|
470
440
|
if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
|
|
471
441
|
throw new Error(
|
|
472
|
-
`[Feature ${name}] r.
|
|
442
|
+
`[Feature ${name}] r.rawTable("${tableName}"): options.reason must be a ` +
|
|
473
443
|
`non-empty string. The reason justifies the audit-trail bypass — ` +
|
|
474
444
|
`if you can't write one, declare data via r.entity() instead.`,
|
|
475
445
|
);
|
|
476
446
|
}
|
|
477
|
-
state.
|
|
447
|
+
state.rawTables[tableName] = {
|
|
478
448
|
name: tableName,
|
|
479
449
|
meta,
|
|
480
450
|
reason: options.reason,
|
|
@@ -2,6 +2,13 @@ import type { DbRow } from "../db/connection";
|
|
|
2
2
|
import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership";
|
|
3
3
|
import type { EntityDefinition, SessionUser } from "./types";
|
|
4
4
|
|
|
5
|
+
// piiEncrypted fields deliberately break that silence (kumiko-platform#463):
|
|
6
|
+
// the field's whole point is "a legitimate reader may see the plaintext",
|
|
7
|
+
// so an unauthorized reader seeing the field exist-but-masked is the
|
|
8
|
+
// intended signal, not a leak — unlike a secret whose existence itself
|
|
9
|
+
// should stay hidden.
|
|
10
|
+
export const PII_MASKED_VALUE = "••••••";
|
|
11
|
+
|
|
5
12
|
// Field-level read filtering. Returns a copy of `data` with fields stripped
|
|
6
13
|
// if the user's roles don't grant read access OR the ownership-rule for the
|
|
7
14
|
// matching role doesn't accept this concrete row. Fields without access
|
|
@@ -11,7 +18,8 @@ import type { EntityDefinition, SessionUser } from "./types";
|
|
|
11
18
|
// one place in the ownership system where silence is the right default:
|
|
12
19
|
// reporting "you tried to read X but can't" leaks the field's existence.
|
|
13
20
|
// Writes do the opposite (loud error) because a silent drop there masks
|
|
14
|
-
// save-bugs.
|
|
21
|
+
// save-bugs. piiEncrypted fields are the one exception — see
|
|
22
|
+
// PII_MASKED_VALUE above.
|
|
15
23
|
export function filterReadFields(
|
|
16
24
|
entity: EntityDefinition,
|
|
17
25
|
data: Readonly<Record<string, unknown>>,
|
|
@@ -29,7 +37,10 @@ export function filterReadFields(
|
|
|
29
37
|
|
|
30
38
|
const accessMap = normalizeAccessEntry(field.access?.read);
|
|
31
39
|
if (!userCanReadFieldRow(user, accessMap, data)) {
|
|
32
|
-
|
|
40
|
+
if ("piiEncrypted" in field && field.piiEncrypted === true) {
|
|
41
|
+
result[key] = PII_MASKED_VALUE;
|
|
42
|
+
}
|
|
43
|
+
continue; // entire field stripped (masked instead, for piiEncrypted)
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
// For embedded fields: filter sub-fields with access restrictions
|
|
@@ -10,3 +10,18 @@ export function splitNamedDefinition<T extends { readonly name: string }>(
|
|
|
10
10
|
const { name, ...rest } = definition;
|
|
11
11
|
return [name, rest];
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
// Shared helper for registrar methods that accept either variadic strings
|
|
15
|
+
// (hand-written call sites) or a single Object-Form object wrapping the
|
|
16
|
+
// same string array under `key` (the feature-ast renderer's canonical
|
|
17
|
+
// shape for Designer/AI-generated code).
|
|
18
|
+
export function unwrapArrayForm<K extends string>(
|
|
19
|
+
args: readonly [Record<K, readonly string[]>] | readonly string[],
|
|
20
|
+
key: K,
|
|
21
|
+
): readonly string[] {
|
|
22
|
+
const [first] = args;
|
|
23
|
+
if (typeof first === "object" && first !== null && key in first) {
|
|
24
|
+
return (first as Record<K, readonly string[]>)[key];
|
|
25
|
+
}
|
|
26
|
+
return args as readonly string[];
|
|
27
|
+
}
|
|
@@ -68,6 +68,10 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
68
68
|
return state.queryHandlerMap.get(name);
|
|
69
69
|
},
|
|
70
70
|
|
|
71
|
+
getAllQueryHandlers(): ReadonlyMap<string, QueryHandlerDef> {
|
|
72
|
+
return state.queryHandlerMap;
|
|
73
|
+
},
|
|
74
|
+
|
|
71
75
|
getSearchableFields(entityName: string): readonly string[] {
|
|
72
76
|
return state.searchableFieldsCache.get(entityName) ?? [];
|
|
73
77
|
},
|
|
@@ -20,10 +20,10 @@ export function populateFeatureCore(state: RegistryState, feature: FeatureDefini
|
|
|
20
20
|
state.entityMap.set(name, entity);
|
|
21
21
|
const physical = resolveTableName(name, entity, feature.name);
|
|
22
22
|
const clash = state.physicalTableOwners.get(physical);
|
|
23
|
-
if (clash?.kind === "
|
|
23
|
+
if (clash?.kind === "raw") {
|
|
24
24
|
throw new Error(
|
|
25
25
|
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
26
|
-
`collides with r.
|
|
26
|
+
`collides with r.rawTable("${physical}") (feature "${clash.featureName}"). ` +
|
|
27
27
|
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
@@ -145,12 +145,14 @@ export function populateEvents(state: RegistryState, feature: FeatureDefinition)
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
// Translations prefixed with featureName: (i18next namespace convention).
|
|
149
148
|
export function populateTranslations(state: RegistryState, feature: FeatureDefinition): void {
|
|
150
149
|
// Translations prefixed with featureName: (i18next namespace convention).
|
|
151
150
|
// Keys that already carry the feature's own namespace prefix (e.g. a nav
|
|
152
151
|
// label referencing "cap-counter:nav.cap-list" verbatim) must NOT be
|
|
153
|
-
// re-prefixed, else server-side t() can never resolve them (#1105).
|
|
152
|
+
// re-prefixed, else server-side t() can never resolve them (#1105). Both
|
|
153
|
+
// bare and qualified spellings for the same key are accepted; if a
|
|
154
|
+
// feature defines both, whichever is declared last wins (Object.entries
|
|
155
|
+
// = insertion order, not "qualified always wins").
|
|
154
156
|
const prefix = `${feature.name}:`;
|
|
155
157
|
for (const [key, value] of Object.entries(feature.translations ?? {})) {
|
|
156
158
|
const qualifiedKey = key.startsWith(prefix) ? key : `${prefix}${key}`;
|
|
@@ -302,7 +304,9 @@ export function populateProjectionsAndTables(
|
|
|
302
304
|
// these bypass the qualified-name namespace because they have no
|
|
303
305
|
// event-stream binding to disambiguate). Reject cross-feature
|
|
304
306
|
// duplicates at boot so the dev-server doesn't race two CREATE TABLE
|
|
305
|
-
// statements that target the same physical table name.
|
|
307
|
+
// statements that target the same physical table name. Two features
|
|
308
|
+
// registering the same physical tableName would also race two CREATE
|
|
309
|
+
// TABLE statements via migrate-runner.
|
|
306
310
|
for (const [rawName, rawDef] of Object.entries(feature.rawTables ?? {})) {
|
|
307
311
|
const existing = state.rawTableMap.get(rawName);
|
|
308
312
|
if (existing) {
|
|
@@ -311,43 +315,29 @@ export function populateProjectionsAndTables(
|
|
|
311
315
|
`"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
|
|
312
316
|
);
|
|
313
317
|
}
|
|
314
|
-
state.
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
|
|
318
|
-
// Two features registering the same physical tableName would race two
|
|
319
|
-
// CREATE TABLE statements via migrate-runner.
|
|
320
|
-
for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
|
|
321
|
-
const existing = state.unmanagedTableMap.get(umName);
|
|
322
|
-
if (existing) {
|
|
323
|
-
throw new Error(
|
|
324
|
-
`Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
|
|
325
|
-
`"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
const physicalClash = state.physicalTableOwners.get(umName);
|
|
318
|
+
const physicalClash = state.physicalTableOwners.get(rawName);
|
|
329
319
|
if (physicalClash?.kind === "entity") {
|
|
330
320
|
throw new Error(
|
|
331
|
-
`
|
|
321
|
+
`Raw-table "${rawName}" (feature "${feature.name}") collides with the physical ` +
|
|
332
322
|
`table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
|
|
333
|
-
`Pick a different tableName — both would emit CREATE TABLE "${
|
|
323
|
+
`Pick a different tableName — both would emit CREATE TABLE "${rawName}".`,
|
|
334
324
|
);
|
|
335
325
|
}
|
|
336
|
-
const piiFields =
|
|
337
|
-
if (piiFields.length > 0 && !
|
|
326
|
+
const piiFields = rawDef.meta.piiSubjectFields ?? [];
|
|
327
|
+
if (piiFields.length > 0 && !rawDef.piiEncryptedOnWrite) {
|
|
338
328
|
throw new Error(
|
|
339
|
-
`
|
|
329
|
+
`Raw-table "${rawName}" (feature "${feature.name}") has PII-annotated fields ` +
|
|
340
330
|
`(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
|
|
341
331
|
`Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
|
|
342
332
|
`{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
|
|
343
333
|
);
|
|
344
334
|
}
|
|
345
|
-
state.physicalTableOwners.set(
|
|
346
|
-
kind: "
|
|
347
|
-
owner:
|
|
335
|
+
state.physicalTableOwners.set(rawName, {
|
|
336
|
+
kind: "raw",
|
|
337
|
+
owner: rawName,
|
|
348
338
|
featureName: feature.name,
|
|
349
339
|
});
|
|
350
|
-
state.
|
|
340
|
+
state.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
|
|
351
341
|
}
|
|
352
342
|
}
|
|
353
343
|
|
|
@@ -38,7 +38,6 @@ import type {
|
|
|
38
38
|
SearchPayloadContributorFn,
|
|
39
39
|
SecretKeyDefinition,
|
|
40
40
|
TreeActionDef,
|
|
41
|
-
UnmanagedTableDef,
|
|
42
41
|
WorkspaceDefinition,
|
|
43
42
|
WriteHandlerDef,
|
|
44
43
|
} from "./types";
|
|
@@ -201,11 +200,7 @@ export type RegistryState = {
|
|
|
201
200
|
multiStreamProjectionMap: Map<string, MultiStreamProjectionDefinition>;
|
|
202
201
|
multiStreamProjectionFeatureMap: Map<string, string>;
|
|
203
202
|
rawTableMap: Map<string, RawTableDef>;
|
|
204
|
-
|
|
205
|
-
physicalTableOwners: Map<
|
|
206
|
-
string,
|
|
207
|
-
{ kind: "entity" | "unmanaged"; owner: string; featureName: string }
|
|
208
|
-
>;
|
|
203
|
+
physicalTableOwners: Map<string, { kind: "entity" | "raw"; owner: string; featureName: string }>;
|
|
209
204
|
authClaimsHooks: AuthClaimsHookDef[];
|
|
210
205
|
claimKeyMap: Map<string, ClaimKeyDefinition>;
|
|
211
206
|
screenMap: Map<string, ScreenDefinition>;
|
|
@@ -266,7 +261,6 @@ export function createInitialState(): RegistryState {
|
|
|
266
261
|
multiStreamProjectionMap: new Map(),
|
|
267
262
|
multiStreamProjectionFeatureMap: new Map(),
|
|
268
263
|
rawTableMap: new Map(),
|
|
269
|
-
unmanagedTableMap: new Map(),
|
|
270
264
|
physicalTableOwners: new Map(),
|
|
271
265
|
authClaimsHooks: [],
|
|
272
266
|
claimKeyMap: new Map(),
|
|
@@ -223,27 +223,6 @@ export function buildImplicitProjections(
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
export function validateNoRawTableProjectionClash(state: RegistryState): void {
|
|
227
|
-
// Cross-cut: a r.rawTable() PgTable must not coincide with any
|
|
228
|
-
// registered projection's table. Silent dedupe via Set would mask a
|
|
229
|
-
// real authoring bug (two owners writing to the same physical table).
|
|
230
|
-
// Run after both passes so implicit projections are visible too.
|
|
231
|
-
const projectionTables = new Set<unknown>();
|
|
232
|
-
for (const proj of state.projectionMap.values()) projectionTables.add(proj.table);
|
|
233
|
-
for (const msp of state.multiStreamProjectionMap.values()) {
|
|
234
|
-
if (msp.table) projectionTables.add(msp.table);
|
|
235
|
-
}
|
|
236
|
-
for (const raw of state.rawTableMap.values()) {
|
|
237
|
-
if (projectionTables.has(raw.table)) {
|
|
238
|
-
throw new Error(
|
|
239
|
-
`r.rawTable "${raw.name}" (feature "${raw.featureName}") shares a Drizzle ` +
|
|
240
|
-
`PgTable with a registered projection. Pick one owner: r.entity() / ` +
|
|
241
|
-
`r.projection() for event-sourced reads, r.rawTable() for the bypass.`,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
226
|
export function buildSearchIncludesAndIncomingRelations(state: RegistryState): void {
|
|
248
227
|
for (const [entityName, rels] of state.relationMap) {
|
|
249
228
|
const includes = new Map<string, readonly string[]>();
|
|
@@ -320,13 +299,11 @@ export function validateEventMigrationVersions(
|
|
|
320
299
|
for (const feature of features) {
|
|
321
300
|
for (const [shortName, migrations] of Object.entries(feature.eventMigrations ?? {})) {
|
|
322
301
|
const qualified = qualify(feature.name, "event", shortName);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
);
|
|
329
|
-
}
|
|
302
|
+
// registerEventMigration only ever runs alongside state.events[name]=def
|
|
303
|
+
// in the same defineEvent call (feature-config-events-jobs.ts:260-269),
|
|
304
|
+
// so a migrations entry here guarantees a matching eventMap entry.
|
|
305
|
+
// biome-ignore lint/style/noNonNullAssertion: invariant proven above
|
|
306
|
+
const eventDef = state.eventMap.get(qualified)!;
|
|
330
307
|
for (const m of migrations) {
|
|
331
308
|
if (m.toVersion > eventDef.version) {
|
|
332
309
|
throw new Error(
|
package/src/engine/registry.ts
CHANGED
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
validateFieldAccessHandlersAreEntityMapped,
|
|
35
35
|
validateJobTriggers,
|
|
36
36
|
validateLifecycleHookTargets,
|
|
37
|
-
validateNoRawTableProjectionClash,
|
|
38
37
|
validateProjectionApplyKeys,
|
|
39
38
|
validateRelationTargetsExist,
|
|
40
39
|
validateRequiredFeatures,
|
|
@@ -68,7 +67,6 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
68
67
|
applyExtensionUsages(state);
|
|
69
68
|
buildSearchableSortableCaches(state);
|
|
70
69
|
buildImplicitProjections(state, features);
|
|
71
|
-
validateNoRawTableProjectionClash(state);
|
|
72
70
|
buildSearchIncludesAndIncomingRelations(state);
|
|
73
71
|
validateFieldAccessHandlersAreEntityMapped(state, features);
|
|
74
72
|
validateExtensionPreSaveWiring(features);
|
|
@@ -89,6 +89,13 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
|
|
|
89
89
|
readonly scope: ConfigScope;
|
|
90
90
|
readonly access: ConfigKeyAccess;
|
|
91
91
|
readonly encrypted?: boolean;
|
|
92
|
+
/** User/admin may legitimately see the value — unlike `encrypted`
|
|
93
|
+
* (shared master-key cipher), this is the subject-KMS: the value is
|
|
94
|
+
* encrypted under the DEK of the scope actually written to (tenant-row
|
|
95
|
+
* → tenant subject, user-row → user subject). Only on `type: "text"`,
|
|
96
|
+
* `scope !== "system"` (no subject there), and mutually exclusive with
|
|
97
|
+
* `encrypted` (kumiko-platform#231/#459). */
|
|
98
|
+
readonly piiEncrypted?: boolean;
|
|
92
99
|
readonly options?: readonly string[];
|
|
93
100
|
readonly bounds?: ConfigBounds;
|
|
94
101
|
// Per-key string-pattern validation for type="text". The value must match
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
2
|
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
3
|
-
|
|
4
|
-
// PgTable historically came from drizzle-orm/pg-core; the native dialect
|
|
5
|
-
// no longer carries drizzle internal class types. Every caller really
|
|
6
|
-
// needs "an opaque table-object with Symbol-based introspection".
|
|
7
|
-
type PgTable = unknown;
|
|
8
|
-
|
|
9
3
|
import type { QueryHandlerDefinition, WriteHandlerDefinition } from "../define-handler";
|
|
4
|
+
import type { RegisterEntityCrudOptions } from "../entity-handlers";
|
|
10
5
|
import type {
|
|
11
6
|
ConfigKeyDefinition,
|
|
12
7
|
ConfigKeyHandle,
|
|
@@ -131,6 +126,11 @@ export type SecretKeyHandle = {
|
|
|
131
126
|
};
|
|
132
127
|
|
|
133
128
|
// --- Raw tables (declared by features via r.rawTable()) ---
|
|
129
|
+
// Post-drizzle-cut: unified with the former r.unmanagedTable() — both
|
|
130
|
+
// carried the same reason/audit contract, differing only in whether the
|
|
131
|
+
// table value was a legacy Drizzle PgTable (rawTable) or the framework-
|
|
132
|
+
// native EntityTableMeta (unmanagedTable, consumed by migrate-runner).
|
|
133
|
+
// EntityTableMeta was the forward-compatible shape, so rawTable adopted it.
|
|
134
134
|
|
|
135
135
|
/** Options accepted by `r.rawTable()`. The `reason` is required so the
|
|
136
136
|
* bypass leaves an audit trail at the registration site — reviewers can
|
|
@@ -142,15 +142,23 @@ export type RawTableOptions = {
|
|
|
142
142
|
* payload cache, write-only by webhook handler", "denormalised
|
|
143
143
|
* projection of a non-Kumiko data source". */
|
|
144
144
|
readonly reason: string;
|
|
145
|
+
/** Direct-write stores skip the executor, so the executor's PII
|
|
146
|
+
* encryption never runs for them — a feature whose meta carries
|
|
147
|
+
* piiSubjectFields must encrypt those fields itself before every
|
|
148
|
+
* insert/update and declare that here, or boot fails (#820). */
|
|
149
|
+
readonly piiEncryptedOnWrite?: true;
|
|
145
150
|
};
|
|
146
151
|
|
|
147
|
-
/** Per-feature raw-table registration.
|
|
148
|
-
*
|
|
149
|
-
*
|
|
152
|
+
/** Per-feature raw-table registration. `meta` is the `EntityTableMeta`
|
|
153
|
+
* (framework-native shape used by `migrate-runner`). Carries the
|
|
154
|
+
* bypass-justification reason but knows nothing about the owning
|
|
155
|
+
* feature — that's added when the registry aggregates entries
|
|
156
|
+
* cross-feature into `RawTableDef`. */
|
|
150
157
|
export type RawTableEntry = {
|
|
151
158
|
readonly name: string;
|
|
152
|
-
readonly
|
|
159
|
+
readonly meta: EntityTableMeta;
|
|
153
160
|
readonly reason: string;
|
|
161
|
+
readonly piiEncryptedOnWrite?: true;
|
|
154
162
|
};
|
|
155
163
|
|
|
156
164
|
/** Registry-aggregated raw-table — the per-feature `RawTableEntry` plus
|
|
@@ -160,32 +168,6 @@ export type RawTableDef = RawTableEntry & {
|
|
|
160
168
|
readonly featureName: string;
|
|
161
169
|
};
|
|
162
170
|
|
|
163
|
-
// --- Unmanaged tables (declared by features via r.unmanagedTable()) ---
|
|
164
|
-
|
|
165
|
-
/** Per-feature unmanaged-table registration. `meta` is the
|
|
166
|
-
* `EntityTableMeta` (framework-native shape used by `migrate-runner`).
|
|
167
|
-
* The `reason` justifies the bypass at the registration site — same
|
|
168
|
-
* contract as `r.rawTable`. */
|
|
169
|
-
export type UnmanagedTableEntry = {
|
|
170
|
-
readonly name: string;
|
|
171
|
-
readonly meta: EntityTableMeta;
|
|
172
|
-
readonly reason: string;
|
|
173
|
-
readonly piiEncryptedOnWrite?: true;
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
/** Options for r.unmanagedTable(). Direct-write stores skip the executor,
|
|
177
|
-
* so the executor's PII encryption never runs for them — a feature whose
|
|
178
|
-
* meta carries piiSubjectFields must encrypt those fields itself before
|
|
179
|
-
* every insert/update and declare that here, or boot fails (#820). */
|
|
180
|
-
export type UnmanagedTableOptions = RawTableOptions & {
|
|
181
|
-
readonly piiEncryptedOnWrite?: true;
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
/** Registry-aggregated unmanaged-table — adds the owning feature name. */
|
|
185
|
-
export type UnmanagedTableDef = UnmanagedTableEntry & {
|
|
186
|
-
readonly featureName: string;
|
|
187
|
-
};
|
|
188
|
-
|
|
189
171
|
// --- UI-Hints (manifest-only, picker/scaffolder metadata) ---
|
|
190
172
|
|
|
191
173
|
// Optional, declarative UI metadata declared via `r.uiHints({...})`. Surfaces
|
|
@@ -360,15 +342,12 @@ export type FeatureDefinition = {
|
|
|
360
342
|
// writeHandlers — Routes leben mit dem Feature, nicht im Bootstrap.
|
|
361
343
|
readonly httpRoutes: Readonly<Record<string, HttpRouteDefinition>>;
|
|
362
344
|
// Raw tables declared via r.rawTable() — bypass the event-sourcing
|
|
363
|
-
// system. Keyed by feature-local short name
|
|
364
|
-
// featureName on aggregation,
|
|
345
|
+
// system. Keyed by feature-local short name (derived from
|
|
346
|
+
// meta.tableName). The registry attaches featureName on aggregation,
|
|
347
|
+
// lifting RawTableEntry → RawTableDef. `kumiko schema generate`
|
|
348
|
+
// aggregates these alongside r.entity()-derived metas to build the
|
|
349
|
+
// full schema.
|
|
365
350
|
readonly rawTables: Readonly<Record<string, RawTableEntry>>;
|
|
366
|
-
// Unmanaged tables declared via r.unmanagedTable() — `EntityTableMeta`
|
|
367
|
-
// shape (post-drizzle), keyed by feature-local table-name. Cousin of
|
|
368
|
-
// rawTables: same bypass-justification contract, different storage
|
|
369
|
-
// shape. `kumiko schema generate` aggregates these alongside
|
|
370
|
-
// r.entity()-derived metas to build the full schema.
|
|
371
|
-
readonly unmanagedTables: Readonly<Record<string, UnmanagedTableEntry>>;
|
|
372
351
|
// Optional Zod-schema for env-vars this feature reads at runtime.
|
|
373
352
|
// Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
|
|
374
353
|
// this to build one app-wide schema for boot-validation + dry-run
|
|
@@ -438,6 +417,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
438
417
|
): EntityRef;
|
|
439
418
|
entity(definition: { readonly name: string } & EntityDefinition): EntityRef;
|
|
440
419
|
|
|
420
|
+
// Shorthand for registerEntityCrud(r, ...), scoped to this registrar.
|
|
421
|
+
crud(entityName: string, entity: EntityDefinition, options?: RegisterEntityCrudOptions): void;
|
|
422
|
+
|
|
441
423
|
writeHandler<TName extends string, TSchema extends ZodType>(
|
|
442
424
|
def: WriteHandlerDefinition<TName, TSchema>,
|
|
443
425
|
): HandlerRef;
|
|
@@ -737,34 +719,23 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
737
719
|
// bleibt runProdApp.extraRoutes.
|
|
738
720
|
httpRoute(definition: HttpRouteDefinition): void;
|
|
739
721
|
|
|
740
|
-
// Declare
|
|
741
|
-
// Reserved for legacy-import, read-only caches,
|
|
742
|
-
// payload buffers, or
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
722
|
+
// Declare an "unmanaged" framework-native table that bypasses the
|
|
723
|
+
// event-sourcing system. Reserved for legacy-import, read-only caches,
|
|
724
|
+
// write-only webhook payload buffers, or read-side projections of
|
|
725
|
+
// event-streams (delivery-attempts, job-run-logs) where r.entity()'s
|
|
726
|
+
// aggregate-lifecycle assumptions don't fit. The dev-server iterates
|
|
727
|
+
// these alongside r.entity() projections at boot so the table exists
|
|
728
|
+
// before the first query.
|
|
729
|
+
//
|
|
730
|
+
// EntityTableMeta carries the same column-shape that r.entity() builds,
|
|
731
|
+
// minus the audit-trail + base-columns scaffolding. The `meta` argument
|
|
732
|
+
// is the result of `defineUnmanagedTable(...)` / `buildEntityTableMeta(...)`
|
|
733
|
+
// from `@cosmicdrift/kumiko-framework/db`.
|
|
747
734
|
//
|
|
748
735
|
// The required `reason` string is the marker that justifies the bypass —
|
|
749
736
|
// a non-empty string is the contract. If you can't write a reason,
|
|
750
737
|
// declare data via `r.entity()` instead.
|
|
751
|
-
rawTable(
|
|
752
|
-
|
|
753
|
-
// Declare an "unmanaged" framework-native table (post-drizzle).
|
|
754
|
-
// EntityTableMeta carries the same column-shape that r.entity() builds,
|
|
755
|
-
// minus the audit-trail + base-columns scaffolding — used for read-side
|
|
756
|
-
// projections of event-streams (delivery-attempts, job-run-logs) where
|
|
757
|
-
// r.entity()'s aggregate-lifecycle assumptions don't fit.
|
|
758
|
-
//
|
|
759
|
-
// The `meta` argument is the result of `defineUnmanagedTable(...)` from
|
|
760
|
-
// `@cosmicdrift/kumiko-framework/db`. Reason-justification + audit-trail
|
|
761
|
-
// contract identical to `r.rawTable`.
|
|
762
|
-
//
|
|
763
|
-
// Why this exists separate from `r.rawTable`: rawTable carries a Drizzle
|
|
764
|
-
// `PgTable` (legacy), unmanagedTable carries the new `EntityTableMeta`
|
|
765
|
-
// shape that `migrate-runner` consumes. After the full drizzle-cut they
|
|
766
|
-
// will likely merge; for now they coexist.
|
|
767
|
-
unmanagedTable(meta: EntityTableMeta, options: UnmanagedTableOptions): void;
|
|
738
|
+
rawTable(meta: EntityTableMeta, options: RawTableOptions): void;
|
|
768
739
|
|
|
769
740
|
// Register the tree-actions schema for this feature — a map of
|
|
770
741
|
// action-name → action-definition (with optional typed args). At-most-
|
|
@@ -815,6 +786,7 @@ export type Registry = {
|
|
|
815
786
|
getAllEntities(): ReadonlyMap<string, EntityDefinition>;
|
|
816
787
|
getWriteHandler(name: string): WriteHandlerDef | undefined;
|
|
817
788
|
getQueryHandler(name: string): QueryHandlerDef | undefined;
|
|
789
|
+
getAllQueryHandlers(): ReadonlyMap<string, QueryHandlerDef>;
|
|
818
790
|
getSearchableFields(entityName: string): readonly string[];
|
|
819
791
|
getSortableFields(entityName: string): readonly string[];
|
|
820
792
|
getRelations(entityName: string): EntityRelations;
|
|
@@ -107,6 +107,12 @@ export type TextFieldDef = {
|
|
|
107
107
|
* Default: false — analog zu `sortable`, opt-in. */
|
|
108
108
|
readonly filterable?: boolean;
|
|
109
109
|
readonly encrypted?: boolean;
|
|
110
|
+
/** User/admin may legitimately see the value (their own IBAN, passport
|
|
111
|
+
* number) — unlike `r.secret()`, which only server code reads. A
|
|
112
|
+
* declarative alias over the subject-KMS (pii/userOwned/tenantOwned):
|
|
113
|
+
* only allowed on `type: "text"`, can't combine with `searchable`/
|
|
114
|
+
* `sortable` (boot validator, kumiko-platform#231/#456). */
|
|
115
|
+
readonly piiEncrypted?: boolean;
|
|
110
116
|
readonly sensitive?: boolean;
|
|
111
117
|
readonly format?: "email" | "url" | "phone";
|
|
112
118
|
readonly default?: string;
|
package/src/es-ops/README.md
CHANGED
|
@@ -7,7 +7,7 @@ ES-Operations für Kumiko-Apps. Phase 1+1.5 liefert `seed-migrations` als file-b
|
|
|
7
7
|
## Quick API
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import { runProdApp } from "@cosmicdrift/kumiko-
|
|
10
|
+
import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
|
|
11
11
|
|
|
12
12
|
await runProdApp({
|
|
13
13
|
features: [...],
|
|
@@ -72,6 +72,14 @@ const seedTestFeature = defineFeature("esopstest", (r) => {
|
|
|
72
72
|
},
|
|
73
73
|
{ access: { openToAll: true } },
|
|
74
74
|
);
|
|
75
|
+
// Role-gated handler (no openToAll): the bare system actor has no bypass
|
|
76
|
+
// for explicit role gates — only systemWriteAs' extraRoles reaches it.
|
|
77
|
+
r.writeHandler(
|
|
78
|
+
"probe:admin-only",
|
|
79
|
+
z.object({ label: z.string().min(1) }),
|
|
80
|
+
async (event, ctx) => probeExecutor.create(event.payload, event.user, ctx.db),
|
|
81
|
+
{ access: { roles: ["SystemAdmin"] } },
|
|
82
|
+
);
|
|
75
83
|
});
|
|
76
84
|
|
|
77
85
|
let testDb: TestDb;
|
|
@@ -233,6 +241,72 @@ describe("runPendingSeedMigrations (integration)", () => {
|
|
|
233
241
|
}
|
|
234
242
|
});
|
|
235
243
|
|
|
244
|
+
test("systemWriteAs ohne extraRoles → access_denied gegen rollen-gated Handler, mit extraRoles → durch", async () => {
|
|
245
|
+
const denyDir = makeTempSeedsDir([
|
|
246
|
+
{
|
|
247
|
+
name: "2026-05-20-denied.ts",
|
|
248
|
+
content: `
|
|
249
|
+
export default {
|
|
250
|
+
description: "no extraRoles against a role-gated handler",
|
|
251
|
+
run: async (ctx) => {
|
|
252
|
+
await ctx.systemWriteAs("esopstest:write:probe:admin-only", { label: "denied" });
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
`,
|
|
256
|
+
},
|
|
257
|
+
]);
|
|
258
|
+
try {
|
|
259
|
+
await expect(
|
|
260
|
+
runPendingSeedMigrations({
|
|
261
|
+
db: testDb.db,
|
|
262
|
+
seedsDir: denyDir,
|
|
263
|
+
appliedBy: "boot",
|
|
264
|
+
registry,
|
|
265
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
|
|
266
|
+
logger: () => {},
|
|
267
|
+
}),
|
|
268
|
+
).rejects.toThrow(/access_denied/);
|
|
269
|
+
expect(await selectEvents()).toHaveLength(0);
|
|
270
|
+
} finally {
|
|
271
|
+
rmSync(denyDir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const allowDir = makeTempSeedsDir([
|
|
275
|
+
{
|
|
276
|
+
name: "2026-05-20-allowed.ts",
|
|
277
|
+
content: `
|
|
278
|
+
export default {
|
|
279
|
+
description: "extraRoles reaches a role-gated handler",
|
|
280
|
+
run: async (ctx) => {
|
|
281
|
+
await ctx.systemWriteAs(
|
|
282
|
+
"esopstest:write:probe:admin-only",
|
|
283
|
+
{ label: "allowed" },
|
|
284
|
+
undefined,
|
|
285
|
+
["SystemAdmin"],
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
`,
|
|
290
|
+
},
|
|
291
|
+
]);
|
|
292
|
+
try {
|
|
293
|
+
await runPendingSeedMigrations({
|
|
294
|
+
db: testDb.db,
|
|
295
|
+
seedsDir: allowDir,
|
|
296
|
+
appliedBy: "boot",
|
|
297
|
+
registry,
|
|
298
|
+
createContext: (dbRunner) => createSeedMigrationContext({ dispatcher, dbRunner }),
|
|
299
|
+
logger: () => {},
|
|
300
|
+
});
|
|
301
|
+
const events = await selectEvents();
|
|
302
|
+
expect(events).toHaveLength(1);
|
|
303
|
+
expect(events[0]?.type).toBe("esops-probe.created");
|
|
304
|
+
expect(events[0]?.tenant_id).toBe(SYSTEM_TENANT_ID);
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(allowDir, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
236
310
|
test("WriteResult{isSuccess:false} → throw + Marker NICHT geschrieben", async () => {
|
|
237
311
|
// Critical: ohne diese Garantie würde ein silent-failed Write den Seed
|
|
238
312
|
// als "applied" markieren → beim nächsten Boot kein retry → DB-Drift
|