@cosmicdrift/kumiko-framework 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/package.json +3 -2
- package/src/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- package/src/db/table-builder.ts +18 -1
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
- package/src/engine/boot-validator.ts +276 -0
- package/src/engine/define-feature.ts +39 -0
- package/src/engine/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +15 -5
- package/src/engine/feature-ast/extractors.ts +40 -0
- package/src/engine/feature-ast/parse.ts +6 -0
- package/src/engine/feature-ast/patterns.ts +22 -0
- package/src/engine/feature-ast/render.ts +14 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
- package/src/engine/pattern-library/library.ts +36 -0
- package/src/engine/schema-builder.ts +8 -0
- package/src/engine/types/feature.ts +51 -0
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/index.ts +3 -0
- package/src/files/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +34 -6
- 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;
|