@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1

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 (149) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -5,6 +5,7 @@ import {
5
5
  type FeatureDefinition,
6
6
  SYSTEM_USER_ID,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { validateGdprHookCompleteness, validateGdprPiiHookCoverage } from "./boot-checks";
8
9
  import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
9
10
  import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
10
11
  import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
@@ -87,7 +88,7 @@ export type UserDataRightsOptions = {
87
88
  readonly sendExportFailedEmail?: SendExportFailedEmailFn;
88
89
  /** Base-URL fuer den Magic-Link, z.B.
89
90
  * "https://app.example.com/user-export/by-token". Worker bauen
90
- * `${appExportDownloadUrl}?token=<plain>`. Required wenn
91
+ * a URL fragment (`#token=<plain>`, issue #1271). Required wenn
91
92
  * sendExportReadyEmail gesetzt. Per-Tenant via reverse-proxy host
92
93
  * routing — nicht via per-Tenant-config-key (App-Author-Decision). */
93
94
  readonly appExportDownloadUrl?: string;
@@ -155,6 +156,13 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
155
156
 
156
157
  r.extendsRegistrar(EXT_USER_DATA, {});
157
158
 
159
+ // GDPR-storage guards V2+V3 (#1314) — moved off the framework-internal
160
+ // boot-validator onto this feature's own r.bootCheck(), since it's the
161
+ // EXT_USER_DATA channel owner: any r.useExtension(EXT_USER_DATA, ...)
162
+ // usage already requires user-data-rights to be mounted.
163
+ r.bootCheck(({ features }) => validateGdprHookCompleteness(features));
164
+ r.bootCheck(({ features }) => validateGdprPiiHookCoverage(features));
165
+
158
166
  // App-level tenant-occupancy model. Default "multi-user" → tenant-scoped
159
167
  // contributors (e.g. credit) NEVER erase tenant data on a per-user forget
160
168
  // (would harm co-members). An app with one user per tenant sets this to
@@ -282,10 +290,16 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
282
290
  access: { openToAll: true },
283
291
  });
284
292
 
285
- // Magic-Link-Pfad (anonymous): GET /user-export/by-token?token=<plain>.
286
- // Ruft via app.fetch /api/query → success: 302-Redirect zur signed-URL →
287
- // Browser folgt → Download startet beim Object-Store. Bei error:
288
- // passthrough (404/410/501) als JSON.
293
+ // Magic-link path (anonymous): the email link carries the token as a
294
+ // URL fragment (`#token=<plain>`) instead of a query param — fragments
295
+ // never leave the browser, so they never hit proxy/access logs
296
+ // (issue #1271). This route only serves the interstitial page; the
297
+ // external script reads the fragment client-side and exchanges the
298
+ // token via POST for the signed URL.
299
+ //
300
+ // The old token-in-query format stays supported as a fallback (not a
301
+ // transitional shim — permanent): emails sent before this deploy still
302
+ // use the old format and must keep working until their TTL expires.
289
303
  //
290
304
  // Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace nicht
291
305
  // claimen darf (reserved fuer write/query/batch/auth/sse-dispatcher).
@@ -302,7 +316,9 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
302
316
  const url = new URL(c.req.url);
303
317
  const token = url.searchParams.get("token");
304
318
  if (!token) {
305
- return c.json({ error: "missing_token" }, 400);
319
+ return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
320
+ "content-type": "text/html; charset=utf-8",
321
+ });
306
322
  }
307
323
  const queryRes = await app.fetch(
308
324
  new Request(`${url.origin}/api/query`, {
@@ -318,6 +334,59 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
318
334
  },
319
335
  });
320
336
 
