@cosmicdrift/kumiko-framework 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 (42) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +4 -3
  3. package/src/auth/__tests__/roles.test.ts +24 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/roles.ts +42 -0
  6. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  7. package/src/compliance/__tests__/profiles.test.ts +308 -0
  8. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  9. package/src/compliance/duration-spec.ts +44 -0
  10. package/src/compliance/index.ts +31 -0
  11. package/src/compliance/override-schema.ts +136 -0
  12. package/src/compliance/profiles.ts +427 -0
  13. package/src/compliance/sub-processors.ts +152 -0
  14. package/src/db/__tests__/big-int-field.test.ts +131 -0
  15. package/src/db/table-builder.ts +18 -1
  16. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  18. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  19. package/src/engine/boot-validator.ts +276 -0
  20. package/src/engine/define-feature.ts +39 -0
  21. package/src/engine/extension-names.ts +105 -0
  22. package/src/engine/extensions/user-data.ts +106 -0
  23. package/src/engine/factories.ts +15 -5
  24. package/src/engine/feature-ast/extractors.ts +40 -0
  25. package/src/engine/feature-ast/parse.ts +6 -0
  26. package/src/engine/feature-ast/patterns.ts +22 -0
  27. package/src/engine/feature-ast/render.ts +14 -0
  28. package/src/engine/index.ts +21 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
  30. package/src/engine/pattern-library/library.ts +36 -0
  31. package/src/engine/schema-builder.ts +8 -0
  32. package/src/engine/types/feature.ts +51 -0
  33. package/src/engine/types/fields.ts +134 -10
  34. package/src/engine/types/index.ts +3 -0
  35. package/src/files/__tests__/read-stream.test.ts +105 -0
  36. package/src/files/__tests__/write-stream.test.ts +233 -0
  37. package/src/files/__tests__/zip-stream.test.ts +357 -0
  38. package/src/files/in-memory-provider.ts +38 -0
  39. package/src/files/index.ts +3 -0
  40. package/src/files/local-provider.ts +58 -1
  41. package/src/files/types.ts +34 -6
  42. package/src/files/zip-stream.ts +251 -0
