@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.
- package/dist/api/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +54 -10
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.js +29 -6
- package/dist/api/server.js +9 -1
- package/dist/bun-db/query.js +8 -2
- package/dist/crypto/event-pii.js +13 -4
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
- package/dist/crypto/pii-ciphertext-format.js +11 -0
- package/dist/crypto/pii-field-encryption.d.ts +4 -2
- package/dist/crypto/pii-field-encryption.js +50 -21
- package/dist/crypto/system-event-pii.js +2 -2
- package/dist/db/queries/backfill-pii.js +4 -0
- package/dist/db/tenant-db.js +16 -12
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +19 -0
- package/dist/engine/boot-validator/pii-retention.js +30 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/extensions/user-data.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/feature-manifest.d.ts +7 -0
- package/dist/engine/feature-manifest.js +8 -0
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +9 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/steps/retry.js +0 -1
- package/dist/engine/steps/wait-for-event.js +0 -1
- package/dist/engine/steps/wait.js +0 -4
- package/dist/engine/steps/workflow-system-events.d.ts +2 -0
- package/dist/engine/steps/workflow-system-events.js +89 -0
- package/dist/pipeline/append-event-core.d.ts +3 -1
- package/dist/pipeline/append-event-core.js +8 -4
- package/dist/pipeline/dispatch-shared.js +5 -2
- package/package.json +4 -4
- 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,
|
|
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
|
|
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 =
|
|
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
|
package/dist/api/routes.d.ts
CHANGED
|
@@ -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>;
|
package/dist/api/routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/api/server.js
CHANGED
|
@@ -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 } : {}),
|
package/dist/bun-db/query.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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)
|
package/dist/crypto/event-pii.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/crypto/index.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
return (
|
|
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
|
|
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
|
-
|
|
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
|
|
39
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
13
|
-
//
|
|
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
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
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;
|
|
444
|
-
report("unsafe-raw",
|
|
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: (
|
|
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(
|
|
33
|
+
* then `ctx.db.unsafeRaw()`.
|
|
34
34
|
*/
|
|
35
35
|
/**
|
|
36
36
|
* Minimal storage surface a file-aware forget hook needs to erase binaries.
|
|
@@ -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
|
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/engine/index.js
CHANGED
|
@@ -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
|
package/dist/engine/registry.js
CHANGED
|
@@ -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,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
|
|
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
|
|
37
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
403
|
-
//
|
|
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.
|
|
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.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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",
|