@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.65.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 +6 -6
- package/src/config/__tests__/write-helpers.test.ts +152 -0
- package/src/config/read-redaction.ts +0 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -4
- package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/entity.ts +10 -3
- package/src/custom-fields/events.ts +4 -1
- package/src/custom-fields/feature.ts +1 -5
- package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
- package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
- package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
- package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
- package/src/custom-fields/wire-for-entity.ts +7 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
- package/src/files-provider-s3/s3-provider.ts +2 -4
- package/src/managed-pages/handlers/set.write.ts +4 -11
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
- package/src/sessions/feature.ts +16 -3
- package/src/tags/__tests__/tags.integration.test.ts +30 -1
- package/src/tags/entity.ts +8 -0
- package/src/tags/handlers/assign-tag.write.ts +20 -5
- package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
- package/src/tags/web/i18n.ts +6 -2
- package/src/tags/web/tag-section.tsx +87 -76
- package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
- package/src/tier-engine/__tests__/trial.test.ts +27 -0
- package/src/tier-engine/entity.ts +8 -0
- package/src/tier-engine/feature.ts +49 -9
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
- package/src/tier-engine/index.ts +1 -0
- package/src/tier-engine/trial.ts +26 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
- package/src/user-data-rights/constants.ts +48 -0
- package/src/user-data-rights/feature.ts +15 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
- package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
- package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
- package/src/user-data-rights/index.ts +3 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
- package/src/user-data-rights/run-forget-cleanup.ts +3 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
- package/src/user-data-rights/web/client-plugin.tsx +30 -0
- package/src/user-data-rights/web/i18n.ts +95 -0
- package/src/user-data-rights/web/index.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { buildEntityTable, extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { DEFAULT_VALUE_WRITE_ROLES } from "../constants";
|
|
6
|
+
import { setCustomFieldValue } from "../db/queries/projection";
|
|
5
7
|
import { customFieldsFeature } from "../feature";
|
|
6
8
|
import { fieldWriteAccessDeniedRoles, loadFieldDefinition } from "../lib/field-access";
|
|
7
9
|
import { buildCustomFieldValueSchema } from "../lib/value-schema";
|
|
@@ -86,15 +88,55 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// PII (`sensitive: true` definition): self-project the value here —
|
|
92
|
+
// synchronously, from the in-memory value — exactly like the entity executor
|
|
93
|
+
// does for sensitive entity fields. The persisted customField.set event then
|
|
94
|
+
// omits the value, so PII never enters the immutable event log; the existing
|
|
95
|
+
// user-data-rights strip of the projection erases it durably. Trade-off: a
|
|
96
|
+
// projection rebuild replays the value-less event and the MSP skips it (see
|
|
97
|
+
// wire-for-entity), so the value is gone — identical to a sensitive entity
|
|
98
|
+
// field. The host table isn't known to this generic handler, so resolve it
|
|
99
|
+
// per-stack via the registry (no module-global state).
|
|
100
|
+
const sensitive = loaded.field.sensitive === true;
|
|
101
|
+
if (sensitive) {
|
|
102
|
+
const entity = ctx.registry.getEntity(payload.entityName);
|
|
103
|
+
if (!entity) {
|
|
104
|
+
// Fail closed: without the host table we cannot self-project, and must
|
|
105
|
+
// NOT fall back to writing the value into the event log.
|
|
106
|
+
return failUnprocessable("custom_field_host_unresolved", {
|
|
107
|
+
entityName: payload.entityName,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Resolves the same canonical table name the MSP/postQuery use (the table
|
|
111
|
+
// NAME, not the drizzle object). Holds unless a host entity is wired with
|
|
112
|
+
// a custom backing table whose name diverges from its definition — rare,
|
|
113
|
+
// and the MSP path makes the same assumption.
|
|
114
|
+
const tableName = extractTableName(
|
|
115
|
+
buildEntityTable(payload.entityName, entity),
|
|
116
|
+
"custom-fields/set-custom-field",
|
|
117
|
+
);
|
|
118
|
+
await setCustomFieldValue(
|
|
119
|
+
ctx.db.raw,
|
|
120
|
+
tableName,
|
|
121
|
+
payload.fieldKey,
|
|
122
|
+
payload.value,
|
|
123
|
+
payload.entityId,
|
|
124
|
+
event.user.tenantId,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
89
128
|
// Emit customField.set on host-aggregate stream. unsafeAppendEvent
|
|
90
129
|
// (statt strict appendEvent) weil event-type-map keine cross-feature-
|
|
91
130
|
// augmentation für diesen event-typ hat — wir nutzen den qualified
|
|
92
|
-
// string-namen direkt.
|
|
131
|
+
// string-namen direkt. Sensitive fields persist a value-less event (the
|
|
132
|
+
// value was self-projected above and must stay out of the log).
|
|
93
133
|
await ctx.unsafeAppendEvent({
|
|
94
134
|
aggregateId: payload.entityId,
|
|
95
135
|
aggregateType: payload.entityName,
|
|
96
136
|
type: customFieldsFeature.exports.setEvent.name,
|
|
97
|
-
payload:
|
|
137
|
+
payload: sensitive
|
|
138
|
+
? { fieldKey: payload.fieldKey }
|
|
139
|
+
: { fieldKey: payload.fieldKey, value: payload.value },
|
|
98
140
|
});
|
|
99
141
|
|
|
100
142
|
return {
|
|
@@ -3,6 +3,7 @@ import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/e
|
|
|
3
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
4
4
|
import { fieldDefinitionExecutor } from "../executor";
|
|
5
5
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
6
|
+
import { parseSerializedField } from "../lib/parse-serialized-field";
|
|
6
7
|
import { type UpdateFieldPayload, updateFieldPayloadSchema } from "../schemas";
|
|
7
8
|
|
|
8
9
|
// update-tenant-field — TenantAdmin ersetzt den Stand einer bestehenden
|
|
@@ -59,6 +60,22 @@ export const updateTenantFieldHandler: WriteHandlerDef = {
|
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// `sensitive` is immutable. Flipping it on an existing field would leave a
|
|
64
|
+
// GDPR hole: a non-sensitive→sensitive switch can't retroactively erase the
|
|
65
|
+
// values already written to host rows by past sets (set-custom-field only
|
|
66
|
+
// routes the PII-safe path at write time). To change sensitivity, delete +
|
|
67
|
+
// re-define (the honest cut — same rationale as the type guard above).
|
|
68
|
+
const currentSensitive = parseSerializedField(existing["serializedField"])?.sensitive === true;
|
|
69
|
+
const requestedSensitive = payload.serializedField.sensitive === true;
|
|
70
|
+
if (currentSensitive !== requestedSensitive) {
|
|
71
|
+
return failUnprocessable("field_sensitive_immutable", {
|
|
72
|
+
entityName: payload.entityName,
|
|
73
|
+
fieldKey: payload.fieldKey,
|
|
74
|
+
currentSensitive,
|
|
75
|
+
requestedSensitive,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
// entityName/fieldKey sind die Identität — nicht Teil der changes.
|
|
63
80
|
const {
|
|
64
81
|
entityName: _entityName,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { fieldDefinitionExecutor } from "../executor";
|
|
2
|
+
import type { FieldDefinitionColumns } from "./field-definition-row";
|
|
3
|
+
|
|
4
|
+
type DefineUser = Parameters<typeof fieldDefinitionExecutor.create>[1];
|
|
5
|
+
type DefineDb = Parameters<typeof fieldDefinitionExecutor.create>[2];
|
|
6
|
+
type WriteResult = Awaited<ReturnType<typeof fieldDefinitionExecutor.create>>;
|
|
7
|
+
|
|
8
|
+
// Resurrection-aware define for the deterministic fieldDefinition aggregate-id.
|
|
9
|
+
//
|
|
10
|
+
// A definition's id is uuidv5(tenant|entity|fieldKey), so deleting it leaves a
|
|
11
|
+
// (created+deleted) event stream under that id. A plain create() appends at
|
|
12
|
+
// version 0 onto that stream → version_conflict — the deleted (entity, fieldKey)
|
|
13
|
+
// could never be re-defined. The lifecycle states and their handling:
|
|
14
|
+
// - active definition exists → let create() raise the natural version_conflict
|
|
15
|
+
// (409) the dedup contract relies on.
|
|
16
|
+
// - soft-deleted definition → restore() the stream, then update() it to the
|
|
17
|
+
// new payload (the caller is defining it afresh).
|
|
18
|
+
// - never defined → create().
|
|
19
|
+
//
|
|
20
|
+
// restore-before-create matters: a create() version_conflict aborts the
|
|
21
|
+
// surrounding tx, so a follow-up restore()/update() on the same connection would
|
|
22
|
+
// fail with "current transaction is aborted". detail() (a read) + restore()
|
|
23
|
+
// (which sees soft-deleted rows via selectMany and only writes on success) keep
|
|
24
|
+
// the tx clean until the single terminal write.
|
|
25
|
+
export async function defineOrResurrectFieldDefinition(
|
|
26
|
+
aggregateId: string,
|
|
27
|
+
columns: FieldDefinitionColumns,
|
|
28
|
+
user: DefineUser,
|
|
29
|
+
db: DefineDb,
|
|
30
|
+
): Promise<WriteResult> {
|
|
31
|
+
const active = await fieldDefinitionExecutor.detail({ id: aggregateId }, user, db);
|
|
32
|
+
if (active) {
|
|
33
|
+
return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const restored = await fieldDefinitionExecutor.restore({ id: aggregateId }, user, db);
|
|
37
|
+
if (restored.isSuccess) {
|
|
38
|
+
// restore() just un-deleted the row in this same tx; no concurrent writer
|
|
39
|
+
// exists, so skip the optimistic-lock version match (we'd otherwise have to
|
|
40
|
+
// thread the post-restore stream version through). Overwrite with the new
|
|
41
|
+
// definition payload — the caller is defining the field afresh.
|
|
42
|
+
// Spread to a mutable copy: `changes` is Record<string, unknown> and the
|
|
43
|
+
// readonly FieldDefinitionColumns isn't assignable to it.
|
|
44
|
+
return fieldDefinitionExecutor.update({ id: aggregateId, changes: { ...columns } }, user, db, {
|
|
45
|
+
skipOptimisticLock: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (restored.error.code === "not_found") {
|
|
49
|
+
return fieldDefinitionExecutor.create({ id: aggregateId, ...columns }, user, db);
|
|
50
|
+
}
|
|
51
|
+
return restored;
|
|
52
|
+
}
|
|
@@ -97,6 +97,13 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
|
|
|
97
97
|
if (event.aggregateType !== entityName) return;
|
|
98
98
|
const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
|
|
99
99
|
|
|
100
|
+
// skip: sensitive fields self-project in the write handler (see
|
|
101
|
+
// set-custom-field) and persist a value-less event so PII never enters
|
|
102
|
+
// the log. Such events arrive here with value === undefined — skipping
|
|
103
|
+
// is correct both live (the handler already wrote the row) and on replay
|
|
104
|
+
// (the value is intentionally gone, the accepted rebuild-loss).
|
|
105
|
+
if (payload.value === undefined) return;
|
|
106
|
+
|
|
100
107
|
// jsonb_set: setze key auf value. Wenn key noch nicht existiert →
|
|
101
108
|
// wird angelegt (create_missing=true ist default). value muss als
|
|
102
109
|
// jsonb-literal kommen.
|
|
@@ -35,9 +35,9 @@ describe("resolveForcePathStyle", () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
// virtualHostedStyle
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// virtualHostedStyle is the inversion createS3Provider hands to Bun.S3Client.
|
|
39
|
+
// The `!` is the silent drift point: flip it and Minio/R2 pick the wrong URL
|
|
40
|
+
// form with no compile or runtime error.
|
|
41
41
|
describe("resolveVirtualHostedStyle (inverse of forcePathStyle)", () => {
|
|
42
42
|
const cases: ReadonlyArray<{ name: string; config: S3ProviderConfig }> = [
|
|
43
43
|
{ name: "no endpoint + no override", config: baseConfig },
|
|
@@ -66,11 +66,10 @@ describe("resolveVirtualHostedStyle (inverse of forcePathStyle)", () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
// presign
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// Dateinamens, lautlos.
|
|
69
|
+
// presign is a pure local signing operation (HMAC, no network), so it tests
|
|
70
|
+
// hermetically with dummy credentials. Proves Bun actually signs contentDisposition
|
|
71
|
+
// as the response-content-disposition query param — otherwise a download would
|
|
72
|
+
// silently return the UUID key instead of the filename.
|
|
74
73
|
describe("getSignedUrl contentDisposition", () => {
|
|
75
74
|
const provider = createS3Provider({
|
|
76
75
|
bucket: "b",
|
|
@@ -54,10 +54,8 @@ export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
|
|
|
54
54
|
return config.forcePathStyle ?? config.endpoint !== undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// Bun's `virtualHostedStyle` is the inverse of the
|
|
58
|
-
//
|
|
59
|
-
// tested alongside resolveForcePathStyle because the inversion is exactly the
|
|
60
|
-
// seam that silently breaks Minio/R2 if the `!` ever drifts.
|
|
57
|
+
// Bun's `virtualHostedStyle` is the strict inverse of the `forcePathStyle` knob
|
|
58
|
+
// this config exposes — the seam that silently breaks Minio/R2 if the `!` drifts.
|
|
61
59
|
export function resolveVirtualHostedStyle(config: S3ProviderConfig): boolean {
|
|
62
60
|
return !resolveForcePathStyle(config);
|
|
63
61
|
}
|
|
@@ -51,20 +51,13 @@ export const setWrite = defineWriteHandler({
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
const tenantId = override ?? event.user.tenantId;
|
|
54
|
-
//
|
|
55
|
-
// umgestellt werden, sonst läuft getStreamVersion gegen user.tenantId
|
|
56
|
-
// statt tenantId → version_conflict trotz vorhandener projection-row.
|
|
54
|
+
// override: point the executor context at the target tenant, else getStreamVersion runs against user.tenantId → version_conflict.
|
|
57
55
|
const executorUser =
|
|
58
56
|
override !== undefined ? { ...event.user, tenantId: override as TenantId } : event.user; // @cast-boundary engine-bridge
|
|
59
57
|
|
|
60
|
-
// ctx.db is
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// re-provision retries as a create → unique_violation) AND the executor's
|
|
64
|
-
// stream reads (getStreamVersion/loadAggregate filtered to the executor's
|
|
65
|
-
// tenant → not_found/version_conflict). Re-scope a TenantDb to the resolved
|
|
66
|
-
// target tenant so reads and writes both land there. Safe: the override
|
|
67
|
-
// branch is SystemAdmin-gated above.
|
|
58
|
+
// ctx.db is scoped to the executing user's tenant, wrong for a cross-tenant override
|
|
59
|
+
// on both the existing-check and the executor's stream reads; re-scope to the target.
|
|
60
|
+
// Safe: the override branch is SystemAdmin-gated above.
|
|
68
61
|
const scopedDb =
|
|
69
62
|
override !== undefined ? createTenantDb(db.raw, override as TenantId, "tenant") : db; // @cast-boundary engine-bridge
|
|
70
63
|
const existing = await fetchOne<PageRow>(scopedDb, pagesTable, {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
asRawClient,
|
|
4
|
+
insertOne,
|
|
5
|
+
selectMany,
|
|
6
|
+
updateMany,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { createRegistry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
|
+
import {
|
|
12
|
+
createProjectionStateTable,
|
|
13
|
+
rebuildProjection,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
15
|
+
import {
|
|
16
|
+
createTestDb,
|
|
17
|
+
type TestDb,
|
|
18
|
+
testTenantId,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { Temporal } from "temporal-polyfill";
|
|
22
|
+
import { createSessionsFeature } from "../feature";
|
|
23
|
+
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
24
|
+
|
|
25
|
+
// read_user_sessions is a hot-path direct-write store: sessionCreator inserts
|
|
26
|
+
// rows and the revoke handlers update them WITHOUT emitting lifecycle events.
|
|
27
|
+
// If the table is registered as an r.entity, the framework makes it a
|
|
28
|
+
// rebuildable implicit projection whose replay finds zero matching events and
|
|
29
|
+
// swaps an EMPTY shadow over the live table — silently wiping every active
|
|
30
|
+
// session on the next projection rebuild (deploy / `schema apply`). #498/#494.
|
|
31
|
+
//
|
|
32
|
+
// Pre-fix both tests are RED: the implicit projection "sessions:projection:
|
|
33
|
+
// user-session-entity" exists and rebuilding it empties read_user_sessions.
|
|
34
|
+
// Post-fix (r.unmanagedTable) the table is no longer a rebuild target.
|
|
35
|
+
|
|
36
|
+
const IMPLICIT_PROJECTION = "sessions:projection:user-session-entity";
|
|
37
|
+
|
|
38
|
+
let testDb: TestDb;
|
|
39
|
+
const TENANT: TenantId = testTenantId(1);
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
testDb = await createTestDb();
|
|
43
|
+
await unsafeCreateEntityTable(testDb.db, userSessionEntity, "user-session");
|
|
44
|
+
await createEventsTable(testDb.db);
|
|
45
|
+
await createProjectionStateTable(testDb.db);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
await testDb.cleanup();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
await asRawClient(testDb.db).unsafe(
|
|
54
|
+
"TRUNCATE read_user_sessions, kumiko_events, kumiko_projections RESTART IDENTITY CASCADE",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Mirrors createSessionCallbacks().sessionCreator + sessionRevoker: a row
|
|
59
|
+
// written directly on the hot path, then revoked — no events anywhere.
|
|
60
|
+
const SID = "00000000-0000-0000-0000-000000000001";
|
|
61
|
+
|
|
62
|
+
async function insertRevokedSession(db: DbConnection): Promise<void> {
|
|
63
|
+
const now = Temporal.Now.instant();
|
|
64
|
+
await insertOne(db, userSessionTable, {
|
|
65
|
+
id: SID,
|
|
66
|
+
tenantId: TENANT,
|
|
67
|
+
userId: "user-1",
|
|
68
|
+
createdAt: now,
|
|
69
|
+
expiresAt: now.add({ milliseconds: 3_600_000 }),
|
|
70
|
+
ip: "1.2.3.4",
|
|
71
|
+
userAgent: "test-agent",
|
|
72
|
+
});
|
|
73
|
+
await updateMany(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("sessions / read_user_sessions survives projection rebuild", () => {
|
|
77
|
+
test("is NOT registered as a rebuildable implicit projection", () => {
|
|
78
|
+
const registry = createRegistry([createSessionsFeature()]);
|
|
79
|
+
expect(registry.getAllProjections().has(IMPLICIT_PROJECTION)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("direct-written rows (incl. revoked state) survive a rebuild", async () => {
|
|
83
|
+
await insertRevokedSession(createTenantDb(testDb.db, TENANT));
|
|
84
|
+
|
|
85
|
+
const registry = createRegistry([createSessionsFeature()]);
|
|
86
|
+
// Pre-fix: the implicit projection exists → rebuild swaps an empty shadow
|
|
87
|
+
// → rows wiped. Post-fix: absent → no rebuild → rows untouched. Either way
|
|
88
|
+
// a regression (re-adding r.entity) makes this fail.
|
|
89
|
+
if (registry.getAllProjections().has(IMPLICIT_PROJECTION)) {
|
|
90
|
+
await rebuildProjection(IMPLICIT_PROJECTION, { db: testDb.db, registry });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rows = await selectMany(testDb.db, userSessionTable, {});
|
|
94
|
+
expect(rows.length).toBe(1);
|
|
95
|
+
expect((rows[0] as { revokedAt: unknown }).revokedAt).not.toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/sessions/feature.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { cleanupJob } from "./handlers/cleanup.job";
|
|
3
4
|
import { listQuery } from "./handlers/list.query";
|
|
@@ -22,8 +23,9 @@ export type SessionsFeatureOptions = {
|
|
|
22
23
|
readonly autoRevokeOnPasswordChange?: SessionMassRevoker;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
// The sessions feature registers the
|
|
26
|
-
//
|
|
26
|
+
// The sessions feature registers the read_user_sessions table (as an
|
|
27
|
+
// unmanaged direct-write store, NOT an r.entity — see below) and the three
|
|
28
|
+
// user-facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
|
|
27
29
|
// export a sessionCreator/sessionRevoker here — those are produced by
|
|
28
30
|
// `createSessionCallbacks()` at app-setup time and wired into
|
|
29
31
|
// `buildServer({ auth: { ... } })`.
|
|
@@ -41,7 +43,18 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
41
43
|
r.describe(
|
|
42
44
|
"Tracks signed-in clients in the `read_user_sessions` table (one row per JWT, keyed by the `sid`/`jti` claim) and exposes handlers for `mine` (list your sessions), `revoke`, and `revokeAllOthers`. Session creation and revocation on the hot auth path are handled by `createSessionCallbacks()`, wired into `buildServer({ auth: { ... } })` outside the dispatcher; the feature also ships a manual-trigger cleanup job for pruning expired rows and an optional `autoRevokeOnPasswordChange` hook that mass-revokes all sessions for a user whenever their `passwordHash` changes.",
|
|
43
45
|
);
|
|
44
|
-
|
|
46
|
+
// read_user_sessions is a hot-path direct-write store: sessionCreator
|
|
47
|
+
// inserts and the revoke handlers update rows WITHOUT emitting lifecycle
|
|
48
|
+
// events (the row columns ARE the audit trail). Registering it as
|
|
49
|
+
// r.entity would make it a rebuildable implicit projection whose replay
|
|
50
|
+
// finds zero session events and swaps an empty shadow over the live
|
|
51
|
+
// table — wiping every active session on the next projection rebuild
|
|
52
|
+
// (#498/#494). r.unmanagedTable keeps the migration DDL but opts the
|
|
53
|
+
// table out of implicit rebuild, like jobs/channel-in-app/feature-toggles
|
|
54
|
+
// which are direct-write stores too.
|
|
55
|
+
r.unmanagedTable(buildEntityTableMeta("user-session", userSessionEntity), {
|
|
56
|
+
reason: "read_side.user_sessions_direct_write",
|
|
57
|
+
});
|
|
45
58
|
|
|
46
59
|
const handlers = {
|
|
47
60
|
revoke: r.writeHandler(revokeWrite),
|
|
@@ -82,9 +82,11 @@ async function listAssignments(
|
|
|
82
82
|
return res.rows;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Active assignments only — remove soft-deletes (the stream is kept so a
|
|
86
|
+
// re-assign can restore it), so isDeleted=true rows must not count as assigned.
|
|
85
87
|
async function countAssignments(tenantId: string): Promise<number> {
|
|
86
88
|
const rows = await asRawClient(stack.db).unsafe(
|
|
87
|
-
"SELECT count(*)::int AS n FROM read_tag_assignments WHERE tenant_id = $1",
|
|
89
|
+
"SELECT count(*)::int AS n FROM read_tag_assignments WHERE tenant_id = $1 AND is_deleted = FALSE",
|
|
88
90
|
[tenantId],
|
|
89
91
|
);
|
|
90
92
|
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
@@ -165,6 +167,33 @@ describe("tags integration — idempotency", () => {
|
|
|
165
167
|
await remove(tagId, "credit", "credit-7");
|
|
166
168
|
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
167
169
|
});
|
|
170
|
+
|
|
171
|
+
test("assign → remove → assign-again resurrects the same deterministic stream", async () => {
|
|
172
|
+
const tagId = await createTag("recurring");
|
|
173
|
+
await assign(tagId, "credit", "credit-r");
|
|
174
|
+
await remove(tagId, "credit", "credit-r");
|
|
175
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
176
|
+
|
|
177
|
+
// Re-attaching the same (tag, entity) must succeed (restore), not 409 — the
|
|
178
|
+
// deterministic aggregate-id reuses the removed stream.
|
|
179
|
+
await assign(tagId, "credit", "credit-r");
|
|
180
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
181
|
+
const rows = await listAssignments({ field: "entityId", op: "eq", value: "credit-r" });
|
|
182
|
+
expect(rows).toHaveLength(1);
|
|
183
|
+
expect(rows[0]?.["tagId"]).toBe(tagId);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("tags integration — referential integrity", () => {
|
|
188
|
+
test("assigning an unknown tagId is rejected (no dangling assignment)", async () => {
|
|
189
|
+
const err = await stack.http.writeErr(
|
|
190
|
+
TagsHandlers.assignTag,
|
|
191
|
+
{ tagId: "00000000-0000-4000-8000-00000000dead", entityType: "credit", entityId: "credit-x" },
|
|
192
|
+
admin,
|
|
193
|
+
);
|
|
194
|
+
expect(err.httpStatus).toBe(404);
|
|
195
|
+
expect(await countAssignments(admin.tenantId)).toBe(0);
|
|
196
|
+
});
|
|
168
197
|
});
|
|
169
198
|
|
|
170
199
|
describe("tags integration — multi-tenant isolation", () => {
|
package/src/tags/entity.ts
CHANGED
|
@@ -21,11 +21,19 @@ export const tagEntity = createEntity({
|
|
|
21
21
|
// (tenantId, tagId, entityType, entityId) — see aggregate-id.ts — so there is
|
|
22
22
|
// exactly one row per (tag, entity) and assign is idempotent.
|
|
23
23
|
//
|
|
24
|
+
// softDelete is required, NOT cosmetic: the aggregate-id is deterministic, so
|
|
25
|
+
// removing a tag leaves a (created+deleted) event stream behind under that id.
|
|
26
|
+
// A hard delete would force the next assign to create() at version 0 onto that
|
|
27
|
+
// existing stream → version_conflict (the same tag could never be re-attached).
|
|
28
|
+
// With softDelete the assign handler resurrects the stream via restore(); the
|
|
29
|
+
// list query filters isDeleted, so removed assignments stay hidden.
|
|
30
|
+
//
|
|
24
31
|
// Cross-entity views compose in the read-layer (no JOIN):
|
|
25
32
|
// - tags of an entity → list assignments filter { field: "entityId", op: "eq" }
|
|
26
33
|
// - entities with a tag → list assignments filter { field: "tagId", op: "eq" }
|
|
27
34
|
export const tagAssignmentEntity = createEntity({
|
|
28
35
|
table: "read_tag_assignments",
|
|
36
|
+
softDelete: true,
|
|
29
37
|
fields: {
|
|
30
38
|
tagId: createTextField({ required: true, maxLength: 64 }),
|
|
31
39
|
entityType: createTextField({ required: true, maxLength: 64 }),
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
3
|
import { tagAssignmentAggregateId } from "../aggregate-id";
|
|
3
4
|
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
4
|
-
import { tagAssignmentExecutor } from "../executor";
|
|
5
|
+
import { tagAssignmentExecutor, tagExecutor } from "../executor";
|
|
5
6
|
import { type AssignTagPayload, assignTagPayloadSchema } from "../schemas";
|
|
6
7
|
|
|
7
8
|
// assign-tag — links a tag to a host entity by (entityType, entityId). The
|
|
8
9
|
// assignment id is deterministic, so the row is unique per (tag, entity).
|
|
9
10
|
//
|
|
10
|
-
// Idempotency
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// Idempotency over the full lifecycle (assign → remove → assign):
|
|
12
|
+
// - already active → return success (requested end state).
|
|
13
|
+
// - removed (soft-deleted) → restore() the existing stream. create() would
|
|
14
|
+
// append at version 0 onto the created+deleted stream and version_conflict;
|
|
15
|
+
// the deterministic id means that stream is permanent.
|
|
16
|
+
// - never assigned → create() (restore reports not_found).
|
|
17
|
+
// A concurrent first-time race still version_conflicts (409); acceptable, since
|
|
14
18
|
// assigning is a low-frequency UI action.
|
|
19
|
+
//
|
|
20
|
+
// Referential integrity: there is no FK (event-sourced, no JOIN), so before a
|
|
21
|
+
// first-time create we verify the tag exists in the catalog — a malformed call
|
|
22
|
+
// with an unknown tagId would otherwise project a dangling assignment.
|
|
15
23
|
export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
|
|
16
24
|
return {
|
|
17
25
|
name: "assign-tag",
|
|
@@ -31,6 +39,13 @@ export function createAssignTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS):
|
|
|
31
39
|
return { isSuccess: true as const, data: { id } };
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
const restored = await tagAssignmentExecutor.restore({ id }, event.user, ctx.db);
|
|
43
|
+
if (restored.isSuccess) return restored;
|
|
44
|
+
if (restored.error.code !== "not_found") return restored;
|
|
45
|
+
|
|
46
|
+
const tag = await tagExecutor.detail({ id: payload.tagId }, event.user, ctx.db);
|
|
47
|
+
if (!tag) return writeFailure(new NotFoundError("tag", payload.tagId));
|
|
48
|
+
|
|
34
49
|
return tagAssignmentExecutor.create(
|
|
35
50
|
{
|
|
36
51
|
id,
|
|
@@ -9,7 +9,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { TagsHandlers, TagsQueries } from "../../constants";
|
|
11
11
|
import { defaultTranslations } from "../i18n";
|
|
12
|
-
import { TagSection } from "../tag-section";
|
|
12
|
+
import { TagSection, tagSelectionDelta } from "../tag-section";
|
|
13
13
|
|
|
14
14
|
type TagRow = { id: string; name: string };
|
|
15
15
|
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
@@ -17,14 +17,12 @@ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
|
17
17
|
let catalogRows: readonly TagRow[] = [];
|
|
18
18
|
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
19
|
|
|
20
|
-
// createTag returns the new id; assign/remove return data-less success.
|
|
21
20
|
const dispatchSpy = mock(async (type: string) =>
|
|
22
21
|
type === TagsHandlers.createTag
|
|
23
22
|
? { isSuccess: true, data: { id: "tag-new" } }
|
|
24
23
|
: { isSuccess: true, data: undefined },
|
|
25
24
|
);
|
|
26
25
|
|
|
27
|
-
// useQuery is called twice (catalog + assignments) — branch on the QN.
|
|
28
26
|
const useQuerySpy = mock((type: string) => ({
|
|
29
27
|
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
30
28
|
loading: false,
|
|
@@ -47,14 +45,33 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
47
45
|
);
|
|
48
46
|
}
|
|
49
47
|
|
|
48
|
+
// The combobox's assign/remove toggle drives onChange with the full new
|
|
49
|
+
// selection; the component diffs it against the current tags via this helper.
|
|
50
|
+
// Popover interaction itself (cmdk + Radix in jsdom) is covered by the
|
|
51
|
+
// combobox primitive's own tests + e2e — here we pin the diff that turns a
|
|
52
|
+
// selection into assign/remove calls.
|
|
53
|
+
describe("tagSelectionDelta", () => {
|
|
54
|
+
test("addition only", () => {
|
|
55
|
+
expect(tagSelectionDelta(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] });
|
|
56
|
+
});
|
|
57
|
+
test("removal only", () => {
|
|
58
|
+
expect(tagSelectionDelta(["a", "b"], ["a"])).toEqual({ added: [], removed: ["b"] });
|
|
59
|
+
});
|
|
60
|
+
test("simultaneous add + remove", () => {
|
|
61
|
+
expect(tagSelectionDelta(["a"], ["b"])).toEqual({ added: ["b"], removed: ["a"] });
|
|
62
|
+
});
|
|
63
|
+
test("no change", () => {
|
|
64
|
+
expect(tagSelectionDelta(["a", "b"], ["b", "a"])).toEqual({ added: [], removed: [] });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
50
68
|
describe("TagSection", () => {
|
|
51
|
-
test("
|
|
69
|
+
test("renders assigned tags as combobox chips", () => {
|
|
52
70
|
catalogRows = [
|
|
53
71
|
{ id: "t1", name: "important" },
|
|
54
72
|
{ id: "t2", name: "project-x" },
|
|
55
73
|
];
|
|
56
74
|
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
|
|
57
|
-
dispatchSpy.mockClear();
|
|
58
75
|
|
|
59
76
|
render(
|
|
60
77
|
<Wrapper>
|
|
@@ -62,28 +79,10 @@ describe("TagSection", () => {
|
|
|
62
79
|
</Wrapper>,
|
|
63
80
|
);
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
expect(screen.
|
|
68
|
-
expect(screen.
|
|
69
|
-
|
|
70
|
-
fireEvent.click(screen.getByTestId("tags-section-assign-t2"));
|
|
71
|
-
await waitFor(() =>
|
|
72
|
-
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
73
|
-
tagId: "t2",
|
|
74
|
-
entityType: "note",
|
|
75
|
-
entityId: "note-1",
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
fireEvent.click(screen.getByTestId("tags-section-remove-t1"));
|
|
80
|
-
await waitFor(() =>
|
|
81
|
-
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
|
|
82
|
-
tagId: "t1",
|
|
83
|
-
entityType: "note",
|
|
84
|
-
entityId: "note-1",
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
82
|
+
expect(screen.getByTestId("combobox-tags-section-select")).toBeTruthy();
|
|
83
|
+
// assigned → chip shown in the trigger; unassigned t2 lives in the (closed) dropdown
|
|
84
|
+
expect(screen.getByText("important")).toBeTruthy();
|
|
85
|
+
expect(screen.queryByText("project-x")).toBeNull();
|
|
87
86
|
});
|
|
88
87
|
|
|
89
88
|
test("create-and-attach dispatches create-tag, then assign-tag with the new id", async () => {
|
package/src/tags/web/i18n.ts
CHANGED
|
@@ -9,7 +9,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
9
9
|
de: {
|
|
10
10
|
"tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
|
|
11
11
|
"tags.section.loading": "Lädt…",
|
|
12
|
-
"tags.section.
|
|
12
|
+
"tags.section.label": "Tags",
|
|
13
|
+
"tags.section.placeholder": "Tags auswählen…",
|
|
14
|
+
"tags.section.empty": "Keine Tags gefunden.",
|
|
13
15
|
"tags.section.newLabel": "Neuer Tag",
|
|
14
16
|
"tags.section.create": "Tag anlegen & zuweisen",
|
|
15
17
|
"tags.section.working": "Speichert…",
|
|
@@ -17,7 +19,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
17
19
|
en: {
|
|
18
20
|
"tags.section.createMode": "Save the entity first to add tags.",
|
|
19
21
|
"tags.section.loading": "Loading…",
|
|
20
|
-
"tags.section.
|
|
22
|
+
"tags.section.label": "Tags",
|
|
23
|
+
"tags.section.placeholder": "Select tags…",
|
|
24
|
+
"tags.section.empty": "No tags found.",
|
|
21
25
|
"tags.section.newLabel": "New tag",
|
|
22
26
|
"tags.section.create": "Create & attach tag",
|
|
23
27
|
"tags.section.working": "Saving…",
|