@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
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { DuplicateScreenTypeException } from "../duplicate_screen_type_exception";
4
+ import { InvalidMissingCaptureTypeException } from "../invalid_missing_capture_type_exception";
4
5
  import { InvalidSelfieCaptureConfigException } from "../invalid_selfie_capture_config_exception";
5
6
  import { LivenessFrameCountException } from "../liveness_frame_count_exception";
6
7
  import { UseSmileIDErrorCode } from "../use_smile_id_error_code";
@@ -65,3 +66,16 @@ describe("InvalidSelfieCaptureConfigException", () => {
65
66
  });
66
67
  });
67
68
  });
69
+
70
+ describe("InvalidMissingCaptureTypeException", () => {
71
+ it("carries the missing-capture-type code and an actionable fix", () => {
72
+ const exception = new InvalidMissingCaptureTypeException();
73
+
74
+ expect(exception).toBeInstanceOf(Error);
75
+ expect(exception.errorCode).toBe(
76
+ UseSmileIDErrorCode.Builder.MissingCaptureType,
77
+ );
78
+ expect(exception.message).toContain("captureType is missing");
79
+ expect(exception.suggestedFix).toContain("selfie or document");
80
+ });
81
+ });
@@ -36,4 +36,14 @@ describe("ProcessingCancelledException", () => {
36
36
  expect(error).toBeInstanceOf(UseSmileIDException);
37
37
  expect(error).toBeInstanceOf(Error);
38
38
  });
39
+
40
+ it("carries an underlying cause when one is supplied", () => {
41
+ const underlying = new Error("upload aborted");
42
+ const error = new ProcessingCancelledException({ cause: underlying });
43
+ expect(error.cause).toBe(underlying);
44
+ });
45
+
46
+ it("leaves cause unset when none is supplied", () => {
47
+ expect(new ProcessingCancelledException().cause).toBeUndefined();
48
+ });
39
49
  });
