@poly-x/next 0.1.1 → 0.3.0

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