@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -123,7 +123,21 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
|
|
|
123
123
|
expect(job).toBeDefined();
|
|
124
124
|
|
|
125
125
|
// EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
|
|
126
|
-
|
|
126
|
+
// log required since #1572 persists incomplete[] via ctx.log.warn.
|
|
127
|
+
const jobCtx = {
|
|
128
|
+
db: stack.db,
|
|
129
|
+
registry: stack.registry,
|
|
130
|
+
configResolver,
|
|
131
|
+
log: {
|
|
132
|
+
info() {},
|
|
133
|
+
warn() {},
|
|
134
|
+
error() {},
|
|
135
|
+
debug() {},
|
|
136
|
+
child() {
|
|
137
|
+
return this;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
127
141
|
await job?.handler({}, jobCtx as never);
|
|
128
142
|
|
|
129
143
|
// Loeschung lief autonom durch.
|
|
@@ -45,6 +45,10 @@ import { createSessionsFeature } from "../../sessions";
|
|
|
45
45
|
import { SessionHandlers } from "../../sessions/constants";
|
|
46
46
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
47
47
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
48
|
+
import {
|
|
49
|
+
SESSION_REVOKED_EVENT_QN,
|
|
50
|
+
type SessionRevokedPayload,
|
|
51
|
+
} from "../../sessions/session-revoked-event";
|
|
48
52
|
import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
|
|
49
53
|
import { hashPassword } from "../../shared";
|
|
50
54
|
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
@@ -211,6 +215,36 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
|
|
|
211
215
|
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
// kumiko-framework#1545: mirrors the lift-restriction cross-tenant guard
|
|
219
|
+
// below — an isAdminActor check alone lets an Admin/TenantAdmin from a
|
|
220
|
+
// DIFFERENT tenant restrict (and force-revoke the sessions of) a user
|
|
221
|
+
// who was never a member of the admin's tenant.
|
|
222
|
+
test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Active", async () => {
|
|
223
|
+
const { userId } = await seedAliceWithMembership();
|
|
224
|
+
const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
|
|
225
|
+
|
|
226
|
+
const err = await stack.http.writeErr(RESTRICT, { userId }, otherTenantAdmin);
|
|
227
|
+
expect(err.httpStatus).toBe(403);
|
|
228
|
+
expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
|
|
229
|
+
|
|
230
|
+
const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
|
|
231
|
+
status: string;
|
|
232
|
+
}>;
|
|
233
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("SystemAdmin darf tenant-übergreifend restricten (platform-weiter Operator)", async () => {
|
|
237
|
+
const { userId } = await seedAliceWithMembership();
|
|
238
|
+
const systemAdmin = await mintActor({
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
tenantId: testTenantId(2),
|
|
241
|
+
roles: ["SystemAdmin"],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = await stack.http.writeOk<{ status: string }>(RESTRICT, { userId }, systemAdmin);
|
|
245
|
+
expect(result.status).toBe(USER_STATUS.Restricted);
|
|
246
|
+
});
|
|
247
|
+
|
|
214
248
|
test("Restricted → Restricted (Idempotenz-Guard): 422 already_restricted", async () => {
|
|
215
249
|
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
216
250
|
// Alice's own session is blocked while Restricted — an admin targets
|
|
@@ -227,6 +261,51 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
|
|
|
227
261
|
const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
|
|
228
262
|
expect(reasonOf(err)).toBe("user_not_in_active_state");
|
|
229
263
|
});
|
|
264
|
+
|
|
265
|
+
// kumiko-framework#1540: access.admin includes TenantAdmin/Admin, which
|
|
266
|
+
// are tenant-scoped roles, but the User-entity lookup uses ctx.db.raw
|
|
267
|
+
// (no auto-tenant-filter — User status is intentionally global). Without
|
|
268
|
+
// a membership check, an Admin from a DIFFERENT tenant than the target
|
|
269
|
+
// user could still pass the isAdminActor gate and flip the target's
|
|
270
|
+
// status. Mirrors the sibling lift-restriction fix (kumiko-framework#1342).
|
|
271
|
+
test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Active", async () => {
|
|
272
|
+
const { userId: targetId } = await seedAliceWithMembership();
|
|
273
|
+
const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
|
|
274
|
+
|
|
275
|
+
const err = await stack.http.writeErr(RESTRICT, { userId: targetId }, otherTenantAdmin);
|
|
276
|
+
expect(err.httpStatus).toBe(403);
|
|
277
|
+
expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
|
|
278
|
+
|
|
279
|
+
const userRow = (await selectMany(stack.db, userTable, { id: targetId })) as Array<{
|
|
280
|
+
status: string;
|
|
281
|
+
}>;
|
|
282
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Active);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("SystemAdmin darf tenant-übergreifend restricten (platform-weiter Operator) + revoked Sessions", async () => {
|
|
286
|
+
const { userId: targetId } = await seedAliceWithMembership();
|
|
287
|
+
const loginRes = await login(ALICE_EMAIL, ALICE_PW);
|
|
288
|
+
expect(loginRes.status).toBe(200);
|
|
289
|
+
|
|
290
|
+
const systemAdmin = await mintActor({
|
|
291
|
+
id: crypto.randomUUID(),
|
|
292
|
+
tenantId: testTenantId(2),
|
|
293
|
+
roles: ["SystemAdmin"],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const result = await stack.http.writeOk<{ userId: string; status: string }>(
|
|
297
|
+
RESTRICT,
|
|
298
|
+
{ userId: targetId },
|
|
299
|
+
systemAdmin,
|
|
300
|
+
);
|
|
301
|
+
expect(result.status).toBe(USER_STATUS.Restricted);
|
|
302
|
+
|
|
303
|
+
const sessionsAfter = (await selectMany(stack.db, userSessionTable, {
|
|
304
|
+
userId: targetId,
|
|
305
|
+
})) as Array<{ revokedAt: unknown }>;
|
|
306
|
+
expect(sessionsAfter.length).toBeGreaterThanOrEqual(1);
|
|
307
|
+
expect(sessionsAfter.every((s) => s.revokedAt !== null)).toBe(true);
|
|
308
|
+
});
|
|
230
309
|
});
|
|
231
310
|
|
|
232
311
|
describe("S2.U6 :: lift-restriction state-transitions", () => {
|
|
@@ -258,6 +337,37 @@ describe("S2.U6 :: lift-restriction state-transitions", () => {
|
|
|
258
337
|
const err = await stack.http.writeErr(LIFT, { userId }, admin);
|
|
259
338
|
expect(reasonOf(err)).toBe("not_restricted");
|
|
260
339
|
});
|
|
340
|
+
|
|
341
|
+
// kumiko-framework#1342: access.admin includes TenantAdmin/Admin, which
|
|
342
|
+
// are tenant-scoped roles, but the User-entity lookup uses ctx.db.raw
|
|
343
|
+
// (no auto-tenant-filter — User status is intentionally global). Without
|
|
344
|
+
// a membership check, an Admin from a DIFFERENT tenant than the target
|
|
345
|
+
// user could still pass the access-gate and flip the target's status.
|
|
346
|
+
test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Restricted", async () => {
|
|
347
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
348
|
+
const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
|
|
349
|
+
|
|
350
|
+
const err = await stack.http.writeErr(LIFT, { userId }, otherTenantAdmin);
|
|
351
|
+
expect(err.httpStatus).toBe(403);
|
|
352
|
+
expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
|
|
353
|
+
|
|
354
|
+
const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
|
|
355
|
+
status: string;
|
|
356
|
+
}>;
|
|
357
|
+
expect(userRow[0]?.status).toBe(USER_STATUS.Restricted);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("SystemAdmin darf tenant-übergreifend liften (platform-weiter Operator)", async () => {
|
|
361
|
+
const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
|
|
362
|
+
const systemAdmin = await mintActor({
|
|
363
|
+
id: crypto.randomUUID(),
|
|
364
|
+
tenantId: testTenantId(2),
|
|
365
|
+
roles: ["SystemAdmin"],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const result = await stack.http.writeOk<{ status: string }>(LIFT, { userId }, systemAdmin);
|
|
369
|
+
expect(result.status).toBe(USER_STATUS.Active);
|
|
370
|
+
});
|
|
261
371
|
});
|
|
262
372
|
|
|
263
373
|
describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () => {
|
|
@@ -337,5 +447,15 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
|
|
|
337
447
|
revokedAt: unknown;
|
|
338
448
|
}>;
|
|
339
449
|
expect(revoked.every((s) => s.revokedAt !== null)).toBe(true);
|
|
450
|
+
|
|
451
|
+
// #1559 — cross-tenant privileged revoke uses the low-level append(),
|
|
452
|
+
// not ctx.unsafeAppendEvent (see revoke-all-for-user.write.ts for why:
|
|
453
|
+
// the SystemAdmin caller's own tenantId has no relationship to the
|
|
454
|
+
// target user's session tenant). One event, both sids listed.
|
|
455
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
456
|
+
expect(events).toHaveLength(1);
|
|
457
|
+
const payload = events[0]?.["payload"] as SessionRevokedPayload;
|
|
458
|
+
expect(payload.userId).toBe(userId);
|
|
459
|
+
expect(new Set(payload.sessionIds)).toEqual(new Set(liveBefore.map((s) => s.id)));
|
|
340
460
|
});
|
|
341
461
|
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// downloadStorageKey wird genullt + storage-key geloescht
|
|
13
13
|
// 6. Idempotency: 2× run → kein Re-Processing von done/failed-Jobs
|
|
14
14
|
|
|
15
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
17
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
@@ -818,7 +818,10 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
818
818
|
const calls: string[] = [];
|
|
819
819
|
// Should not throw despite job A's callback throwing — the try/catch
|
|
820
820
|
// around fireExportFailedCallback swallows it (operator-visibility
|
|
821
|
-
// via console.warn only) and the loop continues to job B.
|
|
821
|
+
// via console.warn only) and the loop continues to job B. A regress to
|
|
822
|
+
// a fully silent swallow (no log call at all) would still pass every
|
|
823
|
+
// assertion below unless the warn itself is pinned.
|
|
824
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
822
825
|
const result = await runExportJobs({
|
|
823
826
|
db: stack.db,
|
|
824
827
|
registry: stack.registry,
|
|
@@ -833,6 +836,8 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
833
836
|
});
|
|
834
837
|
expect(result.failedJobIds).toEqual(expect.arrayContaining([jobAId, jobBId]));
|
|
835
838
|
expect(calls.sort()).toEqual([jobAId, jobBId].sort());
|
|
839
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
840
|
+
warnSpy.mockRestore();
|
|
836
841
|
});
|
|
837
842
|
|
|
838
843
|
test("failed-Job, user ohne email → sendExportFailedEmail skipped (kein Throw)", async () => {
|
|
@@ -840,7 +845,7 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
840
845
|
await seedRow(stack.db, userTable, {
|
|
841
846
|
id: String(aliceUser.id),
|
|
842
847
|
tenantId: tenantA,
|
|
843
|
-
email: ""
|
|
848
|
+
email: "", // empty string — lookupUserEmail returns null
|
|
844
849
|
passwordHash: "hashed",
|
|
845
850
|
displayName: "Alice",
|
|
846
851
|
locale: "de",
|
|
@@ -1270,7 +1275,7 @@ describe("runExportJobs :: Atom 5 notification-callbacks", () => {
|
|
|
1270
1275
|
await seedRow(stack.db, userTable, {
|
|
1271
1276
|
id: String(aliceUser.id),
|
|
1272
1277
|
tenantId: tenantA,
|
|
1273
|
-
email: "" as string, // empty string — lookupUserEmail
|
|
1278
|
+
email: "" as string, // empty string — lookupUserEmail returns null
|
|
1274
1279
|
passwordHash: "h",
|
|
1275
1280
|
displayName: "Alice",
|
|
1276
1281
|
locale: "de",
|
|
@@ -282,6 +282,22 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
282
282
|
// getJob undefined und die Loeschung laeuft nach Grace-Ablauf NIE. Wir
|
|
283
283
|
// treiben den ECHTEN registrierten Job durch den echten JobContext (db +
|
|
284
284
|
// registry, kein config) — nicht den inneren runForgetCleanup-Helper.
|
|
285
|
+
function capturingJobLog() {
|
|
286
|
+
const warns: string[] = [];
|
|
287
|
+
const log: JobContext["log"] = {
|
|
288
|
+
info() {},
|
|
289
|
+
warn(message: string) {
|
|
290
|
+
warns.push(message);
|
|
291
|
+
},
|
|
292
|
+
error() {},
|
|
293
|
+
debug() {},
|
|
294
|
+
child(): JobContext["log"] {
|
|
295
|
+
return this;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
return { log, warns };
|
|
299
|
+
}
|
|
300
|
+
|
|
285
301
|
test("registered job exists + erases through the real JobContext", async () => {
|
|
286
302
|
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
287
303
|
expect(job).toBeTruthy();
|
|
@@ -295,10 +311,8 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
295
311
|
});
|
|
296
312
|
await seedMembership(CRON_USER, TENANT_A);
|
|
297
313
|
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
// systemUser/log/triggeredBy) — double-cast at this test boundary since
|
|
301
|
-
// the handler under test never touches those fields.
|
|
314
|
+
const { log } = capturingJobLog();
|
|
315
|
+
const jobCtx = { db: stack.db, registry: stack.registry, log };
|
|
302
316
|
await job?.handler({}, jobCtx as unknown as JobContext);
|
|
303
317
|
|
|
304
318
|
const row = await fetchUser(CRON_USER);
|
|
@@ -306,6 +320,52 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
306
320
|
expect(row?.email).not.toContain("cron-delete@example.com");
|
|
307
321
|
expect(row?.email).toContain("anonymized.invalid");
|
|
308
322
|
});
|
|
323
|
+
|
|
324
|
+
test("incomplete hooks are written to ctx.log (job-run audit trail, #1572)", async () => {
|
|
325
|
+
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
326
|
+
expect(job).toBeTruthy();
|
|
327
|
+
|
|
328
|
+
await seedUser(ALICE_ID, {
|
|
329
|
+
status: USER_STATUS.DeletionRequested,
|
|
330
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
331
|
+
});
|
|
332
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
333
|
+
|
|
334
|
+
const usages = stack.registry.getExtensionUsages("userData");
|
|
335
|
+
const userUsage = usages.find((u) => u.entityName === "user");
|
|
336
|
+
if (!userUsage?.options) throw new Error("user usage not found");
|
|
337
|
+
type DeleteHook = (
|
|
338
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
339
|
+
strategy: string,
|
|
340
|
+
) => Promise<undefined | { status: "ok" } | { status: "incomplete"; reason: string }>;
|
|
341
|
+
const originalUserDelete = (userUsage.options as { delete: DeleteHook }).delete;
|
|
342
|
+
(userUsage.options as { delete: DeleteHook }).delete = async (ctx, strategy) => {
|
|
343
|
+
if (ctx.userId === ALICE_ID) {
|
|
344
|
+
return { status: "incomplete", reason: "synthetic partial cleanup for alice" };
|
|
345
|
+
}
|
|
346
|
+
return originalUserDelete(ctx, strategy);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const { log, warns } = capturingJobLog();
|
|
350
|
+
try {
|
|
351
|
+
await job?.handler({}, {
|
|
352
|
+
db: stack.db,
|
|
353
|
+
registry: stack.registry,
|
|
354
|
+
log,
|
|
355
|
+
} as unknown as JobContext);
|
|
356
|
+
} finally {
|
|
357
|
+
(userUsage.options as { delete: DeleteHook }).delete = originalUserDelete;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const hit = warns.find((w) => w.includes("incomplete hook") && w.includes(ALICE_ID));
|
|
361
|
+
expect(hit).toBeDefined();
|
|
362
|
+
expect(hit).toContain("entityName=user");
|
|
363
|
+
expect(hit).toContain("rolledBack=false");
|
|
364
|
+
expect(hit).toContain("reason=synthetic partial cleanup for alice");
|
|
365
|
+
|
|
366
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
367
|
+
expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
|
|
368
|
+
});
|
|
309
369
|
});
|
|
310
370
|
|
|
311
371
|
describe("runForgetCleanup :: time-window guards", () => {
|
|
@@ -798,6 +858,7 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
798
858
|
expect(aliceIncomplete?.reason).toBe("synthetic partial cleanup for alice");
|
|
799
859
|
expect(aliceIncomplete?.tenantId).toBe(TENANT_A);
|
|
800
860
|
expect(aliceIncomplete?.entityName).toBe("user");
|
|
861
|
+
expect(aliceIncomplete?.rolledBack).toBe(false);
|
|
801
862
|
|
|
802
863
|
// Status flip proceeded despite the incomplete report.
|
|
803
864
|
const aliceRow = await fetchUser(ALICE_ID);
|
|
@@ -814,7 +875,7 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
814
875
|
}
|
|
815
876
|
});
|
|
816
877
|
|
|
817
|
-
test("hook incomplete on one tenant, then throws on a later tenant → the incomplete report
|
|
878
|
+
test("hook incomplete on one tenant, then throws on a later tenant → the incomplete report survives, flagged rolledBack (#1489)", async () => {
|
|
818
879
|
await seedUser(ALICE_ID, {
|
|
819
880
|
status: USER_STATUS.DeletionRequested,
|
|
820
881
|
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
@@ -857,9 +918,14 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
857
918
|
now: NOW(),
|
|
858
919
|
});
|
|
859
920
|
|
|
860
|
-
// The whole per-user sub-tx rolled back —
|
|
861
|
-
//
|
|
862
|
-
|
|
921
|
+
// The whole per-user sub-tx rolled back — the DB rows (including the
|
|
922
|
+
// status flip) are undone, and the failure surfaces as a hard error
|
|
923
|
+
// too. But tenant A's "incomplete" report describes an external,
|
|
924
|
+
// non-transactional side effect (already ran, not rolled back by a
|
|
925
|
+
// DB rollback) — it must still reach the operator, just marked
|
|
926
|
+
// rolledBack so they know the user itself is still DeletionRequested.
|
|
927
|
+
const aliceIncomplete = result.incomplete.find((i) => i.userId === ALICE_ID);
|
|
928
|
+
expect(aliceIncomplete?.rolledBack).toBe(true);
|
|
863
929
|
expect(result.errors.some((e) => e.userId === ALICE_ID)).toBe(true);
|
|
864
930
|
expect(result.processedUserIds).not.toContain(ALICE_ID);
|
|
865
931
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// kumiko-framework#1525: selectUsersDueForForgetCleanup must not rely on
|
|
2
|
+
// globalThis.Temporal when the caller passes a string cutoff (the
|
|
3
|
+
// run-forget-cleanup.integration.test.ts suite exercises the full
|
|
4
|
+
// pipeline; this is the narrow seam for the ambient-global regression).
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
setupTestStack,
|
|
9
|
+
type TestStack,
|
|
10
|
+
unsafeCreateEntityTable,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
+
import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
14
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../../../user";
|
|
15
|
+
import { selectUsersDueForForgetCleanup } from "../forget-cleanup";
|
|
16
|
+
|
|
17
|
+
let stack: TestStack;
|
|
18
|
+
|
|
19
|
+
const TENANT = "00000000-0000-4000-8000-00000000000a";
|
|
20
|
+
const USER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-000000000001";
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
stack = await setupTestStack({ features: [createUserFeature()] });
|
|
24
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await stack.cleanup();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
await resetTestTables(stack.db, [userTable]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("selectUsersDueForForgetCleanup — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
36
|
+
test("string cutoff resolves without relying on globalThis.Temporal", async () => {
|
|
37
|
+
const pastGracePeriodEnd = getTemporal().Now.instant().subtract({ hours: 1 });
|
|
38
|
+
await seedRow(stack.db, userTable, {
|
|
39
|
+
id: USER_ID,
|
|
40
|
+
tenantId: TENANT,
|
|
41
|
+
email: "due@example.com",
|
|
42
|
+
passwordHash: "hashed",
|
|
43
|
+
displayName: "Due User",
|
|
44
|
+
locale: "de",
|
|
45
|
+
emailVerified: true,
|
|
46
|
+
roles: '["Member"]',
|
|
47
|
+
status: USER_STATUS.DeletionRequested,
|
|
48
|
+
gracePeriodEnd: pastGracePeriodEnd,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Computed with Temporal intact — only the query call itself runs
|
|
52
|
+
// ambient-free below.
|
|
53
|
+
const cutoffIso = getTemporal().Now.instant().toString();
|
|
54
|
+
|
|
55
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
56
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
57
|
+
try {
|
|
58
|
+
const due = await selectUsersDueForForgetCleanup(
|
|
59
|
+
stack.db,
|
|
60
|
+
USER_STATUS.DeletionRequested,
|
|
61
|
+
cutoffIso,
|
|
62
|
+
);
|
|
63
|
+
expect(due.map((u) => u.id)).toContain(USER_ID);
|
|
64
|
+
} finally {
|
|
65
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
66
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
// Value-only import, aliased to avoid shadowing the ambient global
|
|
4
|
+
// `Temporal` TYPE that gracePeriodEndCutoff's parameter type resolves
|
|
5
|
+
// against (same #1438 dual-package-hazard pattern as event-store.ts).
|
|
6
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
3
7
|
import { userTable } from "../../../user";
|
|
4
8
|
|
|
5
9
|
export async function selectUsersDueForForgetCleanup(
|
|
6
10
|
db: DbConnection,
|
|
7
11
|
status: string,
|
|
8
|
-
gracePeriodEndCutoff:
|
|
12
|
+
gracePeriodEndCutoff: InstanceType<typeof TemporalPolyfill.Instant> | string,
|
|
9
13
|
): Promise<readonly { id: string }[]> {
|
|
14
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
15
|
+
// at runtime, distinct nominal types across the two .d.ts sources (see
|
|
16
|
+
// event-store.ts). Only ever consumed via the `lte` comparison below.
|
|
10
17
|
const cutoff =
|
|
11
18
|
typeof gracePeriodEndCutoff === "string"
|
|
12
|
-
?
|
|
19
|
+
? TemporalPolyfill.Instant.from(gracePeriodEndCutoff)
|
|
13
20
|
: gracePeriodEndCutoff;
|
|
14
21
|
return selectMany<{ id: string }>(db, userTable, {
|
|
15
22
|
status,
|
|
@@ -45,7 +45,11 @@ import {
|
|
|
45
45
|
type SendExportFailedEmailFn,
|
|
46
46
|
type SendExportReadyEmailFn,
|
|
47
47
|
} from "./run-export-jobs";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
runForgetCleanup,
|
|
50
|
+
type SendDeletionExecutedEmailFn,
|
|
51
|
+
sanitizeReasonForLog,
|
|
52
|
+
} from "./run-forget-cleanup";
|
|
49
53
|
import { downloadAttemptEntity } from "./schema/download-attempt";
|
|
50
54
|
import { exportDownloadTokenEntity } from "./schema/download-token";
|
|
51
55
|
import { exportJobEntity } from "./schema/export-job";
|
|
@@ -297,9 +301,13 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
297
301
|
// external script reads the fragment client-side and exchanges the
|
|
298
302
|
// token via POST for the signed URL.
|
|
299
303
|
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
304
|
+
// No `?token=` query fallback: framework is zero-legacy (#1512) — no
|
|
305
|
+
// live customer data / no pre-#1271 emails in flight that would need
|
|
306
|
+
// bridging, so there's nothing a fallback would need to cover. A query
|
|
307
|
+
// param reaches the server as part of the request URL and gets written
|
|
308
|
+
// to proxy/access logs regardless of what the handler does with it
|
|
309
|
+
// afterward (a redirect doesn't undo an already-logged request) — the
|
|
310
|
+
// only leak-proof fix is to never accept the token that way.
|
|
303
311
|
//
|
|
304
312
|
// Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace nicht
|
|
305
313
|
// claimen darf (reserved fuer write/query/batch/auth/sse-dispatcher).
|
|
@@ -312,25 +320,10 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
312
320
|
method: "GET",
|
|
313
321
|
path: "/user-export/by-token",
|
|
314
322
|
anonymous: true,
|
|
315
|
-
handler: async (c
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
|
|
320
|
-
"content-type": "text/html; charset=utf-8",
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
const queryRes = await app.fetch(
|
|
324
|
-
new Request(`${url.origin}/api/query`, {
|
|
325
|
-
method: "POST",
|
|
326
|
-
headers: { "content-type": "application/json" },
|
|
327
|
-
body: JSON.stringify({
|
|
328
|
-
type: "user-data-rights:query:download-by-token",
|
|
329
|
-
payload: { token, auditMeta: extractAuditMeta(c.req.raw.headers) },
|
|
330
|
-
}),
|
|
331
|
-
}),
|
|
332
|
-
);
|
|
333
|
-
return mapQueryResponseToRedirect(c, queryRes);
|
|
323
|
+
handler: async (c) => {
|
|
324
|
+
return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
|
|
325
|
+
"content-type": "text/html; charset=utf-8",
|
|
326
|
+
});
|
|
334
327
|
},
|
|
335
328
|
});
|
|
336
329
|
|
|
@@ -374,13 +367,26 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
374
367
|
if (typeof token !== "string" || token.length === 0) {
|
|
375
368
|
return c.json({ error: "missing_token" }, 400);
|
|
376
369
|
}
|
|
370
|
+
const auditMeta = extractAuditMeta(c.req.raw.headers);
|
|
377
371
|
return app.fetch(
|
|
378
372
|
new Request(`${url.origin}/api/query`, {
|
|
379
373
|
method: "POST",
|
|
380
|
-
headers: {
|
|
374
|
+
headers: {
|
|
375
|
+
"content-type": "application/json",
|
|
376
|
+
// download-by-token.query.ts's `rateLimit: { per: "ip", limit: 30 }`
|
|
377
|
+
// keys off request-id-middleware's own x-forwarded-for read on
|
|
378
|
+
// THIS internal request — without forwarding it, every fragment-
|
|
379
|
+
// POST download shares the request's own (empty/localhost) IP,
|
|
380
|
+
// turning the per-user 30/min backstop into one global bucket
|
|
381
|
+
// (#1307). Forwarding the same value extractAuditMeta already
|
|
382
|
+
// computed from the original request's headers doesn't widen the
|
|
383
|
+
// trust boundary: it's the identical XFF-trusts-first-hop model
|
|
384
|
+
// request-id-middleware.ts already applies framework-wide.
|
|
385
|
+
...(auditMeta.ip ? { "x-forwarded-for": auditMeta.ip } : {}),
|
|
386
|
+
},
|
|
381
387
|
body: JSON.stringify({
|
|
382
388
|
type: "user-data-rights:query:download-by-token",
|
|
383
|
-
payload: { token, auditMeta
|
|
389
|
+
payload: { token, auditMeta },
|
|
384
390
|
}),
|
|
385
391
|
}),
|
|
386
392
|
);
|
|
@@ -513,7 +519,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
513
519
|
? makeDefaultDeletionExecutedEmail(forgetMailResolver, opts.mailDefaults)
|
|
514
520
|
: undefined);
|
|
515
521
|
|
|
516
|
-
await runForgetCleanup({
|
|
522
|
+
const forgetResult = await runForgetCleanup({
|
|
517
523
|
db: forgetDb,
|
|
518
524
|
registry: forgetRegistry,
|
|
519
525
|
now: T.Now.instant(),
|
|
@@ -530,30 +536,24 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
530
536
|
}),
|
|
531
537
|
...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
|
|
532
538
|
});
|
|
539
|
+
|
|
540
|
+
// Persist each incomplete report on the job-run log (#1572). The write
|
|
541
|
+
// handler returns incomplete[] in its response; the cron path used to
|
|
542
|
+
// discard the array (only a console.warn for rolledBack cases).
|
|
543
|
+
// ctx.log feeds createJobRunLogger → store_job_run_logs.
|
|
544
|
+
for (const entry of forgetResult.incomplete) {
|
|
545
|
+
ctx.log?.warn?.(
|
|
546
|
+
`[user-data-rights:run-forget-cleanup] incomplete hook ` +
|
|
547
|
+
`userId=${entry.userId} tenantId=${entry.tenantId} ` +
|
|
548
|
+
`entityName=${entry.entityName} rolledBack=${entry.rolledBack} ` +
|
|
549
|
+
`reason=${sanitizeReasonForLog(entry.reason)}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
533
552
|
},
|
|
534
553
|
);
|
|
535
554
|
});
|
|
536
555
|
}
|
|
537
556
|
|
|
538
|
-
// Map /api/query-Response auf 302-Redirect oder Error-Passthrough.
|
|
539
|
-
async function mapQueryResponseToRedirect(
|
|
540
|
-
c: import("hono").Context,
|
|
541
|
-
queryRes: Response,
|
|
542
|
-
): Promise<Response> {
|
|
543
|
-
if (!queryRes.ok) {
|
|
544
|
-
const errorBody = await queryRes.text();
|
|
545
|
-
const statusCode = queryRes.status as 400 | 401 | 404 | 410 | 500; // @cast-boundary engine-payload
|
|
546
|
-
return c.body(errorBody, statusCode, {
|
|
547
|
-
"content-type": queryRes.headers.get("content-type") ?? "application/json",
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
const body = (await queryRes.json()) as { data?: { url?: string } }; // @cast-boundary engine-payload
|
|
551
|
-
if (!body.data?.url) {
|
|
552
|
-
return c.json({ error: "download_resolution_failed" }, 500);
|
|
553
|
-
}
|
|
554
|
-
return c.redirect(body.data.url, 302);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
557
|
// Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
|
|
558
558
|
// query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
|
|
559
559
|
// raw-request gesehen, nicht der direkter /api/query-Caller. User der
|
|
@@ -2,9 +2,12 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { isWithinGracePeriod } from "../../shared";
|
|
5
6
|
import { USER_STATUS, userTable } from "../../user";
|
|
6
7
|
import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
7
8
|
|
|
9
|
+
export { isWithinGracePeriod };
|
|
10
|
+
|
|
8
11
|
// POST /api/user/cancel-deletion (S2.U5).
|
|
9
12
|
//
|
|
10
13
|
// Innerhalb der Grace-Period kann User seinen Forget-Antrag zurueck-
|
|
@@ -24,11 +27,10 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
24
27
|
// ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
|
|
25
28
|
// ist — siehe request-deletion.write.ts fuer die Begruendung. Cancel
|
|
26
29
|
// muss aus jedem Tenant-Mode den User finden + zuruecksetzen koennen.
|
|
27
|
-
const row = await fetchOne<{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
);
|
|
30
|
+
const row = await fetchOne<{
|
|
31
|
+
status: string;
|
|
32
|
+
gracePeriodEnd: Temporal.Instant | null;
|
|
33
|
+
}>(ctx.db.raw, userTable, { id: event.user.id });
|
|
32
34
|
|
|
33
35
|
if (!row) {
|
|
34
36
|
return writeFailure(
|
|
@@ -49,12 +51,7 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
49
51
|
);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
const inGrace =
|
|
54
|
-
gracePeriodEnd != null &&
|
|
55
|
-
Temporal.Instant.compare(gracePeriodEnd, Temporal.Now.instant()) > 0;
|
|
56
|
-
|
|
57
|
-
if (!inGrace) {
|
|
54
|
+
if (!isWithinGracePeriod(row.gracePeriodEnd)) {
|
|
58
55
|
return writeFailure(
|
|
59
56
|
new UnprocessableError("grace_period_expired", {
|
|
60
57
|
details: { reason: "grace_period_expired" },
|