@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.2.3
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 +31 -0
- package/package.json +11 -5
- 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/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- 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 +63 -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 +146 -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 +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- 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 +33 -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/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- 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 +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- 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 +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -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 +334 -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,111 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createBigIntField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
createTimestampField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
// Export-Download-Token (S2.U3 Atom 4a).
|
|
10
|
+
//
|
|
11
|
+
// Sicherheits-Token fuer den Download-Endpoint (Atom 4b). Pattern:
|
|
12
|
+
// 1. Worker generiert plain-Token (32 byte random base64url) +
|
|
13
|
+
// Hash (SHA256 hex) beim Flip auf done.
|
|
14
|
+
// 2. crud.create(downloadTokenEntity, ...) emittiert
|
|
15
|
+
// `exportDownloadToken.created`-Event in den event-store. DB-Row
|
|
16
|
+
// wird via Projection synchron geschrieben (Marten-Pattern, NICHT
|
|
17
|
+
// direct-INSERT). Memory `feedback_event_store_tenant_consistency`.
|
|
18
|
+
// 3. Plain bleibt im Worker-Memory + wird via RunExportJobsResult an
|
|
19
|
+
// Atom 5 (Notification) weitergegeben. NIEMALS plain in DB.
|
|
20
|
+
// 4. Atom 4b's Download-Endpoint: hashed incoming-Token + sucht den
|
|
21
|
+
// Hash-Eintrag. Konstanter-Zeit-Vergleich gegen timing-attacks.
|
|
22
|
+
//
|
|
23
|
+
// **Multi-use within TTL** (Plan-Decision 4a): Token wird beim Download
|
|
24
|
+
// nicht "consumed". Mehrfach-Download bis expiresAt erlaubt — UX bei
|
|
25
|
+
// Connection-Abbrueche, kein Re-Export-Zwang. Pattern matched Google-
|
|
26
|
+
// Takeout (7d) + Facebook-Data-Download (4d).
|
|
27
|
+
//
|
|
28
|
+
// **TTL = job.expiresAt** (denormalized, gleicher Wert wie Job-Row).
|
|
29
|
+
// Storage-Cleanup-Pass nullt downloadStorageKey nach
|
|
30
|
+
// expiresAt + exportStorageCleanupGraceHours. Token-Row bleibt liegen
|
|
31
|
+
// (Audit-Trail) — Atom 4b's Download-Endpoint check't job.downloadStorageKey
|
|
32
|
+
// != null vor Streaming + returns 410 Gone wenn Storage cleared.
|
|
33
|
+
//
|
|
34
|
+
// **idType: "uuid"** matched ExportJob — Token-IDs reisen ueber
|
|
35
|
+
// Process-Grenzen (audit-events fuer DPO, ggf. Re-Issue-Pfade).
|
|
36
|
+
|
|
37
|
+
export const exportDownloadTokenEntity = createEntity({
|
|
38
|
+
table: "read_export_download_tokens",
|
|
39
|
+
idType: "uuid",
|
|
40
|
+
|
|
41
|
+
fields: {
|
|
42
|
+
// FK auf export-jobs. UNIQUE (siehe indexes) — 1 Token pro Job.
|
|
43
|
+
// Audit-Trail-Argument: separate Token-Row statt Job-Spalten weil
|
|
44
|
+
// Audit-Felder (lastUsedAt/IP/UA) semantisch zum Token, nicht zum
|
|
45
|
+
// Job gehoeren.
|
|
46
|
+
jobId: createTextField({
|
|
47
|
+
required: true,
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
// **Hash NICHT plain.** SHA256-hex (64 chars). Atom 4b verifiziert
|
|
51
|
+
// via konstanter-Zeit-Vergleich.
|
|
52
|
+
tokenHash: createTextField({
|
|
53
|
+
required: true,
|
|
54
|
+
maxLength: 64,
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
// Wann wurde das Token ausgegeben (= Job-Done-Flip-Zeit).
|
|
58
|
+
issuedAt: createTimestampField({
|
|
59
|
+
required: true,
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
// Wann laeuft das Token ab. Identisch zu job.expiresAt
|
|
63
|
+
// (denormalized — Worker setzt beide gleich beim Done-Flip).
|
|
64
|
+
expiresAt: createTimestampField({
|
|
65
|
+
required: true,
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
// Audit: wann wurde das Token zuletzt fuer einen Download genutzt.
|
|
69
|
+
// NULL solange noch nie heruntergeladen. Atom 4b's Download-Endpoint
|
|
70
|
+
// setzt es bei jedem successful Stream.
|
|
71
|
+
lastUsedAt: createTimestampField({}),
|
|
72
|
+
|
|
73
|
+
// Audit: Anzahl der Downloads. Atom 4b incrementiert bei jedem
|
|
74
|
+
// Stream. bigInt fuer fall einer pathologischer Re-Download-Schleife
|
|
75
|
+
// (z.B. broken Sync-Tool).
|
|
76
|
+
useCount: createBigIntField({}),
|
|
77
|
+
|
|
78
|
+
// Audit: Source-IP des letzten Downloads. Hilft DPO bei Untersuchung
|
|
79
|
+
// ungewoehnlicher Aktivitaeten ("Token wurde von 5 verschiedenen
|
|
80
|
+
// IPs genutzt"). Plain-IP-V4/IPv6, kein hash — DPO braucht direkt
|
|
81
|
+
// lesbar. is-business-data weil Token-Audit kein User-PII ist
|
|
82
|
+
// (gehoert dem Tenant-Operator).
|
|
83
|
+
lastUsedFromIp: createTextField({
|
|
84
|
+
maxLength: 45, // IPv6 max
|
|
85
|
+
allowPlaintext: "is-business-data",
|
|
86
|
+
}),
|
|
87
|
+
|
|
88
|
+
// Audit: User-Agent des letzten Downloads. Audit-Wert (Email-Client
|
|
89
|
+
// vs Browser vs CLI-Tool unterscheidbar). is-business-data analog.
|
|
90
|
+
lastUsedUserAgent: createTextField({
|
|
91
|
+
maxLength: 500,
|
|
92
|
+
allowPlaintext: "is-business-data",
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// 1 Token pro Job. UNIQUE auf jobId — garantiert dass Worker-
|
|
97
|
+
// Idempotency (Atom 3b's "2× run done-Job → no-op") auch fuer
|
|
98
|
+
// Token-Generierung gilt: zweiter Versuch faellt auf Constraint.
|
|
99
|
+
indexes: [
|
|
100
|
+
{
|
|
101
|
+
unique: true,
|
|
102
|
+
columns: ["jobId"],
|
|
103
|
+
name: "read_export_download_tokens_one_per_job",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const exportDownloadTokensTable = buildDrizzleTable(
|
|
109
|
+
"exportDownloadToken",
|
|
110
|
+
exportDownloadTokenEntity,
|
|
111
|
+
);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createBigIntField,
|
|
4
|
+
createEntity,
|
|
5
|
+
createLongTextField,
|
|
6
|
+
createSelectField,
|
|
7
|
+
createTextField,
|
|
8
|
+
createTimestampField,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { sql } from "drizzle-orm";
|
|
11
|
+
|
|
12
|
+
// Export-Job-Lifecycle (S2.U3+U4 Atom 1).
|
|
13
|
+
//
|
|
14
|
+
// Spec: docs/plans/architecture/user-data-rights.md "Async Export-Pipeline".
|
|
15
|
+
// User triggert `request-export` → ExportJob (status="pending"). Worker
|
|
16
|
+
// pickt auf → "running" → erfolgreich "done" + downloadStorageKey gesetzt
|
|
17
|
+
// + expiresAt = completedAt + compliance-profile.userRights.exportDownloadTtl
|
|
18
|
+
// (per-Tenant-konfigurierbar via Override, Default 7 Tage). Bei Throw
|
|
19
|
+
// oder Stale-Timeout → "failed" mit errorMessage. Stale-Timeout +
|
|
20
|
+
// Storage-Cleanup-Grace ebenfalls aus dem compliance-profile.
|
|
21
|
+
//
|
|
22
|
+
// Status-Werte als Constants — Single source of truth fuer Worker,
|
|
23
|
+
// Application-Code (request-export-Handler), UI-Banner (Polling) und
|
|
24
|
+
// Drift-Guard im Test.
|
|
25
|
+
export const EXPORT_JOB_STATUS = {
|
|
26
|
+
Pending: "pending",
|
|
27
|
+
Running: "running",
|
|
28
|
+
Done: "done",
|
|
29
|
+
Failed: "failed",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type ExportJobStatus = (typeof EXPORT_JOB_STATUS)[keyof typeof EXPORT_JOB_STATUS];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* DB-Constraint-Name fuer den Partial-UNIQUE-Index (1 aktiver Job pro
|
|
36
|
+
* User). Single source of truth — request-export.write catched 23505
|
|
37
|
+
* mit diesem Namen als Race-Schutz, Tests pinnen ihn als
|
|
38
|
+
* `expectUniqueViolation`-Argument. Rename hier propagiert automatisch.
|
|
39
|
+
*/
|
|
40
|
+
export const ACTIVE_JOB_CONSTRAINT = "read_export_jobs_one_active_per_user";
|
|
41
|
+
|
|
42
|
+
const EXPORT_JOB_STATUS_OPTIONS = [
|
|
43
|
+
EXPORT_JOB_STATUS.Pending,
|
|
44
|
+
EXPORT_JOB_STATUS.Running,
|
|
45
|
+
EXPORT_JOB_STATUS.Done,
|
|
46
|
+
EXPORT_JOB_STATUS.Failed,
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
// **Tenant-agnostisch** wie userTable — 1 Job pro {userId} ueber alle
|
|
50
|
+
// Memberships. Die Framework-Auto-`tenant_id`-Spalte ist da, wird aber
|
|
51
|
+
// in der Domain ignoriert (Cross-Tenant-Iteration laeuft im Worker via
|
|
52
|
+
// ctx.db.raw, gleiches Pattern wie runForgetCleanup).
|
|
53
|
+
//
|
|
54
|
+
// **Idempotency:** Partial-UNIQUE-Index auf `(userId)` WHERE
|
|
55
|
+
// `status IN ('pending', 'running')`. User mit pending-Job kann keinen
|
|
56
|
+
// zweiten parallelen Job starten — der Insert faellt mit Constraint-
|
|
57
|
+
// Violation auf, der request-export-Handler (Atom 2) faengt das ab und
|
|
58
|
+
// returnt den existing pending-Job (Insert-or-Return). Race-Window
|
|
59
|
+
// null. Done/Failed-Jobs koennen beliebig viele pro User existieren
|
|
60
|
+
// (Audit-Historie).
|
|
61
|
+
//
|
|
62
|
+
// **idType: "uuid"** — Job-IDs reisen ueber Process-Grenzen
|
|
63
|
+
// (BullMQ-Payload, Job-Run-Logger, Audit-Events fuer DPO). Serial-IDs
|
|
64
|
+
// sind nur prozess-lokal verlaesslich, UUIDs cross-process stabil.
|
|
65
|
+
export const exportJobEntity = createEntity({
|
|
66
|
+
table: "read_export_jobs",
|
|
67
|
+
idType: "uuid",
|
|
68
|
+
|
|
69
|
+
fields: {
|
|
70
|
+
// Tenant-agnostisch: Wert wird beim Schreiben gesetzt (Framework
|
|
71
|
+
// braucht eine tenant_id-Spalte), domain-mäßig ignoriert.
|
|
72
|
+
// Kein maxLength — folgt userTable.id + tenantMembershipsTable.userId
|
|
73
|
+
// (beide ohne maxLength). UserId-Form ist Plattform-Konzern, hier
|
|
74
|
+
// nur Storage.
|
|
75
|
+
userId: createTextField({
|
|
76
|
+
required: true,
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
// **requestedFromTenantId** — der Tenant aus dem der User den Antrag
|
|
80
|
+
// gestellt hat. Persistierter Audit-Pfad fuer den Worker (welches
|
|
81
|
+
// Compliance-Profile gilt fuer Job-TTL/Stale/Cleanup) — DSGVO-
|
|
82
|
+
// konsistent: 1 User = 1 effektives Profile pro Antrag, nicht "wechselt
|
|
83
|
+
// mit jedem Cross-Tenant-Klick". Plan-Doc-Decision: Tenant aus 1. Klick
|
|
84
|
+
// (Option a). Spalte heisst nicht einfach "tenantId" damit kein
|
|
85
|
+
// Verwechseln mit der Framework-Auto-Spalte tenant_id.
|
|
86
|
+
//
|
|
87
|
+
// **Atom 3b Worker-Hinweis:** Profile-Resolution muss ueber
|
|
88
|
+
// `requestedFromTenantId` laufen (`queryAs(systemUserOf(requestedFromTenantId),
|
|
89
|
+
// "compliance-profiles:query:for-tenant", {})`), NICHT ueber
|
|
90
|
+
// `executor.tenantId` — sonst kriegt ein Job-Pickup aus einem anderen
|
|
91
|
+
// Tenant-Context ein falsches Profile.
|
|
92
|
+
requestedFromTenantId: createTextField({
|
|
93
|
+
required: true,
|
|
94
|
+
}),
|
|
95
|
+
|
|
96
|
+
status: createSelectField({
|
|
97
|
+
required: true,
|
|
98
|
+
default: EXPORT_JOB_STATUS.Pending,
|
|
99
|
+
options: EXPORT_JOB_STATUS_OPTIONS,
|
|
100
|
+
}),
|
|
101
|
+
|
|
102
|
+
// Wann hat der User den Job angefordert. Required — kein Job-Row
|
|
103
|
+
// entsteht ohne Klick.
|
|
104
|
+
requestedAt: createTimestampField({
|
|
105
|
+
required: true,
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
// Wann hat der Worker mit dem Pickup angefangen. NULL solange
|
|
109
|
+
// `pending`. Stale-Detection nutzt das +
|
|
110
|
+
// compliance-profile.userRights.exportStaleTimeoutMinutes.
|
|
111
|
+
startedAt: createTimestampField({}),
|
|
112
|
+
|
|
113
|
+
// Wann hat der Worker abgeschlossen (success oder fail). NULL
|
|
114
|
+
// solange running.
|
|
115
|
+
completedAt: createTimestampField({}),
|
|
116
|
+
|
|
117
|
+
// Storage-Key des fertigen ZIP-Files. NULL solange nicht `done`.
|
|
118
|
+
// Worker setzt das + die ZIP-Bytes via storage-provider.write(key).
|
|
119
|
+
// Dies ist KEIN signed-URL — der Download-Endpoint generiert den
|
|
120
|
+
// signed-URL on demand vom Storage-Provider (Atom 4).
|
|
121
|
+
downloadStorageKey: createTextField({
|
|
122
|
+
maxLength: 500,
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
// Ab wann ist der Download nicht mehr abrufbar. Worker setzt
|
|
126
|
+
// `completedAt + compliance-profile.userRights.exportDownloadTtl`
|
|
127
|
+
// beim Flip auf `done`. NULL fuer pending/running/failed.
|
|
128
|
+
//
|
|
129
|
+
// Storage-Cleanup-Pflicht: Worker laesst nach
|
|
130
|
+
// `expiresAt + compliance-profile.userRights.exportStorageCleanupGraceHours`
|
|
131
|
+
// einen separaten Pass loeschen damit abgelaufene ZIPs nicht auf S3
|
|
132
|
+
// verbleiben.
|
|
133
|
+
expiresAt: createTimestampField({}),
|
|
134
|
+
|
|
135
|
+
// Failed-State Diagnose. longText weil Hook-Errors mit Stack-Trace
|
|
136
|
+
// mehrere KB werden koennen. is-business-data weil Job-Status
|
|
137
|
+
// public im UI-Polling sichtbar (kein PII).
|
|
138
|
+
errorMessage: createLongTextField({
|
|
139
|
+
allowPlaintext: "is-business-data",
|
|
140
|
+
}),
|
|
141
|
+
|
|
142
|
+
// Audit-Info fuer Operator: wie gross war der Export. Hilft beim
|
|
143
|
+
// Capacity-Planning + erkennt pathologische Cases (Streaming-ZIP
|
|
144
|
+
// mit 5 GB Files schreibt mehrere Mrd Bytes, integer-Overflow waere
|
|
145
|
+
// silent + Audit-Drift).
|
|
146
|
+
//
|
|
147
|
+
// bigInt liefert JS-`number` Round-trip via mode:"number" — sicher
|
|
148
|
+
// bis 2^53 ≈ 9 PB, JSON-tauglich fuer das Status-Polling.
|
|
149
|
+
bytesWritten: createBigIntField({}),
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// Partial-UNIQUE-Index: nur 1 aktiver Job pro User, Done/Failed-
|
|
153
|
+
// Historie ist unbeschraenkt. request-export.write nutzt App-side-
|
|
154
|
+
// Pre-Check als primaeren Pfad + 23505-Catch dieser Constraint als
|
|
155
|
+
// Race-Schutz fuer das <1ms-Window zwischen fetchOne + crud.create.
|
|
156
|
+
indexes: [
|
|
157
|
+
{
|
|
158
|
+
unique: true,
|
|
159
|
+
columns: ["userId"],
|
|
160
|
+
name: ACTIVE_JOB_CONSTRAINT,
|
|
161
|
+
where: sql`status IN ('pending', 'running')`,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const exportJobsTable = buildDrizzleTable("exportJob", exportJobEntity);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Download-Token-Helper (S2.U3 Atom 4a).
|
|
2
|
+
//
|
|
3
|
+
// Generiert ein kryptographisch-sicheres Token zur Authorisierung des
|
|
4
|
+
// Export-ZIP-Downloads. Pattern matched Magic-Link/Bearer-Token mit DB-
|
|
5
|
+
// gespeichertem Hash:
|
|
6
|
+
//
|
|
7
|
+
// 1. Worker generiert plain-Token (32 byte random base64url) + Hash
|
|
8
|
+
// (SHA256 hex).
|
|
9
|
+
// 2. DB speichert NUR den Hash. Plain bleibt im Worker-Memory.
|
|
10
|
+
// 3. Atom 5 (Notification) versendet plain via Email an User.
|
|
11
|
+
// 4. Atom 4b (Download-Endpoint) hashet incoming-Token + vergleicht
|
|
12
|
+
// mit DB-Hash → konstantes-Zeit-Vergleich gegen timing-attacks.
|
|
13
|
+
//
|
|
14
|
+
// **Multi-use within TTL** (User-Choice 4a-Plan): Token wird NICHT
|
|
15
|
+
// "consumed" beim Download — User kann mehrfach downloaden bis expiresAt.
|
|
16
|
+
// Pattern matched Google-Takeout (7d) + Facebook-Data-Download (4d).
|
|
17
|
+
//
|
|
18
|
+
// **Universal Web-Crypto API:** crypto.getRandomValues + crypto.subtle.digest
|
|
19
|
+
// laeuft in Bun + Node 19+ + Browser identisch — keine plattform-
|
|
20
|
+
// spezifischen Imports (Memory `feedback_universal_deps`).
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generiert ein neues Download-Token. plain wird dem User via Email
|
|
24
|
+
* zugestellt (Atom 5); hash landet in der Token-DB-Row.
|
|
25
|
+
*
|
|
26
|
+
* Token-Format: 32 byte random → base64url-encoded (~43 chars). URL-safe
|
|
27
|
+
* fuer Magic-Link-Verwendung (kein +/= das URL-encoded werden muesste).
|
|
28
|
+
*/
|
|
29
|
+
export async function generateDownloadToken(): Promise<{
|
|
30
|
+
readonly plain: string;
|
|
31
|
+
readonly hash: string;
|
|
32
|
+
}> {
|
|
33
|
+
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
34
|
+
const plain = uint8ArrayToBase64Url(randomBytes);
|
|
35
|
+
const hash = await hashDownloadToken(plain);
|
|
36
|
+
return { plain, hash };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hashed einen plain-Token zu seiner DB-Repraesentation. Verify-Pfad
|
|
41
|
+
* (Atom 4b's Download-Endpoint): hashed incoming-Token + sucht den Hash
|
|
42
|
+
* in DB. Konstante-Zeit-String-Vergleich verhindert timing-attacks
|
|
43
|
+
* (das macht der Caller via `crypto.subtle`-vergleich oder secure-
|
|
44
|
+
* compare-helper).
|
|
45
|
+
*/
|
|
46
|
+
export async function hashDownloadToken(plain: string): Promise<string> {
|
|
47
|
+
const encoded = new TextEncoder().encode(plain);
|
|
48
|
+
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
|
49
|
+
return uint8ArrayToHex(new Uint8Array(digest));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function uint8ArrayToBase64Url(bytes: Uint8Array): string {
|
|
53
|
+
// btoa erwartet binary-string. atob/btoa sind universal in Bun + Node + Browser.
|
|
54
|
+
let binary = "";
|
|
55
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
56
|
+
binary += String.fromCharCode(bytes[i] as number);
|
|
57
|
+
}
|
|
58
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function uint8ArrayToHex(bytes: Uint8Array): string {
|
|
62
|
+
let out = "";
|
|
63
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
64
|
+
out += (bytes[i] as number).toString(16).padStart(2, "0");
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Shared helper fuer ZIP-Path-Berechnung von fileRefs (S2.U3 Atom 3c).
|
|
2
|
+
//
|
|
3
|
+
// Das Bundle hat zwei Sichten auf die selben fileRefs:
|
|
4
|
+
// 1. bundle.json: Eine flat-Liste mit `zipPath` pro fileRef. Reader-
|
|
5
|
+
// Tools koennen so JSON ↔ files/-Pfade verlinken.
|
|
6
|
+
// 2. ZIP-Entries: Bytes liegen unter genau diesen `zipPath`-Schluesseln.
|
|
7
|
+
//
|
|
8
|
+
// Damit beide Sichten NICHT auseinander driften, lebt die Pfad-Berechnung
|
|
9
|
+
// EXAKT EINMAL hier. run-user-export befuellt zipPath im fileRef-Item,
|
|
10
|
+
// run-export-jobs's bundleToZipEntries nutzt dieselben Pfade als Entry-
|
|
11
|
+
// Keys.
|
|
12
|
+
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maximale Laenge des sanitized fileName (ohne Extension). Realistic
|
|
17
|
+
* Filenames sind <50, 100 ist eine sichere Grenze fuer ZIP-Kompatibilitaet
|
|
18
|
+
* (PKWARE-spec keine harte limit, aber viele Reader haengen bei extrem
|
|
19
|
+
* langen Namen).
|
|
20
|
+
*/
|
|
21
|
+
const MAX_SANITIZED_BASENAME_LENGTH = 100;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Erlaubte Filename-Chars: alphanumerisch, dot, dash, underscore. Alles
|
|
25
|
+
* andere wird zu underscore replaced. Path-Separator (`/`, `\`),
|
|
26
|
+
* relative-traversal (`..`), und null-bytes sind insbesondere ausgeschlossen.
|
|
27
|
+
*/
|
|
28
|
+
const FILENAME_SAFE_CHARS = /[^a-zA-Z0-9._-]/g;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sanitize einen vom User-Input stammenden fileName fuer Verwendung als
|
|
32
|
+
* ZIP-internal-path-Suffix. Defense gegen:
|
|
33
|
+
* - Path-Traversal: "../../etc/passwd" → "file.etc_passwd"
|
|
34
|
+
* - Null-Bytes: "report\x00.pdf" → "report_.pdf"
|
|
35
|
+
* - Path-Separator: "sub/dir/file.txt" → "sub_dir_file.txt"
|
|
36
|
+
* - Reserved-Names: "." / ".." / "..." → "file"
|
|
37
|
+
* - Empty input: "" / null / undef → "unnamed"
|
|
38
|
+
* - Ueberlange: lange Strings auf MAX_SANITIZED_BASENAME_LENGTH gekappt
|
|
39
|
+
* (Extension bleibt erhalten falls vorhanden)
|
|
40
|
+
* - Unicode: non-ASCII → underscore. Leading-strip kann auch dazu
|
|
41
|
+
* fuehren dass alle-Unicode-Names zu "file" werden.
|
|
42
|
+
*
|
|
43
|
+
* Output ist garantiert: nicht leer, kein Path-Separator, max-len enforced,
|
|
44
|
+
* keine `..`-Sequenz im finalen String.
|
|
45
|
+
*/
|
|
46
|
+
export function sanitizeZipFilename(raw: string): string {
|
|
47
|
+
if (raw === undefined || raw === null || raw === "") return "unnamed";
|
|
48
|
+
|
|
49
|
+
// Extension extrahieren (letzte ".X" wo X kein "." enthaelt).
|
|
50
|
+
const lastDot = raw.lastIndexOf(".");
|
|
51
|
+
const hasExt = lastDot > 0 && lastDot < raw.length - 1;
|
|
52
|
+
const baseName = hasExt ? raw.slice(0, lastDot) : raw;
|
|
53
|
+
const extension = hasExt ? raw.slice(lastDot + 1) : "";
|
|
54
|
+
|
|
55
|
+
const safeBase = collapseUnsafe(baseName).slice(0, MAX_SANITIZED_BASENAME_LENGTH);
|
|
56
|
+
const safeExt = collapseUnsafe(extension).slice(0, 20);
|
|
57
|
+
|
|
58
|
+
// Empty oder all-underscores → "file" als reproduzierbarer Fallback.
|
|
59
|
+
const finalBase = safeBase.length === 0 ? "file" : safeBase;
|
|
60
|
+
|
|
61
|
+
return safeExt.length > 0 ? `${finalBase}.${safeExt}` : finalBase;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 3-step Sanitize:
|
|
66
|
+
* 1. Replace alles ausser [a-zA-Z0-9._-] mit "_"
|
|
67
|
+
* 2. Collapse `..` (oder mehr) → "_" — verhindert dass `..` nach
|
|
68
|
+
* Sanitize uebrig bleibt (z.B. wenn input nur dots+slashes ist).
|
|
69
|
+
* 3. Strip leading [._-]+ — verhindert hidden-file-Patterns + leere
|
|
70
|
+
* Basenames + leading-segments die nach den ersten zwei Steps
|
|
71
|
+
* lauter underscores haetten.
|
|
72
|
+
*/
|
|
73
|
+
function collapseUnsafe(s: string): string {
|
|
74
|
+
let out = s.replace(FILENAME_SAFE_CHARS, "_");
|
|
75
|
+
out = out.replace(/\.{2,}/g, "_");
|
|
76
|
+
out = out.replace(/^[._-]+/, "");
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Berechnet den ZIP-internal-Pfad fuer einen fileRef. Layout:
|
|
82
|
+
* files/<tenantId>/<fileRefId>-<sanitized-fileName>
|
|
83
|
+
*
|
|
84
|
+
* tenantId + fileRefId sind UUID-shape (sicher); fileName geht durch
|
|
85
|
+
* sanitizeZipFilename. Garantiert kein path-traversal.
|
|
86
|
+
*/
|
|
87
|
+
export function buildFileRefZipPath(args: {
|
|
88
|
+
readonly tenantId: TenantId;
|
|
89
|
+
readonly fileRefId: string;
|
|
90
|
+
readonly fileName: string;
|
|
91
|
+
}): string {
|
|
92
|
+
const safeName = sanitizeZipFilename(args.fileName);
|
|
93
|
+
return `files/${args.tenantId}/${args.fileRefId}-${safeName}`;
|
|
94
|
+
}
|