@rebasepro/client 0.8.0 → 0.9.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.
@@ -0,0 +1,63 @@
1
+ import { FindResult, LogicalCondition, SDKCollectionClient, SDKQueryBuilderInterface, WhereFilterOp, WhereValue } from "@rebasepro/types";
2
+ /**
3
+ * SDK Query Builder — returns flat rows (`FindResult<M>`) instead of
4
+ * Entity-wrapped results (`FindResponse<M>`).
5
+ *
6
+ * @example
7
+ * const { data } = await rebase.data.posts
8
+ * .where("status", "==", "published")
9
+ * .orderBy("created_at", "desc")
10
+ * .limit(10)
11
+ * .find();
12
+ *
13
+ * console.log(data[0].title); // flat access
14
+ */
15
+ export declare class SDKQueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements SDKQueryBuilderInterface<M> {
16
+ private collection;
17
+ private params;
18
+ constructor(collection: SDKCollectionClient<M>);
19
+ /**
20
+ * Add a filter condition to your query.
21
+ * @example
22
+ * client.data.users.where('age', '>=', 18).find()
23
+ */
24
+ where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
25
+ where(logicalCondition: LogicalCondition): this;
26
+ /**
27
+ * Order the results by a specific column.
28
+ */
29
+ orderBy(column: keyof M & string, direction?: "asc" | "desc"): this;
30
+ /**
31
+ * Limit the number of results returned.
32
+ */
33
+ limit(count: number): this;
34
+ /**
35
+ * Skip the first N results.
36
+ */
37
+ offset(count: number): this;
38
+ /**
39
+ * Set a free-text search string if supported by the backend.
40
+ */
41
+ search(searchString: string): this;
42
+ /**
43
+ * Include related entities in the response.
44
+ * Relations will be populated with full data instead of just IDs.
45
+ *
46
+ * @param relations - Relation names to include, or "*" for all.
47
+ * @example
48
+ * client.data.posts.include("tags", "author").find()
49
+ */
50
+ include(...relations: string[]): this;
51
+ /**
52
+ * Execute the find query and return the results as flat rows.
53
+ */
54
+ find(): Promise<FindResult<M>>;
55
+ /**
56
+ * Count the records matching this query.
57
+ */
58
+ count(): Promise<number>;
59
+ /**
60
+ * Listen to realtime updates matching this query.
61
+ */
62
+ listen(onUpdate: (data: FindResult<M>) => void, onError?: (error: Error) => void): () => void;
63
+ }
@@ -1,4 +1,6 @@
1
1
  import { FindParams as TypesFindParams, FindResponse as TypesFindResponse } from "@rebasepro/types";
