@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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/AGENTS.md +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +319 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +215 -0
- package/agents/examples/ledger-invoicing.md +236 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +250 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +418 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +75 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
package/dist/api/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { ROLES } from "../auth/roles.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createProjectingTenantDb } from "../db/tenant-db.js";
|
|
4
4
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
5
5
|
import { buildAppSchema } from "../engine/build-app-schema.js";
|
|
6
6
|
import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
|
|
@@ -69,6 +69,24 @@ export function withFileProviderResolver(registry, context, cacheSync) {
|
|
|
69
69
|
}),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
// A payload-bucket handler without a resolver would answer every call with a
|
|
73
|
+
// 500 at request time; fail at boot instead.
|
|
74
|
+
function assertNoPayloadRateLimitsWithoutResolver(registry) {
|
|
75
|
+
const offenders = [];
|
|
76
|
+
for (const feature of registry.features.values()) {
|
|
77
|
+
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
78
|
+
if (handler.additionalRateLimits !== undefined) {
|
|
79
|
+
offenders.push(`${feature.name}:write:${name}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// skip: every handler's payload buckets are covered by the resolver
|
|
84
|
+
if (offenders.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
throw new Error(`Handlers declare additionalRateLimits but no RateLimitResolver is configured: ${offenders.join(", ")}. ` +
|
|
87
|
+
"Wire a Redis client on context.redis (or pass context.rateLimit), or drop the payload buckets " +
|
|
88
|
+
"(for the waitlist: emailRateLimits: []).");
|
|
89
|
+
}
|
|
72
90
|
export function buildServer(options) {
|
|
73
91
|
assertSeedModeNotInProduction();
|
|
74
92
|
// Single effective hop-count for every IP-derived rate-limit + requestMeta
|
|
@@ -217,12 +235,11 @@ export function buildServer(options) {
|
|
|
217
235
|
// Master for the payload-bucket HMAC: the current signing secret (the
|
|
218
236
|
// keyring's signKid for rotation setups). buildServer always has one, so
|
|
219
237
|
// the digest is available whenever a handler declares additionalRateLimits.
|
|
220
|
-
const payloadDigest = createPayloadDigest(
|
|
221
|
-
? options.jwtSecret
|
|
222
|
-
: (options.jwtSecret.keys[options.jwtSecret.signKid] ?? ""));
|
|
238
|
+
const payloadDigest = createPayloadDigest(payloadDigestMasterSecret(options.jwtSecret));
|
|
223
239
|
const rateLimitResolver = options.context.rateLimit ??
|
|
224
240
|
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
225
241
|
if (!rateLimitResolver) {
|
|
242
|
+
assertNoPayloadRateLimitsWithoutResolver(options.registry);
|
|
226
243
|
// The systemScope default limit only exists once a resolver does, and an app that
|
|
227
244
|
// declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
|
|
228
245
|
// false) — so its cross-tenant handlers are silently unlimited.
|
|
@@ -698,9 +715,7 @@ export function buildServer(options) {
|
|
|
698
715
|
canWriteRecord: createRecordWriteCheck({
|
|
699
716
|
registry: options.registry,
|
|
700
717
|
canReadRecord,
|
|
701
|
-
tenantDbFor: (tenantId) =>
|
|
702
|
-
projectionRegistry: options.registry,
|
|
703
|
-
}),
|
|
718
|
+
tenantDbFor: (tenantId) => createProjectingTenantDb(fileDb, tenantId, options.registry),
|
|
704
719
|
}),
|
|
705
720
|
...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
|
|
706
721
|
...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
|
|
@@ -836,9 +851,7 @@ export function buildServer(options) {
|
|
|
836
851
|
dispatchSystemQuery,
|
|
837
852
|
clientIpResolver,
|
|
838
853
|
});
|
|
839
|
-
mountHonoRoute(app, route.method, route.path, honoHandler, route.
|
|
840
|
-
? bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES)
|
|
841
|
-
: []);
|
|
854
|
+
mountHonoRoute(app, route.method, route.path, honoHandler, bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES));
|
|
842
855
|
}
|
|
843
856
|
}
|
|
844
857
|
// /version-Default registriert NACH feature-routes — Hono "first match
|
|
@@ -949,6 +962,13 @@ c, dispatcher, clientIpResolver) {
|
|
|
949
962
|
export function makeDispatchSystemWrite(dispatcher) {
|
|
950
963
|
return ({ handlerQn, payload, tenantId }) => dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
|
|
951
964
|
}
|
|
965
|
+
// Privilege-scope: the system user gets exactly the roles the caller names —
|
|
966
|
+
// no implicit SystemAdmin — so a seed can write data gated on app roles.
|
|
967
|
+
// Same trust requirement as makeDispatchSystemWrite: never expose to a raw
|
|
968
|
+
// request, only to code that already proved its own authenticity.
|
|
969
|
+
export function makeDispatchWriteAs(dispatcher) {
|
|
970
|
+
return ({ handlerQn, payload, tenantId, roles }) => dispatcher.write(handlerQn, payload, createSystemUser(tenantId, roles));
|
|
971
|
+
}
|
|
952
972
|
export function makeDispatchSystemQuery(dispatcher) {
|
|
953
973
|
return ({ handlerQn, payload, tenantId }) => dispatcher.query(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
|
|
954
974
|
}
|
|
@@ -1199,3 +1219,12 @@ function entitiesWithSearchableScreen(registry) {
|
|
|
1199
1219
|
}
|
|
1200
1220
|
return [...entities];
|
|
1201
1221
|
}
|
|
1222
|
+
function payloadDigestMasterSecret(jwtSecret) {
|
|
1223
|
+
if (typeof jwtSecret === "string")
|
|
1224
|
+
return jwtSecret;
|
|
1225
|
+
const signingSecret = jwtSecret.keys[jwtSecret.signKid];
|
|
1226
|
+
if (signingSecret === undefined) {
|
|
1227
|
+
throw new Error(`[kumiko] jwtSecret.signKid "${jwtSecret.signKid}" not found in jwtSecret.keys`);
|
|
1228
|
+
}
|
|
1229
|
+
return signingSecret;
|
|
1230
|
+
}
|
|
@@ -19,5 +19,6 @@ export declare function hostOnlyDeleteOptions(name: string): {
|
|
|
19
19
|
path: "/";
|
|
20
20
|
secure?: true;
|
|
21
21
|
};
|
|
22
|
+
export declare function expireLegacySessionCookieVariants(c: Context, cookies: ResolvedSessionCookies): void;
|
|
22
23
|
export declare function clearSessionCookies(c: Context, cookies: ResolvedSessionCookies): void;
|
|
23
24
|
export declare function hasDuplicateSessionCookie(c: Context, names: SessionCookieNames): boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, isHostPrefixedCookieName, resolveSessionCookieNames, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
1
|
+
import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, isHostPrefixedCookieName, resolveSessionCookieNames, sessionCookieName, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
2
2
|
import { deleteCookie } from "hono/cookie";
|
|
3
3
|
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, sessionCookieName, TENANT_COOKIE_BASE, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
4
4
|
const SESSION_COOKIES_KEY = "sessionCookies";
|
|
@@ -41,6 +41,24 @@ export function getSessionCookies(c, fallback) {
|
|
|
41
41
|
export function hostOnlyDeleteOptions(name) {
|
|
42
42
|
return isHostPrefixedCookieName(name) ? { path: "/", secure: true } : { path: "/" };
|
|
43
43
|
}
|
|
44
|
+
// Expires the name variant the current config does not write, in both
|
|
45
|
+
// directions: unprefixed leftovers when names are `__Host-` prefixed, and the
|
|
46
|
+
// `__Host-` cookies when a later cookieDomain or secureCookies:false resolves
|
|
47
|
+
// to unprefixed names. Browsers prefer the prefixed csrf cookie when reading
|
|
48
|
+
// the token, so a leftover one would 403 every write until it expires.
|
|
49
|
+
export function expireLegacySessionCookieVariants(c, cookies) {
|
|
50
|
+
if (isHostPrefixedCookieName(cookies.names.auth)) {
|
|
51
|
+
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
|
|
52
|
+
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
|
|
53
|
+
// skip: the unprefixed names are the only legacy variant when the config writes __Host- names.
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (cookies.secure) {
|
|
57
|
+
deleteCookie(c, sessionCookieName(AUTH_COOKIE_BASE, { secure: true }), hostPrefixedDelete);
|
|
58
|
+
deleteCookie(c, sessionCookieName(CSRF_COOKIE_BASE, { secure: true }), hostPrefixedDelete);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const hostPrefixedDelete = { path: "/", secure: true };
|
|
44
62
|
// Expire every variant: the Domain cookie (current) and the host-only one
|
|
45
63
|
// (from before cookieDomain), otherwise a cookie left over from an earlier
|
|
46
64
|
// config keeps the session alive after logout. With __Host- names the
|
|
@@ -50,10 +68,7 @@ export function clearSessionCookies(c, cookies) {
|
|
|
50
68
|
const { names, domain } = cookies;
|
|
51
69
|
deleteCookie(c, names.auth, hostOnlyDeleteOptions(names.auth));
|
|
52
70
|
deleteCookie(c, names.csrf, hostOnlyDeleteOptions(names.csrf));
|
|
53
|
-
|
|
54
|
-
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
|
|
55
|
-
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
|
|
56
|
-
}
|
|
71
|
+
expireLegacySessionCookieVariants(c, cookies);
|
|
57
72
|
if (domain !== undefined) {
|
|
58
73
|
deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain });
|
|
59
74
|
deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain });
|
package/dist/api/sse-route.js
CHANGED
|
@@ -3,7 +3,8 @@ 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 { getAuthTokenExpiry, getUser } from "./auth-middleware.js";
|
|
6
|
+
import { getAuthTokenExpiry, getStaleCredentialCode, getUser } from "./auth-middleware.js";
|
|
7
|
+
import { clearSessionCookies, getSessionCookies } from "./session-cookies.js";
|
|
7
8
|
import { accessInvalidationCredentialFor } from "./sse-broker.js";
|
|
8
9
|
import { scheduleTokenExpiry } from "./token-expiry-timer.js";
|
|
9
10
|
/**
|
|
@@ -35,6 +36,10 @@ export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
|
35
36
|
function isEntityEventData(data) {
|
|
36
37
|
return typeof data["aggregateType"] === "string";
|
|
37
38
|
}
|
|
39
|
+
// First frame on a stream that was downgraded to anonymous because the browser's auth
|
|
40
|
+
// cookie is stale. A downgraded stream answers 200, so EventSource never reports the
|
|
41
|
+
// session end by itself.
|
|
42
|
+
const SESSION_ENDED_SSE_EVENT = "session-ended";
|
|
38
43
|
// Whitelist, never a spread: the wire frame is a signal without field
|
|
39
44
|
// values even if a broker or pusher put a payload into event.data.
|
|
40
45
|
function toEntitySignalWire(event, data) {
|
|
@@ -92,7 +97,17 @@ export function createSseRoute(broker, options) {
|
|
|
92
97
|
// JWT expiry raises no access-invalidation event, so the stream must end
|
|
93
98
|
// itself when the token it was opened with expires.
|
|
94
99
|
const tokenExpiresAtSec = getAuthTokenExpiry(c);
|
|
100
|
+
const staleCredentialCode = getStaleCredentialCode(c);
|
|
101
|
+
// JS cannot remove the HttpOnly auth cookie, so the response does.
|
|
102
|
+
if (staleCredentialCode !== undefined)
|
|
103
|
+
clearSessionCookies(c, getSessionCookies(c));
|
|
95
104
|
return streamSSE(c, async (stream) => {
|
|
105
|
+
if (staleCredentialCode !== undefined) {
|
|
106
|
+
await stream.writeSSE({
|
|
107
|
+
event: SESSION_ENDED_SSE_EVENT,
|
|
108
|
+
data: JSON.stringify({ code: staleCredentialCode }),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
96
111
|
let resolveEnded;
|
|
97
112
|
const ended = new Promise((resolve) => {
|
|
98
113
|
resolveEnded = resolve;
|
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
// nach drizzle-removal. Erstellt parallel zum legacy `db/connection.ts`
|
|
4
4
|
// — apps migrieren schritt-für-schritt, alte Datei weg sobald 0 Konsumenten.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// pending). Nach Landung des Bun-LISTEN-Supports: peer raus.
|
|
9
|
-
import postgres from "postgres";
|
|
6
|
+
// LISTEN for the event dispatcher uses Bun.SQL's listen() on its own
|
|
7
|
+
// dedicated connection, outside the pool's idleTimeout.
|
|
10
8
|
import { readPositiveIntEnv } from "../utils/env-parse.js";
|
|
11
9
|
export function createBunDbConnection(url, options = {}) {
|
|
12
10
|
const bunOpts = {};
|
|
@@ -18,22 +16,11 @@ export function createBunDbConnection(url, options = {}) {
|
|
|
18
16
|
bunOpts.connectionTimeout = options.connectTimeoutSeconds;
|
|
19
17
|
}
|
|
20
18
|
const db = new Bun.SQL(url, bunOpts);
|
|
21
|
-
// LISTEN-only peer — minimal pool (1 connection reicht), eigener idle/
|
|
22
|
-
// connect-timeout falls gesetzt. Wenn das wegfällt, läuft event-dispatcher
|
|
23
|
-
// auf polling-only ohne LISTEN-Wakeup.
|
|
24
|
-
const pgOpts = { max: 1 };
|
|
25
|
-
if (options.idleTimeoutSeconds !== undefined)
|
|
26
|
-
pgOpts.idle_timeout = options.idleTimeoutSeconds;
|
|
27
|
-
if (options.connectTimeoutSeconds !== undefined) {
|
|
28
|
-
pgOpts.connect_timeout = options.connectTimeoutSeconds;
|
|
29
|
-
}
|
|
30
|
-
const listenClient = postgres(url, pgOpts);
|
|
31
19
|
return {
|
|
32
20
|
db,
|
|
33
|
-
listenClient,
|
|
21
|
+
listenClient: db,
|
|
34
22
|
close: async () => {
|
|
35
23
|
await db.end();
|
|
36
|
-
await listenClient.end();
|
|
37
24
|
},
|
|
38
25
|
};
|
|
39
26
|
}
|
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 { InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, 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, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectInnerJoin, 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, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, 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, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
|
4
|
+
export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
|
package/dist/bun-db/query.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AggregateRow, AggregateSpec } from "@cosmicdrift/kumiko-types/aggregate-types";
|
|
2
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
3
|
import type { SelectOptions, WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
3
4
|
import type { EntityTableMeta } from "../db/entity-table-meta.js";
|
|
4
5
|
import { type NotExecutorOnly } from "../db/table-builder.js";
|
|
@@ -6,13 +7,23 @@ import { asEntityTableMeta, requireEntityTableMeta } from "../db/table-introspec
|
|
|
6
7
|
import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
|
|
7
8
|
import type { BunDbRunner } from "./connection.js";
|
|
8
9
|
export { asEntityTableMeta, requireEntityTableMeta };
|
|
9
|
-
type
|
|
10
|
+
export type ReservedClient = {
|
|
11
|
+
unsafe: RawClient["unsafe"];
|
|
12
|
+
release: () => unknown;
|
|
13
|
+
};
|
|
14
|
+
export type RawClient = {
|
|
10
15
|
unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
|
|
11
|
-
begin
|
|
16
|
+
begin?: <T>(fn: (tx: BunDbRunner) => Promise<T>) => Promise<T>;
|
|
12
17
|
savepoint?: <T>(fn: (sp: unknown) => Promise<T>) => Promise<T>;
|
|
13
18
|
release?: unknown;
|
|
19
|
+
reserve?: () => Promise<ReservedClient>;
|
|
20
|
+
};
|
|
21
|
+
export type RawClientWithBegin = RawClient & {
|
|
22
|
+
begin: NonNullable<RawClient["begin"]>;
|
|
14
23
|
};
|
|
24
|
+
export declare function hasBegin(client: RawClient): client is RawClientWithBegin;
|
|
15
25
|
export declare function asRawClient(db: unknown): RawClient;
|
|
26
|
+
export declare function runnerCanBeginTransaction(db: unknown): boolean;
|
|
16
27
|
export declare function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T>;
|
|
17
28
|
export declare function runInSavepointIfSupported<T>(db: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
|
|
18
29
|
export type AnyDb = BunDbRunner | unknown;
|
|
@@ -28,7 +39,8 @@ export type TableInfo = {
|
|
|
28
39
|
readonly hasColumn: (fieldOrColumn: string) => boolean;
|
|
29
40
|
};
|
|
30
41
|
export declare function extractTableInfo(table: TableLike): TableInfo;
|
|
31
|
-
export declare function coerceRow<
|
|
42
|
+
export declare function coerceRow<TOut = Record<string, unknown>>(row: Record<string, unknown>, info: TableInfo): TOut;
|
|
43
|
+
export declare function instantToTimestamptzParam(instant: Temporal.Instant): string;
|
|
32
44
|
export declare function isClosedConnectionError(err: unknown): boolean;
|
|
33
45
|
export declare function describeClosedConnectionFailure(raw: unknown, err: unknown, attempts: number): Record<string, unknown> | undefined;
|
|
34
46
|
export declare function unsafeReadRetrying<TRow>(db: AnyDb, sqlText: string, params: readonly unknown[]): Promise<readonly TRow[]>;
|
package/dist/bun-db/query.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// Static import (not getTemporal()): coercion works without an installed
|
|
22
22
|
// global and returns the same Temporal classes as the rest of the repo (#1480).
|
|
23
23
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
24
|
-
import { isOneOf,
|
|
24
|
+
import { isOneOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
25
25
|
import { requestContext } from "../api/request-context.js";
|
|
26
26
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
|
|
27
27
|
import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
|
|
@@ -50,6 +50,10 @@ function snakeToCamel(key) {
|
|
|
50
50
|
// would overwrite the matching meta key — reading the meta from the symbol is
|
|
51
51
|
// the only collision-safe path.
|
|
52
52
|
export { asEntityTableMeta, requireEntityTableMeta };
|
|
53
|
+
export function hasBegin(client) {
|
|
54
|
+
// Bun.SQL tx/savepoint handles keep a begin() that throws; only savepoint() tells them apart from a pool.
|
|
55
|
+
return typeof client.begin === "function" && typeof client.savepoint !== "function";
|
|
56
|
+
}
|
|
53
57
|
function isRawClient(value) {
|
|
54
58
|
return typeof propertyOf(value, "unsafe") === "function";
|
|
55
59
|
}
|
|
@@ -77,6 +81,9 @@ export function asRawClient(db) {
|
|
|
77
81
|
return sessionClient;
|
|
78
82
|
throw new Error("bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, or a transaction handle.");
|
|
79
83
|
}
|
|
84
|
+
export function runnerCanBeginTransaction(db) {
|
|
85
|
+
return hasBegin(asRawClient(db));
|
|
86
|
+
}
|
|
80
87
|
// postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
|
|
81
88
|
// it errored — even if the JS error was caught (the driver tracks per-block
|
|
82
89
|
// query failures). Error confinement therefore must go through the driver's
|
|
@@ -107,16 +114,27 @@ export async function runInSavepointIfSupported(db, fn) {
|
|
|
107
114
|
const asRunner = (sp) => sp;
|
|
108
115
|
if (typeof raw.savepoint !== "function")
|
|
109
116
|
return fn(asRunner(db));
|
|
117
|
+
// An error from inside fn must never be remapped, even if it carries a closed-connection code.
|
|
118
|
+
let fnStarted = false;
|
|
110
119
|
try {
|
|
111
|
-
return await raw.savepoint((sp) =>
|
|
120
|
+
return await raw.savepoint((sp) => {
|
|
121
|
+
fnStarted = true;
|
|
122
|
+
return fn(asRunner(sp));
|
|
123
|
+
});
|
|
112
124
|
}
|
|
113
125
|
catch (e) {
|
|
114
|
-
|
|
126
|
+
const code = extractPgError(e)?.code;
|
|
127
|
+
if (!fnStarted && (code === "25P01" || code === "ERR_POSTGRES_CONNECTION_CLOSED")) {
|
|
115
128
|
throw new InternalError({
|
|
116
|
-
message:
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
129
|
+
message: code === "25P01"
|
|
130
|
+
? "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
|
|
131
|
+
"this is almost always database work in an afterCommit hook. afterCommit is the default " +
|
|
132
|
+
"phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
|
|
133
|
+
"roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback."
|
|
134
|
+
: "runInSavepointIfSupported: the transaction handle's connection is gone — the transaction " +
|
|
135
|
+
"already committed (almost always database work in an afterCommit hook) or the connection dropped. " +
|
|
136
|
+
"afterCommit is the default phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction " +
|
|
137
|
+
"for writes that must roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
|
|
120
138
|
cause: e instanceof Error ? e : undefined,
|
|
121
139
|
});
|
|
122
140
|
}
|
|
@@ -210,13 +228,8 @@ function quoteIdent(name) {
|
|
|
210
228
|
//
|
|
211
229
|
// Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
|
|
212
230
|
// params — convert to ISO string when the column pgType is timestamptz.
|
|
213
|
-
function toStringTagOf(v) {
|
|
214
|
-
return typeof v === "object" && v !== null && Symbol.toStringTag in v
|
|
215
|
-
? v[Symbol.toStringTag]
|
|
216
|
-
: undefined;
|
|
217
|
-
}
|
|
218
231
|
function isTemporalInstant(v) {
|
|
219
|
-
return
|
|
232
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
|
|
220
233
|
}
|
|
221
234
|
// Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
|
|
222
235
|
// drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
|
|
@@ -266,10 +279,10 @@ function instantFromDriver(value) {
|
|
|
266
279
|
return null;
|
|
267
280
|
}
|
|
268
281
|
function isTemporalPlainDate(v) {
|
|
269
|
-
return
|
|
282
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
|
|
270
283
|
}
|
|
271
284
|
function isTemporalPlainDateTimeLike(v) {
|
|
272
|
-
const tag =
|
|
285
|
+
const tag = propertyOf(v, Symbol.toStringTag);
|
|
273
286
|
return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
|
|
274
287
|
typeof v === "object" &&
|
|
275
288
|
v !== null &&
|
|
@@ -329,8 +342,10 @@ function plainDateFromDriver(value, column) {
|
|
|
329
342
|
//
|
|
330
343
|
// Unknown columns (computed/aliased) pass through unchanged.
|
|
331
344
|
export function coerceRow(row, info) {
|
|
332
|
-
|
|
333
|
-
|
|
345
|
+
// @cast-boundary db-row — the driver row (raw column names/types) becomes the caller's entity row shape here, and only here
|
|
346
|
+
return coerceDriverRow(row, info);
|
|
347
|
+
}
|
|
348
|
+
function coerceDriverRow(row, info) {
|
|
334
349
|
const out = {};
|
|
335
350
|
let changed = false;
|
|
336
351
|
for (const key of Object.keys(row)) {
|
|
@@ -400,7 +415,7 @@ export function coerceRow(row, info) {
|
|
|
400
415
|
changed = true;
|
|
401
416
|
out[fieldName] = coerced;
|
|
402
417
|
}
|
|
403
|
-
return
|
|
418
|
+
return changed ? out : row;
|
|
404
419
|
}
|
|
405
420
|
function coerceRows(rows, info) {
|
|
406
421
|
return rows.map((r) => coerceRow(r, info));
|
|
@@ -451,6 +466,31 @@ function prepareJsonbValue(value) {
|
|
|
451
466
|
}
|
|
452
467
|
return { kind: "param", sql: "::jsonb", bound: value };
|
|
453
468
|
}
|
|
469
|
+
const INTEGER_PG_TYPES = new Set([
|
|
470
|
+
"integer",
|
|
471
|
+
"int4",
|
|
472
|
+
"smallint",
|
|
473
|
+
"int2",
|
|
474
|
+
"bigint",
|
|
475
|
+
"int8",
|
|
476
|
+
"serial",
|
|
477
|
+
"bigserial",
|
|
478
|
+
]);
|
|
479
|
+
// postgres.js truncates timestamptz params to ms via Date; Bun.SQL sends µs and Postgres
|
|
480
|
+
// rounds into timestamptz(3). Truncating here keeps both drivers storing the same instant.
|
|
481
|
+
export function instantToTimestamptzParam(instant) {
|
|
482
|
+
return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
|
|
483
|
+
}
|
|
484
|
+
function prepareDriverNeutralScalar(value, pgType) {
|
|
485
|
+
if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
|
|
486
|
+
return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
|
|
487
|
+
}
|
|
488
|
+
// Bun.SQL binds JS numbers typed, so Postgres would round 3.5 into an integer column; postgres.js sends text and rejects it.
|
|
489
|
+
if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
|
|
490
|
+
return { kind: "param", sql: "", bound: String(value) };
|
|
491
|
+
}
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
454
494
|
function prepareValue(value, pgType) {
|
|
455
495
|
if (isSqlExpression(value)) {
|
|
456
496
|
return { kind: "literal", literal: value.text };
|
|
@@ -465,9 +505,9 @@ function prepareValue(value, pgType) {
|
|
|
465
505
|
if (prepared)
|
|
466
506
|
return prepared;
|
|
467
507
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
508
|
+
const scalar = prepareDriverNeutralScalar(value, pgType);
|
|
509
|
+
if (scalar)
|
|
510
|
+
return scalar;
|
|
471
511
|
if (pgType === "date") {
|
|
472
512
|
const prepared = prepareDateValue(value);
|
|
473
513
|
if (prepared)
|
|
@@ -547,8 +587,14 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
547
587
|
throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
|
|
548
588
|
}
|
|
549
589
|
const matches = isJsonTextMatchList(value.jsonText) ? value.jsonText : [value.jsonText];
|
|
590
|
+
if (matches.length === 0) {
|
|
591
|
+
conditions.push("FALSE");
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
550
594
|
for (const match of matches) {
|
|
551
|
-
if (
|
|
595
|
+
if (match === null ||
|
|
596
|
+
typeof match !== "object" ||
|
|
597
|
+
!Array.isArray(match.keys) ||
|
|
552
598
|
match.keys.length === 0 ||
|
|
553
599
|
!match.keys.every((k) => typeof k === "string") ||
|
|
554
600
|
typeof match.eq !== "string") {
|
|
@@ -1376,12 +1422,18 @@ export async function deleteManyBatched(db, table, where, options) {
|
|
|
1376
1422
|
return { deleted, batches };
|
|
1377
1423
|
}
|
|
1378
1424
|
export async function transaction(db, fn) {
|
|
1379
|
-
|
|
1425
|
+
const raw = asRawClient(db);
|
|
1426
|
+
if (!hasBegin(raw)) {
|
|
1427
|
+
throw new InternalError({
|
|
1428
|
+
message: "transaction: runner has no begin() — already inside a transaction or savepoint.",
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
return raw.begin(fn);
|
|
1380
1432
|
}
|
|
1381
1433
|
// A tx/savepoint handle has no begin(); fail closed instead of nesting silently or TypeError-ing.
|
|
1382
1434
|
export async function runInNewTransaction(db, fn) {
|
|
1383
1435
|
const raw = asRawClient(db);
|
|
1384
|
-
if (
|
|
1436
|
+
if (!hasBegin(raw)) {
|
|
1385
1437
|
throw new InternalError({
|
|
1386
1438
|
message: "runInNewTransaction: runner has no begin() — already inside a transaction or savepoint.",
|
|
1387
1439
|
});
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
2
|
+
export declare function isPlainUserOwnedField(field: EntityDefinition["fields"][string]): boolean;
|
|
2
3
|
export declare function personalFieldsRekeyedOnOwnerTransition(entity: EntityDefinition, previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { PII_ERASED_SENTINEL } from "./kms-adapter.js";
|
|
2
2
|
import { isPiiCiphertext } from "./pii-ciphertext-format.js";
|
|
3
3
|
import { nonEmptyString } from "./subject-resolver.js";
|
|
4
|
+
// userOwned and not recordOwned: the value follows the row's current owner.
|
|
5
|
+
export function isPlainUserOwnedField(field) {
|
|
6
|
+
if (!("userOwned" in field) || field.userOwned === undefined)
|
|
7
|
+
return false;
|
|
8
|
+
return !("recordOwned" in field && field.recordOwned === true);
|
|
9
|
+
}
|
|
4
10
|
// Personal fields keep their stored value when an update moves the row to a
|
|
5
11
|
// new owner, so without re-keying they would stay shreddable only by the old
|
|
6
12
|
// owner. Returns the unchanged-by-the-caller values that must be written again
|
|
@@ -9,9 +15,7 @@ import { nonEmptyString } from "./subject-resolver.js";
|
|
|
9
15
|
export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
|
|
10
16
|
const rekeyed = {};
|
|
11
17
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
12
|
-
if (!("userOwned" in field) || field.userOwned
|
|
13
|
-
continue;
|
|
14
|
-
if ("recordOwned" in field && field.recordOwned === true)
|
|
18
|
+
if (!isPlainUserOwnedField(field) || !("userOwned" in field) || !field.userOwned)
|
|
15
19
|
continue;
|
|
16
20
|
if (name in submittedChanges)
|
|
17
21
|
continue;
|
|
@@ -13,6 +13,7 @@ export interface EncryptPiiOptions {
|
|
|
13
13
|
readonly subjectSource?: Record<string, unknown>;
|
|
14
14
|
readonly entityName: string;
|
|
15
15
|
readonly ownerFallback?: Record<string, unknown>;
|
|
16
|
+
readonly onErasedSubject?: "sentinel";
|
|
16
17
|
}
|
|
17
18
|
export type PiiEncryptedRow<TRow extends Record<string, unknown>> = {
|
|
18
19
|
[K in keyof TRow]: TRow[K] extends string ? string : TRow[K] | string;
|
|
@@ -161,8 +161,15 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
|
|
|
161
161
|
// skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
|
|
162
162
|
if (subject === null)
|
|
163
163
|
continue;
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
try {
|
|
165
|
+
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
166
|
+
out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (!(e instanceof KeyErasedError) || opts.onErasedSubject !== "sentinel")
|
|
170
|
+
throw e;
|
|
171
|
+
out[name] = PII_ERASED_SENTINEL;
|
|
172
|
+
}
|
|
166
173
|
}
|
|
167
174
|
return out;
|
|
168
175
|
}
|
|
@@ -30,5 +30,6 @@ export interface EventSubjectEnvelope {
|
|
|
30
30
|
export declare function resolveEventSubject(fieldName: string, spec: EventPiiSubject, payload: Record<string, unknown>, envelope: EventSubjectEnvelope): SubjectId | null;
|
|
31
31
|
export declare function collectPiiSubjectFields(entity: EntityDefinition): readonly string[];
|
|
32
32
|
export declare function collectPiiEncryptedFields(entity: EntityDefinition): readonly string[];
|
|
33
|
+
export declare function collectPiiFileReferenceFields(entity: EntityDefinition): readonly string[];
|
|
33
34
|
/** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
|
|
34
35
|
export declare function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[];
|
|
@@ -155,6 +155,13 @@ export function collectPiiEncryptedFields(entity) {
|
|
|
155
155
|
.filter(([, field]) => hasSubjectAnnotation(field) && !isFileReferenceField(field))
|
|
156
156
|
.map(([name]) => name);
|
|
157
157
|
}
|
|
158
|
+
// The annotated file-reference fields: never ciphertext today, but events from the
|
|
159
|
+
// old backfill (which encrypted them) still carry it and must replay.
|
|
160
|
+
export function collectPiiFileReferenceFields(entity) {
|
|
161
|
+
return Object.entries(entity.fields)
|
|
162
|
+
.filter(([, field]) => hasSubjectAnnotation(field) && isFileReferenceField(field))
|
|
163
|
+
.map(([name]) => name);
|
|
164
|
+
}
|
|
158
165
|
/** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
|
|
159
166
|
export function collectSearchableSubjectFields(entity) {
|
|
160
167
|
return Object.entries(entity.fields)
|
package/dist/db/api.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
1
2
|
export type DbConnectionOptions = {
|
|
2
3
|
readonly maxConnections?: number;
|
|
3
4
|
readonly idleTimeoutSeconds?: number;
|
|
@@ -8,10 +9,10 @@ export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
|
8
9
|
export type DbPoolHandle = {
|
|
9
10
|
/** Provider Connection — Calls gehen über asRawClient() oder direkt. */
|
|
10
11
|
readonly db: any;
|
|
11
|
-
/**
|
|
12
|
+
/** Raw driver client (postgres-js or Bun.SQL) */
|
|
12
13
|
readonly client: any;
|
|
13
|
-
/**
|
|
14
|
-
readonly listenClient?:
|
|
14
|
+
/** LISTEN-capable client; Bun.SQL serves it from its own dedicated connection */
|
|
15
|
+
readonly listenClient?: PgListenClient;
|
|
15
16
|
/** Pool schliessen */
|
|
16
17
|
close: () => Promise<void>;
|
|
17
18
|
};
|
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
// - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
|
|
45
45
|
// Aggregate). Caller no-op.
|
|
46
46
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
47
|
+
import { requestContext } from "../api/request-context.js";
|
|
47
48
|
import { collectLookupableFields, computeBlindIndexValues } from "../crypto/blind-index.js";
|
|
49
|
+
import { configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/pii-field-encryption.js";
|
|
50
|
+
import { collectPiiFileReferenceFields } from "../crypto/subject-resolver.js";
|
|
48
51
|
import { deleteMany, insertOne, updateMany } from "../db/query.js";
|
|
49
52
|
import { InternalError } from "../errors/index.js";
|
|
50
53
|
const AUTO_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
|
|
@@ -57,6 +60,27 @@ export function parseAutoVerb(event) {
|
|
|
57
60
|
const verb = event.type.slice(prefix.length);
|
|
58
61
|
return isOneOf(AUTO_VERBS, verb) ? verb : null;
|
|
59
62
|
}
|
|
63
|
+
// Events written by the old PII backfill can hold ciphertext in file/image uuid
|
|
64
|
+
// columns. Replay restores the plain fileRef id; an erased subject yields null.
|
|
65
|
+
async function decryptFileReferenceCiphertext(entity, values) {
|
|
66
|
+
const ciphertextFields = collectPiiFileReferenceFields(entity).filter((name) => isPiiCiphertext(values[name]));
|
|
67
|
+
if (ciphertextFields.length === 0)
|
|
68
|
+
return values;
|
|
69
|
+
const kms = configuredPiiSubjectKms();
|
|
70
|
+
if (kms === undefined) {
|
|
71
|
+
throw new InternalError({
|
|
72
|
+
message: `applyEntityEvent: file field(s) ${ciphertextFields.join(", ")} hold PII ciphertext but no subject KMS is configured; configure the KMS before replaying.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const decrypted = await decryptPiiFieldValues(values, ciphertextFields, kms, {
|
|
76
|
+
requestId: requestContext.get()?.requestId ?? "apply-entity-event",
|
|
77
|
+
});
|
|
78
|
+
for (const name of ciphertextFields) {
|
|
79
|
+
if (decrypted[name] === PII_ERASED_SENTINEL)
|
|
80
|
+
decrypted[name] = null;
|
|
81
|
+
}
|
|
82
|
+
return decrypted;
|
|
83
|
+
}
|
|
60
84
|
/** Idempotente Anwendung eines Auto-Events auf die Entity-Tabelle.
|
|
61
85
|
* Wird sowohl beim Live-Append (innerhalb der Write-TX) als auch beim
|
|
62
86
|
* Rebuild (innerhalb der Rebuild-TX) gerufen — identische Logik. */
|
|
@@ -92,7 +116,7 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
92
116
|
});
|
|
93
117
|
}
|
|
94
118
|
const row = await insertOne(tx, table, {
|
|
95
|
-
...event.payload,
|
|
119
|
+
...(await decryptFileReferenceCiphertext(entity, event.payload)),
|
|
96
120
|
// Blind-Index-Spalten aus dem Payload-Wert (ciphertext → decrypt →
|
|
97
121
|
// HMAC, plaintext → HMAC, erased → NULL). Hier statt im Executor,
|
|
98
122
|
// damit Live-Write und Rebuild denselben Wert produzieren und der
|
|
@@ -110,7 +134,7 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
110
134
|
// payload-Shape: { changes, previous } — siehe event-store-executor.ts.
|
|
111
135
|
const rawChanges = (event.payload["changes"] ?? {}); // @cast-boundary engine-payload
|
|
112
136
|
const changes = {};
|
|
113
|
-
for (const [key, value] of Object.entries(rawChanges)) {
|
|
137
|
+
for (const [key, value] of Object.entries(await decryptFileReferenceCiphertext(entity, rawChanges))) {
|
|
114
138
|
const field = entity.fields[key];
|
|
115
139
|
// Historical events can carry fields since removed from the entity; the
|
|
116
140
|
// tolerance lives here (not in updateMany) so a live typo still throws.
|