@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1
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 +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/webhook-step.md +11 -1
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +29 -4
- 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 +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pg-kms-adapter.js +10 -6
- 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/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +85 -58
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- 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.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.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.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- 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/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -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/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- 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.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- 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/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +183 -48
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- 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/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- 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 +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
- package/dist/pipeline/multi-stream-apply-context.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- 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 +4 -4
- package/src/changes.json +323 -0
package/dist/api/auth-routes.js
CHANGED
|
@@ -61,7 +61,7 @@ function normalizeCookieDomain(domain) {
|
|
|
61
61
|
}
|
|
62
62
|
// A malformed entry would corrupt the Set-Cookie header; an entry equal to the
|
|
63
63
|
// current cookieDomain would make login delete the cookie it just sets.
|
|
64
|
-
|
|
64
|
+
function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
|
|
65
65
|
const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
|
|
66
66
|
for (const entry of retiredCookieDomains ?? []) {
|
|
67
67
|
if (entry === "" || normalizeCookieDomain(entry) === "") {
|
|
@@ -322,6 +322,10 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
322
322
|
retiredCookieDomains: config.retiredCookieDomains,
|
|
323
323
|
});
|
|
324
324
|
assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
|
|
325
|
+
if (config.mfaVerifyHandler && !config.switchTenantMfaGateHandler) {
|
|
326
|
+
throw new Error("createAuthRoutes: mfaVerifyHandler is set but switchTenantMfaGateHandler is not; " +
|
|
327
|
+
"POST /auth/switch-tenant would mint admin sessions without the second factor");
|
|
328
|
+
}
|
|
325
329
|
const retiredCookieDomains = config.retiredCookieDomains ?? [];
|
|
326
330
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
327
331
|
// see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
|
|
@@ -330,19 +334,45 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
330
334
|
// shared "unknown" bucket.
|
|
331
335
|
const clientIpResolver = config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
|
|
332
336
|
const getClientIp = (c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
333
|
-
|
|
337
|
+
// undefined = no gate wired (setup without MFA). "clearedRoles" are the roles the gate decided on,
|
|
338
|
+
// so the minted session cannot differ from what the gate checked.
|
|
339
|
+
async function switchTenantMfaGate(c, user, targetTenantId) {
|
|
334
340
|
if (!config.switchTenantMfaGateHandler)
|
|
335
341
|
return undefined;
|
|
336
342
|
const gate = await dispatcher.write(config.switchTenantMfaGateHandler, { userId: user.id, tenantId: targetTenantId }, createSystemUser(user.tenantId));
|
|
337
343
|
if (!gate.isSuccess) {
|
|
338
344
|
const status = gate.error.httpStatus; // @cast-boundary engine-payload
|
|
339
|
-
return c.json({ isSuccess: false, error: gate.error }, status);
|
|
345
|
+
return { response: c.json({ isSuccess: false, error: gate.error }, status) };
|
|
340
346
|
}
|
|
341
347
|
// @cast-boundary engine-payload — the gate handler's three-shape result
|
|
342
348
|
const gateData = gate.data;
|
|
343
349
|
if (gateData.kind === "mfa-gate-clear")
|
|
344
|
-
return
|
|
345
|
-
return pendingMfaStepResponse(c, gateData);
|
|
350
|
+
return { clearedRoles: gateData.roles };
|
|
351
|
+
return { response: pendingMfaStepResponse(c, gateData) };
|
|
352
|
+
}
|
|
353
|
+
// Global roles (SystemAdmin etc.) come from the user feature when userQuery is wired, so they survive
|
|
354
|
+
// the tenant switch. buildSessionRoles calls stripForbiddenMembershipRoles on the membership side only — a backstop
|
|
355
|
+
// for a role a projection rebuild resurrected past command-time validation (engine/membership-roles).
|
|
356
|
+
async function resolveSwitchTenantRoles(user, membership) {
|
|
357
|
+
let globalRoles = [];
|
|
358
|
+
if (config.userQuery) {
|
|
359
|
+
try {
|
|
360
|
+
// @cast-boundary engine-payload — dispatcher.query returnt unknown,
|
|
361
|
+
// Per the AuthUserRow contract, userQuery returns a row with a roles column.
|
|
362
|
+
const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
|
|
363
|
+
// roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
|
|
364
|
+
// JSON-encoded text from pre-0.217.0 rows.
|
|
365
|
+
globalRoles = parseRoles(userRow?.roles ?? null);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
// Non-fatal: when global roles cannot be resolved the switch continues with tenant roles
|
|
369
|
+
// only. A server error carrying just the cause would hurt the UX more than a switch without
|
|
370
|
+
// SystemAdmin, which the user notices and reports.
|
|
371
|
+
if (!isUnknownHandlerError(e))
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return buildSessionRoles(globalRoles, membership.roles);
|
|
346
376
|
}
|
|
347
377
|
// Shared tail of every route that ends a request logged-in: create the
|
|
348
378
|
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
@@ -711,13 +741,13 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
711
741
|
}
|
|
712
742
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
713
743
|
const token = await mintSessionAndRespond(c, data.session);
|
|
714
|
-
const
|
|
715
|
-
flow: "signup",
|
|
744
|
+
const signupOutcome = {
|
|
716
745
|
roles: data.session.roles,
|
|
717
746
|
tenantId: data.session.tenantId,
|
|
718
747
|
tenantKey: data.tenantKey,
|
|
719
748
|
...(data.handover !== undefined && { handover: data.handover }),
|
|
720
|
-
}
|
|
749
|
+
};
|
|
750
|
+
const landingPath = landingPathFragment({ flow: "signup", ...signupOutcome });
|
|
721
751
|
return c.json({
|
|
722
752
|
isSuccess: true,
|
|
723
753
|
token,
|
|
@@ -962,49 +992,20 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
962
992
|
return switchTenantRejectionResponse(c, activeMembership.reason);
|
|
963
993
|
}
|
|
964
994
|
const membership = activeMembership.membership;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
let globalRoles = [];
|
|
970
|
-
if (config.userQuery) {
|
|
971
|
-
try {
|
|
972
|
-
// @cast-boundary engine-payload — dispatcher.query returnt unknown,
|
|
973
|
-
// userQuery liefert per AuthUserRow-Contract eine row mit roles-spalte.
|
|
974
|
-
const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
|
|
975
|
-
// roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
|
|
976
|
-
// JSON-encoded text from pre-0.217.0 rows.
|
|
977
|
-
globalRoles = parseRoles(userRow?.roles ?? null);
|
|
978
|
-
}
|
|
979
|
-
catch (e) {
|
|
980
|
-
// Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
|
|
981
|
-
// läuft weiter mit nur tenant-rollen. Server-error mit nur dem
|
|
982
|
-
// Cause ohne Stack hochwerfen wäre für die UX schlimmer als ein
|
|
983
|
-
// Tenant-Switch ohne SystemAdmin (User merkt's und meldet's). Log
|
|
984
|
-
// it via the dispatcher so Ops sieht's.
|
|
985
|
-
if (!isUnknownHandlerError(e))
|
|
986
|
-
throw e;
|
|
987
|
-
}
|
|
988
|
-
}
|
|
995
|
+
const gate = await switchTenantMfaGate(c, user, targetTenantId);
|
|
996
|
+
if (gate !== undefined && "response" in gate)
|
|
997
|
+
return gate.response;
|
|
998
|
+
const sessionRoles = gate !== undefined ? gate.clearedRoles : await resolveSwitchTenantRoles(user, membership);
|
|
989
999
|
// Issue new JWT with the target tenant and its roles. Claims MUST be
|
|
990
1000
|
// recomputed for the new tenant — stale claims from the previous
|
|
991
1001
|
// tenant would leak identity facts across tenancies (e.g. teamId from
|
|
992
1002
|
// tenant A accidentally surviving into tenant B's session). The
|
|
993
1003
|
// resolver runs each feature's r.authClaims() hook under the new
|
|
994
1004
|
// TenantDb scope.
|
|
995
|
-
// buildSessionRoles calls stripForbiddenMembershipRoles internally and
|
|
996
|
-
// strips reserved roles from the membership side only — globalRoles
|
|
997
|
-
// (where SystemAdmin legitimately lives) is never filtered. Backstop for a
|
|
998
|
-
// membership role that a projection rebuild resurrected past command-time
|
|
999
|
-
// validation (see engine/membership-roles).
|
|
1000
|
-
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
1001
|
-
const pendingMfaStep = await switchTenantMfaGateResponse(c, user, targetTenantId);
|
|
1002
|
-
if (pendingMfaStep !== undefined)
|
|
1003
|
-
return pendingMfaStep;
|
|
1004
1005
|
const targetSession = {
|
|
1005
1006
|
id: user.id,
|
|
1006
1007
|
tenantId: targetTenantId,
|
|
1007
|
-
roles:
|
|
1008
|
+
roles: sessionRoles,
|
|
1008
1009
|
// Tenant-independent prefs must survive the switch (fw#2343).
|
|
1009
1010
|
...tenantIndependentPrefs(user),
|
|
1010
1011
|
};
|
|
@@ -1023,7 +1024,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
1023
1024
|
// fresh csrfToken is minted (inside the helper) so a compromised csrf-
|
|
1024
1025
|
// value can't cross a tenant boundary.
|
|
1025
1026
|
const newToken = await mintSessionAndRespond(c, sessionForJwt);
|
|
1026
|
-
return c.json({ token: newToken, tenantId: targetTenantId, roles:
|
|
1027
|
+
return c.json({ token: newToken, tenantId: targetTenantId, roles: sessionRoles });
|
|
1027
1028
|
});
|
|
1028
1029
|
return api;
|
|
1029
1030
|
}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type { MetricsRouteOptions } from "./route-registrars.js";
|
|
|
26
26
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
27
27
|
export { createApiRoutes } from "./routes.js";
|
|
28
28
|
export type { KumikoServer, ServerOptions } from "./server.js";
|
|
29
|
-
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server.js";
|
|
29
|
+
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
|
|
30
30
|
export type { ResolvedSessionCookies, SessionCookieBase, SessionCookieNames, } from "./session-cookies.js";
|
|
31
31
|
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
32
32
|
export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
|
package/dist/api/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export { readonlyRequestContext as requestContext, } from "./request-context.js"
|
|
|
15
15
|
export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./request-id-middleware.js";
|
|
16
16
|
export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
|
|
17
17
|
export { createApiRoutes } from "./routes.js";
|
|
18
|
-
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, } from "./server.js";
|
|
18
|
+
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite, makeDispatchWriteAs, withFileProviderResolver, } from "./server.js";
|
|
19
19
|
export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
|
|
20
20
|
export { createSseBroker } from "./sse-broker.js";
|
|
21
21
|
export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
|
package/dist/api/server.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
-
import type {
|
|
3
|
+
import type { PgListenClient } from "../db/connection.js";
|
|
4
4
|
import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.js";
|
|
5
5
|
import type { ErrorDocsConfig } from "../errors/docs-url.js";
|
|
6
6
|
import type { Lifecycle } from "../lifecycle/index.js";
|
|
@@ -43,7 +43,7 @@ export type ServerOptions = {
|
|
|
43
43
|
jobTrigger?: boolean;
|
|
44
44
|
accessInvalidation?: boolean;
|
|
45
45
|
};
|
|
46
|
-
pgClient?:
|
|
46
|
+
pgClient?: PgListenClient;
|
|
47
47
|
};
|
|
48
48
|
observability?: ObservabilityProvider;
|
|
49
49
|
observabilityOptions?: ObservabilityOptions;
|
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
|
|
@@ -221,6 +239,7 @@ export function buildServer(options) {
|
|
|
221
239
|
const rateLimitResolver = options.context.rateLimit ??
|
|
222
240
|
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
223
241
|
if (!rateLimitResolver) {
|
|
242
|
+
assertNoPayloadRateLimitsWithoutResolver(options.registry);
|
|
224
243
|
// The systemScope default limit only exists once a resolver does, and an app that
|
|
225
244
|
// declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
|
|
226
245
|
// false) — so its cross-tenant handlers are silently unlimited.
|
|
@@ -291,6 +310,13 @@ export function buildServer(options) {
|
|
|
291
310
|
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
|
292
311
|
const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
|
|
293
312
|
const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
|
|
313
|
+
const mspJobs = jobRunnerForTriggers?.dispatch
|
|
314
|
+
? {
|
|
315
|
+
dispatch: async (jobName, payload, dispatchOptions) => {
|
|
316
|
+
await jobRunnerForTriggers.dispatch?.(jobName, payload, dispatchOptions);
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
: undefined;
|
|
294
320
|
const systemConsumers = [];
|
|
295
321
|
if (sseConsumerEnabled) {
|
|
296
322
|
systemConsumers.push(createSseBroadcastEventConsumer(sseBroker));
|
|
@@ -372,6 +398,7 @@ export function buildServer(options) {
|
|
|
372
398
|
...(mspOwner && { callerFeature: mspOwner }),
|
|
373
399
|
...(mspFiles && { files: mspFiles }),
|
|
374
400
|
...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
|
|
401
|
+
...(mspJobs && { jobs: mspJobs }),
|
|
375
402
|
...(mspFiles && {
|
|
376
403
|
derivatives: createDerivativesContext({
|
|
377
404
|
files: mspFiles,
|
|
@@ -696,9 +723,7 @@ export function buildServer(options) {
|
|
|
696
723
|
canWriteRecord: createRecordWriteCheck({
|
|
697
724
|
registry: options.registry,
|
|
698
725
|
canReadRecord,
|
|
699
|
-
tenantDbFor: (tenantId) =>
|
|
700
|
-
projectionRegistry: options.registry,
|
|
701
|
-
}),
|
|
726
|
+
tenantDbFor: (tenantId) => createProjectingTenantDb(fileDb, tenantId, options.registry),
|
|
702
727
|
}),
|
|
703
728
|
...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
|
|
704
729
|
...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
|
|
@@ -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, runnerCanBeginTransaction, 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, runnerCanBeginTransaction, 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,11 +7,16 @@ 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 };
|
|
10
|
+
export type ReservedClient = {
|
|
11
|
+
unsafe: RawClient["unsafe"];
|
|
12
|
+
release: () => unknown;
|
|
13
|
+
};
|
|
9
14
|
export type RawClient = {
|
|
10
15
|
unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
|
|
11
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>;
|
|
14
20
|
};
|
|
15
21
|
export type RawClientWithBegin = RawClient & {
|
|
16
22
|
begin: NonNullable<RawClient["begin"]>;
|
|
@@ -34,6 +40,7 @@ export type TableInfo = {
|
|
|
34
40
|
};
|
|
35
41
|
export declare function extractTableInfo(table: TableLike): TableInfo;
|
|
36
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;
|
|
37
44
|
export declare function isClosedConnectionError(err: unknown): boolean;
|
|
38
45
|
export declare function describeClosedConnectionFailure(raw: unknown, err: unknown, attempts: number): Record<string, unknown> | undefined;
|
|
39
46
|
export declare function unsafeReadRetrying<TRow>(db: AnyDb, sqlText: string, params: readonly unknown[]): Promise<readonly TRow[]>;
|
package/dist/bun-db/query.js
CHANGED
|
@@ -51,7 +51,8 @@ function snakeToCamel(key) {
|
|
|
51
51
|
// the only collision-safe path.
|
|
52
52
|
export { asEntityTableMeta, requireEntityTableMeta };
|
|
53
53
|
export function hasBegin(client) {
|
|
54
|
-
|
|
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";
|
|
55
56
|
}
|
|
56
57
|
function isRawClient(value) {
|
|
57
58
|
return typeof propertyOf(value, "unsafe") === "function";
|
|
@@ -113,16 +114,27 @@ export async function runInSavepointIfSupported(db, fn) {
|
|
|
113
114
|
const asRunner = (sp) => sp;
|
|
114
115
|
if (typeof raw.savepoint !== "function")
|
|
115
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;
|
|
116
119
|
try {
|
|
117
|
-
return await raw.savepoint((sp) =>
|
|
120
|
+
return await raw.savepoint((sp) => {
|
|
121
|
+
fnStarted = true;
|
|
122
|
+
return fn(asRunner(sp));
|
|
123
|
+
});
|
|
118
124
|
}
|
|
119
125
|
catch (e) {
|
|
120
|
-
|
|
126
|
+
const code = extractPgError(e)?.code;
|
|
127
|
+
if (!fnStarted && (code === "25P01" || code === "ERR_POSTGRES_CONNECTION_CLOSED")) {
|
|
121
128
|
throw new InternalError({
|
|
122
|
-
message:
|
|
123
|
-
"
|
|
124
|
-
|
|
125
|
-
|
|
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.",
|
|
126
138
|
cause: e instanceof Error ? e : undefined,
|
|
127
139
|
});
|
|
128
140
|
}
|
|
@@ -216,13 +228,8 @@ function quoteIdent(name) {
|
|
|
216
228
|
//
|
|
217
229
|
// Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
|
|
218
230
|
// params — convert to ISO string when the column pgType is timestamptz.
|
|
219
|
-
function toStringTagOf(v) {
|
|
220
|
-
return typeof v === "object" && v !== null && Symbol.toStringTag in v
|
|
221
|
-
? v[Symbol.toStringTag]
|
|
222
|
-
: undefined;
|
|
223
|
-
}
|
|
224
231
|
function isTemporalInstant(v) {
|
|
225
|
-
return
|
|
232
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
|
|
226
233
|
}
|
|
227
234
|
// Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
|
|
228
235
|
// drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
|
|
@@ -272,10 +279,10 @@ function instantFromDriver(value) {
|
|
|
272
279
|
return null;
|
|
273
280
|
}
|
|
274
281
|
function isTemporalPlainDate(v) {
|
|
275
|
-
return
|
|
282
|
+
return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
|
|
276
283
|
}
|
|
277
284
|
function isTemporalPlainDateTimeLike(v) {
|
|
278
|
-
const tag =
|
|
285
|
+
const tag = propertyOf(v, Symbol.toStringTag);
|
|
279
286
|
return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
|
|
280
287
|
typeof v === "object" &&
|
|
281
288
|
v !== null &&
|
|
@@ -459,6 +466,31 @@ function prepareJsonbValue(value) {
|
|
|
459
466
|
}
|
|
460
467
|
return { kind: "param", sql: "::jsonb", bound: value };
|
|
461
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
|
+
}
|
|
462
494
|
function prepareValue(value, pgType) {
|
|
463
495
|
if (isSqlExpression(value)) {
|
|
464
496
|
return { kind: "literal", literal: value.text };
|
|
@@ -473,9 +505,9 @@ function prepareValue(value, pgType) {
|
|
|
473
505
|
if (prepared)
|
|
474
506
|
return prepared;
|
|
475
507
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
508
|
+
const scalar = prepareDriverNeutralScalar(value, pgType);
|
|
509
|
+
if (scalar)
|
|
510
|
+
return scalar;
|
|
479
511
|
if (pgType === "date") {
|
|
480
512
|
const prepared = prepareDateValue(value);
|
|
481
513
|
if (prepared)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
|
|
3
3
|
// Non-persistent adapter for tests and dev mode. Erased entries stay as
|
|
4
|
-
// tombstones so the create-after-erase
|
|
4
|
+
// tombstones (also for never-keyed subjects) so the create-after-erase
|
|
5
|
+
// contract holds within a process.
|
|
5
6
|
export class InMemoryKmsAdapter {
|
|
6
7
|
capabilities = { mode: "local-key" };
|
|
7
8
|
keys = new Map();
|
|
@@ -20,12 +21,8 @@ export class InMemoryKmsAdapter {
|
|
|
20
21
|
return entry.key;
|
|
21
22
|
}
|
|
22
23
|
async eraseKey(subject) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!entry)
|
|
26
|
-
return;
|
|
27
|
-
entry.key = null;
|
|
28
|
-
entry.erased = true;
|
|
24
|
+
// A never-keyed subject gets a tombstone too, so createKey cannot mint one later.
|
|
25
|
+
this.keys.set(subjectIdToKey(subject), { key: null, erased: true });
|
|
29
26
|
}
|
|
30
27
|
async health() {
|
|
31
28
|
return { ok: true, latencyMs: 0 };
|
|
@@ -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;
|
|
@@ -107,15 +107,19 @@ export class PgKmsAdapter {
|
|
|
107
107
|
}
|
|
108
108
|
async eraseKey(subject, ctx) {
|
|
109
109
|
await this.ensureSchema();
|
|
110
|
-
//
|
|
111
|
-
//
|
|
110
|
+
// Upsert: a never-keyed subject (pre-KMS plaintext) still gets a tombstone,
|
|
111
|
+
// so a later createKey cannot mint a fresh key and re-encrypt the plaintext.
|
|
112
|
+
// The erased_at IS NULL guard keeps repeat erases from overwriting the
|
|
113
|
+
// original tombstone audit fields.
|
|
112
114
|
await this.sql `
|
|
113
|
-
|
|
115
|
+
INSERT INTO kumiko_subject_keys (subject_id, cipher_key, kek_version, erased_at, erased_by, erase_reason)
|
|
116
|
+
VALUES (${subjectIdToKey(subject)}, NULL, ${this.kekVersion}, now(), ${ctx.userId ?? null}, ${ctx.eraseReason ?? null})
|
|
117
|
+
ON CONFLICT (subject_id) DO UPDATE
|
|
114
118
|
SET cipher_key = NULL,
|
|
115
119
|
erased_at = now(),
|
|
116
|
-
erased_by =
|
|
117
|
-
erase_reason =
|
|
118
|
-
WHERE
|
|
120
|
+
erased_by = EXCLUDED.erased_by,
|
|
121
|
+
erase_reason = EXCLUDED.erase_reason
|
|
122
|
+
WHERE kumiko_subject_keys.erased_at IS NULL`;
|
|
119
123
|
}
|
|
120
124
|
async health() {
|
|
121
125
|
const start = Date.now();
|
|
@@ -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
|
};
|