@nokinc-flur/sdk 0.1.7 → 1.0.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.
- package/LICENSE +26 -15
- package/README.md +229 -198
- package/dist/index.cjs +2294 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1593 -0
- package/dist/index.d.ts +1309 -2
- package/dist/index.js +1459 -7
- package/dist/index.js.map +1 -1
- package/package.json +76 -50
package/dist/index.js
CHANGED
|
@@ -225,6 +225,39 @@ var FlurError = class extends Error {
|
|
|
225
225
|
this.reqId = opts?.reqId;
|
|
226
226
|
}
|
|
227
227
|
};
|
|
228
|
+
var FlurApiError = class extends Error {
|
|
229
|
+
constructor(status, code, message, raw) {
|
|
230
|
+
super(message);
|
|
231
|
+
this.status = status;
|
|
232
|
+
this.code = code;
|
|
233
|
+
this.raw = raw;
|
|
234
|
+
this.name = "FlurApiError";
|
|
235
|
+
}
|
|
236
|
+
status;
|
|
237
|
+
code;
|
|
238
|
+
raw;
|
|
239
|
+
};
|
|
240
|
+
var FlurExpiredError = class extends Error {
|
|
241
|
+
code = "PASS_EXPIRED";
|
|
242
|
+
constructor(message = "pass is expired") {
|
|
243
|
+
super(message);
|
|
244
|
+
this.name = "FlurExpiredError";
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var FlurReplayError = class extends Error {
|
|
248
|
+
code = "PASS_REPLAY";
|
|
249
|
+
constructor(message = "redemption counter is not strictly increasing") {
|
|
250
|
+
super(message);
|
|
251
|
+
this.name = "FlurReplayError";
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
var FlurCapExceededError = class extends Error {
|
|
255
|
+
code = "PASS_CAP_EXCEEDED";
|
|
256
|
+
constructor(message = "redemption exceeds cumulative cap") {
|
|
257
|
+
super(message);
|
|
258
|
+
this.name = "FlurCapExceededError";
|
|
259
|
+
}
|
|
260
|
+
};
|
|
228
261
|
async function mapToFlurError(res) {
|
|
229
262
|
const reqId = res.headers.get("x-request-id");
|
|
230
263
|
let details = void 0;
|
|
@@ -248,7 +281,11 @@ async function mapToFlurError(res) {
|
|
|
248
281
|
}
|
|
249
282
|
} catch {
|
|
250
283
|
}
|
|
251
|
-
return new FlurError(mappedMessage, mappedCode, {
|
|
284
|
+
return new FlurError(mappedMessage, mappedCode, {
|
|
285
|
+
status: res.status,
|
|
286
|
+
details,
|
|
287
|
+
reqId
|
|
288
|
+
});
|
|
252
289
|
}
|
|
253
290
|
|
|
254
291
|
// src/primitives.ts
|
|
@@ -665,13 +702,13 @@ var FlurClient = class {
|
|
|
665
702
|
ResolvePayLinkResponseSchema
|
|
666
703
|
);
|
|
667
704
|
}
|
|
668
|
-
async requestJson(path,
|
|
705
|
+
async requestJson(path, init2, requestSchema, responseSchema, input) {
|
|
669
706
|
const url = `${this.baseUrl}${path}`;
|
|
670
707
|
const controller = new AbortController();
|
|
671
708
|
const t = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
672
|
-
let body =
|
|
709
|
+
let body = init2.body;
|
|
673
710
|
try {
|
|
674
|
-
body = requestSchema ? JSON.stringify(requestSchema.parse(input)) :
|
|
711
|
+
body = requestSchema ? JSON.stringify(requestSchema.parse(input)) : init2.body;
|
|
675
712
|
} catch (err) {
|
|
676
713
|
if (err instanceof z3.ZodError) {
|
|
677
714
|
throw new FlurError("Invalid request payload", "INVALID_REQUEST", {
|
|
@@ -686,10 +723,10 @@ var FlurClient = class {
|
|
|
686
723
|
extraHeaders = await this.getExtraHeaders();
|
|
687
724
|
}
|
|
688
725
|
const finalHeaders = {
|
|
689
|
-
...
|
|
726
|
+
...init2.headers,
|
|
690
727
|
...extraHeaders
|
|
691
728
|
};
|
|
692
|
-
const res = await this.fetchImpl(url, { ...
|
|
729
|
+
const res = await this.fetchImpl(url, { ...init2, headers: finalHeaders, body, signal: controller.signal });
|
|
693
730
|
if (!res.ok) throw await mapToFlurError(res);
|
|
694
731
|
const payload = await res.json();
|
|
695
732
|
if (!responseSchema) return payload;
|
|
@@ -720,12 +757,1427 @@ function getSecureRandomUuid() {
|
|
|
720
757
|
}
|
|
721
758
|
throw new FlurError("Secure UUID generator unavailable; provide idempotencyKey", "INVALID_REQUEST");
|
|
722
759
|
}
|
|
760
|
+
|
|
761
|
+
// src/nqr/fields.ts
|
|
762
|
+
var FIELD = {
|
|
763
|
+
PAYLOAD_FORMAT_INDICATOR: "00",
|
|
764
|
+
POINT_OF_INITIATION: "01",
|
|
765
|
+
// 02..51 — Merchant Account Information templates (issuer-specific)
|
|
766
|
+
MERCHANT_CATEGORY_CODE: "52",
|
|
767
|
+
TRANSACTION_CURRENCY: "53",
|
|
768
|
+
TRANSACTION_AMOUNT: "54",
|
|
769
|
+
TIP_OR_CONVENIENCE_INDICATOR: "55",
|
|
770
|
+
VALUE_CONVENIENCE_FEE_FIXED: "56",
|
|
771
|
+
VALUE_CONVENIENCE_FEE_PERCENT: "57",
|
|
772
|
+
COUNTRY_CODE: "58",
|
|
773
|
+
MERCHANT_NAME: "59",
|
|
774
|
+
MERCHANT_CITY: "60",
|
|
775
|
+
POSTAL_CODE: "61",
|
|
776
|
+
ADDITIONAL_DATA_FIELD: "62",
|
|
777
|
+
CRC: "63",
|
|
778
|
+
MERCHANT_INFO_LANGUAGE: "64"
|
|
779
|
+
// 80..99 — Unreserved Templates
|
|
780
|
+
};
|
|
781
|
+
var ADDITIONAL_DATA_SUBFIELD = {
|
|
782
|
+
BILL_NUMBER: "01",
|
|
783
|
+
MOBILE_NUMBER: "02",
|
|
784
|
+
STORE_LABEL: "03",
|
|
785
|
+
LOYALTY_NUMBER: "04",
|
|
786
|
+
REFERENCE_LABEL: "05",
|
|
787
|
+
CUSTOMER_LABEL: "06",
|
|
788
|
+
TERMINAL_LABEL: "07",
|
|
789
|
+
PURPOSE_OF_TRANSACTION: "08"
|
|
790
|
+
};
|
|
791
|
+
var POINT_OF_INITIATION = {
|
|
792
|
+
STATIC: "11",
|
|
793
|
+
DYNAMIC: "12"
|
|
794
|
+
};
|
|
795
|
+
var PAYLOAD_FORMAT_INDICATOR_VALUE = "01";
|
|
796
|
+
var NGN_CURRENCY_CODE = "566";
|
|
797
|
+
var NG_COUNTRY_CODE = "NG";
|
|
798
|
+
var CRC_TAG_PREFIX = "6304";
|
|
799
|
+
|
|
800
|
+
// src/nqr/crc16.ts
|
|
801
|
+
function crc16ccitt(bytes) {
|
|
802
|
+
let crc = 65535;
|
|
803
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
804
|
+
crc ^= bytes[i] << 8;
|
|
805
|
+
for (let j = 0; j < 8; j++) {
|
|
806
|
+
if (crc & 32768) {
|
|
807
|
+
crc = crc << 1 ^ 4129;
|
|
808
|
+
} else {
|
|
809
|
+
crc <<= 1;
|
|
810
|
+
}
|
|
811
|
+
crc &= 65535;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return crc;
|
|
815
|
+
}
|
|
816
|
+
function crc16ccittHex(bytes) {
|
|
817
|
+
return crc16ccitt(bytes).toString(16).toUpperCase().padStart(4, "0");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/nqr/tlv.ts
|
|
821
|
+
function writeTLV(tag, value) {
|
|
822
|
+
if (!/^\d{2}$/.test(tag)) {
|
|
823
|
+
throw new Error(`TLV: tag must be 2 digits, got "${tag}"`);
|
|
824
|
+
}
|
|
825
|
+
if (value.length > 99) {
|
|
826
|
+
throw new Error(
|
|
827
|
+
`TLV: value for tag ${tag} exceeds 99 chars (${value.length})`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
const len = value.length.toString().padStart(2, "0");
|
|
831
|
+
return `${tag}${len}${value}`;
|
|
832
|
+
}
|
|
833
|
+
function readTLV(buf) {
|
|
834
|
+
const out = [];
|
|
835
|
+
let i = 0;
|
|
836
|
+
while (i < buf.length) {
|
|
837
|
+
if (i + 4 > buf.length) {
|
|
838
|
+
throw new Error(`TLV: truncated header at offset ${i}`);
|
|
839
|
+
}
|
|
840
|
+
const tag = buf.slice(i, i + 2);
|
|
841
|
+
const lenStr = buf.slice(i + 2, i + 4);
|
|
842
|
+
if (!/^\d{2}$/.test(tag)) {
|
|
843
|
+
throw new Error(`TLV: bad tag "${tag}" at offset ${i}`);
|
|
844
|
+
}
|
|
845
|
+
if (!/^\d{2}$/.test(lenStr)) {
|
|
846
|
+
throw new Error(`TLV: bad length "${lenStr}" at offset ${i + 2}`);
|
|
847
|
+
}
|
|
848
|
+
const len = parseInt(lenStr, 10);
|
|
849
|
+
const valStart = i + 4;
|
|
850
|
+
const valEnd = valStart + len;
|
|
851
|
+
if (valEnd > buf.length) {
|
|
852
|
+
throw new Error(
|
|
853
|
+
`TLV: truncated value for tag ${tag} at offset ${valStart}`
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
out.push({ tag, value: buf.slice(valStart, valEnd) });
|
|
857
|
+
i = valEnd;
|
|
858
|
+
}
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// src/nqr/encoder.ts
|
|
863
|
+
var ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
|
|
864
|
+
function buildMAIValue(mai) {
|
|
865
|
+
if (!/^(0[2-9]|[1-4]\d|5[0-1])$/.test(mai.tag)) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
`encodeNQR: merchantAccountInfo.tag must be in 02..51, got "${mai.tag}"`
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
let out = "";
|
|
871
|
+
for (const c of mai.children) {
|
|
872
|
+
out += writeTLV(c.tag, c.value);
|
|
873
|
+
}
|
|
874
|
+
return out;
|
|
875
|
+
}
|
|
876
|
+
function buildAdditionalDataValue(ad) {
|
|
877
|
+
let out = "";
|
|
878
|
+
const map = [
|
|
879
|
+
["billNumber", ADDITIONAL_DATA_SUBFIELD.BILL_NUMBER],
|
|
880
|
+
["mobileNumber", ADDITIONAL_DATA_SUBFIELD.MOBILE_NUMBER],
|
|
881
|
+
["storeLabel", ADDITIONAL_DATA_SUBFIELD.STORE_LABEL],
|
|
882
|
+
["loyaltyNumber", ADDITIONAL_DATA_SUBFIELD.LOYALTY_NUMBER],
|
|
883
|
+
["referenceLabel", ADDITIONAL_DATA_SUBFIELD.REFERENCE_LABEL],
|
|
884
|
+
["customerLabel", ADDITIONAL_DATA_SUBFIELD.CUSTOMER_LABEL],
|
|
885
|
+
["terminalLabel", ADDITIONAL_DATA_SUBFIELD.TERMINAL_LABEL],
|
|
886
|
+
["purposeOfTransaction", ADDITIONAL_DATA_SUBFIELD.PURPOSE_OF_TRANSACTION]
|
|
887
|
+
];
|
|
888
|
+
for (const [k, t] of map) {
|
|
889
|
+
const v = ad[k];
|
|
890
|
+
if (v !== void 0) {
|
|
891
|
+
out += writeTLV(t, v);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
function assertAscii(name, v) {
|
|
897
|
+
if (!ASCII_PRINTABLE.test(v)) {
|
|
898
|
+
throw new Error(
|
|
899
|
+
`encodeNQR: ${name} contains non-printable-ASCII characters`
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
function assertMaxLen(name, v, max) {
|
|
904
|
+
if (v.length > max) {
|
|
905
|
+
throw new Error(`encodeNQR: ${name} length ${v.length} exceeds max ${max}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function assertMCC(mcc) {
|
|
909
|
+
if (!/^\d{4}$/.test(mcc)) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`encodeNQR: merchantCategoryCode must be 4 digits, got "${mcc}"`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function assertAmount(amt) {
|
|
916
|
+
if (!/^\d{1,10}(\.\d{1,2})?$/.test(amt)) {
|
|
917
|
+
throw new Error(
|
|
918
|
+
`encodeNQR: transactionAmount must match /^\\d{1,10}(\\.\\d{1,2})?$/, got "${amt}"`
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (amt.length > 13) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
`encodeNQR: transactionAmount length ${amt.length} exceeds 13`
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function encodeNQR(input) {
|
|
928
|
+
assertMCC(input.merchantCategoryCode);
|
|
929
|
+
assertAscii("merchantName", input.merchantName);
|
|
930
|
+
assertMaxLen("merchantName", input.merchantName, 25);
|
|
931
|
+
assertAscii("merchantCity", input.merchantCity);
|
|
932
|
+
assertMaxLen("merchantCity", input.merchantCity, 15);
|
|
933
|
+
if (input.postalCode !== void 0) {
|
|
934
|
+
assertAscii("postalCode", input.postalCode);
|
|
935
|
+
assertMaxLen("postalCode", input.postalCode, 10);
|
|
936
|
+
}
|
|
937
|
+
if (input.transactionAmount !== void 0) {
|
|
938
|
+
assertAmount(input.transactionAmount);
|
|
939
|
+
}
|
|
940
|
+
if (input.pointOfInitiation === "dynamic" && input.transactionAmount === void 0) {
|
|
941
|
+
}
|
|
942
|
+
let out = "";
|
|
943
|
+
out += writeTLV(
|
|
944
|
+
FIELD.PAYLOAD_FORMAT_INDICATOR,
|
|
945
|
+
PAYLOAD_FORMAT_INDICATOR_VALUE
|
|
946
|
+
);
|
|
947
|
+
out += writeTLV(
|
|
948
|
+
FIELD.POINT_OF_INITIATION,
|
|
949
|
+
input.pointOfInitiation === "dynamic" ? POINT_OF_INITIATION.DYNAMIC : POINT_OF_INITIATION.STATIC
|
|
950
|
+
);
|
|
951
|
+
out += writeTLV(
|
|
952
|
+
input.merchantAccountInfo.tag,
|
|
953
|
+
buildMAIValue(input.merchantAccountInfo)
|
|
954
|
+
);
|
|
955
|
+
out += writeTLV(FIELD.MERCHANT_CATEGORY_CODE, input.merchantCategoryCode);
|
|
956
|
+
out += writeTLV(FIELD.TRANSACTION_CURRENCY, NGN_CURRENCY_CODE);
|
|
957
|
+
if (input.transactionAmount !== void 0) {
|
|
958
|
+
out += writeTLV(FIELD.TRANSACTION_AMOUNT, input.transactionAmount);
|
|
959
|
+
}
|
|
960
|
+
out += writeTLV(FIELD.COUNTRY_CODE, NG_COUNTRY_CODE);
|
|
961
|
+
out += writeTLV(FIELD.MERCHANT_NAME, input.merchantName);
|
|
962
|
+
out += writeTLV(FIELD.MERCHANT_CITY, input.merchantCity);
|
|
963
|
+
if (input.postalCode !== void 0) {
|
|
964
|
+
out += writeTLV(FIELD.POSTAL_CODE, input.postalCode);
|
|
965
|
+
}
|
|
966
|
+
if (input.additionalData) {
|
|
967
|
+
const adfValue = buildAdditionalDataValue(input.additionalData);
|
|
968
|
+
if (adfValue.length > 0) {
|
|
969
|
+
out += writeTLV(FIELD.ADDITIONAL_DATA_FIELD, adfValue);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
out += CRC_TAG_PREFIX;
|
|
973
|
+
const crc = crc16ccittHex(new TextEncoder().encode(out));
|
|
974
|
+
out += crc;
|
|
975
|
+
return out;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// src/nqr/parser.ts
|
|
979
|
+
var NQRParseError = class extends Error {
|
|
980
|
+
path;
|
|
981
|
+
constructor(message, path = "") {
|
|
982
|
+
super(message);
|
|
983
|
+
this.name = "NQRParseError";
|
|
984
|
+
this.path = path;
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
function parseNQR(payload) {
|
|
988
|
+
if (payload.length < 8) {
|
|
989
|
+
throw new NQRParseError(`payload too short (${payload.length} chars)`);
|
|
990
|
+
}
|
|
991
|
+
const crcTagAndLen = payload.slice(-8, -4);
|
|
992
|
+
const crcValue = payload.slice(-4);
|
|
993
|
+
if (crcTagAndLen !== CRC_TAG_PREFIX) {
|
|
994
|
+
throw new NQRParseError(
|
|
995
|
+
`CRC tag prefix not found (expected "6304" at end)`
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
const beforeCrc = payload.slice(0, -4);
|
|
999
|
+
const expectedCrc = crc16ccittHex(new TextEncoder().encode(beforeCrc));
|
|
1000
|
+
if (expectedCrc !== crcValue.toUpperCase()) {
|
|
1001
|
+
throw new NQRParseError(
|
|
1002
|
+
`CRC mismatch: payload says ${crcValue}, computed ${expectedCrc}`,
|
|
1003
|
+
"63"
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
const topBuf = payload.slice(0, -8);
|
|
1007
|
+
const top = readTLV(topBuf);
|
|
1008
|
+
const get = (tag) => top.find((f) => f.tag === tag)?.value;
|
|
1009
|
+
const pfi = get(FIELD.PAYLOAD_FORMAT_INDICATOR);
|
|
1010
|
+
if (pfi !== "01")
|
|
1011
|
+
throw new NQRParseError(
|
|
1012
|
+
`payloadFormatIndicator must be "01", got "${pfi}"`,
|
|
1013
|
+
"00"
|
|
1014
|
+
);
|
|
1015
|
+
const poiRaw = get(FIELD.POINT_OF_INITIATION);
|
|
1016
|
+
const pointOfInitiation = poiRaw === "11" ? "static" : poiRaw === "12" ? "dynamic" : "unknown";
|
|
1017
|
+
const currency = get(FIELD.TRANSACTION_CURRENCY);
|
|
1018
|
+
if (currency !== NGN_CURRENCY_CODE) {
|
|
1019
|
+
throw new NQRParseError(
|
|
1020
|
+
`unsupported currency "${currency}" (expected ${NGN_CURRENCY_CODE})`,
|
|
1021
|
+
"53"
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const country = get(FIELD.COUNTRY_CODE);
|
|
1025
|
+
if (country !== NG_COUNTRY_CODE) {
|
|
1026
|
+
throw new NQRParseError(
|
|
1027
|
+
`unsupported country "${country}" (expected ${NG_COUNTRY_CODE})`,
|
|
1028
|
+
"58"
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
const mcc = get(FIELD.MERCHANT_CATEGORY_CODE);
|
|
1032
|
+
if (!mcc) throw new NQRParseError("merchantCategoryCode missing", "52");
|
|
1033
|
+
const merchantName = get(FIELD.MERCHANT_NAME) ?? "";
|
|
1034
|
+
const merchantCity = get(FIELD.MERCHANT_CITY) ?? "";
|
|
1035
|
+
const transactionAmount = get(FIELD.TRANSACTION_AMOUNT);
|
|
1036
|
+
const postalCode = get(FIELD.POSTAL_CODE);
|
|
1037
|
+
const mais = [];
|
|
1038
|
+
for (const f of top) {
|
|
1039
|
+
const n = parseInt(f.tag, 10);
|
|
1040
|
+
if (n >= 2 && n <= 51) {
|
|
1041
|
+
const children = readTLV(f.value).map((c) => ({
|
|
1042
|
+
tag: c.tag,
|
|
1043
|
+
value: c.value
|
|
1044
|
+
}));
|
|
1045
|
+
mais.push({ tag: f.tag, children });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
let additionalData;
|
|
1049
|
+
const adfRaw = get(FIELD.ADDITIONAL_DATA_FIELD);
|
|
1050
|
+
if (adfRaw !== void 0) {
|
|
1051
|
+
additionalData = {};
|
|
1052
|
+
for (const c of readTLV(adfRaw)) {
|
|
1053
|
+
switch (c.tag) {
|
|
1054
|
+
case "01":
|
|
1055
|
+
additionalData.billNumber = c.value;
|
|
1056
|
+
break;
|
|
1057
|
+
case "02":
|
|
1058
|
+
additionalData.mobileNumber = c.value;
|
|
1059
|
+
break;
|
|
1060
|
+
case "03":
|
|
1061
|
+
additionalData.storeLabel = c.value;
|
|
1062
|
+
break;
|
|
1063
|
+
case "04":
|
|
1064
|
+
additionalData.loyaltyNumber = c.value;
|
|
1065
|
+
break;
|
|
1066
|
+
case "05":
|
|
1067
|
+
additionalData.referenceLabel = c.value;
|
|
1068
|
+
break;
|
|
1069
|
+
case "06":
|
|
1070
|
+
additionalData.customerLabel = c.value;
|
|
1071
|
+
break;
|
|
1072
|
+
case "07":
|
|
1073
|
+
additionalData.terminalLabel = c.value;
|
|
1074
|
+
break;
|
|
1075
|
+
case "08":
|
|
1076
|
+
additionalData.purposeOfTransaction = c.value;
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
payloadFormatIndicator: pfi,
|
|
1083
|
+
pointOfInitiation,
|
|
1084
|
+
merchantAccountInfo: mais,
|
|
1085
|
+
merchantCategoryCode: mcc,
|
|
1086
|
+
transactionCurrency: currency,
|
|
1087
|
+
transactionAmount,
|
|
1088
|
+
countryCode: country,
|
|
1089
|
+
merchantName,
|
|
1090
|
+
merchantCity,
|
|
1091
|
+
postalCode,
|
|
1092
|
+
additionalData,
|
|
1093
|
+
flurReference: additionalData?.referenceLabel?.startsWith("FL-") ? additionalData.referenceLabel : void 0,
|
|
1094
|
+
raw: payload
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// src/nqr/routing.ts
|
|
1099
|
+
function routingHint(parsed) {
|
|
1100
|
+
if (parsed.flurReference && parsed.flurReference.startsWith("FL-")) {
|
|
1101
|
+
return "flur-onus";
|
|
1102
|
+
}
|
|
1103
|
+
if (parsed.merchantAccountInfo.length > 0) return "nibss";
|
|
1104
|
+
return "unknown";
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// src/crypto/canonical.ts
|
|
1108
|
+
function canonicalJSONStringify(value) {
|
|
1109
|
+
return stringify(value);
|
|
1110
|
+
}
|
|
1111
|
+
function canonicalJSONBytes(value) {
|
|
1112
|
+
return new TextEncoder().encode(canonicalJSONStringify(value));
|
|
1113
|
+
}
|
|
1114
|
+
function stringify(v) {
|
|
1115
|
+
if (v === null) return "null";
|
|
1116
|
+
const t = typeof v;
|
|
1117
|
+
if (t === "string") return JSON.stringify(v);
|
|
1118
|
+
if (t === "boolean") return v ? "true" : "false";
|
|
1119
|
+
if (t === "number") {
|
|
1120
|
+
if (!Number.isFinite(v)) {
|
|
1121
|
+
throw new Error("canonicalJSON: non-finite number not allowed");
|
|
1122
|
+
}
|
|
1123
|
+
return JSON.stringify(v);
|
|
1124
|
+
}
|
|
1125
|
+
if (t === "bigint" || t === "function" || t === "symbol" || t === "undefined") {
|
|
1126
|
+
throw new Error(`canonicalJSON: unsupported value type ${t}`);
|
|
1127
|
+
}
|
|
1128
|
+
if (Array.isArray(v)) {
|
|
1129
|
+
const parts = [];
|
|
1130
|
+
for (const item of v) parts.push(stringify(item));
|
|
1131
|
+
return "[" + parts.join(",") + "]";
|
|
1132
|
+
}
|
|
1133
|
+
if (t === "object") {
|
|
1134
|
+
const obj = v;
|
|
1135
|
+
const keys = Object.keys(obj).sort();
|
|
1136
|
+
const parts = [];
|
|
1137
|
+
for (const k of keys) {
|
|
1138
|
+
const val = obj[k];
|
|
1139
|
+
if (val === void 0) {
|
|
1140
|
+
throw new Error(`canonicalJSON: undefined value at key "${k}"`);
|
|
1141
|
+
}
|
|
1142
|
+
parts.push(JSON.stringify(k) + ":" + stringify(val));
|
|
1143
|
+
}
|
|
1144
|
+
return "{" + parts.join(",") + "}";
|
|
1145
|
+
}
|
|
1146
|
+
throw new Error(`canonicalJSON: unsupported value type ${t}`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// src/crypto/ct.ts
|
|
1150
|
+
function constantTimeEqual(a, b) {
|
|
1151
|
+
if (a.length !== b.length) return false;
|
|
1152
|
+
let diff = 0;
|
|
1153
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
1154
|
+
return diff === 0;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// src/crypto/ed25519.ts
|
|
1158
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
1159
|
+
function generateKeyPair() {
|
|
1160
|
+
const privateKey = ed25519.utils.randomPrivateKey();
|
|
1161
|
+
const publicKey = ed25519.getPublicKey(privateKey);
|
|
1162
|
+
return { privateKey, publicKey };
|
|
1163
|
+
}
|
|
1164
|
+
function publicKeyFromPrivate(privateKey) {
|
|
1165
|
+
return ed25519.getPublicKey(privateKey);
|
|
1166
|
+
}
|
|
1167
|
+
function sign(message, privateKey) {
|
|
1168
|
+
return ed25519.sign(message, privateKey);
|
|
1169
|
+
}
|
|
1170
|
+
function verify(message, signature, publicKey) {
|
|
1171
|
+
try {
|
|
1172
|
+
return ed25519.verify(signature, message, publicKey);
|
|
1173
|
+
} catch {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
function signCanonical(value, privateKey) {
|
|
1178
|
+
return sign(canonicalJSONBytes(value), privateKey);
|
|
1179
|
+
}
|
|
1180
|
+
function verifyCanonical(value, signature, publicKey) {
|
|
1181
|
+
return verify(canonicalJSONBytes(value), signature, publicKey);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// src/offline/oac.ts
|
|
1185
|
+
import { z as z4 } from "zod";
|
|
1186
|
+
var OAC_DEFAULT_PER_TX_KOBO = 5e5;
|
|
1187
|
+
var OAC_DEFAULT_CUMULATIVE_KOBO = 2e6;
|
|
1188
|
+
var OAC_DEFAULT_VALIDITY_MS = 24 * 60 * 60 * 1e3;
|
|
1189
|
+
var HexString = (length) => z4.string().regex(
|
|
1190
|
+
new RegExp(`^[0-9a-fA-F]{${length * 2}}$`),
|
|
1191
|
+
`expected ${length}-byte hex string`
|
|
1192
|
+
);
|
|
1193
|
+
var OACSchema = z4.object({
|
|
1194
|
+
userId: z4.string().min(1),
|
|
1195
|
+
deviceId: z4.string().min(1),
|
|
1196
|
+
devicePublicKey: HexString(32),
|
|
1197
|
+
perTxCapKobo: z4.number().int().nonnegative(),
|
|
1198
|
+
cumulativeCapKobo: z4.number().int().nonnegative(),
|
|
1199
|
+
validFromMs: z4.number().int().nonnegative(),
|
|
1200
|
+
validUntilMs: z4.number().int().positive(),
|
|
1201
|
+
counterSeed: z4.number().int().nonnegative(),
|
|
1202
|
+
nonce: z4.string().min(1),
|
|
1203
|
+
issuerSig: HexString(64)
|
|
1204
|
+
}).refine((v) => v.validUntilMs > v.validFromMs, {
|
|
1205
|
+
message: "validUntilMs must be greater than validFromMs"
|
|
1206
|
+
}).refine((v) => v.perTxCapKobo <= v.cumulativeCapKobo, {
|
|
1207
|
+
message: "perTxCapKobo must not exceed cumulativeCapKobo"
|
|
1208
|
+
});
|
|
1209
|
+
function buildOAC(input) {
|
|
1210
|
+
const devicePublicKey = typeof input.devicePublicKey === "string" ? input.devicePublicKey : bytesToHex(input.devicePublicKey);
|
|
1211
|
+
return {
|
|
1212
|
+
userId: input.userId,
|
|
1213
|
+
deviceId: input.deviceId,
|
|
1214
|
+
devicePublicKey,
|
|
1215
|
+
perTxCapKobo: input.perTxCapKobo ?? OAC_DEFAULT_PER_TX_KOBO,
|
|
1216
|
+
cumulativeCapKobo: input.cumulativeCapKobo ?? OAC_DEFAULT_CUMULATIVE_KOBO,
|
|
1217
|
+
validFromMs: input.validFromMs,
|
|
1218
|
+
validUntilMs: input.validUntilMs,
|
|
1219
|
+
counterSeed: input.counterSeed ?? 0,
|
|
1220
|
+
nonce: input.nonce
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function signOAC(unsigned, issuerPrivateKey) {
|
|
1224
|
+
const issuerSig = bytesToHex(
|
|
1225
|
+
sign(canonicalJSONBytes(unsigned), issuerPrivateKey)
|
|
1226
|
+
);
|
|
1227
|
+
return { ...unsigned, issuerSig };
|
|
1228
|
+
}
|
|
1229
|
+
function verifyOAC(oac, issuerPublicKey) {
|
|
1230
|
+
try {
|
|
1231
|
+
const parsed = OACSchema.parse(oac);
|
|
1232
|
+
const { issuerSig, ...unsigned } = parsed;
|
|
1233
|
+
return verify(
|
|
1234
|
+
canonicalJSONBytes(unsigned),
|
|
1235
|
+
hexToBytes(issuerSig),
|
|
1236
|
+
issuerPublicKey
|
|
1237
|
+
);
|
|
1238
|
+
} catch {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
function bytesToHex(b) {
|
|
1243
|
+
let s = "";
|
|
1244
|
+
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
|
|
1245
|
+
return s;
|
|
1246
|
+
}
|
|
1247
|
+
function hexToBytes(s) {
|
|
1248
|
+
if (s.length % 2 !== 0) throw new Error("hex: odd length");
|
|
1249
|
+
const out = new Uint8Array(s.length / 2);
|
|
1250
|
+
for (let i = 0; i < out.length; i++)
|
|
1251
|
+
out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
|
1252
|
+
return out;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/offline/codec.ts
|
|
1256
|
+
var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
|
|
1257
|
+
var REVERSE = {};
|
|
1258
|
+
for (let i = 0; i < ALPHABET.length; i++) REVERSE[ALPHABET[i]] = i;
|
|
1259
|
+
function encodeBase45(bytes) {
|
|
1260
|
+
let out = "";
|
|
1261
|
+
let i = 0;
|
|
1262
|
+
for (; i + 1 < bytes.length; i += 2) {
|
|
1263
|
+
const x = bytes[i] << 8 | bytes[i + 1];
|
|
1264
|
+
const e = Math.floor(x / (45 * 45));
|
|
1265
|
+
const d = Math.floor(x % (45 * 45) / 45);
|
|
1266
|
+
const c = x % 45;
|
|
1267
|
+
out += ALPHABET[c] + ALPHABET[d] + ALPHABET[e];
|
|
1268
|
+
}
|
|
1269
|
+
if (i < bytes.length) {
|
|
1270
|
+
const x = bytes[i];
|
|
1271
|
+
const d = Math.floor(x / 45);
|
|
1272
|
+
const c = x % 45;
|
|
1273
|
+
out += ALPHABET[c] + ALPHABET[d];
|
|
1274
|
+
}
|
|
1275
|
+
return out;
|
|
1276
|
+
}
|
|
1277
|
+
function decodeBase45(s) {
|
|
1278
|
+
if (s.length === 0) return new Uint8Array(0);
|
|
1279
|
+
const fullChunks = Math.floor(s.length / 3);
|
|
1280
|
+
const tail = s.length - fullChunks * 3;
|
|
1281
|
+
if (tail === 1) throw new Error("base45: invalid length");
|
|
1282
|
+
const out = new Uint8Array(fullChunks * 2 + (tail === 2 ? 1 : 0));
|
|
1283
|
+
let oi = 0;
|
|
1284
|
+
for (let i = 0; i < fullChunks; i++) {
|
|
1285
|
+
const c = REVERSE[s[i * 3]];
|
|
1286
|
+
const d = REVERSE[s[i * 3 + 1]];
|
|
1287
|
+
const e = REVERSE[s[i * 3 + 2]];
|
|
1288
|
+
if (c === void 0 || d === void 0 || e === void 0) {
|
|
1289
|
+
throw new Error("base45: invalid character");
|
|
1290
|
+
}
|
|
1291
|
+
const x = c + 45 * d + 45 * 45 * e;
|
|
1292
|
+
if (x > 65535) throw new Error("base45: chunk overflow");
|
|
1293
|
+
out[oi++] = x >> 8 & 255;
|
|
1294
|
+
out[oi++] = x & 255;
|
|
1295
|
+
}
|
|
1296
|
+
if (tail === 2) {
|
|
1297
|
+
const c = REVERSE[s[fullChunks * 3]];
|
|
1298
|
+
const d = REVERSE[s[fullChunks * 3 + 1]];
|
|
1299
|
+
if (c === void 0 || d === void 0)
|
|
1300
|
+
throw new Error("base45: invalid character");
|
|
1301
|
+
const x = c + 45 * d;
|
|
1302
|
+
if (x > 255) throw new Error("base45: tail overflow");
|
|
1303
|
+
out[oi++] = x & 255;
|
|
1304
|
+
}
|
|
1305
|
+
return out;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// src/offline/messages.ts
|
|
1309
|
+
import { z as z5 } from "zod";
|
|
1310
|
+
var HexSig = z5.string().regex(/^[0-9a-fA-F]{128}$/, "expected 64-byte hex signature");
|
|
1311
|
+
var OfflinePaymentRequestSchema = z5.object({
|
|
1312
|
+
reference: z5.string().min(1),
|
|
1313
|
+
amountKobo: z5.number().int().positive(),
|
|
1314
|
+
merchantOAC: OACSchema,
|
|
1315
|
+
expiresAtMs: z5.number().int().positive(),
|
|
1316
|
+
merchantSig: HexSig
|
|
1317
|
+
});
|
|
1318
|
+
var OfflinePaymentAuthorizationSchema = z5.object({
|
|
1319
|
+
request: OfflinePaymentRequestSchema,
|
|
1320
|
+
payerOAC: OACSchema,
|
|
1321
|
+
payerCounter: z5.number().int().positive(),
|
|
1322
|
+
payerSig: HexSig
|
|
1323
|
+
});
|
|
1324
|
+
function buildPaymentRequest(input) {
|
|
1325
|
+
if (!Number.isInteger(input.amountKobo) || input.amountKobo <= 0) {
|
|
1326
|
+
throw new Error("amountKobo must be a positive integer");
|
|
1327
|
+
}
|
|
1328
|
+
if (input.amountKobo > input.merchantOAC.perTxCapKobo) {
|
|
1329
|
+
throw new Error("amountKobo exceeds merchant OAC perTxCapKobo");
|
|
1330
|
+
}
|
|
1331
|
+
return {
|
|
1332
|
+
reference: input.reference,
|
|
1333
|
+
amountKobo: input.amountKobo,
|
|
1334
|
+
merchantOAC: input.merchantOAC,
|
|
1335
|
+
expiresAtMs: input.expiresAtMs
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function signPaymentRequest(unsigned, merchantDevicePrivateKey) {
|
|
1339
|
+
const merchantSig = bytesToHex(
|
|
1340
|
+
sign(canonicalJSONBytes(unsigned), merchantDevicePrivateKey)
|
|
1341
|
+
);
|
|
1342
|
+
return { ...unsigned, merchantSig };
|
|
1343
|
+
}
|
|
1344
|
+
function verifyPaymentRequest(req, issuerPublicKey) {
|
|
1345
|
+
try {
|
|
1346
|
+
const parsed = OfflinePaymentRequestSchema.parse(req);
|
|
1347
|
+
const { issuerSig: merchantOacSig, ...merchantOacUnsigned } = parsed.merchantOAC;
|
|
1348
|
+
if (!verify(
|
|
1349
|
+
canonicalJSONBytes(merchantOacUnsigned),
|
|
1350
|
+
hexToBytes(merchantOacSig),
|
|
1351
|
+
issuerPublicKey
|
|
1352
|
+
)) {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
const { merchantSig, ...unsigned } = parsed;
|
|
1356
|
+
return verify(
|
|
1357
|
+
canonicalJSONBytes(unsigned),
|
|
1358
|
+
hexToBytes(merchantSig),
|
|
1359
|
+
hexToBytes(parsed.merchantOAC.devicePublicKey)
|
|
1360
|
+
);
|
|
1361
|
+
} catch {
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function buildAuthorization(input) {
|
|
1366
|
+
if (!Number.isInteger(input.payerCounter) || input.payerCounter <= 0) {
|
|
1367
|
+
throw new Error("payerCounter must be a positive integer");
|
|
1368
|
+
}
|
|
1369
|
+
if (input.request.amountKobo > input.payerOAC.perTxCapKobo) {
|
|
1370
|
+
throw new Error("amountKobo exceeds payer OAC perTxCapKobo");
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
request: input.request,
|
|
1374
|
+
payerOAC: input.payerOAC,
|
|
1375
|
+
payerCounter: input.payerCounter
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
function signAuthorization(unsigned, payerDevicePrivateKey) {
|
|
1379
|
+
const payerSig = bytesToHex(
|
|
1380
|
+
sign(canonicalJSONBytes(unsigned), payerDevicePrivateKey)
|
|
1381
|
+
);
|
|
1382
|
+
return { ...unsigned, payerSig };
|
|
1383
|
+
}
|
|
1384
|
+
function verifyAuthorization(auth, issuerPublicKey) {
|
|
1385
|
+
try {
|
|
1386
|
+
const parsed = OfflinePaymentAuthorizationSchema.parse(auth);
|
|
1387
|
+
if (!verifyPaymentRequest(parsed.request, issuerPublicKey)) return false;
|
|
1388
|
+
const { issuerSig: payerOacSig, ...payerOacUnsigned } = parsed.payerOAC;
|
|
1389
|
+
if (!verify(
|
|
1390
|
+
canonicalJSONBytes(payerOacUnsigned),
|
|
1391
|
+
hexToBytes(payerOacSig),
|
|
1392
|
+
issuerPublicKey
|
|
1393
|
+
)) {
|
|
1394
|
+
return false;
|
|
1395
|
+
}
|
|
1396
|
+
const { payerSig, ...unsigned } = parsed;
|
|
1397
|
+
return verify(
|
|
1398
|
+
canonicalJSONBytes(unsigned),
|
|
1399
|
+
hexToBytes(payerSig),
|
|
1400
|
+
hexToBytes(parsed.payerOAC.devicePublicKey)
|
|
1401
|
+
);
|
|
1402
|
+
} catch {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function encodePaymentRequestQR(req) {
|
|
1407
|
+
const json = JSON.stringify(req);
|
|
1408
|
+
return "FLUR1:" + encodeBase45(new TextEncoder().encode(json));
|
|
1409
|
+
}
|
|
1410
|
+
function decodePaymentRequestQR(s) {
|
|
1411
|
+
if (!s.startsWith("FLUR1:"))
|
|
1412
|
+
throw new Error("not a Flur offline payment request QR");
|
|
1413
|
+
const bytes = decodeBase45(s.slice("FLUR1:".length));
|
|
1414
|
+
const json = new TextDecoder().decode(bytes);
|
|
1415
|
+
return OfflinePaymentRequestSchema.parse(JSON.parse(json));
|
|
1416
|
+
}
|
|
1417
|
+
function encodeAuthorizationQR(auth) {
|
|
1418
|
+
const json = JSON.stringify(auth);
|
|
1419
|
+
return "FLUR2:" + encodeBase45(new TextEncoder().encode(json));
|
|
1420
|
+
}
|
|
1421
|
+
function decodeAuthorizationQR(s) {
|
|
1422
|
+
if (!s.startsWith("FLUR2:"))
|
|
1423
|
+
throw new Error("not a Flur offline authorization QR");
|
|
1424
|
+
const bytes = decodeBase45(s.slice("FLUR2:".length));
|
|
1425
|
+
const json = new TextDecoder().decode(bytes);
|
|
1426
|
+
return OfflinePaymentAuthorizationSchema.parse(JSON.parse(json));
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// src/auth/hmac.ts
|
|
1430
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
1431
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
1432
|
+
function bytesToHex2(b) {
|
|
1433
|
+
let s = "";
|
|
1434
|
+
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
|
|
1435
|
+
return s;
|
|
1436
|
+
}
|
|
1437
|
+
function constantTimeStringEqual(a, b) {
|
|
1438
|
+
if (a.length !== b.length) return false;
|
|
1439
|
+
let diff = 0;
|
|
1440
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1441
|
+
return diff === 0;
|
|
1442
|
+
}
|
|
1443
|
+
function bodySha256Hex(body) {
|
|
1444
|
+
return bytesToHex2(sha256(new TextEncoder().encode(body)));
|
|
1445
|
+
}
|
|
1446
|
+
function canonicalRequestString(input) {
|
|
1447
|
+
return [
|
|
1448
|
+
input.method.toUpperCase(),
|
|
1449
|
+
input.path,
|
|
1450
|
+
input.timestamp,
|
|
1451
|
+
input.nonce,
|
|
1452
|
+
bodySha256Hex(input.body)
|
|
1453
|
+
].join("\n");
|
|
1454
|
+
}
|
|
1455
|
+
function signRequestHMAC(input) {
|
|
1456
|
+
return bytesToHex2(
|
|
1457
|
+
hmac(sha256, input.apiSecret, canonicalRequestString(input))
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
function verifyRequestHMAC(input) {
|
|
1461
|
+
const expected = signRequestHMAC(input);
|
|
1462
|
+
return constantTimeStringEqual(expected, input.signature.toLowerCase());
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// src/auth/middleware.ts
|
|
1466
|
+
var REPLAY_WINDOW_MS = 5 * 60 * 1e3;
|
|
1467
|
+
var SERVER_TIME_HEADER = "x-flur-server-time";
|
|
1468
|
+
function defaultNonce() {
|
|
1469
|
+
const c = globalThis.crypto;
|
|
1470
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
1471
|
+
if (typeof c?.getRandomValues !== "function") {
|
|
1472
|
+
throw new Error(
|
|
1473
|
+
"Flur SDK: no CSPRNG available (globalThis.crypto.getRandomValues missing). Refusing to fall back to Math.random for HMAC nonce generation."
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
const arr = new Uint8Array(16);
|
|
1477
|
+
c.getRandomValues(arr);
|
|
1478
|
+
let s = "";
|
|
1479
|
+
for (let i = 0; i < arr.length; i++)
|
|
1480
|
+
s += arr[i].toString(16).padStart(2, "0");
|
|
1481
|
+
return s;
|
|
1482
|
+
}
|
|
1483
|
+
function assertCSPRNG() {
|
|
1484
|
+
const c = globalThis.crypto;
|
|
1485
|
+
if (typeof c?.getRandomValues !== "function" && typeof c?.randomUUID !== "function") {
|
|
1486
|
+
throw new Error(
|
|
1487
|
+
"Flur SDK: no CSPRNG available (globalThis.crypto missing). Initialize on a runtime that provides Web Crypto (Node 19+, modern browsers, RN 0.74+)."
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
function parseServerTimeMs(value) {
|
|
1492
|
+
if (!value) return null;
|
|
1493
|
+
const trimmed = value.trim();
|
|
1494
|
+
if (/^\d+$/.test(trimmed)) {
|
|
1495
|
+
const n = Number(trimmed);
|
|
1496
|
+
return n < 1e12 ? n * 1e3 : n;
|
|
1497
|
+
}
|
|
1498
|
+
const t = Date.parse(trimmed);
|
|
1499
|
+
return Number.isFinite(t) ? t : null;
|
|
1500
|
+
}
|
|
1501
|
+
function createHmacFetch(opts) {
|
|
1502
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
1503
|
+
if (!fetchImpl)
|
|
1504
|
+
throw new Error("createHmacFetch: no fetch implementation available");
|
|
1505
|
+
if (!opts.nonceFn) assertCSPRNG();
|
|
1506
|
+
const nowMs = opts.nowMs ?? (() => Date.now());
|
|
1507
|
+
const nonceFn = opts.nonceFn ?? defaultNonce;
|
|
1508
|
+
const scopeHeader = opts.scope && opts.scope.length > 0 ? opts.scope.join(",") : void 0;
|
|
1509
|
+
async function signAndSend(req, skewOffsetMs) {
|
|
1510
|
+
const cloned = req.clone();
|
|
1511
|
+
const url = new URL(cloned.url);
|
|
1512
|
+
const path = url.pathname + url.search;
|
|
1513
|
+
const method = cloned.method.toUpperCase();
|
|
1514
|
+
const bodyText = method === "GET" || method === "HEAD" ? "" : await cloned.clone().text();
|
|
1515
|
+
const timestamp = Math.floor((nowMs() + skewOffsetMs) / 1e3).toString();
|
|
1516
|
+
const nonce = nonceFn();
|
|
1517
|
+
const signature = signRequestHMAC({
|
|
1518
|
+
method,
|
|
1519
|
+
path,
|
|
1520
|
+
timestamp,
|
|
1521
|
+
nonce,
|
|
1522
|
+
body: bodyText,
|
|
1523
|
+
apiSecret: opts.apiSecret
|
|
1524
|
+
});
|
|
1525
|
+
const headers = new Headers(cloned.headers);
|
|
1526
|
+
headers.set("x-flur-key", opts.apiKey);
|
|
1527
|
+
headers.set("x-flur-timestamp", timestamp);
|
|
1528
|
+
headers.set("x-flur-nonce", nonce);
|
|
1529
|
+
headers.set("x-flur-signature", signature);
|
|
1530
|
+
if (scopeHeader) headers.set("x-flur-scope", scopeHeader);
|
|
1531
|
+
const signed = new Request(cloned, { headers });
|
|
1532
|
+
return fetchImpl(signed);
|
|
1533
|
+
}
|
|
1534
|
+
return (async (input, init2) => {
|
|
1535
|
+
const req = input instanceof Request ? input : new Request(input, init2);
|
|
1536
|
+
let resp = await signAndSend(req, 0);
|
|
1537
|
+
if (resp.status === 401) {
|
|
1538
|
+
const serverTimeMs = parseServerTimeMs(
|
|
1539
|
+
resp.headers.get(SERVER_TIME_HEADER)
|
|
1540
|
+
);
|
|
1541
|
+
if (serverTimeMs !== null) {
|
|
1542
|
+
const skew = serverTimeMs - nowMs();
|
|
1543
|
+
if (Math.abs(skew) > 0) {
|
|
1544
|
+
resp = await signAndSend(req, skew);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return resp;
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/passes/pass.ts
|
|
1553
|
+
import { z as z6 } from "zod";
|
|
1554
|
+
var PASS_KINDS = [
|
|
1555
|
+
"ride-ticket",
|
|
1556
|
+
"transit-pass",
|
|
1557
|
+
"event-ticket",
|
|
1558
|
+
"voucher",
|
|
1559
|
+
"loyalty",
|
|
1560
|
+
"receipt-link"
|
|
1561
|
+
];
|
|
1562
|
+
var PASS_STATES = [
|
|
1563
|
+
"issued",
|
|
1564
|
+
"active",
|
|
1565
|
+
"redeemed",
|
|
1566
|
+
"expired",
|
|
1567
|
+
"revoked"
|
|
1568
|
+
];
|
|
1569
|
+
var HexString2 = (length) => z6.string().regex(
|
|
1570
|
+
new RegExp(`^[0-9a-fA-F]{${length * 2}}$`),
|
|
1571
|
+
`expected ${length}-byte hex string`
|
|
1572
|
+
);
|
|
1573
|
+
var PassMetadataSchema = z6.record(
|
|
1574
|
+
z6.union([z6.string(), z6.number(), z6.boolean(), z6.null()])
|
|
1575
|
+
);
|
|
1576
|
+
var PassSchema = z6.object({
|
|
1577
|
+
passId: z6.string().min(1),
|
|
1578
|
+
/** Optional client/template grouping id (server may omit). */
|
|
1579
|
+
templateId: z6.string().min(1).optional(),
|
|
1580
|
+
/** Optional human-facing holder identity (server may omit). The cryptographic binding
|
|
1581
|
+
* is `holderDevicePubkey` below. */
|
|
1582
|
+
holderUserId: z6.string().min(1).optional(),
|
|
1583
|
+
kind: z6.enum(PASS_KINDS),
|
|
1584
|
+
issuerId: z6.string().min(1),
|
|
1585
|
+
issuedAtMs: z6.number().int().nonnegative(),
|
|
1586
|
+
validFromMs: z6.number().int().nonnegative(),
|
|
1587
|
+
validUntilMs: z6.number().int().positive(),
|
|
1588
|
+
state: z6.enum(PASS_STATES),
|
|
1589
|
+
metadata: PassMetadataSchema,
|
|
1590
|
+
nonce: z6.string().min(1),
|
|
1591
|
+
/** Device id this pass is bound to (FK to backend `device_keys`). */
|
|
1592
|
+
holderDeviceId: z6.string().min(1),
|
|
1593
|
+
/** 32-byte hex Ed25519 public key of the bound device. The redemption signature
|
|
1594
|
+
* is verified against this key — it is the security-critical binding. */
|
|
1595
|
+
holderDevicePubkey: HexString2(32),
|
|
1596
|
+
/** Optional fixed amount for monetary passes (vouchers, gift cards) in kobo. */
|
|
1597
|
+
amountKobo: z6.number().int().nonnegative().optional(),
|
|
1598
|
+
/** ISO-4217-ish currency code; required on the wire. SDK builders default to NGN. */
|
|
1599
|
+
currency: z6.string().min(3).max(8),
|
|
1600
|
+
/** Monotonic redemption counter floor. Redemption.counter MUST be > counterSeed. */
|
|
1601
|
+
counterSeed: z6.number().int().nonnegative(),
|
|
1602
|
+
/** Optional cumulative spend cap in kobo across all redemptions of this pass. */
|
|
1603
|
+
cumulativeCapKobo: z6.number().int().nonnegative().optional(),
|
|
1604
|
+
issuerSig: HexString2(64)
|
|
1605
|
+
}).refine((v) => v.validUntilMs > v.validFromMs, {
|
|
1606
|
+
message: "validUntilMs must be greater than validFromMs"
|
|
1607
|
+
});
|
|
1608
|
+
function buildPass(input) {
|
|
1609
|
+
if (input.validUntilMs <= input.validFromMs) {
|
|
1610
|
+
throw new Error("validUntilMs must be greater than validFromMs");
|
|
1611
|
+
}
|
|
1612
|
+
const out = {
|
|
1613
|
+
passId: input.passId,
|
|
1614
|
+
kind: input.kind,
|
|
1615
|
+
issuerId: input.issuerId,
|
|
1616
|
+
issuedAtMs: input.issuedAtMs,
|
|
1617
|
+
validFromMs: input.validFromMs,
|
|
1618
|
+
validUntilMs: input.validUntilMs,
|
|
1619
|
+
state: input.state,
|
|
1620
|
+
metadata: input.metadata,
|
|
1621
|
+
nonce: input.nonce,
|
|
1622
|
+
holderDeviceId: input.holderDeviceId,
|
|
1623
|
+
holderDevicePubkey: input.holderDevicePubkey,
|
|
1624
|
+
currency: input.currency ?? "NGN",
|
|
1625
|
+
counterSeed: input.counterSeed
|
|
1626
|
+
};
|
|
1627
|
+
if (typeof input.templateId === "string") out.templateId = input.templateId;
|
|
1628
|
+
if (typeof input.holderUserId === "string")
|
|
1629
|
+
out.holderUserId = input.holderUserId;
|
|
1630
|
+
if (typeof input.amountKobo === "number") out.amountKobo = input.amountKobo;
|
|
1631
|
+
if (typeof input.cumulativeCapKobo === "number") {
|
|
1632
|
+
out.cumulativeCapKobo = input.cumulativeCapKobo;
|
|
1633
|
+
}
|
|
1634
|
+
return out;
|
|
1635
|
+
}
|
|
1636
|
+
function signPass(unsigned, issuerPrivateKey) {
|
|
1637
|
+
const issuerSig = bytesToHex(
|
|
1638
|
+
sign(canonicalJSONBytes(unsigned), issuerPrivateKey)
|
|
1639
|
+
);
|
|
1640
|
+
return { ...unsigned, issuerSig };
|
|
1641
|
+
}
|
|
1642
|
+
function verifyPass(pass, issuerPublicKey) {
|
|
1643
|
+
try {
|
|
1644
|
+
const parsed = PassSchema.parse(pass);
|
|
1645
|
+
const { issuerSig, ...unsigned } = parsed;
|
|
1646
|
+
return verify(
|
|
1647
|
+
canonicalJSONBytes(unsigned),
|
|
1648
|
+
hexToBytes(issuerSig),
|
|
1649
|
+
issuerPublicKey
|
|
1650
|
+
);
|
|
1651
|
+
} catch {
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
function isPassWithinValidity(pass, nowMs) {
|
|
1656
|
+
return nowMs >= pass.validFromMs && nowMs < pass.validUntilMs;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// src/passes/redemption.ts
|
|
1660
|
+
import { z as z7 } from "zod";
|
|
1661
|
+
var HexSig2 = z7.string().regex(/^[0-9a-fA-F]{128}$/, "expected 64-byte hex signature");
|
|
1662
|
+
var RedemptionSchema = z7.object({
|
|
1663
|
+
pass: PassSchema,
|
|
1664
|
+
redeemerId: z7.string().min(1),
|
|
1665
|
+
redeemedAtMs: z7.number().int().nonnegative(),
|
|
1666
|
+
/** Strictly monotonic counter scoped to a single pass. Must be > pass.counterSeed
|
|
1667
|
+
* and > the redeemer's lastSeenCounter for this pass. */
|
|
1668
|
+
counter: z7.number().int().positive(),
|
|
1669
|
+
/** Amount being redeemed in kobo (0 for non-monetary passes like ride tickets). */
|
|
1670
|
+
amountKobo: z7.number().int().nonnegative(),
|
|
1671
|
+
nonce: z7.string().min(1),
|
|
1672
|
+
holderSig: HexSig2
|
|
1673
|
+
});
|
|
1674
|
+
var REDEEMABLE_STATES = /* @__PURE__ */ new Set(["issued", "active"]);
|
|
1675
|
+
function buildRedemption(input) {
|
|
1676
|
+
if (!REDEEMABLE_STATES.has(input.pass.state)) {
|
|
1677
|
+
throw new Error(`pass not in redeemable state: ${input.pass.state}`);
|
|
1678
|
+
}
|
|
1679
|
+
if (input.redeemedAtMs < input.pass.validFromMs) {
|
|
1680
|
+
throw new Error("redeemedAtMs is before pass validFromMs");
|
|
1681
|
+
}
|
|
1682
|
+
if (input.redeemedAtMs >= input.pass.validUntilMs) {
|
|
1683
|
+
throw new FlurExpiredError(
|
|
1684
|
+
`pass ${input.pass.passId} expired at ${input.pass.validUntilMs}`
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
const now = typeof input.nowMs === "number" ? input.nowMs : input.redeemedAtMs;
|
|
1688
|
+
if (now >= input.pass.validUntilMs) {
|
|
1689
|
+
throw new FlurExpiredError(
|
|
1690
|
+
`pass ${input.pass.passId} expired at ${input.pass.validUntilMs}`
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
if (!input.pass.holderDevicePubkey) {
|
|
1694
|
+
throw new Error(
|
|
1695
|
+
"pass.holderDevicePubkey is required to build a redemption"
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
const lastSeen = Math.max(
|
|
1699
|
+
input.pass.counterSeed,
|
|
1700
|
+
typeof input.lastSeenCounter === "number" ? input.lastSeenCounter : 0
|
|
1701
|
+
);
|
|
1702
|
+
if (input.counter <= lastSeen) {
|
|
1703
|
+
throw new FlurReplayError(
|
|
1704
|
+
`redemption counter ${input.counter} must be > lastSeenCounter ${lastSeen}`
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
const amount = input.amountKobo ?? 0;
|
|
1708
|
+
if (typeof input.pass.cumulativeCapKobo === "number") {
|
|
1709
|
+
const used = input.cumulativeUsedKobo ?? 0;
|
|
1710
|
+
if (used + amount > input.pass.cumulativeCapKobo) {
|
|
1711
|
+
throw new FlurCapExceededError(
|
|
1712
|
+
`pass ${input.pass.passId} cap ${input.pass.cumulativeCapKobo} would be exceeded (used ${used} + ${amount})`
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return {
|
|
1717
|
+
pass: input.pass,
|
|
1718
|
+
redeemerId: input.redeemerId,
|
|
1719
|
+
redeemedAtMs: input.redeemedAtMs,
|
|
1720
|
+
counter: input.counter,
|
|
1721
|
+
amountKobo: amount,
|
|
1722
|
+
nonce: input.nonce
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
function signRedemption(unsigned, holderDevicePrivateKey) {
|
|
1726
|
+
const holderSig = bytesToHex(
|
|
1727
|
+
sign(canonicalJSONBytes(unsigned), holderDevicePrivateKey)
|
|
1728
|
+
);
|
|
1729
|
+
return { ...unsigned, holderSig };
|
|
1730
|
+
}
|
|
1731
|
+
function verifyRedemption(r, issuerPublicKey) {
|
|
1732
|
+
try {
|
|
1733
|
+
const parsed = RedemptionSchema.parse(r);
|
|
1734
|
+
if (parsed.counter <= parsed.pass.counterSeed) return false;
|
|
1735
|
+
const { issuerSig, ...passUnsigned } = parsed.pass;
|
|
1736
|
+
if (!verify(
|
|
1737
|
+
canonicalJSONBytes(passUnsigned),
|
|
1738
|
+
hexToBytes(issuerSig),
|
|
1739
|
+
issuerPublicKey
|
|
1740
|
+
)) {
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
const holderHex = parsed.pass.holderDevicePubkey;
|
|
1744
|
+
if (typeof holderHex !== "string") return false;
|
|
1745
|
+
const { holderSig, ...unsigned } = parsed;
|
|
1746
|
+
return verify(
|
|
1747
|
+
canonicalJSONBytes(unsigned),
|
|
1748
|
+
hexToBytes(holderSig),
|
|
1749
|
+
hexToBytes(holderHex)
|
|
1750
|
+
);
|
|
1751
|
+
} catch {
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// src/passes/client.ts
|
|
1757
|
+
function createPassesClient(opts) {
|
|
1758
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
1759
|
+
if (!fetchImpl)
|
|
1760
|
+
throw new Error("createPassesClient: no fetch implementation available");
|
|
1761
|
+
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
1762
|
+
async function call(method, path, body, parser) {
|
|
1763
|
+
const init2 = {
|
|
1764
|
+
method,
|
|
1765
|
+
headers: {
|
|
1766
|
+
"content-type": "application/json",
|
|
1767
|
+
accept: "application/json"
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
if (body !== void 0) init2.body = JSON.stringify(body);
|
|
1771
|
+
const resp = await fetchImpl(`${baseUrl}${path}`, init2);
|
|
1772
|
+
const text = await resp.text();
|
|
1773
|
+
let raw = void 0;
|
|
1774
|
+
if (text) {
|
|
1775
|
+
try {
|
|
1776
|
+
raw = JSON.parse(text);
|
|
1777
|
+
} catch {
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (!resp.ok) {
|
|
1781
|
+
const code = (raw && typeof raw === "object" && "code" in raw && typeof raw.code === "string" ? raw.code : void 0) ?? `http_${resp.status}`;
|
|
1782
|
+
const message = (raw && typeof raw === "object" && "message" in raw && typeof raw.message === "string" ? raw.message : void 0) ?? `request failed with status ${resp.status}`;
|
|
1783
|
+
throw new FlurApiError(resp.status, code, message, raw);
|
|
1784
|
+
}
|
|
1785
|
+
return parser(raw);
|
|
1786
|
+
}
|
|
1787
|
+
return {
|
|
1788
|
+
issuePass: (input) => call("POST", "/v1/passes", input, (raw) => PassSchema.parse(raw)),
|
|
1789
|
+
listPasses: (input) => {
|
|
1790
|
+
const qs = new URLSearchParams();
|
|
1791
|
+
if (input.holderDeviceId) qs.set("holderDeviceId", input.holderDeviceId);
|
|
1792
|
+
if (input.holderUserId) qs.set("holderUserId", input.holderUserId);
|
|
1793
|
+
if (input.state) qs.set("state", input.state);
|
|
1794
|
+
if (input.kind) qs.set("kind", input.kind);
|
|
1795
|
+
if (input.templateId) qs.set("templateId", input.templateId);
|
|
1796
|
+
if (typeof input.limit === "number") qs.set("limit", String(input.limit));
|
|
1797
|
+
if (input.cursor) qs.set("cursor", input.cursor);
|
|
1798
|
+
const path = `/v1/passes${qs.size > 0 ? `?${qs.toString()}` : ""}`;
|
|
1799
|
+
return call("GET", path, void 0, (raw) => {
|
|
1800
|
+
const obj = raw;
|
|
1801
|
+
const items = obj.items.map(
|
|
1802
|
+
(it) => PassSchema.parse(it)
|
|
1803
|
+
);
|
|
1804
|
+
const nextCursor = typeof obj.nextCursor === "string" ? obj.nextCursor : null;
|
|
1805
|
+
return { items, nextCursor };
|
|
1806
|
+
});
|
|
1807
|
+
},
|
|
1808
|
+
getPass: (passId) => call(
|
|
1809
|
+
"GET",
|
|
1810
|
+
`/v1/passes/${encodeURIComponent(passId)}`,
|
|
1811
|
+
void 0,
|
|
1812
|
+
(raw) => PassSchema.parse(raw)
|
|
1813
|
+
),
|
|
1814
|
+
redeemPass: (passId, redemption) => call(
|
|
1815
|
+
"POST",
|
|
1816
|
+
`/v1/passes/${encodeURIComponent(passId)}/redeem`,
|
|
1817
|
+
RedemptionSchema.parse(redemption),
|
|
1818
|
+
(raw) => PassSchema.parse(raw)
|
|
1819
|
+
),
|
|
1820
|
+
revokePass: (passId, input) => call(
|
|
1821
|
+
"POST",
|
|
1822
|
+
`/v1/passes/${encodeURIComponent(passId)}/revoke`,
|
|
1823
|
+
input,
|
|
1824
|
+
(raw) => PassSchema.parse(raw)
|
|
1825
|
+
),
|
|
1826
|
+
verifyPass: (pass, issuerPublicKey) => verifyPass(pass, issuerPublicKey)
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// src/receipts/receipt.ts
|
|
1831
|
+
import { z as z8 } from "zod";
|
|
1832
|
+
var RECEIPT_CHANNELS = ["cash", "pass"];
|
|
1833
|
+
var RECEIPT_KINDS = RECEIPT_CHANNELS;
|
|
1834
|
+
var HexString3 = (length) => z8.string().regex(
|
|
1835
|
+
new RegExp(`^[0-9a-fA-F]{${length * 2}}$`),
|
|
1836
|
+
`expected ${length}-byte hex string`
|
|
1837
|
+
);
|
|
1838
|
+
var ReceiptPayloadSchema = z8.record(
|
|
1839
|
+
z8.union([z8.string(), z8.number(), z8.boolean(), z8.null()])
|
|
1840
|
+
);
|
|
1841
|
+
var ReceiptSchema = z8.object({
|
|
1842
|
+
receiptId: z8.string().min(1),
|
|
1843
|
+
channel: z8.enum(RECEIPT_CHANNELS),
|
|
1844
|
+
/** Cash-channel: send_intents.id. Required when channel === 'cash'. */
|
|
1845
|
+
intentId: z8.string().min(1).optional(),
|
|
1846
|
+
/** Pass-channel: pass_redemptions.id. Required when channel === 'pass'. */
|
|
1847
|
+
passRedemptionId: z8.string().min(1).optional(),
|
|
1848
|
+
payerUserId: z8.string().min(1),
|
|
1849
|
+
payeeUserId: z8.string().min(1),
|
|
1850
|
+
amountKobo: z8.number().int().nonnegative(),
|
|
1851
|
+
currency: z8.string().min(3).max(8),
|
|
1852
|
+
issuedAtMs: z8.number().int().nonnegative(),
|
|
1853
|
+
issuerId: z8.string().min(1),
|
|
1854
|
+
payload: ReceiptPayloadSchema,
|
|
1855
|
+
issuerSig: HexString3(64)
|
|
1856
|
+
}).superRefine((v, ctx) => {
|
|
1857
|
+
if (v.channel === "cash") {
|
|
1858
|
+
if (!v.intentId) {
|
|
1859
|
+
ctx.addIssue({
|
|
1860
|
+
code: z8.ZodIssueCode.custom,
|
|
1861
|
+
message: "cash receipts require intentId",
|
|
1862
|
+
path: ["intentId"]
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
if (v.passRedemptionId) {
|
|
1866
|
+
ctx.addIssue({
|
|
1867
|
+
code: z8.ZodIssueCode.custom,
|
|
1868
|
+
message: "cash receipts must not carry passRedemptionId",
|
|
1869
|
+
path: ["passRedemptionId"]
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
} else if (v.channel === "pass") {
|
|
1873
|
+
if (!v.passRedemptionId) {
|
|
1874
|
+
ctx.addIssue({
|
|
1875
|
+
code: z8.ZodIssueCode.custom,
|
|
1876
|
+
message: "pass receipts require passRedemptionId",
|
|
1877
|
+
path: ["passRedemptionId"]
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
if (v.intentId) {
|
|
1881
|
+
ctx.addIssue({
|
|
1882
|
+
code: z8.ZodIssueCode.custom,
|
|
1883
|
+
message: "pass receipts must not carry intentId",
|
|
1884
|
+
path: ["intentId"]
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
function buildReceipt(input) {
|
|
1890
|
+
if (input.channel === "cash") {
|
|
1891
|
+
if (!input.intentId) {
|
|
1892
|
+
throw new Error("cash receipts require intentId");
|
|
1893
|
+
}
|
|
1894
|
+
if (input.passRedemptionId) {
|
|
1895
|
+
throw new Error("cash receipts must not carry passRedemptionId");
|
|
1896
|
+
}
|
|
1897
|
+
} else {
|
|
1898
|
+
if (!input.passRedemptionId) {
|
|
1899
|
+
throw new Error("pass receipts require passRedemptionId");
|
|
1900
|
+
}
|
|
1901
|
+
if (input.intentId) {
|
|
1902
|
+
throw new Error("pass receipts must not carry intentId");
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
const out = {
|
|
1906
|
+
receiptId: input.receiptId,
|
|
1907
|
+
channel: input.channel,
|
|
1908
|
+
payerUserId: input.payerUserId,
|
|
1909
|
+
payeeUserId: input.payeeUserId,
|
|
1910
|
+
amountKobo: input.amountKobo,
|
|
1911
|
+
currency: input.currency,
|
|
1912
|
+
issuedAtMs: input.issuedAtMs,
|
|
1913
|
+
issuerId: input.issuerId,
|
|
1914
|
+
payload: input.payload ?? {}
|
|
1915
|
+
};
|
|
1916
|
+
if (input.intentId) out.intentId = input.intentId;
|
|
1917
|
+
if (input.passRedemptionId) out.passRedemptionId = input.passRedemptionId;
|
|
1918
|
+
return out;
|
|
1919
|
+
}
|
|
1920
|
+
function signReceipt(unsigned, issuerPrivateKey) {
|
|
1921
|
+
const issuerSig = bytesToHex(
|
|
1922
|
+
sign(canonicalJSONBytes(unsigned), issuerPrivateKey)
|
|
1923
|
+
);
|
|
1924
|
+
return { ...unsigned, issuerSig };
|
|
1925
|
+
}
|
|
1926
|
+
function verifyReceipt(r, issuerPublicKey) {
|
|
1927
|
+
try {
|
|
1928
|
+
const parsed = ReceiptSchema.parse(r);
|
|
1929
|
+
const { issuerSig, ...unsigned } = parsed;
|
|
1930
|
+
return verify(
|
|
1931
|
+
canonicalJSONBytes(unsigned),
|
|
1932
|
+
hexToBytes(issuerSig),
|
|
1933
|
+
issuerPublicKey
|
|
1934
|
+
);
|
|
1935
|
+
} catch {
|
|
1936
|
+
return false;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/receipts/client.ts
|
|
1941
|
+
function createReceiptsClient(opts) {
|
|
1942
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
1943
|
+
if (!fetchImpl)
|
|
1944
|
+
throw new Error("createReceiptsClient: no fetch implementation available");
|
|
1945
|
+
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
1946
|
+
async function call(method, path, body, parser) {
|
|
1947
|
+
const init2 = {
|
|
1948
|
+
method,
|
|
1949
|
+
headers: {
|
|
1950
|
+
"content-type": "application/json",
|
|
1951
|
+
accept: "application/json"
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
if (body !== void 0) init2.body = JSON.stringify(body);
|
|
1955
|
+
const resp = await fetchImpl(`${baseUrl}${path}`, init2);
|
|
1956
|
+
const text = await resp.text();
|
|
1957
|
+
let raw = void 0;
|
|
1958
|
+
if (text) {
|
|
1959
|
+
try {
|
|
1960
|
+
raw = JSON.parse(text);
|
|
1961
|
+
} catch {
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (!resp.ok) {
|
|
1965
|
+
const code = raw && typeof raw === "object" && "code" in raw && typeof raw.code === "string" ? raw.code : `http_${resp.status}`;
|
|
1966
|
+
const message = raw && typeof raw === "object" && "message" in raw && typeof raw.message === "string" ? raw.message : `request failed with status ${resp.status}`;
|
|
1967
|
+
throw new FlurApiError(resp.status, code, message, raw);
|
|
1968
|
+
}
|
|
1969
|
+
return parser(raw);
|
|
1970
|
+
}
|
|
1971
|
+
const getById = (receiptId) => call(
|
|
1972
|
+
"GET",
|
|
1973
|
+
`/v1/receipts/${encodeURIComponent(receiptId)}`,
|
|
1974
|
+
void 0,
|
|
1975
|
+
(raw) => ReceiptSchema.parse(raw)
|
|
1976
|
+
);
|
|
1977
|
+
return {
|
|
1978
|
+
issueReceipt: (input) => call("POST", "/v1/receipts", input, (raw) => ReceiptSchema.parse(raw)),
|
|
1979
|
+
getReceipt: getById,
|
|
1980
|
+
getById,
|
|
1981
|
+
getByIntentId: (intentId) => call(
|
|
1982
|
+
"GET",
|
|
1983
|
+
`/v1/receipts/by-intent/${encodeURIComponent(intentId)}`,
|
|
1984
|
+
void 0,
|
|
1985
|
+
(raw) => ReceiptSchema.parse(raw)
|
|
1986
|
+
),
|
|
1987
|
+
getByPassRedemptionId: (passRedemptionId) => call(
|
|
1988
|
+
"GET",
|
|
1989
|
+
`/v1/receipts/by-pass-redemption/${encodeURIComponent(passRedemptionId)}`,
|
|
1990
|
+
void 0,
|
|
1991
|
+
(raw) => ReceiptSchema.parse(raw)
|
|
1992
|
+
),
|
|
1993
|
+
listForUser: (input) => {
|
|
1994
|
+
const qs = new URLSearchParams();
|
|
1995
|
+
if (input.payerUserId) qs.set("payerUserId", input.payerUserId);
|
|
1996
|
+
if (input.payeeUserId) qs.set("payeeUserId", input.payeeUserId);
|
|
1997
|
+
if (input.channel) qs.set("channel", input.channel);
|
|
1998
|
+
if (typeof input.limit === "number") qs.set("limit", String(input.limit));
|
|
1999
|
+
if (input.cursor) qs.set("cursor", input.cursor);
|
|
2000
|
+
const path = `/v1/receipts${qs.size > 0 ? `?${qs.toString()}` : ""}`;
|
|
2001
|
+
return call("GET", path, void 0, (raw) => {
|
|
2002
|
+
const obj = raw;
|
|
2003
|
+
const items = obj.items.map(
|
|
2004
|
+
(it) => ReceiptSchema.parse(it)
|
|
2005
|
+
);
|
|
2006
|
+
const nextCursor = typeof obj.nextCursor === "string" ? obj.nextCursor : null;
|
|
2007
|
+
return { items, nextCursor };
|
|
2008
|
+
});
|
|
2009
|
+
},
|
|
2010
|
+
verifyReceipt: (receipt, issuerPublicKey) => verifyReceipt(receipt, issuerPublicKey)
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// src/client/flur.ts
|
|
2015
|
+
function generateStaticQR(input) {
|
|
2016
|
+
return encodeNQR({ ...input, pointOfInitiation: "static" });
|
|
2017
|
+
}
|
|
2018
|
+
function generateDynamicQR(input) {
|
|
2019
|
+
return encodeNQR({ ...input, pointOfInitiation: "dynamic" });
|
|
2020
|
+
}
|
|
2021
|
+
function parseQR(payload) {
|
|
2022
|
+
return parseNQR(payload);
|
|
2023
|
+
}
|
|
2024
|
+
function init(opts) {
|
|
2025
|
+
const signedFetch = createHmacFetch({
|
|
2026
|
+
apiKey: opts.apiKey,
|
|
2027
|
+
apiSecret: opts.apiSecret,
|
|
2028
|
+
fetchImpl: opts.fetchImpl,
|
|
2029
|
+
scope: opts.scope
|
|
2030
|
+
});
|
|
2031
|
+
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
2032
|
+
function subscribeToPayments(s) {
|
|
2033
|
+
const controller = new AbortController();
|
|
2034
|
+
let cancelled = false;
|
|
2035
|
+
(async () => {
|
|
2036
|
+
try {
|
|
2037
|
+
const url = `${baseUrl}/v1/payments/subscribe?reference=${encodeURIComponent(s.reference)}`;
|
|
2038
|
+
const resp = await signedFetch(url, {
|
|
2039
|
+
method: "GET",
|
|
2040
|
+
headers: { accept: "text/event-stream" },
|
|
2041
|
+
signal: controller.signal
|
|
2042
|
+
});
|
|
2043
|
+
if (!resp.body) return;
|
|
2044
|
+
const reader = resp.body.getReader();
|
|
2045
|
+
const decoder = new TextDecoder();
|
|
2046
|
+
let buffer = "";
|
|
2047
|
+
while (!cancelled) {
|
|
2048
|
+
const { value, done } = await reader.read();
|
|
2049
|
+
if (done) return;
|
|
2050
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2051
|
+
let idx;
|
|
2052
|
+
while ((idx = buffer.indexOf("\n\n")) >= 0) {
|
|
2053
|
+
const chunk = buffer.slice(0, idx);
|
|
2054
|
+
buffer = buffer.slice(idx + 2);
|
|
2055
|
+
for (const line of chunk.split("\n")) {
|
|
2056
|
+
if (!line.startsWith("data:")) continue;
|
|
2057
|
+
const json = line.slice(5).trim();
|
|
2058
|
+
if (!json) continue;
|
|
2059
|
+
try {
|
|
2060
|
+
s.onEvent(JSON.parse(json));
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
s.onError?.(err);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
} catch (err) {
|
|
2068
|
+
if (!cancelled) s.onError?.(err);
|
|
2069
|
+
}
|
|
2070
|
+
})();
|
|
2071
|
+
return () => {
|
|
2072
|
+
cancelled = true;
|
|
2073
|
+
controller.abort();
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
const cash = {
|
|
2077
|
+
generateStaticQR,
|
|
2078
|
+
generateDynamicQR,
|
|
2079
|
+
parseQR,
|
|
2080
|
+
subscribeToPayments
|
|
2081
|
+
};
|
|
2082
|
+
const passes = createPassesClient({ baseUrl, fetchImpl: signedFetch });
|
|
2083
|
+
const receipts = createReceiptsClient({ baseUrl, fetchImpl: signedFetch });
|
|
2084
|
+
return {
|
|
2085
|
+
// top-level back-compat surface
|
|
2086
|
+
generateStaticQR,
|
|
2087
|
+
generateDynamicQR,
|
|
2088
|
+
parseQR,
|
|
2089
|
+
subscribeToPayments,
|
|
2090
|
+
// namespaces
|
|
2091
|
+
cash,
|
|
2092
|
+
passes,
|
|
2093
|
+
receipts
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
723
2096
|
export {
|
|
2097
|
+
ADDITIONAL_DATA_SUBFIELD,
|
|
2098
|
+
FIELD,
|
|
2099
|
+
FlurApiError,
|
|
2100
|
+
FlurCapExceededError,
|
|
724
2101
|
FlurClient,
|
|
725
2102
|
FlurError,
|
|
2103
|
+
FlurExpiredError,
|
|
2104
|
+
FlurReplayError,
|
|
2105
|
+
NGN_CURRENCY_CODE,
|
|
2106
|
+
NG_COUNTRY_CODE,
|
|
2107
|
+
NQRParseError,
|
|
2108
|
+
OACSchema,
|
|
2109
|
+
OAC_DEFAULT_CUMULATIVE_KOBO,
|
|
2110
|
+
OAC_DEFAULT_PER_TX_KOBO,
|
|
2111
|
+
OAC_DEFAULT_VALIDITY_MS,
|
|
2112
|
+
OfflinePaymentAuthorizationSchema,
|
|
2113
|
+
OfflinePaymentRequestSchema,
|
|
2114
|
+
PASS_KINDS,
|
|
2115
|
+
PASS_STATES,
|
|
2116
|
+
PAYLOAD_FORMAT_INDICATOR_VALUE,
|
|
2117
|
+
POINT_OF_INITIATION,
|
|
2118
|
+
PassMetadataSchema,
|
|
2119
|
+
PassSchema,
|
|
2120
|
+
RECEIPT_CHANNELS,
|
|
2121
|
+
RECEIPT_KINDS,
|
|
2122
|
+
REPLAY_WINDOW_MS,
|
|
2123
|
+
ReceiptPayloadSchema,
|
|
2124
|
+
ReceiptSchema,
|
|
2125
|
+
RedemptionSchema,
|
|
2126
|
+
bodySha256Hex,
|
|
2127
|
+
buildAuthorization,
|
|
2128
|
+
buildOAC,
|
|
2129
|
+
buildPass,
|
|
2130
|
+
buildPaymentRequest,
|
|
2131
|
+
buildReceipt,
|
|
2132
|
+
buildRedemption,
|
|
2133
|
+
canonicalJSONBytes,
|
|
2134
|
+
canonicalJSONStringify,
|
|
2135
|
+
canonicalRequestString,
|
|
2136
|
+
constantTimeEqual,
|
|
2137
|
+
crc16ccitt,
|
|
2138
|
+
crc16ccittHex,
|
|
2139
|
+
createHmacFetch,
|
|
2140
|
+
createPassesClient,
|
|
2141
|
+
createReceiptsClient,
|
|
2142
|
+
decodeAuthorizationQR,
|
|
2143
|
+
decodeBase45,
|
|
2144
|
+
decodePaymentRequestQR,
|
|
2145
|
+
encodeAuthorizationQR,
|
|
2146
|
+
encodeBase45,
|
|
2147
|
+
encodeNQR,
|
|
2148
|
+
encodePaymentRequestQR,
|
|
726
2149
|
formatAmount,
|
|
2150
|
+
generateDynamicQR,
|
|
2151
|
+
generateKeyPair,
|
|
2152
|
+
generateStaticQR,
|
|
2153
|
+
init,
|
|
2154
|
+
isPassWithinValidity,
|
|
727
2155
|
moneyMinorToNumber,
|
|
728
2156
|
normalizeE164,
|
|
729
|
-
parseAmountInput
|
|
2157
|
+
parseAmountInput,
|
|
2158
|
+
parseNQR,
|
|
2159
|
+
parseQR,
|
|
2160
|
+
publicKeyFromPrivate,
|
|
2161
|
+
readTLV,
|
|
2162
|
+
routingHint,
|
|
2163
|
+
sign,
|
|
2164
|
+
signAuthorization,
|
|
2165
|
+
signCanonical,
|
|
2166
|
+
signOAC,
|
|
2167
|
+
signPass,
|
|
2168
|
+
signPaymentRequest,
|
|
2169
|
+
signReceipt,
|
|
2170
|
+
signRedemption,
|
|
2171
|
+
signRequestHMAC,
|
|
2172
|
+
verify,
|
|
2173
|
+
verifyAuthorization,
|
|
2174
|
+
verifyCanonical,
|
|
2175
|
+
verifyOAC,
|
|
2176
|
+
verifyPass,
|
|
2177
|
+
verifyPaymentRequest,
|
|
2178
|
+
verifyReceipt,
|
|
2179
|
+
verifyRedemption,
|
|
2180
|
+
verifyRequestHMAC,
|
|
2181
|
+
writeTLV
|
|
730
2182
|
};
|
|
731
2183
|
//# sourceMappingURL=index.js.map
|