@cosmicdrift/kumiko-framework 0.338.0 → 0.339.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 (40) hide show
  1. package/dist/api/auth-middleware.d.ts +1 -0
  2. package/dist/api/auth-middleware.js +54 -10
  3. package/dist/api/routes.d.ts +2 -1
  4. package/dist/api/routes.js +29 -6
  5. package/dist/api/server.js +9 -1
  6. package/dist/bun-db/query.js +8 -2
  7. package/dist/crypto/event-pii.js +13 -4
  8. package/dist/crypto/index.d.ts +1 -1
  9. package/dist/crypto/index.js +1 -1
  10. package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
  11. package/dist/crypto/pii-ciphertext-format.js +11 -0
  12. package/dist/crypto/pii-field-encryption.d.ts +4 -2
  13. package/dist/crypto/pii-field-encryption.js +50 -21
  14. package/dist/crypto/system-event-pii.js +2 -2
  15. package/dist/db/queries/backfill-pii.js +4 -0
  16. package/dist/db/tenant-db.js +16 -12
  17. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  18. package/dist/engine/boot-validator/entity-handler.js +19 -0
  19. package/dist/engine/boot-validator/pii-retention.js +30 -0
  20. package/dist/engine/define-handler.js +1 -0
  21. package/dist/engine/extensions/user-data.d.ts +1 -1
  22. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  23. package/dist/engine/feature-entity-handlers.js +1 -0
  24. package/dist/engine/feature-manifest.d.ts +7 -0
  25. package/dist/engine/feature-manifest.js +8 -0
  26. package/dist/engine/index.d.ts +2 -1
  27. package/dist/engine/index.js +1 -0
  28. package/dist/engine/registry-ingest.d.ts +1 -0
  29. package/dist/engine/registry-ingest.js +9 -0
  30. package/dist/engine/registry.js +2 -1
  31. package/dist/engine/steps/retry.js +0 -1
  32. package/dist/engine/steps/wait-for-event.js +0 -1
  33. package/dist/engine/steps/wait.js +0 -4
  34. package/dist/engine/steps/workflow-system-events.d.ts +2 -0
  35. package/dist/engine/steps/workflow-system-events.js +89 -0
  36. package/dist/pipeline/append-event-core.d.ts +3 -1
  37. package/dist/pipeline/append-event-core.js +8 -4
  38. package/dist/pipeline/dispatch-shared.js +5 -2
  39. package/package.json +4 -4
  40. package/src/changes.json +41 -0
@@ -23,6 +23,7 @@ export type AuthMiddlewareOptions = {
23
23
  readonly anonymousAccess?: AnonymousAccessResolved;
24
24
  readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
25
25
  readonly foreignCookieOrigins?: ReadonlySet<string>;
26
+ readonly isTenantlessAnonymousWrite?: (handlerQn: string) => boolean;
26
27
  };
27
28
  export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId | null;
28
29
  export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
@@ -1,7 +1,7 @@
1
1
  import { getCookie } from "hono/cookie";
2
2
  import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
- import { parseTenantId } from "../engine/types/identifiers.js";
4
+ import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
5
  import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
6
6
  import { isForeignCookieOrigin } from "./origin-middleware.js";
7
7
  const USER_KEY = "pipelineUser";
@@ -78,7 +78,7 @@ function extractToken(c) {
78
78
  return { error: "missing" };
79
79
  }
80
80
  export function authMiddleware(jwt, options = {}) {
81
- const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, } = options;
81
+ const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, isTenantlessAnonymousWrite, } = options;
82
82
  // Fail loud at boot, not silently at request time: a tenantResolver
83
83
  // without a declared resolverTrust is an ambiguous trust decision no
84
84
  // sane default can make on the app's behalf (see AnonymousAccessConfig).
@@ -87,6 +87,7 @@ export function authMiddleware(jwt, options = {}) {
87
87
  'declare "authoritative" (resolver wins over client header/cookie) or ' +
88
88
  '"fallback-only" (client wins, resolver is a last resort).');
89
89
  }
90
+ const anonymousExtras = { isTenantlessAnonymousWrite };
90
91
  return async (c, next) => {
91
92
  const extracted = extractToken(c);
92
93
  if ("error" in extracted) {
@@ -117,7 +118,7 @@ export function authMiddleware(jwt, options = {}) {
117
118
  i18nKey: "auth.errors.missingToken",
118
119
  });
119
120
  }
120
- return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
121
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, anonymousExtras);
121
122
  }
