@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.
Files changed (52) hide show
  1. package/dist/api/auth-routes.d.ts +2 -0
  2. package/dist/api/auth-routes.js +35 -2
  3. package/dist/api/request-context.d.ts +2 -0
  4. package/dist/api/request-id-middleware.d.ts +2 -0
  5. package/dist/api/request-id-middleware.js +4 -1
  6. package/dist/api/routes.js +5 -5
  7. package/dist/api/server.d.ts +3 -0
  8. package/dist/api/server.js +4 -1
  9. package/dist/compliance/duration-spec.d.ts +2 -1
  10. package/dist/compliance/duration-spec.js +11 -0
  11. package/dist/compliance/index.d.ts +2 -2
  12. package/dist/compliance/index.js +1 -1
  13. package/dist/compliance/profiles.d.ts +6 -5
  14. package/dist/db/entity-field-encryption.d.ts +1 -1
  15. package/dist/db/entity-field-encryption.js +5 -5
  16. package/dist/db/event-store-executor-write.js +38 -11
  17. package/dist/db/projection-registry-binding.d.ts +3 -0
  18. package/dist/db/projection-registry-binding.js +12 -0
  19. package/dist/db/tenant-db.d.ts +2 -0
  20. package/dist/db/tenant-db.js +22 -7
  21. package/dist/engine/boot-validator/index.d.ts +11 -0
  22. package/dist/engine/boot-validator/index.js +45 -1
  23. package/dist/engine/boot-validator/query-output-columns.js +8 -7
  24. package/dist/engine/boot-validator/query-refs.js +3 -0
  25. package/dist/engine/boot-validator/screens.js +28 -0
  26. package/dist/engine/index.d.ts +2 -2
  27. package/dist/engine/index.js +1 -1
  28. package/dist/engine/types/index.d.ts +2 -2
  29. package/dist/engine/types/index.js +1 -1
  30. package/dist/env/index.d.ts +3 -0
  31. package/dist/env/index.js +12 -0
  32. package/dist/errors/docs-url.d.ts +7 -0
  33. package/dist/errors/docs-url.js +36 -0
  34. package/dist/errors/index.d.ts +2 -0
  35. package/dist/errors/index.js +1 -0
  36. package/dist/errors/kumiko-error.d.ts +0 -2
  37. package/dist/errors/kumiko-error.js +0 -24
  38. package/dist/errors/serialize.d.ts +3 -2
  39. package/dist/errors/serialize.js +4 -2
  40. package/dist/jobs/job-runner.js +31 -2
  41. package/dist/pipeline/dispatch-shared.js +3 -1
  42. package/dist/pipeline/event-retention.d.ts +1 -0
  43. package/dist/pipeline/event-retention.js +6 -0
  44. package/dist/pipeline/index.d.ts +2 -0
  45. package/dist/pipeline/index.js +1 -0
  46. package/dist/pipeline/projections-runner.d.ts +1 -0
  47. package/dist/pipeline/projections-runner.js +23 -14
  48. package/dist/rate-limit/middleware.js +1 -1
  49. package/dist/stack/test-stack.d.ts +7 -0
  50. package/dist/stack/test-stack.js +6 -0
  51. package/package.json +4 -4
  52. 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;
@@ -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 = buildRequestContextData(c, resolver);
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);
@@ -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
  }
@@ -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;
@@ -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({ resolver: clientIpResolver }));
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";
@@ -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: DurationSpec | {
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>>): void;
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 inject a cipher via " +
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: { ...event, payload: stripSensitive(flatCreateData) },
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: { ...event, payload: { previous: stripSensitive(existing) } },
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: { ...event, payload: { previous: stripSensitive(existing) } },
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: { ...event, payload: { previous: stripSensitive(previousPlain) } },
725
+ event: echoEvent,
699
726
  },
700
727
  };
701
728
  },
@@ -0,0 +1,3 @@
1
+ import type { Registry } from "../engine/types/index.js";
2
+ export declare function bindProjectionRegistry(target: object, registry: Registry): void;
3
+ export declare function projectionRegistryOf(target: object): Registry | undefined;
@@ -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
+ }
@@ -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;
@@ -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 ? gatedRunner(runner, personalDataGate) : runner;
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, { ...grants, unsafeRaw: grant }));
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, grants);
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 ? { ...grants, personalDataGate } : grants;
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.