@bloonio/lokotro-pay 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,8 +17,8 @@ import { HttpHeaders, HttpErrorResponse } from '@angular/common/http';
17
17
  */
18
18
  const LOKOTRO_DEV_ENV = {
19
19
  environment: 'development',
20
- apiBaseUrl: 'http://10.37.31.218:6495',
21
- // apiBaseUrl: 'https://dev.app.api.gtwy.lokotro.com',
20
+ // apiBaseUrl: 'http://192.168.30.118:6495',
21
+ apiBaseUrl: 'https://app.api.gtwy.lokotro.com',
22
22
  paymentApiVersion: 'v1',
23
23
  debugMode: true,
24
24
  logLevel: 'debug',
@@ -1244,16 +1244,19 @@ class LokotroCountryUtils {
1244
1244
  * Angular version of the Flutter Lokotro Pay HTTP client
1245
1245
  */
1246
1246
  /**
1247
- * Enhanced HTTP client for Lokotro Pay with modern error handling and logging
1247
+ * Enhanced HTTP client for Lokotro Pay with modern error handling and logging.
1248
+ *
1249
+ * PR-1.2 / CRIT-2: previous code emitted six `console.log` calls that
1250
+ * truncated and printed the merchant `appKey` to the browser console on every
1251
+ * HTTP request. The truncated form ("first 20 chars + ...") is still useful
1252
+ * to anyone scraping the console, especially when paired with the response
1253
+ * body. All such logs were removed.
1248
1254
  */
1249
1255
  class LokotroHttpClientService {
1250
- static { this.instanceCounter = 0; }
1251
1256
  constructor(http) {
1252
1257
  this.http = http;
1253
1258
  this.acceptLanguage = 'fr';
1254
1259
  this.customHeaders = {};
1255
- this.instanceId = ++LokotroHttpClientService.instanceCounter;
1256
- console.log(`[Lokotro HTTP] Instance #${this.instanceId} created`);
1257
1260
  }
1258
1261
  /**
1259
1262
  * Configure the HTTP client
@@ -1273,9 +1276,7 @@ class LokotroHttpClientService {
1273
1276
  * Set app-key for Lokotro Gateway authentication
1274
1277
  */
1275
1278
  setAppKey(appKey) {
1276
- console.log(`[Lokotro HTTP #${this.instanceId}] setAppKey called with:`, appKey?.substring(0, 20) + '...');
1277
1279
  this.appKey = appKey;
1278
- console.log(`[Lokotro HTTP #${this.instanceId}] appKey now set to:`, this.appKey?.substring(0, 20) + '...');
1279
1280
  }
1280
1281
  /**
1281
1282
  * Remove app-key
@@ -1299,7 +1300,6 @@ class LokotroHttpClientService {
1299
1300
  * Build request headers
1300
1301
  */
1301
1302
  buildHeaders() {
1302
- console.log(`[Lokotro HTTP #${this.instanceId}] buildHeaders - appKey value:`, this.appKey?.substring(0, 20) + '...');
1303
1303
  let headers = new HttpHeaders({
1304
1304
  'Content-Type': 'application/json',
1305
1305
  'Accept': 'application/json',
@@ -1308,11 +1308,14 @@ class LokotroHttpClientService {
1308
1308
  'Accept-Language': this.acceptLanguage
1309
1309
  });
1310
1310
  if (this.appKey) {
1311
- headers = headers.set('app-key', this.appKey);
1312
- console.log(`[Lokotro HTTP #${this.instanceId}] app-key header added`);
1313
- }
1314
- else {
1315
- console.warn(`[Lokotro HTTP #${this.instanceId}] WARNING: appKey is not set, app-key header NOT added`);
1311
+ // Auth-v3 / PR-3.5: the gateway's /api/v1/payments/* endpoints now
1312
+ // authenticate via `Authorization: Bearer <session_token>`, where
1313
+ // `session_token` is minted server-to-server by the merchant backend
1314
+ // calling /api/v1/internal/sessions/create. `this.appKey` here holds
1315
+ // that session_token (the field name is kept for backwards-compat
1316
+ // with merchants who haven't migrated yet — gateway returns 401 for
1317
+ // legacy app-key on /collect anyway).
1318
+ headers = headers.set('Authorization', `Bearer ${this.appKey}`);
1316
1319
  }
1317
1320
  // Add custom headers
1318
1321
  Object.entries(this.customHeaders).forEach(([key, value]) => {
@@ -1388,11 +1391,20 @@ class LokotroHttpClientService {
1388
1391
  };
1389
1392
  }
1390
1393
  /**
1391
- * Handle error response
1394
+ * Handle error response.
1395
+ *
1396
+ * PR-1.2: previously logged the full `error` object in debug mode, which
1397
+ * for an HttpErrorResponse includes `error.error` — i.e. the raw server
1398
+ * response body. That body can leak validation echoes of the original
1399
+ * request (PAN/PIN/etc.) when the backend is in development mode. Now we
1400
+ * log only HTTP status code + url; never the body.
1392
1401
  */
1393
1402
  handleError(error) {
1394
1403
  if (LokotroPayEnv.debugMode) {
1395
- console.error('[Lokotro Pay HTTP] Error:', error);
1404
+ const status = error instanceof HttpErrorResponse ? error.status : 0;
1405
+ const url = error instanceof HttpErrorResponse ? error.url : '';
1406
+ // eslint-disable-next-line no-console
1407
+ console.error('[Lokotro Pay HTTP] Error', { status, url });
1396
1408
  }
1397
1409
  let errorResponse;
1398
1410
  if (error instanceof HttpErrorResponse) {
@@ -3392,9 +3404,12 @@ class LokotroPaymentService {
3392
3404
  this.currentPaymentBody = paymentBody;
3393
3405
  this.updateState({ isLoading: true });
3394
3406
  const requestData = this.convertPaymentBodyToRequest(paymentBody);
3395
- console.log('[Lokotro Payment] Creating payment with body:', requestData);
3407
+ // PR-1.2 / CRIT-1: previous `console.log(requestData)` shipped PAN, CVV,
3408
+ // wallet PIN, flash PIN, and full PII to the browser console on every
3409
+ // checkout. Removed.
3396
3410
  return this.httpClient.post(LokotroPayEnv.endpoints.collect, requestData).pipe(switchMap(response => {
3397
- console.log('[Lokotro Payment] Create payment response:', response);
3411
+ // PR-1.2: response contains transaction IDs and (sometimes) redirect
3412
+ // URLs that are sensitive. Don't log it.
3398
3413
  if (!response.isSuccess || !response.data) {
3399
3414
  this.updateState({
3400
3415
  isLoading: false,
@@ -3456,7 +3471,10 @@ class LokotroPaymentService {
3456
3471
  isLoading: false,
3457
3472
  transactionId: submitResponse.transactionId || this.resolvePaymentId(request)
3458
3473
  });
3459
- window.location.href = submitResponse.redirectUrl;
3474
+ // PR-1.2 / Angular HIGH-1: validate redirect URL against allow-list
3475
+ // before handing the tab over. Server-side allow-list (PR-2.0) is
3476
+ // the real defense; this is defense-in-depth on the client.
3477
+ this.redirectIfAllowed(submitResponse.redirectUrl, 'submit-response');
3460
3478
  return;
3461
3479
  }
3462
3480
  if (submitResponse.status === LokotroPaymentStatus.PendingBankProofUpload) {
@@ -3562,11 +3580,11 @@ class LokotroPaymentService {
3562
3580
  startMobileMoneyPolling(transactionId) {
3563
3581
  this.stopMobileMoneyPolling();
3564
3582
  this.mobileMoneyPollingAttempts = 0;
3565
- console.log('[Lokotro Payment] Starting mobile money status polling for:', transactionId);
3583
+ // PR-1.2: removed console.log of transactionId operational logging
3584
+ // should be opt-in via merchant config, not on by default.
3566
3585
  this.mobileMoneyPollingTimer = setInterval(() => {
3567
3586
  this.mobileMoneyPollingAttempts++;
3568
3587
  if (this.mobileMoneyPollingAttempts > LokotroPaymentService.MOBILE_MONEY_MAX_ATTEMPTS) {
3569
- console.warn('[Lokotro Payment] Mobile money polling max attempts reached');
3570
3588
  this.stopMobileMoneyPolling();
3571
3589
  this.handlePaymentFailure('Payment took too long. Please try again or contact support.');
3572
3590
  return;
@@ -3578,7 +3596,6 @@ class LokotroPaymentService {
3578
3596
  return;
3579
3597
  const data = this.unwrapPayload(response.data);
3580
3598
  const status = this.getString(data['status']).toLowerCase();
3581
- console.log(`[Lokotro Payment] Mobile money poll #${this.mobileMoneyPollingAttempts}: status=${status}`);
3582
3599
  if (status === 'completed' || status === 'success' || status === 'approved') {
3583
3600
  this.stopMobileMoneyPolling();
3584
3601
  this.handlePaymentSuccess(data);
@@ -3588,8 +3605,9 @@ class LokotroPaymentService {
3588
3605
  this.handlePaymentFailure(this.getString(data['message']) || 'Payment failed or was declined');
3589
3606
  }
3590
3607
  },
3591
- error: (err) => {
3592
- console.error('[Lokotro Payment] Mobile money polling error:', err);
3608
+ error: (_err) => {
3609
+ // PR-1.2: don't log full error object may contain backend
3610
+ // response details that leak server internals to console.
3593
3611
  }
3594
3612
  });
3595
3613
  }, LokotroPaymentService.MOBILE_MONEY_POLL_INTERVAL);
@@ -3602,9 +3620,47 @@ class LokotroPaymentService {
3602
3620
  clearInterval(this.mobileMoneyPollingTimer);
3603
3621
  this.mobileMoneyPollingTimer = undefined;
3604
3622
  this.mobileMoneyPollingAttempts = 0;
3605
- console.log('[Lokotro Payment] Mobile money polling stopped');
3606
3623
  }
3607
3624
  }
3625
+ /**
3626
+ * Validate a server-supplied redirect URL before handing the browser tab to
3627
+ * it. Defense-in-depth for Angular HIGH-1 — server-side per-merchant
3628
+ * allow-list (PR-2.0) is the real fix.
3629
+ *
3630
+ * Policy:
3631
+ * - Reject non-http(s) schemes (blocks `javascript:`, `data:`, `file:`,
3632
+ * `intent:`, custom schemes).
3633
+ * - Reject http:// in production.
3634
+ * - Reject malformed URLs.
3635
+ * - SSR safety: skip the redirect when window is unavailable.
3636
+ */
3637
+ redirectIfAllowed(url, _origin) {
3638
+ if (typeof window === 'undefined') {
3639
+ return;
3640
+ }
3641
+ if (!url || typeof url !== 'string') {
3642
+ this.handlePaymentFailure('Invalid redirect URL');
3643
+ return;
3644
+ }
3645
+ let parsed;
3646
+ try {
3647
+ parsed = new URL(url, window.location.href);
3648
+ }
3649
+ catch {
3650
+ this.handlePaymentFailure('Untrusted redirect URL');
3651
+ return;
3652
+ }
3653
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
3654
+ this.handlePaymentFailure('Untrusted redirect scheme');
3655
+ return;
3656
+ }
3657
+ const isProduction = LokotroPayEnv.environment === 'production';
3658
+ if (isProduction && parsed.protocol !== 'https:') {
3659
+ this.handlePaymentFailure('Insecure redirect rejected');
3660
+ return;
3661
+ }
3662
+ window.location.href = parsed.toString();
3663
+ }
3608
3664
  /**
3609
3665
  * Handle payment success
3610
3666
  */
@@ -3682,7 +3738,8 @@ class LokotroPaymentService {
3682
3738
  isLoading: false,
3683
3739
  transactionId: paymentId
3684
3740
  });
3685
- window.location.href = redirectUrl;
3741
+ // PR-1.2 / Angular HIGH-1: validate before redirect.
3742
+ this.redirectIfAllowed(redirectUrl, 'session-created');
3686
3743
  return of(void 0);
3687
3744
  }
3688
3745
  if (this.getString(additionalData?.['action']) === 'select_payment_method') {
@@ -3731,7 +3788,8 @@ class LokotroPaymentService {
3731
3788
  this.getString(transactionData['redirect_url']);
3732
3789
  if (hostedCheckoutUrl) {
3733
3790
  this.updateState({ isLoading: false });
3734
- window.location.href = hostedCheckoutUrl;
3791
+ // PR-1.2 / Angular HIGH-1: validate hosted-checkout URL.
3792
+ this.redirectIfAllowed(hostedCheckoutUrl, 'hosted-checkout');
3735
3793
  return of(void 0);
3736
3794
  }
3737
3795
  }
@@ -3778,15 +3836,25 @@ class LokotroPaymentService {
3778
3836
  */
3779
3837
  convertPaymentBodyToRequest(body) {
3780
3838
  const request = {
3781
- customer_reference: body.customerReference,
3782
- amount: body.amount,
3783
- currency: body.currency.toLowerCase(),
3784
3839
  payment_method: body.paymentMethod || 'wallet',
3785
3840
  user_info: body.userInfo || 'full',
3786
3841
  payment_method_info: body.paymentMethodInfo || 'full',
3787
3842
  fee_covered_by: body.feeCoveredBy || 'buyer',
3788
3843
  delivery_behaviour: body.deliveryBehaviour || 'direct_delivery'
3789
3844
  };
3845
+ // Auth-v3 refactor: customer_reference / amount / currency are bound
3846
+ // on the session_token. The gateway reads them straight from the
3847
+ // session — sending them again on the wire is redundant, and was
3848
+ // the source of the `"1.00"` vs `"1"` formatting drift that 400'd
3849
+ // body-binding. Only include them when the merchant explicitly set
3850
+ // them (legacy / display reasons). The gateway treats them as a
3851
+ // soft check when present, ignores when absent.
3852
+ if (body.customerReference)
3853
+ request['customer_reference'] = body.customerReference;
3854
+ if (body.amount)
3855
+ request['amount'] = body.amount;
3856
+ if (body.currency)
3857
+ request['currency'] = body.currency.toLowerCase();
3790
3858
  if (body.notifyUrl)
3791
3859
  request['notify_url'] = body.notifyUrl;
3792
3860
  if (body.successRedirectUrl)
@@ -3966,6 +4034,9 @@ class LokotroPaymentService {
3966
4034
  name,
3967
4035
  displayName: this.getString(data['display_name']) || name,
3968
4036
  channel: this.parseChannel(rawChannel),
4037
+ // PR-3.0 — pull the provider flag through. Legacy responses omit it →
4038
+ // remains undefined, consumers fall back to channel-level branching.
4039
+ flag: this.parsePaymentMethodFlag(this.getString(data['flag'])),
3969
4040
  iconUrl: this.getString(data['icon_url']) || this.getString(data['icon']),
3970
4041
  isEnabled: this.getBoolean(data['is_enabled'], this.getBoolean(data['available'], true)),
3971
4042
  configuration: this.asRecord(data['configuration']),
@@ -3974,6 +4045,19 @@ class LokotroPaymentService {
3974
4045
  : undefined
3975
4046
  };
3976
4047
  }
4048
+ /**
4049
+ * PR-3.0 — narrow an arbitrary string to the LokotroPaymentMethodFlag union.
4050
+ * Unknown / empty inputs return undefined so the field stays optional.
4051
+ */
4052
+ parsePaymentMethodFlag(value) {
4053
+ const known = [
4054
+ 'none', 'all',
4055
+ 'onafriq_mobile_money', 'onafriq_credit_card', 'rawbank_credit_card',
4056
+ 'lokotro_wallet', 'lokotro_eflash',
4057
+ 'cash', 'bank_transfer', 'google_pay', 'apple_pay',
4058
+ ];
4059
+ return known.find(f => f === value);
4060
+ }
3977
4061
  /**
3978
4062
  * Parse submit response from API.
3979
4063
  */
@@ -4193,18 +4277,13 @@ class LokotroPayCheckoutComponent {
4193
4277
  }
4194
4278
  }
4195
4279
  initializeEnvironment() {
4196
- console.log('[Lokotro Checkout] initializeEnvironment - configs:', {
4197
- token: this.configs.token?.substring(0, 20) + '...',
4198
- isProduction: this.configs.isProduction,
4199
- customApiUrl: this.configs.customApiUrl
4200
- });
4280
+ // PR-1.2 / CRIT-2: previous logs printed a truncated form of the merchant
4281
+ // token to the browser console on every checkout init. Removed.
4201
4282
  LokotroPayEnv.initialize(this.configs.isProduction ?? false, this.configs.customApiUrl);
4202
- console.log('[Lokotro Checkout] Calling setAppKey with token');
4203
4283
  this.paymentService.setAppKey(this.configs.token);
4204
4284
  if (this.configs.acceptLanguage) {
4205
4285
  this.paymentService.setAcceptLanguage(this.configs.acceptLanguage);
4206
4286
  }
4207
- console.log('[Lokotro Checkout] Environment initialized');
4208
4287
  }
4209
4288
  initializeLocalization() {
4210
4289
  if (this.language) {