2
+ export { RebaseApiError } from "@rebasepro/types";
3
+ export type { RebaseErrorInit } from "@rebasepro/types";
2
4
  export interface RebaseClientConfig {
3
5
  baseUrl?: string;
4
6
  token?: string;
@@ -12,12 +14,6 @@ export interface RebaseClientConfig {
12
14
  */
13
15
  export type FindParams = TypesFindParams;
14
16
  export type FindResponse<T> = TypesFindResponse<T extends Record<string, unknown> ? T : Record<string, unknown>>;
15
- export declare class RebaseApiError extends Error {
16
- status: number;
17
- code?: string;
18
- details?: unknown;
19
- constructor(status: number, message: string, code?: string, details?: unknown);
20
- }
21
17
  export declare function buildQueryString(params?: FindParams): string;
22
18
  export interface Transport {
23
19
  request: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
@@ -1,4 +1,4 @@
1
- import { DeleteEntityProps, Entity, EntityCollection, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo } from "@rebasepro/types";
1
+ import { DeleteProps, CollectionConfig, FetchCollectionProps, FetchOneProps, SaveProps, TableMetadata, BranchInfo } from "@rebasepro/types";
2
2
  export interface RebaseWebSocketConfig {
3
3
  websocketUrl: string;
4
4
  /** Optional auth token getter for WebSocket authentication */
@@ -8,11 +8,15 @@ export interface RebaseWebSocketConfig {
8
8
  /** Callback to handle unauthorized requests or token expiration (refreshes auth session) */
9
9
  onUnauthorized?: () => Promise<boolean>;
10
10
  }
11
- export declare class ApiError extends Error {
12
- code?: string;
13
- error?: string;
14
- constructor(message: string, error?: string, code?: string);
15
- }
11
+ /**
12
+ * Low-level realtime WebSocket client.
13
+ *
14
+ * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
15
+ * manages this internally (exposed as `client.ws`, typed by the minimal
16
+ * `RebaseWebSocket` contract in `@rebasepro/types`). It is re-exported from the
17
+ * package root only because the `@rebasepro/client-postgresql` driver
18
+ * instantiates it directly; its surface may change without a major bump.
19
+ */
16
20
  export declare class RebaseWebSocketClient {
17
21
  private websocketUrl;
18
22
  private ws;
@@ -22,7 +26,7 @@ export declare class RebaseWebSocketClient {
22
26
  on(event: "connect" | "disconnect" | "reconnect" | "error", cb: (...args: unknown[]) => void): () => boolean;
23
27
  private emit;
24
28
  private collectionSubscriptions;
25
- private entitySubscriptions;
29
+ private singleSubscriptions;
26
30
  private backendToCollectionKey;
27
31
  private backendToEntityKey;
28
32
  private pendingRequests;
@@ -53,7 +57,7 @@ export declare class RebaseWebSocketClient {
53
57
  private isAuthError;
54
58
  private handleAuthFailure;
55
59
  /**
56
- * Shared logic for re-subscribing a collection or entity subscription
60
+ * Shared logic for re-subscribing a collection or row subscription
57
61
  * after an auth error is resolved by refreshing credentials.
58
62
  */
59
63
  private resubscribeAfterAuthRefresh;
@@ -62,10 +66,10 @@ export declare class RebaseWebSocketClient {
62
66
  reauthenticate(): Promise<void>;
63
67
  private sendMessage;
64
68
  private doSendMessage;
65
- fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Entity<M>[]>;
66
- fetchEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>): Promise<Entity<M> | undefined>;
67
- saveEntity<M extends Record<string, unknown>>(props: SaveEntityProps<M>): Promise<Entity<M>>;
68
- deleteEntity<M extends Record<string, unknown>>(props: DeleteEntityProps<M>): Promise<void>;
69
+ fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]>;
70
+ fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
71
+ save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>>;
72
+ delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void>;
69
73
  executeSql(sql: string, options?: {
70
74
  database?: string;
71
75
  role?: string;
@@ -73,8 +77,8 @@ export declare class RebaseWebSocketClient {
73
77
  fetchAvailableDatabases(): Promise<string[]>;
74
78
  fetchAvailableRoles(): Promise<string[]>;
75
79
  fetchCurrentDatabase(): Promise<string | undefined>;
76
- checkUniqueField(path: string, name: string, value: unknown, entityId?: string, collection?: EntityCollection): Promise<boolean>;
77
- countEntities<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;
80
+ checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
81
+ count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number>;
78
82
  fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]>;
79
83
  fetchTableMetadata(tableName: string): Promise<TableMetadata>;
80
84
  createBranch(name: string, options?: {
@@ -89,14 +93,14 @@ export declare class RebaseWebSocketClient {
89
93
  private deepEqual;
90
94
  private normalizeForComparison;
91
95
  /**
92
- * Merge incoming entities with cached data, preserving cached references
93
- * for entities whose values haven't changed. This avoids unnecessary
94
- * React re-renders when the server refetches all entities but most
96
+ * Merge incoming rows with cached data, preserving cached references
97
+ * for rows whose values haven't changed. This avoids unnecessary
98
+ * React re-renders when the server refetches all rows but most
95
99
  * haven't actually changed.
96
100
  */
97
- private mergeEntities;
98
- listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (entities: Entity[]) => void, onError?: (error: Error) => void): () => void;
99
- listenEntity<M extends Record<string, unknown>>(props: FetchEntityProps<M>, onUpdate: (entity: Entity | null) => void, onError?: (error: Error) => void): () => void;
101
+ private mergeRows;
102
+ listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (rows: Record<string, unknown>[]) => void, onError?: (error: Error) => void): () => void;
103
+ listenOne<M extends Record<string, unknown>>(props: FetchOneProps<M>, onUpdate: (row: Record<string, unknown> | null) => void, onError?: (error: Error) => void): () => void;
100
104
  /**
101
105
  * Re-send all active subscriptions to the backend after a reconnect.
102
106
  * The server wipes subscription state when a client disconnects, so
@@ -104,5 +108,5 @@ export declare class RebaseWebSocketClient {
104
108
  */
105
109
  private resubscribeAll;
106
110
  private createCollectionSubscriptionKey;
107
- private createEntitySubscriptionKey;
111
+ private createSingleSubscriptionKey;
108
112
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -30,9 +30,9 @@
30
30
  "./package.json": "./package.json"
31
31
  },
32
32
  "dependencies": {
33
- "@rebasepro/common": "0.8.0",
34
- "@rebasepro/types": "0.8.0",
35
- "@rebasepro/utils": "0.8.0"
33
+ "@rebasepro/types": "0.9.0",
34
+ "@rebasepro/utils": "0.9.0",
35
+ "@rebasepro/common": "0.9.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@jest/globals": "^30.4.1",
package/src/auth.ts CHANGED
@@ -1,32 +1,38 @@
1
1
  import { RebaseApiError, Transport } from "./transport";
2
- import { AuthChangeEvent } from "@rebasepro/types";
2
+ import type { AuthChangeEvent, RebaseSession, AuthTokens, DeviceSession, User } from "@rebasepro/types";
3
3
 
4
+ // Re-export canonical types so `import { RebaseSession } from "@rebasepro/client"` keeps working
5
+ export type { RebaseSession, AuthTokens, AuthChangeEvent, DeviceSession } from "@rebasepro/types";
4
6
 
5
- export interface RebaseUser {
7
+ /** @deprecated Use `User` from `@rebasepro/types` instead. */
8
+ export type RebaseUser = User;
9
+ /** @deprecated Use `AuthTokens` from `@rebasepro/types` instead. */
10
+ export type RebaseTokens = AuthTokens;
11
+
12
+ /** Minimal, non-sensitive user profile returned by {@link findUserByEmail}. */
13
+ export interface PublicUserProfile {
6
14
  uid: string;
7
- email: string | null;
8
15
  displayName: string | null;
9
16
  photoURL: string | null;
10
- emailVerified?: boolean;
11
- roles?: string[];
12
- providerId: string;
13
- isAnonymous: boolean;
14
- }
15
-
16
- export interface RebaseTokens {
17
- accessToken: string;
18
- refreshToken: string;
19
- accessTokenExpiresAt: number;
20
17
  }
21
18
 
22
- export interface RebaseSession {
23
- accessToken: string;
24
- refreshToken: string;
25
- expiresAt: number;
26
- user: RebaseUser;
19
+ /** Map a raw user object from an auth response (`/login`, `/refresh`, `/me`) to a `User`. */
20
+ function mapRawUser(raw: Record<string, unknown>): User {
21
+ return {
22
+ uid: raw.uid as string,
23
+ email: (raw.email as string | null) ?? null,
24
+ displayName: (raw.displayName as string | null) ?? null,
25
+ photoURL: (raw.photoURL as string | null) ?? null,
26
+ providerId: (raw.providerId as string | undefined) ?? "password",
27
+ isAnonymous: (raw.isAnonymous as boolean | undefined) ?? false,
28
+ emailVerified: raw.emailVerified as boolean | undefined,
29
+ roles: raw.roles as string[] | undefined,
30
+ metadata: raw.metadata as Record<string, unknown> | undefined,
31
+ };
27
32
  }
28
33
 
29
- export type { AuthChangeEvent };
34
+ /** Placeholder user, used only as a last resort when none can be resolved. */
35
+ const EMPTY_USER: User = { uid: "", email: null, displayName: null, photoURL: null, providerId: "password", isAnonymous: false };
30
36
 
31
37
 
32
38
  export interface AuthConfig {
@@ -70,6 +76,12 @@ export interface CreateAuthOptions {
70
76
  authPath?: string;
71
77
  autoRefresh?: boolean;
72
78
  persistSession?: boolean;
79
+ /**
80
+ * Authentication flow mode.
81
+ * - 'json' (default): Tokens are sent/received in JSON bodies. Refresh token is stored in local storage.
82
+ * - 'cookie': Refresh token is sent/received via httpOnly cookies. Access token remains in memory.
83
+ */
84
+ authFlowMode?: "json" | "cookie";
73
85
  }
74
86
 
75
87
  export function createAuth(transport: Transport, options?: CreateAuthOptions) {
@@ -78,13 +90,28 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
78
90
  const authPath = opts.authPath || "/auth";
79
91
  const autoRefresh = opts.autoRefresh !== false;
80
92
  const persistSession = opts.persistSession !== false;
93
+ const authFlowMode = opts.authFlowMode || "json";
81
94
 
82
95
  const STORAGE_KEY = "rebase_auth";
83
96
  const REFRESH_BUFFER_MS = 120000;
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;
84
102
 
85
103
  let currentSession: RebaseSession | null = null;
86
104
  const listeners = new Set<(event: AuthChangeEvent, session: RebaseSession | null) => void>();
87
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
+ });
88
115
 
89
116
  function authUrl(endpoint: string) {
90
117
  return transport.baseUrl + transport.apiPath + authPath + endpoint;
@@ -96,10 +123,12 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
96
123
 
97
124
  function throwApiError(status: number, body: { error?: { message?: string; code?: string; details?: unknown }; message?: string; code?: string; details?: unknown } | undefined, statusText: string): never {
98
125
  throw new RebaseApiError(
99
- status,
100
126
  body?.error?.message || body?.message || statusText,
101
- body?.error?.code || body?.code,
102
- body?.error?.details || body?.details
127
+ {
128
+ status,
129
+ code: body?.error?.code || body?.code,
130
+ details: body?.error?.details || body?.details
131
+ }
103
132
  );
104
133
  }
105
134
 
@@ -110,7 +139,7 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
110
139
  }
111
140
 
112
141
  function saveSession(session: RebaseSession) {
113
- if (!persistSession) return;
142
+ if (!persistSession || authFlowMode === "cookie") return;
114
143
  try {
115
144
  storage.setItem(STORAGE_KEY, JSON.stringify(session));
116
145
  } catch (e) { /* ignore */ }
@@ -130,6 +159,37 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
130
159
  return null;
131
160
  }
132
161
 
162
+ /**
163
+ * A refresh failure is only fatal if the refresh token itself is rejected
164
+ * (expired / invalid / forbidden). Network blips, timeouts, and 5xx (e.g. a
165
+ * backend restart mid-session) are transient and must NOT log the user out.
166
+ */
167
+ function isFatalRefreshError(err: unknown): boolean {
168
+ if (!(err instanceof RebaseApiError)) return false; // network/other → transient
169
+ if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
170
+ // 401/403 are auth failures; other statuses (incl. 5xx, 0) are transient.
171
+ return err.status === 401 || err.status === 403;
172
+ }
173
+
174
+ async function attemptScheduledRefresh(attempt: number) {
175
+ try {
176
+ await refreshSession();
177
+ // On success, refreshSession() re-schedules the next refresh itself.
178
+ } catch (err) {
179
+ if (isFatalRefreshError(err)) {
180
+ signOut();
181
+ return;
182
+ }
183
+ if (attempt >= MAX_REFRESH_RETRIES) {
184
+ signOut();
185
+ return;
186
+ }
187
+ // Transient failure — back off and retry rather than dropping the session.
188
+ const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
189
+ refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(attempt + 1); }, backoff);
190
+ }
191
+ }
192
+
133
193
  function scheduleRefresh(expiresAt: number) {
134
194
  if (refreshTimeout) clearTimeout(refreshTimeout);
135
195
  if (!autoRefresh) return;
@@ -137,25 +197,20 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
137
197
  const delay = (expiresAt - REFRESH_BUFFER_MS) - Date.now();
138
198
 
139
199
  if (delay <= 0) {
140
- refreshSession().catch(() => signOut());
200
+ void attemptScheduledRefresh(0);
141
201
  return;
142
202
  }
143
203
 
144
- refreshTimeout = setTimeout(async () => {
145
- try {
146
- await refreshSession();
147
- } catch (e) {
148
- signOut();
149
- }
150
- }, delay);
204
+ refreshTimeout = setTimeout(() => { void attemptScheduledRefresh(0); }, delay);
151
205
  }
152
206
 
153
- function handleAuthResponse(data: { tokens: RebaseTokens, user: RebaseUser }, event?: AuthChangeEvent): RebaseSession {
207
+ function handleAuthResponse(data: { tokens: AuthTokens, user: Record<string, unknown> }, event?: AuthChangeEvent): RebaseSession {
208
+ const user: User = mapRawUser(data.user);
154
209
  const session: RebaseSession = {
155
210
  accessToken: data.tokens.accessToken,
156
- refreshToken: data.tokens.refreshToken,
211
+ refreshToken: data.tokens.refreshToken || (currentSession?.refreshToken) || "",
157
212
  expiresAt: data.tokens.accessTokenExpiresAt,
158
- user: data.user
213
+ user
159
214
  };
160
215
  currentSession = session;
161
216
  saveSession(session);
@@ -171,8 +226,9 @@ export function createAuth(transport: Transport, options?: CreateAuthOptions) {
171
226
  method: "POST",
172
227
  headers: { "Content-Type": "application/json" },
173
228
  body: JSON.stringify({ email,
174
- password })
175
- });
229
+ password }),
230
+ credentials: authFlowMode === "cookie" ? "include" : undefined
231
+ } as RequestInit);
176
232
  const body = await res.json().catch(() => ({}));
177
233
  if (!res.ok) throwApiError(res.status, body, res.statusText);
178
234
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -189,8 +245,9 @@ password };
189
245
  const res = await fetchFn(authUrl("/register"), {
190
246
  method: "POST",
191
247
  headers: { "Content-Type": "application/json" },
192
- body: JSON.stringify(payload)
193
- });
248
+ body: JSON.stringify(payload),
249
+ credentials: authFlowMode === "cookie" ? "include" : undefined
250
+ } as RequestInit);
194
251
  const body = await res.json().catch(() => ({}));
195
252
  if (!res.ok) throwApiError(res.status, body, res.statusText);
196
253
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -214,8 +271,9 @@ refreshToken: session.refreshToken };
214
271
  const res = await fetchFn(authUrl("/google"), {
215
272
  method: "POST",
216
273
  headers: { "Content-Type": "application/json" },
217
- body: JSON.stringify(payload)
218
- });
274
+ body: JSON.stringify(payload),
275
+ credentials: authFlowMode === "cookie" ? "include" : undefined
276
+ } as RequestInit);
219
277
  const responseBody = await res.json().catch(() => ({}));