@@ -0,0 +1,160 @@
1
+ // Integration-Test: alle Sprint-0-Surfaces in einem Mini-Feature-Set.
2
+ //
3
+ // Beweist dass die einzelnen S0-Komponenten (PII-Annotations, retention,
4
+ // extension-names, exposesApi/usesApi, ROLES) zusammen funktionieren —
5
+ // keine still-konkurrierenden Validierungen, keine Race-Conditions
6
+ // zwischen Sub-Validatoren.
7
+ //
8
+ // Mini-Feature-Set:
9
+ // compliance-profiles exposesApi("compliance.forTenant")
10
+ // user-data-rights usesApi + extendsRegistrar(EXT_USER_DATA)
11
+ // tenant useExtension(EXT_USER_DATA, "user", ...)
12
+ // + entity mit pii / userOwned / tenantOwned-Fields
13
+ // + retention.blockDelete + anonymize-Funktion
14
+ // + handler-access mit ROLES.TenantAdmin
15
+
16
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
17
+ import { z } from "zod";
18
+ import { ROLES } from "../../auth";
19
+ import { validateBoot } from "../boot-validator";
20
+ import { defineFeature } from "../define-feature";
21
+ import {
22
+ createEntity,
23
+ createLongTextField,
24
+ createTextField,
25
+ createTimestampField,
26
+ EXT_USER_DATA,
27
+ } from "../index";
28
+
29
+ describe("S0 Integration — full surface stack", () => {
30
+ let warnSpy: ReturnType<typeof vi.spyOn>;
31
+
32
+ beforeEach(() => {
33
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
34
+ });
35
+
36
+ afterEach(() => {
37
+ warnSpy.mockRestore();
38
+ });
39
+
40
+ test("alle S0-Surfaces zusammen passen Boot-Validation", () => {
41
+ const complianceProfiles = defineFeature("compliance-profiles", (r) => {
42
+ r.exposesApi("compliance.forTenant");
43
+ r.queryHandler({
44
+ name: "compliance:query:for-tenant",
45
+ schema: z.object({}),
46
+ handler: async () => ({ profile: "eu-dsgvo" }) as never,
47
+ access: { openToAll: true },
48
+ });
49
+ });
50
+
51
+ const userDataRights = defineFeature("user-data-rights", (r) => {
52
+ r.requires("compliance-profiles");
53
+ r.usesApi("compliance.forTenant");
54
+ r.extendsRegistrar(EXT_USER_DATA, {
55
+ hooks: {},
56
+ });
57
+ });
58
+
59
+ const tenantFeature = defineFeature("tenant-app", (r) => {
60
+ r.requires("user-data-rights", "compliance-profiles");
61
+
62
+ r.entity(
63
+ "user",
64
+ createEntity({
65
+ fields: {
66
+ email: createTextField({ pii: true }),
67
+ displayName: createTextField({ pii: true }),
68
+ lastLoginAt: createTimestampField({ pii: true }),
69
+ },
70
+ retention: {
71
+ keepFor: "10y",
72
+ strategy: "blockDelete",
73
+ reference: "createdAt",
74
+ },
75
+ }),
76
+ );
77
+
78
+ r.entity(
79
+ "comment",
80
+ createEntity({
81
+ fields: {
82
+ body: createLongTextField({
83
+ userOwned: { ownerField: "authorId" },
84
+ anonymize: () => "[ANONYMIZED]",
85
+ }),
86
+ authorId: { type: "reference", entity: "user" },
87
+ },
88
+ }),
89
+ );
90
+
91
+ r.queryHandler({
92
+ name: "user:list",
93
+ schema: z.object({}),
94
+ handler: async () => ({ rows: [], nextCursor: null }) as never,
95
+ access: { openToAll: true },
96
+ });
97
+
98
+ r.useExtension(EXT_USER_DATA, "user", {});
99
+ r.useExtension(EXT_USER_DATA, "comment", {});
100
+
101
+ r.writeHandler({
102
+ name: "user:rename",
103
+ schema: z.object({ id: z.string(), displayName: z.string() }),
104
+ handler: async () => undefined as never,
105
+ access: { roles: [ROLES.TenantAdmin] },
106
+ });
107
+ });
108
+
109
+ expect(() => validateBoot([complianceProfiles, userDataRights, tenantFeature])).not.toThrow();
110
+ });
111
+
112
+ test("missing requires() on usesApi-target throws even when other surfaces are clean", () => {
113
+ const complianceProfiles = defineFeature("compliance-profiles", (r) => {
114
+ r.exposesApi("compliance.forTenant");
115
+ });
116
+
117
+ const userDataRights = defineFeature("user-data-rights", (r) => {
118
+ // VERGESSEN: r.requires("compliance-profiles")
119
+ r.usesApi("compliance.forTenant");
120
+ });
121
+
122
+ expect(() => validateBoot([complianceProfiles, userDataRights])).toThrow(
123
+ /not in requires\/optionalRequires\. Add r\.requires\("compliance-profiles"\)/,
124
+ );
125
+ });
126
+
127
+ test("retention.reference pointing to non-existent field throws even with valid PII annotations", () => {
128
+ const feature = defineFeature("test", (r) => {
129
+ r.entity(
130
+ "user",
131
+ createEntity({
132
+ fields: {
133
+ email: createTextField({ pii: true }),
134
+ },
135
+ retention: {
136
+ keepFor: "30d",
137
+ strategy: "hardDelete",
138
+ reference: "notARealField",
139
+ },
140
+ }),
141
+ );
142
+ });
143
+ expect(() => validateBoot([feature])).toThrow(
144
+ /retention\.reference "notARealField" does not exist/,
145
+ );
146
+ });
147
+
148
+ test("ROLES.TenantAdmin works as handler-access role string (no Admin/TenantAdmin drift)", () => {
149
+ const feature = defineFeature("test", (r) => {
150
+ r.entity("thing", createEntity({ fields: { ts: createTimestampField() } }));
151
+ r.writeHandler({
152
+ name: "thing:create",
153
+ schema: z.object({}),
154
+ handler: async () => undefined as never,
155
+ access: { roles: [ROLES.TenantAdmin] },
156
+ });
157
+ });
158
+ expect(() => validateBoot([feature])).not.toThrow();
159
+ });
160
+ });
@@ -8,10 +8,76 @@ import type {
8
8
  NavDefinition,
9
9
  WorkspaceDefinition,
10
10
  } from "./types";
