@cosmicdrift/kumiko-framework 0.341.0 → 0.343.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 (74) 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/crypto/kek-source.d.ts +3 -4
  15. package/dist/crypto/kek-source.js +2 -5
  16. package/dist/crypto/kms-wiring.d.ts +2 -2
  17. package/dist/crypto/kms-wiring.js +2 -2
  18. package/dist/db/entity-field-encryption.d.ts +1 -1
  19. package/dist/db/entity-field-encryption.js +5 -5
  20. package/dist/db/event-store-executor-write.js +38 -11
  21. package/dist/db/index.d.ts +2 -1
  22. package/dist/db/index.js +2 -1
  23. package/dist/db/migrate-generator.d.ts +6 -0
  24. package/dist/db/migrate-generator.js +15 -1
  25. package/dist/db/migrate-runner.d.ts +1 -0
  26. package/dist/db/migrate-runner.js +11 -1
  27. package/dist/db/projection-registry-binding.d.ts +3 -0
  28. package/dist/db/projection-registry-binding.js +12 -0
  29. package/dist/db/retired-framework-tables.d.ts +10 -0
  30. package/dist/db/retired-framework-tables.js +72 -0
  31. package/dist/db/tenant-db.d.ts +2 -0
  32. package/dist/db/tenant-db.js +22 -7
  33. package/dist/engine/boot-validator/index.d.ts +11 -0
  34. package/dist/engine/boot-validator/index.js +45 -1
  35. package/dist/engine/boot-validator/query-output-columns.js +8 -7
  36. package/dist/engine/boot-validator/query-refs.js +3 -0
  37. package/dist/engine/boot-validator/screens.js +28 -0
  38. package/dist/engine/index.d.ts +2 -2
  39. package/dist/engine/index.js +1 -1
  40. package/dist/engine/soft-delete-cleanup.d.ts +2 -0
  41. package/dist/engine/soft-delete-cleanup.js +28 -9
  42. package/dist/engine/types/index.d.ts +2 -2
  43. package/dist/engine/types/index.js +1 -1
  44. package/dist/env/index.d.ts +3 -0
  45. package/dist/env/index.js +12 -0
  46. package/dist/errors/docs-url.d.ts +7 -0
  47. package/dist/errors/docs-url.js +36 -0
  48. package/dist/errors/index.d.ts +2 -0
  49. package/dist/errors/index.js +1 -0
  50. package/dist/errors/kumiko-error.d.ts +0 -2
  51. package/dist/errors/kumiko-error.js +0 -24
  52. package/dist/errors/serialize.d.ts +3 -2
  53. package/dist/errors/serialize.js +4 -2
  54. package/dist/jobs/job-runner.js +37 -2
  55. package/dist/observability/escape-hatch-report.d.ts +10 -1
  56. package/dist/observability/escape-hatch-report.js +19 -1
  57. package/dist/observability/index.d.ts +2 -2
  58. package/dist/observability/index.js +2 -2
  59. package/dist/observability/standard-metrics.d.ts +1 -0
  60. package/dist/observability/standard-metrics.js +14 -0
  61. package/dist/pipeline/dispatch-shared.js +3 -1
  62. package/dist/pipeline/event-retention.d.ts +1 -0
  63. package/dist/pipeline/event-retention.js +6 -0
  64. package/dist/pipeline/index.d.ts +2 -0
  65. package/dist/pipeline/index.js +1 -0
  66. package/dist/pipeline/projections-runner.d.ts +1 -0
  67. package/dist/pipeline/projections-runner.js +23 -14
  68. package/dist/rate-limit/middleware.js +1 -1
  69. package/dist/schema-cli.d.ts +8 -6
  70. package/dist/schema-cli.js +37 -4
  71. package/dist/stack/test-stack.d.ts +7 -0
  72. package/dist/stack/test-stack.js +6 -0
  73. package/package.json +4 -4
  74. package/src/changes.json +60 -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: {
@@ -16,8 +16,7 @@ export type KekSourceOptions = {
16
16
  readonly logPrefix?: string;
17
17
  /** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
18
18
  readonly log?: (message: string) => void;
19
- /** Env names to resolve, e.g. `kmsSlotsOf(schema)`. Defaults to the three
20
- * platform slots. */
21
- readonly slots?: readonly string[];
19
+ /** Env names to resolve, e.g. `kmsSlotsOf(schema)`. */
20
+ readonly slots: readonly string[];
22
21
  };
23
- export declare function resolvePlatformKeks(env: KekSourceEnv, options?: KekSourceOptions): Promise<KekSourceEnv>;
22
+ export declare function resolvePlatformKeks(env: KekSourceEnv, options: KekSourceOptions): Promise<KekSourceEnv>;
@@ -7,9 +7,6 @@ const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
7
7
  const DEFAULT_REGION = "fr-par";
8
8
  const DECRYPT_TIMEOUT_MS = 5_000;
9
9
  const RETRY_DELAYS_MS = [200, 800];
10
- // Used when the caller names no slots. Apps that predate `kms` schema meta
11
- // rely on it; drop once every consumer declares its slots in the env schema.
12
- const LEGACY_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"];
13
10
  function isRetryableStatus(status) {
14
11
  return status >= 500;
15
12
  }
@@ -97,10 +94,10 @@ async function resolveSlot(name, env, options, fetchImpl) {
97
94
  // another slot's fallback path — the trio check downstream still applies.
98
95
  // Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
99
96
  // ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
100
- export async function resolvePlatformKeks(env, options = {}) {
97
+ export async function resolvePlatformKeks(env, options) {
101
98
  const fetchImpl = options.fetch ?? globalThis.fetch;
102
99
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
103
- const slots = options.slots ?? LEGACY_SLOTS;
100
+ const { slots } = options;
104
101
  // Misconfiguration must not cost a Key Manager round trip.
105
102
  if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
106
103
  (env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
@@ -62,6 +62,6 @@ export declare function requireKmsWiring(env: KmsWiringEnv, options?: KmsWiringO
62
62
  * from a Key Manager ciphertext first when no plaintext is set — see
63
63
  * `resolvePlatformKeks`. The validation stays in the sync function; this only
64
64
  * adds the KEK-fetching step in front of it. */
65
- export declare function resolveKmsWiringAsync(env: KmsWiringEnv, options?: KmsWiringOptions & KekSourceOptions): Promise<KmsWiring>;
65
+ export declare function resolveKmsWiringAsync(env: KmsWiringEnv, options: KmsWiringOptions & KekSourceOptions): Promise<KmsWiring>;
66
66
  /** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
67
- export declare function requireKmsWiringAsync(env: KmsWiringEnv, options?: KmsWiringOptions & KekSourceOptions): Promise<ActiveKmsWiring>;
67
+ export declare function requireKmsWiringAsync(env: KmsWiringEnv, options: KmsWiringOptions & KekSourceOptions): Promise<ActiveKmsWiring>;
@@ -116,10 +116,10 @@ export function requireKmsWiring(env, options = {}) {
116
116
  * from a Key Manager ciphertext first when no plaintext is set — see
117
117
  * `resolvePlatformKeks`. The validation stays in the sync function; this only
118
118
  * adds the KEK-fetching step in front of it. */
119
- export async function resolveKmsWiringAsync(env, options = {}) {
119
+ export async function resolveKmsWiringAsync(env, options) {
120
120
  return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
121
121
  }
122
122
  /** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
123
- export async function requireKmsWiringAsync(env, options = {}) {
123
+ export async function requireKmsWiringAsync(env, options) {
124
124
  return requireKmsWiring(await resolvePlatformKeks(env, options), options);
125
125
  }
@@ -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
  },
@@ -20,7 +20,7 @@ export { createEventStoreExecutor, entityEventName } from "./event-store-executo
20
20
  export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.js";
21
21
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
22
22
  export { assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
23
- export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
23
+ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
24
24
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
25
25
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
26
26
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
@@ -31,6 +31,7 @@ export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from ".
31
31
  export { seedReferenceData } from "./reference-data.js";
32
32
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
33
33
  export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.js";
34
+ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
34
35
  export { tableExists } from "./schema-inspection.js";
35
36
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
36
37
  export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
package/dist/db/index.js CHANGED
@@ -14,7 +14,7 @@ export { createEventStoreExecutor, entityEventName } from "./event-store-executo
14
14
  export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
15
15
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
16
16
  export { assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
17
- export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
17
+ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
18
18
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
19
19
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
20
20
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
@@ -24,6 +24,7 @@ export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from ".
24
24
  export { seedReferenceData } from "./reference-data.js";
25
25
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
26
26
  export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migration-sql.js";
27
+ export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
27
28
  export { tableExists } from "./schema-inspection.js";
28
29
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
29
30
  export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
@@ -1,4 +1,5 @@
1
1
  import type { ColumnMeta, EntityTableMeta, IndexMeta } from "./entity-table-meta.js";
2
+ import { type RetiredFrameworkTable } from "./retired-framework-tables.js";
2
3
  declare const SNAPSHOT_VERSION: 1;
3
4
  export type Snapshot = {
4
5
  readonly version: typeof SNAPSHOT_VERSION;
@@ -62,18 +63,23 @@ export declare function managedChangeRequiresRecreate(td: TableDiff): boolean;
62
63
  export declare function renderMigrationSql(diff: SchemaDiff, options: {
63
64
  readonly name: string;
64
65
  readonly sequenceNumber: number;
66
+ readonly retiredDrops?: readonly RetiredFrameworkTable[];
65
67
  }): string;
66
68
  export type GenerateMigrationInput = {
67
69
  readonly metas: readonly EntityTableMeta[];
68
70
  readonly prevSnapshot: Snapshot | null;
69
71
  readonly name: string;
70
72
  readonly sequenceNumber: number;
73
+ /** Raw SQL of all earlier migrations, in order — lets the generator decide
74
+ * whether a retired framework table still needs its executable drop. */
75
+ readonly priorMigrationsSql?: readonly string[];
71
76
  };
72
77
  export type GenerateMigrationOutput = {
73
78
  readonly filename: string;
74
79
  readonly sqlContent: string;
75
80
  readonly snapshot: Snapshot;
76
81
  readonly diff: SchemaDiff;
82
+ readonly retiredDrops: readonly RetiredFrameworkTable[];
77
83
  };
78
84
  export declare const MIGRATION_NAME_RE: RegExp;
79
85
  export declare function assertValidMigrationName(name: string): void;
@@ -17,6 +17,7 @@
17
17
  import { readFileSync, writeFileSync } from "node:fs";
18
18
  import { compareByCodepoint } from "../utils/index.js";
19
19
  import { renderIndex, renderTableDdl } from "./render-ddl.js";
20
+ import { findRetiredTableDrops } from "./retired-framework-tables.js";
20
21
  const SNAPSHOT_VERSION = 1;
21
22
  export function snapshotFromMetas(metas) {
22
23
  // Stable ordering by tableName so the snapshot.json diff in PRs is
@@ -321,6 +322,7 @@ function describeIndexChange(change) {
321
322
  // the index serves performance) are emitted as commented-out statements
322
323
  // for explicit reviewer-action.
323
324
  export function renderMigrationSql(diff, options) {
325
+ const retiredDrops = options.retiredDrops ?? [];
324
326
  const lines = [];
325
327
  const seq = options.sequenceNumber.toString().padStart(4, "0");
326
328
  lines.push(`-- Migration ${seq}_${options.name}`);
@@ -393,9 +395,18 @@ export function renderMigrationSql(diff, options) {
393
395
  }
394
396
  lines.push("");
395
397
  }
398
+ if (retiredDrops.length > 0) {
399
+ lines.push("-- === Retired framework tables ===");
400
+ for (const retired of retiredDrops) {
401
+ lines.push(`-- ${retired.tableName}: retired since framework ${retired.sinceVersion} — ${retired.explanation}`);
402
+ lines.push(`DROP TABLE IF EXISTS ${quoteIdent(retired.tableName)};`);
403
+ }
404
+ lines.push("");
405
+ }
396
406
  if (diff.newTables.length === 0 &&
397
407
  diff.changedTables.length === 0 &&
398
- diff.droppedTables.length === 0) {
408
+ diff.droppedTables.length === 0 &&
409
+ retiredDrops.length === 0) {
399
410
  lines.push("-- No schema changes detected.");
400
411
  }
401
412
  return lines.join("\n");
@@ -413,9 +424,11 @@ export function generateMigration(input) {
413
424
  assertValidMigrationName(input.name);
414
425
  const nextSnapshot = snapshotFromMetas(input.metas);
415
426
  const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
427
+ const retiredDrops = findRetiredTableDrops(input.priorMigrationsSql ?? [], nextSnapshot);
416
428
  const sqlContent = renderMigrationSql(diff, {
417
429
  name: input.name,
418
430
  sequenceNumber: input.sequenceNumber,
431
+ retiredDrops,
419
432
  });
420
433
  const seq = input.sequenceNumber.toString().padStart(4, "0");
421
434
  return {
@@ -423,5 +436,6 @@ export function generateMigration(input) {
423
436
  sqlContent,
424
437
  snapshot: nextSnapshot,
425
438
  diff,
439
+ retiredDrops,
426
440
  };
427
441
  }
@@ -14,6 +14,7 @@ export type ApplyResult = {
14
14
  };
15
15
  export declare function splitSqlStatements(sqlText: string): readonly string[];
16
16
  export declare function loadMigrationsFromDir(dir: string, preprocess?: (sql: string) => string): readonly Migration[];
17
+ export declare function readMigrationSqlTexts(dir: string): readonly string[];
17
18
  export declare function fetchAppliedMigrations(db: DbConnection): Promise<readonly AppliedMigration[]>;
18
19
  export declare class MigrationChecksumMismatchError extends Error {
19
20
  readonly migrationId: string;