@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -101,6 +101,46 @@ describe("getAllQueryHandlers", () => {
|
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
describe("getAllStreamHandlers", () => {
|
|
105
|
+
test("returns every registered stream handler, qualified, across multiple features", () => {
|
|
106
|
+
const aiFeature = defineFeature("registry-test-ai", (r) => {
|
|
107
|
+
r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
|
|
108
|
+
access: { openToAll: true },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const otherFeature = defineFeature("registry-test-other", (r) => {
|
|
112
|
+
r.streamHandler("chat:complete", z.object({ prompt: z.string() }), async function* () {}, {
|
|
113
|
+
access: { openToAll: true },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const registry = createRegistry([aiFeature, otherFeature]);
|
|
118
|
+
const handlers = registry.getAllStreamHandlers();
|
|
119
|
+
|
|
120
|
+
expect(handlers.get("registry-test-ai:stream:chat:complete")).toBeDefined();
|
|
121
|
+
expect(handlers.get("registry-test-other:stream:chat:complete")).toBeDefined();
|
|
122
|
+
// Same Map instance getStreamHandler reads from, not a copy that can drift.
|
|
123
|
+
expect(handlers.get("registry-test-ai:stream:chat:complete")).toBe(
|
|
124
|
+
registry.getStreamHandler("registry-test-ai:stream:chat:complete"),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("empty registry returns an empty map, not undefined", () => {
|
|
129
|
+
const registry = createRegistry([]);
|
|
130
|
+
expect(registry.getAllStreamHandlers().size).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("duplicate stream-handler short-name across features qualifies independently, no collision", () => {
|
|
134
|
+
const aiFeature = defineFeature("registry-test-dup-a", (r) => {
|
|
135
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
136
|
+
});
|
|
137
|
+
const otherFeature = defineFeature("registry-test-dup-b", (r) => {
|
|
138
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
139
|
+
});
|
|
140
|
+
expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
104
144
|
describe("extensionSelector boot-validation", () => {
|
|
105
145
|
function foundationFeature() {
|
|
106
146
|
return defineFeature("probe-foundation", (r) => {
|
|
@@ -75,6 +75,18 @@ describe("r.storeTable — declaration", () => {
|
|
|
75
75
|
).toThrow(/requires source: "unmanaged"/);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
test("rejects a table name with the reserved read_ prefix (#1220)", () => {
|
|
79
|
+
const readPrefixed = defineUnmanagedTable({
|
|
80
|
+
tableName: "read_rt_probe",
|
|
81
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
82
|
+
});
|
|
83
|
+
expect(() =>
|
|
84
|
+
defineFeature("probe", (r) => {
|
|
85
|
+
r.storeTable(readPrefixed, { reason: "test" });
|
|
86
|
+
}),
|
|
87
|
+
).toThrow(/the "read_" prefix is reserved/);
|
|
88
|
+
});
|
|
89
|
+
|
|
78
90
|
test("accepts valid registration and stores meta + reason", () => {
|
|
79
91
|
const feature = defineFeature("probe", (r) => {
|
|
80
92
|
r.storeTable(probeMeta, {
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
7
|
import { createEntity, defineFeature } from "../index";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
findTierResolverUsage,
|
|
10
|
+
isTierResolverPlugin,
|
|
11
|
+
TENANT_TIER_RESOLVER_EXT,
|
|
12
|
+
} from "../tier-resolver-extension";
|
|
9
13
|
|
|
10
14
|
function tierResolverFeature(name: string) {
|
|
11
15
|
return defineFeature(name, (r) => {
|
|
@@ -49,3 +53,17 @@ describe("findTierResolverUsage", () => {
|
|
|
49
53
|
expect(findTierResolverUsage([])).toBeUndefined();
|
|
50
54
|
});
|
|
51
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("isTierResolverPlugin", () => {
|
|
58
|
+
test("true when build is a function", () => {
|
|
59
|
+
expect(isTierResolverPlugin({ build: async () => new Set() })).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("false for null, non-objects, and missing/non-fn build", () => {
|
|
63
|
+
expect(isTierResolverPlugin(null)).toBe(false);
|
|
64
|
+
expect(isTierResolverPlugin(undefined)).toBe(false);
|
|
65
|
+
expect(isTierResolverPlugin("x")).toBe(false);
|
|
66
|
+
expect(isTierResolverPlugin({})).toBe(false);
|
|
67
|
+
expect(isTierResolverPlugin({ build: 1 })).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
1
2
|
import type {
|
|
2
3
|
EditFieldSpec,
|
|
3
4
|
EditLayout,
|
|
@@ -6,7 +7,6 @@ import type {
|
|
|
6
7
|
RowAction,
|
|
7
8
|
ToolbarAction,
|
|
8
9
|
} from "../types";
|
|
9
|
-
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
10
10
|
|
|
11
11
|
const FUNCTION_DROPPED_HINT =
|
|
12
12
|
"functions are dropped by JSON.stringify when the screen config reaches the client " +
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BootCheckContext, FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// r.bootCheck(fn) lets a feature declare its own mount-invariant instead of
|
|
4
|
+
// relying on framework-internal knowledge (the gdpr-storage.ts guards are
|
|
5
|
+
// the framework-owned version of this same idea). Each registered fn gets
|
|
6
|
+
// the full mounted-feature set and throws to fail the boot; we wrap the
|
|
7
|
+
// message with the owning feature's name so the error text points back at
|
|
8
|
+
// the feature that declared the check.
|
|
9
|
+
export function validateFeatureBootChecks(features: readonly FeatureDefinition[]): void {
|
|
10
|
+
const ctx: BootCheckContext = { features };
|
|
11
|
+
for (const feature of features) {
|
|
12
|
+
for (const check of feature.bootChecks) {
|
|
13
|
+
try {
|
|
14
|
+
check(ctx);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
+
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -119,11 +119,20 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
|
119
119
|
}
|
|
120
120
|
validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
|
|
121
121
|
}
|
|
122
|
+
for (const [name, handler] of Object.entries(feature.streamHandlers)) {
|
|
123
|
+
if (!handler.access) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
|
|
126
|
+
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
|
|
130
|
+
}
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
export function validateAnonymousRateLimit(
|
|
125
134
|
featureName: string,
|
|
126
|
-
kind: "write" | "query",
|
|
135
|
+
kind: "write" | "query" | "stream",
|
|
127
136
|
handlerName: string,
|
|
128
137
|
access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
|
|
129
138
|
rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { normalizeListColumn } from "../screen-helpers";
|
|
1
2
|
import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
|
|
2
|
-
import { normalizeListColumn } from "../types/screen";
|
|
3
3
|
|
|
4
4
|
/** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
|
|
5
5
|
export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
@@ -1,22 +1,4 @@
|
|
|
1
|
-
import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
|
|
2
1
|
import type { FeatureDefinition } from "../types";
|
|
3
|
-
import type { EntityDefinition, PiiAnnotations } from "../types/fields";
|
|
4
|
-
|
|
5
|
-
// r.entity(...) is not the only way a feature exposes an entity shape:
|
|
6
|
-
// r.projection(...) can carry an optional `entity` too (raw read-models with
|
|
7
|
-
// no executor, e.g. billing-foundation's subscription table). Both V3 and V4
|
|
8
|
-
// below need every entity a feature declares, not just the r.entity ones, or
|
|
9
|
-
// a tenantOwned/pii field on a projection-only entity is invisible to the
|
|
10
|
-
// guard it was annotated for.
|
|
11
|
-
function entitiesOf(
|
|
12
|
-
feature: FeatureDefinition,
|
|
13
|
-
): ReadonlyArray<readonly [string, EntityDefinition]> {
|
|
14
|
-
const fromEntities = Object.entries(feature.entities ?? {});
|
|
15
|
-
const fromProjections = Object.values(feature.projections ?? {})
|
|
16
|
-
.filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
|
|
17
|
-
.map((p) => [p.name, p.entity] as const);
|
|
18
|
-
return [...fromEntities, ...fromProjections];
|
|
19
|
-
}
|
|
20
2
|
|
|
21
3
|
// Providers whose bytes do not survive a process restart. Only "inmemory"
|
|
22
4
|
// today; extend if another ephemeral bundled provider lands.
|
|
@@ -75,97 +57,3 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
|
|
|
75
57
|
}
|
|
76
58
|
}
|
|
77
59
|
}
|
|
78
|
-
|
|
79
|
-
// V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
|
|
80
|
-
// export hook without a matching delete hook exports data under Art.20 but
|
|
81
|
-
// never erases it on forget — an Art.17 violation. Hard boot failure: no app
|
|
82
|
-
// should ship a GDPR export path with no erase path. Registry-level signal
|
|
83
|
-
// only; runtime no-ops (a delete hook that silently skips) are not detectable
|
|
84
|
-
// here — those are covered by the export/forget integration tests.
|
|
85
|
-
export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
|
|
86
|
-
for (const feature of features) {
|
|
87
|
-
for (const usage of feature.extensionUsages) {
|
|
88
|
-
if (usage.extensionName !== EXT_USER_DATA) continue;
|
|
89
|
-
const hasExport = typeof usage.options?.["export"] === "function";
|
|
90
|
-
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
91
|
-
if (hasExport && !hasDelete) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// V3: PII-entity-without-hook gate. V2 checks registered hooks for
|
|
101
|
-
// completeness; V3 catches the entity nobody registered at all — fields
|
|
102
|
-
// annotated as user-subject data (pii / userOwned) yet invisible to the
|
|
103
|
-
// Art.15/20 export and Art.17 forget pipeline. Hard boot failure once
|
|
104
|
-
// user-data-rights is mounted: a subject-data entity that skips the pipeline
|
|
105
|
-
// is exactly the "feature built past GDPR" leak this gate exists to stop.
|
|
106
|
-
// Matching is by entity name across all features (usage.entityName is
|
|
107
|
-
// unqualified); a same-named entity in another feature can mask a gap —
|
|
108
|
-
// accepted, as the common case is a distinct entity name.
|
|
109
|
-
export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
|
|
110
|
-
const featureNames = new Set(features.map((f) => f.name));
|
|
111
|
-
if (!featureNames.has("user-data-rights")) {
|
|
112
|
-
// skip: this guard only applies to apps that mount user-data-rights
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const hookedEntities = new Set<string>();
|
|
117
|
-
for (const f of features) {
|
|
118
|
-
for (const usage of f.extensionUsages) {
|
|
119
|
-
if (usage.extensionName === EXT_USER_DATA) hookedEntities.add(usage.entityName);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
for (const feature of features) {
|
|
124
|
-
for (const [entityName, entity] of entitiesOf(feature)) {
|
|
125
|
-
if (hookedEntities.has(entityName)) continue;
|
|
126
|
-
const subjectFields = Object.entries(entity.fields)
|
|
127
|
-
.filter(([, field]) => {
|
|
128
|
-
const annot = field as PiiAnnotations; // @cast-boundary schema-walk
|
|
129
|
-
return Boolean(annot.pii) || Boolean(annot.userOwned);
|
|
130
|
-
})
|
|
131
|
-
.map(([name]) => name);
|
|
132
|
-
if (subjectFields.length === 0) continue;
|
|
133
|
-
throw new Error(
|
|
134
|
-
`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// V4: tenantOwned-entity-without-hook gate. Mirrors validateGdprPiiHookCoverage
|
|
141
|
-
// but for EXT_TENANT_DATA when tenant-lifecycle is mounted.
|
|
142
|
-
export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
|
|
143
|
-
const featureNames = new Set(features.map((f) => f.name));
|
|
144
|
-
if (!featureNames.has("tenant-lifecycle")) {
|
|
145
|
-
// skip: this guard only applies to apps that mount tenant-lifecycle
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const hookedEntities = new Set<string>();
|
|
150
|
-
for (const f of features) {
|
|
151
|
-
for (const usage of f.extensionUsages) {
|
|
152
|
-
if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
for (const feature of features) {
|
|
157
|
-
for (const [entityName, entity] of entitiesOf(feature)) {
|
|
158
|
-
if (hookedEntities.has(entityName)) continue;
|
|
159
|
-
const tenantSubjectFields = Object.entries(entity.fields)
|
|
160
|
-
.filter(([, field]) => {
|
|
161
|
-
const annot = field as PiiAnnotations;
|
|
162
|
-
return Boolean(annot.tenantOwned);
|
|
163
|
-
})
|
|
164
|
-
.map(([name]) => name);
|
|
165
|
-
if (tenantSubjectFields.length === 0) continue;
|
|
166
|
-
throw new Error(
|
|
167
|
-
`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
@@ -3,6 +3,7 @@ import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
|
3
3
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
|
|
4
4
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
5
5
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
6
|
+
import { validateFeatureBootChecks } from "./boot-check";
|
|
6
7
|
import {
|
|
7
8
|
validateCircularDeps,
|
|
8
9
|
validateConfigKeyAllowPerRequest,
|
|
@@ -30,12 +31,7 @@ import {
|
|
|
30
31
|
validateTransitions,
|
|
31
32
|
} from "./entity-handler";
|
|
32
33
|
import { validateEntityListScreens } from "./entity-list-screens";
|
|
33
|
-
import {
|
|
34
|
-
validateGdprHookCompleteness,
|
|
35
|
-
validateGdprPiiHookCoverage,
|
|
36
|
-
validateGdprStoragePersistence,
|
|
37
|
-
validateTenantDataHookCoverage,
|
|
38
|
-
} from "./gdpr-storage";
|
|
34
|
+
import { validateGdprStoragePersistence } from "./gdpr-storage";
|
|
39
35
|
import { validateI18nSurfaceKeys } from "./i18n-keys";
|
|
40
36
|
import {
|
|
41
37
|
collectKnownRoles,
|
|
@@ -198,9 +194,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
198
194
|
validateEntityListScreens(features);
|
|
199
195
|
validateExtensionPreSaveWiring(features);
|
|
200
196
|
validateGdprStoragePersistence(features);
|
|
201
|
-
|
|
202
|
-
validateGdprPiiHookCoverage(features);
|
|
203
|
-
validateTenantDataHookCoverage(features);
|
|
197
|
+
validateFeatureBootChecks(features);
|
|
204
198
|
|
|
205
199
|
if (hasEncryptedFields) {
|
|
206
200
|
// Availability check, not env-presence: eagerly building the keyring
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
8
8
|
import { qualifyEntityName } from "../qualified-name";
|
|
9
9
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
10
|
+
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
10
11
|
import type { FeatureDefinition } from "../types";
|
|
11
12
|
import type {
|
|
12
13
|
DashboardCustomPanel,
|
|
@@ -20,7 +21,6 @@ import type {
|
|
|
20
21
|
ScreenDefinition,
|
|
21
22
|
ToolbarAction,
|
|
22
23
|
} from "../types/screen";
|
|
23
|
-
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
24
24
|
|
|
25
25
|
// --- Screen validation ---
|
|
26
26
|
//
|
|
@@ -106,6 +106,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
106
106
|
relations: state.relations,
|
|
107
107
|
writeHandlers: state.writeHandlers,
|
|
108
108
|
queryHandlers: state.queryHandlers,
|
|
109
|
+
streamHandlers: state.streamHandlers,
|
|
109
110
|
translations: state.translations,
|
|
110
111
|
hooks: {
|
|
111
112
|
validation: state.validationHooks,
|
|
@@ -136,6 +137,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
136
137
|
extensionSelectors: state.extensionSelectors,
|
|
137
138
|
exposedApis: state.exposedApis,
|
|
138
139
|
usedApis: state.usedApis,
|
|
140
|
+
bootChecks: state.bootChecks,
|
|
139
141
|
referenceData: state.referenceData,
|
|
140
142
|
events: state.events,
|
|
141
143
|
eventMigrations: state.eventMigrations,
|
|
@@ -1,81 +1,19 @@
|
|
|
1
1
|
import type { ZodType, z } from "zod";
|
|
2
2
|
import type { ContainsSecret } from "../secrets/types";
|
|
3
3
|
import { runPipeline } from "./run-pipeline";
|
|
4
|
+
import type { HandlerContext, KumikoEventTypeMap, WriteEvent, WriteResult } from "./types";
|
|
4
5
|
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
RateLimitOption,
|
|
10
|
-
WriteEvent,
|
|
11
|
-
WriteResult,
|
|
12
|
-
} from "./types";
|
|
13
|
-
import type { PipelineDef } from "./types/step";
|
|
6
|
+
QueryHandlerDefinition,
|
|
7
|
+
WriteHandlerDefinition,
|
|
8
|
+
WriteHandlerInput,
|
|
9
|
+
} from "./types/define-handler";
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// the augmentation is visible); generic-type-aliases substitute at the
|
|
22
|
-
// definition-site (framework's compile, where the augmentation isn't
|
|
23
|
-
// visible) and collapse `keyof TMap` to `never`. See the spike-findings
|
|
24
|
-
// memory for the empirical proof.
|
|
25
|
-
//
|
|
26
|
-
// Two authoring forms — `handler` (free-form) or `perform: stepsPipeline(...)`
|
|
27
|
-
// (step-pipeline). A `perform` is compiled to a handler-function at
|
|
28
|
-
// definition time; the dispatcher only ever sees `handler`.
|
|
29
|
-
|
|
30
|
-
export type WriteHandlerDefinition<
|
|
31
|
-
TName extends string = string,
|
|
32
|
-
TSchema extends ZodType = ZodType,
|
|
33
|
-
TData = unknown,
|
|
34
|
-
TMap extends object = KumikoEventTypeMap,
|
|
35
|
-
> = {
|
|
36
|
-
readonly name: TName;
|
|
37
|
-
readonly schema: TSchema;
|
|
38
|
-
readonly access?: AccessRule;
|
|
39
|
-
readonly unsafeSkipTransitionGuard?: boolean;
|
|
40
|
-
readonly rateLimit?: RateLimitOption;
|
|
41
|
-
readonly handler: (
|
|
42
|
-
event: WriteEvent<z.infer<TSchema>>,
|
|
43
|
-
context: HandlerContext<TMap>,
|
|
44
|
-
) => Promise<WriteResult<TData>>;
|
|
45
|
-
// Preserved when the author wrote a `perform` block — the original
|
|
46
|
-
// PipelineDef. Designer/AI/AST tools read this when present; the
|
|
47
|
-
// dispatcher ignores it and just calls `handler`. Absent on free-form
|
|
48
|
-
// handlers.
|
|
49
|
-
readonly perform?: PipelineDef<z.infer<TSchema>, TData>;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// Author-facing input — accepts either the free-form `handler` or the
|
|
53
|
-
// pipeline-form `perform`. defineWriteHandler narrows them to the
|
|
54
|
-
// canonical WriteHandlerDefinition shape.
|
|
55
|
-
export type WriteHandlerInput<
|
|
56
|
-
TName extends string = string,
|
|
57
|
-
TSchema extends ZodType = ZodType,
|
|
58
|
-
TData = unknown,
|
|
59
|
-
TMap extends object = KumikoEventTypeMap,
|
|
60
|
-
> = {
|
|
61
|
-
readonly name: TName;
|
|
62
|
-
readonly schema: TSchema;
|
|
63
|
-
readonly access?: AccessRule;
|
|
64
|
-
readonly unsafeSkipTransitionGuard?: boolean;
|
|
65
|
-
readonly rateLimit?: RateLimitOption;
|
|
66
|
-
} & (
|
|
67
|
-
| {
|
|
68
|
-
readonly handler: (
|
|
69
|
-
event: WriteEvent<z.infer<TSchema>>,
|
|
70
|
-
context: HandlerContext<TMap>,
|
|
71
|
-
) => Promise<WriteResult<TData>>;
|
|
72
|
-
readonly perform?: never;
|
|
73
|
-
}
|
|
74
|
-
| {
|
|
75
|
-
readonly perform: PipelineDef<z.infer<TSchema>, TData>;
|
|
76
|
-
readonly handler?: never;
|
|
77
|
-
}
|
|
78
|
-
);
|
|
11
|
+
export type {
|
|
12
|
+
QueryHandlerDefinition,
|
|
13
|
+
StreamHandlerDefinition,
|
|
14
|
+
WriteHandlerDefinition,
|
|
15
|
+
WriteHandlerInput,
|
|
16
|
+
} from "./types/define-handler";
|
|
79
17
|
|
|
80
18
|
export function defineWriteHandler<
|
|
81
19
|
const TName extends string,
|
|
@@ -160,24 +98,6 @@ export function defineWriteHandler<
|
|
|
160
98
|
return { ...base, handler: def.handler };
|
|
161
99
|
}
|
|
162
100
|
|
|
163
|
-
// --- Query Handler Definition ---
|
|
164
|
-
|
|
165
|
-
export type QueryHandlerDefinition<
|
|
166
|
-
TName extends string = string,
|
|
167
|
-
TSchema extends ZodType = ZodType,
|
|
168
|
-
TResult = unknown,
|
|
169
|
-
TMap extends object = KumikoEventTypeMap,
|
|
170
|
-
> = {
|
|
171
|
-
readonly name: TName;
|
|
172
|
-
readonly schema: TSchema;
|
|
173
|
-
readonly access?: AccessRule;
|
|
174
|
-
readonly rateLimit?: RateLimitOption;
|
|
175
|
-
readonly handler: (
|
|
176
|
-
query: QueryEvent<z.infer<TSchema>>,
|
|
177
|
-
context: HandlerContext<TMap>,
|
|
178
|
-
) => Promise<TResult>;
|
|
179
|
-
};
|
|
180
|
-
|
|
181
101
|
export function defineQueryHandler<
|
|
182
102
|
const TName extends string,
|
|
183
103
|
TSchema extends ZodType,
|
|
@@ -18,6 +18,21 @@ import type {
|
|
|
18
18
|
QueryHandlerDef,
|
|
19
19
|
WriteHandlerDef,
|
|
20
20
|
} from "./types";
|
|
21
|
+
import type {
|
|
22
|
+
EntityCrudRegistrar,
|
|
23
|
+
EntityCrudVerb,
|
|
24
|
+
EntityHandlerOptions,
|
|
25
|
+
EntityQueryHandlerOptions,
|
|
26
|
+
RegisterEntityCrudOptions,
|
|
27
|
+
} from "./types/entity-handlers";
|
|
28
|
+
|
|
29
|
+
export type {
|
|
30
|
+
EntityCrudRegistrar,
|
|
31
|
+
EntityCrudVerb,
|
|
32
|
+
EntityHandlerOptions,
|
|
33
|
+
EntityQueryHandlerOptions,
|
|
34
|
+
RegisterEntityCrudOptions,
|
|
35
|
+
} from "./types/entity-handlers";
|
|
21
36
|
|
|
22
37
|
// Convention-based handler factories for event-sourced aggregates.
|
|
23
38
|
//
|
|
@@ -330,16 +345,6 @@ export function defineEntityQueryHandler(
|
|
|
330
345
|
// would need a Branded-EntityDefinition with `softDelete: true` literal
|
|
331
346
|
// — feasible but not yet wired; the runtime guard catches misuse.
|
|
332
347
|
|
|
333
|
-
type EntityHandlerOptions = { readonly access?: AccessRule };
|
|
334
|
-
type EntityQueryHandlerOptions = EntityHandlerOptions & {
|
|
335
|
-
/** Reads across every tenant instead of the caller's own — for a
|
|
336
|
-
* SystemAdmin-only operator inspector over an otherwise tenant-scoped
|
|
337
|
-
* entity. Scope this to the ONE handler that needs it rather than making
|
|
338
|
-
* the whole feature r.systemScope(), which would drop tenant isolation
|
|
339
|
-
* from every other handler the feature registers too. */
|
|
340
|
-
readonly crossTenant?: boolean;
|
|
341
|
-
};
|
|
342
|
-
|
|
343
348
|
// @wrapper-known semantic-alias
|
|
344
349
|
export function defineEntityCreateHandler(
|
|
345
350
|
entityName: string,
|
|
@@ -446,23 +451,6 @@ export function defineProjectionQueryHandler(
|
|
|
446
451
|
};
|
|
447
452
|
}
|
|
448
453
|
|
|
449
|
-
type EntityCrudVerb = "create" | "update" | "delete" | "restore" | "list" | "detail";
|
|
450
|
-
|
|
451
|
-
export type RegisterEntityCrudOptions = {
|
|
452
|
-
readonly write?: EntityHandlerOptions;
|
|
453
|
-
readonly read?: EntityQueryHandlerOptions;
|
|
454
|
-
readonly verbs?: Partial<Record<EntityCrudVerb, boolean>>;
|
|
455
|
-
/** Default true. Set false when the entity was already registered (e.g. before r.relation). */
|
|
456
|
-
readonly registerEntity?: boolean;
|
|
457
|
-
};
|
|
458
|
-
|
|
459
|
-
/** Minimal registrar surface — keeps entity-handlers free of define-feature imports. */
|
|
460
|
-
export type EntityCrudRegistrar = {
|
|
461
|
-
entity(name: string, definition: EntityDefinition): unknown;
|
|
462
|
-
writeHandler(def: WriteHandlerDef): unknown;
|
|
463
|
-
queryHandler(def: QueryHandlerDef): unknown;
|
|
464
|
-
};
|
|
465
|
-
|
|
466
454
|
function defaultCrudVerbs(entity: EntityDefinition): Record<EntityCrudVerb, boolean> {
|
|
467
455
|
return {
|
|
468
456
|
create: true,
|
|
@@ -78,6 +78,15 @@ defineFeature("todoList", (r) => {
|
|
|
78
78
|
access: { openToAll: true },
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
r.streamHandler({
|
|
82
|
+
name: "task:stream",
|
|
83
|
+
schema: z.object({ prompt: z.string() }),
|
|
84
|
+
handler: async function* (input, ctx) {
|
|
85
|
+
yield "token";
|
|
86
|
+
},
|
|
87
|
+
access: { roles: ["user"] },
|
|
88
|
+
});
|
|
89
|
+
|
|
81
90
|
r.hook({
|
|
82
91
|
type: "postSave",
|
|
83
92
|
target: "task",
|
|
@@ -195,7 +204,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
195
204
|
expect(unknowns).toEqual([]);
|
|
196
205
|
});
|
|
197
206
|
|
|
198
|
-
test("alle
|
|
207
|
+
test("alle Pattern-Kinds sind erfasst", () => {
|
|
199
208
|
const kinds: ReadonlySet<string> = new Set(result.patterns.map((p) => p.kind));
|
|
200
209
|
const expected = [
|
|
201
210
|
// Static
|
|
@@ -219,6 +228,7 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
219
228
|
"screen",
|
|
220
229
|
"writeHandler",
|
|
221
230
|
"queryHandler",
|
|
231
|
+
"streamHandler",
|
|
222
232
|
"hook",
|
|
223
233
|
"job",
|
|
224
234
|
"notification",
|