@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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 +1 -7
- package/README.md +1 -1
- package/agents/examples/access-control.md +2 -2
- package/agents/examples/anonymous-access-multitenant.md +2 -2
- package/agents/examples/anonymous-access.md +3 -3
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +6 -7
- package/agents/examples/api-worker-split.md +7 -8
- package/agents/examples/auth-claims.md +2 -1
- package/agents/examples/auth-foundation-providers.md +3 -3
- package/agents/examples/auth-mfa.md +3 -4
- package/agents/examples/basic-entity.md +3 -4
- package/agents/examples/chat-channels.md +4 -4
- package/agents/examples/compliance-profiles-demo.md +2 -3
- package/agents/examples/cross-feature-events.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +14 -9
- package/agents/examples/currencies-global.md +3 -4
- package/agents/examples/currencies-per-tenant.md +3 -4
- package/agents/examples/custom-fields-basic.md +2 -3
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/dashboard-stats.md +2 -3
- package/agents/examples/delivery-notifications.md +5 -6
- package/agents/examples/e2e-generator.md +1 -1
- package/agents/examples/embedded-entity-form.md +2 -3
- package/agents/examples/embedded.md +2 -3
- package/agents/examples/encrypted-tenant-config.md +5 -6
- package/agents/examples/error-contract.md +3 -4
- package/agents/examples/event-sourcing.md +19 -23
- package/agents/examples/feature-toggles.md +4 -4
- package/agents/examples/field-access.md +3 -4
- package/agents/examples/files-post-processing.md +12 -13
- package/agents/examples/golden-path.md +208 -0
- package/agents/examples/idempotency.md +2 -2
- package/agents/examples/inbound-mail-triage.md +7 -7
- package/agents/examples/lane-routing.md +3 -3
- package/agents/examples/ledger-banking.md +2 -3
- package/agents/examples/ledger-invoicing.md +2 -3
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +3 -3
- package/agents/examples/managed-config.md +3 -4
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +4 -5
- package/agents/examples/ownership.md +3 -5
- package/agents/examples/pdf-audit-export.md +2 -3
- package/agents/examples/pipeline-basics.md +2 -2
- package/agents/examples/public-share-token.md +3 -3
- package/agents/examples/rate-limiting.md +2 -1
- package/agents/examples/realtime-sse.md +2 -3
- package/agents/examples/record-detail-layout.md +1 -1
- package/agents/examples/reference-data.md +3 -3
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +1 -1
- package/agents/examples/search.md +2 -2
- package/agents/examples/secrets-demo.md +8 -8
- package/agents/examples/security-baseline.md +2 -2
- package/agents/examples/session-revocation.md +7 -8
- package/agents/examples/state-machine.md +181 -165
- package/agents/examples/tags-basic.md +2 -3
- package/agents/examples/tenant-isolation.md +2 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/tier-admin.md +3 -4
- package/agents/examples/timezones.md +3 -3
- package/agents/examples/user-data-rights.md +12 -8
- package/agents/examples/user-profile.md +3 -3
- package/agents/examples/webhook-step.md +2 -3
- package/agents/examples/wizard-form.md +4 -5
- package/agents/examples/workflow-engine.md +7 -3
- package/agents/examples/writeform-section.md +1 -1
- package/agents/examples.md +1 -0
- package/agents/features.md +4 -4
- package/dist/consumer-cli.js +2 -3
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.js +2 -0
- package/dist/crypto/pg-kms-adapter.js +1 -1
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +30 -15
- package/dist/db/bun-db-deprecated.d.ts +10 -0
- package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
- package/dist/db/config-seed.js +1 -1
- package/dist/db/connection.d.ts +23 -5
- package/dist/db/connection.js +10 -5
- package/dist/db/dialect.js +1 -1
- package/dist/db/entity-field-encryption.d.ts +5 -0
- package/dist/db/entity-field-encryption.js +29 -0
- package/dist/db/entity-table-meta.js +6 -5
- package/dist/db/event-store-executor-read.js +26 -5
- package/dist/db/index.d.ts +4 -4
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +1 -1
- package/dist/db/queries/backfill-pii.js +16 -9
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/event-store.d.ts +2 -0
- package/dist/db/queries/event-store.js +26 -0
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/query.d.ts +105 -1
- package/dist/db/query.js +1450 -3
- package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
- package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
- package/dist/db/tenant-db.d.ts +11 -0
- package/dist/db/tenant-db.js +51 -12
- package/dist/derivatives/derivatives-context.d.ts +1 -1
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/gdpr-storage.js +3 -3
- package/dist/engine/boot-validator/pii-retention.js +3 -3
- package/dist/engine/boot-validator/record-owned.js +3 -3
- package/dist/engine/boot-validator/screens.js +37 -1
- package/dist/engine/bridge-stub.d.ts +4 -0
- package/dist/engine/bridge-stub.js +62 -0
- package/dist/engine/build-config-feature-schema.js +197 -65
- package/dist/engine/event-helpers.d.ts +2 -1
- package/dist/engine/event-helpers.js +1 -0
- package/dist/engine/extension-selector-plugins.d.ts +3 -0
- package/dist/engine/extension-selector-plugins.js +14 -0
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -2
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/es-ops/operations-schema.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/archive.js +1 -1
- package/dist/event-store/errors.d.ts +2 -1
- package/dist/event-store/errors.js +6 -2
- package/dist/event-store/event-store.js +17 -9
- package/dist/event-store/events-schema.js +1 -1
- package/dist/event-store/rebuild-dead-letter.js +3 -3
- package/dist/event-store/snapshot.js +1 -1
- package/dist/event-store/upcaster-dead-letter.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +1 -1
- package/dist/files/file-routes.js +1 -1
- package/dist/i18n/settings-hub-keys.js +1 -2
- package/dist/internal/stack-support.d.ts +12 -0
- package/dist/internal/stack-support.js +12 -0
- package/dist/internal/testing.d.ts +4 -0
- package/dist/internal/testing.js +5 -0
- package/dist/jobs/job-runner.js +23 -11
- package/dist/migrations/pending-rebuilds.js +1 -1
- package/dist/migrations/pii-event-backfill.js +2 -2
- package/dist/pipeline/dispatch-shared.js +43 -23
- package/dist/pipeline/dispatch-write.js +6 -2
- package/dist/pipeline/dispatcher-utils.js +6 -2
- package/dist/pipeline/event-consumer-state.js +1 -1
- package/dist/pipeline/projection-rebuild.d.ts +1 -0
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/pipeline/projection-state.js +1 -1
- package/dist/pipeline/system-hooks.js +1 -1
- package/dist/schema-cli.js +3 -3
- package/dist/search/purge-subject.js +20 -10
- package/dist/seeding/index.d.ts +1 -0
- package/dist/seeding/index.js +1 -0
- package/dist/stack/index.d.ts +10 -9
- package/dist/stack/index.js +11 -19
- package/dist/testing/e2e-generator.d.ts +5 -49
- package/dist/testing/e2e-generator.js +5 -382
- package/dist/testing/handler-context.d.ts +5 -4
- package/dist/testing/handler-context.js +5 -69
- package/dist/testing/index.d.ts +10 -26
- package/dist/testing/index.js +10 -34
- package/dist/testing/real-providers.d.ts +5 -3
- package/dist/testing/real-providers.js +5 -21
- package/dist/upgrade-cli.js +6 -4
- package/package.json +37 -9
- package/src/changes.json +64 -0
- package/src/scripts/codemod/README.md +43 -0
- package/src/scripts/codemod/codemod-support.ts +276 -0
- package/src/scripts/codemod/db-factory.ts +810 -0
- package/src/scripts/codemod/feature-factory.ts +429 -0
- package/src/scripts/codemod/s3-imports.ts +274 -0
- package/src/scripts/codemod/tenant-db-options.ts +454 -0
- package/src/scripts/codemod/testing-move-targets.ts +161 -0
- package/src/scripts/codemod/testing-move.ts +520 -0
- package/dist/bun-db/connection.d.ts +0 -17
- package/dist/bun-db/connection.js +0 -40
- package/dist/bun-db/index.d.ts +0 -4
- package/dist/bun-db/query.d.ts +0 -106
- package/dist/bun-db/query.js +0 -1457
- package/dist/db/api.d.ts +0 -19
- package/dist/db/api.js +0 -23
- package/dist/db/bun-provider.d.ts +0 -2
- package/dist/db/bun-provider.js +0 -28
- package/dist/db/postgres-provider.d.ts +0 -2
- package/dist/db/postgres-provider.js +0 -21
- package/dist/db/query-api.d.ts +0 -2
- package/dist/db/query-api.js +0 -6
- package/dist/stack/db.d.ts +0 -22
- package/dist/stack/db.js +0 -67
- package/dist/stack/drain-event-consumers.d.ts +0 -4
- package/dist/stack/drain-event-consumers.js +0 -51
- package/dist/stack/drain-jobs.d.ts +0 -22
- package/dist/stack/drain-jobs.js +0 -141
- package/dist/stack/event-collector.d.ts +0 -9
- package/dist/stack/event-collector.js +0 -12
- package/dist/stack/push-entity-projection-tables.d.ts +0 -24
- package/dist/stack/push-entity-projection-tables.js +0 -74
- package/dist/stack/redis.d.ts +0 -17
- package/dist/stack/redis.js +0 -52
- package/dist/stack/request-helper.d.ts +0 -35
- package/dist/stack/request-helper.js +0 -144
- package/dist/stack/test-stack.d.ts +0 -192
- package/dist/stack/test-stack.js +0 -429
- package/dist/testing/access-assertions.d.ts +0 -2
- package/dist/testing/access-assertions.js +0 -13
- package/dist/testing/assertions.d.ts +0 -10
- package/dist/testing/assertions.js +0 -22
- package/dist/testing/boot-validator-fixture.d.ts +0 -3
- package/dist/testing/boot-validator-fixture.js +0 -101
- package/dist/testing/closed-connection-error.d.ts +0 -7
- package/dist/testing/closed-connection-error.js +0 -46
- package/dist/testing/db-cleanup.d.ts +0 -9
- package/dist/testing/db-cleanup.js +0 -31
- package/dist/testing/expect-error.d.ts +0 -2
- package/dist/testing/expect-error.js +0 -22
- package/dist/testing/file-provider-contract.d.ts +0 -2
- package/dist/testing/file-provider-contract.js +0 -102
- package/dist/testing/http-cookies.d.ts +0 -7
- package/dist/testing/http-cookies.js +0 -47
- package/dist/testing/late-bound.d.ts +0 -9
- package/dist/testing/late-bound.js +0 -31
- package/dist/testing/manual-clock.d.ts +0 -14
- package/dist/testing/manual-clock.js +0 -12
- package/dist/testing/multipart-helper.d.ts +0 -22
- package/dist/testing/multipart-helper.js +0 -77
- package/dist/testing/mutable-master-key-provider.d.ts +0 -7
- package/dist/testing/mutable-master-key-provider.js +0 -35
- package/dist/testing/observability-recorder.d.ts +0 -9
- package/dist/testing/observability-recorder.js +0 -29
- package/dist/testing/seed.d.ts +0 -9
- package/dist/testing/seed.js +0 -21
- package/dist/testing/shared-entities.d.ts +0 -286
- package/dist/testing/shared-entities.js +0 -56
- package/dist/testing/utils.d.ts +0 -1
- package/dist/testing/utils.js +0 -1
- package/dist/testing/wait-for.d.ts +0 -20
- package/dist/testing/wait-for.js +0 -47
- package/dist/testing/without-ambient-temporal.d.ts +0 -5
- package/dist/testing/without-ambient-temporal.js +0 -17
- package/dist/testing/zip-fixture.d.ts +0 -1
- package/dist/testing/zip-fixture.js +0 -33
- /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
- /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
|
@@ -91,15 +91,14 @@ import {
|
|
|
91
91
|
createEnvMasterKeyProvider,
|
|
92
92
|
type MasterKeyProvider,
|
|
93
93
|
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
94
|
+
import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
|
|
94
95
|
import {
|
|
95
|
-
createTestUser,
|
|
96
96
|
resetEventStore,
|
|
97
97
|
setupTestStack,
|
|
98
98
|
type TestStack,
|
|
99
|
-
testTenantId,
|
|
100
99
|
unsafeCreateEntityTable,
|
|
101
100
|
unsafePushTables,
|
|
102
|
-
} from "@cosmicdrift/kumiko-
|
|
101
|
+
} from "@cosmicdrift/kumiko-testing/stack";
|
|
103
102
|
|
|
104
103
|
import { incidentEntity, incidentTable, webhookDemoFeature } from "../feature";
|
|
105
104
|
|
|
@@ -102,19 +102,18 @@ the draft (reopening the wizard starts fresh on step 1).
|
|
|
102
102
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
103
103
|
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
104
104
|
import {
|
|
105
|
+
createFormDraftFeature,
|
|
105
106
|
FormDraftHandlers,
|
|
106
107
|
FormDraftQueries,
|
|
107
108
|
formDraftEntity,
|
|
108
|
-
formDraftFeature,
|
|
109
109
|
} from "@cosmicdrift/kumiko-bundled-features/form-draft";
|
|
110
110
|
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
111
|
+
import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
111
112
|
import {
|
|
112
|
-
createTestUser,
|
|
113
113
|
setupTestStack,
|
|
114
114
|
type TestStack,
|
|
115
|
-
TestUsers,
|
|
116
115
|
unsafeCreateEntityTable,
|
|
117
|
-
} from "@cosmicdrift/kumiko-
|
|
116
|
+
} from "@cosmicdrift/kumiko-testing/stack";
|
|
118
117
|
import { listingEntity, listingsFeature } from "../feature";
|
|
119
118
|
|
|
120
119
|
let stack: TestStack;
|
|
@@ -131,7 +130,7 @@ const DRAFT_KEY = "listing-wizard";
|
|
|
131
130
|
beforeAll(async () => {
|
|
132
131
|
// form-draft requires the "config" feature (its retention-days setting).
|
|
133
132
|
stack = await setupTestStack({
|
|
134
|
-
features: [listingsFeature,
|
|
133
|
+
features: [listingsFeature, createFormDraftFeature(), createConfigFeature()],
|
|
135
134
|
});
|
|
136
135
|
await unsafeCreateEntityTable(stack.db, listingEntity);
|
|
137
136
|
await unsafeCreateEntityTable(stack.db, formDraftEntity);
|
|
@@ -60,7 +60,8 @@ import {
|
|
|
60
60
|
WORKFLOW_WAITING_TYPE,
|
|
61
61
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
62
62
|
import { append, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
63
|
-
import {
|
|
63
|
+
import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
64
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
64
65
|
import { createSuspendedRunFetcher } from "../postgres-resume-loop";
|
|
65
66
|
|
|
66
67
|
let stack: TestStack;
|
|
@@ -982,8 +983,11 @@ export function startCronScheduler(
|
|
|
982
983
|
### src/db/queries/suspended-runs.ts
|
|
983
984
|
|
|
984
985
|
```ts
|
|
985
|
-
import {
|
|
986
|
-
|
|
986
|
+
import {
|
|
987
|
+
asRawClient,
|
|
988
|
+
type DbRunner,
|
|
989
|
+
toPostgresArrayLiteral,
|
|
990
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
987
991
|
|
|
988
992
|
export async function selectExpiredSuspensionEvents(
|
|
989
993
|
db: DbRunner,
|
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
defineFeature,
|
|
58
58
|
validateBoot as validateBootRaw,
|
|
59
59
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
60
|
-
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-
|
|
60
|
+
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
|
|
61
61
|
import { z } from "zod";
|
|
62
62
|
import { noteDeskFeature } from "../feature";
|
|
63
63
|
|
package/agents/examples.md
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
| `feature-toggles` | Runtime feature-toggle flip — two toggleable features, one registers a cross-feature hook on the other; disabling either has the documented effect without reboot. | `examples/feature-toggles.md` |
|
|
36
36
|
| `field-access` | Field-level access control. When entity-level roles aren't granular enough. | `examples/field-access.md` |
|
|
37
37
|
| `files-post-processing` | The three ways to generate an image variant: declarative field variants, imperative ctx.derivatives.variant() with region-blur, and the public route behind a default-deny predicate. | `examples/files-post-processing.md` |
|
|
38
|
+
| `golden-path` | The smallest complete feature: entity, write handler, event, projection, screen and an integration test. | `examples/golden-path.md` |
|
|
38
39
|
| `i18n` | Feature-scoped translations. 'Hallo' and 'Hello' in the same app. | `examples/i18n.md` |
|
|
39
40
|
| `idempotency` | Same request, same result. Dedupes on request ID. | `examples/idempotency.md` |
|
|
40
41
|
| `inbound-mail-triage` | Inbound-mail consumer pattern: inbound-mail-foundation + in-memory provider, a worker-lane triage job attached to the ingest handler, verified end-to-end through createAllInOneEntrypoint. | `examples/inbound-mail-triage.md` |
|
package/agents/features.md
CHANGED
|
@@ -106,7 +106,7 @@ Requires: `delivery`, `secrets`
|
|
|
106
106
|
|
|
107
107
|
## channel-email
|
|
108
108
|
|
|
109
|
-
Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send
|
|
109
|
+
Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
|
|
110
110
|
|
|
111
111
|
Category: notifications
|
|
112
112
|
|
|
@@ -206,7 +206,7 @@ Exposes APIs: `retention.policyFor`
|
|
|
206
206
|
|
|
207
207
|
## delivery
|
|
208
208
|
|
|
209
|
-
The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
|
|
209
|
+
The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
|
|
210
210
|
|
|
211
211
|
Category: notifications
|
|
212
212
|
|
|
@@ -413,7 +413,7 @@ German UI and mail copy for framework screens. Opt-in; not included by includeBu
|
|
|
413
413
|
|
|
414
414
|
## mail-foundation
|
|
415
415
|
|
|
416
|
-
Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`
|
|
416
|
+
Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.
|
|
417
417
|
|
|
418
418
|
Category: notifications
|
|
419
419
|
|
|
@@ -558,7 +558,7 @@ Requires: `renderer-foundation`
|
|
|
558
558
|
|
|
559
559
|
## secrets
|
|
560
560
|
|
|
561
|
-
Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).
|
|
561
|
+
Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: "secrets:write:set", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.
|
|
562
562
|
|
|
563
563
|
Category: infrastructure
|
|
564
564
|
|
package/dist/consumer-cli.js
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
// kumiko_event_consumers. Mirrors schema-cli.ts's shape (single runXCli(argv,
|
|
6
6
|
// out) entry point, own DB connection) so `kumiko-consumer` ships the same
|
|
7
7
|
// way `kumiko-schema` does.
|
|
8
|
-
import {
|
|
9
|
-
import { dbConnectionOptionsFromEnv } from "./db/connection.js";
|
|
8
|
+
import { createDbConnection, dbConnectionOptionsFromEnv } from "./db/connection.js";
|
|
10
9
|
import { getConsumerState, restartConsumer } from "./pipeline/index.js";
|
|
11
10
|
import { ensureTemporalPolyfill } from "./time/index.js";
|
|
12
11
|
// Single pass: pulls --instance-id (both "--instance-id <id>" and
|
|
@@ -80,7 +79,7 @@ export async function runConsumerCli(argv, out) {
|
|
|
80
79
|
out.err(" DATABASE_URL not set.");
|
|
81
80
|
return 1;
|
|
82
81
|
}
|
|
83
|
-
const { db, close } =
|
|
82
|
+
const { db, close } = createDbConnection({ url: dbUrl, ...dbConnectionOptionsFromEnv() });
|
|
84
83
|
try {
|
|
85
84
|
if (sub === "status") {
|
|
86
85
|
const state = await getConsumerState(db, name, instanceId);
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -8,4 +8,4 @@ export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiring
|
|
|
8
8
|
export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
|
|
9
9
|
export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
|
|
10
10
|
export { createRequestKmsCache, type RequestKmsCache, } from "./request-kms-cache.js";
|
|
11
|
-
export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
11
|
+
export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, isRecordKeyedField, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
package/dist/crypto/index.js
CHANGED
|
@@ -8,4 +8,4 @@ export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsW
|
|
|
8
8
|
export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
|
|
9
9
|
export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
|
|
10
10
|
export { createRequestKmsCache, } from "./request-kms-cache.js";
|
|
11
|
-
export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
11
|
+
export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, isRecordKeyedField, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
|
|
@@ -12,6 +12,8 @@ export function isPlainUserOwnedField(field) {
|
|
|
12
12
|
// owner. Returns the unchanged-by-the-caller values that must be written again
|
|
13
13
|
// so they encrypt under the new owner. Owner → absent is not a transition:
|
|
14
14
|
// the value stays with the last owner so that owner's forget still erases it.
|
|
15
|
+
// Absent → owner moves a whenAbsent value (tenant or record key) to the owner's
|
|
16
|
+
// key; the created event stays keyed under the whenAbsent subject.
|
|
15
17
|
export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
|
|
16
18
|
const rekeyed = {};
|
|
17
19
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
2
|
import postgres from "postgres";
|
|
3
|
-
import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/
|
|
3
|
+
import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/connection.js";
|
|
4
4
|
import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
|
|
5
5
|
const IV_LENGTH = 12;
|
|
6
6
|
const TAG_LENGTH = 16;
|
|
@@ -28,6 +28,8 @@ export interface EventSubjectEnvelope {
|
|
|
28
28
|
readonly aggregateId: string;
|
|
29
29
|
}
|
|
30
30
|
export declare function resolveEventSubject(fieldName: string, spec: EventPiiSubject, payload: Record<string, unknown>, envelope: EventSubjectEnvelope): SubjectId | null;
|
|
31
|
+
/** True when the field can be keyed on the row's own `record:<entity>:<id>` subject. */
|
|
32
|
+
export declare function isRecordKeyedField(field: EntityDefinition["fields"][string]): boolean;
|
|
31
33
|
export declare function collectPiiSubjectFields(entity: EntityDefinition): readonly string[];
|
|
32
34
|
export declare function collectPiiEncryptedFields(entity: EntityDefinition): readonly string[];
|
|
33
35
|
export declare function collectPiiFileReferenceFields(entity: EntityDefinition): readonly string[];
|
|
@@ -34,10 +34,13 @@ function resolveUserOwnedSubject(userOwned, fieldName, row, opts) {
|
|
|
34
34
|
const userId = nonEmptyString(row[ownerField]) ?? nonEmptyString(opts.ownerFallback?.[ownerField]);
|
|
35
35
|
if (userId !== null)
|
|
36
36
|
return { kind: "user", userId };
|
|
37
|
-
if (
|
|
38
|
-
|
|
37
|
+
if (opts.ignoreWhenAbsent !== true) {
|
|
38
|
+
if (userOwned.whenAbsent === "tenant")
|
|
39
|
+
return resolveTenantSubject(fieldName, row, opts);
|
|
40
|
+
if (userOwned.whenAbsent === "self")
|
|
41
|
+
return resolveRecordSubject(fieldName, row, opts);
|
|
39
42
|
}
|
|
40
|
-
throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row and the field declares no whenAbsent — declare { personal: { of: "${ownerField}", whenAbsent: "tenant" } } (fw#3654)`);
|
|
43
|
+
throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row and the field declares no whenAbsent — declare { personal: { of: "${ownerField}", whenAbsent: "tenant" } } or whenAbsent: "self" to key it on the row itself (fw#3654)`);
|
|
41
44
|
}
|
|
42
45
|
function resolveTenantSubject(fieldName, row, opts) {
|
|
43
46
|
const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
|
|
@@ -87,6 +90,19 @@ function resolveEnvelopeTenant(fieldName, envelope) {
|
|
|
87
90
|
}
|
|
88
91
|
return { kind: "tenant", tenantId: envelope.tenantId };
|
|
89
92
|
}
|
|
93
|
+
function resolveEnvelopeRecordSubject(fieldName, envelope) {
|
|
94
|
+
if (nonEmptyString(envelope.aggregateType) === null ||
|
|
95
|
+
nonEmptyString(envelope.aggregateId) === null) {
|
|
96
|
+
throw new SubjectResolutionError(fieldName, "event envelope aggregateType/aggregateId is empty");
|
|
97
|
+
}
|
|
98
|
+
// aggregate_type is a free-form text column (events-schema.ts) — reject
|
|
99
|
+
// here rather than mint a record key that subjectIdSchema (kms-adapter.ts)
|
|
100
|
+
// would later refuse to shred.
|
|
101
|
+
if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
|
|
102
|
+
throw new SubjectResolutionError(fieldName, `event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`);
|
|
103
|
+
}
|
|
104
|
+
return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
|
|
105
|
+
}
|
|
90
106
|
// Same resolver for the live-append path (encryptEventPayloadPii) and the
|
|
91
107
|
// backfill catalog path (backfillEventPiiEncryption) — the reason the two
|
|
92
108
|
// can never encrypt the same field under different subjects. A "user"
|
|
@@ -101,34 +117,33 @@ export function resolveEventSubject(fieldName, spec, payload, envelope) {
|
|
|
101
117
|
return { kind: "user", userId };
|
|
102
118
|
if (normalized.whenAbsent === "tenant")
|
|
103
119
|
return resolveEnvelopeTenant(fieldName, envelope);
|
|
120
|
+
if (normalized.whenAbsent === "self")
|
|
121
|
+
return resolveEnvelopeRecordSubject(fieldName, envelope);
|
|
104
122
|
if (normalized.whenAbsent === "plaintext") {
|
|
105
123
|
// skip: author declared whenAbsent: "plaintext" — value ships unencrypted by decision
|
|
106
124
|
return null;
|
|
107
125
|
}
|
|
108
126
|
throw new SubjectResolutionError(fieldName, `owner field "${normalized.ownerField}" carries no id and the event declares no whenAbsent fallback — ` +
|
|
109
127
|
`refusing to append plaintext PII. Declare { personal: { of: "${normalized.ownerField}", ` +
|
|
110
|
-
`whenAbsent: "tenant" } } to encrypt under the envelope tenant key,
|
|
128
|
+
`whenAbsent: "tenant" } } to encrypt under the envelope tenant key, whenAbsent: "self" to encrypt ` +
|
|
129
|
+
`under the event's own record key, or whenAbsent: "plaintext" ` +
|
|
111
130
|
"to acknowledge that this value cannot be crypto-shredded (fw#2776).");
|
|
112
131
|
}
|
|
113
132
|
if (normalized.kind === "tenant") {
|
|
114
133
|
return resolveEnvelopeTenant(fieldName, envelope);
|
|
115
134
|
}
|
|
116
135
|
if (normalized.kind === "self") {
|
|
117
|
-
|
|
118
|
-
nonEmptyString(envelope.aggregateId) === null) {
|
|
119
|
-
throw new SubjectResolutionError(fieldName, "event envelope aggregateType/aggregateId is empty");
|
|
120
|
-
}
|
|
121
|
-
// aggregate_type is a free-form text column (events-schema.ts) — reject
|
|
122
|
-
// here rather than mint a record key that subjectIdSchema (kms-adapter.ts)
|
|
123
|
-
// would later refuse to shred.
|
|
124
|
-
if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
|
|
125
|
-
throw new SubjectResolutionError(fieldName, `event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`);
|
|
126
|
-
}
|
|
127
|
-
return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
|
|
136
|
+
return resolveEnvelopeRecordSubject(fieldName, envelope);
|
|
128
137
|
}
|
|
129
138
|
const exhaustiveCheck = normalized;
|
|
130
139
|
throw new Error(`Unhandled normalized event PII subject: ${JSON.stringify(exhaustiveCheck)}`);
|
|
131
140
|
}
|
|
141
|
+
/** True when the field can be keyed on the row's own `record:<entity>:<id>` subject. */
|
|
142
|
+
export function isRecordKeyedField(field) {
|
|
143
|
+
if ("recordOwned" in field && field.recordOwned === true)
|
|
144
|
+
return true;
|
|
145
|
+
return "userOwned" in field && field.userOwned?.whenAbsent === "self";
|
|
146
|
+
}
|
|
132
147
|
function hasSubjectAnnotation(field) {
|
|
133
148
|
return (("userOwned" in field && field.userOwned !== undefined) ||
|
|
134
149
|
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Moved to `@cosmicdrift/kumiko-framework/db`. Run `bun kumiko-upgrade --apply`
|
|
3
|
+
* to rewrite imports. Removed in the first release after 2026-10-29.
|
|
4
|
+
*/
|
|
5
|
+
export type { DeleteManyBatchedOptions, DeleteManyBatchedResult, IncrementCounterOptions, InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, UpsertOnConflictOptions, WhereObject, WhereOperator, WhereValue, } from "./query.js";
|
|
6
|
+
/**
|
|
7
|
+
* @deprecated Moved to `@cosmicdrift/kumiko-framework/db`. Run `bun kumiko-upgrade --apply`
|
|
8
|
+
* to rewrite imports. Removed in the first release after 2026-10-29.
|
|
9
|
+
*/
|
|
10
|
+
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";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Moved to `@cosmicdrift/kumiko-framework/db`. Run `bun kumiko-upgrade --apply`
|
|
3
|
+
* to rewrite imports. Removed in the first release after 2026-10-29.
|
|
4
|
+
*/
|
|
4
5
|
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/db/config-seed.js
CHANGED
|
@@ -24,7 +24,7 @@ export async function seedConfigValues(seeds, table, entity, registry, db, ciphe
|
|
|
24
24
|
return { created, skipped };
|
|
25
25
|
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
26
26
|
const executor = createEventStoreExecutor(table, entity, { entityName: "config-value" });
|
|
27
|
-
const tdb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
|
|
27
|
+
const tdb = createTenantDb({ db, tenantId: SYSTEM_TENANT_ID, mode: "system" });
|
|
28
28
|
for (const seed of seeds) {
|
|
29
29
|
const keyDef = registry.getConfigKey(seed.key);
|
|
30
30
|
if (!keyDef) {
|
package/dist/db/connection.d.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
2
|
+
import postgres from "postgres";
|
|
2
3
|
export type * from "@cosmicdrift/kumiko-types/db-connection";
|
|
3
|
-
|
|
4
|
-
export
|
|
5
|
-
|
|
4
|
+
type PostgresDriverOptions = NonNullable<Parameters<typeof postgres>[1]>;
|
|
5
|
+
export type DbConnectionOptions = {
|
|
6
|
+
readonly maxConnections?: number;
|
|
7
|
+
readonly idleTimeoutSeconds?: number;
|
|
8
|
+
readonly connectTimeoutSeconds?: number;
|
|
9
|
+
readonly closeTimeoutSeconds?: number;
|
|
10
|
+
readonly ssl?: PostgresDriverOptions["ssl"];
|
|
11
|
+
readonly onNotice?: (notice: postgres.Notice) => void;
|
|
12
|
+
};
|
|
13
|
+
export type CreateDbConnectionOptions = DbConnectionOptions & {
|
|
14
|
+
readonly url: string;
|
|
15
|
+
};
|
|
16
|
+
export type DbConnectionHandle = {
|
|
6
17
|
db: DbConnection;
|
|
7
18
|
client: PgClient;
|
|
8
19
|
close: () => Promise<void>;
|
|
9
20
|
};
|
|
10
|
-
export declare
|
|
21
|
+
export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
22
|
+
export declare function createDbConnection(options: CreateDbConnectionOptions): DbConnectionHandle;
|
|
23
|
+
/**
|
|
24
|
+
* @deprecated Positional form, use `createDbConnection({ url, ... })`. Run
|
|
25
|
+
* `bun kumiko-upgrade --apply` to rewrite call sites. Removed in the first release after 2026-10-29.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createDbConnection(url: string, options?: DbConnectionOptions): DbConnectionHandle;
|
|
28
|
+
export declare function dbConnectionOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
|
|
11
29
|
export declare const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
|
|
12
|
-
export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>):
|
|
30
|
+
export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
|
package/dist/db/connection.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import postgres from "postgres";
|
|
2
2
|
import { readPositiveIntEnv } from "../utils/env-parse.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
export function createDbConnection(
|
|
3
|
+
// A DB outage can leave an in-flight query referenced on a dead connection,
|
|
4
|
+
// so a plain end() (no timeout = wait for queries to finish) hangs forever —
|
|
5
|
+
// close() must always bound how long shutdown waits.
|
|
6
|
+
export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
7
|
+
export function createDbConnection(urlOrOptions, legacyOptions = {}) {
|
|
8
|
+
const { url, ...options } = typeof urlOrOptions === "string" ? { ...legacyOptions, url: urlOrOptions } : urlOrOptions;
|
|
8
9
|
const pgOptions = {};
|
|
9
10
|
if (options.maxConnections !== undefined)
|
|
10
11
|
pgOptions.max = options.maxConnections;
|
|
@@ -13,6 +14,10 @@ export function createDbConnection(url, options = {}) {
|
|
|
13
14
|
if (options.connectTimeoutSeconds !== undefined) {
|
|
14
15
|
pgOptions.connect_timeout = options.connectTimeoutSeconds;
|
|
15
16
|
}
|
|
17
|
+
if (options.ssl !== undefined)
|
|
18
|
+
pgOptions.ssl = options.ssl;
|
|
19
|
+
if (options.onNotice !== undefined)
|
|
20
|
+
pgOptions.onnotice = options.onNotice;
|
|
16
21
|
const client = postgres(url, pgOptions);
|
|
17
22
|
return {
|
|
18
23
|
db: client,
|
package/dist/db/dialect.js
CHANGED
|
@@ -251,7 +251,7 @@ export function primaryKey(opts) {
|
|
|
251
251
|
// Limits: no nested SqlExpression composition (drizzle's recursive
|
|
252
252
|
// `sql\`${other}\``) — schema-files use single-level expressions only.
|
|
253
253
|
// Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
|
|
254
|
-
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (
|
|
254
|
+
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (db/query.ts)
|
|
255
255
|
// can't be tricked into treating request data as a raw SQL literal. The same
|
|
256
256
|
// brand gate is enforced here in the schema DSL (sql`...` interpolation +
|
|
257
257
|
// literalDefault) and in entity-table-meta's sqlExpressionText — a duck-typed
|
|
@@ -3,6 +3,11 @@ import { type Logger } from "../logging/index.js";
|
|
|
3
3
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
|
|
4
4
|
export declare function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string>;
|
|
5
5
|
export declare function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean;
|
|
6
|
+
export declare function isListFilterAllowed(entity: EntityDefinition, field: string, filter: {
|
|
7
|
+
readonly op: string;
|
|
8
|
+
readonly value: unknown;
|
|
9
|
+
}): boolean;
|
|
10
|
+
export declare function isListSortAllowed(entity: EntityDefinition, field: string): boolean;
|
|
6
11
|
export declare function hasSearchablePlaintext(entity: EntityDefinition, field: string): boolean;
|
|
7
12
|
export declare function encryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
|
|
8
13
|
onlyKeys?: Iterable<string>;
|
|
@@ -22,6 +22,35 @@ export function isSensitiveLabelField(entity, field) {
|
|
|
22
22
|
return (collectEncryptedFieldNames(entity).has(field) ||
|
|
23
23
|
collectPiiEncryptedFields(entity).includes(field));
|
|
24
24
|
}
|
|
25
|
+
// A `sensitive` (find: "secret") field is never comparable either, even while its value is still
|
|
26
|
+
// legacy plaintext.
|
|
27
|
+
function holdsCiphertextOrSecret(entity, field) {
|
|
28
|
+
const def = entity.fields[field];
|
|
29
|
+
if (def === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
return ("sensitive" in def && def.sensitive === true) || isSensitiveLabelField(entity, field);
|
|
32
|
+
}
|
|
33
|
+
// Only a PII field with a blind index (`find: "exact" | "fuzzy"`) can be matched without reading
|
|
34
|
+
// the stored value: the `<field>Bidx` HMAC arm of an equality filter.
|
|
35
|
+
function isBlindIndexEqualityTarget(entity, field) {
|
|
36
|
+
const def = entity.fields[field];
|
|
37
|
+
return (def?.type === "text" &&
|
|
38
|
+
def.lookupable === true &&
|
|
39
|
+
def.sensitive !== true &&
|
|
40
|
+
!collectEncryptedFieldNames(entity).has(field));
|
|
41
|
+
}
|
|
42
|
+
// Filtering ciphertext (or a secret) turns hit/count into a value oracle, and for legacy plaintext
|
|
43
|
+
// rows an `in`/range comparison into a prefix oracle. Blind-index equality is the one exception.
|
|
44
|
+
export function isListFilterAllowed(entity, field, filter) {
|
|
45
|
+
if (!holdsCiphertextOrSecret(entity, field))
|
|
46
|
+
return true;
|
|
47
|
+
return (isBlindIndexEqualityTarget(entity, field) &&
|
|
48
|
+
filter.op === "eq" &&
|
|
49
|
+
typeof filter.value === "string");
|
|
50
|
+
}
|
|
51
|
+
export function isListSortAllowed(entity, field) {
|
|
52
|
+
return !holdsCiphertextOrSecret(entity, field);
|
|
53
|
+
}
|
|
25
54
|
// `searchable: true` on a text field means it is indexed plaintext
|
|
26
55
|
// somewhere findable by term — a plain text field's own column, or (for a
|
|
27
56
|
// personal-data field) the derived search index mirroring it.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// entity-shaped base columns. Escape hatch: no forced audit trail for the
|
|
16
16
|
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
17
|
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
18
|
-
import { collectPiiEncryptedFields } from "../crypto/index.js";
|
|
18
|
+
import { collectPiiEncryptedFields, isRecordKeyedField } from "../crypto/index.js";
|
|
19
19
|
import { isSqlExpression } from "./dialect.js";
|
|
20
20
|
import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
21
21
|
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
@@ -224,11 +224,12 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
224
224
|
const idType = entity.idType ?? "uuid";
|
|
225
225
|
if (source === "unmanaged" && idType === "serial") {
|
|
226
226
|
// Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
|
|
227
|
-
// feature.entities), yet encryptForDirectWrite still keys recordOwned
|
|
228
|
-
// `record:<entity>:<id>`; forgetSubject requires a UUID,
|
|
229
|
-
|
|
227
|
+
// feature.entities), yet encryptForDirectWrite still keys recordOwned and
|
|
228
|
+
// whenAbsent: "self" fields as `record:<entity>:<id>`; forgetSubject requires a UUID,
|
|
229
|
+
// so the value could never be shredded.
|
|
230
|
+
const recordOwnedField = Object.entries(entity.fields).find(([, field]) => isRecordKeyedField(field));
|
|
230
231
|
if (recordOwnedField) {
|
|
231
|
-
throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
|
|
232
|
+
throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned (or whenAbsent: "self") but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
|
|
232
233
|
}
|
|
233
234
|
}
|
|
234
235
|
// Base columns first, then user-fields. User-fields with the same
|
|
@@ -5,12 +5,12 @@ import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
|
5
5
|
import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
6
6
|
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
8
|
-
import { UnprocessableError } from "../errors/index.js";
|
|
8
|
+
import { UnprocessableError, ValidationError } from "../errors/index.js";
|
|
9
9
|
import { getStreamVersion } from "../event-store/index.js";
|
|
10
10
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
13
|
-
import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
|
|
13
|
+
import { hasSearchablePlaintext, isListFilterAllowed, isListSortAllowed, isSensitiveLabelField, } from "./entity-field-encryption.js";
|
|
14
14
|
import { buildFilterWhere, } from "./event-store-executor-context.js";
|
|
15
15
|
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
16
16
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
@@ -144,7 +144,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
146
|
for (const [field, value] of Object.entries(screen)) {
|
|
147
|
-
// #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in
|
|
147
|
+
// #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
|
|
148
148
|
if (value === null) {
|
|
149
149
|
whereSql.push(`${colSql(field)} IS NULL`);
|
|
150
150
|
}
|
|
@@ -176,7 +176,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
|
176
176
|
}
|
|
177
177
|
else {
|
|
178
178
|
// Blind-index OR rewrite (#818), lock-step with buildWhereClause
|
|
179
|
-
// in
|
|
179
|
+
// in db/query.ts — equality on lookupable fields matches the
|
|
180
180
|
// plaintext arm OR the HMAC arm.
|
|
181
181
|
const bidxKey = configuredBlindIndexKey();
|
|
182
182
|
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
@@ -547,7 +547,12 @@ export function createReadVerbs(ctx) {
|
|
|
547
547
|
// tenant filter that follows.
|
|
548
548
|
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
549
549
|
}
|
|
550
|
-
|
|
550
|
+
// A column sort must not 400 (the renderer sends one per header click), and the order of
|
|
551
|
+
// ciphertext carries no meaning for the user while leaking the order of the plaintext.
|
|
552
|
+
const sortCandidate = payload.sort &&
|
|
553
|
+
table[payload.sort] &&
|
|
554
|
+
fieldReadClause(payload.sort).kind === "pass" &&
|
|
555
|
+
isListSortAllowed(entity, payload.sort)
|
|
551
556
|
? payload.sort
|
|
552
557
|
: undefined;
|
|
553
558
|
const sortDescending = payload.sortDirection === "desc";
|
|
@@ -606,6 +611,22 @@ export function createReadVerbs(ctx) {
|
|
|
606
611
|
// itself confirm the field exists (field-access.ts strips silently)
|
|
607
612
|
return;
|
|
608
613
|
}
|
|
614
|
+
if (!isListFilterAllowed(entity, f.field, f)) {
|
|
615
|
+
throw new ValidationError({
|
|
616
|
+
fields: [
|
|
617
|
+
{
|
|
618
|
+
path: `filter.${f.field}`,
|
|
619
|
+
code: "field_not_filterable",
|
|
620
|
+
i18nKey: "errors.validation.invalid_value",
|
|
621
|
+
params: {
|
|
622
|
+
field: f.field,
|
|
623
|
+
op: f.op,
|
|
624
|
+
hint: "Encrypted, personal and secret fields cannot be filtered; only equality on a field with find: exact or fuzzy.",
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
});
|
|
629
|
+
}
|
|
609
630
|
if (readClause.kind === "sql") {
|
|
610
631
|
const shifted = shiftParams({ sqlText: readClause.sqlText, params: readClause.params }, params.length);
|
|
611
632
|
whereSql.push(shifted.sqlText);
|
package/dist/db/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recor
|
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
|
6
|
-
export type { DbConnection,
|
|
6
|
+
export type { CreateDbConnectionOptions, DbConnection, DbConnectionHandle, DbConnectionOptions, DbRow, DbRunner, DbTx, } from "./connection.js";
|
|
7
7
|
export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.js";
|
|
8
8
|
export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor.js";
|
|
9
9
|
export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
@@ -26,8 +26,8 @@ export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation,
|
|
|
26
26
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
27
27
|
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
28
28
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
29
|
-
export type { SelectOptions, WhereObject, WhereValue } from "./query
|
|
30
|
-
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query
|
|
29
|
+
export type { DeleteManyBatchedOptions, DeleteManyBatchedResult, IncrementCounterOptions, InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, UpsertOnConflictOptions, WhereObject, WhereOperator, WhereValue, } from "./query.js";
|
|
30
|
+
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";
|
|
31
31
|
export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
|
|
32
32
|
export { seedReferenceData } from "./reference-data.js";
|
|
33
33
|
export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
|
|
@@ -35,5 +35,5 @@ export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, typ
|
|
|
35
35
|
export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
36
36
|
export { tableExists } from "./schema-inspection.js";
|
|
37
37
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
38
|
-
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
|
|
38
|
+
export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.js";
|
|
39
39
|
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
package/dist/db/index.js
CHANGED
|
@@ -20,7 +20,7 @@ export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation,
|
|
|
20
20
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
21
21
|
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
22
22
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
23
|
-
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query
|
|
23
|
+
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";
|
|
24
24
|
export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
|
|
25
25
|
export { seedReferenceData } from "./reference-data.js";
|
|
26
26
|
export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
|
|
@@ -17,7 +17,7 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
20
|
-
import { asRawClient, hasBegin, } from "
|
|
20
|
+
import { asRawClient, hasBegin, } from "./query.js";
|
|
21
21
|
// Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
|
|
22
22
|
// gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
|
|
23
23
|
// blockiert bis erster fertig).
|