@edge-markets/connect-link 1.0.0

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 ADDED
@@ -0,0 +1,271 @@
1
+ # @edgeboost/edge-connect-link
2
+
3
+ Browser SDK for EDGE Connect popup authentication - inspired by [Plaid Link](https://plaid.com/docs/link/).
4
+
5
+ ## Features
6
+
7
+ - 🔒 **Secure by default** - PKCE OAuth flow, no client secret in browser
8
+ - 🚀 **Simple API** - Just `new EdgeLink()` and `link.open()`
9
+ - 📱 **Works everywhere** - Handles popup blockers gracefully
10
+ - 📊 **Event tracking** - `onSuccess`, `onExit`, `onEvent` callbacks
11
+ - 🎨 **Beautiful loading state** - Professional branded experience
12
+ - 📝 **Full TypeScript** - Complete type definitions
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @edgeboost/edge-connect-link
18
+ # or
19
+ pnpm add @edgeboost/edge-connect-link
20
+ # or
21
+ yarn add @edgeboost/edge-connect-link
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```typescript
27
+ import { EdgeLink } from '@edgeboost/edge-connect-link'
28
+
29
+ // 1. Create instance (do this once)
30
+ const link = new EdgeLink({
31
+ clientId: 'your-client-id',
32
+ environment: 'staging',
33
+ onSuccess: (result) => {
34
+ // Send to your backend for token exchange
35
+ fetch('/api/edge/exchange', {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({
39
+ code: result.code,
40
+ codeVerifier: result.codeVerifier,
41
+ }),
42
+ })
43
+ },
44
+ onExit: (metadata) => {
45
+ if (metadata.reason === 'popup_blocked') {
46
+ alert('Please allow popups for this site')
47
+ }
48
+ },
49
+ })
50
+
51
+ // 2. Open from a click handler
52
+ document.getElementById('connect-btn')!.onclick = () => link.open()
53
+ ```
54
+
55
+ ## ⚠️ Important: User Gesture Requirement
56
+
57
+ **`link.open()` MUST be called directly from a user click handler!**
58
+
59
+ Browsers block popups that aren't triggered by user interaction. Any async work before calling `open()` will break this.
60
+
61
+ ```typescript
62
+ // ✅ Correct - direct click handler
63
+ button.onclick = () => link.open()
64
+
65
+ // ✅ Correct - immediate call in handler
66
+ button.onclick = () => {
67
+ trackClick() // sync ok
68
+ link.open()
69
+ }
70
+
71
+ // ❌ Wrong - async gap breaks user gesture
72
+ button.onclick = async () => {
73
+ await someAsyncWork() // breaks it!
74
+ link.open() // BLOCKED
75
+ }
76
+
77
+ // ❌ Wrong - setTimeout breaks user gesture
78
+ button.onclick = () => {
79
+ setTimeout(() => link.open(), 100) // BLOCKED
80
+ }
81
+ ```
82
+
83
+ ## Configuration
84
+
85
+ ```typescript
86
+ interface EdgeLinkConfig {
87
+ // Required
88
+ clientId: string // Your OAuth client ID
89
+ environment: EdgeEnvironment // 'production' | 'staging' | 'sandbox'
90
+ onSuccess: (result) => void // Called on successful auth
91
+
92
+ // Optional
93
+ onExit?: (metadata) => void // Called when user exits
94
+ onEvent?: (event) => void // Called for analytics events
95
+ scopes?: EdgeScope[] // Scopes to request (default: all)
96
+ linkUrl?: string // Custom Link URL (dev only)
97
+ redirectUri?: string // Custom redirect URI
98
+ }
99
+ ```
100
+
101
+ ## Callbacks
102
+
103
+ ### onSuccess
104
+
105
+ Called when user successfully authenticates and grants consent:
106
+
107
+ ```typescript
108
+ onSuccess: (result) => {
109
+ // result.code - Authorization code (send to backend)
110
+ // result.codeVerifier - PKCE verifier (send to backend)
111
+ // result.state - State parameter (for validation)
112
+
113
+ // IMPORTANT: Exchange tokens on your backend, not here!
114
+ // The backend has your client secret, the browser doesn't.
115
+ }
116
+ ```
117
+
118
+ ### onExit
119
+
120
+ Called when user exits the flow:
121
+
122
+ ```typescript
123
+ onExit: (metadata) => {
124
+ switch (metadata.reason) {
125
+ case 'user_closed':
126
+ // User closed the popup
127
+ break
128
+ case 'popup_blocked':
129
+ // Popup was blocked - show instructions
130
+ alert('Please allow popups')
131
+ break
132
+ case 'error':
133
+ // An error occurred
134
+ console.error(metadata.error?.message)
135
+ break
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### onEvent
141
+
142
+ Called for analytics and debugging:
143
+
144
+ ```typescript
145
+ onEvent: (event) => {
146
+ // event.eventName - 'OPEN' | 'CLOSE' | 'HANDOFF' | 'SUCCESS' | 'ERROR'
147
+ // event.timestamp - Unix timestamp
148
+ // event.metadata - Additional context
149
+
150
+ analytics.track('edge_link_event', event)
151
+ }
152
+ ```
153
+
154
+ ## Methods
155
+
156
+ | Method | Description |
157
+ |--------|-------------|
158
+ | `open(options?)` | Opens the Link popup (must be from click handler) |
159
+ | `close()` | Closes the popup programmatically |
160
+ | `destroy()` | Cleans up resources (call when done) |
161
+ | `isOpen()` | Returns true if popup is open |
162
+
163
+ ## Scopes
164
+
165
+ Request only the permissions you need:
166
+
167
+ ```typescript
168
+ import { EdgeLink, EDGE_SCOPES } from '@edgeboost/edge-connect-link'
169
+
170
+ const link = new EdgeLink({
171
+ clientId: 'your-client-id',
172
+ environment: 'staging',
173
+ scopes: [
174
+ EDGE_SCOPES.USER_READ, // Read profile
175
+ EDGE_SCOPES.BALANCE_READ, // Read balance
176
+ // EDGE_SCOPES.TRANSFER_WRITE - Only if you need transfers
177
+ ],
178
+ onSuccess: handleSuccess,
179
+ })
180
+ ```
181
+
182
+ ## React Example
183
+
184
+ ```tsx
185
+ import { useEffect, useRef, useCallback } from 'react'
186
+ import { EdgeLink } from '@edgeboost/edge-connect-link'
187
+
188
+ function ConnectButton() {
189
+ const linkRef = useRef<EdgeLink | null>(null)
190
+
191
+ useEffect(() => {
192
+ linkRef.current = new EdgeLink({
193
+ clientId: process.env.NEXT_PUBLIC_EDGE_CLIENT_ID!,
194
+ environment: 'staging',
195
+ onSuccess: async (result) => {
196
+ await fetch('/api/edge/exchange', {
197
+ method: 'POST',
198
+ body: JSON.stringify(result),
199
+ })
200
+ // Refresh user state
201
+ },
202
+ onExit: (metadata) => {
203
+ if (metadata.reason === 'popup_blocked') {
204
+ alert('Please allow popups')
205
+ }
206
+ },
207
+ })
208
+
209
+ return () => linkRef.current?.destroy()
210
+ }, [])
211
+
212
+ const handleClick = useCallback(() => {
213
+ linkRef.current?.open()
214
+ }, [])
215
+
216
+ return (
217
+ <button onClick={handleClick}>
218
+ Connect EdgeBoost
219
+ </button>
220
+ )
221
+ }
222
+ ```
223
+
224
+ ## How It Works
225
+
226
+ 1. **User clicks button** → `link.open()` is called
227
+ 2. **Popup opens immediately** → Shows branded loading state
228
+ 3. **PKCE generated** → Secure OAuth without client secret
229
+ 4. **Popup navigates to Link page** → User logs in & grants consent
230
+ 5. **Code returned via postMessage** → Secure cross-origin communication
231
+ 6. **onSuccess called** → You send code to backend for token exchange
232
+
233
+ ```
234
+ ┌─────────────┐ ┌─────────────────┐ ┌──────────────┐
235
+ │ Your App │ │ EdgeLink Popup │ │ Your Backend │
236
+ └──────┬──────┘ └────────┬────────┘ └──────┬───────┘
237
+ │ │ │
238
+ │ link.open() │ │
239
+ ├────────────────────►│ │
240
+ │ │ │
241
+ │ (user logs in, grants consent) │
242
+ │ │ │
243
+ │ postMessage(code) │ │
244
+ │◄────────────────────┤ │
245
+ │ │ │
246
+ │ onSuccess(result) │ │
247
+ ├─────────────────────┼────────────────────►│
248
+ │ │ POST /api/exchange │
249
+ │ │ │
250
+ │ │ tokens │
251
+ │◄────────────────────┼─────────────────────┤
252
+ │ │ │
253
+ ```
254
+
255
+ ## Security
256
+
257
+ - **PKCE** - Prevents authorization code interception
258
+ - **State parameter** - Prevents CSRF attacks
259
+ - **Origin validation** - postMessage only accepted from expected origin
260
+ - **No client secret in browser** - Token exchange happens on your backend
261
+
262
+ ## Related Packages
263
+
264
+ - `@edgeboost/edge-connect-sdk` - Core types and utilities
265
+ - `@edgeboost/edge-connect-server` - Server SDK for token exchange
266
+
267
+ ## License
268
+
269
+ MIT
270
+
271
+
@@ -0,0 +1,409 @@
1
+ import { EdgeEnvironment, EdgeLinkSuccess, EdgeLinkExit, EdgeScope } from '@edge-markets/connect';
2
+ export { ALL_EDGE_SCOPES, EDGE_SCOPES, EdgeEnvironment, EdgeError, EdgeLinkExit, EdgeLinkSuccess, EdgePopupBlockedError, EdgeScope, EdgeStateMismatchError, isEdgeError } from '@edge-markets/connect';
3
+
4
+ /**
5
+ * EdgeLink - Plaid-Style Popup Authentication for EDGE Connect
6
+ *
7
+ * EdgeLink provides a simple, reliable way to authenticate users with EdgeBoost
8
+ * using a popup window - similar to how Plaid Link works.
9
+ *
10
+ * **Key Features:**
11
+ * - Callback-based API (onSuccess, onExit, onEvent)
12
+ * - Handles popup blockers gracefully
13
+ * - PKCE for secure OAuth (no client secret in browser)
14
+ * - Cross-origin communication via postMessage
15
+ * - CSRF protection via state parameter
16
+ *
17
+ * @module @edge-markets/connect-link
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { EdgeLink } from '@edge-markets/connect-link'
22
+ *
23
+ * const link = new EdgeLink({
24
+ * clientId: 'your-client-id',
25
+ * environment: 'staging',
26
+ * onSuccess: (result) => {
27
+ * // Send to your backend for token exchange
28
+ * fetch('/api/edge/exchange', {
29
+ * method: 'POST',
30
+ * body: JSON.stringify({
31
+ * code: result.code,
32
+ * codeVerifier: result.codeVerifier,
33
+ * }),
34
+ * })
35
+ * },
36
+ * onExit: (metadata) => {
37
+ * if (metadata.reason === 'popup_blocked') {
38
+ * alert('Please allow popups for this site')
39
+ * }
40
+ * },
41
+ * })
42
+ *
43
+ * // In a click handler
44
+ * document.getElementById('connect-btn')!.onclick = () => link.open()
45
+ * ```
46
+ */
47
+
48
+ /**
49
+ * Configuration options for EdgeLink.
50
+ *
51
+ * Only `clientId`, `environment`, and `onSuccess` are required.
52
+ * Everything else has sensible defaults.
53
+ */
54
+ interface EdgeLinkConfig {
55
+ /**
56
+ * Your OAuth client ID from the EdgeBoost partner portal.
57
+ * This is public and safe to include in frontend code.
58
+ */
59
+ clientId: string;
60
+ /**
61
+ * Environment to connect to.
62
+ * - `'production'` - Live environment with real money
63
+ * - `'staging'` - Test environment for development
64
+ * - `'sandbox'` - Isolated mock environment (coming soon)
65
+ */
66
+ environment: EdgeEnvironment;
67
+ /**
68
+ * Called when user successfully authenticates and grants consent.
69
+ *
70
+ * Send the `code` and `codeVerifier` to your backend for token exchange.
71
+ * **Never exchange tokens in the frontend** - that would expose your client secret.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * onSuccess: async (result) => {
76
+ * const response = await fetch('/api/edge/exchange', {
77
+ * method: 'POST',
78
+ * headers: { 'Content-Type': 'application/json' },
79
+ * body: JSON.stringify({
80
+ * code: result.code,
81
+ * codeVerifier: result.codeVerifier,
82
+ * }),
83
+ * })
84
+ * if (response.ok) {
85
+ * showSuccess('EdgeBoost connected!')
86
+ * }
87
+ * }
88
+ * ```
89
+ */
90
+ onSuccess: (result: EdgeLinkSuccess) => void;
91
+ /**
92
+ * Called when user exits the Link flow (closes popup, error, etc.).
93
+ *
94
+ * Use this to handle errors and provide user feedback.
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * onExit: (metadata) => {
99
+ * switch (metadata.reason) {
100
+ * case 'user_closed':
101
+ * // User closed popup - maybe show "Connect later" option
102
+ * break
103
+ * case 'popup_blocked':
104
+ * showMessage('Please allow popups and try again')
105
+ * break
106
+ * case 'error':
107
+ * showError(metadata.error?.message || 'Something went wrong')
108
+ * break
109
+ * }
110
+ * }
111
+ * ```
112
+ */
113
+ onExit?: (metadata: EdgeLinkExit) => void;
114
+ /**
115
+ * Called for various events during the Link flow.
116
+ * Useful for analytics and debugging.
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * onEvent: (event) => {
121
+ * analytics.track('edge_link_event', {
122
+ * eventName: event.eventName,
123
+ * timestamp: event.timestamp,
124
+ * })
125
+ * }
126
+ * ```
127
+ */
128
+ onEvent?: (event: EdgeLinkEvent) => void;
129
+ /**
130
+ * OAuth scopes to request.
131
+ * @default All available scopes
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * // Request only what you need
136
+ * scopes: ['user.read', 'balance.read']
137
+ * ```
138
+ */
139
+ scopes?: EdgeScope[];
140
+ /**
141
+ * Custom URL for the Link page.
142
+ * Only use for local development.
143
+ *
144
+ * @default Derived from environment config
145
+ */
146
+ linkUrl?: string;
147
+ /**
148
+ * Custom redirect URI after authentication.
149
+ * Must be registered in your OAuth client settings.
150
+ *
151
+ * @default `${window.location.origin}/oauth/edge/callback`
152
+ */
153
+ redirectUri?: string;
154
+ }
155
+ /**
156
+ * Event emitted during the Link flow.
157
+ */
158
+ interface EdgeLinkEvent {
159
+ /** Name of the event */
160
+ eventName: EdgeLinkEventName;
161
+ /** Unix timestamp when event occurred */
162
+ timestamp: number;
163
+ /** Additional event-specific data */
164
+ metadata?: Record<string, unknown>;
165
+ }
166
+ /**
167
+ * Possible Link events.
168
+ */
169
+ type EdgeLinkEventName = 'OPEN' | 'CLOSE' | 'HANDOFF' | 'TRANSITION' | 'ERROR' | 'SUCCESS';
170
+ /**
171
+ * Options for opening Link.
172
+ */
173
+ interface EdgeLinkOpenOptions {
174
+ /**
175
+ * Override scopes for this open call.
176
+ */
177
+ scopes?: EdgeScope[];
178
+ }
179
+ /**
180
+ * EdgeLink - Simple popup-based authentication for EDGE Connect.
181
+ *
182
+ * Create one instance and reuse it. Call `open()` from a click handler.
183
+ *
184
+ * @example
185
+ * ```typescript
186
+ * // Create instance once
187
+ * const link = new EdgeLink({
188
+ * clientId: 'your-client-id',
189
+ * environment: 'staging',
190
+ * onSuccess: handleSuccess,
191
+ * onExit: handleExit,
192
+ * })
193
+ *
194
+ * // Open from click handler (required for popup to work)
195
+ * connectButton.onclick = () => link.open()
196
+ *
197
+ * // Clean up when done
198
+ * link.destroy()
199
+ * ```
200
+ */
201
+ declare class EdgeLink {
202
+ private readonly config;
203
+ private readonly popup;
204
+ private readonly expectedOrigin;
205
+ private pkce;
206
+ private state;
207
+ private messageHandler;
208
+ private isDestroyed;
209
+ /**
210
+ * Creates a new EdgeLink instance.
211
+ *
212
+ * @param config - Configuration options
213
+ * @throws Error if required config is missing or crypto is unavailable
214
+ */
215
+ constructor(config: EdgeLinkConfig);
216
+ /**
217
+ * Opens the EdgeLink popup.
218
+ *
219
+ * **MUST be called directly from a user click handler!**
220
+ * Calling from setTimeout, Promise.then, or other async contexts will fail.
221
+ *
222
+ * @param options - Optional overrides for this open call
223
+ * @throws EdgePopupBlockedError if popup is blocked
224
+ *
225
+ * @example
226
+ * ```typescript
227
+ * // ✅ Correct - direct click handler
228
+ * button.onclick = () => link.open()
229
+ *
230
+ * // ❌ Wrong - async gap
231
+ * button.onclick = async () => {
232
+ * await someAsyncWork()
233
+ * link.open() // Will be blocked!
234
+ * }
235
+ * ```
236
+ */
237
+ open(options?: EdgeLinkOpenOptions): void;
238
+ /**
239
+ * Closes the EdgeLink popup if open.
240
+ *
241
+ * Call this to programmatically close the popup without triggering onExit.
242
+ */
243
+ close(): void;
244
+ /**
245
+ * Destroys the EdgeLink instance.
246
+ *
247
+ * Call this when unmounting your component or when done with EdgeLink.
248
+ * After destroy(), the instance cannot be reused.
249
+ *
250
+ * @example
251
+ * ```typescript
252
+ * // React cleanup
253
+ * useEffect(() => {
254
+ * const link = new EdgeLink({ ... })
255
+ * return () => link.destroy()
256
+ * }, [])
257
+ * ```
258
+ */
259
+ destroy(): void;
260
+ /**
261
+ * Checks if the popup is currently open.
262
+ */
263
+ isOpen(): boolean;
264
+ /**
265
+ * Initializes PKCE and navigates popup to auth URL.
266
+ */
267
+ private initializeAuth;
268
+ /**
269
+ * Builds the URL for the EdgeLink page.
270
+ *
271
+ * The Link page handles:
272
+ * - User authentication (if not logged in)
273
+ * - Consent UI (showing requested permissions)
274
+ * - OAuth redirect to Cognito
275
+ * - Returning code via postMessage
276
+ */
277
+ private buildLinkUrl;
278
+ /**
279
+ * Sets up the postMessage listener.
280
+ */
281
+ private setupMessageListener;
282
+ /**
283
+ * Removes the postMessage listener.
284
+ */
285
+ private removeMessageListener;
286
+ /**
287
+ * Handles successful authentication from popup.
288
+ */
289
+ private handleSuccess;
290
+ /**
291
+ * Handles exit from popup.
292
+ */
293
+ private handleExit;
294
+ /**
295
+ * Handles user closing the popup manually.
296
+ */
297
+ private handleUserClose;
298
+ /**
299
+ * Emits an event.
300
+ */
301
+ private emitEvent;
302
+ /**
303
+ * Cleans up stored state.
304
+ */
305
+ private cleanup;
306
+ }
307
+
308
+ /**
309
+ * PKCE (Proof Key for Code Exchange) Utilities
310
+ *
311
+ * PKCE is a security extension to OAuth 2.0 that prevents authorization code
312
+ * interception attacks. It's essential for public clients like browser apps
313
+ * where you can't securely store a client secret.
314
+ *
315
+ * How it works:
316
+ * 1. Generate a random `code_verifier` (high entropy secret)
317
+ * 2. Create `code_challenge` = base64url(sha256(code_verifier))
318
+ * 3. Send `code_challenge` in the authorization request
319
+ * 4. Send `code_verifier` in the token exchange
320
+ * 5. Server verifies: sha256(code_verifier) === code_challenge
321
+ *
322
+ * This ensures only the client that started the flow can complete it.
323
+ *
324
+ * @module @edge-markets/connect-link/pkce
325
+ */
326
+ /**
327
+ * A PKCE code verifier and challenge pair.
328
+ *
329
+ * Keep the `verifier` secret until token exchange.
330
+ * Send the `challenge` in the authorization URL.
331
+ */
332
+ interface PKCEPair {
333
+ /**
334
+ * High-entropy random string (43-128 characters).
335
+ * Keep this secret - only send during token exchange.
336
+ */
337
+ verifier: string;
338
+ /**
339
+ * SHA-256 hash of verifier, base64url encoded.
340
+ * This is public - include in authorization URL.
341
+ */
342
+ challenge: string;
343
+ }
344
+ /**
345
+ * Generates a PKCE code verifier and challenge pair.
346
+ *
347
+ * The verifier is a 128-character hex string (64 bytes of entropy).
348
+ * This exceeds the OAuth 2.0 PKCE spec minimum of 43 characters.
349
+ *
350
+ * @returns Promise resolving to verifier and challenge
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * const pkce = await generatePKCE()
355
+ *
356
+ * // Include challenge in authorization URL
357
+ * const authUrl = new URL('https://auth.example.com/authorize')
358
+ * authUrl.searchParams.set('code_challenge', pkce.challenge)
359
+ * authUrl.searchParams.set('code_challenge_method', 'S256')
360
+ *
361
+ * // Later, include verifier in token exchange
362
+ * const tokens = await exchangeCode(code, pkce.verifier)
363
+ * ```
364
+ */
365
+ declare function generatePKCE(): Promise<PKCEPair>;
366
+ /**
367
+ * Generates a random state parameter for CSRF protection.
368
+ *
369
+ * The state parameter prevents cross-site request forgery attacks by ensuring
370
+ * the authorization response came from a request we initiated.
371
+ *
372
+ * @returns 64-character hex string (32 bytes of entropy)
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * const state = generateState()
377
+ *
378
+ * // Store state before redirect
379
+ * sessionStorage.setItem('oauth_state', state)
380
+ *
381
+ * // Include in authorization URL
382
+ * authUrl.searchParams.set('state', state)
383
+ *
384
+ * // After redirect, verify state matches
385
+ * if (responseState !== sessionStorage.getItem('oauth_state')) {
386
+ * throw new Error('State mismatch - possible CSRF attack')
387
+ * }
388
+ * ```
389
+ */
390
+ declare function generateState(): string;
391
+ /**
392
+ * Validates that the Web Crypto API is available.
393
+ *
394
+ * Call this early to fail fast if running in an unsupported environment.
395
+ *
396
+ * @throws Error if Web Crypto API is not available
397
+ *
398
+ * @example
399
+ * ```typescript
400
+ * try {
401
+ * assertCryptoAvailable()
402
+ * } catch {
403
+ * showMessage('Please use a modern browser with HTTPS')
404
+ * }
405
+ * ```
406
+ */
407
+ declare function assertCryptoAvailable(): void;
408
+
409
+ export { EdgeLink, type EdgeLinkConfig, type EdgeLinkEvent, type EdgeLinkEventName, type EdgeLinkOpenOptions, type PKCEPair, assertCryptoAvailable, generatePKCE, generateState };