@mission_sciences/provider-sdk 0.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 +495 -0
- package/dist/index.d.ts +767 -0
- package/dist/marketplace-sdk.es.js +3276 -0
- package/dist/marketplace-sdk.es.js.map +1 -0
- package/dist/marketplace-sdk.umd.js +2 -0
- package/dist/marketplace-sdk.umd.js.map +1 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# Marketplace Provider SDK
|
|
2
|
+
|
|
3
|
+
> JWT-based session management for application providers in the General Wisdom marketplace ecosystem
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
> **📦 Migration Notice**: This package has been renamed from `@marketplace/provider-sdk` to `@mission_sciences/provider-sdk`. Please update your dependencies. See [Migration Guide](#-migration-from-marketplaceprovider-sdk) below.
|
|
9
|
+
|
|
10
|
+
## 🚀 Quick Start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# Install
|
|
14
|
+
npm install @mission_sciences/provider-sdk
|
|
15
|
+
|
|
16
|
+
# Initialize
|
|
17
|
+
import MarketplaceSDK from '@mission_sciences/provider-sdk';
|
|
18
|
+
|
|
19
|
+
const sdk = new MarketplaceSDK({
|
|
20
|
+
jwtParamName: 'jwt',
|
|
21
|
+
applicationId: 'your-app-id',
|
|
22
|
+
jwksUrl: 'https://api.generalwisdom.com/.well-known/jwks.json',
|
|
23
|
+
onSessionStart: async (context) => {
|
|
24
|
+
// Your auth logic
|
|
25
|
+
},
|
|
26
|
+
onSessionEnd: async (context) => {
|
|
27
|
+
// Cleanup logic
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await sdk.initialize();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 📚 Documentation
|
|
35
|
+
|
|
36
|
+
- **[Integration Guide](./INTEGRATION_GUIDE.md)** - Comprehensive guide for all frameworks
|
|
37
|
+
- **[Quick Start Guide](./QUICKSTART.md)** - Get started in 3 minutes
|
|
38
|
+
- **[Testing Guide](./TESTING_GUIDE.md)** - Testing strategies
|
|
39
|
+
- **[JWT Specification](./jwt-specification.md)** - Token format details
|
|
40
|
+
|
|
41
|
+
### Example Integrations
|
|
42
|
+
|
|
43
|
+
- **[GhostDog Integration](../extension-ghostdog/MARKETPLACE_INTEGRATION.md)** - Real-world Chrome extension example
|
|
44
|
+
|
|
45
|
+
## ✨ Features
|
|
46
|
+
|
|
47
|
+
### Core Features
|
|
48
|
+
- ✅ **Zero Dependencies**: Self-contained with minimal external deps
|
|
49
|
+
- ✅ **Framework Agnostic**: Works with vanilla JS, React, Vue, and more
|
|
50
|
+
- ✅ **TypeScript First**: Full type definitions included
|
|
51
|
+
- ✅ **Lightweight**: < 10KB gzipped
|
|
52
|
+
- ✅ **Secure**: RS256 JWT verification with JWKS
|
|
53
|
+
- ✅ **Customizable**: Flexible styling and event handling
|
|
54
|
+
|
|
55
|
+
### Advanced Features (Phase 2)
|
|
56
|
+
- ❤️ **Heartbeat System**: Automatic server sync
|
|
57
|
+
- 🔄 **Multi-Tab Sync**: Master tab election with BroadcastChannel API
|
|
58
|
+
- ⏰ **Session Extension**: Self-service renewal
|
|
59
|
+
- ✅ **Early Completion**: End sessions early with refund calculation
|
|
60
|
+
- 👁️ **Visibility API**: Auto-pause when tab hidden
|
|
61
|
+
- 🔐 **Backend Validation**: Alternative to JWKS for sensitive apps
|
|
62
|
+
|
|
63
|
+
## 🎯 How It Works
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Marketplace → JWT in URL → SDK validates → Your app authenticates → Session active
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
When users launch your app from the marketplace:
|
|
70
|
+
|
|
71
|
+
1. **URL Detection**: SDK checks for JWT parameter (`?jwt=...`)
|
|
72
|
+
2. **JWT Validation**: Verifies signature using RS256 and JWKS
|
|
73
|
+
3. **Session Start**: Calls your `onSessionStart` hook
|
|
74
|
+
4. **Timer Start**: Countdown begins
|
|
75
|
+
5. **Session Active**: User interacts with your app
|
|
76
|
+
6. **Warning**: Alert at 5 minutes remaining (configurable)
|
|
77
|
+
7. **Session End**: Calls your `onSessionEnd` hook
|
|
78
|
+
8. **Redirect**: Returns to marketplace (optional)
|
|
79
|
+
|
|
80
|
+
## 📦 Installation
|
|
81
|
+
|
|
82
|
+
### NPM
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install @mission_sciences/provider-sdk
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Yarn
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
yarn add @mission_sciences/provider-sdk
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### PNPM
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pnpm add @mission_sciences/provider-sdk
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 🏗️ Basic Usage
|
|
101
|
+
|
|
102
|
+
### Vanilla JavaScript
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
import MarketplaceSDK from '@mission_sciences/provider-sdk';
|
|
106
|
+
|
|
107
|
+
const sdk = new MarketplaceSDK({
|
|
108
|
+
jwtParamName: 'jwt',
|
|
109
|
+
applicationId: 'my-app',
|
|
110
|
+
jwksUrl: 'https://api.generalwisdom.com/.well-known/jwks.json',
|
|
111
|
+
|
|
112
|
+
onSessionStart: async (context) => {
|
|
113
|
+
// Store user info
|
|
114
|
+
localStorage.setItem('user_id', context.userId);
|
|
115
|
+
localStorage.setItem('session_id', context.sessionId);
|
|
116
|
+
|
|
117
|
+
// Show app UI
|
|
118
|
+
document.getElementById('app').style.display = 'block';
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
onSessionEnd: async (context) => {
|
|
122
|
+
// Clear storage
|
|
123
|
+
localStorage.clear();
|
|
124
|
+
|
|
125
|
+
// Hide app UI
|
|
126
|
+
document.getElementById('app').style.display = 'none';
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await sdk.initialize();
|
|
131
|
+
|
|
132
|
+
// Mount session header
|
|
133
|
+
const header = sdk.createSessionHeader();
|
|
134
|
+
header.mount('#session-header');
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### React
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { useEffect } from 'react';
|
|
141
|
+
import MarketplaceSDK from '@mission_sciences/provider-sdk';
|
|
142
|
+
|
|
143
|
+
function useMarketplaceSession() {
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const sdk = new MarketplaceSDK({
|
|
146
|
+
jwtParamName: 'jwt',
|
|
147
|
+
applicationId: 'my-react-app',
|
|
148
|
+
jwksUrl: 'https://api.generalwisdom.com/.well-known/jwks.json',
|
|
149
|
+
|
|
150
|
+
onSessionStart: async (context) => {
|
|
151
|
+
// Call your auth API
|
|
152
|
+
await authenticateUser(context);
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
onSessionEnd: async (context) => {
|
|
156
|
+
// Clear auth state
|
|
157
|
+
await logout();
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
sdk.initialize();
|
|
162
|
+
|
|
163
|
+
return () => sdk.destroy();
|
|
164
|
+
}, []);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
See [INTEGRATION_GUIDE.md](./INTEGRATION_GUIDE.md) for Vue, Chrome Extensions, and more.
|
|
169
|
+
|
|
170
|
+
## 🎨 Session Header Component
|
|
171
|
+
|
|
172
|
+
Pre-built UI component for displaying session timer:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
const header = sdk.createSessionHeader({
|
|
176
|
+
containerId: 'session-header',
|
|
177
|
+
theme: 'dark', // 'light' | 'dark' | 'auto'
|
|
178
|
+
showControls: true,
|
|
179
|
+
showEndButton: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
header.mount('#session-header');
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Custom Styling**:
|
|
186
|
+
|
|
187
|
+
```css
|
|
188
|
+
.gw-session-header {
|
|
189
|
+
background: #1a1a1a;
|
|
190
|
+
padding: 12px 24px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.gw-session-timer {
|
|
194
|
+
font-size: 18px;
|
|
195
|
+
color: #00ff88;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.gw-session-timer--warning {
|
|
199
|
+
color: #ff6b00;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## 🔐 Authentication Integration
|
|
204
|
+
|
|
205
|
+
### Exchange JWT for Your App's Tokens
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
onSessionStart: async (context) => {
|
|
209
|
+
// Send marketplace JWT to your backend
|
|
210
|
+
const response = await fetch('https://api.your-app.com/auth/marketplace', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: {
|
|
213
|
+
'Authorization': `Bearer ${context.jwt}`,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const { token } = await response.json();
|
|
218
|
+
|
|
219
|
+
// Store your app's auth token
|
|
220
|
+
localStorage.setItem('auth_token', token);
|
|
221
|
+
},
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Backend Example** (Python/Flask):
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
@app.route('/auth/marketplace', methods=['POST'])
|
|
228
|
+
def marketplace_auth():
|
|
229
|
+
jwt_token = request.headers.get('Authorization').replace('Bearer ', '')
|
|
230
|
+
|
|
231
|
+
# Validate JWT
|
|
232
|
+
claims = validate_marketplace_jwt(jwt_token)
|
|
233
|
+
|
|
234
|
+
# Create/update user
|
|
235
|
+
user = get_or_create_user(claims['userId'], claims.get('email'))
|
|
236
|
+
|
|
237
|
+
# Generate your app's token
|
|
238
|
+
app_token = generate_app_token(user)
|
|
239
|
+
|
|
240
|
+
return jsonify({'token': app_token})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
See [INTEGRATION_GUIDE.md#authentication-integration](./INTEGRATION_GUIDE.md#authentication-integration) for Cognito, Firebase, Auth0 examples.
|
|
244
|
+
|
|
245
|
+
## ⚙️ Configuration
|
|
246
|
+
|
|
247
|
+
### SDK Options
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
interface SDKOptions {
|
|
251
|
+
// Required
|
|
252
|
+
jwtParamName: string; // URL parameter name
|
|
253
|
+
applicationId: string; // Your app ID
|
|
254
|
+
jwksUrl: string; // JWKS endpoint
|
|
255
|
+
onSessionStart: (context) => Promise<void>;
|
|
256
|
+
onSessionEnd: (context) => Promise<void>;
|
|
257
|
+
|
|
258
|
+
// Optional
|
|
259
|
+
onSessionWarning?: (context) => Promise<void>;
|
|
260
|
+
onSessionExtend?: (context) => Promise<void>;
|
|
261
|
+
marketplaceUrl?: string; // Redirect after session end
|
|
262
|
+
warningThresholdMinutes?: number; // Default: 5
|
|
263
|
+
debug?: boolean; // Enable logging
|
|
264
|
+
pauseWhenHidden?: boolean; // Auto-pause when tab hidden
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### JWT Structure
|
|
269
|
+
|
|
270
|
+
```json
|
|
271
|
+
{
|
|
272
|
+
"sessionId": "35iiYDoY1fSwSpYX22H8GP7x61o",
|
|
273
|
+
"applicationId": "your-app-id",
|
|
274
|
+
"userId": "a47884c8-50d1-7040-2de8-b7801699643c",
|
|
275
|
+
"orgId": "org-123",
|
|
276
|
+
"email": "user@example.com",
|
|
277
|
+
"startTime": 1763599337,
|
|
278
|
+
"durationMinutes": 60,
|
|
279
|
+
"exp": 1763602937,
|
|
280
|
+
"iat": 1763599337
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## 🧪 Testing
|
|
285
|
+
|
|
286
|
+
### Generate Test JWT
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
npm run generate-keys # Create RSA key pair (dev only)
|
|
290
|
+
npm run generate-jwt 60 # Generate 60-minute JWT
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Test Server
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
npm run test-server # Start dev server at localhost:3000
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Open: `http://localhost:3000?jwt=<YOUR_JWT>`
|
|
300
|
+
|
|
301
|
+
See [TESTING_GUIDE.md](./TESTING_GUIDE.md) for unit, integration, and E2E testing.
|
|
302
|
+
|
|
303
|
+
## 🛠️ Development
|
|
304
|
+
|
|
305
|
+
### Build
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
npm run build # Build for production
|
|
309
|
+
npm run dev # Watch mode
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Code Quality
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npm run lint # ESLint
|
|
316
|
+
npm run format # Prettier
|
|
317
|
+
npm run type-check # TypeScript
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Examples
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
cd examples/vanilla-js
|
|
324
|
+
npm install
|
|
325
|
+
npm run dev
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## 📖 API Reference
|
|
329
|
+
|
|
330
|
+
### MarketplaceSDK
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
class MarketplaceSDK {
|
|
334
|
+
constructor(options: SDKOptions)
|
|
335
|
+
|
|
336
|
+
// Initialize SDK
|
|
337
|
+
async initialize(): Promise<void>
|
|
338
|
+
|
|
339
|
+
// Session management
|
|
340
|
+
hasActiveSession(): boolean
|
|
341
|
+
getSession(): Session | null
|
|
342
|
+
async endSession(reason: string): Promise<void>
|
|
343
|
+
async extendSession(minutes: number): Promise<void>
|
|
344
|
+
|
|
345
|
+
// UI components
|
|
346
|
+
createSessionHeader(options?: HeaderOptions): SessionHeader
|
|
347
|
+
|
|
348
|
+
// Timer control
|
|
349
|
+
pauseTimer(): void
|
|
350
|
+
resumeTimer(): void
|
|
351
|
+
isTimerPaused(): boolean
|
|
352
|
+
|
|
353
|
+
// Cleanup
|
|
354
|
+
destroy(): void
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Context Types
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
interface SessionStartContext {
|
|
362
|
+
sessionId: string;
|
|
363
|
+
applicationId: string;
|
|
364
|
+
userId: string;
|
|
365
|
+
orgId?: string;
|
|
366
|
+
email?: string;
|
|
367
|
+
startTime: number;
|
|
368
|
+
expiresAt: number;
|
|
369
|
+
durationMinutes: number;
|
|
370
|
+
jwt: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
interface SessionEndContext {
|
|
374
|
+
sessionId: string;
|
|
375
|
+
userId: string;
|
|
376
|
+
reason: 'expired' | 'manual' | 'error';
|
|
377
|
+
actualDurationMinutes: number;
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
See [INTEGRATION_GUIDE.md#api-reference](./INTEGRATION_GUIDE.md#api-reference) for complete API documentation.
|
|
382
|
+
|
|
383
|
+
## 🚀 Production Deployment
|
|
384
|
+
|
|
385
|
+
### Checklist
|
|
386
|
+
|
|
387
|
+
- [ ] Update `jwksUrl` to production endpoint
|
|
388
|
+
- [ ] Set correct `applicationId`
|
|
389
|
+
- [ ] Enable HTTPS for all endpoints
|
|
390
|
+
- [ ] Configure proper CORS headers
|
|
391
|
+
- [ ] Set up secrets management
|
|
392
|
+
- [ ] Enable rate limiting
|
|
393
|
+
- [ ] Configure monitoring and logging
|
|
394
|
+
- [ ] Test with production JWT
|
|
395
|
+
- [ ] Load test auth endpoints
|
|
396
|
+
|
|
397
|
+
See [INTEGRATION_GUIDE.md#production-deployment](./INTEGRATION_GUIDE.md#production-deployment) for complete checklist.
|
|
398
|
+
|
|
399
|
+
## 🐛 Troubleshooting
|
|
400
|
+
|
|
401
|
+
### Common Issues
|
|
402
|
+
|
|
403
|
+
**JWT validation failed**
|
|
404
|
+
- Check JWKS URL is correct
|
|
405
|
+
- Verify applicationId matches
|
|
406
|
+
- Ensure JWT not expired
|
|
407
|
+
|
|
408
|
+
**Session header not showing**
|
|
409
|
+
- Verify mount element exists
|
|
410
|
+
- Check SDK initialized
|
|
411
|
+
- Confirm active session
|
|
412
|
+
|
|
413
|
+
**Auth server 500 error**
|
|
414
|
+
- Check Cognito configuration
|
|
415
|
+
- Verify client secret (if required)
|
|
416
|
+
- Review server logs
|
|
417
|
+
|
|
418
|
+
See [INTEGRATION_GUIDE.md#troubleshooting](./INTEGRATION_GUIDE.md#troubleshooting) for detailed solutions.
|
|
419
|
+
|
|
420
|
+
## 📚 Resources
|
|
421
|
+
|
|
422
|
+
- **[Integration Guide](./INTEGRATION_GUIDE.md)** - Complete integration reference
|
|
423
|
+
- **[Quick Start](./QUICKSTART.md)** - Get started in 3 minutes
|
|
424
|
+
- **[Testing Guide](./TESTING_GUIDE.md)** - Testing strategies
|
|
425
|
+
- **[JWT Spec](./jwt-specification.md)** - Token format details
|
|
426
|
+
- **[Examples](./examples/)** - Sample implementations
|
|
427
|
+
- **[GhostDog Integration](../extension-ghostdog/MARKETPLACE_INTEGRATION.md)** - Real-world example
|
|
428
|
+
|
|
429
|
+
## 📦 Migration from @marketplace/provider-sdk
|
|
430
|
+
|
|
431
|
+
If you're upgrading from the old `@marketplace/provider-sdk` package:
|
|
432
|
+
|
|
433
|
+
### Step 1: Update package.json
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
npm uninstall @marketplace/provider-sdk
|
|
437
|
+
npm install @mission_sciences/provider-sdk
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Step 2: Update imports
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
// Old
|
|
444
|
+
import MarketplaceSDK from '@marketplace/provider-sdk';
|
|
445
|
+
|
|
446
|
+
// New
|
|
447
|
+
import MarketplaceSDK from '@mission_sciences/provider-sdk';
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Step 3: Remove old registry config (if using CodeArtifact)
|
|
451
|
+
|
|
452
|
+
Remove or update your `.npmrc` file:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
# Old (remove this)
|
|
456
|
+
@marketplace:registry=https://ghostdogbase-540845145946.d.codeartifact.us-east-1.amazonaws.com/npm/sdk-packages/
|
|
457
|
+
|
|
458
|
+
# New (use default npm registry - no configuration needed)
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Note**: The API is 100% compatible. No code changes required beyond the package name!
|
|
462
|
+
|
|
463
|
+
## 🤝 Contributing
|
|
464
|
+
|
|
465
|
+
Contributions welcome! Please read [CONTRIBUTING.md](./CONTRIBUTING.md) first.
|
|
466
|
+
|
|
467
|
+
## 📄 License
|
|
468
|
+
|
|
469
|
+
MIT License - see [LICENSE](./LICENSE) file for details
|
|
470
|
+
|
|
471
|
+
## 🆘 Support
|
|
472
|
+
|
|
473
|
+
- **Issues**: [GitHub Issues](https://github.com/Mission-Sciences/provider-sdk/issues)
|
|
474
|
+
- **Email**: support@generalwisdom.com
|
|
475
|
+
- **Docs**: [docs.generalwisdom.com](https://docs.generalwisdom.com)
|
|
476
|
+
|
|
477
|
+
## 📊 Changelog
|
|
478
|
+
|
|
479
|
+
### v2.0.0 (Phase 2)
|
|
480
|
+
- Heartbeat system
|
|
481
|
+
- Multi-tab coordination
|
|
482
|
+
- Session extension
|
|
483
|
+
- Early completion
|
|
484
|
+
- Visibility API integration
|
|
485
|
+
|
|
486
|
+
### v1.0.0 (Phase 1)
|
|
487
|
+
- Initial release
|
|
488
|
+
- JWT validation with JWKS
|
|
489
|
+
- Session timer management
|
|
490
|
+
- Lifecycle hooks
|
|
491
|
+
- Session header component
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
**Built with ❤️ by the General Wisdom team**
|