@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.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/dist/camera/camera_defaults.d.ts +2 -0
- package/dist/camera/camera_defaults.d.ts.map +1 -1
- package/dist/camera/camera_defaults.js +2 -0
- package/dist/camera/camera_defaults.js.map +1 -1
- package/dist/core/exception/flow_exceptions.d.ts +4 -2
- package/dist/core/exception/flow_exceptions.d.ts.map +1 -1
- package/dist/core/exception/flow_exceptions.js +3 -2
- package/dist/core/exception/flow_exceptions.js.map +1 -1
- package/dist/core/exception/missing_job_type_exception.d.ts +1 -3
- package/dist/core/exception/missing_job_type_exception.d.ts.map +1 -1
- package/dist/core/exception/missing_job_type_exception.js +2 -4
- package/dist/core/exception/missing_job_type_exception.js.map +1 -1
- package/dist/data/api/interceptors/smile_auth_interceptor.js +1 -1
- package/dist/data/api/interceptors/smile_auth_interceptor.js.map +1 -1
- package/dist/data/api/smile_id_api.d.ts +4 -0
- package/dist/data/api/smile_id_api.d.ts.map +1 -1
- package/dist/data/api/smile_id_api.js +30 -27
- package/dist/data/api/smile_id_api.js.map +1 -1
- package/dist/data/model/job_request.d.ts +28 -12
- package/dist/data/model/job_request.d.ts.map +1 -1
- package/dist/data/model/token_payload.d.ts +78 -0
- package/dist/data/model/token_payload.d.ts.map +1 -0
- package/dist/data/model/token_payload.js +132 -0
- package/dist/data/model/token_payload.js.map +1 -0
- package/dist/data/model/user_details_payload.d.ts +39 -0
- package/dist/data/model/user_details_payload.d.ts.map +1 -0
- package/dist/data/model/user_details_payload.js +53 -0
- package/dist/data/model/user_details_payload.js.map +1 -0
- package/dist/data/util/use_smile_id_token_decoder.d.ts +15 -3
- package/dist/data/util/use_smile_id_token_decoder.d.ts.map +1 -1
- package/dist/data/util/use_smile_id_token_decoder.js +20 -8
- package/dist/data/util/use_smile_id_token_decoder.js.map +1 -1
- package/dist/l10n/strings_en.d.ts +4 -1
- package/dist/l10n/strings_en.d.ts.map +1 -1
- package/dist/l10n/strings_en.js +4 -1
- package/dist/l10n/strings_en.js.map +1 -1
- package/dist/l10n/use_smile_id_localizations.d.ts +10 -0
- package/dist/l10n/use_smile_id_localizations.d.ts.map +1 -1
- package/dist/l10n/use_smile_id_localizations.js +59 -3
- package/dist/l10n/use_smile_id_localizations.js.map +1 -1
- package/dist/metadata/metadata_configuration_mappers.js +1 -1
- package/dist/metadata/metadata_configuration_mappers.js.map +1 -1
- package/dist/metadata/use_smile_id_metadata_factory.js +1 -1
- package/dist/metadata/use_smile_id_metadata_factory.js.map +1 -1
- package/dist/presentation/components/use_smile_id_button.d.ts.map +1 -1
- package/dist/presentation/components/use_smile_id_button.js +8 -2
- package/dist/presentation/components/use_smile_id_button.js.map +1 -1
- package/dist/presentation/flow/dsl/builder/screens_builder.d.ts +3 -6
- package/dist/presentation/flow/dsl/builder/screens_builder.d.ts.map +1 -1
- package/dist/presentation/flow/dsl/builder/screens_builder.js +4 -6
- package/dist/presentation/flow/dsl/builder/screens_builder.js.map +1 -1
- package/dist/presentation/flow/dsl/use_smile_id_flow_builder.d.ts.map +1 -1
- package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js +23 -1
- package/dist/presentation/flow/dsl/use_smile_id_flow_builder.js.map +1 -1
- package/dist/presentation/flow/flow_navigation_manager.d.ts +17 -2
- package/dist/presentation/flow/flow_navigation_manager.d.ts.map +1 -1
- package/dist/presentation/flow/flow_navigation_manager.js +77 -45
- package/dist/presentation/flow/flow_navigation_manager.js.map +1 -1
- package/dist/presentation/flow/screen/orchestrated_processing_screen.d.ts.map +1 -1
- package/dist/presentation/flow/screen/orchestrated_processing_screen.js +25 -5
- package/dist/presentation/flow/screen/orchestrated_processing_screen.js.map +1 -1
- package/dist/presentation/flow/use_smile_id_flow.d.ts.map +1 -1
- package/dist/presentation/flow/use_smile_id_flow.js +35 -8
- package/dist/presentation/flow/use_smile_id_flow.js.map +1 -1
- package/dist/presentation/flow/validation/flow_validator.d.ts +16 -2
- package/dist/presentation/flow/validation/flow_validator.d.ts.map +1 -1
- package/dist/presentation/flow/validation/flow_validator.js +19 -27
- package/dist/presentation/flow/validation/flow_validator.js.map +1 -1
- package/dist/presentation/flow/validation/job_type_validator.d.ts +7 -0
- package/dist/presentation/flow/validation/job_type_validator.d.ts.map +1 -1
- package/dist/presentation/flow/validation/job_type_validator.js +52 -27
- package/dist/presentation/flow/validation/job_type_validator.js.map +1 -1
- package/dist/presentation/flow/validation/user_details_validator.d.ts +9 -0
- package/dist/presentation/flow/validation/user_details_validator.d.ts.map +1 -0
- package/dist/presentation/flow/validation/user_details_validator.js +40 -0
- package/dist/presentation/flow/validation/user_details_validator.js.map +1 -0
- package/dist/presentation/screen/helpers/capture_screen_helpers.d.ts.map +1 -1
- package/dist/presentation/screen/helpers/capture_screen_helpers.js +1 -0
- package/dist/presentation/screen/helpers/capture_screen_helpers.js.map +1 -1
- package/dist/presentation/screen/use_smile_id_capture_screen.d.ts.map +1 -1
- package/dist/presentation/screen/use_smile_id_capture_screen.js +64 -18
- package/dist/presentation/screen/use_smile_id_capture_screen.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/react_native_mock.tsx +22 -0
- package/src/__tests__/snapshots/__snapshots__/screens_structural.test.tsx.snap +14 -8
- package/src/camera/__tests__/camera_permissions.test.ts +72 -0
- package/src/camera/__tests__/camera_system_focus.test.ts +173 -0
- package/src/camera/__tests__/use_smile_id_camera_preview.test.tsx +461 -0
- package/src/camera/__tests__/use_smile_id_frame_stream_hook.test.tsx +229 -0
- package/src/camera/camera_defaults.ts +3 -0
- package/src/core/exception/__tests__/builder_validation_exceptions.test.ts +14 -0
- package/src/core/exception/flow_exceptions.test.ts +10 -0
- package/src/core/exception/flow_exceptions.ts +3 -2
- package/src/core/exception/missing_job_type_exception.ts +2 -4
- package/src/data/api/__tests__/smile_id_api.test.ts +275 -1
- package/src/data/api/interceptors/__tests__/credential_header_redaction.test.ts +144 -0
- package/src/data/api/interceptors/smile_auth_interceptor.ts +1 -1
- package/src/data/api/smile_id_api.ts +36 -49
- package/src/data/dsl/__tests__/use_smile_id_network_builder.test.ts +157 -0
- package/src/data/factory/__tests__/signed_job_submission_headers.test.ts +1 -1
- package/src/data/model/__tests__/token_payload.test.ts +224 -0
- package/src/data/model/__tests__/user_details_payload.test.ts +100 -0
- package/src/data/model/job_request.ts +27 -12
- package/src/data/model/token_payload.ts +182 -0
- package/src/data/model/user_details_payload.ts +84 -0
- package/src/data/repository/__tests__/network_retries.test.ts +1 -1
- package/src/data/repository/__tests__/repository_dispatch.test.ts +366 -0
- package/src/data/repository/__tests__/use_smile_id_repository.test.ts +1 -1
- package/src/data/security/__tests__/signing_crypto.test.ts +203 -0
- package/src/data/util/__tests__/use_smile_id_token_decoder.test.ts +179 -0
- package/src/data/util/use_smile_id_token_decoder.ts +29 -8
- package/src/l10n/__tests__/use_smile_id_localizations.test.ts +122 -5
- package/src/l10n/__tests__/use_smile_id_localizations_provider.test.tsx +112 -0
- package/src/l10n/__tests__/use_smile_id_localizations_surface.test.ts +231 -0
- package/src/l10n/strings_en.ts +5 -1
- package/src/l10n/use_smile_id_localizations.ts +80 -3
- package/src/metadata/__tests__/device_orientation_sampling.test.ts +165 -0
- package/src/metadata/__tests__/geolocation.test.ts +150 -0
- package/src/metadata/__tests__/metadata_configuration_objects.test.ts +1 -1
- package/src/metadata/__tests__/metadata_device_context.test.ts +3 -3
- package/src/metadata/__tests__/metadata_golden_fixture.json +1 -1
- package/src/metadata/__tests__/metadata_payload_golden.test.ts +4 -4
- package/src/metadata/__tests__/optional_native_modules.test.ts +234 -0
- package/src/metadata/__tests__/use_smile_id_metadata.test.ts +1 -1
- package/src/metadata/__tests__/use_smile_id_metadata_factory.test.ts +615 -0
- package/src/metadata/metadata_configuration_mappers.ts +1 -1
- package/src/metadata/use_smile_id_metadata_factory.ts +1 -1
- package/src/presentation/components/__tests__/use_smile_id_content_card.test.tsx +112 -0
- package/src/presentation/components/__tests__/use_smile_id_face_oval_image.test.tsx +190 -0
- package/src/presentation/components/__tests__/use_smile_id_learn_more_content.test.tsx +159 -0
- package/src/presentation/components/__tests__/use_smile_id_processing_center_indicator.test.tsx +88 -0
- package/src/presentation/components/use_smile_id_button.tsx +7 -2
- package/src/presentation/flow/__tests__/flow_navigation_manager.test.ts +323 -1
- package/src/presentation/flow/__tests__/use_smile_id_flow.test.tsx +651 -0
- package/src/presentation/flow/dsl/__tests__/use_smile_id_builder_component.test.tsx +120 -0
- package/src/presentation/flow/dsl/builder/screens_builder.ts +4 -6
- package/src/presentation/flow/dsl/use_smile_id_flow_builder.ts +27 -1
- package/src/presentation/flow/flow_navigation_manager.ts +102 -92
- package/src/presentation/flow/graph/__tests__/screen_renderer.test.tsx +102 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_capture_screen.test.tsx +836 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_consent_screen.test.tsx +165 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_instructions_screen.test.tsx +163 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_preview_screen.test.tsx +224 -0
- package/src/presentation/flow/screen/__tests__/orchestrated_processing_screen.test.tsx +338 -0
- package/src/presentation/flow/screen/orchestrated_processing_screen.tsx +42 -5
- package/src/presentation/flow/use_smile_id_flow.tsx +52 -8
- package/src/presentation/flow/validation/__tests__/flow_validator.test.ts +158 -0
- package/src/presentation/flow/validation/__tests__/job_type_validator.test.ts +200 -0
- package/src/presentation/flow/validation/flow_validator.ts +30 -31
- package/src/presentation/flow/validation/job_type_validator.ts +73 -39
- package/src/presentation/flow/validation/user_details_validator.ts +53 -0
- package/src/presentation/screen/__tests__/feedback_copy_table.test.ts +145 -0
- package/src/presentation/screen/__tests__/use_smile_id_capture_screen.test.tsx +348 -0
- package/src/presentation/screen/__tests__/use_smile_id_document_back_instructions.test.tsx +3 -1
- package/src/presentation/screen/__tests__/use_smile_id_permission_screen.test.tsx +68 -0
- package/src/presentation/screen/helpers/capture_screen_helpers.ts +1 -0
- package/src/presentation/screen/use_smile_id_capture_screen.tsx +69 -18
- 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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
*
|
|
390
|
-
*
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
/**
|