337
+ // External script for the interstitial page above — same-origin file
338
+ // instead of inline <script>, so the page also works under a strict
339
+ // `script-src 'self'` CSP (apps set CSP via the security-headers
340
+ // option, without assuming `unsafe-inline`).
341
+ r.httpRoute({
342
+ method: "GET",
343
+ path: "/user-export/by-token.js",
344
+ anonymous: true,
345
+ handler: async (c) => {
346
+ return c.body(TOKEN_EXCHANGE_PAGE_JS, 200, {
347
+ "content-type": "application/javascript; charset=utf-8",
348
+ });
349
+ },
350
+ });
351
+
352
+ // Exchange endpoint for the interstitial page: the token arrives here
353
+ // as a POST body instead of a query string (issue #1271). A dedicated
354
+ // route rather than letting the browser JS call /api/query directly,
355
+ // for two reasons:
356
+ // 1. IP/UA audit — extractAuditMeta needs the raw request headers,
357
+ // which only the server has, not the browser (see the GET path
358
+ // above). Without this route every fragment-path download would
359
+ // land in download-attempt with lastUsedFromIp/UA = null.
360
+ // 2. /api/query is the reserved dispatcher namespace for
361
+ // authenticated writes/queries — an anonymous cross-origin POST
362
+ // there isn't guaranteed to be reachable (origin guard).
363
+ r.httpRoute({
364
+ method: "POST",
365
+ path: "/user-export/by-token",
366
+ anonymous: true,
367
+ handler: async (c, { app }) => {
368
+ const url = new URL(c.req.url);
369
+ const parsedBody = await c.req.json().catch(() => null);
370
+ const token =
371
+ parsedBody && typeof parsedBody === "object" && "token" in parsedBody
372
+ ? (parsedBody as { token?: unknown }).token
373
+ : undefined;
374
+ if (typeof token !== "string" || token.length === 0) {
375
+ return c.json({ error: "missing_token" }, 400);
376
+ }
377
+ return app.fetch(
378
+ new Request(`${url.origin}/api/query`, {
379
+ method: "POST",
380
+ headers: { "content-type": "application/json" },
381
+ body: JSON.stringify({
382
+ type: "user-data-rights:query:download-by-token",
383
+ payload: { token, auditMeta: extractAuditMeta(c.req.raw.headers) },
384
+ }),
385
+ }),
386
+ );
387
+ },
388
+ });
389
+
321
390
  // S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
322
391
  r.job(
323
392
  "run-export-jobs",
@@ -504,3 +573,44 @@ function extractAuditMeta(headers: Headers): { ip: string | null; userAgent: str
504
573
  }
505
574
  return { ip, userAgent: headers.get("user-agent") };
506
575
  }
576
+
577
+ // Interstitial page for the magic-link fragment exchange (see the
578
+ // httpRoute above). Plain static markup — no build step, no i18n lookup
579
+ // (the server doesn't know a user locale here; anonymous path).
580
+ const TOKEN_EXCHANGE_PAGE_HTML = `<!doctype html>
581
+ <html lang="en">
582
+ <head><meta charset="utf-8"><title>Export Download</title></head>
583
+ <body>
584
+ <p id="status">Preparing your download…</p>
585
+ <script src="/user-export/by-token.js"></script>
586
+ </body>
587
+ </html>`;
588
+
589
+ // Reads the token client-side from the URL fragment (never sent to the
590
+ // server) and exchanges it via POST for the signed URL. Same-origin
591
+ // external file instead of inline <script>, so the page also works under
592
+ // a strict `script-src 'self'` CSP.
593
+ const TOKEN_EXCHANGE_PAGE_JS = `(async () => {
594
+ const status = document.getElementById("status");
595
+ const params = new URLSearchParams(location.hash.slice(1));
596
+ const token = params.get("token");
597
+ if (!token) {
598
+ status.textContent = "Invalid download link.";
599
+ return;
600
+ }
601
+ try {
602
+ const res = await fetch("/user-export/by-token", {
603
+ method: "POST",
604
+ headers: { "content-type": "application/json" },
605
+ body: JSON.stringify({ token }),
606
+ });
607
+ const body = await res.json();
608
+ if (!res.ok || !body || !body.data || !body.data.url) {
609
+ status.textContent = "This download link is invalid or has expired.";
610
+ return;
611
+ }
612
+ location.href = body.data.url;
613
+ } catch {
614
+ status.textContent = "Could not start the download.";
615
+ }
616
+ })();`;
@@ -75,7 +75,7 @@ export async function startDeletionGracePeriod(
75
75
  ok: true,
76
76
  gracePeriodEnd,
77
77
  userEmail: userRow["email"]
78
- ? await decryptStoredPii(userRow["email"], "user-data-rights:grace-period")
78
+ ? await decryptStoredPii(userRow["email"], "email", "user-data-rights:grace-period")
79
79
  : "",
80
80
  userLocale: userRow["locale"] ?? null,
81
81
  };
@@ -1,7 +1,7 @@
1
1
  // GET /api/query → user-data-rights:query:download-by-token (S2.U3 Atom 4b).
2
2
  //
3
3
  // **Magic-Link-Pfad** (anonymous): User klickt Email-Link mit
4
- // `?token=<plain>`. Worker hat beim done-flip Token-Hash in DB
4
+ // `#token=<plain>` (URL fragment, issue #1271). Worker hat beim done-flip Token-Hash in DB
5
5
  // persistiert (Atom 4a). Verify-Pipeline:
6
6
  //
