@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.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- 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
|
-
*
|
|
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-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// login
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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: {
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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 {
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
88
|
+
userId: targetUserId,
|
|
67
89
|
});
|
|
68
90
|
|
|
69
91
|
return {
|
|
70
92
|
isSuccess: true as const,
|
|
71
93
|
data: {
|
|
72
|
-
userId:
|
|
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
|
-
*
|
|
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}
|
|
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
|
package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -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, {
|