@rebasepro/client 0.7.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/LICENSE +21 -0
- package/README.md +10 -10
- package/dist/api-keys.d.ts +1 -0
- 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 +41 -21
- package/dist/index.es.js +605 -368
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +616 -379
- package/dist/index.umd.js.map +1 -1
- package/dist/sdk_query_builder.d.ts +63 -0
- package/dist/storage-registry.d.ts +42 -0
- package/dist/storage.d.ts +9 -1
- package/dist/transport.d.ts +2 -6
- package/dist/websocket.d.ts +25 -21
- package/package.json +13 -12
- package/src/api-keys.ts +1 -0
- package/src/auth.ts +188 -64
- package/src/collection.test.ts +94 -5
- package/src/collection.ts +103 -184
- package/src/data-proxy.test.ts +167 -0
- package/src/errors.ts +9 -0
- package/src/index.ts +218 -24
- package/src/reviver.ts +2 -2
- package/src/sdk_query_builder.ts +138 -0
- package/src/storage-registry.ts +102 -0
- package/src/storage.ts +92 -50
- package/src/transport.ts +31 -105
- package/src/websocket.ts +133 -135
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
|
|
package/src/collection.test.ts
CHANGED
|
@@ -42,7 +42,7 @@ describe("createCollectionClient", () => {
|
|
|
42
42
|
|
|
43
43
|
const client = createCollectionClient(transport, "products");
|
|
44
44
|
const result = await client.count({
|
|
45
|
-
where: { status: "
|
|
45
|
+
where: { status: ["==", "published"] }
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
expect(transport.request).toHaveBeenCalledWith(
|
|
@@ -60,7 +60,7 @@ describe("createCollectionClient", () => {
|
|
|
60
60
|
|
|
61
61
|
const client = createCollectionClient(transport, "items");
|
|
62
62
|
const result = await client.count({
|
|
63
|
-
orderBy: "created_at
|
|
63
|
+
orderBy: ["created_at", "desc"]
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
@@ -111,7 +111,7 @@ offset: 10 });
|
|
|
111
111
|
const client = createCollectionClient(transport, "orders");
|
|
112
112
|
await client.count({
|
|
113
113
|
where: {
|
|
114
|
-
status: "
|
|
114
|
+
status: ["==", "active"],
|
|
115
115
|
total: [">=", 100]
|
|
116
116
|
}
|
|
117
117
|
});
|
|
@@ -123,7 +123,7 @@ offset: 10 });
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
describe("find()", () => {
|
|
126
|
-
it("should call the list endpoint and return
|
|
126
|
+
it("should call the list endpoint and return flat rows", async () => {
|
|
127
127
|
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({
|
|
128
128
|
data: [{ id: "1",
|
|
129
129
|
name: "Product A" }],
|
|
@@ -141,12 +141,101 @@ hasMore: false }
|
|
|
141
141
|
{ method: "GET" }
|
|
142
142
|
);
|
|
143
143
|
expect(result.data).toHaveLength(1);
|
|
144
|
+
// Flat row access — no .values wrapper
|
|
144
145
|
expect(result.data[0].id).toBe("1");
|
|
145
|
-
expect(result.data[0].
|
|
146
|
+
expect((result.data[0] as Record<string, unknown>).name).toBe("Product A");
|
|
147
|
+
// No path field — that's CMS leakage
|
|
148
|
+
expect((result.data[0] as Record<string, unknown>).path).toBeUndefined();
|
|
146
149
|
expect(result.meta.total).toBe(1);
|
|
147
150
|
});
|
|
148
151
|
});
|
|
149
152
|
|
|
153
|
+
describe("findById()", () => {
|
|
154
|
+
it("should return a flat row directly", async () => {
|
|
155
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "42", title: "Hello" });
|
|
156
|
+
|
|
157
|
+
const client = createCollectionClient(transport, "posts");
|
|
158
|
+
const result = await client.findById("42");
|
|
159
|
+
|
|
160
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
161
|
+
"/data/posts/42",
|
|
162
|
+
{ method: "GET" }
|
|
163
|
+
);
|
|
164
|
+
expect(result).toBeDefined();
|
|
165
|
+
expect(result!.id).toBe("42");
|
|
166
|
+
expect((result as Record<string, unknown>).title).toBe("Hello");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should return undefined for 404", async () => {
|
|
170
|
+
const { RebaseApiError } = await import("./transport");
|
|
171
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
172
|
+
new RebaseApiError("Not Found", { status: 404 })
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const client = createCollectionClient(transport, "posts");
|
|
176
|
+
const result = await client.findById("999");
|
|
177
|
+
expect(result).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("create()", () => {
|
|
182
|
+
it("should return a flat row", async () => {
|
|
183
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "new-1", title: "Created" });
|
|
184
|
+
|
|
185
|
+
const client = createCollectionClient(transport, "posts");
|
|
186
|
+
const result = await client.create({ title: "Created" } as Record<string, unknown>);
|
|
187
|
+
|
|
188
|
+
expect(result.id).toBe("new-1");
|
|
189
|
+
expect((result as Record<string, unknown>).title).toBe("Created");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("update()", () => {
|
|
194
|
+
it("should return a flat row", async () => {
|
|
195
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ id: "1", title: "Updated" });
|
|
196
|
+
|
|
197
|
+
const client = createCollectionClient(transport, "posts");
|
|
198
|
+
const result = await client.update("1", { title: "Updated" } as Record<string, unknown>);
|
|
199
|
+
|
|
200
|
+
expect(result.id).toBe("1");
|
|
201
|
+
expect((result as Record<string, unknown>).title).toBe("Updated");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should throw RebaseApiError for 404", async () => {
|
|
205
|
+
const { RebaseApiError } = await import("./transport");
|
|
206
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
207
|
+
new RebaseApiError("Not Found", { status: 404 })
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const client = createCollectionClient(transport, "posts");
|
|
211
|
+
await expect(
|
|
212
|
+
client.update("999", { title: "Nope" } as Record<string, unknown>)
|
|
213
|
+
).rejects.toMatchObject({ status: 404 });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("delete()", () => {
|
|
218
|
+
it("should throw RebaseApiError for 404", async () => {
|
|
219
|
+
const { RebaseApiError } = await import("./transport");
|
|
220
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
221
|
+
new RebaseApiError("Not Found", { status: 404 })
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const client = createCollectionClient(transport, "posts");
|
|
225
|
+
await expect(client.delete("999")).rejects.toMatchObject({ status: 404 });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should propagate non-404 errors", async () => {
|
|
229
|
+
const { RebaseApiError } = await import("./transport");
|
|
230
|
+
(transport.request as ReturnType<typeof jest.fn>).mockRejectedValue(
|
|
231
|
+
new RebaseApiError("Internal Server Error", { status: 500 })
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const client = createCollectionClient(transport, "posts");
|
|
235
|
+
await expect(client.delete("1")).rejects.toThrow("Internal Server Error");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
150
239
|
describe("count() is defined", () => {
|
|
151
240
|
it("should have count as a defined function on the accessor", () => {
|
|
152
241
|
const client = createCollectionClient(transport, "products");
|