@cosmicdrift/kumiko-framework 0.352.0 → 0.353.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/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/server.js +11 -3
- package/dist/api/sse-route.js +11 -1
- package/dist/bun-db/query.js +7 -2
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.js +20 -25
- package/dist/db/migrate-generator.js +9 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +13 -0
- package/dist/db/queries/shadow-swap.js +5 -2
- package/dist/engine/boot-validator/access-declarations.js +16 -1
- package/dist/engine/boot-validator/entity-handler.js +13 -0
- package/dist/engine/boot-validator/screens.js +45 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/build-app-schema.js +30 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/entity-handlers.js +15 -6
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/i18n-key.js +14 -3
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +8 -2
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +4 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +3 -3
- package/dist/files/types.js +21 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/testing/shared-entities.d.ts +10 -0
- package/package.json +4 -4
- package/src/changes.json +30 -0
|
@@ -13,6 +13,9 @@ const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
|
|
|
13
13
|
// red; prod redacts + logs instead of shipping the blob. Scans unconditionally:
|
|
14
14
|
// legacy ciphertext rows can outlive a subject KMS that later became
|
|
15
15
|
// unconfigured, and the marker check itself needs no KMS access to run.
|
|
16
|
+
// Buffers every JSON body (no streaming, roughly double peak memory for large
|
|
17
|
+
// exports) on purpose: a size threshold or opt-out would be a leak bypass for
|
|
18
|
+
// exactly the large responses most likely to carry raw rows.
|
|
16
19
|
export function piiCiphertextResponseGuard() {
|
|
17
20
|
return async (c, next) => {
|
|
18
21
|
await next();
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
1
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
2
|
import type { DbConnection } from "../db/connection.js";
|
|
3
3
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
4
4
|
import type { Meter, PrometheusMeter } from "../observability/index.js";
|
|
5
5
|
import type { EventConsumer } from "../pipeline/event-dispatcher.js";
|
|
6
6
|
export declare const DEFAULT_MAX_REQUEST_BYTES = 1048576;
|
|
7
7
|
export declare function registerBodyLimit(app: Hono, maxBytes: number): void;
|
|
8
|
+
export declare function bodyLimitForRoutePath(path: string, maxBytes: number): readonly MiddlewareHandler[];
|
|
8
9
|
export type MetricsRouteOptions = {
|
|
9
10
|
readonly token?: string;
|
|
10
11
|
readonly path?: string;
|
|
@@ -31,6 +31,14 @@ export function registerBodyLimit(app, maxBytes) {
|
|
|
31
31
|
return limit(c, next);
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
// registerBodyLimit only covers /api/*; signature routes outside it (webhook
|
|
35
|
+
// receivers, OAuth callbacks) read the raw body before verify() runs and so
|
|
36
|
+
// need the same cap on their own handler chain.
|
|
37
|
+
export function bodyLimitForRoutePath(path, maxBytes) {
|
|
38
|
+
if (maxBytes <= 0 || path === "/api" || path.startsWith("/api/"))
|
|
39
|
+
return [];
|
|
40
|
+
return [bodyLimit({ maxSize: maxBytes })];
|
|
41
|
+
}
|
|
34
42
|
// Timing-safe string compare — equal-length strings compare every byte
|
|
35
43
|
// regardless of where they diverge; different lengths return false without
|
|
36
44
|
// iterating. Prevents side-channel leaks on the Bearer-token check.
|
package/dist/api/server.js
CHANGED
|
@@ -40,7 +40,7 @@ import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
|
|
|
40
40
|
import { createDefaultSseBroker } from "./redis-sse-broker.js";
|
|
41
41
|
import { requestContext } from "./request-context.js";
|
|
42
42
|
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
|
|
43
|
-
import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
|
|
43
|
+
import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
|
|
44
44
|
import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
|
|
45
45
|
import { createSseRoute } from "./sse-route.js";
|
|
46
46
|
import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
|
|
@@ -782,7 +782,9 @@ export function buildServer(options) {
|
|
|
782
782
|
dispatchSystemQuery,
|
|
783
783
|
clientIpResolver,
|
|
784
784
|
});
|
|
785
|
-
mountHonoRoute(app, route.method, route.path, honoHandler
|
|
785
|
+
mountHonoRoute(app, route.method, route.path, honoHandler, route.entry === ExtraRouteEntries.signature
|
|
786
|
+
? bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES)
|
|
787
|
+
: []);
|
|
786
788
|
}
|
|
787
789
|
}
|
|
788
790
|
// /version-Default registriert NACH feature-routes — Hono "first match
|
|
@@ -927,6 +929,7 @@ function unauthenticatedResponse(c) {
|
|
|
927
929
|
}, 401);
|
|
928
930
|
}
|
|
929
931
|
const webSocketLog = createFallbackLogger("websocket");
|
|
932
|
+
const extraRouteLog = createFallbackLogger("extra-route");
|
|
930
933
|
function webSocketErrorResponse(c, status, code, message) {
|
|
931
934
|
return c.json({ error: { code, httpStatus: status, message } }, status);
|
|
932
935
|
}
|
|
@@ -1069,10 +1072,15 @@ function buildExtraRouteHonoHandler(route, shared) {
|
|
|
1069
1072
|
}
|
|
1070
1073
|
return c.json(e.body, e.status);
|
|
1071
1074
|
}
|
|
1075
|
+
// The body goes to unauthenticated callers; infra errors thrown by
|
|
1076
|
+
// verify() (secrets, DB) must not leak, so the detail stays in the log.
|
|
1077
|
+
extraRouteLog.error(`signature verify() threw on ${c.req.path}`, {
|
|
1078
|
+
error: e instanceof Error ? e.message : String(e),
|
|
1079
|
+
});
|
|
1072
1080
|
return c.json({
|
|
1073
1081
|
error: {
|
|
1074
1082
|
code: "extra_route_signature_invalid",
|
|
1075
|
-
message:
|
|
1083
|
+
message: "signature verification failed",
|
|
1076
1084
|
},
|
|
1077
1085
|
}, 401);
|
|
1078
1086
|
}
|
package/dist/api/sse-route.js
CHANGED
|
@@ -3,7 +3,7 @@ import { streamSSE } from "hono/streaming";
|
|
|
3
3
|
import { tenantChannel } from "../engine/constants.js";
|
|
4
4
|
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
5
5
|
import { Routes } from "./api-constants.js";
|
|
6
|
-
import { getUser } from "./auth-middleware.js";
|
|
6
|
+
import { getAuthTokenExpiry, getUser } from "./auth-middleware.js";
|
|
7
7
|
import { accessInvalidationCredentialFor } from "./sse-broker.js";
|
|
8
8
|
/**
|
|
9
9
|
* Heartbeat-Cadence für SSE-Streams.
|
|
@@ -81,6 +81,9 @@ function decideWireFrame(event, user, options) {
|
|
|
81
81
|
return undefined;
|
|
82
82
|
return { name: event.type, data };
|
|
83
83
|
}
|
|
84
|
+
// setTimeout overflows (fires immediately) above 2^31-1 ms; a token that
|
|
85
|
+
// outlives it just gets its stream recycled early, the client reconnects.
|
|
86
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
84
87
|
export function createSseRoute(broker, options) {
|
|
85
88
|
const route = new Hono();
|
|
86
89
|
route.get(Routes.sse, async (c) => {
|
|
@@ -88,6 +91,9 @@ export function createSseRoute(broker, options) {
|
|
|
88
91
|
// Channel is server-derived from authenticated user — never trust client input.
|
|
89
92
|
// Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
|
|
90
93
|
const channel = tenantChannel(user.tenantId);
|
|
94
|
+
// JWT expiry raises no access-invalidation event, so the stream must end
|
|
95
|
+
// itself when the token it was opened with expires.
|
|
96
|
+
const tokenExpiresAtSec = getAuthTokenExpiry(c);
|
|
91
97
|
return streamSSE(c, async (stream) => {
|
|
92
98
|
let resolveEnded;
|
|
93
99
|
const ended = new Promise((resolve) => {
|
|
@@ -105,6 +111,9 @@ export function createSseRoute(broker, options) {
|
|
|
105
111
|
stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
|
|
106
112
|
}, closeStream);
|
|
107
113
|
const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
|
|
114
|
+
const expiryTimer = tokenExpiresAtSec === undefined
|
|
115
|
+
? undefined
|
|
116
|
+
: setTimeout(closeStream, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, tokenExpiresAtSec * 1000 - Date.now())));
|
|
108
117
|
let released = false;
|
|
109
118
|
const release = () => {
|
|
110
119
|
// skip: onAbort and the finally block both release, the second call is a no-op
|
|
@@ -113,6 +122,7 @@ export function createSseRoute(broker, options) {
|
|
|
113
122
|
released = true;
|
|
114
123
|
// A client disconnect must also wake the heartbeat sleep, not only a server close.
|
|
115
124
|
resolveEnded();
|
|
125
|
+
clearTimeout(expiryTimer);
|
|
116
126
|
broker.removeClient(channel, clientId);
|
|
117
127
|
unsubscribeAccessInvalidation();
|
|
118
128
|
};
|
package/dist/bun-db/query.js
CHANGED
|
@@ -507,6 +507,11 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
507
507
|
parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
|
|
508
508
|
values.push(p.bound);
|
|
509
509
|
}
|
|
510
|
+
// A jsonb column holding the scalar itself is never `@>` an array of it.
|
|
511
|
+
if (isJsonbScalar(v)) {
|
|
512
|
+
parts.push(`${ref(col)} = $${idx++}::text::jsonb`);
|
|
513
|
+
values.push(JSON.stringify(v));
|
|
514
|
+
}
|
|
510
515
|
}
|
|
511
516
|
return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
|
|
512
517
|
}
|
|
@@ -592,8 +597,8 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
592
597
|
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
593
598
|
const p = prepareJsonbValue([opVal]);
|
|
594
599
|
if (p && p.kind === "param") {
|
|
595
|
-
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
|
|
596
|
-
values.push(p.bound);
|
|
600
|
+
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql} OR ${ref(col)} = $${idx++}::text::jsonb))`);
|
|
601
|
+
values.push(p.bound, JSON.stringify(opVal));
|
|
597
602
|
continue;
|
|
598
603
|
}
|
|
599
604
|
}
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
|
|
14
14
|
// in Sprint 1 (compliance-profiles).
|
|
15
15
|
//
|
|
16
|
+
// SubProcessor has no "removed" state yet: a removed entry is deleted from
|
|
17
|
+
// the array, so until the notification cron exists a removal is documented
|
|
18
|
+
// only in the changelog. Removed so far: Mailbox.org (mailbox hosting),
|
|
19
|
+
// replaced by ALL-INKL.COM on 2026-09-02.
|
|
20
|
+
//
|
|
16
21
|
// Quelle: docs/plans/datenschutz/compliance-profiles.md "Sub-Processor-
|
|
17
22
|
// Management" + docs/plans/datenschutz/legal-artifacts.md.
|
|
18
23
|
/**
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
2
2
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
3
|
-
import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId } from "./kms-adapter.js";
|
|
3
|
+
import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, PII_ERASED_SENTINEL, type SubjectId } from "./kms-adapter.js";
|
|
4
4
|
import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
|
|
5
|
-
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
|
|
6
|
-
export declare const PII_ERASED_SENTINEL = "[[erased]]";
|
|
5
|
+
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
|
|
7
6
|
export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
8
7
|
export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
9
8
|
export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
// a v2<->v3 prefix swap fails GCM instead of silently changing the type.
|
|
17
17
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
18
18
|
import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
|
|
19
|
-
import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
|
|
19
|
+
import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, PII_ERASED_SENTINEL, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
|
|
20
20
|
import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
|
|
21
21
|
import { resolveSubjectForField } from "./subject-resolver.js";
|
|
22
22
|
// Spec value (crypto-shredding.md) — renderers show it verbatim.
|
|
23
|
-
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
|
|
24
|
-
export const PII_ERASED_SENTINEL = "[[erased]]";
|
|
23
|
+
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
|
|
25
24
|
const IV_LENGTH = 12;
|
|
26
25
|
const AUTH_TAG_LENGTH = 16;
|
|
27
26
|
function buildAad(subject, field, isJson) {
|
|
@@ -222,6 +222,17 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
222
222
|
assertUnmanagedTableName(tableName, "deriveEntityTableMeta/buildEntityTableMeta");
|
|
223
223
|
}
|
|
224
224
|
const idType = entity.idType ?? "uuid";
|
|
225
|
+
if (source === "unmanaged" && idType === "serial") {
|
|
226
|
+
// Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
|
|
227
|
+
// feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
|
|
228
|
+
// `record:<entity>:<id>`; forgetSubject requires a UUID, so the value could never be shredded.
|
|
229
|
+
const recordOwnedField = Object.entries(entity.fields).find(
|
|
230
|
+
// @cast-boundary schema-walk — PII flags live on the resolved field annotation
|
|
231
|
+
([, field]) => field.recordOwned === true);
|
|
232
|
+
if (recordOwnedField) {
|
|
233
|
+
throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
225
236
|
// Base columns first, then user-fields. User-fields with the same
|
|
226
237
|
// pg-name as a base-column OVERRIDE the base-column (last-wins, same
|
|
227
238
|
// behavior as drizzle's `{ ...base, ...fields }` spread in the table
|
|
@@ -4,7 +4,7 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
|
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
6
6
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
7
|
-
import { UniqueViolationError, writeFailure } from "../errors/index.js";
|
|
7
|
+
import { InternalError, UniqueViolationError, writeFailure, } from "../errors/index.js";
|
|
8
8
|
import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
|
|
9
9
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
10
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
@@ -289,31 +289,26 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
289
289
|
...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
|
|
290
290
|
];
|
|
291
291
|
}
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// of the expect fields still shows the pre-write row. One query runs against
|
|
300
|
-
// a single consistent snapshot.
|
|
301
|
-
//
|
|
302
|
-
// The row's own `version` column can't be trusted instead: it is only in
|
|
303
|
-
// lock-step with the events for rows this executor wrote. A raw-seeded row
|
|
304
|
-
// (test fixtures, legacy data) can carry a default version with zero
|
|
305
|
-
// matching events, so deriving expectedVersion from it makes the append
|
|
306
|
-
// target a non-existent predecessor and fail.
|
|
307
|
-
// The events table's MAX(version) (0 for such a row) is the only value
|
|
308
|
-
// append() can safely use as expectedVersion — exactly what
|
|
309
|
-
// getStreamVersion() already returns for every other (non-`expect`)
|
|
310
|
-
// caller; this reads it in the same statement as the expect columns
|
|
311
|
-
// instead of a second round-trip.
|
|
312
|
-
//
|
|
313
|
-
// Reads raw column values — no decryptForRead pass — so `expect:` only
|
|
314
|
-
// supports plain (non-pii, non-encrypted) columns: business-state fields
|
|
315
|
-
// like status flags or foreign-key ids, not PII.
|
|
292
|
+
// Reads the `expect:` fields and the aggregate's MAX(version) in ONE statement:
|
|
293
|
+
// projection rows are written after their event commits, so two reads can
|
|
294
|
+
// pair a post-write version with a pre-write row.
|
|
295
|
+
// row.version can't stand in for MAX(version) — raw-seeded/legacy rows carry
|
|
296
|
+
// a default version with no matching events, so the append would target a
|
|
297
|
+
// non-existent predecessor.
|
|
298
|
+
// Raw column values, no decrypt pass: plain (non-pii, non-encrypted) columns only.
|
|
316
299
|
async function loadExpectSnapshot(db, id, streamTenantId, expectKeys) {
|
|
300
|
+
for (const key of expectKeys) {
|
|
301
|
+
if (table[key] === undefined) {
|
|
302
|
+
throw new InternalError({
|
|
303
|
+
message: `expect: "${key}" is not a column of entity "${entityName}"`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (encryptedFields.has(key) || piiSubjectFields.includes(key)) {
|
|
307
|
+
throw new InternalError({
|
|
308
|
+
message: `expect: "${key}" on entity "${entityName}" is stored encrypted and cannot be compared as plaintext`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
317
312
|
const quote = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
318
313
|
const columnOf = (field) => quote(table[field]?.name ?? toSnakeCase(field));
|
|
319
314
|
const tableName = String(table[KUMIKO_NAME_SYMBOL]);
|
|
@@ -122,7 +122,10 @@ function diffOneTable(prev, next) {
|
|
|
122
122
|
const uniqueChanged = (prevI.unique ?? false) === (idx.unique ?? false)
|
|
123
123
|
? undefined
|
|
124
124
|
: { from: prevI.unique ?? false, to: idx.unique ?? false };
|
|
125
|
-
|
|
125
|
+
// Whitespace-only re-rendering of the predicate must not drop and rebuild a large index.
|
|
126
|
+
const whereSqlChanged = whereSqlEqual(prevI.whereSql, idx.whereSql)
|
|
127
|
+
? undefined
|
|
128
|
+
: { from: prevI.whereSql, to: idx.whereSql };
|
|
126
129
|
const needsManualWhereChanged = (prevI.needsManualWhere ?? false) === (idx.needsManualWhere ?? false)
|
|
127
130
|
? undefined
|
|
128
131
|
: { from: prevI.needsManualWhere ?? false, to: idx.needsManualWhere ?? false };
|
|
@@ -297,6 +300,11 @@ function renderColumnChange(tableName, change) {
|
|
|
297
300
|
function toSingleLine(text) {
|
|
298
301
|
return text.replace(/\s+/g, " ").trim();
|
|
299
302
|
}
|
|
303
|
+
function whereSqlEqual(a, b) {
|
|
304
|
+
if (a === undefined || b === undefined)
|
|
305
|
+
return a === b;
|
|
306
|
+
return toSingleLine(a) === toSingleLine(b);
|
|
307
|
+
}
|
|
300
308
|
function describeIndexChange(change) {
|
|
301
309
|
const parts = [];
|
|
302
310
|
if (change.columnsChanged) {
|
|
@@ -16,6 +16,7 @@ export type PiiBackfillResult = {
|
|
|
16
16
|
};
|
|
17
17
|
export type PiiBackfillScanCache = {
|
|
18
18
|
forgottenAggregates?: ReadonlySet<string>;
|
|
19
|
+
destroyedTenants?: ReadonlySet<string>;
|
|
19
20
|
};
|
|
20
21
|
export type PiiBackfillBatchResult = PiiBackfillResult & {
|
|
21
22
|
readonly lastEventId: string | null;
|
|
@@ -380,8 +380,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
380
380
|
const forgottenAggregates = await loadForgottenAggregates();
|
|
381
381
|
if (forgottenAggregates.has(aggregateId))
|
|
382
382
|
return true;
|
|
383
|
+
if (subject.kind === "record" && subject.entity === "tenant") {
|
|
384
|
+
return (await loadDestroyedTenants()).has(subject.id);
|
|
385
|
+
}
|
|
383
386
|
return subject.kind === "user" && forgottenAggregates.has(subject.userId);
|
|
384
387
|
}
|
|
388
|
+
// A tenant destroyed before its name was record-owned never had a record key
|
|
389
|
+
// to erase, and destroy tombstones the row instead of emitting *.forgotten —
|
|
390
|
+
// read_tenants.destroyed_at is the only durable marker.
|
|
391
|
+
async function loadDestroyedTenants() {
|
|
392
|
+
if (scanCache.destroyedTenants)
|
|
393
|
+
return scanCache.destroyedTenants;
|
|
394
|
+
const rows = (await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`));
|
|
395
|
+
scanCache.destroyedTenants = new Set(rows.map((r) => r.id));
|
|
396
|
+
return scanCache.destroyedTenants;
|
|
397
|
+
}
|
|
385
398
|
}
|
|
386
399
|
// Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
|
|
387
400
|
// (erase) is the caller's call, made from the "unresolved" outcome.
|
|
@@ -362,10 +362,13 @@ export async function countColumnDrift(tx, tableName, meta) {
|
|
|
362
362
|
// object depends on the live table the swap fails loud and the whole rebuild
|
|
363
363
|
// rolls back, leaving the old table untouched.
|
|
364
364
|
export async function swapShadowIntoLive(tx, tableName) {
|
|
365
|
-
// Re-checked right before DROP: RLS could have been enabled after the early check.
|
|
366
|
-
await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
|
|
367
365
|
const raw = asRawClient(tx);
|
|
368
366
|
const ident = quoteTableIdent(tableName);
|
|
367
|
+
// Held before the re-check so a concurrent ENABLE ROW LEVEL SECURITY either commits first
|
|
368
|
+
// (and is seen) or waits behind the DROP. Without it the catalog read takes no lock, and
|
|
369
|
+
// the unfenced paths (MSP rebuild, projection with no subscribed events) would drop RLS.
|
|
370
|
+
await raw.unsafe(`LOCK TABLE public.${ident} IN ACCESS EXCLUSIVE MODE`);
|
|
371
|
+
await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
|
|
369
372
|
await raw.unsafe(`DROP TABLE public.${ident}`);
|
|
370
373
|
await raw.unsafe(`ALTER TABLE ${SCHEMA_IDENT}.${ident} SET SCHEMA public`);
|
|
371
374
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
|
+
import { escapeHatchGrantsProblem, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
|
|
3
3
|
import { ANONYMOUS_ROLE } from "../system-user.js";
|
|
4
4
|
import { collectZodObjectKeys } from "./zod-shape.js";
|
|
@@ -143,6 +143,20 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
|
|
|
143
143
|
'request shares one caller identity, so owner-binding via from("user:id", ...) does not ' +
|
|
144
144
|
"vouch for it; protection comes from the handler's required rateLimit (per ip).");
|
|
145
145
|
}
|
|
146
|
+
// The rateLimit is the only protection of a public-intake handler, so the
|
|
147
|
+
// generic anonymous opt-out (rateLimit: { disabled: true }) must not apply.
|
|
148
|
+
function validatePublicIntakeRateLimit(feature, handlerName, handler) {
|
|
149
|
+
// skip: not a public-intake handler, or its rateLimit is active
|
|
150
|
+
if (declaredPersonalData(handler.access) !== "public-intake")
|
|
151
|
+
return;
|
|
152
|
+
// skip: rateLimit is active
|
|
153
|
+
if (!isRateLimitDisabled(handler.rateLimit))
|
|
154
|
+
return;
|
|
155
|
+
throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
|
|
156
|
+
'access.personalData: "public-intake" with rateLimit: { disabled: true } — the rateLimit ' +
|
|
157
|
+
'is the only protection for anonymous personal-data writes. Declare rateLimit: { per: "ip" ' +
|
|
158
|
+
'or "ip+handler", ... } instead.');
|
|
159
|
+
}
|
|
146
160
|
export function validateAccessDeclarations(feature) {
|
|
147
161
|
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
148
162
|
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
@@ -150,6 +164,7 @@ export function validateAccessDeclarations(feature) {
|
|
|
150
164
|
validatePersonalDataValue(feature, handlerName, handler.access);
|
|
151
165
|
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
152
166
|
validateAnonymousPersonalData(feature, handlerName, handler);
|
|
167
|
+
validatePublicIntakeRateLimit(feature, handlerName, handler);
|
|
153
168
|
}
|
|
154
169
|
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
155
170
|
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
|
|
1
2
|
import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
3
|
import { ZodObject } from "zod";
|
|
3
4
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
|
|
4
5
|
import { access as accessPresets } from "../config-helpers.js";
|
|
5
6
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
7
|
+
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
6
8
|
export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
|
|
7
9
|
// Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
|
|
8
10
|
// `userOwned` / `tenantOwned` / `allowPlaintext`-Marker → Boot-Warning.
|
|
@@ -672,14 +674,25 @@ function validateCheckboxDisplayOptions(field, fieldName, entityName) {
|
|
|
672
674
|
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`);
|
|
673
675
|
}
|
|
674
676
|
}
|
|
677
|
+
function assertSelectOptionTones(field, path, featureName) {
|
|
678
|
+
const tones = field.type === "select" ? Object.entries(field.optionTones ?? {}) : [];
|
|
679
|
+
for (const [optionValue, tone] of tones) {
|
|
680
|
+
if (!isSelectOptionTone(tone)) {
|
|
681
|
+
throw new Error(`[Feature ${featureName}] Entity select field "${path}" optionTones["${optionValue}"] is ` +
|
|
682
|
+
`"${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
675
686
|
export function validateEntitySelectOptionsQuery(feature) {
|
|
676
687
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
677
688
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
678
689
|
assertNoSelectOptionsQuery(field, `${entityName}.${fieldName}`, feature.name);
|
|
690
|
+
assertSelectOptionTones(field, `${entityName}.${fieldName}`, feature.name);
|
|
679
691
|
if (field.type !== "embedded")
|
|
680
692
|
continue;
|
|
681
693
|
for (const [subName, subField] of Object.entries(field.schema ?? {})) {
|
|
682
694
|
assertNoSelectOptionsQuery(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
|
|
695
|
+
assertSelectOptionTones(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
|
|
683
696
|
}
|
|
684
697
|
}
|
|
685
698
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// into validateColumnRendererForm, splitting them would create a
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
7
|
-
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
|
|
7
|
+
import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
|
|
8
8
|
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
9
9
|
import { rowMetaFieldNames } from "../../db/table-builder.js";
|
|
10
10
|
import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
|
|
@@ -12,9 +12,10 @@ import { isEncryptedAtRest } from "../config-helpers.js";
|
|
|
12
12
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
13
13
|
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
14
14
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
|
|
15
|
-
import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
15
|
+
import { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
|
|
16
16
|
import { metricField } from "../types/index.js";
|
|
17
17
|
import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
|
|
18
|
+
import { isSelectOptionTone } from "./select-option-tone.js";
|
|
18
19
|
// entityList and projectionList both allow a rowAction to double as the
|
|
19
20
|
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
20
21
|
// screen, or the renderer can't tell which one should fire.
|
|
@@ -345,19 +346,41 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
|
|
|
345
346
|
`kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
|
|
346
347
|
`actionForm screen, or use kind:"navigate" for a full-page target.`);
|
|
347
348
|
}
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
349
|
+
// The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
|
|
350
|
+
// when the target doesn't declare the field, marks it sensitive/password,
|
|
351
|
+
// or doesn't render it in its layout — each would only show up as an empty
|
|
352
|
+
// field at click time.
|
|
351
353
|
// skip: no params extractor — nothing to check against the target's fields.
|
|
352
354
|
if (action.params === undefined)
|
|
353
355
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
+
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, action.screen, target, action.params);
|
|
357
|
+
}
|
|
358
|
+
function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
|
|
359
|
+
const renderedFieldNames = new Set();
|
|
360
|
+
for (const section of target.layout.sections) {
|
|
361
|
+
if (!isFieldsEditSection(section))
|
|
356
362
|
continue;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
363
|
+
for (const spec of sectionFieldSpecs(section))
|
|
364
|
+
renderedFieldNames.add(normalizeEditField(spec).field);
|
|
365
|
+
}
|
|
366
|
+
for (const fieldName of rowFieldExtractorKeys(params)) {
|
|
367
|
+
if (!Object.hasOwn(target.fields, fieldName)) {
|
|
368
|
+
throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not declare as a ` +
|
|
369
|
+
`field — the renderer would drop it and leave the form empty. Target fields: ` +
|
|
370
|
+
`${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
|
|
371
|
+
}
|
|
372
|
+
const targetField = target.fields[fieldName];
|
|
373
|
+
const isSensitiveOrPassword = targetField !== undefined &&
|
|
374
|
+
(("sensitive" in targetField && targetField.sensitive === true) ||
|
|
375
|
+
("format" in targetField && targetField.format === "password"));
|
|
376
|
+
if (isSensitiveOrPassword) {
|
|
377
|
+
throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" marks as ` +
|
|
378
|
+
`sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
|
|
379
|
+
}
|
|
380
|
+
if (!renderedFieldNames.has(fieldName)) {
|
|
381
|
+
throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not render in ` +
|
|
382
|
+
`its layout — the renderer only prefills fields the layout shows and would drop it.`);
|
|
383
|
+
}
|
|
361
384
|
}
|
|
362
385
|
}
|
|
363
386
|
// The target can be any screen, so this checks the resolved target's
|
|
@@ -564,7 +587,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
|
|
|
564
587
|
throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
|
|
565
588
|
`when using groups.`);
|
|
566
589
|
}
|
|
567
|
-
if (section.fields.length === 0 && section.groups ===
|
|
590
|
+
if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
|
|
568
591
|
throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
|
|
569
592
|
`fields (or groups) to it.`);
|
|
570
593
|
}
|
|
@@ -1048,6 +1071,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
1048
1071
|
}
|
|
1049
1072
|
}
|
|
1050
1073
|
}
|
|
1074
|
+
for (const [statusValue, tone] of Object.entries(screen.header?.statusTones ?? {})) {
|
|
1075
|
+
if (!isSelectOptionTone(tone)) {
|
|
1076
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.statusTones["${statusValue}"] ` +
|
|
1077
|
+
`is "${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1051
1080
|
if (Array.isArray(screen.header?.subtitle)) {
|
|
1052
1081
|
if (screen.header.subtitleHref !== undefined) {
|
|
1053
1082
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
|
|
@@ -1782,8 +1811,9 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
|
|
|
1782
1811
|
throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
|
|
1783
1812
|
}
|
|
1784
1813
|
validateStackedAreaOnlyProps(where, panel);
|
|
1785
|
-
|
|
1786
|
-
|
|
1814
|
+
const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
|
|
1815
|
+
if (nonBooleanProp !== undefined) {
|
|
1816
|
+
throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
|
|
1787
1817
|
}
|
|
1788
1818
|
for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
|
|
1789
1819
|
if (typeof color !== "string" || color.trim() === "") {
|
|
@@ -1800,7 +1830,7 @@ function validateStackedAreaOnlyProps(where, panel) {
|
|
|
1800
1830
|
// skip: stacked-area is the one chart kind that supports these props
|
|
1801
1831
|
if (panel.chart === "stacked-area")
|
|
1802
1832
|
return;
|
|
1803
|
-
const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges"].filter((prop) => panel[prop] !== undefined);
|
|
1833
|
+
const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges", "brush"].filter((prop) => panel[prop] !== undefined);
|
|
1804
1834
|
if (stackedAreaOnly.length > 0) {
|
|
1805
1835
|
throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
|
|
1806
1836
|
}
|
|
@@ -268,6 +268,7 @@ export function findNonJsonSafePath(value, path) {
|
|
|
268
268
|
// one that contradicts the schema.
|
|
269
269
|
function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
|
|
270
270
|
return Object.entries(screens).map(([shortId, screen]) => {
|
|
271
|
+
assertValidScreenMultilineRows(featureName, shortId, screen);
|
|
271
272
|
if (screen.type === "configEdit")
|
|
272
273
|
return projectConfigEditScreen(screen, encryptedConfigKeyQns);
|
|
273
274
|
if (screen.type === "projectionList")
|
|
@@ -330,7 +331,7 @@ function deriveProjectionListCapabilities(schema) {
|
|
|
330
331
|
function projectEntities(entities) {
|
|
331
332
|
const out = {};
|
|
332
333
|
for (const [name, entity] of Object.entries(entities)) {
|
|
333
|
-
out[name] = projectEntity(entity);
|
|
334
|
+
out[name] = projectEntity(name, entity);
|
|
334
335
|
}
|
|
335
336
|
return out;
|
|
336
337
|
}
|
|
@@ -339,9 +340,10 @@ function projectEntities(entities) {
|
|
|
339
340
|
// einzeln auszuhandeln, walken wir die Field-Map und filtern auf die
|
|
340
341
|
// Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate-
|
|
341
342
|
// Functions, Computed-Functions, Default-Functions.
|
|
342
|
-
function projectEntity(entity) {
|
|
343
|
+
function projectEntity(entityName, entity) {
|
|
343
344
|
const fieldsOut = {};
|
|
344
345
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
346
|
+
assertValidMultilineRows(`Entity "${entityName}"`, fieldName, fieldDef);
|
|
345
347
|
fieldsOut[fieldName] = projectField(fieldDef);
|
|
346
348
|
}
|
|
347
349
|
// derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
|
|
@@ -380,6 +382,32 @@ function projectEntity(entity) {
|
|
|
380
382
|
function projectDerivedField(derivedDef) {
|
|
381
383
|
return { valueType: derivedDef.valueType };
|
|
382
384
|
}
|
|
385
|
+
// A textarea height that is not a positive integer cannot render anywhere; fail
|
|
386
|
+
// at boot with the field name instead of letting each renderer guess a fallback.
|
|
387
|
+
function assertValidMultilineRows(owner, fieldName, fieldDef) {
|
|
388
|
+
const multiline = fieldDef.type === "text" || fieldDef.type === "longText" ? fieldDef.multiline : undefined;
|
|
389
|
+
const rows = typeof multiline === "object" ? multiline.rows : undefined;
|
|
390
|
+
// Non-number values are not JSON-safe and get dropped by the projection below.
|
|
391
|
+
if (typeof rows === "number" && (!Number.isInteger(rows) || rows < 1)) {
|
|
392
|
+
throw new Error(`${owner} field "${fieldName}": multiline.rows must be a positive integer, got ${rows}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Screen forms carry their own inline field maps into the client schema.
|
|
396
|
+
function assertValidScreenMultilineRows(featureName, shortId, screen) {
|
|
397
|
+
const owner = `Screen "${featureName}:${shortId}"`;
|
|
398
|
+
const check = (fields) => {
|
|
399
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
400
|
+
assertValidMultilineRows(owner, fieldName, fieldDef);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
if (screen.type === "configEdit" || screen.type === "actionForm")
|
|
404
|
+
check(screen.fields);
|
|
405
|
+
if (screen.type === "secretMint") {
|
|
406
|
+
check(screen.fields);
|
|
407
|
+
if (screen.confirm !== undefined)
|
|
408
|
+
check(screen.confirm.fields);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
383
411
|
// Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
|
|
384
412
|
// an array/plain-object built only from JSON-safe values) — even though the
|
|
385
413
|
// FieldDefinition types only type `default` as a literal, the sample-authoring
|