@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
@@ -18,19 +18,17 @@
18
18
  //
19
19
  // **Boot-Dependencies:** config + secrets + file-foundation.
20
20
 
21
- import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
21
+ import type {
22
+ FileProviderContext,
23
+ FileProviderPlugin,
24
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
22
25
  import { createS3Provider } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
23
26
  import {
24
27
  requireDefined,
25
28
  requireNonEmpty,
26
29
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
27
30
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
28
- import {
29
- access,
30
- createTenantConfig,
31
- defineFeature,
32
- type HandlerContext,
33
- } from "@cosmicdrift/kumiko-framework/engine";
31
+ import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
34
32
  import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
35
33
 
36
34
  const FEATURE_NAME = "file-provider-s3";
@@ -89,7 +87,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
89
87
  // Plugin-Registration. entityName "s3" ist was tenants in
90
88
  // file-foundation's `provider` config-key setzen.
91
89
  const plugin: FileProviderPlugin = {
92
- build: async (ctx: HandlerContext, tenantId: string) => buildS3Provider(ctx, tenantId),
90
+ build: async (ctx: FileProviderContext, tenantId: string) => buildS3Provider(ctx, tenantId),
93
91
  };
94
92
  r.useExtension("fileProvider", "s3", plugin);
95
93
 
@@ -104,7 +102,7 @@ export const S3_SECRET_ACCESS_KEY = fileProviderS3Feature.exports.secretAccessKe
104
102
  // =============================================================================
105
103
 
106
104
  async function buildS3Provider(
107
- ctx: HandlerContext,
105
+ ctx: FileProviderContext,
108
106
  tenantId: string,
109
107
  ): Promise<FileStorageProvider> {
110
108
  const ctxConfig = ctx.config;
@@ -157,7 +155,7 @@ async function buildS3Provider(
157
155
  });
158
156
  }
159
157
 
160
- async function readSecretAccessKey(ctx: HandlerContext, tenantId: string): Promise<string> {
158
+ async function readSecretAccessKey(ctx: FileProviderContext, tenantId: string): Promise<string> {
161
159
  const secrets = requireSecretsContext(ctx, FEATURE_NAME);
162
160
  const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
163
161
  if (!branded) {
@@ -0,0 +1,50 @@
1
+ # files
2
+
3
+ Schema-Sicht der framework-internen `file_refs`-Tabelle als bundled-feature.
4
+ Sprint-1.5 Refactor (Pre-Sprint-2).
5
+
6
+ ## Was es macht
7
+
8
+ Deklariert `r.entity("fileRef", ...)` für die DB-Tabelle die das
9
+ Framework via `createFileRoutes` (multipart-Upload + binary-Download)
10
+ bewirtschaftet. Das öffnet die Tür für Cross-Feature-Hooks:
11
+
12
+ - **Sprint 2** (`user-data-rights-defaults`) registriert `r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })` — Forget-Flow + Daten-Export fassen die Files automatisch an. ✅ done
13
+ - **Sprint 5** (`tenant-lifecycle`) wird `r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })` registrieren — Tenant-Destroy löscht alle FileRefs.
14
+
15
+ ## Was es NICHT macht
16
+
17
+ - **Keine Upload-/Download-Routes** — die bleiben in
18
+ `framework/src/api/server.ts` via `options.files`-Bootstrap.
19
+ Multipart-Form-Body und Binary-Streaming passen nicht ins Write/Query-
20
+ Handler-Pattern; ein Refactor zu `r.httpRoute` wäre orthogonal zu
21
+ diesem Sprint.
22
+ - **Kein eigener Drizzle-Table-Build** — die `file_refs`-Tabelle
23
+ existiert schon in `framework/src/files/file-ref-table.ts`. Diese
24
+ Entity ist nur die Schema-Sicht für Cross-Feature-Hooks; Drizzle-
25
+ Queries laufen weiter über `fileRefsTable` aus
26
+ `@cosmicdrift/kumiko-framework/files`.
27
+
28
+ ## PII-Annotations (Sprint 0.1+0.7)
29
+
30
+ ```ts
31
+ fileName → pii: true (Originalname enthält oft Personen-Bezug)
32
+ storageKey → allowPlaintext: "is-business-data" (interner UUID-Key)
33
+ mimeType → allowPlaintext: "is-business-data"
34
+ size → allowPlaintext: "is-business-data"
35
+ entityType → allowPlaintext: "is-business-data"
36
+ entityId → allowPlaintext: "is-business-data"
37
+ fieldName → allowPlaintext: "is-business-data"
38
+ insertedAt → kein PII-Marker (Audit-Timestamp, Framework-managed)
39
+ insertedById → allowPlaintext: "is-business-data" (User-Reference, kein Eigen-PII)
40
+ ```
41
+
42
+ `fileName: pii: true` heißt: Sprint 3 Crypto-Shredding wird den Wert
43
+ mit dem Author-Subject-Key encrypten (für File-INHALTE: separates
44
+ Subject-Resolver-Pattern via `subjectField` — siehe storage-encryption.md
45
+ Sprint 4).
46
+
47
+ ## Tests
48
+
49
+ `__tests__/files.integration.ts` — 5 Tests die beweisen dass die Feature-
50
+ Definition clean lädt + die PII-Markers + Tabellenname stimmen.
@@ -0,0 +1,157 @@
1
+ // files-feature integration tests (S1.5 + S1.7).
2
+ //
3
+ // Tests sortiert nach Verhaltens-Tiefe:
4
+ // 1. Feature-Definition Smoke (Boot-Validation passes)
5
+ // 2. Cross-Feature-Behavior: fileRef-Entity ist als Hook-Anker für
6
+ // Sprint-2-userData-Extension nutzbar
7
+ // 3. DDL-Konsistenz: Framework-pgTable + Feature-Entity zeigen auf
8
+ // dieselbe Postgres-Struktur (Drift-Guard)
9
+ // 4. Event-QN-Match: r.defineEvent + framework's fileUploadedEvent
10
+ // resolven zum selben QN
11
+
12
+ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { FILE_UPLOADED_EVENT_TYPE, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
14
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { getTableColumns } from "drizzle-orm";
16
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
17
+ import { createFilesFeature, fileRefEntity } from "../feature";
18
+
19
+ let stack: TestStack;
20
+
21
+ const feature = createFilesFeature();
22
+
23
+ beforeAll(async () => {
24
+ stack = await setupTestStack({ features: [feature] });
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await stack.cleanup();
29
+ });
30
+
31
+ describe("files :: feature-definition smoke", () => {
32
+ test("Boot ist clean (PII-Annotations + Event-Schema valide)", async () => {
33
+ // setupTestStack hat das feature im beforeAll geladen — wenn Boot-
34
+ // Validation fehlschlägt, würde dieser Block nie laufen.
35
+ expect(stack).toBeDefined();
36
+ });
37
+
38
+ test("Tabellen-Name matched die Framework-pgTable (file_refs)", () => {
39
+ expect(fileRefEntity.table).toBe("file_refs");
40
+ });
41
+
42
+ test("fileName ist als pii: true markiert", () => {
43
+ const fileName = fileRefEntity.fields["fileName"] as { pii?: boolean };
44
+ expect(fileName.pii).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("files :: cross-feature behavior (F1, S1.7)", () => {
49
+ test("Sprint-2-Pattern: ein Consumer-Feature kann r.useExtension(EXT_USER_DATA, fileRef, ...) registrieren", async () => {
50
+ // Stub-Feature simuliert die Sprint-2-Semantik: extendsRegistrar
51
+ // (kommt aus user-data-rights) + useExtension auf fileRef.
52
+ const userDataProvider = defineFeature("test-user-data-provider", (r) => {
53
+ r.extendsRegistrar(EXT_USER_DATA, {
54
+ // Spec-stub — Sprint 2 wird die echten Hook-Signaturen liefern.
55
+ });
56
+ });
57
+
58
+ const consumer = defineFeature("test-files-consumer", (r) => {
59
+ r.requires("files", "test-user-data-provider");
60
+ r.useExtension(EXT_USER_DATA, "fileRef", {
61
+ // Stub-Hooks: in Sprint 2 werden diese die echte Forget-/Export-
62
+ // Logik tragen. Hier reicht: useExtension findet die fileRef-
63
+ // Entity in der Registry → kein Boot-Error.
64
+ export: async () => [],
65
+ delete: async () => undefined,
66
+ });
67
+ });
68
+
69
+ // Eigener Test-Stack damit dieser Sub-Test isoliert vom outer
70
+ // beforeAll-Stack laeuft.
71
+ const crossStack = await setupTestStack({
72
+ features: [feature, userDataProvider, consumer],
73
+ });
74
+ expect(crossStack).toBeDefined();
75
+ await crossStack.cleanup();
76
+ });
77
+
78
+ test("useExtension auf nicht-existierendes Entity wirft beim Boot", async () => {
79
+ const userDataProvider = defineFeature("test-user-data-provider-2", (r) => {
80
+ r.extendsRegistrar(EXT_USER_DATA, {});
81
+ });
82
+
83
+ const consumer = defineFeature("test-broken-consumer", (r) => {
84
+ r.requires("test-user-data-provider-2");
85
+ // GHOSTLY: useExtension auf "ghostEntity" gibt es nirgends.
86
+ r.useExtension(EXT_USER_DATA, "ghostEntity", {});
87
+ });
88
+
89
+ // Boot soll laufen — es ist NICHT der Job des Boot-Validators zu
90
+ // pruefen ob die referenced Entity existiert (Extensions sind
91
+ // generisch, "ghostEntity" könnte ein App-Entity in einem anderen
92
+ // Feature sein). Test dokumentiert das aktuelle Verhalten als
93
+ // Regression-Guard. Sprint 2 schaerft ggf. mit
94
+ // validateUserDataExtensionTargets.
95
+ const ghostStack = await setupTestStack({
96
+ features: [userDataProvider, consumer],
97
+ });
98
+ expect(ghostStack).toBeDefined();
99
+ await ghostStack.cleanup();
100
+ });
101
+ });
102
+
103
+ describe("files :: DDL-Konsistenz (M3, S1.7)", () => {
104
+ // Drizzle's getTableColumns liefert die typed column-map ohne den
105
+ // Symbol-Properties-Junk. Sauberer als Object.keys(table) das auch
106
+ // interne Drizzle-Symbols mitnimmt.
107
+ function pgColumnNames(): Set<string> {
108
+ return new Set(Object.keys(getTableColumns(fileRefsTable)));
109
+ }
110
+
111
+ test("Feature-Entity-Felder matchen die Framework-pgTable column-set", () => {
112
+ // Framework's fileRefsTable ist die Quelle der Wahrheit fuer die
113
+ // DB-Struktur. Feature-Entity ist Schema-Sicht. Beide muessen
114
+ // konsistent sein — sonst landet Sprint-2's userData-Hook in
115
+ // Drift-Hell beim Forget-Flow.
116
+ //
117
+ // Vergleich: alle Feature-Felder muessen als Spalten in der pgTable
118
+ // existieren (umgekehrt darf pgTable framework-managed Spalten haben
119
+ // wie tenantId/createdAt/updatedAt/deletedAt — die deklariert das
120
+ // Framework automatisch beim buildDrizzleTable-Mapping).
121
+ const pgColumns = pgColumnNames();
122
+ const featureFields = Object.keys(fileRefEntity.fields);
123
+
124
+ for (const field of featureFields) {
125
+ expect(
126
+ pgColumns.has(field),
127
+ `Feature-Field "${field}" fehlt in framework pgTable file_refs — Schema-Drift!`,
128
+ ).toBe(true);
129
+ }
130
+ });
131
+
132
+ test("Framework-pgTable hat die kritischen file_ref-Spalten (storageKey, fileName, mimeType, size)", () => {
133
+ const pgColumns = pgColumnNames();
134
+ expect(pgColumns.has("storageKey")).toBe(true);
135
+ expect(pgColumns.has("fileName")).toBe(true);
136
+ expect(pgColumns.has("mimeType")).toBe(true);
137
+ expect(pgColumns.has("size")).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe("files :: event-QN-match (M4, S1.7)", () => {
142
+ test("framework's fileUploadedEvent.name === 'files:event:uploaded'", () => {
143
+ // Wenn das Framework den Event-Namen aendert, fliegt dieser Test
144
+ // sofort an — und der QN aus r.defineEvent("uploaded") im feature
145
+ // wuerde nicht mehr matchen. Drift-Guard.
146
+ expect(FILE_UPLOADED_EVENT_TYPE).toBe("files:event:uploaded");
147
+ });
148
+
149
+ test("Feature-Name 'files' + Event-Short 'uploaded' = QN 'files:event:uploaded'", () => {
150
+ // r.defineEvent("uploaded") in defineFeature("files", ...) resolved
151
+ // zu QN "files:event:uploaded" via Framework-Convention. Match
152
+ // garantiert dass framework's appendEvent + EventDef-Schema-
153
+ // Validation auf demselben QN landen.
154
+ const expected = `${feature.name}:event:uploaded`;
155
+ expect(expected).toBe(FILE_UPLOADED_EVENT_TYPE);
156
+ });
157
+ });
@@ -0,0 +1,34 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { fileUploadedPayloadSchema } from "@cosmicdrift/kumiko-framework/files";
3
+ import { fileRefEntity } from "./schema/file-ref";
4
+
5
+ export { fileRefEntity } from "./schema/file-ref";
6
+
7
+ // files — Schema-Sicht der framework-internen file_refs-Tabelle als
8
+ // bundled-feature, damit Cross-Feature-Hooks (userData, tenantData) sich
9
+ // an die "fileRef"-Entity hängen können.
10
+ //
11
+ // Sprint 1.5 (this commit):
12
+ // - r.entity("fileRef", fileRefEntity) — Schema-Surface
13
+ // - r.defineEvent("uploaded", schema) — Event-Marker
14
+ //
15
+ // Sprint 2 (kommt):
16
+ // - r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
17
+ //
18
+ // Sprint 5 (kommt):
19
+ // - r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
20
+ //
21
+ // Routes bleiben framework-internal (multipart-Upload + binary-Streaming
22
+ // passen nicht in das Handler-Pattern; siehe schema/file-ref.ts für
23
+ // Architektur-Note).
24
+ //
25
+ // Sprint-1.5-Plan-Roadmap-Wille: "fileRefsTable bleibt in framework
26
+ // (kein Daten-Move), aber r.entity('fileRef') deklariert sie für das
27
+ // Feature." — diese Datei IST die Umsetzung.
28
+ export function createFilesFeature(): FeatureDefinition {
29
+ return defineFeature("files", (r) => {
30
+ r.entity("fileRef", fileRefEntity);
31
+
32
+ r.defineEvent("uploaded", fileUploadedPayloadSchema);
33
+ });
34
+ }
@@ -0,0 +1 @@
1
+ export { createFilesFeature, fileRefEntity } from "./feature";
@@ -0,0 +1,58 @@
1
+ import {
2
+ createEntity,
3
+ createNumberField,
4
+ createTextField,
5
+ createTimestampField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // fileRef — Schema-Sicht der File-Metadata-Tabelle aus dem Framework.
9
+ //
10
+ // Architektur-Entscheidung (Sprint 1.5):
11
+ //
12
+ // Die DB-Tabelle `file_refs` lebt weiterhin in
13
+ // `framework/src/files/file-ref-table.ts` als drizzle pgTable, weil
14
+ // die Hono-Upload-/Download-Routes (`createFileRoutes` in
15
+ // `framework/src/api/server.ts`) sie direkt nutzen. Multipart-Upload
16
+ // und Binary-Streaming passen nicht in das Write/Query-Handler-Pattern
17
+ // — Routes bleiben framework-internal.
18
+ //
19
+ // Was hier passiert: dieselbe DB-Tabelle wird zusätzlich als
20
+ // `r.entity("fileRef")` in einem bundled-feature deklariert. Das
21
+ // ermoeglicht:
22
+ // 1. r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
23
+ // in Sprint 2 — Forget-Flow + Daten-Export erkennen die Entity.
24
+ // 2. r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
25
+ // in Sprint 5 — Tenant-Lifecycle löscht alle FileRefs.
26
+ // 3. Boot-Validation für PII-Annotations greift (fileName, originalName).
27
+ //
28
+ // Kein buildDrizzleTable hier — die Mapping-Tabelle existiert schon im
29
+ // Framework. Drizzle-Reads in den Sprint-2+-Hooks gehen direkt über
30
+ // `fileRefsTable` aus `@cosmicdrift/kumiko-framework/files`.
31
+ //
32
+ // PII-Annotations (Sprint 0.1+0.7+1.7):
33
+ // - fileName → pii: true (Originalname enthält oft Personen-Bezug:
34
+ // "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf")
35
+ //
36
+ // Andere Felder brauchen KEINE Annotation:
37
+ // - storageKey, mimeType, size, entityType, entityId, fieldName,
38
+ // insertedById → keine PII-typischen Field-Namen, PII-Heuristik
39
+ // greift nicht (siehe boot-validator.ts PII_DIRECT_NAME_HINTS).
40
+ // Ein allowPlaintext-Marker wäre Über-Annotation ohne Effekt.
41
+ // - insertedAt → Audit-Timestamp, framework-managed.
42
+ //
43
+ // Tabellenname matched die Framework-pgTable damit r.entity-Reads über
44
+ // dieselbe Postgres-Tabelle laufen.
45
+ export const fileRefEntity = createEntity({
46
+ table: "file_refs",
47
+ fields: {
48
+ storageKey: createTextField({ required: true }),
49
+ fileName: createTextField({ required: true, pii: true }),
50
+ mimeType: createTextField({ required: true }),
51
+ size: createNumberField({ required: true }),
52
+ entityType: createTextField(),
53
+ entityId: createTextField(),
54
+ fieldName: createTextField(),
55
+ insertedAt: createTimestampField({ sortable: true, filterable: true }),
56
+ insertedById: createTextField(),
57
+ },
58
+ });
@@ -1,3 +1,4 @@
1
+ import { Readable } from "node:stream";
1
2
  import {
2
3
  DeleteObjectCommand,
3
4
  GetObjectCommand,
@@ -5,9 +6,45 @@ import {
5
6
  PutObjectCommand,
6
7
  S3Client,
7
8
  } from "@aws-sdk/client-s3";
9
+ import { Upload } from "@aws-sdk/lib-storage";
8
10
  import { getSignedUrl as presign } from "@aws-sdk/s3-request-presigner";
9
11
  import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
10
12
 
13
+ // =============================================================================
14
+ // Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
15
+ // =============================================================================
16
+ //
17
+ // `writeStream` nutzt @aws-sdk/lib-storage's Upload-class fuer echtes
18
+ // multipart-streaming. S3 created dabei eine Multipart-Upload-Session mit
19
+ // einer Upload-ID; bei normaler Completion wird sie via Complete-
20
+ // MultipartUpload geschlossen.
21
+ //
22
+ // **Edge-Case bei Worker-Abort:** wenn der Export-Worker mid-write gecancelt
23
+ // wird (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Multipart-
24
+ // Upload-Session in S3 OFFEN. S3 behaelt die bereits hochgeladenen Parts
25
+ // und berechnet Storage-Kosten dafuer — bis sie manuell oder via Lifecycle-
26
+ // Rule abgebrochen werden.
27
+ //
28
+ // **Pflicht-Operator-Setup auf jedem Bucket:**
29
+ //
30
+ // {
31
+ // "Rules": [{
32
+ // "ID": "AbortIncompleteMultipartUploads",
33
+ // "Status": "Enabled",
34
+ // "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 },
35
+ // "Filter": {}
36
+ // }]
37
+ // }
38
+ //
39
+ // AWS-CLI: `aws s3api put-bucket-lifecycle-configuration --bucket <name>
40
+ // --lifecycle-configuration file://lifecycle.json`. Hetzner Object Storage
41
+ // + R2 + Minio supporten dieselbe Syntax.
42
+ //
43
+ // **Code-side abort()** fuer graceful Worker-Shutdown ist follow-up. Das
44
+ // braucht Worker-Cancel-Semantik (AbortSignal-Propagation durch r.job),
45
+ // die im framework noch nicht existiert. Bis dahin ist die Lifecycle-
46
+ // Rule die einzige Garantie gegen Storage-Leakage.
47
+
11
48
  // Minimal config surface — everything the SDK needs, nothing framework-
12
49
  // specific. Apps wire this into `buildServer({ files: { storageProvider } })`
13
50
  // the same way they'd pass createLocalProvider in dev.
@@ -62,6 +99,33 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
62
99
  );
63
100
  },
64
101
 
102
+ async writeStream(key, source, options): Promise<void> {
103
+ // Echtes multipart-streaming via @aws-sdk/lib-storage.Upload —
104
+ // der Source-AsyncIterable wird chunk-weise zu S3 hochgeladen,
105
+ // niemals alles im Memory aggregiert. lib-storage handled
106
+ // automatisch chunking (5MB-Parts default), parallel-uploads
107
+ // (4 concurrent default), und retry bei Part-Failures.
108
+ //
109
+ // Memory-Footprint: ~5MB pro in-flight-part × 4 concurrent =
110
+ // ~20MB Heap-Bound, unabhaengig von der Total-Bundle-Size. Macht
111
+ // 1GB+ Bundles moeglich ohne OOM.
112
+ //
113
+ // Readable.from(source) adapiert AsyncIterable → node:Readable —
114
+ // lib-storage's Body-Type akzeptiert Web-ReadableStream + node-
115
+ // Readable, nicht direkt AsyncIterable. Adapter ist zero-copy.
116
+ const body = Readable.from(source);
117
+ const upload = new Upload({
118
+ client,
119
+ params: {
120
+ Bucket: config.bucket,
121
+ Key: key,
122
+ Body: body,
123
+ ...(options?.mimeType !== undefined && { ContentType: options.mimeType }),
124
+ },
125
+ });
126
+ await upload.done();
127
+ },
128
+
65
129
  async read(key): Promise<Uint8Array> {
66
130
  const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
67
131
  if (!response.Body) {
@@ -73,6 +137,31 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
73
137
  return response.Body.transformToByteArray();
74
138
  },
75
139
 
140
+ readStream(key): AsyncIterable<Uint8Array> {
141
+ // S3 GetObject.Body ist ein StreamingBlobPayloadOutputTypes — auf
142
+ // node ist das ein Readable-Stream der bereits AsyncIterable<Buffer>
143
+ // ist. Wir wrappen lazy: erst beim ersten chunk-pull wird der
144
+ // GetObject-Request abgesetzt. Wenn der Key nicht existiert, faellt
145
+ // der Error genau dort (nicht beim readStream-Aufruf) — gleiches
146
+ // Lazy-Verhalten wie inmemory + local.
147
+ return {
148
+ async *[Symbol.asyncIterator]() {
149
+ const response = await client.send(
150
+ new GetObjectCommand({ Bucket: config.bucket, Key: key }),
151
+ );
152
+ if (!response.Body) {
153
+ throw new Error(`s3_read_empty_body: ${key}`);
154
+ }
155
+ // SdkStream is AsyncIterable<Buffer> on node. Buffer extends
156
+ // Uint8Array; cast sichert die Surface ohne neue runtime-deps.
157
+ const body = response.Body as AsyncIterable<Uint8Array>;
158
+ for await (const chunk of body) {
159
+ yield chunk;
160
+ }
161
+ },
162
+ };
163
+ },
164
+
76
165
  async delete(key): Promise<void> {
77
166
  await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
78
167
  },
@@ -0,0 +1,81 @@
1
+ // requireSecretsContext-Surface-Tests (S2.U3 Atom 3b.fix2).
2
+ //
3
+ // Pinst dass `requireSecretsContext` mit dem schmalen FileProviderContext-
4
+ // Surface funktioniert — nicht nur mit voller HandlerContext.
5
+ // Regression-Pin fuer den latenten Worker-Pfad-Bug:
6
+ // - Vor 3b.fix wurde `ctx as unknown as HandlerContext` durchgereicht
7
+ // - Im Worker-Pfad ist `ctx._userId` undefined (dispatcher setzt es nur
8
+ // im request-Pfad), also throw `_userId missing`
9
+ // - Fix: Worker-Wrap setzt explizit `_userId: SYSTEM_USER_ID`
10
+ //
11
+ // Der Test inszeniert beide Surfaces (HandlerContext-shape via type-assert
12
+ // + FileProviderContext-shape direkt) und prueft dass beide den happy-path
13
+ // + den fehlt-_userId-throw durchlaufen.
14
+
15
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
16
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
17
+ import { describe, expect, test, vi } from "vitest";
18
+ import { requireSecretsContext } from "../feature";
19
+
20
+ function makeRawSecretsContext(): SecretsContext {
21
+ return {
22
+ get: vi.fn(),
23
+ set: vi.fn(),
24
+ delete: vi.fn(),
25
+ };
26
+ }
27
+
28
+ describe("requireSecretsContext :: FileProviderContext surface", () => {
29
+ test("succeeds with secrets + _userId present (Worker-Pfad mit SYSTEM_USER_ID)", () => {
30
+ const fileProviderCtx = {
31
+ secrets: makeRawSecretsContext(),
32
+ _userId: SYSTEM_USER_ID,
33
+ };
34
+ expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).not.toThrow();
35
+ });
36
+
37
+ test("throws when _userId is missing (latenter Worker-Bug pre-3b.fix)", () => {
38
+ // Pinst die Falle die der 3b.fix abfaengt: wenn ein Provider-Plugin
39
+ // `requireSecretsContext` ruft und der ctx kein _userId hat (z.B. weil
40
+ // ein r.job-Wrap das vergessen hat zu setzen), faellt es FRUEH mit
41
+ // einer klaren Fehlermeldung um — nicht silent broken.
42
+ const fileProviderCtx = {
43
+ secrets: makeRawSecretsContext(),
44
+ // _userId absichtlich undefined
45
+ };
46
+ expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).toThrow(/_userId missing/);
47
+ });
48
+
49
+ test("throws when secrets is missing (boot-Misconfig)", () => {
50
+ const fileProviderCtx = {
51
+ _userId: SYSTEM_USER_ID,
52
+ // secrets absichtlich undefined
53
+ };
54
+ expect(() => requireSecretsContext(fileProviderCtx, "test-handler")).toThrow(
55
+ /ctx\.secrets missing/,
56
+ );
57
+ });
58
+
59
+ test("audit-userId reaches secrets.get when call is delegated", async () => {
60
+ // Pinst den Audit-Pfad: wenn ein Plugin secrets.get(...) aufruft, kommt
61
+ // _userId als audit.userId ohne Override durch. Das ist der Grund warum
62
+ // SYSTEM_USER_ID nicht durch einen ad-hoc-magic-string ersetzt werden
63
+ // darf — sonst wird der Audit-Trail inkonsistent.
64
+ const raw = makeRawSecretsContext();
65
+ const ctx = {
66
+ secrets: raw,
67
+ _userId: SYSTEM_USER_ID,
68
+ };
69
+ const wrapped = requireSecretsContext(ctx, "user-data-rights:run-export-jobs");
70
+ await wrapped.get(
71
+ "tenant-x" as Parameters<SecretsContext["get"]>[0],
72
+ "any-key" as unknown as Parameters<SecretsContext["get"]>[1],
73
+ );
74
+ // Erste-Aufruf-args: [tenantId, key, audit-Object]
75
+ const audit = vi.mocked(raw.get).mock.calls[0]?.[2];
76
+ expect(audit).toEqual({
77
+ userId: SYSTEM_USER_ID,
78
+ handlerName: "user-data-rights:run-export-jobs",
79
+ });
80
+ });
81
+ });
@@ -1,8 +1,4 @@
1
- import {
2
- defineFeature,
3
- type FeatureDefinition,
4
- type HandlerContext,
5
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
6
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
3
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
4
  import { deleteWrite } from "./handlers/delete.write";
@@ -24,7 +20,15 @@ export { type StoredEnvelope, type StoredMetadata, tenantSecretsTable } from "./
24
20
  // wraps that raw context so every `.get(...)` call auto-includes the
25
21
  // current user + handler as audit metadata — feature code can't forget
26
22
  // to log the read (silent bypass of audit was the v1 gap).
27
- export function requireSecretsContext(ctx: HandlerContext, handlerName: string): SecretsContext {
23
+ //
24
+ // Surface bewusst minimal: HandlerContext-Pfade (Set/Delete-Handler) +
25
+ // FileProviderContext-Pfade (S3-Plugin im Worker) liefern dieselben
26
+ // zwei Felder, also reicht die schmale ctx-shape — kein voller
27
+ // HandlerContext-Import noetig.
28
+ export function requireSecretsContext(
29
+ ctx: { readonly secrets?: SecretsContext; readonly _userId?: string | undefined },
30
+ handlerName: string,
31
+ ): SecretsContext {
28
32
  if (!ctx.secrets) {
29
33
  throw new InternalError({
30
34
  message:
@@ -5,6 +5,10 @@ export const SESSIONS_FEATURE = "sessions" as const;
5
5
  export const SessionHandlers = {
6
6
  revoke: "sessions:write:user-session:revoke",
7
7
  revokeAllOthers: "sessions:write:user-session:revoke-all-others",
8
+ /** Privileged: System-Caller (cross-feature) revokes ALL live sessions
9
+ * fuer einen User. Genutzt von user-data-rights:restrict-account
10
+ * (DSGVO Art. 18 Account-Freeze). */
11
+ revokeAllForUser: "sessions:write:user-session:revoke-all-for-user",
8
12
  } as const;
9
13
 
10
14
  export const SessionQueries = {
@@ -3,6 +3,7 @@ import { cleanupJob } from "./handlers/cleanup.job";
3
3
  import { listQuery } from "./handlers/list.query";
4
4
  import { mineQuery } from "./handlers/mine.query";
5
5
  import { revokeWrite } from "./handlers/revoke.write";
6
+ import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
6
7
  import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
7
8
  import { userSessionEntity } from "./schema/user-session";
8
9
  import type { SessionMassRevoker } from "./session-callbacks";
@@ -42,7 +43,9 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
42
43
  const handlers = {
43
44
  revoke: r.writeHandler(revokeWrite),
44
45
  revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
46
+ revokeAllForUser: r.writeHandler(revokeAllForUserWrite),
45
47
  };
48
+ r.exposesApi("sessions.revokeAllForUser");
46
49
 
47
50
  const queries = {
48
51
  mine: r.queryHandler(mineQuery),
@@ -0,0 +1,42 @@
1
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ import { Temporal } from "temporal-polyfill";
4
+ import { z } from "zod";
5
+ import { userSessionTable } from "../schema/user-session";
6
+
7
+ // Mass-revoke ALL live sessions for a target user — privileged-only.
8
+ // Used by user-data-rights:restrict-account zur Account-Freeze
9
+ // (DSGVO Art. 18) sowie potenziell anderen ops-flows ("ban user",
10
+ // "compromised account"). Im Gegensatz zu revoke-all-others wird
11
+ // die ggf. aufrufende Session ebenfalls revoked — Caller ist System
12
+ // (cron/operator/cross-feature), nicht der Endnutzer selbst.
13
+ //
14
+ // Tenant-scope: das userSession-Schema persistiert tenantId pro Row
15
+ // (User kann mehrere Sessions in mehreren Tenants haben). Wir
16
+ // revoken cross-tenant, weil "Account-Restriction" eine globale
17
+ // User-Aussage ist (Forget-Pfad ist auch global, sieht User-Entity-
18
+ // special-Doc). UPDATE filtert nur auf userId.
19
+ export const revokeAllForUserWrite = defineWriteHandler({
20
+ name: "user-session:revoke-all-for-user",
21
+ schema: z.object({
22
+ userId: z.string().min(1),
23
+ }),
24
+ access: { roles: access.privileged },
25
+ handler: async (event, ctx) => {
26
+ const updated = await ctx.db.raw
27
+ .update(userSessionTable)
28
+ .set({ revokedAt: Temporal.Now.instant() })
29
+ .where(
30
+ and(
31
+ eq(userSessionTable["userId"], event.payload.userId),
32
+ isNull(userSessionTable["revokedAt"]),
33
+ ),
34
+ )
35
+ .returning();
36
+
37
+ return {
38
+ isSuccess: true as const,
39
+ data: { count: updated.length, userId: event.payload.userId },
40
+ };
41
+ },
42
+ });