@poly-x/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,10 +16,36 @@ don't import this directly; reach for the framework package instead.
16
16
  - **Transport** — a `Transport` seam with a fetch implementation (timeout +
17
17
  bounded transient retries) and a `MockTransport` for tests.
18
18
  - **Session engine** — the auth state machine (`resolving → authenticated ⇄
19
- refreshing → signed-out`), single-flight refresh with reuse→revoke, cross-tab
20
- sign-out, and server-authoritative proactive refresh, over swappable seams.
19
+ refreshing → signed-out`), single-flight refresh, cross-tab sign-out, and
20
+ server-authoritative proactive refresh, over swappable seams.
21
21
  - **Auth protocol client** — PKCE, authorize-URL building, the membership-outcome
22
- parse, code exchange, refresh, logout.
22
+ parse, code exchange, refresh, logout, and the silent warm-hop probe.
23
+
24
+ ### `completeAuthorize` refuses to run on a server
25
+
26
+ The warm hop asks the platform to recognise a browser it has seen before, and the platform
27
+ resolves that from a cookie on **its own** domain. A server-side request carries no browser
28
+ cookie, so it would be told "sign-in required" every single time — correctly, and
29
+ indistinguishably from someone who genuinely is signed out. Built that way, the feature looks
30
+ finished and never works.
31
+
32
+ So calling it from a server context throws `WARM_HOP_ON_SERVER` rather than returning that
33
+ plausible answer, and does not send the request at all. `@poly-x/next`'s `hop` routes prepare the
34
+ attempt for the browser to make; use those.
35
+
36
+ ### What refresh does and does not protect against
37
+
38
+ Refresh is **single-flight** — concurrent callers coalesce onto one in-flight request, so a burst
39
+ of expired calls produces one refresh rather than a stampede.
40
+
41
+ It is **not** refresh-token rotation, and the SDK does not claim reuse detection. Verified against
42
+ a running platform (2026-07-29): PolyX returns the *same* refresh token on refresh, so a refresh
43
+ token is a long-lived credential that can be replayed for its lifetime, and its capture is not
44
+ detectable. Session expiry and revocation still apply.
45
+
46
+ **This is why custody matters more than it looks.** Under `@poly-x/next`'s BFF the refresh token
47
+ never reaches the browser at all. If you hold refresh tokens client-side in your own integration,
48
+ you are carrying that risk yourself.
23
49
 
24
50
  ```ts
25
51
  import { createSessionEngine, AuthClient, resolveConfig } from "@poly-x/core";
package/dist/index.cjs CHANGED
@@ -630,7 +630,7 @@ var SessionEngine = class {
630
630
  this.applySignOut("expired", true);
631
631
  return;
632
632
  }
633
- if (error instanceof UpstreamError || error instanceof Error) this.dispatch({ type: "refresh-failed-transient" });
633
+ this.dispatch({ type: "refresh-failed-transient" });
634
634
  }
