@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.
- package/README.md +10 -10
- package/dist/auth.d.ts +35 -38
- package/dist/collection.d.ts +9 -13
- package/dist/data-proxy.test.d.ts +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/index.d.ts +23 -22
- package/dist/index.es.js +485 -215
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +499 -230
- package/dist/index.umd.js.map +1 -1
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/transport.d.ts +2 -6
- package/dist/websocket.d.ts +25 -21
- package/package.json +4 -4
- package/src/auth.ts +188 -64
- package/src/collection.test.ts +92 -3
- package/src/collection.ts +72 -75
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +146 -27
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +138 -0
- package/src/storage.ts +64 -32
- package/src/transport.ts +25 -25
- package/src/websocket.ts +133 -135
|
@@ -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
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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>;
|
package/dist/websocket.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
77
|
-
|
|
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
|
|
93
|
-
* for
|
|
94
|
-
* React re-renders when the server refetches all
|
|
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
|
|
98
|
-
listenCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>, onUpdate: (
|
|
99
|
-
|
|
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
|
|
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.
|
|
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/
|
|
34
|
-
"@rebasepro/
|
|
35
|
-
"@rebasepro/
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
200
|
+
void attemptScheduledRefresh(0);
|
|
141
201
|
return;
|
|
142
202
|
}
|
|
143
203
|
|
|
144
|
-
refreshTimeout = setTimeout(
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
338
|
-
|
|
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
|
|
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
|
|
351
|
-
refreshToken: body.tokens.refreshToken,
|
|
440
|
+
accessToken,
|
|
441
|
+
refreshToken: body.tokens.refreshToken || currentSession?.refreshToken || "",
|
|
352
442
|
expiresAt: body.tokens.accessTokenExpiresAt,
|
|
353
|
-
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
617
|
+
resolveInitialized!();
|
|
618
|
+
} else if (authFlowMode === "cookie" || stored.refreshToken) {
|
|
513
619
|
currentSession = stored;
|
|
514
|
-
refreshSession().
|
|
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
|
|