@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.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 (125) hide show
  1. package/package.json +6 -8
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -9
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +4 -7
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
@@ -74,6 +74,11 @@ export const defaultTranslations: TranslationsByLocale = {
74
74
  "userDataRights.privacyCenter.deletion.dialogTitle": "Konto wirklich löschen?",
75
75
  "userDataRights.privacyCenter.deletion.dialogDescription":
76
76
  "Mit dem Bestätigen startet die Lösch-Frist. Du kannst die Löschung bis zu ihrem Ablauf wieder abbrechen.",
77
+
78
+ "userDataRights.privacyCenter.audit.title": "Aktivitätsprotokoll (Art. 15)",
79
+ "userDataRights.privacyCenter.audit.intro":
80
+ "Die letzten Ereignisse zu deinem Konto über alle Tenants hinweg.",
81
+ "userDataRights.privacyCenter.audit.empty": "Noch keine Ereignisse.",
77
82
  },
78
83
  en: {
79
84
  "userDataRights.deletion.request.title": "Request account deletion",
@@ -141,5 +146,10 @@ export const defaultTranslations: TranslationsByLocale = {
141
146
  "userDataRights.privacyCenter.deletion.dialogTitle": "Delete your account?",
142
147
  "userDataRights.privacyCenter.deletion.dialogDescription":
143
148
  "Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
149
+
150
+ "userDataRights.privacyCenter.audit.title": "Activity log (Art. 15)",
151
+ "userDataRights.privacyCenter.audit.intro":
152
+ "The most recent events on your account across all tenants.",
153
+ "userDataRights.privacyCenter.audit.empty": "No events yet.",
144
154
  },
145
155
  };
