@poly-x/next 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.cjs CHANGED
@@ -1,7 +1,197 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_server_session = require("./server-session-Df9HXl_5.cjs");
2
+ const require_session_cookie = require("./session-cookie-BQD10B3D.cjs");
3
3
  let _poly_x_core = require("@poly-x/core");
4
4
  let next_headers = require("next/headers");
5
+ //#region src/cookie-codec.ts
6
+ /**
7
+ * AES-GCM sealing of the session cookie (F007 / ADR-0004). The key is derived
8
+ * (SHA-256) from the consumer's `POLYX_SESSION_SECRET`; AES-GCM gives
9
+ * confidentiality + tamper-detection together, so any corruption, truncation,
10
+ * or wrong-secret input decrypts to `null` rather than a partial session.
11
+ * Web Crypto is universal (Node ≥20 + edge runtimes), so this runs anywhere a
12
+ * Next route handler runs.
13
+ */
14
+ const IV_BYTES = 12;
15
+ /**
16
+ * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
17
+ * name, value and attributes together — and enforce it by DISCARDING the cookie without
18
+ * an error. For a session cookie that failure is invisible and badly misleading: the
19
+ * platform authenticates, the handler answers 200, and the user is bounced back to
20
+ * sign-in on the next request because the cookie was never stored.
21
+ *
22
+ * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
23
+ * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
24
+ * the line. We fail loudly here instead.
25
+ */
26
+ const SESSION_COOKIE_MAX_BYTES = 4096;
27
+ /** Attributes `buildSessionCookieOptions` always sets: `Path=/; HttpOnly; Secure; SameSite=Lax`. */
28
+ const COOKIE_ATTRIBUTE_BYTES = 45;
29
+ /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
30
+ function sealedCookieByteLength(value, cookieName = "polyx_session") {
31
+ return new TextEncoder().encode(cookieName).length + 1 + value.length + COOKIE_ATTRIBUTE_BYTES;
32
+ }
33
+ /**
34
+ * Thrown when a sealed session would exceed what a browser will store. Never includes the
35
+ * session itself — the claims that caused the overflow are exactly the data we must not log.
36
+ */
37
+ var SessionCookieTooLargeError = class extends _poly_x_core.PolyXError {
38
+ bytes;
39
+ limit;
40
+ constructor(bytes, limit, cookieName) {
41
+ super(`PolyX session cookie is ${bytes} bytes, over the ~${limit}-byte browser limit for "${cookieName}". The browser would discard it silently and the user would appear signed out. Reduce what the platform puts in the session — the usual cause is a tenant configured to embed full permissions in the JWT (includeFullPermissionInJwt).`, { code: "SESSION_COOKIE_TOO_LARGE" });
42
+ this.bytes = bytes;
43
+ this.limit = limit;
44
+ }
45
+ };
46
+ function base64UrlEncode(bytes) {
47
+ let binary = "";
48
+ for (const byte of bytes) binary += String.fromCharCode(byte);
49
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
50
+ }
51
+ function base64UrlDecode(text) {
52
+ const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
53
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
54
+ const binary = atob(padded);
55
+ const bytes = new Uint8Array(binary.length);
56
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
57
+ return bytes;
58
+ }
59
+ async function deriveKey(secret) {
60
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
61
+ return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt", "decrypt"]);
62
+ }
63
+ async function sealSession(session, secret, options = {}) {
64
+ const key = await deriveKey(secret);
65
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
66
+ const plaintext = new TextEncoder().encode(JSON.stringify(session));
67
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
68
+ name: "AES-GCM",
69
+ iv
70
+ }, key, plaintext));
71
+ const packed = new Uint8Array(iv.length + ciphertext.length);
72
+ packed.set(iv);
73
+ packed.set(ciphertext, iv.length);
74
+ const sealed = base64UrlEncode(packed);
75
+ const cookieName = options.cookieName ?? "polyx_session";
76
+ const bytes = sealedCookieByteLength(sealed, cookieName);
77
+ if (bytes > 4096) throw new SessionCookieTooLargeError(bytes, SESSION_COOKIE_MAX_BYTES, cookieName);
78
+ return sealed;
79
+ }
80
+ function nonEmptyString(value) {
81
+ return typeof value === "string" && value.length > 0;
82
+ }
83
+ /**
84
+ * Does this decrypt to something every caller can safely use?
85
+ *
86
+ * Decryption proving *authenticity* is not the same as proving *shape*. A cookie sealed with the
87
+ * right secret by a different `StoredSession` version is authentic and unusable — and the SDK is
88
+ * pre-1.0, so that shape has already grown once and will again.
89
+ *
90
+ * It matters because eight call sites reach straight through `session.session.claims`, including
91
+ * `toAuthContext` behind `auth()`. Handing back a malformed object turns each into a TypeError, and
92
+ * the failure does not self-heal: `read()` *succeeded*, so nothing clears the cookie and the person
93
+ * gets a 500 on every request until they clear it by hand. Reporting `null` instead routes into the
94
+ * signed-out path that already exists, which re-authenticates and replaces the cookie.
95
+ *
96
+ * Checked here rather than at each call site because this is the one place every read passes
97
+ * through — a guard at the boundary cannot be forgotten by the ninth caller.
98
+ */
99
+ function isUsableSession(value) {
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
101
+ const { tokens, session } = value;
102
+ if (!tokens || typeof tokens !== "object") return false;
103
+ if (!nonEmptyString(tokens.accessToken)) return false;
104
+ if (!session || typeof session !== "object") return false;
105
+ const claims = session.claims;
106
+ if (!claims || typeof claims !== "object") return false;
107
+ return nonEmptyString(claims.userId);
108
+ }
109
+ async function openSession(token, secret) {
110
+ try {
111
+ const packed = base64UrlDecode(token);
112
+ if (packed.length <= IV_BYTES) return null;
113
+ const iv = packed.slice(0, IV_BYTES);
114
+ const ciphertext = packed.slice(IV_BYTES);
115
+ const key = await deriveKey(secret);
116
+ const plaintext = await crypto.subtle.decrypt({
117
+ name: "AES-GCM",
118
+ iv
119
+ }, key, ciphertext);
120
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
121
+ return isUsableSession(parsed) ? parsed : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ //#endregion
127
+ //#region src/auth-event.ts
128
+ /**
129
+ * One-shot auth events that survive a navigation (F017 / FR-CLIENT-5).
130
+ *
131
+ * Sign-in and sign-out are full-page navigations, so anything an app queues before them —
132
+ * a toast, an analytics call — is destroyed when the page unloads. Odin built ~80 lines of
133
+ * cookie plumbing to show "Logged in successfully"; this absorbs the shape.
134
+ *
135
+ * A cookie rather than a query parameter on the redirect target: the destination may
136
+ * redirect again before it renders (a route gate sending an unauthorised user elsewhere,
137
+ * for one), which drops the query and the event with it. A cookie survives the chain.
138
+ *
139
+ * It carries no secret — it exists to be read by page script, which is why it is
140
+ * deliberately NOT httpOnly. Never put anything in it that the browser should not see.
141
+ */
142
+ /** The cookie the SDK publishes a one-shot auth event in. Readable by script, by design. */
143
+ const AUTH_EVENT_COOKIE = "polyx_auth_event";
144
+ /** Serialize the `Set-Cookie` value for a one-shot event. */
145
+ function buildAuthEventCookie(kind, secure) {
146
+ return `${AUTH_EVENT_COOKIE}=${kind}; Path=/; Max-Age=30; SameSite=Lax${secure ? "; Secure" : ""}`;
147
+ }
148
+ //#endregion
149
+ //#region src/public-origin.ts
150
+ /** First entry of a comma-separated proxy chain, matching how `resolveCookieSecure` reads proto. */
151
+ function firstHop(value) {
152
+ const first = value?.split(",")[0]?.trim();
153
+ return first && first.length > 0 ? first : void 0;
154
+ }
155
+ /**
156
+ * A forwarded host must be `hostname[:port]` (or a bracketed IPv6 literal) and nothing
157
+ * else. Without this check a value like `http://[bad` concatenates into
158
+ * `https://http://[bad`, which the URL parser happily reads as host `http` and yields the
159
+ * nonsense origin `https://http` — a broken redirect URI produced from hostile input
160
+ * rather than a clean fall-back.
161
+ */
162
+ const VALID_FORWARDED_HOST = /^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9._-]+)(?::\d{1,5})?$/;
163
+ /** `origin` of a URL, or undefined when it will not parse. */
164
+ function originOf(candidate) {
165
+ try {
166
+ const url = new URL(candidate);
167
+ return url.origin && url.origin !== "null" ? url.origin : void 0;
168
+ } catch {
169
+ return;
170
+ }
171
+ }
172
+ /**
173
+ * Resolve the origin the browser actually reached this app on.
174
+ *
175
+ * Precedence: explicit configuration → forwarded headers (only when trusted) → the
176
+ * request URL. Every step falls through on malformed input rather than emitting a broken
177
+ * origin, because a broken `redirect_uri` fails at the platform with a message about
178
+ * credentials rather than configuration.
179
+ */
180
+ function resolvePublicOrigin(request, options = {}) {
181
+ if (options.publicOrigin) {
182
+ const configured = originOf(options.publicOrigin);
183
+ if (configured) return configured;
184
+ }
185
+ if (options.trustForwardedOrigin) {
186
+ const host = firstHop(request.headers.get("x-forwarded-host"));
187
+ if (host && VALID_FORWARDED_HOST.test(host)) {
188
+ const forwarded = originOf(`${firstHop(request.headers.get("x-forwarded-proto")) ?? originOf(request.url)?.split(":")[0] ?? "https"}://${host}`);
189
+ if (forwarded) return forwarded;
190
+ }
191
+ }
192
+ return originOf(request.url) ?? "";
193
+ }
194
+ //#endregion
5
195
  //#region src/config.ts
