@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.
Files changed (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  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/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -17,8 +17,11 @@
17
17
  // **NICHT für Production.** Buffer ist Process-Memory, geht beim
18
18
  // Restart verloren + wächst monoton mit jedem write.
19
19
 
20
- import type { FileProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
21
- import { defineFeature, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
20
+ import type {
21
+ FileProviderContext,
22
+ FileProviderPlugin,
23
+ } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
24
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
22
25
  import {
23
26
  createInMemoryFileProvider,
24
27
  type FileStorageProvider,
@@ -63,7 +66,7 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
63
66
  r.requires("file-foundation");
64
67
 
65
68
  const plugin: FileProviderPlugin = {
66
- build: async (_ctx: HandlerContext, tenantId: string): Promise<FileStorageProvider> => {
69
+ build: async (_ctx: FileProviderContext, tenantId: string): Promise<FileStorageProvider> => {
67
70
  // Returnt den per-tenant Storage. Identitätsstabil zwischen calls
68
71
  // damit accumulated state erhalten bleibt.
69
72
  return getOrCreateProviderForTenant(tenantId);
@@ -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;
@@ -131,13 +129,13 @@ async function buildS3Provider(
131
129
  await ctxConfig(fileProviderS3Feature.exports.configKeys.endpoint),
132
130
  FEATURE_NAME,
133
131
  "endpoint",
134
- ) as string;
132
+ ) as string; // @cast-boundary engine-payload
135
133
  const endpoint = endpointRaw.length > 0 ? endpointRaw : undefined;
136
134
  const forcePathStyle = requireDefined(
137
135
  await ctxConfig(fileProviderS3Feature.exports.configKeys.forcePathStyle),
138
136
  FEATURE_NAME,
139
137
  "forcePathStyle",
140
- ) as boolean;
138
+ ) as boolean; // @cast-boundary engine-payload
141
139
  const accessKeyId = requireNonEmpty(
142
140
  await ctxConfig(fileProviderS3Feature.exports.configKeys.accessKeyId),
143
141
  FEATURE_NAME,
@@ -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>; // @cast-boundary engine-bridge
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
  },
@@ -85,7 +174,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
85
174
  // S3 SDK throws either NotFound or a generic 404. Check both the
86
175
  // `.name` property (newer SDKs) and the `$metadata.httpStatusCode`
87
176
  // (what the SDK guarantees on every error).
88
- const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
177
+ const err = error as { name?: string; $metadata?: { httpStatusCode?: number } }; // @cast-boundary error-details
89
178
  if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
90
179
  return false;
91
180
  }
@@ -1,5 +1,5 @@
1
1
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import { and, desc, eq } from "drizzle-orm";
2
+ import { and, desc, eq, type SQL } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import { type JobRunStatus, jobRunsTable } from "../job-run-table";
5
5
 
@@ -13,13 +13,13 @@ export const listQuery = defineQueryHandler({
13
13
  access: { roles: ["SystemAdmin"] },
14
14
  handler: async (query, ctx) => {
15
15
  const db = ctx.db;
16
- const conditions = [];
16
+ const conditions: SQL[] = [];
17
17
 
18
18
  if (query.payload.jobName) {
19
19
  conditions.push(eq(jobRunsTable.jobName, query.payload.jobName));
20
20
  }
21
21
  if (query.payload.status) {
22
- conditions.push(eq(jobRunsTable.status, query.payload.status as JobRunStatus));
22
+ conditions.push(eq(jobRunsTable.status, query.payload.status as JobRunStatus)); // @cast-boundary engine-payload
23
23
  }
24
24
 
25
25
  const limit = query.payload.limit ?? 50;
@@ -14,7 +14,7 @@ export const triggerWrite = defineWriteHandler({
14
14
  handler: async (event, ctx) => {
15
15
  const registry = ctx.registry;
16
16
  // `jobRunner` is a dynamic context extension — not a core HandlerContext field.
17
- const jobRunner = ctx["jobRunner"] as JobRunner;
17
+ const jobRunner = ctx["jobRunner"] as JobRunner; // @cast-boundary dynamic-key
18
18
 
19
19
  const jobDef = registry.getJob(event.payload.jobName);
20
20
  if (!jobDef) {
@@ -1,3 +1,4 @@
1
+ // @runtime client
1
2
  // Feature name
2
3
  export const LEGAL_PAGES_FEATURE = "legal-pages" as const;
3
4
 
@@ -0,0 +1,42 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für legal-pages Visual-Tree. Liefert statische
3
+ // Tree-Knoten für die DACH-Compliance-Blöcke (imprint, privacy in de/en).
4
+ // Jeder Knoten linkt auf text-content's edit-Action — reines Cross-Feature-
5
+ // Linking, kein eigener State oder Fetch nötig.
6
+ //
7
+ // **Static statt fetch**: legal-pages weiß out-of-the-box welche Blocks
8
+ // existieren (LEGAL_REQUIRED_BLOCKS + LEGAL_OPTIONAL_BLOCKS aus constants).
9
+ // Anders als text-content's Provider (der alle Slugs des Tenants holt)
10
+ // ist diese Liste bekannt zur Build-Zeit — kein /api/query-Round-trip nötig.
11
+ //
12
+ // **Content-State unbekannt**: V.1.2 setzt keine state-Markierung; alle
13
+ // Knoten erscheinen "filled" (default). V.1.3+ könnte via by-slug-Query
14
+ // ermitteln ob ein Block tatsächlich body hat und „stub" markieren wenn
15
+ // leer (Provider-Author-Hinweis dass Block existiert aber befüllt werden
16
+ // muss). Aktuell ist legal-pages's Boot-Check der primäre Wächter für
17
+ // fehlende Pflicht-Blocks.
18
+
19
+ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
20
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
21
+ import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants";
22
+
23
+ const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
24
+ const allBlocks = [...LEGAL_REQUIRED_BLOCKS, ...LEGAL_OPTIONAL_BLOCKS];
25
+ const nodes: readonly TreeNode[] = allBlocks.map((b) => ({
26
+ label: `${b.slug} (${b.lang})`,
27
+ target: {
28
+ featureId: "text-content",
29
+ action: "edit",
30
+ args: { slug: b.slug, lang: b.lang },
31
+ },
32
+ }));
33
+ emit(nodes);
34
+ return () => {};
35
+ };
36
+
37
+ export function legalPagesClient(): ClientFeatureDefinition {
38
+ return {
39
+ name: "legal-pages",
40
+ treeProvider,
41
+ };
42
+ }
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+ // Public exports für die Browser-Seite des legal-pages Features.
3
+
4
+ export { legalPagesClient } from "./client-plugin";
@@ -134,7 +134,7 @@ export async function createTransportForTenant(
134
134
  await ctxConfig(mailFoundationFeature.exports.configKeys.provider),
135
135
  FEATURE_NAME,
136
136
  "provider",
137
- ) as string;
137
+ ) as string; // @cast-boundary engine-payload
138
138
  if (provider.length === 0) {
139
139
  const usages = ctx.registry.getExtensionUsages("mailTransport");
140
140
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
@@ -140,12 +140,12 @@ async function buildSmtpTransport(ctx: HandlerContext, tenantId: string): Promis
140
140
  await ctxConfig(mailTransportSmtpFeature.exports.configKeys.port),
141
141
  FEATURE_NAME,
142
142
  "port",
143
- ) as number;
143
+ ) as number; // @cast-boundary engine-payload
144
144
  const secure = requireDefined(
145
145
  await ctxConfig(mailTransportSmtpFeature.exports.configKeys.secure),
146
146
  FEATURE_NAME,
147
147
  "secure",
148
- ) as boolean;
148
+ ) as boolean; // @cast-boundary engine-payload
149
149
  const from = requireNonEmpty(
150
150
  await ctxConfig(mailTransportSmtpFeature.exports.configKeys.from),
151
151
  FEATURE_NAME,
@@ -38,7 +38,7 @@ export const simpleRenderer: NotificationRenderer = {
38
38
  name: "simple",
39
39
 
40
40
  async render(input) {
41
- const data = input.variables as EmailTemplateData;
41
+ const data = input.variables as EmailTemplateData; // @cast-boundary render-helper
42
42
 
43
43
  // Fallback: if no structured fields, use title + body as header + single text section
44
44
  const header = data.header ?? data.title;