@poly-x/core 0.1.0-alpha.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 ADDED
@@ -0,0 +1,940 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/errors.ts
3
+ /**
4
+ * The typed error taxonomy shared by every @poly-x package (FR-DX-5).
5
+ * Every failure a consumer can see is one of these classes — branchable by
6
+ * `instanceof` or `code`, and safe to serialize: `toJSON()` redacts key
7
+ * material and tokens (FR-DX-3).
8
+ */
9
+ const KEY_PATTERN = /\b(?:pk|sk)_(?:test|live)_[A-Za-z0-9_-]{6,}/g;
10
+ const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
11
+ const JWT_PATTERN = /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
12
+ function redactSensitive(text) {
13
+ return text.replace(KEY_PATTERN, "[REDACTED]").replace(BEARER_PATTERN, "Bearer [REDACTED]").replace(JWT_PATTERN, "[REDACTED]");
14
+ }
15
+ function redactDeep(value) {
16
+ if (typeof value === "string") return redactSensitive(value);
17
+ if (Array.isArray(value)) return value.map(redactDeep);
18
+ if (value && typeof value === "object") {
19
+ const result = {};
20
+ for (const [key, entry] of Object.entries(value)) result[key] = redactDeep(entry);
21
+ return result;
22
+ }
23
+ return value;
24
+ }
25
+ var PolyXError = class extends Error {
26
+ code;
27
+ requestId;
28
+ meta;
29
+ constructor(message, options) {
30
+ super(redactSensitive(message), { cause: options.cause });
31
+ this.name = new.target.name;
32
+ this.code = options.code;
33
+ this.requestId = options.requestId;
34
+ this.meta = options.meta;
35
+ }
36
+ toJSON() {
37
+ return {
38
+ name: this.name,
39
+ code: this.code,
40
+ message: this.message,
41
+ ...this.requestId ? { requestId: this.requestId } : {},
42
+ ...this.meta ? { meta: redactDeep(this.meta) } : {}
43
+ };
44
+ }
45
+ };
46
+ var PolyXConfigError = class extends PolyXError {
47
+ constructor(message, options) {
48
+ super(message, {
49
+ code: options?.code ?? "CONFIG_ERROR",
50
+ ...options
51
+ });
52
+ }
53
+ };
54
+ /** Not signed in. `refreshable: true` means a silent refresh may recover it. */
55
+ var UnauthenticatedError = class extends PolyXError {
56
+ refreshable;
57
+ constructor(message, options) {
58
+ super(message, options);
59
+ this.refreshable = options.refreshable;
60
+ }
61
+ };
62
+ /** Terminal session end (expiry / invalid refresh) — re-authentication required. */
63
+ var SessionExpiredError = class extends PolyXError {
64
+ shouldRelogin = true;
65
+ constructor(message, options) {
66
+ super(message, options);
67
+ }
68
+ };
69
+ /** Session (or its whole family, on refresh-token reuse) was revoked. */
70
+ var SessionRevokedError = class extends PolyXError {
71
+ shouldRelogin = true;
72
+ constructor(message, options) {
73
+ super(message, options);
74
+ }
75
+ };
76
+ var ForbiddenError = class extends PolyXError {};
77
+ var ValidationError = class extends PolyXError {
78
+ errors;
79
+ constructor(message, options) {
80
+ super(message, {
81
+ code: options.code ?? "VALIDATION_ERROR",
82
+ ...options
83
+ });
84
+ this.errors = options.errors;
85
+ }
86
+ };
87
+ var RateLimitedError = class extends PolyXError {
88
+ retryAfterSeconds;
89
+ constructor(message, options) {
90
+ super(message, {
91
+ code: options.code ?? "RATE_LIMITED",
92
+ ...options
93
+ });
94
+ this.retryAfterSeconds = options.retryAfterSeconds;
95
+ }
96
+ };
97
+ /** Platform unreachable, 5xx, timeout, or an unrecognizable response. */
98
+ var UpstreamError = class extends PolyXError {};
99
+ //#endregion
100
+ //#region src/keys.ts
101
+ /**
102
+ * PolyX key parsing (FR-CFG-3/4). Format draft v1 — the cross-repo contract
103
+ * poly-x v4 (FR-KEY-2) implements; test vectors live in test/fixtures/keys.ts
104
+ * and are mirrored into poly-x's tests:
105
+ *
106
+ * {kind}_{env}_{base64url("v1|" + deploymentHost + "|" + random)}
107
+ *
108
+ * The deployment host is the issuing PolyX deployment's authority plus optional
109
+ * path prefix, no scheme (e.g. "poly.fintra.ai/poly-x") — there is no single
110
+ * SaaS PolyX, so the key locates its own backend (discovery D8).
111
+ */
112
+ const KEY_SHAPE = /^(pk|sk)_(test|live)_([A-Za-z0-9_-]+)$/;
113
+ const SUPPORTED_FORMAT_VERSIONS = /* @__PURE__ */ new Set(["v1"]);
114
+ function decodeBase64Url(payload) {
115
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
116
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
117
+ try {
118
+ return atob(padded);
119
+ } catch {
120
+ throw new PolyXConfigError("PolyX key payload is not valid base64url.", { code: "KEY_MALFORMED" });
121
+ }
122
+ }
123
+ function parsePolyXKey(raw) {
124
+ if (typeof raw !== "string" || raw.length === 0) throw new PolyXConfigError("PolyX key is missing. Provide the key from your App instance.", { code: "KEY_MISSING" });
125
+ const match = KEY_SHAPE.exec(raw);
126
+ if (!match) throw new PolyXConfigError("PolyX key has an unrecognized prefix. Expected a key starting with pk_test_, pk_live_, sk_test_ or sk_live_.", { code: "KEY_MALFORMED" });
127
+ const [, kind, env, payload] = match;
128
+ const segments = decodeBase64Url(payload).split("|");
129
+ const formatVersion = segments[0] ?? "";
130
+ if (!SUPPORTED_FORMAT_VERSIONS.has(formatVersion)) throw new PolyXConfigError(`PolyX key has an unsupported format version ("${formatVersion || "none"}"). Upgrade @poly-x/core or re-issue the key.`, { code: "KEY_UNSUPPORTED_VERSION" });
131
+ if (segments.length !== 3) throw new PolyXConfigError("PolyX key payload is incomplete.", { code: "KEY_MALFORMED" });
132
+ const host = segments[1] ?? "";
133
+ if (host.length === 0) throw new PolyXConfigError("PolyX key does not carry an issuing deployment host.", { code: "KEY_MALFORMED" });
134
+ return {
135
+ kind,
136
+ env,
137
+ host,
138
+ formatVersion,
139
+ raw
140
+ };
141
+ }
142
+ //#endregion
143
+ //#region src/config.ts
144
+ /**
145
+ * Effective SDK configuration (FR-CFG-4/5/6): the key locates its deployment;
146
+ * an explicit baseUrl override wins (proxies, container-internal hostnames,
147
+ * deployment migrations); secret keys are refused in browser contexts
148
+ * (FR-CFG-1/2).
149
+ */
150
+ function detectContext() {
151
+ const globals = globalThis;
152
+ return globals.window !== void 0 && globals.document !== void 0 ? "browser" : "server";
153
+ }
154
+ function normalizeBaseUrl(url) {
155
+ return url.replace(/\/+$/, "");
156
+ }
157
+ function resolveConfig(input) {
158
+ const context = input.context ?? detectContext();
159
+ const key = parsePolyXKey(input.key);
160
+ if (key.kind === "sk" && context === "browser") throw new PolyXConfigError("A PolyX secret key was passed to a browser-side package. Secret keys belong on your server only — use the publishable key (pk_…) in the browser.", { code: "SECRET_KEY_IN_BROWSER" });
161
+ let baseUrl;
162
+ if (input.baseUrl !== void 0) {
163
+ try {
164
+ new URL(input.baseUrl);
165
+ } catch {
166
+ throw new PolyXConfigError("The baseUrl override is not a valid URL.", { code: "BASE_URL_INVALID" });
167
+ }
168
+ baseUrl = normalizeBaseUrl(input.baseUrl);
169
+ } else baseUrl = `https://${normalizeBaseUrl(key.host)}`;
170
+ return {
171
+ key,
172
+ kind: key.kind,
173
+ env: key.env,
174
+ baseUrl,
175
+ context
176
+ };
177
+ }
178
+ //#endregion
179
+ //#region src/contract/error-mapping.ts
180
+ /**
181
+ * Maps poly-auth's standardized error contract onto the SDK taxonomy — the
182
+ * backward-compat shim's error half (FR-DX-2/3). Discriminator codes verified
183
+ * against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
184
+ * shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
185
+ */
186
+ const REVOKED_CODES = /* @__PURE__ */ new Set(["REFRESH_TOKEN_REUSE", "SESSION_REVOKED"]);
187
+ const EXPIRED_CODES = /* @__PURE__ */ new Set([
188
+ "SESSION_EXPIRED",
189
+ "REFRESH_TOKEN_REQUIRED",
190
+ "REFRESH_TOKEN_INVALID",
191
+ "REFRESH_TOKEN_EXPIRED"
192
+ ]);
193
+ const FORBIDDEN_CODES = /* @__PURE__ */ new Set([
194
+ "TENANT_MEMBERSHIP_DENIED",
195
+ "ACCOUNT_ARCHIVED",
196
+ "NO_ACCESS"
197
+ ]);
198
+ function asPlatformBody(body) {
199
+ return body && typeof body === "object" ? body : {};
200
+ }
201
+ function mapPlatformError(response) {
202
+ const { status, requestId } = response;
203
+ const body = asPlatformBody(response.body);
204
+ const message = typeof body.message === "string" ? body.message : `PolyX request failed (${status})`;
205
+ const code = typeof body.code === "string" ? body.code : void 0;
206
+ const common = { requestId };
207
+ if (code && REVOKED_CODES.has(code)) return new SessionRevokedError(message, {
208
+ code,
209
+ ...common
210
+ });
211
+ if (code && EXPIRED_CODES.has(code)) return new SessionExpiredError(message, {
212
+ code,
213
+ ...common
214
+ });
215
+ if (code === "ACCESS_TOKEN_EXPIRED") return new UnauthenticatedError(message, {
216
+ code,
217
+ refreshable: true,
218
+ ...common
219
+ });
220
+ if (code === "TOKEN_REQUIRED") return new UnauthenticatedError(message, {
221
+ code,
222
+ refreshable: false,
223
+ ...common
224
+ });
225
+ if (code && FORBIDDEN_CODES.has(code)) return new ForbiddenError(message, {
226
+ code,
227
+ ...common
228
+ });
229
+ if (status === 400 || status === 422) return new ValidationError(message, {
230
+ code: code ?? "BAD_REQUEST",
231
+ errors: Array.isArray(body.errors) ? body.errors : [],
232
+ ...common
233
+ });
234
+ if (status === 429) {
235
+ const header = response.headers?.["retry-after"];
236
+ return new RateLimitedError(message, {
237
+ retryAfterSeconds: header !== void 0 && /^\d+$/.test(header) ? Number(header) : void 0,
238
+ ...common
239
+ });
240
+ }
241
+ if (status === 401) return new UnauthenticatedError(message, {
242
+ code: code ?? "UNAUTHENTICATED",
243
+ refreshable: false,
244
+ ...common
245
+ });
246
+ if (status === 403) return new ForbiddenError(message, {
247
+ code: code ?? "FORBIDDEN",
248
+ ...common
249
+ });
250
+ if (status >= 500) return new UpstreamError(message, {
251
+ code: code ?? "UPSTREAM_ERROR",
252
+ meta: { status },
253
+ ...common
254
+ });
255
+ return new UpstreamError("PolyX returned a response this SDK version does not recognize.", {
256
+ code: "UNRECOGNIZED_RESPONSE",
257
+ meta: { status },
258
+ ...common
259
+ });
260
+ }
261
+ //#endregion
262
+ //#region src/transport/index.ts
263
+ const DEFAULT_RETRY_POLICY = {
264
+ maxAttempts: 3,
265
+ retryDelayMs: 250
266
+ };
267
+ const DEFAULT_TIMEOUT_MS = 15e3;
268
+ function isTransientStatus(status) {
269
+ return status >= 500;
270
+ }
271
+ //#endregion
272
+ //#region src/transport/fetch.ts
273
+ function sleep(ms) {
274
+ return new Promise((resolve) => setTimeout(resolve, ms));
275
+ }
276
+ async function parseBody(response) {
277
+ const text = await response.text();
278
+ if (text.length === 0) return void 0;
279
+ try {
280
+ return JSON.parse(text);
281
+ } catch {
282
+ return text;
283
+ }
284
+ }
285
+ function headersToRecord(headers) {
286
+ const record = {};
287
+ headers.forEach((value, key) => {
288
+ record[key.toLowerCase()] = value;
289
+ });
290
+ return record;
291
+ }
292
+ /** Default transport: global fetch + timeout + bounded transient-only retries. */
293
+ var FetchTransport = class {
294
+ retry;
295
+ constructor(retry = {}) {
296
+ this.retry = {
297
+ ...DEFAULT_RETRY_POLICY,
298
+ ...retry
299
+ };
300
+ }
301
+ async request(request) {
302
+ const timeoutMs = request.timeoutMs ?? 15e3;
303
+ let lastNetworkError;
304
+ for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt += 1) {
305
+ let response;
306
+ try {
307
+ response = await fetch(request.url, {
308
+ method: request.method,
309
+ headers: {
310
+ ...request.body !== void 0 ? { "content-type": "application/json" } : {},
311
+ ...request.headers
312
+ },
313
+ body: request.body !== void 0 ? JSON.stringify(request.body) : void 0,
314
+ signal: AbortSignal.timeout(timeoutMs)
315
+ });
316
+ } catch (error) {
317
+ lastNetworkError = error;
318
+ if (attempt < this.retry.maxAttempts) {
319
+ await sleep(this.retry.retryDelayMs * 2 ** (attempt - 1));
320
+ continue;
321
+ }
322
+ throw new UpstreamError("PolyX deployment is unreachable.", {
323
+ code: "NETWORK_ERROR",
324
+ meta: {
325
+ url: request.url,
326
+ attempts: attempt
327
+ },
328
+ cause: error
329
+ });
330
+ }
331
+ const result = {
332
+ status: response.status,
333
+ headers: headersToRecord(response.headers),
334
+ body: await parseBody(response)
335
+ };
336
+ if (isTransientStatus(result.status) && attempt < this.retry.maxAttempts) {
337
+ await sleep(this.retry.retryDelayMs * 2 ** (attempt - 1));
338
+ continue;
339
+ }
340
+ return result;
341
+ }
342
+ throw new UpstreamError("PolyX request could not be completed.", {
343
+ code: "NETWORK_ERROR",
344
+ cause: lastNetworkError
345
+ });
346
+ }
347
+ };
348
+ //#endregion
349
+ //#region src/transport/mock.ts
350
+ /**
351
+ * Fixture-driven transport with the exact contract of FetchTransport — the
352
+ * seam mock mode (FR-DX-4) plugs in. Unmatched requests fail loudly so fixture
353
+ * bugs surface instead of hanging tests.
354
+ */
355
+ var MockTransport = class {
356
+ routes;
357
+ constructor(routes) {
358
+ this.routes = routes.map((route) => ({
359
+ ...route,
360
+ used: 0
361
+ }));
362
+ }
363
+ request(request) {
364
+ const pathname = new URL(request.url).pathname;
365
+ const route = this.routes.find((candidate) => candidate.method === request.method && candidate.path === pathname && (candidate.times === void 0 || candidate.used < candidate.times));
366
+ if (!route) return Promise.reject(/* @__PURE__ */ new Error(`MockTransport: no mock route for ${request.method} ${pathname}`));
367
+ route.used += 1;
368
+ if (route.networkError) return Promise.reject(new UpstreamError("PolyX deployment is unreachable.", {
369
+ code: "NETWORK_ERROR",
370
+ meta: {
371
+ url: request.url,
372
+ mock: true
373
+ }
374
+ }));
375
+ const response = route.response ?? {
376
+ status: 200,
377
+ body: void 0
378
+ };
379
+ return Promise.resolve({
380
+ status: response.status,
381
+ headers: Object.fromEntries(Object.entries(response.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value])),
382
+ body: response.body
383
+ });
384
+ }
385
+ };
386
+ //#endregion
387
+ //#region src/session/state.ts
388
+ const INITIAL_STATE = { status: "resolving" };
389
+ function reduce(state, event) {
390
+ switch (event.type) {
391
+ case "hydrated":
392
+ if (state.status !== "resolving") return state;
393
+ return event.session ? {
394
+ status: "authenticated",
395
+ session: event.session
396
+ } : {
397
+ status: "signed-out",
398
+ reason: "signed-out"
399
+ };
400
+ case "established": return {
401
+ status: "authenticated",
402
+ session: event.session
403
+ };
404
+ case "refresh-started":
405
+ if (state.status !== "authenticated") return state;
406
+ return {
407
+ status: "refreshing",
408
+ session: state.session
409
+ };
410
+ case "refresh-succeeded":
411
+ if (state.status !== "refreshing") return state;
412
+ return {
413
+ status: "authenticated",
414
+ session: event.session
415
+ };
416
+ case "refresh-failed-transient":
417
+ if (state.status !== "refreshing") return state;
418
+ return {
419
+ status: "authenticated",
420
+ session: state.session
421
+ };
422
+ case "expired":
423
+ if (state.status !== "authenticated" && state.status !== "refreshing") return state;
424
+ return {
425
+ status: "signed-out",
426
+ reason: "expired"
427
+ };
428
+ case "revoked":
429
+ if (state.status !== "authenticated" && state.status !== "refreshing") return state;
430
+ return {
431
+ status: "signed-out",
432
+ reason: "revoked"
433
+ };
434
+ case "signed-out": return {
435
+ status: "signed-out",
436
+ reason: "signed-out"
437
+ };
438
+ default: return state;
439
+ }
440
+ }
441
+ //#endregion
442
+ //#region src/session/seams.ts
443
+ var InMemorySessionStore = class {
444
+ stored = null;
445
+ load() {
446
+ return Promise.resolve(this.stored);
447
+ }
448
+ save(stored) {
449
+ this.stored = stored;
450
+ return Promise.resolve();
451
+ }
452
+ clear() {
453
+ this.stored = null;
454
+ return Promise.resolve();
455
+ }
456
+ };
457
+ /**
458
+ * A shared in-process bus. Two engines given the same instance simulate two
459
+ * tabs on one channel. Delivers to every subscriber including the publisher;
460
+ * the engine's transitions are idempotent so an echo is a harmless no-op.
461
+ */
462
+ var InMemorySessionChannel = class {
463
+ listeners = /* @__PURE__ */ new Set();
464
+ publish(event) {
465
+ for (const listener of [...this.listeners]) listener(event);
466
+ }
467
+ subscribe(listener) {
468
+ this.listeners.add(listener);
469
+ return () => this.listeners.delete(listener);
470
+ }
471
+ };
472
+ /** Per-key promise chain: fn runs after the prior holder settles (either way). */
473
+ var InMemoryLock = class {
474
+ chains = /* @__PURE__ */ new Map();
475
+ runExclusive(key, fn) {
476
+ const run = (this.chains.get(key) ?? Promise.resolve()).then(fn, fn);
477
+ this.chains.set(key, run.then(() => void 0, () => void 0));
478
+ return run;
479
+ }
480
+ };
481
+ var SystemClock = class {
482
+ now() {
483
+ return Date.now();
484
+ }
485
+ setTimer(fn, delayMs) {
486
+ return setTimeout(fn, delayMs);
487
+ }
488
+ clearTimer(handle) {
489
+ clearTimeout(handle);
490
+ }
491
+ };
492
+ //#endregion
493
+ //#region src/session/engine.ts
494
+ /**
495
+ * SessionEngine (F003): the one owner of session lifecycle — hydrate,
496
+ * establish, single-flight refresh, sign-out, cross-tab mirroring, and
497
+ * server-authoritative proactive-refresh scheduling. Network calls are
498
+ * injected (`refreshTokens`) so core stays endpoint-agnostic; custody,
499
+ * cross-tab, lock, and clock are seams (in-memory defaults provided).
500
+ */
501
+ const REFRESH_LOCK_KEY = "session.refresh";
502
+ const DEFAULT_SKEW_MS = 6e4;
503
+ var SessionEngine = class {
504
+ state = INITIAL_STATE;
505
+ subscribers = /* @__PURE__ */ new Set();
506
+ store;
507
+ channel;
508
+ lock;
509
+ clock;
510
+ refreshTokens;
511
+ skewMs;
512
+ onSignOut;
513
+ unsubscribeChannel;
514
+ refreshTimer = null;
515
+ /** Bumped by establish/sign-out so an in-flight refresh can detect supersession. */
516
+ generation = 0;
517
+ constructor(options) {
518
+ this.store = options.store ?? new InMemorySessionStore();
519
+ this.channel = options.channel ?? new InMemorySessionChannel();
520
+ this.lock = options.lock ?? new InMemoryLock();
521
+ this.clock = options.clock ?? new SystemClock();
522
+ this.refreshTokens = options.refreshTokens;
523
+ this.skewMs = options.refreshSkewMs ?? DEFAULT_SKEW_MS;
524
+ this.onSignOut = options.onSignOut;
525
+ this.unsubscribeChannel = this.channel.subscribe((event) => {
526
+ this.applySignOut(event.reason, false);
527
+ });
528
+ }
529
+ getState() {
530
+ return this.state;
531
+ }
532
+ subscribe(listener) {
533
+ this.subscribers.add(listener);
534
+ return () => this.subscribers.delete(listener);
535
+ }
536
+ /** Restore a persisted session (or resolve to signed-out). Call once at startup. */
537
+ async hydrate() {
538
+ const stored = await this.store.load();
539
+ this.dispatch({
540
+ type: "hydrated",
541
+ session: stored?.session ?? null
542
+ });
543
+ if (stored) this.scheduleRefresh(stored);
544
+ }
545
+ /** Establish a session from freshly-issued or externally-exchanged tokens (FR-SES-1/2). */
546
+ async establishSession(stored) {
547
+ this.generation += 1;
548
+ await this.store.save(stored);
549
+ this.dispatch({
550
+ type: "established",
551
+ session: stored.session
552
+ });
553
+ this.scheduleRefresh(stored);
554
+ }
555
+ /** Current access token, refreshing first if it is at/near expiry (FR-SES-4). */
556
+ async getAccessToken() {
557
+ const stored = await this.store.load();
558
+ if (stored && !this.isStale(stored.tokens)) return stored.tokens.accessToken;
559
+ return (await this.refresh()).accessToken;
560
+ }
561
+ async signOut() {
562
+ await this.applySignOut("signed-out", true);
563
+ }
564
+ /** Release the cross-tab subscription (call on teardown). */
565
+ dispose() {
566
+ this.unsubscribeChannel();
567
+ this.cancelScheduledRefresh();
568
+ }
569
+ isStale(tokens) {
570
+ return this.clock.now() >= tokens.accessTokenExpiresAt - this.skewMs;
571
+ }
572
+ refresh() {
573
+ return this.lock.runExclusive(REFRESH_LOCK_KEY, async () => {
574
+ const gen = this.generation;
575
+ const current = await this.store.load();
576
+ if (!current) throw new UnauthenticatedError("No session to refresh.", {
577
+ code: "UNAUTHENTICATED",
578
+ refreshable: false
579
+ });
580
+ if (!this.isStale(current.tokens)) return current.tokens;
581
+ this.dispatch({ type: "refresh-started" });
582
+ let next;
583
+ try {
584
+ next = await this.refreshTokens(current);
585
+ } catch (error) {
586
+ if (this.generation === gen) this.handleRefreshError(error);
587
+ throw error;
588
+ }
589
+ if (this.generation !== gen) {
590
+ if (this.state.status === "signed-out") throw new UnauthenticatedError("Session ended during refresh.", {
591
+ code: "UNAUTHENTICATED",
592
+ refreshable: false
593
+ });
594
+ return (await this.store.load())?.tokens ?? next.tokens;
595
+ }
596
+ await this.store.save(next);
597
+ this.dispatch({
598
+ type: "refresh-succeeded",
599
+ session: next.session
600
+ });
601
+ this.scheduleRefresh(next);
602
+ return next.tokens;
603
+ });
604
+ }
605
+ handleRefreshError(error) {
606
+ if (error instanceof SessionRevokedError) {
607
+ this.applySignOut("revoked", true);
608
+ return;
609
+ }
610
+ if (error instanceof SessionExpiredError) {
611
+ this.applySignOut("expired", true);
612
+ return;
613
+ }
614
+ if (error instanceof UpstreamError || error instanceof Error) this.dispatch({ type: "refresh-failed-transient" });
615
+ }
616
+ async applySignOut(reason, broadcast) {
617
+ if (this.state.status === "signed-out") return;
618
+ this.generation += 1;
619
+ this.cancelScheduledRefresh();
620
+ await this.store.clear();
621
+ this.dispatch(signOutEvent(reason));
622
+ if (broadcast) this.channel.publish({
623
+ type: "signed-out",
624
+ reason
625
+ });
626
+ this.onSignOut?.(reason);
627
+ }
628
+ scheduleRefresh(stored) {
629
+ this.cancelScheduledRefresh();
630
+ const delay = stored.tokens.accessTokenExpiresAt - this.skewMs - this.clock.now();
631
+ this.refreshTimer = this.clock.setTimer(() => {
632
+ this.refresh().catch(() => void 0);
633
+ }, Math.max(0, delay));
634
+ }
635
+ cancelScheduledRefresh() {
636
+ if (this.refreshTimer !== null) {
637
+ this.clock.clearTimer(this.refreshTimer);
638
+ this.refreshTimer = null;
639
+ }
640
+ }
641
+ dispatch(event) {
642
+ const next = reduce(this.state, event);
643
+ if (next === this.state) return;
644
+ this.state = next;
645
+ for (const listener of [...this.subscribers]) listener(next);
646
+ }
647
+ };
648
+ function signOutEvent(reason) {
649
+ if (reason === "expired") return { type: "expired" };
650
+ if (reason === "revoked") return { type: "revoked" };
651
+ return { type: "signed-out" };
652
+ }
653
+ function createSessionEngine(options) {
654
+ return new SessionEngine(options);
655
+ }
656
+ //#endregion
657
+ //#region src/auth/pkce.ts
658
+ /**
659
+ * PKCE (RFC 7636) via Web Crypto — `crypto.subtle` / `crypto.getRandomValues`
660
+ * are universal (browser + Node ≥20), so core needs no runtime-specific import.
661
+ */
662
+ function base64UrlEncode(bytes) {
663
+ let binary = "";
664
+ for (const byte of bytes) binary += String.fromCharCode(byte);
665
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
666
+ }
667
+ /** S256 code challenge for a given verifier (exported for RFC test-vector coverage). */
668
+ async function computeChallenge(verifier) {
669
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
670
+ return base64UrlEncode(new Uint8Array(digest));
671
+ }
672
+ async function generatePkce() {
673
+ const bytes = /* @__PURE__ */ new Uint8Array(32);
674
+ crypto.getRandomValues(bytes);
675
+ const verifier = base64UrlEncode(bytes);
676
+ return {
677
+ verifier,
678
+ challenge: await computeChallenge(verifier),
679
+ method: "S256"
680
+ };
681
+ }
682
+ function randomState() {
683
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
684
+ crypto.getRandomValues(bytes);
685
+ return base64UrlEncode(bytes);
686
+ }
687
+ //#endregion
688
+ //#region src/auth/endpoints.ts
689
+ /**
690
+ * Every PolyX auth endpoint path in one place — the cross-repo contract surface
691
+ * with poly-x. Paths are relative to the deployment base URL (which already
692
+ * carries any deployment path prefix, e.g. `/poly-x`).
693
+ *
694
+ * Verified against poly-auth routes 2026-07-07: the IdP has no refresh grant;
695
+ * refresh is the legacy `/auth/refresh-token`.
696
+ */
697
+ const ENDPOINTS = {
698
+ authorize: "/v1/idp/authorize",
699
+ token: "/v1/idp/token",
700
+ refresh: "/v1/auth/refresh-token",
701
+ logout: "/v1/idp/logout",
702
+ orgResolve: "/v1/idp/org-resolve"
703
+ };
704
+ //#endregion
705
+ //#region src/auth/outcomes.ts
706
+ function asRecord$1(value) {
707
+ return value && typeof value === "object" ? value : {};
708
+ }
709
+ function parseTenants(value) {
710
+ if (!Array.isArray(value)) return [];
711
+ return value.map((raw) => {
712
+ const t = asRecord$1(raw);
713
+ return {
714
+ tenantId: String(t.tenantId ?? ""),
715
+ organizationCode: typeof t.organizationCode === "string" ? t.organizationCode : void 0,
716
+ slug: typeof t.slug === "string" ? t.slug : void 0,
717
+ name: typeof t.name === "string" ? t.name : void 0
718
+ };
719
+ });
720
+ }
721
+ function parseAuthorizeOutcome(status, body) {
722
+ const b = asRecord$1(body);
723
+ const statusField = typeof b.status === "string" ? b.status : void 0;
724
+ if (status === 403) return { type: "no_access" };
725
+ if (status === 401) return { type: "login_required" };
726
+ if (statusField === "authorized" && typeof b.code === "string") return {
727
+ type: "authorized",
728
+ code: b.code,
729
+ state: typeof b.state === "string" ? b.state : "",
730
+ redirectUri: typeof b.redirectUri === "string" ? b.redirectUri : void 0
731
+ };
732
+ if (statusField === "select_tenant") return {
733
+ type: "select_tenant",
734
+ state: typeof b.state === "string" ? b.state : "",
735
+ tenants: parseTenants(b.tenants)
736
+ };
737
+ if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
738
+ return { type: "login_required" };
739
+ }
740
+ //#endregion
741
+ //#region src/auth/normalize.ts
742
+ const FALLBACK_TTL_MS = 5 * 6e4;
743
+ function decodeJwtExp(token) {
744
+ const parts = token.split(".");
745
+ if (parts.length < 2 || !parts[1]) return null;
746
+ try {
747
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
748
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
749
+ const payload = JSON.parse(atob(padded));
750
+ return typeof payload.exp === "number" ? payload.exp * 1e3 : null;
751
+ } catch {
752
+ return null;
753
+ }
754
+ }
755
+ function toTokenSet(accessToken, refreshToken, now) {
756
+ return {
757
+ accessToken,
758
+ refreshToken,
759
+ accessTokenExpiresAt: decodeJwtExp(accessToken) ?? now + FALLBACK_TTL_MS
760
+ };
761
+ }
762
+ function asRecord(value) {
763
+ return value && typeof value === "object" ? value : {};
764
+ }
765
+ /** `POST /idp/token` → StoredSession. Claims mapping is minimal in v1 (userId + org); AUTHZ populates roles/permissions later. */
766
+ function normalizeExchange(body, now) {
767
+ const data = asRecord(asRecord(body).data);
768
+ const user = asRecord(data.user);
769
+ const userId = String(user._id ?? user.id ?? "");
770
+ if (userId.length === 0) throw new Error("PolyX token response is missing a user identity.");
771
+ const claims = {
772
+ userId,
773
+ organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
774
+ roles: [],
775
+ permissions: []
776
+ };
777
+ return {
778
+ tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
779
+ session: { claims }
780
+ };
781
+ }
782
+ /** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
783
+ function normalizeRefresh(body, current, now) {
784
+ const b = asRecord(body);
785
+ return {
786
+ tokens: toTokenSet(String(b.accessToken ?? ""), stringOrUndefined(b.refreshToken) ?? current.tokens.refreshToken, now),
787
+ session: current.session
788
+ };
789
+ }
790
+ function stringOrUndefined(value) {
791
+ return typeof value === "string" ? value : void 0;
792
+ }
793
+ //#endregion
794
+ //#region src/auth/pkce-store.ts
795
+ var InMemoryPkceStore = class {
796
+ entries = /* @__PURE__ */ new Map();
797
+ save(entry) {
798
+ this.entries.set(entry.state, entry);
799
+ return Promise.resolve();
800
+ }
801
+ take(state) {
802
+ const entry = this.entries.get(state) ?? null;
803
+ if (entry) this.entries.delete(state);
804
+ return Promise.resolve(entry);
805
+ }
806
+ };
807
+ //#endregion
808
+ //#region src/auth/client.ts
809
+ var AuthClient = class {
810
+ config;
811
+ transport;
812
+ clock;
813
+ constructor(options) {
814
+ this.config = options.config;
815
+ this.transport = options.transport;
816
+ this.clock = options.clock ?? new SystemClock();
817
+ }
818
+ buildAuthorizeUrl(params) {
819
+ const url = new URL(this.config.baseUrl + ENDPOINTS.authorize);
820
+ url.searchParams.set("response_type", "code");
821
+ url.searchParams.set("client", this.config.key.raw);
822
+ url.searchParams.set("redirect_uri", params.redirectUri);
823
+ url.searchParams.set("state", params.state);
824
+ url.searchParams.set("code_challenge", params.challenge);
825
+ url.searchParams.set("code_challenge_method", "S256");
826
+ if (params.organizationCode) url.searchParams.set("organizationCode", params.organizationCode);
827
+ if (params.prompt) url.searchParams.set("prompt", params.prompt);
828
+ return url.toString();
829
+ }
830
+ /** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
831
+ async completeAuthorize(params) {
832
+ const response = await this.transport.request({
833
+ method: "GET",
834
+ url: this.buildAuthorizeUrl(params)
835
+ });
836
+ if (response.status >= 500) throw this.toError(response);
837
+ return parseAuthorizeOutcome(response.status, response.body);
838
+ }
839
+ async exchangeCode(params) {
840
+ const response = await this.transport.request({
841
+ method: "POST",
842
+ url: this.config.baseUrl + ENDPOINTS.token,
843
+ body: {
844
+ grant_type: "authorization_code",
845
+ code: params.code,
846
+ code_verifier: params.codeVerifier,
847
+ redirect_uri: params.redirectUri,
848
+ client: this.config.key.raw
849
+ }
850
+ });
851
+ if (response.status !== 200) throw this.toError(response);
852
+ return normalizeExchange(response.body, this.clock.now());
853
+ }
854
+ /** The `RefreshTokens` function the SessionEngine (F003) injects. */
855
+ createRefresher() {
856
+ return async (current) => {
857
+ const response = await this.transport.request({
858
+ method: "POST",
859
+ url: this.config.baseUrl + ENDPOINTS.refresh,
860
+ body: current.tokens.refreshToken ? { refreshToken: current.tokens.refreshToken } : {}
861
+ });
862
+ if (response.status !== 200) throw this.toError(response);
863
+ return normalizeRefresh(response.body, current, this.clock.now());
864
+ };
865
+ }
866
+ async logout(scope = "device") {
867
+ const response = await this.transport.request({
868
+ method: "POST",
869
+ url: this.config.baseUrl + ENDPOINTS.logout,
870
+ body: { scope }
871
+ });
872
+ if (response.status >= 400) throw this.toError(response);
873
+ }
874
+ async resolveOrg(email) {
875
+ const response = await this.transport.request({
876
+ method: "POST",
877
+ url: this.config.baseUrl + ENDPOINTS.orgResolve,
878
+ body: { email }
879
+ });
880
+ if (response.status !== 200) throw this.toError(response);
881
+ const body = response.body ?? {};
882
+ return {
883
+ resolved: body.resolved === true,
884
+ organizationCode: typeof body.organizationCode === "string" ? body.organizationCode : void 0
885
+ };
886
+ }
887
+ toError(response) {
888
+ return mapPlatformError({
889
+ status: response.status,
890
+ body: response.body,
891
+ headers: response.headers
892
+ });
893
+ }
894
+ };
895
+ //#endregion
896
+ //#region src/index.ts
897
+ /**
898
+ * @poly-x/core — framework-agnostic guts of the PolyX SDK.
899
+ */
900
+ const PACKAGE_NAME = "@poly-x/core";
901
+ const version = "0.0.0";
902
+ //#endregion
903
+ exports.AuthClient = AuthClient;
904
+ exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
905
+ exports.DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
906
+ exports.ENDPOINTS = ENDPOINTS;
907
+ exports.FetchTransport = FetchTransport;
908
+ exports.ForbiddenError = ForbiddenError;
909
+ exports.INITIAL_SESSION_STATE = INITIAL_STATE;
910
+ exports.InMemoryLock = InMemoryLock;
911
+ exports.InMemoryPkceStore = InMemoryPkceStore;
912
+ exports.InMemorySessionChannel = InMemorySessionChannel;
913
+ exports.InMemorySessionStore = InMemorySessionStore;
914
+ exports.MockTransport = MockTransport;
915
+ exports.PACKAGE_NAME = PACKAGE_NAME;
916
+ exports.PolyXConfigError = PolyXConfigError;
917
+ exports.PolyXError = PolyXError;
918
+ exports.RateLimitedError = RateLimitedError;
919
+ exports.SessionEngine = SessionEngine;
920
+ exports.SessionExpiredError = SessionExpiredError;
921
+ exports.SessionRevokedError = SessionRevokedError;
922
+ exports.SystemClock = SystemClock;
923
+ exports.UnauthenticatedError = UnauthenticatedError;
924
+ exports.UpstreamError = UpstreamError;
925
+ exports.ValidationError = ValidationError;
926
+ exports.computeChallenge = computeChallenge;
927
+ exports.createSessionEngine = createSessionEngine;
928
+ exports.decodeJwtExp = decodeJwtExp;
929
+ exports.generatePkce = generatePkce;
930
+ exports.isTransientStatus = isTransientStatus;
931
+ exports.mapPlatformError = mapPlatformError;
932
+ exports.normalizeExchange = normalizeExchange;
933
+ exports.normalizeRefresh = normalizeRefresh;
934
+ exports.parseAuthorizeOutcome = parseAuthorizeOutcome;
935
+ exports.parsePolyXKey = parsePolyXKey;
936
+ exports.randomState = randomState;
937
+ exports.redactSensitive = redactSensitive;
938
+ exports.reduceSessionState = reduce;
939
+ exports.resolveConfig = resolveConfig;
940
+ exports.version = version;