122
123
  return middlewareReject(c, {
123
124
  code: "missing_token",
@@ -136,7 +137,7 @@ export function authMiddleware(jwt, options = {}) {
136
137
  !c.req.path.startsWith("/api/auth/") &&
137
138
  foreignCookieOrigins &&
138
139
  isForeignCookieOrigin(c, foreignCookieOrigins)) {
139
- return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
140
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, anonymousExtras);
140
141
  }
141
142
  // Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
142
143
  // JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
@@ -153,7 +154,7 @@ export function authMiddleware(jwt, options = {}) {
153
154
  const rejectInvalidCredential = async (rejection) => {
154
155
  if (isStaleCookieDowngradable(c, transport, anonymousAccess)) {
155
156
  return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus, {
156
- rejectionIfTenantUnresolved: rejection,
157
+ staleCredential: { rejectionIfTenantUnresolved: rejection },
157
158
  });
158
159
  }
159
160
  return middlewareReject(c, rejection);
@@ -271,7 +272,8 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
271
272
  // 5. Synthesise the anonymous SessionUser. The transport flag stays unset
272
273
  // so csrf-middleware skips the double-submit check (no auth-cookie ⇒
273
274
  // no CSRF vector to defend against).
274
- async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, staleCredential) {
275
+ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, extras = {}) {
276
+ const { staleCredential, isTenantlessAnonymousWrite } = extras;
275
277
  // A downgraded stale credential whose anonymous tenant can't be resolved or
276
278
  // is torn down answers with the credential's own 401: a tenant error would
277
279
  // hide the session end from the client.
@@ -292,11 +294,14 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
292
294
  ? { id: cookieCheck.tenantId, source: "cookie" }
293
295
  : null;
294
296
  // Step 3: pick the authoritative tenant.
295
- const resolved = await resolveTenant(c, config, clientTenant);
297
+ const resolved = await resolveTenantOrTenantless(c, config, clientTenant, {
298
+ allowTenantless: staleCredential === undefined,
299
+ isTenantlessAnonymousWrite,
300
+ });
296
301
  if ("error" in resolved)
297
302
  return rejectTenant(resolved.error);
298
303
  // Step 4: existence check for untrusted sources.
299
- if (resolved.source !== "default" && config.tenantExists) {
304
+ if (resolved.source !== "default" && resolved.source !== "tenantless" && config.tenantExists) {
300
305
  const exists = await config.tenantExists(resolved.tenantId);
301
306
  if (!exists) {
302
307
  return rejectTenant({
@@ -308,8 +313,10 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
308
313
  });
309
314
  }
310
315
  }
311
- // Step 5: synthesise + continue.
312
- const lifecycleReject = await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
316
+ // Step 5: synthesise + continue. SYSTEM_TENANT_ID is no lifecycle-managed tenant.
317
+ const lifecycleReject = resolved.source === "tenantless"
318
+ ? undefined
319
+ : await rejectIfTenantTeardown(c, resolved.tenantId, resolveTenantLifecycleStatus);
313
320
  if (lifecycleReject) {
314
321
  return staleCredential
315
322
  ? middlewareReject(c, staleCredential.rejectionIfTenantUnresolved)
@@ -321,6 +328,43 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, st
321
328
  // when next() ran; explicit return makes the union return-type honest.
322
329
  return;
323
330
  }
331
+ // The exemption applies only where resolveTenant answers tenant_required:
332
+ // never on tenant_mismatch, never when the client named a tenant via X-Tenant,
333
+ // never for a downgraded stale credential. An ambient tenant cookie does not
334
+ // block it: tenant_required with a client tenant only happens when an
335
+ // authoritative resolver stayed silent, and that path ignores the cookie
336
+ // anyway, so a customer who also uses the app on this host can still cancel.
337
+ async function resolveTenantOrTenantless(c, config, clientTenant, tenantless) {
338
+ const resolved = await resolveTenant(c, config, clientTenant);
339
+ const { isTenantlessAnonymousWrite } = tenantless;
340
+ if ("error" in resolved &&
341
+ resolved.error.code === "tenant_required" &&
342
+ clientTenant?.source !== "header" &&
343
+ tenantless.allowTenantless &&
344
+ isTenantlessAnonymousWrite !== undefined &&
345
+ (await isFlaggedTenantlessWrite(c, isTenantlessAnonymousWrite))) {
346
+ return { tenantId: SYSTEM_TENANT_ID, source: "tenantless" };
347
+ }
348
+ return resolved;
349
+ }
350
+ // Body peek for the tenantless exemption: only a single-command POST
351
+ // /api/write qualifies, never /api/batch. Parsing the clone leaves the body
352
+ // intact for the dispatcher; the body-size limit already ran upstream.
353
+ async function isFlaggedTenantlessWrite(c, isTenantlessAnonymousWrite) {
354
+ if (c.req.method !== "POST" || c.req.path !== "/api/write")
355
+ return false;
356
+ try {
357
+ const body = await c.req.raw.clone().json();
358
+ return (typeof body === "object" &&
359
+ body !== null &&
360
+ "type" in body &&
361
+ typeof body.type === "string" &&
362
+ isTenantlessAnonymousWrite(body.type));
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
324
368
  // Validates an X-Tenant / cookie value against the tenantId format. Returns
325
369
  // `{tenantId: null}` when absent, `{tenantId: TenantId}` when valid, or
326
370
  // `{error: …}` when the value is non-empty junk (so the caller can return
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ import { type Context, Hono } from "hono";
2
2
  import type { Dispatcher } from "../pipeline/dispatcher.js";
3
3
  export declare const StreamFrame: {
4
4
  readonly chunk: "chunk";
@@ -18,3 +18,4 @@ export type SseWriter = {
18
18
  };
19
19
  export declare function pumpStream(stream: SseWriter, generator: AsyncGenerator<unknown>, heartbeatMs: number, firstPull?: Promise<IteratorResult<unknown>>): Promise<void>;
20
20
  export declare const MAX_PAYLOAD_DEPTH = 20;
21
+ export declare function handleUncaughtRouteError(err: Error, c: Context): Response | Promise<Response>;
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
2
3
  import { streamSSE } from "hono/streaming";
3
4
  import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError, ValidationError, } from "../errors/index.js";
4
5
  import { createFallbackLogger } from "../logging/index.js";
@@ -356,17 +357,39 @@ function writeErrorResponse(c, err, type) {
356
357
  const { error } = serializeError(err, requestId);
357
358
  return c.json({ isSuccess: false, error }, err.httpStatus); // @cast-boundary engine-payload
358
359
  }
360
+ function clientAbortResponse(c, requestId, type) {
361
+ logClientWarn("request aborted by client", requestId, type, {
362
+ status: CLIENT_CLOSED_REQUEST_STATUS,
363
+ });
364
+ return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
365
+ }
359
366
  // For /query + /command: no isSuccess on success (just { data } / {ok}), so we
360
367
  // keep the same lean shape on failure — only the `error` key.
361
368
  function queryErrorResponse(c, err, type) {
362
369
  const requestId = requestContext.get()?.requestId;
363
- if (isClientAbort(err)) {
364
- logClientWarn("request aborted by client", requestId, type, {
365
- status: CLIENT_CLOSED_REQUEST_STATUS,
366
- });
367
- return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
368
- }
370
+ if (isClientAbort(err))
371
+ return clientAbortResponse(c, requestId, type);
369
372
  logServerFault(err, requestId, type);
370
373
  const body = serializeError(err, requestId);
371
374
  return c.json(body, err.httpStatus); // @cast-boundary engine-payload
372
375
  }
376
+ // A raw throw under the request signal (signal.throwIfAborted(), an aborted
377
+ // fetch) is the signal's own reason or wraps it as `cause`.
378
+ function isRequestAbortError(err, signal) {
379
+ return signal.aborted && (err === signal.reason || err.cause === signal.reason);
380
+ }
381
+ // Root `app.onError`: whatever a route rethrows instead of shaping itself.
382
+ // Without it Hono's default handler console.error()s the bare error and
383
+ // answers an unclassified 500 — including for a client that simply hung up.
384
+ // requestContext only exists under /api/*, so the abort check reads the raw
385
+ // request signal.
386
+ export function handleUncaughtRouteError(err, c) {
387
+ if (err instanceof HTTPException)
388
+ return err.getResponse();
389
+ const requestId = requestContext.get()?.requestId;
390
+ if (isRequestAbortError(err, c.req.raw.signal))
391
+ return clientAbortResponse(c, requestId);
392
+ const kumikoError = toKumiko(err);
393
+ logServerFault(kumikoError, requestId);
394
+ return c.json(serializeError(kumikoError, requestId), kumikoError.httpStatus); // @cast-boundary engine-payload
395
+ }
@@ -39,7 +39,7 @@ import { createDefaultSseBroker } from "./redis-sse-broker.js";
39
39
  import { requestContext } from "./request-context.js";
40
40
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
41
41
  import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
42
- import { createApiRoutes } from "./routes.js";
42
+ import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
43
43
  import { createSseRoute } from "./sse-route.js";
44
44
  import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
45
45
  // The per-tenant file-provider resolver, built once for a registry+context so
@@ -380,6 +380,8 @@ export function buildServer(options) {
380
380
  });
381
381
  }
382
382
  const app = new Hono();
383
+ // Sub-apps mounted via app.route() without their own handler inherit this one.
384
+ app.onError(handleUncaughtRouteError);
383
385
  // Only entry:"signature" bypasses jwtGuard (verify() authenticates
384
386
  // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
385
387
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
@@ -453,7 +455,13 @@ export function buildServer(options) {
453
455
  const normalizedForeignCookieOrigins = options.auth?.allowedOrigins?.length
454
456
  ? new Set(options.auth.allowedOrigins.map(normalizeOrigin))
455
457
  : undefined;
458
+ const tenantlessAnonymousQns = new Set([...options.registry.getAllWriteHandlers()]
459
+ .filter(([, handler]) => handler.tenantlessAnonymous === true)
460
+ .map(([qn]) => qn));
456
461
  const jwtGuard = authMiddleware(jwt, {
462
+ ...(tenantlessAnonymousQns.size > 0
463
+ ? { isTenantlessAnonymousWrite: (qn) => tenantlessAnonymousQns.has(qn) }
464
+ : {}),
457
465
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
458
466
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
459
467
  ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
@@ -24,6 +24,7 @@ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
24
24
  import { Temporal } from "temporal-polyfill";
25
25
  import { requestContext } from "../api/request-context.js";
26
26
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
27
+ import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
27
28
  import { SQL_EXPR_BRAND } from "../db/dialect.js";
28
29
  import { extractPgError } from "../db/pg-error.js";
29
30
  import { toSnakeCase } from "../db/table-builder.js";
@@ -88,7 +89,7 @@ export function asRawClient(db) {
88
89
  // closed instead of silently unwrapping a tenant-scoped handle.
89
90
  if (isTenantDbShape(db)) {
90
91
  throw new Error("asRawClient: received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the " +
91
- "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: " +
92
+ "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw() with escapeHatch: " +
92
93
  "{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).");
93
94
  }
94
95
  // Drizzle DbConnection (legacy compat): $client = postgres-js Sql.
@@ -174,7 +175,7 @@ function tenantDbDelegate(db) {
174
175
  function assertNotTenantScoped(db, fnName) {
175
176
  if (tenantDbDelegate(db) !== undefined) {
176
177
  throw new Error(`${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
177
- "Pass ctx.db.unsafeRaw(reason) (with escapeHatch: { reason } on the handler/hook) for an " +
178
+ "Pass ctx.db.unsafeRaw() (with escapeHatch: { reason } on the handler/hook) for an " +
178
179
  "explicit cross-tenant op, or use a scoped TenantDb method.");
179
180
  }
180
181
  }
@@ -462,6 +463,11 @@ function prepareValue(value, pgType) {
462
463
  if (isSqlExpression(value)) {
463
464
  return { kind: "literal", literal: value.text };
464
465
  }
466
+ // A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
467
+ // ::jsonb would be parsed as JSON text (or double-encoded under Bun.SQL).
468
+ if (pgType === "jsonb" && isPiiCiphertext(value)) {
469
+ return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
470
+ }
465
471
  if (pgType === "jsonb" && value !== null) {
466
472
  const prepared = prepareJsonbValue(value);
467
473
  if (prepared)
@@ -14,7 +14,7 @@
14
14
  // silent no-op (forgetting the option) is now either an explicit "none" or a
15
15
  // missing subject KMS at encrypt-time, both visible states, not a gap.
16
16
  import { requestContext } from "../api/request-context.js";
17
- import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption.js";
17
+ import { configuredPiiSubjectKms, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, } from "./pii-field-encryption.js";
18
18
  import { resolveEventSubject } from "./subject-resolver.js";
19
19
  import { SYSTEM_EVENT_PII_STANCES, SYSTEM_EVENT_PREFIX } from "./system-event-pii.js";
20
20
  // Boot-injected like configurePiiSubjectKms — createRegistry calls this with
@@ -41,6 +41,12 @@ function resolvePiiStance(eventType) {
41
41
  }
42
42
  return stance;
43
43
  }
44
+ // The catalog carries no field types: strings keep the plain format, objects
45
+ // and arrays (jsonb payload fields) use the self-describing JSON format.
46
+ // Bare numbers/booleans stay a loud error so a mistyped string field is caught.
47
+ function isStructuredJsonValue(value) {
48
+ return typeof value === "object" && value !== null;
49
+ }
44
50
  // Encrypts catalogued payload fields under the declared subject's DEK
45
51
  // (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
46
52
  // type is uncatalogued or no subject KMS is configured (plaintext rollout
@@ -63,13 +69,16 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
63
69
  const value = payload[field];
64
70
  if (value === null || value === undefined)
65
71
  continue;
66
- if (typeof value !== "string") {
67
- throw new Error(`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`);
72
+ if (typeof value !== "string" && !isStructuredJsonValue(value)) {
73
+ throw new Error(`Event "${eventType}" piiFields."${field}" must be a string payload field (or an object/array for jsonb), got ${typeof value}`);
68
74
  }
69
75
  const subject = resolveEventSubject(field, spec, payload, envelope);
70
76
  if (subject === null)
71
77
  continue;
72
- const encrypted = await encryptPiiValueForSubject(kms, subject, value, { requestId: requestContext.get()?.requestId ?? "append-event" }, field);
78
+ const kmsCtx = { requestId: requestContext.get()?.requestId ?? "append-event" };
79
+ const encrypted = typeof value === "string"
80
+ ? await encryptPiiValueForSubject(kms, subject, value, kmsCtx, field)
81
+ : await encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx, field);
73
82
  if (encrypted !== value) {
74
83
  out ??= { ...payload };
75
84
  out[field] = encrypted;
@@ -6,6 +6,6 @@ export { type KekSourceEnv, type KekSourceOptions, resolvePlatformKeks } from ".
6
6
  export { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, type KmsAdapter, type KmsContext, type KmsHealth, type LocalKeyKmsAdapter, RECORD_ENTITY_PATTERN, type RemoteCryptoKmsAdapter, type SubjectDek, type SubjectId, type SubjectKey, subjectIdFromKey, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForTenant, subjectKeyForUser, } from "./kms-adapter.js";
7
7
  export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiringEnv, type KmsWiringOptions, type KmsWiringRelease, type PgKmsRotationEnv, type PlaintextPiiWiring, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
8
8
  export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
- export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
9
+ export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, type RequestKmsCache, } from "./request-kms-cache.js";
11
11
  export { collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -6,6 +6,6 @@ export { resolvePlatformKeks } from "./kek-source.js";
6
6
  export { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, RECORD_ENTITY_PATTERN, subjectIdFromKey, subjectIdSchema, subjectIdToKey, subjectKeyForRecord, subjectKeyForTenant, subjectKeyForUser, } from "./kms-adapter.js";
7
7
  export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsWiring, resolveKmsWiringAsync, } from "./kms-wiring.js";
8
8
  export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
- export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
9
+ export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, } from "./request-kms-cache.js";
11
11
  export { collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -0,0 +1,4 @@
1
+ export declare const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
2
+ export declare const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
3
+ export declare const PII_CIPHERTEXT_PREFIX_JSON = "kumiko-pii:v3:";
4
+ export declare function isPiiCiphertext(value: unknown): value is string;
@@ -0,0 +1,11 @@
1
+ // Leaf module (no engine imports) so bun-db and the executors can sniff PII
2
+ // ciphertext without pulling the KMS/pipeline graph into an import cycle.
3
+ export const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
4
+ export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
5
+ export const PII_CIPHERTEXT_PREFIX_JSON = "kumiko-pii:v3:";
6
+ export function isPiiCiphertext(value) {
7
+ return (typeof value === "string" &&
8
+ (value.startsWith(PII_CIPHERTEXT_PREFIX) ||
9
+ value.startsWith(PII_CIPHERTEXT_PREFIX_JSON) ||
10
+ value.startsWith(PII_CIPHERTEXT_PREFIX_V1)));
11
+ }
@@ -1,10 +1,11 @@
1
1
  import type { EntityDefinition } from "../engine/types/fields.js";
2
2
  import type { TenantId } from "../engine/types/identifiers.js";
3
3
  import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId } from "./kms-adapter.js";
4
+ import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON } from "./pii-ciphertext-format.js";
5
+ export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
4
6
  export declare const PII_ERASED_SENTINEL = "[[erased]]";
5
- export declare const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
6
- export declare function isPiiCiphertext(value: unknown): value is string;
7
7
  export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
8
+ export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
8
9
  export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
9
10
  export interface EncryptPiiOptions {
10
11
  readonly onlyKeys?: Iterable<string>;
@@ -13,6 +14,7 @@ export interface EncryptPiiOptions {
13
14
  readonly entityName: string;
14
15
  }
15
16
  export declare function encryptPiiFieldValues(row: Record<string, unknown>, entity: EntityDefinition, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext, opts: EncryptPiiOptions): Promise<Record<string, unknown>>;
17
+ export declare function serializeJsonbValue(field: string, value: unknown): string;
16
18
  export declare function decryptPiiFieldValues(row: Record<string, unknown>, piiFields: readonly string[], kms: LocalKeyKmsAdapter, kmsCtx: KmsContext): Promise<Record<string, unknown>>;
17
19
  export declare function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void;
18
20
  export declare function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined;
@@ -9,34 +9,41 @@
9
9
  // SAME subject (same DEK, so key selection alone can't catch that) from
10
10
  // decrypting silently. v1 (no AAD) stays decrypt-only for pre-#1263 rows;
11
11
  // every new write emits v2.
12
+ // v3 = v2 for jsonb fields: the plaintext is JSON.stringify(value) and decrypt
13
+ // JSON.parses it back, so a jsonb string/number/object round-trips by type.
14
+ // The version digit (not a suffix) keeps `kumiko-pii:v\d+:` leak guards and
15
+ // the `kumiko-pii:v%:` LIKE sweeps matching. The AAD carries a "json" tag so
16
+ // a v2<->v3 prefix swap fails GCM instead of silently changing the type.
12
17
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
13
18
  import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
14
19
  import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
20
+ import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
15
21
  import { resolveSubjectForField } from "./subject-resolver.js";
16
22
  // Spec value (crypto-shredding.md) — renderers show it verbatim.
23
+ export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
17
24
  export const PII_ERASED_SENTINEL = "[[erased]]";
18
- const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
19
- export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
20
25
  const IV_LENGTH = 12;
21
26
  const AUTH_TAG_LENGTH = 16;
22
- export function isPiiCiphertext(value) {
23
- return (typeof value === "string" &&
24
- (value.startsWith(PII_CIPHERTEXT_PREFIX) || value.startsWith(PII_CIPHERTEXT_PREFIX_V1)));
27
+ function buildAad(subject, field, isJson) {
28
+ return Buffer.from(`${subjectIdToKey(subject)}|${field}${isJson ? "|json" : ""}`, "utf8");
25
29
  }
26
- function buildAad(subject, field) {
27
- return Buffer.from(`${subjectIdToKey(subject)}|${field}`, "utf8");
28
- }
29
- function encryptValue(subject, dek, plaintext, field) {
30
+ function encryptValue(subject, dek, plaintext, field, isJson = false) {
30
31
  const iv = randomBytes(IV_LENGTH);
31
32
  const cipher = createCipheriv("aes-256-gcm", dek, iv);
32
- cipher.setAAD(buildAad(subject, field));
33
+ cipher.setAAD(buildAad(subject, field, isJson));
33
34
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
34
35
  const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
35
- return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
36
+ const prefix = isJson ? PII_CIPHERTEXT_PREFIX_JSON : PII_CIPHERTEXT_PREFIX;
37
+ return `${prefix}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
36
38
  }
37
39
  function parseCiphertext(value) {
38
- const hasAad = value.startsWith(PII_CIPHERTEXT_PREFIX);
39
- const prefix = hasAad ? PII_CIPHERTEXT_PREFIX : PII_CIPHERTEXT_PREFIX_V1;
40
+ const isJson = value.startsWith(PII_CIPHERTEXT_PREFIX_JSON);
41
+ const hasAad = isJson || value.startsWith(PII_CIPHERTEXT_PREFIX);
42
+ const prefix = isJson
43
+ ? PII_CIPHERTEXT_PREFIX_JSON
44
+ : hasAad
45
+ ? PII_CIPHERTEXT_PREFIX
46
+ : PII_CIPHERTEXT_PREFIX_V1;
40
47
  const rest = value.slice(prefix.length);
41
48
  // subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
42
49
  // last ":" is always the key/blob separator.
@@ -47,15 +54,16 @@ function parseCiphertext(value) {
47
54
  subject: subjectIdFromKey(rest.slice(0, sep)),
48
55
  blob: Buffer.from(rest.slice(sep + 1), "base64"),
49
56
  hasAad,
57
+ isJson,
50
58
  };
51
59
  }
52
- function decryptValue(dek, blob, subject, field, hasAad) {
60
+ function decryptValue(dek, blob, subject, field, hasAad, isJson) {
53
61
  const iv = blob.subarray(0, IV_LENGTH);
54
62
  const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
55
63
  const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
56
64
  const decipher = createDecipheriv("aes-256-gcm", dek, iv);
57
65
  if (hasAad)
58
- decipher.setAAD(buildAad(subject, field));
66
+ decipher.setAAD(buildAad(subject, field, isJson));
59
67
  decipher.setAuthTag(tag);
60
68
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
61
69
  }
@@ -91,6 +99,12 @@ export async function encryptPiiValueForSubject(kms, subject, value, kmsCtx, fie
91
99
  const dek = await getOrCreateDek(kms, subject, kmsCtx);
92
100
  return encryptValue(subject, dek, value, field);
93
101
  }
102
+ // Event-payload variant for non-string values (jsonb-typed payload fields):
103
+ // same DEK path, but the self-describing v3 format so decrypt restores the type.
104
+ export async function encryptPiiJsonValueForSubject(kms, subject, value, kmsCtx, field) {
105
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
106
+ return encryptValue(subject, dek, serializeJsonbValue(field, value), field, true);
107
+ }
94
108
  // Single-value decrypt for callers that don't have an entity/field-map to
95
109
  // pass through decryptPiiFieldValues (config values). The subject lives
96
110
  // inside the ciphertext itself, so no subject/scope resolution is needed
@@ -99,9 +113,12 @@ export async function encryptPiiValueForSubject(kms, subject, value, kmsCtx, fie
99
113
  export async function decryptPiiValueForSubject(kms, value, kmsCtx, field) {
100
114
  if (!isPiiCiphertext(value))
101
115
  return value;
102
- const { subject, blob, hasAad } = parseCiphertext(value);
116
+ const { subject, blob, hasAad, isJson } = parseCiphertext(value);
117
+ if (isJson) {
118
+ throw new Error(`PII field "${field}" holds a jsonb ciphertext — decrypt it via decryptPiiFieldValues`);
119
+ }
103
120
  try {
104
- return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad);
121
+ return decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, field, hasAad, false);
105
122
  }
106
123
  catch (e) {
107
124
  if (!(e instanceof KeyErasedError))
@@ -127,9 +144,11 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
127
144
  // are already ciphertext or the erased sentinel — both stay as-is.
128
145
  if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
129
146
  continue;
130
- if (typeof value !== "string") {
147
+ const isJsonbField = entity.fields[name]?.type === "jsonb";
148
+ if (!isJsonbField && typeof value !== "string") {
131
149
  throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
132
150
  }
151
+ const plaintext = isJsonbField ? serializeJsonbValue(name, value) : String(value);
133
152
  const subject = resolveSubjectForField(entity, name, subjectSource, {
134
153
  entityName: opts.entityName,
135
154
  ...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
@@ -138,10 +157,17 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
138
157
  if (subject === null)
139
158
  continue;
140
159
  const dek = await getOrCreateDek(kms, subject, kmsCtx);
141
- out[name] = encryptValue(subject, dek, value, name);
160
+ out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
142
161
  }
143
162
  return out;
144
163
  }
164
+ export function serializeJsonbValue(field, value) {
165
+ const json = JSON.stringify(value);
166
+ // undefined/function/symbol serialize to undefined — never storable as jsonb
167
+ if (json === undefined)
168
+ throw new Error(`PII jsonb field "${field}" is not JSON-serializable`);
169
+ return json;
170
+ }
145
171
  export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
146
172
  if (piiFields.length === 0)
147
173
  return row;
@@ -152,9 +178,12 @@ export async function decryptPiiFieldValues(row, piiFields, kms, kmsCtx) {
152
178
  // work during rollout; backfill is tracked in kumiko-framework#799).
153
179
  if (!isPiiCiphertext(value))
154
180
  continue;
155
- const { subject, blob, hasAad } = parseCiphertext(value);
181
+ const { subject, blob, hasAad, isJson } = parseCiphertext(value);
156
182
  try {
157
- out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob, subject, name, hasAad);
183
+ const key = await kms.getKey(subject, kmsCtx);
184
+ const plain = decryptValue(key, blob, subject, name, hasAad, isJson);
185
+ // @cast-boundary crypto-decrypt — AEAD-authenticated plaintext that this module serialized
186
+ out[name] = isJson ? JSON.parse(plain) : plain;
158
187
  }
159
188
  catch (e) {
160
189
  // KeyNotFound deliberately propagates: ciphertext without a key row
@@ -9,8 +9,8 @@ export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
9
9
  // an event-store import cycle; transfer.ts re-exports it.
10
10
  export const AGGREGATE_TRANSFERRED_EVENT_TYPE = `${SYSTEM_EVENT_PREFIX}aggregate.transferred`;
11
11
  const SELF = { personal: "self" };
12
- // Workflow payloads carry `triggerPayload`, the stored payload of the trigger
13
- // event: catalogued fields there are already ciphertext, so "none" leaks nothing.
12
+ // Workflow run-stream payloads carry references and step bookkeeping only,
13
+ // never a copy of a foreign event payload, so "none" leaves nothing to shred.
14
14
  export const SYSTEM_EVENT_PII_STANCES = new Map([
15
15
  [
16
16
  STEP_DISPATCH_REQUESTED_TYPE,
@@ -300,6 +300,10 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
300
300
  const value = section[field];
301
301
  if (value === null || value === undefined)
302
302
  continue;
303
+ // jsonb PII is not retro-encrypted (consumer wave); a legacy string
304
+ // value here would otherwise get the v2 text format into a jsonb column.
305
+ if (target.entity.fields[field]?.type === "jsonb")
306
+ continue;
303
307
  if (typeof value !== "string")
304
308
  continue;
305
309
  // Already-ciphertext/sentinel fields must never enter owner
@@ -11,7 +11,7 @@ const declaredUnsafeRawRunners = new WeakMap();
11
11
  // TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
12
12
  // (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
13
13
  const personalDataGates = new WeakMap();
14
- // Lets createTenantDb(ctx.db.unsafeRaw(reason), ...) inherit the gate. Keyed by a
14
+ // Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate. Keyed by a
15
15
  // per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
16
16
  const runnerPersonalDataGates = new WeakMap();
17
17
  function gatedRunner(runner, gate) {
@@ -61,7 +61,7 @@ export function unsafeRawForDeclaredStep(holder, reason) {
61
61
  "createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
62
62
  });
63
63
  }
64
- return runner(reason, true);
64
+ return runner(reason);
65
65
  }
66
66
  const systemDbRebinders = new WeakMap();
67
67
  // Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
@@ -104,7 +104,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
104
104
  message: "createSystemDbView received a TenantDb with no declared unsafeRaw runner bound.",
105
105
  });
106
106
  }
107
- return sourceRunner(reason, forwardedDeclaredStep);
107
+ return sourceRunner(forwardedDeclaredStep ? reason : undefined);
108
108
  }
109
109
  if (reason.trim().length === 0) {
110
110
  throw new Error("unsafeRaw requires a non-empty reason");
@@ -170,7 +170,14 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
170
170
  },
171
171
  },
172
172
  };
173
- declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
173
+ declaredUnsafeRawRunners.set(uncheckedSystemDb, (declaredStepReason) => {
174
+ if (declaredStepReason === undefined) {
175
+ throw new InternalError({
176
+ message: "A declared unsafeRaw runner on a system db requires the step reason.",
177
+ });
178
+ }
179
+ return grantedUnsafeRawRunner(declaredStepReason, true);
180
+ });
174
181
  if (gate?.kind !== "source-tenant-db") {
175
182
  systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) => buildUncheckedSystemDb(withUnsafeRawGrant(db, grant), dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant), report, { kind: "hook-grant", grant, deniedCallerLabel }));
176
183
  }
@@ -426,29 +433,26 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
426
433
  // @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
427
434
  };
428
435
  }
429
- function grantedUnsafeRawRunner(reason, forwardedDeclaredStep) {
430
- if (reason.trim().length === 0) {
431
- throw new Error("unsafeRaw requires a non-empty reason");
432
- }
436
+ function grantedUnsafeRawRunner(declaredStepReason) {
433
437
  // Ahead of the grant check: a declared escapeHatch must not buy a raw runner here either.
434
438
  if (grants?.memberReadOnly) {
435
439
  throw memberResolutionReadOnlyDenied();
436
440
  }
437
441
  if (!hasGrant(grants?.unsafeRaw)) {
438
442
  throw new AccessDeniedError({
439
- message: 'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
443
+ message: 'ctx.db.unsafeRaw(): rejected — declare `escapeHatch: { reason: "..." }` on ' +
440
444
  "the handler or hook to allow unsafeRaw.",
441
445
  });
442
446
  }
443
- // Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
444
- report("unsafe-raw", forwardedDeclaredStep ? reason : (grants?.unsafeRaw?.reason ?? reason));
447
+ // Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
448
+ report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
445
449
  return personalDataGate ? gatedRunner(db, personalDataGate) : db;
446
450
  }
447
451
  const tenantDb = {
448
452
  tenantId,
449
453
  mode,
450
454
  global: globalTable,
451
- unsafeRaw: (reason) => grantedUnsafeRawRunner(reason, false),
455
+ unsafeRaw: () => grantedUnsafeRawRunner(),
452
456
  selectMany(table, where, options) {
453
457
  const filter = readWhere(table, where);
454
458
  return withDbSpan("select", table, async () => bunSelectMany(db, table, filter, options));
@@ -6,6 +6,7 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
6
6
  /** Extensions with preSave must target an entity that has mapped write handlers. */
7
7
  export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
8
8
  export declare function validateHandlerAccess(feature: FeatureDefinition): void;
9
+ export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
9
10
  export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
10
11
  export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
11
12
  export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
@@ -134,6 +134,25 @@ export function validateHandlerAccess(feature) {
134
134
  validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
135
135
  }
136
136
  }
137
+ for (const [name, handler] of Object.entries(feature.writeHandlers)) {
138
+ validateTenantlessAnonymous(feature.name, name, handler);
139
+ }
140
+ }
141
+ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
142
+ // skip: handler does not opt into tenantless anonymous calls
143
+ if (!handler.tenantlessAnonymous)
144
+ return;
145
+ const where = `write handler "${featureName}:write:${handlerName}" declares tenantlessAnonymous`;
146
+ const { access } = handler;
147
+ const isAnonymousOnly = "roles" in access && access.roles.length === 1 && access.roles[0] === "anonymous";
148
+ if (!isAnonymousOnly) {
149
+ throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
150
+ `carries no principal beyond the anonymous visitor.`);
151
+ }
152
+ if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
153
+ throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
154
+ `needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
155
+ }
137
156
  }
138
157
  export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
139
158
  // skip: rate limit isn't disabled, so no reason is required
@@ -71,6 +71,33 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
71
71
  }
72
72
  }
73
73
  }
74
+ // A personal jsonb field is stored as a ciphertext JSON string scalar, so
75
+ // anything that reads inside the column (index, filter, sort, search, jsonb
76
+ // operators/merges such as the custom-fields column) sees only ciphertext.
77
+ const JSONB_PERSONAL_FORBIDDEN_FLAGS = [
78
+ "sortable",
79
+ "filterable",
80
+ "searchable",
81
+ "lookupable",
82
+ ];
83
+ const CUSTOM_FIELDS_COLUMN = "customFields";
84
+ function validatePersonalJsonbField(featureName, entityName, entity, fieldName, field) {
85
+ const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
86
+ const reason = "a personal jsonb field is stored as a ciphertext string, so nothing inside it can be indexed, filtered, sorted, searched or merged";
87
+ const flags = field; // @cast-boundary schema-walk
88
+ for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
89
+ if (flags[flag] !== undefined && flags[flag] !== false) {
90
+ throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}.`);
91
+ }
92
+ }
93
+ const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
94
+ if (index) {
95
+ throw new Error(`${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`);
96
+ }
97
+ if (fieldName === CUSTOM_FIELDS_COLUMN) {
98
+ throw new Error(`${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`);
99
+ }
100
+ }
74
101
  // --- PII / Subject-Key Annotations + Retention validation ---
