@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.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.
Files changed (60) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  3. package/src/config/__tests__/write-helpers.test.ts +152 -0
  4. package/src/config/handlers/readiness.query.ts +1 -0
  5. package/src/config/read-redaction.ts +0 -1
  6. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
  7. package/src/custom-fields/__tests__/feature.test.ts +1 -4
  8. package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
  9. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
  10. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
  11. package/src/custom-fields/db/queries/quota.ts +3 -1
  12. package/src/custom-fields/entity.ts +10 -3
  13. package/src/custom-fields/events.ts +4 -1
  14. package/src/custom-fields/feature.ts +1 -5
  15. package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
  17. package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
  18. package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
  19. package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
  20. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
  21. package/src/custom-fields/wire-for-entity.ts +7 -0
  22. package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
  23. package/src/files-provider-s3/s3-provider.ts +2 -4
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
  25. package/src/legal-pages/web/client-plugin.ts +9 -10
  26. package/src/managed-pages/handlers/set.write.ts +4 -11
  27. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
  28. package/src/sessions/feature.ts +16 -3
  29. package/src/tags/__tests__/tags.integration.test.ts +30 -1
  30. package/src/tags/entity.ts +8 -0
  31. package/src/tags/handlers/assign-tag.write.ts +20 -5
  32. package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
  33. package/src/tags/web/i18n.ts +6 -2
  34. package/src/tags/web/tag-section.tsx +87 -76
  35. package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
  36. package/src/text-content/web/client-plugin.tsx +16 -13
  37. package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
  38. package/src/tier-engine/__tests__/trial.test.ts +27 -0
  39. package/src/tier-engine/entity.ts +8 -0
  40. package/src/tier-engine/feature.ts +49 -9
  41. package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
  42. package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
  43. package/src/tier-engine/index.ts +1 -0
  44. package/src/tier-engine/trial.ts +26 -0
  45. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
  46. package/src/user-data-rights/constants.ts +48 -0
  47. package/src/user-data-rights/feature.ts +15 -0
  48. package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
  49. package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
  50. package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
  51. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
  52. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
  53. package/src/user-data-rights/index.ts +3 -0
  54. package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
  55. package/src/user-data-rights/run-forget-cleanup.ts +3 -2
  56. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
  57. package/src/user-data-rights/web/client-plugin.tsx +30 -0
  58. package/src/user-data-rights/web/i18n.ts +95 -0
  59. package/src/user-data-rights/web/index.ts +2 -0
  60. 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: { fieldKey: payload.fieldKey, value: payload.value },
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
+ }
@@ -334,11 +334,13 @@ describe("CustomFieldsFormSection — boolean/date-Pfade", () => {
334
334
  </Wrapper>,
335
335
  );
336
336
 
337
- // boolean rendert als Checkbox Bestand steckt in checked, nicht value.
338
- const input = document.getElementById("custom-field-active") as HTMLInputElement;
339
- expect(input.checked).toBe(true);
337
+ // boolean = vendored Radix-Checkbox button[role=checkbox], Bestand steckt
338
+ // in aria-checked (kein natives .checked).
339
+ const checkbox = document.getElementById("custom-field-active");
340
+ if (checkbox === null) throw new Error("boolean checkbox not rendered");
341
+ expect(checkbox.getAttribute("aria-checked")).toBe("true");
340
342
 
341
- fireEvent.click(input);
343
+ fireEvent.click(checkbox);
342
344
  fireEvent.click(screen.getByTestId("custom-fields-form-save"));
343
345
  await Promise.resolve();
344
346
  await Promise.resolve();
@@ -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 ist die Inversion, die createS3Provider an Bun.S3Client
39
- // durchreicht (#175/2). Der `!` ist die stille Drift-Stelle: kippt er, picken
40
- // Minio/R2 die falsche URL-Form ohne Compile- oder Runtime-Fehler.
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 ist eine reine lokale Signier-Operation (HMAC, kein Netzwerk)
70
- // hermetisch testbar mit Dummy-Credentials. Beweist, dass Bun das
71
- // contentDisposition-Feld tatsächlich als response-content-disposition-Query-
72
- // Param signiert (#175/3) sonst lieferte ein Download den UUID-Key statt des
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 AWS-SDK `forcePathStyle`
58
- // knob this config exposes: path-style virtualHostedStyle=false. Exported +
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
  }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { legalPagesClient } from "../client-plugin";
