@cosmicdrift/kumiko-bundled-features 0.2.1 → 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.
Files changed (100) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/package.json +12 -6
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/login.write.ts +31 -1
  6. package/src/auth-email-password/i18n.ts +4 -0
  7. package/src/compliance-profiles/README.md +88 -0
  8. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  9. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  10. package/src/compliance-profiles/feature.ts +51 -0
  11. package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
  12. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  13. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  14. package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
  15. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  16. package/src/compliance-profiles/index.ts +6 -0
  17. package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
  18. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  19. package/src/compliance-profiles/seeding.ts +96 -0
  20. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  21. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  22. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  23. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  24. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  25. package/src/data-retention/_internal/parse-override.ts +33 -0
  26. package/src/data-retention/feature.ts +57 -0
  27. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  28. package/src/data-retention/index.ts +18 -0
  29. package/src/data-retention/keep-for.ts +75 -0
  30. package/src/data-retention/override-schema.ts +37 -0
  31. package/src/data-retention/presets.ts +72 -0
  32. package/src/data-retention/resolve-for-tenant.ts +50 -0
  33. package/src/data-retention/resolver.ts +107 -0
  34. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  35. package/src/file-foundation/feature.ts +43 -3
  36. package/src/file-foundation/index.ts +1 -0
  37. package/src/file-provider-inmemory/feature.ts +6 -3
  38. package/src/file-provider-s3/feature.ts +8 -10
  39. package/src/files/README.md +50 -0
  40. package/src/files/__tests__/files.integration.ts +157 -0
  41. package/src/files/feature.ts +34 -0
  42. package/src/files/index.ts +1 -0
  43. package/src/files/schema/file-ref.ts +58 -0
  44. package/src/files-provider-s3/s3-provider.ts +89 -0
  45. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  46. package/src/secrets/feature.ts +10 -6
  47. package/src/sessions/constants.ts +4 -0
  48. package/src/sessions/feature.ts +3 -0
  49. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  50. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  51. package/src/tier-engine/feature.ts +16 -6
  52. package/src/user/__tests__/user-status.test.ts +39 -0
  53. package/src/user/index.ts +11 -1
  54. package/src/user/schema/user.ts +76 -0
  55. package/src/user-data-rights/COMPLIANCE.md +182 -0
  56. package/src/user-data-rights/README.md +109 -0
  57. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  58. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  59. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  60. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  61. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  62. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  63. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  64. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  65. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  66. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  67. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  68. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  69. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  70. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  71. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  72. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  73. package/src/user-data-rights/audit-download.ts +125 -0
  74. package/src/user-data-rights/feature.ts +309 -0
  75. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
  78. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  79. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  80. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  81. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  82. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  83. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  84. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  85. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  86. package/src/user-data-rights/i18n.ts +37 -0
  87. package/src/user-data-rights/index.ts +19 -0
  88. package/src/user-data-rights/run-export-jobs.ts +878 -0
  89. package/src/user-data-rights/run-forget-cleanup.ts +334 -0
  90. package/src/user-data-rights/run-user-export.ts +211 -0
  91. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  92. package/src/user-data-rights/schema/download-token.ts +111 -0
  93. package/src/user-data-rights/schema/export-job.ts +166 -0
  94. package/src/user-data-rights/token-helpers.ts +67 -0
  95. package/src/user-data-rights/zip-path.ts +94 -0
  96. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  97. package/src/user-data-rights-defaults/feature.ts +40 -0
  98. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  99. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  100. 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
+ }