75
102
  //
76
103
  // Drei Klassen von Checks:
@@ -153,6 +180,9 @@ export function validatePiiAndRetention(feature) {
153
180
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
154
181
  }
155
182
  }
183
+ if (field.type === "jsonb" && annotCount > 0) {
184
+ validatePersonalJsonbField(feature.name, entityName, entity, fieldName, field);
185
+ }
156
186
  if (annot.userOwned) {
157
187
  const ownerName = annot.userOwned.ownerField;
158
188
  if (!ownerName || typeof ownerName !== "string") {
@@ -51,6 +51,7 @@ export function defineWriteHandler(def,
51
51
  }),
52
52
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
53
53
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
54
+ ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
54
55
  };
55
56
  if ("perform" in def && def.perform !== undefined) {
56
57
  const performDef = def.perform;
@@ -30,7 +30,7 @@ export type TenantUserModel = "single-user" | "multi-user";
30
30
  * fw#2914 — `db` is a tenant-filtered `TenantDb`, bound to `tenantId`
31
31
  * (or the per-user sub-tx in the forget path). Unfiltered access needs
32
32
  * `escapeHatch: { reason }` on the `r.useExtension(...)` registration,
33
- * then `ctx.db.unsafeRaw(reason)`.
33
+ * then `ctx.db.unsafeRaw()`.
34
34
  */
35
35
  /**
36
36
  * Minimal storage surface a file-aware forget hook needs to erase binaries.
@@ -32,6 +32,7 @@ const WRITE_HANDLER_KEY_KINDS = {
32
32
  unsafeSkipTransitionGuard: "modeled",
33
33
  rateLimit: "modeled",
34
34
  escapeHatch: "modeled",
35
+ tenantlessAnonymous: "opaque",
35
36
  perform: "opaque",
36
37
  };
37
38
  const QUERY_HANDLER_KEY_KINDS = {
@@ -106,6 +106,7 @@ export function buildEntityHandlerMethods(state, name) {
106
106
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
107
107
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
108
108
  ...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
109
+ ...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
109
110
  // Forward the pipeline-build closure so boot-validators and
110
111
  // Designer/AI tooling can inspect the step list. Absent on
111
112
  // free-form handlers — defineWriteHandler only sets `perform`
@@ -30,6 +30,12 @@ export type ManifestExtension = {
30
30
  readonly extensionName: string;
31
31
  readonly entityName: string;
32
32
  };
33
+ /** Framework-owned registered event (`kumiko:system:*`), e.g. the workflow
34
+ * run-stream events a projection can apply. Not owned by any feature. */
35
+ export type ManifestSystemEvent = {
36
+ readonly name: string;
37
+ readonly version: number;
38
+ };
33
39
  export type ManifestFeature = {
34
40
  readonly name: string;
35
41
  readonly description: string | null;
@@ -58,6 +64,7 @@ export type FeatureManifest = {
58
64
  readonly source: string;
59
65
  readonly featureCount: number;
60
66
  readonly features: readonly ManifestFeature[];
67
+ readonly systemEvents?: readonly ManifestSystemEvent[];
61
68
  readonly tier?: string;
62
69
  };
63
70
  export type BuildManifestOptions = {
@@ -5,6 +5,7 @@
5
5
  // die Kopie still. Quelle der Wahrheit ist die GEBOOTETE Registry — der
6
6
  // AST-Parser kann die imperativen Factory-Helper der bundled features nicht
7
7
  // lesen.
8
+ import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii.js";
8
9
  import { compareByCodepoint } from "../utils/index.js";
9
10
  import { isEncryptedAtRest } from "./config-helpers.js";
10
11
  import { qualifyEntityName } from "./qualified-name.js";
@@ -76,10 +77,17 @@ export function buildManifestFromRegistry(registry, options) {
76
77
  });
77
78
  }
78
79
  manifestFeatures.sort((a, b) => compareByCodepoint(a.name, b.name));
80
+ const systemEvents = [];
81
+ for (const [name, info] of registry.getEventUpcasters()) {
82
+ if (name.startsWith(SYSTEM_EVENT_PREFIX))
83
+ systemEvents.push({ name, version: info.currentVersion });
84
+ }
85
+ systemEvents.sort((a, b) => compareByCodepoint(a.name, b.name));
79
86
  return {
80
87
  source: options.source,
81
88
  featureCount: manifestFeatures.length,
82
89
  features: manifestFeatures,
90
+ systemEvents,
83
91
  ...(options.tier !== undefined && { tier: options.tier }),
84
92
  };
85
93
  }
