@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.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 (115) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
  48. package/src/personal-access-tokens/constants.ts +33 -0
  49. package/src/personal-access-tokens/feature.ts +78 -0
  50. package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
  51. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  52. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  53. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  54. package/src/personal-access-tokens/hash.ts +17 -0
  55. package/src/personal-access-tokens/index.ts +45 -0
  56. package/src/personal-access-tokens/resolver.ts +58 -0
  57. package/src/personal-access-tokens/roles.ts +29 -0
  58. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  59. package/src/personal-access-tokens/scopes.ts +22 -0
  60. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  61. package/src/personal-access-tokens/web/i18n.ts +45 -0
  62. package/src/personal-access-tokens/web/index.ts +7 -0
  63. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
  64. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  65. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  66. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  67. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  68. package/src/sessions/schema/user-session.ts +8 -2
  69. package/src/sessions/session-callbacks.ts +9 -3
  70. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  71. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  72. package/src/tags/handlers/assign-tag.write.ts +13 -3
  73. package/src/tags/schemas.ts +10 -4
  74. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  75. package/src/template-resolver/testing.ts +4 -3
  76. package/src/tier-engine/feature.ts +22 -10
  77. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  78. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  79. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  80. package/src/user/index.ts +4 -0
  81. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  82. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  85. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  86. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  87. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  88. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  89. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  90. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  91. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  92. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  93. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  95. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  96. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  97. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  98. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  99. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  100. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  101. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  102. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  103. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  104. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  105. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  106. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  107. package/src/user-data-rights/web/i18n.ts +0 -10
  108. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  109. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  110. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  111. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  112. package/src/user-profile/handlers/change-email.write.ts +11 -8
  113. package/src/user-profile/web/profile-screen.tsx +10 -8
  114. package/src/auth-email-password/stream-tenant.ts +0 -31
  115. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -1,11 +1,24 @@
1
- import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type {
3
- UserDataDeleteHook,
4
- UserDataExportHook,
5
- UserDataHookCtx,
6
- UserDataStorageProvider,
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ createEventStoreExecutor,
4
+ createTenantDb,
5
+ type TenantDb,
6
+ } from "@cosmicdrift/kumiko-framework/db";
7
+ import {
8
+ createSystemUser,
9
+ type SessionUser,
10
+ type UserDataDeleteHook,
11
+ type UserDataExportHook,
12
+ type UserDataHookCtx,
13
+ type UserDataStorageProvider,
7
14
  } from "@cosmicdrift/kumiko-framework/engine";
8
- import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
15
+ import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
+
17
+ // Forget writes go through the executor (events), not deleteMany/updateMany:
18
+ // a projection rebuild replays the events, so the erasure survives. Eventless
19
+ // writes are wiped/resurrected on rebuild — the Art.17 hole this fixes. Bounded:
20
+ // per-user forget flows are rare, so per-row events are acceptable.
21
+ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
9
22
 
10
23
  // userData-Hook fuer fileRef-entity (S2.H2).
11
24
  //
@@ -27,7 +40,7 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
27
40
  //
28
41
  // **Zwei Fehlerklassen, bewusst verschieden behandelt:**
29
42
  // 1. Resolution schlaegt fehl (kein Provider konfiguriert / configResolver
30
- // fehlt) → NICHT fail-closed: EIN Warn pro Process + row-only-delete. Ein
43
+ // fehlt) → NICHT fail-closed: Warn pro Aufruf + row-only-delete. Ein
31
44
  // fehlkonfigurierter Store darf die Art.-17-Loeschung nicht DAUERHAFT
32
45
  // blockieren (sonst haengt jeder User fuer immer in DeletionRequested);
33
46
  // der Boot-Guard macht die Fehlkonfiguration sichtbar, Binaries werden
@@ -94,8 +107,6 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
94
107
  };
95
108
  };
96
109
 
97
- let missingStorageWarned = false;
98
-
99
110
  // Resolve the per-tenant provider the forget orchestrator injected. A
100
111
  // resolution failure (no provider configured / configResolver absent) collapses
101
112
  // to `undefined` so the hook degrades to a row-only delete instead of throwing —
@@ -107,58 +118,95 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
107
118
  return await ctx.buildStorageProvider(ctx.tenantId);
108
119
  } catch {
109
120
  // skip: provider unresolvable (not configured) → fall through to row-only
110
- // delete; warn-once below gives operator visibility, boot guard catches it.
121
+ // delete; the warn below gives operator visibility, boot guard catches it.
111
122
  return undefined;
112
123
  }
