@nokinc-flur/sdk 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -23,6 +23,31 @@ npm run gen
23
23
  FLUR_BASE_URL=http://localhost:8080 RUN_E2E=1 npm test
24
24
  ```
25
25
 
26
+ Contract-focused E2E only:
27
+ ```bash
28
+ FLUR_BASE_URL=https://your-oci-gateway.example.com RUN_E2E=1 npm run test:contract
29
+ ```
30
+
31
+ Optional envs for full send-money E2E coverage:
32
+ - `FLUR_E2E_SESSION_TOKEN` (required for account/transactions/recipient)
33
+ - `FLUR_E2E_RECIPIENT_IDENTIFIER` (required for recipient resolve / transfer)
34
+ - `FLUR_E2E_SEND_AUTH_TOKEN` (required for transfer create)
35
+ - `FLUR_E2E_DEVICE_ID` (required for transfer create)
36
+ - `FLUR_E2E_AMOUNT_MINOR` (optional, default `100`)
37
+ - `FLUR_E2E_CURRENCY` (optional, default `NGN`)
38
+
39
+ Generate real E2E auth inputs against deployed backend:
40
+ ```bash
41
+ # Step 1: start onboarding and get requestId
42
+ npm run e2e:bootstrap -- --base-url https://your-oci-gateway --phone +23480xxxxxxx
43
+
44
+ # Step 2: rerun with OTP/silent-auth code (and optional recipient)
45
+ npm run e2e:bootstrap -- --base-url https://your-oci-gateway --phone +23480xxxxxxx --code 123456 --recipient +23481xxxxxxx
46
+
47
+ # Script prints PowerShell env exports, then run:
48
+ npm run test:contract
49
+ ```
50
+
26
51
  ## Onboarding flow methods
27
52
 
28
53
  ```ts
@@ -30,12 +55,17 @@ import { FlurClient } from "@nokinc-flur/sdk";
30
55
 
31
56
  const client = new FlurClient({ baseUrl: "https://api.example.com" });
32
57
 
33
- const started = await client.onboardingStart({ phoneE164: "+14155550123" });
58
+ const started = await client.onboardingStart({
59
+ phoneE164: "+14155550123",
60
+ appInstanceId: "app-instance-1",
61
+ platform: "ios",
62
+ });
34
63
  // started: { requestId, checkUrl?, expiresInSec, fallback }
35
64
 
36
65
  const completed = await client.onboardingComplete({
37
66
  requestId: started.requestId,
38
67
  code: "123456",
68
+ appInstanceId: "app-instance-1",
39
69
  });
40
70
  // completed: { sessionToken, userId, restricted, risk_reasons }
41
71
  ```
@@ -48,7 +78,7 @@ const registered = await client.registerDevice({
48
78
  appInstanceId: "app-instance-1",
49
79
  platform: "ios",
50
80
  networkSignals: { ip: "1.2.3.4" },
51
- });
81
+ }, { accessToken: completed.sessionToken });
52
82
  // { deviceId, fingerprintHash, driftScore, trustState, stepUpRequired }
53
83
 
54
84
  const refreshed = await client.authRefresh({
@@ -59,9 +89,60 @@ const refreshed = await client.authRefresh({
59
89
  });
60
90
  // { refreshToken, stepUpRequired }
61
91
 
62
- await client.pinSet({ userId: completed.userId, pin: "123456" });
63
- await client.pinVerify({ userId: completed.userId, pin: "123456" });
64
- await client.authLogout({ userId: completed.userId, refreshToken: refreshed.refreshToken });
92
+ await client.pinSet({ userId: completed.userId, pin: "123456" }, { accessToken: completed.sessionToken });
93
+ await client.pinVerify({ userId: completed.userId, pin: "123456" }, { accessToken: completed.sessionToken });
94
+ await client.authLogout(
95
+ { userId: completed.userId, refreshToken: refreshed.refreshToken },
96
+ { accessToken: refreshed.refreshToken }
97
+ );
98
+ ```
99
+
100
+ ## Send-money methods
101
+
102
+ ```ts
103
+ // Register per-device signing key for send authorization
104
+ await client.registerSendDeviceKey({
105
+ userId: completed.userId,
106
+ deviceId: registered.deviceId,
107
+ publicKey: "-----BEGIN PUBLIC KEY-----...",
108
+ }, { accessToken: completed.sessionToken });
109
+
110
+ // Obtain challenge and verify signature to get short-lived send token
111
+ const challenge = await client.createSendChallenge({
112
+ userId: completed.userId,
113
+ deviceId: registered.deviceId,
114
+ }, { accessToken: completed.sessionToken });
115
+
116
+ const send = await client.verifySendChallenge({
117
+ userId: completed.userId,
118
+ deviceId: registered.deviceId,
119
+ challengeId: challenge.challengeId,
120
+ signature: "base64-signature",
121
+ }, { accessToken: completed.sessionToken });
122
+
123
+ // Resolve recipient and create transfer
124
+ const recipient = await client.resolveRecipient(
125
+ { identifier: "+14155550123" },
126
+ { accessToken: completed.sessionToken }
127
+ );
128
+
129
+ await client.createTransfer(
130
+ {
131
+ recipientIdentifier: recipient.normalizedIdentifier,
132
+ amountMinor: 5000,
133
+ currency: "NGN",
134
+ sendAuthToken: send.sendAuthToken,
135
+ },
136
+ {
137
+ accessToken: completed.sessionToken,
138
+ deviceId: registered.deviceId,
139
+ idempotencyKey: crypto.randomUUID(),
140
+ }
141
+ );
142
+
143
+ // Account + transaction endpoints
144
+ await client.accountSummary({ accessToken: completed.sessionToken });
145
+ await client.listTransactions({ accessToken: completed.sessionToken, limit: 20 });
65
146
  ```
66
147
 
67
148
  ## Error code mapping