11
+ import type { PiiAnnotations } from "./types/fields";
11
12
  import { normalizeEditField, normalizeListColumn } from "./types/screen";
12
13
 
13
14
  const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
14
15
 
16
+ // Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
17
+ // `userOwned` / `tenantOwned` / `allowPlaintext`-Marker → Boot-Warning.
18
+ // Lower-case compare für case-insensitive Match (displayName vs displayname).
19
+ //
20
+ // Bewusst NICHT in der Liste:
21
+ // - `name` allein — zu viele Geschäfts-Kontexte (product.name,
22
+ // tenant.name, role.name) sind kein PII. Personen-Namen werden
23
+ // ueber displayName / firstName / lastName / fullName erfasst.
24
+ //
25
+ // Quelle: docs/plans/datenschutz/crypto-shredding.md Boot-Validation-Sektion.
26
+ const PII_DIRECT_NAME_HINTS: ReadonlySet<string> = new Set([
27
+ "email",
28
+ "phone",
29
+ "phonenumber",
30
+ "mobile",
31
+ "address",
32
+ "street",
33
+ "postalcode",
34
+ "zipcode",
35
+ "zip",
36
+ "city",
37
+ "displayname",
38
+ "firstname",
39
+ "lastname",
40
+ "fullname",
41
+ "birthday",
42
+ "birthdate",
43
+ "dateofbirth",
44
+ "dob",
45
+ "ssn",
46
+ "taxid",
47
+ "vatid",
48
+ "passport",
49
+ "iban",
50
+ "bic",
51
+ ]);
52
+
53
+ // Field-Namen die typischerweise User-Generated-Content enthalten —
54
+ // User-Forget muss diese mit Author-Subject-Key encrypten.
55
+ const PII_USER_OWNED_NAME_HINTS: ReadonlySet<string> = new Set([
56
+ "body",
57
+ "text",
58
+ "content",
59
+ "message",
60
+ "comment",
61
+ "description",
62
+ "note",
63
+ "notes",
64
+ ]);
65
+
66
+ // Framework-managed Timestamp-Spalten — dürfen als retention.reference
67
+ // genutzt werden auch wenn nicht in entity.fields deklariert.
68
+ const FRAMEWORK_TIMESTAMP_FIELDS: ReadonlySet<string> = new Set([
69
+ "createdAt",
70
+ "updatedAt",
71
+ "lastSeenAt",
72
+ "deletedAt",
73
+ ]);
74
+
75
+ // Erlaubtes Format fuer retention.keepFor — Zahlen + Suffix (h/d/w/m/y).
76
+ // Echtes Parsen kommt mit dem Cleanup-Job in Sprint 2; Boot-Validator
77
+ // macht nur den Sanity-Check damit Tippfehler ("30days") frueh sichtbar
78
+ // werden statt erst beim ersten Cleanup-Run.
79
+ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
80
+
15
81
  /**
16
82
  * Validates all feature configurations at boot time.
17
83
  * Throws on the first error found — fail fast.
@@ -71,6 +137,24 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
71
137
  const allWorkspaceQns = collectWorkspaceQns(features);
72
138
  const allWriteHandlerQns = collectWriteHandlerQns(features);
73
139
 
140
+ // Cross-feature API exposure-map — jedes Feature deklariert Marker via
141
+ // r.exposesApi(name). Per-feature validateApiExposureMatching walkt
142
+ // usedApis-Set und checkt dass jeder Eintrag hier einen Match findet.
143
+ // Verhindert dass typo-getroffene oder gedroppte QN-Aufrufe zu
144
+ // Runtime-Crash statt Boot-Fail werden.
145
+ const allExposedApis = new Map<string, string>(); // apiName → providerFeature
146
+ for (const f of features) {
147
+ for (const apiName of f.exposedApis) {
148
+ const existing = allExposedApis.get(apiName);
149
+ if (existing && existing !== f.name) {
150
+ throw new Error(
151
+ `Cross-feature API "${apiName}" exposed by both "${existing}" and "${f.name}" — API names must be globally unique.`,
152
+ );
153
+ }
154
+ allExposedApis.set(apiName, f.name);
155
+ }
156
+ }
157
+
74
158
  let hasEncryptedFields = false;
75
159
  let hasFileFields = false;
76
160
 
@@ -78,6 +162,8 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
78
162
  validateCircularDeps(feature.name, featureMap);
79
163
  if (validateEncryptedFields(feature)) hasEncryptedFields = true;
80
164
  if (validateFileFields(feature)) hasFileFields = true;
165
+ validatePiiAndRetention(feature);
166
+ validateApiExposureMatching(feature, allExposedApis, featureMap);
81
167
  validateEmbeddedFields(feature);
82
168
  validateMultiSelectFields(feature);
83
169
  validateReferenceFields(feature, featureMap);
@@ -498,6 +584,196 @@ function validateFileFields(feature: FeatureDefinition): boolean {
498
584
  return false;
499
585
  }
500
586
 
587
+ // --- PII / Subject-Key Annotations + Retention validation ---
588
+ //
589
+ // Drei Klassen von Checks:
590
+ //
591
+ // 1. Mutual exclusion: pro Field nur EINE der drei Subject-Annotations
592
+ // (pii / userOwned / tenantOwned). Mehr ist semantisch widersprüchlich
593
+ // weil pro Field genau ein Subject-Key gehört.
594
+ //
595
+ // 2. Reference-Integrity: userOwned.ownerField muss auf ein existierendes
596
+ // reference-Field zeigen (das auf user-Entity zeigen sollte). Erkennt
597
+ // Tippfehler und Drop-Refactorings beim Boot statt beim ersten
598
+ // Encrypt-Aufruf.
599
+ //
600
+ // 3. Heuristik-Warnings: Field-Namen die typischerweise PII enthalten
601
+ // (email, name, phone, body, etc.) ohne Annotation → Boot-Warning.
602
+ // Mit `allowPlaintext: "<reason>"` unterdrückbar (geht in Audit).
603
+ //
604
+ // 4. Retention-Integrity: retention.reference (wenn gesetzt) muss auf
605
+ // ein bestehendes Field zeigen (oder Framework-Timestamp). retention.
606
+ // strategy="blockDelete" ohne anonymize-Felder ist sinnlos — User-
607
+ // Forget kann nichts machen, Warning.
608
+ //
609
+ // Encrypt/Decrypt-Mechanik landet in Sprint 3 (crypto-shredding); diese
610
+ // Validation greift schon ab Sprint 0 damit Schema-Drift früh auffällt.
611
+ function validatePiiAndRetention(feature: FeatureDefinition): void {
612
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
613
+ const fieldsByName = entity.fields;
614
+
615
+ for (const [fieldName, field] of Object.entries(fieldsByName)) {
616
+ // PiiAnnotations-Properties sind type-level optional. Auf Field-
617
+ // Defs die nicht via "& PiiAnnotations" erweitert sind (Boolean,
618
+ // Money, Reference, Embedded, Tz, LocatedTimestamp, File*, Image*)
619
+ // liefert property-access undefined zur Runtime. Die TS-Compile-
620
+ // Time-Validation hat dort schon abgelehnt → Cast ist safe.
621
+ const annot = field as PiiAnnotations;
622
+
623
+ const hasPii = Boolean(annot.pii);
624
+ const hasUserOwned = Boolean(annot.userOwned);
625
+ const hasTenantOwned = Boolean(annot.tenantOwned);
626
+ const annotCount = (hasPii ? 1 : 0) + (hasUserOwned ? 1 : 0) + (hasTenantOwned ? 1 : 0);
627
+
628
+ if (annotCount > 1) {
629
+ throw new Error(
630
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (pii / userOwned / tenantOwned). Pick one — each field belongs to exactly one subject.`,
631
+ );
632
+ }
633
+
634
+ if (annot.userOwned) {
635
+ const ownerName = annot.userOwned.ownerField;
636
+ if (!ownerName || typeof ownerName !== "string") {
637
+ throw new Error(
638
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has userOwned without ownerField name`,
639
+ );
640
+ }
641
+ const ownerField = fieldsByName[ownerName];
642
+ if (!ownerField) {
643
+ const known = Object.keys(fieldsByName).sort().join(", ");
644
+ throw new Error(
645
+ `[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references userOwned.ownerField "${ownerName}" but no such field exists. Known fields: ${known}`,
646
+ );
647
+ }
648
+ if (ownerField.type !== "reference") {
649
+ throw new Error(
650
+ `[Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" must be a reference field, got type "${ownerField.type}"`,
651
+ );
652
+ }
653
+ // Soft-Warning wenn das reference-target nicht offensichtlich user
654
+ // ist — custom subject-entities (HR-Mitarbeiter, Patient) sind
655
+ // erlaubt, müssen aber bewusste Wahl sein.
656
+ const refTarget = ownerField.entity;
657
+ const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
658
+ if (targetEntity !== "user") {
659
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
660
+ console.warn(
661
+ `[kumiko:boot] [Feature ${feature.name}] userOwned.ownerField "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
662
+ );
663
+ }
664
+ }
665
+
666
+ // PII-Heuristik: nur wenn keine Annotation gesetzt UND kein
667
+ // allowPlaintext-Marker. Ergibt false positives auf Geschäftsdaten
668
+ // mit personenartigem Namen (z.B. company.legalName) — Author
669
+ // unterdrückt mit { allowPlaintext: "is-business-data" }.
670
+ const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
671
+ if (noAnnotation) {
672
+ const lower = fieldName.toLowerCase();
673
+ if (PII_DIRECT_NAME_HINTS.has(lower)) {
674
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
675
+ console.warn(
676
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no { pii: true } annotation. If this is PII, mark it. If business data, set { allowPlaintext: "is-business-data" } to silence.`,
677
+ );
678
+ } else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
679
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
680
+ console.warn(
681
+ `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no { userOwned } annotation. If this contains user-generated content, mark it { userOwned: { ownerField: "<authorIdField>" }}. If business data, set { allowPlaintext: "..." } to silence.`,
682
+ );
683
+ }
684
+ }
685
+ }
686
+
687
+ // --- Entity-level retention ---
688
+ const retention = entity.retention;
689
+ if (retention) {
690
+ if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
691
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
692
+ console.warn(
693
+ `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" hat ungueltiges Format. Erwartet: <Zahl><h|d|w|m|y> (z.B. "30d", "10y", "6m"). Cleanup-Job (Sprint 2) wird das nicht parsen koennen.`,
694
+ );
695
+ }
696
+
697
+ if (retention.reference !== undefined) {
698
+ const refName = retention.reference;
699
+ if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
700
+ const known = Object.keys(fieldsByName).sort().join(", ");
701
+ const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
702
+ throw new Error(
703
+ `[Feature ${feature.name}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`,
704
+ );
705
+ }
706
+ }
707
+
708
+ if (retention.strategy === "blockDelete") {
709
+ const hasAnonymize = Object.values(fieldsByName).some((f) => {
710
+ const a = f as PiiAnnotations;
711
+ return Boolean(a.anonymize);
712
+ });
713
+ if (!hasAnonymize) {
714
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
715
+ console.warn(
716
+ `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
717
+ );
718
+ }
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ // --- Cross-feature API exposure / usage matching ---
725
+ //
726
+ // `r.exposesApi(name, impl)` registers a callable; `r.usesApi(name)`
727
+ // declares a caller. Boot-Validator prüft drei Invarianten:
728
+ // 1. Jeder usesApi(name) findet einen exposesApi(name) in irgendeinem
729
+ // Feature.
730
+ // 2. Das exposing-Feature ist in requires/optionalRequires des callers
731
+ // gelisted (sonst klappt die Cross-Feature-Aufruf-Reihenfolge nicht).
732
+ // 3. Self-exposure ist erlaubt (Feature ruft eigene API), wird aber
733
+ // mit Warning markiert weil es typisch ein Refactor-Restbestand ist.
734
+ //
735
+ // Globale Eindeutigkeit der apiNames (kein Dublicate über Features)
736
+ // wird in validateBoot() vor dem Per-Feature-Walk geprüft.
737
+ function validateApiExposureMatching(
738
+ feature: FeatureDefinition,
739
+ allExposedApis: ReadonlyMap<string, string>,
740
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
741
+ ): void {
742
+ for (const apiName of feature.usedApis) {
743
+ const providerFeature = allExposedApis.get(apiName);
744
+ if (!providerFeature) {
745
+ const known = [...allExposedApis.keys()].sort().join(", ") || "(none)";
746
+ throw new Error(
747
+ `[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`,
748
+ );
749
+ }
750
+
751
+ if (providerFeature === feature.name) {
752
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
753
+ console.warn(
754
+ `[kumiko:boot] [Feature ${feature.name}] r.usesApi("${apiName}") on its own r.exposesApi — typically a refactor leftover. Call the impl directly instead.`,
755
+ );
756
+ continue;
757
+ }
758
+
759
+ const allDeps = [...feature.requires, ...feature.optionalRequires];
760
+ if (!allDeps.includes(providerFeature)) {
761
+ throw new Error(
762
+ `[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires("${providerFeature}").`,
763
+ );
764
+ }
765
+
766
+ // Sanity: provider feature actually exists in this app's feature set.
767
+ // Should always be true if allExposedApis was built from `features`,
768
+ // aber defensiv für unklare Constructor-Pfade.
769
+ if (!featureMap.has(providerFeature)) {
770
+ throw new Error(
771
+ `[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map`,
772
+ );
773
+ }
774
+ }
775
+ }
776
+
501
777
  // --- Extension usage validation ---
502
778
 
503
779
  function validateExtensionUsages(
@@ -112,6 +112,8 @@ export function defineFeature<const TName extends string, TExports = undefined>(
112
112
  const notifications: Record<string, NotificationDefinition> = {};
113
113
  const registrarExtensions: Record<string, RegistrarExtensionDef> = {};
114
114
  const extensionUsages: RegistrarExtensionRegistration[] = [];
115
+ const exposedApis: Set<string> = new Set();
116
+ const usedApis: Set<string> = new Set();
115
117
  const referenceData: ReferenceDataDef[] = [];
116
118
  const handlerEntityMappings: Record<string, string> = {};
117
119
  const metrics: Record<string, FeatureMetricDef> = {};
@@ -464,6 +466,41 @@ export function defineFeature<const TName extends string, TExports = undefined>(
464
466
  extensionUsages.push({ extensionName, entityName: resolveName(entityRef), options });
465
467
  },
466
468
 
469
+ /**
470
+ * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
471
+ * unter dem genannten Namen bereit. Die eigentliche Implementation
472
+ * wird separat als Query- oder Write-Handler unter dem QN-Pattern
473
+ * registriert; r.exposesApi ist reine Boot-Check-Surface.
474
+ *
475
+ * Beispiel:
476
+ * defineFeature("compliance-profiles", (r) => {
477
+ * r.exposesApi("compliance.forTenant");
478
+ * r.queryHandler({ name: "compliance:query:for-tenant", ... });
479
+ * });
480
+ * defineFeature("user-data-rights", (r) => {
481
+ * r.requires("compliance-profiles");
482
+ * r.usesApi("compliance.forTenant");
483
+ * // ruft im Handler: ctx.callQuery("compliance:query:for-tenant", ...)
484
+ * });
485
+ */
486
+ exposesApi(apiName: string): void {
487
+ if (exposedApis.has(apiName)) {
488
+ throw new Error(
489
+ `[Feature ${name}] r.exposesApi("${apiName}") called twice — API names must be unique within a feature.`,
490
+ );
491
+ }
492
+ exposedApis.add(apiName);
493
+ },
494
+
495
+ /**
496
+ * Declares that this feature calls a cross-feature API. Boot-Validator
497
+ * checkt dass irgendein anderes Feature `r.exposesApi(name)` macht und
498
+ * dass dieses Feature `r.requires` darauf hat.
499
+ */
500
+ usesApi(apiName: string): void {
501
+ usedApis.add(apiName);
502
+ },
503
+
467
504
  metric(shortName: string, options: MetricOptions): void {
468
505
  if (metrics[shortName]) {
469
506
  throw new Error(
@@ -720,6 +757,8 @@ export function defineFeature<const TName extends string, TExports = undefined>(
720
757
  notifications,
721
758
  registrarExtensions,
722
759
  extensionUsages,
760
+ exposedApis,
761
+ usedApis,
723
762
  referenceData,
724
763
  events,
725
764
  eventMigrations,
@@ -0,0 +1,105 @@
1
+ // Standardisierte Extension-Namen fuer Datenschutz-Hook-Achsen.
2
+ //
3
+ // Features registrieren Extensions via:
4
+ // r.extendsRegistrar(EXT_USER_DATA, { hooks: { ... } });
5
+ //
6
+ // Andere Features haengen sich ein via:
7
+ // r.useExtension(EXT_USER_DATA, "myEntity", { ...hookImpls });
8
+ //
9
+ // Hintergrund: Magic-Strings driften zwischen Bundled-Features (Beispiel:
10
+ // text-content nutzt "Admin" als Rolle, tenant-handler nutzt "TenantAdmin").
11
+ // Constants sind die einzige Quelle der Wahrheit; String-Literale werden
12
+ // in den Sprint-Touchpoints schrittweise ersetzt. Boot-Validator
13
+ // (validateExtensionUsages) checkt dass jedes useExtension einen
14
+ // passenden extendsRegistrar findet — Tippfehler in Constants →
15
+ // Compile-Time-Fail.
16
+ //
17
+ // Hook-Signaturen + Boot-Validation pro Extension-Achse kommen mit dem
18
+ // jeweiligen registrierenden Sprint:
19
+ // userData / tenantData → Sprint 2 (user-data-rights, retention)
20
+ // storageProvider → Sprint 4 (storage-encryption)
21
+ // searchAdapter / external / → Sprint 5 (tenant-lifecycle)
22
+ // infraResource
23
+
24
+ /**
25
+ * `userData` — User-Daten-Rights-Hooks (DSGVO Art. 15 + 17 + 20).
26
+ *
27
+ * Erwartete Hook-Methoden:
28
+ * - `export(userId, ctx) => Promise<UserDataExport>`
29
+ * - `delete(userId, strategy: "delete" | "anonymize", ctx) => Promise<void>`
30
+ *
31
+ * Registriert von: `user-data-rights` (Sprint 2).
32
+ * Genutzt von: jedes Feature mit User-Referenzen (tasks, comments, files, ...).
33
+ */
34
+ export const EXT_USER_DATA = "userData" as const;
35
+
36
+ /**
37
+ * `tenantData` — Tenant-Destroy-Hooks pro Entity (DSGVO + AVV-Beendigung).
38
+ *
39
+ * Erwartete Hook-Methoden:
40
+ * - `destroy(tenantId, ctx) => Promise<void>`
41
+ *
42
+ * Registriert von: `tenant-lifecycle` (Sprint 5).
43
+ * Genutzt von: jedes Feature mit tenantId-Field.
44
+ */
45
+ export const EXT_TENANT_DATA = "tenantData" as const;
46
+
47
+ /**
48
+ * `storageProvider` — File-Storage-Plugin-Hooks (Crypto-Shredding fuer Files).
49
+ *
50
+ * Erwartete Hook-Methoden:
51
+ * - `destroyTenant(tenantId, ctx) => Promise<void>`
52
+ * - `destroySubject(subject, ctx) => Promise<{ deleted: number }>`
53
+ *
54
+ * Registriert von: `storage-encryption` (Sprint 4).
55
+ * Genutzt von: pluggable Provider (Local, MinIO, S3, R2).
56
+ */
57
+ export const EXT_STORAGE_PROVIDER = "storageProvider" as const;
58
+
59
+ /**
60
+ * `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
61
+ * bei User-Forget oder Tenant-Destroy).
62
+ *
63
+ * Erwartete Hook-Methoden:
64
+ * - `destroyTenant(tenantId, ctx) => Promise<void>`
65
+ * - `eraseSubject(subject, ctx) => Promise<void>`
66
+ *
67
+ * Registriert von: `tenant-lifecycle` (Sprint 5).
68
+ * Genutzt von: Meilisearch- und andere Search-Adapter-Implementierungen.
69
+ */
70
+ export const EXT_SEARCH_ADAPTER = "searchAdapter" as const;
71
+
72
+ /**
73
+ * `externalResource` — External-Service-Tenant-Cleanup
74
+ * (Webhook-Subscriptions, Brevo-Empfaenger-Listen, Stripe-Customer-Account).
75
+ *
76
+ * Erwartete Hook-Methoden:
77
+ * - `destroyTenant(tenantId, ctx) => Promise<void>`
78
+ *
79
+ * Registriert von: `tenant-lifecycle` (Sprint 5).
80
+ */
81
+ export const EXT_EXTERNAL_RESOURCE = "externalResource" as const;
82
+
83
+ /**
84
+ * `infraResource` — Pulumi-managed Resources pro Tenant
85
+ * (Custom-Domain, Cert-Manager-Issuer, dedicated Pod/Volume).
86
+ *
87
+ * Erwartete Hook-Methoden:
88
+ * - `destroyTenant(tenantId, ctx) => Promise<void>`
89
+ *
90
+ * Registriert von: `tenant-lifecycle` (Sprint 5).
91
+ */
92
+ export const EXT_INFRA_RESOURCE = "infraResource" as const;
93
+
94
+ /**
95
+ * Union aller standardisierten Extension-Namen der Datenschutz-Surface.
96
+ * Nicht alle Extensions im System sind in dieser Liste — andere
97
+ * Features koennen weiterhin eigene Extension-Namen registrieren.
98
+ */
99
+ export type KumikoExtensionName =
100
+ | typeof EXT_USER_DATA
101
+ | typeof EXT_TENANT_DATA
102
+ | typeof EXT_STORAGE_PROVIDER
103
+ | typeof EXT_SEARCH_ADAPTER
104
+ | typeof EXT_EXTERNAL_RESOURCE
105
+ | typeof EXT_INFRA_RESOURCE;