@poly-x/core 0.1.0-alpha.0 → 0.1.0-alpha.10

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/dist/index.cjs CHANGED
@@ -167,11 +167,21 @@ function resolveConfig(input) {
167
167
  }
168
168
  baseUrl = normalizeBaseUrl(input.baseUrl);
169
169
  } else baseUrl = `https://${normalizeBaseUrl(key.host)}`;
170
+ let signInUrl;
171
+ if (input.signInUrl !== void 0) {
172
+ try {
173
+ new URL(input.signInUrl);
174
+ } catch {
175
+ throw new PolyXConfigError("The signInUrl is not a valid URL.", { code: "SIGN_IN_URL_INVALID" });
176
+ }
177
+ signInUrl = normalizeBaseUrl(input.signInUrl);
178
+ }
170
179
  return {
171
180
  key,
172
181
  kind: key.kind,
173
182
  env: key.env,
174
183
  baseUrl,
184
+ signInUrl,
175
185
  context
176
186
  };
177
187
  }
@@ -183,7 +193,16 @@ function resolveConfig(input) {
183
193
  * against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
184
194
  * shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
185
195
  */
186
- const REVOKED_CODES = /* @__PURE__ */ new Set(["REFRESH_TOKEN_REUSE", "SESSION_REVOKED"]);
196
+ /**
197
+ * Terminal: the session is gone and no retry brings it back. The platform revokes every session
198
+ * for a user on permission change, suspension, and password reset — `SESSION_REVOKED` for the
199
+ * revocation itself, `SESSION_MISMATCH` when the presented token no longer matches its session.
200
+ */
201
+ const REVOKED_CODES = /* @__PURE__ */ new Set([
202
+ "REFRESH_TOKEN_REUSE",
203
+ "SESSION_REVOKED",
204
+ "SESSION_MISMATCH"
205
+ ]);
187
206
  const EXPIRED_CODES = /* @__PURE__ */ new Set([
188
207
  "SESSION_EXPIRED",
189
208
  "REFRESH_TOKEN_REQUIRED",
@@ -698,8 +717,41 @@ const ENDPOINTS = {
698
717
  authorize: "/v1/idp/authorize",
699
718
  token: "/v1/idp/token",
700
719
  refresh: "/v1/auth/refresh-token",
720
+ /**
721
+ * Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
722
+ * so it only revokes when called from a context that carries it — a top-level hop.
723
+ * A server-to-server call reaches it without the cookie and revokes nothing (it still
724
+ * answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
725
+ */
701
726
  logout: "/v1/idp/logout",
702
- orgResolve: "/v1/idp/org-resolve"
727
+ /**
728
+ * Bearer-authenticated revocation: the access token identifies the session, so these
729
+ * work from any context that holds one — notably the BFF, where the token is sealed in
730
+ * the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
731
+ * session; `logoutAll` revokes every session for the user.
732
+ */
733
+ authLogout: "/v1/auth/logout",
734
+ authLogoutAll: "/v1/auth/logout-all",
735
+ orgResolve: "/v1/idp/org-resolve",
736
+ /**
737
+ * Completes the admin temp-password flow: the user signed in with a temporary
738
+ * password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
739
+ * the real one. Only succeeds while the account is flagged `mustChangePassword`.
740
+ */
741
+ setPassword: "/v1/auth/set-password",
742
+ /**
743
+ * Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
744
+ * — always a uniform 202, the token is emailed to the mailbox, never returned.
745
+ * `resetPassword` redeems that opaque token single-use and sets the new password.
746
+ */
747
+ forgotPassword: "/v1/idp/forgot-password",
748
+ resetPassword: "/v1/idp/reset-password",
749
+ /**
750
+ * Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
751
+ * (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
752
+ * never blocks login; the SDK falls back to its built-in defaults on any failure.
753
+ */
754
+ branding: "/v1/idp/branding"
703
755
  };
704
756
  //#endregion
705
757
  //#region src/auth/outcomes.ts
@@ -737,6 +789,33 @@ function parseAuthorizeOutcome(status, body) {
737
789
  if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
738
790
  return { type: "login_required" };
739
791
  }
792
+ function parsePasswordOutcome(status, body) {
793
+ const b = asRecord$1(body);
794
+ const statusField = typeof b.status === "string" ? b.status : void 0;
795
+ if (statusField === "authorized" && typeof b.code === "string") return {
796
+ type: "authorized",
797
+ code: b.code,
798
+ state: typeof b.state === "string" ? b.state : "",
799
+ redirectUri: typeof b.redirectUri === "string" ? b.redirectUri : void 0
800
+ };
801
+ if (statusField === "select_tenant") return {
802
+ type: "select_tenant",
803
+ state: typeof b.state === "string" ? b.state : "",
804
+ tenants: parseTenants(b.tenants)
805
+ };
806
+ if (status === 403 && b.userPasswordSet === true && typeof b.userId === "string") return {
807
+ type: "password_change_required",
808
+ userId: b.userId,
809
+ ticket: typeof b.ticket === "string" ? b.ticket : void 0
810
+ };
811
+ if (status === 403) return { type: "no_access" };
812
+ return { type: "invalid_credentials" };
813
+ }
814
+ function parseResetOutcome(status, body) {
815
+ if (status === 200) return "ok";
816
+ if (asRecord$1(body).status === "weak_password") return "weak_password";
817
+ return "invalid_or_expired";
818
+ }
740
819
  //#endregion
741
820
  //#region src/auth/normalize.ts
742
821
  const FALLBACK_TTL_MS = 5 * 6e4;
@@ -762,7 +841,35 @@ function toTokenSet(accessToken, refreshToken, now) {
762
841
  function asRecord(value) {
763
842
  return value && typeof value === "object" ? value : {};
764
843
  }
765
- /** `POST /idp/token` → StoredSession. Claims mapping is minimal in v1 (userId + org); AUTHZ populates roles/permissions later. */
844
+ /**
845
+ * Display-claim caps (ADR-0006). These values are user-controlled and unbounded in the
846
+ * platform's schema, and they ride in a sealed ~4KB cookie whose failure mode is the browser
847
+ * silently dropping it. Cap at the boundary rather than trusting the input.
848
+ */
849
+ const MAX_DISPLAY_NAME = 128;
850
+ const MAX_AVATAR_URL = 512;
851
+ /** A non-blank string, trimmed and capped — otherwise undefined. The shim never guesses. */
852
+ function displayString(value, max) {
853
+ if (typeof value !== "string") return void 0;
854
+ const trimmed = value.trim();
855
+ if (trimmed.length === 0) return void 0;
856
+ return trimmed.slice(0, max);
857
+ }
858
+ /**
859
+ * The best human label the token response can give us: full name, else username, else email.
860
+ * Each part is checked independently so a user with only one of them never renders "undefined".
861
+ */
862
+ function deriveDisplayName(user) {
863
+ return displayString([displayString(user.firstName, MAX_DISPLAY_NAME), displayString(user.lastName, MAX_DISPLAY_NAME)].filter(Boolean).join(" "), MAX_DISPLAY_NAME) ?? displayString(user.username, MAX_DISPLAY_NAME) ?? displayString(user.email, MAX_DISPLAY_NAME);
864
+ }
865
+ /**
866
+ * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
867
+ * (ADR-0006); AUTHZ populates roles/permissions later.
868
+ *
869
+ * `organizationId` comes from the exchange's `tenantId`, never from the user document: a
870
+ * cross-membership login resolves a tenant that is deliberately *not* the user's home
871
+ * organization, and reading it off the user would silently sign them into the wrong one.
872
+ */
766
873
  function normalizeExchange(body, now) {
767
874
  const data = asRecord(asRecord(body).data);
768
875
  const user = asRecord(data.user);
@@ -772,7 +879,10 @@ function normalizeExchange(body, now) {
772
879
  userId,
773
880
  organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
774
881
  roles: [],
775
- permissions: []
882
+ permissions: [],
883
+ displayName: deriveDisplayName(user),
884
+ email: displayString(user.email, MAX_DISPLAY_NAME),
885
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
776
886
  };
777
887
  return {
778
888
  tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
@@ -815,10 +925,9 @@ var AuthClient = class {
815
925
  this.transport = options.transport;
816
926
  this.clock = options.clock ?? new SystemClock();
817
927
  }
818
- buildAuthorizeUrl(params) {
819
- const url = new URL(this.config.baseUrl + ENDPOINTS.authorize);
928
+ appendAuthorizeParams(url, params) {
820
929
  url.searchParams.set("response_type", "code");
821
- url.searchParams.set("client", this.config.key.raw);
930
+ url.searchParams.set("client_id", this.config.key.raw);
822
931
  url.searchParams.set("redirect_uri", params.redirectUri);
823
932
  url.searchParams.set("state", params.state);
824
933
  url.searchParams.set("code_challenge", params.challenge);
@@ -827,6 +936,20 @@ var AuthClient = class {
827
936
  if (params.prompt) url.searchParams.set("prompt", params.prompt);
828
937
  return url.toString();
829
938
  }
939
+ /** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
940
+ buildAuthorizeUrl(params) {
941
+ return this.appendAuthorizeParams(new URL(this.config.baseUrl + ENDPOINTS.authorize), params);
942
+ }
943
+ /**
944
+ * The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
945
+ * PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
946
+ * to `redirect_uri` with the code. Falls back to the API authorize URL when no
947
+ * `signInUrl` is configured (deployments where the API host serves the page).
948
+ */
949
+ buildSignInUrl(params) {
950
+ if (!this.config.signInUrl) return this.buildAuthorizeUrl(params);
951
+ return this.appendAuthorizeParams(new URL(this.config.signInUrl), params);
952
+ }
830
953
  /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
831
954
  async completeAuthorize(params) {
832
955
  const response = await this.transport.request({
@@ -836,6 +959,86 @@ var AuthClient = class {
836
959
  if (response.status >= 500) throw this.toError(response);
837
960
  return parseAuthorizeOutcome(response.status, response.body);
838
961
  }
962
+ /**
963
+ * The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
964
+ * email/password + PKCE. On `authorized` the caller exchanges the returned
965
+ * `code` (server-side, via the BFF) for a session — no redirect to a hosted
966
+ * login page. Creds live only for the duration of this request; the SDK never
967
+ * stores them. 5xx surfaces as a typed error; every other status is a typed
968
+ * outcome the UI branches on.
969
+ */
970
+ async authorizeWithPassword(params) {
971
+ const response = await this.transport.request({
972
+ method: "POST",
973
+ url: this.config.baseUrl + ENDPOINTS.authorize,
974
+ body: {
975
+ response_type: "code",
976
+ client_id: this.config.key.raw,
977
+ redirect_uri: params.redirectUri,
978
+ state: params.state,
979
+ code_challenge: params.challenge,
980
+ code_challenge_method: "S256",
981
+ email: params.email,
982
+ password: params.password,
983
+ ...params.organizationCode ? { organizationCode: params.organizationCode } : {},
984
+ ...params.tenantId ? { tenantId: params.tenantId } : {}
985
+ }
986
+ });
987
+ if (response.status >= 500) throw this.toError(response);
988
+ return parsePasswordOutcome(response.status, response.body);
989
+ }
990
+ /**
991
+ * Set the real password for a user the platform has flagged `mustChangePassword`
992
+ * (the admin temp-password flow). Call this only after `authorizeWithPassword`
993
+ * has returned `password_change_required` for that same user — that response IS
994
+ * the proof the caller knows the temporary password.
995
+ */
996
+ async setPassword(params) {
997
+ const response = await this.transport.request({
998
+ method: "PATCH",
999
+ url: this.config.baseUrl + ENDPOINTS.setPassword,
1000
+ body: {
1001
+ userId: params.userId,
1002
+ newPassword: params.newPassword,
1003
+ confirmPassword: params.confirmPassword,
1004
+ ...params.ticket ? { ticket: params.ticket } : {}
1005
+ }
1006
+ });
1007
+ if (response.status !== 200) throw this.toError(response);
1008
+ }
1009
+ /**
1010
+ * Start a server-mediated self-service reset (F040). POSTs the email + this app's
1011
+ * publishable key; the backend resolves the account, mints a single-use token, and
1012
+ * emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
1013
+ * a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
1014
+ */
1015
+ async requestPasswordReset(params) {
1016
+ const response = await this.transport.request({
1017
+ method: "POST",
1018
+ url: this.config.baseUrl + ENDPOINTS.forgotPassword,
1019
+ body: {
1020
+ email: params.email,
1021
+ client_id: this.config.key.raw
1022
+ }
1023
+ });
1024
+ if (response.status >= 400) throw this.toError(response);
1025
+ }
1026
+ /**
1027
+ * Redeem a reset token (the opaque value from the emailed link) single-use and set
1028
+ * the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
1029
+ */
1030
+ async resetPassword(params) {
1031
+ const response = await this.transport.request({
1032
+ method: "POST",
1033
+ url: this.config.baseUrl + ENDPOINTS.resetPassword,
1034
+ body: {
1035
+ token: params.token,
1036
+ newPassword: params.newPassword
1037
+ }
1038
+ });
1039
+ if (response.status >= 500) throw this.toError(response);
1040
+ return parseResetOutcome(response.status, response.body);
1041
+ }
839
1042
  async exchangeCode(params) {
840
1043
  const response = await this.transport.request({
841
1044
  method: "POST",
@@ -845,12 +1048,33 @@ var AuthClient = class {
845
1048
  code: params.code,
846
1049
  code_verifier: params.codeVerifier,
847
1050
  redirect_uri: params.redirectUri,
848
- client: this.config.key.raw
1051
+ client_id: this.config.key.raw
849
1052
  }
850
1053
  });
851
1054
  if (response.status !== 200) throw this.toError(response);
852
1055
  return normalizeExchange(response.body, this.clock.now());
853
1056
  }
1057
+ /**
1058
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
1059
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
1060
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
1061
+ */
1062
+ async fetchBranding(params = {}) {
1063
+ try {
1064
+ const url = new URL(this.config.baseUrl + ENDPOINTS.branding);
1065
+ url.searchParams.set("client_id", this.config.key.raw);
1066
+ if (params.organizationCode) url.searchParams.set("organizationCode", params.organizationCode);
1067
+ const response = await this.transport.request({
1068
+ method: "GET",
1069
+ url: url.toString()
1070
+ });
1071
+ if (response.status !== 200) return {};
1072
+ const tokens = response.body?.tokens;
1073
+ return tokens && typeof tokens === "object" ? tokens : {};
1074
+ } catch {
1075
+ return {};
1076
+ }
1077
+ }
854
1078
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
855
1079
  createRefresher() {
856
1080
  return async (current) => {
@@ -863,6 +1087,11 @@ var AuthClient = class {
863
1087
  return normalizeRefresh(response.body, current, this.clock.now());
864
1088
  };
865
1089
  }
1090
+ /**
1091
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
1092
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
1093
+ * still answers 200; use `revokeSession` there.
1094
+ */
866
1095
  async logout(scope = "device") {
867
1096
  const response = await this.transport.request({
868
1097
  method: "POST",
@@ -871,6 +1100,22 @@ var AuthClient = class {
871
1100
  });
872
1101
  if (response.status >= 400) throw this.toError(response);
873
1102
  }
1103
+ /**
1104
+ * Revoke a session using the access token as proof — the only logout that works
1105
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
1106
+ * `everywhere` ends every session for the user (FR-SSO-5).
1107
+ *
1108
+ * The token goes in the Authorization header and nowhere else: repeating it in the
1109
+ * body would only widen where it can leak.
1110
+ */
1111
+ async revokeSession(accessToken, scope = "device") {
1112
+ const response = await this.transport.request({
1113
+ method: "POST",
1114
+ url: this.config.baseUrl + (scope === "everywhere" ? ENDPOINTS.authLogoutAll : ENDPOINTS.authLogout),
1115
+ headers: { Authorization: `Bearer ${accessToken}` }
1116
+ });
1117
+ if (response.status >= 400) throw this.toError(response);
1118
+ }
874
1119
  async resolveOrg(email) {
875
1120
  const response = await this.transport.request({
876
1121
  method: "POST",
@@ -932,7 +1177,9 @@ exports.mapPlatformError = mapPlatformError;
932
1177
  exports.normalizeExchange = normalizeExchange;
933
1178
  exports.normalizeRefresh = normalizeRefresh;
934
1179
  exports.parseAuthorizeOutcome = parseAuthorizeOutcome;
1180
+ exports.parsePasswordOutcome = parsePasswordOutcome;
935
1181
  exports.parsePolyXKey = parsePolyXKey;
1182
+ exports.parseResetOutcome = parseResetOutcome;
936
1183
  exports.randomState = randomState;
937
1184
  exports.redactSensitive = redactSensitive;
938
1185
  exports.reduceSessionState = reduce;
package/dist/index.d.cts CHANGED
@@ -27,6 +27,14 @@ interface ResolveConfigInput {
27
27
  key: string;
28
28
  /** Wins over the key-embedded host. */
29
29
  baseUrl?: string;
30
+ /**
31
+ * URL of the HOSTED sign-in page (the account portal that renders the login
32
+ * form) — distinct from the API `baseUrl` the key encodes. The interactive
33
+ * sign-in popup/redirect targets this; the silent warm-hop probe still hits the
34
+ * API. When unset, sign-in falls back to the API's `/v1/idp/authorize` (for
35
+ * deployments where the API host also serves the login page).
36
+ */
37
+ signInUrl?: string;
30
38
  /** Defaults to runtime auto-detection. */
31
39
  context?: RuntimeContext;
32
40
  }
@@ -35,6 +43,8 @@ interface ResolvedConfig {
35
43
  kind: KeyKind;
36
44
  env: KeyEnv;
37
45
  baseUrl: string;
46
+ /** Hosted sign-in page URL (see ResolveConfigInput.signInUrl); undefined = use the API. */
47
+ signInUrl?: string;
38
48
  context: RuntimeContext;
39
49
  }
40
50
  declare function resolveConfig(input: ResolveConfigInput): ResolvedConfig;
@@ -193,11 +203,24 @@ interface TokenSet {
193
203
  /** Epoch milliseconds when the access token expires, per the server. */
194
204
  accessTokenExpiresAt: number;
195
205
  }
206
+ /**
207
+ * The verified identity a session carries. Per ADR-0006 this holds identity plus a **bounded
208
+ * display subset** — enough for a signed-in user surface — and never bulk authorization data
209
+ * (module graphs, team lists). Claims are sealed into a ~4KB cookie that fails by being
210
+ * silently dropped, so a field earns its place by being bounded and small, not by being useful.
211
+ *
212
+ * The display fields are optional: they are derived defensively from whatever the platform
213
+ * returns, and their absence is normal, not an error.
214
+ */
196
215
  interface SessionClaims {
197
216
  userId: string;
198
217
  organizationId?: string;
199
218
  roles: readonly string[];
200
219
  permissions: readonly string[];
220
+ /** Best available human label: full name → username → email. */
221
+ displayName?: string;
222
+ email?: string;
223
+ avatarUrl?: string;
201
224
  }
202
225
  interface Session {
203
226
  claims: SessionClaims;
@@ -380,8 +403,41 @@ declare const ENDPOINTS: {
380
403
  readonly authorize: "/v1/idp/authorize";
381
404
  readonly token: "/v1/idp/token";
382
405
  readonly refresh: "/v1/auth/refresh-token";
406
+ /**
407
+ * Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
408
+ * so it only revokes when called from a context that carries it — a top-level hop.
409
+ * A server-to-server call reaches it without the cookie and revokes nothing (it still
410
+ * answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
411
+ */
383
412
  readonly logout: "/v1/idp/logout";
413
+ /**
414
+ * Bearer-authenticated revocation: the access token identifies the session, so these
415
+ * work from any context that holds one — notably the BFF, where the token is sealed in
416
+ * the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
417
+ * session; `logoutAll` revokes every session for the user.
418
+ */
419
+ readonly authLogout: "/v1/auth/logout";
420
+ readonly authLogoutAll: "/v1/auth/logout-all";
384
421
  readonly orgResolve: "/v1/idp/org-resolve";
422
+ /**
423
+ * Completes the admin temp-password flow: the user signed in with a temporary
424
+ * password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
425
+ * the real one. Only succeeds while the account is flagged `mustChangePassword`.
426
+ */
427
+ readonly setPassword: "/v1/auth/set-password";
428
+ /**
429
+ * Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
430
+ * — always a uniform 202, the token is emailed to the mailbox, never returned.
431
+ * `resetPassword` redeems that opaque token single-use and sets the new password.
432
+ */
433
+ readonly forgotPassword: "/v1/idp/forgot-password";
434
+ readonly resetPassword: "/v1/idp/reset-password";
435
+ /**
436
+ * Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
437
+ * (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
438
+ * never blocks login; the SDK falls back to its built-in defaults on any failure.
439
+ */
440
+ readonly branding: "/v1/idp/branding";
385
441
  };
386
442
  //#endregion
387
443
  //#region src/auth/outcomes.d.ts
@@ -411,10 +467,50 @@ type AuthorizeOutcome = {
411
467
  type: "login_required";
412
468
  };
413
469
  declare function parseAuthorizeOutcome(status: number, body: unknown): AuthorizeOutcome;
470
+ /**
471
+ * Outcome of a PASSWORD authorize (`POST /idp/authorize` with credentials) — the
472
+ * embedded `<SignIn/>` flow. Distinguishes invalid-credentials and the
473
+ * must-set-password step from the membership outcomes (the GET warm-hop parser
474
+ * collapses those into login_required).
475
+ */
476
+ type PasswordAuthOutcome = {
477
+ type: "authorized";
478
+ code: string;
479
+ state: string;
480
+ redirectUri?: string;
481
+ } | {
482
+ type: "select_tenant";
483
+ state: string;
484
+ tenants: TenantOption[];
485
+ } | {
486
+ type: "invalid_credentials";
487
+ } | {
488
+ type: "password_change_required";
489
+ userId: string;
490
+ ticket?: string;
491
+ } | {
492
+ type: "no_access";
493
+ };
494
+ declare function parsePasswordOutcome(status: number, body: unknown): PasswordAuthOutcome;
495
+ /**
496
+ * The outcome of redeeming a reset token (`POST /idp/reset-password`, F040):
497
+ * `ok` on success, `weak_password` when it fails the platform floor, and a
498
+ * uniform `invalid_or_expired` for an unknown/used/expired token (the backend
499
+ * does not distinguish unknown from expired to the caller).
500
+ */
501
+ type PasswordResetOutcome = "ok" | "weak_password" | "invalid_or_expired";
502
+ declare function parseResetOutcome(status: number, body: unknown): PasswordResetOutcome;
414
503
  //#endregion
415
504
  //#region src/auth/normalize.d.ts
416
505
  declare function decodeJwtExp(token: string): number | null;
417
- /** `POST /idp/token` → StoredSession. Claims mapping is minimal in v1 (userId + org); AUTHZ populates roles/permissions later. */
506
+ /**
507
+ * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
508
+ * (ADR-0006); AUTHZ populates roles/permissions later.
509
+ *
510
+ * `organizationId` comes from the exchange's `tenantId`, never from the user document: a
511
+ * cross-membership login resolves a tenant that is deliberately *not* the user's home
512
+ * organization, and reading it off the user would silently sign them into the wrong one.
513
+ */
418
514
  declare function normalizeExchange(body: unknown, now: number): StoredSession;
419
515
  /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
420
516
  declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
@@ -462,18 +558,112 @@ interface ExchangeCodeParams {
462
558
  codeVerifier: string;
463
559
  redirectUri: string;
464
560
  }
561
+ interface SetPasswordParams {
562
+ userId: string;
563
+ newPassword: string;
564
+ confirmPassword: string;
565
+ /**
566
+ * Single-use proof-of-possession ticket from the `password_change_required`
567
+ * outcome. When present the platform trusts the ticket over `userId`; when the
568
+ * backend enforces tickets, it's required. Omit against older backends.
569
+ */
570
+ ticket?: string;
571
+ }
572
+ interface AuthorizeWithPasswordParams {
573
+ email: string;
574
+ password: string;
575
+ redirectUri: string;
576
+ state: string;
577
+ challenge: string;
578
+ organizationCode?: string;
579
+ /** Set on the re-attempt after the user picks a workspace (select_tenant). */
580
+ tenantId?: string;
581
+ }
582
+ /** The bounded login-page branding token set (FR-BRND-1). Every field is optional. */
583
+ interface BrandingTokens {
584
+ displayName?: string;
585
+ logoUrl?: string;
586
+ primaryColor?: string;
587
+ backgroundColor?: string;
588
+ }
589
+ interface FetchBrandingParams {
590
+ /** Refine app-level branding with an org's when the org is already known. */
591
+ organizationCode?: string;
592
+ }
465
593
  declare class AuthClient {
466
594
  private readonly config;
467
595
  private readonly transport;
468
596
  private readonly clock;
469
597
  constructor(options: AuthClientOptions);
598
+ private appendAuthorizeParams;
599
+ /** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
470
600
  buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string;
601
+ /**
602
+ * The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
603
+ * PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
604
+ * to `redirect_uri` with the code. Falls back to the API authorize URL when no
605
+ * `signInUrl` is configured (deployments where the API host serves the page).
606
+ */
607
+ buildSignInUrl(params: BuildAuthorizeUrlParams): string;
471
608
  /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
472
609
  completeAuthorize(params: BuildAuthorizeUrlParams): Promise<AuthorizeOutcome>;
610
+ /**
611
+ * The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
612
+ * email/password + PKCE. On `authorized` the caller exchanges the returned
613
+ * `code` (server-side, via the BFF) for a session — no redirect to a hosted
614
+ * login page. Creds live only for the duration of this request; the SDK never
615
+ * stores them. 5xx surfaces as a typed error; every other status is a typed
616
+ * outcome the UI branches on.
617
+ */
618
+ authorizeWithPassword(params: AuthorizeWithPasswordParams): Promise<PasswordAuthOutcome>;
619
+ /**
620
+ * Set the real password for a user the platform has flagged `mustChangePassword`
621
+ * (the admin temp-password flow). Call this only after `authorizeWithPassword`
622
+ * has returned `password_change_required` for that same user — that response IS
623
+ * the proof the caller knows the temporary password.
624
+ */
625
+ setPassword(params: SetPasswordParams): Promise<void>;
626
+ /**
627
+ * Start a server-mediated self-service reset (F040). POSTs the email + this app's
628
+ * publishable key; the backend resolves the account, mints a single-use token, and
629
+ * emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
630
+ * a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
631
+ */
632
+ requestPasswordReset(params: {
633
+ email: string;
634
+ }): Promise<void>;
635
+ /**
636
+ * Redeem a reset token (the opaque value from the emailed link) single-use and set
637
+ * the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
638
+ */
639
+ resetPassword(params: {
640
+ token: string;
641
+ newPassword: string;
642
+ }): Promise<PasswordResetOutcome>;
473
643
  exchangeCode(params: ExchangeCodeParams): Promise<StoredSession>;
644
+ /**
645
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
646
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
647
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
648
+ */
649
+ fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
474
650
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
475
651
  createRefresher(): RefreshTokens;
652
+ /**
653
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
654
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
655
+ * still answers 200; use `revokeSession` there.
656
+ */
476
657
  logout(scope?: "device" | "everywhere"): Promise<void>;
658
+ /**
659
+ * Revoke a session using the access token as proof — the only logout that works
660
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
661
+ * `everywhere` ends every session for the user (FR-SSO-5).
662
+ *
663
+ * The token goes in the Authorization header and nowhere else: repeating it in the
664
+ * body would only widen where it can leak.
665
+ */
666
+ revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
477
667
  resolveOrg(email: string): Promise<{
478
668
  resolved: boolean;
479
669
  organizationCode?: string;
@@ -488,4 +678,4 @@ declare class AuthClient {
488
678
  declare const PACKAGE_NAME = "@poly-x/core";
489
679
  declare const version = "0.0.0";
490
680
  //#endregion
491
- export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePolyXKey, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
681
+ export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type AuthorizeWithPasswordParams, type BrandingTokens, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, type FetchBrandingParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PasswordAuthOutcome, type PasswordResetOutcome, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SetPasswordParams, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
package/dist/index.d.mts CHANGED
@@ -27,6 +27,14 @@ interface ResolveConfigInput {
27
27
  key: string;
28
28
  /** Wins over the key-embedded host. */
29
29
  baseUrl?: string;
30
+ /**
31
+ * URL of the HOSTED sign-in page (the account portal that renders the login
32
+ * form) — distinct from the API `baseUrl` the key encodes. The interactive
33
+ * sign-in popup/redirect targets this; the silent warm-hop probe still hits the
34
+ * API. When unset, sign-in falls back to the API's `/v1/idp/authorize` (for
35
+ * deployments where the API host also serves the login page).
36
+ */
37
+ signInUrl?: string;
30
38
  /** Defaults to runtime auto-detection. */
31
39
  context?: RuntimeContext;
32
40
  }
@@ -35,6 +43,8 @@ interface ResolvedConfig {
35
43
  kind: KeyKind;
36
44
  env: KeyEnv;
37
45
  baseUrl: string;
46
+ /** Hosted sign-in page URL (see ResolveConfigInput.signInUrl); undefined = use the API. */
47
+ signInUrl?: string;
38
48
  context: RuntimeContext;
39
49
  }
40
50
  declare function resolveConfig(input: ResolveConfigInput): ResolvedConfig;
@@ -193,11 +203,24 @@ interface TokenSet {
193
203
  /** Epoch milliseconds when the access token expires, per the server. */
194
204
  accessTokenExpiresAt: number;
195
205
  }
206
+ /**
207
+ * The verified identity a session carries. Per ADR-0006 this holds identity plus a **bounded
208
+ * display subset** — enough for a signed-in user surface — and never bulk authorization data
209
+ * (module graphs, team lists). Claims are sealed into a ~4KB cookie that fails by being
210
+ * silently dropped, so a field earns its place by being bounded and small, not by being useful.
211
+ *
212
+ * The display fields are optional: they are derived defensively from whatever the platform
213
+ * returns, and their absence is normal, not an error.
214
+ */
196
215
  interface SessionClaims {
197
216
  userId: string;
198
217
  organizationId?: string;
199
218
  roles: readonly string[];
200
219
  permissions: readonly string[];
220
+ /** Best available human label: full name → username → email. */
221
+ displayName?: string;
222
+ email?: string;
223
+ avatarUrl?: string;
201
224
  }
202
225
  interface Session {
203
226
  claims: SessionClaims;
@@ -380,8 +403,41 @@ declare const ENDPOINTS: {
380
403
  readonly authorize: "/v1/idp/authorize";
381
404
  readonly token: "/v1/idp/token";
382
405
  readonly refresh: "/v1/auth/refresh-token";
406
+ /**
407
+ * Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
408
+ * so it only revokes when called from a context that carries it — a top-level hop.
409
+ * A server-to-server call reaches it without the cookie and revokes nothing (it still
410
+ * answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
411
+ */
383
412
  readonly logout: "/v1/idp/logout";
413
+ /**
414
+ * Bearer-authenticated revocation: the access token identifies the session, so these
415
+ * work from any context that holds one — notably the BFF, where the token is sealed in
416
+ * the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
417
+ * session; `logoutAll` revokes every session for the user.
418
+ */
419
+ readonly authLogout: "/v1/auth/logout";
420
+ readonly authLogoutAll: "/v1/auth/logout-all";
384
421
  readonly orgResolve: "/v1/idp/org-resolve";
422
+ /**
423
+ * Completes the admin temp-password flow: the user signed in with a temporary
424
+ * password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
425
+ * the real one. Only succeeds while the account is flagged `mustChangePassword`.
426
+ */
427
+ readonly setPassword: "/v1/auth/set-password";
428
+ /**
429
+ * Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
430
+ * — always a uniform 202, the token is emailed to the mailbox, never returned.
431
+ * `resetPassword` redeems that opaque token single-use and sets the new password.
432
+ */
433
+ readonly forgotPassword: "/v1/idp/forgot-password";
434
+ readonly resetPassword: "/v1/idp/reset-password";
435
+ /**
436
+ * Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
437
+ * (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
438
+ * never blocks login; the SDK falls back to its built-in defaults on any failure.
439
+ */
440
+ readonly branding: "/v1/idp/branding";
385
441
  };
386
442
  //#endregion
387
443
  //#region src/auth/outcomes.d.ts
@@ -411,10 +467,50 @@ type AuthorizeOutcome = {
411
467
  type: "login_required";
412
468
  };
413
469
  declare function parseAuthorizeOutcome(status: number, body: unknown): AuthorizeOutcome;
470
+ /**
471
+ * Outcome of a PASSWORD authorize (`POST /idp/authorize` with credentials) — the
472
+ * embedded `<SignIn/>` flow. Distinguishes invalid-credentials and the
473
+ * must-set-password step from the membership outcomes (the GET warm-hop parser
474
+ * collapses those into login_required).
475
+ */
476
+ type PasswordAuthOutcome = {
477
+ type: "authorized";
478
+ code: string;
479
+ state: string;
480
+ redirectUri?: string;
481
+ } | {
482
+ type: "select_tenant";
483
+ state: string;
484
+ tenants: TenantOption[];
485
+ } | {
486
+ type: "invalid_credentials";
487
+ } | {
488
+ type: "password_change_required";
489
+ userId: string;
490
+ ticket?: string;
491
+ } | {
492
+ type: "no_access";
493
+ };
494
+ declare function parsePasswordOutcome(status: number, body: unknown): PasswordAuthOutcome;
495
+ /**
496
+ * The outcome of redeeming a reset token (`POST /idp/reset-password`, F040):
497
+ * `ok` on success, `weak_password` when it fails the platform floor, and a
498
+ * uniform `invalid_or_expired` for an unknown/used/expired token (the backend
499
+ * does not distinguish unknown from expired to the caller).
500
+ */
501
+ type PasswordResetOutcome = "ok" | "weak_password" | "invalid_or_expired";
502
+ declare function parseResetOutcome(status: number, body: unknown): PasswordResetOutcome;
414
503
  //#endregion
415
504
  //#region src/auth/normalize.d.ts
416
505
  declare function decodeJwtExp(token: string): number | null;
417
- /** `POST /idp/token` → StoredSession. Claims mapping is minimal in v1 (userId + org); AUTHZ populates roles/permissions later. */
506
+ /**
507
+ * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
508
+ * (ADR-0006); AUTHZ populates roles/permissions later.
509
+ *
510
+ * `organizationId` comes from the exchange's `tenantId`, never from the user document: a
511
+ * cross-membership login resolves a tenant that is deliberately *not* the user's home
512
+ * organization, and reading it off the user would silently sign them into the wrong one.
513
+ */
418
514
  declare function normalizeExchange(body: unknown, now: number): StoredSession;
419
515
  /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
420
516
  declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
@@ -462,18 +558,112 @@ interface ExchangeCodeParams {
462
558
  codeVerifier: string;
463
559
  redirectUri: string;
464
560
  }
561
+ interface SetPasswordParams {
562
+ userId: string;
563
+ newPassword: string;
564
+ confirmPassword: string;
565
+ /**
566
+ * Single-use proof-of-possession ticket from the `password_change_required`
567
+ * outcome. When present the platform trusts the ticket over `userId`; when the
568
+ * backend enforces tickets, it's required. Omit against older backends.
569
+ */
570
+ ticket?: string;
571
+ }
572
+ interface AuthorizeWithPasswordParams {
573
+ email: string;
574
+ password: string;
575
+ redirectUri: string;
576
+ state: string;
577
+ challenge: string;
578
+ organizationCode?: string;
579
+ /** Set on the re-attempt after the user picks a workspace (select_tenant). */
580
+ tenantId?: string;
581
+ }
582
+ /** The bounded login-page branding token set (FR-BRND-1). Every field is optional. */
583
+ interface BrandingTokens {
584
+ displayName?: string;
585
+ logoUrl?: string;
586
+ primaryColor?: string;
587
+ backgroundColor?: string;
588
+ }
589
+ interface FetchBrandingParams {
590
+ /** Refine app-level branding with an org's when the org is already known. */
591
+ organizationCode?: string;
592
+ }
465
593
  declare class AuthClient {
466
594
  private readonly config;
467
595
  private readonly transport;
468
596
  private readonly clock;
469
597
  constructor(options: AuthClientOptions);
598
+ private appendAuthorizeParams;
599
+ /** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
470
600
  buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string;
601
+ /**
602
+ * The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
603
+ * PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
604
+ * to `redirect_uri` with the code. Falls back to the API authorize URL when no
605
+ * `signInUrl` is configured (deployments where the API host serves the page).
606
+ */
607
+ buildSignInUrl(params: BuildAuthorizeUrlParams): string;
471
608
  /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
472
609
  completeAuthorize(params: BuildAuthorizeUrlParams): Promise<AuthorizeOutcome>;
610
+ /**
611
+ * The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
612
+ * email/password + PKCE. On `authorized` the caller exchanges the returned
613
+ * `code` (server-side, via the BFF) for a session — no redirect to a hosted
614
+ * login page. Creds live only for the duration of this request; the SDK never
615
+ * stores them. 5xx surfaces as a typed error; every other status is a typed
616
+ * outcome the UI branches on.
617
+ */
618
+ authorizeWithPassword(params: AuthorizeWithPasswordParams): Promise<PasswordAuthOutcome>;
619
+ /**
620
+ * Set the real password for a user the platform has flagged `mustChangePassword`
621
+ * (the admin temp-password flow). Call this only after `authorizeWithPassword`
622
+ * has returned `password_change_required` for that same user — that response IS
623
+ * the proof the caller knows the temporary password.
624
+ */
625
+ setPassword(params: SetPasswordParams): Promise<void>;
626
+ /**
627
+ * Start a server-mediated self-service reset (F040). POSTs the email + this app's
628
+ * publishable key; the backend resolves the account, mints a single-use token, and
629
+ * emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
630
+ * a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
631
+ */
632
+ requestPasswordReset(params: {
633
+ email: string;
634
+ }): Promise<void>;
635
+ /**
636
+ * Redeem a reset token (the opaque value from the emailed link) single-use and set
637
+ * the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
638
+ */
639
+ resetPassword(params: {
640
+ token: string;
641
+ newPassword: string;
642
+ }): Promise<PasswordResetOutcome>;
473
643
  exchangeCode(params: ExchangeCodeParams): Promise<StoredSession>;
644
+ /**
645
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
646
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
647
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
648
+ */
649
+ fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
474
650
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
475
651
  createRefresher(): RefreshTokens;
652
+ /**
653
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
654
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
655
+ * still answers 200; use `revokeSession` there.
656
+ */
476
657
  logout(scope?: "device" | "everywhere"): Promise<void>;
658
+ /**
659
+ * Revoke a session using the access token as proof — the only logout that works
660
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
661
+ * `everywhere` ends every session for the user (FR-SSO-5).
662
+ *
663
+ * The token goes in the Authorization header and nowhere else: repeating it in the
664
+ * body would only widen where it can leak.
665
+ */
666
+ revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
477
667
  resolveOrg(email: string): Promise<{
478
668
  resolved: boolean;
479
669
  organizationCode?: string;
@@ -488,4 +678,4 @@ declare class AuthClient {
488
678
  declare const PACKAGE_NAME = "@poly-x/core";
489
679
  declare const version = "0.0.0";
490
680
  //#endregion
491
- export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePolyXKey, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
681
+ export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type AuthorizeWithPasswordParams, type BrandingTokens, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, type FetchBrandingParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PasswordAuthOutcome, type PasswordResetOutcome, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SetPasswordParams, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
package/dist/index.mjs CHANGED
@@ -166,11 +166,21 @@ function resolveConfig(input) {
166
166
  }
167
167
  baseUrl = normalizeBaseUrl(input.baseUrl);
168
168
  } else baseUrl = `https://${normalizeBaseUrl(key.host)}`;
169
+ let signInUrl;
170
+ if (input.signInUrl !== void 0) {
171
+ try {
172
+ new URL(input.signInUrl);
173
+ } catch {
174
+ throw new PolyXConfigError("The signInUrl is not a valid URL.", { code: "SIGN_IN_URL_INVALID" });
175
+ }
176
+ signInUrl = normalizeBaseUrl(input.signInUrl);
177
+ }
169
178
  return {
170
179
  key,
171
180
  kind: key.kind,
172
181
  env: key.env,
173
182
  baseUrl,
183
+ signInUrl,
174
184
  context
175
185
  };
176
186
  }
@@ -182,7 +192,16 @@ function resolveConfig(input) {
182
192
  * against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
183
193
  * shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
184
194
  */
185
- const REVOKED_CODES = /* @__PURE__ */ new Set(["REFRESH_TOKEN_REUSE", "SESSION_REVOKED"]);
195
+ /**
196
+ * Terminal: the session is gone and no retry brings it back. The platform revokes every session
197
+ * for a user on permission change, suspension, and password reset — `SESSION_REVOKED` for the
198
+ * revocation itself, `SESSION_MISMATCH` when the presented token no longer matches its session.
199
+ */
200
+ const REVOKED_CODES = /* @__PURE__ */ new Set([
201
+ "REFRESH_TOKEN_REUSE",
202
+ "SESSION_REVOKED",
203
+ "SESSION_MISMATCH"
204
+ ]);
186
205
  const EXPIRED_CODES = /* @__PURE__ */ new Set([
187
206
  "SESSION_EXPIRED",
188
207
  "REFRESH_TOKEN_REQUIRED",
@@ -697,8 +716,41 @@ const ENDPOINTS = {
697
716
  authorize: "/v1/idp/authorize",
698
717
  token: "/v1/idp/token",
699
718
  refresh: "/v1/auth/refresh-token",
719
+ /**
720
+ * Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
721
+ * so it only revokes when called from a context that carries it — a top-level hop.
722
+ * A server-to-server call reaches it without the cookie and revokes nothing (it still
723
+ * answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
724
+ */
700
725
  logout: "/v1/idp/logout",
701
- orgResolve: "/v1/idp/org-resolve"
726
+ /**
727
+ * Bearer-authenticated revocation: the access token identifies the session, so these
728
+ * work from any context that holds one — notably the BFF, where the token is sealed in
729
+ * the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
730
+ * session; `logoutAll` revokes every session for the user.
731
+ */
732
+ authLogout: "/v1/auth/logout",
733
+ authLogoutAll: "/v1/auth/logout-all",
734
+ orgResolve: "/v1/idp/org-resolve",
735
+ /**
736
+ * Completes the admin temp-password flow: the user signed in with a temporary
737
+ * password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
738
+ * the real one. Only succeeds while the account is flagged `mustChangePassword`.
739
+ */
740
+ setPassword: "/v1/auth/set-password",
741
+ /**
742
+ * Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
743
+ * — always a uniform 202, the token is emailed to the mailbox, never returned.
744
+ * `resetPassword` redeems that opaque token single-use and sets the new password.
745
+ */
746
+ forgotPassword: "/v1/idp/forgot-password",
747
+ resetPassword: "/v1/idp/reset-password",
748
+ /**
749
+ * Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
750
+ * (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
751
+ * never blocks login; the SDK falls back to its built-in defaults on any failure.
752
+ */
753
+ branding: "/v1/idp/branding"
702
754
  };
703
755
  //#endregion
704
756
  //#region src/auth/outcomes.ts
@@ -736,6 +788,33 @@ function parseAuthorizeOutcome(status, body) {
736
788
  if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
737
789
  return { type: "login_required" };
738
790
  }
791
+ function parsePasswordOutcome(status, body) {
792
+ const b = asRecord$1(body);
793
+ const statusField = typeof b.status === "string" ? b.status : void 0;
794
+ if (statusField === "authorized" && typeof b.code === "string") return {
795
+ type: "authorized",
796
+ code: b.code,
797
+ state: typeof b.state === "string" ? b.state : "",
798
+ redirectUri: typeof b.redirectUri === "string" ? b.redirectUri : void 0
799
+ };
800
+ if (statusField === "select_tenant") return {
801
+ type: "select_tenant",
802
+ state: typeof b.state === "string" ? b.state : "",
803
+ tenants: parseTenants(b.tenants)
804
+ };
805
+ if (status === 403 && b.userPasswordSet === true && typeof b.userId === "string") return {
806
+ type: "password_change_required",
807
+ userId: b.userId,
808
+ ticket: typeof b.ticket === "string" ? b.ticket : void 0
809
+ };
810
+ if (status === 403) return { type: "no_access" };
811
+ return { type: "invalid_credentials" };
812
+ }
813
+ function parseResetOutcome(status, body) {
814
+ if (status === 200) return "ok";
815
+ if (asRecord$1(body).status === "weak_password") return "weak_password";
816
+ return "invalid_or_expired";
817
+ }
739
818
  //#endregion
740
819
  //#region src/auth/normalize.ts
741
820
  const FALLBACK_TTL_MS = 5 * 6e4;
@@ -761,7 +840,35 @@ function toTokenSet(accessToken, refreshToken, now) {
761
840
  function asRecord(value) {
762
841
  return value && typeof value === "object" ? value : {};
763
842
  }
764
- /** `POST /idp/token` → StoredSession. Claims mapping is minimal in v1 (userId + org); AUTHZ populates roles/permissions later. */
843
+ /**
844
+ * Display-claim caps (ADR-0006). These values are user-controlled and unbounded in the
845
+ * platform's schema, and they ride in a sealed ~4KB cookie whose failure mode is the browser
846
+ * silently dropping it. Cap at the boundary rather than trusting the input.
847
+ */
848
+ const MAX_DISPLAY_NAME = 128;
849
+ const MAX_AVATAR_URL = 512;
850
+ /** A non-blank string, trimmed and capped — otherwise undefined. The shim never guesses. */
851
+ function displayString(value, max) {
852
+ if (typeof value !== "string") return void 0;
853
+ const trimmed = value.trim();
854
+ if (trimmed.length === 0) return void 0;
855
+ return trimmed.slice(0, max);
856
+ }
857
+ /**
858
+ * The best human label the token response can give us: full name, else username, else email.
859
+ * Each part is checked independently so a user with only one of them never renders "undefined".
860
+ */
861
+ function deriveDisplayName(user) {
862
+ return displayString([displayString(user.firstName, MAX_DISPLAY_NAME), displayString(user.lastName, MAX_DISPLAY_NAME)].filter(Boolean).join(" "), MAX_DISPLAY_NAME) ?? displayString(user.username, MAX_DISPLAY_NAME) ?? displayString(user.email, MAX_DISPLAY_NAME);
863
+ }
864
+ /**
865
+ * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
866
+ * (ADR-0006); AUTHZ populates roles/permissions later.
867
+ *
868
+ * `organizationId` comes from the exchange's `tenantId`, never from the user document: a
869
+ * cross-membership login resolves a tenant that is deliberately *not* the user's home
870
+ * organization, and reading it off the user would silently sign them into the wrong one.
871
+ */
765
872
  function normalizeExchange(body, now) {
766
873
  const data = asRecord(asRecord(body).data);
767
874
  const user = asRecord(data.user);
@@ -771,7 +878,10 @@ function normalizeExchange(body, now) {
771
878
  userId,
772
879
  organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
773
880
  roles: [],
774
- permissions: []
881
+ permissions: [],
882
+ displayName: deriveDisplayName(user),
883
+ email: displayString(user.email, MAX_DISPLAY_NAME),
884
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
775
885
  };
776
886
  return {
777
887
  tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
@@ -814,10 +924,9 @@ var AuthClient = class {
814
924
  this.transport = options.transport;
815
925
  this.clock = options.clock ?? new SystemClock();
816
926
  }
817
- buildAuthorizeUrl(params) {
818
- const url = new URL(this.config.baseUrl + ENDPOINTS.authorize);
927
+ appendAuthorizeParams(url, params) {
819
928
  url.searchParams.set("response_type", "code");
820
- url.searchParams.set("client", this.config.key.raw);
929
+ url.searchParams.set("client_id", this.config.key.raw);
821
930
  url.searchParams.set("redirect_uri", params.redirectUri);
822
931
  url.searchParams.set("state", params.state);
823
932
  url.searchParams.set("code_challenge", params.challenge);
@@ -826,6 +935,20 @@ var AuthClient = class {
826
935
  if (params.prompt) url.searchParams.set("prompt", params.prompt);
827
936
  return url.toString();
828
937
  }
938
+ /** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
939
+ buildAuthorizeUrl(params) {
940
+ return this.appendAuthorizeParams(new URL(this.config.baseUrl + ENDPOINTS.authorize), params);
941
+ }
942
+ /**
943
+ * The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
944
+ * PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
945
+ * to `redirect_uri` with the code. Falls back to the API authorize URL when no
946
+ * `signInUrl` is configured (deployments where the API host serves the page).
947
+ */
948
+ buildSignInUrl(params) {
949
+ if (!this.config.signInUrl) return this.buildAuthorizeUrl(params);
950
+ return this.appendAuthorizeParams(new URL(this.config.signInUrl), params);
951
+ }
829
952
  /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
830
953
  async completeAuthorize(params) {
831
954
  const response = await this.transport.request({
@@ -835,6 +958,86 @@ var AuthClient = class {
835
958
  if (response.status >= 500) throw this.toError(response);
836
959
  return parseAuthorizeOutcome(response.status, response.body);
837
960
  }
961
+ /**
962
+ * The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
963
+ * email/password + PKCE. On `authorized` the caller exchanges the returned
964
+ * `code` (server-side, via the BFF) for a session — no redirect to a hosted
965
+ * login page. Creds live only for the duration of this request; the SDK never
966
+ * stores them. 5xx surfaces as a typed error; every other status is a typed
967
+ * outcome the UI branches on.
968
+ */
969
+ async authorizeWithPassword(params) {
970
+ const response = await this.transport.request({
971
+ method: "POST",
972
+ url: this.config.baseUrl + ENDPOINTS.authorize,
973
+ body: {
974
+ response_type: "code",
975
+ client_id: this.config.key.raw,
976
+ redirect_uri: params.redirectUri,
977
+ state: params.state,
978
+ code_challenge: params.challenge,
979
+ code_challenge_method: "S256",
980
+ email: params.email,
981
+ password: params.password,
982
+ ...params.organizationCode ? { organizationCode: params.organizationCode } : {},
983
+ ...params.tenantId ? { tenantId: params.tenantId } : {}
984
+ }
985
+ });
986
+ if (response.status >= 500) throw this.toError(response);
987
+ return parsePasswordOutcome(response.status, response.body);
988
+ }
989
+ /**
990
+ * Set the real password for a user the platform has flagged `mustChangePassword`
991
+ * (the admin temp-password flow). Call this only after `authorizeWithPassword`
992
+ * has returned `password_change_required` for that same user — that response IS
993
+ * the proof the caller knows the temporary password.
994
+ */
995
+ async setPassword(params) {
996
+ const response = await this.transport.request({
997
+ method: "PATCH",
998
+ url: this.config.baseUrl + ENDPOINTS.setPassword,
999
+ body: {
1000
+ userId: params.userId,
1001
+ newPassword: params.newPassword,
1002
+ confirmPassword: params.confirmPassword,
1003
+ ...params.ticket ? { ticket: params.ticket } : {}
1004
+ }
1005
+ });
1006
+ if (response.status !== 200) throw this.toError(response);
1007
+ }
1008
+ /**
1009
+ * Start a server-mediated self-service reset (F040). POSTs the email + this app's
1010
+ * publishable key; the backend resolves the account, mints a single-use token, and
1011
+ * emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
1012
+ * a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
1013
+ */
1014
+ async requestPasswordReset(params) {
1015
+ const response = await this.transport.request({
1016
+ method: "POST",
1017
+ url: this.config.baseUrl + ENDPOINTS.forgotPassword,
1018
+ body: {
1019
+ email: params.email,
1020
+ client_id: this.config.key.raw
1021
+ }
1022
+ });
1023
+ if (response.status >= 400) throw this.toError(response);
1024
+ }
1025
+ /**
1026
+ * Redeem a reset token (the opaque value from the emailed link) single-use and set
1027
+ * the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
1028
+ */
1029
+ async resetPassword(params) {
1030
+ const response = await this.transport.request({
1031
+ method: "POST",
1032
+ url: this.config.baseUrl + ENDPOINTS.resetPassword,
1033
+ body: {
1034
+ token: params.token,
1035
+ newPassword: params.newPassword
1036
+ }
1037
+ });
1038
+ if (response.status >= 500) throw this.toError(response);
1039
+ return parseResetOutcome(response.status, response.body);
1040
+ }
838
1041
  async exchangeCode(params) {
839
1042
  const response = await this.transport.request({
840
1043
  method: "POST",
@@ -844,12 +1047,33 @@ var AuthClient = class {
844
1047
  code: params.code,
845
1048
  code_verifier: params.codeVerifier,
846
1049
  redirect_uri: params.redirectUri,
847
- client: this.config.key.raw
1050
+ client_id: this.config.key.raw
848
1051
  }
849
1052
  });
850
1053
  if (response.status !== 200) throw this.toError(response);
851
1054
  return normalizeExchange(response.body, this.clock.now());
852
1055
  }
1056
+ /**
1057
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
1058
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
1059
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
1060
+ */
1061
+ async fetchBranding(params = {}) {
1062
+ try {
1063
+ const url = new URL(this.config.baseUrl + ENDPOINTS.branding);
1064
+ url.searchParams.set("client_id", this.config.key.raw);
1065
+ if (params.organizationCode) url.searchParams.set("organizationCode", params.organizationCode);
1066
+ const response = await this.transport.request({
1067
+ method: "GET",
1068
+ url: url.toString()
1069
+ });
1070
+ if (response.status !== 200) return {};
1071
+ const tokens = response.body?.tokens;
1072
+ return tokens && typeof tokens === "object" ? tokens : {};
1073
+ } catch {
1074
+ return {};
1075
+ }
1076
+ }
853
1077
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
854
1078
  createRefresher() {
855
1079
  return async (current) => {
@@ -862,6 +1086,11 @@ var AuthClient = class {
862
1086
  return normalizeRefresh(response.body, current, this.clock.now());
863
1087
  };
864
1088
  }
1089
+ /**
1090
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
1091
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
1092
+ * still answers 200; use `revokeSession` there.
1093
+ */
865
1094
  async logout(scope = "device") {
866
1095
  const response = await this.transport.request({
867
1096
  method: "POST",
@@ -870,6 +1099,22 @@ var AuthClient = class {
870
1099
  });
871
1100
  if (response.status >= 400) throw this.toError(response);
872
1101
  }
1102
+ /**
1103
+ * Revoke a session using the access token as proof — the only logout that works
1104
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
1105
+ * `everywhere` ends every session for the user (FR-SSO-5).
1106
+ *
1107
+ * The token goes in the Authorization header and nowhere else: repeating it in the
1108
+ * body would only widen where it can leak.
1109
+ */
1110
+ async revokeSession(accessToken, scope = "device") {
1111
+ const response = await this.transport.request({
1112
+ method: "POST",
1113
+ url: this.config.baseUrl + (scope === "everywhere" ? ENDPOINTS.authLogoutAll : ENDPOINTS.authLogout),
1114
+ headers: { Authorization: `Bearer ${accessToken}` }
1115
+ });
1116
+ if (response.status >= 400) throw this.toError(response);
1117
+ }
873
1118
  async resolveOrg(email) {
874
1119
  const response = await this.transport.request({
875
1120
  method: "POST",
@@ -899,4 +1144,4 @@ var AuthClient = class {
899
1144
  const PACKAGE_NAME = "@poly-x/core";
900
1145
  const version = "0.0.0";
901
1146
  //#endregion
902
- export { AuthClient, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, FetchTransport, ForbiddenError, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, MockTransport, PACKAGE_NAME, PolyXConfigError, PolyXError, RateLimitedError, SessionEngine, SessionExpiredError, SessionRevokedError, SystemClock, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePolyXKey, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
1147
+ export { AuthClient, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, FetchTransport, ForbiddenError, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, MockTransport, PACKAGE_NAME, PolyXConfigError, PolyXError, RateLimitedError, SessionEngine, SessionExpiredError, SessionRevokedError, SystemClock, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/core",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.10",
4
4
  "description": "Framework-agnostic core of the PolyX SDK",
5
5
  "license": "MIT",
6
6
  "type": "module",