@poly-x/core 0.1.0-alpha.1 → 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
@@ -193,7 +193,16 @@ function resolveConfig(input) {
193
193
  * against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
194
194
  * shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
195
195
  */
196
- 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
+ ]);
197
206
  const EXPIRED_CODES = /* @__PURE__ */ new Set([
198
207
  "SESSION_EXPIRED",
199
208
  "REFRESH_TOKEN_REQUIRED",
@@ -708,8 +717,41 @@ const ENDPOINTS = {
708
717
  authorize: "/v1/idp/authorize",
709
718
  token: "/v1/idp/token",
710
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
+ */
711
726
  logout: "/v1/idp/logout",
712
- 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"
713
755
  };
714
756
  //#endregion
715
757
  //#region src/auth/outcomes.ts
@@ -747,6 +789,33 @@ function parseAuthorizeOutcome(status, body) {
747
789
  if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
748
790
  return { type: "login_required" };
749
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
+ }
750
819
  //#endregion
751
820
  //#region src/auth/normalize.ts
752
821
  const FALLBACK_TTL_MS = 5 * 6e4;
@@ -772,7 +841,35 @@ function toTokenSet(accessToken, refreshToken, now) {
772
841
  function asRecord(value) {
773
842
  return value && typeof value === "object" ? value : {};
774
843
  }
775
- /** `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
+ */
776
873
  function normalizeExchange(body, now) {
777
874
  const data = asRecord(asRecord(body).data);
778
875
  const user = asRecord(data.user);
@@ -782,7 +879,10 @@ function normalizeExchange(body, now) {
782
879
  userId,
783
880
  organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
784
881
  roles: [],
785
- permissions: []
882
+ permissions: [],
883
+ displayName: deriveDisplayName(user),
884
+ email: displayString(user.email, MAX_DISPLAY_NAME),
885
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
786
886
  };
787
887
  return {
788
888
  tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
@@ -859,6 +959,86 @@ var AuthClient = class {
859
959
  if (response.status >= 500) throw this.toError(response);
860
960
  return parseAuthorizeOutcome(response.status, response.body);
861
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
+ }
862
1042
  async exchangeCode(params) {
863
1043
  const response = await this.transport.request({
864
1044
  method: "POST",
@@ -874,6 +1054,27 @@ var AuthClient = class {
874
1054
  if (response.status !== 200) throw this.toError(response);
875
1055
  return normalizeExchange(response.body, this.clock.now());
876
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
+ }
877
1078
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
878
1079
  createRefresher() {
879
1080
  return async (current) => {
@@ -886,6 +1087,11 @@ var AuthClient = class {
886
1087
  return normalizeRefresh(response.body, current, this.clock.now());
887
1088
  };
888
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
+ */
889
1095
  async logout(scope = "device") {
890
1096
  const response = await this.transport.request({
891
1097
  method: "POST",
@@ -894,6 +1100,22 @@ var AuthClient = class {
894
1100
  });
895
1101
  if (response.status >= 400) throw this.toError(response);
896
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
+ }
897
1119
  async resolveOrg(email) {
898
1120
  const response = await this.transport.request({
899
1121
  method: "POST",
@@ -955,7 +1177,9 @@ exports.mapPlatformError = mapPlatformError;
955
1177
  exports.normalizeExchange = normalizeExchange;
956
1178
  exports.normalizeRefresh = normalizeRefresh;
957
1179
  exports.parseAuthorizeOutcome = parseAuthorizeOutcome;
1180
+ exports.parsePasswordOutcome = parsePasswordOutcome;
958
1181
  exports.parsePolyXKey = parsePolyXKey;
1182
+ exports.parseResetOutcome = parseResetOutcome;
959
1183
  exports.randomState = randomState;
960
1184
  exports.redactSensitive = redactSensitive;
961
1185
  exports.reduceSessionState = reduce;
package/dist/index.d.cts CHANGED
@@ -203,11 +203,24 @@ interface TokenSet {
203
203
  /** Epoch milliseconds when the access token expires, per the server. */
204
204
  accessTokenExpiresAt: number;
205
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
+ */
206
215
  interface SessionClaims {
207
216
  userId: string;
208
217
  organizationId?: string;
209
218
  roles: readonly string[];
210
219
  permissions: readonly string[];
220
+ /** Best available human label: full name → username → email. */
221
+ displayName?: string;
222
+ email?: string;
223
+ avatarUrl?: string;
211
224
  }
212
225
  interface Session {
213
226
  claims: SessionClaims;
@@ -390,8 +403,41 @@ declare const ENDPOINTS: {
390
403
  readonly authorize: "/v1/idp/authorize";
391
404
  readonly token: "/v1/idp/token";
392
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
+ */
393
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";
394
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";
395
441
  };
396
442
  //#endregion
397
443
  //#region src/auth/outcomes.d.ts
@@ -421,10 +467,50 @@ type AuthorizeOutcome = {
421
467
  type: "login_required";
422
468
  };
423
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;
424
503
  //#endregion
425
504
  //#region src/auth/normalize.d.ts
426
505
  declare function decodeJwtExp(token: string): number | null;
427
- /** `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
+ */
428
514
  declare function normalizeExchange(body: unknown, now: number): StoredSession;
429
515
  /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
430
516
  declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
@@ -472,6 +558,38 @@ interface ExchangeCodeParams {
472
558
  codeVerifier: string;
473
559
  redirectUri: string;
474
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
+ }
475
593
  declare class AuthClient {
476
594
  private readonly config;
477
595
  private readonly transport;
@@ -489,10 +607,63 @@ declare class AuthClient {
489
607
  buildSignInUrl(params: BuildAuthorizeUrlParams): string;
490
608
  /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
491
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>;
492
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>;
493
650
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
494
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
+ */
495
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>;
496
667
  resolveOrg(email: string): Promise<{
497
668
  resolved: boolean;
498
669
  organizationCode?: string;
@@ -507,4 +678,4 @@ declare class AuthClient {
507
678
  declare const PACKAGE_NAME = "@poly-x/core";
508
679
  declare const version = "0.0.0";
509
680
  //#endregion
510
- 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
@@ -203,11 +203,24 @@ interface TokenSet {
203
203
  /** Epoch milliseconds when the access token expires, per the server. */
204
204
  accessTokenExpiresAt: number;
205
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
+ */
206
215
  interface SessionClaims {
207
216
  userId: string;
208
217
  organizationId?: string;
209
218
  roles: readonly string[];
210
219
  permissions: readonly string[];
220
+ /** Best available human label: full name → username → email. */
221
+ displayName?: string;
222
+ email?: string;
223
+ avatarUrl?: string;
211
224
  }
212
225
  interface Session {
213
226
  claims: SessionClaims;
@@ -390,8 +403,41 @@ declare const ENDPOINTS: {
390
403
  readonly authorize: "/v1/idp/authorize";
391
404
  readonly token: "/v1/idp/token";
392
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
+ */
393
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";
394
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";
395
441
  };
396
442
  //#endregion
397
443
  //#region src/auth/outcomes.d.ts
@@ -421,10 +467,50 @@ type AuthorizeOutcome = {
421
467
  type: "login_required";
422
468
  };
423
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;
424
503
  //#endregion
425
504
  //#region src/auth/normalize.d.ts
426
505
  declare function decodeJwtExp(token: string): number | null;
427
- /** `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
+ */
428
514
  declare function normalizeExchange(body: unknown, now: number): StoredSession;
429
515
  /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
430
516
  declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
@@ -472,6 +558,38 @@ interface ExchangeCodeParams {
472
558
  codeVerifier: string;
473
559
  redirectUri: string;
474
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
+ }
475
593
  declare class AuthClient {
476
594
  private readonly config;
477
595
  private readonly transport;
@@ -489,10 +607,63 @@ declare class AuthClient {
489
607
  buildSignInUrl(params: BuildAuthorizeUrlParams): string;
490
608
  /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
491
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>;
492
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>;
493
650
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
494
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
+ */
495
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>;
496
667
  resolveOrg(email: string): Promise<{
497
668
  resolved: boolean;
498
669
  organizationCode?: string;
@@ -507,4 +678,4 @@ declare class AuthClient {
507
678
  declare const PACKAGE_NAME = "@poly-x/core";
508
679
  declare const version = "0.0.0";
509
680
  //#endregion
510
- 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
@@ -192,7 +192,16 @@ function resolveConfig(input) {
192
192
  * against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
193
193
  * shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
194
194
  */
195
- 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
+ ]);
196
205
  const EXPIRED_CODES = /* @__PURE__ */ new Set([
197
206
  "SESSION_EXPIRED",
198
207
  "REFRESH_TOKEN_REQUIRED",
@@ -707,8 +716,41 @@ const ENDPOINTS = {
707
716
  authorize: "/v1/idp/authorize",
708
717
  token: "/v1/idp/token",
709
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
+ */
710
725
  logout: "/v1/idp/logout",
711
- 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"
712
754
  };
713
755
  //#endregion
714
756
  //#region src/auth/outcomes.ts
@@ -746,6 +788,33 @@ function parseAuthorizeOutcome(status, body) {
746
788
  if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
747
789
  return { type: "login_required" };
748
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
+ }
749
818
  //#endregion
750
819
  //#region src/auth/normalize.ts
751
820
  const FALLBACK_TTL_MS = 5 * 6e4;
@@ -771,7 +840,35 @@ function toTokenSet(accessToken, refreshToken, now) {
771
840
  function asRecord(value) {
772
841
  return value && typeof value === "object" ? value : {};
773
842
  }
774
- /** `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
+ */
775
872
  function normalizeExchange(body, now) {
776
873
  const data = asRecord(asRecord(body).data);
777
874
  const user = asRecord(data.user);
@@ -781,7 +878,10 @@ function normalizeExchange(body, now) {
781
878
  userId,
782
879
  organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
783
880
  roles: [],
784
- permissions: []
881
+ permissions: [],
882
+ displayName: deriveDisplayName(user),
883
+ email: displayString(user.email, MAX_DISPLAY_NAME),
884
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
785
885
  };
786
886
  return {
787
887
  tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
@@ -858,6 +958,86 @@ var AuthClient = class {
858
958
  if (response.status >= 500) throw this.toError(response);
859
959
  return parseAuthorizeOutcome(response.status, response.body);
860
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
+ }
861
1041
  async exchangeCode(params) {
862
1042
  const response = await this.transport.request({
863
1043
  method: "POST",
@@ -873,6 +1053,27 @@ var AuthClient = class {
873
1053
  if (response.status !== 200) throw this.toError(response);
874
1054
  return normalizeExchange(response.body, this.clock.now());
875
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
+ }
876
1077
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
877
1078
  createRefresher() {
878
1079
  return async (current) => {
@@ -885,6 +1086,11 @@ var AuthClient = class {
885
1086
  return normalizeRefresh(response.body, current, this.clock.now());
886
1087
  };
887
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
+ */
888
1094
  async logout(scope = "device") {
889
1095
  const response = await this.transport.request({
890
1096
  method: "POST",
@@ -893,6 +1099,22 @@ var AuthClient = class {
893
1099
  });
894
1100
  if (response.status >= 400) throw this.toError(response);
895
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
+ }
896
1118
  async resolveOrg(email) {
897
1119
  const response = await this.transport.request({
898
1120
  method: "POST",
@@ -922,4 +1144,4 @@ var AuthClient = class {
922
1144
  const PACKAGE_NAME = "@poly-x/core";
923
1145
  const version = "0.0.0";
924
1146
  //#endregion
925
- 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.1",
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",