@cosmicdrift/kumiko-framework 0.290.0 → 0.291.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 (36) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  4. package/src/api/request-context.ts +3 -0
  5. package/src/api/request-id-middleware.ts +2 -1
  6. package/src/api/routes.ts +35 -3
  7. package/src/changes.json +51 -0
  8. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  9. package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
  10. package/src/crypto/subject-resolver.ts +25 -8
  11. package/src/db/queries/shadow-swap.ts +35 -0
  12. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
  13. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  14. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  15. package/src/engine/__tests__/engine.test.ts +87 -0
  16. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  17. package/src/engine/boot-validator/entity-handler.ts +44 -0
  18. package/src/engine/boot-validator/index.ts +7 -2
  19. package/src/engine/boot-validator/pii-retention.ts +8 -0
  20. package/src/engine/boot-validator/screens.ts +50 -0
  21. package/src/engine/create-app.ts +54 -0
  22. package/src/engine/feature-config-events-jobs.ts +19 -0
  23. package/src/engine/index.ts +1 -0
  24. package/src/engine/screen-helpers.ts +1 -0
  25. package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
  26. package/src/i18n/required-surface-keys.ts +1 -0
  27. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  28. package/src/jobs/index.ts +7 -1
  29. package/src/jobs/job-runner.ts +94 -4
  30. package/src/logging/utils.ts +14 -1
  31. package/src/observability/index.ts +1 -0
  32. package/src/observability/standard-metrics.ts +20 -0
  33. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  34. package/src/pipeline/projection-rebuild.ts +7 -0
  35. package/src/schema-cli.ts +21 -0
  36. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
@@ -23,9 +23,11 @@
23
23
  //
24
24
  // Usage: bun scripts/codemod/pii-personal-migration.ts <targetDir> [--dry-run]
25
25
 
26
+ import { readFileSync } from "node:fs";
26
27
  import { relative, resolve } from "node:path";
27
28
  import { Glob } from "bun";