220
278
  if (!res.ok) throwApiError(res.status, responseBody, res.statusText);
221
279
  const session = handleAuthResponse(responseBody, "SIGNED_IN");
@@ -230,8 +288,9 @@ refreshToken: session.refreshToken };
230
288
  method: "POST",
231
289
  headers: { "Content-Type": "application/json" },
232
290
  body: JSON.stringify({ code,
233
- redirectUri })
234
- });
291
+ redirectUri }),
292
+ credentials: authFlowMode === "cookie" ? "include" : undefined
293
+ } as RequestInit);
235
294
  const body = await res.json().catch(() => ({}));
236
295
  if (!res.ok) throwApiError(res.status, body, res.statusText);
237
296
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -249,8 +308,9 @@ refreshToken: session.refreshToken };
249
308
  const res = await fetchFn(authUrl(`/${providerId}`), {
250
309
  method: "POST",
251
310
  headers: { "Content-Type": "application/json" },
252
- body: JSON.stringify(payload)
253
- });
311
+ body: JSON.stringify(payload),
312
+ credentials: authFlowMode === "cookie" ? "include" : undefined
313
+ } as RequestInit);
254
314
  const body = await res.json().catch(() => ({}));
255
315
  if (!res.ok) throwApiError(res.status, body, res.statusText);
