@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 (163) 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-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -0,0 +1,86 @@
1
+ import {
2
+ type EntityDefinition,
3
+ EXT_USER_DATA,
4
+ type FeatureDefinition,
5
+ type PiiAnnotations,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // r.entity(...) is not the only way a feature exposes an entity shape:
9
+ // r.projection(...) can carry an optional `entity` too (raw read-models with
10
+ // no executor, e.g. billing-foundation's subscription table). V3 below needs
11
+ // every entity a feature declares, not just the r.entity ones, or a pii/
12
+ // userOwned field on a projection-only entity is invisible to the guard it
13
+ // was annotated for.
14
+ function entitiesOf(
15
+ feature: FeatureDefinition,
16
+ ): ReadonlyArray<readonly [string, EntityDefinition]> {
17
+ const fromEntities = Object.entries(feature.entities ?? {});
18
+ const fromProjections = Object.values(feature.projections ?? {})
19
+ .filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
20
+ .map((p) => [p.name, p.entity] as const);
21
+ return [...fromEntities, ...fromProjections];
22
+ }
23
+
24
+ // V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
25
+ // export hook without a matching delete hook exports data under Art.20 but
26
+ // never erases it on forget — an Art.17 violation. Hard boot failure: no app
27
+ // should ship a GDPR export path with no erase path. Registry-level signal
28
+ // only; runtime no-ops (a delete hook that silently skips) are not detectable
29
+ // here — those are covered by the export/forget integration tests.
30
+ //
31
+ // Registered as an `r.bootCheck()` by this feature (#1314) — EXT_USER_DATA is
32
+ // owned by user-data-rights (r.extendsRegistrar(EXT_USER_DATA, {})), so any
33
+ // r.useExtension(EXT_USER_DATA, ...) usage across the app already requires
34
+ // user-data-rights to be mounted (boot-validator's extension-provider check
35
+ // fails otherwise); running this from user-data-rights' own bootCheck is
36
+ // equivalent to the framework-internal call it replaces.
37
+ export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
38
+ for (const feature of features) {
39
+ for (const usage of feature.extensionUsages) {
40
+ if (usage.extensionName !== EXT_USER_DATA) continue;
41
+ const hasExport = typeof usage.options?.["export"] === "function";
42
+ const hasDelete = typeof usage.options?.["delete"] === "function";
43
+ if (hasExport && !hasDelete) {
44
+ throw new Error(
45
+ `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
46
+ );
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ // V3: PII-entity-without-hook gate. V2 checks registered hooks for
53
+ // completeness; V3 catches the entity nobody registered at all — fields
54
+ // annotated as user-subject data (pii / userOwned) yet invisible to the
55
+ // Art.15/20 export and Art.17 forget pipeline. Hard boot failure: a
56
+ // subject-data entity that skips the pipeline is exactly the "feature built
57
+ // past GDPR" leak this gate exists to stop. No "is user-data-rights mounted"
58
+ // guard needed here (unlike the framework-internal predecessor) — this runs
59
+ // as user-data-rights' own bootCheck, so it's only reachable when mounted.
60
+ // Matching is by entity name across all features (usage.entityName is
61
+ // unqualified); a same-named entity in another feature can mask a gap —
62
+ // accepted, as the common case is a distinct entity name.
63
+ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
64
+ const hookedEntities = new Set<string>();
65
+ for (const f of features) {
66
+ for (const usage of f.extensionUsages) {
67
+ if (usage.extensionName === EXT_USER_DATA) hookedEntities.add(usage.entityName);
68
+ }
69
+ }
70
+
71
+ for (const feature of features) {
72
+ for (const [entityName, entity] of entitiesOf(feature)) {
73
+ if (hookedEntities.has(entityName)) continue;
74
+ const subjectFields = Object.entries(entity.fields)
75
+ .filter(([, field]) => {
76
+ const annot = field as PiiAnnotations; // @cast-boundary schema-walk
77
+ return Boolean(annot.pii) || Boolean(annot.userOwned);
78
+ })
79
+ .map(([name]) => name);
80
+ if (subjectFields.length === 0) continue;
81
+ throw new Error(
82
+ `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
83
+ );
84
+ }
85
+ }
86
+ }
@@ -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
 
@@ -71,6 +71,7 @@ function makeDispatcher(
71
71
  query,
72
72
  batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
73
73
  statusStore,
74
+ async *stream() {},
74
75
  pendingWrites: () => [],
75
76
  pendingFiles: () => [],
76
77
  } as unknown as Dispatcher; // @cast-boundary test-stub
@@ -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(),
@@ -37,6 +37,7 @@ function makeDispatcher(me: Record<string, unknown>): Dispatcher {
37
37
  query: (async () => ({ isSuccess: true, data: me })) as unknown as Dispatcher["query"],
38
38
  batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
39
39
  statusStore,
40
+ async *stream() {},
40
41
  pendingWrites: () => [],
41
42
  pendingFiles: () => [],
42
43
  } as unknown as Dispatcher; // @cast-boundary test-stub
@@ -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, {