6
196
  /** Parse a boolean-ish env var; undefined when unset/unrecognized (→ auto-derive). */
7
197
  function parseBoolEnv(value) {
@@ -20,7 +210,10 @@ function resolveNextConfig(overrides = {}) {
20
210
  secret,
21
211
  baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
22
212
  signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
23
- cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
213
+ cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE),
214
+ publicOrigin: overrides.publicOrigin ?? process.env.POLYX_PUBLIC_ORIGIN,
215
+ trustForwardedOrigin: overrides.trustForwardedOrigin ?? parseBoolEnv(process.env.POLYX_TRUST_FORWARDED_ORIGIN) ?? false,
216
+ realtimeBaseUrl: overrides.realtimeBaseUrl ?? process.env.POLYX_REALTIME_URL
24
217
  };
25
218
  }
26
219
  function buildServerAuthClient(config) {
@@ -35,6 +228,503 @@ function buildServerAuthClient(config) {
35
228
  });
36
229
  }
37
230
  //#endregion
231
+ //#region src/cookie-adapter.ts
232
+ /**
233
+ * Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
234
+ * write time from the actual transport: a `Secure` cookie is silently dropped by the
235
+ * browser over plain http, so http/localhost dev must set `secure:false` while https
236
+ * always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
237
+ * http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
238
+ */
239
+ function buildSessionCookieOptions(secure) {
240
+ return {
241
+ httpOnly: true,
242
+ secure,
243
+ sameSite: "lax",
244
+ path: "/"
245
+ };
246
+ }
247
+ //#endregion
248
+ //#region src/server-session.ts
249
+ /**
250
+ * The framework-agnostic BFF session controller (F007): tokens sealed in an
251
+ * httpOnly cookie on the consumer's domain, exchanged/refreshed server-side.
252
+ * Composes core's AuthClient with a CookieAdapter seam; fully unit-testable.
253
+ */
254
+ var ServerSession = class {
255
+ authClient;
256
+ secret;
257
+ cookies;
258
+ cookieName;
259
+ secure;
260
+ constructor(options) {
261
+ this.authClient = options.authClient;
262
+ this.secret = options.secret;
263
+ this.cookies = options.cookies;
264
+ this.cookieName = options.cookieName ?? "polyx_session";
265
+ this.secure = options.secure ?? true;
266
+ }
267
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
268
+ const stored = await this.authClient.exchangeCode({
269
+ code,
270
+ codeVerifier,
271
+ redirectUri,
272
+ forwardedHeaders
273
+ });
274
+ await this.write(stored);
275
+ return stored;
276
+ }
277
+ async read() {
278
+ const raw = this.cookies.get(this.cookieName);
279
+ if (!raw) return null;
280
+ return openSession(raw, this.secret);
281
+ }
282
+ /**
283
+ * Re-seal the session with an updated **display** subset — name, email, avatar (ADR-0006).
284
+ *
285
+ * Needed because `normalizeRefresh` carries claims forward unchanged: the display claims are
286
+ * derived once, at login, and never again. So after a profile save the session still describes
287
+ * the person as they were when they signed in, and every surface reading `useUser()` shows the
288
+ * old name and photo until they sign out and back in (FR-PROF-5).
289
+ *
290
+ * Deliberately narrow. `userId` and `organizationId` are authorization-relevant and are **not**
291
+ * writable here — a profile save must never become a route to changing who the session is for,
292
+ * whatever the platform echoes back. Only the three display fields move.
293
+ *
294
+ * Returns `false` rather than throwing when the session cannot be re-sealed (an oversized
295
+ * cookie, a missing session): the caller's own operation has usually already succeeded
296
+ * upstream, and failing it because the *display* copy could not be updated would report a
297
+ * successful save as a failure.
298
+ */
299
+ async updateDisplayClaims(patch) {
300
+ const current = await this.read();
301
+ if (!current) return false;
302
+ const next = {
303
+ ...current,
304
+ session: {
305
+ ...current.session,
306
+ claims: {
307
+ ...current.session.claims,
308
+ ...patch.displayName !== void 0 ? { displayName: patch.displayName } : {},
309
+ ...patch.email !== void 0 ? { email: patch.email } : {},
310
+ ...patch.avatarUrl !== void 0 ? { avatarUrl: patch.avatarUrl } : {}
311
+ }
312
+ }
313
+ };
314
+ try {
315
+ await this.write(next);
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+ /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
322
+ async refresh() {
323
+ const current = await this.read();
324
+ if (!current) return null;
325
+ try {
326
+ const next = await this.authClient.createRefresher()(current);
327
+ await this.write(next);
328
+ return next;
329
+ } catch (error) {
330
+ if (error instanceof _poly_x_core.SessionRevokedError || error instanceof _poly_x_core.SessionExpiredError) {
331
+ this.clear();
332
+ return null;
333
+ }
334
+ if (error instanceof SessionCookieTooLargeError) {
335
+ console.error(`[polyx] ${error.message}`);
336
+ this.clear();
337
+ return null;
338
+ }
339
+ throw error;
340
+ }
341
+ }
342
+ /**
343
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
344
+ * revokes every session for the user (FR-SSO-5).
345
+ *
346
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
347
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
348
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
349
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
350
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
351
+ * nothing and answered 200.
352
+ */
353
+ async revoke(scope = "device") {
354
+ const current = await this.read();
355
+ if (current) try {
356
+ await this.authClient.revokeSession(current.tokens.accessToken, scope);
357
+ } catch {}
358
+ this.clear();
359
+ }
360
+ clear() {
361
+ this.cookies.delete(this.cookieName);
362
+ }
363
+ async write(session) {
364
+ const sealed = await sealSession(session, this.secret, { cookieName: this.cookieName });
365
+ this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
366
+ }
367
+ };
368
+ function toAuthContext(session) {
369
+ if (!session) return {
370
+ isSignedIn: false,
371
+ userId: null,
372
+ claims: null
373
+ };
374
+ return {
375
+ isSignedIn: true,
376
+ userId: session.session.claims.userId,
377
+ organizationId: session.session.claims.organizationId,
378
+ claims: session.session.claims
379
+ };
380
+ }
381
+ //#endregion
382
+ //#region src/session-context.ts
383
+ /**
384
+ * Session context (F015 / FR-SESS-2…6).
385
+ *
386
+ * The sealed session carries only a bounded display subset (`poly-x-sdk/ADR-0006`) —
387
+ * correct, because bulk authorization must not live in a cookie. But it left every app
388
+ * needing more than `{userId, organizationId}` to assemble it themselves, and both did:
389
+ * an `/api/polyx/context` route plus a hand-written profile fetch against poly-auth,
390
+ * roughly 170 lines each. The app was issuing platform API calls the SDK should make.
391
+ *
392
+ * The fix is that the SDK makes the same call — NOT that authorization data moves into
393
+ * the session. ADR-0006 is unchanged and apps keep deriving their own route gating.
394
+ *
395
+ * One deliberate improvement over the copies being absorbed: they returned `null` on any
396
+ * failure, which conflates "the directory was briefly unavailable" with "this user has no
397
+ * profile". An app cannot tell those apart, and the safe-looking reaction — sign the user
398
+ * out — is wrong for the first. Here the two are distinct.
399
+ */
400
+ const SIGNED_OUT = {
401
+ isSignedIn: false,
402
+ userId: null,
403
+ claims: null,
404
+ user: null
405
+ };
406
+ /**
407
+ * Fetch the platform user profile with the session's own credential.
408
+ *
409
+ * `organization-id` is required, not optional: poly-auth rejects the call outright
410
+ * without it, and it is what scopes the returned permission to the workspace the user
411
+ * actually signed in to.
412
+ */
413
+ async function fetchProfile(baseUrl, userId, accessToken, organizationId) {
414
+ try {
415
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/user/${encodeURIComponent(userId)}`, { headers: {
416
+ Authorization: `Bearer ${accessToken}`,
417
+ "organization-id": organizationId,
418
+ Accept: "application/json"
419
+ } });
420
+ if (response.status === 401 || response.status === 403) return {
421
+ user: null,
422
+ status: "unavailable",
423
+ unauthorized: true
424
+ };
425
+ if (!response.ok) return {
426
+ user: null,
427
+ status: "unavailable",
428
+ unauthorized: false
429
+ };
430
+ const body = await response.json();
431
+ return {
432
+ user: body.data ?? body ?? null,
433
+ status: "ok",
434
+ unauthorized: false
435
+ };
436
+ } catch {
437
+ return {
438
+ user: null,
439
+ status: "unavailable",
440
+ unauthorized: false
441
+ };
442
+ }
443
+ }
444
+ function adaptStore$1(store) {
445
+ return {
446
+ get: (name) => store.get(name)?.value,
447
+ set: (name, value, options) => store.set(name, value, options),
448
+ delete: (name) => store.delete(name)
449
+ };
450
+ }
451
+ /**
452
+ * Read who is signed in, including the platform profile.
453
+ *
454
+ * Safe in a Server Component: it reads the session and never writes it. The access
455
+ * credential is used to call the platform and is never part of the result (FR-SESS-5).
456
+ */
457
+ async function getSessionContext(options = {}) {
458
+ const config = resolveNextConfig(options);
459
+ const current = await new ServerSession({
460
+ authClient: buildServerAuthClient(config),
461
+ secret: config.secret,
462
+ cookies: adaptStore$1(await (0, next_headers.cookies)())
463
+ }).read();
464
+ if (!current) return SIGNED_OUT;
465
+ const { claims } = current.session;
466
+ const base = {
467
+ isSignedIn: true,
468
+ userId: claims.userId,
469
+ organizationId: claims.organizationId,
470
+ claims,
471
+ user: null
472
+ };
473
+ if (options.skipProfile) return base;
474
+ if (!claims.organizationId) return {
475
+ ...base,
476
+ profileStatus: "no_organization"
477
+ };
478
+ if (!config.baseUrl) return {
479
+ ...base,
480
+ profileStatus: "unavailable"
481
+ };
482
+ const result = await fetchProfile(config.baseUrl, claims.userId, current.tokens.accessToken, claims.organizationId);
483
+ if (result.unauthorized) return SIGNED_OUT;
484
+ return {
485
+ ...base,
486
+ user: result.user,
487
+ profileStatus: result.status
488
+ };
489
+ }
490
+ //#endregion
491
+ //#region src/handler.ts
492
+ /**
493
+ * One-declaration route mounting (F012 / FR-MOUNT-1…5).
494
+ *
495
+ * `createAuthHandlers()` returns nine handler methods and mounts nothing, so every
496
+ * consuming app hand-wrote its own action→handler switch plus the GET/POST split —
497
+ * ~100 lines, twice, identically. Worse, the published example
498
+ * (`export const { signin: GET } = createAuthHandlers()`) cannot dispatch by action at
499
+ * all: it wires exactly one action and the rest fail at runtime.
500
+ *
501
+ * This is a thin dispatcher over the existing handlers, not a replacement.
502
+ * `createAuthHandlers()` stays exported and unchanged for apps that already wired it.
503
+ */
504
+ /**
505
+ * URL segment → handler key. The segments are kebab-case (what a developer types in a
506
+ * path); the handler keys are camelCase (what the object exposes). Keeping the map
507
+ * explicit means a new handler cannot be reached by an unintended segment spelling.
508
+ */
509
+ const POLYX_ACTIONS = {
510
+ signin: "signin",
511
+ authenticate: "authenticate",
512
+ "forgot-password": "forgotPassword",
513
+ "reset-password": "resetPassword",
514
+ callback: "callback",
515
+ refresh: "refresh",
516
+ signout: "signout",
517
+ session: "session",
518
+ "subscription-credential": "subscriptionCredential",
519
+ "realtime-ticket": "realtimeTicket",
520
+ profile: "profile"
521
+ };
522
+ /**
523
+ * Allowed methods per action, stated rather than inferred.
524
+ *
525
+ * `signout` accepts GET as well as POST because signing out has to be a full-page
526
+ * navigation: a client-side redirect leaves the httpOnly cookie in place and the route
527
+ * gate bounces the user straight back in. Both consumers hard-code a
528
+ * `window.location` sign-out for exactly this reason.
529
+ */
530
+ const POLYX_ACTION_METHODS = {
531
+ signin: ["GET"],
532
+ callback: ["GET"],
533
+ session: ["GET"],
534
+ "subscription-credential": ["GET"],
535
+ "realtime-ticket": ["GET"],
536
+ authenticate: ["POST"],
537
+ "forgot-password": ["POST"],
538
+ "reset-password": ["POST"],
539
+ refresh: ["POST"],
540
+ profile: ["GET", "POST"],
541
+ signout: ["GET", "POST"]
542
+ };
543
+ function jsonError$1(error, status) {
544
+ return Response.json({ error }, { status });
545
+ }
546
+ /**
547
+ * Resolve the action from the ROUTE PARAMS, never from the request URL.
548
+ *
549
+ * The handler must not trust a path it did not route: the framework tells us what it
550
+ * matched, and a URL string can disagree with that (rewrites, proxies, or a crafted
551
+ * request). Exactly one segment is required, so `/api/polyx/session/extra` is a 404
552
+ * rather than silently resolving to `session`.
553
+ */
554
+ function resolveActionSegment(params) {
555
+ const values = Object.values(params);
556
+ if (values.length !== 1) return null;
557
+ const segments = values[0];
558
+ if (!Array.isArray(segments)) return typeof segments === "string" ? segments : null;
559
+ if (segments.length !== 1) return null;
560
+ return segments[0] ?? null;
561
+ }
562
+ function isKnownAction(segment) {
563
+ return Object.prototype.hasOwnProperty.call(POLYX_ACTIONS, segment);
564
+ }
565
+ /**
566
+ * Mount every SDK auth route from one declaration:
567
+ *
568
+ * ```ts
569
+ * // app/api/polyx/[...polyx]/route.ts
570
+ * export const { GET, POST } = createPolyXHandler();
571
+ * ```
572
+ *
573
+ * `handlers` is injectable for tests; production callers pass only the config.
574
+ */
575
+ function createPolyXHandler(config = {}, handlers = createAuthHandlers(config)) {
576
+ async function dispatch(request, context, method) {
577
+ const segment = resolveActionSegment(await context.params);
578
+ if (segment === null || !isKnownAction(segment)) return jsonError$1("unknown_action", 404);
579
+ if (!POLYX_ACTION_METHODS[segment].includes(method)) return jsonError$1("method_not_allowed", 405);
580
+ return handlers[POLYX_ACTIONS[segment]](request);
581
+ }
582
+ return {
583
+ GET: (request, context) => dispatch(request, context, "GET"),
584
+ POST: (request, context) => dispatch(request, context, "POST")
585
+ };
586
+ }
587
+ //#endregion
588
+ //#region src/data-proxy.ts
589
+ /**
590
+ * The authenticated data path (F014 / FR-DATA-1…10).
591
+ *
592
+ * Under BFF custody the browser holds no credential, so every authenticated call to an
593
+ * app's own backend has to be proxied server-side. The SDK never shipped that half, even
594
+ * though the tech discovery that chose this custody model said it would ("proxies/
595
+ * decorates outbound PolyX calls"). So both live integrations wrote it themselves — and
596
+ * the result is byte-for-byte identical in each, ~250 lines of the subtlest code in the
597
+ * whole integration, sitting in two places to drift apart.
598
+ *
599
+ * This is a PORT of that proven implementation, not a rewrite: it has run in production
600
+ * in two apps. Two things are deliberately better here:
601
+ *
602
+ * 1. Refresh is IN-PROCESS (`refreshSession`, F013). The consumers had to `fetch` their
603
+ * own `/api/polyx/refresh` route and re-parse the returned `Set-Cookie` to recover
604
+ * the new token — a network round trip through their own edge, and a fragile parse.
605
+ * 2. Rotation needs no manual propagation. `refreshSession` writes through Next's cookie
606
+ * store, so the refreshed cookie lands on the response by itself; the consumers had
607
+ * to append the re-parsed `Set-Cookie` by hand.
608
+ */
609
+ /** Never copied from the browser request onto the upstream call. */
610
+ const STRIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
611
+ "host",
612
+ "connection",
613
+ "content-length",
614
+ "cookie",
615
+ "authorization"
616
+ ]);
617
+ /** Never copied from the upstream response back to the browser. */
618
+ const STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
619
+ "content-encoding",
620
+ "content-length",
621
+ "transfer-encoding",
622
+ "connection",
623
+ "set-cookie"
624
+ ]);
625
+ function jsonError(message, status) {
626
+ return Response.json({ message }, { status });
627
+ }
628
+ function trailingSlashOff(base) {
629
+ return base.replace(/\/$/, "");
630
+ }
631
+ /** The catch-all segments, whatever the route names its parameter. */
632
+ function pathSegments(params) {
633
+ for (const value of Object.values(params)) if (Array.isArray(value)) return value;
634
+ return [];
635
+ }
636
+ /**
637
+ * Read the sealed session's access credential. Server-side only — this value must never
638
+ * be handed to the browser.
639
+ */
640
+ async function readAccessToken(store, config) {
641
+ return (await new ServerSession({
642
+ authClient: buildServerAuthClient(config),
643
+ secret: config.secret,
644
+ cookies: store
645
+ }).read())?.tokens.accessToken ?? null;
646
+ }
647
+ function adaptStore(store) {
648
+ return {
649
+ get: (name) => store.get(name)?.value,
650
+ set: (name, value, options) => store.set(name, value, options),
651
+ delete: (name) => store.delete(name)
652
+ };
653
+ }
654
+ /**
655
+ * Mount an authenticated proxy to one declared backend:
656
+ *
657
+ * ```ts
658
+ * // app/api/billing/proxy/[...path]/route.ts
659
+ * export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
660
+ * upstream: process.env.BILLING_URL!,
661
+ * label: "Billing",
662
+ * });
663
+ * ```
664
+ *
665
+ * The client keeps calling a same-origin path; the credential is attached on this side of
666
+ * it and never reaches page scripts.
667
+ */
668
+ function createDataProxy(options) {
669
+ const label = options.label ?? "PolyX";
670
+ async function handle(request, context) {
671
+ const upstreamBase = typeof options.upstream === "function" ? options.upstream() : options.upstream;
672
+ if (!upstreamBase) return jsonError(`${label} proxy misconfigured: no upstream URL configured.`, 500);
673
+ let config;
674
+ try {
675
+ config = resolveNextConfig(options);
676
+ } catch {
677
+ return jsonError(`${label} proxy misconfigured: PolyX configuration is incomplete.`, 500);
678
+ }
679
+ const accessToken = await readAccessToken(adaptStore(await (0, next_headers.cookies)()), config);
680
+ if (!accessToken) return jsonError("Not authenticated.", 401);
681
+ const incoming = new URL(request.url);
682
+ const target = `${trailingSlashOff(upstreamBase)}/${pathSegments(await context.params).map(encodeURIComponent).join("/")}${incoming.search}`;
683
+ const baseHeaders = new Headers();
684
+ request.headers.forEach((value, key) => {
685
+ if (!STRIP_REQUEST_HEADERS.has(key.toLowerCase())) baseHeaders.set(key, value);
686
+ });
687
+ const method = request.method.toUpperCase();
688
+ const body = method !== "GET" && method !== "HEAD" ? await request.arrayBuffer() : void 0;
689
+ const send = (token) => {
690
+ const headers = new Headers(baseHeaders);
691
+ headers.set("Authorization", `Bearer ${token}`);
692
+ return fetch(target, {
693
+ method,
694
+ headers,
695
+ body,
696
+ redirect: "manual"
697
+ });
698
+ };
699
+ let upstream = await send(accessToken);
700
+ if (upstream.status === 401) {
701
+ const refreshed = await refreshSession({
702
+ ...options,
703
+ request
704
+ }).catch(() => null);
705
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return jsonError("Not authenticated.", 401);
706
+ upstream = await send(refreshed.accessToken);
707
+ }
708
+ const responseHeaders = new Headers();
709
+ upstream.headers.forEach((value, key) => {
710
+ if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) responseHeaders.set(key, value);
711
+ });
712
+ return new Response(upstream.body, {
713
+ status: upstream.status,
714
+ statusText: upstream.statusText,
715
+ headers: responseHeaders
716
+ });
717
+ }
718
+ return {
719
+ GET: handle,
720
+ POST: handle,
721
+ PUT: handle,
722
+ PATCH: handle,
723
+ DELETE: handle,
724
+ HEAD: handle
725
+ };
726
+ }
727
+ //#endregion
38
728
  //#region src/server.ts
39
729
  /**
40
730
  * @poly-x/next/server — App Router server helpers (F007). `auth()` reads the
@@ -46,6 +736,25 @@ const VERIFIER_COOKIE = "polyx_pkce";
46
736
  const VERIFIER_MAX_AGE = 600;
47
737
  /** poly-auth's own floor (Joi: min 6, max 128) — reject early rather than round-trip. */
48
738
  const MIN_PASSWORD_LENGTH = 6;
739
+ /**
740
+ * Narrow the platform's user record to what the profile surface needs.
741
+ *
742
+ * Relaying the whole record would leak whatever else poly-auth happens to return alongside it —
743
+ * role and permission references, internal flags, fields added by a future release nobody
744
+ * revisited this code for. An allow-list means new upstream fields stay server-side by default.
745
+ */
746
+ function pickProfileFields(user) {
747
+ const picked = {};
748
+ for (const field of [
749
+ ..._poly_x_core.PROFILE_FIELDS,
750
+ "profilePicture",
751
+ "email"
752
+ ]) {
753
+ const value = user[field];
754
+ if (typeof value === "string") picked[field] = value;
755
+ }
756
+ return picked;
757
+ }
49
758
  function adapt(store) {
50
759
  return {
51
760
  get: (name) => store.get(name)?.value,
@@ -148,17 +857,68 @@ function deviceContextHeadersFromBody(deviceContext) {
148
857
  /** Read the current session — safe in Server Components (no cookie write). */
149
858
  async function auth(overrides) {
150
859
  const config = resolveNextConfig(overrides);
151
- return require_server_session.toAuthContext(await new require_server_session.ServerSession({
860
+ return toAuthContext(await new ServerSession({
152
861
  authClient: buildServerAuthClient(config),
153
862
  secret: config.secret,
154
863
  cookies: await requestCookies()
155
864
  }).read());
156
865
  }
866
+ /**
867
+ * Single-flight keyed on the per-request cookie store. Next hands back the same store
868
+ * object for the life of a request, so concurrent callers within one request coalesce,
869
+ * while unrelated users in the same server process never serialise behind each other —
870
+ * which a module-level lock would have caused.
871
+ */
872
+ const inFlightRefresh = /* @__PURE__ */ new WeakMap();
873
+ /**
874
+ * Refresh the current session from server code (F013 / FR-SESS-1).
875
+ *
876
+ * Before this existed there was no supported way to do it, so both live integrations
877
+ * did something worse: the server issued an HTTP request to its OWN
878
+ * `/api/polyx/refresh` route and then re-parsed the returned `Set-Cookie` header to
879
+ * recover the new token. That is a network round trip through the app's own edge to do
880
+ * something already possible in-process, and the header re-parse is fragile.
881
+ *
882
+ * Terminal-vs-transient classification lives in `ServerSession.refresh()` and is not
883
+ * duplicated here: a revoked family or an unstorable session signs the user out, and a
884
+ * transient upstream failure propagates so the caller can fail closed.
885
+ */
886
+ async function refreshSession(options = {}) {
887
+ const store = await (0, next_headers.cookies)();
888
+ const pending = inFlightRefresh.get(store);
889
+ if (pending) return pending;
890
+ const run = (async () => {
891
+ const config = resolveNextConfig(options);
892
+ const secure = options.request ? resolveCookieSecure(options.request, config) : config.cookieSecure ?? true;
893
+ const next = await new ServerSession({
894
+ authClient: buildServerAuthClient(config),
895
+ secret: config.secret,
896
+ cookies: adapt(store),
897
+ secure
898
+ }).refresh();
899
+ if (!next) return {
900
+ isSignedIn: false,
901
+ accessToken: null,
902
+ claims: null
903
+ };
904
+ return {
905
+ isSignedIn: true,
906
+ accessToken: next.tokens.accessToken,
907
+ claims: next.session.claims
908
+ };
909
+ })();
910
+ inFlightRefresh.set(store, run);
911
+ try {
912
+ return await run;
913
+ } finally {
914
+ inFlightRefresh.delete(store);
915
+ }
916
+ }
157
917
  function createAuthHandlers(handlersConfig = {}) {
158
918
  const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
159
919
  const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
160
920
  function newSession(store, config, secure) {
161
- return new require_server_session.ServerSession({
921
+ return new ServerSession({
162
922
  authClient: buildServerAuthClient(config),
163
923
  secret: config.secret,
164
924
  cookies: store,
@@ -169,7 +929,7 @@ function createAuthHandlers(handlersConfig = {}) {
169
929
  async signin(request) {
170
930
  const config = resolveNextConfig(handlersConfig);
171
931
  const secure = resolveCookieSecure(request, config);
172
- const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
932
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
173
933
  const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
174
934
  const state = (0, _poly_x_core.randomState)();
175
935
  (await (0, next_headers.cookies)()).set(VERIFIER_COOKIE, JSON.stringify({
@@ -193,7 +953,7 @@ function createAuthHandlers(handlersConfig = {}) {
193
953
  async authenticate(request) {
194
954
  const config = resolveNextConfig(handlersConfig);
195
955
  const secure = resolveCookieSecure(request, config);
196
- const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
956
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
197
957
  const client = buildServerAuthClient(config);
198
958
  let payload;
199
959
  try {
@@ -232,7 +992,7 @@ function createAuthHandlers(handlersConfig = {}) {
232
992
  ...deviceContextHeaders
233
993
  });
234
994
  } catch (error) {
235
- if (error instanceof require_server_session.SessionCookieTooLargeError) {
995
+ if (error instanceof SessionCookieTooLargeError) {
236
996
  console.error(`[polyx] ${error.message}`);
237
997
  return Response.json({
238
998
  status: "session_too_large",
@@ -245,7 +1005,7 @@ function createAuthHandlers(handlersConfig = {}) {
245
1005
  return Response.json({
246
1006
  status: "success",
247
1007
  redirectTo: afterSignInPath
248
- });
1008
+ }, { headers: { "set-cookie": buildAuthEventCookie("signed-in", secure) } });
249
1009
  }
250
1010
  case "select_tenant": return Response.json({
251
1011
  status: "select_tenant",
@@ -257,6 +1017,18 @@ function createAuthHandlers(handlersConfig = {}) {
257
1017
  userId: outcome.userId
258
1018
  }, { status: 403 });
259
1019
  case "no_access": return Response.json({ status: "no_access" }, { status: 403 });
1020
+ case "verification_required": return Response.json({
1021
+ status: "verification_required",
1022
+ challengeId: outcome.challengeId,
1023
+ factorTypes: outcome.factorTypes
1024
+ }, { status: 401 });
1025
+ case "configuration_error":
1026
+ console.error(`[polyx] Sign-in rejected for configuration, not credentials: ${outcome.code}${outcome.message ? ` — ${outcome.message}` : ""}. redirect_uri used: ${redirectUri}. If the app is behind a proxy, set POLYX_PUBLIC_ORIGIN or enable POLYX_TRUST_FORWARDED_ORIGIN.`);
1027
+ return Response.json({
1028
+ status: "configuration_error",
1029
+ code: outcome.code,
1030
+ message: outcome.message
1031
+ }, { status: 500 });
260
1032
  default: return Response.json({ status: "invalid_credentials" }, { status: 401 });
261
1033
  }
262
1034
  }
@@ -345,7 +1117,7 @@ function createAuthHandlers(handlersConfig = {}) {
345
1117
  try {
346
1118
  await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
347
1119
  } catch (error) {
348
- if (error instanceof require_server_session.SessionCookieTooLargeError) {
1120
+ if (error instanceof SessionCookieTooLargeError) {
349
1121
  console.error(`[polyx] ${error.message}`);
350
1122
  return Response.redirect(home, 302);
351
1123
  }
@@ -354,10 +1126,11 @@ function createAuthHandlers(handlersConfig = {}) {
354
1126
  return Response.redirect(home, 302);
355
1127
  },
356
1128
  async refresh(request) {
357
- const config = resolveNextConfig(handlersConfig);
358
- const secure = resolveCookieSecure(request, config);
359
- const session = await newSession(await requestCookies(), config, secure).refresh();
360
- return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
1129
+ const { isSignedIn } = await refreshSession({
1130
+ ...handlersConfig,
1131
+ request
1132
+ });
1133
+ return Response.json({ isSignedIn }, { status: isSignedIn ? 200 : 401 });
361
1134
  },
362
1135
  /**
363
1136
  * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
@@ -374,15 +1147,188 @@ function createAuthHandlers(handlersConfig = {}) {
374
1147
  const secure = resolveCookieSecure(request, config);
375
1148
  const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
376
1149
  await newSession(adapt(await (0, next_headers.cookies)()), config, secure).revoke(scope);
377
- if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
378
- return Response.redirect(new URL(request.url).origin + "/", 302);
1150
+ const event = buildAuthEventCookie("signed-out", secure);
1151
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true }, { headers: { "set-cookie": event } });
1152
+ return new Response(null, {
1153
+ status: 302,
1154
+ headers: {
1155
+ location: resolvePublicOrigin(request, config) + "/",
1156
+ "set-cookie": event
1157
+ }
1158
+ });
379
1159
  },
380
1160
  async session() {
381
1161
  return Response.json(await auth(handlersConfig));
382
1162
  },
1163
+ /**
1164
+ * Exchange the sealed session for a realtime handshake ticket (F019 / FR-RT-2).
1165
+ *
1166
+ * The session credential stays on this side: the SDK calls the platform with it and
1167
+ * hands the browser only the ticket, which is single-use and expires in seconds.
1168
+ */
1169
+ async realtimeTicket(request) {
1170
+ const config = resolveNextConfig(handlersConfig);
1171
+ if (!config.realtimeBaseUrl) return Response.json({
1172
+ status: "unavailable",
1173
+ message: "Set POLYX_REALTIME_URL to issue realtime tickets."
1174
+ }, { status: 503 });
1175
+ const current = await new ServerSession({
1176
+ authClient: buildServerAuthClient(config),
1177
+ secret: config.secret,
1178
+ cookies: await requestCookies()
1179
+ }).read();
1180
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
1181
+ const endpoint = `${config.realtimeBaseUrl.replace(/\/$/, "")}/v1/socket-ticket`;
1182
+ const send = (token) => fetch(endpoint, {
1183
+ method: "POST",
1184
+ headers: {
1185
+ Authorization: `Bearer ${token}`,
1186
+ Accept: "application/json"
1187
+ }
1188
+ });
1189
+ let organizationId = current.session.claims.organizationId;
1190
+ try {
1191
+ let upstream = await send(current.tokens.accessToken);
1192
+ if (upstream.status === 401) {
1193
+ const refreshed = await refreshSession({
1194
+ ...handlersConfig,
1195
+ request
1196
+ }).catch(() => null);
1197
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
1198
+ organizationId = refreshed.claims?.organizationId ?? organizationId;
1199
+ upstream = await send(refreshed.accessToken);
1200
+ }
1201
+ if (!upstream.ok) return Response.json({ status: "unavailable" }, { status: 503 });
1202
+ const body = await upstream.json();
1203
+ if (!body.ticket) return Response.json({ status: "unavailable" }, { status: 503 });
1204
+ return Response.json({
1205
+ ticket: body.ticket,
1206
+ expiresInSeconds: body.expiresInSeconds,
1207
+ organizationId
1208
+ });
1209
+ } catch {
1210
+ return Response.json({ status: "unavailable" }, { status: 503 });
1211
+ }
1212
+ },
1213
+ async profile(request) {
1214
+ const config = resolveNextConfig(handlersConfig);
1215
+ const session = new ServerSession({
1216
+ authClient: buildServerAuthClient(config),
1217
+ secret: config.secret,
1218
+ cookies: await requestCookies()
1219
+ });
1220
+ const current = await session.read();
1221
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
1222
+ const { userId, organizationId } = current.session.claims;
1223
+ if (!organizationId) return Response.json({ status: "no_organization" }, { status: 409 });
1224
+ if (!config.baseUrl) return Response.json({ status: "configuration_error" }, { status: 503 });
1225
+ const target = `${config.baseUrl.replace(/\/$/, "")}/v1/user/${encodeURIComponent(userId)}`;
1226
+ const headers = {
1227
+ "organization-id": organizationId,
1228
+ Accept: "application/json"
1229
+ };
1230
+ let body;
1231
+ if (request.method !== "GET") {
1232
+ /**
1233
+ * The body is REBUILT from an allow-list rather than forwarded.
1234
+ *
1235
+ * `updateUser` upstream applies whatever recognised fields it is given — including
1236
+ * `email`, `password` and `role`. Relaying the browser's multipart verbatim would
1237
+ * therefore turn a profile form into a way to change a credential or grant a role,
1238
+ * skipping the verification each of those has (D25). The allow-list is the control:
1239
+ * anything not in it never leaves this process.
1240
+ */
1241
+ /**
1242
+ * Parsed inside its own guard. `formData()` throws on a body that is not multipart or
1243
+ * form-encoded — the likeliest cause being an integrator posting JSON, which is the
1244
+ * obvious guess — and this sits before the try block that covers the upstream call, so
1245
+ * the rejection escaped the handler and Next answered 500. A 500 says the SDK broke;
1246
+ * an unreadable request is the caller's, and it belongs in nobody's alerting.
1247
+ */
1248
+ let submitted;
1249
+ try {
1250
+ submitted = await request.formData();
1251
+ } catch {
1252
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1253
+ }
1254
+ body = new FormData();
1255
+ for (const field of _poly_x_core.PROFILE_FIELDS) {
1256
+ const value = submitted.get(field);
1257
+ if (typeof value === "string" && value.trim() !== "") body.append(field, value.trim());
1258
+ }
1259
+ const image = submitted.get("image");
1260
+ if (image && typeof image !== "string") body.append("image", image);
1261
+ }
1262
+ const send = (token) => fetch(target, {
1263
+ method: request.method === "GET" ? "GET" : "PUT",
1264
+ headers: {
1265
+ ...headers,
1266
+ Authorization: `Bearer ${token}`
1267
+ },
1268
+ body
1269
+ });
1270
+ let activeToken = current.tokens.accessToken;
1271
+ try {
1272
+ let upstream = await send(activeToken);
1273
+ if (upstream.status === 401) {
1274
+ const refreshed = await refreshSession({
1275
+ ...handlersConfig,
1276
+ request
1277
+ }).catch(() => null);
1278
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
1279
+ activeToken = refreshed.accessToken;
1280
+ upstream = await send(activeToken);
1281
+ }
1282
+ const payload = await upstream.json().catch(() => ({}));
1283
+ if (!upstream.ok) return Response.json({
1284
+ status: "error",
1285
+ message: typeof payload.message === "string" ? payload.message : void 0
1286
+ }, { status: upstream.status });
1287
+ let user = payload.user ?? payload.data ?? payload;
1288
+ if (request.method !== "GET") {
1289
+ /**
1290
+ * Re-read after a save, because the update answers with the id ALONE.
1291
+ *
1292
+ * poly-auth returns `{message, data: {_id}}` from the update — deliberately, so a
1293
+ * password hash never rides on the response. So the saved document is simply not in
1294
+ * hand here: without this read the caller gets `{}` back and the display-claim update
1295
+ * below is a no-op against every field, leaving the account menu showing the name the
1296
+ * person just corrected (the exact thing FR-PROF-5 exists to prevent).
1297
+ *
1298
+ * The platform contract is frozen for the SDK, so the extra round trip is the answer
1299
+ * rather than asking poly-auth to return more.
1300
+ */
1301
+ const reread = await fetch(target, {
1302
+ method: "GET",
1303
+ headers: {
1304
+ ...headers,
1305
+ Authorization: `Bearer ${activeToken}`
1306
+ }
1307
+ }).catch(() => null);
1308
+ if (reread?.ok) {
1309
+ const body = await reread.json().catch(() => ({}));
1310
+ const fresh = body.user ?? body.data ?? body;
1311
+ if (fresh && typeof fresh === "object") user = fresh;
1312
+ }
1313
+ await session.updateDisplayClaims((0, _poly_x_core.deriveDisplayClaims)(user));
1314
+ }
1315
+ return Response.json({
1316
+ status: "ok",
1317
+ user: pickProfileFields(user)
1318
+ });
1319
+ } catch {
1320
+ return Response.json({ status: "unavailable" }, { status: 503 });
1321
+ }
1322
+ },
1323
+ /**
1324
+ * @deprecated Superseded by `realtimeTicket` (FR-RT-4). This hands the browser the
1325
+ * SESSION ACCESS TOKEN — the one place the custody guarantee is knowingly broken.
1326
+ * Kept working for this release so consumers migrate on their own schedule
1327
+ * (FR-COMPAT-3); it will be removed in a later major.
1328
+ */
383
1329
  async subscriptionCredential() {
384
1330
  const config = resolveNextConfig(handlersConfig);
385
- const current = await new require_server_session.ServerSession({
1331
+ const current = await new ServerSession({
386
1332
  authClient: buildServerAuthClient(config),
387
1333
  secret: config.secret,
388
1334
  cookies: await requestCookies()
@@ -396,13 +1342,22 @@ function createAuthHandlers(handlersConfig = {}) {
396
1342
  };
397
1343
  }
398
1344
  //#endregion
399
- exports.DEFAULT_SESSION_COOKIE = require_server_session.DEFAULT_SESSION_COOKIE;
400
- exports.SESSION_COOKIE_MAX_BYTES = require_server_session.SESSION_COOKIE_MAX_BYTES;
401
- exports.ServerSession = require_server_session.ServerSession;
402
- exports.SessionCookieTooLargeError = require_server_session.SessionCookieTooLargeError;
1345
+ exports.AUTH_EVENT_COOKIE = AUTH_EVENT_COOKIE;
1346
+ exports.DEFAULT_SESSION_COOKIE = require_session_cookie.DEFAULT_SESSION_COOKIE;
1347
+ exports.POLYX_ACTIONS = POLYX_ACTIONS;
1348
+ exports.SESSION_COOKIE_MAX_BYTES = SESSION_COOKIE_MAX_BYTES;
1349
+ exports.ServerSession = ServerSession;
1350
+ exports.SessionCookieTooLargeError = SessionCookieTooLargeError;
403
1351
  exports.auth = auth;
1352
+ exports.buildAuthEventCookie = buildAuthEventCookie;
404
1353
  exports.createAuthHandlers = createAuthHandlers;
405
- exports.openSession = require_server_session.openSession;
1354
+ exports.createDataProxy = createDataProxy;
1355
+ exports.createPolyXHandler = createPolyXHandler;
1356
+ exports.getSessionContext = getSessionContext;
1357
+ exports.openSession = openSession;
1358
+ exports.readSessionCookie = require_session_cookie.readSessionCookie;
1359
+ exports.refreshSession = refreshSession;
406
1360
  exports.resolveNextConfig = resolveNextConfig;
407
- exports.sealSession = require_server_session.sealSession;
408
- exports.sealedCookieByteLength = require_server_session.sealedCookieByteLength;
1361
+ exports.resolvePublicOrigin = resolvePublicOrigin;
1362
+ exports.sealSession = sealSession;
1363
+ exports.sealedCookieByteLength = sealedCookieByteLength;