js-bao-wss-client 2.1.0-alpha.9 → 2.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.
package/README.md CHANGED
@@ -921,10 +921,17 @@ const all = await client.documents.list({ includeRoot: true });
921
921
 
922
922
  ## OAuth Authentication
923
923
 
924
+ Google registers a separate OAuth client per platform, so the server publishes a
925
+ client MAP keyed by client type (`web`, `ios`, `android`, `desktop`,
926
+ `chrome-extension`). A browser reads the `web` entry; there is no single
927
+ "is Google available" flag, because one could only ever be right for one
928
+ platform.
929
+
924
930
  ```typescript
925
- // Check if OAuth is available
926
- const hasOAuth = await client.checkOAuthAvailable();
927
- if (hasOAuth) {
931
+ // Check if Google sign-in is available for THIS (browser) client: the provider
932
+ // is enabled and the web entry is usable.
933
+ const googleAvailable = await client.checkOAuthAvailable();
934
+ if (googleAvailable) {
928
935
  // Start OAuth flow (redirects to Google)
929
936
  await client.startOAuthFlow();
930
937
  }
@@ -975,18 +982,45 @@ if (client.isAuthenticated()) {
975
982
  client.setToken("new-jwt-token");
976
983
  ```
977
984
 
978
- ## Magic Link Authentication
985
+ ## Email Sign-In
979
986
 
980
- The client supports passwordless email authentication via magic links. Magic links must be enabled in the admin console for your app.
987
+ One request sends ONE email carrying a 6-digit code and — when the redirect
988
+ target is on the app's `[auth].emailRedirectUris` allow-list — a sign-in link.
989
+ The user finishes with whichever suits them, and consuming either one retires
990
+ both: one email signs a user in once. Email sign-in must be enabled for the app
991
+ (`[auth].emailSignInEnabled`, on by default).
981
992
 
982
- ### Request Magic Link
993
+ ### Request the email
983
994
 
984
995
  ```typescript
985
- // Send a magic link email to the user
986
- await client.magicLinkRequest("user@example.com");
996
+ // `redirectUri` defaults to the client's oauthRedirectUri. With no target at
997
+ // all the email carries the code alone, from the same template. A target that
998
+ // IS sent must match the app's non-empty allow-list, or the request is
999
+ // rejected 400 `Invalid redirect URI` — it never degrades to code-only.
1000
+ await client.emailSignInRequest("user@example.com", {
1001
+ redirectUri: "https://app.example.com/auth/callback",
1002
+ });
1003
+ ```
1004
+
1005
+ Both credentials expire together, 15 minutes after the request. Rate limits
1006
+ apply (5 requests per email per hour, 20 per IP per hour).
1007
+
1008
+ `magicLinkRequest` and `otpRequest` still work and are **deprecated**: both are
1009
+ aliases of the same issuance path and send the same email.
1010
+
1011
+ ### Finish with the code
1012
+
1013
+ ```typescript
1014
+ const { user, isNewUser } = await client.otpVerify("user@example.com", "123456");
1015
+ console.log("Logged in as:", user.email);
1016
+
1017
+ // isNewUser is true if this is the user's first sign-in (account was just created)
1018
+ if (isNewUser) {
1019
+ // Show onboarding flow for new users
1020
+ }
987
1021
  ```
988
1022
 
989
- ### Handle Magic Link Callback
1023
+ ### Finish with the link
990
1024
 
991
1025
  ```typescript
992
1026
  // In your callback page (e.g., /oauth/callback)
@@ -998,7 +1032,6 @@ if (magicToken) {
998
1032
  const { user, promptAddPasskey, isNewUser } = await client.magicLinkVerify(magicToken);
999
1033
  console.log("Logged in as:", user.email);
1000
1034
 
1001
- // isNewUser is true if this is the user's first sign-in (account was just created)
1002
1035
  if (isNewUser) {
1003
1036
  // Show onboarding flow for new users
1004
1037
  }
@@ -1010,56 +1043,32 @@ if (magicToken) {
1010
1043
  }
1011
1044
  ```
1012
1045
 
