@cosmicdrift/kumiko-framework 0.353.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/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/routes.js +4 -2
- package/dist/api/server.js +28 -3
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +2 -6
- 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 +29 -2
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +59 -18
- 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 +1 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +11 -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 +32 -3
- 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 +5 -0
- 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 +40 -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 +98 -7
- 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 +3 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +55 -34
- 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-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +41 -4
- 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 +2 -3
- package/dist/engine/index.d.ts +8 -2
- package/dist/engine/index.js +6 -1
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/project-app-schema-for-roles.js +3 -1
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/run-pipeline.js +12 -1
- 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/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.js +45 -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/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-routes.js +10 -4
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- 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/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/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/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 +198 -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";
|
|
@@ -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) —
|
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
|
@@ -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] : []),
|
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
|
@@ -5,6 +5,7 @@ import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
|
5
5
|
import { Routes } from "./api-constants.js";
|
|
6
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
|
*
|
|
@@ -81,9 +82,6 @@ function decideWireFrame(event, user, options) {
|
|
|
81
82
|
return undefined;
|
|
82
83
|
return { name: event.type, data };
|
|
83
84
|
}
|
|
84
|
-
// setTimeout overflows (fires immediately) above 2^31-1 ms; a token that
|
|
85
|
-
// outlives it just gets its stream recycled early, the client reconnects.
|
|
86
|
-
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
87
85
|
export function createSseRoute(broker, options) {
|
|
88
86
|
const route = new Hono();
|
|
89
87
|
route.get(Routes.sse, async (c) => {
|
|
@@ -111,9 +109,7 @@ export function createSseRoute(broker, options) {
|
|
|
111
109
|
stream.writeSSE({ event: frame.name, data: JSON.stringify(frame.data) });
|
|
112
110
|
}, closeStream);
|
|
113
111
|
const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
|
|
114
|
-
const expiryTimer = tokenExpiresAtSec
|
|
115
|
-
? undefined
|
|
116
|
-
: setTimeout(closeStream, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, tokenExpiresAtSec * 1000 - Date.now())));
|
|
112
|
+
const expiryTimer = scheduleTokenExpiry(tokenExpiresAtSec, closeStream);
|
|
117
113
|
let released = false;
|
|
118
114
|
const release = () => {
|
|
119
115
|
// skip: onAbort and the finally block both release, the second call is a no-op
|
|
@@ -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
|
}
|
|
@@ -594,6 +608,19 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
594
608
|
conditions.push("FALSE");
|
|
595
609
|
continue;
|
|
596
610
|
}
|
|
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);
|
|
618
|
+
if (p && p.kind === "param") {
|
|
619
|
+
conditions.push(`(${ref(col)} IS NULL OR NOT (${ref(col)} @> $${idx++}${p.sql}))`);
|
|
620
|
+
values.push(p.bound);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
597
624
|
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
598
625
|
const p = prepareJsonbValue([opVal]);
|
|
599
626
|
if (p && p.kind === "param") {
|
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
|
}
|
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 {};
|