256
316
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -316,12 +376,13 @@ redirectUri });
316
376
  async function signOut() {
317
377
  const fetchFn = getFetch();
318
378
  try {
319
- if (currentSession?.refreshToken) {
379
+ if (authFlowMode === "cookie" || currentSession?.refreshToken) {
320
380
  await fetchFn(authUrl("/logout"), {
321
381
  method: "POST",
322
382
  headers: { "Content-Type": "application/json" },
323
- body: JSON.stringify({ refreshToken: currentSession.refreshToken })
324
- });
383
+ body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
384
+ credentials: authFlowMode === "cookie" ? "include" : undefined
385
+ } as RequestInit);
325
386
  }
326
387
  } catch (e) { /* ignore */ }
327
388
  currentSession = null;
@@ -334,23 +395,52 @@ redirectUri });
334
395
  emit("SIGNED_OUT", null);
335
396
  }
336
397
 
337
- async function refreshSession() {
338
- if (!currentSession?.refreshToken) {
398
+ function refreshSession(): Promise<RebaseSession> {
399
+ // Share a single in-flight refresh across concurrent callers.
400
+ if (inFlightRefresh) return inFlightRefresh;
401
+ inFlightRefresh = doRefreshSession().finally(() => {
402
+ inFlightRefresh = null;
403
+ });
404
+ return inFlightRefresh;
405
+ }
406
+
407
+ async function doRefreshSession(): Promise<RebaseSession> {
408
+ if (authFlowMode !== "cookie" && !currentSession?.refreshToken) {
339
409
  throw new Error("No active session to refresh");
340
410
  }
341
411
  const fetchFn = getFetch();
342
412
  const res = await fetchFn(authUrl("/refresh"), {
343
413
  method: "POST",
344
414
  headers: { "Content-Type": "application/json" },
345
- body: JSON.stringify({ refreshToken: currentSession.refreshToken })
346
- });
415
+ body: JSON.stringify({ refreshToken: currentSession?.refreshToken }),
416
+ credentials: authFlowMode === "cookie" ? "include" : undefined
417
+ } as RequestInit);
347
418
  const body = await res.json().catch(() => ({}));
348
419
  if (!res.ok) throwApiError(res.status, body, res.statusText);
420
+
421
+ const accessToken = body.tokens.accessToken;
422
+ transport.setToken(accessToken);
423
+
424
+ // Resolve the user, in order of preference:
425
+ // 1. the user returned by /refresh (modern backends include it),
426
+ // 2. the user already in memory,
427
+ // 3. a fetch of /me — required to restore a session from an httpOnly
428
+ // cookie alone (cold start in cookie mode), where there is no
429
+ // in-memory user and the backend didn't echo one.
430
+ let user = currentSession?.user;
431
+ if (body.user && typeof body.user.uid === "string") {
432
+ user = mapRawUser(body.user as Record<string, unknown>);
433
+ } else if (!user || !user.uid) {
434
+ try {
435
+ user = await getUser();
436
+ } catch { /* fall through to the empty stub below */ }
437
+ }
438
+
349
439
  const session: RebaseSession = {
350
- accessToken: body.tokens.accessToken,
351
- refreshToken: body.tokens.refreshToken,
440
+ accessToken,
441
+ refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
352
442
  expiresAt: body.tokens.accessTokenExpiresAt,
353
- user: currentSession.user
443
+ user: user ?? EMPTY_USER
354
444
  };
355
445
  currentSession = session;
356
446
  saveSession(session);
@@ -361,12 +451,26 @@ redirectUri });
361
451
  }
