@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +39 -6
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +7 -1
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +36 -4
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +78 -42
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +10 -2
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +24 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +37 -5
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +21 -1
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +53 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +136 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +70 -40
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +44 -4
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +13 -3
- package/dist/engine/index.d.ts +9 -3
- package/dist/engine/index.js +7 -2
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +11 -3
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +49 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +13 -7
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/files/types.js +21 -1
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +228 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
package/dist/api/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type { PostAuthLandingArgs, PostAuthLandingFlow, PostAuthLandingResolver,
|
|
|
19
19
|
export { isSafeLandingPath } from "./post-auth-landing.js";
|
|
20
20
|
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker.js";
|
|
21
21
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
|
|
22
|
-
export { type RequestContextData, requestContext,
|
|
22
|
+
export { type RequestContextData, readonlyRequestContext as requestContext, } from "./request-context.js";
|
|
23
23
|
export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
|
|
24
24
|
export type { MetricsRouteOptions } from "./route-registrars.js";
|
|
25
25
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
package/dist/api/index.js
CHANGED
|
@@ -10,7 +10,7 @@ export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt.js";
|
|
|
10
10
|
export { patAllows, qnMatches } from "./pat-scope.js";
|
|
11
11
|
export { isSafeLandingPath } from "./post-auth-landing.js";
|
|
12
12
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
|
|
13
|
-
export { requestContext,
|
|
13
|
+
export { readonlyRequestContext as requestContext, } from "./request-context.js";
|
|
14
14
|
export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
|
|
15
15
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
16
16
|
export { createApiRoutes } from "./routes.js";
|
|
@@ -13,6 +13,9 @@ const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
|
|
|
13
13
|
// red; prod redacts + logs instead of shipping the blob. Scans unconditionally:
|
|
14
14
|
// legacy ciphertext rows can outlive a subject KMS that later became
|
|
15
15
|
// unconfigured, and the marker check itself needs no KMS access to run.
|
|
16
|
+
// Buffers every JSON body (no streaming, roughly double peak memory for large
|
|
17
|
+
// exports) on purpose: a size threshold or opt-out would be a leak bypass for
|
|
18
|
+
// exactly the large responses most likely to carry raw rows.
|
|
16
19
|
export function piiCiphertextResponseGuard() {
|
|
17
20
|
return async (c, next) => {
|
|
18
21
|
await next();
|
|
@@ -3,6 +3,7 @@ import { type SseBroker } from "./sse-broker.js";
|
|
|
3
3
|
export type RedisSseBrokerOptions = {
|
|
4
4
|
readonly redisUrl: string;
|
|
5
5
|
readonly clientOptions?: RedisClientOptions;
|
|
6
|
+
readonly channelPrefix?: string;
|
|
6
7
|
};
|
|
7
8
|
export type RedisSseBroker = SseBroker & {
|
|
8
9
|
readonly kind: "redis";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { redisClientOptionsFromEnv } from "../redis/client.js";
|
|
1
|
+
import { redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "../redis/client.js";
|
|
2
2
|
import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
|
|
3
3
|
import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
|
|
4
4
|
// Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
|
|
@@ -8,7 +8,7 @@ import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
|
|
|
8
8
|
// cursor stays the mechanism for "read every event exactly once".
|
|
9
9
|
const CHANNEL_PREFIX = "kumiko:sse:ch:";
|
|
10
10
|
const INVALIDATION_PREFIX = "kumiko:sse:inval:";
|
|
11
|
-
const
|
|
11
|
+
const PSUBSCRIBE_SUFFIX = "kumiko:sse:*";
|
|
12
12
|
// Narrows on the `kind` discriminant, not on `close`: an app-owned SseBroker
|
|
13
13
|
// may have its own close() and must not be mistaken for the Redis one.
|
|
14
14
|
export function isRedisSseBroker(broker) {
|
|
@@ -26,7 +26,11 @@ export function isRedisSseBroker(broker) {
|
|
|
26
26
|
export function createDefaultSseBroker(env = process.env) {
|
|
27
27
|
const redisUrl = env["REDIS_URL"];
|
|
28
28
|
const ownedRedisSseBroker = redisUrl
|
|
29
|
-
? createRedisSseBroker({
|
|
29
|
+
? createRedisSseBroker({
|
|
30
|
+
redisUrl,
|
|
31
|
+
clientOptions: redisClientOptionsFromEnv(env),
|
|
32
|
+
channelPrefix: redisChannelPrefixFromEnv(env),
|
|
33
|
+
})
|
|
30
34
|
: undefined;
|
|
31
35
|
return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
|
|
32
36
|
}
|
|
@@ -92,26 +96,28 @@ function encodeInvalidationScope(scope) {
|
|
|
92
96
|
// the request.
|
|
93
97
|
export function createRedisSseBroker(opts) {
|
|
94
98
|
const inner = createSseBroker();
|
|
99
|
+
const channelPrefix = `${opts.channelPrefix ?? ""}${CHANNEL_PREFIX}`;
|
|
100
|
+
const invalidationPrefix = `${opts.channelPrefix ?? ""}${INVALIDATION_PREFIX}`;
|
|
95
101
|
const signal = createRedisPubSubSignal({
|
|
96
102
|
redisUrl: opts.redisUrl,
|
|
97
|
-
channelPattern:
|
|
103
|
+
channelPattern: `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
|
|
98
104
|
label: "sse-broker",
|
|
99
105
|
...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
|
|
100
106
|
});
|
|
101
107
|
signal.onMessage((channel, payload) => {
|
|
102
|
-
if (channel.startsWith(
|
|
108
|
+
if (channel.startsWith(channelPrefix)) {
|
|
103
109
|
if (!isSseEvent(payload)) {
|
|
104
110
|
console.error(`[kumiko:sse-broker] dropping malformed event on "${channel}"`);
|
|
105
111
|
// skip: malformed payload already logged above, nothing to deliver
|
|
106
112
|
return;
|
|
107
113
|
}
|
|
108
|
-
inner.pushToChannel(channel.slice(
|
|
114
|
+
inner.pushToChannel(channel.slice(channelPrefix.length), payload);
|
|
109
115
|
// skip: channel already routed to the SSE-event branch above, the
|
|
110
116
|
// invalidation branch below is mutually exclusive with this one
|
|
111
117
|
return;
|
|
112
118
|
}
|
|
113
|
-
if (channel.startsWith(
|
|
114
|
-
inner.publishAccessInvalidation(channel.slice(
|
|
119
|
+
if (channel.startsWith(invalidationPrefix)) {
|
|
120
|
+
inner.publishAccessInvalidation(channel.slice(invalidationPrefix.length), parseInvalidationScope(payload));
|
|
115
121
|
}
|
|
116
122
|
});
|
|
117
123
|
return {
|
|
@@ -122,14 +128,14 @@ export function createRedisSseBroker(opts) {
|
|
|
122
128
|
getTotalClientCount: inner.getTotalClientCount,
|
|
123
129
|
subscribeAccessInvalidation: inner.subscribeAccessInvalidation,
|
|
124
130
|
pushToChannel(channel, event) {
|
|
125
|
-
signal.
|
|
131
|
+
return signal.publishConfirmed(`${channelPrefix}${channel}`, event);
|
|
126
132
|
},
|
|
127
133
|
// fw#1601: this is the security-critical call — a revoked session's
|
|
128
134
|
// stream must close on every replica, not just the one that observed
|
|
129
135
|
// the revocation event. Publishing (rather than calling inner directly,
|
|
130
136
|
// like the in-memory broker does) is what makes that true here.
|
|
131
137
|
publishAccessInvalidation(userId, scope) {
|
|
132
|
-
signal.
|
|
138
|
+
return signal.publishConfirmed(`${invalidationPrefix}${userId}`, encodeInvalidationScope(scope));
|
|
133
139
|
},
|
|
134
140
|
close: signal.close,
|
|
135
141
|
};
|
|
@@ -25,6 +25,7 @@ export declare const requestContext: {
|
|
|
25
25
|
get(): RequestContextData | undefined;
|
|
26
26
|
generateId(): string;
|
|
27
27
|
};
|
|
28
|
+
export declare const readonlyRequestContext: Pick<typeof requestContext, "get">;
|
|
28
29
|
export declare function runWithOrigin<T>(origin: {
|
|
29
30
|
readonly feature?: string;
|
|
30
31
|
readonly handler?: string;
|
|
@@ -13,6 +13,11 @@ export const requestContext = {
|
|
|
13
13
|
return generateId();
|
|
14
14
|
},
|
|
15
15
|
};
|
|
16
|
+
// Public view: `run` would let a caller replace the request scope (entryHandler,
|
|
17
|
+
// writeOrigin), so only the read side is exposed through the /api barrel.
|
|
18
|
+
export const readonlyRequestContext = {
|
|
19
|
+
get: requestContext.get,
|
|
20
|
+
};
|
|
16
21
|
// Enter a scope that attributes every event written inside it. Keeps the
|
|
17
22
|
// surrounding request's ids so correlation survives, and mints fresh ones
|
|
18
23
|
// when there is no surrounding request (job-runner, event-dispatcher) —
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
1
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
2
|
import type { DbConnection } from "../db/connection.js";
|
|
3
3
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
4
4
|
import type { Meter, PrometheusMeter } from "../observability/index.js";
|
|
5
5
|
import type { EventConsumer } from "../pipeline/event-dispatcher.js";
|
|
6
6
|
export declare const DEFAULT_MAX_REQUEST_BYTES = 1048576;
|
|
7
7
|
export declare function registerBodyLimit(app: Hono, maxBytes: number): void;
|
|
8
|
+
export declare function bodyLimitForRoutePath(path: string, maxBytes: number): readonly MiddlewareHandler[];
|
|
8
9
|
export type MetricsRouteOptions = {
|
|
9
10
|
readonly token?: string;
|
|
10
11
|
readonly path?: string;
|
|
@@ -31,6 +31,14 @@ export function registerBodyLimit(app, maxBytes) {
|
|
|
31
31
|
return limit(c, next);
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
// registerBodyLimit only covers /api/*; signature routes outside it (webhook
|
|
35
|
+
// receivers, OAuth callbacks) read the raw body before verify() runs and so
|
|
36
|
+
// need the same cap on their own handler chain.
|
|
37
|
+
export function bodyLimitForRoutePath(path, maxBytes) {
|
|
38
|
+
if (maxBytes <= 0 || path === "/api" || path.startsWith("/api/"))
|
|
39
|
+
return [];
|
|
40
|
+
return [bodyLimit({ maxSize: maxBytes })];
|
|
41
|
+
}
|
|
34
42
|
// Timing-safe string compare — equal-length strings compare every byte
|
|
35
43
|
// regardless of where they diverge; different lengths return false without
|
|
36
44
|
// iterating. Prevents side-channel leaks on the Bearer-token check.
|
package/dist/api/routes.js
CHANGED
|
@@ -5,7 +5,7 @@ import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError,
|
|
|
5
5
|
import { createFallbackLogger } from "../logging/index.js";
|
|
6
6
|
import { stringifyJson } from "../utils/safe-json.js";
|
|
7
7
|
import { Routes } from "./api-constants.js";
|
|
8
|
-
import { getUser } from "./auth-middleware.js";
|
|
8
|
+
import { getAuthTokenExpiry, getUser } from "./auth-middleware.js";
|
|
9
9
|
import { patAllows } from "./pat-scope.js";
|
|
10
10
|
import { requestContext } from "./request-context.js";
|
|
11
11
|
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
|
|
@@ -133,7 +133,9 @@ export function createApiRoutes(dispatcher, options = {}) {
|
|
|
133
133
|
try {
|
|
134
134
|
assertPayloadDepthAllowed(body.payload);
|
|
135
135
|
assertPatAllowed(user, body.type);
|
|
136
|
-
generator = dispatcher.stream(body.type, body.payload, user
|
|
136
|
+
generator = dispatcher.stream(body.type, body.payload, user, {
|
|
137
|
+
tokenExpiresAtSec: getAuthTokenExpiry(c),
|
|
138
|
+
});
|
|
137
139
|
}
|
|
138
140
|
catch (e) {
|
|
139
141
|
return queryErrorResponse(c, toKumiko(e), body.type);
|
package/dist/api/server.js
CHANGED
|
@@ -40,7 +40,7 @@ import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
|
|
|
40
40
|
import { createDefaultSseBroker } from "./redis-sse-broker.js";
|
|
41
41
|
import { requestContext } from "./request-context.js";
|
|
42
42
|
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
|
|
43
|
-
import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
|
|
43
|
+
import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
|
|
44
44
|
import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
|
|
45
45
|
import { createSseRoute } from "./sse-route.js";
|
|
46
46
|
import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
|
|
@@ -130,6 +130,9 @@ export function buildServer(options) {
|
|
|
130
130
|
const defaults = createDefaultSseBroker();
|
|
131
131
|
sseBroker = defaults.sseBroker;
|
|
132
132
|
ownedRedisSseBroker = defaults.ownedRedisSseBroker;
|
|
133
|
+
if (!ownedRedisSseBroker) {
|
|
134
|
+
console.warn("[kumiko:boot] REDIS_URL is not set: cross-replica fanout disabled. SSE, access-invalidation and toggle-sync reach only the cursor-owning process, so with more than one replica clients on the other replicas miss events and revoked sessions stay open there.");
|
|
135
|
+
}
|
|
133
136
|
}
|
|
134
137
|
// Same decision as the SSE broker: an explicit dispatcherOptions value
|
|
135
138
|
// (incl. null = opt out) wins, otherwise REDIS_URL turns on cross-replica
|
|
@@ -215,6 +218,21 @@ export function buildServer(options) {
|
|
|
215
218
|
: (options.jwtSecret.keys[options.jwtSecret.signKid] ?? ""));
|
|
216
219
|
const rateLimitResolver = options.context.rateLimit ??
|
|
217
220
|
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
221
|
+
if (!rateLimitResolver) {
|
|
222
|
+
// The systemScope default limit only exists once a resolver does, and an app that
|
|
223
|
+
// declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
|
|
224
|
+
// false) — so its cross-tenant handlers are silently unlimited.
|
|
225
|
+
const unlimitedSystemScopeFeatures = [...options.registry.features.values()]
|
|
226
|
+
.filter((feature) => feature.systemScope &&
|
|
227
|
+
Object.keys(feature.writeHandlers).length + Object.keys(feature.queryHandlers).length > 0)
|
|
228
|
+
.map((feature) => feature.name);
|
|
229
|
+
if (unlimitedSystemScopeFeatures.length > 0) {
|
|
230
|
+
console.warn(`[kumiko:boot] r.systemScope() features have handlers but no RateLimitResolver is configured, ` +
|
|
231
|
+
`so their default rate limit is not enforced: ${unlimitedSystemScopeFeatures.join(", ")}. ` +
|
|
232
|
+
"Wire a Redis client on context.redis and declare rateLimit on at least one handler (or pass " +
|
|
233
|
+
"context.rateLimit) to enable the resolver.");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
218
236
|
const contextWithObservability = {
|
|
219
237
|
...contextWithFiles,
|
|
220
238
|
...(wrappedRedis ? { redis: wrappedRedis } : {}),
|
|
@@ -342,7 +360,7 @@ export function buildServer(options) {
|
|
|
342
360
|
// cross-feature ctx.appendEvent calls at emit-site.
|
|
343
361
|
const mspOwner = msp.name.split(":")[0];
|
|
344
362
|
const mspFiles = fileProviderResolver
|
|
345
|
-
? createFileContext(() => fileProviderResolver(event.tenantId))
|
|
363
|
+
? createFileContext(() => fileProviderResolver(event.tenantId), event.tenantId)
|
|
346
364
|
: undefined;
|
|
347
365
|
const applyCtx = createMultiStreamApplyContext({
|
|
348
366
|
registry: options.registry,
|
|
@@ -438,17 +456,19 @@ export function buildServer(options) {
|
|
|
438
456
|
// resolver is the auto-wired one (or `context.rateLimit` if set);
|
|
439
457
|
// boot-fails loudly when the caller asked for middleware without a
|
|
440
458
|
// working Redis to back it.
|
|
459
|
+
let globalRateLimitGuard;
|
|
441
460
|
if (wantsL1L2) {
|
|
442
461
|
if (!rateLimitResolver) {
|
|
443
462
|
throw new Error("rateLimit middleware requested but no resolver available — pass `context.redis` " +
|
|
444
463
|
"or `context.rateLimit` so the resolver can be built.");
|
|
445
464
|
}
|
|
446
465
|
if (options.rateLimit?.global) {
|
|
447
|
-
|
|
466
|
+
globalRateLimitGuard = globalIpRateLimit({
|
|
448
467
|
...options.rateLimit.global,
|
|
449
468
|
resolver: rateLimitResolver,
|
|
450
469
|
clientIpResolver,
|
|
451
|
-
})
|
|
470
|
+
});
|
|
471
|
+
app.use("/api/*", globalRateLimitGuard);
|
|
452
472
|
}
|
|
453
473
|
if (options.rateLimit?.auth) {
|
|
454
474
|
const { path: l2Path = "/api/auth/*", ...l2Opts } = options.rateLimit.auth;
|
|
@@ -564,7 +584,12 @@ export function buildServer(options) {
|
|
|
564
584
|
return csrfGuard(c, next);
|
|
565
585
|
});
|
|
566
586
|
// No PAT rate-limit here: patRouteGuardMiddleware rejects every PAT on httpRoutes before it could count.
|
|
587
|
+
// httpRoutes sit outside /api/*, so the pre-auth /api/* middlewares do not run for them: the
|
|
588
|
+
// global IP limit (an unauthenticated flood must not reach JWT verify / session checks) and the
|
|
589
|
+
// PII ciphertext tripwire are re-attached here. requestId, body limit and observability are not.
|
|
567
590
|
const sessionOnlyHttpRouteGuards = [
|
|
591
|
+
...(globalRateLimitGuard ? [globalRateLimitGuard] : []),
|
|
592
|
+
piiCiphertextResponseGuard(),
|
|
568
593
|
sessionOnlyGuard,
|
|
569
594
|
patRouteGuardMiddleware,
|
|
570
595
|
...(originGuard ? [originGuard] : []),
|
|
@@ -782,7 +807,9 @@ export function buildServer(options) {
|
|
|
782
807
|
dispatchSystemQuery,
|
|
783
808
|
clientIpResolver,
|
|
784
809
|
});
|
|
785
|
-
mountHonoRoute(app, route.method, route.path, honoHandler
|
|
810
|
+
mountHonoRoute(app, route.method, route.path, honoHandler, route.entry === ExtraRouteEntries.signature
|
|
811
|
+
? bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES)
|
|
812
|
+
: []);
|
|
786
813
|
}
|
|
787
814
|
}
|
|
788
815
|
// /version-Default registriert NACH feature-routes — Hono "first match
|
|
@@ -927,6 +954,7 @@ function unauthenticatedResponse(c) {
|
|
|
927
954
|
}, 401);
|
|
928
955
|
}
|
|
929
956
|
const webSocketLog = createFallbackLogger("websocket");
|
|
957
|
+
const extraRouteLog = createFallbackLogger("extra-route");
|
|
930
958
|
function webSocketErrorResponse(c, status, code, message) {
|
|
931
959
|
return c.json({ error: { code, httpStatus: status, message } }, status);
|
|
932
960
|
}
|
|
@@ -1069,10 +1097,15 @@ function buildExtraRouteHonoHandler(route, shared) {
|
|
|
1069
1097
|
}
|
|
1070
1098
|
return c.json(e.body, e.status);
|
|
1071
1099
|
}
|
|
1100
|
+
// The body goes to unauthenticated callers; infra errors thrown by
|
|
1101
|
+
// verify() (secrets, DB) must not leak, so the detail stays in the log.
|
|
1102
|
+
extraRouteLog.error(`signature verify() threw on ${c.req.path}`, {
|
|
1103
|
+
error: e instanceof Error ? e.message : String(e),
|
|
1104
|
+
});
|
|
1072
1105
|
return c.json({
|
|
1073
1106
|
error: {
|
|
1074
1107
|
code: "extra_route_signature_invalid",
|
|
1075
|
-
message:
|
|
1108
|
+
message: "signature verification failed",
|
|
1076
1109
|
},
|
|
1077
1110
|
}, 401);
|
|
1078
1111
|
}
|
package/dist/api/sse-broker.d.ts
CHANGED
|
@@ -29,11 +29,15 @@ export declare function accessInvalidationCredentialFor(user: Pick<SessionUser,
|
|
|
29
29
|
export type SseBroker = {
|
|
30
30
|
addClient(channel: string, send: (event: SseEvent) => void, close: () => void): string;
|
|
31
31
|
removeClient(channel: string, clientId: string): void;
|
|
32
|
-
pushToChannel(channel: string, event: SseEvent): void
|
|
32
|
+
pushToChannel(channel: string, event: SseEvent): void | Promise<void>;
|
|
33
33
|
getClientCount(channel: string): number;
|
|
34
34
|
getTotalClientCount(): number;
|
|
35
35
|
subscribeAccessInvalidation(userId: string, onInvalidate: () => void, credential?: AccessInvalidationCredential): () => void;
|
|
36
|
-
publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void
|
|
36
|
+
publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void | Promise<void>;
|
|
37
37
|
};
|
|
38
38
|
export declare function shouldInvalidateListener(credential: AccessInvalidationCredential, scope: AccessInvalidationScope): boolean;
|
|
39
|
-
export
|
|
39
|
+
export type LocalSseBroker = Omit<SseBroker, "publishAccessInvalidation" | "pushToChannel"> & {
|
|
40
|
+
pushToChannel(channel: string, event: SseEvent): void;
|
|
41
|
+
publishAccessInvalidation(userId: string, scope?: AccessInvalidationScope): void;
|
|
42
|
+
};
|
|
43
|
+
export declare function createSseBroker(): LocalSseBroker;
|
package/dist/api/sse-route.js
CHANGED
|
@@ -3,8 +3,9 @@ import { streamSSE } from "hono/streaming";
|
|
|
3
3
|
import { tenantChannel } from "../engine/constants.js";
|
|
4
4
|
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
5
5
|
import { Routes } from "./api-constants.js";
|
|
6
|
-
import { getUser } from "./auth-middleware.js";
|
|
6
|
+
import { getAuthTokenExpiry, getUser } from "./auth-middleware.js";
|
|
7
7
|
import { accessInvalidationCredentialFor } from "./sse-broker.js";
|
|
8
|
+
import { scheduleTokenExpiry } from "./token-expiry-timer.js";
|
|
8
9
|
/**
|
|
9
10
|
* Heartbeat-Cadence für SSE-Streams.
|
|
10
11
|
*
|
|
@@ -88,6 +89,9 @@ export function createSseRoute(broker, options) {
|
|
|
88
89
|
// Channel is server-derived from authenticated user — never trust client input.
|
|
89
90
|
// Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
|
|
90
91
|
const channel = tenantChannel(user.tenantId);
|
|
92
|
+
// JWT expiry raises no access-invalidation event, so the stream must end
|
|
93
|
+
// itself when the token it was opened with expires.
|
|
94
|
+
const tokenExpiresAtSec = getAuthTokenExpiry(c);
|
|
91
95
|
return streamSSE(c, async (stream) => {
|
|
92
96
|
let resolveEnded;
|
|
93
97
|
const ended = new Promise((resolve) => {
|
|
@@ -105,6 +109,7 @@ export function createSseRoute(broker, options) {
|
|
|
105
109
|
stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
|
|
106
110
|
}, closeStream);
|
|
107
111
|
const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
|
|
112
|
+
const expiryTimer = scheduleTokenExpiry(tokenExpiresAtSec, closeStream);
|
|
108
113
|
let released = false;
|
|
109
114
|
const release = () => {
|
|
110
115
|
// skip: onAbort and the finally block both release, the second call is a no-op
|
|
@@ -113,6 +118,7 @@ export function createSseRoute(broker, options) {
|
|
|
113
118
|
released = true;
|
|
114
119
|
// A client disconnect must also wake the heartbeat sleep, not only a server close.
|
|
115
120
|
resolveEnded();
|
|
121
|
+
clearTimeout(expiryTimer);
|
|
116
122
|
broker.removeClient(channel, clientId);
|
|
117
123
|
unsubscribeAccessInvalidation();
|
|
118
124
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function scheduleTokenExpiry(tokenExpiresAtSec: number | undefined, onExpired: () => void): ReturnType<typeof setTimeout> | undefined;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// setTimeout overflows (fires immediately) above 2^31-1 ms; a token that
|
|
2
|
+
// outlives it just gets its stream recycled early, the client reconnects.
|
|
3
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
4
|
+
// JWT expiry raises no access-invalidation event, so a long-lived stream must end itself
|
|
5
|
+
// when the token it was opened with expires. Callers clear the returned timer on release.
|
|
6
|
+
export function scheduleTokenExpiry(tokenExpiresAtSec, onExpired) {
|
|
7
|
+
if (tokenExpiresAtSec === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
return setTimeout(onExpired, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, tokenExpiresAtSec * 1000 - Date.now())));
|
|
10
|
+
}
|
package/dist/bun-db/query.js
CHANGED
|
@@ -309,7 +309,20 @@ function isTemporalPlainDateTimeLike(v) {
|
|
|
309
309
|
// back deterministically without ever touching a local Date getter — the
|
|
310
310
|
// same +value idiom instantFromDriver uses (guard-no-date-api: no
|
|
311
311
|
// `.getTime()`), just anchored to a fixed zone instead of process-local.
|
|
312
|
-
|
|
312
|
+
// A real `date` column always arrives as a UTC-midnight Date. Anything else means the column is
|
|
313
|
+
// still timestamptz (declared `date` before its ALTER ran); the UTC anchor then silently shifts
|
|
314
|
+
// the day, so say so once per column instead of returning the wrong day quietly.
|
|
315
|
+
const warnedNonMidnightDateColumns = new Set();
|
|
316
|
+
const MS_PER_DAY = 86_400_000;
|
|
317
|
+
const dateColumnDriftLog = createFallbackLogger("bun-db");
|
|
318
|
+
function warnIfNotDateColumn(epochMs, column) {
|
|
319
|
+
// skip: midnight value is a correct date column, and an already-warned column is deduped
|
|
320
|
+
if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
|
|
321
|
+
return;
|
|
322
|
+
warnedNonMidnightDateColumns.add(column);
|
|
323
|
+
dateColumnDriftLog.warn(`column "${column}" is declared as date but returned a non-midnight timestamp; it is probably still timestamptz. Run the ALTER ... TYPE date migration, otherwise days can shift.`);
|
|
324
|
+
}
|
|
325
|
+
function plainDateFromDriver(value, column) {
|
|
313
326
|
if (value === null || value === undefined)
|
|
314
327
|
return null;
|
|
315
328
|
if (isTemporalPlainDate(value))
|
|
@@ -326,6 +339,7 @@ function plainDateFromDriver(value) {
|
|
|
326
339
|
}
|
|
327
340
|
}
|
|
328
341
|
if (value instanceof Date) {
|
|
342
|
+
warnIfNotDateColumn(+value, column);
|
|
329
343
|
return Temporal.Instant.fromEpochMilliseconds(+value).toZonedDateTimeISO("UTC").toPlainDate();
|
|
330
344
|
}
|
|
331
345
|
return null;
|
|
@@ -357,7 +371,7 @@ export function coerceRow(row, info) {
|
|
|
357
371
|
coerced = t;
|
|
358
372
|
}
|
|
359
373
|
else if (pgType === "date") {
|
|
360
|
-
const d = plainDateFromDriver(value);
|
|
374
|
+
const d = plainDateFromDriver(value, key);
|
|
361
375
|
if (d !== null)
|
|
362
376
|
coerced = d;
|
|
363
377
|
}
|
|
@@ -507,6 +521,11 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
507
521
|
parts.push(`${ref(col)} @> $${idx++}${p.sql}`);
|
|
508
522
|
values.push(p.bound);
|
|
509
523
|
}
|
|
524
|
+
// A jsonb column holding the scalar itself is never `@>` an array of it.
|
|
525
|
+
if (isJsonbScalar(v)) {
|
|
526
|
+
parts.push(`${ref(col)} = $${idx++}::text::jsonb`);
|
|
527
|
+
values.push(JSON.stringify(v));
|
|
528
|
+
}
|
|
510
529
|
}
|
|
511
530
|
return parts.length > 0 ? `(${parts.join(" OR ")})` : "FALSE";
|
|
512
531
|
}
|
|
@@ -589,14 +608,27 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
589
608
|
conditions.push("FALSE");
|
|
590
609
|
continue;
|
|
591
610
|
}
|
|
592
|
-
|
|
593
|
-
|
|
611
|
+
// Mirrors applyMultiSelectFilter: an array `ne` means "does not contain all of these".
|
|
612
|
+
if (opKey === "ne" && pgType === "jsonb" && Array.isArray(opVal)) {
|
|
613
|
+
if (opVal.length === 0) {
|
|
614
|
+
conditions.push("FALSE");
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const p = prepareJsonbValue(opVal);
|
|
594
618
|
if (p && p.kind === "param") {
|
|
595
619
|
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
|
|
596
620
|
values.push(p.bound);
|
|
597
621
|
continue;
|
|
598
622
|
}
|
|
599
623
|
}
|
|
624
|
+
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
625
|
+
const p = prepareJsonbValue([opVal]);
|
|
626
|
+
if (p && p.kind === "param") {
|
|
627
|
+
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql} OR ${ref(col)} = $${idx++}::text::jsonb))`);
|
|
628
|
+
values.push(p.bound, JSON.stringify(opVal));
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
600
632
|
const p = prepareValue(opVal, pgType);
|
|
601
633
|
if (p.kind === "literal") {
|
|
602
634
|
conditions.push(`${ref(col)} ${opSym} ${p.literal}`);
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
|
|
14
14
|
// in Sprint 1 (compliance-profiles).
|
|
15
15
|
//
|
|
16
|
+
// SubProcessor has no "removed" state yet: a removed entry is deleted from
|
|
17
|
+
// the array, so until the notification cron exists a removal is documented
|
|
18
|
+
// only in the changelog. Removed so far: Mailbox.org (mailbox hosting),
|
|
19
|
+
// replaced by ALL-INKL.COM on 2026-09-02.
|
|
20
|
+
//
|
|
16
21
|
// Quelle: docs/plans/datenschutz/compliance-profiles.md "Sub-Processor-
|
|
17
22
|
// Management" + docs/plans/datenschutz/legal-artifacts.md.
|
|
18
23
|
/**
|
package/dist/crypto/event-pii.js
CHANGED
|
@@ -62,8 +62,6 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
|
|
|
62
62
|
if (!piiFields || piiFields === "none")
|
|
63
63
|
return payload;
|
|
64
64
|
const kms = configuredPiiSubjectKms();
|
|
65
|
-
if (!kms)
|
|
66
|
-
return payload;
|
|
67
65
|
let out;
|
|
68
66
|
for (const [field, spec] of Object.entries(piiFields)) {
|
|
69
67
|
const value = payload[field];
|
|
@@ -75,6 +73,10 @@ export async function encryptEventPayloadPii(eventType, payload, envelope) {
|
|
|
75
73
|
const subject = resolveEventSubject(field, spec, payload, envelope);
|
|
76
74
|
if (subject === null)
|
|
77
75
|
continue;
|
|
76
|
+
// Resolved before this skip so a missing owner fails in dev exactly as it
|
|
77
|
+
// does in prod, instead of surfacing only once a KMS is configured.
|
|
78
|
+
if (!kms)
|
|
79
|
+
continue;
|
|
78
80
|
const kmsCtx = { requestId: requestContext.get()?.requestId ?? "append-event" };
|
|
79
81
|
const encrypted = typeof value === "string"
|
|
80
82
|
? await encryptPiiValueForSubject(kms, subject, value, kmsCtx, field)
|
|
@@ -58,7 +58,25 @@ export function buildPgKmsOptions(env) {
|
|
|
58
58
|
previousKeks: { [previousVersion]: env.PLATFORM_KEK_PREVIOUS },
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
const CIPHERTEXT_CAPABLE_SLOTS = [
|
|
62
|
+
"PLATFORM_KEK",
|
|
63
|
+
"PLATFORM_KEK_PREVIOUS",
|
|
64
|
+
"KUMIKO_BLIND_INDEX_KEY",
|
|
65
|
+
];
|
|
66
|
+
// The sync entry points only read plaintext slots. A ciphertext-only slot
|
|
67
|
+
// would otherwise surface as a misleading "trio required" error or, worse,
|
|
68
|
+
// a silent plaintext-PII fallback in dev.
|
|
69
|
+
function assertNoUnresolvedCiphertext(env, logPrefix) {
|
|
70
|
+
for (const slot of CIPHERTEXT_CAPABLE_SLOTS) {
|
|
71
|
+
if (!env[slot] && env[`${slot}_CIPHERTEXT`]) {
|
|
72
|
+
throw new Error(`${logPrefix ? `${logPrefix} ` : ""}${slot}_CIPHERTEXT is set but ${slot} is not — ` +
|
|
73
|
+
"the sync KMS wiring cannot resolve ciphertext slots; use resolveKmsWiringAsync / requireKmsWiringAsync.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function assertTrioConsistent(env, logPrefix, rejectUnresolvedCiphertext) {
|
|
78
|
+
if (rejectUnresolvedCiphertext)
|
|
79
|
+
assertNoUnresolvedCiphertext(env, logPrefix);
|
|
62
80
|
const trio = [env.PLATFORM_KEK, env.SUBJECT_KEYS_DATABASE_URL, env.KUMIKO_BLIND_INDEX_KEY];
|
|
63
81
|
const complete = trio.every(Boolean);
|
|
64
82
|
if (!complete && trio.some(Boolean)) {
|
|
@@ -81,7 +99,13 @@ function assertTrioConsistent(env, logPrefix) {
|
|
|
81
99
|
*
|
|
82
100
|
* Prod entry points that must never fall back: use `requireKmsWiring`. */
|
|
83
101
|
export function resolveKmsWiring(env, options = {}) {
|
|
84
|
-
|
|
102
|
+
return wireKms(env, options, true);
|
|
103
|
+
}
|
|
104
|
+
// The async entry points resolve only their declared slots; a ciphertext slot
|
|
105
|
+
// outside that list is the caller's deliberate choice, so only the sync entry
|
|
106
|
+
// points treat a leftover ciphertext as a wiring error.
|
|
107
|
+
function wireKms(env, options, rejectUnresolvedCiphertext) {
|
|
108
|
+
const complete = assertTrioConsistent(env, options.logPrefix, rejectUnresolvedCiphertext);
|
|
85
109
|
if (complete && env.PLATFORM_KEK && env.SUBJECT_KEYS_DATABASE_URL && env.KUMIKO_BLIND_INDEX_KEY) {
|
|
86
110
|
const kms = createPgKmsAdapter(buildPgKmsOptions({
|
|
87
111
|
...env,
|
|
@@ -104,7 +128,9 @@ export function resolveKmsWiring(env, options = {}) {
|
|
|
104
128
|
* flag so the return type is the active wiring, with no narrowing at the call
|
|
105
129
|
* site. */
|
|
106
130
|
export function requireKmsWiring(env, options = {}) {
|
|
107
|
-
|
|
131
|
+
return requireActive(wireKms(env, options, true), options);
|
|
132
|
+
}
|
|
133
|
+
function requireActive(wiring, options) {
|
|
108
134
|
if ("allowPlaintextPii" in wiring) {
|
|
109
135
|
throw new Error(`${options.logPrefix ? `${options.logPrefix} ` : ""}PLATFORM_KEK / ` +
|
|
110
136
|
"SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY are required here — " +
|
|
@@ -117,9 +143,9 @@ export function requireKmsWiring(env, options = {}) {
|
|
|
117
143
|
* `resolvePlatformKeks`. The validation stays in the sync function; this only
|
|
118
144
|
* adds the KEK-fetching step in front of it. */
|
|
119
145
|
export async function resolveKmsWiringAsync(env, options) {
|
|
120
|
-
return
|
|
146
|
+
return wireKms(await resolvePlatformKeks(env, options), options, false);
|
|
121
147
|
}
|
|
122
148
|
/** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
|
|
123
149
|
export async function requireKmsWiringAsync(env, options) {
|
|
124
|
-
return
|
|
150
|
+
return requireActive(wireKms(await resolvePlatformKeks(env, options), options, false), options);
|
|
125
151
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
2
2
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
3
|
-
import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, type SubjectId } from "./kms-adapter.js";
|
|
3
|
+
import { type KmsAdapter, type KmsContext, type LocalKeyKmsAdapter, PII_ERASED_SENTINEL, type SubjectId } from "./kms-adapter.js";
|
|
4
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 };
|
|
6
|
-
export declare const PII_ERASED_SENTINEL = "[[erased]]";
|
|
5
|
+
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
|
|
7
6
|
export declare function encryptPiiValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
8
7
|
export declare function encryptPiiJsonValueForSubject(kms: LocalKeyKmsAdapter, subject: SubjectId, value: unknown, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
9
8
|
export declare function decryptPiiValueForSubject(kms: LocalKeyKmsAdapter, value: string, kmsCtx: KmsContext, field: string): Promise<string>;
|
|
@@ -16,12 +16,11 @@
|
|
|
16
16
|
// a v2<->v3 prefix swap fails GCM instead of silently changing the type.
|
|
17
17
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
18
18
|
import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate.js";
|
|
19
|
-
import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
|
|
19
|
+
import { isLocalKeyKmsAdapter, KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, PII_ERASED_SENTINEL, subjectIdFromKey, subjectIdToKey, } from "./kms-adapter.js";
|
|
20
20
|
import { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_CIPHERTEXT_PREFIX_V1, } from "./pii-ciphertext-format.js";
|
|
21
21
|
import { resolveSubjectForField } from "./subject-resolver.js";
|
|
22
22
|
// Spec value (crypto-shredding.md) — renderers show it verbatim.
|
|
23
|
-
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON };
|
|
24
|
-
export const PII_ERASED_SENTINEL = "[[erased]]";
|
|
23
|
+
export { isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL };
|
|
25
24
|
const IV_LENGTH = 12;
|
|
26
25
|
const AUTH_TAG_LENGTH = 16;
|
|
27
26
|
function buildAad(subject, field, isJson) {
|
package/dist/db/eagerload.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { EntityDefinition } from "../engine/types/index.js";
|
|
1
|
+
import type { EntityDefinition, SessionUser } from "../engine/types/index.js";
|
|
2
|
+
import { type ParentVisibilityOption } from "./parent-ref-clause.js";
|
|
2
3
|
import type { TenantDb } from "./tenant-db.js";
|
|
3
4
|
export type EagerLoadEntityResolver = (entityName: string) => EntityDefinition | undefined;
|
|
4
5
|
export type EagerloadedRow<T extends Record<string, unknown> = Record<string, unknown>> = T & {
|
|
@@ -10,9 +11,13 @@ type ReferenceFieldEntry = {
|
|
|
10
11
|
readonly multiple: boolean;
|
|
11
12
|
};
|
|
12
13
|
export declare function collectReferenceFields(entity: EntityDefinition): readonly ReferenceFieldEntry[];
|
|
14
|
+
export type EagerLoadViewer = {
|
|
15
|
+
readonly user: SessionUser;
|
|
16
|
+
readonly parentVisibility: ParentVisibilityOption;
|
|
17
|
+
};
|
|
13
18
|
/** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
|
|
14
19
|
* flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
|
|
15
|
-
export declare function enrichWithReferences(rows: ReadonlyArray<Record<string, unknown>>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb): Promise<Array<Record<string, unknown>>>;
|
|
20
|
+
export declare function enrichWithReferences(rows: ReadonlyArray<Record<string, unknown>>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb, viewer: EagerLoadViewer): Promise<Array<Record<string, unknown>>>;
|
|
16
21
|
/** Single-Row-Variante für detail-Calls. */
|
|
17
|
-
export declare function enrichRowWithReferences(row: Record<string, unknown>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb): Promise<Record<string, unknown>>;
|
|
22
|
+
export declare function enrichRowWithReferences(row: Record<string, unknown>, entity: EntityDefinition, resolveEntity: EagerLoadEntityResolver, db: TenantDb, viewer: EagerLoadViewer): Promise<Record<string, unknown>>;
|
|
18
23
|
export {};
|