28
29
  import {
30
+ type CallExpression,
29
31
  Node,
30
32
  type ObjectLiteralExpression,
31
33
  Project,
@@ -33,6 +35,11 @@ import {
33
35
  type SourceFile,
34
36
  SyntaxKind,
35
37
  } from "ts-morph";
38
+ import {
39
+ PII_DIRECT_NAME_HINTS,
40
+ PII_USER_OWNED_NAME_HINTS,
41
+ PII_USER_REFERENCE_NAME_HINTS,
42
+ } from "../../engine/boot-validator/entity-handler";
36
43
 
37
44
  const SUBJECT_FLAG_NAMES = [
38
45
  "pii",
@@ -431,11 +438,242 @@ function findTargetFiles(rootDir: string): string[] {
431
438
  return files.sort();
432
439
  }
433
440
 
441
+ export type StanceClass = "direct" | "user-owned" | "user-reference" | "near-miss" | "unclassified";
442
+
443
+ export type StanceSite = {
444
+ readonly line: number;
445
+ readonly field: string;
446
+ readonly entity: string | null;
447
+ readonly callee: string;
448
+ readonly stance: StanceClass;
449
+ readonly hint: string | undefined;
450
+ };
451
+
452
+ const ALL_PII_NAME_HINTS: readonly string[] = [
453
+ ...PII_DIRECT_NAME_HINTS,
454
+ ...PII_USER_OWNED_NAME_HINTS,
455
+ ...PII_USER_REFERENCE_NAME_HINTS,
456
+ ];
457
+
458
+ const REPORT_STANCE_CALLEES = new Set(["createTextField", "createLongTextField"]);
459
+
460
+ // Mirrors guard-text-field-stance.ts's hasPersonalStance exactly.
461
+ function reportStanceHasPersonalStance(obj: ObjectLiteralExpression): boolean {
462
+ const prop = obj.getProperty("personal");
463
+ if (!prop || !Node.isPropertyAssignment(prop)) return false;
464
+ const init = prop.getInitializer();
465
+ return (
466
+ init !== undefined &&
467
+ init.getKind() !== SyntaxKind.UndefinedKeyword &&
468
+ !(Node.isIdentifier(init) && init.getText() === "undefined") &&
469
+ init.getKind() !== SyntaxKind.NullKeyword
470
+ );
471
+ }
472
+
473
+ function reportStanceEnclosingFieldName(call: CallExpression): string | undefined {
474
+ let node = call.getParent();
475
+ while (node) {
476
+ if (Node.isPropertyAssignment(node)) return node.getName();
477
+ node = node.getParent();
478
+ }
479
+ return undefined;
480
+ }
481
+
482
+ function reportStanceResolveEntity(call: CallExpression): string | null {
483
+ let node: Node | undefined = call.getParent();
484
+ let entityCall: CallExpression | undefined;
485
+ while (node) {
486
+ if (Node.isCallExpression(node)) {
487
+ const expr = node.getExpression();
488
+ if (Node.isIdentifier(expr) && expr.getText() === "createEntity") {
489
+ entityCall = node;
490
+ break;
491
+ }
492
+ }
493
+ node = node.getParent();
494
+ }
495
+ if (!entityCall) return null;
496
+
497
+ const firstArg = entityCall.getArguments()[0];
498
+ if (firstArg && Node.isObjectLiteralExpression(firstArg)) {
499
+ const tableProp = firstArg.getProperty("table");
500
+ if (tableProp && Node.isPropertyAssignment(tableProp)) {
501
+ const init = tableProp.getInitializer();
502
+ if (init && Node.isStringLiteral(init)) return init.getLiteralText();
503
+ }
504
+ }
505
+ const varDecl = entityCall.getParentIfKind(SyntaxKind.VariableDeclaration);
506
+ return varDecl ? varDecl.getName() : null;
507
+ }
508
+
509
+ // A hint only counts at a segment boundary (index 0, an uppercase letter in
510
+ // the original, or preceded by `_`) — otherwise a coincidental substring
511
+ // like "text" inside "contextId" would false-positive.
512
+ function hintOccursAtBoundary(fieldLower: string, fieldOriginal: string, hint: string): boolean {
513
+ let searchFrom = 0;
514
+ for (;;) {
515
+ const index = fieldLower.indexOf(hint, searchFrom);
516
+ if (index === -1) return false;
517
+ const atBoundary =
518
+ index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
519
+ if (atBoundary) return true;
520
+ searchFrom = index + 1;
521
+ }
522
+ }
523
+
524
+ function findLongestBoundaryHint(fieldLower: string, fieldOriginal: string): string | undefined {
525
+ let best: string | undefined;
526
+ for (const hint of ALL_PII_NAME_HINTS) {
527
+ if (best && hint.length <= best.length) continue;
528
+ if (hintOccursAtBoundary(fieldLower, fieldOriginal, hint)) best = hint;
529
+ }
530
+ return best;
531
+ }
532
+
533
+ function segmentAlignedSuffixes(fieldOriginal: string): string[] {
534
+ const fieldLower = fieldOriginal.toLowerCase();
535
+ const suffixes: string[] = [];
536
+ for (let index = 0; index < fieldOriginal.length; index++) {
537
+ const atBoundary =
538
+ index === 0 || /[A-Z]/.test(fieldOriginal[index] ?? "") || fieldOriginal[index - 1] === "_";
539
+ if (atBoundary) suffixes.push(fieldLower.slice(index));
540
+ }
541
+ return suffixes;
542
+ }
543
+
544
+ // The hint sets only carry exact full names, so a suffix variant of one
545
+ // (e.g. "...UserId" of "assigneeUserId") otherwise slips through undetected.
546
+ function findShortestHintContainingSuffix(fieldOriginal: string): string | undefined {
547
+ let best: string | undefined;
548
+ for (const suffix of segmentAlignedSuffixes(fieldOriginal)) {
549
+ if (suffix.length < 5) continue;
550
+ for (const hint of ALL_PII_NAME_HINTS) {
551
+ if (!hint.includes(suffix)) continue;
552
+ if (!best || hint.length < best.length) best = hint;
553
+ }
554
+ }
555
+ return best;
556
+ }
557
+
558
+ function classifyFieldStance(field: string): { stance: StanceClass; hint: string | undefined } {
559
+ const fieldLower = field.toLowerCase();
560
+ if (PII_DIRECT_NAME_HINTS.has(fieldLower)) return { stance: "direct", hint: fieldLower };
561
+ if (PII_USER_OWNED_NAME_HINTS.has(fieldLower)) return { stance: "user-owned", hint: fieldLower };
562
+ if (PII_USER_REFERENCE_NAME_HINTS.has(fieldLower))
563
+ return { stance: "user-reference", hint: fieldLower };
564
+ const containmentHint = findLongestBoundaryHint(fieldLower, field);
565
+ if (containmentHint) return { stance: "near-miss", hint: containmentHint };
566
+ const suffixHint = findShortestHintContainingSuffix(field);
567
+ if (suffixHint) return { stance: "near-miss", hint: suffixHint };
568
+ return { stance: "unclassified", hint: undefined };
569
+ }
570
+
571
+ export function reportStanceForSource(source: string, filePath: string): StanceSite[] {
572
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
573
+ const sourceFile = project.createSourceFile(filePath, source);
574
+
575
+ const sites: StanceSite[] = [];
576
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
577
+ const exprNode = call.getExpression();
578
+ if (!Node.isIdentifier(exprNode)) continue;
579
+ const callee = exprNode.getText();
580
+ if (!REPORT_STANCE_CALLEES.has(callee)) continue;
581
+
582
+ const args = call.getArguments();
583
+ if (args.length > 0) {
584
+ const options = args[0];
585
+ if (!options || !Node.isObjectLiteralExpression(options)) continue;
586
+ if (options.getProperties().some((p) => p.isKind(SyntaxKind.SpreadAssignment))) continue;
587
+ if (reportStanceHasPersonalStance(options)) continue;
588
+ }
589
+
590
+ const enclosingField = reportStanceEnclosingFieldName(call);
591
+ // A call with no enclosing field (e.g. a bare createTextField() at top
592
+ // level) has no real name to classify — `createTextField(...)` would
593
+ // otherwise false-positive as a near-miss on its own "Text".
594
+ const { stance, hint } = enclosingField
595
+ ? classifyFieldStance(enclosingField)
596
+ : { stance: "unclassified" as const, hint: undefined };
597
+
598
+ sites.push({
599
+ line: call.getStartLineNumber(),
600
+ field: enclosingField ?? `${callee}(...)`,
601
+ entity: reportStanceResolveEntity(call),
602
+ callee,
603
+ stance,
604
+ hint,
605
+ });
606
+ }
607
+ return sites;
608
+ }
609
+
610
+ function findReportStanceFiles(rootDir: string): string[] {
611
+ const glob = new Glob("**/*.{ts,tsx}");
612
+ const EXCLUDE = ["/node_modules/", "/dist/", "/build/"];
613
+ const files: string[] = [];
614
+ for (const file of glob.scanSync({ cwd: rootDir, dot: false })) {
615
+ if (file.endsWith(".d.ts")) continue;
616
+ const abs = resolve(rootDir, file);
617
+ if (EXCLUDE.some((p) => abs.includes(p))) continue;
618
+ files.push(abs);
619
+ }
620
+ return files.sort();
621
+ }
622
+
623
+ function reportStance(rootDir: string): void {
624
+ console.log(
625
+ `Scanning every .ts/.tsx under ${rootDir} except node_modules/dist/build/*.d.ts — guard-text-field-stance scans packages/*/src/** only, so counts can differ outside that scope.`,
626
+ );
627
+
628
+ const totals: Record<StanceClass, number> = {
629
+ direct: 0,
630
+ "user-owned": 0,
631
+ "user-reference": 0,
632
+ "near-miss": 0,
633
+ unclassified: 0,
634
+ };
635
+ let total = 0;
636
+
637
+ for (const file of findReportStanceFiles(rootDir)) {
638
+ const sites = reportStanceForSource(readFileSync(file, "utf8"), file);
639
+ if (sites.length === 0) continue;
640
+
641
+ console.log(`\n${relative(rootDir, file)}`);
642
+ for (const site of sites) {
643
+ totals[site.stance]++;
644
+ total++;
645
+ const entity = site.entity ?? "<unresolved>";
646
+ const hintSuffix = site.hint ? ` (hint: ${site.hint})` : "";
647
+ console.log(
648
+ ` ${site.line} ${site.field} entity=${entity} ${site.callee} ${site.stance}${hintSuffix}`,
649
+ );
650
+ }
651
+ }
652
+
653
+ console.log("\nBy stance:");
654
+ for (const [stance, count] of Object.entries(totals)) {
655
+ if (count > 0) console.log(` ${stance}: ${count}`);
656
+ }
657
+ console.log(`Total: ${total}`);
658
+
659
+ if (totals["near-miss"] > 0) {
660
+ console.log(
661
+ "\nHint sets in entity-handler.ts are exact name matches — near-miss field names slip past the boot heuristic.",
662
+ );
663
+ }
664
+ }
665
+
434
666
  async function main(): Promise<void> {
435
667
  const positional = process.argv.slice(2).filter((a) => !a.startsWith("--"));
436
- const dryRun = process.argv.includes("--dry-run");
437
668
  const rootDir = resolve(positional[0] ?? process.cwd());
438
669
 
670
+ if (process.argv.includes("--report-stance")) {
671
+ reportStance(rootDir);
672
+ // skip: report mode never rewrites, so the transform path below must not run
673
+ return;
674
+ }
675
+
676
+ const dryRun = process.argv.includes("--dry-run");
439
677
  const files = findTargetFiles(rootDir);
440
678
  const project = new Project({
441
679
  skipAddingFilesFromTsConfig: true,
@@ -477,4 +715,6 @@ async function main(): Promise<void> {
477
715
  }
478
716
  }
479
717
 
480
- await main();
718
+ if (import.meta.main) {
719
+ await main();
720
+ }