@koolbase/core 10.0.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 (104) hide show
  1. package/README.md +19 -0
  2. package/dist/cjs/analytics.d.ts +24 -0
  3. package/dist/cjs/analytics.js +110 -0
  4. package/dist/cjs/auth-errors.d.ts +117 -0
  5. package/dist/cjs/auth-errors.js +250 -0
  6. package/dist/cjs/auth.d.ts +213 -0
  7. package/dist/cjs/auth.js +813 -0
  8. package/dist/cjs/cache-store.d.ts +50 -0
  9. package/dist/cjs/cache-store.js +194 -0
  10. package/dist/cjs/conflict.d.ts +80 -0
  11. package/dist/cjs/conflict.js +84 -0
  12. package/dist/cjs/database-errors.d.ts +101 -0
  13. package/dist/cjs/database-errors.js +200 -0
  14. package/dist/cjs/database.d.ts +298 -0
  15. package/dist/cjs/database.js +852 -0
  16. package/dist/cjs/device-id.d.ts +1 -0
  17. package/dist/cjs/device-id.js +57 -0
  18. package/dist/cjs/device-metadata.d.ts +36 -0
  19. package/dist/cjs/device-metadata.js +94 -0
  20. package/dist/cjs/errors.d.ts +64 -0
  21. package/dist/cjs/errors.js +85 -0
  22. package/dist/cjs/flags.d.ts +15 -0
  23. package/dist/cjs/flags.js +76 -0
  24. package/dist/cjs/function-errors.d.ts +51 -0
  25. package/dist/cjs/function-errors.js +103 -0
  26. package/dist/cjs/functions.d.ts +15 -0
  27. package/dist/cjs/functions.js +83 -0
  28. package/dist/cjs/index.d.ts +22 -0
  29. package/dist/cjs/index.js +56 -0
  30. package/dist/cjs/messaging.d.ts +13 -0
  31. package/dist/cjs/messaging.js +36 -0
  32. package/dist/cjs/offline-state.d.ts +97 -0
  33. package/dist/cjs/offline-state.js +198 -0
  34. package/dist/cjs/package.json +3 -0
  35. package/dist/cjs/pending-write.d.ts +47 -0
  36. package/dist/cjs/pending-write.js +22 -0
  37. package/dist/cjs/platform.d.ts +51 -0
  38. package/dist/cjs/platform.js +41 -0
  39. package/dist/cjs/realtime.d.ts +44 -0
  40. package/dist/cjs/realtime.js +195 -0
  41. package/dist/cjs/record.d.ts +2 -0
  42. package/dist/cjs/record.js +23 -0
  43. package/dist/cjs/shared.d.ts +9 -0
  44. package/dist/cjs/shared.js +43 -0
  45. package/dist/cjs/storage-errors.d.ts +163 -0
  46. package/dist/cjs/storage-errors.js +253 -0
  47. package/dist/cjs/storage.d.ts +198 -0
  48. package/dist/cjs/storage.js +451 -0
  49. package/dist/cjs/sync-engine.d.ts +30 -0
  50. package/dist/cjs/sync-engine.js +286 -0
  51. package/dist/cjs/types.d.ts +487 -0
  52. package/dist/cjs/types.js +40 -0
  53. package/dist/esm/analytics.d.ts +24 -0
  54. package/dist/esm/analytics.js +106 -0
  55. package/dist/esm/auth-errors.d.ts +117 -0
  56. package/dist/esm/auth-errors.js +222 -0
  57. package/dist/esm/auth.d.ts +213 -0
  58. package/dist/esm/auth.js +809 -0
  59. package/dist/esm/cache-store.d.ts +50 -0
  60. package/dist/esm/cache-store.js +179 -0
  61. package/dist/esm/conflict.d.ts +80 -0
  62. package/dist/esm/conflict.js +80 -0
  63. package/dist/esm/database-errors.d.ts +101 -0
  64. package/dist/esm/database-errors.js +189 -0
  65. package/dist/esm/database.d.ts +298 -0
  66. package/dist/esm/database.js +848 -0
  67. package/dist/esm/device-id.d.ts +1 -0
  68. package/dist/esm/device-id.js +54 -0
  69. package/dist/esm/device-metadata.d.ts +36 -0
  70. package/dist/esm/device-metadata.js +90 -0
  71. package/dist/esm/errors.d.ts +64 -0
  72. package/dist/esm/errors.js +79 -0
  73. package/dist/esm/flags.d.ts +15 -0
  74. package/dist/esm/flags.js +72 -0
  75. package/dist/esm/function-errors.d.ts +51 -0
  76. package/dist/esm/function-errors.js +93 -0
  77. package/dist/esm/functions.d.ts +15 -0
  78. package/dist/esm/functions.js +79 -0
  79. package/dist/esm/index.d.ts +22 -0
  80. package/dist/esm/index.js +25 -0
  81. package/dist/esm/messaging.d.ts +13 -0
  82. package/dist/esm/messaging.js +32 -0
  83. package/dist/esm/offline-state.d.ts +97 -0
  84. package/dist/esm/offline-state.js +189 -0
  85. package/dist/esm/package.json +3 -0
  86. package/dist/esm/pending-write.d.ts +47 -0
  87. package/dist/esm/pending-write.js +19 -0
  88. package/dist/esm/platform.d.ts +51 -0
  89. package/dist/esm/platform.js +36 -0
  90. package/dist/esm/realtime.d.ts +44 -0
  91. package/dist/esm/realtime.js +191 -0
  92. package/dist/esm/record.d.ts +2 -0
  93. package/dist/esm/record.js +20 -0
  94. package/dist/esm/shared.d.ts +9 -0
  95. package/dist/esm/shared.js +39 -0
  96. package/dist/esm/storage-errors.d.ts +163 -0
  97. package/dist/esm/storage-errors.js +239 -0
  98. package/dist/esm/storage.d.ts +198 -0
  99. package/dist/esm/storage.js +447 -0
  100. package/dist/esm/sync-engine.d.ts +30 -0
  101. package/dist/esm/sync-engine.js +282 -0
  102. package/dist/esm/types.d.ts +487 -0
  103. package/dist/esm/types.js +37 -0
  104. package/package.json +45 -0
