@cosmicdrift/kumiko-framework 0.342.0 → 0.344.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/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +35 -2
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-id-middleware.d.ts +2 -0
- package/dist/api/request-id-middleware.js +4 -1
- package/dist/api/routes.js +5 -5
- package/dist/api/server.d.ts +3 -0
- package/dist/api/server.js +4 -1
- package/dist/compliance/duration-spec.d.ts +2 -1
- package/dist/compliance/duration-spec.js +11 -0
- package/dist/compliance/index.d.ts +2 -2
- package/dist/compliance/index.js +1 -1
- package/dist/compliance/profiles.d.ts +6 -5
- package/dist/db/entity-field-encryption.d.ts +1 -1
- package/dist/db/entity-field-encryption.js +5 -5
- package/dist/db/event-store-executor-write.js +38 -11
- package/dist/db/projection-registry-binding.d.ts +3 -0
- package/dist/db/projection-registry-binding.js +12 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +22 -7
- package/dist/engine/boot-validator/index.d.ts +11 -0
- package/dist/engine/boot-validator/index.js +45 -1
- package/dist/engine/boot-validator/query-output-columns.js +8 -7
- package/dist/engine/boot-validator/query-refs.js +3 -0
- package/dist/engine/boot-validator/screens.js +28 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/env/index.d.ts +3 -0
- package/dist/env/index.js +12 -0
- package/dist/errors/docs-url.d.ts +7 -0
- package/dist/errors/docs-url.js +36 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +1 -0
- package/dist/errors/kumiko-error.d.ts +0 -2
- package/dist/errors/kumiko-error.js +0 -24
- package/dist/errors/serialize.d.ts +3 -2
- package/dist/errors/serialize.js +4 -2
- package/dist/jobs/job-runner.js +31 -2
- package/dist/pipeline/dispatch-shared.js +3 -1
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +6 -0
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/projections-runner.d.ts +1 -0
- package/dist/pipeline/projections-runner.js +23 -14
- package/dist/rate-limit/middleware.js +1 -1
- package/dist/stack/test-stack.d.ts +7 -0
- package/dist/stack/test-stack.js +6 -0
- package/package.json +4 -4
- package/src/changes.json +32 -0
|
@@ -6,6 +6,7 @@ import { type AuthSessionChecker, type AuthSessionStatus, type TokenVerifier } f
|
|
|
6
6
|
import { type ClientIpResolver } from "./client-ip.js";
|
|
7
7
|
import type { JwtHelper } from "./jwt.js";
|
|
8
8
|
import type { PostAuthLandingResolver } from "./post-auth-landing.js";
|
|
9
|
+
export declare function assertRetiredCookieDomains(retiredCookieDomains: readonly string[] | undefined, cookieDomain: string | undefined): void;
|
|
9
10
|
export type LoginRateLimiter = {
|
|
10
11
|
check(key: string): Promise<boolean>;
|
|
11
12
|
reset(key: string): Promise<void>;
|
|
@@ -48,6 +49,7 @@ export type AuthRoutesConfig = {
|
|
|
48
49
|
invite?: InviteConfig;
|
|
49
50
|
cookieSameSite?: "lax" | "strict";
|
|
50
51
|
cookieDomain?: string;
|
|
52
|
+
retiredCookieDomains?: readonly string[];
|
|
51
53
|
allowedOrigins?: readonly string[];
|
|
52
54
|
unsafeSkipOriginCheck?: boolean;
|
|
53
55
|
trustedProxyHops?: number;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -44,12 +44,18 @@ function setAuthCookies(c, opts) {
|
|
|
44
44
|
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
|
|
45
45
|
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
|
|
46
46
|
}
|
|
47
|
+
// Must precede the setCookie calls: browsers apply Set-Cookie in order and
|
|
48
|
+
// a retired domain equal to the new one is rejected at config time.
|
|
49
|
+
for (const retiredDomain of opts.retiredDomains) {
|
|
50
|
+
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
51
|
+
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
52
|
+
}
|
|
47
53
|
setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
|
|
48
54
|
// Intentionally NOT HttpOnly — the web client has to read this from
|
|
49
55
|
// document.cookie to include it in the X-CSRF-Token request header.
|
|
50
56
|
setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
|
|
51
57
|
}
|
|
52
|
-
function clearAuthCookies(c, domain) {
|
|
58
|
+
function clearAuthCookies(c, domain, retiredDomains) {
|
|
53
59
|
// Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
|
|
54
60
|
// (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
|
|
55
61
|
// Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
|
|
@@ -60,6 +66,30 @@ function clearAuthCookies(c, domain) {
|
|
|
60
66
|
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
|
|
61
67
|
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
|
|
62
68
|
}
|
|
69
|
+
for (const retiredDomain of retiredDomains) {
|
|
70
|
+
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
71
|
+
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const INVALID_COOKIE_DOMAIN_CHARS = /[;,\s]/;
|
|
75
|
+
function normalizeCookieDomain(domain) {
|
|
76
|
+
return domain.replace(/^\./, "").toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
// A malformed entry would corrupt the Set-Cookie header; an entry equal to the
|
|
79
|
+
// current cookieDomain would make login delete the cookie it just sets.
|
|
80
|
+
export function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
|
|
81
|
+
const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
|
|
82
|
+
for (const entry of retiredCookieDomains ?? []) {
|
|
83
|
+
if (entry === "" || normalizeCookieDomain(entry) === "") {
|
|
84
|
+
throw new Error("createAuthRoutes: retiredCookieDomains must not contain an empty entry");
|
|
85
|
+
}
|
|
86
|
+
if (INVALID_COOKIE_DOMAIN_CHARS.test(entry)) {
|
|
87
|
+
throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" contains an invalid character (;, comma or whitespace)`);
|
|
88
|
+
}
|
|
89
|
+
if (normalizeCookieDomain(entry) === current) {
|
|
90
|
+
throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" equals cookieDomain; login would delete its own cookie`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
}
|
|
64
94
|
// Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
|
|
65
95
|
// malformed body (`email: 42`, missing password, …) returns 400 instead of
|
|
@@ -297,6 +327,8 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
297
327
|
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
298
328
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
299
329
|
const cookieDomain = config.cookieDomain;
|
|
330
|
+
assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
|
|
331
|
+
const retiredCookieDomains = config.retiredCookieDomains ?? [];
|
|
300
332
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
301
333
|
// see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
|
|
302
334
|
// doc comment. createClientIpResolver fails loud on a non-finite/negative
|
|
@@ -322,6 +354,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
322
354
|
csrfToken,
|
|
323
355
|
sameSite: cookieSameSite,
|
|
324
356
|
domain: cookieDomain,
|
|
357
|
+
retiredDomains: retiredCookieDomains,
|
|
325
358
|
ttlSeconds: jwt.ttlSeconds,
|
|
326
359
|
});
|
|
327
360
|
return token;
|
|
@@ -849,7 +882,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
849
882
|
}
|
|
850
883
|
// Clear cookies on the cookie-transport path. Idempotent — clearing a
|
|
851
884
|
// missing cookie is a no-op, so bearer-only clients aren't affected.
|
|
852
|
-
clearAuthCookies(c, cookieDomain);
|
|
885
|
+
clearAuthCookies(c, cookieDomain, retiredCookieDomains);
|
|
853
886
|
return c.json({ isSuccess: true });
|
|
854
887
|
});
|
|
855
888
|
// GET /auth/tenants — list tenants the current user belongs to
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
2
|
import type { AgentRisk } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
|
+
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
3
4
|
export type RequestContextData = {
|
|
4
5
|
readonly requestId: string;
|
|
5
6
|
readonly correlationId: string;
|
|
6
7
|
readonly causationId?: string;
|
|
7
8
|
readonly signal?: AbortSignal;
|
|
9
|
+
readonly errorDocs?: ErrorDocsConfig;
|
|
8
10
|
readonly ip?: string;
|
|
9
11
|
readonly userAgent?: string;
|
|
10
12
|
readonly locale?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
|
+
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
2
3
|
import { type ClientIpResolver } from "./client-ip.js";
|
|
3
4
|
import { type RequestContextData } from "./request-context.js";
|
|
4
5
|
/**
|
|
@@ -34,4 +35,5 @@ export declare function buildRequestContextData(c: Context, resolver?: ClientIpR
|
|
|
34
35
|
export declare function requestIdMiddleware(options?: {
|
|
35
36
|
readonly resolver?: ClientIpResolver;
|
|
36
37
|
readonly trustedProxyHops?: number;
|
|
38
|
+
readonly errorDocs?: ErrorDocsConfig;
|
|
37
39
|
}): (c: Context, next: Next) => Promise<void>;
|
|
@@ -103,7 +103,10 @@ export function requestIdMiddleware(options) {
|
|
|
103
103
|
const resolver = options?.resolver ??
|
|
104
104
|
createClientIpResolver(options?.trustedProxyHops ?? 0, "requestIdMiddleware");
|
|
105
105
|
return async (c, next) => {
|
|
106
|
-
const data =
|
|
106
|
+
const data = {
|
|
107
|
+
...buildRequestContextData(c, resolver),
|
|
108
|
+
...(options?.errorDocs && { errorDocs: options.errorDocs }),
|
|
109
|
+
};
|
|
107
110
|
c.header(REQUEST_ID_HEADER, data.requestId);
|
|
108
111
|
c.header(CORRELATION_ID_HEADER, data.correlationId);
|
|
109
112
|
c.set("requestId", data.requestId);
|
package/dist/api/routes.js
CHANGED
|
@@ -71,7 +71,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
71
71
|
const requestId = requestContext.get()?.requestId;
|
|
72
72
|
const failedType = result.failedIndex != null ? body.commands[result.failedIndex]?.type : undefined;
|
|
73
73
|
logServerFault(err, requestId, failedType);
|
|
74
|
-
const { error } = serializeError(err, requestId);
|
|
74
|
+
const { error } = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
75
75
|
// Keep failedIndex + results alongside the error envelope so callers
|
|
76
76
|
// can tell which command in the batch failed and inspect the partial
|
|
77
77
|
// results from the successful commands before the rollback.
|
|
@@ -182,7 +182,7 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
182
182
|
catch (e) {
|
|
183
183
|
const err = toKumiko(e);
|
|
184
184
|
logServerFault(err, requestId, body.type);
|
|
185
|
-
const { error } = serializeError(err, requestId);
|
|
185
|
+
const { error } = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
186
186
|
await stream.writeSSE({ event: StreamFrame.error, data: stringifyJson(error) });
|
|
187
187
|
}
|
|
188
188
|
});
|
|
@@ -354,7 +354,7 @@ function logServerFault(err, requestId, type) {
|
|
|
354
354
|
function writeErrorResponse(c, err, type) {
|
|
355
355
|
const requestId = requestContext.get()?.requestId;
|
|
356
356
|
logServerFault(err, requestId, type);
|
|
357
|
-
const { error } = serializeError(err, requestId);
|
|
357
|
+
const { error } = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
358
358
|
return c.json({ isSuccess: false, error }, err.httpStatus); // @cast-boundary engine-payload
|
|
359
359
|
}
|
|
360
360
|
function clientAbortResponse(c, requestId, type) {
|
|
@@ -370,7 +370,7 @@ function queryErrorResponse(c, err, type) {
|
|
|
370
370
|
if (isClientAbort(err))
|
|
371
371
|
return clientAbortResponse(c, requestId, type);
|
|
372
372
|
logServerFault(err, requestId, type);
|
|
373
|
-
const body = serializeError(err, requestId);
|
|
373
|
+
const body = serializeError(err, requestId, requestContext.get()?.errorDocs);
|
|
374
374
|
return c.json(body, err.httpStatus); // @cast-boundary engine-payload
|
|
375
375
|
}
|
|
376
376
|
// A raw throw under the request signal (signal.throwIfAborted(), an aborted
|
|
@@ -391,5 +391,5 @@ export function handleUncaughtRouteError(err, c) {
|
|
|
391
391
|
return clientAbortResponse(c, requestId);
|
|
392
392
|
const kumikoError = toKumiko(err);
|
|
393
393
|
logServerFault(kumikoError, requestId);
|
|
394
|
-
return c.json(serializeError(kumikoError, requestId), kumikoError.httpStatus); // @cast-boundary engine-payload
|
|
394
|
+
return c.json(serializeError(kumikoError, requestId, requestContext.get()?.errorDocs), kumikoError.httpStatus); // @cast-boundary engine-payload
|
|
395
395
|
}
|
package/dist/api/server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import type { PgClient } from "../db/connection.js";
|
|
4
4
|
import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.js";
|
|
5
|
+
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
5
6
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
6
7
|
import { type ObservabilityOptions, type ObservabilityProvider } from "../observability/index.js";
|
|
7
8
|
import type { DispatcherOptions } from "../pipeline/dispatcher.js";
|
|
@@ -27,6 +28,8 @@ export type ServerOptions = {
|
|
|
27
28
|
sseBroker?: SseBroker;
|
|
28
29
|
auth?: AuthRoutesConfig;
|
|
29
30
|
trustedProxyHops?: number;
|
|
31
|
+
/** Links the app's own error reasons to its own docs (`docsUrl` in error responses). Framework reasons keep linking to the framework docs. */
|
|
32
|
+
errorDocs?: ErrorDocsConfig;
|
|
30
33
|
eventDispatcher?: {
|
|
31
34
|
pollIntervalMs?: number;
|
|
32
35
|
batchSize?: number;
|
package/dist/api/server.js
CHANGED
|
@@ -422,7 +422,10 @@ export function buildServer(options) {
|
|
|
422
422
|
if (options.metrics) {
|
|
423
423
|
registerMetricsRoute(app, observability.meter, options.metrics);
|
|
424
424
|
}
|
|
425
|
-
app.use("/api/*", requestIdMiddleware({
|
|
425
|
+
app.use("/api/*", requestIdMiddleware({
|
|
426
|
+
resolver: clientIpResolver,
|
|
427
|
+
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
428
|
+
}));
|
|
426
429
|
// Cap JSON bodies before rate-limit/auth/observability even run. Header-
|
|
427
430
|
// check is O(1); oversized requests never allocate memory for a full body
|
|
428
431
|
// parse. Upload route keeps its own per-field maxSize.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getTemporal } from "../time/index.js";
|
|
2
|
-
import type { DurationSpec } from "./profiles.js";
|
|
2
|
+
import type { DurationSpec, RetentionSpec } from "./profiles.js";
|
|
3
3
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
4
4
|
export declare function durationSpecToMs(spec: DurationSpec): number;
|
|
5
5
|
export declare function addDurationSpec(now: Instant, spec: DurationSpec): Instant;
|
|
6
6
|
export declare function describeDurationSpec(spec: DurationSpec): string;
|
|
7
|
+
export declare function subtractRetentionSpec(now: Instant, spec: RetentionSpec): Instant;
|
|
7
8
|
export {};
|
|
@@ -35,3 +35,14 @@ export function describeDurationSpec(spec) {
|
|
|
35
35
|
return `${spec.days} day${spec.days === 1 ? "" : "s"}`;
|
|
36
36
|
return `${spec.hours} hour${spec.hours === 1 ? "" : "s"}`;
|
|
37
37
|
}
|
|
38
|
+
// Calendar-aware cutoff: months/years are subtracted on a UTC calendar so
|
|
39
|
+
// "24 months" is not approximated as 730 fixed days (leap years, month ends).
|
|
40
|
+
export function subtractRetentionSpec(now, spec) {
|
|
41
|
+
if ("months" in spec) {
|
|
42
|
+
return now.toZonedDateTimeISO("UTC").subtract({ months: spec.months }).toInstant();
|
|
43
|
+
}
|
|
44
|
+
if ("years" in spec) {
|
|
45
|
+
return now.toZonedDateTimeISO("UTC").subtract({ years: spec.years }).toInstant();
|
|
46
|
+
}
|
|
47
|
+
return getTemporal().Instant.fromEpochMilliseconds(now.epochMilliseconds - durationSpecToMs(spec));
|
|
48
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { addDurationSpec, describeDurationSpec, durationSpecToMs, } from "./duration-spec.js";
|
|
1
|
+
export { addDurationSpec, describeDurationSpec, durationSpecToMs, subtractRetentionSpec, } from "./duration-spec.js";
|
|
2
2
|
export { complianceProfileOverrideSchema } from "./override-schema.js";
|
|
3
|
-
export type { AuthorityNotificationDeadline, ComplianceProfile, ComplianceProfileKey, ComplianceProfileOverride, DurationSpec, EffectiveComplianceProfile, UserNotificationRequiredPolicy, } from "./profiles.js";
|
|
3
|
+
export type { AuthorityNotificationDeadline, ComplianceProfile, ComplianceProfileKey, ComplianceProfileOverride, DurationSpec, EffectiveComplianceProfile, RetentionSpec, UserNotificationRequiredPolicy, } from "./profiles.js";
|
|
4
4
|
export { COMPLIANCE_PROFILES, OVERRIDABLE_PROFILE_KEYS, resolveComplianceProfile, SELECTABLE_PROFILE_KEYS, } from "./profiles.js";
|
|
5
5
|
export type { BundleTier, SubProcessor } from "./sub-processors.js";
|
|
6
6
|
export { getActiveSubProcessors, getPlannedSubProcessors, KUMIKO_SUB_PROCESSORS, } from "./sub-processors.js";
|
package/dist/compliance/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `@cosmicdrift/kumiko-framework/compliance` — Datenschutz/Compliance-
|
|
2
2
|
// Foundation. Wird von Sprint-1+ Features genutzt (compliance-profiles,
|
|
3
3
|
// data-retention, user-data-rights, ...).
|
|
4
|
-
export { addDurationSpec, describeDurationSpec, durationSpecToMs, } from "./duration-spec.js";
|
|
4
|
+
export { addDurationSpec, describeDurationSpec, durationSpecToMs, subtractRetentionSpec, } from "./duration-spec.js";
|
|
5
5
|
export { complianceProfileOverrideSchema } from "./override-schema.js";
|
|
6
6
|
export { COMPLIANCE_PROFILES, OVERRIDABLE_PROFILE_KEYS, resolveComplianceProfile, SELECTABLE_PROFILE_KEYS, } from "./profiles.js";
|
|
7
7
|
export { getActiveSubProcessors, getPlannedSubProcessors, KUMIKO_SUB_PROCESSORS, } from "./sub-processors.js";
|
|
@@ -5,6 +5,11 @@ export type DurationSpec = {
|
|
|
5
5
|
} | {
|
|
6
6
|
readonly hours: number;
|
|
7
7
|
};
|
|
8
|
+
export type RetentionSpec = DurationSpec | {
|
|
9
|
+
readonly months: number;
|
|
10
|
+
} | {
|
|
11
|
+
readonly years: number;
|
|
12
|
+
};
|
|
8
13
|
export type AuthorityNotificationDeadline = DurationSpec | "as-soon-as-feasible" | "in-most-expedient-time" | "manual";
|
|
9
14
|
export type UserNotificationRequiredPolicy = "if-high-risk" | "if-real-risk-of-significant-harm" | "if-serious-risk-of-injury" | "always-if-encrypted-data-or-pii" | "always-without-undue-delay" | "manual";
|
|
10
15
|
export interface ComplianceProfile {
|
|
@@ -42,11 +47,7 @@ export interface ComplianceProfile {
|
|
|
42
47
|
readonly mandatoryRegisterOfBreaches?: boolean;
|
|
43
48
|
};
|
|
44
49
|
readonly auditLog: {
|
|
45
|
-
readonly retention:
|
|
46
|
-
readonly months: number;
|
|
47
|
-
} | {
|
|
48
|
-
readonly years: number;
|
|
49
|
-
};
|
|
50
|
+
readonly retention: RetentionSpec;
|
|
50
51
|
readonly reportFrequency: "quarterly" | "yearly" | "annual-required" | "manual";
|
|
51
52
|
};
|
|
52
53
|
readonly subProcessor: {
|
|
@@ -12,4 +12,4 @@ export declare function resolveEntityFieldEncryption(): EnvelopeCipher;
|
|
|
12
12
|
export declare function configuredEntityFieldEncryption(): EnvelopeCipher | undefined;
|
|
13
13
|
/** @internal test-only */
|
|
14
14
|
export declare function resetEntityFieldEncryptionCacheForTests(): void;
|
|
15
|
-
export declare function validateEntityFieldEncryptionAvailable(env?: Readonly<Record<string, string | undefined
|
|
15
|
+
export declare function validateEntityFieldEncryptionAvailable(env?: Readonly<Record<string, string | undefined>>, bootCipher?: EnvelopeCipher): void;
|
|
@@ -106,9 +106,9 @@ export function resetEntityFieldEncryptionCacheForTests() {
|
|
|
106
106
|
// surfaces missing AND malformed keys (wrong length, bad base64) at boot
|
|
107
107
|
// instead of on the first encrypted read in prod. validateBoot runs before
|
|
108
108
|
// configureEntityFieldEncryption, so the env probe is the common path.
|
|
109
|
-
export function validateEntityFieldEncryptionAvailable(env = process.env) {
|
|
110
|
-
// skip: an injected cipher (test seam / custom KMS) satisfies availability
|
|
111
|
-
if (injectedCipher)
|
|
109
|
+
export function validateEntityFieldEncryptionAvailable(env = process.env, bootCipher) {
|
|
110
|
+
// skip: an injected cipher (test seam / custom KMS) or the runner's boot cipher satisfies availability
|
|
111
|
+
if (bootCipher ?? injectedCipher)
|
|
112
112
|
return;
|
|
113
113
|
try {
|
|
114
114
|
createEnvMasterKeyProvider({
|
|
@@ -121,7 +121,7 @@ export function validateEntityFieldEncryptionAvailable(env = process.env) {
|
|
|
121
121
|
catch (err) {
|
|
122
122
|
const reason = err instanceof Error ? err.message : String(err);
|
|
123
123
|
throw new Error(`encrypted entity fields in use but no usable master key (${reason}) — set ` +
|
|
124
|
-
"KUMIKO_SECRETS_MASTER_KEY_V1 (32 bytes, base64) or
|
|
125
|
-
"configureEntityFieldEncryption().");
|
|
124
|
+
"KUMIKO_SECRETS_MASTER_KEY_V1 (32 bytes, base64) or pass `masterKey` to " +
|
|
125
|
+
"runProdApp/runWorkerApp (or inject a cipher via configureEntityFieldEncryption()).");
|
|
126
126
|
}
|
|
127
127
|
}
|
|
@@ -5,13 +5,28 @@ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
|
5
5
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
6
6
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
7
7
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
8
|
+
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
8
9
|
import { generateId } from "../utils/index.js";
|
|
9
10
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
10
11
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
11
12
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
13
|
+
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
12
14
|
import { runInSavepointIfSupported } from "./query.js";
|
|
13
15
|
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
14
16
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
17
|
+
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
18
|
+
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
19
|
+
// projection fails the write and the caller's transaction rolls it back.
|
|
20
|
+
async function projectEntityEvent(db, runner, event) {
|
|
21
|
+
const registry = projectionRegistryOf(db);
|
|
22
|
+
// skip: TenantDb not built by a dispatcher/job runner — nothing bound to project with
|
|
23
|
+
if (!registry)
|
|
24
|
+
return;
|
|
25
|
+
// skip: no live projection listens — avoid a SAVEPOINT round trip on every plain write
|
|
26
|
+
if (!hasLiveProjectionsForEvent(event, registry))
|
|
27
|
+
return;
|
|
28
|
+
await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
|
|
29
|
+
}
|
|
15
30
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
16
31
|
// per-role ownership map can never cover it, and a silent deny means the
|
|
17
32
|
// erasure never happened.
|
|
@@ -215,6 +230,10 @@ export function createWriteVerbs(ctx) {
|
|
|
215
230
|
if (entityCache && entityName) {
|
|
216
231
|
await entityCache.del(user.tenantId, entityName, aggregateId);
|
|
217
232
|
}
|
|
233
|
+
// The echoed event is the one projections see (and the dispatcher later
|
|
234
|
+
// skips), so build it before projecting.
|
|
235
|
+
const echoEvent = { ...event, payload: stripSensitive(flatCreateData) };
|
|
236
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
218
237
|
return {
|
|
219
238
|
isSuccess: true,
|
|
220
239
|
data: {
|
|
@@ -227,7 +246,7 @@ export function createWriteVerbs(ctx) {
|
|
|
227
246
|
entityName,
|
|
228
247
|
// Persisted event carries ciphertext by design — the caller-facing
|
|
229
248
|
// echo must be plaintext like every other response field (#820).
|
|
230
|
-
event:
|
|
249
|
+
event: echoEvent,
|
|
231
250
|
},
|
|
232
251
|
};
|
|
233
252
|
},
|
|
@@ -404,6 +423,14 @@ export function createWriteVerbs(ctx) {
|
|
|
404
423
|
if (entityCache && entityName) {
|
|
405
424
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
406
425
|
}
|
|
426
|
+
const echoEvent = {
|
|
427
|
+
...event,
|
|
428
|
+
payload: {
|
|
429
|
+
changes: stripSensitive(flatChangesPlain),
|
|
430
|
+
previous: stripSensitive(previous),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
407
434
|
return {
|
|
408
435
|
isSuccess: true,
|
|
409
436
|
data: {
|
|
@@ -414,13 +441,7 @@ export function createWriteVerbs(ctx) {
|
|
|
414
441
|
previous,
|
|
415
442
|
isNew: false,
|
|
416
443
|
entityName,
|
|
417
|
-
event:
|
|
418
|
-
...event,
|
|
419
|
-
payload: {
|
|
420
|
-
changes: stripSensitive(flatChangesPlain),
|
|
421
|
-
previous: stripSensitive(previous),
|
|
422
|
-
},
|
|
423
|
-
},
|
|
444
|
+
event: echoEvent,
|
|
424
445
|
},
|
|
425
446
|
};
|
|
426
447
|
}
|
|
@@ -508,6 +529,8 @@ export function createWriteVerbs(ctx) {
|
|
|
508
529
|
if (entityCache && entityName) {
|
|
509
530
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
510
531
|
}
|
|
532
|
+
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
533
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
511
534
|
return {
|
|
512
535
|
isSuccess: true,
|
|
513
536
|
data: {
|
|
@@ -515,7 +538,7 @@ export function createWriteVerbs(ctx) {
|
|
|
515
538
|
id: payload.id,
|
|
516
539
|
data: existing,
|
|
517
540
|
entityName,
|
|
518
|
-
event:
|
|
541
|
+
event: echoEvent,
|
|
519
542
|
},
|
|
520
543
|
};
|
|
521
544
|
},
|
|
@@ -579,6 +602,8 @@ export function createWriteVerbs(ctx) {
|
|
|
579
602
|
if (entityCache && entityName) {
|
|
580
603
|
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
581
604
|
}
|
|
605
|
+
const echoEvent = { ...event, payload: { previous: stripSensitive(existing) } };
|
|
606
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
582
607
|
return {
|
|
583
608
|
isSuccess: true,
|
|
584
609
|
data: {
|
|
@@ -586,7 +611,7 @@ export function createWriteVerbs(ctx) {
|
|
|
586
611
|
id: payload.id,
|
|
587
612
|
data: existing,
|
|
588
613
|
entityName,
|
|
589
|
-
event:
|
|
614
|
+
event: echoEvent,
|
|
590
615
|
},
|
|
591
616
|
};
|
|
592
617
|
},
|
|
@@ -685,6 +710,8 @@ export function createWriteVerbs(ctx) {
|
|
|
685
710
|
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
686
711
|
const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
|
|
687
712
|
const previousPlain = await decryptForRead(data);
|
|
713
|
+
const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
|
|
714
|
+
await projectEntityEvent(db, runner, echoEvent);
|
|
688
715
|
return {
|
|
689
716
|
isSuccess: true,
|
|
690
717
|
data: {
|
|
@@ -695,7 +722,7 @@ export function createWriteVerbs(ctx) {
|
|
|
695
722
|
previous: previousPlain,
|
|
696
723
|
isNew: false,
|
|
697
724
|
entityName,
|
|
698
|
-
event:
|
|
725
|
+
event: echoEvent,
|
|
699
726
|
},
|
|
700
727
|
};
|
|
701
728
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// A WeakMap rather than a process-global registry: test files boot several
|
|
2
|
+
// stacks in one process, each with its own Registry. Keys are TenantDbs and
|
|
3
|
+
// per-grant runner proxies — never the shared pool or tx handle, which would
|
|
4
|
+
// leak one dispatcher's registry onto sibling TenantDbs.
|
|
5
|
+
const bindings = new WeakMap();
|
|
6
|
+
export function bindProjectionRegistry(target, registry) {
|
|
7
|
+
bindings.set(target, registry);
|
|
8
|
+
}
|
|
9
|
+
// WeakMap.get never reads a property off target — safe for fail-closed guard Proxies.
|
|
10
|
+
export function projectionRegistryOf(target) {
|
|
11
|
+
return bindings.get(target);
|
|
12
|
+
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmi
|
|
|
5
5
|
import { asRawClient } from "../db/query.js";
|
|
6
6
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
7
7
|
import { type TenantId } from "../engine/types/identifiers.js";
|
|
8
|
+
import type { Registry } from "../engine/types/index.js";
|
|
8
9
|
import { type Meter, type Tracer } from "../observability/index.js";
|
|
9
10
|
import type { DbRunner } from "./connection.js";
|
|
10
11
|
type Table = SchemaTable;
|
|
@@ -23,6 +24,7 @@ export type TenantDbGrants = {
|
|
|
23
24
|
readonly report?: EscapeHatchReporter;
|
|
24
25
|
readonly memberReadOnly?: boolean;
|
|
25
26
|
readonly personalDataGate?: PersonalDataGate;
|
|
27
|
+
readonly projectionRegistry?: Registry;
|
|
26
28
|
};
|
|
27
29
|
export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
|
|
28
30
|
export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -4,6 +4,7 @@ import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, co
|
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
6
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
7
|
+
import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
7
8
|
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
|
|
8
9
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
9
10
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
@@ -14,7 +15,7 @@ const personalDataGates = new WeakMap();
|
|
|
14
15
|
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate. Keyed by a
|
|
15
16
|
// per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
|
|
16
17
|
const runnerPersonalDataGates = new WeakMap();
|
|
17
|
-
function gatedRunner(runner, gate) {
|
|
18
|
+
function gatedRunner(runner, gate, projectionRegistry) {
|
|
18
19
|
const proxy = new Proxy(runner, {
|
|
19
20
|
// Tagged-template calls need the real driver object as `this`.
|
|
20
21
|
apply(target, _thisArg, args) {
|
|
@@ -33,7 +34,7 @@ function gatedRunner(runner, gate) {
|
|
|
33
34
|
}
|
|
34
35
|
const gatedArgs = [
|
|
35
36
|
...args.slice(0, -1),
|
|
36
|
-
(tx) => callback(gatedRunner(tx, gate)),
|
|
37
|
+
(tx) => callback(gatedRunner(tx, gate, projectionRegistry)),
|
|
37
38
|
];
|
|
38
39
|
return Reflect.apply(value, target, gatedArgs);
|
|
39
40
|
};
|
|
@@ -43,6 +44,8 @@ function gatedRunner(runner, gate) {
|
|
|
43
44
|
// @cast-boundary proxy-erasure — Proxy<object> re-tags as the wrapped DbRunner shape.
|
|
44
45
|
});
|
|
45
46
|
runnerPersonalDataGates.set(proxy, gate);
|
|
47
|
+
if (projectionRegistry)
|
|
48
|
+
bindProjectionRegistry(proxy, projectionRegistry);
|
|
46
49
|
return proxy;
|
|
47
50
|
}
|
|
48
51
|
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
@@ -119,7 +122,9 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
119
122
|
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
120
123
|
const runner = tenantDbRunner(db);
|
|
121
124
|
const personalDataGate = personalDataGates.get(db);
|
|
122
|
-
return personalDataGate
|
|
125
|
+
return personalDataGate
|
|
126
|
+
? gatedRunner(runner, personalDataGate, projectionRegistryOf(db))
|
|
127
|
+
: runner;
|
|
123
128
|
}
|
|
124
129
|
const uncheckedSystemDb = {
|
|
125
130
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
@@ -272,6 +277,11 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
272
277
|
registerStandardMetrics(meter);
|
|
273
278
|
const report = grants?.report ?? fallbackEscapeHatchReporter(tenantId);
|
|
274
279
|
const personalDataGate = grants?.personalDataGate ?? runnerPersonalDataGates.get(db);
|
|
280
|
+
const projectionRegistry = grants?.projectionRegistry ?? projectionRegistryOf(db);
|
|
281
|
+
// Rebinders carry the resolved registry explicitly: a fresh tx handle is not tagged.
|
|
282
|
+
const rebindGrants = projectionRegistry && grants?.projectionRegistry === undefined
|
|
283
|
+
? { ...grants, projectionRegistry }
|
|
284
|
+
: grants;
|
|
275
285
|
function withDbSpan(operation, table, runner) {
|
|
276
286
|
signal?.throwIfAborted();
|
|
277
287
|
if (!tracer && !meter)
|
|
@@ -446,7 +456,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
446
456
|
}
|
|
447
457
|
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
448
458
|
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
449
|
-
return personalDataGate ? gatedRunner(db, personalDataGate) : db;
|
|
459
|
+
return personalDataGate ? gatedRunner(db, personalDataGate, projectionRegistry) : db;
|
|
450
460
|
}
|
|
451
461
|
const tenantDb = {
|
|
452
462
|
tenantId,
|
|
@@ -509,10 +519,13 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
509
519
|
},
|
|
510
520
|
};
|
|
511
521
|
declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
|
|
512
|
-
unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
|
|
522
|
+
unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
|
|
523
|
+
...rebindGrants,
|
|
524
|
+
unsafeRaw: grant,
|
|
525
|
+
}));
|
|
513
526
|
crossTenantRebinders.set(tenantDb, (reason) => {
|
|
514
527
|
report("acknowledge-cross-tenant", reason);
|
|
515
|
-
return createTenantDb(db, tenantId, "system", tracer, meter, signal,
|
|
528
|
+
return createTenantDb(db, tenantId, "system", tracer, meter, signal, rebindGrants);
|
|
516
529
|
});
|
|
517
530
|
ownTransactionRebinders.set(tenantDb, async (fn) => {
|
|
518
531
|
if (grants?.memberReadOnly) {
|
|
@@ -522,12 +535,14 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
522
535
|
});
|
|
523
536
|
}
|
|
524
537
|
// Carries a runner-bound gate (grants.personalDataGate unset, resolved via the fallback above) forward explicitly, since the fresh tx-handle isn't itself registered in runnerPersonalDataGates.
|
|
525
|
-
const txGrants = personalDataGate ? { ...
|
|
538
|
+
const txGrants = personalDataGate ? { ...rebindGrants, personalDataGate } : rebindGrants;
|
|
526
539
|
return runInNewTransaction(db, (tx) => fn(createTenantDb(tx, tenantId, mode, tracer, meter, signal, txGrants)));
|
|
527
540
|
});
|
|
528
541
|
bindTenantDbRunner(tenantDb, db);
|
|
529
542
|
if (personalDataGate)
|
|
530
543
|
personalDataGates.set(tenantDb, personalDataGate);
|
|
544
|
+
if (projectionRegistry)
|
|
545
|
+
bindProjectionRegistry(tenantDb, projectionRegistry);
|
|
531
546
|
return tenantDb;
|
|
532
547
|
}
|
|
533
548
|
export { asRawClient };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EnvelopeCipher } from "../../secrets/envelope-cipher.js";
|
|
1
2
|
import type { FeatureDefinition } from "../types/index.js";
|
|
2
3
|
export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
|
|
3
4
|
export { collectWriteHandlerQns } from "./nav.js";
|
|
@@ -26,7 +27,17 @@ export type ValidateBootOptions = {
|
|
|
26
27
|
* `process.env`; runners pass their resolved env so a ciphertext-only
|
|
27
28
|
* key (already decrypted at boot) is visible to the probe. */
|
|
28
29
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** The cipher the runner injects after validation (built from the app's
|
|
31
|
+
* `masterKey` or the env keyring). Satisfies the availability check for
|
|
32
|
+
* `encrypted: true` fields without an env KEK. */
|
|
33
|
+
readonly entityFieldCipher?: EnvelopeCipher;
|
|
29
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Subset-safe slice of validateBoot for test stacks: nav references (screen,
|
|
37
|
+
* parent, workspace, tree actions), workspaces and nav cycles. The checks that
|
|
38
|
+
* need the whole app composition (screens, ref entities) run only in validateBoot.
|
|
39
|
+
*/
|
|
40
|
+
export declare function validateNavBootSubset(rawFeatures: readonly FeatureDefinition[]): void;
|
|
30
41
|
/**
|
|
31
42
|
* Validates all feature configurations at boot time.
|
|
32
43
|
* Throws on the first error found — fail fast.
|