@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +105 -0
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +12 -5
- package/src/db/event-store-executor-read.ts +125 -37
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
- package/src/engine/__tests__/boot-validator.test.ts +71 -1
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +150 -0
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +68 -3
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +7 -4
- package/src/engine/entity-handlers.ts +74 -22
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/registry-validate.ts +36 -6
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
- package/src/jobs/job-runner.ts +35 -5
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- package/src/utils/index.ts +1 -1
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
} from "../db/eagerload";
|
|
9
9
|
import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
|
|
10
10
|
import { buildEntityTable, type EntityTable } from "../db/table-builder";
|
|
11
|
-
import {
|
|
11
|
+
import { acknowledgeConventionCrossTenant, type TenantDb } from "../db/tenant-db";
|
|
12
|
+
import { isSystemIdentity } from "../pipeline/system-identity-switch";
|
|
12
13
|
import { assertUnreachable } from "../utils";
|
|
13
14
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
|
|
14
15
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
AccessRule,
|
|
17
18
|
DeriveContext,
|
|
18
19
|
EntityDefinition,
|
|
20
|
+
EscapeHatchDeclaration,
|
|
19
21
|
HandlerContext,
|
|
20
22
|
QueryHandlerDef,
|
|
21
23
|
SessionUser,
|
|
@@ -100,13 +102,40 @@ type ListPayload = {
|
|
|
100
102
|
// den gleichen Query-Handler.
|
|
101
103
|
filter?: {
|
|
102
104
|
readonly field: string;
|
|
103
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
105
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
|
|
104
106
|
readonly value: unknown;
|
|
105
107
|
};
|
|
106
108
|
};
|
|
107
109
|
|
|
108
110
|
const idSchema = z.object({ id: z.uuid() });
|
|
109
111
|
|
|
112
|
+
// Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
|
|
113
|
+
const deprecatedCrossTenantHandlers = new WeakSet<object>();
|
|
114
|
+
|
|
115
|
+
export function isDeprecatedCrossTenantHandler(handler: object): boolean {
|
|
116
|
+
return deprecatedCrossTenantHandlers.has(handler);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
|
|
120
|
+
|
|
121
|
+
function resolveCrossTenantReason(name: string, options: EntityHandlerOptions): string | undefined {
|
|
122
|
+
if (options.escapeHatch !== undefined && options.crossTenant === true) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (options.escapeHatch !== undefined) {
|
|
128
|
+
if (options.escapeHatch.reason.trim().length === 0) {
|
|
129
|
+
throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
|
|
130
|
+
}
|
|
131
|
+
return options.escapeHatch.reason;
|
|
132
|
+
}
|
|
133
|
+
if (options.crossTenant === true) {
|
|
134
|
+
return `deprecated crossTenant: true on entity convention handler ${name}`;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
// Upper bound on entity-list `limit`: unbounded/fractional values ran
|
|
111
140
|
// straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
|
|
112
141
|
// read.ts), letting a client demand a full-table materialisation or trip a
|
|
@@ -128,7 +157,7 @@ export const entityListSchema = z.object({
|
|
|
128
157
|
filter: z
|
|
129
158
|
.object({
|
|
130
159
|
field: z.string(),
|
|
131
|
-
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
160
|
+
op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
|
|
132
161
|
// Value ist `unknown` zur Compile-Zeit; Server-Side prüft beim
|
|
133
162
|
// Build der WHERE-Clause ob der Type zum Field passt. z.unknown()
|
|
134
163
|
// lässt alles durch; Type-Check kommt im executor.list.
|
|
@@ -141,7 +170,7 @@ export const entityListSchema = z.object({
|
|
|
141
170
|
.array(
|
|
142
171
|
z.object({
|
|
143
172
|
field: z.string(),
|
|
144
|
-
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
173
|
+
op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
|
|
145
174
|
value: z.unknown(),
|
|
146
175
|
}),
|
|
147
176
|
)
|
|
@@ -194,10 +223,10 @@ export function defineEntityWriteHandler(
|
|
|
194
223
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
195
224
|
// TenantDb for these handlers before the ctx.db cutover.
|
|
196
225
|
//
|
|
197
|
-
//
|
|
198
|
-
// write handler reads/writes across
|
|
199
|
-
// whole feature r.systemScope().
|
|
200
|
-
const
|
|
226
|
+
// crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
|
|
227
|
+
// query-handler branch below: this ONE write handler reads/writes across
|
|
228
|
+
// every tenant without making the whole feature r.systemScope().
|
|
229
|
+
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
201
230
|
|
|
202
231
|
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
203
232
|
if (ctx.systemDb) {
|
|
@@ -205,7 +234,9 @@ export function defineEntityWriteHandler(
|
|
|
205
234
|
`entity convention handler for r.systemScope() feature (${name})`,
|
|
206
235
|
);
|
|
207
236
|
}
|
|
208
|
-
return
|
|
237
|
+
return crossTenantReason !== undefined
|
|
238
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
239
|
+
: ctx.db;
|
|
209
240
|
};
|
|
210
241
|
|
|
211
242
|
// The event stream is keyed by the acting user's tenantId (streamTenantFor in
|
|
@@ -219,7 +250,7 @@ export function defineEntityWriteHandler(
|
|
|
219
250
|
id: unknown,
|
|
220
251
|
db: TenantDb,
|
|
221
252
|
): Promise<SessionUser> => {
|
|
222
|
-
if (
|
|
253
|
+
if (crossTenantReason === undefined || entity.systemStream) return user;
|
|
223
254
|
const row = await db.fetchOne<Record<string, unknown>>(table, { id });
|
|
224
255
|
const rowTenantId = row?.["tenantId"];
|
|
225
256
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
|
|
@@ -234,7 +265,14 @@ export function defineEntityWriteHandler(
|
|
|
234
265
|
schema = buildInsertSchema(entity);
|
|
235
266
|
handler = async (event, ctx) => {
|
|
236
267
|
const { runPreSave } = ctx;
|
|
237
|
-
|
|
268
|
+
// A caller-chosen id (deterministic-id idempotent creates) is only
|
|
269
|
+
// honored for a system-identity caller (jobs/hooks via
|
|
270
|
+
// createSystemUser) — an HTTP-authenticated end user can never
|
|
271
|
+
// present that identity, so their payload.id is dropped exactly as
|
|
272
|
+
// before this field became part of the schema.
|
|
273
|
+
const { id: _callerId, ...payloadWithoutId } = event.payload as DbRow;
|
|
274
|
+
const payload = isSystemIdentity(event.user) ? (event.payload as DbRow) : payloadWithoutId;
|
|
275
|
+
return executor.create(payload, event.user, dbFor(ctx), {
|
|
238
276
|
preSave:
|
|
239
277
|
runPreSave &&
|
|
240
278
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -288,6 +326,9 @@ export function defineEntityWriteHandler(
|
|
|
288
326
|
assertUnreachable(verb, "write verb");
|
|
289
327
|
}
|
|
290
328
|
|
|
329
|
+
if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
|
|
330
|
+
|
|
331
|
+
// escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
|
|
291
332
|
return {
|
|
292
333
|
name,
|
|
293
334
|
schema,
|
|
@@ -340,21 +381,22 @@ export function defineEntityQueryHandler(
|
|
|
340
381
|
// useReferenceLookup stays as a fallback (for apps that write custom
|
|
341
382
|
// handlers by hand without this wrapper).
|
|
342
383
|
const hasRefFields = collectReferenceFields(entity).length > 0;
|
|
384
|
+
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
343
385
|
|
|
344
386
|
// Preference order:
|
|
345
387
|
// 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
|
|
346
388
|
// cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
|
|
347
389
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
348
390
|
// TenantDb for these handlers before that cutover.
|
|
349
|
-
// 2.
|
|
350
|
-
// (e.g. a SystemAdmin-only
|
|
351
|
-
// whole feature r.systemScope()
|
|
352
|
-
// from every OTHER handler the
|
|
353
|
-
// list()/detail() only add a
|
|
354
|
-
// (see event-store-executor.ts),
|
|
355
|
-
// TenantDb built from the same raw
|
|
356
|
-
// access-control (who may call this
|
|
357
|
-
// still gated by `options.access`.
|
|
391
|
+
// 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
|
|
392
|
+
// ONE handler reads across every tenant (e.g. a SystemAdmin-only
|
|
393
|
+
// operator inspector) without making the whole feature r.systemScope()
|
|
394
|
+
// — that would drop tenant isolation from every OTHER handler the
|
|
395
|
+
// feature registers too. The executor's list()/detail() only add a
|
|
396
|
+
// tenant filter when db.mode === "tenant" (see event-store-executor.ts),
|
|
397
|
+
// so handing them a "system"-mode TenantDb built from the same raw
|
|
398
|
+
// connection is enough to skip it — access-control (who may call this
|
|
399
|
+
// handler at all) is unaffected, still gated by `options.access`.
|
|
358
400
|
// 3. plain ctx.db — the common case, tenant-filtered.
|
|
359
401
|
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
360
402
|
if (ctx.systemDb) {
|
|
@@ -362,7 +404,9 @@ export function defineEntityQueryHandler(
|
|
|
362
404
|
`entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
|
|
363
405
|
);
|
|
364
406
|
}
|
|
365
|
-
return
|
|
407
|
+
return crossTenantReason !== undefined
|
|
408
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
|
|
409
|
+
: ctx.db;
|
|
366
410
|
};
|
|
367
411
|
|
|
368
412
|
switch (verb) {
|
|
@@ -418,6 +462,9 @@ export function defineEntityQueryHandler(
|
|
|
418
462
|
assertUnreachable(verb, "query verb");
|
|
419
463
|
}
|
|
420
464
|
|
|
465
|
+
if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
|
|
466
|
+
|
|
467
|
+
// escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
|
|
421
468
|
return {
|
|
422
469
|
name,
|
|
423
470
|
schema,
|
|
@@ -543,7 +590,11 @@ export function createEntityExecutor(
|
|
|
543
590
|
export function defineProjectionQueryHandler(
|
|
544
591
|
name: string,
|
|
545
592
|
projectionQualifiedName: string,
|
|
546
|
-
options: {
|
|
593
|
+
options: {
|
|
594
|
+
access: AccessRule;
|
|
595
|
+
unsafeAllTenants?: boolean;
|
|
596
|
+
escapeHatch?: EscapeHatchDeclaration;
|
|
597
|
+
},
|
|
547
598
|
): QueryHandlerDef {
|
|
548
599
|
return {
|
|
549
600
|
name,
|
|
@@ -558,6 +609,7 @@ export function defineProjectionQueryHandler(
|
|
|
558
609
|
options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
|
|
559
610
|
), // @wrapper-known semantic-alias
|
|
560
611
|
access: options.access,
|
|
612
|
+
...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
|
|
561
613
|
};
|
|
562
614
|
}
|
|
563
615
|
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
HandlerRef,
|
|
24
24
|
NameOrRef,
|
|
25
25
|
QueryHandlerFn,
|
|
26
|
-
|
|
26
|
+
RateLimitDeclaration,
|
|
27
27
|
RelationDefinition,
|
|
28
28
|
StreamHandlerFn,
|
|
29
29
|
WriteHandlerFn,
|
|
@@ -124,7 +124,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
124
124
|
handler?: WriteHandlerFn<z.infer<TSchema>>,
|
|
125
125
|
options?: {
|
|
126
126
|
access: AccessRule;
|
|
127
|
-
rateLimit?:
|
|
127
|
+
rateLimit?: RateLimitDeclaration;
|
|
128
128
|
description?: string;
|
|
129
129
|
agent?: AgentHandlerHints;
|
|
130
130
|
escapeHatch?: EscapeHatchDeclaration;
|
|
@@ -181,7 +181,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
181
181
|
handler?: QueryHandlerFn<z.infer<TSchema>>,
|
|
182
182
|
options?: {
|
|
183
183
|
access: AccessRule;
|
|
184
|
-
rateLimit?:
|
|
184
|
+
rateLimit?: RateLimitDeclaration;
|
|
185
185
|
outputSchema?: ZodType;
|
|
186
186
|
description?: string;
|
|
187
187
|
agent?: AgentHandlerHints;
|
|
@@ -230,7 +230,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
230
230
|
handler?: StreamHandlerFn<z.infer<TSchema>>,
|
|
231
231
|
options?: {
|
|
232
232
|
access: AccessRule;
|
|
233
|
-
rateLimit?:
|
|
233
|
+
rateLimit?: RateLimitDeclaration;
|
|
234
234
|
escapeHatch?: EscapeHatchDeclaration;
|
|
235
235
|
},
|
|
236
236
|
): HandlerRef {
|
package/src/engine/index.ts
CHANGED
|
@@ -374,7 +374,12 @@ export type {
|
|
|
374
374
|
EntityListScreenDefinition,
|
|
375
375
|
EntityRef,
|
|
376
376
|
EntityRelations,
|
|
377
|
+
EscapeHatchAuditSink,
|
|
377
378
|
EscapeHatchDeclaration,
|
|
379
|
+
EscapeHatchKind,
|
|
380
|
+
EscapeHatchReporter,
|
|
381
|
+
EscapeHatchTarget,
|
|
382
|
+
EscapeHatchUseEvent,
|
|
378
383
|
EventDef,
|
|
379
384
|
FeatureDefinition,
|
|
380
385
|
FeatureRegistrar,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
|
+
import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
|
|
2
3
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
3
4
|
import { resolveName } from "./handler-helpers";
|
|
4
5
|
import type {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
softDeleteGraceDaysConfig,
|
|
17
18
|
} from "./soft-delete-cleanup";
|
|
18
19
|
import type {
|
|
20
|
+
EntityDefinition,
|
|
19
21
|
EventPiiFields,
|
|
20
22
|
EventUpcastFn,
|
|
21
23
|
FeatureDefinition,
|
|
@@ -25,7 +27,7 @@ import type {
|
|
|
25
27
|
PreSaveHookFn,
|
|
26
28
|
ReferenceFieldDef,
|
|
27
29
|
} from "./types";
|
|
28
|
-
import { HookPhases } from "./types";
|
|
30
|
+
import { HookPhases, isRateLimitDisabled } from "./types";
|
|
29
31
|
|
|
30
32
|
function allHandlerQns(state: RegistryState): ReadonlySet<string> {
|
|
31
33
|
return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
|
|
@@ -206,6 +208,7 @@ function buildSearchableReferenceField(
|
|
|
206
208
|
entityName: string,
|
|
207
209
|
fieldName: string,
|
|
208
210
|
field: ReferenceFieldDef,
|
|
211
|
+
entityMap: ReadonlyMap<string, EntityDefinition>,
|
|
209
212
|
): SearchableReferenceField {
|
|
210
213
|
if (field.labelField === undefined || field.labelField === "id") {
|
|
211
214
|
throw new Error(
|
|
@@ -220,9 +223,26 @@ function buildSearchableReferenceField(
|
|
|
220
223
|
`(array) reference fields — remove "multiple" or "searchable".`,
|
|
221
224
|
);
|
|
222
225
|
}
|
|
226
|
+
const targetEntityName = parseReferenceTargetEntityName(field.entity);
|
|
227
|
+
const targetEntity = entityMap.get(targetEntityName);
|
|
228
|
+
if (targetEntity !== undefined) {
|
|
229
|
+
if (
|
|
230
|
+
isSensitiveLabelField(targetEntity, field.labelField) &&
|
|
231
|
+
!hasSearchablePlaintext(targetEntity, field.labelField)
|
|
232
|
+
) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`[Entity ${entityName}] field "${fieldName}": searchable reference targets ` +
|
|
235
|
+
`"${targetEntityName}.${field.labelField}", which is encrypted/PII and not itself ` +
|
|
236
|
+
`searchable — there is no plaintext anywhere to match a search term against. Mark ` +
|
|
237
|
+
`"${targetEntityName}.${field.labelField}" searchable: true (add find: "fuzzy" if it ` +
|
|
238
|
+
`also carries a personal annotation, so it lands in the derived search index) or ` +
|
|
239
|
+
`remove searchable from "${fieldName}".`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
223
243
|
return {
|
|
224
244
|
fieldName,
|
|
225
|
-
targetEntityName
|
|
245
|
+
targetEntityName,
|
|
226
246
|
labelField: field.labelField,
|
|
227
247
|
};
|
|
228
248
|
}
|
|
@@ -273,7 +293,9 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
|
273
293
|
}
|
|
274
294
|
}
|
|
275
295
|
if (field.type === "reference" && field.searchable === true) {
|
|
276
|
-
searchableReferences.push(
|
|
296
|
+
searchableReferences.push(
|
|
297
|
+
buildSearchableReferenceField(name, fieldName, field, state.entityMap),
|
|
298
|
+
);
|
|
277
299
|
}
|
|
278
300
|
if (field.type === "reference" && field.sortable === true) {
|
|
279
301
|
sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
|
|
@@ -721,10 +743,18 @@ export function validateExtensionUsageTargets(state: RegistryState): void {
|
|
|
721
743
|
export function computeHasRateLimitedHandler(state: RegistryState): void {
|
|
722
744
|
// Pre-compute: any handler with a rateLimit option? Keeps the boot
|
|
723
745
|
// path able to short-circuit the RateLimitResolver wiring (and its
|
|
724
|
-
// Lua-script registration on Redis) when nobody opted in.
|
|
746
|
+
// Lua-script registration on Redis) when nobody opted in. Deliberately
|
|
747
|
+
// stays limited to explicit, non-disabled `rateLimit` declarations —
|
|
748
|
+
// `{ disabled: true }` must not auto-wire a resolver for an app that only
|
|
749
|
+
// opted OUT, and fw#2861's systemScope default only fires once a resolver
|
|
750
|
+
// already exists, so it must not force wantsL3 either.
|
|
725
751
|
state.hasRateLimitedHandlerCached = (() => {
|
|
726
|
-
for (const h of state.writeHandlerMap.values())
|
|
727
|
-
|
|
752
|
+
for (const h of state.writeHandlerMap.values()) {
|
|
753
|
+
if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
|
|
754
|
+
}
|
|
755
|
+
for (const h of state.queryHandlerMap.values()) {
|
|
756
|
+
if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
|
|
757
|
+
}
|
|
728
758
|
return false;
|
|
729
759
|
})();
|
|
730
760
|
}
|
|
@@ -380,6 +380,12 @@ export function buildInsertSchema(
|
|
|
380
380
|
shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
// Caller-chosen aggregate id for idempotent creates (deterministic-id
|
|
384
|
+
// patterns). Accepting it here only lets it survive parsing — the create
|
|
385
|
+
// handler still honors it only for a system-identity caller (jobs/hooks
|
|
386
|
+
// via createSystemUser), never for an ordinary HTTP-authenticated user.
|
|
387
|
+
shape["id"] = z.uuid().optional();
|
|
388
|
+
|
|
383
389
|
return applyTotalsMatchRefinements(entity, z.object(shape));
|
|
384
390
|
}
|
|
385
391
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// compare nutzt der Author selten bewusst); Author kann den
|
|
7
7
|
// Filter dann einfach nicht setzen.
|
|
8
8
|
// - boolean: eq, ne (in/lt/gt sinnlos für 2-Werte-Type).
|
|
9
|
-
// - number/money/decimal/date/timestamp/locatedTimestamp: alle
|
|
9
|
+
// - number/money/decimal/date/timestamp/locatedTimestamp: alle 7
|
|
10
10
|
// Ops — die Felder sind natürlich vergleichbar.
|
|
11
11
|
//
|
|
12
12
|
// Boot-Validator nutzt diese Map um Author-Fehler früh zu fangen
|
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
import type { FieldDefinition, ScreenFilterOp } from "./types";
|
|
18
18
|
|
|
19
19
|
const EQUALITY_ONLY = ["eq", "ne", "in"] as const satisfies readonly ScreenFilterOp[];
|
|
20
|
-
const COMPARABLE = [
|
|
20
|
+
const COMPARABLE = [
|
|
21
|
+
"eq",
|
|
22
|
+
"ne",
|
|
23
|
+
"lt",
|
|
24
|
+
"gt",
|
|
25
|
+
"lte",
|
|
26
|
+
"gte",
|
|
27
|
+
"in",
|
|
28
|
+
] as const satisfies readonly ScreenFilterOp[];
|
|
21
29
|
const BOOL_OPS = ["eq", "ne"] as const satisfies readonly ScreenFilterOp[];
|
|
22
30
|
|
|
23
31
|
export function getAllowedFilterOps(field: FieldDefinition): readonly ScreenFilterOp[] {
|
|
@@ -11,10 +11,62 @@ import type {
|
|
|
11
11
|
FormatSpec,
|
|
12
12
|
ListColumnSpec,
|
|
13
13
|
ProjectionDetailScreenDefinition,
|
|
14
|
+
RowAction,
|
|
14
15
|
ScreenDefinition,
|
|
15
16
|
SecretMintScreenDefinition,
|
|
17
|
+
ToolbarAction,
|
|
16
18
|
} from "./types/screen";
|
|
17
19
|
|
|
20
|
+
// Every screen type carrying `listScreenId` declares it identically; centralizing
|
|
21
|
+
// here keeps the switch the only place that grows for a new screen type.
|
|
22
|
+
export function explicitListScreenId(screen: ScreenDefinition): string | undefined {
|
|
23
|
+
switch (screen.type) {
|
|
24
|
+
case "custom":
|
|
25
|
+
case "projectionDetail":
|
|
26
|
+
case "entityEdit":
|
|
27
|
+
case "actionForm":
|
|
28
|
+
case "secretMint":
|
|
29
|
+
return screen.listScreenId;
|
|
30
|
+
default:
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shared by the renderer breadcrumb and the boot-validator so both resolve
|
|
36
|
+
// parents the same way; `getId` lets each caller normalize `screen.id`.
|
|
37
|
+
export function resolveNavParentScreen(
|
|
38
|
+
screens: readonly ScreenDefinition[],
|
|
39
|
+
detail: ScreenDefinition,
|
|
40
|
+
getId: (screen: ScreenDefinition) => string,
|
|
41
|
+
): ScreenDefinition | undefined {
|
|
42
|
+
const detailScreenId = getId(detail);
|
|
43
|
+
|
|
44
|
+
const listFromExplicit = ((): ScreenDefinition | undefined => {
|
|
45
|
+
const explicitId = explicitListScreenId(detail);
|
|
46
|
+
return explicitId !== undefined ? screens.find((s) => getId(s) === explicitId) : undefined;
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
// rowActions, toolbarActions, and drawer-kind actions (which mount inline
|
|
50
|
+
// and have no page of their own) all count as "reached from" this list.
|
|
51
|
+
const navigatesToDetail = (
|
|
52
|
+
actions: readonly (RowAction | ToolbarAction)[] | undefined,
|
|
53
|
+
): boolean =>
|
|
54
|
+
(actions ?? []).some(
|
|
55
|
+
(a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId,
|
|
56
|
+
);
|
|
57
|
+
const listFromRowAction = screens.find((s) => {
|
|
58
|
+
if (s.type !== "entityList" && s.type !== "projectionList") return false;
|
|
59
|
+
return navigatesToDetail(s.rowActions) || navigatesToDetail(s.toolbarActions);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const listFromEntity =
|
|
63
|
+
detail.type === "entityEdit"
|
|
64
|
+
? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
67
|
+
return listFromExplicit ?? listFromRowAction ?? listFromEntity;
|
|
68
|
+
}
|
|
69
|
+
|
|
18
70
|
export type EditLayoutScreen =
|
|
19
71
|
| ProjectionDetailScreenDefinition
|
|
20
72
|
| EntityEditScreenDefinition
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DbRunner } from "../../db/connection";
|
|
2
|
+
import { type TenantDb, unsafeRawForDeclaredStep } from "../../db/tenant-db";
|
|
3
|
+
import type { PipelineCtx } from "../types/step";
|
|
4
|
+
|
|
5
|
+
// unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
|
|
6
|
+
export function readSourceFor(
|
|
7
|
+
ctx: PipelineCtx,
|
|
8
|
+
unsafeAllTenants: { readonly reason: string } | undefined,
|
|
9
|
+
): TenantDb | DbRunner {
|
|
10
|
+
if (!unsafeAllTenants) return ctx.db;
|
|
11
|
+
return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
|
|
12
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// r.step.read.findMany — load multiple rows from a projection table.
|
|
2
2
|
//
|
|
3
|
-
// Sibling to read.findOne — same tenant-
|
|
3
|
+
// Sibling to read.findOne — same tenant-filtering: own tenant +
|
|
4
|
+
// SYSTEM_TENANT_ID reference rows by default, `unsafeAllTenants: { reason }`
|
|
5
|
+
// plus `escapeHatch: { reason }` on the handler to read across tenants.
|
|
4
6
|
// Resolves to a row-array (possibly empty), landed under steps.<name>.
|
|
5
7
|
//
|
|
6
8
|
// Optional `limit` — defaults to no-limit (caller-chosen, NOT a
|
|
@@ -11,6 +13,7 @@
|
|
|
11
13
|
import { selectMany, type WhereObject } from "../../db/query";
|
|
12
14
|
import { defineStep } from "../define-step";
|
|
13
15
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
16
|
+
import { readSourceFor } from "./_read-source";
|
|
14
17
|
import { resolveOptional } from "./_resolver-utils";
|
|
15
18
|
|
|
16
19
|
type ReadFindManyArgs = {
|
|
@@ -18,6 +21,7 @@ type ReadFindManyArgs = {
|
|
|
18
21
|
readonly table: unknown;
|
|
19
22
|
readonly where?: StepResolver<WhereObject | undefined>;
|
|
20
23
|
readonly limit?: number;
|
|
24
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
|
|
@@ -26,8 +30,9 @@ defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
|
|
|
26
30
|
resultKey: (args) => args.name,
|
|
27
31
|
run: async (args, ctx: PipelineCtx) => {
|
|
28
32
|
const where = resolveOptional(args.where, ctx);
|
|
33
|
+
const source = readSourceFor(ctx, args.unsafeAllTenants);
|
|
29
34
|
const rows = await selectMany(
|
|
30
|
-
|
|
35
|
+
source,
|
|
31
36
|
args.table,
|
|
32
37
|
where,
|
|
33
38
|
args.limit !== undefined ? { limit: args.limit } : undefined,
|
|
@@ -42,6 +47,7 @@ export function buildReadFindManyStep(
|
|
|
42
47
|
readonly table: unknown;
|
|
43
48
|
readonly where?: StepResolver<WhereObject | undefined>;
|
|
44
49
|
readonly limit?: number;
|
|
50
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
45
51
|
},
|
|
46
52
|
): StepInstance {
|
|
47
53
|
return {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// r.step.read.findOne — load a single row from a projection table.
|
|
2
2
|
//
|
|
3
3
|
// Thin wrapper on selectMany(db, table, where, { limit: 1 }) (bun-db).
|
|
4
|
-
// Resolves to the first row or null. Tenant-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// who pass an explicit tenantId.
|
|
4
|
+
// Resolves to the first row or null. Tenant-filtered like ctx.db
|
|
5
|
+
// method-form reads (own tenant + SYSTEM_TENANT_ID reference rows); a
|
|
6
|
+
// foreign `where.tenantId` is narrowed to the caller's own scope.
|
|
7
|
+
// Cross-tenant reads require `unsafeAllTenants: { reason }` here AND
|
|
8
|
+
// `escapeHatch: { reason }` on the handler — reported as an "unsafe-raw"
|
|
9
|
+
// escape-hatch audit event.
|
|
11
10
|
//
|
|
12
11
|
// Use when a subsequent step needs a row from the read-side. For
|
|
13
12
|
// cross-feature reads, prefer `r.step.callFeature(...)` (M.2) so the
|
|
@@ -23,12 +22,14 @@
|
|
|
23
22
|
import { selectMany, type WhereObject } from "../../db/query";
|
|
24
23
|
import { defineStep } from "../define-step";
|
|
25
24
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
25
|
+
import { readSourceFor } from "./_read-source";
|
|
26
26
|
import { resolveRequired } from "./_resolver-utils";
|
|
27
27
|
|
|
28
28
|
type ReadFindOneArgs = {
|
|
29
29
|
readonly name: string;
|
|
30
30
|
readonly table: unknown;
|
|
31
31
|
readonly where: StepResolver<WhereObject | undefined>;
|
|
32
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
32
33
|
};
|
|
33
34
|
|
|
34
35
|
defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
|
|
@@ -37,7 +38,8 @@ defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
|
|
|
37
38
|
resultKey: (args) => args.name,
|
|
38
39
|
run: async (args, ctx: PipelineCtx) => {
|
|
39
40
|
const where = resolveRequired(args.where, ctx);
|
|
40
|
-
const
|
|
41
|
+
const source = readSourceFor(ctx, args.unsafeAllTenants);
|
|
42
|
+
const rows = await selectMany(source, args.table, where, { limit: 1 });
|
|
41
43
|
return (rows[0] as Record<string, unknown> | undefined) ?? null;
|
|
42
44
|
},
|
|
43
45
|
});
|
|
@@ -47,6 +49,7 @@ export function buildReadFindOneStep(
|
|
|
47
49
|
opts: {
|
|
48
50
|
readonly table: unknown;
|
|
49
51
|
readonly where: StepResolver<WhereObject | undefined>;
|
|
52
|
+
readonly unsafeAllTenants?: { readonly reason: string };
|
|
50
53
|
},
|
|
51
54
|
): StepInstance {
|
|
52
55
|
return {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
20
20
|
import { deleteMany, type WhereObject } from "../../db/query";
|
|
21
|
+
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
21
22
|
import { defineStep } from "../define-step";
|
|
22
23
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
23
24
|
import { resolveRequired } from "./_resolver-utils";
|
|
@@ -40,7 +41,7 @@ defineStep<UnsafeProjectionDeleteArgs, void>({
|
|
|
40
41
|
// The `unsafe*`-step IS the sanctioned direct projection write — it exists
|
|
41
42
|
// to bypass the executor deliberately (boot-validated allowlist + reviewer
|
|
42
43
|
// gate). The brand-strip cast makes that explicit at the one blessed seam.
|
|
43
|
-
await deleteMany(ctx.db
|
|
44
|
+
await deleteMany(tenantDbRunner(ctx.db), args.table as EntityTableMeta, where);
|
|
44
45
|
},
|
|
45
46
|
});
|
|
46
47
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
15
15
|
import { extractTableName } from "../../db";
|
|
16
16
|
import { executeRawQuery } from "../../db/queries/raw-sql";
|
|
17
|
+
import { tenantDbRunner } from "../../db/tenant-db-runner";
|
|
17
18
|
import { defineStep } from "../define-step";
|
|
18
19
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
19
20
|
import { resolveRequired } from "./_resolver-utils";
|
|
@@ -78,7 +79,7 @@ defineStep<UnsafeProjectionUpsertArgs, void>({
|
|
|
78
79
|
`INSERT INTO ${quoteIdent(tableName)} (${colNames.join(", ")}) VALUES (${placeholders.join(", ")}) ` +
|
|
79
80
|
`ON CONFLICT (${conflictCols}) DO UPDATE SET ${setClauses.join(", ")}`;
|
|
80
81
|
|
|
81
|
-
await executeRawQuery(ctx.db
|
|
82
|
+
await executeRawQuery(tenantDbRunner(ctx.db), sqlText, params);
|
|
82
83
|
},
|
|
83
84
|
});
|
|
84
85
|
|
|
@@ -159,7 +159,12 @@ export type {
|
|
|
159
159
|
DeclarativeEventMigration,
|
|
160
160
|
DispatchWriteRef,
|
|
161
161
|
EntityRef,
|
|
162
|
+
EscapeHatchAuditSink,
|
|
162
163
|
EscapeHatchDeclaration,
|
|
164
|
+
EscapeHatchKind,
|
|
165
|
+
EscapeHatchReporter,
|
|
166
|
+
EscapeHatchTarget,
|
|
167
|
+
EscapeHatchUseEvent,
|
|
163
168
|
EventDef,
|
|
164
169
|
EventMigrationDef,
|
|
165
170
|
EventPiiFields,
|
|
@@ -184,6 +189,8 @@ export type {
|
|
|
184
189
|
QueryEvent,
|
|
185
190
|
QueryHandlerDef,
|
|
186
191
|
QueryHandlerFn,
|
|
192
|
+
RateLimitDeclaration,
|
|
193
|
+
RateLimitDisabled,
|
|
187
194
|
RateLimitOption,
|
|
188
195
|
RateLimitPer,
|
|
189
196
|
SessionUser,
|
|
@@ -198,7 +205,7 @@ export type {
|
|
|
198
205
|
WriteHandlerFn,
|
|
199
206
|
WriteResult,
|
|
200
207
|
} from "@cosmicdrift/kumiko-types/handlers";
|
|
201
|
-
export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
208
|
+
export { isOpenToAllGranted, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
202
209
|
export type {
|
|
203
210
|
DeleteContext,
|
|
204
211
|
EntityHookMap,
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -113,6 +113,20 @@ identity_switch_denied:
|
|
|
113
113
|
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
114
114
|
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
115
115
|
|
|
116
|
+
unsafe_all_tenants_denied:
|
|
117
|
+
endUser: |
|
|
118
|
+
Diese Aktion ist nicht erlaubt.
|
|
119
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
120
|
+
developer: |
|
|
121
|
+
`AccessDeniedError`: ein Handler oder Hook hat
|
|
122
|
+
`ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })`
|
|
123
|
+
aufgerufen, ohne `r.systemScope()`-Handler zu sein oder
|
|
124
|
+
`escapeHatch: { reason }` zu deklarieren.
|
|
125
|
+
|
|
126
|
+
Deklariere `escapeHatch: { reason: "..." }` am Write-, Query- oder
|
|
127
|
+
Stream-Handler (oder am `r.hook(...)`-Aufruf, wenn der Read in einem Hook
|
|
128
|
+
passiert), oder verlagere die Logik in ein `r.systemScope()`-Feature.
|
|
129
|
+
|
|
116
130
|
member_resolution_denied:
|
|
117
131
|
endUser: |
|
|
118
132
|
Diese Aktion ist nicht erlaubt.
|