package/dist/index.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ type Money = {
2
+ amountMinor: bigint;
3
+ currency: string;
4
+ };
5
+ declare function parseAmountInput(value: string, currency: string): Money;
6
+ declare function formatAmount(amountMinor: bigint, currency: string): string;
7
+ declare function normalizeE164(input: string, defaultCountry?: string): string;
8
+ declare function moneyMinorToNumber(amountMinor: bigint): number;
9
+
1
10
  type FlurClientOptions = {
2
11
  baseUrl: string;
3
12
  fetchImpl?: typeof fetch;
@@ -6,6 +15,13 @@ type FlurClientOptions = {
6
15
  type OnboardingFallback = "SILENT_AUTH" | "OTP";
7
16
  type OnboardingStartInput = {
8
17
  phoneE164: string;
18
+ appInstanceId: string;
19
+ platform: "android" | "ios" | "web";
20
+ turnstileToken?: string;
21
+ appAttestation?: {
22
+ provider: "android" | "ios" | "web";
23
+ token: string;
24
+ };
9
25
  firstName?: string;
10
26
  lastName?: string;
11
27
  };
@@ -19,7 +35,7 @@ type OnboardingRiskReason = "SIM_SWAP_RECENT" | "ROAMING" | "CARRIER_CHANGED";
19
35
  type OnboardingCompleteInput = {
20
36
  requestId: string;
21
37
  code: string;
22
- appInstanceId?: string;
38
+ appInstanceId: string;
23
39
  fingerprintHash?: string;
24
40
  };
25
41
  type OnboardingCompleteResponse = {
@@ -72,6 +88,134 @@ type PinVerifyInput = {
72
88
  userId: string;
73
89
  pin: string;
74
90
  };
91
+ type RegisterSendDeviceKeyInput = {
92
+ userId: string;
93
+ deviceId: string;
94
+ publicKey: string;
95
+ };
96
+ type SendChallengeInput = {
97
+ userId: string;
98
+ deviceId: string;
99
+ };
100
+ type SendChallengeResponse = {
101
+ challengeId: string;
102
+ nonce: string;
103
+ expiresAt: string;
104
+ };
105
+ type SendVerifyInput = {
106
+ userId: string;
107
+ deviceId: string;
108
+ challengeId: string;
109
+ signature: string;
110
+ };
111
+ type SendVerifyResponse = {
112
+ sendAuthToken: string;
113
+ };
114
+ type RecipientResolveInput = {
115
+ identifier: string;
116
+ };
117
+ type RecipientResolveResponse = {
118
+ recipientUserId: string;
119
+ displayName: string;
120
+ normalizedIdentifier: string;
121
+ isActive: boolean;
122
+ };
123
+ type TransferInput = {
124
+ recipientIdentifier: string;
125
+ amountMinor: number;
126
+ currency: string;
127
+ sendAuthToken: string;
128
+ };
129
+ type TransferStatus = "SETTLED" | "PENDING_REVIEW" | "DECLINED";
130
+ type TransferResponse = {
131
+ transactionId: string;
132
+ status: TransferStatus;
133
+ userStatus: TransferStatus;
134
+ recipientName: string;
135
+ timestamp: string;
136
+ };
137
+ type TransactionDirection = "OUTGOING" | "INCOMING";
138
+ type AccountActivityItem = {
139
+ id: string;
140
+ type: string;
141
+ direction: TransactionDirection;
142
+ name: string;
143
+ identifier: string;
144
+ amountMinor: number;
145
+ currency: string;
146
+ status: string;
147
+ timestamp: string;
148
+ };
149
+ type AccountSummaryResponse = {
150
+ balance: number;
151
+ currency: string;
152
+ dailySendLimit: number;
153
+ dailySendRemaining: number;
154
+ kycTier: string;
155
+ kycStatus: string;
156
+ recentActivity: AccountActivityItem[];
157
+ };
158
+ type TransactionsListResponse = {
159
+ items: AccountActivityItem[];
160
+ nextCursor: string | null;
161
+ };
162
+ type TransactionDetailResponse = {
163
+ transactionId: string;
164
+ type: string;
165
+ direction: TransactionDirection;
166
+ counterpartyName: string;
167
+ counterpartyIdentifier: string;
168
+ amountMinor: number;
169
+ currency: string;
170
+ status: string;
171
+ timestamp: string;
172
+ };
173
+ type PushPlatform = "ios" | "android" | "web";
174
+ type PushRegisterInput = {
175
+ deviceId: string;
176
+ platform: PushPlatform;
177
+ token: string;
178
+ };
179
+ type CreatePayLinkResponse = {
180
+ token: string;
181
+ };
182
+ type ResolvePayLinkResponse = {
183
+ recipientUserId: string;
184
+ displayName: string;
185
+ normalizedIdentifier: string;
186
+ isActive: boolean;
187
+ };
188
+ type AuthorizedOptions = {
189
+ accessToken: string;
190
+ };
191
+ type CreateTransferOptions = AuthorizedOptions & {
192
+ deviceId: string;
193
+ idempotencyKey: string;
194
+ };
195
+ type ListTransactionsOptions = AuthorizedOptions & {
196
+ cursor?: string;
197
+ limit?: number;
198
+ };
199
+ type BiometricSigner = {
200
+ getOrCreateKeyPair(deviceId: string): Promise<string>;
201
+ sign(payload: string): Promise<string>;
202
+ };
203
+ type SendMoneyInput = {
204
+ recipientIdentifier: string;
205
+ money: Money;
206
+ sendAuthToken: string;
207
+ idempotencyKey?: string;
208
+ defaultCountry?: string;
209
+ };
210
+ type SendMoneyOptions = AuthorizedOptions & {
211
+ deviceId: string;
212
+ };
213
+ type AuthorizeSendWithBiometricInput = {
214
+ userId: string;
215
+ deviceId: string;
216
+ accessToken: string;
217
+ signer: BiometricSigner;
218
+ };
75
219
  declare class FlurClient {
76
220
  private readonly baseUrl;
77
221
  private readonly fetchImpl;
@@ -85,21 +229,46 @@ declare class FlurClient {
85
229
  }>;
86
230
  onboardingStart(input: OnboardingStartInput): Promise<OnboardingStartResponse>;
87
231
  onboardingComplete(input: OnboardingCompleteInput): Promise<OnboardingCompleteResponse>;
88
- registerDevice(input: RegisterDeviceInput): Promise<RegisterDeviceResponse>;
232
+ registerDevice(input: RegisterDeviceInput, options: AuthorizedOptions): Promise<RegisterDeviceResponse>;
89
233
  authRefresh(input: AuthRefreshInput): Promise<AuthRefreshResponse>;
90
- authLogout(input: AuthLogoutInput): Promise<{
234
+ authLogout(input: AuthLogoutInput, options: AuthorizedOptions): Promise<{
235
+ ok: boolean;
236
+ }>;
237
+ pinSet(input: PinSetInput, options: AuthorizedOptions): Promise<{
238
+ ok: boolean;
239
+ }>;
240
+ pinVerify(input: PinVerifyInput, options: AuthorizedOptions): Promise<{
241
+ ok: boolean;
242
+ }>;
243
+ registerSendDeviceKey(input: RegisterSendDeviceKeyInput, options: AuthorizedOptions): Promise<{
91
244
  ok: boolean;
92
245
  }>;
93
- pinSet(input: PinSetInput): Promise<{
246
+ createSendChallenge(input: SendChallengeInput, options: AuthorizedOptions): Promise<SendChallengeResponse>;
247
+ verifySendChallenge(input: SendVerifyInput, options: AuthorizedOptions): Promise<SendVerifyResponse>;
248
+ registerBiometricDeviceKey(input: {
249
+ userId: string;
250
+ deviceId: string;
251
+ accessToken: string;
252
+ signer: BiometricSigner;
253
+ }): Promise<{
94
254
  ok: boolean;
95
255
  }>;
96
- pinVerify(input: PinVerifyInput): Promise<{
256
+ authorizeSendWithBiometric(input: AuthorizeSendWithBiometricInput): Promise<SendVerifyResponse>;
257
+ resolveRecipient(input: RecipientResolveInput, options: AuthorizedOptions): Promise<RecipientResolveResponse>;
258
+ createTransfer(input: TransferInput, options: CreateTransferOptions): Promise<TransferResponse>;
259
+ sendMoney(input: SendMoneyInput, options: SendMoneyOptions): Promise<TransferResponse>;
260
+ accountSummary(options: AuthorizedOptions): Promise<AccountSummaryResponse>;
261
+ listTransactions(options: ListTransactionsOptions): Promise<TransactionsListResponse>;
262
+ transactionDetail(transactionId: string, options: AuthorizedOptions): Promise<TransactionDetailResponse>;
263
+ registerPushToken(input: PushRegisterInput, options: AuthorizedOptions): Promise<{
97
264
  ok: boolean;
98
265
  }>;
266
+ createPayLink(options: AuthorizedOptions): Promise<CreatePayLinkResponse>;
267
+ resolvePayLink(token: string, options: AuthorizedOptions): Promise<ResolvePayLinkResponse>;
99
268
  private requestJson;
100
269
  }
101
270
 
102
- type FlurErrorCode = "NETWORK_ERROR" | "HTTP_ERROR" | "TIMEOUT" | "UNKNOWN" | "TOKEN_REPLAYED" | "SESSION_MISMATCH" | "PIN_INVALID" | "PIN_LOCKED" | "PIN_NOT_SET" | "STEP_UP_REQUIRED";
271
+ type FlurErrorCode = "NETWORK_ERROR" | "HTTP_ERROR" | "TIMEOUT" | "UNKNOWN" | "UNAUTHORIZED" | "INVALID_REQUEST" | "RATE_LIMITED" | "NOT_FOUND" | "USER_NOT_FOUND" | "TOKEN_REPLAYED" | "SESSION_MISMATCH" | "PIN_INVALID" | "PIN_LOCKED" | "PIN_NOT_SET" | "STEP_UP_REQUIRED" | "DEVICE_KEY_NOT_REGISTERED" | "CHALLENGE_INVALID" | "CHALLENGE_EXPIRED" | "DEVICE_KEY_REVOKED" | "SIGNATURE_INVALID" | "INVALID_RECIPIENT" | "SEND_AUTH_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "CANNOT_SEND_TO_SELF" | "INSUFFICIENT_FUNDS";
103
272
  declare class FlurError extends Error {
104
273
  readonly code: FlurErrorCode;
105
274
  readonly status?: number;
@@ -110,4 +279,4 @@ declare class FlurError extends Error {
110
279
  });
111
280
  }
112
281
 
113
- export { type AuthLogoutInput, type AuthRefreshInput, type AuthRefreshResponse, type DeviceTrustState, FlurClient, type FlurClientOptions, FlurError, type OnboardingCompleteInput, type OnboardingCompleteResponse, type OnboardingFallback, type OnboardingRiskReason, type OnboardingStartInput, type OnboardingStartResponse, type PinSetInput, type PinVerifyInput, type RegisterDeviceInput, type RegisterDeviceResponse };
282
+ export { type AccountActivityItem, type AccountSummaryResponse, type AuthLogoutInput, type AuthRefreshInput, type AuthRefreshResponse, type AuthorizeSendWithBiometricInput, type AuthorizedOptions, type BiometricSigner, type CreatePayLinkResponse, type CreateTransferOptions, type DeviceTrustState, FlurClient, type FlurClientOptions, FlurError, type ListTransactionsOptions, type Money, type OnboardingCompleteInput, type OnboardingCompleteResponse, type OnboardingFallback, type OnboardingRiskReason, type OnboardingStartInput, type OnboardingStartResponse, type PinSetInput, type PinVerifyInput, type PushPlatform, type PushRegisterInput, type RecipientResolveInput, type RecipientResolveResponse, type RegisterDeviceInput, type RegisterDeviceResponse, type RegisterSendDeviceKeyInput, type ResolvePayLinkResponse, type SendChallengeInput, type SendChallengeResponse, type SendMoneyInput, type SendMoneyOptions, type SendVerifyInput, type SendVerifyResponse, type TransactionDetailResponse, type TransactionDirection, type TransactionsListResponse, type TransferInput, type TransferResponse, type TransferStatus, formatAmount, moneyMinorToNumber, normalizeE164, parseAmountInput };
package/dist/index.js CHANGED
@@ -1,11 +1,215 @@
1
+ // src/client.ts
2
+ import { z as z3 } from "zod";
3
+
4
+ // src/contracts.ts
5
+ import { z } from "zod";
6
+ var E164Regex = /^\+[1-9]\d{7,14}$/;
7
+ var UuidSchema = z.string().uuid();
8
+ var IsoDateSchema = z.string().datetime({ offset: true });
9
+ var CurrencySchema = z.string().trim().length(3).transform((value) => value.toUpperCase());
10
+ var HealthResponseSchema = z.object({
11
+ ok: z.boolean()
12
+ });
13
+ var WelcomeResponseSchema = z.object({
14
+ message: z.string()
15
+ });
16
+ var OnboardingStartRequestSchema = z.object({
17
+ phoneE164: z.string().regex(E164Regex),
18
+ appInstanceId: z.string().min(3),
19
+ platform: z.enum(["android", "ios", "web"]),
20
+ turnstileToken: z.string().min(20).optional(),
21
+ appAttestation: z.object({
22
+ provider: z.enum(["android", "ios", "web"]),
23
+ token: z.string().min(24)
24
+ }).optional(),
25
+ firstName: z.string().trim().min(1).max(80).optional(),
26
+ lastName: z.string().trim().min(1).max(80).optional()
27
+ });
28
+ var OnboardingStartResponseSchema = z.object({
29
+ requestId: z.string().min(1),
30
+ checkUrl: z.string().url().optional(),
31
+ expiresInSec: z.number().int().positive(),
32
+ fallback: z.enum(["SILENT_AUTH", "OTP"])
33
+ });
34
+ var OnboardingCompleteRequestSchema = z.object({
35
+ requestId: z.string().min(1),
36
+ code: z.string().min(1).max(32),
37
+ appInstanceId: z.string().min(3),
38
+ fingerprintHash: z.string().min(3).optional()
39
+ });
40
+ var OnboardingCompleteResponseSchema = z.object({
41
+ sessionToken: z.string().min(1),
42
+ userId: UuidSchema,
43
+ restricted: z.boolean(),
44
+ risk_reasons: z.array(z.enum(["SIM_SWAP_RECENT", "ROAMING", "CARRIER_CHANGED"])),
45
+ stepUpRequired: z.boolean().optional(),
46
+ riskStatus: z.enum(["ok", "unavailable"]).optional()
47
+ });
48
+ var RegisterDeviceRequestSchema = z.object({
49
+ userId: UuidSchema,
50
+ appInstanceId: z.string().min(3),
51
+ platform: z.string().min(2),
52
+ model: z.string().optional(),
53
+ networkSignals: z.object({
54
+ ip: z.string().min(3),
55
+ asn: z.number().int().optional(),
56
+ country: z.string().min(2).optional(),
57
+ carrier: z.string().optional()
58
+ })
59
+ });
60
+ var RegisterDeviceResponseSchema = z.object({
61
+ deviceId: z.string().min(1),
62
+ fingerprintHash: z.string().min(1),
63
+ driftScore: z.number(),
64
+ trustState: z.enum(["TRUSTED_PRIMARY", "TRUSTED_SECONDARY", "UNVERIFIED"]),
65
+ stepUpRequired: z.boolean()
66
+ });
67
+ var AuthRefreshRequestSchema = z.object({
68
+ userId: UuidSchema,
69
+ refreshToken: z.string().min(8),
70
+ appInstanceId: z.string().min(3),
71
+ fingerprintHash: z.string().min(3)
72
+ });
73
+ var AuthRefreshResponseSchema = z.object({
74
+ refreshToken: z.string().min(8),
75
+ stepUpRequired: z.boolean()
76
+ });
77
+ var AuthLogoutRequestSchema = z.object({
78
+ userId: UuidSchema,
79
+ refreshToken: z.string().min(8)
80
+ });
81
+ var PinSetRequestSchema = z.object({
82
+ userId: UuidSchema,
83
+ pin: z.string().regex(/^\d{6}$/)
84
+ });
85
+ var PinVerifyRequestSchema = z.object({
86
+ userId: UuidSchema,
87
+ pin: z.string().regex(/^\d{6}$/)
88
+ });
89
+ var OkResponseSchema = z.object({
90
+ ok: z.boolean()
91
+ });
92
+ var RegisterSendDeviceKeyRequestSchema = z.object({
93
+ userId: UuidSchema,
94
+ deviceId: z.string().min(3),
95
+ publicKey: z.string().min(32)
96
+ });
97
+ var SendChallengeRequestSchema = z.object({
98
+ userId: UuidSchema,
99
+ deviceId: z.string().min(3)
100
+ });
101
+ var SendChallengeResponseSchema = z.object({
102
+ challengeId: UuidSchema,
103
+ nonce: z.string().min(1),
104
+ expiresAt: IsoDateSchema
105
+ });
106
+ var SendVerifyRequestSchema = z.object({
107
+ userId: UuidSchema,
108
+ deviceId: z.string().min(3),
109
+ challengeId: UuidSchema,
110
+ signature: z.string().min(16)
111
+ });
112
+ var SendVerifyResponseSchema = z.object({
113
+ sendAuthToken: z.string().min(16)
114
+ });
115
+ var ResolveRecipientRequestSchema = z.object({
116
+ identifier: z.string().min(3)
117
+ });
118
+ var ResolveRecipientResponseSchema = z.object({
119
+ recipientUserId: UuidSchema,
120
+ displayName: z.string().min(1),
121
+ normalizedIdentifier: z.string().regex(E164Regex),
122
+ isActive: z.boolean()
123
+ });
124
+ var CreateTransferRequestSchema = z.object({
125
+ recipientIdentifier: z.string().min(3),
126
+ amountMinor: z.number().int().positive(),
127
+ currency: CurrencySchema,
128
+ sendAuthToken: z.string().min(16)
129
+ });
130
+ var TransferStatusSchema = z.enum(["SETTLED", "PENDING_REVIEW", "DECLINED"]);
131
+ var TransferResponseSchema = z.object({
132
+ transactionId: z.string().min(1),
133
+ status: TransferStatusSchema,
134
+ userStatus: TransferStatusSchema,
135
+ recipientName: z.string().min(1),
136
+ timestamp: IsoDateSchema
137
+ });
138
+ var DirectionSchema = z.enum(["OUTGOING", "INCOMING"]);
139
+ var AccountActivityItemSchema = z.object({
140
+ id: z.string().min(1),
141
+ type: z.string().min(1),
142
+ direction: DirectionSchema,
143
+ name: z.string().min(1),
144
+ identifier: z.string().min(1),
145
+ amountMinor: z.number().int(),
146
+ currency: CurrencySchema,
147
+ status: z.string().min(1),
148
+ timestamp: IsoDateSchema
149
+ });
150
+ var AccountSummaryResponseSchema = z.object({
151
+ balance: z.number().int(),
152
+ currency: CurrencySchema,
153
+ dailySendLimit: z.number().int().nonnegative(),
154
+ dailySendRemaining: z.number().int().nonnegative(),
155
+ kycTier: z.string().min(1),
156
+ kycStatus: z.string().min(1),
157
+ recentActivity: z.array(AccountActivityItemSchema)
158
+ });
159
+ var TransactionsListResponseSchema = z.object({
160
+ items: z.array(AccountActivityItemSchema),
161
+ nextCursor: z.string().nullable()
162
+ });
163
+ var TransactionDetailResponseSchema = z.object({
164
+ transactionId: z.string().min(1),
165
+ type: z.string().min(1),
166
+ direction: DirectionSchema,
167
+ counterpartyName: z.string().min(1),
168
+ counterpartyIdentifier: z.string().min(1),
169
+ amountMinor: z.number().int(),
170
+ currency: CurrencySchema,
171
+ status: z.string().min(1),
172
+ timestamp: IsoDateSchema
173
+ });
174
+ var PushRegisterRequestSchema = z.object({
175
+ deviceId: z.string().min(3),
176
+ platform: z.enum(["ios", "android", "web"]),
177
+ token: z.string().min(16)
178
+ });
179
+ var CreatePayLinkResponseSchema = z.object({
180
+ token: z.string().min(1)
181
+ });
182
+ var ResolvePayLinkResponseSchema = z.object({
183
+ recipientUserId: UuidSchema,
184
+ displayName: z.string().min(1),
185
+ normalizedIdentifier: z.string().regex(E164Regex),
186
+ isActive: z.boolean()
187
+ });
188
+
1
189
  // src/errors.ts
2
190
  var backendErrorCodeSet = /* @__PURE__ */ new Set([
191
+ "UNAUTHORIZED",
192
+ "INVALID_REQUEST",
193
+ "RATE_LIMITED",
194
+ "NOT_FOUND",
195
+ "USER_NOT_FOUND",
3
196
  "TOKEN_REPLAYED",
4
197
  "SESSION_MISMATCH",
5
198
  "PIN_INVALID",
6
199
  "PIN_LOCKED",
7
200
  "PIN_NOT_SET",
8
- "STEP_UP_REQUIRED"
201
+ "STEP_UP_REQUIRED",
202
+ "DEVICE_KEY_NOT_REGISTERED",
203
+ "CHALLENGE_INVALID",
204
+ "CHALLENGE_EXPIRED",
205
+ "DEVICE_KEY_REVOKED",
206
+ "SIGNATURE_INVALID",
207
+ "INVALID_RECIPIENT",
208
+ "SEND_AUTH_INVALID",
209
+ "IDEMPOTENCY_KEY_CONFLICT",
210
+ "IDEMPOTENCY_IN_PROGRESS",
211
+ "CANNOT_SEND_TO_SELF",
212
+ "INSUFFICIENT_FUNDS"
9
213
  ]);
10
214
  var FlurError = class extends Error {
11
215
  code;
@@ -44,6 +248,101 @@ async function mapToFlurError(res) {
44
248
  return new FlurError(mappedMessage, mappedCode, { status: res.status, details });
45
249
  }
46
250
 
251
+ // src/primitives.ts
252
+ import { z as z2 } from "zod";
253
+ var CurrencyCodeSchema = z2.string().trim().length(3).transform((value) => value.toUpperCase());
254
+ var currencyFractionDigits = {
255
+ NGN: 2,
256
+ USD: 2,
257
+ EUR: 2,
258
+ GBP: 2,
259
+ CAD: 2,
260
+ JPY: 0
261
+ };
262
+ function getFractionDigits(currency) {
263
+ return currencyFractionDigits[currency] ?? 2;
264
+ }
265
+ function parseAmountInput(value, currency) {
266
+ const normalizedCurrency = CurrencyCodeSchema.parse(currency);
267
+ const raw = value.trim();
268
+ if (!/^\d+(\.\d+)?$/.test(raw)) {
269
+ throw new FlurError("Invalid amount format", "INVALID_REQUEST");
270
+ }
271
+ const [whole, fraction = ""] = raw.split(".");
272
+ const fractionDigits = getFractionDigits(normalizedCurrency);
273
+ if (fraction.length > fractionDigits) {
274
+ throw new FlurError("Too many decimal places for currency", "INVALID_REQUEST");
275
+ }
276
+ const paddedFraction = fraction.padEnd(fractionDigits, "0");
277
+ const minorAsString = `${whole}${paddedFraction}`.replace(/^0+(\d)/, "$1") || "0";
278
+ const amountMinor = BigInt(minorAsString);
279
+ if (amountMinor < 0n) {
280
+ throw new FlurError("Amount cannot be negative", "INVALID_REQUEST");
281
+ }
282
+ return {
283
+ amountMinor,
284
+ currency: normalizedCurrency
285
+ };
286
+ }
287
+ function formatAmount(amountMinor, currency) {
288
+ const normalizedCurrency = CurrencyCodeSchema.parse(currency);
289
+ if (amountMinor < 0n) {
290
+ throw new FlurError("Amount cannot be negative", "INVALID_REQUEST");
291
+ }
292
+ const fractionDigits = getFractionDigits(normalizedCurrency);
293
+ if (fractionDigits === 0) {
294
+ return amountMinor.toString();
295
+ }
296
+ const divisor = 10n ** BigInt(fractionDigits);
297
+ const whole = amountMinor / divisor;
298
+ const fraction = (amountMinor % divisor).toString().padStart(fractionDigits, "0");
299
+ return `${whole.toString()}.${fraction}`;
300
+ }
301
+ var defaultCountryDialingCode = {
302
+ NG: "234",
303
+ US: "1",
304
+ CA: "1",
305
+ GB: "44"
306
+ };
307
+ function normalizeE164(input, defaultCountry) {
308
+ const trimmed = input.trim();
309
+ if (trimmed.startsWith("+")) {
310
+ if (!E164Regex.test(trimmed)) {
311
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
312
+ }
313
+ return trimmed;
314
+ }
315
+ const digits = trimmed.replace(/\D/g, "");
316
+ if (digits.length === 0) {
317
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
318
+ }
319
+ if (digits.startsWith("00")) {
320
+ const candidate2 = `+${digits.slice(2)}`;
321
+ if (!E164Regex.test(candidate2)) {
322
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
323
+ }
324
+ return candidate2;
325
+ }
326
+ const normalizedCountry = defaultCountry?.trim().toUpperCase();
327
+ const countryCode = normalizedCountry ? defaultCountryDialingCode[normalizedCountry] : void 0;
328
+ if (!countryCode) {
329
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
330
+ }
331
+ const localDigits = digits.startsWith("0") ? digits.slice(1) : digits;
332
+ const candidate = `+${countryCode}${localDigits}`;
333
+ if (!E164Regex.test(candidate)) {
334
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
335
+ }
336
+ return candidate;
337
+ }
338
+ function moneyMinorToNumber(amountMinor) {
339
+ const asNumber = Number(amountMinor);
340
+ if (!Number.isSafeInteger(asNumber)) {
341
+ throw new FlurError("Amount exceeds safe integer range", "INVALID_REQUEST");
342
+ }
343
+ return asNumber;
344
+ }
345
+
47
346
  // src/client.ts
48
347
  var FlurClient = class {
49
348
  baseUrl;
@@ -55,68 +354,343 @@ var FlurClient = class {
55
354
  this.timeoutMs = opts.timeoutMs ?? 1e4;
56
355
  }
57
356
  async health() {
58
- return this.requestJson("/health", { method: "GET" });
357
+ return this.requestJson("/health", { method: "GET" }, void 0, HealthResponseSchema);
59
358
  }
60
359
  async welcome() {
61
- return this.requestJson("/welcome", { method: "GET" });
360
+ return this.requestJson("/welcome", { method: "GET" }, void 0, WelcomeResponseSchema);
62
361
  }
63
362
  async onboardingStart(input) {
64
- return this.requestJson("/v1/onboarding/start", {
65
- method: "POST",
66
- headers: { "content-type": "application/json" },
67
- body: JSON.stringify(input)
68
- });
363
+ return this.requestJson(
364
+ "/v1/onboarding/start",
365
+ {
366
+ method: "POST",
367
+ headers: { "content-type": "application/json" }
368
+ },
369
+ OnboardingStartRequestSchema,
370
+ OnboardingStartResponseSchema,
371
+ input
372
+ );
69
373
  }
70
374
  async onboardingComplete(input) {
71
- return this.requestJson("/v1/onboarding/complete", {
72
- method: "POST",
73
- headers: { "content-type": "application/json" },
74
- body: JSON.stringify(input)
75
- });
76
- }
77
- async registerDevice(input) {
78
- return this.requestJson("/v1/devices/register", {
79
- method: "POST",
80
- headers: { "content-type": "application/json" },
81
- body: JSON.stringify(input)
82
- });
375
+ return this.requestJson(
376
+ "/v1/onboarding/complete",
377
+ {
378
+ method: "POST",
379
+ headers: { "content-type": "application/json" }
380
+ },
381
+ OnboardingCompleteRequestSchema,
382
+ OnboardingCompleteResponseSchema,
383
+ input
384
+ );
385
+ }
386
+ async registerDevice(input, options) {
387
+ return this.requestJson(
388
+ "/v1/devices/register",
389
+ {
390
+ method: "POST",
391
+ headers: {
392
+ "content-type": "application/json",
393
+ authorization: `Bearer ${options.accessToken}`
394
+ }
395
+ },
396
+ RegisterDeviceRequestSchema,
397
+ RegisterDeviceResponseSchema,
398
+ input
399
+ );
83
400
  }
84
401
  async authRefresh(input) {
85
- return this.requestJson("/v1/auth/refresh", {
86
- method: "POST",
87
- headers: { "content-type": "application/json" },
88
- body: JSON.stringify(input)
89
- });
90
- }
91
- async authLogout(input) {
92
- return this.requestJson("/v1/auth/logout", {
93
- method: "POST",
94
- headers: { "content-type": "application/json" },
95
- body: JSON.stringify(input)
96
- });
97
- }
98
- async pinSet(input) {
99
- return this.requestJson("/v1/auth/pin/set", {
100
- method: "POST",
101
- headers: { "content-type": "application/json" },
102
- body: JSON.stringify(input)
103
- });
104
- }
105
- async pinVerify(input) {
106
- return this.requestJson("/v1/auth/pin/verify", {
107
- method: "POST",
108
- headers: { "content-type": "application/json" },
109
- body: JSON.stringify(input)
110
- });
111
- }
112
- async requestJson(path, init) {
402
+ return this.requestJson(
403
+ "/v1/auth/refresh",
404
+ {
405
+ method: "POST",
406
+ headers: { "content-type": "application/json" }
407
+ },
408
+ AuthRefreshRequestSchema,
409
+ AuthRefreshResponseSchema,
410
+ input
411
+ );
412
+ }
413
+ async authLogout(input, options) {
414
+ return this.requestJson(
415
+ "/v1/auth/logout",
416
+ {
417
+ method: "POST",
418
+ headers: {
419
+ "content-type": "application/json",
420
+ authorization: `Bearer ${options.accessToken}`
421
+ }
422
+ },
423
+ AuthLogoutRequestSchema,
424
+ OkResponseSchema,
425
+ input
426
+ );
427
+ }
428
+ async pinSet(input, options) {
429
+ return this.requestJson(
430
+ "/v1/auth/pin/set",
431
+ {
432
+ method: "POST",
433
+ headers: {
434
+ "content-type": "application/json",
435
+ authorization: `Bearer ${options.accessToken}`
436
+ }
437
+ },
438
+ PinSetRequestSchema,
439
+ OkResponseSchema,
440
+ input
441
+ );
442
+ }
443
+ async pinVerify(input, options) {
444
+ return this.requestJson(
445
+ "/v1/auth/pin/verify",
446
+ {
447
+ method: "POST",
448
+ headers: {
449
+ "content-type": "application/json",
450
+ authorization: `Bearer ${options.accessToken}`
451
+ }
452
+ },
453
+ PinVerifyRequestSchema,
454
+ OkResponseSchema,
455
+ input
456
+ );
457
+ }
458
+ async registerSendDeviceKey(input, options) {
459
+ return this.requestJson(
460
+ "/api/v1/auth/send/device-key",
461
+ {
462
+ method: "POST",
463
+ headers: {
464
+ "content-type": "application/json",
465
+ authorization: `Bearer ${options.accessToken}`
466
+ }
467
+ },
468
+ RegisterSendDeviceKeyRequestSchema,
469
+ OkResponseSchema,
470
+ input
471
+ );
472
+ }
473
+ async createSendChallenge(input, options) {
474
+ return this.requestJson(
475
+ "/api/v1/auth/send/challenge",
476
+ {
477
+ method: "POST",
478
+ headers: {
479
+ "content-type": "application/json",
480
+ authorization: `Bearer ${options.accessToken}`
481
+ }
482
+ },
483
+ SendChallengeRequestSchema,
484
+ SendChallengeResponseSchema,
485
+ input
486
+ );
487
+ }
488
+ async verifySendChallenge(input, options) {
489
+ return this.requestJson(
490
+ "/api/v1/auth/send/verify",
491
+ {
492
+ method: "POST",
493
+ headers: {
494
+ "content-type": "application/json",
495
+ authorization: `Bearer ${options.accessToken}`
496
+ }
497
+ },
498
+ SendVerifyRequestSchema,
499
+ SendVerifyResponseSchema,
500
+ input
501
+ );
502
+ }
503
+ async registerBiometricDeviceKey(input) {
504
+ const publicKey = await input.signer.getOrCreateKeyPair(input.deviceId);
505
+ return this.registerSendDeviceKey({
506
+ userId: input.userId,
507
+ deviceId: input.deviceId,
508
+ publicKey
509
+ }, { accessToken: input.accessToken });
510
+ }
511
+ async authorizeSendWithBiometric(input) {
512
+ const challenge = await this.createSendChallenge({
513
+ userId: input.userId,
514
+ deviceId: input.deviceId
515
+ }, { accessToken: input.accessToken });
516
+ const signature = await input.signer.sign(challenge.nonce);
517
+ return this.verifySendChallenge({
518
+ userId: input.userId,
519
+ deviceId: input.deviceId,
520
+ challengeId: challenge.challengeId,
521
+ signature
522
+ }, { accessToken: input.accessToken });
523
+ }
524
+ async resolveRecipient(input, options) {
525
+ return this.requestJson(
526
+ "/api/v1/recipients/resolve",
527
+ {
528
+ method: "POST",
529
+ headers: {
530
+ "content-type": "application/json",
531
+ authorization: `Bearer ${options.accessToken}`
532
+ }
533
+ },
534
+ ResolveRecipientRequestSchema,
535
+ ResolveRecipientResponseSchema,
536
+ input
537
+ );
538
+ }
539
+ async createTransfer(input, options) {
540
+ return this.requestJson(
541
+ "/api/v1/transfers",
542
+ {
543
+ method: "POST",
544
+ headers: {
545
+ "content-type": "application/json",
546
+ authorization: `Bearer ${options.accessToken}`,
547
+ "x-device-id": options.deviceId,
548
+ "x-idempotency-key": options.idempotencyKey
549
+ }
550
+ },
551
+ CreateTransferRequestSchema,
552
+ TransferResponseSchema,
553
+ input
554
+ );
555
+ }
556
+ async sendMoney(input, options) {
557
+ const normalizedRecipient = E164Regex.test(input.recipientIdentifier) ? input.recipientIdentifier : normalizeE164(input.recipientIdentifier, input.defaultCountry);
558
+ const idempotencyKey = input.idempotencyKey ?? getSecureRandomUuid();
559
+ return this.createTransfer(
560
+ {
561
+ recipientIdentifier: normalizedRecipient,
562
+ amountMinor: moneyMinorToNumber(input.money.amountMinor),
563
+ currency: input.money.currency,
564
+ sendAuthToken: input.sendAuthToken
565
+ },
566
+ {
567
+ accessToken: options.accessToken,
568
+ deviceId: options.deviceId,
569
+ idempotencyKey
570
+ }
571
+ );
572
+ }
573
+ async accountSummary(options) {
574
+ return this.requestJson(
575
+ "/api/v1/account/summary",
576
+ {
577
+ method: "GET",
578
+ headers: {
579
+ authorization: `Bearer ${options.accessToken}`
580
+ }
581
+ },
582
+ void 0,
583
+ AccountSummaryResponseSchema
584
+ );
585
+ }
586
+ async listTransactions(options) {
587
+ const query = new URLSearchParams();
588
+ if (typeof options.cursor === "string" && options.cursor.trim().length > 0) {
589
+ query.set("cursor", options.cursor);
590
+ }
591
+ if (typeof options.limit === "number") {
592
+ query.set("limit", String(options.limit));
593
+ }
594
+ const path = query.size > 0 ? `/api/v1/transactions?${query.toString()}` : "/api/v1/transactions";
595
+ return this.requestJson(
596
+ path,
597
+ {
598
+ method: "GET",
599
+ headers: {
600
+ authorization: `Bearer ${options.accessToken}`
601
+ }
602
+ },
603
+ void 0,
604
+ TransactionsListResponseSchema
605
+ );
606
+ }
607
+ async transactionDetail(transactionId, options) {
608
+ const encodedId = encodeURIComponent(transactionId);
609
+ return this.requestJson(
610
+ `/api/v1/transactions/${encodedId}`,
611
+ {
612
+ method: "GET",
613
+ headers: {
614
+ authorization: `Bearer ${options.accessToken}`
615
+ }
616
+ },
617
+ void 0,
618
+ TransactionDetailResponseSchema
619
+ );
620
+ }
621
+ async registerPushToken(input, options) {
622
+ return this.requestJson(
623
+ "/api/v1/push/register",
624
+ {
625
+ method: "POST",
626
+ headers: {
627
+ "content-type": "application/json",
628
+ authorization: `Bearer ${options.accessToken}`
629
+ }
630
+ },
631
+ PushRegisterRequestSchema,
632
+ OkResponseSchema,
633
+ input
634
+ );
635
+ }
636
+ async createPayLink(options) {
637
+ return this.requestJson(
638
+ "/api/v1/pay-links",
639
+ {
640
+ method: "POST",
641
+ headers: {
642
+ "content-type": "application/json",
643
+ authorization: `Bearer ${options.accessToken}`
644
+ }
645
+ },
646
+ void 0,
647
+ CreatePayLinkResponseSchema
648
+ );
649
+ }
650
+ async resolvePayLink(token, options) {
651
+ const encodedToken = encodeURIComponent(token);
652
+ return this.requestJson(
653
+ `/api/v1/pay-links/${encodedToken}`,
654
+ {
655
+ method: "GET",
656
+ headers: {
657
+ authorization: `Bearer ${options.accessToken}`
658
+ }
659
+ },
660
+ void 0,
661
+ ResolvePayLinkResponseSchema
662
+ );
663
+ }
664
+ async requestJson(path, init, requestSchema, responseSchema, input) {
113
665
  const url = `${this.baseUrl}${path}`;
114
666
  const controller = new AbortController();
115
667
  const t = setTimeout(() => controller.abort(), this.timeoutMs);
668
+ let body = init.body;
116
669
  try {
117
- const res = await this.fetchImpl(url, { ...init, signal: controller.signal });
670
+ body = requestSchema ? JSON.stringify(requestSchema.parse(input)) : init.body;
671
+ } catch (err) {
672
+ if (err instanceof z3.ZodError) {
673
+ throw new FlurError("Invalid request payload", "INVALID_REQUEST", {
674
+ details: err.flatten()
675
+ });
676
+ }
677
+ throw err;
678
+ }
679
+ try {
680
+ const res = await this.fetchImpl(url, { ...init, body, signal: controller.signal });
118
681
  if (!res.ok) throw await mapToFlurError(res);
119
- return await res.json();
682
+ const payload = await res.json();
683
+ if (!responseSchema) return payload;
684
+ try {
685
+ return responseSchema.parse(payload);
686
+ } catch (err) {
687
+ if (err instanceof z3.ZodError) {
688
+ throw new FlurError("SDK contract validation failed", "INVALID_REQUEST", {
689
+ details: err.flatten()
690
+ });
691
+ }
692
+ throw err;
693
+ }
120
694
  } catch (err) {
121
695
  if (err instanceof Error && err.name === "AbortError") {
122
696
  throw new FlurError("Request timed out", "TIMEOUT");
@@ -128,7 +702,17 @@ var FlurClient = class {
128
702
  }
129
703
  }
130
704
  };
705
+ function getSecureRandomUuid() {
706
+ if (typeof globalThis.crypto?.randomUUID === "function") {
707
+ return globalThis.crypto.randomUUID();
708
+ }
709
+ throw new FlurError("Secure UUID generator unavailable; provide idempotencyKey", "INVALID_REQUEST");
710
+ }
131
711
  export {
132
712
  FlurClient,
133
- FlurError
713
+ FlurError,
714
+ formatAmount,
715
+ moneyMinorToNumber,
716
+ normalizeE164,
717
+ parseAmountInput
134
718
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nokinc-flur/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Flur Wallet SDK (sprint 1 scaffold)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,8 +22,12 @@
22
22
  "lint": "eslint .",
23
23
  "typecheck": "tsc -p tsconfig.json --noEmit",
24
24
  "test": "vitest run",
25
+ "test:contract": "vitest run test/e2e.health.test.ts test/e2e.send-money.test.ts",
26
+ "e2e:bootstrap": "node scripts/bootstrap-e2e-sendmoney.mjs",
25
27
  "build": "tsup src/index.ts --format esm --dts",
26
28
  "gen": "openapi-typescript openapi/flur.openapi.json -o src/gen/openapi-types.ts",
29
+ "prod:release": "pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/prod-build-publish-npm.ps1",
30
+ "prod:release:publish": "pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/prod-build-publish-npm.ps1 -Publish",
27
31
  "release:patch": "pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/release.ps1 -Bump patch",
28
32
  "release:minor": "pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/release.ps1 -Bump minor",
29
33
  "release:major": "pwsh -NoProfile -ExecutionPolicy Bypass -File ./scripts/release.ps1 -Bump major"