@rebasepro/client 0.17.3 → 0.18.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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
package/src/auth.ts DELETED
@@ -1,982 +0,0 @@
1
- import { RebaseApiError, Transport } from "./transport";
2
- import type { AuthAdapterCapabilities, AuthChangeEvent, RebaseSession, AuthTokens, DeviceSession, User } from "@rebasepro/types";
3
-
4
- // Re-export canonical types so `import { RebaseSession } from "@rebasepro/client"` keeps working
5
- export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
6
-
7
- /** Minimal, non-sensitive user profile returned by {@link findUserByEmail}. */
8
- export interface PublicUserProfile {
9
- uid: string;
10
- displayName: string | null;
11
- photoURL: string | null;
12
- }
13
-
14
- /** Map a raw user object from an auth response (`/login`, `/refresh`, `/me`) to a `User`. */
15
- function mapRawUser(raw: Record<string, unknown>): User {
16
- return {
17
- uid: raw.uid as string,
18
- email: (raw.email as string | null) ?? null,
19
- displayName: (raw.displayName as string | null) ?? null,
20
- photoURL: (raw.photoURL as string | null) ?? null,
21
- providerId: (raw.providerId as string | undefined) ?? "password",
22
- isAnonymous: (raw.isAnonymous as boolean | undefined) ?? false,
23
- emailVerified: raw.emailVerified as boolean | undefined,
24
- roles: raw.roles as string[] | undefined,
25
- metadata: raw.metadata as Record<string, unknown> | undefined,
26
- };
27
- }
28
-
29
- /** Placeholder user, used only as a last resort when none can be resolved. */
30
- const EMPTY_USER: User = { uid: "", email: null, displayName: null, photoURL: null, providerId: "password", isAnonymous: false };
31
-
32
-
33
- /**
34
- * What `GET /auth/config` answers: the backend's auth capability document.
35
- *
36
- * Declared once, in `@rebasepro/types`, and re-exported here under the name the
37
- * endpoint uses. The SDK used to carry its own near-copy of it, which listed an
38
- * `emailServiceEnabled` flag no backend has ever sent and omitted half of what
39
- * every backend does send.
40
- */
41
- export type AuthConfig = AuthAdapterCapabilities;
42
-
43
- export interface AuthStorage {
44
- getItem: (key: string) => string | null;
45
- setItem: (key: string, value: string) => void;
46
- removeItem: (key: string) => void;
47
- }
48
-
49
- export function createMemoryStorage(): AuthStorage {
50
- const store: Record<string, string> = {};
51
- return {
52
- getItem(key) { return store[key] ?? null; },
53
- setItem(key, value) { store[key] = value; },
54
- removeItem(key) { delete store[key]; }
55
- };
56
- }
57
-
58
- function detectStorage(): AuthStorage {
59
- try {
60
- if (typeof localStorage !== "undefined") {
61
- localStorage.setItem("__rebase_test__", "1");
62
- localStorage.removeItem("__rebase_test__");
63
- return localStorage;
64
- }
65
- } catch (e) { /* ignore */ }
66
- return createMemoryStorage();
67
- }
68
-
69
- export interface CreateAuthOptions {
70
- storage?: AuthStorage;
71
- authPath?: string;
72
- autoRefresh?: boolean;
73
- persistSession?: boolean;
74
- /**
75
- * Authentication flow mode.
76
- * - 'json' (default): Tokens are sent/received in JSON bodies. Refresh token is stored in local storage.
77
- * - 'cookie': Refresh token is sent/received via httpOnly cookies. Access token remains in memory.
78
- */
79
- authFlowMode?: "json" | "cookie";
80
- }
81
-
82
- export function createAuth(transport: Transport, options?: CreateAuthOptions) {
83
- const opts = options || {};
84
- const storage = opts.storage || detectStorage();
85
- const authPath = opts.authPath || "/auth";
86
- const autoRefresh = opts.autoRefresh !== false;
87
- const persistSession = opts.persistSession !== false;
88
- const authFlowMode = opts.authFlowMode || "json";
89
-
90
- const STORAGE_KEY = "rebase_auth";
91
- const REFRESH_BUFFER_MS = 120000;
92
- /**
93
- * The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
94
- * Anything larger is silently clamped to 1ms by Node and every browser.
95
- */
96
- const MAX_TIMER_DELAY_MS = 2_147_483_647;
97
- // Auto-refresh resilience: retry transient failures with exponential backoff
98
- // (1s, 2s, 4s, … capped) before giving up and signing out.
99
- const MAX_REFRESH_RETRIES = 5;
100
- const REFRESH_RETRY_BASE_MS = 1000;
101
- const REFRESH_RETRY_MAX_MS = 30000;
102
-
103
- let currentSession: RebaseSession | null = null;
104
- const listeners = new Set<(event: AuthChangeEvent, session: RebaseSession | null) => void>();
105
- let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
106
- // De-dupe concurrent refreshes. On boot (esp. cookie mode + React StrictMode)
107
- // multiple callers can trigger refresh at once; without this they race — the
108
- // server rotates the refresh token twice and the browser can end up with a
109
- // cookie the DB no longer matches. A single in-flight promise is shared.
110
- let inFlightRefresh: Promise<RebaseSession> | null = null;
111
- let resolveInitialized: (value: void | PromiseLike<void>) => void;
112
- const isInitialized = new Promise<void>((resolve) => {
113
- resolveInitialized = resolve;
114
- });
115
-
116
- function authUrl(endpoint: string) {
117
- return transport.baseUrl + transport.apiPath + authPath + endpoint;
118
- }
119
-
120
- function getFetch() {
121
- return transport.fetchFn || globalThis.fetch;
122
- }
123
-
124
- function throwApiError(status: number, body: { error?: { message?: string; code?: string; details?: unknown }; message?: string; code?: string; details?: unknown } | undefined, statusText: string): never {
125
- throw new RebaseApiError(
126
- body?.error?.message || body?.message || statusText,
127
- {
128
- status,
129
- code: body?.error?.code || body?.code,
130
- details: body?.error?.details || body?.details
131
- }
132
- );
133
- }
134
-
135
- function emit(event: AuthChangeEvent, session: RebaseSession | null) {
136
- for (const fn of listeners) {
137
- try {
138
- fn(event, session);
139
- } catch (e) {
140
- // Isolated so one bad handler cannot stop the rest being told —
141
- // but reported, because the throw came from the caller's own
142
- // code and discarding it made a broken `onAuthStateChange`
143
- // handler look like an event that never fired. The socket in
144
- // this package already reports handler errors this way.
145
- console.error("Error in auth state change listener:", e);
146
- }
147
- }
148
- }
149
-
150
- function saveSession(session: RebaseSession) {
151
- if (!persistSession || authFlowMode === "cookie") return;
152
- try {
153
- storage.setItem(STORAGE_KEY, JSON.stringify(session));
154
- } catch (e) { /* ignore */ }
155
- }
156
-
157
- function clearStoredSession() {
158
- try {
159
- storage.removeItem(STORAGE_KEY);
160
- } catch (e) { /* ignore */ }
161
- }
162
-
163
- function loadStoredSession(): RebaseSession | null {
164
- try {
165
- const raw = storage.getItem(STORAGE_KEY);
166
- if (raw) return JSON.parse(raw) as RebaseSession;
167
- } catch (e) { /* ignore */ }
168
- return null;
169
- }
170
-
171
- /**
172
- * A refresh failure is only fatal if the refresh token itself is rejected
173
- * (expired / invalid / forbidden). Network blips, timeouts, and 5xx (e.g. a
174
- * backend restart mid-session) are transient and must NOT log the user out.
175
- */
176
- function isFatalRefreshError(err: unknown): boolean {
177
- if (!(err instanceof RebaseApiError)) return false; // network/other → transient
178
- // Another tab (or a retry of our own request) rotated the token we
179
- // were holding. In cookie mode the jar may ALREADY contain the
180
- // replacement, so this is the one 401 that is worth retrying: giving
181
- // up here is precisely the bug where opening a second tab signs you
182
- // out of both.
183
- if (err.code === "TOKEN_ALREADY_USED") return false;
184
- if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
185
- // 401/403 are auth failures; other statuses (incl. 5xx, 0) are transient.
186
- return err.status === 401 || err.status === 403;
187
- }
188
-
189
- /**
190
- * Drop this client's session without telling the server.
191
- *
192
- * `signOut()` is a user action: it POSTs /logout, which revokes the whole
193
- * sign-in. That is the wrong hammer for a refresh that failed. Our token
194
- * may be stale precisely because a sibling tab holds a live one, and
195
- * logging out on its behalf would turn one tab's bad luck into everybody
196
- * being signed out — the exact failure this work exists to remove.
197
- */
198
- function abandonSessionLocally() {
199
- currentSession = null;
200
- clearStoredSession();
201
- if (refreshTimeout) {
202
- clearTimeout(refreshTimeout);
203
- refreshTimeout = null;
204
- }
205
- transport.setToken(null);
206
- emit("SIGNED_OUT", null);
207
- }
208
-
209
- /**
210
- * Recover from a 401 on an ordinary API request.
211
- *
212
- * Returns `true` when the caller should retry — we minted a fresh access
213
- * token. When the refresh is rejected *fatally* (the refresh token itself
214
- * is invalid, expired or revoked) this client can no longer act as the
215
- * user at all, so we drop the session and emit `SIGNED_OUT`. UIs gate on
216
- * that event, so they show their login screen instead of leaving the user
217
- * staring at "Invalid or expired token" on every view.
218
- *
219
- * Transient failures (offline, 5xx, backend restarting) keep the session:
220
- * the scheduled refresh backs off and retries, and the token is very
221
- * likely still good once the backend answers again.
222
- */
223
- async function handleUnauthorized(): Promise<boolean> {
224
- // No session to recover: the 401 is just an anonymous caller hitting a
225
- // protected route. Emitting SIGNED_OUT here would fire sign-out
226
- // handlers for a user who was never signed in.
227
- if (!currentSession) return false;
228
-
229
- // Nothing to refresh *with* — the access token is dead and there is no
230
- // way back. Same end state as a rejected refresh token.
231
- if (authFlowMode !== "cookie" && !currentSession.refreshToken) {
232
- abandonSessionLocally();
233
- return false;
234
- }
235
-
236
- try {
237
- await refreshSession();
238
- return true;
239
- } catch (err) {
240
- if (isFatalRefreshError(err)) {
241
- abandonSessionLocally();
242
- }
243
- return false;
244
- }
245
- }
246
-
247
- async function attemptScheduledRefresh(attempt: number) {
248
- try {
249
- await refreshSession();
250
- // On success, refreshSession() re-schedules the next refresh itself.
251
- } catch (err) {
252
- if (isFatalRefreshError(err)) {
253
- abandonSessionLocally();
254
- return;
255
- }
256
- if (attempt >= MAX_REFRESH_RETRIES) {
257
- abandonSessionLocally();
258
- return;
259
- }
260
- // Transient failure — back off and retry rather than dropping the session.
261
- const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
262
- refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(attempt + 1); }, backoff);
263
- }
264
- }
265
-
266
- function scheduleRefresh(expiresAt: number) {
267
- if (refreshTimeout) clearTimeout(refreshTimeout);
268
- if (!autoRefresh) return;
269
-
270
- const delay = (expiresAt - REFRESH_BUFFER_MS) - Date.now();
271
-
272
- if (delay <= 0) {
273
- void attemptScheduledRefresh(0);
274
- return;
275
- }
276
-
277
- // `setTimeout` holds its delay in a 32-bit signed integer. Past
278
- // ~24.8 days it does not wait — it clamps to 1ms and fires at once. The
279
- // refresh would then land, receive a token expiring just as far out,
280
- // schedule again and overflow again: a hot loop against
281
- // `/auth/refresh`, one per open tab.
282
- //
283
- // `auth.accessExpiresIn` is configurable and defaults to "1h", so this
284
- // is dormant on a default deployment and immediate on `"30d"` — an
285
- // ordinary setting for an internal tool. Re-arm instead of refreshing:
286
- // sleep the maximum, then work out again how long is left.
287
- if (delay > MAX_TIMER_DELAY_MS) {
288
- refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
289
- return;
290
- }
291
-
292
- refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(0); }, delay);
293
- }
294
-
295
- /**
296
- * Stop the scheduled token refresh, leaving the session itself alone.
297
- *
298
- * This is teardown, not sign-out. `scheduleRefresh` arms an ordinary
299
- * `setTimeout` up to a token lifetime away, and it is not `unref`'d — so on
300
- * Node it holds the event loop open by itself. `client.close()` promised
301
- * that "a script that does not call this will not exit on its own", which
302
- * was true, while the converse it plainly implies was not: a signed-in
303
- * client that closed its socket still hung, because this timer outlived it.
304
- * Any script, cron handler or job that signs in hit that.
305
- *
306
- * Deliberately does NOT clear the session, touch storage, or emit
307
- * SIGNED_OUT. Closing a client is not the user signing out — `signOut()`
308
- * POSTs /logout and revokes the whole sign-in, which is the wrong hammer
309
- * (see `abandonSessionLocally`) — and a persisted session must still be
310
- * there for the next client to restore.
311
- */
312
- function stopAutoRefresh() {
313
- if (refreshTimeout) {
314
- clearTimeout(refreshTimeout);
315
- refreshTimeout = null;
316
- }
317
- }
318
-
319
- function handleAuthResponse(data: { tokens: AuthTokens, user: Record<string, unknown> }, event?: AuthChangeEvent): RebaseSession {
320
- const user: User = mapRawUser(data.user);
321
- const session: RebaseSession = {
322
- accessToken: data.tokens.accessToken,
323
- refreshToken: data.tokens.refreshToken || (currentSession?.refreshToken) || "",
324
- expiresAt: data.tokens.accessTokenExpiresAt,
325
- user
326
- };
327
- currentSession = session;
328
- saveSession(session);
329
- transport.setToken(session.accessToken);
330
- scheduleRefresh(session.expiresAt);
331
- emit(event || "SIGNED_IN", session);
332
- return session;
333
- }
334
-
335
- async function signInWithEmail(email: string, password: string) {
336
- const fetchFn = getFetch();
337
- const res = await fetchFn(authUrl("/login"), {
338
- method: "POST",
339
- headers: { "Content-Type": "application/json" },
340
- body: JSON.stringify({ email,
341
- password }),
342
- credentials: authFlowMode === "cookie" ? "include" : undefined
343
- } as RequestInit);
344
- const body = await res.json().catch(() => ({}));
345
- if (!res.ok) throwApiError(res.status, body, res.statusText);
346
- const session = handleAuthResponse(body, "SIGNED_IN");
347
- return { user: session.user,
348
- accessToken: session.accessToken,
349
- refreshToken: session.refreshToken };
350
- }
351
-
352
- async function signUp(email: string, password: string, displayName?: string) {
353
- const fetchFn = getFetch();
354
- const payload: Record<string, string> = { email,
355
- password };
356
- if (displayName !== undefined) payload.displayName = displayName;
357
- const res = await fetchFn(authUrl("/register"), {
358
- method: "POST",
359
- headers: { "Content-Type": "application/json" },
360
- body: JSON.stringify(payload),
361
- credentials: authFlowMode === "cookie" ? "include" : undefined
362
- } as RequestInit);
363
- const body = await res.json().catch(() => ({}));
364
- if (!res.ok) throwApiError(res.status, body, res.statusText);
365
- const session = handleAuthResponse(body, "SIGNED_IN");
366
- return { user: session.user,
367
- accessToken: session.accessToken,
368
- refreshToken: session.refreshToken };
369
- }
370
-
371
- /**
372
- * Sign in with Google.
373
- *
374
- * Supports three invocation styles:
375
- * - `signInWithGoogle({ idToken })` — ID-token flow (One Tap / Sign In button)
376
- * - `signInWithGoogle({ accessToken })` — Access-token flow (popup)
377
- * - `signInWithGoogle({ code, redirectUri })` — Authorization code flow (most secure)
378
- */
379
- async function signInWithGoogle(
380
- payload: { idToken: string } | { accessToken: string } | { code: string; redirectUri: string }
381
- ) {
382
- const fetchFn = getFetch();
383
- const res = await fetchFn(authUrl("/google"), {
384
- method: "POST",
385
- headers: { "Content-Type": "application/json" },
386
- body: JSON.stringify(payload),
387
- credentials: authFlowMode === "cookie" ? "include" : undefined
388
- } as RequestInit);
389
- const responseBody = await res.json().catch(() => ({}));
390
- if (!res.ok) throwApiError(res.status, responseBody, res.statusText);
391
- const session = handleAuthResponse(responseBody, "SIGNED_IN");
392
- return { user: session.user,
393
- accessToken: session.accessToken,
394
- refreshToken: session.refreshToken };
395
- }
396
-
397
- async function signInWithLinkedin(code: string, redirectUri: string) {
398
- const fetchFn = getFetch();
399
- const res = await fetchFn(authUrl("/linkedin"), {
400
- method: "POST",
401
- headers: { "Content-Type": "application/json" },
402
- body: JSON.stringify({ code,
403
- redirectUri }),
404
- credentials: authFlowMode === "cookie" ? "include" : undefined
405
- } as RequestInit);
406
- const body = await res.json().catch(() => ({}));
407
- if (!res.ok) throwApiError(res.status, body, res.statusText);
408
- const session = handleAuthResponse(body, "SIGNED_IN");
409
- return { user: session.user,
410
- accessToken: session.accessToken,
411
- refreshToken: session.refreshToken };
412
- }
413
-
414
- /**
415
- * Generic OAuth sign-in. Posts the given payload to `/auth/{providerId}`.
416
- * Use this for any provider registered on the backend.
417
- */
418
- async function signInWithOAuth(providerId: string, payload: Record<string, unknown>) {
419
- const fetchFn = getFetch();
420
- const res = await fetchFn(authUrl(`/${providerId}`), {
421
- method: "POST",
422
- headers: { "Content-Type": "application/json" },
423
- body: JSON.stringify(payload),
424
- credentials: authFlowMode === "cookie" ? "include" : undefined
425
- } as RequestInit);
426
- const body = await res.json().catch(() => ({}));
427
- if (!res.ok) throwApiError(res.status, body, res.statusText);
428
- const session = handleAuthResponse(body, "SIGNED_IN");
429
- return { user: session.user,
430
- accessToken: session.accessToken,
431
- refreshToken: session.refreshToken };
432
- }
433
-
434
- // Convenience wrappers for all supported OAuth providers
435
-
436
- async function signInWithGitHub(code: string, redirectUri: string) {
437
- return signInWithOAuth("github", { code,
438
- redirectUri });
439
- }
440
-
441
- async function signInWithMicrosoft(code: string, redirectUri: string) {
442
- return signInWithOAuth("microsoft", { code,
443
- redirectUri });
444
- }
445
-
446
- async function signInWithApple(code: string, redirectUri: string, user?: { name?: { firstName?: string; lastName?: string }; email?: string }) {
447
- return signInWithOAuth("apple", { code,
448
- redirectUri,
449
- user });
450
- }
451
-
452
- async function signInWithFacebook(code: string, redirectUri: string) {
453
- return signInWithOAuth("facebook", { code,
454
- redirectUri });
455
- }
456
-
457
- async function signInWithTwitter(code: string, redirectUri: string, codeVerifier: string) {
458
- return signInWithOAuth("twitter", { code,
459
- redirectUri,
460
- codeVerifier });
461
- }
462
-
463
- async function signInWithDiscord(code: string, redirectUri: string) {
464
- return signInWithOAuth("discord", { code,
465
- redirectUri });
466
- }
467
-
468
- async function signInWithGitLab(code: string, redirectUri: string) {
469
- return signInWithOAuth("gitlab", { code,
470
- redirectUri });
471
- }
472
-
473
- async function signInWithBitbucket(code: string, redirectUri: string) {
474
- return signInWithOAuth("bitbucket", { code,
475
- redirectUri });
476
- }
477
-
478
- async function signInWithSlack(code: string, redirectUri: string) {
479
- return signInWithOAuth("slack", { code,
480
- redirectUri });
481
- }
482
-
483
- async function signInWithSpotify(code: string, redirectUri: string) {
484
- return signInWithOAuth("spotify", { code,
485
- redirectUri });
486
- }
487
-
488
- async function signOut() {
489
- const fetchFn = getFetch();
490
- try {
491
- if (authFlowMode === "cookie" || currentSession?.refreshToken) {
492
- await fetchFn(authUrl("/logout"), {
493
- method: "POST",
494
- headers: { "Content-Type": "application/json" },
495
- body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
496
- credentials: authFlowMode === "cookie" ? "include" : undefined
497
- } as RequestInit);
498
- }
499
- } catch (e) { /* ignore */ }
500
- currentSession = null;
501
- clearStoredSession();
502
- if (refreshTimeout) {
503
- clearTimeout(refreshTimeout);
504
- refreshTimeout = null;
505
- }
506
- transport.setToken(null);
507
- emit("SIGNED_OUT", null);
508
- }
509
-
510
- /**
511
- * Serialise refreshes across TABS, not just within one.
512
- *
513
- * The in-flight promise below covers callers inside a single JavaScript
514
- * context. It does nothing about the far more common case: two tabs of the
515
- * same app booting together, each firing its own /refresh with the same
516
- * cookie. The server tolerates that now (superseded tokens stay usable for
517
- * a grace window), but tolerating a stampede is not the same as avoiding
518
- * one, and every extra rotation is another chance to end up holding a
519
- * token whose response never arrived.
520
- *
521
- * Web Locks are best-effort on purpose. supabase-js shipped this and then
522
- * spent a year fielding deadlock reports — a lock held by a crashed or
523
- * frozen tab must never be able to wedge sign-in — so a lock we cannot
524
- * take within the timeout is simply not taken, and the refresh proceeds
525
- * unserialised, exactly as it did before.
526
- */
527
- const REFRESH_LOCK_NAME = "rebase-auth-refresh";
528
- const REFRESH_LOCK_TIMEOUT_MS = 5000;
529
-
530
- async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
531
- const locks = (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks;
532
- if (!locks?.request) return fn();
533
-
534
- const controller = new AbortController();
535
- const giveUp = setTimeout(() => controller.abort(), REFRESH_LOCK_TIMEOUT_MS);
536
- try {
537
- return await locks.request(
538
- REFRESH_LOCK_NAME,
539
- { signal: controller.signal },
540
- async () => fn()
541
- ) as T;
542
- } catch (e) {
543
- // AbortError means only that we waited long enough for the lock.
544
- // Anything the callback itself threw has to keep propagating.
545
- if ((e as { name?: string })?.name !== "AbortError") throw e;
546
- return fn();
547
- } finally {
548
- clearTimeout(giveUp);
549
- }
550
- }
551
-
552
- function refreshSession(): Promise<RebaseSession> {
553
- // Share a single in-flight refresh across concurrent callers.
554
- if (inFlightRefresh) return inFlightRefresh;
555
- inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
556
- inFlightRefresh = null;
557
- });
558
- return inFlightRefresh;
559
- }
560
-
561
- async function doRefreshSession(): Promise<RebaseSession> {
562
- if (authFlowMode !== "cookie" && !currentSession?.refreshToken) {
563
- throw new Error("No active session to refresh");
564
- }
565
- const fetchFn = getFetch();
566
- const res = await fetchFn(authUrl("/refresh"), {
567
- method: "POST",
568
- headers: { "Content-Type": "application/json" },
569
- body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
570
- credentials: authFlowMode === "cookie" ? "include" : undefined
571
- } as RequestInit);
572
- const body = await res.json().catch(() => ({}));
573
- if (!res.ok) throwApiError(res.status, body, res.statusText);
574
-
575
- const accessToken = body.tokens.accessToken;
576
- transport.setToken(accessToken);
577
-
578
- // Resolve the user, in order of preference:
579
- // 1. the user returned by /refresh (modern backends include it),
580
- // 2. the user already in memory,
581
- // 3. a fetch of /me — required to restore a session from an httpOnly
582
- // cookie alone (cold start in cookie mode), where there is no
583
- // in-memory user and the backend didn't echo one.
584
- let user = currentSession?.user;
585
- if (body.user && typeof body.user.uid === "string") {
586
- user = mapRawUser(body.user as Record<string, unknown>);
587
- } else if (!user || !user.uid) {
588
- try {
589
- user = await getUser();
590
- } catch { /* fall through to the empty stub below */ }
591
- }
592
-
593
- const session: RebaseSession = {
594
- accessToken,
595
- refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
596
- expiresAt: body.tokens.accessTokenExpiresAt,
597
- user: user ?? EMPTY_USER
598
- };
599
- currentSession = session;
600
- saveSession(session);
601
- transport.setToken(session.accessToken);
602
- scheduleRefresh(session.expiresAt);
603
- emit("TOKEN_REFRESHED", session);
604
- return session;
605
- }
606
-
607
- async function getUser() {
608
- const data = await transport.request<{ user: User }>(authPath + "/me", { method: "GET" });
609
- return data.user;
610
- }
611
-
612
- /**
613
- * Resolve an email to a minimal public profile (`uid`, `displayName`,
614
- * `photoURL`) for invite-by-email flows. Returns `null` when no account
615
- * matches. Requires the backend to opt in via `auth.allowUserLookup`;
616
- * otherwise the endpoint is absent and this rejects.
617
- */
618
- async function findUserByEmail(email: string): Promise<PublicUserProfile | null> {
619
- const data = await transport.request<{ user: PublicUserProfile | null }>(authPath + "/find-user", {
620
- method: "POST",
621
- body: JSON.stringify({ email })
622
- });
623
- return data.user;
624
- }
625
-
626
- async function updateUser(updates: { displayName?: string, photoURL?: string }) {
627
- const data = await transport.request<{ user: User }>(authPath + "/me", {
628
- method: "PATCH",
629
- body: JSON.stringify(updates)
630
- });
631
- if (currentSession) {
632
- currentSession = { ...currentSession,
633
- user: data.user };
634
- saveSession(currentSession);
635
- emit("USER_UPDATED", currentSession);
636
- }
637
- return data.user;
638
- }
639
-
640
- async function resetPasswordForEmail(email: string) {
641
- const fetchFn = getFetch();
642
- const res = await fetchFn(authUrl("/forgot-password"), {
643
- method: "POST",
644
- headers: { "Content-Type": "application/json" },
645
- body: JSON.stringify({ email })
646
- });
647
- const body = await res.json().catch(() => ({}));
648
- if (!res.ok) throwApiError(res.status, body, res.statusText);
649
- return body as { success: boolean; message: string; };
650
- }
651
-
652
- async function resetPassword(token: string, password: string) {
653
- const fetchFn = getFetch();
654
- const res = await fetchFn(authUrl("/reset-password"), {
655
- method: "POST",
656
- headers: { "Content-Type": "application/json" },
657
- body: JSON.stringify({ token,
658
- password })
659
- });
660
- const body = await res.json().catch(() => ({}));
661
- if (!res.ok) throwApiError(res.status, body, res.statusText);
662
- return body as { success: boolean; message: string; };
663
- }
664
-
665
- async function changePassword(oldPassword: string, newPassword: string) {
666
- return transport.request<{ success: boolean; message: string; }>(authPath + "/change-password", {
667
- method: "POST",
668
- body: JSON.stringify({ oldPassword,
669
- newPassword })
670
- });
671
- }
672
-
673
- /**
674
- * Link an OAuth provider to the **currently signed-in** account.
675
- *
676
- * Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
677
- * with that email already exists under a different sign-in method — or to
678
- * attach a provider whose email differs from the account's.
679
- *
680
- * The payload is the same one the provider's sign-in method takes, e.g.
681
- * `linkProvider("google", { idToken })`.
682
- *
683
- * Unlike sign-in, this does not require the provider to have verified the
684
- * email, and the emails need not match: the active session already proves
685
- * account ownership.
686
- *
687
- * Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
688
- * attached to a different user. Succeeds idempotently (`alreadyLinked:
689
- * true`) if it is already attached to the current one.
690
- */
691
- async function linkProvider(
692
- providerId: string,
693
- payload: Record<string, unknown>
694
- ) {
695
- return transport.request<{ success: boolean; provider: string; alreadyLinked: boolean; }>(
696
- authPath + "/link/" + providerId,
697
- {
698
- method: "POST",
699
- body: JSON.stringify(payload)
700
- }
701
- );
702
- }
703
-
704
- async function sendVerificationEmail() {
705
- return transport.request<{ success: boolean; message: string; }>(authPath + "/send-verification", {
706
- method: "POST"
707
- });
708
- }
709
-
710
- async function verifyEmail(token: string) {
711
- const fetchFn = getFetch();
712
- const res = await fetchFn(authUrl("/verify-email?token=" + encodeURIComponent(token)), {
713
- method: "GET",
714
- headers: { "Content-Type": "application/json" }
715
- });
716
- const body = await res.json().catch(() => ({}));
717
- if (!res.ok) throwApiError(res.status, body, res.statusText);
718
- return body as { success: boolean; message: string; };
719
- }
720
-
721
- async function sendMagicLink(email: string) {
722
- const fetchFn = getFetch();
723
- const res = await fetchFn(authUrl("/magic-link"), {
724
- method: "POST",
725
- headers: { "Content-Type": "application/json" },
726
- body: JSON.stringify({ email })
727
- });
728
- const body = await res.json().catch(() => ({}));
729
- if (!res.ok) throwApiError(res.status, body, res.statusText);
730
- return body as { success: boolean; message: string; };
731
- }
732
-
733
- async function verifyMagicLink(token: string) {
734
- const fetchFn = getFetch();
735
- const res = await fetchFn(authUrl("/magic-link/verify"), {
736
- method: "POST",
737
- headers: { "Content-Type": "application/json" },
738
- body: JSON.stringify({ token }),
739
- credentials: authFlowMode === "cookie" ? "include" : undefined
740
- } as RequestInit);
741
- const body = await res.json().catch(() => ({}));
742
- if (!res.ok) throwApiError(res.status, body, res.statusText);
743
- const session = handleAuthResponse(body, "SIGNED_IN");
744
- return { user: session.user,
745
- accessToken: session.accessToken,
746
- refreshToken: session.refreshToken };
747
- }
748
-
749
- /**
750
- * Ask for a six-digit sign-in code by email.
751
- *
752
- * Answers the same thing whether or not the address has an account — do not
753
- * use the result to tell a person whether they are registered, because it
754
- * does not know.
755
- */
756
- async function sendEmailOtp(email: string) {
757
- const fetchFn = getFetch();
758
- const res = await fetchFn(authUrl("/otp"), {
759
- method: "POST",
760
- headers: { "Content-Type": "application/json" },
761
- body: JSON.stringify({ email })
762
- });
763
- const body = await res.json().catch(() => ({}));
764
- if (!res.ok) throwApiError(res.status, body, res.statusText);
765
- return body as { success: boolean; message: string; expiresInSeconds: number };
766
- }
767
-
768
- /**
769
- * Trade a code for a session.
770
- *
771
- * The address is sent with the code because the code is only valid for it:
772
- * that is what keeps a six-digit guess a guess against one account rather
773
- * than against every account at once.
774
- */
775
- async function verifyEmailOtp(email: string, code: string) {
776
- const fetchFn = getFetch();
777
- const res = await fetchFn(authUrl("/otp/verify"), {
778
- method: "POST",
779
- headers: { "Content-Type": "application/json" },
780
- body: JSON.stringify({ email, code }),
781
- credentials: authFlowMode === "cookie" ? "include" : undefined
782
- } as RequestInit);
783
- const body = await res.json().catch(() => ({}));
784
- if (!res.ok) throwApiError(res.status, body, res.statusText);
785
- const session = handleAuthResponse(body, "SIGNED_IN");
786
- return { user: session.user,
787
- accessToken: session.accessToken,
788
- refreshToken: session.refreshToken };
789
- }
790
-
791
- async function getSessions(): Promise<DeviceSession[]> {
792
- const data = await transport.request<{ sessions: DeviceSession[] }>(authPath + "/sessions", { method: "GET" });
793
- return data.sessions;
794
- }
795
-
796
- async function revokeSession(sessionId: string) {
797
- return transport.request<{ success: boolean }>(authPath + "/sessions/" + encodeURIComponent(sessionId), {
798
- method: "DELETE"
799
- });
800
- }
801
-
802
- async function revokeAllSessions() {
803
- const result = await transport.request<{ success: boolean }>(authPath + "/sessions", {
804
- method: "DELETE"
805
- });
806
- currentSession = null;
807
- clearStoredSession();
808
- if (refreshTimeout) {
809
- clearTimeout(refreshTimeout);
810
- refreshTimeout = null;
811
- }
812
- transport.setToken(null);
813
- emit("SIGNED_OUT", null);
814
- return result;
815
- }
816
-
817
- async function getAuthConfig() {
818
- const fetchFn = getFetch();
819
- const res = await fetchFn(authUrl("/config"), {
820
- method: "GET",
821
- headers: { "Content-Type": "application/json" }
822
- });
823
- const body = await res.json().catch(() => ({}));
824
- if (!res.ok) throwApiError(res.status, body, res.statusText);
825
- return body as AuthConfig;
826
- }
827
-
828
- function getSession() {
829
- return currentSession;
830
- }
831
-
832
- function onAuthStateChange(callback: (event: AuthChangeEvent, session: RebaseSession | null) => void) {
833
- listeners.add(callback);
834
- return () => listeners.delete(callback);
835
- }
836
-
837
- if (persistSession) {
838
- const stored = loadStoredSession();
839
- if (stored && stored.accessToken) {
840
- if (stored.expiresAt > Date.now()) {
841
- currentSession = stored;
842
- transport.setToken(stored.accessToken);
843
- scheduleRefresh(stored.expiresAt);
844
- resolveInitialized!();
845
- } else if (authFlowMode === "cookie" || stored.refreshToken) {
846
- currentSession = stored;
847
- refreshSession().then(() => {
848
- resolveInitialized!();
849
- }).catch(() => {
850
- currentSession = null;
851
- clearStoredSession();
852
- transport.setToken(null);
853
- resolveInitialized!();
854
- });
855
- } else {
856
- resolveInitialized!();
857
- }
858
- } else if (authFlowMode === "cookie") {
859
- // Silent refresh on boot to pick up httpOnly session
860
- refreshSession().then(() => {
861
- resolveInitialized!();
862
- }).catch(() => {
863
- resolveInitialized!();
864
- });
865
- } else {
866
- resolveInitialized!();
867
- }
868
- } else {
869
- resolveInitialized!();
870
- }
871
-
872
- return {
873
- signInWithEmail,
874
- signUp,
875
- signInWithGoogle,
876
- signInWithLinkedin,
877
- signInWithOAuth,
878
- signInWithGitHub,
879
- signInWithMicrosoft,
880
- signInWithApple,
881
- signInWithFacebook,
882
- signInWithTwitter,
883
- signInWithDiscord,
884
- signInWithGitLab,
885
- signInWithBitbucket,
886
- signInWithSlack,
887
- signInWithSpotify,
888
- signOut,
889
- stopAutoRefresh,
890
- refreshSession,
891
- handleUnauthorized,
892
- getUser,
893
- findUserByEmail,
894
- updateUser,
895
- resetPasswordForEmail,
896
- resetPassword,
897
- changePassword,
898
- linkProvider,
899
- sendVerificationEmail,
900
- verifyEmail,
901
- sendMagicLink,
902
- verifyMagicLink,
903
- sendEmailOtp,
904
- verifyEmailOtp,
905
- getSessions,
906
- revokeSession,
907
- revokeAllSessions,
908
- getAuthConfig,
909
- getSession,
910
- onAuthStateChange,
911
- // A client that neither persists sessions nor uses cookie auth has
912
- // nowhere to restore one from, so "no session in memory" is the final
913
- // answer rather than a reason to ask the server. See the docblock on
914
- // `AuthClient.canRestoreSession`.
915
- canRestoreSession: () => persistSession || authFlowMode === "cookie",
916
- isInitialized: () => isInitialized
917
- };
918
- }
919
-
920
- export interface CookieStorageOptions {
921
- path?: string;
922
- domain?: string;
923
- secure?: boolean;
924
- sameSite?: "Lax" | "Strict" | "None";
925
- maxAge?: number;
926
- }
927
-
928
- export function createCookieStorage(options: CookieStorageOptions = {}): AuthStorage {
929
- const defaultOptions = {
930
- path: "/",
931
- sameSite: "Lax" as const,
932
- ...options
933
- };
934
-
935
- return {
936
- getItem(key: string): string | null {
937
- if (typeof document === "undefined") return null;
938
- const nameEQ = encodeURIComponent(key) + "=";
939
- const ca = document.cookie.split(";");
940
- for (let i = 0; i < ca.length; i++) {
941
- let c = ca[i];
942
- while (c.charAt(0) === " ") c = c.substring(1, c.length);
943
- if (c.indexOf(nameEQ) === 0) {
944
- return decodeURIComponent(c.substring(nameEQ.length, c.length));
945
- }
946
- }
947
- return null;
948
- },
949
- setItem(key: string, value: string): void {
950
- if (typeof document === "undefined") return;
951
- let cookieStr = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
952
-
953
- if (defaultOptions.path) {
954
- cookieStr += `; path=${defaultOptions.path}`;
955
- }
956
- if (defaultOptions.domain) {
957
- cookieStr += `; domain=${defaultOptions.domain}`;
958
- }
959
- if (defaultOptions.maxAge !== undefined) {
960
- cookieStr += `; max-age=${defaultOptions.maxAge}`;
961
- } else {
962
- cookieStr += `; max-age=${365 * 24 * 60 * 60}`;
963
- }
964
- if (defaultOptions.secure) {
965
- cookieStr += "; secure";
966
- }
967
- if (defaultOptions.sameSite) {
968
- cookieStr += `; samesite=${defaultOptions.sameSite}`;
969
- }
970
-
971
- document.cookie = cookieStr;
972
- },
973
- removeItem(key: string): void {
974
- if (typeof document === "undefined") return;
975
- let cookieStr = `${encodeURIComponent(key)}=; path=${defaultOptions.path || "/"}; max-age=-1`;
976
- if (defaultOptions.domain) {
977
- cookieStr += `; domain=${defaultOptions.domain}`;
978
- }
979
- document.cookie = cookieStr;
980
- }
981
- };
982
- }