7
7
  // 1. hashDownloadToken(plain) → fetchOne in download-tokens
@@ -1,5 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
5
  import { USER_STATUS, userTable } from "../../user";
@@ -7,18 +7,11 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
7
7
 
8
8
  // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
9
9
  //
10
- // **Wichtige Eigenheit:** Der User kann diesen Endpoint NICHT selber
11
- // aufrufen weil sein Login geblockt ist (Restricted-Status, siehe
12
- // login.write.ts Atom 3). Wer ein Restricted-Konto wieder aktiviert,
13
- // muss dafuer einen anderen Pfad nutzen — typisch Operator-Tool oder
14
- // Email-Magic-Link an die User-Email. App-Author entscheidet das per
15
- // access-Konfig oder Custom-Wrapper.
16
- //
17
- // MVP-Default: openToAll mit Self-Service-Semantik. Die Asymmetrie
18
- // (User koennte sich selbst freischalten WENN er einen Weg ohne Login
19
- // hat — z.B. valid Magic-Link aus pre-Restriction) ist akzeptabel:
20
- // Restriction ist *Verarbeitungs-Pause*, nicht *Sperre durch Operator*.
21
- // User der "ich will doch wieder mitmachen" sagt, soll das koennen.
10
+ // Operator-only. A Restricted user's own session is unconditionally
11
+ // rejected by sessionChecker (BLOCKED_STATUSES) the moment their status
12
+ // flips — their JWT/login can't reach this or any other authenticated
13
+ // endpoint. There is no self-service path today, so lifting a restriction
14
+ // always targets someone else's account by id.
22
15
  //
23
16
  // State-Transitions:
24
17
  // Restricted → Active ✓
@@ -27,17 +20,18 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
27
20
  // Deleted → ... ✗ 422 not_restricted
28
21
  export const liftRestrictionWrite = defineWriteHandler({
29
22
  name: "lift-restriction",
30
- schema: z.object({}),
31
- access: { openToAll: true },
23
+ schema: z.object({ userId: z.string() }),
24
+ access: { roles: access.admin },
32
25
  handler: async (event, ctx) => {
26
+ const targetUserId = event.payload.userId;
33
27
  const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
34
- id: event.user.id,
28
+ id: targetUserId,
35
29
  });
36
30
 
37
31
  if (!userRow) {
38
32
  return writeFailure(
39
33
  new UnprocessableError("user_not_found", {
40
- details: { reason: "user_not_found", userId: event.user.id },
34
+ details: { reason: "user_not_found", userId: targetUserId },
41
35
  }),
42
36
  );
43
37
  }
@@ -51,12 +45,12 @@ export const liftRestrictionWrite = defineWriteHandler({
51
45
  );
52
46
  }
53
47
 
54
- await updateUserLifecycle(ctx.db.raw, event.user.id, { status: USER_STATUS.Active });
48
+ await updateUserLifecycle(ctx.db.raw, targetUserId, { status: USER_STATUS.Active });
55
49
 
56
50
  return {
57
51
  isSuccess: true as const,
58
52
  data: {
59
- userId: event.user.id,
53
+ userId: targetUserId,
60
54
  status: USER_STATUS.Active,
61
55
  },
62
56
  };
@@ -1,6 +1,10 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ AccessDeniedError,
5
+ UnprocessableError,
6
+ writeFailure,
7
+ } from "@cosmicdrift/kumiko-framework/errors";
4
8
  import { z } from "zod";
5
9
  import { USER_STATUS, userTable } from "../../user";
6
10
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
@@ -15,6 +19,12 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
15
19
  // muss gemountet sein). App-Author ohne sessions-feature kriegt einen
16
20
  // Boot-Resolver-Error via r.usesApi("sessions.revokeAllForUser").
17
21
  //
22
+ // Self-service by default (userId omitted → event.user.id). An admin can
23
+ // also target someone else's account — needed once a user is already
24
+ // Restricted, since their own session is then unconditionally rejected by
25
+ // sessionChecker and they can no longer call this (or any) endpoint
26
+ // themselves; only an operator path reaches it at that point.
27
+ //
18
28
  // State-Transitions:
19
29
  // Active → Restricted ✓ (dieser Handler)
20
30
  // Restricted → Restricted ✗ 422 already_restricted (Idempotenz-Guard)
@@ -22,19 +32,31 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
22
32
  // Deleted → ... ✗ 422 user_not_in_active_state
