@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/package.json +3 -2
  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,570 @@
1
+ // Boot-Validator-Tests für PII-Annotations + Retention (S0.2).
2
+ //
3
+ // Pflicht-Validierungen (Error / throw):
4
+ // - Mutual exclusion: pii / userOwned / tenantOwned exklusiv pro Feld.
5
+ // - userOwned.ownerField muss existieren + ein reference-Feld sein.
6
+ // - retention.reference muss auf existierendes Feld oder Framework-
7
+ // Timestamp (createdAt/updatedAt/lastSeenAt/deletedAt) zeigen.
8
+ //
9
+ // Heuristik-Warnings (console.warn, kein throw):
10
+ // - Field-Name email/name/phone etc. ohne pii-Annotation.
11
+ // - Field-Name body/text/content etc. ohne userOwned-Annotation.
12
+ // - blockDelete-Strategy ohne anonymize-Felder.
13
+ // - userOwned.ownerField zeigt auf reference, target ist NICHT user.
14
+ //
15
+ // allowPlaintext-Marker unterdrückt Heuristik-Warnings.
16
+
17
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
18
+ import { z } from "zod";
19
+ import { validateBoot } from "../boot-validator";
20
+ import { defineFeature } from "../define-feature";
21
+ import {
22
+ createBooleanField,
23
+ createDateField,
24
+ createEmbeddedField,
25
+ createEntity,
26
+ createLocatedTimestampField,
27
+ createLongTextField,
28
+ createMultiSelectField,
29
+ createNumberField,
30
+ createSelectField,
31
+ createTextField,
32
+ createTimestampField,
33
+ createTzField,
34
+ } from "../factories";
35
+
36
+ // Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
37
+ // Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
38
+ // wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
39
+ // "no list-query-handler is registered there".
40
+ // biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
41
+ function stubListHandler(r: any, entityName: string): void {
42
+ r.queryHandler({
43
+ name: `${entityName}:list`,
44
+ schema: z.object({}),
45
+ handler: async () => ({ rows: [], nextCursor: null }) as never,
46
+ access: { openToAll: true },
47
+ });
48
+ }
49
+
50
+ describe("validateBoot — PII annotations", () => {
51
+ let warnSpy: ReturnType<typeof vi.spyOn>;
52
+
53
+ beforeEach(() => {
54
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
55
+ });
56
+
57
+ afterEach(() => {
58
+ warnSpy.mockRestore();
59
+ });
60
+
61
+ test("pii: true passes on text field", () => {
62
+ const feature = defineFeature("test", (r) => {
63
+ r.entity(
64
+ "user",
65
+ createEntity({
66
+ fields: {
67
+ email: createTextField({ pii: true }),
68
+ },
69
+ }),
70
+ );
71
+ });
72
+ expect(() => validateBoot([feature])).not.toThrow();
73
+ });
74
+
75
+ test("tenantOwned: true passes on text field", () => {
76
+ const feature = defineFeature("test", (r) => {
77
+ r.entity(
78
+ "branding",
79
+ createEntity({
80
+ fields: {
81
+ brandColor: createTextField({ tenantOwned: true }),
82
+ },
83
+ }),
84
+ );
85
+ });
86
+ expect(() => validateBoot([feature])).not.toThrow();
87
+ });
88
+
89
+ test("userOwned with valid ownerField on reference passes", () => {
90
+ const feature = defineFeature("test", (r) => {
91
+ r.entity("user", createEntity({ fields: { email: createTextField({ pii: true }) } }));
92
+ stubListHandler(r, "user");
93
+ r.entity(
94
+ "comment",
95
+ createEntity({
96
+ fields: {
97
+ body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
98
+ authorId: { type: "reference", entity: "user" },
99
+ },
100
+ }),
101
+ );
102
+ });
103
+ expect(() => validateBoot([feature])).not.toThrow();
104
+ });
105
+
106
+ test("multiple subject annotations on same field throw", () => {
107
+ const feature = defineFeature("test", (r) => {
108
+ r.entity(
109
+ "thing",
110
+ createEntity({
111
+ fields: {
112
+ confused: createTextField({ pii: true, tenantOwned: true }),
113
+ },
114
+ }),
115
+ );
116
+ });
117
+ expect(() => validateBoot([feature])).toThrow(/multiple subject-key annotations/);
118
+ });
119
+
120
+ test("userOwned.ownerField pointing to non-existent field throws", () => {
121
+ const feature = defineFeature("test", (r) => {
122
+ r.entity(
123
+ "comment",
124
+ createEntity({
125
+ fields: {
126
+ body: createLongTextField({ userOwned: { ownerField: "ghostField" } }),
127
+ },
128
+ }),
129
+ );
130
+ });
131
+ expect(() => validateBoot([feature])).toThrow(
132
+ /userOwned\.ownerField "ghostField" but no such field exists/,
133
+ );
134
+ });
135
+
136
+ test("userOwned.ownerField pointing to non-reference field throws", () => {
137
+ const feature = defineFeature("test", (r) => {
138
+ r.entity(
139
+ "comment",
140
+ createEntity({
141
+ fields: {
142
+ body: createLongTextField({ userOwned: { ownerField: "authorName" } }),
143
+ authorName: createTextField(),
144
+ },
145
+ }),
146
+ );
147
+ });
148
+ expect(() => validateBoot([feature])).toThrow(/must be a reference field, got type "text"/);
149
+ });
150
+
151
+ test("userOwned.ownerField referencing non-user entity warns", () => {
152
+ const feature = defineFeature("test", (r) => {
153
+ r.entity("employee", createEntity({ fields: { name: createTextField() } }));
154
+ stubListHandler(r, "employee");
155
+ r.entity(
156
+ "personalNote",
157
+ createEntity({
158
+ fields: {
159
+ body: createLongTextField({ userOwned: { ownerField: "employeeId" } }),
160
+ employeeId: { type: "reference", entity: "employee" },
161
+ },
162
+ }),
163
+ );
164
+ });
165
+ validateBoot([feature]);
166
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
167
+ String(args[0]).includes('targets reference "employee"'),
168
+ );
169
+ expect(matchingWarn).toBeDefined();
170
+ });
171
+
172
+ test("PII-name heuristic warns when email field has no pii annotation", () => {
173
+ const feature = defineFeature("test", (r) => {
174
+ r.entity(
175
+ "thing",
176
+ createEntity({
177
+ fields: {
178
+ email: createTextField(),
179
+ },
180
+ }),
181
+ );
182
+ });
183
+ validateBoot([feature]);
184
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
185
+ String(args[0]).includes("PII-typical name"),
186
+ );
187
+ expect(matchingWarn).toBeDefined();
188
+ });
189
+
190
+ test("user-content-name heuristic warns when body field has no userOwned annotation", () => {
191
+ const feature = defineFeature("test", (r) => {
192
+ r.entity(
193
+ "thing",
194
+ createEntity({
195
+ fields: {
196
+ body: createLongTextField(),
197
+ },
198
+ }),
199
+ );
200
+ });
201
+ validateBoot([feature]);
202
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
203
+ String(args[0]).includes("user-content-typical name"),
204
+ );
205
+ expect(matchingWarn).toBeDefined();
206
+ });
207
+
208
+ test("allowPlaintext marker silences PII-name heuristic warning", () => {
209
+ const feature = defineFeature("test", (r) => {
210
+ r.entity(
211
+ "company",
212
+ createEntity({
213
+ fields: {
214
+ name: createTextField({ allowPlaintext: "is-business-data" }),
215
+ },
216
+ }),
217
+ );
218
+ });
219
+ validateBoot([feature]);
220
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
221
+ String(args[0]).includes("PII-typical name"),
222
+ );
223
+ expect(matchingWarn).toBeUndefined();
224
+ });
225
+
226
+ test("pii: true on email field silences PII-name heuristic warning", () => {
227
+ const feature = defineFeature("test", (r) => {
228
+ r.entity(
229
+ "user",
230
+ createEntity({
231
+ fields: {
232
+ email: createTextField({ pii: true }),
233
+ },
234
+ }),
235
+ );
236
+ });
237
+ validateBoot([feature]);
238
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
239
+ String(args[0]).includes("PII-typical name"),
240
+ );
241
+ expect(matchingWarn).toBeUndefined();
242
+ });
243
+
244
+ // --- T1: PII-Annotations auf weiteren Field-Defs ---
245
+
246
+ test("pii: true on number field passes (z.B. salary, kontostand)", () => {
247
+ const feature = defineFeature("test", (r) => {
248
+ r.entity(
249
+ "payslip",
250
+ createEntity({
251
+ fields: {
252
+ grossAmount: createNumberField({ pii: true }),
253
+ },
254
+ }),
255
+ );
256
+ });
257
+ expect(() => validateBoot([feature])).not.toThrow();
258
+ });
259
+
260
+ test("pii: true on select field passes (z.B. gender, marital status)", () => {
261
+ const feature = defineFeature("test", (r) => {
262
+ r.entity(
263
+ "profile",
264
+ createEntity({
265
+ fields: {
266
+ gender: createSelectField({
267
+ options: ["male", "female", "diverse", "prefer-not-to-say"] as const,
268
+ pii: true,
269
+ }),
270
+ },
271
+ }),
272
+ );
273
+ });
274
+ expect(() => validateBoot([feature])).not.toThrow();
275
+ });
276
+
277
+ test("pii: true on multiSelect field passes (z.B. dietary restrictions)", () => {
278
+ const feature = defineFeature("test", (r) => {
279
+ r.entity(
280
+ "profile",
281
+ createEntity({
282
+ fields: {
283
+ dietaryRestrictions: createMultiSelectField({
284
+ options: ["vegan", "vegetarian", "halal", "kosher", "gluten-free"] as const,
285
+ pii: true,
286
+ }),
287
+ },
288
+ }),
289
+ );
290
+ });
291
+ expect(() => validateBoot([feature])).not.toThrow();
292
+ });
293
+
294
+ test("pii: true on date field passes (z.B. dateOfBirth)", () => {
295
+ const feature = defineFeature("test", (r) => {
296
+ r.entity(
297
+ "profile",
298
+ createEntity({
299
+ fields: {
300
+ dateOfBirth: createDateField({ pii: true }),
301
+ },
302
+ }),
303
+ );
304
+ });
305
+ expect(() => validateBoot([feature])).not.toThrow();
306
+ });
307
+
308
+ test("pii: true on timestamp field passes (z.B. lastLoginAt)", () => {
309
+ const feature = defineFeature("test", (r) => {
310
+ r.entity(
311
+ "session",
312
+ createEntity({
313
+ fields: {
314
+ lastLoginAt: createTimestampField({ pii: true }),
315
+ },
316
+ }),
317
+ );
318
+ });
319
+ expect(() => validateBoot([feature])).not.toThrow();
320
+ });
321
+
322
+ test("pii: true on tz field passes (Standort verraet Person)", () => {
323
+ const feature = defineFeature("test", (r) => {
324
+ r.entity(
325
+ "profile",
326
+ createEntity({
327
+ fields: {
328
+ homeTz: createTzField({ pii: true }),
329
+ },
330
+ }),
331
+ );
332
+ });
333
+ expect(() => validateBoot([feature])).not.toThrow();
334
+ });
335
+
336
+ test("pii: true on locatedTimestamp field passes (z.B. employee shift)", () => {
337
+ const feature = defineFeature("test", (r) => {
338
+ r.entity(
339
+ "shift",
340
+ createEntity({
341
+ fields: {
342
+ startsAt: createLocatedTimestampField({ pii: true }),
343
+ },
344
+ }),
345
+ );
346
+ });
347
+ expect(() => validateBoot([feature])).not.toThrow();
348
+ });
349
+
350
+ test("pii: true on embedded field passes (z.B. customerAddress)", () => {
351
+ const feature = defineFeature("test", (r) => {
352
+ r.entity(
353
+ "order",
354
+ createEntity({
355
+ fields: {
356
+ customerAddress: createEmbeddedField(
357
+ {
358
+ street: { type: "text" },
359
+ city: { type: "text" },
360
+ postalCode: { type: "text" },
361
+ },
362
+ { pii: true },
363
+ ),
364
+ },
365
+ }),
366
+ );
367
+ });
368
+ expect(() => validateBoot([feature])).not.toThrow();
369
+ });
370
+
371
+ // --- T3: Edge-Cases ---
372
+
373
+ test("pii on boolean field is ignored at runtime (TS prevents it at compile, runtime is silent)", () => {
374
+ // Boolean-FieldDef hat kein PiiAnnotations-Intersection. TypeScript
375
+ // wuerde `pii: true` auf createBooleanField() ablehnen. Runtime-Cast
376
+ // simuliert programmatisch konstruierte FieldDefs — Validator soll
377
+ // das stillschweigend tolerieren (kein Error, kein false-Warning).
378
+ const feature = defineFeature("test", (r) => {
379
+ r.entity(
380
+ "thing",
381
+ createEntity({
382
+ fields: {
383
+ isPublic: { ...createBooleanField(), pii: true } as ReturnType<
384
+ typeof createBooleanField
385
+ >,
386
+ },
387
+ }),
388
+ );
389
+ });
390
+ expect(() => validateBoot([feature])).not.toThrow();
391
+ });
392
+
393
+ test('"name" alone is NOT in PII direct hints (too broad — product.name, tenant.name)', () => {
394
+ const feature = defineFeature("test", (r) => {
395
+ r.entity(
396
+ "product",
397
+ createEntity({
398
+ fields: {
399
+ name: createTextField(),
400
+ },
401
+ }),
402
+ );
403
+ });
404
+ validateBoot([feature]);
405
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
406
+ String(args[0]).includes("PII-typical name"),
407
+ );
408
+ expect(matchingWarn).toBeUndefined();
409
+ });
410
+
411
+ test("displayName / firstName / lastName / fullName remain in PII hints", () => {
412
+ const feature = defineFeature("test", (r) => {
413
+ r.entity(
414
+ "person",
415
+ createEntity({
416
+ fields: {
417
+ displayName: createTextField(),
418
+ },
419
+ }),
420
+ );
421
+ });
422
+ validateBoot([feature]);
423
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
424
+ String(args[0]).includes("PII-typical name"),
425
+ );
426
+ expect(matchingWarn).toBeDefined();
427
+ });
428
+ });
429
+
430
+ describe("validateBoot — retention", () => {
431
+ let warnSpy: ReturnType<typeof vi.spyOn>;
432
+
433
+ beforeEach(() => {
434
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
435
+ });
436
+
437
+ afterEach(() => {
438
+ warnSpy.mockRestore();
439
+ });
440
+
441
+ test("retention with hardDelete + valid reference field passes", () => {
442
+ const feature = defineFeature("test", (r) => {
443
+ r.entity(
444
+ "session",
445
+ createEntity({
446
+ fields: {
447
+ lastSeenAt: { type: "timestamp" },
448
+ },
449
+ retention: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
450
+ }),
451
+ );
452
+ });
453
+ expect(() => validateBoot([feature])).not.toThrow();
454
+ });
455
+
456
+ test("retention.reference pointing to framework createdAt passes", () => {
457
+ const feature = defineFeature("test", (r) => {
458
+ r.entity(
459
+ "auditEvent",
460
+ createEntity({
461
+ fields: {
462
+ note: createTextField(),
463
+ },
464
+ retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
465
+ }),
466
+ );
467
+ });
468
+ expect(() => validateBoot([feature])).not.toThrow();
469
+ });
470
+
471
+ test("retention.reference pointing to non-existent field throws", () => {
472
+ const feature = defineFeature("test", (r) => {
473
+ r.entity(
474
+ "thing",
475
+ createEntity({
476
+ fields: {
477
+ note: createTextField(),
478
+ },
479
+ retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
480
+ }),
481
+ );
482
+ });
483
+ expect(() => validateBoot([feature])).toThrow(
484
+ /retention\.reference "ghostField" does not exist/,
485
+ );
486
+ });
487
+
488
+ test("blockDelete without any anonymize-fields warns", () => {
489
+ const feature = defineFeature("test", (r) => {
490
+ r.entity(
491
+ "invoice",
492
+ createEntity({
493
+ fields: {
494
+ invoiceNumber: createTextField({ allowPlaintext: "is-business-data" }),
495
+ },
496
+ retention: { keepFor: "10y", strategy: "blockDelete" },
497
+ }),
498
+ );
499
+ });
500
+ validateBoot([feature]);
501
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
502
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
503
+ );
504
+ expect(matchingWarn).toBeDefined();
505
+ });
506
+
507
+ test('retention.keepFor with invalid format "30days" warns', () => {
508
+ const feature = defineFeature("test", (r) => {
509
+ r.entity(
510
+ "thing",
511
+ createEntity({
512
+ fields: {
513
+ note: createTextField({ allowPlaintext: "is-business-data" }),
514
+ },
515
+ retention: { keepFor: "30days", strategy: "hardDelete" },
516
+ }),
517
+ );
518
+ });
519
+ validateBoot([feature]);
520
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
521
+ String(args[0]).includes('keepFor="30days" hat ungueltiges Format'),
522
+ );
523
+ expect(matchingWarn).toBeDefined();
524
+ });
525
+
526
+ test('retention.keepFor with valid format "30d" / "10y" / "6m" / "1w" / "24h" passes silently', () => {
527
+ const validFormats = ["30d", "10y", "6m", "1w", "24h"];
528
+ for (const keepFor of validFormats) {
529
+ const feature = defineFeature("test", (r) => {
530
+ r.entity(
531
+ "thing",
532
+ createEntity({
533
+ fields: {
534
+ note: createTextField({ allowPlaintext: "is-business-data" }),
535
+ },
536
+ retention: { keepFor, strategy: "hardDelete" },
537
+ }),
538
+ );
539
+ });
540
+ validateBoot([feature]);
541
+ }
542
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
543
+ String(args[0]).includes("hat ungueltiges Format"),
544
+ );
545
+ expect(matchingWarn).toBeUndefined();
546
+ });
547
+
548
+ test("blockDelete with at least one anonymize-field is silent", () => {
549
+ const feature = defineFeature("test", (r) => {
550
+ r.entity(
551
+ "invoice",
552
+ createEntity({
553
+ fields: {
554
+ invoiceNumber: createTextField({ allowPlaintext: "is-business-data" }),
555
+ customerName: createTextField({
556
+ pii: true,
557
+ anonymize: () => "[ANONYMIZED]",
558
+ }),
559
+ },
560
+ retention: { keepFor: "10y", strategy: "blockDelete" },
561
+ }),
562
+ );
563
+ });
564
+ validateBoot([feature]);
565
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
566
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
567
+ );
568
+ expect(matchingWarn).toBeUndefined();
569
+ });
570
+ });