@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 +29 -3
- package/dist/index.cjs +172 -3
- package/dist/index.d.cts +114 -3
- package/dist/index.d.mts +114 -3
- package/dist/index.mjs +166 -4
- package/package.json +1 -1
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
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 };
|