@ovok/core 0.4.43 → 0.4.44

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
@@ -446,8 +446,9 @@ The backend allows three kinds of read:
446
446
  Signed out, a read rejects with `SignInRequiredError` (`isSignInRequiredError(error)`) before
447
447
  any request is sent, unless it passes `allowSignedOut: true` and a `tenantCode`. `getLegalPage`,
448
448
  `getTranslations` and their hooks turn `allowSignedOut` on by default; pass it yourself only for
449
- another collection the backend serves without a key or session. A request that returns 401 can
450
- clear the session storage and call `onUnauthenticated`. A hook that rendered before sign-in needs
449
+ another collection the backend serves without a key or session. A 401 from an ordinary request can
450
+ clear the session storage and call `onUnauthenticated`; a 401 from password sign-in is instead a
451
+ `LoginError` and leaves the active account untouched. A hook that rendered before sign-in needs
451
452
  `reload()` once the session exists.
452
453
 
453
454
  `locale` takes an app language or a CMS locale (`en`, `de`, `fr`, `es`). `toCmsLocale` maps
@@ -541,6 +542,14 @@ if ("nextStep" in result && result.nextStep === "mfa") {
541
542
  }
542
543
  ```
543
544
 
545
+ ### Password sign-in errors
546
+
547
+ Password sign-in failures throw `LoginError`. A 401 has `code: "invalid_credentials"` and the
548
+ server's message; it does not refresh tokens, clear an existing account, or call
549
+ `onUnauthenticated`. A 429 has `code: "rate_limited"` and includes `retryAfterMs` when the server
550
+ provides a retry delay. Show the error or a wait message to the user, then honor that delay before
551
+ another attempt. The backend controls the lockout threshold and duration.
552
+
544
553
  ## Patient registration invitations
545
554
 
546
555
  When a project invites people with a business email address to join as clinicians,
@@ -3,8 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createSessionFetch = createSessionFetch;
4
4
  /* eslint-disable max-lines -- one refresh state machine; splitting it would scatter its shared session state. */
5
5
  const core_1 = require("@medplum/core");
6
+ const ovok_error_1 = require("../errors/ovok-error");
6
7
  const account_storage_1 = require("./account-storage");
7
8
  const session_http_1 = require("./session-http");
9
+ const LoginError_1 = require("./types/LoginError");
10
+ /** These POSTs authenticate a new session; a failed attempt must not invalidate an existing one. */
11
+ const isSignInRequest = (url, options, normalizedBaseUrl) => {
12
+ var _a;
13
+ if (((_a = options === null || options === void 0 ? void 0 : options.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== "POST") {
14
+ return false;
15
+ }
16
+ const baseUrl = new URL(normalizedBaseUrl);
17
+ const requestUrl = new URL(url, normalizedBaseUrl);
18
+ if (requestUrl.origin !== baseUrl.origin) {
19
+ return false;
20
+ }
21
+ const path = (requestUrl.pathname.startsWith(baseUrl.pathname)
22
+ ? requestUrl.pathname.slice(baseUrl.pathname.length)
23
+ : requestUrl.pathname.slice(1)).replace(/^\/+|\/+$/g, "");
24
+ return [
25
+ /^auth\/login$/,
26
+ /^v2\/auth\/login$/,
27
+ /^auth\/tenant\/(?:Patient|Practitioner)\/login\/(?:start|mfa|token)$/,
28
+ /^v1\/saas\/login(?:\/token)?$/,
29
+ ].some((route) => route.test(path));
30
+ };
8
31
  /**
9
32
  * The client's `fetch`: request timeouts, token-refresh retries within the access token's
10
33
  * lifetime, and tracking why a session ended, which `onUnauthenticated` reports.
@@ -28,6 +51,7 @@ function createSessionFetch({ config, storage, fetchImplementation, normalizedBa
28
51
  };
29
52
  const fetchOnce = async (url, options) => {
30
53
  const response = await (0, session_http_1.fetchWithTimeout)(fetchImplementation, url, options, config.requestTimeoutMs);
54
+ const signInRequest = isSignInRequest(url, options, normalizedBaseUrl);
31
55
  const retry = !response.ok && !isTokenRefresh(url, options)
32
56
  ? { milliseconds: await (0, session_http_1.retryAfterMs)(response) }
33
57
  : undefined;
@@ -71,12 +95,18 @@ function createSessionFetch({ config, storage, fetchImplementation, normalizedBa
71
95
  status: response.status,
72
96
  };
73
97
  }
74
- else if (response.status === 401) {
98
+ else if (response.status === 401 && !signInRequest) {
75
99
  unauthenticatedReason = { type: "request-unauthorized", status: 401 };
76
100
  }
77
- return isTokenRefresh(url, options)
101
+ const responseWithMetadata = isTokenRefresh(url, options)
78
102
  ? response
79
- : (0, session_http_1.withHttpErrorMetadata)(response, retry);
103
+ : await (0, session_http_1.withHttpErrorMetadata)(response, retry);
104
+ if (signInRequest && (response.status === 401 || response.status === 429)) {
105
+ const status = response.status;
106
+ const details = (0, ovok_error_1.readOvokError)({ outcome: await responseWithMetadata.clone().json() });
107
+ throw new LoginError_1.LoginError(status === 401 ? "invalid_credentials" : "rate_limited", details.message, [], details.requestId, status, details.retryAfterMs);
108
+ }
109
+ return responseWithMetadata;
80
110
  };
81
111
  const fetchWithRefreshRetry = async (url, options) => {
82
112
  var _a, _b, _c, _d, _e;
@@ -1,12 +1,13 @@
1
1
  import type { LoginResponse } from "./LoginResponse";
2
2
  import type { StartLoginResponse } from "./StartLoginResponse";
3
- /** A login selection error with fields apps can branch on without parsing prose. */
3
+ /** A typed sign-in error with fields apps can branch on without parsing prose. */
4
4
  export declare class LoginError extends Error {
5
- readonly code: "profile_not_found" | "tenant_code_required" | "tenant_code_not_found";
5
+ readonly code: "profile_not_found" | "tenant_code_required" | "tenant_code_not_found" | "invalid_credentials" | "rate_limited";
6
6
  readonly profiles: LoginProfile[];
7
- readonly status = 400;
7
+ readonly status: 400 | 401 | 429;
8
8
  readonly requestId: string | undefined;
9
- constructor(code: "profile_not_found" | "tenant_code_required" | "tenant_code_not_found", message: string, profiles?: LoginProfile[], requestId?: string);
9
+ readonly retryAfterMs: number | undefined;
10
+ constructor(code: "profile_not_found" | "tenant_code_required" | "tenant_code_not_found" | "invalid_credentials" | "rate_limited", message: string, profiles?: LoginProfile[], requestId?: string, status?: 400 | 401 | 429, retryAfterMs?: number);
10
11
  }
11
12
  export type LoginProfile = NonNullable<StartLoginResponse["profiles"]>[number];
12
13
  export type TenantLoginStart = {
@@ -1,15 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LoginError = void 0;
4
- /** A login selection error with fields apps can branch on without parsing prose. */
4
+ /** A typed sign-in error with fields apps can branch on without parsing prose. */
5
5
  class LoginError extends Error {
6
- constructor(code, message, profiles = [], requestId) {
6
+ constructor(code, message, profiles = [], requestId, status = 400, retryAfterMs) {
7
7
  super(message);
8
8
  this.code = code;
9
9
  this.profiles = profiles;
10
- this.status = 400;
11
10
  this.name = "LoginError";
11
+ this.status = status;
12
12
  this.requestId = requestId;
13
+ this.retryAfterMs = retryAfterMs;
13
14
  }
14
15
  }
15
16
  exports.LoginError = LoginError;
@@ -43,6 +43,7 @@ const errors_1 = require("./auth/errors");
43
43
  const authMethods = __importStar(require("./auth/methods"));
44
44
  const session_fetch_1 = require("./auth/session-fetch");
45
45
  const session_http_1 = require("./auth/session-http");
46
+ const LoginError_1 = require("./auth/types/LoginError");
46
47
  const botMethods = __importStar(require("./bot/methods"));
47
48
  const changeHistoryMethods = __importStar(require("./change-history/methods"));
48
49
  const aiMethods = __importStar(require("./chat/ai/methods"));
@@ -125,7 +126,7 @@ class OvokClient extends core_1.MedplumClient {
125
126
  this.refreshState = session.refreshState;
126
127
  this.socialLoginClientId = (_e = config.socialLoginClientId) !== null && _e !== void 0 ? _e : "";
127
128
  this.addEventListener("profileRefreshed", () => this.syncSavedAccountProfile());
128
- this.bindMethods(authMethods);
129
+ this.bindMethods(authMethods, (error) => error instanceof LoginError_1.LoginError ? error : this.mapError(error));
129
130
  this.bindMethods(observationMethods);
130
131
  this.bindMethods(questionnaireResponseMethods);
131
132
  this.bindMethods(aiMethods);
@@ -48,7 +48,7 @@ exports.OVOK_CORE_REQUIREMENTS = {
48
48
  description: "The FHIR surface a server must provide for the @ovok/core client library's own methods to function.",
49
49
  software: {
50
50
  name: "@ovok/core",
51
- version: "0.4.43",
51
+ version: "0.4.44",
52
52
  },
53
53
  fhirVersion: "4.0.1",
54
54
  format: ["json"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovok/core",
3
- "version": "0.4.43",
3
+ "version": "0.4.44",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",