@tdfc/sunbreak-react 0.1.11 → 0.1.13

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
@@ -15,3 +15,643 @@ Complete integration guides and API references are available at:
15
15
  ```bash
16
16
  npm install @tdfc/sunbreak-react
17
17
  ```
18
+
19
+ ---
20
+
21
+ ## Table of Contents
22
+
23
+ - [Architecture Overview](#architecture-overview)
24
+ - [Quick Start](#quick-start)
25
+ - [Core Concepts](#core-concepts)
26
+ - [Authentication Flows](#authentication-flows)
27
+ - [State Machine](#state-machine)
28
+ - [Autopilot System](#autopilot-system)
29
+ - [Provider Adapters](#provider-adapters)
30
+ - [Cryptographic Layer](#cryptographic-layer)
31
+ - [Storage Layer](#storage-layer)
32
+ - [HTTP Layer](#http-layer)
33
+ - [Public API](#public-api)
34
+ - [Expected Behaviors](#expected-behaviors)
35
+ - [Debugging](#debugging)
36
+
37
+ ---
38
+
39
+ ## Architecture Overview
40
+
41
+ The SDK follows a layered architecture with clear separation of concerns:
42
+
43
+ ```
44
+ +------------------------------------------------------------------+
45
+ | SunbreakProvider |
46
+ | (React Context - Composes all providers and exposes public API) |
47
+ +------------------------------------------------------------------+
48
+ | Autopilot |
49
+ | (9 Effects - Orchestrates lifecycle: probe -> auth -> session) |
50
+ +------------------------------------------------------------------+
51
+ | SessionStateMachine | Auth Hooks | Session Manager |
52
+ | (State transitions) | (register, | (session fetch, |
53
+ | | refresh) | request wrapper) |
54
+ +------------------------------------------------------------------+
55
+ | Crypto Utils | Storage Layer | HTTP Layer |
56
+ | (DPoP, PODE, | (IndexedDB + | (Error classification, |
57
+ | JWK thumbs) | localStorage) | fresh host rotation) |
58
+ +------------------------------------------------------------------+
59
+ ```
60
+
61
+ ### Key Design Principles
62
+
63
+ 1. **Single Source of Truth**: `SessionStateMachine` controls all auth state transitions
64
+ 2. **Proof Isolation**: Proof changes don't trigger re-registration when already authenticated
65
+ 3. **HttpOnly Cookie Awareness**: Session history tracked separately from visible state
66
+ 4. **Race Condition Prevention**: Locks prevent concurrent operations (`refreshLock`, `registerLock`, `probeLock`)
67
+ 5. **Automatic Recovery**: Session failures trigger graceful fallbacks
68
+
69
+ ---
70
+
71
+ ## Quick Start
72
+
73
+ ```tsx
74
+ import { SunbreakProvider, useSunbreak } from "@sunbreak/react";
75
+
76
+ function App() {
77
+ return (
78
+ <SunbreakProvider
79
+ clientId="your-client-id"
80
+ wallet={connectedWalletAddress}
81
+ providerAdapter={{
82
+ name: "privy",
83
+ appId: "your-privy-app-id",
84
+ getToken: () => privy.getAccessToken(),
85
+ }}
86
+ >
87
+ <YourApp />
88
+ </SunbreakProvider>
89
+ );
90
+ }
91
+
92
+ function YourApp() {
93
+ const { authenticated, loading, session, get, post } = useSunbreak();
94
+
95
+ if (loading) return <div>Loading...</div>;
96
+ if (!authenticated) return <div>Please connect your wallet</div>;
97
+
98
+ return <div>Authenticated!</div>;
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Core Concepts
105
+
106
+ ### Proof Types
107
+
108
+ The SDK supports four proof methods for authentication:
109
+
110
+ | Method | Use Case | Fingerprint Format |
111
+ | -------------- | ------------------------------- | ---------------------------- |
112
+ | `provider_jwt` | Privy, Dynamic, Web3Auth tokens | `{issuer}:{sub_claim}` |
113
+ | `siwe` | Sign-In With Ethereum messages | `siwe:{signature_prefix}` |
114
+ | `eip191` | Personal sign messages | `eip191:{signature_prefix}` |
115
+ | `ed25519` | Solana/Ed25519 signatures | `ed25519:{signature_prefix}` |
116
+
117
+ ### Session States
118
+
119
+ The SDK manages five distinct session states:
120
+
121
+ | State | Description | Next Actions |
122
+ | --------------- | ------------------------------------------------ | --------------------- |
123
+ | `UNKNOWN` | Initial state, haven't determined session status | Probe |
124
+ | `UNREGISTERED` | No session history, need proof to register | Register (with proof) |
125
+ | `REFRESHABLE` | Have session history (boundWallet/refreshId) | Refresh first |
126
+ | `AUTHENTICATED` | Have valid access token | Session calls allowed |
127
+ | `EXPIRED` | Refresh failed with "missing refresh identifier" | Register (with proof) |
128
+
129
+ ### Key Terminology
130
+
131
+ - **boundWallet**: The wallet address associated with the current session (persisted)
132
+ - **refreshId**: Identifier for the refresh token (may be in HttpOnly cookie)
133
+ - **proofFingerprint**: Hash of credentials to detect changes
134
+ - **inActiveSession**: Flag preventing re-registration loops
135
+
136
+ ---
137
+
138
+ ## Authentication Flows
139
+
140
+ ### First-Time Registration
141
+
142
+ ```
143
+ 1. Wallet connects
144
+ 2. Probe runs -> warms DPoP nonce cache
145
+ 3. State machine initializes -> UNREGISTERED (no session history)
146
+ 4. Proof becomes available (via provider adapter or prop)
147
+ 5. shouldAttemptRegister() returns true
148
+ 6. Register request sent with:
149
+ - DPoP token (signed with ephemeral key)
150
+ - PODE (Proof of Delegation from device root key)
151
+ - Proof payload
152
+ 7. Success -> AUTHENTICATED, boundWallet saved, fingerprint stored
153
+ 8. Session call fetches allowed/expiry data
154
+ ```
155
+
156
+ ### Returning User (Page Refresh)
157
+
158
+ ```
159
+ 1. Page loads
160
+ 2. MetaProvider hydrates from IndexedDB/localStorage
161
+ 3. Probe runs -> warms nonce cache
162
+ 4. State machine initializes -> REFRESHABLE (has boundWallet/refreshId)
163
+ 5. shouldAttemptRefresh() returns true
164
+ 6. Refresh request sent with:
165
+ - DPoP token
166
+ - boundWallet (from storage, even if wallet not connected yet)
167
+ - HttpOnly refresh cookie
168
+ 7. Success -> AUTHENTICATED
169
+ 8. Session call runs
170
+ ```
171
+
172
+ ### Credential Change Detection
173
+
174
+ When a user switches accounts (e.g., logs into a different Privy account):
175
+
176
+ ```
177
+ 1. New token arrives from provider adapter
178
+ 2. Fingerprint computed: {issuer}:{sub_claim}
179
+ 3. Compared against stored registeredProofId
180
+ 4. If different:
181
+ - onNewCredentialsReceived() called
182
+ - inActiveSession set to false
183
+ - State transitions to UNREGISTERED
184
+ - Re-registration allowed
185
+ 5. If same:
186
+ - Registration blocked (prevents loops)
187
+ ```
188
+
189
+ ### Wallet Change Handling
190
+
191
+ **Same wallet reconnects** (matches boundWallet):
192
+
193
+ - State -> `REFRESHABLE`
194
+ - Refresh attempted using existing session
195
+
196
+ **Different wallet connects**:
197
+
198
+ - State -> `UNREGISTERED`
199
+ - Auth state cleared
200
+ - Key rotation triggered
201
+ - Re-registration required
202
+
203
+ **Wallet disconnects**:
204
+
205
+ - State -> `UNKNOWN`
206
+ - All auth state cleared
207
+
208
+ ---
209
+
210
+ ## State Machine
211
+
212
+ The `SessionStateMachine` is the single source of truth for authentication state.
213
+
214
+ ### State Transition Diagram
215
+
216
+ ```
217
+ +--------------------------------------+
218
+ | |
219
+ v |
220
+ +---------+ |
221
+ | UNKNOWN | <--- Wallet disconnect |
222
+ +----+----+ |
223
+ | |
224
+ | Probe completes |
225
+ | |
226
+ +----------+----------+ |
227
+ | | |
228
+ v v |
229
+ +--------------+ +-------------+ |
230
+ | UNREGISTERED | | REFRESHABLE | <-- Token expired |
231
+ +------+-------+ +------+------+ |
232
+ | | |
233
+ | Register | Refresh |
234
+ | succeeds | succeeds |
235
+ | | |
236
+ +-------+-----------+ |
237
+ | |
238
+ v |
239
+ +---------------+ |
240
+ | AUTHENTICATED |-------------------------------->+
241
+ +---------------+
242
+ |
243
+ | Refresh fails
244
+ | (missing refresh identifier)
245
+ v
246
+ +---------+
247
+ | EXPIRED | --- Can register again with proof
248
+ +---------+
249
+ ```
250
+
251
+ ### Decision Methods
252
+
253
+ | Method | Returns `true` when |
254
+ | ---------------------------------- | ------------------------------------------------------------------------------- |
255
+ | `shouldAttemptProbe()` | State is `UNKNOWN` |
256
+ | `shouldAttemptRefresh(ctx)` | State is `REFRESHABLE` or `AUTHENTICATED`, wallet available |
257
+ | `shouldAttemptRegister(ctx)` | State is `UNREGISTERED` or `EXPIRED`, not in active session, has wallet + proof |
258
+ | `shouldWaitForInitialRefresh(...)` | Returning user, refresh not yet attempted |
259
+
260
+ ### Key Flags
261
+
262
+ - **`inActiveSession`**: Set `true` after successful register/refresh; prevents re-registration
263
+ - **`hadSessionHistory`**: Tracks if user ever had a session; survives wallet changes
264
+
265
+ ---
266
+
267
+ ## Autopilot System
268
+
269
+ The autopilot orchestrates the SDK lifecycle through 9 React effects:
270
+
271
+ ### Effect #0: Probe + State Machine Initialize
272
+
273
+ - **Trigger**: `metaReady` becomes true
274
+ - **Actions**: Initialize state machine, run probe request
275
+ - **Guards**: Three-layer protection against double-probing (probeLock, hasProbedRef, pageProbeGuard)
276
+
277
+ ### Effect #1: Wallet Change Handling
278
+
279
+ - **Trigger**: `st.wallet` changes
280
+ - **Actions**:
281
+ - Disconnect: Clear all auth state, transition to UNKNOWN
282
+ - Rotation: Clear state, rotate keys, update state machine
283
+ - Reconnection: Check if matches boundWallet
284
+
285
+ ### Effect #2: Provider Adapter Token -> Proof
286
+
287
+ - **Trigger**: Provider adapter available + wallet connected
288
+ - **Actions**: Fetch token, compute fingerprint, detect credential changes, attempt register
289
+ - **Guards**: Cooldown, metaReady, wallet presence
290
+
291
+ ### Effect #3: Proof Prop -> Register
292
+
293
+ - **Trigger**: `proofProp` changes
294
+ - **Actions**: Similar to Effect #2 but for direct proof props
295
+ - **Guards**: Same as Effect #2
296
+
297
+ ### Effect #4: Initial Refresh
298
+
299
+ - **Trigger**: `metaReady` + state machine decides refresh
300
+ - **Actions**: Wait for probe, attempt refresh, call session on success
301
+
302
+ ### Effect #5: Init Resolved
303
+
304
+ - **Trigger**: Various initialization conditions
305
+ - **Actions**: Mark initialization complete, resolve init barrier
306
+
307
+ ### Effect #6: Session After Auth
308
+
309
+ - **Trigger**: `authenticated` becomes true
310
+ - **Actions**: Call session to fetch allowed/expiry data
311
+ - **Guards**: didInitialSession prevents double calls
312
+
313
+ ### Effect #7: Wallet Mismatch Reset
314
+
315
+ - **Trigger**: wallet != authWalletRef
316
+ - **Actions**: Clear auth if wallet changed after authentication
317
+
318
+ ### Effect #8: Refresh on Focus
319
+
320
+ - **Trigger**: Window gains focus + session near expiry
321
+ - **Actions**: Refresh token, call session
322
+
323
+ ---
324
+
325
+ ## Provider Adapters
326
+
327
+ Provider adapters bridge authentication providers to the SDK:
328
+
329
+ ### Privy Adapter
330
+
331
+ ```typescript
332
+ const privyAdapter = {
333
+ name: "privy",
334
+ appId: "your-privy-app-id",
335
+ getToken: () => privy.getAccessToken(),
336
+ };
337
+ ```
338
+
339
+ ### Dynamic Adapter
340
+
341
+ ```typescript
342
+ const dynamicAdapter = {
343
+ name: "dynamic",
344
+ envId: "your-dynamic-env-id",
345
+ expectedAud: "optional-audience",
346
+ getToken: () => dynamic.getToken(),
347
+ };
348
+ ```
349
+
350
+ ### Custom Adapter
351
+
352
+ ```typescript
353
+ const customAdapter = {
354
+ name: "custom",
355
+ meta: {
356
+ /* your metadata */
357
+ },
358
+ getToken: () => yourProvider.getToken(),
359
+ };
360
+ ```
361
+
362
+ ### Token -> Proof Conversion
363
+
364
+ The SDK automatically converts provider tokens to proof objects:
365
+
366
+ ```typescript
367
+ // Input: JWT token from adapter
368
+ const token = await adapter.getToken();
369
+
370
+ // Output: ProviderJwtProof
371
+ const proof = {
372
+ method: "provider_jwt",
373
+ issuer: "privy",
374
+ token: token,
375
+ meta: { app_id: adapter.appId },
376
+ };
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Cryptographic Layer
382
+
383
+ ### DPoP (Demonstration of Proof-of-Possession)
384
+
385
+ DPoP tokens prove possession of a private key without revealing it:
386
+
387
+ ```typescript
388
+ const dpop = await createDpop({
389
+ method: "POST",
390
+ url: "https://api.sunbreak.com/auth/register",
391
+ nonce: cachedNonce, // From previous response
392
+ privateKey: ephemeralPrivateKey,
393
+ publicJwk: ephemeralPublicJwk,
394
+ });
395
+ ```
396
+
397
+ **Soft Nonce Caching**: The SDK caches DPoP nonces per endpoint, reducing round trips.
398
+
399
+ ### PODE (Proof of Delegation)
400
+
401
+ PODE proves that the ephemeral key was authorized by a device root key:
402
+
403
+ ```typescript
404
+ const pode = await createPode({
405
+ rootPrivateKey,
406
+ rootPublicJwk,
407
+ childJkt: thumbprint(ephemeralPublicJwk),
408
+ clientId: "your-client-id",
409
+ sid: sessionId,
410
+ ttlSec: 300,
411
+ });
412
+ ```
413
+
414
+ **Root Key Persistence**: The device root key survives across sessions for continuity.
415
+
416
+ ### JWK Thumbprints
417
+
418
+ Key thumbprints (JKT) uniquely identify public keys:
419
+
420
+ ```typescript
421
+ const jkt = await ecP256ThumbprintJkt(publicJwk);
422
+ // Returns: Base64url-encoded SHA-256 hash of canonical JWK
423
+ ```
424
+
425
+ ---
426
+
427
+ ## Storage Layer
428
+
429
+ ### Dual-Layer Persistence
430
+
431
+ | Storage | Priority | Use Case |
432
+ | ------------ | -------- | -------------------------------------- |
433
+ | IndexedDB | Primary | Full metadata storage, survives longer |
434
+ | localStorage | Fallback | Sync operations, quick access |
435
+
436
+ ### Stored Metadata (Meta)
437
+
438
+ ```typescript
439
+ type Meta = {
440
+ boundWallet: string | null; // Wallet bound to session
441
+ clientId: string | null; // Application client ID
442
+ jkt: string | null; // Ephemeral key thumbprint
443
+ refreshId: string | null; // Refresh token identifier
444
+ lastPolicyHash: string | null; // For policy caching
445
+ lastPolicyProof: string | null; // Policy signature
446
+ lastHost: string | null; // Last successful API host
447
+ rootJkt: string | null; // Device root key thumbprint
448
+ registeredProofId: string | null; // Proof fingerprint for change detection
449
+ };
450
+ ```
451
+
452
+ ### Storage Key Format
453
+
454
+ ```
455
+ sunbreak:meta:{clientId} // Client-specific
456
+ sunbreak:meta // Legacy fallback
457
+ sunbreak:keypair // Ephemeral keypair
458
+ sunbreak:rootkeypair // Device root keypair
459
+ ```
460
+
461
+ ---
462
+
463
+ ## HTTP Layer
464
+
465
+ ### Request Flow
466
+
467
+ ```
468
+ 1. Request initiated (get/post)
469
+ 2. awaitKeyStable() - Wait for any key rotation
470
+ 3. awaitProbe() - Ensure probe completed
471
+ 4. ensureKeypair() - Generate key if needed
472
+ 5. Create DPoP token
473
+ 6. Build X-Sunbreak-Meta header
474
+ 7. Attach authorization (if authenticated)
475
+ 8. Send request
476
+ 9. Handle 401 -> Retry with new nonce
477
+ 10. Parse response, update nonce cache
478
+ ```
479
+
480
+ ### Error Classification
481
+
482
+ | Code | Classification | SDK Behavior |
483
+ | --------- | -------------- | ---------------------------------- |
484
+ | 401 | Auth expired | Retry with new nonce, then refresh |
485
+ | 403 | Forbidden | Return error (rate limit possible) |
486
+ | 429 | Rate limited | Set cooldown, return error |
487
+ | 503 | Unavailable | May indicate WAF block |
488
+ | Other 5xx | Server error | Return error |
489
+
490
+ ### Fresh Host Rotation
491
+
492
+ When the primary host fails (WAF/ALB issues), the SDK rotates to a fresh subdomain:
493
+
494
+ ```typescript
495
+ // Primary: api.sunbreak.com
496
+ // Fallback: api-{random}.sunbreak.com
497
+ ```
498
+
499
+ ---
500
+
501
+ ## Public API
502
+
503
+ ### SunbreakContextType
504
+
505
+ ```typescript
506
+ interface SunbreakContextType {
507
+ // HTTP Methods
508
+ get: <T>(path: string, opts?: RequestInit) => Promise<T | undefined>;
509
+ post: <T>(
510
+ path: string,
511
+ body?: unknown,
512
+ opts?: RequestInit
513
+ ) => Promise<T | undefined>;
514
+
515
+ // Session
516
+ session: () => Promise<SessionResp | undefined>;
517
+ refresh: () => Promise<boolean>;
518
+
519
+ // State
520
+ authenticated: boolean;
521
+ loading: boolean;
522
+ error: string | null;
523
+ allowed: boolean | null;
524
+ sessionExpiry: number | null;
525
+ sessionData: SessionResp | null;
526
+ wallet?: string;
527
+ }
528
+ ```
529
+
530
+ ### useSunbreak Hook
531
+
532
+ ```typescript
533
+ const {
534
+ authenticated, // true when session is active
535
+ loading, // true during any auth operation
536
+ error, // Error message if any
537
+ allowed, // From session response (app-specific)
538
+ sessionExpiry, // Unix timestamp of session expiry
539
+ sessionData, // Full session response
540
+ get, // Authenticated GET request
541
+ post, // Authenticated POST request
542
+ session, // Manually fetch session
543
+ refresh, // Manually trigger refresh
544
+ } = useSunbreak();
545
+ ```
546
+
547
+ ---
548
+
549
+ ## Expected Behaviors
550
+
551
+ ### First Load (New User)
552
+
553
+ 1. Provider shows loading briefly
554
+ 2. Probe request fires
555
+ 3. User connects wallet
556
+ 4. Authentication proof generated
557
+ 5. Register request succeeds
558
+ 6. Session fetched
559
+ 7. `authenticated` becomes `true`
560
+
561
+ ### Page Refresh (Returning User)
562
+
563
+ 1. Provider shows loading
564
+ 2. Meta loaded from storage
565
+ 3. Probe fires
566
+ 4. Refresh attempted with boundWallet
567
+ 5. Session fetched
568
+ 6. `authenticated` becomes `true`
569
+ 7. Wallet may connect later (OK - session already active)
570
+
571
+ ### Wallet Switch
572
+
573
+ 1. State cleared immediately
574
+ 2. Keys rotated
575
+ 3. If same wallet as boundWallet: refresh attempted
576
+ 4. If different wallet: requires new proof to register
577
+
578
+ ### Session Expiry
579
+
580
+ 1. On window focus near expiry: auto-refresh
581
+ 2. If refresh succeeds: session updated
582
+ 3. If refresh fails: may need re-registration
583
+
584
+ ### Provider Account Switch
585
+
586
+ 1. New token detected via fingerprint comparison
587
+ 2. `onNewCredentialsReceived()` called
588
+ 3. Re-registration permitted
589
+ 4. New session established
590
+
591
+ ---
592
+
593
+ ## Debugging
594
+
595
+ ### Enable Debug Logging
596
+
597
+ ```tsx
598
+ <SunbreakProvider
599
+ clientId="your-client-id"
600
+ wallet={wallet}
601
+ debug={true} // Enables verbose console logging
602
+ >
603
+ ```
604
+
605
+ ### Logger Methods
606
+
607
+ The SDK uses structured logging with these categories:
608
+
609
+ | Method | Use Case |
610
+ | ------------------------------------------- | ------------------------------------------- |
611
+ | `logger.flow(name, msg)` | Auth flow events (probe, register, refresh) |
612
+ | `logger.api(method, path, info)` | HTTP request/response |
613
+ | `logger.guard(name, passed, reason)` | Guard conditions |
614
+ | `logger.decision(question, answer, reason)` | State machine decisions |
615
+ | `logger.state(from, to, reason)` | State transitions |
616
+
617
+ ### State Machine Report
618
+
619
+ ```typescript
620
+ const report = stateMachine.getStateReport(context);
621
+ console.log(report);
622
+ // +-------------------------------------------+
623
+ // | Session State Machine Report |
624
+ // +-------------------------------------------+
625
+ // | Current State: authenticated |
626
+ // | Previous State: refreshable |
627
+ // | Active Session: true |
628
+ // | Had History: true |
629
+ // +-------------------------------------------+
630
+ ```
631
+
632
+ ### Common Issues
633
+
634
+ **"Stuck in loading"**
635
+
636
+ - Check if wallet is connected
637
+ - Check if provider adapter is returning tokens
638
+ - Verify `metaReady` is becoming true
639
+ - Check browser console for probe/register errors
640
+
641
+ **"Re-registration loops"**
642
+
643
+ - Verify proof fingerprint is consistent
644
+ - Check `inActiveSession` flag
645
+ - Ensure provider isn't returning different tokens on each call
646
+
647
+ **"Session not fetching"**
648
+
649
+ - Check `didInitialSession` flag
650
+ - Verify `authenticated` is true
651
+ - Check wallet matches boundWallet
652
+
653
+ ---
654
+
655
+ ## License
656
+
657
+ MIT