@@ -162,7 +162,6 @@ describe("S2.H1 :: userExportHook", () => {
162
162
 
163
163
  const result = await userExportHook({
164
164
  db: stack.db,
165
- registry: stack.registry,
166
165
  tenantId: TENANT_A,
167
166
  userId: uuid(1001),
168
167
  });
@@ -183,7 +182,6 @@ describe("S2.H1 :: userExportHook", () => {
183
182
  test("returns null wenn User nicht existiert", async () => {
184
183
  const result = await userExportHook({
185
184
  db: stack.db,
186
- registry: stack.registry,
187
185
  tenantId: TENANT_A,
188
186
  userId: uuid(1002),
189
187
  });
@@ -195,10 +193,7 @@ describe("S2.H1 :: userDeleteHook", () => {
195
193
  test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
196
194
  await seedUser(uuid(1003));
197
195
 
198
- await userDeleteHook(
199
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
200
- "delete",
201
- );
196
+ await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1003) }, "delete");
202
197
 
203
198
  const row = await fetchUser(uuid(1003));
204
199
  expect(row).not.toBeNull();
@@ -214,10 +209,7 @@ describe("S2.H1 :: userDeleteHook", () => {
214
209
  test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
215
210
  await seedUser(uuid(1004));
216
211
 
217
- await userDeleteHook(
218
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
219
- "anonymize",
220
- );
212
+ await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1004) }, "anonymize");
221
213
 
222
214
  const row = await fetchUser(uuid(1004));
223
215
  if (!row) throw new Error("row should exist");
@@ -230,19 +222,13 @@ describe("S2.H1 :: userDeleteHook", () => {
230
222
  test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
231
223
  await seedUser(uuid(1005));
232
224
 
233
- await userDeleteHook(
234
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
235
- "delete",
236
- );
225
+ await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete");
237
226
  const afterFirst = await fetchUser(uuid(1005));
238
227
  if (!afterFirst) throw new Error("user should exist after first delete");
239
228
 
240
229
  // Zweiter Call: kein Crash + State unverändert
241
230
  await expect(
242
- userDeleteHook(
243
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
244
- "delete",
245
- ),
231
+ userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete"),
246
232
  ).resolves.toBeUndefined();
247
233
 
248
234
  // State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
@@ -263,7 +249,6 @@ describe("S2.H2 :: fileRefExportHook", () => {
263
249
 
264
250
  const result = await fileRefExportHook({
265
251
  db: stack.db,
266
- registry: stack.registry,
267
252
  tenantId: TENANT_A,
268
253
  userId: "user-files-1",
269
254
  });
@@ -278,7 +263,6 @@ describe("S2.H2 :: fileRefExportHook", () => {
278
263
  test("returns null wenn User keine Files hat", async () => {
279
264
  const result = await fileRefExportHook({
280
265
  db: stack.db,
281
- registry: stack.registry,
282
266
  tenantId: TENANT_A,
283
267
  userId: "ghost-user-no-files",
284
268
  });
@@ -292,7 +276,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
292
276
  await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
293
277
 
294
278
  await fileRefDeleteHook(
295
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-delete-files" },
279
+ { db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
296
280
  "delete",
297
281
  );
298
282
 
@@ -304,7 +288,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
304
288
  await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
305
289
 
306
290
  await fileRefDeleteHook(
307
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-anon-files" },
291
+ { db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
308
292
  "anonymize",
309
293
  );
310
294
 
@@ -321,10 +305,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
321
305
  await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
322
306
 
323
307
  // Tenant A loescht alle Files von "shared-user"
324
- await fileRefDeleteHook(
325
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
326
- "delete",
327
- );
308
+ await fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "shared-user" }, "delete");
328
309
 
329
310
  const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
330
311
  const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
@@ -338,7 +319,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
338
319
  await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
339
320
 
340
321
  await fileRefDeleteHook(
341
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
322
+ { db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
342
323
  "delete",
343
324
  );
344
325
  const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
@@ -346,10 +327,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
346
327
 
347
328
  // Zweiter Call: kein Crash + State weiter 0 Files
348
329
  await expect(
349
- fileRefDeleteHook(
350
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
351
- "delete",
352
- ),
330
+ fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" }, "delete"),
353
331
  ).resolves.toBeUndefined();
354
332
  const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
355
333
  expect(afterSecond).toHaveLength(0);
@@ -76,10 +76,6 @@ describe("ProfileScreen", () => {
76
76
  expect(view.getByTestId("profile-email")).toBeTruthy();
77
77
  expect(view.getByTestId("profile-password")).toBeTruthy();
78
78
  expect(view.getByTestId("profile-danger")).toBeTruthy();
79
- // 607/4: page heading is <h1> (Heading variant="page") — the danger-zone
80
- // title must be <h2>, not Card's default <h3>, so the levels don't skip.
81
- expect(view.getByTestId("profile-danger").querySelector("h2")).not.toBeNull();
82
- expect(view.getByTestId("profile-danger").querySelector("h3")).toBeNull();
83
79
  expect(view.getByTestId("profile-email-current").textContent).toContain("marc@example.com");
84
80
  expect(view.getByTestId("profile-danger-delete")).toBeTruthy();
85
81
  // Echte i18n: kein einziger roher Key im sichtbaren Text.
@@ -1,13 +1,9 @@
1
- import {
2
- access,
3
- createSystemUser,
4
- defineWriteHandler,
5
- SYSTEM_TENANT_ID,
6
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
7
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
8
4
  import { z } from "zod";
9
5
  import { AuthErrors, verifyPassword } from "../../auth-email-password";
10
- import { UserErrors, UserHandlers, UserQueries } from "../../user";
6
+ import { USER_FEATURE, UserErrors, UserHandlers, UserQueries } from "../../user";
11
7
  import { UserProfileErrors } from "../constants";
12
8
 
13
9
  // Gleiche Failure-Shape wie auth-email-password (anti-enumeration):
@@ -70,10 +66,11 @@ export const changeEmailWrite = defineWriteHandler({
70
66
  );
71
67
  }
72
68
 
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);
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);
77
74
 
78
75
  const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
79
76
  id: me.id,
@@ -246,17 +246,6 @@ function DangerZoneSection({
246
246
  }): ReactNode {
247
247
  const t = useTranslation();
248
248
  const { Button, Banner, Dialog, Card } = usePrimitives();
249
- // Card.slots.title always renders <h3> — with the page's <h1> (variant="page")
250
- // as this screen's only other heading, that skips <h2> (607/4). An explicit
251
- // header slot with a real <h2> keeps the levels contiguous; layout mirrors
252
- // DefaultCard's own default-header markup.
253
- const dangerZoneHeader = (
254
- <div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
255
- <h2 className="text-base font-semibold leading-none tracking-tight">
256
- {t("profile.danger.title")}
257
- </h2>
258
- </div>
259
- );
260
249
  const dispatcher = useDispatcher();
261
250
  const [dialogOpen, setDialogOpen] = useState(false);
262
251
  const [status, setStatus] = useState<SectionStatus>({ kind: "idle" });
@@ -301,7 +290,7 @@ function DangerZoneSection({
301
290
  <Card
302
291
  testId="profile-danger"
303
292
  className="border-destructive/40"
304
- slots={{ header: dangerZoneHeader, footer }}
293
+ slots={{ title: t("profile.danger.title"), footer }}
305
294
  >
306
295
  <div className="flex flex-col gap-4">
307
296
  {deletionRequested ? (
@@ -1,33 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { MIN_HMAC_SECRET_LENGTH } from "../constants";
3
- import { createAuthEmailPasswordFeature } from "../feature";
4
-
5
- // A short HMAC secret makes reset/verify tokens forgeable (account takeover),
6
- // so the factory must fail fast — same bar as the ≥32-char JWT_SECRET check.
7
-
8
- const okSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH);
9
- const shortSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH - 1);
10
- const appUrl = "https://app.example.com/flow";
11
-
12
- describe("createAuthEmailPasswordFeature hmacSecret validation", () => {
13
- test("rejects a short passwordReset.hmacSecret", () => {
14
- expect(() =>
15
- createAuthEmailPasswordFeature({ passwordReset: { hmacSecret: shortSecret, appUrl } }),
16
- ).toThrow(/passwordReset\.hmacSecret must be/);
17
- });
18
-
19
- test("rejects a short emailVerification.hmacSecret", () => {
20
- expect(() =>
21
- createAuthEmailPasswordFeature({ emailVerification: { hmacSecret: shortSecret, appUrl } }),
22
- ).toThrow(/emailVerification\.hmacSecret must be/);
23
- });
24
-
25
- test("accepts secrets at the minimum length", () => {
26
- expect(() =>
27
- createAuthEmailPasswordFeature({
28
- passwordReset: { hmacSecret: okSecret, appUrl },
29
- emailVerification: { hmacSecret: okSecret, appUrl },
30
- }),
31
- ).not.toThrow();
32
- });
33
- });
@@ -1,35 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
3
-
4
- const medianMs = async (fn: () => Promise<unknown>, runs = 5): Promise<number> => {
5
- const samples: number[] = [];
6
- for (let i = 0; i < runs; i++) {
7
- const start = performance.now();
8
- await fn();
9
- samples.push(performance.now() - start);
10
- }
11
- samples.sort((a, b) => a - b);
12
- return samples[Math.floor(samples.length / 2)] ?? 0;
13
- };
14
-
15
- // #774: the login no-user path must cost the same argon2 latency as a real
16
- // verify, otherwise response timing leaks whether an email is registered.
17
- describe("verifyDummyPassword (anti-enumeration timing)", () => {
18
- test("resolves to void, never matches", async () => {
19
- expect(await verifyDummyPassword("whatever")).toBeUndefined();
20
- });
21
-
22
- test("burns real argon2 cost, comparable to a failed verify (not a no-op)", async () => {
23
- const realHash = await hashPassword("correct-horse-battery");
24
- // warm the lazily-cached dummy hash so we time the verify, not the one-off hash
25
- await verifyDummyPassword("warmup");
26
-
27
- const realMs = await medianMs(() => verifyPassword(realHash, "wrong-password"));
28
- const dummyMs = await medianMs(() => verifyDummyPassword("wrong-password"));
29
-
30
- // A skipped miss-path (the bug) is sub-millisecond; a real argon2 verify
31
- // is ~20ms. A half-of-real floor is a wide, non-flaky bound that still
32
- // fails hard if the dummy verify is ever removed.
33
- expect(dummyMs).toBeGreaterThan(realMs * 0.5);
34
- });
35
- });
@@ -1,29 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { renderHook } from "@testing-library/react";
3
- import { useUrlToken } from "../auth-form-primitives";
4
-
5
- // #774: magic-link tokens must not linger in browser history / Referer.
6
- // useUrlToken reads the token once, then scrubs the param via replaceState.
7
- // (dom.preload resets window.location to http://localhost/ after each test.)
8
- describe("useUrlToken (magic-link history hygiene)", () => {
9
- test("reads ?token= and strips it from the URL, keeping other params", () => {
10
- window.history.replaceState(null, "", "http://localhost/reset?token=secret-abc&keep=1");
11
- const { result } = renderHook(() => useUrlToken());
12
- expect(result.current).toBe("secret-abc");
13
- expect(window.location.search).toBe("?keep=1");
14
- });
15
-
16
- test("no token param → URL untouched", () => {
17
- window.history.replaceState(null, "", "http://localhost/reset?keep=1");
18
- const { result } = renderHook(() => useUrlToken());
19
- expect(result.current).toBe("");
20
- expect(window.location.search).toBe("?keep=1");
21
- });
22
-
23
- test("explicit override short-circuits both URL read and scrub", () => {
24
- window.history.replaceState(null, "", "http://localhost/reset?token=url-token");
25
- const { result } = renderHook(() => useUrlToken("prop-token"));
26
- expect(result.current).toBe("prop-token");
27
- expect(window.location.search).toBe("?token=url-token");
28
- });
29
- });
@@ -1,179 +0,0 @@
1
- // Pins the legacy → envelope migration story for encrypted config values:
2
- // 1. rows written under the old CONFIG_ENCRYPTION_KEY format stay readable
3
- // as long as the legacy key is wired as the cipher's fallback,
4
- // 2. reading an encrypted row WITHOUT any cipher throws (the pre-envelope
5
- // code silently returned the ciphertext as the value),
6
- // 3. the reencrypt job migrates legacy rows onto the envelope format
7
- // through the event-store executor (event payload carries the new
8
- // envelope, so a projection rebuild no longer needs the legacy key),
9
- // and is idempotent on re-run.
10
-
11
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
- import { randomBytes } from "node:crypto";
13
- import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
- import {
15
- createEncryptionProvider,
16
- createEventStoreExecutor,
17
- createTenantDb,
18
- } from "@cosmicdrift/kumiko-framework/db";
19
- import {
20
- type AppContext,
21
- access,
22
- createRegistry,
23
- createSystemUser,
24
- createTenantConfig,
25
- defineFeature,
26
- type TenantId,
27
- } from "@cosmicdrift/kumiko-framework/engine";
28
- import {
29
- createEnvelopeCipher,
30
- type EnvelopeCipher,
31
- isStoredEnvelope,
32
- } from "@cosmicdrift/kumiko-framework/secrets";
33
- import {
34
- setupTestStack,
35
- type TestStack,
36
- testTenantId,
37
- unsafePushTables,
38
- } from "@cosmicdrift/kumiko-framework/stack";
39
- import { createTestMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
40
- import { createConfigFeature } from "../feature";
41
- import { reencryptJob } from "../handlers/reencrypt.job";
42
- import { createConfigResolver } from "../resolver";
43
- import { configValueEntity, configValuesTable } from "../table";
44
-
45
- const KEY = "enc-mig:config:secret-pass";
46
- const TENANT: TenantId = testTenantId(1);
47
- const PLAINTEXT = "legacy-smtp-password";
48
-
49
- const keyDef = createTenantConfig("text", {
50
- encrypted: true,
51
- read: access.all,
52
- write: access.all,
53
- });
54
-
55
- // legacy single-key provider — the pre-envelope CONFIG_ENCRYPTION_KEY world
56
- const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
57
- const masterKeyProvider = createTestMasterKeyProvider();
58
- const cipherWithLegacy = createEnvelopeCipher(masterKeyProvider, { legacy });
59
- const cipherWithoutLegacy = createEnvelopeCipher(masterKeyProvider);
60
-
61
- const encMigFeature = defineFeature("enc-mig", (r) => {
62
- r.config({ keys: { "secret-pass": keyDef } });
63
- });
64
-
65
- const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
66
- entityName: "config-value",
67
- });
68
-
69
- let stack: TestStack;
70
-
71
- async function seedLegacyRow(): Promise<string> {
72
- const legacyValue = legacy.encrypt(JSON.stringify(PLAINTEXT));
73
- const systemUser = createSystemUser(TENANT);
74
- const tdb = createTenantDb(stack.db, TENANT, "system");
75
- const result = await executor.create(
76
- { key: KEY, value: legacyValue, tenantId: TENANT, userId: null },
77
- systemUser,
78
- tdb,
79
- );
80
- if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
81
- return (result.data as { id: string }).id;
82
- }
83
-
84
- async function readStoredValue(): Promise<string> {
85
- const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
86
- key: KEY,
87
- tenantId: TENANT,
88
- });
89
- if (!row) throw new Error("row missing");
90
- return row.value;
91
- }
92
-
93
- type ReencryptCtx = Pick<AppContext, "db" | "masterKeyProvider" | "configEncryption" | "registry">;
94
- function jobCtx(cipher: EnvelopeCipher): Parameters<typeof reencryptJob>[1] {
95
- const ctx: ReencryptCtx = {
96
- db: stack.db,
97
- masterKeyProvider,
98
- configEncryption: cipher,
99
- registry: stack.registry,
100
- };
101
- return ctx as unknown as Parameters<typeof reencryptJob>[1];
102
- }
103
-
104
- beforeAll(async () => {
105
- stack = await setupTestStack({ features: [createConfigFeature(), encMigFeature] });
106
- await unsafePushTables(stack.db, { configValuesTable });
107
- await seedLegacyRow();
108
- });
109
-
110
- afterAll(async () => {
111
- await stack.cleanup();
112
- });
113
-
114
- describe("legacy encrypted config rows", () => {
115
- test("stay readable through the cipher's legacy fallback (get + cascade path)", async () => {
116
- const resolver = createConfigResolver({ cipher: cipherWithLegacy });
117
- const value = await resolver.get(KEY, keyDef, TENANT, "u1", stack.db);
118
- expect(value).toBe(PLAINTEXT);
119
-
120
- const cascade = await resolver.getCascade(KEY, keyDef, TENANT, "u1", stack.db);
121
- const active = cascade.levels.find((l) => l.hasValue);
122
- expect(active?.value).toBe(PLAINTEXT);
123
- });
124
-
125
- test("throw without any cipher instead of leaking ciphertext as the value", async () => {
126
- const resolver = createConfigResolver({});
127
- await expect(resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(
128
- /encrypted but no cipher/,
129
- );
130
- await expect(resolver.getCascade(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(
131
- /encrypted but no cipher/,
132
- );
133
- });
134
-
135
- test("throw with a remediation hint when the legacy key is missing", async () => {
136
- const resolver = createConfigResolver({ cipher: cipherWithoutLegacy });
137
- await expect(resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(/legacy/);
138
- });
139
- });
140
-
141
- describe("config:reencrypt job", () => {
142
- test("migrates the legacy row to envelope format via the executor", async () => {
143
- expect((await readStoredValue()).startsWith("{")).toBe(false);
144
-
145
- await reencryptJob({}, jobCtx(cipherWithLegacy));
146
-
147
- const stored = await readStoredValue();
148
- expect(stored.startsWith("{")).toBe(true);
149
- expect(isStoredEnvelope(JSON.parse(stored))).toBe(true);
150
-
151
- // decryptable WITHOUT the legacy key now — retiring CONFIG_ENCRYPTION_KEY
152
- // after a clean run is safe
153
- const resolver = createConfigResolver({ cipher: cipherWithoutLegacy });
154
- expect(await resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).toBe(PLAINTEXT);
155
- });
156
-
157
- test("event payload carries the new envelope — rebuild-safe without legacy key", async () => {
158
- // The migration wrote a configValue.updated event whose payload holds
159
- // the envelope value; replaying events verbatim reproduces the
160
- // migrated state, so the final projection never needs the legacy key.
161
- const registryCheck = createRegistry([createConfigFeature(), encMigFeature]);
162
- expect(registryCheck.getConfigKey(KEY)?.encrypted).toBe(true);
163
-
164
- const events = await asRawClient(stack.db).unsafe<{
165
- payload: { changes?: { value?: string } };
166
- }>(
167
- `SELECT payload FROM kumiko_events WHERE aggregate_type = 'config-value' AND type LIKE '%.updated' ORDER BY created_at DESC LIMIT 1`,
168
- );
169
- const lastValue = events[0]?.payload?.changes?.value;
170
- expect(typeof lastValue).toBe("string");
171
- expect((lastValue as string).startsWith("{")).toBe(true);
172
- });
173
-
174
- test("is idempotent — a second run migrates nothing", async () => {
175
- const before = await readStoredValue();
176
- await reencryptJob({}, jobCtx(cipherWithLegacy));
177
- expect(await readStoredValue()).toBe(before);
178
- });
179
- });