1013
- ## OTP (Email Code) Authentication
1014
-
1015
- The client supports passwordless authentication via one-time 6-digit codes sent by email. OTP authentication must be enabled in the admin console for your app.
1016
-
1017
- ### Request OTP Code
1018
-
1019
- ```typescript
1020
- // Send a 6-digit code to the user's email
1021
- await client.otpRequest("user@example.com");
1022
- ```
1023
-
1024
- The code is valid for 10 minutes. Rate limits apply (5 codes per email per hour, 20 per IP per hour).
1025
-
1026
- ### Verify OTP Code
1027
-
1028
- ```typescript
1029
- // Verify the code and complete authentication
1030
- const { user, isNewUser } = await client.otpVerify("user@example.com", "123456");
1031
- console.log("Logged in as:", user.email);
1032
-
1033
- // isNewUser is true if this is the user's first sign-in (account was just created)
1034
- if (isNewUser) {
1035
- // Show onboarding flow for new users
1036
- }
1037
- ```
1038
-
1039
1046
  ### Error Handling
1040
1047
 
1041
1048
  ```typescript
1042
1049
  try {
1043
1050
  await client.otpVerify("user@example.com", "123456");
1044
1051
  } catch (error) {
1045
- if (error.code === "OTP_NOT_ENABLED") {
1046
- // OTP auth not enabled for this app
1047
- } else if (error.code === "RATE_LIMITED") {
1052
+ if (error.code === "RATE_LIMITED") {
1048
1053
  // Too many attempts, try again later
1049
1054
  } else if (error.code === "OTP_MAX_ATTEMPTS") {
1050
- // Maximum verification attempts exceeded, request a new code
1055
+ // Maximum verification attempts exceeded, request a new email
1051
1056
  } else if (error.code === "INVALID_TOKEN") {
1052
1057
  // Invalid or expired code
1053
1058
  }
1054
1059
  }
1055
1060
  ```
1056
1061
 
1062
+ When email sign-in is disabled the request endpoints answer a plain 400 with
1063
+ `"Email sign-in is not enabled for this app"` and no error code — gate the UI
1064
+ on `getAuthConfig()`'s `emailSignInEnabled` instead.
1065
+
1057
1066
  ## Passkey Authentication
1058
1067
 
1059
1068
  The client supports WebAuthn/passkey authentication for passwordless sign-in. Passkeys must be enabled in the admin console for your app.
1060
1069
 
1061
- Note: Passkeys can only be added to existing accounts (created via OAuth or Magic Link). To use passkey authentication:
1062
- 1. User creates account via OAuth or Magic Link
1070
+ Note: Passkeys can only be added to existing accounts (created via OAuth or email sign-in). To use passkey authentication:
1071
+ 1. User creates account via OAuth or email sign-in
1063
1072
  2. User adds a passkey to their account
1064
1073
  3. User can then sign in with the passkey on future visits
1065
1074
 
@@ -1073,14 +1082,15 @@ const config = await client.getAuthConfig();
1073
1082
  if (config.hasPasskey) {
1074
1083
  console.log("Passkeys are available");
1075
1084
  }
