@focura/auth-core 1.0.0 → 1.1.1
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 +407 -25
- package/dist/index.d.ts +182 -93
- package/dist/index.js +410 -131
- package/dist/index.js.map +1 -1
- package/package.json +23 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @focura/auth-core
|
|
2
2
|
|
|
3
|
-
Production-ready authentication
|
|
3
|
+
Production-ready authentication engine for Node.js backends.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -19,19 +19,19 @@ Production-ready authentication core for Express.js backends.
|
|
|
19
19
|
## Installation
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm install @focura/auth-core
|
|
23
|
-
npm install -D @types/express
|
|
22
|
+
npm install @focura/auth-core ioredis
|
|
24
23
|
```
|
|
25
24
|
|
|
26
25
|
## Quick Start
|
|
27
26
|
|
|
28
27
|
```typescript
|
|
29
|
-
import {
|
|
28
|
+
import { AuthService } from "@focura/auth-core";
|
|
30
29
|
import Redis from "ioredis";
|
|
30
|
+
import fs from "fs";
|
|
31
31
|
|
|
32
32
|
const redis = new Redis(process.env.REDIS_URL!);
|
|
33
33
|
|
|
34
|
-
const auth = new
|
|
34
|
+
const auth = new AuthService({
|
|
35
35
|
redis,
|
|
36
36
|
userStore: {
|
|
37
37
|
findById: (id) => prisma.user.findUnique({ where: { id } }),
|
|
@@ -39,34 +39,147 @@ const auth = new MiddlewareFactory({
|
|
|
39
39
|
update: (id, data) => prisma.user.update({ where: { id }, data }),
|
|
40
40
|
updateEmailVerified: (id, date) => prisma.user.update({ where: { id }, data: { emailVerified: date } }),
|
|
41
41
|
},
|
|
42
|
-
hmacSecret: process.env.
|
|
42
|
+
hmacSecret: process.env.AUTH_SECRET!,
|
|
43
43
|
jwt: {
|
|
44
44
|
privateKey: fs.readFileSync("keys/private.pem", "utf8"),
|
|
45
45
|
publicKey: fs.readFileSync("keys/public.pem", "utf8"),
|
|
46
46
|
},
|
|
47
|
-
cache: {
|
|
48
|
-
get: (key) => redis.get(key).then(JSON.parse),
|
|
49
|
-
set: (key, val, ttl) => redis.setex(key, ttl!, JSON.stringify(val)),
|
|
50
|
-
delete: (key) => redis.del(key),
|
|
51
|
-
},
|
|
52
|
-
auditLogger: {
|
|
53
|
-
log: (event, data) => prisma.auditLog.create({ data: { event, ...data } }),
|
|
54
|
-
},
|
|
55
47
|
});
|
|
48
|
+
```
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
app.post("/api/v1/auth/exchange", auth.createExchangeHandler());
|
|
59
|
-
app.post("/api/v1/auth/refresh", auth.createRefreshHandler());
|
|
60
|
-
app.post("/api/v1/auth/logout", auth.createLogoutHandler());
|
|
50
|
+
## High-Level API
|
|
61
51
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
52
|
+
### Token Exchange
|
|
53
|
+
|
|
54
|
+
Exchange a HMAC-signed proof for JWT tokens:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const tokens = await auth.exchange({
|
|
58
|
+
userId: user.id,
|
|
59
|
+
email: user.email,
|
|
60
|
+
role: user.role,
|
|
61
|
+
sessionId: crypto.randomUUID(),
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
signature: hmacSignature,
|
|
65
64
|
});
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// tokens.accessToken, tokens.refreshToken, tokens.sseToken, tokens.sessionId
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Verify Token
|
|
70
|
+
|
|
71
|
+
Verify an access token and load the user:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const { user, payload } = await auth.verifyToken({
|
|
75
|
+
token: accessToken,
|
|
76
|
+
ipAddress: "192.168.1.1",
|
|
77
|
+
userAgent: "Mozilla/5.0...",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// payload.id, payload.email, payload.role, payload.jti, payload.sessionId
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Refresh Tokens
|
|
84
|
+
|
|
85
|
+
Rotate refresh tokens atomically:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
const newTokens = await auth.refresh({
|
|
89
|
+
refreshToken: currentRefreshToken,
|
|
69
90
|
});
|
|
91
|
+
|
|
92
|
+
// newTokens.accessToken, newTokens.refreshToken, newTokens.sseToken
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Logout
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// Single session
|
|
99
|
+
await auth.logout({
|
|
100
|
+
userId: user.id,
|
|
101
|
+
sessionId: sessionId,
|
|
102
|
+
accessTokenJti: jti,
|
|
103
|
+
accessToken: token,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// All sessions
|
|
107
|
+
await auth.logout({
|
|
108
|
+
userId: user.id,
|
|
109
|
+
sessionId: sessionId,
|
|
110
|
+
logoutAll: true,
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Two-Factor Authentication
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// Setup — generate secret + URI for QR code
|
|
118
|
+
const { secret, uri } = auth.generateTwoFactor();
|
|
119
|
+
// Display uri as QR code to user, store secret in database
|
|
120
|
+
|
|
121
|
+
// Verify TOTP code
|
|
122
|
+
const valid = await auth.verifyTwoFactor({ token: "123456", secret });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Session Management
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// List active sessions
|
|
129
|
+
const sessions = await auth.getActiveSessions(userId);
|
|
130
|
+
|
|
131
|
+
// Revoke a specific session
|
|
132
|
+
await auth.revokeSession(userId, sessionId);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Account Lockout
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// Record failed login attempt
|
|
139
|
+
const result = await auth.recordLoginFailure(email);
|
|
140
|
+
if (result.locked) {
|
|
141
|
+
console.log(`Account locked until ${result.unlocksAt}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Clear failures on successful login
|
|
145
|
+
await auth.clearLoginFailures(email);
|
|
146
|
+
|
|
147
|
+
// Check if account is locked
|
|
148
|
+
const status = await auth.isAccountLocked(email);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Audit Logging
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
auth.log("WORKSPACE_CREATED", { userId, workspaceId });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Express Integration
|
|
158
|
+
|
|
159
|
+
For Express.js applications, use `MiddlewareFactory` for HTTP middleware:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { MiddlewareFactory } from "@focura/auth-core";
|
|
163
|
+
|
|
164
|
+
const factory = new MiddlewareFactory(config);
|
|
165
|
+
|
|
166
|
+
// Auth routes
|
|
167
|
+
app.post("/api/v1/auth/exchange", factory.createExchangeHandler());
|
|
168
|
+
app.post("/api/v1/auth/refresh", factory.createRefreshHandler());
|
|
169
|
+
app.post("/api/v1/auth/logout", factory.createLogoutHandler());
|
|
170
|
+
|
|
171
|
+
// Protected routes
|
|
172
|
+
app.get("/api/v1/profile",
|
|
173
|
+
factory.createAuthenticateMiddleware(),
|
|
174
|
+
(req, res) => { res.json({ user: req.user }); }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Role-based access
|
|
178
|
+
app.get("/api/v1/admin",
|
|
179
|
+
factory.createAuthenticateMiddleware(),
|
|
180
|
+
factory.createAuthorizeMiddleware("ADMIN"),
|
|
181
|
+
(req, res) => { res.json({ admin: true }); }
|
|
182
|
+
);
|
|
70
183
|
```
|
|
71
184
|
|
|
72
185
|
## Adapters
|
|
@@ -79,8 +192,7 @@ Works with ioredis or any compatible client:
|
|
|
79
192
|
import Redis from "ioredis";
|
|
80
193
|
const redis = new Redis(process.env.REDIS_URL);
|
|
81
194
|
|
|
82
|
-
|
|
83
|
-
const auth = new MiddlewareFactory({ redis, ... });
|
|
195
|
+
const auth = new AuthService({ redis, ... });
|
|
84
196
|
```
|
|
85
197
|
|
|
86
198
|
### UserStore
|
|
@@ -133,6 +245,276 @@ openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
|
|
|
133
245
|
openssl rsa -in private.pem -pubout -out public.pem
|
|
134
246
|
```
|
|
135
247
|
|
|
248
|
+
## API Hierarchy
|
|
249
|
+
|
|
250
|
+
### Primary API (Recommended)
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { AuthService } from "@focura/auth-core";
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The `AuthService` class provides high-level, framework-agnostic authentication operations.
|
|
257
|
+
|
|
258
|
+
### Extension API
|
|
259
|
+
|
|
260
|
+
Interfaces for integrating your infrastructure:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
import type { UserStore, RedisAdapter, CacheAdapter, AuditLogger } from "@focura/auth-core";
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Advanced API
|
|
267
|
+
|
|
268
|
+
Low-level classes for custom integrations:
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
import { TokenManager, SessionManager, TotpManager, AccountLockout } from "@focura/auth-core";
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
These are available but not required for normal application development.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Default Configuration
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
import { DEFAULTS } from "@focura/auth-core";
|
|
282
|
+
|
|
283
|
+
// All default values:
|
|
284
|
+
DEFAULTS.keyPrefix; // "focura:"
|
|
285
|
+
DEFAULTS.issuer; // "focura-app"
|
|
286
|
+
DEFAULTS.audience; // "focura-backend"
|
|
287
|
+
DEFAULTS.accessTokenExpiry; // "15m"
|
|
288
|
+
DEFAULTS.refreshTokenExpiry; // "7d"
|
|
289
|
+
DEFAULTS.sseTokenExpiry; // "30s"
|
|
290
|
+
DEFAULTS.maxConcurrentSessions; // 5
|
|
291
|
+
DEFAULTS.lockoutMaxFailures; // 10
|
|
292
|
+
DEFAULTS.lockoutSeconds; // 900 (15 minutes)
|
|
293
|
+
DEFAULTS.lockoutWindowSeconds; // 3600 (1 hour)
|
|
294
|
+
DEFAULTS.inactivityTimeout; // 604800 (7 days)
|
|
295
|
+
DEFAULTS.absoluteTimeout; // 604800 (7 days)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### resolveConfig
|
|
299
|
+
|
|
300
|
+
Merges your config with defaults. Useful for advanced customization:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
import { resolveConfig } from "@focura/auth-core";
|
|
304
|
+
|
|
305
|
+
const resolved = resolveConfig({
|
|
306
|
+
redis,
|
|
307
|
+
userStore,
|
|
308
|
+
hmacSecret: "...",
|
|
309
|
+
jwt: { privateKey: "...", publicKey: "..." },
|
|
310
|
+
lockout: { maxFailures: 5 }, // override default 10
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// resolved contains all values with defaults applied
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Error Handling
|
|
319
|
+
|
|
320
|
+
All errors include `code` and `statusCode` for API responses:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import {
|
|
324
|
+
UnauthorizedError, // 401, code: UNAUTHORIZED
|
|
325
|
+
TokenExpiredError, // 401, code: TOKEN_EXPIRED
|
|
326
|
+
InvalidTokenError, // 401, code: INVALID_TOKEN
|
|
327
|
+
TokenRevokedError, // 401, code: TOKEN_REVOKED
|
|
328
|
+
SessionHijackError, // 401, code: SESSION_HIJACK_DETECTED
|
|
329
|
+
EmailNotVerifiedError, // 403, code: EMAIL_NOT_VERIFIED
|
|
330
|
+
AccountBannedError, // 403, code: ACCOUNT_BANNED
|
|
331
|
+
ForbiddenError, // 403, code: FORBIDDEN
|
|
332
|
+
BadRequestError, // 400, code: BAD_REQUEST
|
|
333
|
+
ValidationError, // 400, code: VALIDATION_ERROR
|
|
334
|
+
} from "@focura/auth-core";
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Example: Catching errors
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
try {
|
|
341
|
+
const { user, payload } = await auth.verifyToken({ token, ipAddress, userAgent });
|
|
342
|
+
} catch (e) {
|
|
343
|
+
if (e instanceof TokenExpiredError) {
|
|
344
|
+
return res.status(401).json({ error: "Token expired", code: e.code });
|
|
345
|
+
}
|
|
346
|
+
if (e instanceof InvalidTokenError) {
|
|
347
|
+
return res.status(401).json({ error: "Invalid token", code: e.code });
|
|
348
|
+
}
|
|
349
|
+
if (e instanceof SessionHijackError) {
|
|
350
|
+
return res.status(401).json({ error: "Session hijack detected", code: e.code });
|
|
351
|
+
}
|
|
352
|
+
if (e instanceof AccountBannedError) {
|
|
353
|
+
return res.status(403).json({ error: "Account banned", reason: e.message });
|
|
354
|
+
}
|
|
355
|
+
if (e instanceof ValidationError) {
|
|
356
|
+
return res.status(400).json({ error: e.message, details: e.details });
|
|
357
|
+
}
|
|
358
|
+
return res.status(500).json({ error: "Internal server error" });
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### defaultErrors Factory
|
|
363
|
+
|
|
364
|
+
Use as base for custom error classes:
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
import { defaultErrors } from "@focura/auth-core";
|
|
368
|
+
|
|
369
|
+
const customErrors = {
|
|
370
|
+
...defaultErrors,
|
|
371
|
+
UnauthorizedError: (msg) => new MyCustomUnauthorizedError(msg),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const auth = new AuthService({ ..., errors: customErrors });
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Input & Result Types
|
|
380
|
+
|
|
381
|
+
All types are exported for TypeScript autocompletion:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
import type {
|
|
385
|
+
// Token exchange
|
|
386
|
+
ExchangeInput, // { userId, email, role, sessionId, timestamp, signature }
|
|
387
|
+
ExchangeResult, // { accessToken, refreshToken, sseToken, sessionId }
|
|
388
|
+
|
|
389
|
+
// Token verification
|
|
390
|
+
VerifyTokenInput, // { token, ipAddress?, userAgent? }
|
|
391
|
+
VerifyTokenResult, // { user, payload }
|
|
392
|
+
|
|
393
|
+
// Refresh
|
|
394
|
+
RefreshInput, // { refreshToken }
|
|
395
|
+
AuthRefreshResult, // { accessToken, refreshToken, sseToken }
|
|
396
|
+
|
|
397
|
+
// Logout
|
|
398
|
+
LogoutInput, // { userId, sessionId, accessTokenJti?, accessToken?, logoutAll? }
|
|
399
|
+
|
|
400
|
+
// 2FA
|
|
401
|
+
TwoFactorSetupResult, // { secret, uri }
|
|
402
|
+
TwoFactorVerifyInput, // { token, secret }
|
|
403
|
+
|
|
404
|
+
// Tokens
|
|
405
|
+
TokenPayload, // { id, email, role, type, version, jti, sessionId? }
|
|
406
|
+
TokenPair, // { accessToken, refreshToken, accessTokenExpiry, refreshTokenExpiry }
|
|
407
|
+
|
|
408
|
+
// Config sub-types
|
|
409
|
+
TokenConfig, // { privateKey, publicKey, issuer?, audience?, accessTokenExpiry?, ... }
|
|
410
|
+
SessionConfig, // { inactivityTimeout?, absoluteTimeout?, maxConcurrent?, metadataTtl? }
|
|
411
|
+
LockoutConfig, // { maxFailures?, lockoutSeconds?, windowSeconds? }
|
|
412
|
+
|
|
413
|
+
// Adapters
|
|
414
|
+
RedisAdapter,
|
|
415
|
+
RedisPipeline,
|
|
416
|
+
UserStore,
|
|
417
|
+
User,
|
|
418
|
+
CacheAdapter,
|
|
419
|
+
AuditLogger,
|
|
420
|
+
ObservabilitySink,
|
|
421
|
+
ErrorFactory,
|
|
422
|
+
ZodSchema,
|
|
423
|
+
|
|
424
|
+
// Session
|
|
425
|
+
SessionMetadata, // { deviceId, ipAddress, userAgent, location?, lastActivity }
|
|
426
|
+
SessionLifecycle, // { recordCreation, invalidate, isTracked, isInactive }
|
|
427
|
+
DeviceFingerprint, // { userAgent, acceptLanguage, acceptEncoding, ipAddress }
|
|
428
|
+
AuthRequest, // Extended request with user property
|
|
429
|
+
} from "@focura/auth-core";
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## MiddlewareFactory Methods
|
|
435
|
+
|
|
436
|
+
All HTTP middleware and route handlers:
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
import { MiddlewareFactory } from "@focura/auth-core";
|
|
440
|
+
|
|
441
|
+
const factory = new MiddlewareFactory(config);
|
|
442
|
+
|
|
443
|
+
// Route handlers
|
|
444
|
+
factory.createExchangeHandler(); // POST /api/v1/auth/exchange
|
|
445
|
+
factory.createRefreshHandler(); // POST /api/v1/auth/refresh
|
|
446
|
+
factory.createLogoutHandler(); // POST /api/v1/auth/logout
|
|
447
|
+
|
|
448
|
+
// Auth middleware
|
|
449
|
+
factory.createAuthenticateMiddleware(); // Verifies JWT, attaches req.user
|
|
450
|
+
factory.createAuthorizeMiddleware("ADMIN", "MOD"); // Role-based access
|
|
451
|
+
|
|
452
|
+
// Security middleware
|
|
453
|
+
factory.createCsrfMiddleware(); // CSRF token validation
|
|
454
|
+
factory.createRateLimitMiddleware(); // Sliding window rate limiting
|
|
455
|
+
factory.createSessionTimeoutMiddleware(); // Inactivity + absolute timeout
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Utility Functions
|
|
461
|
+
|
|
462
|
+
Low-level helpers available for advanced use:
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
import {
|
|
466
|
+
generateDeviceFingerprint, // (req) => DeviceFingerprint
|
|
467
|
+
getClientIp, // (req) => string
|
|
468
|
+
isPrivateIp, // (ip) => boolean — checks 10.x, 172.16-31.x, 192.168.x, 127.x
|
|
469
|
+
normalizeUserAgent, // (ua) => string — truncates long user agents
|
|
470
|
+
createSessionMetadata, // (fingerprint, sessionId) => SessionMetadata
|
|
471
|
+
validateSessionBinding, // (metadata, fingerprint, options) => { bound, reason? }
|
|
472
|
+
looksLikeServerToServerRequest, // (req) => boolean
|
|
473
|
+
looksLikeServerToServerUA, // (ua) => boolean
|
|
474
|
+
} from "@focura/auth-core";
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Audit Event Types
|
|
480
|
+
|
|
481
|
+
50+ event types with severity levels:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { AUDIT_SEVERITY } from "@focura/auth-core";
|
|
485
|
+
|
|
486
|
+
// AUDIT_SEVERITY maps event types to severity:
|
|
487
|
+
// "info" — LOGIN_SUCCESS, LOGOUT, TOKEN_REFRESHED, SESSION_BOUND, etc.
|
|
488
|
+
// "warn" — LOGIN_FAILED, DEVICE_MISMATCH, RATE_LIMIT_EXCEEDED, etc.
|
|
489
|
+
// "critical" — TOKEN_REPLAY_DETECTED, ACCOUNT_LOCKED, MALWARE_DETECTED, etc.
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
import type { AuditEventType, AuditSeverity } from "@focura/auth-core";
|
|
494
|
+
|
|
495
|
+
// AuditEventType — union of all 50+ event strings
|
|
496
|
+
// AuditSeverity — "info" | "warn" | "critical"
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## Zod Schemas
|
|
502
|
+
|
|
503
|
+
Validation schemas used internally (available for external use):
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
import { exchangeSchema, refreshSchema, logoutSchema } from "@focura/auth-core";
|
|
507
|
+
|
|
508
|
+
// exchangeSchema validates ExchangeInput
|
|
509
|
+
// refreshSchema validates RefreshInput
|
|
510
|
+
// logoutSchema validates LogoutInput
|
|
511
|
+
|
|
512
|
+
const result = exchangeSchema.safeParse(data);
|
|
513
|
+
if (!result.success) {
|
|
514
|
+
console.log(result.error.issues);
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
136
518
|
## License
|
|
137
519
|
|
|
138
520
|
MIT
|