@rxferrix/client 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/api.d.ts +60 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/api.js +22 -0
  4. package/dist/api.js.map +1 -0
  5. package/dist/auth.d.ts +251 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +531 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/bytes.d.ts +21 -0
  10. package/dist/bytes.d.ts.map +1 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/bytes.js.map +1 -0
  13. package/dist/canonical.d.ts +13 -0
  14. package/dist/canonical.d.ts.map +1 -0
  15. package/dist/canonical.js +66 -0
  16. package/dist/canonical.js.map +1 -0
  17. package/dist/client.d.ts +170 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +638 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/diff.d.ts +20 -0
  22. package/dist/diff.d.ts.map +1 -0
  23. package/dist/diff.js +100 -0
  24. package/dist/diff.js.map +1 -0
  25. package/dist/errors.d.ts +24 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +34 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/index.d.ts +14 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/local_query_set.d.ts +56 -0
  34. package/dist/local_query_set.d.ts.map +1 -0
  35. package/dist/local_query_set.js +108 -0
  36. package/dist/local_query_set.js.map +1 -0
  37. package/dist/logging.d.ts +15 -0
  38. package/dist/logging.d.ts.map +1 -0
  39. package/dist/logging.js +17 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/optimistic_updates.d.ts +48 -0
  42. package/dist/optimistic_updates.d.ts.map +1 -0
  43. package/dist/optimistic_updates.js +113 -0
  44. package/dist/optimistic_updates.js.map +1 -0
  45. package/dist/paginated_query.d.ts +118 -0
  46. package/dist/paginated_query.d.ts.map +1 -0
  47. package/dist/paginated_query.js +199 -0
  48. package/dist/paginated_query.js.map +1 -0
  49. package/dist/protocol.d.ts +136 -0
  50. package/dist/protocol.d.ts.map +1 -0
  51. package/dist/protocol.js +11 -0
  52. package/dist/protocol.js.map +1 -0
  53. package/dist/query_state.d.ts +16 -0
  54. package/dist/query_state.d.ts.map +1 -0
  55. package/dist/query_state.js +2 -0
  56. package/dist/query_state.js.map +1 -0
  57. package/dist/remote_query_set.d.ts +37 -0
  58. package/dist/remote_query_set.d.ts.map +1 -0
  59. package/dist/remote_query_set.js +87 -0
  60. package/dist/remote_query_set.js.map +1 -0
  61. package/dist/request_manager.d.ts +57 -0
  62. package/dist/request_manager.d.ts.map +1 -0
  63. package/dist/request_manager.js +163 -0
  64. package/dist/request_manager.js.map +1 -0
  65. package/dist/storage.d.ts +45 -0
  66. package/dist/storage.d.ts.map +1 -0
  67. package/dist/storage.js +120 -0
  68. package/dist/storage.js.map +1 -0
  69. package/dist/testing.d.ts +68 -0
  70. package/dist/testing.d.ts.map +1 -0
  71. package/dist/testing.js +97 -0
  72. package/dist/testing.js.map +1 -0
  73. package/dist/web_socket_manager.d.ts +88 -0
  74. package/dist/web_socket_manager.d.ts.map +1 -0
  75. package/dist/web_socket_manager.js +291 -0
  76. package/dist/web_socket_manager.js.map +1 -0
  77. package/package.json +50 -4
  78. package/src/api.ts +86 -0
  79. package/src/auth.ts +636 -0
  80. package/src/bytes.ts +57 -0
  81. package/src/canonical.ts +61 -0
  82. package/src/client.ts +740 -0
  83. package/src/diff.ts +87 -0
  84. package/src/errors.ts +36 -0
  85. package/src/index.ts +60 -0
  86. package/src/local_query_set.ts +130 -0
  87. package/src/logging.ts +26 -0
  88. package/src/optimistic_updates.ts +137 -0
  89. package/src/paginated_query.ts +299 -0
  90. package/src/protocol.ts +152 -0
  91. package/src/query_state.ts +12 -0
  92. package/src/remote_query_set.ts +105 -0
  93. package/src/request_manager.ts +199 -0
  94. package/src/storage.ts +171 -0
  95. package/src/testing.ts +120 -0
  96. package/src/web_socket_manager.ts +322 -0
  97. package/README.md +0 -3
