@nokinc-flur/sdk 0.1.2 → 0.1.4

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,15 @@ 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
+ };
25
+ firstName?: string;
26
+ lastName?: string;
9
27
  };
10
28
  type OnboardingStartResponse = {
11
29
  requestId: string;
@@ -17,7 +35,7 @@ type OnboardingRiskReason = "SIM_SWAP_RECENT" | "ROAMING" | "CARRIER_CHANGED";
17
35
  type OnboardingCompleteInput = {
18
36
  requestId: string;
19
37
  code: string;
20
- appInstanceId?: string;
38
+ appInstanceId: string;
21
39
  fingerprintHash?: string;
22
40
  };
23
41
  type OnboardingCompleteResponse = {
@@ -70,6 +88,125 @@ type PinVerifyInput = {
70
88
  userId: string;
71
89
  pin: string;
72
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 AuthorizedOptions = {
180
+ accessToken: string;
181
+ };
182
+ type CreateTransferOptions = AuthorizedOptions & {
183
+ deviceId: string;
184
+ idempotencyKey: string;
185
+ };
186
+ type ListTransactionsOptions = AuthorizedOptions & {
187
+ cursor?: string;
188
+ limit?: number;
189
+ };
190
+ type BiometricSigner = {
191
+ getOrCreateKeyPair(deviceId: string): Promise<string>;
192
+ sign(payload: string): Promise<string>;
193
+ };
194
+ type SendMoneyInput = {
195
+ recipientIdentifier: string;
196
+ money: Money;
197
+ sendAuthToken: string;
198
+ idempotencyKey?: string;
199
+ defaultCountry?: string;
200
+ };
201
+ type SendMoneyOptions = AuthorizedOptions & {
202
+ deviceId: string;
203
+ };
204
+ type AuthorizeSendWithBiometricInput = {
205
+ userId: string;
206
+ deviceId: string;
207
+ accessToken: string;
208
+ signer: BiometricSigner;
209
+ };
73
210
  declare class FlurClient {
74
211
  private readonly baseUrl;
75
212
  private readonly fetchImpl;
@@ -83,21 +220,44 @@ declare class FlurClient {
83
220
  }>;
84
221
  onboardingStart(input: OnboardingStartInput): Promise<OnboardingStartResponse>;
85
222
  onboardingComplete(input: OnboardingCompleteInput): Promise<OnboardingCompleteResponse>;
86
- registerDevice(input: RegisterDeviceInput): Promise<RegisterDeviceResponse>;
223
+ registerDevice(input: RegisterDeviceInput, options: AuthorizedOptions): Promise<RegisterDeviceResponse>;
87
224
  authRefresh(input: AuthRefreshInput): Promise<AuthRefreshResponse>;
88
- authLogout(input: AuthLogoutInput): Promise<{
225
+ authLogout(input: AuthLogoutInput, options: AuthorizedOptions): Promise<{
226
+ ok: boolean;
227
+ }>;
228
+ pinSet(input: PinSetInput, options: AuthorizedOptions): Promise<{
229
+ ok: boolean;
230
+ }>;
231
+ pinVerify(input: PinVerifyInput, options: AuthorizedOptions): Promise<{
232
+ ok: boolean;
233
+ }>;
234
+ registerSendDeviceKey(input: RegisterSendDeviceKeyInput, options: AuthorizedOptions): Promise<{
89
235
  ok: boolean;
90
236
  }>;
91
- pinSet(input: PinSetInput): Promise<{
237
+ createSendChallenge(input: SendChallengeInput, options: AuthorizedOptions): Promise<SendChallengeResponse>;
238
+ verifySendChallenge(input: SendVerifyInput, options: AuthorizedOptions): Promise<SendVerifyResponse>;
239
+ registerBiometricDeviceKey(input: {
240
+ userId: string;
241
+ deviceId: string;
242
+ accessToken: string;
243
+ signer: BiometricSigner;
244
+ }): Promise<{
92
245
  ok: boolean;
93
246
  }>;
94
- pinVerify(input: PinVerifyInput): Promise<{
247
+ authorizeSendWithBiometric(input: AuthorizeSendWithBiometricInput): Promise<SendVerifyResponse>;
248
+ resolveRecipient(input: RecipientResolveInput, options: AuthorizedOptions): Promise<RecipientResolveResponse>;
249
+ createTransfer(input: TransferInput, options: CreateTransferOptions): Promise<TransferResponse>;
250
+ sendMoney(input: SendMoneyInput, options: SendMoneyOptions): Promise<TransferResponse>;
251
+ accountSummary(options: AuthorizedOptions): Promise<AccountSummaryResponse>;
252
+ listTransactions(options: ListTransactionsOptions): Promise<TransactionsListResponse>;
253
+ transactionDetail(transactionId: string, options: AuthorizedOptions): Promise<TransactionDetailResponse>;
254
+ registerPushToken(input: PushRegisterInput, options: AuthorizedOptions): Promise<{
95
255
  ok: boolean;
96
256
  }>;
97
257
  private requestJson;
98
258
  }
99
259
 
100
- type FlurErrorCode = "NETWORK_ERROR" | "HTTP_ERROR" | "TIMEOUT" | "UNKNOWN" | "TOKEN_REPLAYED" | "SESSION_MISMATCH" | "PIN_INVALID" | "PIN_LOCKED" | "PIN_NOT_SET" | "STEP_UP_REQUIRED";
260
+ 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";
101
261
  declare class FlurError extends Error {
102
262
  readonly code: FlurErrorCode;
103
263
  readonly status?: number;
@@ -108,4 +268,4 @@ declare class FlurError extends Error {
108
268
  });
109
269
  }
110
270
 
111
- 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 };
271
+ export { type AccountActivityItem, type AccountSummaryResponse, type AuthLogoutInput, type AuthRefreshInput, type AuthRefreshResponse, type AuthorizeSendWithBiometricInput, type AuthorizedOptions, type BiometricSigner, 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 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,206 @@
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
+
1
180
  // src/errors.ts
2
181
  var backendErrorCodeSet = /* @__PURE__ */ new Set([
182
+ "UNAUTHORIZED",
183
+ "INVALID_REQUEST",
184
+ "RATE_LIMITED",
185
+ "NOT_FOUND",
186
+ "USER_NOT_FOUND",
3
187
  "TOKEN_REPLAYED",
4
188
  "SESSION_MISMATCH",
5
189
  "PIN_INVALID",
6
190
  "PIN_LOCKED",
7
191
  "PIN_NOT_SET",
8
- "STEP_UP_REQUIRED"
192
+ "STEP_UP_REQUIRED",
193
+ "DEVICE_KEY_NOT_REGISTERED",
194
+ "CHALLENGE_INVALID",
195
+ "CHALLENGE_EXPIRED",
196
+ "DEVICE_KEY_REVOKED",
197
+ "SIGNATURE_INVALID",
198
+ "INVALID_RECIPIENT",
199
+ "SEND_AUTH_INVALID",
200
+ "IDEMPOTENCY_KEY_CONFLICT",
201
+ "IDEMPOTENCY_IN_PROGRESS",
202
+ "CANNOT_SEND_TO_SELF",
203
+ "INSUFFICIENT_FUNDS"
9
204
  ]);
10
205
  var FlurError = class extends Error {
11
206
  code;
@@ -44,6 +239,101 @@ async function mapToFlurError(res) {
44
239
  return new FlurError(mappedMessage, mappedCode, { status: res.status, details });
45
240
  }
46
241
 
242
+ // src/primitives.ts
243
+ import { z as z2 } from "zod";
244
+ var CurrencyCodeSchema = z2.string().trim().length(3).transform((value) => value.toUpperCase());
245
+ var currencyFractionDigits = {
246
+ NGN: 2,
247
+ USD: 2,
248
+ EUR: 2,
249
+ GBP: 2,
250
+ CAD: 2,
251
+ JPY: 0
252
+ };
253
+ function getFractionDigits(currency) {
254
+ return currencyFractionDigits[currency] ?? 2;
255
+ }
256
+ function parseAmountInput(value, currency) {
257
+ const normalizedCurrency = CurrencyCodeSchema.parse(currency);
258
+ const raw = value.trim();
259
+ if (!/^\d+(\.\d+)?$/.test(raw)) {
260
+ throw new FlurError("Invalid amount format", "INVALID_REQUEST");
261
+ }
262
+ const [whole, fraction = ""] = raw.split(".");
263
+ const fractionDigits = getFractionDigits(normalizedCurrency);
264
+ if (fraction.length > fractionDigits) {
265
+ throw new FlurError("Too many decimal places for currency", "INVALID_REQUEST");
266
+ }
267
+ const paddedFraction = fraction.padEnd(fractionDigits, "0");
268
+ const minorAsString = `${whole}${paddedFraction}`.replace(/^0+(\d)/, "$1") || "0";
269
+ const amountMinor = BigInt(minorAsString);
270
+ if (amountMinor < 0n) {
271
+ throw new FlurError("Amount cannot be negative", "INVALID_REQUEST");
272
+ }
273
+ return {
274
+ amountMinor,
275
+ currency: normalizedCurrency
276
+ };
277
+ }
278
+ function formatAmount(amountMinor, currency) {
279
+ const normalizedCurrency = CurrencyCodeSchema.parse(currency);
280
+ if (amountMinor < 0n) {
281
+ throw new FlurError("Amount cannot be negative", "INVALID_REQUEST");
282
+ }
283
+ const fractionDigits = getFractionDigits(normalizedCurrency);
284
+ if (fractionDigits === 0) {
285
+ return amountMinor.toString();
286
+ }
287
+ const divisor = 10n ** BigInt(fractionDigits);
288
+ const whole = amountMinor / divisor;
289
+ const fraction = (amountMinor % divisor).toString().padStart(fractionDigits, "0");
290
+ return `${whole.toString()}.${fraction}`;
291
+ }
292
+ var defaultCountryDialingCode = {
293
+ NG: "234",
294
+ US: "1",
295
+ CA: "1",
296
+ GB: "44"
297
+ };
298
+ function normalizeE164(input, defaultCountry) {
299
+ const trimmed = input.trim();
300
+ if (trimmed.startsWith("+")) {
301
+ if (!E164Regex.test(trimmed)) {
302
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
303
+ }
304
+ return trimmed;
305
+ }
306
+ const digits = trimmed.replace(/\D/g, "");
307
+ if (digits.length === 0) {
308
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
309
+ }
310
+ if (digits.startsWith("00")) {
311
+ const candidate2 = `+${digits.slice(2)}`;
312
+ if (!E164Regex.test(candidate2)) {
313
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
314
+ }
315
+ return candidate2;
316
+ }
317
+ const normalizedCountry = defaultCountry?.trim().toUpperCase();
318
+ const countryCode = normalizedCountry ? defaultCountryDialingCode[normalizedCountry] : void 0;
319
+ if (!countryCode) {
320
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
321
+ }
322
+ const localDigits = digits.startsWith("0") ? digits.slice(1) : digits;
323
+ const candidate = `+${countryCode}${localDigits}`;
324
+ if (!E164Regex.test(candidate)) {
325
+ throw new FlurError("Invalid phone number", "INVALID_REQUEST");
326
+ }
327
+ return candidate;
328
+ }
329
+ function moneyMinorToNumber(amountMinor) {
330
+ const asNumber = Number(amountMinor);
331
+ if (!Number.isSafeInteger(asNumber)) {
332
+ throw new FlurError("Amount exceeds safe integer range", "INVALID_REQUEST");
333
+ }
334
+ return asNumber;
335
+ }
336
+
47
337
  // src/client.ts
48
338
  var FlurClient = class {
49
339
  baseUrl;
@@ -55,68 +345,315 @@ var FlurClient = class {
55
345
  this.timeoutMs = opts.timeoutMs ?? 1e4;
56
346
  }
57
347
  async health() {
58
- return this.requestJson("/health", { method: "GET" });
348
+ return this.requestJson("/health", { method: "GET" }, void 0, HealthResponseSchema);
59
349
  }
60
350
  async welcome() {
61
- return this.requestJson("/welcome", { method: "GET" });
351
+ return this.requestJson("/welcome", { method: "GET" }, void 0, WelcomeResponseSchema);
62
352
  }
63
353
  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
- });
354
+ return this.requestJson(
355
+ "/v1/onboarding/start",
356
+ {
357
+ method: "POST",
358
+ headers: { "content-type": "application/json" }
359
+ },
360
+ OnboardingStartRequestSchema,
361
+ OnboardingStartResponseSchema,
362
+ input
363
+ );
69
364
  }
70
365
  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
- });
366
+ return this.requestJson(
367
+ "/v1/onboarding/complete",
368
+ {
369
+ method: "POST",
370
+ headers: { "content-type": "application/json" }
371
+ },
372
+ OnboardingCompleteRequestSchema,
373
+ OnboardingCompleteResponseSchema,
374
+ input
375
+ );
376
+ }
377
+ async registerDevice(input, options) {
378
+ return this.requestJson(
379
+ "/v1/devices/register",
380
+ {
381
+ method: "POST",
382
+ headers: {
383
+ "content-type": "application/json",
384
+ authorization: `Bearer ${options.accessToken}`
385
+ }
386
+ },
387
+ RegisterDeviceRequestSchema,
388
+ RegisterDeviceResponseSchema,
389
+ input
390
+ );
83
391
  }
84
392
  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) {
393
+ return this.requestJson(
394
+ "/v1/auth/refresh",
395
+ {
396
+ method: "POST",
397
+ headers: { "content-type": "application/json" }
398
+ },
399
+ AuthRefreshRequestSchema,
400
+ AuthRefreshResponseSchema,
401
+ input
402
+ );
403
+ }
404
+ async authLogout(input, options) {
405
+ return this.requestJson(
406
+ "/v1/auth/logout",
407
+ {
408
+ method: "POST",
409
+ headers: {
410
+ "content-type": "application/json",
411
+ authorization: `Bearer ${options.accessToken}`
412
+ }
413
+ },
414
+ AuthLogoutRequestSchema,
415
+ OkResponseSchema,
416
+ input
417
+ );
418
+ }
419
+ async pinSet(input, options) {
420
+ return this.requestJson(
421
+ "/v1/auth/pin/set",
422
+ {
423
+ method: "POST",
424
+ headers: {
425
+ "content-type": "application/json",
426
+ authorization: `Bearer ${options.accessToken}`
427
+ }
428
+ },
429
+ PinSetRequestSchema,
430
+ OkResponseSchema,
431
+ input
432
+ );
433
+ }
434
+ async pinVerify(input, options) {
435
+ return this.requestJson(
436
+ "/v1/auth/pin/verify",
437
+ {
438
+ method: "POST",
439
+ headers: {
440
+ "content-type": "application/json",
441
+ authorization: `Bearer ${options.accessToken}`
442
+ }
443
+ },
444
+ PinVerifyRequestSchema,
445
+ OkResponseSchema,
446
+ input
447
+ );
448
+ }
449
+ async registerSendDeviceKey(input, options) {
450
+ return this.requestJson(
451
+ "/api/v1/auth/send/device-key",
452
+ {
453
+ method: "POST",
454
+ headers: {
455
+ "content-type": "application/json",
456
+ authorization: `Bearer ${options.accessToken}`
457
+ }
458
+ },
459
+ RegisterSendDeviceKeyRequestSchema,
460
+ OkResponseSchema,
461
+ input
462
+ );
463
+ }
464
+ async createSendChallenge(input, options) {
465
+ return this.requestJson(
466
+ "/api/v1/auth/send/challenge",
467
+ {
468
+ method: "POST",
469
+ headers: {
470
+ "content-type": "application/json",
471
+ authorization: `Bearer ${options.accessToken}`
472
+ }
473
+ },
474
+ SendChallengeRequestSchema,
475
+ SendChallengeResponseSchema,
476
+ input
477
+ );
478
+ }
479
+ async verifySendChallenge(input, options) {
480
+ return this.requestJson(
481
+ "/api/v1/auth/send/verify",
482
+ {
483
+ method: "POST",
484
+ headers: {
485
+ "content-type": "application/json",
486
+ authorization: `Bearer ${options.accessToken}`
487
+ }
488
+ },
489
+ SendVerifyRequestSchema,
490
+ SendVerifyResponseSchema,
491
+ input
492
+ );
493
+ }
494
+ async registerBiometricDeviceKey(input) {
495
+ const publicKey = await input.signer.getOrCreateKeyPair(input.deviceId);
496
+ return this.registerSendDeviceKey({
497
+ userId: input.userId,
498
+ deviceId: input.deviceId,
499
+ publicKey
500
+ }, { accessToken: input.accessToken });
501
+ }
502
+ async authorizeSendWithBiometric(input) {
503
+ const challenge = await this.createSendChallenge({
504
+ userId: input.userId,
505
+ deviceId: input.deviceId
506
+ }, { accessToken: input.accessToken });
507
+ const signature = await input.signer.sign(challenge.nonce);
508
+ return this.verifySendChallenge({
509
+ userId: input.userId,
510
+ deviceId: input.deviceId,
511
+ challengeId: challenge.challengeId,
512
+ signature
513
+ }, { accessToken: input.accessToken });
514
+ }
515
+ async resolveRecipient(input, options) {
516
+ return this.requestJson(
517
+ "/api/v1/recipients/resolve",
518
+ {
519
+ method: "POST",
520
+ headers: {
521
+ "content-type": "application/json",
522
+ authorization: `Bearer ${options.accessToken}`
523
+ }
524
+ },
525
+ ResolveRecipientRequestSchema,
526
+ ResolveRecipientResponseSchema,
527
+ input
528
+ );
529
+ }
530
+ async createTransfer(input, options) {
531
+ return this.requestJson(
532
+ "/api/v1/transfers",
533
+ {
534
+ method: "POST",
535
+ headers: {
536
+ "content-type": "application/json",
537
+ authorization: `Bearer ${options.accessToken}`,
538
+ "x-device-id": options.deviceId,
539
+ "x-idempotency-key": options.idempotencyKey
540
+ }
541
+ },
542
+ CreateTransferRequestSchema,
543
+ TransferResponseSchema,
544
+ input
545
+ );
546
+ }
547
+ async sendMoney(input, options) {
548
+ const normalizedRecipient = E164Regex.test(input.recipientIdentifier) ? input.recipientIdentifier : normalizeE164(input.recipientIdentifier, input.defaultCountry);
549
+ const idempotencyKey = input.idempotencyKey ?? getSecureRandomUuid();
550
+ return this.createTransfer(
551
+ {
552
+ recipientIdentifier: normalizedRecipient,
553
+ amountMinor: moneyMinorToNumber(input.money.amountMinor),
554
+ currency: input.money.currency,
555
+ sendAuthToken: input.sendAuthToken
556
+ },
557
+ {
558
+ accessToken: options.accessToken,
559
+ deviceId: options.deviceId,
560
+ idempotencyKey
561
+ }
562
+ );
563
+ }
564
+ async accountSummary(options) {
565
+ return this.requestJson(
566
+ "/api/v1/account/summary",
567
+ {
568
+ method: "GET",
569
+ headers: {
570
+ authorization: `Bearer ${options.accessToken}`
571
+ }
572
+ },
573
+ void 0,
574
+ AccountSummaryResponseSchema
575
+ );
576
+ }
577
+ async listTransactions(options) {
578
+ const query = new URLSearchParams();
579
+ if (typeof options.cursor === "string" && options.cursor.trim().length > 0) {
580
+ query.set("cursor", options.cursor);
581
+ }
582
+ if (typeof options.limit === "number") {
583
+ query.set("limit", String(options.limit));
584
+ }
585
+ const path = query.size > 0 ? `/api/v1/transactions?${query.toString()}` : "/api/v1/transactions";
586
+ return this.requestJson(
587
+ path,
588
+ {
589
+ method: "GET",
590
+ headers: {
591
+ authorization: `Bearer ${options.accessToken}`
592
+ }
593
+ },
594
+ void 0,
595
+ TransactionsListResponseSchema
596
+ );
597
+ }
598
+ async transactionDetail(transactionId, options) {
599
+ const encodedId = encodeURIComponent(transactionId);
600
+ return this.requestJson(
601
+ `/api/v1/transactions/${encodedId}`,
602
+ {
603
+ method: "GET",
604
+ headers: {
605
+ authorization: `Bearer ${options.accessToken}`
606
+ }
607
+ },
608
+ void 0,
609
+ TransactionDetailResponseSchema
610
+ );
611
+ }
612
+ async registerPushToken(input, options) {
613
+ return this.requestJson(
614
+ "/api/v1/push/register",
615
+ {
616
+ method: "POST",
617
+ headers: {
618
+ "content-type": "application/json",
619
+ authorization: `Bearer ${options.accessToken}`
620
+ }
621
+ },
622
+ PushRegisterRequestSchema,
623
+ OkResponseSchema,
624
+ input
625
+ );
626
+ }
627
+ async requestJson(path, init, requestSchema, responseSchema, input) {
113
628
  const url = `${this.baseUrl}${path}`;
114
629
  const controller = new AbortController();
115
630
  const t = setTimeout(() => controller.abort(), this.timeoutMs);
631
+ let body = init.body;
632
+ try {
633
+ body = requestSchema ? JSON.stringify(requestSchema.parse(input)) : init.body;
634
+ } catch (err) {
635
+ if (err instanceof z3.ZodError) {
636
+ throw new FlurError("Invalid request payload", "INVALID_REQUEST", {
637
+ details: err.flatten()
638
+ });
639
+ }
640
+ throw err;
641
+ }
116
642
  try {
117
- const res = await this.fetchImpl(url, { ...init, signal: controller.signal });
643
+ const res = await this.fetchImpl(url, { ...init, body, signal: controller.signal });
118
644
  if (!res.ok) throw await mapToFlurError(res);
119
- return await res.json();
645
+ const payload = await res.json();
646
+ if (!responseSchema) return payload;
647
+ try {
648
+ return responseSchema.parse(payload);
649
+ } catch (err) {
650
+ if (err instanceof z3.ZodError) {
651
+ throw new FlurError("SDK contract validation failed", "INVALID_REQUEST", {
652
+ details: err.flatten()
653
+ });
654
+ }
655
+ throw err;
656
+ }
120
657
  } catch (err) {
121
658
  if (err instanceof Error && err.name === "AbortError") {
122
659
  throw new FlurError("Request timed out", "TIMEOUT");
@@ -128,7 +665,17 @@ var FlurClient = class {
128
665
  }
129
666
  }
130
667
  };
668
+ function getSecureRandomUuid() {
669
+ if (typeof globalThis.crypto?.randomUUID === "function") {
670
+ return globalThis.crypto.randomUUID();
671
+ }
672
+ throw new FlurError("Secure UUID generator unavailable; provide idempotencyKey", "INVALID_REQUEST");
673
+ }
131
674
  export {
132
675
  FlurClient,
133
- FlurError
676
+ FlurError,
677
+ formatAmount,
678
+ moneyMinorToNumber,
679
+ normalizeE164,
680
+ parseAmountInput
134
681
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nokinc-flur/sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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"