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