113
124
  }
114
125
 
115
- export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
116
- if (strategy === "delete") {
117
- const storageProvider = await resolveProvider(ctx);
118
- if (storageProvider) {
119
- const rows = await selectMany(ctx.db, fileRefsTable, {
120
- tenantId: ctx.tenantId,
121
- insertedById: ctx.userId,
122
- });
123
- const failedKeys: string[] = [];
124
- for (const row of rows) {
125
- const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
126
- if (typeof key !== "string" || key.length === 0) continue;
127
- try {
128
- await storageProvider.delete(key);
129
- } catch (err) {
130
- // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
131
- console.warn(
132
- `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
133
- );
134
- failedKeys.push(key);
135
- }
136
- }
137
- // Fail-closed: abort before the row hard-delete so the sub-tx rolls back
138
- // and the next forget run retries (delete is idempotent → converges).
139
- if (failedKeys.length > 0) {
140
- throw new Error(
141
- `[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
142
- );
143
- }
144
- } else if (!missingStorageWarned) {
145
- missingStorageWarned = true;
146
- // biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
126
+ // Delete every row's binary via the provider. Returns the keys whose delete
127
+ // threw the caller fails closed on a non-empty list so the sub-tx rolls back
128
+ // and the next forget run retries (delete is idempotent → converges).
129
+ async function deleteBinaries(
130
+ rows: readonly Record<string, unknown>[],
131
+ provider: UserDataStorageProvider,
132
+ ): Promise<readonly string[]> {
133
+ const failedKeys: string[] = [];
134
+ for (const row of rows) {
135
+ const key = row["storageKey"]; // @cast-boundary db-row
136
+ if (typeof key !== "string" || key.length === 0) continue;
137
+ try {
138
+ await provider.delete(key);
139
+ } catch (err) {
140
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
147
141
  console.warn(
148
- "[user-data-rights-defaults:fileRef] no file provider resolvable from ctx.buildStorageProvider file binaries are NOT deleted on forget (row-only delete). Mount file-foundation + a file-provider-* feature and set the provider config so erasure can reach the binaries.",
142
+ `[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
143
+ );
144
+ failedKeys.push(key);
145
+ }
146
+ }
147
+ return failedKeys;
148
+ }
149
+
150
+ // Null the person-link on every row via the executor (event → rebuild-safe).
151
+ // tdb: the executor needs a TenantDb (loadById → db.fetchOne), not the raw
152
+ // ctx.db runner.
153
+ async function severPersonLink(
154
+ tdb: TenantDb,
155
+ systemUser: SessionUser,
156
+ rows: readonly Record<string, unknown>[],
157
+ ): Promise<void> {
158
+ for (const row of rows) {
159
+ const id = row["id"]; // @cast-boundary db-row
160
+ if (typeof id !== "string") continue;
161
+ await crud.update({ id, changes: { insertedById: null } }, systemUser, tdb, {
162
+ skipOptimisticLock: true,
163
+ });
164
+ }
165
+ }
166
+
167
+ export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
168
+ const systemUser = createSystemUser(ctx.tenantId);
169
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
170
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, fileRefsTable, {
171
+ tenantId: ctx.tenantId,
172
+ insertedById: ctx.userId,
173
+ });
174
+
175
+ if (strategy !== "delete") {
176
+ // anonymize: insertedById=null, FileRef + binary bleiben. Use-case: shared
177
+ // chat-Attachment im Multi-User-Channel — Author-ID raus, Datei bleibt sichtbar.
178
+ await severPersonLink(tdb, systemUser, rows);
179
+ // skip: anonymize is complete — the hard-delete path below runs only for strategy "delete".
180
+ return;
181
+ }
182
+
183
+ const storageProvider = await resolveProvider(ctx);
184
+ if (storageProvider) {
185
+ const failedKeys = await deleteBinaries(rows, storageProvider);
186
+ if (failedKeys.length > 0) {
187
+ throw new Error(
188
+ `[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
149
189
  );
150
190
  }
151
- await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
152
191
  } else {
153
- // anonymize: insertedById=null, FileRef + binary bleiben.
154
- // Use-case: shared chat-Attachment in einem Multi-User-Channel
155
- // Author-Identifikation raus, Datei bleibt fuer andere User
156
- // sichtbar.
157
- await updateMany(
158
- ctx.db,
159
- fileRefsTable,
160
- { insertedById: null },
161
- { tenantId: ctx.tenantId, insertedById: ctx.userId },
192
+ // No warn-once guard: a forget-cleanup cron runs rarely enough (not a hot
193
+ // path) that logging every occurrence is fine, and an operator who fixes the
194
+ // provider config mid-process sees the warning stop on the very next run —
195
+ // a module-level "warned once" flag would silence it for the rest of the
196
+ // process even after the misconfiguration is corrected.
197
+ // biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
198
+ console.warn(
199
+ "[user-data-rights-defaults:fileRef] no file provider resolvable from ctx.buildStorageProvider — file binaries are NOT deleted on forget (row-only delete). Mount file-foundation + a file-provider-* feature and set the provider config so erasure can reach the binaries.",
162
200
  );
163
201
  }
202
+ // Hard-purge each row via the executor forget-verb: emits fileRef.forgotten,
203
+ // which hard-deletes the row even though fileRef is softDelete — and, being an
204
+ // auto-verb, the erasure replays on rebuild (created → forgotten → row gone).
205
+ // The old hard deleteMany was resurrected on rebuild; this closes that Art.17
206
+ // hole without a direct write.
207
+ for (const row of rows) {
208
+ const id = row["id"]; // @cast-boundary db-row
209
+ if (typeof id !== "string") continue;
210
+ await crud.forget({ id }, systemUser, tdb);
211
+ }
164
212
  };
@@ -1,5 +1,10 @@
1
- import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createSystemUser,
5
+ type UserDataDeleteHook,
6
+ type UserDataExportHook,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
3
8
  import {
4
9
  USER_ANONYMIZED_DISPLAY_NAME,
5
10
  USER_ANONYMIZED_EMAIL_DOMAIN,
@@ -7,9 +12,14 @@ import {
7
12
  USER_DELETED_DISPLAY_NAME,
8
13
  USER_DELETED_EMAIL_PREFIX,
9
14
  USER_STATUS,
15
+ userEntity,
10
16
  userTable,
11
17
  } from "../../user";
12
- import { anonymizeDeletedUser } from "../db/queries/user-hook";
18
+
19
+ // Forget writes go through the executor (events), not a raw UPDATE: a
20
+ // projection rebuild replays the events, so the anonymization survives it.
21
+ // A direct write would be wiped on rebuild — the Art.17 hole this fixes.
22
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
13
23
 
14
24
  // userData-Hook fuer user-entity (S2.H1).
15
25
  //
@@ -63,27 +73,55 @@ export const userDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
63
73
  // korrelieren — user.id ist tenant-agnostic (User kann in mehreren
64
74
  // Tenants Member sein), kein tenantId-Filter noetig.
65
75
 
76
+ // System actor + skipOptimisticLock: the forget pipeline has no read
77
+ // version and writes privileged identity columns ("system" is privileged).
78
+ // The executor needs a TenantDb (loadById → db.fetchOne); ctx.db is a raw
79
+ // runner, so wrap it. "system" mode = no tenant filter — user is a systemStream
80
+ // entity (tenant-agnostic) and forget must reach the row in any tenant.
81
+ const systemUser = createSystemUser(ctx.tenantId);
82
+ const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
83
+
66
84
  if (strategy === "delete") {
67
- await anonymizeDeletedUser(ctx.db, {
68
- email: `${USER_DELETED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
69
- displayName: USER_DELETED_DISPLAY_NAME,
70
- status: USER_STATUS.Deleted,
71
- userId: ctx.userId,
72
- });
85
+ // PII raus + status=deleted (Login geblockt) via Event → Rebuild spielt den
86
+ // Forget mit. KEIN softDelete: user ist systemStream (eine tenant-agnostische
87
+ // Row), der Forget laeuft pro Tenant — wuerde isDeleted gesetzt, faende der
88
+ // loadById (isDeleted:false) die Row beim zweiten Tenant nicht mehr
89
+ // (not_found). status=Deleted blockt Login; die Row bleibt findbar +
90
+ // idempotent ueber mehrere Tenant-Durchlaeufe (wie der alte raw UPDATE).
91
+ await crud.update(
92
+ {
93
+ id: ctx.userId,
94
+ changes: {
95
+ email: `${USER_DELETED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
96
+ displayName: USER_DELETED_DISPLAY_NAME,
97
+ passwordHash: null,
98
+ status: USER_STATUS.Deleted,
99
+ // deleted_at as an audit timestamp only — NOT isDeleted (see above).
100
+ // Frozen into the event payload, so a rebuild replays the same value.
101
+ deletedAt: Temporal.Now.instant(),
102
+ },
103
+ },
104
+ systemUser,
105
+ tdb,
106
+ { skipOptimisticLock: true },
107
+ );
73
108
  } else {
74
109
  // anonymize: PII raus, aber Row bleibt active (damit FK-References
75
110
  // weiter aufloesbar sind). Account ist effektiv weiter nutzbar
76
111
  // wenn der User sich neu authentifiziert — pragmatisch akzeptabel
77
112
  // weil "anonymize" auf user-entity ein seltener Edge-Case ist
78
113
  // (typisch hard-delete fuer User).
79
- await updateMany(
80
- ctx.db,
81
- userTable,
114
+ await crud.update(
82
115
  {
83
- email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
84
- displayName: USER_ANONYMIZED_DISPLAY_NAME,
116
+ id: ctx.userId,
117
+ changes: {
118
+ email: `${USER_ANONYMIZED_EMAIL_PREFIX}-${ctx.userId}@${USER_ANONYMIZED_EMAIL_DOMAIN}`,
119
+ displayName: USER_ANONYMIZED_DISPLAY_NAME,
120
+ },
85
121
  },
86
- { id: ctx.userId },
122
+ systemUser,
123
+ tdb,
124
+ { skipOptimisticLock: true },
87
125
  );
88
126
  }
89
127
  };
@@ -88,6 +88,15 @@ describe("ProfileScreen", () => {
88
88
  expect(cardCount(view.getByTestId("profile-password"))).toBe(1);
89
89
  });
90
90
 
91
+ test("Passwort- und Email-Section rendern auf einem echten <form> (Enter-to-submit, Password-Manager-Boundary) (555/1)", async () => {
92
+ const view = renderProfile(activeMe);
93
+ await waitFor(() => {
94
+ if (view.queryByTestId("profile-screen") === null) throw new Error("not mounted yet");
95
+ });
96
+ expect(view.getByTestId("profile-password").tagName).toBe("FORM");
97
+ expect(view.getByTestId("profile-email").tagName).toBe("FORM");
98
+ });
99
+
91
100
  test("deletionRequested: Frist-Banner + Abbrechen statt Lösch-Button", async () => {
92
101
  const view = renderProfile({
93
102
  ...activeMe,
@@ -1,9 +1,13 @@
1
- import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ access,
3
+ createSystemUser,
4
+ defineWriteHandler,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
2
7
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
- import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
4
8
  import { z } from "zod";
5
9
  import { AuthErrors, verifyPassword } from "../../auth-email-password";
6
- import { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "../../user";
10
+ import { UserErrors, UserHandlers, UserQueries } from "../../user";
7
11
  import { UserProfileErrors } from "../constants";
8
12
 
9
13
  // Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
@@ -66,11 +70,10 @@ export const changeEmailWrite = defineWriteHandler({
66
70
  );
67
71
  }
68
72
 
69
- // Stream-Tenant-Auflösung wie in change-password: das user-Aggregate
70
- // ist systemScope, sein Event-Stream kann in einem anderen Tenant
71
- // liegen als die Session — optimistic locking braucht den echten.
72
- const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
73
- const writer = createSystemUser(streamTenant ?? event.user.tenantId);
73
+ // user ist systemStream (#497): der Event-Stream liegt deterministisch
74
+ // auf SYSTEM_TENANT_ID. Pre-#497-Streams brauchen einmalig
75
+ // backfillUserStreamTenants (#762).
76
+ const writer = createSystemUser(SYSTEM_TENANT_ID);
74
77
 
75
78
  const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
76
79
  id: me.id,
@@ -59,7 +59,7 @@ function StatusBanner({ status }: { readonly status: SectionStatus }): ReactNode
59
59
 
60
60
  function ChangePasswordSection(): ReactNode {
61
61
  const t = useTranslation();
62
- const { Section, Field, Input, Button } = usePrimitives();
62
+ const { Form, Field, Input, Button } = usePrimitives();
63
63
  const dispatcher = useDispatcher();
64
64
  const [oldPassword, setOldPassword] = useState("");
65
65
  const [newPassword, setNewPassword] = useState("");
@@ -90,11 +90,12 @@ function ChangePasswordSection(): ReactNode {
90
90
 
91
91
  const submitting = status.kind === "submitting";
92
92
  return (
93
- <Section
93
+ <Form
94
94
  testId="profile-password"
95
95
  title={t("profile.password.title")}
96
+ onSubmit={onSubmit}
96
97
  actions={
97
- <Button onClick={() => onSubmit()} disabled={submitting} testId="profile-password-submit">
98
+ <Button type="submit" disabled={submitting} testId="profile-password-submit">
98
99
  {t("profile.password.submit")}
99
100
  </Button>
100
101
  }
@@ -136,7 +137,7 @@ function ChangePasswordSection(): ReactNode {
136
137
  />
137
138
  </Field>
138
139
  <StatusBanner status={status} />
139
- </Section>
140
+ </Form>
140
141
  );
141
142
  }
142
143
 
@@ -148,7 +149,7 @@ function ChangeEmailSection({
148
149
  readonly onChanged: () => void;
149
150
  }): ReactNode {
150
151
  const t = useTranslation();
151
- const { Section, Field, Input, Button } = usePrimitives();
152
+ const { Form, Field, Input, Button } = usePrimitives();
152
153
  const dispatcher = useDispatcher();
153
154
  const [newEmail, setNewEmail] = useState("");
154
155
  const [currentPassword, setCurrentPassword] = useState("");
@@ -192,16 +193,17 @@ function ChangeEmailSection({
192
193
 
193
194
  const submitting = status.kind === "submitting";
194
195
  return (
195
- <Section
196
+ <Form
196
197
  testId="profile-email"
197
198
  title={t("profile.email.title")}
199
+ onSubmit={onSubmit}
198
200
  subtitle={
199
201
  <span data-testid="profile-email-current">
200
202
  {t("profile.email.current")}: {me.email}
201
203
  </span>
202
204
  }
203
205
  actions={
204
- <Button onClick={() => onSubmit()} disabled={submitting} testId="profile-email-submit">
206
+ <Button type="submit" disabled={submitting} testId="profile-email-submit">
205
207
  {t("profile.email.submit")}
206
208
  </Button>
207
209
  }
@@ -231,7 +233,7 @@ function ChangeEmailSection({
231
233
  />
232
234
  </Field>
233
235
  <StatusBanner status={status} />
234
- </Section>
236
+ </Form>
235
237
  );
236
238
  }
237
239
 
@@ -1,31 +0,0 @@
1
- // user-feature runs with r.systemScope() but events land on a concrete
2
- // tenant stream. The row.version tracks whichever stream the user's last
3
- // modification wrote to — and that tenant is NOT discoverable from the
4
- // row alone.
5
- //
6
- // Strategy: prioritize lastActiveTenantId (most likely holds the latest
7
- // event), fall through to the remaining memberships in insertion order,
8
- // and let the caller try each one in sequence. The first stream whose
9
- // version matches row.version wins; the rest are bypassed.
10
- //
11
- // This is pragmatic — the real fix is to scope user events to
12
- // SYSTEM_TENANT_ID when the feature is r.systemScope(), which is a
13
- // framework-level change tracked separately. Until then, "try each
14
- // tenant the user belongs to" is robust against non-deterministic
15
- // memberships-query ordering (tenant:query:memberships has no ORDER BY).
16
-
17
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
-
19
- export function orderTenantsByPreference(
20
- memberships: readonly { readonly tenantId: TenantId }[],
21
- lastActiveTenantId: string | null | undefined,
22
- ): TenantId[] {
23
- if (memberships.length === 0) return [];
24
- const ids = memberships.map((m) => m.tenantId);
25
- if (!lastActiveTenantId) return ids;
26
- // Move lastActiveTenantId to the front; preserve relative order of rest.
27
- const preferred = ids.find((id) => id === lastActiveTenantId);
28
- if (!preferred) return ids;
29
- const rest = ids.filter((id) => id !== preferred);
30
- return [preferred, ...rest];
31
- }
@@ -1,17 +0,0 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
-
4
- export async function anonymizeDeletedUser(
5
- db: DbRunner,
6
- params: {
7
- readonly email: string;
8
- readonly displayName: string;
9
- readonly status: string;
10
- readonly userId: string;
11
- },
12
- ): Promise<void> {
13
- await asRawClient(db).unsafe(
14
- 'UPDATE "read_users" SET email = $1, display_name = $2, password_hash = $3, status = $4, deleted_at = now() WHERE id = $5',
15
- [params.email, params.displayName, null, params.status, params.userId],
16
- );
17
- }