@@ -27,16 +27,17 @@ export class ConsentDeniedException extends UseSmileIDGeneralException {
27
27
  *
28
28
  * Surfaced through `onResult.failure` so partners can distinguish a
29
29
  * user-initiated cancellation from a submission error, mirroring the
30
- * native SDKs' `ProcessingCancelledException`.
30
+ * native SDKs' `ProcessingCancelledException`. An optional `cause` carries a submission failure that preceded the exit.
31
31
  */
32
32
  export class ProcessingCancelledException extends UseSmileIDGeneralException {
33
33
  readonly errorCode = UseSmileIDErrorCode.General.OperationFailed;
34
34
 
35
- constructor() {
35
+ constructor(args: { cause?: unknown } = {}) {
36
36
  super({
37
37
  message: "Processing was cancelled",
38
38
  suggestedFix:
39
39
  "The user exited the processing screen before submission completed.",
40
+ ...(args.cause !== undefined ? { cause: args.cause } : {}),
40
41
  });
41
42
  }
42
43
  }
@@ -2,9 +2,7 @@ import { UseSmileIDBuilderException } from "./use_smile_id_builder_exception";
2
2
  import { UseSmileIDErrorCode } from "./use_smile_id_error_code";
3
3
 
4
4
  /**
5
- * The `network { config { } }` block is present but `jobType` was
6
- * never set — the orchestrator can't pick a submission path without
7
- * it.
5
+ * `jobType` was never set, or was set to a value that is not one of the SDK's job types.
8
6
  */
9
7
  export class MissingJobTypeException extends UseSmileIDBuilderException {
10
8
  readonly errorCode = UseSmileIDErrorCode.Builder.MissingJobType;
@@ -12,7 +10,7 @@ export class MissingJobTypeException extends UseSmileIDBuilderException {
12
10
  constructor() {
13
11
  super({
14
12
  message:
15
- "jobType is not set — it must be configured explicitly in the network { config { } } block",
13
+ "jobType is not set to one of the SDK's job types — it must be configured explicitly in the network { config { } } block",
16
14
  suggestedFix:
17
15
  "Set config.jobType in the network { config { } } block, e.g. config.jobType = JobType.DocumentVerification",
18
16
  });
@@ -39,6 +39,7 @@ import type {
39
39
  ConsentInformation,
40
40
  UserDetails,
41
41
  } from "../../../core/models/params";
42
+ import type { TokenPayload } from "../../model/token_payload";
42
43
  import { UseSmileIDMetadata } from "../../../metadata/use_smile_id_metadata";
43
44
 
44
45
  type RecordedCall = {
@@ -109,7 +110,7 @@ const consent: ConsentInformation = Object.freeze({
109
110
  // builds this and it travels on every `JobRequest`; the API
110
111
  // serializes `request.metadata` as the `metadata` multipart part.
111
112
  const metadata = new UseSmileIDMetadata({
112
- sdkVersion: "12.0.1",
113
+ sdkVersion: "12.0.2",
113
114
  sdk: "react-native-expo",
114
115
  deviceOs: "",
115
116
  deviceModel: "",
@@ -1071,6 +1072,279 @@ describe("SmileIDAPI response-body parse failures", () => {
1071
1072
  });
1072
1073
  });
1073
1074
 
1075
+ describe("SmileIDAPI — token-provided user details & consent (wire)", () => {
1076
+ const livenessImages: readonly string[] = [
1077
+ "file:///l1.jpg",
1078
+ "file:///l2.jpg",
1079
+ "file:///l3.jpg",
1080
+ "file:///l4.jpg",
1081
+ "file:///l5.jpg",
1082
+ "file:///l6.jpg",
1083
+ "file:///l7.jpg",
1084
+ ];
1085
+
1086
+ const fullUserDetails: UserDetails = {
1087
+ givenNames: "John",
1088
+ lastName: "Doe",
1089
+ email: "john@example.test",
1090
+ phoneNumber: "+254700000000",
1091
+ };
1092
+
1093
+ function tokenPayload(overrides: Partial<TokenPayload> = {}): TokenPayload {
1094
+ return {
1095
+ hasGivenNames: false,
1096
+ hasLastName: false,
1097
+ hasEmail: false,
1098
+ hasPhoneNumber: false,
1099
+ ...overrides,
1100
+ };
1101
+ }
1102
+
1103
+ const fullBinding = tokenPayload({
1104
+ hasGivenNames: true,
1105
+ hasLastName: true,
1106
+ hasEmail: true,
1107
+ hasPhoneNumber: true,
1108
+ });
1109
+
1110
+ const completeTokenConsent = {
1111
+ granted: true,
1112
+ grantedAt: "2026-01-01T09:00:00.000Z",
1113
+ noticeLanguage: "EN",
1114
+ noticePrivacyPolicyUrl: "https://partner.test/privacy",
1115
+ } as const;
1116
+
1117
+ function enrollmentRequest(
1118
+ overrides: Partial<SmartSelfieEnrollmentRequest> = {},
1119
+ ): SmartSelfieEnrollmentRequest {
1120
+ return {
1121
+ jobType: JobType.smartSelfieEnrollment,
1122
+ selfieImage: "file:///selfie.jpg",
1123
+ livenessImages,
1124
+ consent,
1125
+ metadata,
1126
+ expectedLivenessImageCount: 7,
1127
+ userDetails: fullUserDetails,
1128
+ enhanced: false,
1129
+ ...overrides,
1130
+ };
1131
+ }
1132
+
1133
+ it("a partial user_details payload sends only its remaining fields", async () => {
1134
+ // Assembly already dropped the token-bound names (token always wins);
1135
+ // only email/phone ride the wire.
1136
+ const { api, calls } = makeApi();
1137
+ await api.smartSelfieEnrollment(
1138
+ enrollmentRequest({
1139
+ userDetails: {
1140
+ email: "john@example.test",
1141
+ phoneNumber: "+254700000000",
1142
+ },
1143
+ }),
1144
+ );
1145
+ const parts = partsByName(calls[0]!.body);
1146
+ const parsed = JSON.parse(await readJsonPart(parts["user_details"]![0]));
1147
+ expect(parsed).toEqual({
1148
+ email: "john@example.test",
1149
+ phone_number: "+254700000000",
1150
+ });
1151
+ });
1152
+
1153
+ it("omits the user_details part when the request carries no userDetails", async () => {
1154
+ const { api, calls } = makeApi();
1155
+ const { userDetails: _details, ...withoutUserDetails } =
1156
+ enrollmentRequest();
1157
+ await api.smartSelfieEnrollment({
1158
+ ...withoutUserDetails,
1159
+ tokenPayload: fullBinding,
1160
+ });
1161
+ const parts = partsByName(calls[0]!.body);
1162
+ expect(parts["user_details"]).toBeUndefined();
1163
+ });
1164
+
1165
+ it("an empty user_details payload omits the part entirely", async () => {
1166
+ const { api, calls } = makeApi();
1167
+ await api.smartSelfieEnrollment(enrollmentRequest({ userDetails: {} }));
1168
+ const parts = partsByName(calls[0]!.body);
1169
+ expect(parts["user_details"]).toBeUndefined();
1170
+ });
1171
+
1172
+ it("omits the consent part when the request carries no consent (token-complete path)", async () => {
1173
+ const { api, calls } = makeApi();
1174
+ const { consent: _consent, ...withoutConsent } = enrollmentRequest();
1175
+ await api.smartSelfieEnrollment({
1176
+ ...withoutConsent,
1177
+ tokenPayload: {
1178
+ ...tokenPayload(),
1179
+ consent: completeTokenConsent,
1180
+ },
1181
+ });
1182
+ const parts = partsByName(calls[0]!.body);
1183
+ expect(parts["consent"]).toBeUndefined();
1184
+ // The metadata part is unconditional — it must survive the omission.
1185
+ expect(parts["metadata"]).toBeDefined();
1186
+ });
1187
+
1188
+ it("sends a local consent in full even when the token also binds consent", async () => {
1189
+ // Server merges per-subfield with the token winning; the SDK never
1190
+ // trims a locally captured / preset consent.
1191
+ const { api, calls } = makeApi();
1192
+ await api.smartSelfieEnrollment(
1193
+ enrollmentRequest({
1194
+ tokenPayload: {
1195
+ ...tokenPayload(),
1196
+ consent: completeTokenConsent,
1197
+ },
1198
+ }),
1199
+ );
1200
+ const parts = partsByName(calls[0]!.body);
1201
+ const parsed = JSON.parse(await readJsonPart(parts["consent"]![0]));
1202
+ expect(parsed).toEqual({
1203
+ granted: true,
1204
+ granted_at: "2026-04-30T00:00:00.000Z",
1205
+ notice_language: "EN",
1206
+ notice_privacy_policy_url: "https://example.test/p",
1207
+ });
1208
+ });
1209
+
1210
+ it("regression: without a tokenPayload the parts are identical to today", async () => {
1211
+ const { api, calls } = makeApi();
1212
+ await api.smartSelfieEnrollment(enrollmentRequest());
1213
+ const parts = partsByName(calls[0]!.body);
1214
+ const userDetailsParsed = JSON.parse(
1215
+ await readJsonPart(parts["user_details"]![0]),
1216
+ );
1217
+ expect(userDetailsParsed).toEqual({
1218
+ given_names: "John",
1219
+ last_name: "Doe",
1220
+ email: "john@example.test",
1221
+ phone_number: "+254700000000",
1222
+ });
1223
+ const consentParsed = JSON.parse(await readJsonPart(parts["consent"]![0]));
1224
+ expect(consentParsed).toEqual({
1225
+ granted: true,
1226
+ granted_at: "2026-04-30T00:00:00.000Z",
1227
+ notice_language: "EN",
1228
+ notice_privacy_policy_url: "https://example.test/p",
1229
+ });
1230
+ });
1231
+
1232
+ it("keeps the stripped user_details part an untagged text form field", async () => {
1233
+ const { api, calls } = makeApi();
1234
+ await api.smartSelfieEnrollment(
1235
+ enrollmentRequest({
1236
+ tokenPayload: tokenPayload({ hasGivenNames: true }),
1237
+ }),
1238
+ );
1239
+ const parts = partsByName(calls[0]!.body);
1240
+ expectNoJsonContentType(parts["user_details"]![0]);
1241
+ });
1242
+
1243
+ // Every endpoint routes user_details / consent through the same
1244
+ // helpers — pin the omission behavior across all six.
1245
+ const omissionSubmissions: ReadonlyArray<{
1246
+ readonly endpoint: string;
1247
+ readonly submit: (api: SmileIDAPI) => Promise<unknown>;
1248
+ }> = [
1249
+ {
1250
+ endpoint: "/v3/registration",
1251
+ submit: (api) =>
1252
+ api.smartSelfieEnrollment({
1253
+ jobType: JobType.smartSelfieEnrollment,
1254
+ selfieImage: "file:///selfie.jpg",
1255
+ livenessImages,
1256
+ enhanced: false,
1257
+ metadata,
1258
+ expectedLivenessImageCount: 7,
1259
+ tokenPayload: { ...fullBinding, consent: completeTokenConsent },
1260
+ }),
1261
+ },
1262
+ {
1263
+ endpoint: "/v3/authentication",
1264
+ submit: (api) =>
1265
+ api.smartSelfieAuthentication({
1266
+ jobType: JobType.smartSelfieAuthentication,
1267
+ selfieImage: "file:///selfie.jpg",
1268
+ livenessImages,
1269
+ userId: "user-1",
1270
+ enhanced: false,
1271
+ metadata,
1272
+ expectedLivenessImageCount: 7,
1273
+ tokenPayload: { ...fullBinding, consent: completeTokenConsent },
1274
+ }),
1275
+ },
1276
+ {
1277
+ endpoint: "/v3/enhanced_kyc",
1278
+ submit: (api) =>
1279
+ api.enhancedKyc({
1280
+ jobType: JobType.enhancedKyc,
1281
+ params: { idType: "NATIONAL_ID", idNumber: "1", country: "KE" },
1282
+ metadata,
1283
+ tokenPayload: { ...fullBinding, consent: completeTokenConsent },
1284
+ }),
1285
+ },
1286
+ {
1287
+ endpoint: "/v3/biometric_kyc",
1288
+ submit: (api) =>
1289
+ api.biometricKyc({
1290
+ jobType: JobType.biometricKyc,
1291
+ selfieImage: "file:///selfie.jpg",
1292
+ livenessImages,
1293
+ params: {
1294
+ idType: "NATIONAL_ID",
1295
+ idNumber: "1",
1296
+ country: "KE",
1297
+ useEnrolledImage: false,
1298
+ },
1299
+ metadata,
1300
+ expectedLivenessImageCount: 7,
1301
+ tokenPayload: { ...fullBinding, consent: completeTokenConsent },
1302
+ }),
1303
+ },
1304
+ {
1305
+ endpoint: "/v3/document_verification",
1306
+ submit: (api) =>
1307
+ api.documentVerification({
1308
+ jobType: JobType.documentVerification,
1309
+ selfieImage: "file:///selfie.jpg",
1310
+ livenessImages,
1311
+ frontDocument: "file:///front.jpg",
1312
+ country: "KE",
1313
+ metadata,
1314
+ expectedLivenessImageCount: 7,
1315
+ tokenPayload: { ...fullBinding, consent: completeTokenConsent },
1316
+ }),
1317
+ },
1318
+ {
1319
+ endpoint: "/v3/enhanced_document_verification",
1320
+ submit: (api) =>
1321
+ api.enhancedDocumentVerification({
1322
+ jobType: JobType.enhancedDocumentVerification,
1323
+ selfieImage: "file:///selfie.jpg",
1324
+ livenessImages,
1325
+ frontDocument: "file:///front.jpg",
1326
+ country: "KE",
1327
+ idType: "NATIONAL_ID",
1328
+ metadata,
1329
+ expectedLivenessImageCount: 7,
1330
+ tokenPayload: { ...fullBinding, consent: completeTokenConsent },
1331
+ }),
1332
+ },
1333
+ ];
1334
+
1335
+ for (const { endpoint, submit } of omissionSubmissions) {
1336
+ it(`${endpoint} omits user_details and consent on the token-complete path`, async () => {
1337
+ const { api, calls } = makeApi();
1338
+ await submit(api);
1339
+ expect(calls[0]!.url).toBe(endpoint);
1340
+ const parts = partsByName(calls[0]!.body);
1341
+ expect(parts["user_details"]).toBeUndefined();
1342
+ expect(parts["consent"]).toBeUndefined();
1343
+ expect(parts["metadata"]).toBeDefined();
1344
+ });
1345
+ }
1346
+ });
1347
+
1074
1348
  describe("SmileIDAPI signed job opt-in (request MAC + attestation)", () => {
1075
1349
  // An endpoint missing the opt-in flags ships an unsigned job the
1076
1350
  // server rejects — while the interceptors' own tests stay green.
@@ -0,0 +1,144 @@
1
+ import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
2
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3
+
4
+ import { LogLevel } from "../../../../core/models/network_config";
5
+ import { LoggingConfigBuilder } from "../../../dsl/builder/config_builder";
6
+ import { InterceptorConfigBuilder } from "../../../dsl/builder/interceptor_config_builder";
7
+ import { attachLoggingInterceptor } from "../logging_interceptor";
8
+
9
+ const CANONICAL_CREDENTIAL_HEADERS: ReadonlyArray<
10
+ readonly [name: string, value: string]
11
+ > = [
12
+ ["SmileID-Token", "secret-jwt-value"],
13
+ ["smileid-partner-id", "secret-partner-002"],
14
+ ["SmileID-Api-Key", "secret-api-key"],
15
+ ["smileid-device-nonce", "secret-nonce"],
16
+ ["Authorization", "Bearer secret-bearer"],
17
+ ["Cookie", "session=secret-cookie"],
18
+ ["Set-Cookie", "session=secret-set-cookie"],
19
+ ];
20
+
21
+ function requestHeaders(): AxiosHeaders {
22
+ const headers = new AxiosHeaders();
23
+ for (const [name, value] of CANONICAL_CREDENTIAL_HEADERS) {
24
+ headers.set(name, value);
25
+ }
26
+ headers.set("Content-Type", "multipart/form-data");
27
+ return headers;
28
+ }
29
+
30
+ function invokeRequestFulfilled(
31
+ client: ReturnType<typeof axios.create>,
32
+ ): InternalAxiosRequestConfig {
33
+ const handlers = (
34
+ client.interceptors.request as unknown as {
35
+ handlers: {
36
+ fulfilled?: (
37
+ config: InternalAxiosRequestConfig,
38
+ ) => InternalAxiosRequestConfig;
39
+ }[];
40
+ }
41
+ ).handlers;
42
+ const lastHandler = handlers[handlers.length - 1];
43
+ if (typeof lastHandler?.fulfilled !== "function") {
44
+ throw new Error("No fulfilled request handler found");
45
+ }
46
+ return lastHandler.fulfilled({
47
+ method: "post",
48
+ baseURL: "https://testapi.smileidentity.com",
49
+ url: "/v2/submit-job",
50
+ headers: requestHeaders(),
51
+ } as InternalAxiosRequestConfig);
52
+ }
53
+
54
+ describe("credential headers under the partner-facing logging config", () => {
55
+ let client: ReturnType<typeof axios.create>;
56
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
57
+
58
+ beforeEach(() => {
59
+ client = axios.create();
60
+ consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
61
+ });
62
+
63
+ afterEach(() => {
64
+ consoleLogSpy.mockRestore();
65
+ });
66
+
67
+ function printedOutput(): string {
68
+ return consoleLogSpy.mock.calls
69
+ .map((call) => call.map((arg) => JSON.stringify(arg)).join(" "))
70
+ .join("\n");
71
+ }
72
+
73
+ const configurations: ReadonlyArray<
74
+ readonly [label: string, attach: (client_: typeof client) => void]
75
+ > = [
76
+ [
77
+ "network { config { logging { } } } at default redactHeaders",
78
+ (client_) => {
79
+ const builder = new LoggingConfigBuilder();
80
+ builder.level = LogLevel.headers;
81
+ attachLoggingInterceptor(client_, builder.build());
82
+ },
83
+ ],
84
+ [
85
+ "network { interceptors { logging() } } at default redactHeaders",
86
+ (client_) => {
87
+ const interceptors = new InterceptorConfigBuilder();
88
+ interceptors
89
+ .logging((l) => {
90
+ l.level = LogLevel.headers;
91
+ })
92
+ .attach(client_);
93
+ },
94
+ ],
95
+ ];
96
+
97
+ for (const [label, attach] of configurations) {
98
+ it(`masks every credential header — ${label}`, () => {
99
+ attach(client);
100
+ invokeRequestFulfilled(client);
101
+
102
+ const output = printedOutput();
103
+ expect(output).not.toBe("");
104
+ for (const [name, value] of CANONICAL_CREDENTIAL_HEADERS) {
105
+ expect(output, `${name} leaked its value`).not.toContain(value);
106
+ }
107
+ expect(output).toContain("multipart/form-data");
108
+ });
109
+
110
+ it(`keeps the header names visible so the audit trail survives — ${label}`, () => {
111
+ attach(client);
112
+ invokeRequestFulfilled(client);
113
+
114
+ const output = printedOutput().toLowerCase();
115
+ for (const [name] of CANONICAL_CREDENTIAL_HEADERS) {
116
+ expect(output, `${name} was dropped entirely`).toContain(
117
+ name.toLowerCase(),
118
+ );
119
+ }
120
+ });
121
+ }
122
+
123
+ it("does not print headers at the default verbosity", () => {
124
+ const builder = new LoggingConfigBuilder();
125
+ attachLoggingInterceptor(client, builder.build());
126
+ invokeRequestFulfilled(client);
127
+
128
+ expect(printedOutput()).not.toContain("headers");
129
+ });
130
+
131
+ it("unions the partner's list with the always-redact set rather than replacing it", () => {
132
+ const builder = new LoggingConfigBuilder();
133
+ builder.level = LogLevel.headers;
134
+ builder.redactHeaders = ["content-type"];
135
+ attachLoggingInterceptor(client, builder.build());
136
+ invokeRequestFulfilled(client);
137
+
138
+ const output = printedOutput();
139
+ expect(output).not.toContain("multipart/form-data");
140
+ for (const [, value] of CANONICAL_CREDENTIAL_HEADERS) {
141
+ expect(output).not.toContain(value);
142
+ }
143
+ });
144
+ });
@@ -26,7 +26,7 @@ import type { TokenHolder } from "../auth/token_holder";
26
26
  * by the next outgoing request.
27
27
  */
28
28
 
29
- const SDK_VERSION = "12.0.1";
29
+ const SDK_VERSION = "12.0.2"; // x-release-please-version
30
30
 
31
31
  export type SmileAuthInterceptorOptions = {
32
32
  readonly partnerConfig: PartnerConfig;
@@ -4,7 +4,9 @@ import type {
4
4
  PartnerConfig,
5
5
  } from "../../core/models/network_config";
6
6
  import { SmileIDUrls } from "../smile_id_urls";
7
- import type { ConsentInformation, UserDetails } from "../../core/models/params";
7
+ import type { ConsentInformation } from "../../core/models/params";
8
+ import { userDetailsPayloadToWire } from "../model/user_details_payload";
9
+ import type { UserDetailsPayload } from "../model/user_details_payload";
8
10
  import type {
9
11
  BiometricKycRequest,
10
12
  DocumentVerificationRequest,
@@ -87,11 +89,7 @@ export class SmileIDAPI {
87
89
  request: SmartSelfieEnrollmentRequest,
88
90
  ): Promise<JobSubmissionResponse> {
89
91
  const formData = this._buildSelfieFormData(request);
90
- appendJsonPart(
91
- formData,
92
- "user_details",
93
- userDetailsToWire(request.userDetails),
94
- );
92
+ appendUserDetailsPart(formData, request);
95
93
  this._appendPartnerConfigParts(formData);
96
94
  this._appendConsentAndMetadata(formData, request.consent, request.metadata);
97
95
  return this._postMultipart("/v3/registration", formData);
@@ -108,11 +106,7 @@ export class SmileIDAPI {
108
106
  ): Promise<JobSubmissionResponse> {
109
107
  const formData = this._buildSelfieFormData(request);
110
108
  appendStringPart(formData, "user_id", request.userId);
111
- appendJsonPart(
112
- formData,
113
- "user_details",
114
- userDetailsToWire(request.userDetails),
115
- );
109
+ appendUserDetailsPart(formData, request);
116
110
  this._appendPartnerConfigParts(formData);
117
111
  this._appendConsentAndMetadata(formData, request.consent, request.metadata);
118
112
  return this._postMultipart("/v3/authentication", formData);
@@ -126,11 +120,7 @@ export class SmileIDAPI {
126
120
  // multipart/form-data`.
127
121
  const formData = new FormData();
128
122
  this._appendIdFields(formData, request.params);
129
- appendJsonPart(
130
- formData,
131
- "user_details",
132
- userDetailsToWire(request.userDetails),
133
- );
123
+ appendUserDetailsPart(formData, request);
134
124
  if (request.params.bankCode) {
135
125
  appendStringPart(formData, "bank_code", request.params.bankCode);
136
126
  }
@@ -149,11 +139,7 @@ export class SmileIDAPI {
149
139
  const useEnrolled = request.params.useEnrolledImage;
150
140
  const formData = this._buildSelfieFormData(request);
151
141
  this._appendIdFields(formData, request.params);
152
- appendJsonPart(
153
- formData,
154
- "user_details",
155
- userDetailsToWire(request.userDetails),
156
- );
142
+ appendUserDetailsPart(formData, request);
157
143
  if (useEnrolled) {
158
144
  appendStringPart(formData, "use_enrolled_image", "true");
159
145
  }
@@ -194,11 +180,7 @@ export class SmileIDAPI {
194
180
  if (request.idType) {
195
181
  appendStringPart(formData, "id_type", request.idType);
196
182
  }
197
- appendJsonPart(
198
- formData,
199
- "user_details",
200
- userDetailsToWire(request.userDetails),
201
- );
183
+ appendUserDetailsPart(formData, request);
202
184
  this._appendPartnerConfigParts(formData);
203
185
  this._appendConsentAndMetadata(formData, request.consent, request.metadata);
204
186
  return this._postMultipart(url, formData);
@@ -306,6 +288,10 @@ export class SmileIDAPI {
306
288
  * the part with `Content-Type: application/json` causes the parser
307
289
  * to reject the part as "missing or invalid".
308
290
  *
291
+ * `consent` is `undefined` only when the token binds complete
292
+ * consent and no local source exists — the part is then omitted
293
+ * entirely and the server injects the token's consent.
294
+ *
309
295
  * The `metadata` snapshot is built by the request-construction site
310
296
  * (the flow navigation manager) and travels on the request — this
311
297
  * method serializes the snapshot it is handed rather than generating
@@ -313,19 +299,21 @@ export class SmileIDAPI {
313
299
  */
314
300
  private _appendConsentAndMetadata(
315
301
  formData: FormData,
316
- consent: ConsentInformation,
302
+ consent: ConsentInformation | undefined,
317
303
  metadata: UseSmileIDMetadata,
318
304
  ): void {
319
305
  // Transform the JS camelCase shape into the canonical snake_case
320
306
  // wire JSON before serialization. The server validates the wire
321
307
  // shape exactly — `consent.granted` (snake_case via `granted`
322
308
  // already) and `consent.granted_at` are required.
323
- appendJsonPart(formData, "consent", {
324
- granted: consent.granted,
325
- granted_at: consent.grantedAt,
326
- notice_language: consent.noticeLanguage,
327
- notice_privacy_policy_url: consent.noticePrivacyPolicyUrl,
328
- });
309
+ if (consent !== undefined) {
310
+ appendJsonPart(formData, "consent", {
311
+ granted: consent.granted,
312
+ granted_at: consent.grantedAt,
313
+ notice_language: consent.noticeLanguage,
314
+ notice_privacy_policy_url: consent.noticePrivacyPolicyUrl,
315
+ });
316
+ }
329
317
  appendJsonPart(formData, "metadata", metadata.toEntries());
330
318
  }
331
319
 
@@ -386,23 +374,22 @@ function mapJobSubmissionResponse(data: unknown): JobSubmissionResponse {
386
374
  }
387
375
 
388
376
  /**
389
- * Convert `UserDetails` to the snake_case wire shape. Optional fields
390
- * are trimmed; blank values are omitted.
377
+ * Append the `user_details` JSON multipart part. The payload arrives
378
+ * pre-reduced from assembly (token-bound fields already dropped); the
379
+ * part is omitted entirely when nothing remains — or when `userDetails`
380
+ * was legitimately omitted because the token binds every required field.
391
381
  */
392
- function userDetailsToWire(details: UserDetails): Record<string, string> {
393
- const wire: Record<string, string> = {
394
- given_names: details.givenNames,
395
- last_name: details.lastName,
396
- };
397
- const trimmedEmail = details.email?.trim();
398
- if (trimmedEmail) {
399
- wire["email"] = trimmedEmail;
400
- }
401
- const trimmedPhone = details.phoneNumber?.trim();
402
- if (trimmedPhone) {
403
- wire["phone_number"] = trimmedPhone;
404
- }
405
- return wire;
382
+ function appendUserDetailsPart(
383
+ formData: FormData,
384
+ request: {
385
+ readonly userDetails?: UserDetailsPayload;
386
+ },
387
+ ): void {
388
+ const details = request.userDetails;
389
+ if (details === undefined) return;
390
+ const wire = userDetailsPayloadToWire(details);
391
+ if (Object.keys(wire).length === 0) return;
392
+ appendJsonPart(formData, "user_details", wire);
406
393
  }
407
394
 
408
395
  /**