@@ -40,7 +40,7 @@ export { createBigIntField, createBooleanField, createDateField, createDecimalFi
40
40
  export type { AddEntityArgs, AddHookArgs, AddRelationArgs, AddWriteHandlerArgs, FeaturePatcher, FeaturePattern, FeaturePatternKind, FormFieldLabel, FormFieldSpec, FormInputType, HandlerHeaderUpdate, ParseError, ParseResult, PatternCategory, PatternChange, PatternChangeIssue, PatternChangesParseResult, PatternFormSchema, PatternId, RenderFeatureFileInput, SourceLocation, } from "./feature-ast/index.js";
41
41
  export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, groupByCategory, PATTERN_LIBRARY, parseFeatureFile, parsePatternChanges, parseSourceFile, removePattern, renderFeatureFile, renderPattern, replacePattern, updatePattern, VERSION_HEADER, } from "./feature-ast/index.js";
42
42
  export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
43
- export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, serializeManifest, } from "./feature-manifest.js";
43
+ export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.js";
44
44
  export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
45
45
  export { resolveName, withResponseData } from "./handler-helpers.js";
46
46
  export { i18nKey } from "./i18n-key.js";
@@ -70,6 +70,7 @@ export { defineTransitions, guardTransition } from "./state-machine.js";
70
70
  export { evaluateEventMatch } from "./steps/_event-match.js";
