@syncular/client-plugin-offline-auth 0.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/src/index.ts ADDED
@@ -0,0 +1,1126 @@
1
+ import type {
2
+ SyncAuthErrorContext,
3
+ SyncAuthLifecycle,
4
+ SyncAuthRetryContext,
5
+ SyncIdentityBase,
6
+ } from '@syncular/core';
7
+
8
+ export type MaybePromise<T> = T | Promise<T>;
9
+
10
+ export interface OfflineAuthStorage {
11
+ getItem(key: string): MaybePromise<string | null>;
12
+ setItem(key: string, value: string): MaybePromise<void>;
13
+ removeItem(key: string): MaybePromise<void>;
14
+ }
15
+
16
+ export interface OfflineSubjectIdentity extends SyncIdentityBase {
17
+ teamId?: string | null;
18
+ }
19
+
20
+ export interface OfflineAuthCachedValue<TValue> {
21
+ value: TValue;
22
+ savedAtMs: number;
23
+ expiresAtMs: number | null;
24
+ }
25
+
26
+ export interface OfflineAuthState<
27
+ TSession,
28
+ TIdentity extends OfflineSubjectIdentity,
29
+ > {
30
+ version: typeof OFFLINE_AUTH_STATE_VERSION;
31
+ session: OfflineAuthCachedValue<TSession> | null;
32
+ identity: OfflineAuthCachedValue<TIdentity> | null;
33
+ lastActorId: string | null;
34
+ }
35
+
36
+ export interface OfflineAuthStateCodec<
37
+ TSession,
38
+ TIdentity extends OfflineSubjectIdentity,
39
+ > {
40
+ parseSession(value: unknown): TSession | null;
41
+ parseIdentity(value: unknown): TIdentity | null;
42
+ }
43
+
44
+ export interface LoadOfflineAuthStateOptions<
45
+ TSession,
46
+ TIdentity extends OfflineSubjectIdentity,
47
+ > {
48
+ storage: OfflineAuthStorage;
49
+ codec: OfflineAuthStateCodec<TSession, TIdentity>;
50
+ storageKey?: string;
51
+ }
52
+
53
+ export interface SaveOfflineAuthStateOptions {
54
+ storage: OfflineAuthStorage;
55
+ storageKey?: string;
56
+ }
57
+
58
+ export interface CreateSessionCacheEntryOptions<TSession> {
59
+ nowMs?: number;
60
+ skewMs?: number;
61
+ allowMissingExpiry?: boolean;
62
+ getExpiresAtMs?: (session: TSession) => number | null | undefined;
63
+ getJwt?: (session: TSession) => string | null | undefined;
64
+ }
65
+
66
+ export interface CreateIdentityCacheEntryOptions<TIdentity> {
67
+ nowMs?: number;
68
+ getExpiresAtMs?: (identity: TIdentity) => number | null | undefined;
69
+ }
70
+
71
+ export interface PersistOnlineSessionOptions<
72
+ TSession,
73
+ TIdentity extends OfflineSubjectIdentity,
74
+ > extends CreateSessionCacheEntryOptions<TSession> {
75
+ state: OfflineAuthState<TSession, TIdentity>;
76
+ session: TSession | null;
77
+ getSessionActorId: (session: TSession) => string | null | undefined;
78
+ deriveIdentity?: (session: TSession) => TIdentity | null | undefined;
79
+ getIdentityExpiresAtMs?: (identity: TIdentity) => number | null | undefined;
80
+ }
81
+
82
+ export interface PersistOfflineIdentityOptions<
83
+ TSession,
84
+ TIdentity extends OfflineSubjectIdentity,
85
+ > extends CreateIdentityCacheEntryOptions<TIdentity> {
86
+ state: OfflineAuthState<TSession, TIdentity>;
87
+ identity: TIdentity | null;
88
+ }
89
+
90
+ export interface ResolveOfflineAuthSubjectOptions<
91
+ TSession,
92
+ TIdentity extends OfflineSubjectIdentity,
93
+ > {
94
+ state: OfflineAuthState<TSession, TIdentity>;
95
+ nowMs?: number;
96
+ skewMs?: number;
97
+ getSessionActorId: (session: TSession) => string | null | undefined;
98
+ getSessionTeamId?: (session: TSession) => string | null | undefined;
99
+ getIdentityActorId?: (identity: TIdentity) => string | null | undefined;
100
+ getIdentityTeamId?: (identity: TIdentity) => string | null | undefined;
101
+ }
102
+
103
+ export type OfflineAuthSubjectSource =
104
+ | 'online-session'
105
+ | 'offline-identity'
106
+ | 'last-actor'
107
+ | 'none';
108
+
109
+ export interface OfflineResolvedSubject<
110
+ TSession,
111
+ TIdentity extends OfflineSubjectIdentity,
112
+ > {
113
+ source: OfflineAuthSubjectSource;
114
+ actorId: string | null;
115
+ teamId: string | null;
116
+ isOffline: boolean;
117
+ session: OfflineAuthCachedValue<TSession> | null;
118
+ identity: OfflineAuthCachedValue<TIdentity> | null;
119
+ }
120
+
121
+ export interface TokenRetryContext extends SyncAuthRetryContext {
122
+ previousToken: string | null;
123
+ nextToken: string | null;
124
+ }
125
+
126
+ export interface CreateTokenLifecycleBridgeOptions {
127
+ resolveToken: () => MaybePromise<string | null | undefined>;
128
+ refreshToken?: (
129
+ context: SyncAuthErrorContext
130
+ ) => MaybePromise<string | null | undefined>;
131
+ onAuthExpired?: (context: SyncAuthErrorContext) => MaybePromise<void>;
132
+ retryWithFreshToken?: (context: TokenRetryContext) => MaybePromise<boolean>;
133
+ }
134
+
135
+ export interface TokenLifecycleBridge {
136
+ resolveToken: () => Promise<string | null>;
137
+ getAuthorizationHeaders: () => Promise<Record<string, string>>;
138
+ getRealtimeParams: (paramName?: string) => Promise<Record<string, string>>;
139
+ authLifecycle: SyncAuthLifecycle;
140
+ }
141
+
142
+ export interface OfflineLockPolicyOptions {
143
+ now?: () => number;
144
+ initiallyLocked?: boolean;
145
+ maxFailedAttempts?: number;
146
+ cooldownMs?: number;
147
+ idleTimeoutMs?: number;
148
+ }
149
+
150
+ export interface OfflineLockState {
151
+ isLocked: boolean;
152
+ failedAttempts: number;
153
+ blockedUntilMs: number | null;
154
+ lastActivityAtMs: number;
155
+ }
156
+
157
+ type OfflineLockEventBase = {
158
+ nowMs?: number;
159
+ };
160
+
161
+ export type OfflineLockEvent =
162
+ | ({ type: 'lock' } & OfflineLockEventBase)
163
+ | ({ type: 'unlock' } & OfflineLockEventBase)
164
+ | ({ type: 'activity' } & OfflineLockEventBase)
165
+ | ({ type: 'failed-unlock' } & OfflineLockEventBase)
166
+ | ({ type: 'reset-failures' } & OfflineLockEventBase)
167
+ | ({ type: 'tick' } & OfflineLockEventBase);
168
+
169
+ export type OfflineUnlockFailureReason = 'blocked' | 'rejected';
170
+
171
+ export interface OfflineUnlockResult {
172
+ ok: boolean;
173
+ reason: OfflineUnlockFailureReason | null;
174
+ state: OfflineLockState;
175
+ }
176
+
177
+ export interface AttemptOfflineUnlockArgs {
178
+ state: OfflineLockState;
179
+ verify: () => boolean;
180
+ options?: OfflineLockPolicyOptions;
181
+ nowMs?: number;
182
+ }
183
+
184
+ export interface AttemptOfflineUnlockAsyncArgs {
185
+ state: OfflineLockState;
186
+ verify: () => MaybePromise<boolean>;
187
+ options?: OfflineLockPolicyOptions;
188
+ nowMs?: number;
189
+ }
190
+
191
+ export interface OfflineLockController {
192
+ getState(): OfflineLockState;
193
+ replaceState(nextState: OfflineLockState): OfflineLockState;
194
+ dispatch(event: OfflineLockEvent): OfflineLockState;
195
+ lock(): OfflineLockState;
196
+ forceUnlock(): OfflineUnlockResult;
197
+ recordActivity(): OfflineLockState;
198
+ recordFailedUnlock(): OfflineLockState;
199
+ resetFailures(): OfflineLockState;
200
+ evaluateIdleTimeout(): OfflineLockState;
201
+ attemptUnlock(verify: () => boolean): OfflineUnlockResult;
202
+ attemptUnlockAsync(
203
+ verify: () => MaybePromise<boolean>
204
+ ): Promise<OfflineUnlockResult>;
205
+ }
206
+
207
+ export const OFFLINE_AUTH_STATE_VERSION = 1;
208
+ export const DEFAULT_OFFLINE_AUTH_STORAGE_KEY = 'syncular-offline-auth-v1';
209
+ export const DEFAULT_EXPIRY_SKEW_MS = 30_000;
210
+ export const DEFAULT_MAX_FAILED_ATTEMPTS = 5;
211
+ export const DEFAULT_LOCK_COOLDOWN_MS = 30_000;
212
+
213
+ interface NormalizedLockPolicy {
214
+ now: () => number;
215
+ maxFailedAttempts: number;
216
+ cooldownMs: number;
217
+ idleTimeoutMs: number;
218
+ }
219
+
220
+ function isRecord(value: unknown): value is Record<string, unknown> {
221
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
222
+ }
223
+
224
+ function readFiniteNumber(value: unknown): number | null {
225
+ if (typeof value !== 'number') return null;
226
+ if (!Number.isFinite(value)) return null;
227
+ return value;
228
+ }
229
+
230
+ function readNullableFiniteNumber(value: unknown): number | null | undefined {
231
+ if (value === null) return null;
232
+ const parsed = readFiniteNumber(value);
233
+ if (parsed === null) return undefined;
234
+ return parsed;
235
+ }
236
+
237
+ function readTrimmedString(value: unknown): string | null {
238
+ if (typeof value !== 'string') return null;
239
+ const trimmed = value.trim();
240
+ return trimmed.length > 0 ? trimmed : null;
241
+ }
242
+
243
+ function readNullableTrimmedString(value: unknown): string | null | undefined {
244
+ if (value === null) return null;
245
+ const parsed = readTrimmedString(value);
246
+ if (parsed === null) return undefined;
247
+ return parsed;
248
+ }
249
+
250
+ function parseCachedValue<TValue>(
251
+ value: unknown,
252
+ parseValue: (value: unknown) => TValue | null
253
+ ): OfflineAuthCachedValue<TValue> | null {
254
+ if (value === null || value === undefined) return null;
255
+ if (!isRecord(value)) return null;
256
+
257
+ const parsedValue = parseValue(value.value);
258
+ if (parsedValue === null) return null;
259
+
260
+ const savedAtMs = readFiniteNumber(value.savedAtMs);
261
+ if (savedAtMs === null) return null;
262
+
263
+ const expiresAtMs = readNullableFiniteNumber(value.expiresAtMs);
264
+ if (expiresAtMs === undefined) return null;
265
+
266
+ return {
267
+ value: parsedValue,
268
+ savedAtMs,
269
+ expiresAtMs,
270
+ };
271
+ }
272
+
273
+ function toNowMs(
274
+ nowMs: number | undefined,
275
+ now: (() => number) | undefined
276
+ ): number {
277
+ if (typeof nowMs === 'number' && Number.isFinite(nowMs)) {
278
+ return nowMs;
279
+ }
280
+ if (now) {
281
+ return now();
282
+ }
283
+ return Date.now();
284
+ }
285
+
286
+ function normalizeLockPolicy(
287
+ options: OfflineLockPolicyOptions | undefined
288
+ ): NormalizedLockPolicy {
289
+ const maxFailedAttempts = Math.max(
290
+ 1,
291
+ Math.floor(options?.maxFailedAttempts ?? DEFAULT_MAX_FAILED_ATTEMPTS)
292
+ );
293
+ const cooldownMs = Math.max(
294
+ 0,
295
+ Math.floor(options?.cooldownMs ?? DEFAULT_LOCK_COOLDOWN_MS)
296
+ );
297
+ const idleTimeoutMs = Math.max(0, Math.floor(options?.idleTimeoutMs ?? 0));
298
+
299
+ return {
300
+ now: options?.now ?? (() => Date.now()),
301
+ maxFailedAttempts,
302
+ cooldownMs,
303
+ idleTimeoutMs,
304
+ };
305
+ }
306
+
307
+ function normalizeLockStateForTime(
308
+ state: OfflineLockState,
309
+ nowMs: number
310
+ ): OfflineLockState {
311
+ if (state.blockedUntilMs === null) return state;
312
+ if (state.blockedUntilMs > nowMs) return state;
313
+
314
+ return {
315
+ ...state,
316
+ blockedUntilMs: null,
317
+ failedAttempts: 0,
318
+ };
319
+ }
320
+
321
+ function decodeBase64UrlSegment(segment: string): string | null {
322
+ const normalized = segment.replace(/-/g, '+').replace(/_/g, '/');
323
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
324
+ const base64 = `${normalized}${padding}`;
325
+
326
+ try {
327
+ if (typeof Buffer !== 'undefined') {
328
+ return Buffer.from(base64, 'base64').toString('utf8');
329
+ }
330
+
331
+ if (typeof atob === 'function') {
332
+ return atob(base64);
333
+ }
334
+ } catch {
335
+ return null;
336
+ }
337
+
338
+ return null;
339
+ }
340
+
341
+ function parseJwtPayload(token: string): Record<string, unknown> | null {
342
+ const segments = token.split('.');
343
+ if (segments.length < 2) return null;
344
+
345
+ const payloadSegment = segments[1];
346
+ if (!payloadSegment) return null;
347
+
348
+ const decoded = decodeBase64UrlSegment(payloadSegment);
349
+ if (!decoded) return null;
350
+
351
+ try {
352
+ const parsed: unknown = JSON.parse(decoded);
353
+ if (!isRecord(parsed)) return null;
354
+ return parsed;
355
+ } catch {
356
+ return null;
357
+ }
358
+ }
359
+
360
+ export function createWebStorageAdapter(storage: {
361
+ getItem(key: string): string | null;
362
+ setItem(key: string, value: string): void;
363
+ removeItem(key: string): void;
364
+ }): OfflineAuthStorage {
365
+ return {
366
+ getItem(key) {
367
+ return storage.getItem(key);
368
+ },
369
+ setItem(key, value) {
370
+ storage.setItem(key, value);
371
+ },
372
+ removeItem(key) {
373
+ storage.removeItem(key);
374
+ },
375
+ };
376
+ }
377
+
378
+ export function createMemoryStorageAdapter(
379
+ initialEntries?: Record<string, string>
380
+ ): OfflineAuthStorage {
381
+ const state = new Map<string, string>(Object.entries(initialEntries ?? {}));
382
+
383
+ return {
384
+ getItem(key) {
385
+ return state.get(key) ?? null;
386
+ },
387
+ setItem(key, value) {
388
+ state.set(key, value);
389
+ },
390
+ removeItem(key) {
391
+ state.delete(key);
392
+ },
393
+ };
394
+ }
395
+
396
+ export function isOfflineSubjectIdentity(
397
+ value: unknown
398
+ ): value is OfflineSubjectIdentity {
399
+ if (!isRecord(value)) return false;
400
+ const actorId = readTrimmedString(value.actorId);
401
+ if (!actorId) return false;
402
+
403
+ const teamId = value.teamId;
404
+ if (teamId === undefined || teamId === null) return true;
405
+ return readTrimmedString(teamId) !== null;
406
+ }
407
+
408
+ export function createEmptyOfflineAuthState<
409
+ TSession,
410
+ TIdentity extends OfflineSubjectIdentity,
411
+ >(): OfflineAuthState<TSession, TIdentity> {
412
+ return {
413
+ version: OFFLINE_AUTH_STATE_VERSION,
414
+ session: null,
415
+ identity: null,
416
+ lastActorId: null,
417
+ };
418
+ }
419
+
420
+ export async function loadOfflineAuthState<
421
+ TSession,
422
+ TIdentity extends OfflineSubjectIdentity,
423
+ >(
424
+ options: LoadOfflineAuthStateOptions<TSession, TIdentity>
425
+ ): Promise<OfflineAuthState<TSession, TIdentity>> {
426
+ const storageKey = options.storageKey ?? DEFAULT_OFFLINE_AUTH_STORAGE_KEY;
427
+ const fallback = createEmptyOfflineAuthState<TSession, TIdentity>();
428
+
429
+ let rawValue: string | null = null;
430
+ try {
431
+ rawValue = await options.storage.getItem(storageKey);
432
+ } catch {
433
+ return fallback;
434
+ }
435
+
436
+ if (!rawValue) {
437
+ return fallback;
438
+ }
439
+
440
+ let parsed: unknown;
441
+ try {
442
+ parsed = JSON.parse(rawValue);
443
+ } catch {
444
+ return fallback;
445
+ }
446
+
447
+ if (!isRecord(parsed)) {
448
+ return fallback;
449
+ }
450
+
451
+ const version = readFiniteNumber(parsed.version);
452
+ if (version !== OFFLINE_AUTH_STATE_VERSION) {
453
+ return fallback;
454
+ }
455
+
456
+ const session = parseCachedValue(parsed.session, options.codec.parseSession);
457
+ const identity = parseCachedValue(
458
+ parsed.identity,
459
+ options.codec.parseIdentity
460
+ );
461
+ const lastActorId = readNullableTrimmedString(parsed.lastActorId);
462
+
463
+ return {
464
+ version: OFFLINE_AUTH_STATE_VERSION,
465
+ session,
466
+ identity,
467
+ lastActorId: lastActorId ?? null,
468
+ };
469
+ }
470
+
471
+ export async function saveOfflineAuthState<
472
+ TSession,
473
+ TIdentity extends OfflineSubjectIdentity,
474
+ >(
475
+ state: OfflineAuthState<TSession, TIdentity>,
476
+ options: SaveOfflineAuthStateOptions
477
+ ): Promise<void> {
478
+ const storageKey = options.storageKey ?? DEFAULT_OFFLINE_AUTH_STORAGE_KEY;
479
+ const payload = JSON.stringify(state);
480
+ await options.storage.setItem(storageKey, payload);
481
+ }
482
+
483
+ export async function removeOfflineAuthState(
484
+ options: SaveOfflineAuthStateOptions
485
+ ): Promise<void> {
486
+ const storageKey = options.storageKey ?? DEFAULT_OFFLINE_AUTH_STORAGE_KEY;
487
+ await options.storage.removeItem(storageKey);
488
+ }
489
+
490
+ export function getJwtExpiryMs(token: string): number | null {
491
+ const payload = parseJwtPayload(token);
492
+ if (!payload) return null;
493
+
494
+ const exp = readFiniteNumber(payload.exp);
495
+ if (exp === null) return null;
496
+
497
+ return exp * 1000;
498
+ }
499
+
500
+ export function isExpiryElapsed(args: {
501
+ expiresAtMs: number | null | undefined;
502
+ nowMs?: number;
503
+ skewMs?: number;
504
+ }): boolean {
505
+ if (args.expiresAtMs === null) return false;
506
+ if (args.expiresAtMs === undefined) return true;
507
+ if (!Number.isFinite(args.expiresAtMs)) return true;
508
+
509
+ const nowMs = toNowMs(args.nowMs, undefined);
510
+ const skewMs = Math.max(0, args.skewMs ?? DEFAULT_EXPIRY_SKEW_MS);
511
+
512
+ return args.expiresAtMs <= nowMs + skewMs;
513
+ }
514
+
515
+ export function isCachedValueExpired<TValue>(
516
+ cachedValue: OfflineAuthCachedValue<TValue>,
517
+ options?: {
518
+ nowMs?: number;
519
+ skewMs?: number;
520
+ }
521
+ ): boolean {
522
+ return isExpiryElapsed({
523
+ expiresAtMs: cachedValue.expiresAtMs,
524
+ nowMs: options?.nowMs,
525
+ skewMs: options?.skewMs,
526
+ });
527
+ }
528
+
529
+ export function createSessionCacheEntry<TSession>(
530
+ session: TSession,
531
+ options?: CreateSessionCacheEntryOptions<TSession>
532
+ ): OfflineAuthCachedValue<TSession> | null {
533
+ const nowMs = toNowMs(options?.nowMs, undefined);
534
+ const explicitExpiry = options?.getExpiresAtMs?.(session);
535
+ const jwtExpiry = options?.getJwt
536
+ ? getJwtExpiryMs(options.getJwt(session) ?? '')
537
+ : null;
538
+
539
+ const expiresAtMs = explicitExpiry ?? jwtExpiry;
540
+
541
+ if (expiresAtMs === null || expiresAtMs === undefined) {
542
+ if (!options?.allowMissingExpiry) {
543
+ return null;
544
+ }
545
+
546
+ return {
547
+ value: session,
548
+ savedAtMs: nowMs,
549
+ expiresAtMs: null,
550
+ };
551
+ }
552
+
553
+ const skewMs = Math.max(0, options?.skewMs ?? DEFAULT_EXPIRY_SKEW_MS);
554
+ if (expiresAtMs <= nowMs + skewMs) {
555
+ return null;
556
+ }
557
+
558
+ return {
559
+ value: session,
560
+ savedAtMs: nowMs,
561
+ expiresAtMs,
562
+ };
563
+ }
564
+
565
+ export function createIdentityCacheEntry<TIdentity>(
566
+ identity: TIdentity,
567
+ options?: CreateIdentityCacheEntryOptions<TIdentity>
568
+ ): OfflineAuthCachedValue<TIdentity> {
569
+ const nowMs = toNowMs(options?.nowMs, undefined);
570
+ const expiresAtMs = options?.getExpiresAtMs?.(identity) ?? null;
571
+
572
+ return {
573
+ value: identity,
574
+ savedAtMs: nowMs,
575
+ expiresAtMs,
576
+ };
577
+ }
578
+
579
+ export function persistOnlineSession<
580
+ TSession,
581
+ TIdentity extends OfflineSubjectIdentity,
582
+ >(
583
+ options: PersistOnlineSessionOptions<TSession, TIdentity>
584
+ ): OfflineAuthState<TSession, TIdentity> {
585
+ if (!options.session) {
586
+ return clearOfflineAuthState(options.state);
587
+ }
588
+
589
+ const sessionEntry = createSessionCacheEntry(options.session, {
590
+ nowMs: options.nowMs,
591
+ skewMs: options.skewMs,
592
+ allowMissingExpiry: options.allowMissingExpiry,
593
+ getExpiresAtMs: options.getExpiresAtMs,
594
+ getJwt: options.getJwt,
595
+ });
596
+
597
+ const actorId = readTrimmedString(options.getSessionActorId(options.session));
598
+
599
+ let nextIdentity = options.state.identity;
600
+ if (options.deriveIdentity) {
601
+ const derivedIdentity = options.deriveIdentity(options.session);
602
+ if (derivedIdentity) {
603
+ nextIdentity = createIdentityCacheEntry(derivedIdentity, {
604
+ nowMs: options.nowMs,
605
+ getExpiresAtMs: options.getIdentityExpiresAtMs,
606
+ });
607
+ } else {
608
+ nextIdentity = null;
609
+ }
610
+ }
611
+
612
+ return {
613
+ ...options.state,
614
+ session: sessionEntry,
615
+ identity: nextIdentity,
616
+ lastActorId: actorId ?? options.state.lastActorId,
617
+ };
618
+ }
619
+
620
+ export function persistOfflineIdentity<
621
+ TSession,
622
+ TIdentity extends OfflineSubjectIdentity,
623
+ >(
624
+ options: PersistOfflineIdentityOptions<TSession, TIdentity>
625
+ ): OfflineAuthState<TSession, TIdentity> {
626
+ if (!options.identity) {
627
+ return {
628
+ ...options.state,
629
+ identity: null,
630
+ };
631
+ }
632
+
633
+ const identityEntry = createIdentityCacheEntry(options.identity, {
634
+ nowMs: options.nowMs,
635
+ getExpiresAtMs: options.getExpiresAtMs,
636
+ });
637
+
638
+ const actorId = readTrimmedString(options.identity.actorId);
639
+
640
+ return {
641
+ ...options.state,
642
+ identity: identityEntry,
643
+ lastActorId: actorId ?? options.state.lastActorId,
644
+ };
645
+ }
646
+
647
+ export function setOfflineAuthLastActorId<
648
+ TSession,
649
+ TIdentity extends OfflineSubjectIdentity,
650
+ >(
651
+ state: OfflineAuthState<TSession, TIdentity>,
652
+ actorId: string | null
653
+ ): OfflineAuthState<TSession, TIdentity> {
654
+ return {
655
+ ...state,
656
+ lastActorId: readTrimmedString(actorId) ?? null,
657
+ };
658
+ }
659
+
660
+ export function clearOfflineAuthState<
661
+ TSession,
662
+ TIdentity extends OfflineSubjectIdentity,
663
+ >(
664
+ state: OfflineAuthState<TSession, TIdentity>,
665
+ options?: {
666
+ preserveLastActorId?: boolean;
667
+ }
668
+ ): OfflineAuthState<TSession, TIdentity> {
669
+ return {
670
+ version: OFFLINE_AUTH_STATE_VERSION,
671
+ session: null,
672
+ identity: null,
673
+ lastActorId: options?.preserveLastActorId ? state.lastActorId : null,
674
+ };
675
+ }
676
+
677
+ export function resolveOfflineAuthSubject<
678
+ TSession,
679
+ TIdentity extends OfflineSubjectIdentity,
680
+ >(
681
+ options: ResolveOfflineAuthSubjectOptions<TSession, TIdentity>
682
+ ): OfflineResolvedSubject<TSession, TIdentity> {
683
+ const nowMs = toNowMs(options.nowMs, undefined);
684
+ const skewMs = Math.max(0, options.skewMs ?? DEFAULT_EXPIRY_SKEW_MS);
685
+
686
+ const session =
687
+ options.state.session &&
688
+ !isCachedValueExpired(options.state.session, { nowMs, skewMs })
689
+ ? options.state.session
690
+ : null;
691
+
692
+ if (session) {
693
+ const actorId = readTrimmedString(options.getSessionActorId(session.value));
694
+ if (actorId) {
695
+ const teamId = options.getSessionTeamId
696
+ ? readTrimmedString(options.getSessionTeamId(session.value))
697
+ : null;
698
+
699
+ return {
700
+ source: 'online-session',
701
+ actorId,
702
+ teamId,
703
+ isOffline: false,
704
+ session,
705
+ identity: null,
706
+ };
707
+ }
708
+ }
709
+
710
+ const identity =
711
+ options.state.identity &&
712
+ !isCachedValueExpired(options.state.identity, { nowMs, skewMs })
713
+ ? options.state.identity
714
+ : null;
715
+
716
+ if (identity) {
717
+ const actorId = options.getIdentityActorId
718
+ ? readTrimmedString(options.getIdentityActorId(identity.value))
719
+ : readTrimmedString(identity.value.actorId);
720
+
721
+ if (actorId) {
722
+ const rawTeamId = options.getIdentityTeamId
723
+ ? options.getIdentityTeamId(identity.value)
724
+ : identity.value.teamId;
725
+
726
+ const teamId =
727
+ rawTeamId === null || rawTeamId === undefined
728
+ ? null
729
+ : readTrimmedString(rawTeamId);
730
+
731
+ return {
732
+ source: 'offline-identity',
733
+ actorId,
734
+ teamId,
735
+ isOffline: true,
736
+ session: null,
737
+ identity,
738
+ };
739
+ }
740
+ }
741
+
742
+ const lastActorId = readTrimmedString(options.state.lastActorId);
743
+ if (lastActorId) {
744
+ return {
745
+ source: 'last-actor',
746
+ actorId: lastActorId,
747
+ teamId: null,
748
+ isOffline: true,
749
+ session: null,
750
+ identity: null,
751
+ };
752
+ }
753
+
754
+ return {
755
+ source: 'none',
756
+ actorId: null,
757
+ teamId: null,
758
+ isOffline: true,
759
+ session: null,
760
+ identity: null,
761
+ };
762
+ }
763
+
764
+ export function createBearerAuthHeaders(
765
+ token: string | null | undefined
766
+ ): Record<string, string> {
767
+ const resolved = readTrimmedString(token);
768
+ if (!resolved) return {};
769
+ return {
770
+ Authorization: `Bearer ${resolved}`,
771
+ };
772
+ }
773
+
774
+ export function createTokenLifecycleBridge(
775
+ options: CreateTokenLifecycleBridgeOptions
776
+ ): TokenLifecycleBridge {
777
+ let latestToken: string | null = null;
778
+ let latestRefreshTokens: {
779
+ previousToken: string | null;
780
+ nextToken: string | null;
781
+ } | null = null;
782
+ let refreshInFlight: Promise<boolean> | null = null;
783
+
784
+ const resolveToken = async (): Promise<string | null> => {
785
+ const token = readTrimmedString(await options.resolveToken());
786
+ latestToken = token;
787
+ return token;
788
+ };
789
+
790
+ const runRefresh = async (
791
+ context: SyncAuthErrorContext
792
+ ): Promise<boolean> => {
793
+ const previousToken = latestToken;
794
+ const refreshTokenResolver = options.refreshToken ?? options.resolveToken;
795
+ const nextToken = readTrimmedString(await refreshTokenResolver(context));
796
+
797
+ latestToken = nextToken;
798
+ latestRefreshTokens = {
799
+ previousToken,
800
+ nextToken,
801
+ };
802
+
803
+ return Boolean(nextToken) && nextToken !== previousToken;
804
+ };
805
+
806
+ const authLifecycle: SyncAuthLifecycle = {
807
+ onAuthExpired: options.onAuthExpired,
808
+ refreshToken: async (context) => {
809
+ if (!refreshInFlight) {
810
+ refreshInFlight = runRefresh(context).finally(() => {
811
+ refreshInFlight = null;
812
+ });
813
+ }
814
+
815
+ return refreshInFlight;
816
+ },
817
+ retryWithFreshToken: async (context) => {
818
+ const previousToken = latestRefreshTokens?.previousToken ?? latestToken;
819
+ const nextToken = latestRefreshTokens?.nextToken ?? latestToken;
820
+
821
+ if (options.retryWithFreshToken) {
822
+ return options.retryWithFreshToken({
823
+ ...context,
824
+ previousToken,
825
+ nextToken,
826
+ });
827
+ }
828
+
829
+ return context.refreshResult;
830
+ },
831
+ };
832
+
833
+ return {
834
+ resolveToken,
835
+ getAuthorizationHeaders: async () => {
836
+ const token = await resolveToken();
837
+ return createBearerAuthHeaders(token);
838
+ },
839
+ getRealtimeParams: async (paramName = 'token') => {
840
+ const token = await resolveToken();
841
+ if (!token) return {};
842
+ return {
843
+ [paramName]: token,
844
+ };
845
+ },
846
+ authLifecycle,
847
+ };
848
+ }
849
+
850
+ export function createOfflineLockState(
851
+ options?: OfflineLockPolicyOptions
852
+ ): OfflineLockState {
853
+ const policy = normalizeLockPolicy(options);
854
+ const nowMs = policy.now();
855
+
856
+ return {
857
+ isLocked: options?.initiallyLocked ?? false,
858
+ failedAttempts: 0,
859
+ blockedUntilMs: null,
860
+ lastActivityAtMs: nowMs,
861
+ };
862
+ }
863
+
864
+ export function isOfflineLockBlocked(
865
+ state: OfflineLockState,
866
+ nowMs = Date.now()
867
+ ): boolean {
868
+ return state.blockedUntilMs !== null && state.blockedUntilMs > nowMs;
869
+ }
870
+
871
+ export function applyOfflineLockEvent(
872
+ state: OfflineLockState,
873
+ event: OfflineLockEvent,
874
+ options?: OfflineLockPolicyOptions
875
+ ): OfflineLockState {
876
+ const policy = normalizeLockPolicy(options);
877
+ const nowMs = toNowMs(event.nowMs, policy.now);
878
+ const normalized = normalizeLockStateForTime(state, nowMs);
879
+
880
+ if (event.type === 'lock') {
881
+ return {
882
+ ...normalized,
883
+ isLocked: true,
884
+ lastActivityAtMs: nowMs,
885
+ };
886
+ }
887
+
888
+ if (event.type === 'unlock') {
889
+ if (isOfflineLockBlocked(normalized, nowMs)) {
890
+ return {
891
+ ...normalized,
892
+ isLocked: true,
893
+ lastActivityAtMs: nowMs,
894
+ };
895
+ }
896
+
897
+ return {
898
+ ...normalized,
899
+ isLocked: false,
900
+ failedAttempts: 0,
901
+ blockedUntilMs: null,
902
+ lastActivityAtMs: nowMs,
903
+ };
904
+ }
905
+
906
+ if (event.type === 'activity') {
907
+ return {
908
+ ...normalized,
909
+ lastActivityAtMs: nowMs,
910
+ };
911
+ }
912
+
913
+ if (event.type === 'reset-failures') {
914
+ return {
915
+ ...normalized,
916
+ failedAttempts: 0,
917
+ blockedUntilMs: null,
918
+ lastActivityAtMs: nowMs,
919
+ };
920
+ }
921
+
922
+ if (event.type === 'failed-unlock') {
923
+ if (isOfflineLockBlocked(normalized, nowMs)) {
924
+ return {
925
+ ...normalized,
926
+ isLocked: true,
927
+ lastActivityAtMs: nowMs,
928
+ };
929
+ }
930
+
931
+ const failedAttempts = normalized.failedAttempts + 1;
932
+
933
+ if (failedAttempts >= policy.maxFailedAttempts) {
934
+ const blockedUntilMs = nowMs + policy.cooldownMs;
935
+ return {
936
+ ...normalized,
937
+ isLocked: true,
938
+ failedAttempts,
939
+ blockedUntilMs,
940
+ lastActivityAtMs: nowMs,
941
+ };
942
+ }
943
+
944
+ return {
945
+ ...normalized,
946
+ isLocked: true,
947
+ failedAttempts,
948
+ blockedUntilMs: null,
949
+ lastActivityAtMs: nowMs,
950
+ };
951
+ }
952
+
953
+ if (
954
+ policy.idleTimeoutMs > 0 &&
955
+ !normalized.isLocked &&
956
+ nowMs - normalized.lastActivityAtMs >= policy.idleTimeoutMs
957
+ ) {
958
+ return {
959
+ ...normalized,
960
+ isLocked: true,
961
+ lastActivityAtMs: nowMs,
962
+ };
963
+ }
964
+
965
+ return normalized;
966
+ }
967
+
968
+ export function attemptOfflineUnlock(
969
+ args: AttemptOfflineUnlockArgs
970
+ ): OfflineUnlockResult {
971
+ const policy = normalizeLockPolicy(args.options);
972
+ const nowMs = toNowMs(args.nowMs, policy.now);
973
+ const normalized = normalizeLockStateForTime(args.state, nowMs);
974
+
975
+ if (isOfflineLockBlocked(normalized, nowMs)) {
976
+ return {
977
+ ok: false,
978
+ reason: 'blocked',
979
+ state: {
980
+ ...normalized,
981
+ isLocked: true,
982
+ },
983
+ };
984
+ }
985
+
986
+ if (args.verify()) {
987
+ const state = applyOfflineLockEvent(
988
+ normalized,
989
+ { type: 'unlock', nowMs },
990
+ policy
991
+ );
992
+ return {
993
+ ok: true,
994
+ reason: null,
995
+ state,
996
+ };
997
+ }
998
+
999
+ const state = applyOfflineLockEvent(
1000
+ normalized,
1001
+ { type: 'failed-unlock', nowMs },
1002
+ policy
1003
+ );
1004
+
1005
+ return {
1006
+ ok: false,
1007
+ reason: isOfflineLockBlocked(state, nowMs) ? 'blocked' : 'rejected',
1008
+ state,
1009
+ };
1010
+ }
1011
+
1012
+ export async function attemptOfflineUnlockAsync(
1013
+ args: AttemptOfflineUnlockAsyncArgs
1014
+ ): Promise<OfflineUnlockResult> {
1015
+ const policy = normalizeLockPolicy(args.options);
1016
+ const nowMs = toNowMs(args.nowMs, policy.now);
1017
+ const normalized = normalizeLockStateForTime(args.state, nowMs);
1018
+
1019
+ if (isOfflineLockBlocked(normalized, nowMs)) {
1020
+ return {
1021
+ ok: false,
1022
+ reason: 'blocked',
1023
+ state: {
1024
+ ...normalized,
1025
+ isLocked: true,
1026
+ },
1027
+ };
1028
+ }
1029
+
1030
+ if (await args.verify()) {
1031
+ const state = applyOfflineLockEvent(
1032
+ normalized,
1033
+ { type: 'unlock', nowMs },
1034
+ policy
1035
+ );
1036
+ return {
1037
+ ok: true,
1038
+ reason: null,
1039
+ state,
1040
+ };
1041
+ }
1042
+
1043
+ const state = applyOfflineLockEvent(
1044
+ normalized,
1045
+ { type: 'failed-unlock', nowMs },
1046
+ policy
1047
+ );
1048
+
1049
+ return {
1050
+ ok: false,
1051
+ reason: isOfflineLockBlocked(state, nowMs) ? 'blocked' : 'rejected',
1052
+ state,
1053
+ };
1054
+ }
1055
+
1056
+ export function createOfflineLockController(
1057
+ options?: OfflineLockPolicyOptions
1058
+ ): OfflineLockController {
1059
+ const policy = normalizeLockPolicy(options);
1060
+ let state = createOfflineLockState(policy);
1061
+
1062
+ const dispatch = (event: OfflineLockEvent): OfflineLockState => {
1063
+ state = applyOfflineLockEvent(state, event, policy);
1064
+ return state;
1065
+ };
1066
+
1067
+ return {
1068
+ getState() {
1069
+ return state;
1070
+ },
1071
+ replaceState(nextState) {
1072
+ state = nextState;
1073
+ return state;
1074
+ },
1075
+ dispatch,
1076
+ lock() {
1077
+ return dispatch({ type: 'lock' });
1078
+ },
1079
+ forceUnlock() {
1080
+ const nextState = dispatch({ type: 'unlock' });
1081
+ if (nextState.isLocked) {
1082
+ return {
1083
+ ok: false,
1084
+ reason: 'blocked',
1085
+ state: nextState,
1086
+ };
1087
+ }
1088
+
1089
+ return {
1090
+ ok: true,
1091
+ reason: null,
1092
+ state: nextState,
1093
+ };
1094
+ },
1095
+ recordActivity() {
1096
+ return dispatch({ type: 'activity' });
1097
+ },
1098
+ recordFailedUnlock() {
1099
+ return dispatch({ type: 'failed-unlock' });
1100
+ },
1101
+ resetFailures() {
1102
+ return dispatch({ type: 'reset-failures' });
1103
+ },
1104
+ evaluateIdleTimeout() {
1105
+ return dispatch({ type: 'tick' });
1106
+ },
1107
+ attemptUnlock(verify) {
1108
+ const result = attemptOfflineUnlock({
1109
+ state,
1110
+ verify,
1111
+ options: policy,
1112
+ });
1113
+ state = result.state;
1114
+ return result;
1115
+ },
1116
+ async attemptUnlockAsync(verify) {
1117
+ const result = await attemptOfflineUnlockAsync({
1118
+ state,
1119
+ verify,
1120
+ options: policy,
1121
+ });
1122
+ state = result.state;
1123
+ return result;
1124
+ },
1125
+ };
1126
+ }