1076
- if (config.magicLinkEnabled) {
1077
- console.log("Magic link sign-in is available");
1078
- }
1079
- if (config.otpEnabled) {
1080
- console.log("OTP (email code) sign-in is available");
1085
+ if (config.emailSignInEnabled) {
1086
+ console.log("Email sign-in is available");
1081
1087
  }
1082
- if (config.hasOAuth) {
1083
- console.log("Google OAuth is available");
1088
+ // Per-platform Google configuration. `usable` is the server's shape verdict for
1089
+ // one entry — a client id, at least one redirect URI, and a client secret
1090
+ // exactly when that client type takes one. Availability is that AND the
1091
+ // provider switch, which is what `checkOAuthAvailable()` computes.
1092
+ if (config.googleOAuthEnabled && config.googleClients.clients.web?.usable) {
1093
+ console.log("Google OAuth is available in the browser");
1084
1094
  }
1085
1095
  ```
1086
1096
 
@@ -8,6 +8,7 @@ import { type SubscribeOptions } from "./internal/databaseSubscriptions";
8
8
  import { type StorageConfig, type YjsPersistenceFactory } from "./internal/storage/index.js";
9
9
  import { BlobManager } from "./internal/blobManager";
10
10
  import { type AnalyticsEventInput } from "./internal/analyticsQueue";
11
+ import { type GoogleClientsConfig } from "./internal/authController";
11
12
  import { type RequestOptions } from "./internal/httpClient";
12
13
  import { type DocumentPermission as DocumentAccessLevel, type LocalDocumentEntry, type LocalMetadataEntry, type DocumentDebugSnapshot } from "./internal/documentManager";
13
14
  import { DocumentsAPI, DocumentContext, type DocumentInfo, type ResolveAliasParams, type CreateDocumentOptions } from "./api/documentsApi";
@@ -32,8 +33,8 @@ import { InvitationsAPI } from "./api/invitationsApi";
32
33
  import { NotificationsAPI } from "./api/notificationsApi";
33
34
  export { JsBaoError, isJsBaoError, LockTimeoutError, JsBaoApiError, isJsBaoApiError, JsBaoNetworkError, isJsBaoNetworkError, } from "./errors";
34
35
  export type { JsBaoErrorCode } from "./errors";
35
- export { AuthError, AUTH_CODES } from "./internal/authController";
36
- export type { AuthCode } from "./internal/authController";
36
+ export { AuthError, AUTH_CODES, googleWebClientAvailable, } from "./internal/authController";
37
+ export type { AuthCode, GoogleClientConfig, GoogleClientsConfig, } from "./internal/authController";
37
38
  export type { DocumentInfo, DocumentPermissionEntry, DocumentInvitation, DocumentInvitationResponse, DocumentAccessResult, DocumentGroupPermissionEntry, DocumentAliasInfo, DocumentAliasScope, PermissionUpdateResult, DirectPermissionGrant, DeferredPermissionGrant, PendingInvitationEntry, PendingGroupInvitationEntry, LinkAccessResult, } from "./api/documentsApi";
38
39
  export type { UserProfile, SharedDocument, SharedDocumentListResult, SharedDocumentsOptions, OwnedDocumentsOptions, } from "./api/meApi";
39
40
  export type { SessionInfo } from "./api/sessionApi";
@@ -69,7 +70,7 @@ export type { GroupTypeConfigsAPI } from "./api/groupTypeConfigsApi";
69
70
  export type { CollectionTypeConfigsAPI } from "./api/collectionTypeConfigsApi";
70
71
  export type { DatabaseTypeConfigsAPI } from "./api/databaseTypeConfigsApi";
71
72
  export type { CronTriggersAPI } from "./api/cronTriggersApi";
72
- export type { CronTriggerInfo, CreateCronTriggerParams, UpdateCronTriggerParams, CronTriggerListResult, } from "./api/cronTriggersApi";
73
+ export type { CronTriggerInfo, CreateCronTriggerParams, UpdateCronTriggerParams, CronTriggerListResult, ObjectStatus, } from "./api/cronTriggersApi";
73
74
  export type { CreateDocumentOptions, CreateOfflineOptions, CreateWithAliasOptions, GetOrCreateWithAliasOptions, DeleteDocumentOptions, DocumentAccessRequest, DocumentAccessRequestResponse, DocumentListOptions, DocumentListPage, EvictAllDocumentsOptions, EvictDocumentOptions, GrantGroupPermissionParams, ResolveAliasParams, SetAliasParams, UpdateDocumentData, } from "./api/documentsApi";
74
75
  export type { CreateDatabaseParams, UpdateDatabaseParams } from "./api/databasesApi";
75
76
  export type { CreateCollectionParams, UpdateCollectionParams, GrantCollectionGroupPermissionParams, ListCollectionsOptions, } from "./api/collectionsApi";
@@ -325,7 +326,10 @@ export interface PromptsAPI {
325
326
  * refusal rejects with a `JsBaoApiError` whose `status` is `403` and whose
326
327
  * `code` is `"PROMPT_ACCESS_DENIED"` — including when the prompt has no rule
327
328
  * stored at all, which denies every caller who is not an app admin or owner.
328
- * A prompt that is neither `active` nor `draft` rejects with a `400`.
329
+ * A prompt that is not in service rejects with a `400` whose `code` is
330
+ * `"PROMPT_NOT_EXECUTABLE"`. Availability is a single server-owned `status`:
331
+ * only an `active` prompt executes here, and `primitive prompts enable`
332
+ * puts an inactive one back in service.
329
333
  */
330
334
  execute<V extends object = Record<string, unknown>, R = unknown>(promptKey: string, options: ExecutePromptOptions<V>): Promise<ExecutePromptResult<R>>;
331
335
  }
@@ -384,6 +388,16 @@ export interface JsBaoClientOptions {
384
388
  };
385
389
  sync?: {
386
390
  outboundDebounceMs?: number;
391
+ /**
392
+ * How long a handshake may take before the client gives up on it and lets
393
+ * the reconnect logic retry, in milliseconds. Defaults to 10000, and is
394
+ * also settable through `CLIENT_SYNC_HANDSHAKE_TIMEOUT_MS`.
395
+ *
396
+ * The budget covers both halves: opening the WebSocket (an endpoint that
397
+ * accepts the connection but never answers the upgrade is abandoned here,
398
+ * rather than holding the connect open forever) and the sync handshake
399
+ * that follows it.
400
+ */
387
401
  handshakeTimeoutMs?: number;
388
402
  };
389
403
  commitRetryBackoff?: {
@@ -1938,9 +1952,41 @@ export declare class JsBaoClient extends Observable<any> {
1938
1952
  * only the union of a completed walk evicts. Anything short of that — a
1939
1953
  * failed request, a server that ignores `limit` and answers with a bare
1940
1954
  * array, a page that claims more rows with no cursor to ask for them, a
1941
- * cursor that stops advancing, the page cap — evicts nothing.
1955
+ * cursor that stops advancing, the page cap — evicts nothing. Nor does a
1956
+ * completed walk whose union is empty: the request asks `includeRoot=true`,
1957
+ * so zero rows is an anomalous answer rather than a user with no documents
1958
+ * (#2859).
1942
1959
  */
1943
1960
  private reconcileDocumentScope;
1961
+ /**
1962
+ * The hand-off from an unpaged `documents.list()` to the scope walk (#2919).
1963
+ *
1964
+ * `documentsApi` used to reconcile the caller's own listing response
1965
+ * `authoritative: true` whenever the request was not paged. But the unpaged
1966
+ * `GET /documents` is a bare array the server silently truncated at
1967
+ * dynamo-bao's 100-row `defaultQueryLimit`, so a truncated listing and a
1968
+ * complete one are the same bytes: reading one as the server's whole view of
1969
+ * the scope deleted the metadata row — and the cached CRDT state, unflushed
1970
+ * offline edits included — of every cached document past the truncation
1971
+ * point. So the response now merges only, and the eviction decision is left
1972
+ * to `reconcileDocumentScope`, whose paged walk is the only read that can
1973
+ * say where the scope ends. Mirrors the swift-client's
1974
+ * `reconcileByWalkingScope(seenIds:)` (#2827 / PR #2841).
1975
+ *
1976
+ * `seenIds` is what the caller's response already accounted for. When no
1977
+ * cached document is missing from it, an authoritative pass would have
1978
+ * nothing to delete and the walk is skipped — an app whose documents fit in
1979
+ * one response issues no extra request. The root is exempt from that
1980
+ * question: the listing filters it out of its own response, so it is always
1981
+ * "missing", and treating it as a truncation would put a scope walk behind
1982
+ * every listing.
1983
+ *
1984
+ * The walk is NOT forced. Above the truncation threshold every unpaged
1985
+ * listing looks truncated, so forcing would make each `documents.list()` pay
1986
+ * a full N/100-request walk; it shares the connect-time walk's staleness
1987
+ * window instead.
1988
+ */
1989
+ private _reconcileScopeAfterListing;
1944
1990
  private _runScopeReconcile;
1945
1991
  /** Update local metadata cache with server document data.
1946
1992
  * @param items - Array of server document records to merge into the local cache
@@ -2673,12 +2719,49 @@ export declare class JsBaoClient extends Observable<any> {
2673
2719
  name: string;
2674
2720
  mode: "public" | "invite-only" | "domain";
2675
2721
  waitlistEnabled: boolean;
2676
- hasOAuth: boolean;
2722
+ /**
2723
+ * Whether THIS (browser) client can start Google sign-in: the provider is
2724
+ * enabled and the `web` entry is usable. A single server-side flag could
2725
+ * not tell a web-configured app from a native-configured one, so this is
2726
+ * computed per platform from the client map.
2727
+ */
2728
+ googleAvailable: boolean;
2677
2729
  hasPasskey: boolean;
2730
+ /**
2731
+ * Is email sign-in available? ONE flag: one request sends one email
2732
+ * carrying both credentials.
2733
+ */
2734
+ emailSignInEnabled: boolean;
2735
+ /** @deprecated Always equal to `emailSignInEnabled`. */
2678
2736
  magicLinkEnabled: boolean;
2679
2737
  }>;
2738
+ /**
2739
+ * Request one sign-in email. It carries a 6-digit code and — when a redirect
2740
+ * target is available AND the app allow-lists it — a sign-in link, so the
2741
+ * user can finish on the device they started on or on whichever device has
2742
+ * their mail. Nothing selects a method up front.
2743
+ *
2744
+ * Finish with `otpVerify(email, code)` for the code, or `magicLinkVerify(token)`
2745
+ * for the link. Consuming either one retires both: one email, one session.
2746
+ *
2747
+ * @param email - The email address to sign in
2748
+ * @param options - Optional configuration
2749
+ * @param options.redirectUri - Where the link should land. Defaults to the
2750
+ * client's `oauthRedirectUri`. With neither, the email carries the code
2751
+ * alone rather than failing.
2752
+ * @group Authentication
2753
+ */
2754
+ emailSignInRequest(email: string, options?: {
2755
+ redirectUri?: string;
2756
+ }): Promise<{
2757
+ success: boolean;
2758
+ }>;
2680
2759
  /**
2681
2760
  * Request a magic link email for passwordless authentication.
2761
+ *
2762
+ * @deprecated Use {@link emailSignInRequest}. This is an alias of it — the
2763
+ * server issues through the same path and sends the same email, which now
2764
+ * also carries a 6-digit code.
2682
2765
  * @param email - The email address to send the magic link to
2683
2766
  * @param options - Optional configuration
2684
2767
  * @param options.redirectUri - Override the default OAuth redirect URI for the magic link callback
@@ -2712,6 +2795,9 @@ export declare class JsBaoClient extends Observable<any> {
2712
2795
  /**
2713
2796
  * Request a one-time password (OTP) code to be sent to the specified email.
2714
2797
  * The code can be verified using `otpVerify()`.
2798
+ *
2799
+ * @deprecated Use {@link emailSignInRequest}. This is an alias of it that
2800
+ * carries no redirect target, so the email it sends is code-only.
2715
2801
  * @param email - The email address to send the OTP code to
2716
2802
  * @group Authentication
2717
2803
  */
@@ -2839,17 +2925,21 @@ export declare class JsBaoClient extends Observable<any> {
2839
2925
  mode: string;
2840
2926
  waitlistEnabled: boolean;
2841
2927
  googleOAuthEnabled: boolean;
2842
- googleClientId: string | null;
2843
- hasOAuth: boolean;
2844
- redirectUris: string[] | null;
2928
+ googleClients: GoogleClientsConfig;
2845
2929
  passkeyEnabled: boolean;
2846
- passkeyRpId: string | null;
2847
- passkeyRpName: string | null;
2848
2930
  passkeyRpConfig: Record<string, {
2849
2931
  name: string;
2850
2932
  }> | null;
2851
2933
  hasPasskey: boolean;
2934
+ /**
2935
+ * Is email sign-in available at all (#2884)? ONE flag — one request sends
2936
+ * one email carrying both credentials, so there is no per-method
2937
+ * availability to report.
2938
+ */
2939
+ emailSignInEnabled: boolean;
2940
+ /** @deprecated (#2884) Always equal to `emailSignInEnabled`. */
2852
2941
  magicLinkEnabled: boolean;
2942
+ /** @deprecated (#2884) Always equal to `emailSignInEnabled`. */
2853
2943
  otpEnabled: boolean;
2854
2944
  hasApple: boolean;
2855
2945
  appleSignInEnabled: boolean;
@@ -11,7 +11,7 @@ import { deleteIdbByName } from "./internal/storage/idbUtils.js";
11
11
  import { BlobManager } from "./internal/blobManager";
12
12
  import { BrowserConnectivityMonitor, CONNECTIVITY_LOST, CONNECTIVITY_RESTORED, USER_SET, } from "./internal/connectivityMonitor";
13
13
  import { createAnalyticsQueue, } from "./internal/analyticsQueue";
14
- import { AuthController, exchangeOAuthCode, parseJwtPayload, } from "./internal/authController";
14
+ import { AuthController, exchangeOAuthCode, googleWebClientAvailable, parseJwtPayload, } from "./internal/authController";
15
15
  import { HttpClient, } from "./internal/httpClient";
16
16
  import { DocumentManager, } from "./internal/documentManager";
17
17
  import { ensureArrayBuffer, toBase64, fromBase64, decompressGzip, } from "./utils/binary";
@@ -38,7 +38,7 @@ import { CollectionsAPI } from "./api/collectionsApi";
38
38
  import { InvitationsAPI } from "./api/invitationsApi";
39
39
  import { NotificationsAPI } from "./api/notificationsApi";
40
40
  export { JsBaoError, isJsBaoError, LockTimeoutError, JsBaoApiError, isJsBaoApiError, JsBaoNetworkError, isJsBaoNetworkError, } from "./errors";
41
- export { AuthError, AUTH_CODES } from "./internal/authController";
41
+ export { AuthError, AUTH_CODES, googleWebClientAvailable, } from "./internal/authController";
42
42
  export { ANALYTICS_UNAUTHENTICATED_USER } from "./internal/analyticsQueue";
43
43
  const DEFAULT_RETURN_ACTIVE_MIN_MS = 5 * 60 * 1000;
44
44
  const DEFAULT_SYNC_ERROR_MIN_MS = 30 * 1000;
@@ -771,6 +771,10 @@ export class JsBaoClient extends Observable {
771
771
  this.wsManager = new WebSocketManager({
772
772
  logger,
773
773
  maxReconnectDelayMs: this.maxReconnectDelay,
774
+ // The handshake budget covers the socket coming up as well as the sync
775
+ // handshake that follows it: an endpoint that accepts the connection and
776
+ // never answers the upgrade must not hold connect() open forever (#2923).
777
+ connectTimeoutMs: this.syncWatchdogTimeoutMs,
774
778
  hasAccessToken: () => !!this.token,
775
779
  buildConnectionRequest: (connectionId) => this.buildWebSocketRequest(connectionId),
776
780
  createWebSocket: (url, headers) => this.createWebSocketInstance(url, headers),
@@ -1384,7 +1388,10 @@ export class JsBaoClient extends Observable {
1384
1388
  * only the union of a completed walk evicts. Anything short of that — a
1385
1389
  * failed request, a server that ignores `limit` and answers with a bare
1386
1390
  * array, a page that claims more rows with no cursor to ask for them, a
1387
- * cursor that stops advancing, the page cap — evicts nothing.
1391
+ * cursor that stops advancing, the page cap — evicts nothing. Nor does a
1392
+ * completed walk whose union is empty: the request asks `includeRoot=true`,
1393
+ * so zero rows is an anomalous answer rather than a user with no documents
1394
+ * (#2859).
1388
1395
  */
1389
1396
  reconcileDocumentScope(options) {
1390
1397
  if (this.scopeReconcileInFlight)
@@ -1418,6 +1425,62 @@ export class JsBaoClient extends Observable {
1418
1425
  this.scopeReconcileInFlight = run;
1419
1426
  return run;
1420
1427
  }
1428
+ /**
1429
+ * The hand-off from an unpaged `documents.list()` to the scope walk (#2919).
1430
+ *
1431
+ * `documentsApi` used to reconcile the caller's own listing response
1432
+ * `authoritative: true` whenever the request was not paged. But the unpaged
1433
+ * `GET /documents` is a bare array the server silently truncated at
1434
+ * dynamo-bao's 100-row `defaultQueryLimit`, so a truncated listing and a
1435
+ * complete one are the same bytes: reading one as the server's whole view of
1436
+ * the scope deleted the metadata row — and the cached CRDT state, unflushed
1437
+ * offline edits included — of every cached document past the truncation
1438
+ * point. So the response now merges only, and the eviction decision is left
1439
+ * to `reconcileDocumentScope`, whose paged walk is the only read that can
1440
+ * say where the scope ends. Mirrors the swift-client's
1441
+ * `reconcileByWalkingScope(seenIds:)` (#2827 / PR #2841).
1442
+ *
1443
+ * `seenIds` is what the caller's response already accounted for. When no
1444
+ * cached document is missing from it, an authoritative pass would have
1445
+ * nothing to delete and the walk is skipped — an app whose documents fit in
1446
+ * one response issues no extra request. The root is exempt from that
1447
+ * question: the listing filters it out of its own response, so it is always
1448
+ * "missing", and treating it as a truncation would put a scope walk behind
1449
+ * every listing.
1450
+ *
1451
+ * The walk is NOT forced. Above the truncation threshold every unpaged
1452
+ * listing looks truncated, so forcing would make each `documents.list()` pay
1453
+ * a full N/100-request walk; it shares the connect-time walk's staleness
1454
+ * window instead.
1455
+ */
1456
+ async _reconcileScopeAfterListing(seenIds) {
1457
+ // The index is what says which documents are cached, and at launch it is
1458
+ // filled asynchronously from persistence. Asking before that read lands
1459
+ // finds nothing unaccounted for and skips a walk that was owed.
1460
+ try {
1461
+ await this._loadAllMetadataFromIdb();
1462
+ }
1463
+ catch { }
1464
+ const rootDocId = this.getRootDocId();
1465
+ let unaccounted = false;
1466
+ for (const [documentId, meta] of this.docManager.metadataIndex) {
1467
+ if (!documentId || seenIds.has(documentId))
1468
+ continue;
1469
+ if (rootDocId && documentId === rootDocId)
1470
+ continue;
1471
+ // Local-only documents and pending creates the server has never heard of
1472
+ // are absent from every listing, and say nothing about truncation.
1473
+ if (meta?.localOnly === true)
1474
+ continue;
1475
+ if (this.isPendingCreate(documentId))
1476
+ continue;
1477
+ unaccounted = true;
1478
+ break;
1479
+ }
1480
+ if (!unaccounted)
1481
+ return;
1482
+ void this.reconcileDocumentScope({ reason: "list-truncated" });
1483
+ }
1421
1484
  async _runScopeReconcile(reason, generation) {
1422
1485
  // The index is what says which documents are cached, and at launch it is
1423
1486
  // filled asynchronously from persistence. Walking before that read lands
@@ -1504,11 +1567,29 @@ export class JsBaoClient extends Observable {
1504
1567
  // A scope walked as one user says nothing about the one signed in now.
1505
1568
  if (generation !== this.scopeReconcileGeneration)
1506
1569
  return;
1570
+ // A completed walk that found nothing at all. The request asks
1571
+ // `includeRoot=true`, so the server's answer to it always carries at
1572
+ // least the caller's root: zero rows is an anomalous answer, not a user
1573
+ // with no documents, and reading it as the whole scope evicts every
1574
+ // cached document on the device (#2859). One row — the root, and
1575
+ // nothing else — is the user whose documents really were all deleted,
1576
+ // and that page still evicts the rest of the cache.
1577
+ if (union.length === 0) {
1578
+ logger.debug("[metadata] scope reconcile declined (walk found no documents)", {
1579
+ reason,
1580
+ pages: page + 1,
1581
+ });
1582
+ return;
1583
+ }
1507
1584
  logger.debug("[metadata] scope reconcile walked the scope", {
1508
1585
  reason,
1509
1586
  documents: union.length,
1510
1587
  pages: page + 1,
1511
1588
  });
1589
+ // The walk asks for the root, so the union carries it — retaining it by
1590
+ // id as well means the cache does not depend on the server having said
1591
+ // so. Mirrors the swift client's `reconcileScopeAtConnect`.
1592
+ const rootDocId = this.getRootDocId();
1512
1593
  await this.syncMetadata({
1513
1594
  scope: "all",
1514
1595
  payloadType: "full",
@@ -1516,6 +1597,7 @@ export class JsBaoClient extends Observable {
1516
1597
  authoritative: true,
1517
1598
  includeRoot: true,
1518
1599
  background: true,
1600
+ retainIds: rootDocId ? [rootDocId] : undefined,
1519
1601
  shouldRetain: (documentId) => !isEvictable(documentId),
1520
1602
  });
1521
1603
  return;
@@ -4614,7 +4696,13 @@ export class JsBaoClient extends Observable {
4614
4696
  return new WebSocketImpl(finalUrl);
4615
4697
  }
4616
4698
  logger.log(`[DIAGNOSTIC] Node.js environment - using headers option`);
4617
- return new WebSocketImpl(wsUrl, { headers });
4699
+ // `handshakeTimeout` makes the transport itself give up on an upgrade that
4700
+ // is never answered; the manager's own connect deadline covers the socket
4701
+ // implementations (browser included) that have no such option.
4702
+ return new WebSocketImpl(wsUrl, {
4703
+ headers,
4704
+ handshakeTimeout: this.syncWatchdogTimeoutMs,
4705
+ });
4618
4706
  }
4619
4707
  shouldTriggerHandshakeRecovery() {
4620
4708
  // A close the client asked for is not a handshake the server refused. A
@@ -4625,6 +4713,15 @@ export class JsBaoClient extends Observable {
4625
4713
  // close.
4626
4714
  if (this.wsLastCloseInitiator === "forceReconnect")
4627
4715
  return false;
4716
+ // An endpoint that never answered the upgrade refused nothing either: the
4717
+ // connect deadline abandoned the attempt without the server ever ruling on
4718
+ // the token (#2923). Treating that as an auth failure would refresh
4719
+ // against an endpoint that is not answering, and — because recovery
4720
+ // suppresses `shouldReconnect` — swallow the one reconnect the manager
4721
+ // would otherwise schedule, leaving the retry to a refresh that may never
4722
+ // succeed. A transport timeout belongs to the backoff loop.
4723
+ if (this.wsLastCloseInitiator === "connectTimeout")
4724
+ return false;
4628
4725
  if (this.wsHandshakeCompleted)
4629
4726
  return false;
4630
4727
  if (this.wsHandshakeRecoveryInFlight)
@@ -7104,14 +7201,43 @@ export class JsBaoClient extends Observable {
7104
7201
  name: config.name,
7105
7202
  mode: config.mode,
7106
7203
  waitlistEnabled: config.waitlistEnabled,
7107
- hasOAuth: config.hasOAuth,
7204
+ googleAvailable: googleWebClientAvailable(config),
7108
7205
  hasPasskey: config.hasPasskey,
7109
- magicLinkEnabled: config.magicLinkEnabled,
7206
+ emailSignInEnabled: config.emailSignInEnabled,
7207
+ magicLinkEnabled: config.emailSignInEnabled,
7110
7208
  };
7111
7209
  }
7210
+ // ============ Email Sign-In (#2884) ============
7211
+ /**
7212
+ * Request one sign-in email. It carries a 6-digit code and — when a redirect
7213
+ * target is available AND the app allow-lists it — a sign-in link, so the
7214
+ * user can finish on the device they started on or on whichever device has
7215
+ * their mail. Nothing selects a method up front.
7216
+ *
7217
+ * Finish with `otpVerify(email, code)` for the code, or `magicLinkVerify(token)`
7218
+ * for the link. Consuming either one retires both: one email, one session.
7219
+ *
7220
+ * @param email - The email address to sign in
7221
+ * @param options - Optional configuration
7222
+ * @param options.redirectUri - Where the link should land. Defaults to the
7223
+ * client's `oauthRedirectUri`. With neither, the email carries the code
7224
+ * alone rather than failing.
7225
+ * @group Authentication
7226
+ */
7227
+ async emailSignInRequest(email, options) {
7228
+ const redirectUri = options?.redirectUri || this.options.oauthRedirectUri;
7229
+ return this.auth.emailSignInRequest({
7230
+ email,
7231
+ ...(redirectUri ? { redirectUri } : {}),
7232
+ });
7233
+ }
7112
7234
  // ============ Magic Link Methods ============
7113
7235
  /**
7114
7236
  * Request a magic link email for passwordless authentication.
7237
+ *
7238
+ * @deprecated Use {@link emailSignInRequest}. This is an alias of it — the
7239
+ * server issues through the same path and sends the same email, which now
7240
+ * also carries a 6-digit code.
7115
7241
  * @param email - The email address to send the magic link to
7116
7242
  * @param options - Optional configuration
7117
7243
  * @param options.redirectUri - Override the default OAuth redirect URI for the magic link callback
@@ -7142,6 +7268,9 @@ export class JsBaoClient extends Observable {
7142
7268
  /**
7143
7269
  * Request a one-time password (OTP) code to be sent to the specified email.
7144
7270
  * The code can be verified using `otpVerify()`.
7271
+ *
7272
+ * @deprecated Use {@link emailSignInRequest}. This is an alias of it that
7273
+ * carries no redirect target, so the email it sends is code-only.
7145
7274
  * @param email - The email address to send the OTP code to
7146
7275
  * @group Authentication
7147
7276
  */
@@ -7247,8 +7376,8 @@ export class JsBaoClient extends Observable {
7247
7376
  if (!this.token &&
7248
7377
  this.options.autoOAuth &&
7249
7378
  this.options.oauthRedirectUri) {
7250
- const hasOAuth = await this.auth.checkOAuthAvailable();
7251
- if (hasOAuth) {
7379
+ const googleAvailable = await this.auth.checkOAuthAvailable();
7380
+ if (googleAvailable) {
7252
7381
  await this.auth.startOAuthFlow({
7253
7382
  redirectUri: this.options.oauthRedirectUri,
7254
7383
  });