71
71
  export { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./steps/_step-dispatch-constants.js";
72
72
  export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.js";
73
+ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
73
74
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
74
75
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
75
76
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
@@ -64,6 +64,7 @@ export { defineTransitions, guardTransition } from "./state-machine.js";
64
64
  export { evaluateEventMatch } from "./steps/_event-match.js";
65
65
  export { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./steps/_step-dispatch-constants.js";
66
66
  export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.js";
67
+ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
67
68
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
68
69
  export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
69
70
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
@@ -5,6 +5,7 @@ export declare function populateHandlers(state: RegistryState, feature: FeatureD
5
5
  export declare function populateConfigKeys(state: RegistryState, feature: FeatureDefinition): void;
6
6
  export declare function populateJobsAndNotifications(state: RegistryState, feature: FeatureDefinition): void;
7
7
  export declare function populateEvents(state: RegistryState, feature: FeatureDefinition): void;
8
+ export declare function populateSystemEvents(state: RegistryState): void;
8
9
  export declare function populateTranslations(state: RegistryState, feature: FeatureDefinition): void;
9
10
  export declare function populateHooks(state: RegistryState, feature: FeatureDefinition): void;
10
11
  export declare function populateExtensionsAndSeeds(state: RegistryState, feature: FeatureDefinition): void;
@@ -1,6 +1,7 @@
1
1
  import { resolveTableName } from "../db/entity-table-meta.js";
2
2
  import { buildMetricName, validateMetricName } from "../observability/index.js";
3
3
  import { mergeHookList, mergeHookListQualified, qualify } from "./registry-state.js";
4
+ import { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
4
5
  // Feature registration + entities (globally-unique, physical-table-checked) + relations
5
6
  // (additive per entity, duplicate-per-name guarded).
6
7
  export function populateFeatureCore(state, feature) {
@@ -132,6 +133,14 @@ export function populateEvents(state, feature) {
132
133
  state.eventMap.set(qualified, { ...eventDef, name: qualified });
133
134
  }
134
135
  }
136
+ // Framework-owned events outside any feature namespace (kumiko:system:workflow.*).
137
+ // Seeded into the same eventMap as r.defineEvent so apply-key validation, the
138
+ // PII catalog and the upcaster chain see them through one path.
139
+ export function populateSystemEvents(state) {
140
+ for (const [name, eventDef] of WORKFLOW_SYSTEM_EVENT_DEFS) {
141
+ state.eventMap.set(name, eventDef);
142
+ }
143
+ }
135
144
  export function populateTranslations(state, feature) {
136
145
  // Translations prefixed with featureName: (i18next namespace convention).
137
146
  // Keys that already carry the feature's own namespace prefix (e.g. a nav
@@ -1,7 +1,7 @@
1
1
  import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler.js";
2
2
  import { dedupeFeatures } from "./dedupe-features.js";
3
3
  import { buildRegistryFacade } from "./registry-facade.js";
4
- import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateTranslations, } from "./registry-ingest.js";
4
+ import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateSystemEvents, populateTranslations, } from "./registry-ingest.js";
5
5
  import { createInitialState } from "./registry-state.js";
6
6
  import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
7
7
  // This is where the magic happens. By "magic" I mean: precomputed maps.
@@ -24,6 +24,7 @@ export function createRegistry(rawFeatures) {
24
24
  populateClaimsAndAuth(state, feature);
25
25
  populateScreensNavWorkspaces(state, feature);
26
26
  }
27
+ populateSystemEvents(state);
27
28
  finalizeWorkspaceNavMembership(state);
28
29
  populateHandlerEntityMappings(state, features);
29
30
  validateExtensionSelectors(state);
@@ -54,7 +54,6 @@ defineStep({
54
54
  workflowName: ctx.workflow.workflowName,
55
55
  error: describeWorkflowStepError(error),
56
56
  triggerEventType: ctx.event.type,
57
- triggerPayload: ctx.event.payload,
58
57
  ...(ctx.workflow.definitionFingerprint && {
59
58
  definitionFingerprint: ctx.workflow.definitionFingerprint,
60
59
  }),
@@ -49,7 +49,6 @@ defineStep({
49
49
  stepIndex: ctx.workflow.stepIndex,
50
50
  workflowName: ctx.workflow.workflowName,
51
51
  triggerEventType: ctx.event.type,
52
- triggerPayload: ctx.event.payload,
53
52
  ...(ctx.workflow.definitionFingerprint && {
54
53
  definitionFingerprint: ctx.workflow.definitionFingerprint,
55
54
  }),
@@ -32,11 +32,7 @@ defineStep({
32
32
  wakeAt,
33
33
  stepIndex: ctx.workflow.stepIndex,
34
34
  workflowName: ctx.workflow.workflowName,
35
- // Trigger snapshot: pinned so the resume-loop re-feeds the
36
- // pipeline with what the original run saw. event-sourcing across
37
- // suspensions hinges on this being stable.
38
35
  triggerEventType: ctx.event.type,
39
- triggerPayload: ctx.event.payload,
40
36
  ...(ctx.workflow.definitionFingerprint && {
41
37
  definitionFingerprint: ctx.workflow.definitionFingerprint,
42
38
  }),
@@ -0,0 +1,2 @@
1
+ import type { EventDef } from "@cosmicdrift/kumiko-types/handlers";
2
+ export declare const WORKFLOW_SYSTEM_EVENT_DEFS: ReadonlyMap<string, EventDef>;
@@ -0,0 +1,89 @@
1
+ // Registered-event declarations for the `kumiko:system:workflow.*` run-stream
2
+ // events. The names stay the literal system types stored in kumiko_events;
3
+ // registering them makes projections (r.extendEntityProjection / r.projection
4
+ // with source `workflow-run`) valid apply-key targets and lists them in the
5
+ // registry's event catalog.
6
+ //
7
+ // Appends of these types keep bypassing schema validation (append-event-core):
8
+ // the schemas here document the payloads the writers append and tolerate the
9
+ // legacy shapes still stored in old streams.
10
+ import * as z from "zod";
11
+ import { SYSTEM_EVENT_PII_STANCES } from "../../crypto/system-event-pii.js";
12
+ import { WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./_step-dispatch-constants.js";
13
+ const triggerEventRefSchema = z.object({
14
+ eventId: z.string(),
15
+ aggregateId: z.string(),
16
+ version: z.number().int(),
17
+ });
18
+ // Optional on every step event: runs started before the Q7 fingerprint existed
19
+ // have none.
20
+ const stepContext = {
21
+ workflowName: z.string(),
22
+ stepIndex: z.number().int(),
23
+ triggerEventType: z.string(),
24
+ definitionFingerprint: z.string().optional(),
25
+ };
26
+ const WORKFLOW_SYSTEM_EVENT_SCHEMAS = [
27
+ [
28
+ WORKFLOW_RUN_STARTED_TYPE,
29
+ z.object({
30
+ workflowName: z.string(),
31
+ triggerEventType: z.string(),
32
+ // Current streams reference the trigger event; streams written before
33
+ // #3474 carry a copy of its payload instead.
34
+ triggerEventRef: triggerEventRefSchema.optional(),
35
+ triggerPayload: z.unknown().optional(),
36
+ definitionFingerprint: z.string().optional(),
37
+ idempotencyKey: z.string().optional(),
38
+ }),
39
+ ],
40
+ [
41
+ WORKFLOW_RUN_COMPLETED_TYPE,
42
+ z.object({ workflowName: z.string(), stepIndex: z.number().int() }),
43
+ ],
44
+ [
45
+ WORKFLOW_RUN_FAILED_TYPE,
46
+ z.object({
47
+ workflowName: z.string(),
48
+ stepIndex: z.number().int(),
49
+ error: z.string(),
50
+ reason: z.string().optional(),
51
+ }),
52
+ ],
53
+ [WORKFLOW_WAITING_TYPE, z.object({ ...stepContext, wakeAt: z.string() })],
54
+ [
55
+ WORKFLOW_WAITING_FOR_EVENT_TYPE,
56
+ z.object({
57
+ ...stepContext,
58
+ eventType: z.string(),
59
+ match: z.record(z.string(), z.unknown()).optional(),
60
+ timeoutAt: z.string(),
61
+ }),
62
+ ],
63
+ [
64
+ WORKFLOW_RESUMED_TYPE,
65
+ z.object({ stepIndex: z.number().int(), retryAttempt: z.number().int().optional() }),
66
+ ],
67
+ [
68
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
69
+ z.object({
70
+ ...stepContext,
71
+ attempt: z.number().int(),
72
+ maxAttempts: z.number().int(),
73
+ wakeAt: z.string(),
74
+ error: z.string(),
75
+ }),
76
+ ],
77
+ ];
78
+ function buildWorkflowSystemEventDefs() {
79
+ const defs = new Map();
80
+ for (const [name, schema] of WORKFLOW_SYSTEM_EVENT_SCHEMAS) {
81
+ const piiFields = SYSTEM_EVENT_PII_STANCES.get(name);
82
+ if (piiFields === undefined) {
83
+ throw new Error(`Workflow system event "${name}" has no entry in SYSTEM_EVENT_PII_STANCES`);
84
+ }
85
+ defs.set(name, { name, schema, version: 1, piiFields });
86
+ }
87
+ return defs;
88
+ }
89
+ export const WORKFLOW_SYSTEM_EVENT_DEFS = buildWorkflowSystemEventDefs();
@@ -9,4 +9,6 @@ export type AppendDomainEventCoreDeps = {
9
9
  readonly callSiteLabel: string;
10
10
  readonly callerFeature?: string;
11
11
  };
12
- export declare function appendDomainEventCore(deps: AppendDomainEventCoreDeps, args: AppendEventArgs): Promise<StoredEvent>;
12
+ export declare function appendDomainEventCore(deps: AppendDomainEventCoreDeps, args: AppendEventArgs & {
13
+ readonly expectedVersion?: number;
14
+ }): Promise<StoredEvent>;
@@ -33,10 +33,12 @@ export async function appendDomainEventCore(deps, args) {
33
33
  });
34
34
  }
35
35
  }
36
- // System events skip schema validation — payload shape is owned by the
37
- // framework step that emits + the bundled MSP that consumes.
36
+ // System events skip schema validation even when registered (workflow
37
+ // events are, so projections can apply them): payload shape is owned by the
38
+ // framework step that emits + the bundled MSP that consumes, and stored
39
+ // streams keep legacy shapes. Parsing would also strip unknown keys.
38
40
  let validatedPayload;
39
- if (eventDef) {
41
+ if (eventDef && !isSystemEvent) {
40
42
  const parsed = eventDef.schema.safeParse(args.payload ?? {});
41
43
  if (!parsed.success)
42
44
  throw validationErrorFromZod(parsed.error);
@@ -53,7 +55,9 @@ export async function appendDomainEventCore(deps, args) {
53
55
  }
54
56
  // Stream-version authoritative. See Block 0 / getStreamVersion doc for
55
57
  // why row.version isn't sufficient once ctx.appendEvent enters the picture.
56
- const expectedVersion = await getStreamVersion(deps.db, args.aggregateId, deps.tenantId);
58
+ // A caller-pinned version (fetchForWriting handles) must reach append() so a
59
+ // write landing between fetch and append conflicts instead of being re-read.
60
+ const expectedVersion = args.expectedVersion ?? (await getStreamVersion(deps.db, args.aggregateId, deps.tenantId));
57
61
  const reqCtx = requestContext.get();
58
62
  // metadata.requestId is a plain trace marker — no uniqueness constraint,
59
63
  // every event of the request carries it. HTTP-level idempotency runs in
@@ -399,8 +399,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
399
399
  currentVersion: fetchedVersion,
400
400
  });
401
401
  }
402
- // Handle's internal version bumps on every appendOne so multiple
403
- // appends in a row stay in order without re-reading the DB.
402
+ // Pinned into every append: a write landing between fetch and append
403
+ // fails with VersionConflictError instead of stacking on a state the
404
+ // caller's business rules never saw. Bumps per appendOne so consecutive
405
+ // appends stay in order without re-reading the DB.
404
406
  let handleVersion = fetchedVersion;
405
407
  const appendOne = async (appendArgs) => {
406
408
  await appendDomainEvent(ctx, {
@@ -408,6 +410,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
408
410
  aggregateType: args.aggregateType,
409
411
  type: appendArgs.type,
410
412
  payload: appendArgs.payload,
413
+ expectedVersion: handleVersion,
411
414
  }, user, tx, registry.getHandlerFeature(type));
412
415
  handleVersion += 1;
413
416
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.338.0",
3
+ "version": "0.339.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.338.0",
210
- "@cosmicdrift/kumiko-types": "0.338.0",
209
+ "@cosmicdrift/kumiko-http": "0.339.0",
210
+ "@cosmicdrift/kumiko-types": "0.339.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.338.0",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.339.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,45 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "Write handlers can declare tenantlessAnonymous",
6
+ "detail": "`r.writeHandler({ ..., tenantlessAnonymous: true })` lets an anonymous `POST /api/write` for exactly that handler run under `SYSTEM_TENANT_ID` where the anonymous middleware would otherwise answer `400 tenant_required` (resolver silent, no client tenant). It never applies to `/api/batch`, to a client-supplied `X-Tenant` or `kumiko_tenant`, to `tenant_mismatch` or when a tenant resolved; `tenantExists` and the lifecycle gate are skipped for the system tenant. Boot rejects the flag unless `access.roles` is exactly `[\"anonymous\"]` and a real `rateLimit` (not `{ disabled: true }`) is declared. `authMiddleware` gets the option `isTenantlessAnonymousWrite`, which `buildServer` builds from the registry.",
7
+ "migration": "keine"
8
+ },
9
+ {
10
+ "version": "0.339.0",
11
+ "type": "breaking",
12
+ "title": "fetchForWriting handles reject appends after a concurrent write",
13
+ "migration": "Appends through a ctx.fetchForWriting handle now use the version the handle was read at. When two requests write the same aggregate concurrently, the later append no longer stacks on top silently: the request fails with 409 version_conflict (no automatic retry), and its transaction rolls back. Tests that fire concurrent writes on one aggregate and expected both to succeed must expect exactly one success. Clients should treat 409 version_conflict like a stale form: reload and retry."
14
+ },
15
+ {
16
+ "version": "0.339.0",
17
+ "type": "improvement",
18
+ "title": "PII encryption for jsonb fields",
19
+ "detail": "`createJsonbField({ personal: \"tenant\" })` (or `\"self\"` / `{ of }`) is encrypted per subject like a text field, so crypto-shredding applies: after the key is erased, list and detail return `[[erased]]` for the field. The ciphertext uses the self-describing format `kumiko-pii:v3:` (v2 plus a JSON-typed plaintext, AAD-bound to subject and field), so a string, number or object round-trips by type. Existing plaintext objects, arrays and strings are read as-is and are not re-encrypted; they become ciphertext on the next write of the field, and the backfill skips jsonb fields. There is no search, filter, sort or index inside an encrypted jsonb field: boot rejects `personal` on a jsonb field that is in an entity index, has `sortable`, `filterable`, `searchable` or `lookupable`, or is the custom-fields `customFields` column. Event payload fields declared in `piiFields` may now be objects or arrays; they are encrypted in the same JSON format. Non-jsonb fields still require a string.",
20
+ "migration": "Runtimes before this release cannot read `kumiko-pii:v3:` ciphertext. Before annotating a jsonb field with `personal`, roll this version out to every instance, so a rolling deploy never has an old instance reading a value a new one wrote."
21
+ },
22
+ {
23
+ "version": "0.339.0",
24
+ "type": "fix",
25
+ "title": "Uncaught route errors and client aborts go through one error handler",
26
+ "detail": "`buildServer` calls `app.onError(handleUncaughtRouteError)`, which reuses the `/api/query` helpers: a rethrown abort of the request signal maps to 499, everything else is classified with `toKumikoError`, logged with `logServerFault` and serialized with `serializeError`. Sub-apps mounted via `app.route()` inherit it.",
27
+ "migration": "keine"
28
+ },
29
+ {
30
+ "version": "0.339.0",
31
+ "type": "breaking",
32
+ "title": "ctx.db.unsafeRaw() takes no reason; the audit uses the declared escapeHatch reason",
33
+ "detail": "`TenantDb.unsafeRaw` is now `unsafeRaw(): DbRunner`. The `unsafe-raw` audit entry carries the reason from the `escapeHatch` declaration, as before. Calls without a matching declaration are still rejected with an `AccessDeniedError`.",
34
+ "migration": "Replace `ctx.db.unsafeRaw(\"...\")` with `ctx.db.unsafeRaw()`. The reason lives only in `escapeHatch: { reason }` on the handler, hook or job; TypeScript flags old calls. `ctx.systemDb.unsafeRaw(reason)` is unchanged."
35
+ },
36
+ {
37
+ "version": "0.339.0",
38
+ "type": "improvement",
39
+ "title": "Workflow run-stream events are registered, so projections can apply them",
40
+ "detail": "`WORKFLOW_SYSTEM_EVENT_DEFS` declares the seven `kumiko:system:workflow.*` events with a payload schema, version 1 and the stance from `SYSTEM_EVENT_PII_STANCES`. `createRegistry` seeds them into the event map next to `r.defineEvent` events, so apply-key validation, the PII catalog and the upcaster chain see them. A typo such as `kumiko:system:workflow.run-faild` still fails boot. `appendDomainEventCore` keeps skipping schema validation for `kumiko:system:*` types. The feature manifest gains an optional top-level `systemEvents` list.",
41
+ "migration": "A projection whose row ids are not the workflow-run aggregate id (for example uuidv5-derived ids) still cannot pass the ghost-row guard and needs its own change: its row id must equal the workflow-run aggregate id (this applies to kumiko-enterprise's run-state projection). Hand-built `FeatureManifest` objects stay valid because `systemEvents` is optional; regenerate committed manifests with `bun run gen:manifest` in `samples/apps/use-all-bundled` to pick the list up."
42
+ },
2
43
  {
3
44
  "version": "0.338.0",
4
45
  "type": "improvement",