@cosmicdrift/kumiko-framework 0.351.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/seed-context.d.ts +17 -0
- package/dist/db/queries/seed-context.js +26 -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.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +30 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-refs.js +7 -2
- package/dist/engine/boot-validator/screens.js +60 -22
- 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 +33 -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 +34 -2
- package/dist/es-ops/index.d.ts +1 -1
- package/dist/es-ops/types.d.ts +20 -0
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/tenant-db-append.d.ts +8 -0
- package/dist/event-store/tenant-db-append.js +16 -0
- 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/dispatch-batch.js +5 -1
- package/dist/pipeline/dispatch-write.js +36 -82
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/pre-handler-gates.d.ts +9 -0
- package/dist/pipeline/pre-handler-gates.js +104 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
- package/dist/pipeline/pre-transaction-reservations.js +34 -21
- package/dist/testing/shared-entities.d.ts +10 -0
- package/package.json +4 -4
- package/src/changes.json +57 -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.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DbRunner } from "../connection.js";
|
|
1
2
|
import type { AnyDb } from "../query.js";
|
|
2
3
|
export type SeedUserRow = {
|
|
3
4
|
readonly id: string;
|
|
@@ -18,3 +19,19 @@ export type SeedTenantDbRow = {
|
|
|
18
19
|
export declare function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null>;
|
|
19
20
|
export declare function selectMembershipsOfUser(db: AnyDb, userId: string): Promise<readonly SeedMembershipDbRow[]>;
|
|
20
21
|
export declare function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]>;
|
|
22
|
+
export type SeedTemplateResourceDbRow = {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly tenant_id: string;
|
|
25
|
+
readonly slug: string;
|
|
26
|
+
readonly kind: string;
|
|
27
|
+
readonly locale: string;
|
|
28
|
+
readonly status: string;
|
|
29
|
+
};
|
|
30
|
+
export type SeedTemplateResourceDbFilter = {
|
|
31
|
+
readonly tenantId: string;
|
|
32
|
+
readonly slug?: string;
|
|
33
|
+
readonly kind?: string;
|
|
34
|
+
readonly status?: string;
|
|
35
|
+
readonly locale?: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function selectTemplateResources(db: DbRunner, filter: SeedTemplateResourceDbFilter): Promise<readonly SeedTemplateResourceDbRow[]>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "../query.js";
|
|
2
|
+
import { tableExists } from "../schema-inspection.js";
|
|
3
|
+
import { SOFT_DELETE_LIVE_ROW_PREDICATE } from "../table-builder.js";
|
|
2
4
|
export async function selectUserByEmail(db, email) {
|
|
3
5
|
const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
|
|
4
6
|
FROM read_users
|
|
@@ -23,3 +25,27 @@ export async function selectAllTenants(db) {
|
|
|
23
25
|
FROM read_tenants
|
|
24
26
|
ORDER BY inserted_at`, []);
|
|
25
27
|
}
|
|
28
|
+
// Column names are fixed fragments; every filter value is a bound parameter.
|
|
29
|
+
// template-resolver is an optional feature — without its table the answer is
|
|
30
|
+
// "no templates", not an error.
|
|
31
|
+
export async function selectTemplateResources(db, filter) {
|
|
32
|
+
if (!(await tableExists(db, "read_template_resources")))
|
|
33
|
+
return [];
|
|
34
|
+
const conditions = ["tenant_id = $1", SOFT_DELETE_LIVE_ROW_PREDICATE];
|
|
35
|
+
const params = [filter.tenantId];
|
|
36
|
+
for (const [column, value] of [
|
|
37
|
+
["slug", filter.slug],
|
|
38
|
+
["kind", filter.kind],
|
|
39
|
+
["status", filter.status],
|
|
40
|
+
["locale", filter.locale],
|
|
41
|
+
]) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
params.push(value);
|
|
45
|
+
conditions.push(`${column} = $${params.length}`);
|
|
46
|
+
}
|
|
47
|
+
return unsafeReadRetrying(db, `SELECT id::text AS id, tenant_id::text AS tenant_id, slug, kind, locale, status
|
|
48
|
+
FROM read_template_resources
|
|
49
|
+
WHERE ${conditions.join(" AND ")}
|
|
50
|
+
ORDER BY slug, locale`, params);
|
|
51
|
+
}
|
|
@@ -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);
|
|
@@ -19,6 +19,7 @@ export declare function validateFileFields(feature: FeatureDefinition): boolean;
|
|
|
19
19
|
export declare function validateReferenceFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
20
20
|
export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
21
21
|
export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
|
|
22
|
+
export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDefinition, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
22
23
|
export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
|
|
23
24
|
export declare function validateImageVariants(feature: FeatureDefinition): void;
|
|
24
25
|
export declare function validateTransitions(feature: FeatureDefinition): void;
|
|
@@ -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
|
}
|
|
@@ -692,6 +705,23 @@ function assertNoSelectOptionsQuery(field, path, featureName) {
|
|
|
692
705
|
throw new Error(`[Feature ${featureName}] Entity select field "${path}" declares optionsQuery — not supported on entity fields because the stored value would not be validated against the query result. Use static options, or a reference field.`);
|
|
693
706
|
}
|
|
694
707
|
}
|
|
708
|
+
// A typo'd availability QN would only surface as a failed query when the form
|
|
709
|
+
// mounts, so it is pinned at boot like the other option queries.
|
|
710
|
+
export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
|
|
711
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
712
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
713
|
+
if (field.type !== "select" || field.optionsAvailabilityQuery === undefined)
|
|
714
|
+
continue;
|
|
715
|
+
const query = field.optionsAvailabilityQuery;
|
|
716
|
+
if (query.length === 0 || !queryHandlers.has(query)) {
|
|
717
|
+
throw new Error(`[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}" ` +
|
|
718
|
+
`declares optionsAvailabilityQuery "${query}" which is not a registered query-handler. ` +
|
|
719
|
+
`Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
|
|
720
|
+
`is declared via r.queryHandler(...).`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
695
725
|
export function validateMultiSelectFields(feature) {
|
|
696
726
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
697
727
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -9,7 +9,7 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
|
|
|
9
9
|
import { validateFeatureBootChecks } from "./boot-check.js";
|
|
10
10
|
import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
|
|
11
11
|
import { collectDetailForScreens } from "./detail-screens.js";
|
|
12
|
-
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
12
|
+
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
13
13
|
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
14
14
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
15
15
|
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
@@ -169,6 +169,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
169
169
|
validateRecordOwnedSubjects(feature);
|
|
170
170
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
171
171
|
validateEntitySelectOptionsQuery(feature);
|
|
172
|
+
validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
|
|
172
173
|
validateEmbeddedFields(feature, featureMap, queryHandlerQns);
|
|
173
174
|
validateMultiSelectFields(feature);
|
|
174
175
|
validateImageVariants(feature);
|
|
@@ -45,9 +45,14 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
45
45
|
}
|
|
46
46
|
function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
|
|
47
47
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
48
|
-
if (field.type !== "select"
|
|
48
|
+
if (field.type !== "select")
|
|
49
49
|
continue;
|
|
50
|
-
|
|
50
|
+
if (field.optionsQuery !== undefined) {
|
|
51
|
+
checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
|
|
52
|
+
}
|
|
53
|
+
if (field.optionsAvailabilityQuery !== undefined) {
|
|
54
|
+
checkQueryRef(queryHandlers, field.optionsAvailabilityQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsAvailabilityQuery`);
|
|
55
|
+
}
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|