@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// The five write-verbs (create/update/delete/forget/restore) have several
|
|
2
|
+
// error paths the existing suites (event-store-executor.integration.test.ts,
|
|
3
|
+
// unique-violation-mapping.integration.test.ts) don't touch: entity- and
|
|
4
|
+
// field-level ownership denials, explicit version conflicts, the forget()
|
|
5
|
+
// verb entirely, and restore()'s two precondition failures.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { asRawClient } from "../../db/query";
|
|
9
|
+
import { createEntity, createTextField } from "../../engine";
|
|
10
|
+
import { from } from "../../engine/ownership";
|
|
11
|
+
import { createEventsTable } from "../../event-store";
|
|
12
|
+
import type { EntityCache } from "../../pipeline/entity-cache";
|
|
13
|
+
import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
14
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
15
|
+
import { buildEntityTable } from "../table-builder";
|
|
16
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
17
|
+
|
|
18
|
+
let testDb: TestDb;
|
|
19
|
+
let tdb: TenantDb;
|
|
20
|
+
const admin = TestUsers.admin;
|
|
21
|
+
const nonAdmin = TestUsers.user;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
testDb = await createTestDb();
|
|
25
|
+
await createEventsTable(testDb.db);
|
|
26
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await testDb.cleanup();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// entity-level ownership_denied — create/update/delete/forget/restore
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
const restrictedEntity = createEntity({
|
|
38
|
+
table: "read_es_write_restricted",
|
|
39
|
+
fields: {
|
|
40
|
+
email: createTextField({ required: true }),
|
|
41
|
+
},
|
|
42
|
+
softDelete: true,
|
|
43
|
+
access: { write: { Admin: "all" } },
|
|
44
|
+
});
|
|
45
|
+
const restrictedTable = buildEntityTable("esWriteRestricted", restrictedEntity);
|
|
46
|
+
|
|
47
|
+
describe("event-store-executor write-verbs — entity-level ownership_denied", () => {
|
|
48
|
+
const crud = createEventStoreExecutor(restrictedTable, restrictedEntity, {
|
|
49
|
+
entityName: "esWriteRestricted",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
await unsafeCreateEntityTableFor(restrictedEntity, "esWriteRestricted");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
await asRawClient(testDb.db).unsafe(
|
|
58
|
+
`TRUNCATE kumiko_events, read_es_write_restricted RESTART IDENTITY CASCADE`,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("create: role without a write-rule → ownership_denied", async () => {
|
|
63
|
+
const result = await crud.create({ email: "denied@test.de" }, nonAdmin, tdb);
|
|
64
|
+
expect(result.isSuccess).toBe(false);
|
|
65
|
+
if (result.isSuccess) return;
|
|
66
|
+
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("update: role without a write-rule → ownership_denied", async () => {
|
|
70
|
+
const created = await crud.create({ email: "owner@test.de" }, admin, tdb);
|
|
71
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
72
|
+
|
|
73
|
+
const result = await crud.update(
|
|
74
|
+
{ id: created.data.id, version: 1, changes: { email: "changed@test.de" } },
|
|
75
|
+
nonAdmin,
|
|
76
|
+
tdb,
|
|
77
|
+
);
|
|
78
|
+
expect(result.isSuccess).toBe(false);
|
|
79
|
+
if (result.isSuccess) return;
|
|
80
|
+
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("delete: role without a write-rule → ownership_denied", async () => {
|
|
84
|
+
const created = await crud.create({ email: "todelete@test.de" }, admin, tdb);
|
|
85
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
86
|
+
|
|
87
|
+
const result = await crud.delete({ id: created.data.id }, nonAdmin, tdb);
|
|
88
|
+
expect(result.isSuccess).toBe(false);
|
|
89
|
+
if (result.isSuccess) return;
|
|
90
|
+
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("forget: role without a write-rule → ownership_denied", async () => {
|
|
94
|
+
const created = await crud.create({ email: "toforget@test.de" }, admin, tdb);
|
|
95
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
96
|
+
|
|
97
|
+
const result = await crud.forget({ id: created.data.id }, nonAdmin, tdb);
|
|
98
|
+
expect(result.isSuccess).toBe(false);
|
|
99
|
+
if (result.isSuccess) return;
|
|
100
|
+
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("forget: happy path hard-deletes the row and appends a forgotten event", async () => {
|
|
104
|
+
const created = await crud.create({ email: "gone@test.de" }, admin, tdb);
|
|
105
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
106
|
+
|
|
107
|
+
const result = await crud.forget({ id: created.data.id }, admin, tdb);
|
|
108
|
+
expect(result.isSuccess).toBe(true);
|
|
109
|
+
|
|
110
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
111
|
+
`SELECT id FROM read_es_write_restricted WHERE email = 'gone@test.de'`,
|
|
112
|
+
)) as unknown[];
|
|
113
|
+
expect(rows).toHaveLength(0);
|
|
114
|
+
|
|
115
|
+
const events = (await asRawClient(testDb.db).unsafe(
|
|
116
|
+
`SELECT type FROM kumiko_events WHERE type = 'esWriteRestricted.forgotten'`,
|
|
117
|
+
)) as unknown[];
|
|
118
|
+
expect(events).toHaveLength(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("restore: role without a write-rule → ownership_denied", async () => {
|
|
122
|
+
const created = await crud.create({ email: "torestore@test.de" }, admin, tdb);
|
|
123
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
124
|
+
await crud.delete({ id: created.data.id }, admin, tdb);
|
|
125
|
+
|
|
126
|
+
const result = await crud.restore({ id: created.data.id }, nonAdmin, tdb);
|
|
127
|
+
expect(result.isSuccess).toBe(false);
|
|
128
|
+
if (result.isSuccess) return;
|
|
129
|
+
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("restore: not yet deleted row → not_deleted", async () => {
|
|
133
|
+
const created = await crud.create({ email: "notdeleted@test.de" }, admin, tdb);
|
|
134
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
135
|
+
|
|
136
|
+
const result = await crud.restore({ id: created.data.id }, admin, tdb);
|
|
137
|
+
expect(result.isSuccess).toBe(false);
|
|
138
|
+
if (result.isSuccess) return;
|
|
139
|
+
expect((result.error.details as { reason?: string }).reason).toBe("not_deleted");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// restore() on a non-soft-delete entity → soft_delete_not_enabled
|
|
145
|
+
// =============================================================================
|
|
146
|
+
|
|
147
|
+
const hardDeleteEntity = createEntity({
|
|
148
|
+
table: "read_es_write_hard",
|
|
149
|
+
fields: {
|
|
150
|
+
email: createTextField({ required: true }),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const hardDeleteTable = buildEntityTable("esWriteHard", hardDeleteEntity);
|
|
154
|
+
|
|
155
|
+
describe("event-store-executor write-verbs — restore without softDelete", () => {
|
|
156
|
+
const crud = createEventStoreExecutor(hardDeleteTable, hardDeleteEntity, {
|
|
157
|
+
entityName: "esWriteHard",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
beforeAll(async () => {
|
|
161
|
+
await unsafeCreateEntityTableFor(hardDeleteEntity, "esWriteHard");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
beforeEach(async () => {
|
|
165
|
+
await asRawClient(testDb.db).unsafe(
|
|
166
|
+
`TRUNCATE kumiko_events, read_es_write_hard RESTART IDENTITY CASCADE`,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("restore on an entity without softDelete → soft_delete_not_enabled", async () => {
|
|
171
|
+
const created = await crud.create({ email: "hard@test.de" }, admin, tdb);
|
|
172
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
173
|
+
|
|
174
|
+
const result = await crud.restore({ id: created.data.id }, admin, tdb);
|
|
175
|
+
expect(result.isSuccess).toBe(false);
|
|
176
|
+
if (result.isSuccess) return;
|
|
177
|
+
expect((result.error.details as { reason?: string }).reason).toBe("soft_delete_not_enabled");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// field-level ownership_denied — checkWriteFieldOwnership via a row-scoped rule
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
const ownedFieldEntity = createEntity({
|
|
186
|
+
table: "read_es_write_owned_field",
|
|
187
|
+
fields: {
|
|
188
|
+
authorId: createTextField({ required: true }),
|
|
189
|
+
note: createTextField({
|
|
190
|
+
access: { write: { Admin: "all", User: from("user:id", "authorId") } },
|
|
191
|
+
}),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
const ownedFieldTable = buildEntityTable("esWriteOwnedField", ownedFieldEntity);
|
|
195
|
+
|
|
196
|
+
describe("event-store-executor write-verbs — field-level ownership_denied", () => {
|
|
197
|
+
const crud = createEventStoreExecutor(ownedFieldTable, ownedFieldEntity, {
|
|
198
|
+
entityName: "esWriteOwnedField",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
beforeAll(async () => {
|
|
202
|
+
await unsafeCreateEntityTableFor(ownedFieldEntity, "esWriteOwnedField");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
beforeEach(async () => {
|
|
206
|
+
await asRawClient(testDb.db).unsafe(
|
|
207
|
+
`TRUNCATE kumiko_events, read_es_write_owned_field RESTART IDENTITY CASCADE`,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("create: writing `note` while authorId names someone else → ownership_denied (field scope)", async () => {
|
|
212
|
+
const result = await crud.create(
|
|
213
|
+
{ authorId: TestUsers.driver.id, note: "not mine" },
|
|
214
|
+
nonAdmin,
|
|
215
|
+
tdb,
|
|
216
|
+
);
|
|
217
|
+
expect(result.isSuccess).toBe(false);
|
|
218
|
+
if (result.isSuccess) return;
|
|
219
|
+
const details = result.error.details as { reason?: string; scope?: string; field?: string };
|
|
220
|
+
expect(details.reason).toBe("ownership_denied");
|
|
221
|
+
expect(details.scope).toBe("field");
|
|
222
|
+
expect(details.field).toBe("note");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("create: writing `note` as the named author succeeds", async () => {
|
|
226
|
+
const result = await crud.create({ authorId: nonAdmin.id, note: "mine" }, nonAdmin, tdb);
|
|
227
|
+
expect(result.isSuccess).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("update: changing `note` on someone else's row → ownership_denied (field scope)", async () => {
|
|
231
|
+
const created = await crud.create(
|
|
232
|
+
{ authorId: TestUsers.driver.id, note: "original" },
|
|
233
|
+
admin,
|
|
234
|
+
tdb,
|
|
235
|
+
);
|
|
236
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
237
|
+
|
|
238
|
+
const result = await crud.update(
|
|
239
|
+
{ id: created.data.id, version: 1, changes: { note: "hijacked" } },
|
|
240
|
+
nonAdmin,
|
|
241
|
+
tdb,
|
|
242
|
+
);
|
|
243
|
+
expect(result.isSuccess).toBe(false);
|
|
244
|
+
if (result.isSuccess) return;
|
|
245
|
+
expect((result.error.details as { reason?: string }).reason).toBe("ownership_denied");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// =============================================================================
|
|
250
|
+
// version_conflict — explicit-id create collision, missing version on update
|
|
251
|
+
// =============================================================================
|
|
252
|
+
|
|
253
|
+
const versionEntity = createEntity({
|
|
254
|
+
table: "read_es_write_version",
|
|
255
|
+
fields: {
|
|
256
|
+
email: createTextField({ required: true }),
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
const versionTable = buildEntityTable("esWriteVersion", versionEntity);
|
|
260
|
+
|
|
261
|
+
describe("event-store-executor write-verbs — version_conflict edge cases", () => {
|
|
262
|
+
const crud = createEventStoreExecutor(versionTable, versionEntity, {
|
|
263
|
+
entityName: "esWriteVersion",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
beforeAll(async () => {
|
|
267
|
+
await unsafeCreateEntityTableFor(versionEntity, "esWriteVersion");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
beforeEach(async () => {
|
|
271
|
+
await asRawClient(testDb.db).unsafe(
|
|
272
|
+
`TRUNCATE kumiko_events, read_es_write_version RESTART IDENTITY CASCADE`,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("create with an explicit id that already has a stream → version_conflict", async () => {
|
|
277
|
+
const first = await crud.create({ email: "first@test.de" }, admin, tdb);
|
|
278
|
+
if (!first.isSuccess) throw new Error("setup failed");
|
|
279
|
+
|
|
280
|
+
const collision = await crud.create({ id: first.data.id, email: "second@test.de" }, admin, tdb);
|
|
281
|
+
expect(collision.isSuccess).toBe(false);
|
|
282
|
+
if (collision.isSuccess) return;
|
|
283
|
+
expect(collision.error.code).toBe("version_conflict");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("update without a version field (and no skipOptimisticLock) → version_conflict", async () => {
|
|
287
|
+
const created = await crud.create({ email: "noversion@test.de" }, admin, tdb);
|
|
288
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
289
|
+
|
|
290
|
+
const result = await crud.update(
|
|
291
|
+
{ id: created.data.id, changes: { email: "changed@test.de" } },
|
|
292
|
+
admin,
|
|
293
|
+
tdb,
|
|
294
|
+
);
|
|
295
|
+
expect(result.isSuccess).toBe(false);
|
|
296
|
+
if (result.isSuccess) return;
|
|
297
|
+
expect(result.error.code).toBe("version_conflict");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
async function unsafeCreateEntityTableFor(
|
|
302
|
+
entity: Parameters<typeof buildEntityTable>[1],
|
|
303
|
+
name: string,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
await unsafeCreateEntityTable(testDb.db, entity, name);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// =============================================================================
|
|
309
|
+
// Concurrent update race → EventStoreVersionConflict catch + entityCache.del
|
|
310
|
+
// on forget/restore (create/update/delete already exercise cache in the
|
|
311
|
+
// main suite; forget/restore del() stayed uncovered).
|
|
312
|
+
// =============================================================================
|
|
313
|
+
|
|
314
|
+
const raceEntity = createEntity({
|
|
315
|
+
table: "read_es_write_race",
|
|
316
|
+
fields: {
|
|
317
|
+
email: createTextField({ required: true }),
|
|
318
|
+
},
|
|
319
|
+
softDelete: true,
|
|
320
|
+
});
|
|
321
|
+
const raceTable = buildEntityTable("esWriteRace", raceEntity);
|
|
322
|
+
|
|
323
|
+
describe("event-store-executor write-verbs — concurrent version race + cache", () => {
|
|
324
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
325
|
+
const entityCache: EntityCache = {
|
|
326
|
+
get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
|
|
327
|
+
mget: async () => new Map(),
|
|
328
|
+
set: async (tenantId, name, id, data) => {
|
|
329
|
+
store.set(`${tenantId}:${name}:${id}`, data);
|
|
330
|
+
},
|
|
331
|
+
mset: async (tenantId, name, entries) => {
|
|
332
|
+
for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
|
|
333
|
+
},
|
|
334
|
+
del: async (tenantId, name, id) => {
|
|
335
|
+
store.delete(`${tenantId}:${name}:${id}`);
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const crud = createEventStoreExecutor(raceTable, raceEntity, {
|
|
339
|
+
entityName: "esWriteRace",
|
|
340
|
+
entityCache,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
beforeAll(async () => {
|
|
344
|
+
await unsafeCreateEntityTableFor(raceEntity, "esWriteRace");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
beforeEach(async () => {
|
|
348
|
+
store.clear();
|
|
349
|
+
await asRawClient(testDb.db).unsafe(
|
|
350
|
+
`TRUNCATE kumiko_events, read_es_write_race RESTART IDENTITY CASCADE`,
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("two concurrent updates with the same version → one wins, one version_conflict", async () => {
|
|
355
|
+
const created = await crud.create({ email: "race@test.de" }, admin, tdb);
|
|
356
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
357
|
+
const id = created.data.id;
|
|
358
|
+
|
|
359
|
+
const [a, b] = await Promise.all([
|
|
360
|
+
crud.update({ id, version: 1, changes: { email: "a@test.de" } }, admin, tdb),
|
|
361
|
+
crud.update({ id, version: 1, changes: { email: "b@test.de" } }, admin, tdb),
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
const results = [a, b];
|
|
365
|
+
expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
|
|
366
|
+
expect(results.filter((r) => !r.isSuccess && r.error.code === "version_conflict")).toHaveLength(
|
|
367
|
+
1,
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("forget with entityCache clears the cache entry", async () => {
|
|
372
|
+
const created = await crud.create({ email: "cache-forget@test.de" }, admin, tdb);
|
|
373
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
374
|
+
const id = created.data.id;
|
|
375
|
+
const key = `${admin.tenantId}:esWriteRace:${id}`;
|
|
376
|
+
store.set(key, { email: "poison" });
|
|
377
|
+
|
|
378
|
+
const result = await crud.forget({ id }, admin, tdb);
|
|
379
|
+
expect(result.isSuccess).toBe(true);
|
|
380
|
+
expect(store.has(key)).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("restore with entityCache clears the cache entry", async () => {
|
|
384
|
+
const created = await crud.create({ email: "cache-restore@test.de" }, admin, tdb);
|
|
385
|
+
if (!created.isSuccess) throw new Error("setup failed");
|
|
386
|
+
const id = created.data.id;
|
|
387
|
+
await crud.delete({ id }, admin, tdb);
|
|
388
|
+
|
|
389
|
+
const key = `${admin.tenantId}:esWriteRace:${id}`;
|
|
390
|
+
store.set(key, { email: "poison" });
|
|
391
|
+
|
|
392
|
+
const result = await crud.restore({ id }, admin, tdb);
|
|
393
|
+
expect(result.isSuccess).toBe(true);
|
|
394
|
+
expect(store.has(key)).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { InMemoryKmsAdapter, PII_ERASED_SENTINEL } from "../../crypto";
|
|
2
|
+
import { InMemoryKmsAdapter, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL } from "../../crypto";
|
|
3
3
|
import { asRawClient } from "../../db/query";
|
|
4
4
|
import { createBooleanField, createEntity, createTextField } from "../../engine";
|
|
5
5
|
import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
|
|
@@ -726,13 +726,13 @@ describe("event-store-executor — pii subject encryption", () => {
|
|
|
726
726
|
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
727
727
|
`SELECT email, plain FROM read_es_exec_pii LIMIT 1`,
|
|
728
728
|
)) as Array<{ email: string; plain: string }>;
|
|
729
|
-
expect(rawRows[0]!.email).toStartWith(
|
|
729
|
+
expect(rawRows[0]!.email).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${result.data.id}:`);
|
|
730
730
|
expect(rawRows[0]!.plain).toBe("visible");
|
|
731
731
|
|
|
732
732
|
const events = (await asRawClient(testDb.db).unsafe(
|
|
733
733
|
`SELECT payload FROM kumiko_events WHERE type = 'esExecPii.created' LIMIT 1`,
|
|
734
734
|
)) as Array<{ payload: { email?: string } }>;
|
|
735
|
-
expect(events[0]?.payload.email).toStartWith(
|
|
735
|
+
expect(events[0]?.payload.email).toStartWith(PII_CIPHERTEXT_PREFIX);
|
|
736
736
|
|
|
737
737
|
const detail = await crud.detail({ id: result.data.id }, adminUser, tdb);
|
|
738
738
|
expect(detail?.["email"]).toBe("pii@test.de");
|
|
@@ -759,7 +759,7 @@ describe("event-store-executor — pii subject encryption", () => {
|
|
|
759
759
|
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
760
760
|
`SELECT note FROM read_es_exec_pii LIMIT 1`,
|
|
761
761
|
)) as Array<{ note: string }>;
|
|
762
|
-
expect(rawRows[0]!.note).toStartWith(
|
|
762
|
+
expect(rawRows[0]!.note).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${TestUsers.admin.id}:`);
|
|
763
763
|
});
|
|
764
764
|
|
|
765
765
|
test("eraseKey: detail renders the sentinel, stored events stay byte-identical", async () => {
|
|
@@ -805,7 +805,7 @@ describe("event-store-executor — pii subject encryption", () => {
|
|
|
805
805
|
const rawRows = (await asRawClient(testDb.db).unsafe(
|
|
806
806
|
`SELECT email FROM read_es_exec_pii LIMIT 1`,
|
|
807
807
|
)) as Array<{ email: string }>;
|
|
808
|
-
expect(rawRows[0]!.email).toStartWith(
|
|
808
|
+
expect(rawRows[0]!.email).toStartWith(PII_CIPHERTEXT_PREFIX);
|
|
809
809
|
expect(rawRows[0]!.email).not.toContain("rebuild@test.de");
|
|
810
810
|
|
|
811
811
|
const detail = await crud.detail({ id: created.data.id }, adminUser, tdb);
|
|
@@ -31,7 +31,9 @@ export async function nullBlindIndexesForSubject(
|
|
|
31
31
|
features: ReadonlyMap<string, FeatureDefinition>,
|
|
32
32
|
subjectKey: string,
|
|
33
33
|
): Promise<void> {
|
|
34
|
-
|
|
34
|
+
// "v%" matches any format version (v1 no-AAD, v2 AAD-bound, #1263) — the
|
|
35
|
+
// subject key placement is stable across versions.
|
|
36
|
+
const likePattern = `kumiko-pii:v%:${escapeLikePattern(subjectKey)}:%`;
|
|
35
37
|
for (const feature of features.values()) {
|
|
36
38
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
37
39
|
const lookupable = collectLookupableFields(entity);
|
package/src/db/connection.ts
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
// DB-Connection-Types: provider-agnostic via db/api.ts.
|
|
2
2
|
// createConnection delegiert an postgres-provider (default) oder bun-provider (DB_PROVIDER=bun).
|
|
3
|
+
import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
3
4
|
import postgres from "postgres";
|
|
4
5
|
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
5
6
|
|
|
6
|
-
export { createConnection, type DbConnectionOptions } from "./api";
|
|
7
|
-
|
|
8
7
|
// Legacy Types — für Aufrufer die direkt diese Module importieren
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
// biome-ignore lint/suspicious/noExplicitAny: postgres-js namespace lookup
|
|
12
|
-
export type DbTx = postgres.TransactionSql<any> | any;
|
|
13
|
-
export type DbRunner = DbConnection | DbTx;
|
|
14
|
-
export type DbRow = Record<string, unknown>;
|
|
15
|
-
export type PgClient = ReturnType<typeof postgres>;
|
|
16
|
-
|
|
17
|
-
export type PgListenClient = ReturnType<typeof postgres>;
|
|
8
|
+
export * from "@cosmicdrift/kumiko-types/db-connection";
|
|
9
|
+
export { createConnection, type DbConnectionOptions } from "./api";
|
|
18
10
|
|
|
19
11
|
// Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
|
|
20
12
|
// guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
|
package/src/db/cursor.ts
CHANGED
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export type CursorQueryOptions = {
|
|
4
|
-
tenantId: TenantId;
|
|
5
|
-
cursor?: string;
|
|
6
|
-
limit?: number;
|
|
7
|
-
filterIds?: readonly EntityId[];
|
|
8
|
-
sort?: string;
|
|
9
|
-
sortDirection?: "asc" | "desc";
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export type CursorResult<T> = {
|
|
13
|
-
rows: T[];
|
|
14
|
-
nextCursor: string | null;
|
|
15
|
-
/** Optional total row count — nur present wenn der Caller `totalCount: true`
|
|
16
|
-
* in der Query setzt. */
|
|
17
|
-
total?: number;
|
|
18
|
-
};
|
|
1
|
+
export type { CursorQueryOptions, CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
|
|
19
2
|
|
|
20
3
|
// String-basiert damit UUIDs (Default seit Sprint F) + Integer-Auto-Increment
|
|
21
4
|
// durch denselben Cursor-Pfad laufen. UUIDv7 erfüllt die lex-Monotonie
|
package/src/db/dialect.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// The framework no longer imports drizzle-orm at runtime — schema-files
|
|
14
14
|
// use only this module.
|
|
15
15
|
|
|
16
|
+
import {
|
|
17
|
+
type ColumnHandle,
|
|
18
|
+
KUMIKO_COLUMNS_SYMBOL,
|
|
19
|
+
KUMIKO_NAME_SYMBOL,
|
|
20
|
+
type SchemaTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
16
22
|
import type {
|
|
17
23
|
ColumnMeta,
|
|
18
24
|
CompositePrimaryKeyMeta,
|
|
@@ -21,6 +27,8 @@ import type {
|
|
|
21
27
|
PgType,
|
|
22
28
|
} from "./entity-table-meta";
|
|
23
29
|
|
|
30
|
+
export type { ColumnHandle, SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
31
|
+
|
|
24
32
|
// Public type aliases — historical compat for callers that used to import
|
|
25
33
|
// these from drizzle-orm/pg-core. SelectQuery is no longer a meaningful
|
|
26
34
|
// shape (no chain builder); TableColumns is the new SchemaTable union.
|
|
@@ -29,31 +37,12 @@ export type TableColumns<_T = any> = SchemaTable;
|
|
|
29
37
|
// biome-ignore lint/suspicious/noExplicitAny: legacy type — chain API is gone
|
|
30
38
|
export type SelectQuery = any;
|
|
31
39
|
|
|
32
|
-
// Column handle exposed on the SchemaTable. The `name` is the SQL column
|
|
33
|
-
// name (snake_case); legacy code accesses `table.fieldName.name` to
|
|
34
|
-
// produce raw SQL.
|
|
35
|
-
export type ColumnHandle = {
|
|
36
|
-
readonly name: string;
|
|
37
|
-
readonly pgType: PgType;
|
|
38
|
-
readonly getSQLType: () => string;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
42
|
-
const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
|
|
43
40
|
// Shadow-proof handle on the EntityTableMeta. The column handles below are
|
|
44
41
|
// spread as enumerable props, so an entity field named `source`/`columns`/
|
|
45
42
|
// `tableName`/… would overwrite the matching meta key. extractTableInfo reads
|
|
46
43
|
// the canonical meta from this symbol instead of the (shadowable) props.
|
|
47
44
|
const KUMIKO_META_SYMBOL = Symbol.for("kumiko:schema:Meta");
|
|
48
45
|
|
|
49
|
-
// SchemaTable — opaque shape with both EntityTableMeta + Symbol-based
|
|
50
|
-
// introspection. Returned by `table(...)`.
|
|
51
|
-
export type SchemaTable = EntityTableMeta & {
|
|
52
|
-
readonly [KUMIKO_NAME_SYMBOL]: string;
|
|
53
|
-
readonly [KUMIKO_COLUMNS_SYMBOL]: Record<string, ColumnHandle>;
|
|
54
|
-
readonly [field: string]: unknown;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
46
|
function isNumericPgType(t: PgType): t is `numeric(${number},${number})` {
|
|
58
47
|
return t.startsWith("numeric(");
|
|
59
48
|
}
|
package/src/db/encryption.ts
CHANGED
|
@@ -12,9 +12,8 @@ export type EncryptionProvider = {
|
|
|
12
12
|
/**
|
|
13
13
|
* @deprecated Legacy single-key format: base64(iv+tag+ct) with NO key id —
|
|
14
14
|
* a key change makes existing ciphertexts permanently undecryptable. Use
|
|
15
|
-
* `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code;
|
|
16
|
-
* provider
|
|
17
|
-
* (EnvelopeCipherOptions.legacy) until a re-encrypt job migrated them.
|
|
15
|
+
* `createEnvelopeCipher` (@cosmicdrift/kumiko-framework/secrets) for new code;
|
|
16
|
+
* this provider has no consumers left in the framework.
|
|
18
17
|
*/
|
|
19
18
|
export function createEncryptionProvider(key: string): EncryptionProvider {
|
|
20
19
|
// Key must be 32 bytes for AES-256
|