@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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/CHANGELOG.md +91 -0
- package/package.json +22 -13
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +32 -2
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +34 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +44 -4
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +10 -12
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +90 -1
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +42 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +23 -13
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +310 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +333 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineFeature,
|
|
3
|
+
EXT_USER_DATA,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
SYSTEM_USER_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { createFileProviderForTenant } from "../file-foundation";
|
|
8
|
+
import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
|
|
9
|
+
import { downloadByJobQuery } from "./handlers/download-by-job.query";
|
|
10
|
+
import { downloadByTokenQuery } from "./handlers/download-by-token.query";
|
|
11
|
+
import { exportStatusQuery } from "./handlers/export-status.query";
|
|
12
|
+
import { liftRestrictionWrite } from "./handlers/lift-restriction.write";
|
|
13
|
+
import { listDownloadAttemptsQuery } from "./handlers/list-download-attempts.query";
|
|
14
|
+
import { myAuditLogQuery } from "./handlers/my-audit-log.query";
|
|
15
|
+
import {
|
|
16
|
+
createRequestDeletionHandler,
|
|
17
|
+
type SendDeletionRequestedEmailFn,
|
|
18
|
+
} from "./handlers/request-deletion.write";
|
|
19
|
+
import { requestExportWrite } from "./handlers/request-export.write";
|
|
20
|
+
import { restrictAccountWrite } from "./handlers/restrict-account.write";
|
|
21
|
+
import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
|
|
22
|
+
import {
|
|
23
|
+
runExportJobs,
|
|
24
|
+
type SendExportFailedEmailFn,
|
|
25
|
+
type SendExportReadyEmailFn,
|
|
26
|
+
} from "./run-export-jobs";
|
|
27
|
+
import type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
|
|
28
|
+
import { downloadAttemptEntity } from "./schema/download-attempt";
|
|
29
|
+
import { exportDownloadTokenEntity } from "./schema/download-token";
|
|
30
|
+
import { exportJobEntity } from "./schema/export-job";
|
|
31
|
+
|
|
32
|
+
// user-data-rights — DSGVO Art. 15 (Auskunft) + Art. 17 (Löschung) +
|
|
33
|
+
// Art. 18 (Restriction) + Art. 20 (Portabilität) als Core-Feature.
|
|
34
|
+
//
|
|
35
|
+
// Pattern (Plan-Roadmap docs/plans/datenschutz/user-data-rights.md):
|
|
36
|
+
// Statt jedes Feature seine eigene Forget-/Export-Logik schreibt,
|
|
37
|
+
// haengt es sich via `r.useExtension(EXT_USER_DATA, "<entity>", {
|
|
38
|
+
// export, delete })` an. user-data-rights orchestriert:
|
|
39
|
+
// - Export-Job: iteriert alle Extension-Registrierungen, sammelt JSON
|
|
40
|
+
// - Forget-Job: iteriert alle, ruft delete-Hook mit Strategy aus
|
|
41
|
+
// retention.policyFor (data-retention)
|
|
42
|
+
// - Restriction: status-Flip auf user-Schema, Auth-Middleware-Guard
|
|
43
|
+
/**
|
|
44
|
+
* Options fuer createUserDataRightsFeature. Notification-Callbacks
|
|
45
|
+
* (Atom 5) folgen dem password-reset-Pattern aus auth-routes.ts.
|
|
46
|
+
*
|
|
47
|
+
* Plain-Token landet NIE in DB/event-store/jobRunsTable — Worker reicht
|
|
48
|
+
* ihn ephemeral via Callback-arg an die App-Author-Implementation
|
|
49
|
+
* (typisch: `delivery.notify(...)` mit `r.notification`-Templates,
|
|
50
|
+
* `mailFoundation.send` direkt, oder Custom Resend/SES).
|
|
51
|
+
*
|
|
52
|
+
* Worker-Run-Tracking + Retry kommt automatisch via existing
|
|
53
|
+
* `jobs`-Feature (siehe CLAUDE.md "Bundled-Features by Concern").
|
|
54
|
+
*/
|
|
55
|
+
export type UserDataRightsOptions = {
|
|
56
|
+
/** Email-Notification beim Export-done. App-Author wired das an seinen
|
|
57
|
+
* Email-Provider. Best-effort (Atom 5.fix3): send-Throw fuer Job A
|
|
58
|
+
* killt den Batch nicht — restliche pending-Jobs werden weiter
|
|
59
|
+
* verarbeitet, Failure wird via console.warn sichtbar. (Vorher
|
|
60
|
+
* bubbelte der Throw zum r.job-Wrap, was bei mehreren pending Jobs
|
|
61
|
+
* zum silent-miss fuehrte: Job A done committed, B/C/D nie
|
|
62
|
+
* verarbeitet, retry findet niemand.) */
|
|
63
|
+
readonly sendExportReadyEmail?: SendExportReadyEmailFn;
|
|
64
|
+
/** Email-Notification beim Export-failed. Best-effort analog
|
|
65
|
+
* sendExportReadyEmail. */
|
|
66
|
+
readonly sendExportFailedEmail?: SendExportFailedEmailFn;
|
|
67
|
+
/** Base-URL fuer den Magic-Link, z.B.
|
|
68
|
+
* "https://app.example.com/user-export/by-token". Worker bauen
|
|
69
|
+
* `${appExportDownloadUrl}?token=<plain>`. Required wenn
|
|
70
|
+
* sendExportReadyEmail gesetzt. Per-Tenant via reverse-proxy host
|
|
71
|
+
* routing — nicht via per-Tenant-config-key (App-Author-Decision). */
|
|
72
|
+
readonly appExportDownloadUrl?: string;
|
|
73
|
+
/** Atom 5b — Email-Notification beim deletion-requested-flip
|
|
74
|
+
* ("Account-Loeschung in 30 Tagen"). Best-effort: send-failure killt
|
|
75
|
+
* den Status-Flip nicht (siehe handlers/request-deletion.write.ts). */
|
|
76
|
+
readonly sendDeletionRequestedEmail?: SendDeletionRequestedEmailFn;
|
|
77
|
+
/** Atom 5b — Email-Notification beim Cleanup-Runner-done-Pfad
|
|
78
|
+
* ("Account wurde geloescht"). Best-effort. Der Versand passiert NACH
|
|
79
|
+
* dem User-Hook-Anonymisieren, deshalb cached der Worker
|
|
80
|
+
* userEmail+tenantIds PRE-tx und reicht sie ephemeral an die
|
|
81
|
+
* Callback-Implementation (siehe run-forget-cleanup.ts). */
|
|
82
|
+
readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
|
|
86
|
+
return defineFeature("user-data-rights", (r) => {
|
|
87
|
+
r.requires("user", "data-retention", "compliance-profiles");
|
|
88
|
+
r.usesApi("compliance.forTenant");
|
|
89
|
+
r.usesApi("retention.policyFor");
|
|
90
|
+
// S2.U6 — restrict-account ruft sessions.revokeAllForUser cross-feature.
|
|
91
|
+
// r.usesApi sorgt fuer Boot-Validation: App ohne sessions-feature wirft
|
|
92
|
+
// beim Boot, statt erst beim ersten Restrict-Call ein opaque "handler
|
|
93
|
+
// not found" zu werfen.
|
|
94
|
+
r.usesApi("sessions.revokeAllForUser");
|
|
95
|
+
// file-foundation ist soft-dep: nur der Export-Worker (Atom 3b)
|
|
96
|
+
// braucht ihn fuer Storage-Schreiben. Apps die nur Forget nutzen
|
|
97
|
+
// (kein Export) muessen file-foundation nicht mounten.
|
|
98
|
+
|
|
99
|
+
r.extendsRegistrar(EXT_USER_DATA, {});
|
|
100
|
+
|
|
101
|
+
// S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
|
|
102
|
+
r.entity("export-job", exportJobEntity);
|
|
103
|
+
|
|
104
|
+
// S2.U3 Atom 4a — Download-Token-Entity. Worker generiert Token beim
|
|
105
|
+
// Flip auf done (siehe run-export-jobs.ts). Hash in DB, plain im
|
|
106
|
+
// RunExportJobsResult fuer Atom 5 (Notification per Email).
|
|
107
|
+
r.entity("export-download-token", exportDownloadTokenEntity);
|
|
108
|
+
|
|
109
|
+
// S2.U7 — Audit-Trail invalid Download-Attempts (DPO Brute-Force-Detection).
|
|
110
|
+
r.entity("download-attempt", downloadAttemptEntity);
|
|
111
|
+
|
|
112
|
+
// S2.U6 — DSGVO Art. 18 Account-Freeze (Verarbeitungs-Pause).
|
|
113
|
+
// Endpoints fuer Restrict + Lift. Login-Block fuer Restricted Users
|
|
114
|
+
// lebt in auth-email-password/login.write.ts.
|
|
115
|
+
r.writeHandler(restrictAccountWrite);
|
|
116
|
+
r.writeHandler(liftRestrictionWrite);
|
|
117
|
+
|
|
118
|
+
// S2.U5a — Endpoints fuer DSGVO Art. 17 Forget-Pfad mit Grace.
|
|
119
|
+
r.writeHandler(
|
|
120
|
+
createRequestDeletionHandler(
|
|
121
|
+
opts.sendDeletionRequestedEmail
|
|
122
|
+
? { sendDeletionRequestedEmail: opts.sendDeletionRequestedEmail }
|
|
123
|
+
: {},
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
r.writeHandler(cancelDeletionWrite);
|
|
127
|
+
|
|
128
|
+
// S2.U5b — Cleanup-Runner als privileged-Handler. Atom 5b: Wenn
|
|
129
|
+
// sendDeletionExecutedEmail gesetzt, reicht der Handler den Callback
|
|
130
|
+
// an runForgetCleanup weiter (Worker cached userEmail+tenantIds
|
|
131
|
+
// PRE-tx, siehe run-forget-cleanup.ts).
|
|
132
|
+
r.writeHandler(
|
|
133
|
+
createRunForgetCleanupHandler(
|
|
134
|
+
opts.sendDeletionExecutedEmail
|
|
135
|
+
? { sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail }
|
|
136
|
+
: {},
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
r.exposesApi("userDataRights.runForget");
|
|
140
|
+
|
|
141
|
+
// S2.U3 Atom 2 — User-Touchpoints fuer Async Export-Pipeline.
|
|
142
|
+
r.writeHandler(requestExportWrite);
|
|
143
|
+
r.queryHandler(exportStatusQuery);
|
|
144
|
+
r.exposesApi("userDataRights.runExport");
|
|
145
|
+
|
|
146
|
+
// S2.U3 Atom 4b — Download-Endpoints (Token-Pfad + Session-Pfad).
|
|
147
|
+
// Beide query-handlers verifizieren Token/Session, holen signed-URL
|
|
148
|
+
// vom Storage-Provider, und persistieren Audit-Felder am Token-Row.
|
|
149
|
+
// r.httpRoute-Wrapper unten machen den 302-Redirect zu signedUrl.
|
|
150
|
+
r.queryHandler(downloadByTokenQuery);
|
|
151
|
+
r.queryHandler(downloadByJobQuery);
|
|
152
|
+
|
|
153
|
+
// S2.U7 — User-Selbstauskunft (Art. 15) + Operator-Sicht auf
|
|
154
|
+
// invalid Download-Attempts (DPO).
|
|
155
|
+
r.queryHandler(myAuditLogQuery);
|
|
156
|
+
r.queryHandler(listDownloadAttemptsQuery);
|
|
157
|
+
|
|
158
|
+
// r.httpRoute-Wrapper: Magic-Link-Pfad (anonymous) + UI-Klick-Pfad.
|
|
159
|
+
//
|
|
160
|
+
// Beide rufen via app.fetch /api/query → wenn success: 302-Redirect
|
|
161
|
+
// zur signed-URL → Browser folgt → Download startet beim Object-Store.
|
|
162
|
+
// Bei error: passthrough (404/410/501) als JSON.
|
|
163
|
+
//
|
|
164
|
+
// **Token-Pfad (anonymous):** GET /user-export/by-token?token=<plain>
|
|
165
|
+
//
|
|
166
|
+
// Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace
|
|
167
|
+
// nicht claimen darf (reserved fuer write/query/batch/auth/sse-
|
|
168
|
+
// dispatcher).
|
|
169
|
+
r.httpRoute({
|
|
170
|
+
method: "GET",
|
|
171
|
+
path: "/user-export/by-token",
|
|
172
|
+
anonymous: true,
|
|
173
|
+
handler: async (c, { app }) => {
|
|
174
|
+
const url = new URL(c.req.url);
|
|
175
|
+
const token = url.searchParams.get("token");
|
|
176
|
+
if (!token) {
|
|
177
|
+
return c.json({ error: "missing_token" }, 400);
|
|
178
|
+
}
|
|
179
|
+
const queryRes = await app.fetch(
|
|
180
|
+
new Request(`${url.origin}/api/query`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "content-type": "application/json" },
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
type: "user-data-rights:query:download-by-token",
|
|
185
|
+
payload: { token, auditMeta: extractAuditMeta(c.req.raw.headers) },
|
|
186
|
+
}),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
return mapQueryResponseToRedirect(c, queryRes);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// **Session-Pfad (auth):** GET /user-export/by-job/:jobId
|
|
194
|
+
r.httpRoute({
|
|
195
|
+
method: "GET",
|
|
196
|
+
path: "/user-export/by-job/:jobId",
|
|
197
|
+
handler: async (c, { app }) => {
|
|
198
|
+
const url = new URL(c.req.url);
|
|
199
|
+
const jobId = c.req.param("jobId");
|
|
200
|
+
if (!jobId) {
|
|
201
|
+
return c.json({ error: "missing_job_id" }, 400);
|
|
202
|
+
}
|
|
203
|
+
const queryRes = await app.fetch(
|
|
204
|
+
new Request(`${url.origin}/api/query`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"content-type": "application/json",
|
|
208
|
+
...forwardAuthHeaders(c.req.raw.headers),
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
type: "user-data-rights:query:download-by-job",
|
|
212
|
+
payload: { jobId, auditMeta: extractAuditMeta(c.req.raw.headers) },
|
|
213
|
+
}),
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
return mapQueryResponseToRedirect(c, queryRes);
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
|
|
221
|
+
r.job(
|
|
222
|
+
"run-export-jobs",
|
|
223
|
+
{ trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
|
|
224
|
+
async (_payload, ctx) => {
|
|
225
|
+
if (!ctx.db || !ctx.registry) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
"run-export-jobs: ctx.db + ctx.registry required (JobContext incomplete)",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
231
|
+
// SYSTEM_USER_ID ist die framework-weite Konvention. Der job-
|
|
232
|
+
// Discriminator wird via handlerName="user-data-rights:run-export-
|
|
233
|
+
// jobs" im Secret-Read-Audit erfasst.
|
|
234
|
+
const providerCtx = {
|
|
235
|
+
config: ctx.config,
|
|
236
|
+
registry: ctx.registry,
|
|
237
|
+
secrets: ctx.secrets,
|
|
238
|
+
_userId: ctx._userId ?? SYSTEM_USER_ID,
|
|
239
|
+
};
|
|
240
|
+
await runExportJobs({
|
|
241
|
+
db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection, // @cast-boundary db-operator
|
|
242
|
+
registry: ctx.registry,
|
|
243
|
+
buildStorageProvider: async (tenantId) =>
|
|
244
|
+
createFileProviderForTenant(providerCtx, tenantId, "user-data-rights:run-export-jobs"),
|
|
245
|
+
now: T.Now.instant(),
|
|
246
|
+
// Atom 5 — App-Author-Callbacks fuer Email-Notification.
|
|
247
|
+
// Optional: wenn nicht gesetzt, kein Email; User pollt
|
|
248
|
+
// export-status.query + UI-Klick.
|
|
249
|
+
...(opts.sendExportReadyEmail && {
|
|
250
|
+
sendExportReadyEmail: opts.sendExportReadyEmail,
|
|
251
|
+
}),
|
|
252
|
+
...(opts.sendExportFailedEmail && {
|
|
253
|
+
sendExportFailedEmail: opts.sendExportFailedEmail,
|
|
254
|
+
}),
|
|
255
|
+
...(opts.appExportDownloadUrl !== undefined && {
|
|
256
|
+
appExportDownloadUrl: opts.appExportDownloadUrl,
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Map /api/query-Response auf 302-Redirect oder Error-Passthrough.
|
|
265
|
+
async function mapQueryResponseToRedirect(
|
|
266
|
+
c: import("hono").Context,
|
|
267
|
+
queryRes: Response,
|
|
268
|
+
): Promise<Response> {
|
|
269
|
+
if (!queryRes.ok) {
|
|
270
|
+
const errorBody = await queryRes.text();
|
|
271
|
+
const statusCode = queryRes.status as 400 | 401 | 404 | 410 | 500; // @cast-boundary engine-payload
|
|
272
|
+
return c.body(errorBody, statusCode, {
|
|
273
|
+
"content-type": queryRes.headers.get("content-type") ?? "application/json",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const body = (await queryRes.json()) as { data?: { url?: string } }; // @cast-boundary engine-payload
|
|
277
|
+
if (!body.data?.url) {
|
|
278
|
+
return c.json({ error: "download_resolution_failed" }, 500);
|
|
279
|
+
}
|
|
280
|
+
return c.redirect(body.data.url, 302);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function forwardAuthHeaders(headers: Headers): Record<string, string> {
|
|
284
|
+
const out: Record<string, string> = {};
|
|
285
|
+
const auth = headers.get("authorization");
|
|
286
|
+
if (auth) out["authorization"] = auth;
|
|
287
|
+
const cookie = headers.get("cookie");
|
|
288
|
+
if (cookie) out["cookie"] = cookie;
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
|
|
293
|
+
// query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
|
|
294
|
+
// raw-request gesehen, nicht der direkter /api/query-Caller. User der
|
|
295
|
+
// /api/query direkt mit eigenem auditMeta aufruft kann luegen, aber
|
|
296
|
+
// auditMeta ist nicht security-relevant (operator kann mit server-logs
|
|
297
|
+
// crossreferencen wenn forensik gebraucht).
|
|
298
|
+
function extractAuditMeta(headers: Headers): { ip: string | null; userAgent: string | null } {
|
|
299
|
+
const xff = headers.get("x-forwarded-for");
|
|
300
|
+
let ip: string | null = null;
|
|
301
|
+
if (xff) {
|
|
302
|
+
const first = xff.split(",")[0]?.trim();
|
|
303
|
+
if (first && first.length > 0) ip = first;
|
|
304
|
+
}
|
|
305
|
+
if (!ip) {
|
|
306
|
+
const real = headers.get("x-real-ip");
|
|
307
|
+
if (real && real.length > 0) ip = real;
|
|
308
|
+
}
|
|
309
|
+
return { ip, userAgent: headers.get("user-agent") };
|
|
310
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { eq, sql } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { USER_STATUS, userTable } from "../../user";
|
|
6
|
+
|
|
7
|
+
// POST /api/user/cancel-deletion (S2.U5).
|
|
8
|
+
//
|
|
9
|
+
// Innerhalb der Grace-Period kann User seinen Forget-Antrag zurueck-
|
|
10
|
+
// nehmen. Setzt:
|
|
11
|
+
// - status = "active"
|
|
12
|
+
// - gracePeriodEnd = null
|
|
13
|
+
//
|
|
14
|
+
// Nach Grace-Period: 422 (run-forget-cleanup hat in der Zwischenzeit
|
|
15
|
+
// die Hooks schon getriggert — Reversal nicht moeglich).
|
|
16
|
+
//
|
|
17
|
+
// Sonderfall: Cancel als "active"-User → 422 (kein pending Forget).
|
|
18
|
+
export const cancelDeletionWrite = defineWriteHandler({
|
|
19
|
+
name: "cancel-deletion",
|
|
20
|
+
schema: z.object({}),
|
|
21
|
+
access: { openToAll: true },
|
|
22
|
+
handler: async (event, ctx) => {
|
|
23
|
+
// ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
|
|
24
|
+
// ist — siehe request-deletion.write.ts fuer die Begruendung. Cancel
|
|
25
|
+
// muss aus jedem Tenant-Mode den User finden + zuruecksetzen koennen.
|
|
26
|
+
//
|
|
27
|
+
// Combined query: status + grace_period_end-vs-now in einem Pass.
|
|
28
|
+
const checkRows = await ctx.db.raw
|
|
29
|
+
.select({
|
|
30
|
+
status: userTable["status"],
|
|
31
|
+
inGrace: sql<boolean>`(${userTable["gracePeriodEnd"]} > now())`,
|
|
32
|
+
})
|
|
33
|
+
.from(userTable)
|
|
34
|
+
.where(eq(userTable["id"], event.user.id))
|
|
35
|
+
.limit(1);
|
|
36
|
+
|
|
37
|
+
if (checkRows.length === 0) {
|
|
38
|
+
return writeFailure(
|
|
39
|
+
new UnprocessableError("user_not_found", {
|
|
40
|
+
details: { reason: "user_not_found", userId: event.user.id },
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const row = checkRows[0];
|
|
46
|
+
if (!row || row.status !== USER_STATUS.DeletionRequested) {
|
|
47
|
+
return writeFailure(
|
|
48
|
+
new UnprocessableError("no_pending_deletion", {
|
|
49
|
+
details: {
|
|
50
|
+
reason: "no_pending_deletion",
|
|
51
|
+
currentStatus: row?.status,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!row.inGrace) {
|
|
58
|
+
return writeFailure(
|
|
59
|
+
new UnprocessableError("grace_period_expired", {
|
|
60
|
+
details: { reason: "grace_period_expired" },
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await ctx.db.raw
|
|
66
|
+
.update(userTable)
|
|
67
|
+
.set({
|
|
68
|
+
status: USER_STATUS.Active,
|
|
69
|
+
gracePeriodEnd: null,
|
|
70
|
+
})
|
|
71
|
+
.where(eq(userTable["id"], event.user.id));
|
|
72
|
+
|
|
73
|
+
// gracePeriodEnd=null im Response symmetrisch zu request-deletion's
|
|
74
|
+
// ISO-Timestamp — Frontend kann beide Endpoints uniform behandeln.
|
|
75
|
+
return {
|
|
76
|
+
isSuccess: true as const,
|
|
77
|
+
data: {
|
|
78
|
+
userId: event.user.id,
|
|
79
|
+
status: USER_STATUS.Active,
|
|
80
|
+
gracePeriodEnd: null as string | null, // @cast-boundary generic-record
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// GET /api/query → user-data-rights:query:download-by-job (S2.U3 Atom 4b).
|
|
2
|
+
//
|
|
3
|
+
// **UI-Klick-Pfad** (session-auth): User pollt status, sieht "done",
|
|
4
|
+
// klickt "Download" im UI mit jobId. Server checkt session.userId ==
|
|
5
|
+
// job.userId — kein Token noetig (Session IS the auth).
|
|
6
|
+
//
|
|
7
|
+
// **Cross-Tenant-Same-User:** Job ist tenant-agnostisch (1 Job pro
|
|
8
|
+
// userId ueber alle Memberships). Alice triggert Export aus Tenant A,
|
|
9
|
+
// loggt sich spaeter in Tenant B ein, klickt Download. Server akzeptiert
|
|
10
|
+
// — `session.userId == job.userId` reicht, kein Tenant-Match-Check.
|
|
11
|
+
// Plan-Decision (Atom 4b Plan, User-Choice).
|
|
12
|
+
//
|
|
13
|
+
// **Cross-User-Isolation:** Wenn session.userId != job.userId → 404
|
|
14
|
+
// (NICHT 403, kein Existenz-Leak). Selber error wie "Job-ID nicht
|
|
15
|
+
// gefunden".
|
|
16
|
+
//
|
|
17
|
+
// Verify-Pipeline:
|
|
18
|
+
// 1. fetchOne job by jobId
|
|
19
|
+
// 2. job.userId === session.userId (cross-user-isolation)
|
|
20
|
+
// 3. job.status === "done"
|
|
21
|
+
// 4. job.downloadStorageKey != null
|
|
22
|
+
// 5. tokenRow lookup (audit-row update only)
|
|
23
|
+
// 6. Audit-Update: useCount + 1, IP, UA, lastUsedAt (best-effort)
|
|
24
|
+
// 7. Return {url, expiresAt}
|
|
25
|
+
|
|
26
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
27
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
28
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
+
import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
31
|
+
import { eq } from "drizzle-orm";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { createFileProviderForTenant } from "../../file-foundation";
|
|
34
|
+
import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
|
|
35
|
+
import { exportDownloadTokensTable } from "../schema/download-token";
|
|
36
|
+
import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
|
|
37
|
+
|
|
38
|
+
const SIGNED_URL_TTL_SECONDS = 300; // 5 min — matched download-by-token
|
|
39
|
+
|
|
40
|
+
interface TokenRow {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly version: number;
|
|
43
|
+
readonly useCount: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface JobRow {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly userId: string;
|
|
49
|
+
readonly requestedFromTenantId: string;
|
|
50
|
+
readonly status: string;
|
|
51
|
+
readonly downloadStorageKey: string | null;
|
|
52
|
+
readonly bytesWritten: number | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const downloadByJobQuery = defineQueryHandler({
|
|
56
|
+
name: "download-by-job",
|
|
57
|
+
schema: z.object({
|
|
58
|
+
jobId: z.string().min(1, "jobId required"),
|
|
59
|
+
auditMeta: z
|
|
60
|
+
.object({
|
|
61
|
+
ip: z.string().nullable(),
|
|
62
|
+
userAgent: z.string().nullable(),
|
|
63
|
+
})
|
|
64
|
+
.optional(),
|
|
65
|
+
}),
|
|
66
|
+
access: { openToAll: true }, // openToAll = auth-required, kein anonymous
|
|
67
|
+
handler: async (query, ctx) => {
|
|
68
|
+
const T = getTemporal();
|
|
69
|
+
const now = T.Now.instant();
|
|
70
|
+
const userId = query.user.id;
|
|
71
|
+
const jobId = query.payload.jobId;
|
|
72
|
+
const tenantId = query.user.tenantId;
|
|
73
|
+
const auditIp = query.payload.auditMeta?.ip ?? null;
|
|
74
|
+
const auditUa = query.payload.auditMeta?.userAgent ?? null;
|
|
75
|
+
|
|
76
|
+
// Step 1-2: job-lookup + cross-user-isolation
|
|
77
|
+
// ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
|
|
78
|
+
// aus Tenant A erstellten Job.
|
|
79
|
+
const jobRow = (await fetchOne(
|
|
80
|
+
ctx.db.raw,
|
|
81
|
+
exportJobsTable,
|
|
82
|
+
eq(exportJobsTable["id"], jobId),
|
|
83
|
+
)) as JobRow | null; // @cast-boundary db-row
|
|
84
|
+
|
|
85
|
+
if (!jobRow || jobRow.userId !== userId) {
|
|
86
|
+
await recordInvalidAttempt({
|
|
87
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
88
|
+
tenantId,
|
|
89
|
+
now,
|
|
90
|
+
result: "notFound",
|
|
91
|
+
via: "job",
|
|
92
|
+
tokenHash: null,
|
|
93
|
+
jobId,
|
|
94
|
+
attemptedByUserId: userId,
|
|
95
|
+
ip: auditIp,
|
|
96
|
+
userAgent: auditUa,
|
|
97
|
+
});
|
|
98
|
+
throw new NotFoundError("export-download", jobId, {
|
|
99
|
+
i18nKey: "userDataRights.errors.download.notFound",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
104
|
+
await recordInvalidAttempt({
|
|
105
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
106
|
+
tenantId,
|
|
107
|
+
now,
|
|
108
|
+
result: "failed",
|
|
109
|
+
via: "job",
|
|
110
|
+
tokenHash: null,
|
|
111
|
+
jobId,
|
|
112
|
+
attemptedByUserId: userId,
|
|
113
|
+
ip: auditIp,
|
|
114
|
+
userAgent: auditUa,
|
|
115
|
+
});
|
|
116
|
+
throw new NotFoundError("export-download", jobId, {
|
|
117
|
+
i18nKey: "userDataRights.errors.download.unavailable",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (!jobRow.downloadStorageKey) {
|
|
121
|
+
await recordInvalidAttempt({
|
|
122
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
123
|
+
tenantId,
|
|
124
|
+
now,
|
|
125
|
+
result: "expired",
|
|
126
|
+
via: "job",
|
|
127
|
+
tokenHash: null,
|
|
128
|
+
jobId,
|
|
129
|
+
attemptedByUserId: userId,
|
|
130
|
+
ip: auditIp,
|
|
131
|
+
userAgent: auditUa,
|
|
132
|
+
});
|
|
133
|
+
throw new NotFoundError("export-download", jobId, {
|
|
134
|
+
i18nKey: "userDataRights.errors.download.expired",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const provider = await createFileProviderForTenant(
|
|
139
|
+
ctx,
|
|
140
|
+
jobRow.requestedFromTenantId,
|
|
141
|
+
"user-data-rights:query:download-by-job",
|
|
142
|
+
);
|
|
143
|
+
if (!provider.getSignedUrl) {
|
|
144
|
+
await recordInvalidAttempt({
|
|
145
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
146
|
+
tenantId,
|
|
147
|
+
now,
|
|
148
|
+
result: "signedUrlNotSupported",
|
|
149
|
+
via: "job",
|
|
150
|
+
tokenHash: null,
|
|
151
|
+
jobId,
|
|
152
|
+
attemptedByUserId: userId,
|
|
153
|
+
ip: auditIp,
|
|
154
|
+
userAgent: auditUa,
|
|
155
|
+
});
|
|
156
|
+
throw new UnprocessableError("storage_provider_signed_url_not_supported", {
|
|
157
|
+
i18nKey: "userDataRights.errors.download.signedUrlNotSupported",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const signedUrl = await provider.getSignedUrl(
|
|
162
|
+
jobRow.downloadStorageKey,
|
|
163
|
+
SIGNED_URL_TTL_SECONDS,
|
|
164
|
+
{
|
|
165
|
+
contentDisposition: `attachment; filename="user-data-export-${jobRow.id}.zip"`,
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
const signedUrlExpiresAt = T.Instant.fromEpochMilliseconds(
|
|
169
|
+
now.epochMilliseconds + SIGNED_URL_TTL_SECONDS * 1000,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Step 6: Audit-Update via tokenRow-lookup. UI-Pfad benutzt nicht
|
|
173
|
+
// den plain-Token, aber wir wollen den useCount inkrementieren
|
|
174
|
+
// damit die Audit-Felder konsistent sind (UI-clicks zaehlen auch
|
|
175
|
+
// als Use). Lookup via jobId — UNIQUE-Index garantiert max 1 Row.
|
|
176
|
+
const tokenRow = (await fetchOne(
|
|
177
|
+
ctx.db.raw,
|
|
178
|
+
exportDownloadTokensTable,
|
|
179
|
+
eq(exportDownloadTokensTable["jobId"], jobId),
|
|
180
|
+
)) as TokenRow | null; // @cast-boundary db-row
|
|
181
|
+
|
|
182
|
+
if (tokenRow) {
|
|
183
|
+
await recordDownloadUse({
|
|
184
|
+
db: ctx.db.raw as DbConnection, // @cast-boundary db-runner
|
|
185
|
+
tokenId: tokenRow.id,
|
|
186
|
+
tokenVersion: tokenRow.version,
|
|
187
|
+
tokenUseCount: tokenRow.useCount ?? 0,
|
|
188
|
+
tenantId: jobRow.requestedFromTenantId as Parameters<
|
|
189
|
+
typeof recordDownloadUse
|
|
190
|
+
>[0]["tenantId"], // @cast-boundary engine-bridge
|
|
191
|
+
now,
|
|
192
|
+
ip: query.payload.auditMeta?.ip ?? null,
|
|
193
|
+
userAgent: query.payload.auditMeta?.userAgent ?? null,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// Wenn tokenRow fehlt (sollte nicht passieren wenn Atom 4a sauber
|
|
197
|
+
// lief): Audit-Update skipped, Download laeuft weiter. Niemand wird
|
|
198
|
+
// hier durch Audit-Failure blockiert.
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
url: signedUrl,
|
|
202
|
+
expiresAt: signedUrlExpiresAt.toString(),
|
|
203
|
+
bytesWritten: jobRow.bytesWritten,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
});
|