js-bao-wss-client 2.1.0-alpha.8 → 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?: {
@@ -1884,6 +1898,96 @@ export declare class JsBaoClient extends Observable<any> {
1884
1898
  background?: boolean;
1885
1899
  shouldRetain?: (docId: string) => boolean;
1886
1900
  }): Promise<void>;
1901
+ /**
1902
+ * Documents per page for the whole-scope walk. The server caps `limit` at
1903
+ * 100 (`parseListLimitParam`), so asking for more buys nothing.
1904
+ */
1905
+ private static readonly SCOPE_RECONCILE_PAGE_SIZE;
1906
+ /**
1907
+ * How many pages the walk follows before giving up. 100 pages × 100 rows
1908
+ * bounds it at 10k documents; a scope larger than that (or a server handing
1909
+ * back an endless cursor) is simply not reconciled, which is the safe
1910
+ * direction — nothing is deleted.
1911
+ */
1912
+ private static readonly SCOPE_RECONCILE_MAX_PAGES;
1913
+ /**
1914
+ * Shortest gap between two whole-scope reconciliations. A walk costs one
1915
+ * request per 100 documents, so a client that reconnects through a flaky
1916
+ * network must not pay for it on every socket.
1917
+ */
1918
+ private scopeReconcileMinIntervalMs;
1919
+ /** The walk currently running, so concurrent connects share one. */
1920
+ private scopeReconcileInFlight;
1921
+ /** When the last walk finished, completed or not. */
1922
+ private scopeReconcileLastRunAt;
1923
+ /**
1924
+ * Bumped whenever the signed-in user changes. Both the staleness window and
1925
+ * a walk in flight are about one user's scope, and say nothing about the
1926
+ * next user's.
1927
+ */
1928
+ private scopeReconcileGeneration;
1929
+ /**
1930
+ * Reconcile the local metadata cache against the user's whole document
1931
+ * scope, evicting the documents the server no longer has (#2852).
1932
+ *
1933
+ * Eviction used to happen only inside `documents.list()`, whose response is
1934
+ * reconciled `authoritative: true`. Both clients steer developers off that
1935
+ * method (#628) and onto `me.ownedDocuments` / `me.sharedDocuments`, which
1936
+ * are strict subsets of the cached scope and so stay merge-only — correct
1937
+ * for a subset, but it left an app that never calls the deprecated method
1938
+ * with no eviction path at all: metadata rows and multi-megabyte `yjs_docs`
1939
+ * snapshots of deleted documents accumulated for as long as it kept
1940
+ * launching.
1941
+ *
1942
+ * So this runs on its own schedule — once per connect, and at most once per
1943
+ * `scopeReconcileMinIntervalMs` — rather than as a side effect of a listing
1944
+ * call, which would put a full scope walk behind every list.
1945
+ *
1946
+ * The walk is what makes the result trustworthy. An unpaged `GET /documents`
1947
+ * returns a bare array silently truncated at dynamo-bao's 100-row
1948
+ * `defaultQueryLimit`, so a truncated listing and a complete one are the same
1949
+ * bytes; reading one as the whole scope would delete real local data for
1950
+ * anyone with more than 100 documents. The paged form returns
1951
+ * `{ items, hasMore, nextCursor }`, which is followed here to exhaustion, and
1952
+ * only the union of a completed walk evicts. Anything short of that — a
1953
+ * failed request, a server that ignores `limit` and answers with a bare
1954
+ * array, a page that claims more rows with no cursor to ask for them, a
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).
1959
+ */
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;
1990
+ private _runScopeReconcile;
1887
1991
  /** Update local metadata cache with server document data.
1888
1992
  * @param items - Array of server document records to merge into the local cache
1889
1993
  * @param options - Controls how the merge is performed
@@ -2615,12 +2719,49 @@ export declare class JsBaoClient extends Observable<any> {
2615
2719
  name: string;
2616
2720
  mode: "public" | "invite-only" | "domain";
2617
2721
  waitlistEnabled: boolean;
2618
- 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;
2619
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`. */
2620
2736
  magicLinkEnabled: boolean;
2621
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
+ }>;
2622
2759
  /**
2623
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.
2624
2765
  * @param email - The email address to send the magic link to
2625
2766
  * @param options - Optional configuration
2626
2767
  * @param options.redirectUri - Override the default OAuth redirect URI for the magic link callback
@@ -2654,6 +2795,9 @@ export declare class JsBaoClient extends Observable<any> {
2654
2795
  /**
2655
2796
  * Request a one-time password (OTP) code to be sent to the specified email.
2656
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.
2657
2801
  * @param email - The email address to send the OTP code to
2658
2802
  * @group Authentication
2659
2803
  */
@@ -2781,17 +2925,21 @@ export declare class JsBaoClient extends Observable<any> {
2781
2925
  mode: string;
2782
2926
  waitlistEnabled: boolean;
2783
2927
  googleOAuthEnabled: boolean;
2784
- googleClientId: string | null;
2785
- hasOAuth: boolean;
2786
- redirectUris: string[] | null;
2928
+ googleClients: GoogleClientsConfig;
2787
2929
  passkeyEnabled: boolean;
2788
- passkeyRpId: string | null;
2789
- passkeyRpName: string | null;
2790
2930
  passkeyRpConfig: Record<string, {
2791
2931
  name: string;
2792
2932
  }> | null;
2793
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`. */
2794
2941
  magicLinkEnabled: boolean;
2942
+ /** @deprecated (#2884) Always equal to `emailSignInEnabled`. */
2795
2943
  otpEnabled: boolean;
2796
2944
  hasApple: boolean;
2797
2945
  appleSignInEnabled: boolean;
@@ -2975,7 +3123,9 @@ export declare class JsBaoClient extends Observable<any> {
2975
3123
  ttlMs?: number;
2976
3124
  preserveOnSignOut?: boolean;
2977
3125
  }): void;
2978
- /** Create a new document, optionally local-only for offline-first creation.
3126
+ /** Create a new document. Writable locally immediately, with the server
3127
+ * commit racing in the background — unless `options.localOnly` is set, in
3128
+ * which case the document never syncs.
2979
3129
  * @param options - Document creation options
2980
3130
  * @group Documents */
2981
3131
  createDocument(options: CreateDocumentOptions): Promise<{