@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +11 -1
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +39 -36
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +103 -33
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +62 -19
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +6 -1
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +150 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-jobs.js +24 -12
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +206 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
package/dist/api/auth-routes.js
CHANGED
|
@@ -866,7 +866,12 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
866
866
|
api.post(Routes.authSwitchTenant, async (c) => {
|
|
867
867
|
const user = getUser(c);
|
|
868
868
|
const body = await c.req.json();
|
|
869
|
-
|
|
869
|
+
// The id seeds a SYSTEM identity in resolveActiveMembership — reject anything
|
|
870
|
+
// that is not a non-empty string before it gets that far.
|
|
871
|
+
if (!isNonEmptyString(body.tenantId)) {
|
|
872
|
+
return c.json({ error: "invalid_tenant" }, 400);
|
|
873
|
+
}
|
|
874
|
+
const targetTenantId = body.tenantId; // @cast-boundary request-body
|
|
870
875
|
if (targetTenantId === user.tenantId) {
|
|
871
876
|
return c.json({ error: "already_in_tenant" }, 400);
|
|
872
877
|
}
|
|
@@ -929,8 +934,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
929
934
|
tenantId: targetTenantId,
|
|
930
935
|
roles: mergedRoles,
|
|
931
936
|
// Tenant-independent prefs must survive the switch (fw#2343).
|
|
932
|
-
...(user
|
|
933
|
-
...(user.locale ? { locale: user.locale } : {}),
|
|
937
|
+
...tenantIndependentPrefs(user),
|
|
934
938
|
};
|
|
935
939
|
const claims = await dispatcher.resolveAuthClaims(targetSession);
|
|
936
940
|
const sessionForJwt = Object.keys(claims).length > 0 ? { ...targetSession, claims } : targetSession;
|
|
@@ -959,6 +963,15 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
959
963
|
// validates the token + does the state change → typed failure or 200. Keeping
|
|
960
964
|
// the silent-success invariant in one place means changing how the framework
|
|
961
965
|
// handles "invalid_body" on a public token endpoint is one edit, not three.
|
|
966
|
+
function tenantIndependentPrefs(user) {
|
|
967
|
+
return {
|
|
968
|
+
...(user.timezone ? { timezone: user.timezone } : {}),
|
|
969
|
+
...(user.locale ? { locale: user.locale } : {}),
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
function isNonEmptyString(value) {
|
|
973
|
+
return typeof value === "string" && value.length > 0;
|
|
974
|
+
}
|
|
962
975
|
function registerTokenRequestRoute(opts) {
|
|
963
976
|
const body = opts.schema ?? RequestTokenBody;
|
|
964
977
|
opts.api.post(opts.path, async (c) => {
|
|
@@ -974,7 +987,9 @@ function registerTokenRequestRoute(opts) {
|
|
|
974
987
|
// they are logged — otherwise the mail silently never goes out. No email in
|
|
975
988
|
// the log line (PII).
|
|
976
989
|
const result = await opts.dispatcher.write(opts.requestHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
|
|
977
|
-
|
|
990
|
+
// rate_limited is an expected, attacker-triggerable outcome; logging it
|
|
991
|
+
// would flood the sink and bury real misconfiguration errors.
|
|
992
|
+
if (!result.isSuccess && result.error.code !== "rate_limited") {
|
|
978
993
|
console.error(`[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`);
|
|
979
994
|
}
|
|
980
995
|
return c.json({ isSuccess: true });
|
package/dist/api/client-ip.js
CHANGED
|
@@ -50,16 +50,13 @@ export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
|
|
|
50
50
|
// worse than staying on the default. Digits-only (not parseInt) —
|
|
51
51
|
// parseInt("0x10")/("1e3")/("2x") would silently coerce instead of failing.
|
|
52
52
|
export function parseTrustedProxyHopsEnv(raw, context) {
|
|
53
|
-
|
|
53
|
+
// Empty string = unset, matching readEnv's normalization (e.g. a blank .env template line).
|
|
54
|
+
if (raw === undefined || raw === "")
|
|
54
55
|
return undefined;
|
|
55
56
|
if (!/^\d+$/.test(raw)) {
|
|
56
57
|
throw new Error(`${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`);
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
60
|
-
throw new Error(`${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`);
|
|
61
|
-
}
|
|
62
|
-
return parsed;
|
|
59
|
+
return Number(raw);
|
|
63
60
|
}
|
|
64
61
|
// Instance-scoped, NOT a module singleton: the warn-once flag must reset
|
|
65
62
|
// per server boot (and per test), otherwise the first test to trigger it
|
package/dist/api/extra-route.js
CHANGED
|
@@ -39,8 +39,9 @@ export class ExtraRouteRejection extends Error {
|
|
|
39
39
|
if (status !== 503) {
|
|
40
40
|
throw new RangeError("ExtraRouteRejection: retryAfterSeconds is only valid with status 503");
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// isSafeInteger, not isInteger: String(1e21) is "1e+21", not 1*DIGIT.
|
|
43
|
+
if (!Number.isSafeInteger(retryAfterSeconds) || retryAfterSeconds < 0) {
|
|
44
|
+
throw new RangeError("ExtraRouteRejection: retryAfterSeconds must be a non-negative safe integer");
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
@@ -2,8 +2,11 @@ import { PII_CIPHERTEXT_PREFIX } from "../crypto/index.js";
|
|
|
2
2
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
3
3
|
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
4
4
|
// older/decrypt-only format version still present in unmigrated rows.
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Bound to the full ciphertext shape (prefix, "<kind>:<id>" subject key, base64
|
|
6
|
+
// blob of at least IV + GCM tag = 28 bytes) so a user-typed "kumiko-pii:v" in a
|
|
7
|
+
// note or name field cannot turn every read of that record into a 500.
|
|
8
|
+
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^:"\s<>\\]+:[^:"\s<>\\]+:[A-Za-z0-9+/=]{40,}/;
|
|
9
|
+
const CIPHERTEXT_REDACT_RE = new RegExp(CIPHERTEXT_RE.source, "g");
|
|
7
10
|
// A PII subject ciphertext never belongs in an API response — its presence
|
|
8
11
|
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
9
12
|
// fail loud (500) so a forgotten decrypt turns the first integration test
|
|
@@ -19,7 +22,7 @@ export function piiCiphertextResponseGuard() {
|
|
|
19
22
|
return;
|
|
20
23
|
const text = await c.res.clone().text();
|
|
21
24
|
// skip: clean response — the common case
|
|
22
|
-
if (!
|
|
25
|
+
if (!CIPHERTEXT_RE.test(text))
|
|
23
26
|
return;
|
|
24
27
|
const detail = `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
|
|
25
28
|
`("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
|
|
@@ -32,7 +35,7 @@ export function piiCiphertextResponseGuard() {
|
|
|
32
35
|
console.error(detail);
|
|
33
36
|
const headers = new Headers(c.res.headers);
|
|
34
37
|
headers.delete("content-length");
|
|
35
|
-
c.res = new Response(text.replace(
|
|
38
|
+
c.res = new Response(text.replace(CIPHERTEXT_REDACT_RE, "[pii-redacted]"), {
|
|
36
39
|
status: c.res.status,
|
|
37
40
|
headers,
|
|
38
41
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type RedisClientOptions } from "../redis/client.js";
|
|
1
2
|
import { type SseBroker } from "./sse-broker.js";
|
|
2
3
|
export type RedisSseBrokerOptions = {
|
|
3
4
|
readonly redisUrl: string;
|
|
5
|
+
readonly clientOptions?: RedisClientOptions;
|
|
4
6
|
};
|
|
5
7
|
export type RedisSseBroker = SseBroker & {
|
|
6
8
|
close(): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { redisClientOptionsFromEnv } from "../redis/client.js";
|
|
1
2
|
import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
|
|
2
|
-
import { createSseBroker } from "./sse-broker.js";
|
|
3
|
+
import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
|
|
3
4
|
// Channel namespace for cross-replica fanout (fw#2625). Every pod publishes
|
|
4
5
|
// here and every pod's psubscribe listens here, so a push on one instance
|
|
5
6
|
// reaches SSE clients connected to any other instance without a shared
|
|
@@ -26,7 +27,9 @@ export function isRedisSseBroker(broker) {
|
|
|
26
27
|
// buildServer, so its own REDIS_URL-based default never ran.
|
|
27
28
|
export function createDefaultSseBroker(env = process.env) {
|
|
28
29
|
const redisUrl = env["REDIS_URL"];
|
|
29
|
-
const ownedRedisSseBroker = redisUrl
|
|
30
|
+
const ownedRedisSseBroker = redisUrl
|
|
31
|
+
? createRedisSseBroker({ redisUrl, clientOptions: redisClientOptionsFromEnv(env) })
|
|
32
|
+
: undefined;
|
|
30
33
|
return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
|
|
31
34
|
}
|
|
32
35
|
function isSseEvent(value) {
|
|
@@ -71,11 +74,6 @@ function parseInvalidationScope(payload) {
|
|
|
71
74
|
}
|
|
72
75
|
return { kind: "user" };
|
|
73
76
|
}
|
|
74
|
-
function readNonEmptyStringArray(value) {
|
|
75
|
-
if (!Array.isArray(value) || value.length === 0)
|
|
76
|
-
return undefined;
|
|
77
|
-
return value.every((v) => typeof v === "string") ? value : undefined;
|
|
78
|
-
}
|
|
79
77
|
// Mirror of parseInvalidationScope's wire shapes. "sessions"/"pat-tokens"
|
|
80
78
|
// deliberately never include a `keptSessionId` key — see the parser comment.
|
|
81
79
|
function encodeInvalidationScope(scope) {
|
|
@@ -100,6 +98,7 @@ export function createRedisSseBroker(opts) {
|
|
|
100
98
|
redisUrl: opts.redisUrl,
|
|
101
99
|
channelPattern: PSUBSCRIBE_PATTERN,
|
|
102
100
|
label: "sse-broker",
|
|
101
|
+
...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
|
|
103
102
|
});
|
|
104
103
|
signal.onMessage((channel, payload) => {
|
|
105
104
|
if (channel.startsWith(CHANNEL_PREFIX)) {
|
package/dist/api/routes.js
CHANGED
|
@@ -292,26 +292,33 @@ const FAULT_LOG_SILENCED_LEVELS = new Set(["error", "fatal", "silent"]);
|
|
|
292
292
|
// `type` is client-supplied on an unknown-handler 404, so cap it before it
|
|
293
293
|
// reaches the log — an unbounded field would let a caller flood the sink.
|
|
294
294
|
const MAX_LOGGED_TYPE_LENGTH = 120;
|
|
295
|
+
// The body's `type` is only typed as string — a client can send an object, and
|
|
296
|
+
// a throw from inside the fault logger would turn a clean 4xx into a 500.
|
|
297
|
+
function loggableType(type) {
|
|
298
|
+
return typeof type === "string" ? type.slice(0, MAX_LOGGED_TYPE_LENGTH) : undefined;
|
|
299
|
+
}
|
|
295
300
|
function clientFaultLoggingEnabled() {
|
|
296
301
|
return !FAULT_LOG_SILENCED_LEVELS.has(process.env["LOG_LEVEL"] ?? "");
|
|
297
302
|
}
|
|
298
303
|
// A failing request must leave a trace even when it ends in 4xx — a paid
|
|
299
304
|
// external call that 422s was invisible before (offlot#117). Status, error
|
|
300
305
|
// code and duration only: message/details/stack can carry submitted values.
|
|
301
|
-
function
|
|
306
|
+
function logClientWarn(message, requestId, type, fields) {
|
|
302
307
|
if (!clientFaultLoggingEnabled()) {
|
|
303
308
|
// skip: LOG_LEVEL silences the 4xx tier — the deployment opted out of client-fault volume
|
|
304
309
|
return;
|
|
305
310
|
}
|
|
306
311
|
const startedAt = requestContext.get()?.startedAt;
|
|
307
|
-
createFallbackLogger("api").warn(
|
|
312
|
+
createFallbackLogger("api").warn(message, {
|
|
308
313
|
requestId,
|
|
309
|
-
type: type
|
|
310
|
-
|
|
311
|
-
code: err.code,
|
|
314
|
+
type: loggableType(type),
|
|
315
|
+
...fields,
|
|
312
316
|
...(startedAt === undefined ? {} : { durationMs: Math.round(performance.now() - startedAt) }),
|
|
313
317
|
});
|
|
314
318
|
}
|
|
319
|
+
function logClientFault(err, requestId, type) {
|
|
320
|
+
logClientWarn("handler rejected", requestId, type, { status: err.httpStatus, code: err.code });
|
|
321
|
+
}
|
|
315
322
|
// Identity-checked against THIS request's signal — signal.aborted alone
|
|
316
323
|
// would also match a real 5xx that happens to race a disconnect.
|
|
317
324
|
function isClientAbort(err) {
|
|
@@ -333,7 +340,7 @@ function logServerFault(err, requestId, type) {
|
|
|
333
340
|
const cause = err.cause;
|
|
334
341
|
createFallbackLogger("api").error("handler failed", {
|
|
335
342
|
requestId,
|
|
336
|
-
type,
|
|
343
|
+
type: loggableType(type),
|
|
337
344
|
code: err.code,
|
|
338
345
|
message: err.message,
|
|
339
346
|
cause: cause instanceof Error ? cause.message : cause,
|
|
@@ -354,17 +361,9 @@ function writeErrorResponse(c, err, type) {
|
|
|
354
361
|
function queryErrorResponse(c, err, type) {
|
|
355
362
|
const requestId = requestContext.get()?.requestId;
|
|
356
363
|
if (isClientAbort(err)) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
requestId,
|
|
361
|
-
type: type?.slice(0, MAX_LOGGED_TYPE_LENGTH),
|
|
362
|
-
status: CLIENT_CLOSED_REQUEST_STATUS,
|
|
363
|
-
...(startedAt === undefined
|
|
364
|
-
? {}
|
|
365
|
-
: { durationMs: Math.round(performance.now() - startedAt) }),
|
|
366
|
-
});
|
|
367
|
-
}
|
|
364
|
+
logClientWarn("request aborted by client", requestId, type, {
|
|
365
|
+
status: CLIENT_CLOSED_REQUEST_STATUS,
|
|
366
|
+
});
|
|
368
367
|
return c.body(null, CLIENT_CLOSED_REQUEST_STATUS); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
|
|
369
368
|
}
|
|
370
369
|
logServerFault(err, requestId, type);
|
package/dist/api/server.js
CHANGED
|
@@ -537,13 +537,20 @@ export function buildServer(options) {
|
|
|
537
537
|
// Public auth routes (login) need to be registered BEFORE the generic
|
|
538
538
|
// api routes so Hono matches them first.
|
|
539
539
|
if (options.auth) {
|
|
540
|
-
// A membershipQuery handler is registered but
|
|
541
|
-
//
|
|
542
|
-
if (options.registry.getQueryHandler(options.auth.membershipQuery)
|
|
543
|
-
options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
540
|
+
// A membershipQuery handler is registered but principalStatus is not provided by exactly
|
|
541
|
+
// one feature — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
|
|
542
|
+
if (options.registry.getQueryHandler(options.auth.membershipQuery)) {
|
|
543
|
+
const principalStatusProviders = options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length;
|
|
544
|
+
if (principalStatusProviders === 0) {
|
|
545
|
+
throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
|
|
546
|
+
'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
|
|
547
|
+
"need it to reject blocked principals).");
|
|
548
|
+
}
|
|
549
|
+
if (principalStatusProviders > 1) {
|
|
550
|
+
throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but ` +
|
|
551
|
+
`${principalStatusProviders} features provide the "principalStatus" contract — exactly one ` +
|
|
552
|
+
"is expected (the bundled user feature already provides it).");
|
|
553
|
+
}
|
|
547
554
|
}
|
|
548
555
|
app.route("/api", createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }));
|
|
549
556
|
}
|
package/dist/api/sse-broker.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionUser } from "../engine/types/index.js";
|
|
1
2
|
export type SseClient = {
|
|
2
3
|
id: string;
|
|
3
4
|
send: (event: SseEvent) => void;
|
|
@@ -7,6 +8,7 @@ export type SseEvent = {
|
|
|
7
8
|
type: string;
|
|
8
9
|
data: Record<string, unknown>;
|
|
9
10
|
};
|
|
11
|
+
export declare function readNonEmptyStringArray(value: unknown): string[] | undefined;
|
|
10
12
|
export type AccessInvalidationScope = {
|
|
11
13
|
readonly kind: "user";
|
|
12
14
|
} | {
|
|
@@ -23,6 +25,7 @@ export type AccessInvalidationCredential = {
|
|
|
23
25
|
readonly sid?: string;
|
|
24
26
|
readonly patTokenId?: string;
|
|
25
27
|
};
|
|
28
|
+
export declare function accessInvalidationCredentialFor(user: Pick<SessionUser, "sid" | "pat">): AccessInvalidationCredential;
|
|
26
29
|
export type SseBroker = {
|
|
27
30
|
addClient(channel: string, send: (event: SseEvent) => void, close: () => void): string;
|
|
28
31
|
removeClient(channel: string, clientId: string): void;
|
package/dist/api/sse-broker.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { userAccessChannel } from "../engine/constants.js";
|
|
2
2
|
import { generateId } from "../utils/index.js";
|
|
3
|
+
// What a publish invalidates. "user" is the broadest (and the historical
|
|
4
|
+
// default — every scope narrower than this is an opt-in from a caller that
|
|
5
|
+
// knows exactly which credential(s) it revoked).
|
|
6
|
+
export function readNonEmptyStringArray(value) {
|
|
7
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
8
|
+
return undefined;
|
|
9
|
+
return value.every((v) => typeof v === "string") ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function accessInvalidationCredentialFor(user) {
|
|
12
|
+
return {
|
|
13
|
+
...(user.sid !== undefined && { sid: user.sid }),
|
|
14
|
+
...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
3
17
|
// Fail-closed: a narrow scope only spares/limits a listener it can positively
|
|
4
18
|
// match against its own credential. A listener with neither sid nor
|
|
5
19
|
// patTokenId of its own can't be matched by "sessions"/"pat-tokens", so it
|
package/dist/api/sse-route.d.ts
CHANGED
package/dist/api/sse-route.js
CHANGED
|
@@ -3,6 +3,7 @@ import { streamSSE } from "hono/streaming";
|
|
|
3
3
|
import { tenantChannel } from "../engine/constants.js";
|
|
4
4
|
import { Routes } from "./api-constants.js";
|
|
5
5
|
import { getUser } from "./auth-middleware.js";
|
|
6
|
+
import { accessInvalidationCredentialFor } from "./sse-broker.js";
|
|
6
7
|
/**
|
|
7
8
|
* Heartbeat-Cadence für SSE-Streams.
|
|
8
9
|
*
|
|
@@ -32,6 +33,12 @@ export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
|
32
33
|
function isEntityEventData(data) {
|
|
33
34
|
return typeof data["aggregateType"] === "string";
|
|
34
35
|
}
|
|
36
|
+
// The tenant channel fans out to every member; user-addressed frames
|
|
37
|
+
// (e.g. channel-in-app:event:delivered) would otherwise reach the whole tenant.
|
|
38
|
+
function isAddressedToOtherUser(event, userId) {
|
|
39
|
+
const addressee = event.data["userId"];
|
|
40
|
+
return typeof addressee === "string" && addressee !== userId;
|
|
41
|
+
}
|
|
35
42
|
export function createSseRoute(broker) {
|
|
36
43
|
const route = new Hono();
|
|
37
44
|
route.get(Routes.sse, async (c) => {
|
|
@@ -40,20 +47,46 @@ export function createSseRoute(broker) {
|
|
|
40
47
|
// Allowing ?channel=... would let any authenticated user subscribe to other tenants' feeds.
|
|
41
48
|
const channel = tenantChannel(user.tenantId);
|
|
42
49
|
return streamSSE(c, async (stream) => {
|
|
50
|
+
let resolveEnded;
|
|
51
|
+
const ended = new Promise((resolve) => {
|
|
52
|
+
resolveEnded = resolve;
|
|
53
|
+
});
|
|
54
|
+
const closeStream = () => {
|
|
55
|
+
resolveEnded();
|
|
56
|
+
void stream.close();
|
|
57
|
+
};
|
|
43
58
|
const clientId = broker.addClient(channel, (event) => {
|
|
59
|
+
// skip: user-addressed frames on the shared tenant channel belong to their recipient only
|
|
60
|
+
if (isAddressedToOtherUser(event, user.id))
|
|
61
|
+
return;
|
|
44
62
|
const wireEventName = isEntityEventData(event.data)
|
|
45
63
|
? event.data.aggregateType
|
|
46
64
|
: event.type;
|
|
47
65
|
stream.writeSSE({ event: wireEventName, data: JSON.stringify(event.data) });
|
|
48
|
-
},
|
|
49
|
-
|
|
66
|
+
}, closeStream);
|
|
67
|
+
const unsubscribeAccessInvalidation = broker.subscribeAccessInvalidation(user.id, closeStream, accessInvalidationCredentialFor(user));
|
|
68
|
+
let released = false;
|
|
69
|
+
const release = () => {
|
|
70
|
+
// skip: onAbort and the finally block both release, the second call is a no-op
|
|
71
|
+
if (released)
|
|
72
|
+
return;
|
|
73
|
+
released = true;
|
|
74
|
+
// A client disconnect must also wake the heartbeat sleep, not only a server close.
|
|
75
|
+
resolveEnded();
|
|
50
76
|
broker.removeClient(channel, clientId);
|
|
51
|
-
|
|
77
|
+
unsubscribeAccessInvalidation();
|
|
78
|
+
};
|
|
79
|
+
stream.onAbort(release);
|
|
52
80
|
// Keep connection alive with heartbeat — siehe SSE_HEARTBEAT_INTERVAL_MS
|
|
53
81
|
// header für die Layer-für-Layer-Begründung der 15s-Cadence.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
try {
|
|
83
|
+
while (!stream.closed && !stream.aborted) {
|
|
84
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
85
|
+
await Promise.race([stream.sleep(SSE_HEARTBEAT_INTERVAL_MS), ended]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
release();
|
|
57
90
|
}
|
|
58
91
|
});
|
|
59
92
|
});
|
|
@@ -27,8 +27,6 @@ export function createBunDbConnection(url, options = {}) {
|
|
|
27
27
|
if (options.connectTimeoutSeconds !== undefined) {
|
|
28
28
|
pgOpts.connect_timeout = options.connectTimeoutSeconds;
|
|
29
29
|
}
|
|
30
|
-
// ponytail: "postgres" resolves to the @bender0oo0/postgres fork (nextWrite
|
|
31
|
-
// null-socket guard, porsager/postgres#1209); switch back per kumiko-framework#3248.
|
|
32
30
|
const listenClient = postgres(url, pgOpts);
|
|
33
31
|
return {
|
|
34
32
|
db,
|
package/dist/bun-db/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { BunDbConnection, BunDbConnectionOptions, BunDbRunner, BunDbTx, PgListenClient, } from "./connection.js";
|
|
2
2
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
|
|
3
3
|
export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query.js";
|
|
4
|
-
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
|
4
|
+
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
package/dist/bun-db/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// bun-db: Bun.sql-basierte DB-API ohne drizzle.
|
|
2
2
|
// Production-Pfad nach drizzle-removal.
|
|
3
3
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
|
|
4
|
-
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
|
4
|
+
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
package/dist/bun-db/query.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type NotExecutorOnly } from "../db/table-builder.js";
|
|
|
5
5
|
import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
|
|
6
6
|
import type { BunDbRunner } from "./connection.js";
|
|
7
7
|
export declare function asEntityTableMeta(table: unknown): EntityTableMeta | undefined;
|
|
8
|
+
export declare function requireEntityTableMeta(table: unknown, tableName: string): EntityTableMeta;
|
|
8
9
|
type RawClient = {
|
|
9
10
|
unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
|
|
10
11
|
begin: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>;
|
package/dist/bun-db/query.js
CHANGED
|
@@ -68,6 +68,13 @@ export function asEntityTableMeta(table) {
|
|
|
68
68
|
return fromSymbol;
|
|
69
69
|
return isEntityTableMeta(table) ? table : undefined;
|
|
70
70
|
}
|
|
71
|
+
export function requireEntityTableMeta(table, tableName) {
|
|
72
|
+
const meta = asEntityTableMeta(table);
|
|
73
|
+
if (!meta) {
|
|
74
|
+
throw new Error(`${tableName}: table carries no EntityTableMeta — built via table()?`);
|
|
75
|
+
}
|
|
76
|
+
return meta;
|
|
77
|
+
}
|
|
71
78
|
export function asRawClient(db) {
|
|
72
79
|
const dbAny = db;
|
|
73
80
|
// Direct: Bun.SQL / postgres-js Sql / postgres-js TransactionSql. All three
|
|
@@ -542,10 +549,16 @@ function buildWhereClause(info, where, startIndex) {
|
|
|
542
549
|
const opVal = value[opKey];
|
|
543
550
|
if (opVal === undefined)
|
|
544
551
|
continue;
|
|
552
|
+
// Mirrors applyMultiSelectFilter: ordering/pattern ops have no meaning on a jsonb
|
|
553
|
+
// option array (`like` would crash as `jsonb ~~ jsonb`), so match nothing.
|
|
554
|
+
if (pgType === "jsonb" && opKey !== "ne") {
|
|
555
|
+
conditions.push("FALSE");
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
545
558
|
if (opKey === "ne" && pgType === "jsonb" && isJsonbScalar(opVal)) {
|
|
546
559
|
const p = prepareJsonbValue([opVal]);
|
|
547
560
|
if (p && p.kind === "param") {
|
|
548
|
-
conditions.push(`NOT (${quoteIdent(col)} @> $${idx++}${p.sql})`);
|
|
561
|
+
conditions.push(`(${quoteIdent(col)} IS NULL OR NOT (${quoteIdent(col)} @> $${idx++}${p.sql}))`);
|
|
549
562
|
values.push(p.bound);
|
|
550
563
|
continue;
|
|
551
564
|
}
|
|
@@ -885,9 +898,7 @@ export async function updateMany(db, table, set, where) {
|
|
|
885
898
|
return scoped.updateMany(table, set, where);
|
|
886
899
|
}
|
|
887
900
|
const info = extractTableInfo(table);
|
|
888
|
-
const setEntries = Object.entries(set)
|
|
889
|
-
.filter(([k]) => info.hasColumn(k))
|
|
890
|
-
.map(([k, v]) => {
|
|
901
|
+
const setEntries = Object.entries(set).map(([k, v]) => {
|
|
891
902
|
const col = info.columnOf(k);
|
|
892
903
|
const pgType = info.pgTypeOf(col);
|
|
893
904
|
return { col, prepared: prepareValue(v, pgType) };
|
|
@@ -34,12 +34,12 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
|
|
|
34
34
|
signal: AbortSignal.timeout(DECRYPT_TIMEOUT_MS),
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
catch {
|
|
37
|
+
catch (error) {
|
|
38
38
|
if (attempt < maxAttempts) {
|
|
39
39
|
await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
40
40
|
continue;
|
|
41
41
|
}
|
|
42
|
-
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout
|
|
42
|
+
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`, { cause: error });
|
|
43
43
|
}
|
|
44
44
|
if (!response.ok) {
|
|
45
45
|
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
@@ -62,60 +62,64 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
|
|
|
62
62
|
}
|
|
63
63
|
throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: retries exhausted`);
|
|
64
64
|
}
|
|
65
|
+
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
66
|
+
// migrated, which is indistinguishable from a finished cutover unless the
|
|
67
|
+
// boot says which source won. The line is built from the path actually taken.
|
|
65
68
|
async function resolveSlot(name, env, options, fetchImpl) {
|
|
66
69
|
const plaintext = env[name];
|
|
67
|
-
if (plaintext)
|
|
68
|
-
return plaintext;
|
|
69
70
|
const ciphertext = env[`${name}_CIPHERTEXT`];
|
|
71
|
+
if (plaintext) {
|
|
72
|
+
return {
|
|
73
|
+
value: plaintext,
|
|
74
|
+
sourceLine: ciphertext
|
|
75
|
+
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
76
|
+
: `${name} source=plaintext-env`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
70
79
|
if (!ciphertext)
|
|
71
|
-
return undefined;
|
|
80
|
+
return { value: undefined, sourceLine: undefined };
|
|
72
81
|
const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
|
|
73
82
|
const token = env.PLATFORM_KEK_KMS_TOKEN;
|
|
74
83
|
if (!keyId || !token) {
|
|
75
84
|
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
76
|
-
throw new Error(`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are
|
|
77
|
-
}
|
|
78
|
-
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
79
|
-
return decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
|
|
80
|
-
}
|
|
81
|
-
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
82
|
-
// migrated, which is indistinguishable from a finished cutover unless the
|
|
83
|
-
// boot says which source won. Never carries a key value, only its origin.
|
|
84
|
-
function describeKekSource(name, env) {
|
|
85
|
-
const plaintext = env[name];
|
|
86
|
-
const ciphertext = env[`${name}_CIPHERTEXT`];
|
|
87
|
-
if (plaintext) {
|
|
88
|
-
return ciphertext
|
|
89
|
-
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
90
|
-
: `${name} source=plaintext-env`;
|
|
85
|
+
throw new Error(`${prefix}${name}_CIPHERTEXT is set but PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are missing — a partial set means the KMS wiring is broken.`);
|
|
91
86
|
}
|
|
92
|
-
if (!ciphertext)
|
|
93
|
-
return undefined;
|
|
94
87
|
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
95
|
-
|
|
88
|
+
const value = await decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
|
|
89
|
+
return {
|
|
90
|
+
value,
|
|
91
|
+
sourceLine: `${name} source=key-manager keyId=${keyId} region=${region}`,
|
|
92
|
+
};
|
|
96
93
|
}
|
|
97
94
|
// Each slot resolves independently so a rollback that clears one slot's
|
|
98
95
|
// plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
|
|
99
96
|
// another slot's fallback path — the trio check downstream still applies.
|
|
97
|
+
// Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
|
|
98
|
+
// ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
|
|
100
99
|
export async function resolvePlatformKeks(env, options = {}) {
|
|
101
100
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
101
|
+
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
102
102
|
const slots = options.slots ?? LEGACY_SLOTS;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
// Misconfiguration must not cost a Key Manager round trip.
|
|
104
|
+
if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
|
|
105
|
+
(env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
|
|
106
|
+
!env.PLATFORM_KEK_PREVIOUS_VERSION) {
|
|
107
|
+
throw new Error(`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`);
|
|
106
108
|
}
|
|
107
|
-
const
|
|
109
|
+
const results = await Promise.all(slots.map((name) => resolveSlot(name, env, options, fetchImpl)));
|
|
108
110
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
109
111
|
const log = options.log ?? console.info;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
112
|
+
const resolved = {};
|
|
113
|
+
slots.forEach((name, index) => {
|
|
114
|
+
const result = results[index];
|
|
115
|
+
// skip: slot without a resolved result stays unset
|
|
116
|
+
if (!result)
|
|
117
|
+
return;
|
|
118
|
+
resolved[name] = result.value;
|
|
119
|
+
if (result.sourceLine)
|
|
120
|
+
log(`${prefix}${result.sourceLine}`);
|
|
121
|
+
});
|
|
122
|
+
const changed = Object.keys(resolved).some((name) => resolved[name] !== env[name]);
|
|
119
123
|
if (!changed)
|
|
120
124
|
return env;
|
|
121
125
|
return { ...env, ...resolved };
|
|
@@ -3,8 +3,8 @@ import * as z from "zod";
|
|
|
3
3
|
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
4
4
|
// The KMS error classes live here and not in kumiko-types (#1629): callers
|
|
5
5
|
// branch on them with `instanceof`, which needs a single copy of the class.
|
|
6
|
-
// `satisfies z.ZodType<SubjectId>`
|
|
7
|
-
// (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
|
|
6
|
+
// `satisfies z.ZodType<SubjectId>` plus the reverse check in subject-id-schema.test.ts bind this to the TS type
|
|
7
|
+
// at compile time (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
|
|
8
8
|
// longer drift apart on which subjects are valid, the way #2809 happened.
|
|
9
9
|
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
10
10
|
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
@@ -114,8 +114,14 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
114
114
|
// UPDATE so Postgres doesn't error with "column X does not exist".
|
|
115
115
|
const changes = {};
|
|
116
116
|
for (const [key, value] of Object.entries(rawChanges)) {
|
|
117
|
-
const
|
|
118
|
-
|
|
117
|
+
const field = entity.fields[key];
|
|
118
|
+
// Historical events can carry fields since removed from the entity; the
|
|
119
|
+
// tolerance lives here (not in updateMany) so a live typo still throws.
|
|
120
|
+
// Base/soft-delete columns (insertedById, deletedAt, ...) are table
|
|
121
|
+
// columns without an entity field and must still be applied.
|
|
122
|
+
if (field === undefined && !(key in table))
|
|
123
|
+
continue;
|
|
124
|
+
if (field?.type === "files" || field?.type === "images")
|
|
119
125
|
continue;
|
|
120
126
|
changes[key] = value;
|
|
121
127
|
}
|