@cosmicdrift/kumiko-framework 0.366.0 → 0.368.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 +22 -33
- 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/seed-context.js +3 -3
- 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 +8 -1
- package/dist/engine/bridge-stub.d.ts +4 -0
- package/dist/engine/bridge-stub.js +62 -0
- package/dist/engine/event-helpers.d.ts +2 -1
- package/dist/engine/event-helpers.js +1 -0
- package/dist/engine/feature-config-events-jobs.d.ts +4 -0
- package/dist/engine/feature-config-events-jobs.js +4 -2
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/registry-ingest.js +3 -0
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -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.d.ts +1 -0
- package/dist/files/create-file-ref-from-bytes.js +8 -3
- package/dist/files/file-routes.js +1 -1
- 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/package.json +37 -9
- package/src/changes.json +70 -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
|
@@ -123,7 +123,7 @@ export const features = [
|
|
|
123
123
|
// löschen — Row bleibt für Multi-User-Refs intakt.
|
|
124
124
|
|
|
125
125
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
126
|
-
import {
|
|
126
|
+
import { createAuthFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
127
127
|
import {
|
|
128
128
|
createComplianceProfilesFeature,
|
|
129
129
|
tenantComplianceProfileEntity,
|
|
@@ -133,6 +133,10 @@ import {
|
|
|
133
133
|
tenantRetentionOverrideEntity,
|
|
134
134
|
} from "@cosmicdrift/kumiko-bundled-features/data-retention";
|
|
135
135
|
import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
136
|
+
import {
|
|
137
|
+
tenantMembershipEntity,
|
|
138
|
+
tenantMembershipsTable,
|
|
139
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
136
140
|
import {
|
|
137
141
|
createUserFeature,
|
|
138
142
|
USER_STATUS,
|
|
@@ -144,15 +148,15 @@ import {
|
|
|
144
148
|
runForgetCleanup,
|
|
145
149
|
runUserExport,
|
|
146
150
|
} from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
|
|
147
|
-
import {
|
|
151
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
148
152
|
import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
153
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
154
|
+
import { seedRow } from "@cosmicdrift/kumiko-testing/helpers";
|
|
149
155
|
import {
|
|
150
156
|
setupTestStack,
|
|
151
157
|
type TestStack,
|
|
152
158
|
unsafeCreateEntityTable,
|
|
153
|
-
} from "@cosmicdrift/kumiko-
|
|
154
|
-
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
155
|
-
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
159
|
+
} from "@cosmicdrift/kumiko-testing/stack";
|
|
156
160
|
import { noteEntity, notesFeature, notesTable } from "../feature";
|
|
157
161
|
|
|
158
162
|
let stack: TestStack;
|
|
@@ -173,7 +177,7 @@ beforeAll(async () => {
|
|
|
173
177
|
stack = await setupTestStack({
|
|
174
178
|
features: [
|
|
175
179
|
createUserFeature(),
|
|
176
|
-
|
|
180
|
+
createAuthFoundationFeature(),
|
|
177
181
|
createSessionsFeature(),
|
|
178
182
|
createDataRetentionFeature(),
|
|
179
183
|
createComplianceProfilesFeature(),
|
|
@@ -185,23 +189,7 @@ beforeAll(async () => {
|
|
|
185
189
|
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
186
190
|
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
187
191
|
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
188
|
-
await
|
|
189
|
-
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
190
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
191
|
-
tenant_id UUID NOT NULL,
|
|
192
|
-
user_id TEXT NOT NULL,
|
|
193
|
-
version INTEGER NOT NULL DEFAULT 0,
|
|
194
|
-
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
195
|
-
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
196
|
-
inserted_by_id TEXT,
|
|
197
|
-
modified_by_id TEXT,
|
|
198
|
-
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
199
|
-
deleted_at TIMESTAMPTZ,
|
|
200
|
-
deleted_by_id TEXT,
|
|
201
|
-
roles TEXT NOT NULL DEFAULT '[]',
|
|
202
|
-
UNIQUE(user_id, tenant_id)
|
|
203
|
-
)
|
|
204
|
-
`);
|
|
192
|
+
await unsafeCreateEntityTable(stack.db, tenantMembershipEntity);
|
|
205
193
|
});
|
|
206
194
|
|
|
207
195
|
afterAll(async () => {
|
|
@@ -233,14 +221,11 @@ async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {
|
|
|
233
221
|
status: opts.status ?? USER_STATUS.Active,
|
|
234
222
|
gracePeriodEnd: opts.gracePeriodEnd ?? null,
|
|
235
223
|
});
|
|
236
|
-
await
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
`,
|
|
242
|
-
[TENANT_A, ALICE_ID],
|
|
243
|
-
);
|
|
224
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
225
|
+
tenantId: TENANT_A,
|
|
226
|
+
userId: ALICE_ID,
|
|
227
|
+
roles: '["Member"]',
|
|
228
|
+
});
|
|
244
229
|
}
|
|
245
230
|
|
|
246
231
|
async function seedNote(id: string, title: string): Promise<void> {
|
|
@@ -336,8 +321,12 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
|
|
|
336
321
|
// - HTTP-Endpoints fuer create/list
|
|
337
322
|
// - Compliance-Profile-Wiring + Cron-Scheduling
|
|
338
323
|
|
|
339
|
-
import {
|
|
340
|
-
|
|
324
|
+
import {
|
|
325
|
+
deleteMany,
|
|
326
|
+
deriveEntityTableMeta,
|
|
327
|
+
selectMany,
|
|
328
|
+
updateMany,
|
|
329
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
341
330
|
import {
|
|
342
331
|
createEntity,
|
|
343
332
|
createTextField,
|
|
@@ -109,7 +109,7 @@ import {
|
|
|
109
109
|
createRegistry,
|
|
110
110
|
validateBoot as validateBootRaw,
|
|
111
111
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
112
|
-
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-
|
|
112
|
+
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
|
|
113
113
|
import { composeAccountApp } from "../feature";
|
|
114
114
|
|
|
115
115
|
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
@@ -166,7 +166,7 @@ describe("user-profile recipe — boot validation", () => {
|
|
|
166
166
|
// })
|
|
167
167
|
|
|
168
168
|
import { createAuthEmailPasswordFeature } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
169
|
-
import {
|
|
169
|
+
import { createAuthFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
170
170
|
import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
|
|
171
171
|
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
172
172
|
import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";
|
|
@@ -211,7 +211,7 @@ export function composeAccountApp(): FeatureDefinition[] {
|
|
|
211
211
|
createAuthEmailPasswordFeature(),
|
|
212
212
|
createDataRetentionFeature(),
|
|
213
213
|
createComplianceProfilesFeature(),
|
|
214
|
-
|
|
214
|
+
createAuthFoundationFeature(),
|
|
215
215
|
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
216
216
|
createSessionsFeature(),
|
|
217
217
|
createFilesFeature(),
|
|
@@ -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.
|
|
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 each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. 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.
|
|
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.
|
|
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.
|
|
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` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.
|
|
417
417
|
|
|
418
418
|
Category: notifications
|
|
419
419
|
|
|
@@ -538,7 +538,7 @@ Requires: `template-resolver`
|
|
|
538
538
|
|
|
539
539
|
## renderer-mail-html
|
|
540
540
|
|
|
541
|
-
A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout,
|
|
541
|
+
A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource="logo"` images use the branding logo upload, or the branding logo https address when no file is uploaded. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
|
|
542
542
|
|
|
543
543
|
Requires: `renderer-foundation`, `template-resolver`
|
|
544
544
|
|
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
|