4
+
5
+ // Deckt die drei neuen Migrations-Pfade (advisor-Gap): navId-Attach,
6
+ // no-leak ohne navId, und der Unwrap (Provider emittiert die slug-Folder
7
+ // direkt, NICHT mehr unter einem "Legal"-Wrapper — der App-r.nav-Knoten
8
+ // IST der Container). legal-pages ist fetch-frei → Provider direkt aufrufbar.
9
+
10
+ function collect(
11
+ provider: () => (emit: (n: readonly TreeNode[]) => void) => () => void,
12
+ ): readonly TreeNode[] {
13
+ let emitted: readonly TreeNode[] | undefined;
14
+ const unsub = provider()((nodes) => {
15
+ emitted = nodes;
16
+ });
17
+ unsub();
18
+ if (emitted === undefined) throw new Error("provider emitted nothing");
19
+ return emitted;
20
+ }
21
+
22
+ describe("legalPagesClient", () => {
23
+ test("ohne navId: kein navProvider (server-only-Consumer leaken keinen Node)", () => {
24
+ const def = legalPagesClient();
25
+ expect(def.name).toBe("legal-pages");
26
+ expect(def.navProviders).toBeUndefined();
27
+ });
28
+
29
+ test("mit navId: Provider hängt unter exakt dieser (pass-through) QN", () => {
30
+ const navId = "publicstatus:nav:legal";
31
+ const def = legalPagesClient({ navId });
32
+ expect(Object.keys(def.navProviders ?? {})).toEqual([navId]);
33
+ });
34
+
35
+ test("Provider unwrappt den Legal-Container: Top-Level sind die slug-Folder", () => {
36
+ const navId = "publicstatus:nav:legal";
37
+ const provider = legalPagesClient({ navId }).navProviders?.[navId];
38
+ if (provider === undefined) throw new Error("provider missing");
39
+ const emitted = collect(provider);
40
+
41
+ // Kein "Legal"-Wrapper mehr — der App-Knoten ist der Container.
42
+ expect(emitted.some((n) => n.label === "Legal")).toBe(false);
43
+ expect(emitted.length).toBeGreaterThan(0);
44
+
45
+ // Jeder Top-Level-Knoten ist ein slug-Folder mit lang-Leaves, die per
46
+ // Cross-Link auf text-content:edit zeigen.
47
+ const folder = emitted[0];
48
+ expect(Array.isArray(folder?.children)).toBe(true);
49
+ const langLeaf = Array.isArray(folder?.children) ? folder?.children[0] : undefined;
50
+ expect(langLeaf?.target?.featureId).toBe("text-content");
51
+ expect(langLeaf?.target?.action).toBe("edit");
52
+ });
53
+ });
@@ -63,20 +63,19 @@ const treeProvider: TreeChildrenSubscribe = () => (emit) => {
63
63
  });
64
64
  }
65
65
 
66
- emit([
67
- {
68
- label: "Legal",
69
- icon: "folder",
70
- state: "filled",
71
- children: slugFolders,
72
- },
73
- ]);
66
+ // Der App-seitige r.nav-Knoten IST der "Legal"-Container → der Provider
67
+ // emittiert die slug-Folder direkt darunter.
68
+ emit(slugFolders);
74
69
  return () => {};
75
70
  };
76
71
 
77
- export function legalPagesClient(): ClientFeatureDefinition {
72
+ // `navId` = QN des r.nav({ provider: true })-Knotens den die App registriert.
73
+ // Statisch (kein Fetch, keine Entities) → kein SSE-Refresh nötig. Ohne navId:
74
+ // kein Sidebar-Knoten (server-only-Consumer leaken nichts).
75
+ export function legalPagesClient(opts?: { readonly navId?: string }): ClientFeatureDefinition {
76
+ const navId = opts?.navId;
78
77
  return {
79
78
  name: "legal-pages",
80
- treeProvider,
79
+ ...(navId !== undefined && { navProviders: { [navId]: treeProvider } }),
81
80
  };
82
81
  }
@@ -51,20 +51,13 @@ export const setWrite = defineWriteHandler({
51
51
  );
52
52
  }
53
53
  const tenantId = override ?? event.user.tenantId;
54
- // Bei Override muss der executor-user-Context auf den ziel-tenant
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 tenant-scoped to the EXECUTING user (createTenantDb "tenant"
61
- // mode). For a cross-tenant override that scope is wrong on BOTH the
62
- // existing-check (blind to the target tenant's projection row → every
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
+ });
@@ -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 userSession entity and the three user-
26
- // facing handlers (mine/revoke/revoke-all-others). It intentionally does NOT
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
- r.entity("user-session", userSessionEntity);
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", () => {
@@ -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: a re-assign hits the existing stream and would version_conflict,
11
- // which leaves the transaction aborted so we pre-check existence and return
12
- // success when the assignment is already present (the requested end state). A
13
- // concurrent first-time race still version_conflicts (409); acceptable, since
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,