@smileid/usesmileid 12.0.1 → 12.0.2

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 (160) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/dist/camera/camera_defaults.d.ts +2 -0
  4. package/dist/camera/camera_defaults.d.ts.map +1 -1
  5. package/dist/camera/camera_defaults.js +2 -0
  6. package/dist/camera/camera_defaults.js.map +1 -1
  7. package/dist/core/exception/flow_exceptions.d.ts +4 -2
  8. package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
  9. package/dist/core/exception/flow_exceptions.js +3 -2
  10. package/dist/core/exception/flow_exceptions.js.map +1 -1
  11. package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
  12. package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
  13. package/dist/core/exception/missing_job_type_exception.js +2 -4
  14. package/dist/core/exception/missing_job_type_exception.js.map +1 -1
  15. package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
  16. package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
  17. package/dist/data/api/smile_id_api.d.ts +4 -0
  18. package/dist/data/api/smile_id_api.d.ts.map +1 -1
  19. package/dist/data/api/smile_id_api.js +30 -27
  20. package/dist/data/api/smile_id_api.js.map +1 -1
  21. package/dist/data/model/job_request.d.ts +28 -12
  22. package/dist/data/model/job_request.d.ts.map +1 -1
  23. package/dist/data/model/token_payload.d.ts +78 -0
  24. package/dist/data/model/token_payload.d.ts.map +1 -0
  25. package/dist/data/model/token_payload.js +132 -0
  26. package/dist/data/model/token_payload.js.map +1 -0
  27. package/dist/data/model/user_details_payload.d.ts +39 -0
  28. package/dist/data/model/user_details_payload.d.ts.map +1 -0
  29. package/dist/data/model/user_details_payload.js +53 -0
  30. package/dist/data/model/user_details_payload.js.map +1 -0
  31. package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
  32. package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
  33. package/dist/data/util/use_smile_id_token_decoder.js +20 -8
  34. package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
  35. package/dist/l10n/strings_en.d.ts +4 -1
  36. package/dist/l10n/strings_en.d.ts.map +1 -1
  37. package/dist/l10n/strings_en.js +4 -1
  38. package/dist/l10n/strings_en.js.map +1 -1
  39. package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
  40. package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
  41. package/dist/l10n/use_smile_id_localizations.js +59 -3
  42. package/dist/l10n/use_smile_id_localizations.js.map +1 -1
  43. package/dist/metadata/metadata_configuration_mappers.js +1 -1
  44. package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
  45. package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
  46. package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
  47. package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
  48. package/dist/presentation/components/use_smile_id_button.js +8 -2
  49. package/dist/presentation/components/use_smile_id_button.js.map +1 -1
  50. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
  51. package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
  52. package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
  53. package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
  54. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
  55. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
  56. package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
  57. package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
  58. package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
  59. package/dist/presentation/flow/flow_navigation_manager.js +77 -45
  60. package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
  61. package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
  62. package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
  63. package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
  64. package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
  65. package/dist/presentation/flow/use_smile_id_flow.js +35 -8
  66. package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
  67. package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
  68. package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
  69. package/dist/presentation/flow/validation/flow_validator.js +19 -27
  70. package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
  71. package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
  72. package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
  73. package/dist/presentation/flow/validation/job_type_validator.js +52 -27
  74. package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
  75. package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
  76. package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
  77. package/dist/presentation/flow/validation/user_details_validator.js +40 -0
  78. package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
  79. package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
  80. package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
  81. package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
  82. package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
  83. package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
  84. package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
  85. package/package.json +3 -3
  86. package/src/__tests__/react_native_mock.tsx +22 -0
  87. package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
  88. package/src/camera/__tests__/camera_permissions.test.ts +72 -0
  89. package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
  90. package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
  91. package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
  92. package/src/camera/camera_defaults.ts +3 -0
  93. package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
  94. package/src/core/exception/flow_exceptions.test.ts +10 -0
  95. package/src/core/exception/flow_exceptions.ts +3 -2
  96. package/src/core/exception/missing_job_type_exception.ts +2 -4
  97. package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
  98. package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
  99. package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
  100. package/src/data/api/smile_id_api.ts +36 -49
  101. package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
  102. package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
  103. package/src/data/model/__tests__/token_payload.test.ts +224 -0
  104. package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
  105. package/src/data/model/job_request.ts +27 -12
  106. package/src/data/model/token_payload.ts +182 -0
  107. package/src/data/model/user_details_payload.ts +84 -0
  108. package/src/data/repository/__tests__/network_retries.test.ts +1 -1
  109. package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
  110. package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
  111. package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
  112. package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
  113. package/src/data/util/use_smile_id_token_decoder.ts +29 -8
  114. package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
  115. package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
  116. package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
  117. package/src/l10n/strings_en.ts +5 -1
  118. package/src/l10n/use_smile_id_localizations.ts +80 -3
  119. package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
  120. package/src/metadata/__tests__/geolocation.test.ts +150 -0
  121. package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
  122. package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
  123. package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
  124. package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
  125. package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
  126. package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
  127. package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
  128. package/src/metadata/metadata_configuration_mappers.ts +1 -1
  129. package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
  130. package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
  131. package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
  132. package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
  133. package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
  134. package/src/presentation/components/use_smile_id_button.tsx +7 -2
  135. package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
  136. package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
  137. package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
  138. package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
  139. package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
  140. package/src/presentation/flow/flow_navigation_manager.ts +102 -92
  141. package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
  142. package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
  143. package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
  144. package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
  145. package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
  146. package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
  147. package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
  148. package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
  149. package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
  150. package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
  151. package/src/presentation/flow/validation/flow_validator.ts +30 -31
  152. package/src/presentation/flow/validation/job_type_validator.ts +73 -39
  153. package/src/presentation/flow/validation/user_details_validator.ts +53 -0
  154. package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
  155. package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
  156. package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
  157. package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
  158. package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
  159. package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
  160. package/src/presentation/theme/__tests__/use_use_smile_id_fonts.test.tsx +166 -0
