@poly-x/core 0.1.0-alpha.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,7 +717,21 @@ 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",
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",
712
735
  orgResolve: "/v1/idp/org-resolve",
713
736
  /**
714
737
  * Completes the admin temp-password flow: the user signed in with a temporary
@@ -722,7 +745,13 @@ const ENDPOINTS = {
722
745
  * `resetPassword` redeems that opaque token single-use and sets the new password.
723
746
  */
724
747
  forgotPassword: "/v1/idp/forgot-password",
725
- resetPassword: "/v1/idp/reset-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"
726
755
  };
727
756
  //#endregion
728
757
  //#region src/auth/outcomes.ts
@@ -812,7 +841,35 @@ function toTokenSet(accessToken, refreshToken, now) {
812
841
  function asRecord(value) {
813
842
  return value && typeof value === "object" ? value : {};
814
843
  }
815
- /** `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
+ */
816
873
  function normalizeExchange(body, now) {
817
874
  const data = asRecord(asRecord(body).data);
818
875
  const user = asRecord(data.user);
@@ -822,7 +879,10 @@ function normalizeExchange(body, now) {
822
879
  userId,
823
880
  organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
824
881
  roles: [],
825
- permissions: []
882
+ permissions: [],
883
+ displayName: deriveDisplayName(user),
884
+ email: displayString(user.email, MAX_DISPLAY_NAME),
885
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
826
886
  };
827
887
  return {
828
888
  tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
@@ -983,6 +1043,7 @@ var AuthClient = class {
983
1043
  const response = await this.transport.request({
984
1044
  method: "POST",
985
1045
  url: this.config.baseUrl + ENDPOINTS.token,
1046
+ ...params.forwardedHeaders ? { headers: params.forwardedHeaders } : {},
986
1047
  body: {
987
1048
  grant_type: "authorization_code",
988
1049
  code: params.code,
@@ -994,6 +1055,27 @@ var AuthClient = class {
994
1055
  if (response.status !== 200) throw this.toError(response);
995
1056
  return normalizeExchange(response.body, this.clock.now());
996
1057
  }
1058
+ /**
1059
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
1060
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
1061
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
1062
+ */
1063
+ async fetchBranding(params = {}) {
1064
+ try {
1065
+ const url = new URL(this.config.baseUrl + ENDPOINTS.branding);
1066
+ url.searchParams.set("client_id", this.config.key.raw);
1067
+ if (params.organizationCode) url.searchParams.set("organizationCode", params.organizationCode);
1068
+ const response = await this.transport.request({
1069
+ method: "GET",
1070
+ url: url.toString()
1071
+ });
1072
+ if (response.status !== 200) return {};
1073
+ const tokens = response.body?.tokens;
1074
+ return tokens && typeof tokens === "object" ? tokens : {};
1075
+ } catch {
1076
+ return {};
1077
+ }
1078
+ }
997
1079
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
998
1080
  createRefresher() {
999
1081
  return async (current) => {
@@ -1006,6 +1088,11 @@ var AuthClient = class {
1006
1088
  return normalizeRefresh(response.body, current, this.clock.now());
1007
1089
  };
1008
1090
  }
1091
+ /**
1092
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
1093
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
1094
+ * still answers 200; use `revokeSession` there.
1095
+ */
1009
1096
  async logout(scope = "device") {
1010
1097
  const response = await this.transport.request({
1011
1098
  method: "POST",
@@ -1014,6 +1101,22 @@ var AuthClient = class {
1014
1101
  });
1015
1102
  if (response.status >= 400) throw this.toError(response);
1016
1103
  }
1104
+ /**
1105
+ * Revoke a session using the access token as proof — the only logout that works
1106
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
1107
+ * `everywhere` ends every session for the user (FR-SSO-5).
1108
+ *
1109
+ * The token goes in the Authorization header and nowhere else: repeating it in the
1110
+ * body would only widen where it can leak.
1111
+ */
1112
+ async revokeSession(accessToken, scope = "device") {
1113
+ const response = await this.transport.request({
1114
+ method: "POST",
1115
+ url: this.config.baseUrl + (scope === "everywhere" ? ENDPOINTS.authLogoutAll : ENDPOINTS.authLogout),
1116
+ headers: { Authorization: `Bearer ${accessToken}` }
1117
+ });
1118
+ if (response.status >= 400) throw this.toError(response);
1119
+ }
1017
1120
  async resolveOrg(email) {
1018
1121
  const response = await this.transport.request({
1019
1122
  method: "POST",
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,7 +403,21 @@ 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";
395
422
  /**
396
423
  * Completes the admin temp-password flow: the user signed in with a temporary
@@ -405,6 +432,12 @@ declare const ENDPOINTS: {
405
432
  */
406
433
  readonly forgotPassword: "/v1/idp/forgot-password";
407
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";
408
441
  };
409
442
  //#endregion
410
443
  //#region src/auth/outcomes.d.ts
@@ -470,7 +503,14 @@ declare function parseResetOutcome(status: number, body: unknown): PasswordReset
470
503
  //#endregion
471
504
  //#region src/auth/normalize.d.ts
472
505
  declare function decodeJwtExp(token: string): number | null;
473
- /** `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
+ */
474
514
  declare function normalizeExchange(body: unknown, now: number): StoredSession;
475
515
  /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
476
516
  declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
@@ -517,6 +557,16 @@ interface ExchangeCodeParams {
517
557
  code: string;
518
558
  codeVerifier: string;
519
559
  redirectUri: string;
560
+ /**
561
+ * Browser context to attach to the token exchange (the call that creates the
562
+ * session). In a BFF deployment this request originates from Node, so without
563
+ * these the platform records the server's user-agent/IP ("node"/Unknown) instead
564
+ * of the end user's device. The BFF forwards a curated allowlist of the browser's
565
+ * request headers (user-agent, accept-language, the sec-ch-ua* client hints, and
566
+ * the observed client IP as x-forwarded-for). Optional and additive — omitting it
567
+ * preserves the prior behavior.
568
+ */
569
+ forwardedHeaders?: Record<string, string>;
520
570
  }
521
571
  interface SetPasswordParams {
522
572
  userId: string;
@@ -539,6 +589,17 @@ interface AuthorizeWithPasswordParams {
539
589
  /** Set on the re-attempt after the user picks a workspace (select_tenant). */
540
590
  tenantId?: string;
541
591
  }
592
+ /** The bounded login-page branding token set (FR-BRND-1). Every field is optional. */
593
+ interface BrandingTokens {
594
+ displayName?: string;
595
+ logoUrl?: string;
596
+ primaryColor?: string;
597
+ backgroundColor?: string;
598
+ }
599
+ interface FetchBrandingParams {
600
+ /** Refine app-level branding with an org's when the org is already known. */
601
+ organizationCode?: string;
602
+ }
542
603
  declare class AuthClient {
543
604
  private readonly config;
544
605
  private readonly transport;
@@ -590,9 +651,29 @@ declare class AuthClient {
590
651
  newPassword: string;
591
652
  }): Promise<PasswordResetOutcome>;
592
653
  exchangeCode(params: ExchangeCodeParams): Promise<StoredSession>;
654
+ /**
655
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
656
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
657
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
658
+ */
659
+ fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
593
660
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
594
661
  createRefresher(): RefreshTokens;
662
+ /**
663
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
664
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
665
+ * still answers 200; use `revokeSession` there.
666
+ */
595
667
  logout(scope?: "device" | "everywhere"): Promise<void>;
668
+ /**
669
+ * Revoke a session using the access token as proof — the only logout that works
670
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
671
+ * `everywhere` ends every session for the user (FR-SSO-5).
672
+ *
673
+ * The token goes in the Authorization header and nowhere else: repeating it in the
674
+ * body would only widen where it can leak.
675
+ */
676
+ revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
596
677
  resolveOrg(email: string): Promise<{
597
678
  resolved: boolean;
598
679
  organizationCode?: string;
@@ -607,4 +688,4 @@ declare class AuthClient {
607
688
  declare const PACKAGE_NAME = "@poly-x/core";
608
689
  declare const version = "0.0.0";
609
690
  //#endregion
610
- export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type AuthorizeWithPasswordParams, 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 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 };
691
+ 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,7 +403,21 @@ 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";
395
422
  /**
396
423
  * Completes the admin temp-password flow: the user signed in with a temporary
@@ -405,6 +432,12 @@ declare const ENDPOINTS: {
405
432
  */
406
433
  readonly forgotPassword: "/v1/idp/forgot-password";
407
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";
408
441
  };
409
442
  //#endregion
410
443
  //#region src/auth/outcomes.d.ts
@@ -470,7 +503,14 @@ declare function parseResetOutcome(status: number, body: unknown): PasswordReset
470
503
  //#endregion
471
504
  //#region src/auth/normalize.d.ts
472
505
  declare function decodeJwtExp(token: string): number | null;
473
- /** `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
+ */
474
514
  declare function normalizeExchange(body: unknown, now: number): StoredSession;
475
515
  /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
476
516
  declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
@@ -517,6 +557,16 @@ interface ExchangeCodeParams {
517
557
  code: string;
518
558
  codeVerifier: string;
519
559
  redirectUri: string;
560
+ /**
561
+ * Browser context to attach to the token exchange (the call that creates the
562
+ * session). In a BFF deployment this request originates from Node, so without
563
+ * these the platform records the server's user-agent/IP ("node"/Unknown) instead
564
+ * of the end user's device. The BFF forwards a curated allowlist of the browser's
565
+ * request headers (user-agent, accept-language, the sec-ch-ua* client hints, and
566
+ * the observed client IP as x-forwarded-for). Optional and additive — omitting it
567
+ * preserves the prior behavior.
568
+ */
569
+ forwardedHeaders?: Record<string, string>;
520
570
  }
521
571
  interface SetPasswordParams {
522
572
  userId: string;
@@ -539,6 +589,17 @@ interface AuthorizeWithPasswordParams {
539
589
  /** Set on the re-attempt after the user picks a workspace (select_tenant). */
540
590
  tenantId?: string;
541
591
  }
592
+ /** The bounded login-page branding token set (FR-BRND-1). Every field is optional. */
593
+ interface BrandingTokens {
594
+ displayName?: string;
595
+ logoUrl?: string;
596
+ primaryColor?: string;
597
+ backgroundColor?: string;
598
+ }
599
+ interface FetchBrandingParams {
600
+ /** Refine app-level branding with an org's when the org is already known. */
601
+ organizationCode?: string;
602
+ }
542
603
  declare class AuthClient {
543
604
  private readonly config;
544
605
  private readonly transport;
@@ -590,9 +651,29 @@ declare class AuthClient {
590
651
  newPassword: string;
591
652
  }): Promise<PasswordResetOutcome>;
592
653
  exchangeCode(params: ExchangeCodeParams): Promise<StoredSession>;
654
+ /**
655
+ * Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
656
+ * blocks the sign-in form: any non-200, malformed body, or network failure resolves
657
+ * to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
658
+ */
659
+ fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
593
660
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
594
661
  createRefresher(): RefreshTokens;
662
+ /**
663
+ * Ecosystem logout — only revokes when the caller carries the browser's ecosystem
664
+ * cookie (a top-level hop). From a server-to-server context it clears nothing and
665
+ * still answers 200; use `revokeSession` there.
666
+ */
595
667
  logout(scope?: "device" | "everywhere"): Promise<void>;
668
+ /**
669
+ * Revoke a session using the access token as proof — the only logout that works
670
+ * server-side, where no browser cookie reaches poly-auth. `device` ends this session;
671
+ * `everywhere` ends every session for the user (FR-SSO-5).
672
+ *
673
+ * The token goes in the Authorization header and nowhere else: repeating it in the
674
+ * body would only widen where it can leak.
675
+ */
676
+ revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
596
677
  resolveOrg(email: string): Promise<{
597
678
  resolved: boolean;
598
679
  organizationCode?: string;
@@ -607,4 +688,4 @@ declare class AuthClient {
607
688
  declare const PACKAGE_NAME = "@poly-x/core";
608
689
  declare const version = "0.0.0";
609
690
  //#endregion
610
- export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type AuthorizeWithPasswordParams, 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 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 };
691
+ 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,7 +716,21 @@ 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",
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",
711
734
  orgResolve: "/v1/idp/org-resolve",
712
735
  /**
713
736
  * Completes the admin temp-password flow: the user signed in with a temporary
@@ -721,7 +744,13 @@ const ENDPOINTS = {
721
744
  * `resetPassword` redeems that opaque token single-use and sets the new password.
722
745
  */
723
746
  forgotPassword: "/v1/idp/forgot-password",
724
- resetPassword: "/v1/idp/reset-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"
725
754
  };
726
755
  //#endregion
727
756
  //#region src/auth/outcomes.ts
@@ -811,7 +840,35 @@ function toTokenSet(accessToken, refreshToken, now) {
811
840
  function asRecord(value) {
812
841
  return value && typeof value === "object" ? value : {};
813
842
  }
814
- /** `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
+ */
815
872
  function normalizeExchange(body, now) {
816
873
  const data = asRecord(asRecord(body).data);
817
874
  const user = asRecord(data.user);
@@ -821,7 +878,10 @@ function normalizeExchange(body, now) {
821
878
  userId,
822
879
  organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
823
880
  roles: [],
824
- permissions: []
881
+ permissions: [],
882
+ displayName: deriveDisplayName(user),
883
+ email: displayString(user.email, MAX_DISPLAY_NAME),
884
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
825
885
  };
826
886
  return {
827
887
  tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
@@ -982,6 +1042,7 @@ var AuthClient = class {
982
1042
  const response = await this.transport.request({
983
1043
  method: "POST",
984
1044
  url: this.config.baseUrl + ENDPOINTS.token,
1045
+ ...params.forwardedHeaders ? { headers: params.forwardedHeaders } : {},
985
1046
  body: {
986
1047
  grant_type: "authorization_code",
987
1048
  code: params.code,
@@ -993,6 +1054,27 @@ var AuthClient = class {
993
1054
  if (response.status !== 200) throw this.toError(response);
994
1055
  return normalizeExchange(response.body, this.clock.now());
995
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
+ }
996
1078
  /** The `RefreshTokens` function the SessionEngine (F003) injects. */
997
1079
  createRefresher() {
998
1080
  return async (current) => {
@@ -1005,6 +1087,11 @@ var AuthClient = class {
1005
1087
  return normalizeRefresh(response.body, current, this.clock.now());
1006
1088
  };
1007
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
+ */
1008
1095
  async logout(scope = "device") {
1009
1096
  const response = await this.transport.request({
1010
1097
  method: "POST",
@@ -1013,6 +1100,22 @@ var AuthClient = class {
1013
1100
  });
1014
1101
  if (response.status >= 400) throw this.toError(response);
1015
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
+ }
1016
1119
  async resolveOrg(email) {
1017
1120
  const response = await this.transport.request({
1018
1121
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/core",
3
- "version": "0.1.0-alpha.8",
3
+ "version": "0.1.0",
4
4
  "description": "Framework-agnostic core of the PolyX SDK",
5
5
  "license": "MIT",
6
6
  "type": "module",