package/src/auth.ts ADDED
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Auth for a {@link RxFerrixClient}: sign-up / sign-in against the server's `/api/auth/*` endpoints, token storage,
3
+ * automatic refresh (wired into `client.setAuth`), OAuth redirects and cross-tab sync.
4
+ *
5
+ * ```ts
6
+ * const client = new RxFerrixClient("http://localhost:8090");
7
+ * const auth = getAuth(client); // restores a stored session, if any
8
+ * await auth.signIn({ email, password });
9
+ * auth.subscribe(() => console.log(auth.state())); // { isLoading, isAuthenticated, userId, isAdmin }
10
+ * ```
11
+ *
12
+ * Tokens are kept in `localStorage` (or the `storage` you pass; an in-memory store when neither exists, e.g. SSR).
13
+ * The access token is short-lived (15 minutes by default) and refreshed with the refresh token shortly before it
14
+ * expires and whenever the server rejects it. Refresh tokens rotate on every use; refreshes are serialized across
15
+ * tabs with the Web Locks API when available, and tabs pick up each other's tokens through `storage` events.
16
+ */
17
+ import { makeFunctionReference, type Id } from "./api.js";
18
+ import type { RxFerrixClient } from "./client.js";
19
+ import { RxFerrixError } from "./errors.js";
20
+ import type { ErrorData } from "./protocol.js";
21
+
22
+ /** Where tokens are persisted. `localStorage` satisfies it; async implementations (e.g. secure storage) work too. */
23
+ export interface AuthStorage {
24
+ getItem(key: string): string | null | Promise<string | null>;
25
+ setItem(key: string, value: string): void | Promise<void>;
26
+ removeItem(key: string): void | Promise<void>;
27
+ }
28
+
29
+ /** Keeps tokens in memory only (lost on reload). */
30
+ export class MemoryAuthStorage implements AuthStorage {
31
+ private readonly items = new Map<string, string>();
32
+ getItem(key: string): string | null {
33
+ return this.items.get(key) ?? null;
34
+ }
35
+ setItem(key: string, value: string): void {
36
+ this.items.set(key, value);
37
+ }
38
+ removeItem(key: string): void {
39
+ this.items.delete(key);
40
+ }
41
+ }
42
+
43
+ export interface RxFerrixAuthOptions {
44
+ /** Token storage. Defaults to `localStorage` when available, else memory. */
45
+ storage?: AuthStorage;
46
+ /** Storage key. Defaults to `"rxferrix.auth:" + client.url`. */
47
+ storageKey?: string;
48
+ /** `fetch` implementation (defaults to the global one). */
49
+ fetch?: typeof fetch;
50
+ /** Follow sign-in / sign-out / refreshes made in other tabs (via `storage` events). Default true. */
51
+ syncTabs?: boolean;
52
+ }
53
+
54
+ /** The auth state, as shown by `useRxFerrixAuth()` / `useAuth()`. */
55
+ export interface AuthState {
56
+ /** True until the stored session has been checked, and after a sign-in until the server confirmed the token. */
57
+ readonly isLoading: boolean;
58
+ /** The server has accepted the current access token. */
59
+ readonly isAuthenticated: boolean;
60
+ /** The signed-in user's id (also set while `isLoading` right after a sign-in). */
61
+ readonly userId: Id<"_users"> | null;
62
+ /** True for admin sessions (`signInAsAdmin`). */
63
+ readonly isAdmin: boolean;
64
+ }
65
+
66
+ /** The tokens the server returns on sign-in and refresh. */
67
+ export interface AuthTokens {
68
+ accessToken: string;
69
+ refreshToken: string;
70
+ /** Seconds until the access token expires. */
71
+ expiresIn: number;
72
+ userId?: string;
73
+ adminId?: string;
74
+ }
75
+
76
+ /** Proof of email ownership: the token from an email link, or the email address plus the code from the email. */
77
+ export type CodeProof = { token: string } | { email: string; code: string };
78
+
79
+ /** What {@link RxFerrixAuth.handleRedirectCallback} found in the URL. */
80
+ export type RedirectResult =
81
+ | { type: "signedIn" }
82
+ | { type: "emailVerified" }
83
+ /** A password reset link: ask for a new password, then call `resetPassword({ token }, password)`. */
84
+ | { type: "passwordReset"; token: string }
85
+ | { type: "error"; error: RxFerrixError };
86
+
87
+ /** Sign-in methods enabled on the server (`GET /api/auth/providers`). */
88
+ export interface AuthProviders {
89
+ password: boolean;
90
+ magicLink: boolean;
91
+ signups: boolean;
92
+ /** OAuth provider ids, e.g. `["github", "google"]`. */
93
+ oauth: string[];
94
+ }
95
+
96
+ /** A user document (`_users`). Contains no secrets. */
97
+ export interface User {
98
+ _id: Id<"_users">;
99
+ _creationTime: number;
100
+ email: string | null;
101
+ emailVerified: boolean;
102
+ name: string | null;
103
+ image: string | null;
104
+ }
105
+
106
+ /**
107
+ * Hand-written references to rxferrix's built-in functions (codegen leaves system functions out of `api`).
108
+ *
109
+ * - `systemApi.auth.me`: the signed-in user (reactive), or `null`.
110
+ * - `systemApi.auth.updateProfile`: set `name` / `image`; omitted fields are unchanged, `""` clears.
111
+ */
112
+ export const systemApi = {
113
+ auth: {
114
+ me: makeFunctionReference<"query", Record<string, never>, User | null>("query", "_system/auth:me"),
115
+ updateProfile: makeFunctionReference<"mutation", { name?: string | null; image?: string | null }, null>(
116
+ "mutation",
117
+ "_system/auth:update_profile",
118
+ ),
119
+ },
120
+ } as const;
121
+
122
+ interface StoredTokens {
123
+ accessToken: string;
124
+ refreshToken: string;
125
+ /** ms since the epoch. */
126
+ expiresAt: number;
127
+ userId: string | null;
128
+ adminId: string | null;
129
+ }
130
+
131
+ /** Refresh access tokens that expire within this window instead of using them. */
132
+ const EXPIRY_LEEWAY_MS = 10_000;
133
+
134
+ function defaultStorage(): AuthStorage {
135
+ try {
136
+ const ls = (globalThis as { localStorage?: AuthStorage }).localStorage;
137
+ if (ls) {
138
+ // Accessing it can throw (disabled storage, sandboxed iframes).
139
+ ls.getItem("rxferrix.auth:probe");
140
+ return ls;
141
+ }
142
+ } catch {
143
+ // fall through
144
+ }
145
+ return new MemoryAuthStorage();
146
+ }
147
+
148
+ function parseStored(raw: string | null): StoredTokens | null {
149
+ if (!raw) return null;
150
+ try {
151
+ const v = JSON.parse(raw) as Partial<StoredTokens>;
152
+ if (typeof v.accessToken !== "string" || typeof v.refreshToken !== "string" || typeof v.expiresAt !== "number") {
153
+ return null;
154
+ }
155
+ return {
156
+ accessToken: v.accessToken,
157
+ refreshToken: v.refreshToken,
158
+ expiresAt: v.expiresAt,
159
+ userId: typeof v.userId === "string" ? v.userId : null,
160
+ adminId: typeof v.adminId === "string" ? v.adminId : null,
161
+ };
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ function toStored(t: AuthTokens): StoredTokens {
168
+ return {
169
+ accessToken: t.accessToken,
170
+ refreshToken: t.refreshToken,
171
+ expiresAt: Date.now() + t.expiresIn * 1000,
172
+ userId: t.userId ?? null,
173
+ adminId: t.adminId ?? null,
174
+ };
175
+ }
176
+
177
+ const LOADING_STATE: AuthState = Object.freeze({ isLoading: true, isAuthenticated: false, userId: null, isAdmin: false });
178
+ const SIGNED_OUT_STATE: AuthState = Object.freeze({
179
+ isLoading: false,
180
+ isAuthenticated: false,
181
+ userId: null,
182
+ isAdmin: false,
183
+ });
184
+
185
+ /** An error from an auth endpoint (`RxFerrixError` with the server's `code`, e.g. `"invalid_credentials"`). */
186
+ function errorFrom(status: number, body: unknown): RxFerrixError {
187
+ const b = (body ?? {}) as { errorMessage?: string; errorData?: ErrorData | null };
188
+ if (typeof b.errorMessage === "string") return RxFerrixError.fromServer(b.errorMessage, b.errorData ?? null);
189
+ return new RxFerrixError(`Auth request failed with HTTP ${status}`, { code: "http_error", data: { status } });
190
+ }
191
+
192
+ /** Auth bound to one {@link RxFerrixClient}. Create it with {@link getAuth}. */
193
+ export class RxFerrixAuth {
194
+ readonly client: RxFerrixClient;
195
+ private readonly storage: AuthStorage;
196
+ private readonly storageKey: string;
197
+ private readonly fetchImpl: typeof fetch;
198
+ private readonly baseUrl: string;
199
+ private tokens: StoredTokens | null = null;
200
+ private current: AuthState = LOADING_STATE;
201
+ private readonly listeners = new Set<() => void>();
202
+ private readonly loaded: Promise<void>;
203
+ private refreshing: Promise<string | null> | null = null;
204
+ private removeStorageListener: () => void = () => {};
205
+
206
+ constructor(client: RxFerrixClient, options: RxFerrixAuthOptions = {}) {
207
+ this.client = client;
208
+ this.storage = options.storage ?? defaultStorage();
209
+ this.storageKey = options.storageKey ?? `rxferrix.auth:${client.url}`;
210
+ const f = options.fetch ?? (globalThis as { fetch?: typeof fetch }).fetch;
211
+ if (!f) throw new Error("RxFerrixAuth: no global fetch available; pass the `fetch` option");
212
+ // Called as a plain function: browsers throw "Illegal invocation" when `window.fetch` is called as a method of
213
+ // another object (e.g. `this.fetchImpl(...)`).
214
+ this.fetchImpl = (input, init) => f(input, init);
215
+ this.baseUrl = client.url.replace(/\/+$/, "");
216
+ this.loaded = this.load();
217
+ // Holds the socket's subscriptions back until the stored session (if any) has been sent with Authenticate.
218
+ this.client.setAuth(this.fetchToken, this.onServerAuthChange);
219
+ if (options.syncTabs !== false) this.listenToOtherTabs();
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------------------------------------------
223
+ // State
224
+
225
+ /** The current state; referentially stable until it changes. */
226
+ state(): AuthState {
227
+ return this.current;
228
+ }
229
+
230
+ /** Calls `listener` whenever {@link state} changes. Returns an unsubscribe function. */
231
+ subscribe(listener: () => void): () => void {
232
+ this.listeners.add(listener);
233
+ return () => {
234
+ this.listeners.delete(listener);
235
+ };
236
+ }
237
+
238
+ private setState(next: AuthState): void {
239
+ const prev = this.current;
240
+ if (
241
+ prev.isLoading === next.isLoading &&
242
+ prev.isAuthenticated === next.isAuthenticated &&
243
+ prev.userId === next.userId &&
244
+ prev.isAdmin === next.isAdmin
245
+ ) {
246
+ return;
247
+ }
248
+ this.current = Object.freeze({ ...next });
249
+ for (const l of [...this.listeners]) {
250
+ try {
251
+ l();
252
+ } catch (e) {
253
+ console.error("[rxferrix] auth listener threw", e);
254
+ }
255
+ }
256
+ }
257
+
258
+ private pendingState(t: StoredTokens): AuthState {
259
+ return { isLoading: true, isAuthenticated: false, userId: t.userId as Id<"_users"> | null, isAdmin: t.adminId !== null };
260
+ }
261
+
262
+ /** Called by the client once the server accepted (true) or rejected / dropped (false) the token. */
263
+ private onServerAuthChange = (isAuthenticated: boolean): void => {
264
+ const t = this.tokens;
265
+ if (isAuthenticated && t) {
266
+ this.setState({ isLoading: false, isAuthenticated: true, userId: t.userId as Id<"_users"> | null, isAdmin: t.adminId !== null });
267
+ } else if (!isAuthenticated) {
268
+ // No token, or the server rejected it (and the forced refresh did not help), or the refresh failed.
269
+ this.setState(SIGNED_OUT_STATE);
270
+ }
271
+ };
272
+
273
+ // ---------------------------------------------------------------------------------------------------------------
274
+ // Tokens
275
+
276
+ private async load(): Promise<void> {
277
+ try {
278
+ this.tokens = parseStored(await this.storage.getItem(this.storageKey));
279
+ } catch {
280
+ this.tokens = null;
281
+ }
282
+ this.setState(this.tokens ? this.pendingState(this.tokens) : SIGNED_OUT_STATE);
283
+ }
284
+
285
+ private async readStorage(): Promise<StoredTokens | null> {
286
+ try {
287
+ return parseStored(await this.storage.getItem(this.storageKey));
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ private async saveTokens(t: StoredTokens | null): Promise<void> {
294
+ this.tokens = t;
295
+ try {
296
+ if (t) await this.storage.setItem(this.storageKey, JSON.stringify(t));
297
+ else await this.storage.removeItem(this.storageKey);
298
+ } catch (e) {
299
+ console.warn("[rxferrix] could not persist auth tokens", e);
300
+ }
301
+ }
302
+
303
+ /** Store fresh tokens and authenticate the socket with them. */
304
+ private async signedIn(tokens: AuthTokens): Promise<void> {
305
+ await this.loaded;
306
+ const t = toStored(tokens);
307
+ await this.saveTokens(t);
308
+ this.setState(this.pendingState(t));
309
+ this.client.setAuth(this.fetchToken, this.onServerAuthChange);
310
+ }
311
+
312
+ /** The `fetchToken` given to `client.setAuth`. */
313
+ private fetchToken = async ({ forceRefresh }: { forceRefresh: boolean }): Promise<string | null> => {
314
+ await this.loaded;
315
+ const t = this.tokens;
316
+ if (!t) return null;
317
+ if (!forceRefresh && t.expiresAt - Date.now() > EXPIRY_LEEWAY_MS) return t.accessToken;
318
+ return this.refresh();
319
+ };
320
+
321
+ /**
322
+ * A valid access token (refreshed when it is about to expire), or null when signed out. Useful for calling your
323
+ * own HTTP endpoints or the HTTP function API with `Authorization: Bearer`.
324
+ */
325
+ async getAccessToken(options: { forceRefresh?: boolean } = {}): Promise<string | null> {
326
+ return this.fetchToken({ forceRefresh: options.forceRefresh ?? false });
327
+ }
328
+
329
+ /** One refresh at a time per tab, and (with Web Locks) per origin. */
330
+ private refresh(): Promise<string | null> {
331
+ if (!this.refreshing) {
332
+ this.refreshing = this.withLock(() => this.doRefresh()).finally(() => {
333
+ this.refreshing = null;
334
+ });
335
+ }
336
+ return this.refreshing;
337
+ }
338
+
339
+ private async withLock<T>(fn: () => Promise<T>): Promise<T> {
340
+ const locks = (globalThis as { navigator?: { locks?: { request(name: string, cb: () => Promise<T>): Promise<T> } } })
341
+ .navigator?.locks;
342
+ if (locks && typeof locks.request === "function") return locks.request(`rxferrix-auth-refresh:${this.storageKey}`, fn);
343
+ return fn();
344
+ }
345
+
346
+ /** Use tokens another tab stored, if they are newer than ours and still fresh. */
347
+ private async adoptNewerStoredTokens(used: StoredTokens): Promise<string | null> {
348
+ const stored = await this.readStorage();
349
+ if (stored && stored.refreshToken !== used.refreshToken) {
350
+ this.tokens = stored;
351
+ if (stored.expiresAt - Date.now() > EXPIRY_LEEWAY_MS) return stored.accessToken;
352
+ }
353
+ return null;
354
+ }
355
+
356
+ private async doRefresh(): Promise<string | null> {
357
+ const before = this.tokens;
358
+ if (!before) return null;
359
+ const adopted = await this.adoptNewerStoredTokens(before);
360
+ if (adopted) return adopted;
361
+ const t = this.tokens;
362
+ if (!t) return null;
363
+ let res: Response;
364
+ try {
365
+ res = await this.fetchImpl(`${this.baseUrl}/api/auth/refresh`, {
366
+ method: "POST",
367
+ headers: { "Content-Type": "application/json" },
368
+ body: JSON.stringify({ refreshToken: t.refreshToken }),
369
+ });
370
+ } catch (e) {
371
+ // Offline: keep the session; the next reconnect or AuthError tries again.
372
+ throw new RxFerrixError("Could not reach the server to refresh the session", { code: "network_error", cause: e });
373
+ }
374
+ const body = (await res.json().catch(() => null)) as unknown;
375
+ if (res.ok) {
376
+ const next = toStored(body as AuthTokens);
377
+ await this.saveTokens(next);
378
+ return next.accessToken;
379
+ }
380
+ if (res.status === 401) {
381
+ // Lost a race with another tab that rotated the token a moment ago: use its tokens.
382
+ const raced = await this.adoptNewerStoredTokens(t);
383
+ if (raced) return raced;
384
+ await this.saveTokens(null);
385
+ this.setState(SIGNED_OUT_STATE);
386
+ return null;
387
+ }
388
+ throw errorFrom(res.status, body);
389
+ }
390
+
391
+ private listenToOtherTabs(): void {
392
+ const g = globalThis as {
393
+ addEventListener?: (type: string, l: (e: { key: string | null; newValue: string | null }) => void) => void;
394
+ removeEventListener?: (type: string, l: (e: { key: string | null; newValue: string | null }) => void) => void;
395
+ };
396
+ if (typeof g.addEventListener !== "function") return;
397
+ const handler = (e: { key: string | null; newValue: string | null }) => {
398
+ if (e.key !== this.storageKey && e.key !== null) return;
399
+ void this.loaded.then(() => this.onOtherTabChange(parseStored(e.key === null ? null : e.newValue)));
400
+ };
401
+ g.addEventListener("storage", handler);
402
+ this.removeStorageListener = () => g.removeEventListener?.("storage", handler);
403
+ }
404
+
405
+ private onOtherTabChange(next: StoredTokens | null): void {
406
+ const had = this.tokens;
407
+ this.tokens = next;
408
+ if (!next) {
409
+ if (had) {
410
+ this.client.clearAuth();
411
+ this.setState(SIGNED_OUT_STATE);
412
+ }
413
+ return;
414
+ }
415
+ const sameSession = had !== null && had.userId === next.userId && had.adminId === next.adminId;
416
+ if (!sameSession) {
417
+ // Signed in (or as someone else) in another tab.
418
+ this.setState(this.pendingState(next));
419
+ this.client.setAuth(this.fetchToken, this.onServerAuthChange);
420
+ }
421
+ // Same session, rotated tokens: the next refresh picks them up.
422
+ }
423
+
424
+ /** Stop listening to other tabs. Does not sign out. */
425
+ dispose(): void {
426
+ this.removeStorageListener();
427
+ this.listeners.clear();
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------------------------------------------
431
+ // Endpoints
432
+
433
+ private async post<T>(path: string, body: unknown, bearer?: string): Promise<T> {
434
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
435
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
436
+ let res: Response;
437
+ try {
438
+ res = await this.fetchImpl(`${this.baseUrl}/api/auth/${path}`, { method: "POST", headers, body: JSON.stringify(body) });
439
+ } catch (e) {
440
+ throw new RxFerrixError("Could not reach the server", { code: "network_error", cause: e });
441
+ }
442
+ const json = (await res.json().catch(() => null)) as unknown;
443
+ if (!res.ok) throw errorFrom(res.status, json);
444
+ return json as T;
445
+ }
446
+
447
+ /** The sign-in methods enabled on the server. */
448
+ async providers(): Promise<AuthProviders> {
449
+ const res = await this.fetchImpl(`${this.baseUrl}/api/auth/providers`);
450
+ const json = (await res.json().catch(() => null)) as unknown;
451
+ if (!res.ok) throw errorFrom(res.status, json);
452
+ return json as AuthProviders;
453
+ }
454
+
455
+ /**
456
+ * Create an account with email and password and sign in. Resolves `{ verificationRequired: true }` (without
457
+ * signing in) when the server requires a verified email first.
458
+ */
459
+ async signUp(args: { email: string; password: string; name?: string }): Promise<{ verificationRequired: boolean }> {
460
+ const res = await this.post<AuthTokens | { verificationRequired: true }>("signup", args);
461
+ if ("accessToken" in res) {
462
+ await this.signedIn(res);
463
+ return { verificationRequired: false };
464
+ }
465
+ return { verificationRequired: true };
466
+ }
467
+
468
+ /** Sign in with email and password. Rejects with code `"invalid_credentials"` on a wrong email or password. */
469
+ async signIn(args: { email: string; password: string }): Promise<void> {
470
+ await this.signedIn(await this.post<AuthTokens>("signin", args));
471
+ }
472
+
473
+ /** Sign in to an admin account (dashboard). Functions then see `ctx.auth.is_admin()`. */
474
+ async signInAsAdmin(args: { email: string; password: string }): Promise<void> {
475
+ await this.signedIn(await this.post<AuthTokens>("admin/signin", args));
476
+ }
477
+
478
+ /** Whether the server has no administrator yet (`GET /api/auth/admin/setup`). */
479
+ async needsAdminSetup(): Promise<boolean> {
480
+ const res = await this.fetchImpl(`${this.baseUrl}/api/auth/admin/setup`);
481
+ const json = (await res.json().catch(() => null)) as { needsSetup?: boolean } | null;
482
+ if (!res.ok) throw errorFrom(res.status, json);
483
+ return json?.needsSetup === true;
484
+ }
485
+
486
+ /**
487
+ * Create the first administrator with the one-time setup token that `serve` prints as `/_/setup#token=…`, and
488
+ * sign in as them. Rejects with code `"invalid_setup_token"` or `"setup_done"`.
489
+ */
490
+ async setupAdmin(args: { token: string; email: string; password: string }): Promise<void> {
491
+ await this.signedIn(await this.post<AuthTokens>("admin/setup", args));
492
+ }
493
+
494
+ /** End the session on the server and forget the tokens (also in other tabs). */
495
+ async signOut(): Promise<void> {
496
+ await this.loaded;
497
+ const t = this.tokens;
498
+ await this.saveTokens(null);
499
+ this.client.clearAuth();
500
+ this.setState(SIGNED_OUT_STATE);
501
+ if (t) {
502
+ try {
503
+ await this.post("signout", { refreshToken: t.refreshToken });
504
+ } catch {
505
+ // The local sign-out already happened; the session expires on its own.
506
+ }
507
+ }
508
+ }
509
+
510
+ /** Email a verification link and code (no-op for unknown or already verified addresses). */
511
+ async requestEmailVerification(args: { email: string; redirectTo?: string }): Promise<void> {
512
+ await this.post("verify-email/request", args);
513
+ }
514
+
515
+ /** Confirm an email address with the link token or the code. */
516
+ async verifyEmail(proof: CodeProof): Promise<void> {
517
+ await this.post("verify-email/confirm", proof);
518
+ }
519
+
520
+ /** Email a password reset link and code. Succeeds whether or not the account exists. */
521
+ async requestPasswordReset(args: { email: string; redirectTo?: string }): Promise<void> {
522
+ await this.post("reset/request", args);
523
+ }
524
+
525
+ /** Set a new password with the reset link token or code; ends other sessions and signs in. */
526
+ async resetPassword(proof: CodeProof, password: string): Promise<void> {
527
+ await this.signedIn(await this.post<AuthTokens>("reset/confirm", { ...proof, password }));
528
+ }
529
+
530
+ /** Email a magic sign-in link and a one-time code. Succeeds whether or not the account exists. */
531
+ async requestMagicLink(args: { email: string; redirectTo?: string }): Promise<void> {
532
+ await this.post("magic/request", args);
533
+ }
534
+
535
+ /** Sign in with the one-time code from a magic link email. */
536
+ async verifyCode(args: { email: string; code: string }): Promise<void> {
537
+ await this.signedIn(await this.post<AuthTokens>("magic/verify", args));
538
+ }
539
+
540
+ /** Sign in with the token of a magic link (usually done by {@link handleRedirectCallback}). */
541
+ async verifyMagicLink(token: string): Promise<void> {
542
+ await this.signedIn(await this.post<AuthTokens>("magic/verify", { token }));
543
+ }
544
+
545
+ /** The URL that starts an OAuth sign-in with `provider`, returning to `redirectTo` (default: this page). */
546
+ oauthUrl(provider: string, options: { redirectTo?: string } = {}): string {
547
+ const loc = (globalThis as { location?: { href: string } }).location;
548
+ const redirectTo = options.redirectTo ?? (loc ? loc.href.split("#")[0] : undefined);
549
+ const query = redirectTo ? `?redirect_to=${encodeURIComponent(redirectTo)}` : "";
550
+ return `${this.baseUrl}/api/auth/oauth/${encodeURIComponent(provider)}${query}`;
551
+ }
552
+
553
+ /**
554
+ * Start an OAuth sign-in: a full-page redirect to the provider. After approval the server redirects back to
555
+ * `redirectTo` (which must be allowed by the server's `--auth-redirect-url` list) with the tokens in the URL
556
+ * fragment; call {@link handleRedirectCallback} on that page.
557
+ */
558
+ signInWithOAuth(provider: string, options: { redirectTo?: string } = {}): void {
559
+ const loc = (globalThis as { location?: { assign(url: string): void } }).location;
560
+ if (!loc) throw new Error("signInWithOAuth needs a browser (window.location)");
561
+ loc.assign(this.oauthUrl(provider, options));
562
+ }
563
+
564
+ /**
565
+ * Handle the `#rxferrix_…` fragment that OAuth redirects and email links put on the URL: stores tokens (OAuth),
566
+ * signs in (magic link), confirms the email (verification link) or returns the reset token (password reset). The
567
+ * `rxferrix_` parameters are removed from the address bar first, so a reload can't replay them. Returns null when
568
+ * the URL has none.
569
+ */
570
+ async handleRedirectCallback(url?: string): Promise<RedirectResult | null> {
571
+ const g = globalThis as {
572
+ location?: { href: string };
573
+ history?: { state: unknown; replaceState(state: unknown, title: string, url: string): void };
574
+ };
575
+ const href = url ?? g.location?.href;
576
+ if (!href) return null;
577
+ const parsed = new URL(href);
578
+ const params = new URLSearchParams(parsed.hash.replace(/^#/, ""));
579
+ const ours = new Map<string, string>();
580
+ for (const [k, v] of params) if (k.startsWith("rxferrix_")) ours.set(k, v);
581
+ if (ours.size === 0) return null;
582
+ for (const k of ours.keys()) params.delete(k);
583
+ const rest = params.toString();
584
+ parsed.hash = rest ? `#${rest}` : "";
585
+ if (g.history && g.location && (url === undefined || url === g.location.href)) {
586
+ g.history.replaceState(g.history.state, "", parsed.toString());
587
+ }
588
+
589
+ try {
590
+ const error = ours.get("rxferrix_error");
591
+ if (error) {
592
+ const message = ours.get("rxferrix_error_description") ?? "Sign-in failed";
593
+ return { type: "error", error: new RxFerrixError(message, { code: error }) };
594
+ }
595
+ const access = ours.get("rxferrix_access_token");
596
+ const refresh = ours.get("rxferrix_refresh_token");
597
+ if (access && refresh) {
598
+ const expiresIn = Number(ours.get("rxferrix_expires_in") ?? "900");
599
+ const userId = ours.get("rxferrix_user_id");
600
+ await this.signedIn({ accessToken: access, refreshToken: refresh, expiresIn, ...(userId ? { userId } : {}) });
601
+ return { type: "signedIn" };
602
+ }
603
+ const magic = ours.get("rxferrix_magic_token");
604
+ if (magic) {
605
+ await this.verifyMagicLink(magic);
606
+ return { type: "signedIn" };
607
+ }
608
+ const verify = ours.get("rxferrix_verify_token");
609
+ if (verify) {
610
+ await this.verifyEmail({ token: verify });
611
+ return { type: "emailVerified" };
612
+ }
613
+ const reset = ours.get("rxferrix_reset_token");
614
+ if (reset) return { type: "passwordReset", token: reset };
615
+ } catch (e) {
616
+ return { type: "error", error: e instanceof RxFerrixError ? e : new RxFerrixError(String(e), { cause: e }) };
617
+ }
618
+ return null;
619
+ }
620
+ }
621
+
622
+ const instances = new WeakMap<RxFerrixClient, RxFerrixAuth>();
623
+
624
+ /**
625
+ * The {@link RxFerrixAuth} of `client`, created on first use (with `options`). Later calls return the same instance
626
+ * (and ignore `options`), so hooks like `useRxFerrixAuth()` share it. Call it once early (e.g. next to
627
+ * `new RxFerrixClient`) to pass options such as a custom `storage`.
628
+ */
629
+ export function getAuth(client: RxFerrixClient, options?: RxFerrixAuthOptions): RxFerrixAuth {
630
+ let auth = instances.get(client);
631
+ if (!auth) {
632
+ auth = new RxFerrixAuth(client, options);
633
+ instances.set(client, auth);
634
+ }
635
+ return auth;
636
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Binary values: `rxferrix::Bytes` travels (and is stored) as `{ $bytes: "<base64>" }`, which is also its generated
3
+ * TypeScript type. These helpers convert between that form and `Uint8Array`.
4
+ *
5
+ * ```ts
6
+ * await client.mutation(api.files.save_thumbnail, { id, thumb: bytesToJson(new Uint8Array(png)) });
7
+ * const doc = await client.query(api.files.get, { id });
8
+ * const png: Uint8Array = bytesFromJson(doc.thumb);
9
+ * ```
10
+ */
11
+
12
+ /** The JSON form of `rxferrix::Bytes`: standard, padded base64. */
13
+ export type Bytes = { $bytes: string };
14
+
15
+ const CHUNK = 0x8000;
16
+
17
+ /** Wrap binary data as `{ $bytes: "<base64>" }` (for args and documents). */
18
+ export function bytesToJson(data: Uint8Array | ArrayBuffer | ArrayBufferView): Bytes {
19
+ const bytes =
20
+ data instanceof Uint8Array
21
+ ? data
22
+ : ArrayBuffer.isView(data)
23
+ ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
24
+ : new Uint8Array(data);
25
+ let binary = "";
26
+ for (let i = 0; i < bytes.length; i += CHUNK) {
27
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
28
+ }
29
+ return { $bytes: btoa(binary) };
30
+ }
31
+
32
+ /** The bytes of a `{ $bytes: "<base64>" }` value (from a query result or document). Throws a `TypeError` otherwise. */
33
+ export function bytesFromJson(value: Bytes): Uint8Array {
34
+ if (!isBytes(value)) {
35
+ throw new TypeError("expected bytes as { $bytes: string }");
36
+ }
37
+ let binary: string;
38
+ try {
39
+ binary = atob(value.$bytes);
40
+ } catch {
41
+ throw new TypeError("`$bytes` is not valid base64");
42
+ }
43
+ const out = new Uint8Array(binary.length);
44
+ for (let i = 0; i < binary.length; i++) {
45
+ out[i] = binary.charCodeAt(i);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** True for a value in the `{ $bytes: string }` form (an object with exactly that key). */
51
+ export function isBytes(value: unknown): value is Bytes {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
53
+ return false;
54
+ }
55
+ const keys = Object.keys(value);
56
+ return keys.length === 1 && keys[0] === "$bytes" && typeof (value as Bytes).$bytes === "string";
57
+ }