635
635
  async applySignOut(reason, broadcast) {
636
636
  if (this.state.status === "signed-out") return;
@@ -789,6 +789,18 @@ function parseAuthorizeOutcome(status, body) {
789
789
  if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
790
790
  return { type: "login_required" };
791
791
  }
792
+ /**
793
+ * Platform codes that mean "this app is misconfigured", verified against poly-auth
794
+ * `api/constants/auth-responses.ts` and `idp.controller.ts:validateClientAndRedirect`
795
+ * (2026-07-29). Only these map across — an unrecognised 400 keeps the previous
796
+ * behaviour rather than being mislabelled as misconfiguration.
797
+ */
798
+ const CONFIGURATION_ERROR_CODES = /* @__PURE__ */ new Set([
799
+ "REDIRECT_URI_NOT_ALLOWED",
800
+ "INVALID_CLIENT",
801
+ "KEY_DEPLOYMENT_MISMATCH",
802
+ "KEY_ENVIRONMENT_MISMATCH"
803
+ ]);
792
804
  function parsePasswordOutcome(status, body) {
793
805
  const b = asRecord$1(body);
794
806
  const statusField = typeof b.status === "string" ? b.status : void 0;
@@ -809,6 +821,16 @@ function parsePasswordOutcome(status, body) {
809
821
  ticket: typeof b.ticket === "string" ? b.ticket : void 0
810
822
  };
811
823
  if (status === 403) return { type: "no_access" };
824
+ if (statusField === "verification_required" && typeof b.challengeId === "string") return {
825
+ type: "verification_required",
826
+ challengeId: b.challengeId,
827
+ factorTypes: Array.isArray(b.factorTypes) ? b.factorTypes.filter((f) => typeof f === "string") : []
828
+ };
829
+ if (status === 400 && typeof b.code === "string" && CONFIGURATION_ERROR_CODES.has(b.code)) return {
830
+ type: "configuration_error",
831
+ code: b.code,
832
+ message: typeof b.message === "string" ? b.message : void 0
833
+ };
812
834
  return { type: "invalid_credentials" };
813
835
  }
814
836
  function parseResetOutcome(status, body) {
@@ -863,6 +885,23 @@ function deriveDisplayName(user) {
863
885
  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
886
  }
865
887
  /**
888
+ * The bounded display subset of a user record: name, email, avatar (ADR-0006).
889
+ *
890
+ * Exported because a profile update has to re-derive exactly these — and only these — after the
891
+ * platform saves. `normalizeRefresh` carries claims forward unchanged, so without a deliberate
892
+ * re-derivation the display name and avatar are whatever they were at login, forever.
893
+ *
894
+ * Identity and authorization claims are deliberately absent: this returns what is safe to
895
+ * recompute from a profile response, not a whole claim set.
896
+ */
897
+ function deriveDisplayClaims(user) {
898
+ return {
899
+ displayName: deriveDisplayName(user),
900
+ email: displayString(user.email, MAX_DISPLAY_NAME),
901
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
902
+ };
903
+ }
904
+ /**
866
905
  * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
867
906
  * (ADR-0006); AUTHZ populates roles/permissions later.
868
907
  *
@@ -950,8 +989,26 @@ var AuthClient = class {
950
989
  if (!this.config.signInUrl) return this.buildAuthorizeUrl(params);
951
990
  return this.appendAuthorizeParams(new URL(this.config.signInUrl), params);
952
991
  }
953
- /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
992
+ /**
993
+ * Probe the warm hop (GET /idp/authorize) and return the typed outcome.
994
+ *
995
+ * **Browser only, and it refuses to run anywhere else** (FR-HOP-5/6).
996
+ *
997
+ * The platform resolves this from the *browser's* ecosystem cookie, set on the platform's
998
+ * own domain. A request issued from a server carries no browser cookie, so the platform
999
+ * would answer `login_required` — correctly, and every single time. The caller would see a
1000
+ * perfectly ordinary "this person must sign in", identical to the answer for someone who
1001
+ * genuinely is signed out. The whole feature would look implemented and never once work,
1002
+ * with nothing red anywhere to say so.
1003
+ *
1004
+ * That is why this throws instead of returning the plausible answer: a loud failure at the
1005
+ * one wrong call site is worth far more than a silent no-op nobody notices for a release.
1006
+ * A cross-origin `fetch(credentials:"include")` from the browser is the supported path
1007
+ * (`@poly-x/next`'s hop routes prepare it); it works because the ecosystem cookie is
1008
+ * attached by the browser to the platform host.
1009
+ */
954
1010
  async completeAuthorize(params) {
1011
+ if (this.config.context === "server") throw new PolyXConfigError("The warm hop cannot be attempted from a server. The platform reads the ecosystem session from the browser's cookie, which a server-side request does not carry — so this call would report 'sign-in required' every time, whether or not the person is signed in. Start the hop from the browser instead (see createAuthHandlers().hop).", { code: "WARM_HOP_ON_SERVER" });
955
1012
  const response = await this.transport.request({
956
1013
  method: "GET",
957
1014
  url: this.buildAuthorizeUrl(params)
@@ -1139,12 +1196,117 @@ var AuthClient = class {
1139
1196
  }
1140
1197
  };
1141
1198
  //#endregion
1199
+ //#region src/profile/validation.ts
1200
+ /**
1201
+ * Profile input rules (FR-VALID-1…8).
1202
+ *
1203
+ * These are **mirrored from the platform's own validation** (`poly-auth`
1204
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
1205
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
1206
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
1207
+ *
1208
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
1209
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
1210
+ */
1211
+ /**
1212
+ * One or more Unicode letters, optionally joined by a single space, hyphen or apostrophe —
1213
+ * "José", "O'Brien", "Anne-Marie", "van der Berg". No digits, no leading/trailing/double
1214
+ * separators. (poly-auth `PERSON_NAME_REGEX`.)
1215
+ */
1216
+ const PERSON_NAME_REGEX = /^\p{L}+(?:[ '-]\p{L}+)*$/u;
1217
+ /** Leading "+", country code starting 1-9, then 7-14 more digits. (poly-auth `E164_PHONE_REGEX`.) */
1218
+ const E164_PHONE_REGEX = /^\+[1-9]\d{7,14}$/;
1219
+ /** poly-auth's person-name bounds for these two fields (`min: 1, max: 20`). */
1220
+ const NAME_MAX = 20;
1221
+ /** poly-auth's `imageUpload.middleware` cap. */
1222
+ const MAX_PROFILE_IMAGE_BYTES = 50 * 1024 * 1024;
1223
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
1224
+ const PROFILE_FIELDS = [
1225
+ "firstName",
1226
+ "lastName",
1227
+ "phoneNumber",
1228
+ "cnic",
1229
+ "gender"
1230
+ ];
1231
+ function name(value, label) {
1232
+ const trimmed = (value ?? "").trim();
1233
+ if (trimmed === "") return `${label} is required`;
1234
+ if (trimmed.length > NAME_MAX) return `${label} cannot exceed ${NAME_MAX} characters`;
1235
+ if (!PERSON_NAME_REGEX.test(trimmed)) return `${label} may contain only letters, spaces, hyphens and apostrophes`;
1236
+ }
1237
+ /**
1238
+ * Validate a whole draft and report **every** bad field, not the first one.
1239
+ *
1240
+ * Returning on the first failure would make someone fix one field per attempt — the experience
1241
+ * D27 exists to rule out.
1242
+ */
1243
+ function validateProfileDraft(draft) {
1244
+ const errors = {};
1245
+ const firstName = name(draft.firstName, "First name");
1246
+ if (firstName) errors.firstName = firstName;
1247
+ const lastName = name(draft.lastName, "Last name");
1248
+ if (lastName) errors.lastName = lastName;
1249
+ const phone = (draft.phoneNumber ?? "").trim();
1250
+ if (phone !== "" && !E164_PHONE_REGEX.test(phone)) errors.phoneNumber = "Phone number must include the country code, e.g. +14155552671";
1251
+ const cnic = (draft.cnic ?? "").trim();
1252
+ if (cnic !== "" && cnic.replace(/\D/g, "") === "") errors.cnic = "National ID must contain digits";
1253
+ return errors;
1254
+ }
1255
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
1256
+ function validateProfileImage(file) {
1257
+ if (!file) return null;
1258
+ if (!file.type.startsWith("image/")) return "Choose an image file";
1259
+ if (file.size > 52428800) return `Image must be smaller than ${Math.round(MAX_PROFILE_IMAGE_BYTES / (1024 * 1024))}MB`;
1260
+ return null;
1261
+ }
1262
+ /**
1263
+ * The changed fields only, trimmed.
1264
+ *
1265
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
1266
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
1267
+ * one is ignored today — but sending it would mean a future backend that honours empties could
1268
+ * blank stored data on a save nobody intended as a deletion.
1269
+ */
1270
+ function buildProfileUpdate(draft, stored) {
1271
+ const update = {};
1272
+ for (const field of PROFILE_FIELDS) {
1273
+ const next = (draft[field] ?? "").trim();
1274
+ const current = (stored[field] ?? "").trim();
1275
+ if (next === "" || next === current) continue;
1276
+ update[field] = next;
1277
+ }
1278
+ return update;
1279
+ }
1280
+ /**
1281
+ * poly-auth's fixed conflict messages, mapped to the field each is about.
1282
+ *
1283
+ * Mirrors `IDENTITY_CONFLICT_MESSAGES` in `poly-auth/src/api/controllers/identity-conflict.util.ts`.
1284
+ * The platform reports a uniqueness conflict as a 409 with one of a known set of messages and no
1285
+ * machine-readable field, so this is the only way to put the error where the person can act on it
1286
+ * (FR-VALID-6) — and matching a fixed enum of strings is a mirror, not a guess at prose.
1287
+ *
1288
+ * `username` and `email` are absent on purpose: neither is editable here (D25), so there is no
1289
+ * field to attach them to and the caller must show them generally rather than drop them.
1290
+ */
1291
+ const CONFLICT_MESSAGES = [["an account with this phone number already exists.", "phoneNumber"], ["an account with this cnic already exists.", "cnic"]];
1292
+ /**
1293
+ * Which editable field a platform conflict message is about, or `null` when it is about something
1294
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
1295
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
1296
+ */
1297
+ function conflictField(message) {
1298
+ const normalized = (message ?? "").trim().toLowerCase();
1299
+ if (normalized === "") return null;
1300
+ return CONFLICT_MESSAGES.find(([text]) => text === normalized)?.[1] ?? null;
1301
+ }
1302
+ //#endregion
1142
1303
  //#region src/index.ts
1143
1304
  /**
1144
1305
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
1145
1306
  */
1146
1307
  const PACKAGE_NAME = "@poly-x/core";
1147
- const version = "0.0.0";
1308
+ /** The published version of this package, injected at build time. */
1309
+ const version = "0.3.0";
1148
1310
  //#endregion
1149
1311
  exports.AuthClient = AuthClient;
1150
1312
  exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
@@ -1157,8 +1319,10 @@ exports.InMemoryLock = InMemoryLock;
1157
1319
  exports.InMemoryPkceStore = InMemoryPkceStore;
1158
1320
  exports.InMemorySessionChannel = InMemorySessionChannel;
1159
1321
  exports.InMemorySessionStore = InMemorySessionStore;
1322
+ exports.MAX_PROFILE_IMAGE_BYTES = MAX_PROFILE_IMAGE_BYTES;
1160
1323
  exports.MockTransport = MockTransport;
1161
1324
  exports.PACKAGE_NAME = PACKAGE_NAME;
1325
+ exports.PROFILE_FIELDS = PROFILE_FIELDS;
1162
1326
  exports.PolyXConfigError = PolyXConfigError;
1163
1327
  exports.PolyXError = PolyXError;
1164
1328
  exports.RateLimitedError = RateLimitedError;
@@ -1169,9 +1333,12 @@ exports.SystemClock = SystemClock;
1169
1333
  exports.UnauthenticatedError = UnauthenticatedError;
1170
1334
  exports.UpstreamError = UpstreamError;
1171
1335
  exports.ValidationError = ValidationError;
1336
+ exports.buildProfileUpdate = buildProfileUpdate;
1172
1337
  exports.computeChallenge = computeChallenge;
1338
+ exports.conflictField = conflictField;
1173
1339
  exports.createSessionEngine = createSessionEngine;
1174
1340
  exports.decodeJwtExp = decodeJwtExp;
1341
+ exports.deriveDisplayClaims = deriveDisplayClaims;
1175
1342
  exports.generatePkce = generatePkce;
1176
1343
  exports.isTransientStatus = isTransientStatus;
1177
1344
  exports.mapPlatformError = mapPlatformError;
@@ -1185,4 +1352,6 @@ exports.randomState = randomState;
1185
1352
  exports.redactSensitive = redactSensitive;
1186
1353
  exports.reduceSessionState = reduce;
1187
1354
  exports.resolveConfig = resolveConfig;
1355
+ exports.validateProfileDraft = validateProfileDraft;
1356
+ exports.validateProfileImage = validateProfileImage;
1188
1357
  exports.version = version;
package/dist/index.d.cts CHANGED
@@ -490,6 +490,34 @@ type PasswordAuthOutcome = {
490
490
  ticket?: string;
491
491
  } | {
492
492
  type: "no_access";
493
+ }
494
+ /**
495
+ * The credentials were accepted, but the platform wants a second proof of identity
496
+ * before issuing a session.
497
+ *
498
+ * RESERVED, NOT IMPLEMENTED. No deployment produces this today — multi-factor
499
+ * authentication is a platform release of its own. It is modelled now because the v1
500
+ * specification claimed the sign-in machine already accommodated a step like this and
501
+ * it did not: adding the variant later would be a breaking change across three
502
+ * packages, which is precisely the "flip, not a rebuild" promise that would have been
503
+ * broken. See the FSD (FR-STEP-1…4).
504
+ */
505
+ | {
506
+ type: "verification_required";
507
+ challengeId: string;
508
+ factorTypes: readonly string[];
509
+ }
510
+ /**
511
+ * The APP is misconfigured — not the user's credentials. Reported for an
512
+ * unregistered `redirect_uri`, an unknown client, or a key bound to another
513
+ * deployment/environment. Kept separate because collapsing it into
514
+ * `invalid_credentials` tells a developer with a deployment problem that their
515
+ * password is wrong, which is exactly what consuming apps had to work around.
516
+ */
517
+ | {
518
+ type: "configuration_error";
519
+ code: string;
520
+ message?: string;
493
521
  };
494
522
  declare function parsePasswordOutcome(status: number, body: unknown): PasswordAuthOutcome;
495
523
  /**
@@ -503,6 +531,21 @@ declare function parseResetOutcome(status: number, body: unknown): PasswordReset
503
531
  //#endregion
504
532
  //#region src/auth/normalize.d.ts
505
533
  declare function decodeJwtExp(token: string): number | null;
534
+ /**
535
+ * The bounded display subset of a user record: name, email, avatar (ADR-0006).
536
+ *
537
+ * Exported because a profile update has to re-derive exactly these — and only these — after the
538
+ * platform saves. `normalizeRefresh` carries claims forward unchanged, so without a deliberate
539
+ * re-derivation the display name and avatar are whatever they were at login, forever.
540
+ *
541
+ * Identity and authorization claims are deliberately absent: this returns what is safe to
542
+ * recompute from a profile response, not a whole claim set.
543
+ */
544
+ declare function deriveDisplayClaims(user: Record<string, unknown>): {
545
+ displayName?: string;
546
+ email?: string;
547
+ avatarUrl?: string;
548
+ };
506
549
  /**
507
550
  * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
508
551
  * (ADR-0006); AUTHZ populates roles/permissions later.
@@ -615,7 +658,24 @@ declare class AuthClient {
615
658
  * `signInUrl` is configured (deployments where the API host serves the page).
616
659
  */
617
660
  buildSignInUrl(params: BuildAuthorizeUrlParams): string;
618
- /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
661
+ /**
662
+ * Probe the warm hop (GET /idp/authorize) and return the typed outcome.
663
+ *
664
+ * **Browser only, and it refuses to run anywhere else** (FR-HOP-5/6).
665
+ *
666
+ * The platform resolves this from the *browser's* ecosystem cookie, set on the platform's
667
+ * own domain. A request issued from a server carries no browser cookie, so the platform
668
+ * would answer `login_required` — correctly, and every single time. The caller would see a
669
+ * perfectly ordinary "this person must sign in", identical to the answer for someone who
670
+ * genuinely is signed out. The whole feature would look implemented and never once work,
671
+ * with nothing red anywhere to say so.
672
+ *
673
+ * That is why this throws instead of returning the plausible answer: a loud failure at the
674
+ * one wrong call site is worth far more than a silent no-op nobody notices for a release.
675
+ * A cross-origin `fetch(credentials:"include")` from the browser is the supported path
676
+ * (`@poly-x/next`'s hop routes prepare it); it works because the ecosystem cookie is
677
+ * attached by the browser to the platform host.
678
+ */
619
679
  completeAuthorize(params: BuildAuthorizeUrlParams): Promise<AuthorizeOutcome>;
620
680
  /**
621
681
  * The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
@@ -681,11 +741,62 @@ declare class AuthClient {
681
741
  private toError;
682
742
  }
683
743
  //#endregion
744
+ //#region src/profile/validation.d.ts
745
+ /**
746
+ * Profile input rules (FR-VALID-1…8).
747
+ *
748
+ * These are **mirrored from the platform's own validation** (`poly-auth`
749
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
750
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
751
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
752
+ *
753
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
754
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
755
+ */
756
+ /** poly-auth's `imageUpload.middleware` cap. */
757
+ declare const MAX_PROFILE_IMAGE_BYTES: number;
758
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
759
+ declare const PROFILE_FIELDS: readonly ["firstName", "lastName", "phoneNumber", "cnic", "gender"];
760
+ type ProfileField = (typeof PROFILE_FIELDS)[number];
761
+ type ProfileDraft = Partial<Record<ProfileField, string>>;
762
+ type ProfileErrors = Partial<Record<ProfileField, string>>;
763
+ /** The subset of `File` these rules need, so this stays testable without a DOM. */
764
+ interface ProfileImageLike {
765
+ type: string;
766
+ size: number;
767
+ name?: string;
768
+ }
769
+ /**
770
+ * Validate a whole draft and report **every** bad field, not the first one.
771
+ *
772
+ * Returning on the first failure would make someone fix one field per attempt — the experience
773
+ * D27 exists to rule out.
774
+ */
775
+ declare function validateProfileDraft(draft: ProfileDraft): ProfileErrors;
776
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
777
+ declare function validateProfileImage(file: ProfileImageLike | null | undefined): string | null;
778
+ /**
779
+ * The changed fields only, trimmed.
780
+ *
781
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
782
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
783
+ * one is ignored today — but sending it would mean a future backend that honours empties could
784
+ * blank stored data on a save nobody intended as a deletion.
785
+ */
786
+ declare function buildProfileUpdate(draft: ProfileDraft, stored: ProfileDraft): ProfileDraft;
787
+ /**
788
+ * Which editable field a platform conflict message is about, or `null` when it is about something
789
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
790
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
791
+ */
792
+ declare function conflictField(message: string | undefined | null): ProfileField | null;
793
+ //#endregion
684
794
  //#region src/index.d.ts
685
795
  /**
686
796
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
687
797
  */
688
798
  declare const PACKAGE_NAME = "@poly-x/core";
689
- declare const version = "0.0.0";
799
+ /** The published version of this package, injected at build time. */
800
+ declare const version: string;
690
801
  //#endregion
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 };
802
+ 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, MAX_PROFILE_IMAGE_BYTES, type MockRoute, MockTransport, PACKAGE_NAME, PROFILE_FIELDS, type ParsedKey, type PasswordAuthOutcome, type PasswordResetOutcome, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, type ProfileDraft, type ProfileErrors, type ProfileField, type ProfileImageLike, 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, buildProfileUpdate, computeChallenge, conflictField, createSessionEngine, decodeJwtExp, deriveDisplayClaims, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, validateProfileDraft, validateProfileImage, version };
package/dist/index.d.mts CHANGED
@@ -490,6 +490,34 @@ type PasswordAuthOutcome = {
490
490
  ticket?: string;
491
491
  } | {
492
492
  type: "no_access";
493
+ }
494
+ /**
495
+ * The credentials were accepted, but the platform wants a second proof of identity
496
+ * before issuing a session.
497
+ *
498
+ * RESERVED, NOT IMPLEMENTED. No deployment produces this today — multi-factor
499
+ * authentication is a platform release of its own. It is modelled now because the v1
500
+ * specification claimed the sign-in machine already accommodated a step like this and
501
+ * it did not: adding the variant later would be a breaking change across three
502
+ * packages, which is precisely the "flip, not a rebuild" promise that would have been
503
+ * broken. See the FSD (FR-STEP-1…4).
504
+ */
505
+ | {
506
+ type: "verification_required";
507
+ challengeId: string;
508
+ factorTypes: readonly string[];
509
+ }
510
+ /**
511
+ * The APP is misconfigured — not the user's credentials. Reported for an
512
+ * unregistered `redirect_uri`, an unknown client, or a key bound to another
513
+ * deployment/environment. Kept separate because collapsing it into
514
+ * `invalid_credentials` tells a developer with a deployment problem that their
515
+ * password is wrong, which is exactly what consuming apps had to work around.
516
+ */
517
+ | {
518
+ type: "configuration_error";
519
+ code: string;
520
+ message?: string;
493
521
  };
494
522
  declare function parsePasswordOutcome(status: number, body: unknown): PasswordAuthOutcome;
495
523
  /**
@@ -503,6 +531,21 @@ declare function parseResetOutcome(status: number, body: unknown): PasswordReset
503
531
  //#endregion
504
532
  //#region src/auth/normalize.d.ts
505
533
  declare function decodeJwtExp(token: string): number | null;
534
+ /**
535
+ * The bounded display subset of a user record: name, email, avatar (ADR-0006).
536
+ *
537
+ * Exported because a profile update has to re-derive exactly these — and only these — after the
538
+ * platform saves. `normalizeRefresh` carries claims forward unchanged, so without a deliberate
539
+ * re-derivation the display name and avatar are whatever they were at login, forever.
540
+ *
541
+ * Identity and authorization claims are deliberately absent: this returns what is safe to
542
+ * recompute from a profile response, not a whole claim set.
543
+ */
544
+ declare function deriveDisplayClaims(user: Record<string, unknown>): {
545
+ displayName?: string;
546
+ email?: string;
547
+ avatarUrl?: string;
548
+ };
506
549
  /**
507
550
  * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
508
551
  * (ADR-0006); AUTHZ populates roles/permissions later.
@@ -615,7 +658,24 @@ declare class AuthClient {
615
658
  * `signInUrl` is configured (deployments where the API host serves the page).
616
659
  */
617
660
  buildSignInUrl(params: BuildAuthorizeUrlParams): string;
618
- /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
661
+ /**
662
+ * Probe the warm hop (GET /idp/authorize) and return the typed outcome.
663
+ *
664
+ * **Browser only, and it refuses to run anywhere else** (FR-HOP-5/6).
665
+ *
666
+ * The platform resolves this from the *browser's* ecosystem cookie, set on the platform's
667
+ * own domain. A request issued from a server carries no browser cookie, so the platform
668
+ * would answer `login_required` — correctly, and every single time. The caller would see a
669
+ * perfectly ordinary "this person must sign in", identical to the answer for someone who
670
+ * genuinely is signed out. The whole feature would look implemented and never once work,
671
+ * with nothing red anywhere to say so.
672
+ *
673
+ * That is why this throws instead of returning the plausible answer: a loud failure at the
674
+ * one wrong call site is worth far more than a silent no-op nobody notices for a release.
675
+ * A cross-origin `fetch(credentials:"include")` from the browser is the supported path
676
+ * (`@poly-x/next`'s hop routes prepare it); it works because the ecosystem cookie is
677
+ * attached by the browser to the platform host.
678
+ */
619
679
  completeAuthorize(params: BuildAuthorizeUrlParams): Promise<AuthorizeOutcome>;
620
680
  /**
621
681
  * The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
@@ -681,11 +741,62 @@ declare class AuthClient {
681
741
  private toError;
682
742
  }
683
743
  //#endregion
744
+ //#region src/profile/validation.d.ts
745
+ /**
746
+ * Profile input rules (FR-VALID-1…8).
747
+ *
748
+ * These are **mirrored from the platform's own validation** (`poly-auth`
749
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
750
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
751
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
752
+ *
753
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
754
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
755
+ */
756
+ /** poly-auth's `imageUpload.middleware` cap. */
757
+ declare const MAX_PROFILE_IMAGE_BYTES: number;
758
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
759
+ declare const PROFILE_FIELDS: readonly ["firstName", "lastName", "phoneNumber", "cnic", "gender"];
760
+ type ProfileField = (typeof PROFILE_FIELDS)[number];
761
+ type ProfileDraft = Partial<Record<ProfileField, string>>;
762
+ type ProfileErrors = Partial<Record<ProfileField, string>>;
763
+ /** The subset of `File` these rules need, so this stays testable without a DOM. */
764
+ interface ProfileImageLike {
765
+ type: string;
766
+ size: number;
767
+ name?: string;
768
+ }
769
+ /**
770
+ * Validate a whole draft and report **every** bad field, not the first one.
771
+ *
772
+ * Returning on the first failure would make someone fix one field per attempt — the experience
773
+ * D27 exists to rule out.
774
+ */
775
+ declare function validateProfileDraft(draft: ProfileDraft): ProfileErrors;
776
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
777
+ declare function validateProfileImage(file: ProfileImageLike | null | undefined): string | null;
778
+ /**
779
+ * The changed fields only, trimmed.
780
+ *
781
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
782
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
783
+ * one is ignored today — but sending it would mean a future backend that honours empties could
784
+ * blank stored data on a save nobody intended as a deletion.
785
+ */
786
+ declare function buildProfileUpdate(draft: ProfileDraft, stored: ProfileDraft): ProfileDraft;
787
+ /**
788
+ * Which editable field a platform conflict message is about, or `null` when it is about something
789
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
790
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
791
+ */
792
+ declare function conflictField(message: string | undefined | null): ProfileField | null;
793
+ //#endregion
684
794
  //#region src/index.d.ts
685
795
  /**
686
796
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
687
797
  */
688
798
  declare const PACKAGE_NAME = "@poly-x/core";
689
- declare const version = "0.0.0";
799
+ /** The published version of this package, injected at build time. */
800
+ declare const version: string;
690
801
  //#endregion
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 };
802
+ 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, MAX_PROFILE_IMAGE_BYTES, type MockRoute, MockTransport, PACKAGE_NAME, PROFILE_FIELDS, type ParsedKey, type PasswordAuthOutcome, type PasswordResetOutcome, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, type ProfileDraft, type ProfileErrors, type ProfileField, type ProfileImageLike, 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, buildProfileUpdate, computeChallenge, conflictField, createSessionEngine, decodeJwtExp, deriveDisplayClaims, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, validateProfileDraft, validateProfileImage, version };
package/dist/index.mjs CHANGED
@@ -629,7 +629,7 @@ var SessionEngine = class {
629
629
  this.applySignOut("expired", true);
630
630
  return;
631
631
  }
632
- if (error instanceof UpstreamError || error instanceof Error) this.dispatch({ type: "refresh-failed-transient" });
632
+ this.dispatch({ type: "refresh-failed-transient" });
633
633
  }
634
634
  async applySignOut(reason, broadcast) {
635
635
  if (this.state.status === "signed-out") return;
@@ -788,6 +788,18 @@ function parseAuthorizeOutcome(status, body) {
788
788
  if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
789
789
  return { type: "login_required" };
790
790
  }
791
+ /**
792
+ * Platform codes that mean "this app is misconfigured", verified against poly-auth
793
+ * `api/constants/auth-responses.ts` and `idp.controller.ts:validateClientAndRedirect`
794
+ * (2026-07-29). Only these map across — an unrecognised 400 keeps the previous
795
+ * behaviour rather than being mislabelled as misconfiguration.
796
+ */
797
+ const CONFIGURATION_ERROR_CODES = /* @__PURE__ */ new Set([
798
+ "REDIRECT_URI_NOT_ALLOWED",
799
+ "INVALID_CLIENT",
800
+ "KEY_DEPLOYMENT_MISMATCH",
801
+ "KEY_ENVIRONMENT_MISMATCH"
802
+ ]);
791
803
  function parsePasswordOutcome(status, body) {
792
804
  const b = asRecord$1(body);
793
805
  const statusField = typeof b.status === "string" ? b.status : void 0;
@@ -808,6 +820,16 @@ function parsePasswordOutcome(status, body) {
808
820
  ticket: typeof b.ticket === "string" ? b.ticket : void 0
809
821
  };
810
822
  if (status === 403) return { type: "no_access" };
823
+ if (statusField === "verification_required" && typeof b.challengeId === "string") return {
824
+ type: "verification_required",
825
+ challengeId: b.challengeId,
826
+ factorTypes: Array.isArray(b.factorTypes) ? b.factorTypes.filter((f) => typeof f === "string") : []
827
+ };
828
+ if (status === 400 && typeof b.code === "string" && CONFIGURATION_ERROR_CODES.has(b.code)) return {
829
+ type: "configuration_error",
830
+ code: b.code,
831
+ message: typeof b.message === "string" ? b.message : void 0
832
+ };
811
833
  return { type: "invalid_credentials" };
812
834
  }
813
835
  function parseResetOutcome(status, body) {
@@ -862,6 +884,23 @@ function deriveDisplayName(user) {
862
884
  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
885
  }
864
886
  /**
887
+ * The bounded display subset of a user record: name, email, avatar (ADR-0006).
888
+ *
889
+ * Exported because a profile update has to re-derive exactly these — and only these — after the
890
+ * platform saves. `normalizeRefresh` carries claims forward unchanged, so without a deliberate
891
+ * re-derivation the display name and avatar are whatever they were at login, forever.
892
+ *
893
+ * Identity and authorization claims are deliberately absent: this returns what is safe to
894
+ * recompute from a profile response, not a whole claim set.
895
+ */
896
+ function deriveDisplayClaims(user) {
897
+ return {
898
+ displayName: deriveDisplayName(user),
899
+ email: displayString(user.email, MAX_DISPLAY_NAME),
900
+ avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
901
+ };
902
+ }
903
+ /**
865
904
  * `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
866
905
  * (ADR-0006); AUTHZ populates roles/permissions later.
867
906
  *
@@ -949,8 +988,26 @@ var AuthClient = class {
949
988
  if (!this.config.signInUrl) return this.buildAuthorizeUrl(params);
950
989
  return this.appendAuthorizeParams(new URL(this.config.signInUrl), params);
951
990
  }
952
- /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
991
+ /**
992
+ * Probe the warm hop (GET /idp/authorize) and return the typed outcome.
993
+ *
994
+ * **Browser only, and it refuses to run anywhere else** (FR-HOP-5/6).
995
+ *
996
+ * The platform resolves this from the *browser's* ecosystem cookie, set on the platform's
997
+ * own domain. A request issued from a server carries no browser cookie, so the platform
998
+ * would answer `login_required` — correctly, and every single time. The caller would see a
999
+ * perfectly ordinary "this person must sign in", identical to the answer for someone who
1000
+ * genuinely is signed out. The whole feature would look implemented and never once work,
1001
+ * with nothing red anywhere to say so.
1002
+ *
1003
+ * That is why this throws instead of returning the plausible answer: a loud failure at the
1004
+ * one wrong call site is worth far more than a silent no-op nobody notices for a release.
1005
+ * A cross-origin `fetch(credentials:"include")` from the browser is the supported path
1006
+ * (`@poly-x/next`'s hop routes prepare it); it works because the ecosystem cookie is
1007
+ * attached by the browser to the platform host.
1008
+ */
953
1009
  async completeAuthorize(params) {
1010
+ if (this.config.context === "server") throw new PolyXConfigError("The warm hop cannot be attempted from a server. The platform reads the ecosystem session from the browser's cookie, which a server-side request does not carry — so this call would report 'sign-in required' every time, whether or not the person is signed in. Start the hop from the browser instead (see createAuthHandlers().hop).", { code: "WARM_HOP_ON_SERVER" });
954
1011
  const response = await this.transport.request({
955
1012
  method: "GET",
956
1013
  url: this.buildAuthorizeUrl(params)
@@ -1138,11 +1195,116 @@ var AuthClient = class {
1138
1195
  }
1139
1196
  };
1140
1197
  //#endregion
1198
+ //#region src/profile/validation.ts
1199
+ /**
1200
+ * Profile input rules (FR-VALID-1…8).
1201
+ *
1202
+ * These are **mirrored from the platform's own validation** (`poly-auth`
1203
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
1204
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
1205
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
1206
+ *
1207
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
1208
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
1209
+ */
1210
+ /**
1211
+ * One or more Unicode letters, optionally joined by a single space, hyphen or apostrophe —
1212
+ * "José", "O'Brien", "Anne-Marie", "van der Berg". No digits, no leading/trailing/double
1213
+ * separators. (poly-auth `PERSON_NAME_REGEX`.)
1214
+ */
1215
+ const PERSON_NAME_REGEX = /^\p{L}+(?:[ '-]\p{L}+)*$/u;
1216
+ /** Leading "+", country code starting 1-9, then 7-14 more digits. (poly-auth `E164_PHONE_REGEX`.) */
1217
+ const E164_PHONE_REGEX = /^\+[1-9]\d{7,14}$/;
1218
+ /** poly-auth's person-name bounds for these two fields (`min: 1, max: 20`). */
1219
+ const NAME_MAX = 20;
1220
+ /** poly-auth's `imageUpload.middleware` cap. */
1221
+ const MAX_PROFILE_IMAGE_BYTES = 50 * 1024 * 1024;
1222
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
1223
+ const PROFILE_FIELDS = [
1224
+ "firstName",
1225
+ "lastName",
1226
+ "phoneNumber",
1227
+ "cnic",
1228
+ "gender"
1229
+ ];
1230
+ function name(value, label) {
1231
+ const trimmed = (value ?? "").trim();
1232
+ if (trimmed === "") return `${label} is required`;
1233
+ if (trimmed.length > NAME_MAX) return `${label} cannot exceed ${NAME_MAX} characters`;
1234
+ if (!PERSON_NAME_REGEX.test(trimmed)) return `${label} may contain only letters, spaces, hyphens and apostrophes`;
1235
+ }
1236
+ /**
1237
+ * Validate a whole draft and report **every** bad field, not the first one.
1238
+ *
1239
+ * Returning on the first failure would make someone fix one field per attempt — the experience
1240
+ * D27 exists to rule out.
1241
+ */
1242
+ function validateProfileDraft(draft) {
1243
+ const errors = {};
1244
+ const firstName = name(draft.firstName, "First name");
1245
+ if (firstName) errors.firstName = firstName;
1246
+ const lastName = name(draft.lastName, "Last name");
1247
+ if (lastName) errors.lastName = lastName;
1248
+ const phone = (draft.phoneNumber ?? "").trim();
1249
+ if (phone !== "" && !E164_PHONE_REGEX.test(phone)) errors.phoneNumber = "Phone number must include the country code, e.g. +14155552671";
1250
+ const cnic = (draft.cnic ?? "").trim();
1251
+ if (cnic !== "" && cnic.replace(/\D/g, "") === "") errors.cnic = "National ID must contain digits";
1252
+ return errors;
1253
+ }
1254
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
1255
+ function validateProfileImage(file) {
1256
+ if (!file) return null;
1257
+ if (!file.type.startsWith("image/")) return "Choose an image file";
1258
+ if (file.size > 52428800) return `Image must be smaller than ${Math.round(MAX_PROFILE_IMAGE_BYTES / (1024 * 1024))}MB`;
1259
+ return null;
1260
+ }
1261
+ /**
1262
+ * The changed fields only, trimmed.
1263
+ *
1264
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
1265
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
1266
+ * one is ignored today — but sending it would mean a future backend that honours empties could
1267
+ * blank stored data on a save nobody intended as a deletion.
1268
+ */
1269
+ function buildProfileUpdate(draft, stored) {
1270
+ const update = {};
1271
+ for (const field of PROFILE_FIELDS) {
1272
+ const next = (draft[field] ?? "").trim();
1273
+ const current = (stored[field] ?? "").trim();
1274
+ if (next === "" || next === current) continue;
1275
+ update[field] = next;
1276
+ }
1277
+ return update;
1278
+ }
1279
+ /**
1280
+ * poly-auth's fixed conflict messages, mapped to the field each is about.
1281
+ *
1282
+ * Mirrors `IDENTITY_CONFLICT_MESSAGES` in `poly-auth/src/api/controllers/identity-conflict.util.ts`.
1283
+ * The platform reports a uniqueness conflict as a 409 with one of a known set of messages and no
1284
+ * machine-readable field, so this is the only way to put the error where the person can act on it
1285
+ * (FR-VALID-6) — and matching a fixed enum of strings is a mirror, not a guess at prose.
1286
+ *
1287
+ * `username` and `email` are absent on purpose: neither is editable here (D25), so there is no
1288
+ * field to attach them to and the caller must show them generally rather than drop them.
1289
+ */
1290
+ const CONFLICT_MESSAGES = [["an account with this phone number already exists.", "phoneNumber"], ["an account with this cnic already exists.", "cnic"]];
1291
+ /**
1292
+ * Which editable field a platform conflict message is about, or `null` when it is about something
1293
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
1294
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
1295
+ */
1296
+ function conflictField(message) {
1297
+ const normalized = (message ?? "").trim().toLowerCase();
1298
+ if (normalized === "") return null;
1299
+ return CONFLICT_MESSAGES.find(([text]) => text === normalized)?.[1] ?? null;
1300
+ }
1301
+ //#endregion
1141
1302
  //#region src/index.ts
1142
1303
  /**
1143
1304
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
1144
1305
  */
1145
1306
  const PACKAGE_NAME = "@poly-x/core";
1146
- const version = "0.0.0";
1307
+ /** The published version of this package, injected at build time. */
1308
+ const version = "0.3.0";
1147
1309
  //#endregion
1148
- 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 };
1310
+ export { AuthClient, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, FetchTransport, ForbiddenError, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, MAX_PROFILE_IMAGE_BYTES, MockTransport, PACKAGE_NAME, PROFILE_FIELDS, PolyXConfigError, PolyXError, RateLimitedError, SessionEngine, SessionExpiredError, SessionRevokedError, SystemClock, UnauthenticatedError, UpstreamError, ValidationError, buildProfileUpdate, computeChallenge, conflictField, createSessionEngine, decodeJwtExp, deriveDisplayClaims, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, validateProfileDraft, validateProfileImage, version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/core",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Framework-agnostic core of the PolyX SDK",
5
5
  "license": "MIT",
6
6
  "type": "module",