23
33
  export const restrictAccountWrite = defineWriteHandler({
24
34
  name: "restrict-account",
25
- schema: z.object({}),
35
+ schema: z.object({ userId: z.string().optional() }),
26
36
  access: { openToAll: true },
27
37
  handler: async (event, ctx) => {
38
+ const targetUserId = event.payload.userId ?? event.user.id;
39
+ if (targetUserId !== event.user.id) {
40
+ const isAdmin = event.user.roles.some((role) => access.admin.includes(role));
41
+ if (!isAdmin) {
42
+ return writeFailure(
43
+ new AccessDeniedError({
44
+ details: { reason: "admin_required_for_other_user" },
45
+ }),
46
+ );
47
+ }
48
+ }
49
+
28
50
  // ctx.db.raw weil User-Entity tenant-agnostisch ist (analog
29
51
  // request-deletion.write.ts Cross-Tenant-Section).
30
52
  const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
31
- id: event.user.id,
53
+ id: targetUserId,
32
54
  });
33
55
 
34
56
  if (!userRow) {
35
57
  return writeFailure(
36
58
  new UnprocessableError("user_not_found", {
37
- details: { reason: "user_not_found", userId: event.user.id },
59
+ details: { reason: "user_not_found", userId: targetUserId },
38
60
  }),
39
61
  );
40
62
  }
@@ -55,7 +77,7 @@ export const restrictAccountWrite = defineWriteHandler({
55
77
  );
56
78
  }
57
79
 
58
- await updateUserLifecycle(ctx.db.raw, event.user.id, { status: USER_STATUS.Restricted });
80
+ await updateUserLifecycle(ctx.db.raw, targetUserId, { status: USER_STATUS.Restricted });
59
81
 
60
82
  // Cross-Feature: alle live sessions revoken — sonst koennte der User
61
83
  // mit existierendem JWT bis zur Token-Expiry weiter schreiben.
@@ -63,13 +85,13 @@ export const restrictAccountWrite = defineWriteHandler({
63
85
  // System-User-Roles im access-gate hat.
64
86
  const systemUser = createSystemUser(event.user.tenantId);
65
87
  await ctx.writeAs(systemUser, "sessions:write:user-session:revoke-all-for-user", {
66
- userId: event.user.id,
88
+ userId: targetUserId,
67
89
  });
68
90
 
69
91
  return {
70
92
  isSuccess: true as const,
71
93
  data: {
72
- userId: event.user.id,
94
+ userId: targetUserId,
73
95
  status: USER_STATUS.Restricted,
74
96
  },
75
97
  };
@@ -152,7 +152,7 @@ export interface RunExportJobsArgs {
152
152
  readonly sendExportFailedEmail?: SendExportFailedEmailFn;
153
153
  /** Base-URL fuer den Magic-Link. App-Author setzt das (z.B.
154
154
  * "https://app.example.com/user-export/by-token"). Worker baut
155
- * `${appExportDownloadUrl}?token=<plain>` und reicht das im
155
+ * a URL fragment (not a query param — see issue #1271) and passes that
156
156
  * Callback durch. Required wenn sendExportReadyEmail gesetzt. */
157
157
  readonly appExportDownloadUrl?: string;
158
158
  }
@@ -801,7 +801,7 @@ async function lookupUserContact(
801
801
  locale: string | null;
802
802
  } | null;
803
803
  if (!row?.email) return null;
804
- const email = await decryptStoredPii(row.email, "user-data-rights:export-ready");
804
+ const email = await decryptStoredPii(row.email, "email", "user-data-rights:export-ready");
805
805
  if (email === PII_ERASED_SENTINEL) return null;
806
806
  return { email, locale: row.locale ?? null };
807
807
  }
@@ -844,7 +844,7 @@ async function fireExportReadyCallback(args: {
844
844
  "user-data-rights: sendExportReadyEmail gesetzt aber appExportDownloadUrl fehlt — beide muessen zusammen konfiguriert sein",
845
845
  );
846
846
  }
847
- const downloadUrl = `${baseUrl}?token=${encodeURIComponent(args.plainToken)}`;
847
+ const downloadUrl = `${baseUrl}#token=${encodeURIComponent(args.plainToken)}`;
848
848
  await args.send({
849
849
  userId: args.job.userId,
850
850
  userEmail: contact.email,
@@ -281,7 +281,7 @@ async function processUser(args: {
281
281
  });
282
282
  const userEmailBeforeDelete =
283
283
  userPreTx?.email && userPreTx.email.length > 0
284
- ? await decryptStoredPii(userPreTx.email, "user-data-rights:forget-cleanup")
284
+ ? await decryptStoredPii(userPreTx.email, "email", "user-data-rights:forget-cleanup")
285
285
  : null;
286
286
  const userLocaleBeforeDelete = userPreTx?.locale ?? null;
287
287
 
@@ -19,6 +19,7 @@ import {
19
19
  unsafePushTables,
20
20
  } from "@cosmicdrift/kumiko-framework/stack";
21
21
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
22
+ import { authFoundationFeature } from "../../auth-foundation";
22
23
  import { createChannelInAppFeature, inAppMessagesTable } from "../../channel-in-app";
23
24
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
24
25
  import { configValueEntity, createConfigFeature } from "../../config";
@@ -72,6 +73,7 @@ beforeAll(async () => {
72
73
  createConfigFeature(),
73
74
  createTenantFeature(),
74
75
  createPersonalAccessTokensFeature({ scopes: PAT_SCOPES }),
76
+ authFoundationFeature,
75
77
  createDeliveryFeature(),
76
78
  createChannelInAppFeature(),
77
79
  createJobsFeature(),
@@ -93,6 +95,7 @@ beforeAll(async () => {
93
95
  createFilesFeature(),
94
96
  createDataRetentionFeature(),
95
97
  createComplianceProfilesFeature(),
98
+ authFoundationFeature,
96
99
  createSessionsFeature(),
97
100
  createUserDataRightsFeature(),
98
101
  createUserDataRightsDefaultsFeature(),
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { featureMounted } from "../hooks/feature-mounted";
4
+
5
+ function ctxWithFeatures(mounted: readonly string[]): UserDataHookCtx {
6
+ return {
7
+ registry: {
8
+ getFeature: (name: string) => (mounted.includes(name) ? { name } : undefined),
9
+ // @cast-boundary test fixture — featureMounted only reads ctx.registry.getFeature
10
+ } as unknown as UserDataHookCtx["registry"],
11
+ } as unknown as UserDataHookCtx;
12
+ }
13
+
14
+ describe("featureMounted", () => {
15
+ test("returns true when the feature is registered", () => {
16
+ const ctx = ctxWithFeatures(["sessions"]);
17
+ expect(featureMounted(ctx, "sessions")).toBe(true);
18
+ });
19
+
20
+ test("returns false when the feature is not registered", () => {
21
+ const ctx = ctxWithFeatures(["sessions"]);
22
+ expect(featureMounted(ctx, "channel-email")).toBe(false);
23
+ });
24
+ });
@@ -11,6 +11,7 @@
11
11
  // fileRef-Forget beruehrt Tenant B's Files nicht)
12
12
 
13
13
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
15
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
16
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
17
  import {
@@ -43,6 +44,7 @@ const features = [
43
44
  createFilesFeature(),
44
45
  createDataRetentionFeature(),
45
46
  createComplianceProfilesFeature(),
47
+ authFoundationFeature,
46
48
  createSessionsFeature(),
47
49
  createUserDataRightsFeature(),
48
50
  createUserDataRightsDefaultsFeature(),
@@ -42,7 +42,7 @@ async function resolveUserEmail(ctx: UserDataHookCtx): Promise<string | null> {
42
42
  if (!row) return null;
43
43
  // Stored value is ciphertext with an active KMS; lowercasing a base64
44
44
  // blob would break both lookup arms — decrypt first.
45
- const email = await decryptStoredPii(row.email, "user-data-rights:invitation-hook");
45
+ const email = await decryptStoredPii(row.email, "email", "user-data-rights:invitation-hook");
46
46
  return email.toLowerCase();
47
47
  }
48
48
 
@@ -4,6 +4,7 @@
4
4
  // dispatcht genau diese Strings).
5
5
 
6
6
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
7
8
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
8
9
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -60,6 +61,7 @@ beforeAll(async () => {
60
61
  createAuthEmailPasswordFeature(),
61
62
  createDataRetentionFeature(),
62
63
  createComplianceProfilesFeature(),
64
+ authFoundationFeature,
63
65
  createSessionsFeature(),
64
66
  createFilesFeature(),
65
67
  createUserDataRightsFeature(),
@@ -54,7 +54,7 @@ export const changeEmailWrite = defineWriteHandler({
54
54
  }
55
55
 
56
56
  const newEmail = event.payload.newEmail.toLowerCase();
57
- const currentEmail = await decryptStoredPii(me.email, "user-profile:change-email");
57
+ const currentEmail = await decryptStoredPii(me.email, "email", "user-profile:change-email");
58
58
  if (newEmail === currentEmail.toLowerCase()) {
59
59
  return writeFailure(
60
60
  new UnprocessableError(UserProfileErrors.emailUnchanged, {
@@ -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
- });