@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.
- 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-gate.test.tsx +65 -3
- 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 +20 -0
- 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__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -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/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -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 +22 -7
- 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/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 +4 -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-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/handlers/change-email.write.ts +1 -1
- 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
|
-
*
|
|
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
|
|
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(),
|
|
@@ -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
|
-
});
|