@morseai/sdk 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MORSE Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,592 @@
1
+ # MORSE SDK
2
+
3
+ [![npm version](https://badge.fury.io/js/%40morseai%2Fsdk.svg)](https://badge.fury.io/js/%40morseai%2Fsdk)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue.svg)](https://www.typescriptlang.org/)
6
+
7
+ TypeScript SDK for creating and accessing encrypted signals in the MORSE platform.
8
+
9
+ **Version:** 0.1.0-beta.10 (Beta Release)
10
+
11
+ > ⚠️ **Beta Notice**: This is a beta release. The API is stable but may have minor changes before the 1.0.0 release. Please report any issues you encounter.
12
+
13
+ ## Features
14
+
15
+ - ✅ Full TypeScript support with autocomplete
16
+ - ✅ Automatic encryption/decryption (AES-GCM and X25519)
17
+ - ✅ Wallet authentication (browser, private key, custom)
18
+ - ✅ X25519 + XChaCha20-Poly1305 for shared signals
19
+ - ✅ Rate limiting (configurable)
20
+ - ✅ Request retry logic
21
+ - ✅ Comprehensive error handling
22
+ - ✅ Input validation
23
+ - ✅ Security-first design
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @morseai/sdk
29
+ # or
30
+ pnpm add @morseai/sdk
31
+ # or
32
+ yarn add @morseai/sdk
33
+ ```
34
+
35
+ ## Prerequisites
36
+
37
+ This SDK requires `ethers` v6+ as a peer dependency for wallet signature functionality.
38
+
39
+ ```bash
40
+ npm install ethers
41
+ ```
42
+
43
+ ## Authentication
44
+
45
+ MORSE uses **wallet signature authentication** - you sign a message with your Ethereum wallet to authenticate requests. See [AUTHENTICATION.md](./docs/AUTHENTICATION.md) for complete details.
46
+
47
+ **Quick summary:**
48
+ - ✅ API Key (required for SDK usage)
49
+ - ✅ Wallet signature (for frontend/operations)
50
+ - ✅ Private key (for backend/server)
51
+ - ✅ Custom implementation
52
+
53
+ ## Quick Start
54
+
55
+ ```typescript
56
+ import { MorseSDK, createWalletFromPrivateKey, Expiration } from "@morseai/sdk";
57
+
58
+ // Initialize SDK (only apiKey is required)
59
+ const sdk = new MorseSDK({
60
+ apiKey: process.env.MORSE_API_KEY!,
61
+ });
62
+
63
+ // Create wallet from private key
64
+ const wallet = createWalletFromPrivateKey({
65
+ privateKey: process.env.PRIVATE_KEY!,
66
+ });
67
+
68
+ // Create a shared signal (X25519 encryption)
69
+ const result = await sdk.createSignalEncrypted(wallet, {
70
+ walletTarget: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
71
+ mode: "shared_wallet",
72
+ message: "Secret message! 🔐",
73
+ expiresIn: Expiration.ONE_DAY, // or "24h", "7d", etc.
74
+ });
75
+
76
+ console.log("Signal ID:", result.signalId);
77
+ console.log("Shareable link:", result.shareableLink);
78
+
79
+ // Open and decrypt signal
80
+ const decrypted = await sdk.openSignalDecrypted(wallet, result.signalId);
81
+ console.log("Message:", decrypted.message);
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ ### Basic Setup
87
+
88
+ ```typescript
89
+ import { MorseSDK } from "@morseai/sdk";
90
+
91
+ // Simple initialization (only apiKey is required)
92
+ const sdk = new MorseSDK({
93
+ apiKey: "sk_your_api_key_here", // REQUIRED
94
+ });
95
+ ```
96
+
97
+ **Note:** `baseUrl` and `frontendUrl` are internal constants. The SDK automatically uses the correct API endpoints.
98
+
99
+ ### Advanced Configuration
100
+
101
+ ```typescript
102
+ const sdk = new MorseSDK({
103
+ apiKey: "sk_your_api_key_here", // REQUIRED
104
+ apiVersion: "v1", // Optional, defaults to "v1"
105
+ timeout: 30000, // Request timeout in ms (default: 30000)
106
+ retries: 3, // Number of retries on failure (default: 0)
107
+ retryDelay: 1000, // Delay between retries in ms (default: 1000)
108
+ rateLimit: {
109
+ enabled: true, // Enable rate limiting (default: true)
110
+ maxRequests: 100, // Maximum requests per window (default: 100)
111
+ windowMs: 60000, // Time window in milliseconds (default: 60000 = 1 minute)
112
+ },
113
+ onRequest: (url, options) => {
114
+ console.log("Making request to:", url);
115
+ },
116
+ onResponse: (url, response) => {
117
+ console.log("Response received from:", url, response.status);
118
+ },
119
+ onError: (error) => {
120
+ console.error("Request error:", error);
121
+ },
122
+ });
123
+ ```
124
+
125
+ ## Wallet Authentication
126
+
127
+ The SDK supports multiple ways to authenticate, depending on your use case:
128
+
129
+ ### 1. Browser/Web3 Wallet (Frontend)
130
+
131
+ ```typescript
132
+ import { MorseSDK, createBrowserWallet } from "@morseai/sdk";
133
+
134
+ const sdk = new MorseSDK({
135
+ apiKey: process.env.MORSE_API_KEY!,
136
+ });
137
+
138
+ // For MetaMask or other browser wallets
139
+ const wallet = await createBrowserWallet(window.ethereum);
140
+ ```
141
+
142
+ ### 2. Private Key (Backend/Server)
143
+
144
+ ```typescript
145
+ import { MorseSDK, createWalletFromPrivateKey } from "@morseai/sdk";
146
+
147
+ const sdk = new MorseSDK({
148
+ apiKey: process.env.MORSE_API_KEY!,
149
+ });
150
+
151
+ // For backend applications with a private key
152
+ const wallet = createWalletFromPrivateKey({
153
+ privateKey: process.env.PRIVATE_KEY!, // Keep this secure!
154
+ });
155
+ ```
156
+
157
+ ### 3. Custom Implementation
158
+
159
+ ```typescript
160
+ import { MorseSDK, type WalletAuth } from "@morseai/sdk";
161
+
162
+ const sdk = new MorseSDK({
163
+ apiKey: process.env.MORSE_API_KEY!,
164
+ });
165
+
166
+ // Implement your own wallet auth
167
+ const wallet: WalletAuth = {
168
+ address: "0x...",
169
+ signMessage: async (message: string) => {
170
+ // Your custom signing logic
171
+ return signature;
172
+ },
173
+ };
174
+ ```
175
+
176
+ ## Creating Signals
177
+
178
+ ### Automatic Encryption (Recommended)
179
+
180
+ The SDK can handle encryption automatically. This is the recommended approach:
181
+
182
+ ```typescript
183
+ import { MorseSDK, Expiration } from "@morseai/sdk";
184
+
185
+ // Create a private signal (AES-GCM, key in URL)
186
+ const privateSignal = await sdk.createSignalEncrypted(wallet, {
187
+ mode: "private",
188
+ message: "Private message - key will be in URL",
189
+ expiresIn: Expiration.ONE_DAY,
190
+ });
191
+
192
+ // Create a shared signal (X25519, recipient decrypts with wallet)
193
+ const sharedSignal = await sdk.createSignalEncrypted(wallet, {
194
+ walletTarget: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
195
+ mode: "shared_wallet", // shareWithRecipient is automatically true
196
+ message: "Shared message - recipient decrypts with wallet",
197
+ expiresIn: Expiration.ONE_DAY,
198
+ });
199
+ ```
200
+
201
+ ### Signal Modes
202
+
203
+ - **`mode: "private"`** - Private signal, key stored in URL fragment (`#k=...`)
204
+ - Uses AES-GCM encryption
205
+ - `shareWithRecipient` is automatically `false`
206
+ - Key is derived from wallet (deterministic) or random (for URL sharing)
207
+
208
+ - **`mode: "shared_wallet"`** - Shared signal, recipient decrypts with their wallet
209
+ - Uses X25519 + XChaCha20-Poly1305 encryption
210
+ - `shareWithRecipient` is automatically `true`
211
+ - Requires `walletTarget` (recipient's wallet address)
212
+ - No key in URL needed
213
+
214
+ ### Expiration
215
+
216
+ You can specify expiration in two ways:
217
+
218
+ #### Option 1: Relative Time (`expiresIn`)
219
+
220
+ ```typescript
221
+ import { Expiration } from "@morseai/sdk";
222
+
223
+ await sdk.createSignalEncrypted(wallet, {
224
+ mode: "shared_wallet",
225
+ walletTarget: "0x...",
226
+ message: "...",
227
+ expiresIn: Expiration.ONE_DAY, // Use constants for autocomplete
228
+ // Or use string format: "24h", "7d", "1h", "30m", "5s"
229
+ });
230
+ ```
231
+
232
+ **Available Constants:**
233
+ - `Expiration.FIVE_SECONDS` → `"5s"`
234
+ - `Expiration.ONE_MINUTE` → `"1m"`
235
+ - `Expiration.ONE_HOUR` → `"1h"`
236
+ - `Expiration.ONE_DAY` → `"24h"`
237
+ - `Expiration.ONE_WEEK` → `"7d"`
238
+ - `Expiration.ONE_MONTH` → `"30d"`
239
+ - And more...
240
+
241
+ #### Option 2: Specific Date (`expiresAt`)
242
+
243
+ ```typescript
244
+ // Specific date and time
245
+ const customDate = new Date("2026-12-31T23:59:59.000Z");
246
+ await sdk.createSignalEncrypted(wallet, {
247
+ mode: "shared_wallet",
248
+ walletTarget: "0x...",
249
+ message: "...",
250
+ expiresAt: customDate.toISOString(), // ISO 8601 format
251
+ });
252
+
253
+ // Or calculate from now
254
+ const futureDate = new Date();
255
+ futureDate.setHours(futureDate.getHours() + 2); // 2 hours from now
256
+ await sdk.createSignalEncrypted(wallet, {
257
+ mode: "shared_wallet",
258
+ walletTarget: "0x...",
259
+ message: "...",
260
+ expiresAt: futureDate.toISOString(),
261
+ });
262
+ ```
263
+
264
+ **Note:** Either `expiresIn` OR `expiresAt` must be provided (not both).
265
+
266
+ ### Creating Signals with Files
267
+
268
+ ```typescript
269
+ import * as fs from "fs";
270
+
271
+ const fileData = fs.readFileSync("./document.pdf");
272
+
273
+ const result = await sdk.createSignalEncrypted(wallet, {
274
+ walletTarget: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
275
+ mode: "shared_wallet",
276
+ message: "Check out this document!",
277
+ file: {
278
+ data: fileData,
279
+ originalName: "document.pdf",
280
+ mimeType: "application/pdf",
281
+ },
282
+ expiresIn: Expiration.ONE_DAY,
283
+ });
284
+ ```
285
+
286
+ ## Opening Signals
287
+
288
+ ### Automatic Decryption
289
+
290
+ The SDK automatically detects the encryption type and decrypts accordingly:
291
+
292
+ ```typescript
293
+ // Open and decrypt signal (automatic detection)
294
+ const decrypted = await sdk.openSignalDecrypted(wallet, signalId);
295
+
296
+ console.log("Message:", decrypted.message);
297
+ console.log("File:", decrypted.file);
298
+ console.log("Key source:", decrypted.keySource); // "derived" (X25519) or "provided" (URL key)
299
+ ```
300
+
301
+ **For X25519 signals:**
302
+ - No key needed - recipient decrypts with their wallet
303
+ - SDK automatically derives the key from wallet signature
304
+
305
+ **For private signals (AES-GCM):**
306
+ - Key is in the URL fragment (`#k=...`)
307
+ - Or you can provide it manually:
308
+
309
+ ```typescript
310
+ const decrypted = await sdk.openSignalDecrypted(wallet, signalId, keyFromUrl);
311
+ ```
312
+
313
+ ### Raw Encrypted Data
314
+
315
+ If you need the raw encrypted data (for manual decryption):
316
+
317
+ ```typescript
318
+ const encrypted = await sdk.openSignal(wallet, signalId);
319
+
320
+ console.log("Encrypted text:", encrypted.encryptedText);
321
+ console.log("Payload nonce:", encrypted.payloadNonce);
322
+ console.log("Cipher version:", encrypted.cipherVersion);
323
+ ```
324
+
325
+ ## Listing Signals
326
+
327
+ ```typescript
328
+ const mySignals = await sdk.listMySignals(wallet);
329
+
330
+ console.log(`You have ${mySignals.count} signals`);
331
+ mySignals.signals.forEach((signal) => {
332
+ console.log(`- ${signal.signalId}: ${signal.status}`);
333
+ console.log(` Created: ${signal.createdAt}`);
334
+ console.log(` Expires: ${signal.expiresAt}`);
335
+ });
336
+ ```
337
+
338
+ ## Burning Signals
339
+
340
+ ```typescript
341
+ await sdk.burnSignal(wallet, "signal-id-here");
342
+ ```
343
+
344
+ **Note:** Only the signal creator or recipient can burn a signal.
345
+
346
+ ## Encryption Methods
347
+
348
+ ### X25519 (Shared Signals)
349
+
350
+ For `mode: "shared_wallet"` signals:
351
+ - **Encryption:** X25519 ECDH + XChaCha20-Poly1305
352
+ - **Key Exchange:** Ephemeral sender key + recipient's static key
353
+ - **Decryption:** Recipient uses their wallet to derive key
354
+ - **No key in URL:** Recipient decrypts with their wallet signature
355
+
356
+ ### AES-GCM (Private Signals)
357
+
358
+ For `mode: "private"` signals:
359
+ - **Encryption:** AES-GCM-256
360
+ - **Key:** Derived from wallet or random (for URL sharing)
361
+ - **Key in URL:** For shareable links, key is in URL fragment (`#k=...`)
362
+
363
+ ## Error Handling
364
+
365
+ The SDK throws specific error types for better error handling:
366
+
367
+ ```typescript
368
+ import {
369
+ MorseSDKError,
370
+ SignalNotFoundError,
371
+ SignalExpiredError,
372
+ SignalAlreadyUsedError,
373
+ WalletNotAllowedError,
374
+ ValidationError,
375
+ NetworkError,
376
+ RateLimitError,
377
+ } from "@morseai/sdk";
378
+
379
+ try {
380
+ const signal = await sdk.openSignalDecrypted(wallet, signalId);
381
+ } catch (error) {
382
+ if (error instanceof SignalExpiredError) {
383
+ console.log("Signal expired");
384
+ } else if (error instanceof SignalAlreadyUsedError) {
385
+ console.log("Signal already used");
386
+ } else if (error instanceof WalletNotAllowedError) {
387
+ console.log("Wallet not allowed");
388
+ } else if (error instanceof RateLimitError) {
389
+ console.log(`Rate limit exceeded. Retry after ${error.retryAfterMs}ms`);
390
+ } else {
391
+ console.error("Unknown error:", error);
392
+ }
393
+ }
394
+ ```
395
+
396
+ ## Examples
397
+
398
+ See the `examples/` directory for complete working examples:
399
+
400
+ - `create-signal-example.ts` - Creating signals
401
+ - `open-signal-example.ts` - Opening and decrypting signals
402
+ - `backend-to-backend.ts` - Backend-to-backend communication
403
+ - `browser-example.ts` - Browser/Web3 wallet usage
404
+ - `custom-expiration-example.ts` - Custom expiration dates
405
+ - `advanced-config.ts` - Advanced configuration options
406
+
407
+ **Running examples:**
408
+
409
+ ```bash
410
+ # Set environment variables
411
+ export MORSE_API_KEY=sk_your_api_key
412
+ export PRIVATE_KEY=your_private_key_hex
413
+
414
+ # Run examples
415
+ npx tsx examples/create-signal-example.ts
416
+ npx tsx examples/open-signal-example.ts <signalId>
417
+ ```
418
+
419
+ ## API Reference
420
+
421
+ ### `MorseSDK`
422
+
423
+ The main SDK class for creating and accessing encrypted signals.
424
+
425
+ #### Constructor
426
+
427
+ ```typescript
428
+ new MorseSDK(config: MorseSDKConfig)
429
+ ```
430
+
431
+ **Config Options:**
432
+
433
+ | Option | Type | Default | Description |
434
+ |--------|------|---------|-------------|
435
+ | `apiKey` | `string` | **Required** | Your MORSE API key (starts with `sk_`) |
436
+ | `apiVersion` | `string` | `"v1"` | API version to use |
437
+ | `timeout` | `number` | `30000` | Request timeout in milliseconds |
438
+ | `retries` | `number` | `0` | Number of retries on failure |
439
+ | `retryDelay` | `number` | `1000` | Delay between retries in ms |
440
+ | `rateLimit` | `RateLimitConfig` | `{ enabled: true, maxRequests: 100, windowMs: 60000 }` | Rate limiting config |
441
+ | `onRequest` | `function` | - | Callback before each request |
442
+ | `onResponse` | `function` | - | Callback after each response |
443
+ | `onError` | `function` | - | Callback on errors |
444
+
445
+ #### Methods
446
+
447
+ ##### `createSignalEncrypted(wallet, options): Promise<CreateSignalResponseEncrypted>`
448
+
449
+ Create a signal with automatic encryption. **Recommended method.**
450
+
451
+ **Parameters:**
452
+ - `wallet: WalletAuth` - Wallet authentication object
453
+ - `options: CreateSignalOptionsEncrypted` - Signal creation options
454
+
455
+ **Returns:** `Promise<CreateSignalResponseEncrypted>` - Created signal with shareable link
456
+
457
+ **Example:**
458
+ ```typescript
459
+ const result = await sdk.createSignalEncrypted(wallet, {
460
+ walletTarget: "0x...", // Required for shared_wallet mode
461
+ mode: "shared_wallet", // or "private"
462
+ message: "Secret message",
463
+ expiresIn: Expiration.ONE_DAY,
464
+ });
465
+
466
+ console.log("Signal ID:", result.signalId);
467
+ console.log("Shareable link:", result.shareableLink);
468
+ console.log("Key (for private signals):", result.keyBase64);
469
+ ```
470
+
471
+ ##### `openSignalDecrypted(wallet, signalId, keyBase64?): Promise<OpenSignalResponseDecrypted>`
472
+
473
+ Open and decrypt a signal automatically.
474
+
475
+ **Parameters:**
476
+ - `wallet: WalletAuth` - Wallet authentication object
477
+ - `signalId: string` - Signal ID to open
478
+ - `keyBase64?: string` - Optional key for private signals (from URL fragment)
479
+
480
+ **Returns:** `Promise<OpenSignalResponseDecrypted>` - Decrypted signal data
481
+
482
+ **Example:**
483
+ ```typescript
484
+ const decrypted = await sdk.openSignalDecrypted(wallet, signalId);
485
+ console.log("Message:", decrypted.message);
486
+ console.log("File:", decrypted.file);
487
+ ```
488
+
489
+ ##### `createSignal(wallet, options): Promise<CreateSignalResponse>`
490
+
491
+ Create a signal with pre-encrypted data (manual encryption).
492
+
493
+ **Parameters:**
494
+ - `wallet: WalletAuth` - Wallet authentication object
495
+ - `options: CreateSignalOptions` - Signal creation options with encrypted data
496
+
497
+ **Returns:** `Promise<CreateSignalResponse>` - Created signal info
498
+
499
+ ##### `openSignal(wallet, signalId): Promise<OpenSignalResponse>`
500
+
501
+ Open a signal and return encrypted data (for manual decryption).
502
+
503
+ **Parameters:**
504
+ - `wallet: WalletAuth` - Wallet authentication object
505
+ - `signalId: string` - Signal ID to open
506
+
507
+ **Returns:** `Promise<OpenSignalResponse>` - Encrypted signal data
508
+
509
+ ##### `listMySignals(wallet): Promise<ListMySignalsResponse>`
510
+
511
+ List all signals accessible by the wallet.
512
+
513
+ **Parameters:**
514
+ - `wallet: WalletAuth` - Wallet authentication object
515
+
516
+ **Returns:** `Promise<ListMySignalsResponse>` - List of signals
517
+
518
+ ##### `burnSignal(wallet, signalId): Promise<{ success: boolean }>`
519
+
520
+ Manually burn a signal (mark as used).
521
+
522
+ **Parameters:**
523
+ - `wallet: WalletAuth` - Wallet authentication object
524
+ - `signalId: string` - Signal ID to burn
525
+
526
+ **Returns:** `Promise<{ success: boolean }>`
527
+
528
+ ## TypeScript Support
529
+
530
+ The SDK is fully typed. All types are exported:
531
+
532
+ ```typescript
533
+ import type {
534
+ CreateSignalOptions,
535
+ CreateSignalOptionsEncrypted,
536
+ CreateSignalResponse,
537
+ CreateSignalResponseEncrypted,
538
+ OpenSignalResponse,
539
+ OpenSignalResponseDecrypted,
540
+ SignalMode,
541
+ SignalStatus,
542
+ WalletAuth,
543
+ MorseSDKConfig,
544
+ ExpirationValue,
545
+ } from "@morseai/sdk";
546
+
547
+ import { Expiration } from "@morseai/sdk";
548
+ ```
549
+
550
+ ## Helper Functions
551
+
552
+ ### Signal Validation
553
+
554
+ ```typescript
555
+ import { isValidSignalId, isValidWalletAddress } from "@morseai/sdk";
556
+
557
+ isValidSignalId("abc123"); // true
558
+ isValidWalletAddress("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"); // true
559
+ ```
560
+
561
+ ### Expiration Utilities
562
+
563
+ ```typescript
564
+ import {
565
+ formatExpiration,
566
+ isSignalExpired,
567
+ getTimeUntilExpiration,
568
+ parseExpiresIn,
569
+ } from "@morseai/sdk";
570
+
571
+ formatExpiration("2025-12-31T23:59:59Z");
572
+ isSignalExpired("2025-12-31T23:59:59Z");
573
+ getTimeUntilExpiration("2025-12-31T23:59:59Z");
574
+ parseExpiresIn("24h");
575
+ ```
576
+
577
+ ### Expiration Constants
578
+
579
+ ```typescript
580
+ import { Expiration } from "@morseai/sdk";
581
+
582
+ // Use constants for autocomplete and type safety
583
+ Expiration.ONE_DAY // "24h"
584
+ Expiration.ONE_WEEK // "7d"
585
+ Expiration.ONE_HOUR // "1h"
586
+ Expiration.ONE_MONTH // "30d"
587
+ // ... and more
588
+ ```
589
+
590
+ ## License
591
+
592
+ MIT