@poly-x/core 0.1.1 → 0.2.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
@@ -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
  *
@@ -1139,12 +1178,117 @@ var AuthClient = class {
1139
1178
  }
1140
1179
  };
1141
1180
  //#endregion
1181
+ //#region src/profile/validation.ts
1182
+ /**
1183
+ * Profile input rules (FR-VALID-1…8).
1184
+ *
1185
+ * These are **mirrored from the platform's own validation** (`poly-auth`
1186
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
1187
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
1188
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
1189
+ *
1190
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
1191
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
1192
+ */
1193
+ /**
1194
+ * One or more Unicode letters, optionally joined by a single space, hyphen or apostrophe —
1195
+ * "José", "O'Brien", "Anne-Marie", "van der Berg". No digits, no leading/trailing/double
1196
+ * separators. (poly-auth `PERSON_NAME_REGEX`.)
1197
+ */
1198
+ const PERSON_NAME_REGEX = /^\p{L}+(?:[ '-]\p{L}+)*$/u;
1199
+ /** Leading "+", country code starting 1-9, then 7-14 more digits. (poly-auth `E164_PHONE_REGEX`.) */
1200
+ const E164_PHONE_REGEX = /^\+[1-9]\d{7,14}$/;
1201
+ /** poly-auth's person-name bounds for these two fields (`min: 1, max: 20`). */
1202
+ const NAME_MAX = 20;
1203
+ /** poly-auth's `imageUpload.middleware` cap. */
1204
+ const MAX_PROFILE_IMAGE_BYTES = 50 * 1024 * 1024;
1205
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
1206
+ const PROFILE_FIELDS = [
1207
+ "firstName",
1208
+ "lastName",
1209
+ "phoneNumber",
1210
+ "cnic",
1211
+ "gender"
1212
+ ];
1213
+ function name(value, label) {
1214
+ const trimmed = (value ?? "").trim();
1215
+ if (trimmed === "") return `${label} is required`;
1216
+ if (trimmed.length > NAME_MAX) return `${label} cannot exceed ${NAME_MAX} characters`;
1217
+ if (!PERSON_NAME_REGEX.test(trimmed)) return `${label} may contain only letters, spaces, hyphens and apostrophes`;
1218
+ }
1219
+ /**
1220
+ * Validate a whole draft and report **every** bad field, not the first one.
1221
+ *
1222
+ * Returning on the first failure would make someone fix one field per attempt — the experience
1223
+ * D27 exists to rule out.
1224
+ */
1225
+ function validateProfileDraft(draft) {
1226
+ const errors = {};
1227
+ const firstName = name(draft.firstName, "First name");
1228
+ if (firstName) errors.firstName = firstName;
1229
+ const lastName = name(draft.lastName, "Last name");
1230
+ if (lastName) errors.lastName = lastName;
1231
+ const phone = (draft.phoneNumber ?? "").trim();
1232
+ if (phone !== "" && !E164_PHONE_REGEX.test(phone)) errors.phoneNumber = "Phone number must include the country code, e.g. +14155552671";
1233
+ const cnic = (draft.cnic ?? "").trim();
1234
+ if (cnic !== "" && cnic.replace(/\D/g, "") === "") errors.cnic = "National ID must contain digits";
1235
+ return errors;
1236
+ }
1237
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
1238
+ function validateProfileImage(file) {
1239
+ if (!file) return null;
1240
+ if (!file.type.startsWith("image/")) return "Choose an image file";
1241
+ if (file.size > 52428800) return `Image must be smaller than ${Math.round(MAX_PROFILE_IMAGE_BYTES / (1024 * 1024))}MB`;
1242
+ return null;
1243
+ }
1244
+ /**
1245
+ * The changed fields only, trimmed.
1246
+ *
1247
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
1248
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
1249
+ * one is ignored today — but sending it would mean a future backend that honours empties could
1250
+ * blank stored data on a save nobody intended as a deletion.
1251
+ */
1252
+ function buildProfileUpdate(draft, stored) {
1253
+ const update = {};
1254
+ for (const field of PROFILE_FIELDS) {
1255
+ const next = (draft[field] ?? "").trim();
1256
+ const current = (stored[field] ?? "").trim();
1257
+ if (next === "" || next === current) continue;
1258
+ update[field] = next;
1259
+ }
1260
+ return update;
1261
+ }
1262
+ /**
1263
+ * poly-auth's fixed conflict messages, mapped to the field each is about.
1264
+ *
1265
+ * Mirrors `IDENTITY_CONFLICT_MESSAGES` in `poly-auth/src/api/controllers/identity-conflict.util.ts`.
1266
+ * The platform reports a uniqueness conflict as a 409 with one of a known set of messages and no
1267
+ * machine-readable field, so this is the only way to put the error where the person can act on it
1268
+ * (FR-VALID-6) — and matching a fixed enum of strings is a mirror, not a guess at prose.
1269
+ *
1270
+ * `username` and `email` are absent on purpose: neither is editable here (D25), so there is no
1271
+ * field to attach them to and the caller must show them generally rather than drop them.
1272
+ */
1273
+ const CONFLICT_MESSAGES = [["an account with this phone number already exists.", "phoneNumber"], ["an account with this cnic already exists.", "cnic"]];
1274
+ /**
1275
+ * Which editable field a platform conflict message is about, or `null` when it is about something
1276
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
1277
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
1278
+ */
1279
+ function conflictField(message) {
1280
+ const normalized = (message ?? "").trim().toLowerCase();
1281
+ if (normalized === "") return null;
1282
+ return CONFLICT_MESSAGES.find(([text]) => text === normalized)?.[1] ?? null;
1283
+ }
1284
+ //#endregion
1142
1285
  //#region src/index.ts
1143
1286
  /**
1144
1287
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
1145
1288
  */
1146
1289
  const PACKAGE_NAME = "@poly-x/core";
1147
- const version = "0.0.0";
1290
+ /** The published version of this package, injected at build time. */
1291
+ const version = "0.2.0";
1148
1292
  //#endregion
1149
1293
  exports.AuthClient = AuthClient;
1150
1294
  exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
@@ -1157,8 +1301,10 @@ exports.InMemoryLock = InMemoryLock;
1157
1301
  exports.InMemoryPkceStore = InMemoryPkceStore;
1158
1302
  exports.InMemorySessionChannel = InMemorySessionChannel;
1159
1303
  exports.InMemorySessionStore = InMemorySessionStore;
1304
+ exports.MAX_PROFILE_IMAGE_BYTES = MAX_PROFILE_IMAGE_BYTES;
1160
1305
  exports.MockTransport = MockTransport;
1161
1306
  exports.PACKAGE_NAME = PACKAGE_NAME;
1307
+ exports.PROFILE_FIELDS = PROFILE_FIELDS;
1162
1308
  exports.PolyXConfigError = PolyXConfigError;
1163
1309
  exports.PolyXError = PolyXError;
1164
1310
  exports.RateLimitedError = RateLimitedError;
@@ -1169,9 +1315,12 @@ exports.SystemClock = SystemClock;
1169
1315
  exports.UnauthenticatedError = UnauthenticatedError;
1170
1316
  exports.UpstreamError = UpstreamError;
1171
1317
  exports.ValidationError = ValidationError;
1318
+ exports.buildProfileUpdate = buildProfileUpdate;
1172
1319
  exports.computeChallenge = computeChallenge;
1320
+ exports.conflictField = conflictField;
1173
1321
  exports.createSessionEngine = createSessionEngine;
1174
1322
  exports.decodeJwtExp = decodeJwtExp;
1323
+ exports.deriveDisplayClaims = deriveDisplayClaims;
1175
1324
  exports.generatePkce = generatePkce;
1176
1325
  exports.isTransientStatus = isTransientStatus;
1177
1326
  exports.mapPlatformError = mapPlatformError;
@@ -1185,4 +1334,6 @@ exports.randomState = randomState;
1185
1334
  exports.redactSensitive = redactSensitive;
1186
1335
  exports.reduceSessionState = reduce;
1187
1336
  exports.resolveConfig = resolveConfig;
1337
+ exports.validateProfileDraft = validateProfileDraft;
1338
+ exports.validateProfileImage = validateProfileImage;
1188
1339
  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.
@@ -681,11 +724,62 @@ declare class AuthClient {
681
724
  private toError;
682
725
  }
683
726
  //#endregion
727
+ //#region src/profile/validation.d.ts
728
+ /**
729
+ * Profile input rules (FR-VALID-1…8).
730
+ *
731
+ * These are **mirrored from the platform's own validation** (`poly-auth`
732
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
733
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
734
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
735
+ *
736
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
737
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
738
+ */
739
+ /** poly-auth's `imageUpload.middleware` cap. */
740
+ declare const MAX_PROFILE_IMAGE_BYTES: number;
741
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
742
+ declare const PROFILE_FIELDS: readonly ["firstName", "lastName", "phoneNumber", "cnic", "gender"];
743
+ type ProfileField = (typeof PROFILE_FIELDS)[number];
744
+ type ProfileDraft = Partial<Record<ProfileField, string>>;
745
+ type ProfileErrors = Partial<Record<ProfileField, string>>;
746
+ /** The subset of `File` these rules need, so this stays testable without a DOM. */
747
+ interface ProfileImageLike {
748
+ type: string;
749
+ size: number;
750
+ name?: string;
751
+ }
752
+ /**
753
+ * Validate a whole draft and report **every** bad field, not the first one.
754
+ *
755
+ * Returning on the first failure would make someone fix one field per attempt — the experience
756
+ * D27 exists to rule out.
757
+ */
758
+ declare function validateProfileDraft(draft: ProfileDraft): ProfileErrors;
759
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
760
+ declare function validateProfileImage(file: ProfileImageLike | null | undefined): string | null;
761
+ /**
762
+ * The changed fields only, trimmed.
763
+ *
764
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
765
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
766
+ * one is ignored today — but sending it would mean a future backend that honours empties could
767
+ * blank stored data on a save nobody intended as a deletion.
768
+ */
769
+ declare function buildProfileUpdate(draft: ProfileDraft, stored: ProfileDraft): ProfileDraft;
770
+ /**
771
+ * Which editable field a platform conflict message is about, or `null` when it is about something
772
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
773
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
774
+ */
775
+ declare function conflictField(message: string | undefined | null): ProfileField | null;
776
+ //#endregion
684
777
  //#region src/index.d.ts
685
778
  /**
686
779
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
687
780
  */
688
781
  declare const PACKAGE_NAME = "@poly-x/core";
689
- declare const version = "0.0.0";
782
+ /** The published version of this package, injected at build time. */
783
+ declare const version: string;
690
784
  //#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 };
785
+ 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.
@@ -681,11 +724,62 @@ declare class AuthClient {
681
724
  private toError;
682
725
  }
683
726
  //#endregion
727
+ //#region src/profile/validation.d.ts
728
+ /**
729
+ * Profile input rules (FR-VALID-1…8).
730
+ *
731
+ * These are **mirrored from the platform's own validation** (`poly-auth`
732
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
733
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
734
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
735
+ *
736
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
737
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
738
+ */
739
+ /** poly-auth's `imageUpload.middleware` cap. */
740
+ declare const MAX_PROFILE_IMAGE_BYTES: number;
741
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
742
+ declare const PROFILE_FIELDS: readonly ["firstName", "lastName", "phoneNumber", "cnic", "gender"];
743
+ type ProfileField = (typeof PROFILE_FIELDS)[number];
744
+ type ProfileDraft = Partial<Record<ProfileField, string>>;
745
+ type ProfileErrors = Partial<Record<ProfileField, string>>;
746
+ /** The subset of `File` these rules need, so this stays testable without a DOM. */
747
+ interface ProfileImageLike {
748
+ type: string;
749
+ size: number;
750
+ name?: string;
751
+ }
752
+ /**
753
+ * Validate a whole draft and report **every** bad field, not the first one.
754
+ *
755
+ * Returning on the first failure would make someone fix one field per attempt — the experience
756
+ * D27 exists to rule out.
757
+ */
758
+ declare function validateProfileDraft(draft: ProfileDraft): ProfileErrors;
759
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
760
+ declare function validateProfileImage(file: ProfileImageLike | null | undefined): string | null;
761
+ /**
762
+ * The changed fields only, trimmed.
763
+ *
764
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
765
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
766
+ * one is ignored today — but sending it would mean a future backend that honours empties could
767
+ * blank stored data on a save nobody intended as a deletion.
768
+ */
769
+ declare function buildProfileUpdate(draft: ProfileDraft, stored: ProfileDraft): ProfileDraft;
770
+ /**
771
+ * Which editable field a platform conflict message is about, or `null` when it is about something
772
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
773
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
774
+ */
775
+ declare function conflictField(message: string | undefined | null): ProfileField | null;
776
+ //#endregion
684
777
  //#region src/index.d.ts
685
778
  /**
686
779
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
687
780
  */
688
781
  declare const PACKAGE_NAME = "@poly-x/core";
689
- declare const version = "0.0.0";
782
+ /** The published version of this package, injected at build time. */
783
+ declare const version: string;
690
784
  //#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 };
785
+ 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
  *
@@ -1138,11 +1177,116 @@ var AuthClient = class {
1138
1177
  }
1139
1178
  };
1140
1179
  //#endregion
1180
+ //#region src/profile/validation.ts
1181
+ /**
1182
+ * Profile input rules (FR-VALID-1…8).
1183
+ *
1184
+ * These are **mirrored from the platform's own validation** (`poly-auth`
1185
+ * `src/api/validations/common-fields.ts`), not invented here. That is the whole point: a value
1186
+ * this module accepts must not be one the platform rejects, or a person is shown an error they
1187
+ * cannot act on — and the error arrives after a round trip, attached to nothing in particular.
1188
+ *
1189
+ * The mirroring is the fragile part. It is pinned by tests that restate the platform's patterns
1190
+ * as literals and assert both agree, so a drift fails a build rather than a person's save.
1191
+ */
1192
+ /**
1193
+ * One or more Unicode letters, optionally joined by a single space, hyphen or apostrophe —
1194
+ * "José", "O'Brien", "Anne-Marie", "van der Berg". No digits, no leading/trailing/double
1195
+ * separators. (poly-auth `PERSON_NAME_REGEX`.)
1196
+ */
1197
+ const PERSON_NAME_REGEX = /^\p{L}+(?:[ '-]\p{L}+)*$/u;
1198
+ /** Leading "+", country code starting 1-9, then 7-14 more digits. (poly-auth `E164_PHONE_REGEX`.) */
1199
+ const E164_PHONE_REGEX = /^\+[1-9]\d{7,14}$/;
1200
+ /** poly-auth's person-name bounds for these two fields (`min: 1, max: 20`). */
1201
+ const NAME_MAX = 20;
1202
+ /** poly-auth's `imageUpload.middleware` cap. */
1203
+ const MAX_PROFILE_IMAGE_BYTES = 50 * 1024 * 1024;
1204
+ /** The text fields this release makes editable (FR-PROF-4 / D25). */
1205
+ const PROFILE_FIELDS = [
1206
+ "firstName",
1207
+ "lastName",
1208
+ "phoneNumber",
1209
+ "cnic",
1210
+ "gender"
1211
+ ];
1212
+ function name(value, label) {
1213
+ const trimmed = (value ?? "").trim();
1214
+ if (trimmed === "") return `${label} is required`;
1215
+ if (trimmed.length > NAME_MAX) return `${label} cannot exceed ${NAME_MAX} characters`;
1216
+ if (!PERSON_NAME_REGEX.test(trimmed)) return `${label} may contain only letters, spaces, hyphens and apostrophes`;
1217
+ }
1218
+ /**
1219
+ * Validate a whole draft and report **every** bad field, not the first one.
1220
+ *
1221
+ * Returning on the first failure would make someone fix one field per attempt — the experience
1222
+ * D27 exists to rule out.
1223
+ */
1224
+ function validateProfileDraft(draft) {
1225
+ const errors = {};
1226
+ const firstName = name(draft.firstName, "First name");
1227
+ if (firstName) errors.firstName = firstName;
1228
+ const lastName = name(draft.lastName, "Last name");
1229
+ if (lastName) errors.lastName = lastName;
1230
+ const phone = (draft.phoneNumber ?? "").trim();
1231
+ if (phone !== "" && !E164_PHONE_REGEX.test(phone)) errors.phoneNumber = "Phone number must include the country code, e.g. +14155552671";
1232
+ const cnic = (draft.cnic ?? "").trim();
1233
+ if (cnic !== "" && cnic.replace(/\D/g, "") === "") errors.cnic = "National ID must contain digits";
1234
+ return errors;
1235
+ }
1236
+ /** `null` when the file is acceptable (or absent — a photo is optional). */
1237
+ function validateProfileImage(file) {
1238
+ if (!file) return null;
1239
+ if (!file.type.startsWith("image/")) return "Choose an image file";
1240
+ if (file.size > 52428800) return `Image must be smaller than ${Math.round(MAX_PROFILE_IMAGE_BYTES / (1024 * 1024))}MB`;
1241
+ return null;
1242
+ }
1243
+ /**
1244
+ * The changed fields only, trimmed.
1245
+ *
1246
+ * Two deliberate exclusions: unchanged fields (so an untouched save is a no-op, FR-PROF-7) and
1247
+ * fields the person *emptied*. The platform's update applies truthy values only, so an empty
1248
+ * one is ignored today — but sending it would mean a future backend that honours empties could
1249
+ * blank stored data on a save nobody intended as a deletion.
1250
+ */
1251
+ function buildProfileUpdate(draft, stored) {
1252
+ const update = {};
1253
+ for (const field of PROFILE_FIELDS) {
1254
+ const next = (draft[field] ?? "").trim();
1255
+ const current = (stored[field] ?? "").trim();
1256
+ if (next === "" || next === current) continue;
1257
+ update[field] = next;
1258
+ }
1259
+ return update;
1260
+ }
1261
+ /**
1262
+ * poly-auth's fixed conflict messages, mapped to the field each is about.
1263
+ *
1264
+ * Mirrors `IDENTITY_CONFLICT_MESSAGES` in `poly-auth/src/api/controllers/identity-conflict.util.ts`.
1265
+ * The platform reports a uniqueness conflict as a 409 with one of a known set of messages and no
1266
+ * machine-readable field, so this is the only way to put the error where the person can act on it
1267
+ * (FR-VALID-6) — and matching a fixed enum of strings is a mirror, not a guess at prose.
1268
+ *
1269
+ * `username` and `email` are absent on purpose: neither is editable here (D25), so there is no
1270
+ * field to attach them to and the caller must show them generally rather than drop them.
1271
+ */
1272
+ const CONFLICT_MESSAGES = [["an account with this phone number already exists.", "phoneNumber"], ["an account with this cnic already exists.", "cnic"]];
1273
+ /**
1274
+ * Which editable field a platform conflict message is about, or `null` when it is about something
1275
+ * this surface does not edit — or is a message we do not recognise, in which case the caller
1276
+ * should show it as a general error. A reworded message degrades to that, rather than breaking.
1277
+ */
1278
+ function conflictField(message) {
1279
+ const normalized = (message ?? "").trim().toLowerCase();
1280
+ if (normalized === "") return null;
1281
+ return CONFLICT_MESSAGES.find(([text]) => text === normalized)?.[1] ?? null;
1282
+ }
1283
+ //#endregion
1141
1284
  //#region src/index.ts
1142
1285
  /**
1143
1286
  * @poly-x/core — framework-agnostic guts of the PolyX SDK.
1144
1287
  */
1145
1288
  const PACKAGE_NAME = "@poly-x/core";
1146
- const version = "0.0.0";
1289
+ /** The published version of this package, injected at build time. */
1290
+ const version = "0.2.0";
1147
1291
  //#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 };
1292
+ 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.2.0",
4
4
  "description": "Framework-agnostic core of the PolyX SDK",
5
5
  "license": "MIT",
6
6
  "type": "module",