362
452
 
363
453
  async function getUser() {
364
- const data = await transport.request<{ user: RebaseUser }>(authPath + "/me", { method: "GET" });
454
+ const data = await transport.request<{ user: User }>(authPath + "/me", { method: "GET" });
455
+ return data.user;
456
+ }
457
+
458
+ /**
459
+ * Resolve an email to a minimal public profile (`uid`, `displayName`,
460
+ * `photoURL`) for invite-by-email flows. Returns `null` when no account
461
+ * matches. Requires the backend to opt in via `auth.allowUserLookup`;
462
+ * otherwise the endpoint is absent and this rejects.
463
+ */
464
+ async function findUserByEmail(email: string): Promise<PublicUserProfile | null> {
465
+ const data = await transport.request<{ user: PublicUserProfile | null }>(authPath + "/find-user", {
466
+ method: "POST",
467
+ body: JSON.stringify({ email })
468
+ });
365
469
  return data.user;
366
470
  }
367
471
 
368
472
  async function updateUser(updates: { displayName?: string, photoURL?: string }) {
369
- const data = await transport.request<{ user: RebaseUser }>(authPath + "/me", {
473
+ const data = await transport.request<{ user: User }>(authPath + "/me", {
370
474
  method: "PATCH",
371
475
  body: JSON.stringify(updates)
372
476
  });
@@ -446,8 +550,9 @@ newPassword })
446
550
  const res = await fetchFn(authUrl("/magic-link/verify"), {
447
551
  method: "POST",
448
552
  headers: { "Content-Type": "application/json" },
449
- body: JSON.stringify({ token })
450
- });
553
+ body: JSON.stringify({ token }),
554
+ credentials: authFlowMode === "cookie" ? "include" : undefined
555
+ } as RequestInit);
451
556
  const body = await res.json().catch(() => ({}));
