@rebasepro/client 0.0.1-canary.000dc36
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 +6 -0
- package/dist/admin.d.ts +94 -0
- package/dist/auth.d.ts +161 -0
- package/dist/collection.d.ts +20 -0
- package/dist/collection.test.d.ts +1 -0
- package/dist/cron.d.ts +25 -0
- package/dist/cron.test.d.ts +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.es.js +2283 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +2285 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/query_builder.d.ts +53 -0
- package/dist/reviver.d.ts +1 -0
- package/dist/storage.d.ts +3 -0
- package/dist/transport.d.ts +33 -0
- package/dist/websocket.d.ts +99 -0
- package/package.json +82 -0
- package/src/admin.ts +119 -0
- package/src/auth.ts +512 -0
- package/src/collection.test.ts +159 -0
- package/src/collection.ts +257 -0
- package/src/cron.test.ts +164 -0
- package/src/cron.ts +62 -0
- package/src/index.ts +167 -0
- package/src/query_builder.ts +125 -0
- package/src/reviver.ts +39 -0
- package/src/storage.ts +181 -0
- package/src/transport.ts +259 -0
- package/src/websocket.ts +1176 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { RebaseApiError, Transport } from "./transport";
|
|
2
|
+
|
|
3
|
+
export interface RebaseUser {
|
|
4
|
+
uid: string;
|
|
5
|
+
email: string | null;
|
|
6
|
+
displayName: string | null;
|
|
7
|
+
photoURL: string | null;
|
|
8
|
+
emailVerified?: boolean;
|
|
9
|
+
roles?: string[];
|
|
10
|
+
providerId: string;
|
|
11
|
+
isAnonymous: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RebaseTokens {
|
|
15
|
+
accessToken: string;
|
|
16
|
+
refreshToken: string;
|
|
17
|
+
accessTokenExpiresAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RebaseSession {
|
|
21
|
+
accessToken: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
user: RebaseUser;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AuthChangeEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED";
|
|
28
|
+
|
|
29
|
+
export interface AuthConfig {
|
|
30
|
+
needsSetup: boolean;
|
|
31
|
+
registrationEnabled: boolean;
|
|
32
|
+
googleEnabled: boolean;
|
|
33
|
+
emailServiceEnabled: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AuthStorage {
|
|
37
|
+
getItem: (key: string) => string | null;
|
|
38
|
+
setItem: (key: string, value: string) => void;
|
|
39
|
+
removeItem: (key: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createMemoryStorage(): AuthStorage {
|
|
43
|
+
const store: Record<string, string> = {};
|
|
44
|
+
return {
|
|
45
|
+
getItem(key) { return store[key] ?? null; },
|
|
46
|
+
setItem(key, value) { store[key] = value; },
|
|
47
|
+
removeItem(key) { delete store[key]; }
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function detectStorage(): AuthStorage {
|
|
52
|
+
try {
|
|
53
|
+
if (typeof localStorage !== "undefined") {
|
|
54
|
+
localStorage.setItem("__rebase_test__", "1");
|
|
55
|
+
localStorage.removeItem("__rebase_test__");
|
|
56
|
+
return localStorage;
|
|
57
|
+
}
|
|
58
|
+
} catch (e) { /* ignore */ }
|
|
59
|
+
return createMemoryStorage();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CreateAuthOptions {
|
|
63
|
+
storage?: AuthStorage;
|
|
64
|
+
authPath?: string;
|
|
65
|
+
autoRefresh?: boolean;
|
|
66
|
+
persistSession?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createAuth(transport: Transport, options?: CreateAuthOptions) {
|
|
70
|
+
const opts = options || {};
|
|
71
|
+
const storage = opts.storage || detectStorage();
|
|
72
|
+
const authPath = opts.authPath || "/auth";
|
|
73
|
+
const autoRefresh = opts.autoRefresh !== false;
|
|
74
|
+
const persistSession = opts.persistSession !== false;
|
|
75
|
+
|
|
76
|
+
const STORAGE_KEY = "rebase_auth";
|
|
77
|
+
const REFRESH_BUFFER_MS = 120000;
|
|
78
|
+
|
|
79
|
+
let currentSession: RebaseSession | null = null;
|
|
80
|
+
const listeners = new Set<(event: AuthChangeEvent, session: RebaseSession | null) => void>();
|
|
81
|
+
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
82
|
+
|
|
83
|
+
function authUrl(endpoint: string) {
|
|
84
|
+
return transport.baseUrl + transport.apiPath + authPath + endpoint;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getFetch() {
|
|
88
|
+
return transport.fetchFn || globalThis.fetch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function throwApiError(status: number, body: { error?: { message?: string; code?: string; details?: unknown }; message?: string; code?: string; details?: unknown } | undefined, statusText: string): never {
|
|
92
|
+
throw new RebaseApiError(
|
|
93
|
+
status,
|
|
94
|
+
body?.error?.message || body?.message || statusText,
|
|
95
|
+
body?.error?.code || body?.code,
|
|
96
|
+
body?.error?.details || body?.details
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emit(event: AuthChangeEvent, session: RebaseSession | null) {
|
|
101
|
+
for (const fn of listeners) {
|
|
102
|
+
try { fn(event, session); } catch (e) { /* ignore */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function saveSession(session: RebaseSession) {
|
|
107
|
+
if (!persistSession) return;
|
|
108
|
+
try {
|
|
109
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(session));
|
|
110
|
+
} catch (e) { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function clearStoredSession() {
|
|
114
|
+
try {
|
|
115
|
+
storage.removeItem(STORAGE_KEY);
|
|
116
|
+
} catch (e) { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function loadStoredSession(): RebaseSession | null {
|
|
120
|
+
try {
|
|
121
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
122
|
+
if (raw) return JSON.parse(raw) as RebaseSession;
|
|
123
|
+
} catch (e) { /* ignore */ }
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scheduleRefresh(expiresAt: number) {
|
|
128
|
+
if (refreshTimeout) clearTimeout(refreshTimeout);
|
|
129
|
+
if (!autoRefresh) return;
|
|
130
|
+
|
|
131
|
+
const delay = (expiresAt - REFRESH_BUFFER_MS) - Date.now();
|
|
132
|
+
|
|
133
|
+
if (delay <= 0) {
|
|
134
|
+
refreshSession().catch(() => signOut());
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
refreshTimeout = setTimeout(async () => {
|
|
139
|
+
try {
|
|
140
|
+
await refreshSession();
|
|
141
|
+
} catch (e) {
|
|
142
|
+
signOut();
|
|
143
|
+
}
|
|
144
|
+
}, delay);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleAuthResponse(data: { tokens: RebaseTokens, user: RebaseUser }, event?: AuthChangeEvent): RebaseSession {
|
|
148
|
+
const session: RebaseSession = {
|
|
149
|
+
accessToken: data.tokens.accessToken,
|
|
150
|
+
refreshToken: data.tokens.refreshToken,
|
|
151
|
+
expiresAt: data.tokens.accessTokenExpiresAt,
|
|
152
|
+
user: data.user
|
|
153
|
+
};
|
|
154
|
+
currentSession = session;
|
|
155
|
+
saveSession(session);
|
|
156
|
+
transport.setToken(session.accessToken);
|
|
157
|
+
scheduleRefresh(session.expiresAt);
|
|
158
|
+
emit(event || "SIGNED_IN", session);
|
|
159
|
+
return session;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function signInWithEmail(email: string, password: string) {
|
|
163
|
+
const fetchFn = getFetch();
|
|
164
|
+
const res = await fetchFn(authUrl("/login"), {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json" },
|
|
167
|
+
body: JSON.stringify({ email,
|
|
168
|
+
password })
|
|
169
|
+
});
|
|
170
|
+
const body = await res.json().catch(() => ({}));
|
|
171
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
172
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
173
|
+
return { user: session.user,
|
|
174
|
+
accessToken: session.accessToken,
|
|
175
|
+
refreshToken: session.refreshToken };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function signUp(email: string, password: string, displayName?: string) {
|
|
179
|
+
const fetchFn = getFetch();
|
|
180
|
+
const payload: Record<string, string> = { email,
|
|
181
|
+
password };
|
|
182
|
+
if (displayName !== undefined) payload.displayName = displayName;
|
|
183
|
+
const res = await fetchFn(authUrl("/register"), {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify(payload)
|
|
187
|
+
});
|
|
188
|
+
const body = await res.json().catch(() => ({}));
|
|
189
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
190
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
191
|
+
return { user: session.user,
|
|
192
|
+
accessToken: session.accessToken,
|
|
193
|
+
refreshToken: session.refreshToken };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function signInWithGoogle(idToken: string) {
|
|
197
|
+
const fetchFn = getFetch();
|
|
198
|
+
const res = await fetchFn(authUrl("/google"), {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify({ idToken })
|
|
202
|
+
});
|
|
203
|
+
const body = await res.json().catch(() => ({}));
|
|
204
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
205
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
206
|
+
return { user: session.user,
|
|
207
|
+
accessToken: session.accessToken,
|
|
208
|
+
refreshToken: session.refreshToken };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function signInWithLinkedin(code: string, redirectUri: string) {
|
|
212
|
+
const fetchFn = getFetch();
|
|
213
|
+
const res = await fetchFn(authUrl("/linkedin"), {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "Content-Type": "application/json" },
|
|
216
|
+
body: JSON.stringify({ code,
|
|
217
|
+
redirectUri })
|
|
218
|
+
});
|
|
219
|
+
const body = await res.json().catch(() => ({}));
|
|
220
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
221
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
222
|
+
return { user: session.user,
|
|
223
|
+
accessToken: session.accessToken,
|
|
224
|
+
refreshToken: session.refreshToken };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Generic OAuth sign-in. Posts the given payload to `/auth/{providerId}`.
|
|
229
|
+
* Use this for any provider registered on the backend.
|
|
230
|
+
*/
|
|
231
|
+
async function signInWithOAuth(providerId: string, payload: Record<string, unknown>) {
|
|
232
|
+
const fetchFn = getFetch();
|
|
233
|
+
const res = await fetchFn(authUrl(`/${providerId}`), {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "Content-Type": "application/json" },
|
|
236
|
+
body: JSON.stringify(payload)
|
|
237
|
+
});
|
|
238
|
+
const body = await res.json().catch(() => ({}));
|
|
239
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
240
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
241
|
+
return { user: session.user,
|
|
242
|
+
accessToken: session.accessToken,
|
|
243
|
+
refreshToken: session.refreshToken };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Convenience wrappers for all supported OAuth providers
|
|
247
|
+
|
|
248
|
+
async function signInWithGitHub(code: string, redirectUri: string) {
|
|
249
|
+
return signInWithOAuth("github", { code,
|
|
250
|
+
redirectUri });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function signInWithMicrosoft(code: string, redirectUri: string) {
|
|
254
|
+
return signInWithOAuth("microsoft", { code,
|
|
255
|
+
redirectUri });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function signInWithApple(code: string, redirectUri: string, user?: { name?: { firstName?: string; lastName?: string }; email?: string }) {
|
|
259
|
+
return signInWithOAuth("apple", { code,
|
|
260
|
+
redirectUri,
|
|
261
|
+
user });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function signInWithFacebook(code: string, redirectUri: string) {
|
|
265
|
+
return signInWithOAuth("facebook", { code,
|
|
266
|
+
redirectUri });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function signInWithTwitter(code: string, redirectUri: string, codeVerifier: string) {
|
|
270
|
+
return signInWithOAuth("twitter", { code,
|
|
271
|
+
redirectUri,
|
|
272
|
+
codeVerifier });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function signInWithDiscord(code: string, redirectUri: string) {
|
|
276
|
+
return signInWithOAuth("discord", { code,
|
|
277
|
+
redirectUri });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function signInWithGitLab(code: string, redirectUri: string) {
|
|
281
|
+
return signInWithOAuth("gitlab", { code,
|
|
282
|
+
redirectUri });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function signInWithBitbucket(code: string, redirectUri: string) {
|
|
286
|
+
return signInWithOAuth("bitbucket", { code,
|
|
287
|
+
redirectUri });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function signInWithSlack(code: string, redirectUri: string) {
|
|
291
|
+
return signInWithOAuth("slack", { code,
|
|
292
|
+
redirectUri });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function signInWithSpotify(code: string, redirectUri: string) {
|
|
296
|
+
return signInWithOAuth("spotify", { code,
|
|
297
|
+
redirectUri });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function signOut() {
|
|
301
|
+
const fetchFn = getFetch();
|
|
302
|
+
try {
|
|
303
|
+
if (currentSession?.refreshToken) {
|
|
304
|
+
await fetchFn(authUrl("/logout"), {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "Content-Type": "application/json" },
|
|
307
|
+
body: JSON.stringify({ refreshToken: currentSession.refreshToken })
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
} catch (e) { /* ignore */ }
|
|
311
|
+
currentSession = null;
|
|
312
|
+
clearStoredSession();
|
|
313
|
+
if (refreshTimeout) {
|
|
314
|
+
clearTimeout(refreshTimeout);
|
|
315
|
+
refreshTimeout = null;
|
|
316
|
+
}
|
|
317
|
+
transport.setToken(null);
|
|
318
|
+
emit("SIGNED_OUT", null);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function refreshSession() {
|
|
322
|
+
if (!currentSession?.refreshToken) {
|
|
323
|
+
throw new Error("No active session to refresh");
|
|
324
|
+
}
|
|
325
|
+
const fetchFn = getFetch();
|
|
326
|
+
const res = await fetchFn(authUrl("/refresh"), {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: { "Content-Type": "application/json" },
|
|
329
|
+
body: JSON.stringify({ refreshToken: currentSession.refreshToken })
|
|
330
|
+
});
|
|
331
|
+
const body = await res.json().catch(() => ({}));
|
|
332
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
333
|
+
const session: RebaseSession = {
|
|
334
|
+
accessToken: body.tokens.accessToken,
|
|
335
|
+
refreshToken: body.tokens.refreshToken,
|
|
336
|
+
expiresAt: body.tokens.accessTokenExpiresAt,
|
|
337
|
+
user: currentSession.user
|
|
338
|
+
};
|
|
339
|
+
currentSession = session;
|
|
340
|
+
saveSession(session);
|
|
341
|
+
transport.setToken(session.accessToken);
|
|
342
|
+
scheduleRefresh(session.expiresAt);
|
|
343
|
+
emit("TOKEN_REFRESHED", session);
|
|
344
|
+
return session;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function getUser() {
|
|
348
|
+
const data = await transport.request<{ user: RebaseUser }>(authPath + "/me", { method: "GET" });
|
|
349
|
+
return data.user;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function updateUser(updates: { displayName?: string, photoURL?: string }) {
|
|
353
|
+
const data = await transport.request<{ user: RebaseUser }>(authPath + "/me", {
|
|
354
|
+
method: "PATCH",
|
|
355
|
+
body: JSON.stringify(updates)
|
|
356
|
+
});
|
|
357
|
+
if (currentSession) {
|
|
358
|
+
currentSession = { ...currentSession,
|
|
359
|
+
user: data.user };
|
|
360
|
+
saveSession(currentSession);
|
|
361
|
+
emit("USER_UPDATED", currentSession);
|
|
362
|
+
}
|
|
363
|
+
return data.user;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function resetPasswordForEmail(email: string) {
|
|
367
|
+
const fetchFn = getFetch();
|
|
368
|
+
const res = await fetchFn(authUrl("/forgot-password"), {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: { "Content-Type": "application/json" },
|
|
371
|
+
body: JSON.stringify({ email })
|
|
372
|
+
});
|
|
373
|
+
const body = await res.json().catch(() => ({}));
|
|
374
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
375
|
+
return body as { success: boolean; message: string; };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function resetPassword(token: string, password: string) {
|
|
379
|
+
const fetchFn = getFetch();
|
|
380
|
+
const res = await fetchFn(authUrl("/reset-password"), {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers: { "Content-Type": "application/json" },
|
|
383
|
+
body: JSON.stringify({ token,
|
|
384
|
+
password })
|
|
385
|
+
});
|
|
386
|
+
const body = await res.json().catch(() => ({}));
|
|
387
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
388
|
+
return body as { success: boolean; message: string; };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function changePassword(oldPassword: string, newPassword: string) {
|
|
392
|
+
return transport.request<{ success: boolean; message: string; }>(authPath + "/change-password", {
|
|
393
|
+
method: "POST",
|
|
394
|
+
body: JSON.stringify({ oldPassword,
|
|
395
|
+
newPassword })
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function sendVerificationEmail() {
|
|
400
|
+
return transport.request<{ success: boolean; message: string; }>(authPath + "/send-verification", {
|
|
401
|
+
method: "POST"
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function verifyEmail(token: string) {
|
|
406
|
+
const fetchFn = getFetch();
|
|
407
|
+
const res = await fetchFn(authUrl("/verify-email?token=" + encodeURIComponent(token)), {
|
|
408
|
+
method: "GET",
|
|
409
|
+
headers: { "Content-Type": "application/json" }
|
|
410
|
+
});
|
|
411
|
+
const body = await res.json().catch(() => ({}));
|
|
412
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
413
|
+
return body as { success: boolean; message: string; };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function getSessions() {
|
|
417
|
+
const data = await transport.request<{ sessions: Record<string, unknown>[] }>(authPath + "/sessions", { method: "GET" });
|
|
418
|
+
return data.sessions;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function revokeSession(sessionId: string) {
|
|
422
|
+
return transport.request<{ success: boolean }>(authPath + "/sessions/" + encodeURIComponent(sessionId), {
|
|
423
|
+
method: "DELETE"
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function revokeAllSessions() {
|
|
428
|
+
const result = await transport.request<{ success: boolean }>(authPath + "/sessions", {
|
|
429
|
+
method: "DELETE"
|
|
430
|
+
});
|
|
431
|
+
currentSession = null;
|
|
432
|
+
clearStoredSession();
|
|
433
|
+
if (refreshTimeout) {
|
|
434
|
+
clearTimeout(refreshTimeout);
|
|
435
|
+
refreshTimeout = null;
|
|
436
|
+
}
|
|
437
|
+
transport.setToken(null);
|
|
438
|
+
emit("SIGNED_OUT", null);
|
|
439
|
+
return result;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function getAuthConfig() {
|
|
443
|
+
const fetchFn = getFetch();
|
|
444
|
+
const res = await fetchFn(authUrl("/config"), {
|
|
445
|
+
method: "GET",
|
|
446
|
+
headers: { "Content-Type": "application/json" }
|
|
447
|
+
});
|
|
448
|
+
const body = await res.json().catch(() => ({}));
|
|
449
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
450
|
+
return body as AuthConfig;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getSession() {
|
|
454
|
+
return currentSession;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function onAuthStateChange(callback: (event: AuthChangeEvent, session: RebaseSession | null) => void) {
|
|
458
|
+
listeners.add(callback);
|
|
459
|
+
return () => listeners.delete(callback);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (persistSession) {
|
|
463
|
+
const stored = loadStoredSession();
|
|
464
|
+
if (stored && stored.accessToken && stored.refreshToken) {
|
|
465
|
+
if (stored.expiresAt > Date.now()) {
|
|
466
|
+
currentSession = stored;
|
|
467
|
+
transport.setToken(stored.accessToken);
|
|
468
|
+
scheduleRefresh(stored.expiresAt);
|
|
469
|
+
} else if (stored.refreshToken) {
|
|
470
|
+
currentSession = stored;
|
|
471
|
+
refreshSession().catch(() => {
|
|
472
|
+
currentSession = null;
|
|
473
|
+
clearStoredSession();
|
|
474
|
+
transport.setToken(null);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
signInWithEmail,
|
|
482
|
+
signUp,
|
|
483
|
+
signInWithGoogle,
|
|
484
|
+
signInWithLinkedin,
|
|
485
|
+
signInWithOAuth,
|
|
486
|
+
signInWithGitHub,
|
|
487
|
+
signInWithMicrosoft,
|
|
488
|
+
signInWithApple,
|
|
489
|
+
signInWithFacebook,
|
|
490
|
+
signInWithTwitter,
|
|
491
|
+
signInWithDiscord,
|
|
492
|
+
signInWithGitLab,
|
|
493
|
+
signInWithBitbucket,
|
|
494
|
+
signInWithSlack,
|
|
495
|
+
signInWithSpotify,
|
|
496
|
+
signOut,
|
|
497
|
+
refreshSession,
|
|
498
|
+
getUser,
|
|
499
|
+
updateUser,
|
|
500
|
+
resetPasswordForEmail,
|
|
501
|
+
resetPassword,
|
|
502
|
+
changePassword,
|
|
503
|
+
sendVerificationEmail,
|
|
504
|
+
verifyEmail,
|
|
505
|
+
getSessions,
|
|
506
|
+
revokeSession,
|
|
507
|
+
revokeAllSessions,
|
|
508
|
+
getAuthConfig,
|
|
509
|
+
getSession,
|
|
510
|
+
onAuthStateChange
|
|
511
|
+
};
|
|
512
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import { createCollectionClient } from "./collection";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
|
|
5
|
+
function createMockTransport(): Transport {
|
|
6
|
+
return {
|
|
7
|
+
request: jest.fn<any>().mockResolvedValue({}),
|
|
8
|
+
setToken: jest.fn(),
|
|
9
|
+
setAuthTokenGetter: jest.fn(),
|
|
10
|
+
setOnUnauthorized: jest.fn(),
|
|
11
|
+
baseUrl: "http://localhost:3000",
|
|
12
|
+
apiPath: "/api",
|
|
13
|
+
fetchFn: globalThis.fetch,
|
|
14
|
+
getHeaders: () => ({}),
|
|
15
|
+
resolveToken: jest.fn<any>().mockResolvedValue(null),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("createCollectionClient", () => {
|
|
20
|
+
let transport: Transport;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
transport = createMockTransport();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("count()", () => {
|
|
27
|
+
it("should call the /count endpoint and return the count", async () => {
|
|
28
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 42 });
|
|
29
|
+
|
|
30
|
+
const client = createCollectionClient(transport, "products");
|
|
31
|
+
const result = await client.count();
|
|
32
|
+
|
|
33
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
34
|
+
"/data/products/count",
|
|
35
|
+
{ method: "GET" }
|
|
36
|
+
);
|
|
37
|
+
expect(result).toBe(42);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should forward where filters to the count endpoint", async () => {
|
|
41
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 7 });
|
|
42
|
+
|
|
43
|
+
const client = createCollectionClient(transport, "products");
|
|
44
|
+
const result = await client.count({
|
|
45
|
+
where: { status: "eq.published" }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
49
|
+
expect.stringContaining("/data/products/count?"),
|
|
50
|
+
{ method: "GET" }
|
|
51
|
+
);
|
|
52
|
+
// Verify the query string includes the filter
|
|
53
|
+
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
54
|
+
expect(calledUrl).toContain("status=");
|
|
55
|
+
expect(result).toBe(7);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should forward orderBy to the count endpoint", async () => {
|
|
59
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 15 });
|
|
60
|
+
|
|
61
|
+
const client = createCollectionClient(transport, "items");
|
|
62
|
+
const result = await client.count({
|
|
63
|
+
orderBy: "created_at:desc"
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
67
|
+
expect(calledUrl).toContain("/data/items/count?");
|
|
68
|
+
expect(calledUrl).toContain("orderBy=");
|
|
69
|
+
expect(result).toBe(15);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should NOT include limit or offset in the count request", async () => {
|
|
73
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 100 });
|
|
74
|
+
|
|
75
|
+
const client = createCollectionClient(transport, "users");
|
|
76
|
+
// Even if the caller passes limit/offset in params, they should be stripped
|
|
77
|
+
await client.count({ limit: 50, offset: 10 });
|
|
78
|
+
|
|
79
|
+
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
80
|
+
expect(calledUrl).not.toContain("limit=");
|
|
81
|
+
expect(calledUrl).not.toContain("offset=");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should return 0 when meta.count is missing", async () => {
|
|
85
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({});
|
|
86
|
+
|
|
87
|
+
const client = createCollectionClient(transport, "empty");
|
|
88
|
+
const result = await client.count();
|
|
89
|
+
|
|
90
|
+
expect(result).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should forward searchString to the count endpoint", async () => {
|
|
94
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 3 });
|
|
95
|
+
|
|
96
|
+
const client = createCollectionClient(transport, "articles");
|
|
97
|
+
const result = await client.count({
|
|
98
|
+
searchString: "hello world"
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
102
|
+
expect(calledUrl).toContain("searchString=");
|
|
103
|
+
expect(calledUrl).toContain("hello");
|
|
104
|
+
expect(result).toBe(3);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should forward multiple where filters correctly", async () => {
|
|
108
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({ count: 5 });
|
|
109
|
+
|
|
110
|
+
const client = createCollectionClient(transport, "orders");
|
|
111
|
+
await client.count({
|
|
112
|
+
where: {
|
|
113
|
+
status: "eq.active",
|
|
114
|
+
total: [">=", 100],
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const calledUrl = (transport.request as ReturnType<typeof jest.fn>).mock.calls[0][0] as string;
|
|
119
|
+
expect(calledUrl).toContain("status=");
|
|
120
|
+
expect(calledUrl).toContain("total=");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("find()", () => {
|
|
125
|
+
it("should call the list endpoint and return entities", async () => {
|
|
126
|
+
(transport.request as ReturnType<typeof jest.fn>).mockResolvedValue({
|
|
127
|
+
data: [{ id: "1", name: "Product A" }],
|
|
128
|
+
meta: { total: 1, limit: 20, offset: 0, hasMore: false }
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const client = createCollectionClient(transport, "products");
|
|
132
|
+
const result = await client.find();
|
|
133
|
+
|
|
134
|
+
expect(transport.request).toHaveBeenCalledWith(
|
|
135
|
+
"/data/products",
|
|
136
|
+
{ method: "GET" }
|
|
137
|
+
);
|
|
138
|
+
expect(result.data).toHaveLength(1);
|
|
139
|
+
expect(result.data[0].id).toBe("1");
|
|
140
|
+
expect(result.data[0].path).toBe("products");
|
|
141
|
+
expect(result.meta.total).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("count() is defined", () => {
|
|
146
|
+
it("should have count as a defined function on the accessor", () => {
|
|
147
|
+
const client = createCollectionClient(transport, "products");
|
|
148
|
+
expect(typeof client.count).toBe("function");
|
|
149
|
+
expect(client.count).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should pass the accessor.count truthiness check (used by EntitiesCount component)", () => {
|
|
153
|
+
const client = createCollectionClient(transport, "products");
|
|
154
|
+
// The EntitiesCount component does `if (accessor.count) { ... }`
|
|
155
|
+
// This verifies that check would pass
|
|
156
|
+
expect(!!client.count).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|