@@ -18,6 +18,7 @@ import {
18
18
  import {
19
19
  InvalidFieldValueException,
20
20
  InvalidJobTypeScreenConfigException,
21
+ MissingJobTypeException,
21
22
  MissingMLConfigurationBlockException,
22
23
  } from "../../../../core/exception/job_validation_exceptions";
23
24
  import {
@@ -25,9 +26,13 @@ import {
25
26
  InvalidDocumentCaptureConfigException,
26
27
  } from "../../../../core/exception/capture_validation_exceptions";
27
28
  import {
29
+ runConsentValidation,
28
30
  runDocumentVerificationParamsValidation,
29
31
  runEnhancedDocumentVerificationParamsValidation,
32
+ runUserDetailsValidation,
30
33
  } from "../flow_validator";
34
+ import { InvalidConsentInformationException } from "../../../../core/exception/consent_validation_exceptions";
35
+ import type { TokenPayload } from "../../../../data/model/token_payload";
31
36
 
32
37
  function stubFaceAnalyzer(): FaceAnalyzer {
33
38
  return {
@@ -456,3 +461,156 @@ describe("FlowValidator — document verification params", () => {
456
461
  ).toHaveLength(0);
457
462
  });
458
463
  });
464
+
465
+ describe("FlowValidator — jobType recognition", () => {
466
+ const ml: MLConfig = { analyzers: { selfie: stubFaceAnalyzer() } };
467
+
468
+ function jobTypeIssue(jobType: JobType) {
469
+ const issues = runFlowValidation({
470
+ steps: [captureStep(CaptureType.selfie), processingStep()],
471
+ ml,
472
+ network: validNetwork(jobType),
473
+ userDetails: validUserDetails,
474
+ });
475
+ return issues.find(
476
+ (i): i is MissingJobTypeException => i instanceof MissingJobTypeException,
477
+ );
478
+ }
479
+
480
+ it("rejects a jobType that is not one of the SDK's job types", () => {
481
+ expect(jobTypeIssue(99 as JobType)).toBeInstanceOf(MissingJobTypeException);
482
+ });
483
+
484
+ it("still rejects the unknown sentinel", () => {
485
+ expect(jobTypeIssue(JobType.unknown)).toBeInstanceOf(
486
+ MissingJobTypeException,
487
+ );
488
+ });
489
+
490
+ it("accepts every job type the SDK submits", () => {
491
+ for (const jobType of Object.values(JobType)) {
492
+ if (jobType === JobType.unknown) continue;
493
+ expect(jobTypeIssue(jobType)).toBeUndefined();
494
+ }
495
+ });
496
+ });
497
+
498
+ describe("FlowValidator — token-provided user details (field-level relaxation)", () => {
499
+ function tokenPayload(overrides: Partial<TokenPayload> = {}): TokenPayload {
500
+ return {
501
+ hasGivenNames: false,
502
+ hasLastName: false,
503
+ hasEmail: false,
504
+ hasPhoneNumber: false,
505
+ ...overrides,
506
+ };
507
+ }
508
+
509
+ it("accepts contact-only userDetails when the token binds both names", () => {
510
+ const issues = runUserDetailsValidation(
511
+ { givenNames: "", lastName: "", email: "ada@example.com" },
512
+ tokenPayload({ hasGivenNames: true, hasLastName: true }),
513
+ );
514
+ expect(issues).toHaveLength(0);
515
+ });
516
+
517
+ it("accepts names-only userDetails when the token binds a contact field", () => {
518
+ const emailBound = runUserDetailsValidation(
519
+ { givenNames: "Ada", lastName: "Lovelace" },
520
+ tokenPayload({ hasEmail: true }),
521
+ );
522
+ expect(emailBound).toHaveLength(0);
523
+
524
+ const phoneBound = runUserDetailsValidation(
525
+ { givenNames: "Ada", lastName: "Lovelace" },
526
+ tokenPayload({ hasPhoneNumber: true }),
527
+ );
528
+ expect(phoneBound).toHaveLength(0);
529
+ });
530
+
531
+ it("relaxes per-field — a name the token does not bind still errors with today's message", () => {
532
+ const issues = runUserDetailsValidation(
533
+ { givenNames: "", lastName: "", email: "ada@example.com" },
534
+ tokenPayload({ hasGivenNames: true }),
535
+ );
536
+ expect(issues).toHaveLength(1);
537
+ expect(issues[0]).toBeInstanceOf(InvalidFieldValueException);
538
+ expect(issues[0]?.message).toContain("userDetails.lastName");
539
+ });
540
+
541
+ it("regression: without a token payload every existing rule fires unchanged", () => {
542
+ const issues = runUserDetailsValidation({ givenNames: "", lastName: "" });
543
+ expect(issues).toHaveLength(3);
544
+ const messages = issues.map((i) => i.message).join("\n");
545
+ expect(messages).toContain("userDetails.givenNames");
546
+ expect(messages).toContain("userDetails.lastName");
547
+ expect(messages).toContain(
548
+ "at least one of email or phoneNumber must be non blank",
549
+ );
550
+ });
551
+
552
+ it("threads tokenPayload through runFlowValidation to the field rules", () => {
553
+ const issues = runFlowValidation({
554
+ steps: [captureStep(CaptureType.selfie), processingStep()],
555
+ network: validNetwork(JobType.smartSelfieEnrollment),
556
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
557
+ userDetails: { givenNames: "", lastName: "", email: "ada@example.com" },
558
+ consentInformation: {
559
+ granted: true,
560
+ grantedAt: "2026-01-01T00:00:00.000Z",
561
+ noticeLanguage: "EN",
562
+ noticePrivacyPolicyUrl: "https://example.com",
563
+ },
564
+ tokenPayload: tokenPayload({ hasGivenNames: true, hasLastName: true }),
565
+ });
566
+ expect(issues.some((i) => i instanceof InvalidFieldValueException)).toBe(
567
+ false,
568
+ );
569
+ });
570
+
571
+ it("a complete token consent skips content validation of the ignored preset", () => {
572
+ // Token data always wins: the preset is never sent, so its
573
+ // contents raise no issue even when they would fail on their own.
574
+ const issues = runFlowValidation({
575
+ steps: [captureStep(CaptureType.selfie), processingStep()],
576
+ network: validNetwork(JobType.smartSelfieEnrollment),
577
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
578
+ userDetails: validUserDetails,
579
+ consentInformation: {
580
+ granted: false,
581
+ grantedAt: "",
582
+ noticeLanguage: "",
583
+ noticePrivacyPolicyUrl: "",
584
+ },
585
+ tokenPayload: {
586
+ ...tokenPayload(),
587
+ consent: {
588
+ granted: true,
589
+ grantedAt: "2026-01-01T09:00:00.000Z",
590
+ noticeLanguage: "EN",
591
+ noticePrivacyPolicyUrl: "https://partner.test/privacy",
592
+ },
593
+ },
594
+ });
595
+ expect(
596
+ issues.some((i) => i instanceof InvalidConsentInformationException),
597
+ ).toBe(false);
598
+ });
599
+
600
+ it("the standalone pre-flight validator stays strict (no token context)", () => {
601
+ // `b.validateUserDetails(payload)` has no token context by design —
602
+ // partners pre-flight the payload they would assign, and blank
603
+ // names still fail there.
604
+ expect(
605
+ runUserDetailsValidation({ givenNames: "", lastName: "Lovelace" }),
606
+ ).not.toHaveLength(0);
607
+ expect(
608
+ runConsentValidation({
609
+ granted: false,
610
+ grantedAt: "",
611
+ noticeLanguage: "",
612
+ noticePrivacyPolicyUrl: "",
613
+ }),
614
+ ).not.toHaveLength(0);
615
+ });
616
+ });
@@ -20,10 +20,15 @@ import {
20
20
  PreviewConfigBuilder,
21
21
  } from "../../dsl/builder/screens_builder";
22
22
  import { InvalidJobTypeScreenConfigException } from "../../../../core/exception/job_validation_exceptions";
23
+ import { InvalidFieldValueException } from "../../../../core/exception/invalid_field_value_exception";
23
24
  import {
24
25
  InvalidScreenOrderException,
25
26
  MissingCaptureForPreviewException,
26
27
  } from "../../../../core/exception/screen_config_validation_exceptions";
28
+ import type {
29
+ TokenConsent,
30
+ TokenPayload,
31
+ } from "../../../../data/model/token_payload";
27
32
 
28
33
  function stubFaceAnalyzer(): FaceAnalyzer {
29
34
  return {
@@ -733,6 +738,201 @@ describe("DocumentJobTypeValidator — Android-parity rules", () => {
733
738
  });
734
739
  });
735
740
 
741
+ describe("JobTypeValidator — token-provided user details & consent", () => {
742
+ function tokenPayload(overrides: Partial<TokenPayload> = {}): TokenPayload {
743
+ return {
744
+ hasGivenNames: false,
745
+ hasLastName: false,
746
+ hasEmail: false,
747
+ hasPhoneNumber: false,
748
+ ...overrides,
749
+ };
750
+ }
751
+
752
+ const fullBinding = tokenPayload({
753
+ hasGivenNames: true,
754
+ hasLastName: true,
755
+ hasEmail: true,
756
+ hasPhoneNumber: true,
757
+ });
758
+
759
+ const completeTokenConsent: TokenConsent = {
760
+ granted: true,
761
+ grantedAt: "2026-01-01T09:00:00.000Z",
762
+ noticeLanguage: "EN",
763
+ noticePrivacyPolicyUrl: "https://partner.test/privacy",
764
+ };
765
+
766
+ const validConsentInformation = {
767
+ granted: true,
768
+ grantedAt: "2024-01-01T00:00:00.000Z",
769
+ noticeLanguage: "EN",
770
+ noticePrivacyPolicyUrl: "https://example.com",
771
+ } as const;
772
+
773
+ // Steps + params that make each submitting job type otherwise valid,
774
+ // so the only variable under test is the userDetails / consent rule.
775
+ function validInputFor(jobType: JobType): JobTypeValidatorInput {
776
+ switch (jobType) {
777
+ case JobType.documentVerification:
778
+ case JobType.enhancedDocumentVerification:
779
+ return {
780
+ steps: [
781
+ consentStep(),
782
+ captureStep(CaptureType.selfie),
783
+ captureStep(CaptureType.document),
784
+ processingStep(),
785
+ ],
786
+ network: validNetwork(jobType),
787
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
788
+ documentVerificationParams: validDocumentParams,
789
+ enhancedDocumentVerificationParams: validEnhancedDocumentParams,
790
+ };
791
+ case JobType.enhancedKyc:
792
+ return {
793
+ steps: [consentStep(), processingStep()],
794
+ network: validNetwork(jobType),
795
+ enhancedKYCParams: {
796
+ idType: "NATIONAL_ID",
797
+ idNumber: "1",
798
+ country: "KE",
799
+ },
800
+ };
801
+ default:
802
+ return {
803
+ steps: [
804
+ consentStep(),
805
+ captureStep(CaptureType.selfie),
806
+ processingStep(),
807
+ ],
808
+ network: validNetwork(jobType),
809
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
810
+ biometricKYCParams: validBiometricParams,
811
+ };
812
+ }
813
+ }
814
+
815
+ const USER_DETAILS_JOB_TYPES: readonly JobType[] = [
816
+ JobType.smartSelfieEnrollment,
817
+ JobType.smartSelfieAuthentication,
818
+ JobType.biometricKyc,
819
+ JobType.documentVerification,
820
+ JobType.enhancedDocumentVerification,
821
+ JobType.enhancedKyc,
822
+ ];
823
+
824
+ for (const jobType of USER_DETAILS_JOB_TYPES) {
825
+ it(`${jobType}: userDetails may be omitted when the token binds names + a contact field`, () => {
826
+ const issues = run({
827
+ ...validInputFor(jobType),
828
+ tokenPayload: fullBinding,
829
+ });
830
+ expect(
831
+ issues.some(
832
+ (i) =>
833
+ i instanceof InvalidJobTypeScreenConfigException &&
834
+ i.message.includes("userDetails is required"),
835
+ ),
836
+ ).toBe(false);
837
+ });
838
+ }
839
+
840
+ it("a names-only token binding reports the missing contact field by name", () => {
841
+ const issues = run({
842
+ ...validInputFor(JobType.smartSelfieEnrollment),
843
+ tokenPayload: tokenPayload({ hasGivenNames: true, hasLastName: true }),
844
+ });
845
+ expect(
846
+ issues.some(
847
+ (i) =>
848
+ i instanceof InvalidFieldValueException &&
849
+ i.message.includes("userDetails") &&
850
+ i.message.includes(
851
+ "at least one contact field must be bound by the token or supplied on the builder",
852
+ ),
853
+ ),
854
+ ).toBe(true);
855
+ });
856
+
857
+ it("complete token consent satisfies the consent-source rule with no local source", () => {
858
+ const issues = run({
859
+ steps: [captureStep(CaptureType.selfie), processingStep()],
860
+ network: validNetwork(JobType.smartSelfieEnrollment),
861
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
862
+ userDetails: validUserDetails,
863
+ tokenPayload: { ...tokenPayload(), consent: completeTokenConsent },
864
+ });
865
+ expect(
866
+ issues.some(
867
+ (i) =>
868
+ i instanceof InvalidJobTypeScreenConfigException &&
869
+ i.message.includes(
870
+ "must include either a Consent screen or a pre-supplied consentInformation",
871
+ ),
872
+ ),
873
+ ).toBe(false);
874
+ });
875
+
876
+ it("a partial token consent fails validation naming the missing subfields", () => {
877
+ const issues = run({
878
+ steps: [captureStep(CaptureType.selfie), processingStep()],
879
+ network: validNetwork(JobType.smartSelfieEnrollment),
880
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
881
+ userDetails: validUserDetails,
882
+ tokenPayload: {
883
+ ...tokenPayload(),
884
+ consent: {
885
+ granted: true,
886
+ grantedAt: "2026-01-01T09:00:00.000Z",
887
+ },
888
+ },
889
+ });
890
+ expect(
891
+ issues.some(
892
+ (i) =>
893
+ i instanceof InvalidJobTypeScreenConfigException &&
894
+ i.message.includes(
895
+ "the token's consent binding is incomplete — missing: notice_language, notice_privacy_policy_url",
896
+ ),
897
+ ),
898
+ ).toBe(true);
899
+ });
900
+
901
+ it("complete token consent alongside a consentInformation preset is allowed", () => {
902
+ const issues = run({
903
+ steps: [captureStep(CaptureType.selfie), processingStep()],
904
+ network: validNetwork(JobType.smartSelfieEnrollment),
905
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
906
+ userDetails: validUserDetails,
907
+ consentInformation: validConsentInformation,
908
+ tokenPayload: { ...tokenPayload(), consent: completeTokenConsent },
909
+ });
910
+ expect(
911
+ issues.some((i) => i instanceof InvalidJobTypeScreenConfigException),
912
+ ).toBe(false);
913
+ });
914
+
915
+ it("the consent-source mutex still fires with complete token consent", () => {
916
+ const issues = run({
917
+ steps: [consentStep(), captureStep(CaptureType.selfie), processingStep()],
918
+ network: validNetwork(JobType.smartSelfieEnrollment),
919
+ ml: { analyzers: { selfie: stubFaceAnalyzer() } },
920
+ userDetails: validUserDetails,
921
+ consentInformation: validConsentInformation,
922
+ tokenPayload: { ...tokenPayload(), consent: completeTokenConsent },
923
+ });
924
+ expect(
925
+ issues.some(
926
+ (i) =>
927
+ i instanceof InvalidJobTypeScreenConfigException &&
928
+ i.message.includes(
929
+ "must not include both a Consent screen and a pre-supplied consentInformation",
930
+ ),
931
+ ),
932
+ ).toBe(true);
933
+ });
934
+ });
935
+
736
936
  describe("NonCaptureJobTypeValidator — Android-parity rules", () => {
737
937
  it("passes a fully-configured EnhancedKyc flow", () => {
738
938
  const issues = run({
@@ -9,7 +9,7 @@ import type {
9
9
  EnhancedKYCParams,
10
10
  UserDetails,
11
11
  } from "../../../core/models/params";
12
- import { JobType } from "../../../core/models/job_type";
12
+ import { JobType, jobTypeFromValue } from "../../../core/models/job_type";
13
13
  import type {
14
14
  DocumentCaptureConfigBuilder,
15
15
  ScreenSpec,
@@ -34,8 +34,10 @@ import {
34
34
  InvalidMissingCaptureTypeException,
35
35
  } from "../../../core/exception/capture_validation_exceptions";
36
36
  import { EmptyScreensBlockException } from "../../../core/exception/screen_config_validation_exceptions";
37
+ import type { TokenPayload } from "../../../data/model/token_payload";
37
38
  import { ValidationState, type ValidationIssue } from "./validation_state";
38
39
  import { collectJobTypeIssues } from "./job_type_validator";
40
+ import { missingUserDetailsFieldIssues } from "./user_details_validator";
39
41
 
40
42
  /**
41
43
  * Pure flow validator — returns typed `UseSmileIDValidationException`
@@ -72,6 +74,12 @@ type FlowValidatorInput = {
72
74
  | EnhancedDocumentVerificationParams
73
75
  | undefined;
74
76
  readonly userDetails?: UserDetails | undefined;
77
+ /**
78
+ * User-detail/consent bindings decoded from the JWT `payload` claim.
79
+ * `undefined` (no token, offline mode, legacy token) keeps every rule
80
+ * exactly as strict as before token support existed.
81
+ */
82
+ readonly tokenPayload?: TokenPayload | undefined;
75
83
  };
76
84
 
77
85
  /**
@@ -185,9 +193,13 @@ export function runFlowValidation(
185
193
  // build (ERROR) whenever it's missing — whether or not a network
186
194
  // block was provided. The missing-network WARNING above still fires
187
195
  // alongside this when the block is absent.
196
+ // `jobTypeFromValue` collapses the unknown sentinel and any out-of-enum value, so an unrecognised job_type_id fails the build too.
188
197
  if (hasProcessingScreen) {
189
198
  const jobType = input.network?.config?.jobType;
190
- if (jobType === undefined || jobType === JobType.unknown) {
199
+ if (
200
+ jobType === undefined ||
201
+ jobTypeFromValue(jobType) === JobType.unknown
202
+ ) {
191
203
  issues.push(new MissingJobTypeException());
192
204
  }
193
205
  }
@@ -238,7 +250,9 @@ export function runFlowValidation(
238
250
  );
239
251
  }
240
252
 
241
- if (input.consentInformation) {
253
+ // A token consent binding supersedes the preset entirely (token data
254
+ // always wins), so the ignored preset's contents are not validated.
255
+ if (input.consentInformation && input.tokenPayload?.consent === undefined) {
242
256
  issues.push(...runConsentValidation(input.consentInformation));
243
257
  }
244
258
  if (input.enhancedKYCParams) {
@@ -262,7 +276,9 @@ export function runFlowValidation(
262
276
  );
263
277
  }
264
278
  if (input.userDetails) {
265
- issues.push(...runUserDetailsValidation(input.userDetails));
279
+ issues.push(
280
+ ...runUserDetailsValidation(input.userDetails, input.tokenPayload),
281
+ );
266
282
  }
267
283
 
268
284
  // Every flow-composition rule (screen ordering, duplicate screens,
@@ -408,40 +424,23 @@ function runKYCParamsValidation(
408
424
  * keep working unchanged after the consolidation away from nesting
409
425
  * `userDetails` inside the KYC param shapes.
410
426
  *
427
+ * Each field requirement is evaluated against the union of builder
428
+ * input and token bindings: a field the token binds (see
429
+ * `TokenPayload`) never has to be supplied locally — the server
430
+ * injects it, and the SDK drops the local value from the wire. With
431
+ * no `tokenPayload` the behavior is exactly the pre-token rule set.
432
+ *
411
433
  * Exposed via `b.validateUserDetails(payload)` so partners can
412
434
  * pre-flight a payload before assigning to `b.userDetails`, mirroring
413
435
  * the existing `b.validateConsent` / `b.validateEnhancedKYCParams`
414
- * pattern.
436
+ * pattern (the pre-flight helper always validates strictly, without
437
+ * token context).
415
438
  */
416
439
  export function runUserDetailsValidation(
417
440
  userDetails: UserDetails,
441
+ tokenPayload?: TokenPayload,
418
442
  ): ValidationIssue[] {
419
- const issues: ValidationIssue[] = [];
420
- for (const field of ["givenNames", "lastName"] as const) {
421
- const value = userDetails[field];
422
- if (!value || !value.trim()) {
423
- issues.push(
424
- new InvalidFieldValueException({
425
- fieldName: `userDetails.${field}`,
426
- reason: "must not be blank",
427
- }),
428
- );
429
- }
430
- }
431
- // At-least-one-of contact rule: email and phoneNumber are each
432
- // optional, but the partner must supply one of them.
433
- const hasEmail = !!userDetails.email && userDetails.email.trim().length > 0;
434
- const hasPhone =
435
- !!userDetails.phoneNumber && userDetails.phoneNumber.trim().length > 0;
436
- if (!hasEmail && !hasPhone) {
437
- issues.push(
438
- new InvalidFieldValueException({
439
- fieldName: "userDetails",
440
- reason: "at least one of email or phoneNumber must be non blank",
441
- }),
442
- );
443
- }
444
- return issues;
443
+ return missingUserDetailsFieldIssues(userDetails, tokenPayload);
445
444
  }
446
445
 
447
446
  /**
@@ -18,6 +18,12 @@ import {
18
18
  MissingCaptureForPreviewException,
19
19
  } from "../../../core/exception/screen_config_validation_exceptions";
20
20
  import { IssueSeverity } from "../../../core/exception/issue_severity";
21
+ import {
22
+ tokenConsentMissingSubfields,
23
+ bindsRequiredUserDetails,
24
+ type TokenPayload,
25
+ } from "../../../data/model/token_payload";
26
+ import { missingUserDetailsFieldIssues } from "./user_details_validator";
21
27
  import type { ValidationIssue } from "./validation_state";
22
28
 
23
29
  /**
@@ -66,6 +72,12 @@ export type JobTypeValidatorInput = {
66
72
  | EnhancedDocumentVerificationParams
67
73
  | undefined;
68
74
  readonly userDetails?: UserDetails | undefined;
75
+ /**
76
+ * User-detail/consent bindings decoded from the JWT `payload` claim.
77
+ * `undefined` (no token, offline mode, legacy token) keeps every rule
78
+ * exactly as strict as before token support existed.
79
+ */
80
+ readonly tokenPayload?: TokenPayload | undefined;
69
81
  };
70
82
 
71
83
  /**
@@ -93,9 +105,13 @@ function jobTypeName(jobType: JobType): string {
93
105
  }
94
106
 
95
107
  /**
96
- * Consent rule: exactly one form of consent is required — either a
97
- * Consent screen or pre-supplied `consentInformation`, never both and
98
- * never neither.
108
+ * Consent rule: token consent always wins. A token that carries a
109
+ * consent binding must carry all four subfields — the local sources
110
+ * are then ignored (the consent screen is skipped and any preset is
111
+ * not sent), so an incomplete binding is a build-time error rather
112
+ * than a silent fallback. Without a token binding, exactly one local
113
+ * form of consent is required — either a Consent screen or
114
+ * pre-supplied `consentInformation`, never both.
99
115
  */
100
116
  function appendConsentRule(
101
117
  jobType: JobType,
@@ -104,6 +120,33 @@ function appendConsentRule(
104
120
  ): void {
105
121
  const hasConsentScreen = input.steps.some((s) => s.kind === "consent");
106
122
  const hasConsentInformation = input.consentInformation !== undefined;
123
+ if (hasConsentScreen && hasConsentInformation) {
124
+ issues.push(
125
+ InvalidJobTypeScreenConfigException.forJob({
126
+ jobType,
127
+ reason:
128
+ "must not include both a Consent screen and a pre-supplied consentInformation — use one or the other",
129
+ suggestedFix:
130
+ "Remove the consent { } screen if supplying consentInformation, or clear consentInformation if using the consent screen",
131
+ }),
132
+ );
133
+ return;
134
+ }
135
+ const tokenConsent = input.tokenPayload?.consent;
136
+ if (tokenConsent !== undefined) {
137
+ const missing = tokenConsentMissingSubfields(tokenConsent);
138
+ if (missing.length > 0) {
139
+ issues.push(
140
+ InvalidJobTypeScreenConfigException.forJob({
141
+ jobType,
142
+ reason: `the token's consent binding is incomplete — missing: ${missing.join(", ")}`,
143
+ suggestedFix:
144
+ "Bind all four consent fields when minting the token at /v3/token, or bind none and collect consent in the app",
145
+ }),
146
+ );
147
+ }
148
+ return;
149
+ }
107
150
  if (!hasConsentScreen && !hasConsentInformation) {
108
151
  issues.push(
109
152
  InvalidJobTypeScreenConfigException.forJob({
@@ -114,17 +157,36 @@ function appendConsentRule(
114
157
  "Add a consent { } screen to your flow, or supply consentInformation via the builder",
115
158
  }),
116
159
  );
117
- } else if (hasConsentScreen && hasConsentInformation) {
160
+ }
161
+ }
162
+
163
+ /**
164
+ * userDetails rule: the token and the builder fill the requirement
165
+ * together — token-bound fields (see `bindsRequiredUserDetails`) count
166
+ * as supplied and the builder must cover the rest. With no token
167
+ * binding and no builder value the legacy blanket error fires; with a
168
+ * partial token binding the per-field errors name exactly what neither
169
+ * side provided.
170
+ */
171
+ function appendUserDetailsRequiredRule(
172
+ jobType: JobType,
173
+ input: JobTypeValidatorInput,
174
+ issues: ValidationIssue[],
175
+ ): void {
176
+ if (input.userDetails !== undefined) return;
177
+ if (bindsRequiredUserDetails(input.tokenPayload)) return;
178
+ if (input.tokenPayload === undefined) {
118
179
  issues.push(
119
180
  InvalidJobTypeScreenConfigException.forJob({
120
181
  jobType,
121
- reason:
122
- "must not include both a Consent screen and a pre-supplied consentInformation — use one or the other",
182
+ reason: `userDetails is required for ${jobTypeName(jobType)} jobs`,
123
183
  suggestedFix:
124
- "Remove the consent { } screen if supplying consentInformation, or clear consentInformation if using the consent screen",
184
+ "Set builder.userDetails with the end user PII before building",
125
185
  }),
126
186
  );
187
+ return;
127
188
  }
189
+ issues.push(...missingUserDetailsFieldIssues(undefined, input.tokenPayload));
128
190
  }
129
191
 
130
192
  /**
@@ -381,16 +443,7 @@ export class SelfieJobTypeValidator implements JobTypeValidator {
381
443
  );
382
444
  }
383
445
 
384
- if (input.userDetails === undefined) {
385
- issues.push(
386
- InvalidJobTypeScreenConfigException.forJob({
387
- jobType: this.jobType,
388
- reason: `userDetails is required for ${jobTypeName(this.jobType)} jobs`,
389
- suggestedFix:
390
- "Set builder.userDetails with the end user PII before building",
391
- }),
392
- );
393
- }
446
+ appendUserDetailsRequiredRule(this.jobType, input, issues);
394
447
  }
395
448
  }
396
449
 
@@ -477,16 +530,7 @@ export class DocumentJobTypeValidator implements JobTypeValidator {
477
530
  );
478
531
  }
479
532
 
480
- if (input.userDetails === undefined) {
481
- issues.push(
482
- InvalidJobTypeScreenConfigException.forJob({
483
- jobType: this.jobType,
484
- reason: `userDetails is required for ${jobTypeName(this.jobType)} jobs`,
485
- suggestedFix:
486
- "Set builder.userDetails with the end user PII before building",
487
- }),
488
- );
489
- }
533
+ appendUserDetailsRequiredRule(this.jobType, input, issues);
490
534
  }
491
535
 
492
536
  /**
@@ -578,18 +622,8 @@ export class NonCaptureJobTypeValidator implements JobTypeValidator {
578
622
  );
579
623
  }
580
624
 
581
- if (
582
- this.jobType === JobType.enhancedKyc &&
583
- input.userDetails === undefined
584
- ) {
585
- issues.push(
586
- InvalidJobTypeScreenConfigException.forJob({
587
- jobType: this.jobType,
588
- reason: "userDetails is required for EnhancedKyc jobs",
589
- suggestedFix:
590
- "Set builder.userDetails with the end user PII before building",
591
- }),
592
- );
625
+ if (this.jobType === JobType.enhancedKyc) {
626
+ appendUserDetailsRequiredRule(this.jobType, input, issues);
593
627
  }
594
628
  }
595
629
  }