452
557
  if (!res.ok) throwApiError(res.status, body, res.statusText);
453
558
  const session = handleAuthResponse(body, "SIGNED_IN");
@@ -456,8 +561,8 @@ accessToken: session.accessToken,
456
561
  refreshToken: session.refreshToken };
457
562
  }
458
563
 
459
- async function getSessions() {
460
- const data = await transport.request<{ sessions: Record<string, unknown>[] }>(authPath + "/sessions", { method: "GET" });
564
+ async function getSessions(): Promise<DeviceSession[]> {
565
+ const data = await transport.request<{ sessions: DeviceSession[] }>(authPath + "/sessions", { method: "GET" });
461
566
  return data.sessions;
462
567
  }
463
568
 
@@ -504,20 +609,37 @@ refreshToken: session.refreshToken };
504
609
 
505
610
  if (persistSession) {
506
611
  const stored = loadStoredSession();
507
- if (stored && stored.accessToken && stored.refreshToken) {
612
+ if (stored && stored.accessToken) {
508
613
  if (stored.expiresAt > Date.now()) {
509
614
  currentSession = stored;
510
615
  transport.setToken(stored.accessToken);
511
616
  scheduleRefresh(stored.expiresAt);
512
- } else if (stored.refreshToken) {
617
+ resolveInitialized!();
618
+ } else if (authFlowMode === "cookie" || stored.refreshToken) {
513
619
  currentSession = stored;
514
- refreshSession().catch(() => {
620
+ refreshSession().then(() => {
621
+ resolveInitialized!();
622
+ }).catch(() => {
515
623
  currentSession = null;
516
624
  clearStoredSession();
517
625
  transport.setToken(null);
626
+ resolveInitialized!();
518
627
  });
628
+ } else {
629
+ resolveInitialized!();
519
630
  }
631
+ } else if (authFlowMode === "cookie") {
632
+ // Silent refresh on boot to pick up httpOnly session
633
+ refreshSession().then(() => {
634
+ resolveInitialized!();
635
+ }).catch(() => {
636
+ resolveInitialized!();
637
+ });
638
+ } else {
639
+ resolveInitialized!();
520
640
  }
641
+ } else {
642
+ resolveInitialized!();
521
643
  }
522
644
 
523
645
  return {
@@ -539,6 +661,7 @@ refreshToken: session.refreshToken };
539
661
  signOut,
540
662
  refreshSession,
541
663
  getUser,
664
+ findUserByEmail,
542
665
  updateUser,
543
666
  resetPasswordForEmail,
544
667
  resetPassword,
@@ -552,7 +675,8 @@ refreshToken: session.refreshToken };
552
675
  revokeAllSessions,
553
676
  getAuthConfig,
554
677
  getSession,
555
- onAuthStateChange
678
+ onAuthStateChange,
679
+ isInitialized: () => isInitialized
556
680
  };
557
681
  }
558
682