@@ -0,0 +1,813 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KoolbaseAuth = void 0;
4
+ const types_js_1 = require("./types.js");
5
+ const auth_errors_js_1 = require("./auth-errors.js");
6
+ const platform_js_1 = require("./platform.js");
7
+ const device_metadata_js_1 = require("./device-metadata.js");
8
+ class KoolbaseAuth {
9
+ constructor(config) {
10
+ this.session = null;
11
+ this.ongoingRefresh = null;
12
+ this.listeners = new Set();
13
+ this.config = config;
14
+ this.metadata = new device_metadata_js_1.DeviceMetadata(config.appVersion);
15
+ this.fetchFn = config.fetch ?? ((url, init) => fetch(url, init));
16
+ this.timeoutMs = config.authTimeout ?? 10000;
17
+ if (config.authStorage) {
18
+ this.storage = config.authStorage;
19
+ }
20
+ else if ((0, platform_js_1.getPlatform)().authStorage()) {
21
+ this.storage = (0, platform_js_1.getPlatform)().authStorage();
22
+ }
23
+ else {
24
+ this.storage = null;
25
+ // eslint-disable-next-line no-console
26
+ console.warn(
27
+ // The host names its own remedy: core serves several, and a browser
28
+ // told to install react-native-keychain learns nothing.
29
+ `[Koolbase] No persistent auth storage on this platform ` +
30
+ `(${(0, platform_js_1.getPlatform)().info.os}). Sessions will not survive a restart. ` +
31
+ `Provide KoolbaseConfig.authStorage with your own implementation, ` +
32
+ `or install the platform's optional storage dependency.`);
33
+ }
34
+ }
35
+ // ─── Auth state listener ────────────────────────────────────────────────
36
+ /**
37
+ * Subscribe to authentication state changes. The listener fires:
38
+ * - Immediately on subscribe, with the current user (or null).
39
+ * - On every successful login, register, refresh, session restoration.
40
+ * - On logout / explicit setSession(null).
41
+ * - On linkPhone success (user object updated with phone fields).
42
+ *
43
+ * Returns an unsubscribe function. Call it when the consumer no longer
44
+ * needs updates (e.g. in a React useEffect cleanup).
45
+ *
46
+ * Listener errors are swallowed so a buggy listener can't break auth
47
+ * state propagation to other listeners.
48
+ *
49
+ * @example
50
+ * const unsubscribe = auth.onAuthStateChange((user) => {
51
+ * setCurrentUser(user);
52
+ * });
53
+ * // later:
54
+ * unsubscribe();
55
+ */
56
+ onAuthStateChange(listener) {
57
+ this.listeners.add(listener);
58
+ // Fire immediately with current state — matches RN ecosystem
59
+ // convention (Firebase Auth, Supabase Auth) so consumers don't
60
+ // need to separately read currentUser on mount.
61
+ try {
62
+ listener(this.session?.user ?? null);
63
+ }
64
+ catch {
65
+ // swallow
66
+ }
67
+ return () => {
68
+ this.listeners.delete(listener);
69
+ };
70
+ }
71
+ fireAuthStateChange() {
72
+ const user = this.session?.user ?? null;
73
+ for (const listener of this.listeners) {
74
+ try {
75
+ listener(user);
76
+ }
77
+ catch {
78
+ // swallow — one broken listener doesn't break others
79
+ }
80
+ }
81
+ }
82
+ // ─── Headers ────────────────────────────────────────────────────────────
83
+ /**
84
+ * Compose the full header set for an outbound request: base headers,
85
+ * device metadata, and optionally the Authorization bearer token.
86
+ * Async because device metadata's first build may read from keychain.
87
+ */
88
+ async prepareHeaders(includeAuth) {
89
+ const deviceHeaders = await this.metadata.build();
90
+ return {
91
+ 'Content-Type': 'application/json',
92
+ 'x-api-key': this.config.publicKey,
93
+ ...deviceHeaders,
94
+ ...(includeAuth && this.session
95
+ ? { Authorization: `Bearer ${this.session.accessToken}` }
96
+ : {}),
97
+ };
98
+ }
99
+ // ─── Request plumbing ───────────────────────────────────────────────────
100
+ /**
101
+ * Low-level request helper used by every endpoint. Wires together:
102
+ * - The injected fetch implementation (config.fetch or global fetch)
103
+ * - Device metadata + x-api-key + auth header in one place
104
+ * - AbortController-based timeout (config.authTimeout, default 10s)
105
+ *
106
+ * On timeout, fetch rejects with an AbortError; callers see this as a
107
+ * non-KoolbaseAuthError exception, which restoreSession() treats as
108
+ * Offline (preserving optimistic state).
109
+ */
110
+ async authRequest(path, options = {}) {
111
+ const headers = await this.prepareHeaders(options.includeAuth ?? false);
112
+ const controller = new AbortController();
113
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
114
+ try {
115
+ return await this.fetchFn(`${this.config.baseUrl}${path}`, {
116
+ method: options.method ?? 'GET',
117
+ headers,
118
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
119
+ signal: controller.signal,
120
+ });
121
+ }
122
+ finally {
123
+ clearTimeout(timer);
124
+ }
125
+ }
126
+ /**
127
+ * Authenticated request wrapper. Refreshes the access token if it's
128
+ * stale (within 1-min buffer of expiry) before issuing the call, then
129
+ * delegates to {@link authRequest} with includeAuth=true.
130
+ */
131
+ async authedRequest(path, options = {}) {
132
+ await this._ensureValidToken();
133
+ return this.authRequest(path, { ...options, includeAuth: true });
134
+ }
135
+ // ─── Internal session lifecycle ─────────────────────────────────────────
136
+ async setSessionInternal(session) {
137
+ this.session = session;
138
+ if (this.storage) {
139
+ try {
140
+ await this.storage.saveSession(session);
141
+ }
142
+ catch (err) {
143
+ // eslint-disable-next-line no-console
144
+ console.warn('[Koolbase] Failed to persist session; staying signed in for this ' +
145
+ 'session only:', err);
146
+ }
147
+ }
148
+ this.fireAuthStateChange();
149
+ }
150
+ /**
151
+ * Discards the stored session without contacting the server.
152
+ *
153
+ * For when the session is already known to be unusable — the server rejected
154
+ * the token, or a build was pointed at a different project and the persisted
155
+ * session belongs to the old one. Unlike `logout()` there is no server call:
156
+ * the token has already been refused, and asking for it to be revoked would
157
+ * only add a round trip that cannot succeed.
158
+ *
159
+ * Safe in any state, including with no session at all. The SDK calls this
160
+ * itself when a request is rejected as unauthenticated, so most apps will not
161
+ * need to.
162
+ */
163
+ async clearStoredSession() {
164
+ await this.clearSessionInternal();
165
+ }
166
+ async clearSessionInternal() {
167
+ this.session = null;
168
+ if (this.storage) {
169
+ try {
170
+ await this.storage.clear();
171
+ }
172
+ catch {
173
+ // best effort
174
+ }
175
+ }
176
+ this.fireAuthStateChange();
177
+ }
178
+ // ─── Session restoration ────────────────────────────────────────────────
179
+ async restoreSession() {
180
+ if (!this.storage)
181
+ return types_js_1.RestoreResult.NoSession;
182
+ const persisted = await this.storage.readSession();
183
+ if (!persisted)
184
+ return types_js_1.RestoreResult.NoSession;
185
+ // Optimistic restore — populate state and fire listener before any
186
+ // network call. App can render authenticated UI immediately.
187
+ this.session = persisted;
188
+ this.fireAuthStateChange();
189
+ const expiresAt = persisted.expiresAt
190
+ ? new Date(persisted.expiresAt).getTime()
191
+ : 0;
192
+ const oneMinuteMs = 60 * 1000;
193
+ if (expiresAt > Date.now() + oneMinuteMs) {
194
+ return types_js_1.RestoreResult.Restored;
195
+ }
196
+ try {
197
+ await this.refresh(persisted.refreshToken);
198
+ return types_js_1.RestoreResult.Restored;
199
+ }
200
+ catch (e) {
201
+ if (e instanceof auth_errors_js_1.SessionExpiredError ||
202
+ e instanceof auth_errors_js_1.TokenRevokedError ||
203
+ e instanceof auth_errors_js_1.InvalidCredentialsError) {
204
+ await this.clearSessionInternal();
205
+ return types_js_1.RestoreResult.Expired;
206
+ }
207
+ return types_js_1.RestoreResult.Offline;
208
+ }
209
+ }
210
+ // ─── Public auth API ────────────────────────────────────────────────────
211
+ async register(params) {
212
+ if (params.password.length < 8)
213
+ throw new auth_errors_js_1.WeakPasswordError();
214
+ const res = await this.authRequest('/v1/sdk/auth/register', {
215
+ method: 'POST',
216
+ body: params,
217
+ });
218
+ const session = await this.parseSessionResponse(res, false);
219
+ await this.setSessionInternal(session);
220
+ return session.user;
221
+ }
222
+ async login(params) {
223
+ const res = await this.authRequest('/v1/sdk/auth/login', {
224
+ method: 'POST',
225
+ body: params,
226
+ });
227
+ const session = await this.parseSessionResponse(res, false);
228
+ await this.setSessionInternal(session);
229
+ return session;
230
+ }
231
+ /**
232
+ * Sign in with Apple using a credential obtained from a native Apple
233
+ * Sign-In SDK.
234
+ *
235
+ * The SDK is library-agnostic — use any native Apple Sign-In package
236
+ * (`@invertase/react-native-apple-authentication`, etc.) and pass the
237
+ * resulting `identityToken`, optional `nonce`, and optional `fullName`.
238
+ *
239
+ * `fullName` is meaningful only on first sign-in — Apple omits name
240
+ * data on subsequent sign-ins. The server persists at link time and
241
+ * ignores on subsequent sign-ins.
242
+ *
243
+ * On success the session is persisted via the configured storage and
244
+ * `onAuthStateChange` fires with the resolved user.
245
+ *
246
+ * @throws AppleSignInNotConfiguredError when Apple is not enabled in
247
+ * the dashboard OAuth config for this environment (400).
248
+ * @throws InvalidAppleTokenError when the token signature, audience,
249
+ * expiry, replay, or nonce check failed server-side (401).
250
+ * @throws UserDisabledError when the account flag is set to disabled (403).
251
+ * @throws AppleEmailRequiredError when Apple did not return email for
252
+ * a new-account sign-in (400).
253
+ * @throws OAuthEmailConflictError when email matches existing user
254
+ * but auto-link rule blocked (409).
255
+ */
256
+ async signInWithApple(params) {
257
+ const body = {
258
+ identity_token: params.identityToken,
259
+ };
260
+ if (params.nonce && params.nonce.length > 0) {
261
+ body.nonce = params.nonce;
262
+ }
263
+ if (params.fullName) {
264
+ const nameJson = {};
265
+ if (params.fullName.givenName)
266
+ nameJson.given_name = params.fullName.givenName;
267
+ if (params.fullName.familyName)
268
+ nameJson.family_name = params.fullName.familyName;
269
+ if (Object.keys(nameJson).length > 0) {
270
+ body.full_name = nameJson;
271
+ }
272
+ }
273
+ const res = await this.authRequest('/v1/sdk/auth/oauth/apple', {
274
+ method: 'POST',
275
+ body,
276
+ });
277
+ const session = await this.parseAppleSessionResponse(res);
278
+ await this.setSessionInternal(session);
279
+ return session;
280
+ }
281
+ /**
282
+ * Sign in with Google using an idToken from a native Google Sign-In SDK.
283
+ *
284
+ * The SDK is library-agnostic — use any native Google Sign-In package
285
+ * (`@react-native-google-signin/google-signin`, etc.) and pass the
286
+ * resulting `idToken`. Google embeds the user's name and email in the
287
+ * idToken itself, so this method does not take a `fullName` parameter
288
+ * (unlike `signInWithApple`).
289
+ *
290
+ * On success the session is persisted via the configured storage and
291
+ * `onAuthStateChange` fires with the resolved user.
292
+ *
293
+ * @throws GoogleSignInNotConfiguredError when Google is not enabled
294
+ * in the OAuth config for this environment (400).
295
+ * @throws InvalidGoogleTokenError when the token signature, audience,
296
+ * expiry, replay, or nonce check failed server-side (401).
297
+ * @throws UserDisabledError when the account flag is set to disabled (403).
298
+ * @throws GoogleEmailRequiredError when Google did not return email
299
+ * for a new-account sign-in (400).
300
+ * @throws OAuthEmailConflictError when email matches existing user
301
+ * but auto-link rule blocked (409).
302
+ */
303
+ async signInWithGoogle(params) {
304
+ const body = {
305
+ identity_token: params.idToken,
306
+ };
307
+ if (params.nonce && params.nonce.length > 0) {
308
+ body.nonce = params.nonce;
309
+ }
310
+ const res = await this.authRequest('/v1/sdk/auth/oauth/google', {
311
+ method: 'POST',
312
+ body,
313
+ });
314
+ const session = await this.parseGoogleSessionResponse(res);
315
+ await this.setSessionInternal(session);
316
+ return session;
317
+ }
318
+ /**
319
+ * Parses a /v1/sdk/auth/oauth/google response. Code-first: the server
320
+ * emits unified OAuth codes (oauth_not_configured, invalid_oauth_token,
321
+ * oauth_email_required, oauth_email_conflict) for both providers; the
322
+ * provider distinction is made here so Google codes map to Google-specific
323
+ * errors. Status + message logic is retained as a fallback for older servers.
324
+ */
325
+ async parseGoogleSessionResponse(res) {
326
+ if (res.status === 200) {
327
+ const data = await res.json();
328
+ return {
329
+ accessToken: data.access_token,
330
+ refreshToken: data.refresh_token,
331
+ expiresAt: data.expires_at,
332
+ user: this.mapUser(data.user),
333
+ };
334
+ }
335
+ let body = {};
336
+ try {
337
+ body = await res.json();
338
+ }
339
+ catch {
340
+ // best-effort error message extraction
341
+ }
342
+ const code = body?.code ?? '';
343
+ const errorMessage = body?.error ?? '';
344
+ // ─── code-first ───
345
+ switch (code) {
346
+ case 'oauth_not_configured':
347
+ throw new auth_errors_js_1.GoogleSignInNotConfiguredError();
348
+ case 'invalid_oauth_token':
349
+ throw new auth_errors_js_1.InvalidGoogleTokenError();
350
+ case 'account_disabled':
351
+ throw new auth_errors_js_1.UserDisabledError();
352
+ case 'oauth_email_required':
353
+ throw new auth_errors_js_1.GoogleEmailRequiredError();
354
+ case 'oauth_email_conflict':
355
+ throw new auth_errors_js_1.OAuthEmailConflictError();
356
+ case 'rate_limit':
357
+ throw new auth_errors_js_1.RateLimitError(errorMessage || undefined);
358
+ }
359
+ // ─── status + message fallback (pre-code servers) ───
360
+ if (res.status === 400) {
361
+ if (errorMessage.includes('not configured')) {
362
+ throw new auth_errors_js_1.GoogleSignInNotConfiguredError();
363
+ }
364
+ if (errorMessage.includes('did not return email')) {
365
+ throw new auth_errors_js_1.GoogleEmailRequiredError();
366
+ }
367
+ throw new auth_errors_js_1.KoolbaseAuthError(`google sign-in failed: ${errorMessage}`, 'google_signin_failed');
368
+ }
369
+ if (res.status === 401)
370
+ throw new auth_errors_js_1.InvalidGoogleTokenError();
371
+ if (res.status === 403)
372
+ throw new auth_errors_js_1.UserDisabledError();
373
+ if (res.status === 409)
374
+ throw new auth_errors_js_1.OAuthEmailConflictError();
375
+ if (res.status === 429)
376
+ throw new auth_errors_js_1.RateLimitError(errorMessage);
377
+ throw new auth_errors_js_1.KoolbaseAuthError(`google sign-in failed: ${res.status} ${errorMessage}`, `google_signin_http_${res.status}`);
378
+ }
379
+ /**
380
+ * Parses a /v1/sdk/auth/oauth/apple response. Code-first; the provider
381
+ * distinction is made here so the server's unified OAuth codes map to
382
+ * Apple-specific errors. Status + message logic is retained as a fallback
383
+ * for older servers.
384
+ */
385
+ async parseAppleSessionResponse(res) {
386
+ if (res.status === 200) {
387
+ const data = await res.json();
388
+ return {
389
+ accessToken: data.access_token,
390
+ refreshToken: data.refresh_token,
391
+ expiresAt: data.expires_at,
392
+ user: this.mapUser(data.user),
393
+ };
394
+ }
395
+ let body = {};
396
+ try {
397
+ body = await res.json();
398
+ }
399
+ catch {
400
+ // best-effort error message extraction
401
+ }
402
+ const code = body?.code ?? '';
403
+ const errorMessage = body?.error ?? '';
404
+ // ─── code-first ───
405
+ switch (code) {
406
+ case 'oauth_not_configured':
407
+ throw new auth_errors_js_1.AppleSignInNotConfiguredError();
408
+ case 'invalid_oauth_token':
409
+ throw new auth_errors_js_1.InvalidAppleTokenError();
410
+ case 'account_disabled':
411
+ throw new auth_errors_js_1.UserDisabledError();
412
+ case 'oauth_email_required':
413
+ throw new auth_errors_js_1.AppleEmailRequiredError();
414
+ case 'oauth_email_conflict':
415
+ throw new auth_errors_js_1.OAuthEmailConflictError();
416
+ case 'rate_limit':
417
+ throw new auth_errors_js_1.RateLimitError(errorMessage || undefined);
418
+ }
419
+ // ─── status + message fallback (pre-code servers) ───
420
+ if (res.status === 400) {
421
+ if (errorMessage.includes('not configured')) {
422
+ throw new auth_errors_js_1.AppleSignInNotConfiguredError();
423
+ }
424
+ if (errorMessage.includes('did not return email')) {
425
+ throw new auth_errors_js_1.AppleEmailRequiredError();
426
+ }
427
+ throw new auth_errors_js_1.KoolbaseAuthError(`apple sign-in failed: ${errorMessage}`, 'apple_signin_failed');
428
+ }
429
+ if (res.status === 401)
430
+ throw new auth_errors_js_1.InvalidAppleTokenError();
431
+ if (res.status === 403)
432
+ throw new auth_errors_js_1.UserDisabledError();
433
+ if (res.status === 409)
434
+ throw new auth_errors_js_1.OAuthEmailConflictError();
435
+ if (res.status === 429)
436
+ throw new auth_errors_js_1.RateLimitError(errorMessage);
437
+ throw new auth_errors_js_1.KoolbaseAuthError(`apple sign-in failed: ${res.status} ${errorMessage}`, `apple_signin_http_${res.status}`);
438
+ }
439
+ async refresh(refreshToken) {
440
+ if (this.ongoingRefresh) {
441
+ return this.ongoingRefresh;
442
+ }
443
+ const promise = this._doRefresh(refreshToken);
444
+ this.ongoingRefresh = promise;
445
+ promise
446
+ .catch(() => {
447
+ // swallow; original promise still rejects to awaiters
448
+ })
449
+ .finally(() => {
450
+ if (this.ongoingRefresh === promise) {
451
+ this.ongoingRefresh = null;
452
+ }
453
+ });
454
+ return promise;
455
+ }
456
+ async _doRefresh(refreshToken) {
457
+ const token = refreshToken ?? this.session?.refreshToken;
458
+ if (!token) {
459
+ throw new auth_errors_js_1.SessionExpiredError();
460
+ }
461
+ const res = await this.authRequest('/v1/sdk/auth/refresh', {
462
+ method: 'POST',
463
+ body: { refresh_token: token },
464
+ });
465
+ const session = await this.parseSessionResponse(res, true);
466
+ await this.setSessionInternal(session);
467
+ return session;
468
+ }
469
+ async logout() {
470
+ let serverSucceeded = true;
471
+ try {
472
+ if (this.session) {
473
+ // Best-effort: don't auto-refresh during logout. If the token's
474
+ // already expired, we still want to clear local state — server
475
+ // will reap expired sessions itself.
476
+ const res = await this.authRequest('/v1/sdk/auth/logout', {
477
+ method: 'POST',
478
+ includeAuth: true,
479
+ });
480
+ if (!res.ok)
481
+ serverSucceeded = false;
482
+ }
483
+ }
484
+ catch {
485
+ serverSucceeded = false;
486
+ }
487
+ finally {
488
+ await this.clearSessionInternal();
489
+ }
490
+ return serverSucceeded;
491
+ }
492
+ async forgotPassword(email) {
493
+ const res = await this.authRequest('/v1/sdk/auth/password-reset', {
494
+ method: 'POST',
495
+ body: { email },
496
+ });
497
+ await this.checkResponse(res);
498
+ }
499
+ async resetPassword(token, password) {
500
+ const res = await this.authRequest('/v1/sdk/auth/password-reset/confirm', {
501
+ method: 'POST',
502
+ body: { token, password },
503
+ });
504
+ await this.checkResponse(res);
505
+ }
506
+ async unlock(token) {
507
+ const res = await this.authRequest('/v1/sdk/auth/unlock', {
508
+ method: 'POST',
509
+ body: { token },
510
+ });
511
+ await this.checkResponse(res);
512
+ }
513
+ get currentUser() {
514
+ return this.session?.user ?? null;
515
+ }
516
+ get accessToken() {
517
+ return this.session?.accessToken ?? null;
518
+ }
519
+ /**
520
+ * Currently-valid access token for data-plane requests, refreshing
521
+ * (via refresh()) if the cached one is near expiry. Returns null when no
522
+ * session exists or refresh fails — callers then go api-key-only and the
523
+ * server treats it as having no end-user identity. The db/storage/functions
524
+ * clients pull from this per request so identity follows the live session.
525
+ */
526
+ async validAccessToken() {
527
+ if (!this.session)
528
+ return null;
529
+ try {
530
+ return await this._ensureValidToken();
531
+ }
532
+ catch {
533
+ return null;
534
+ }
535
+ }
536
+ async setSession(session) {
537
+ if (session) {
538
+ await this.setSessionInternal(session);
539
+ }
540
+ else {
541
+ await this.clearSessionInternal();
542
+ }
543
+ }
544
+ // ─── OAuth (DEPRECATED — see v1.10.0) ───────────────────────────────────
545
+ /**
546
+ * @deprecated v1.9.0: Server endpoint /v1/sdk/auth/oauth not yet
547
+ * shipped. This method previously routed to /v1/auth/oauth (dashboard
548
+ * developer OAuth) which never created project-scoped end-user
549
+ * sessions. Properly implemented in v1.10.0 with provider-specific
550
+ * server endpoints under /v1/sdk/auth/oauth/{apple,google,github}.
551
+ * Use email/password sign-in for now.
552
+ *
553
+ * @throws Always throws KoolbaseAuthError('not_implemented').
554
+ */
555
+ async oauthLogin(_params) {
556
+ throw new auth_errors_js_1.KoolbaseAuthError('OAuth sign-in is not yet implemented for the Koolbase SDK. ' +
557
+ 'Planned for v1.10.0 (server-side endpoints under ' +
558
+ '/v1/sdk/auth/oauth/{provider}). Use email/password authentication ' +
559
+ 'in the meantime.', 'not_implemented');
560
+ }
561
+ // ─── Phone OTP ──────────────────────────────────────────────────────────
562
+ async sendOtp(params) {
563
+ this.validatePhone(params.phoneNumber);
564
+ const res = await this.authRequest('/v1/sdk/auth/phone/send-otp', {
565
+ method: 'POST',
566
+ body: { phone_number: params.phoneNumber },
567
+ });
568
+ const data = await this.parsePhoneResponse(res);
569
+ return { expiresAt: data.expires_at };
570
+ }
571
+ async verifyOtp(params) {
572
+ this.validatePhone(params.phoneNumber);
573
+ const res = await this.authRequest('/v1/sdk/auth/phone/verify-otp', {
574
+ method: 'POST',
575
+ body: {
576
+ phone_number: params.phoneNumber,
577
+ code: params.code,
578
+ },
579
+ });
580
+ const data = await this.parsePhoneResponse(res);
581
+ const session = {
582
+ accessToken: data.access_token,
583
+ refreshToken: data.refresh_token,
584
+ expiresAt: data.expires_at,
585
+ user: this.mapUser(data.user),
586
+ };
587
+ await this.setSessionInternal(session);
588
+ return { session, isNewUser: data.is_new_user ?? false };
589
+ }
590
+ async linkPhone(params) {
591
+ if (!this.session) {
592
+ throw new auth_errors_js_1.KoolbaseAuthError('Must be signed in to link a phone number', 'unauthenticated');
593
+ }
594
+ this.validatePhone(params.phoneNumber);
595
+ const res = await this.authedRequest('/v1/sdk/auth/phone/link', {
596
+ method: 'POST',
597
+ body: {
598
+ phone_number: params.phoneNumber,
599
+ code: params.code,
600
+ },
601
+ });
602
+ const body = await this.parsePhoneResponse(res);
603
+ // Update local session: prefer the canonical user from the server
604
+ // response if present; otherwise merge the linked phone into the
605
+ // existing in-memory user. Either way, setSessionInternal fires the
606
+ // auth state listener so consumers can react to the phone link.
607
+ if (this.session) {
608
+ const updatedUser = body.user
609
+ ? this.mapUser(body.user)
610
+ : {
611
+ ...this.session.user,
612
+ phoneNumber: params.phoneNumber,
613
+ phoneVerified: true,
614
+ };
615
+ await this.setSessionInternal({
616
+ ...this.session,
617
+ user: updatedUser,
618
+ });
619
+ }
620
+ }
621
+ // ─── Cleanup ────────────────────────────────────────────────────────────
622
+ /**
623
+ * Release resources held by this auth client. Clears the in-memory
624
+ * listener set. Does not invalidate sessions or clear storage — call
625
+ * {@link logout} for that.
626
+ */
627
+ dispose() {
628
+ this.listeners.clear();
629
+ }
630
+ // ─── Helpers ────────────────────────────────────────────────────────────
631
+ validatePhone(phoneNumber) {
632
+ if (!/^\+[1-9]\d{6,14}$/.test(phoneNumber)) {
633
+ throw new auth_errors_js_1.InvalidPhoneNumberError();
634
+ }
635
+ }
636
+ async _ensureValidToken() {
637
+ if (this.session && this.session.expiresAt) {
638
+ const expiresAt = new Date(this.session.expiresAt).getTime();
639
+ if (Date.now() < expiresAt - 60 * 1000) {
640
+ return this.session.accessToken;
641
+ }
642
+ }
643
+ if (!this.session) {
644
+ throw new auth_errors_js_1.SessionExpiredError();
645
+ }
646
+ try {
647
+ const session = await this.refresh();
648
+ return session.accessToken;
649
+ }
650
+ catch (e) {
651
+ if (e instanceof auth_errors_js_1.KoolbaseAuthError)
652
+ throw e;
653
+ throw new auth_errors_js_1.SessionExpiredError();
654
+ }
655
+ }
656
+ mapUser(raw) {
657
+ return {
658
+ id: raw.id,
659
+ email: raw.email ?? '',
660
+ phoneNumber: raw.phone_number,
661
+ phoneVerified: raw.phone_verified ?? false,
662
+ fullName: raw.full_name,
663
+ avatarUrl: raw.avatar_url,
664
+ verified: raw.verified ?? false,
665
+ createdAt: raw.created_at,
666
+ };
667
+ }
668
+ /**
669
+ * Parse a session-returning response (login, register, refresh).
670
+ * Non-2xx is delegated to throwTypedError, which is code-first
671
+ * (reads body.code) with a status/message fallback. isRefresh only
672
+ * affects how a bare 401 (no code, older server) is interpreted.
673
+ */
674
+ async parseSessionResponse(res, isRefresh) {
675
+ if (!res.ok)
676
+ await this.throwTypedError(res, isRefresh); // never returns
677
+ const data = await res.json();
678
+ return {
679
+ accessToken: data.access_token,
680
+ refreshToken: data.refresh_token,
681
+ expiresAt: data.expires_at,
682
+ user: this.mapUser(data.user),
683
+ };
684
+ }
685
+ async checkResponse(res) {
686
+ if (res.ok)
687
+ return;
688
+ await this.throwTypedError(res);
689
+ }
690
+ /**
691
+ * Map a non-2xx credential/session response to a typed error.
692
+ *
693
+ * Code-first: the server now emits a stable `code` on every error
694
+ * (contract conformance), so we switch on body.code. The status +
695
+ * message logic is retained as a fallback for older servers or any
696
+ * response that arrives without a code. isRefresh only changes how a
697
+ * bare 401 is interpreted in the fallback path.
698
+ */
699
+ async throwTypedError(res, isRefresh = false) {
700
+ let body = {};
701
+ try {
702
+ body = await res.json();
703
+ }
704
+ catch {
705
+ // ignore
706
+ }
707
+ const code = body.code ?? '';
708
+ const msg = body.error ?? '';
709
+ // ─── code-first ───
710
+ switch (code) {
711
+ case 'invalid_credentials':
712
+ throw new auth_errors_js_1.InvalidCredentialsError();
713
+ case 'email_in_use':
714
+ throw new auth_errors_js_1.EmailAlreadyInUseError();
715
+ case 'account_disabled':
716
+ throw new auth_errors_js_1.UserDisabledError();
717
+ case 'account_locked':
718
+ throw new auth_errors_js_1.AccountLockedError();
719
+ case 'invalid_refresh_token':
720
+ // Refresh token rejected — the session is unrecoverable; re-login.
721
+ throw new auth_errors_js_1.SessionExpiredError();
722
+ case 'token_revoked':
723
+ throw new auth_errors_js_1.TokenRevokedError();
724
+ case 'invalid_unlock_token':
725
+ throw new auth_errors_js_1.UnlockTokenInvalidError();
726
+ case 'rate_limit':
727
+ throw new auth_errors_js_1.RateLimitError(msg || undefined);
728
+ }
729
+ // ─── status fallback (pre-code servers) ───
730
+ if (res.status === 409)
731
+ throw new auth_errors_js_1.EmailAlreadyInUseError();
732
+ if (res.status === 401) {
733
+ throw isRefresh ? new auth_errors_js_1.SessionExpiredError() : new auth_errors_js_1.InvalidCredentialsError();
734
+ }
735
+ if (res.status === 403)
736
+ throw new auth_errors_js_1.UserDisabledError();
737
+ if (res.status === 429) {
738
+ if (msg.includes('account temporarily locked')) {
739
+ throw new auth_errors_js_1.AccountLockedError();
740
+ }
741
+ throw new auth_errors_js_1.RateLimitError(msg || undefined);
742
+ }
743
+ // ─── legacy message fallback ───
744
+ if (msg.includes('invalid or expired unlock token')) {
745
+ throw new auth_errors_js_1.UnlockTokenInvalidError();
746
+ }
747
+ if (msg.includes('session revoked') ||
748
+ msg.includes('token revoked') ||
749
+ msg.includes('session has been revoked')) {
750
+ throw new auth_errors_js_1.TokenRevokedError();
751
+ }
752
+ throw new auth_errors_js_1.KoolbaseAuthError(msg || `Request failed: ${res.status}`, code || `http_${res.status}`);
753
+ }
754
+ /**
755
+ * Parse a phone-auth response. Code-first, with a phone-specific twist:
756
+ * the server emits the generic `rate_limit` code for the phone endpoints
757
+ * (they share the default 429), but phone has a dedicated server-side
758
+ * rate-limiter, so we surface OtpRateLimitError rather than RateLimitError.
759
+ * Status + message logic is retained as a fallback for older servers.
760
+ */
761
+ async parsePhoneResponse(res) {
762
+ let body = {};
763
+ try {
764
+ body = await res.json();
765
+ }
766
+ catch {
767
+ // ignore
768
+ }
769
+ if (res.ok)
770
+ return body;
771
+ const code = body.code ?? '';
772
+ const msg = body.error ?? '';
773
+ // ─── code-first ───
774
+ switch (code) {
775
+ case 'invalid_phone':
776
+ throw new auth_errors_js_1.InvalidPhoneNumberError();
777
+ case 'otp_expired':
778
+ throw new auth_errors_js_1.OtpExpiredError();
779
+ case 'otp_invalid':
780
+ throw new auth_errors_js_1.OtpInvalidError();
781
+ case 'otp_max_attempts':
782
+ throw new auth_errors_js_1.OtpMaxAttemptsError();
783
+ case 'phone_in_use':
784
+ throw new auth_errors_js_1.PhoneAlreadyLinkedError();
785
+ case 'sms_not_configured':
786
+ throw new auth_errors_js_1.SmsConfigMissingError();
787
+ case 'rate_limit':
788
+ throw new auth_errors_js_1.OtpRateLimitError();
789
+ }
790
+ // ─── status fallback (pre-code servers) ───
791
+ if (res.status === 429)
792
+ throw new auth_errors_js_1.OtpRateLimitError();
793
+ if (res.status === 409)
794
+ throw new auth_errors_js_1.PhoneAlreadyLinkedError();
795
+ // ─── legacy message fallback ───
796
+ if (msg.includes('E.164'))
797
+ throw new auth_errors_js_1.InvalidPhoneNumberError();
798
+ if (msg.includes('OTP has expired'))
799
+ throw new auth_errors_js_1.OtpExpiredError();
800
+ if (msg.includes('too many incorrect attempts')) {
801
+ throw new auth_errors_js_1.OtpMaxAttemptsError();
802
+ }
803
+ if (msg.includes('invalid OTP') ||
804
+ msg.includes('invalid or expired OTP')) {
805
+ throw new auth_errors_js_1.OtpInvalidError();
806
+ }
807
+ if (msg.includes('SMS provider not configured')) {
808
+ throw new auth_errors_js_1.SmsConfigMissingError();
809
+ }
810
+ throw new auth_errors_js_1.KoolbaseAuthError(msg || 'An unexpected error occurred', code || undefined);
811
+ }
812
+ }
813
+ exports.KoolbaseAuth = KoolbaseAuth;