@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.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,507 @@ 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
+ hop: "hop",
516
+ "hop-complete": "hopComplete",
517
+ refresh: "refresh",
518
+ signout: "signout",
519
+ session: "session",
520
+ "subscription-credential": "subscriptionCredential",
521
+ "realtime-ticket": "realtimeTicket",
522
+ profile: "profile"
523
+ };
524
+ /**
525
+ * Allowed methods per action, stated rather than inferred.
526
+ *
527
+ * `signout` accepts GET as well as POST because signing out has to be a full-page
528
+ * navigation: a client-side redirect leaves the httpOnly cookie in place and the route
529
+ * gate bounces the user straight back in. Both consumers hard-code a
530
+ * `window.location` sign-out for exactly this reason.
531
+ */
532
+ const POLYX_ACTION_METHODS = {
533
+ signin: ["GET"],
534
+ callback: ["GET"],
535
+ session: ["GET"],
536
+ "subscription-credential": ["GET"],
537
+ "realtime-ticket": ["GET"],
538
+ authenticate: ["POST"],
539
+ hop: ["POST"],
540
+ "hop-complete": ["POST"],
541
+ "forgot-password": ["POST"],
542
+ "reset-password": ["POST"],
543
+ refresh: ["POST"],
544
+ profile: ["GET", "POST"],
545
+ signout: ["GET", "POST"]
546
+ };
547
+ function jsonError$1(error, status) {
548
+ return Response.json({ error }, { status });
549
+ }
550
+ /**
551
+ * Resolve the action from the ROUTE PARAMS, never from the request URL.
552
+ *
553
+ * The handler must not trust a path it did not route: the framework tells us what it
554
+ * matched, and a URL string can disagree with that (rewrites, proxies, or a crafted
555
+ * request). Exactly one segment is required, so `/api/polyx/session/extra` is a 404
556
+ * rather than silently resolving to `session`.
557
+ */
558
+ function resolveActionSegment(params) {
559
+ const values = Object.values(params);
560
+ if (values.length !== 1) return null;
561
+ const segments = values[0];
562
+ if (!Array.isArray(segments)) return typeof segments === "string" ? segments : null;
563
+ if (segments.length !== 1) return null;
564
+ return segments[0] ?? null;
565
+ }
566
+ function isKnownAction(segment) {
567
+ return Object.prototype.hasOwnProperty.call(POLYX_ACTIONS, segment);
568
+ }
569
+ /**
570
+ * Mount every SDK auth route from one declaration:
571
+ *
572
+ * ```ts
573
+ * // app/api/polyx/[...polyx]/route.ts
574
+ * export const { GET, POST } = createPolyXHandler();
575
+ * ```
576
+ *
577
+ * `handlers` is injectable for tests; production callers pass only the config.
578
+ */
579
+ function createPolyXHandler(config = {}, handlers = createAuthHandlers(config)) {
580
+ async function dispatch(request, context, method) {
581
+ const segment = resolveActionSegment(await context.params);
582
+ if (segment === null || !isKnownAction(segment)) return jsonError$1("unknown_action", 404);
583
+ if (!POLYX_ACTION_METHODS[segment].includes(method)) return jsonError$1("method_not_allowed", 405);
584
+ return handlers[POLYX_ACTIONS[segment]](request);
585
+ }
586
+ return {
587
+ GET: (request, context) => dispatch(request, context, "GET"),
588
+ POST: (request, context) => dispatch(request, context, "POST")
589
+ };
590
+ }
591
+ //#endregion
592
+ //#region src/data-proxy.ts
593
+ /**
594
+ * The authenticated data path (F014 / FR-DATA-1…10).
595
+ *
596
+ * Under BFF custody the browser holds no credential, so every authenticated call to an
597
+ * app's own backend has to be proxied server-side. The SDK never shipped that half, even
598
+ * though the tech discovery that chose this custody model said it would ("proxies/
599
+ * decorates outbound PolyX calls"). So both live integrations wrote it themselves — and
600
+ * the result is byte-for-byte identical in each, ~250 lines of the subtlest code in the
601
+ * whole integration, sitting in two places to drift apart.
602
+ *
603
+ * This is a PORT of that proven implementation, not a rewrite: it has run in production
604
+ * in two apps. Two things are deliberately better here:
605
+ *
606
+ * 1. Refresh is IN-PROCESS (`refreshSession`, F013). The consumers had to `fetch` their
607
+ * own `/api/polyx/refresh` route and re-parse the returned `Set-Cookie` to recover
608
+ * the new token — a network round trip through their own edge, and a fragile parse.
609
+ * 2. Rotation needs no manual propagation. `refreshSession` writes through Next's cookie
610
+ * store, so the refreshed cookie lands on the response by itself; the consumers had
611
+ * to append the re-parsed `Set-Cookie` by hand.
612
+ */
613
+ /** Never copied from the browser request onto the upstream call. */
614
+ const STRIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
615
+ "host",
616
+ "connection",
617
+ "content-length",
618
+ "cookie",
619
+ "authorization"
620
+ ]);
621
+ /** Never copied from the upstream response back to the browser. */
622
+ const STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
623
+ "content-encoding",
624
+ "content-length",
625
+ "transfer-encoding",
626
+ "connection",
627
+ "set-cookie"
628
+ ]);
629
+ function jsonError(message, status) {
630
+ return Response.json({ message }, { status });
631
+ }
632
+ function trailingSlashOff(base) {
633
+ return base.replace(/\/$/, "");
634
+ }
635
+ /** The catch-all segments, whatever the route names its parameter. */
636
+ function pathSegments(params) {
637
+ for (const value of Object.values(params)) if (Array.isArray(value)) return value;
638
+ return [];
639
+ }
640
+ /**
641
+ * Read the sealed session's access credential. Server-side only — this value must never
642
+ * be handed to the browser.
643
+ */
644
+ async function readAccessToken(store, config) {
645
+ return (await new ServerSession({
646
+ authClient: buildServerAuthClient(config),
647
+ secret: config.secret,
648
+ cookies: store
649
+ }).read())?.tokens.accessToken ?? null;
650
+ }
651
+ function adaptStore(store) {
652
+ return {
653
+ get: (name) => store.get(name)?.value,
654
+ set: (name, value, options) => store.set(name, value, options),
655
+ delete: (name) => store.delete(name)
656
+ };
657
+ }
658
+ /**
659
+ * Mount an authenticated proxy to one declared backend:
660
+ *
661
+ * ```ts
662
+ * // app/api/billing/proxy/[...path]/route.ts
663
+ * export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
664
+ * upstream: process.env.BILLING_URL!,
665
+ * label: "Billing",
666
+ * });
667
+ * ```
668
+ *
669
+ * The client keeps calling a same-origin path; the credential is attached on this side of
670
+ * it and never reaches page scripts.
671
+ */
672
+ function createDataProxy(options) {
673
+ const label = options.label ?? "PolyX";
674
+ async function handle(request, context) {
675
+ const upstreamBase = typeof options.upstream === "function" ? options.upstream() : options.upstream;
676
+ if (!upstreamBase) return jsonError(`${label} proxy misconfigured: no upstream URL configured.`, 500);
677
+ let config;
678
+ try {
679
+ config = resolveNextConfig(options);
680
+ } catch {
681
+ return jsonError(`${label} proxy misconfigured: PolyX configuration is incomplete.`, 500);
682
+ }
683
+ const accessToken = await readAccessToken(adaptStore(await (0, next_headers.cookies)()), config);
684
+ if (!accessToken) return jsonError("Not authenticated.", 401);
685
+ const incoming = new URL(request.url);
686
+ const target = `${trailingSlashOff(upstreamBase)}/${pathSegments(await context.params).map(encodeURIComponent).join("/")}${incoming.search}`;
687
+ const baseHeaders = new Headers();
688
+ request.headers.forEach((value, key) => {
689
+ if (!STRIP_REQUEST_HEADERS.has(key.toLowerCase())) baseHeaders.set(key, value);
690
+ });
691
+ const method = request.method.toUpperCase();
692
+ const body = method !== "GET" && method !== "HEAD" ? await request.arrayBuffer() : void 0;
693
+ const send = (token) => {
694
+ const headers = new Headers(baseHeaders);
695
+ headers.set("Authorization", `Bearer ${token}`);
696
+ return fetch(target, {
697
+ method,
698
+ headers,
699
+ body,
700
+ redirect: "manual"
701
+ });
702
+ };
703
+ let upstream = await send(accessToken);
704
+ if (upstream.status === 401) {
705
+ const refreshed = await refreshSession({
706
+ ...options,
707
+ request
708
+ }).catch(() => null);
709
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return jsonError("Not authenticated.", 401);
710
+ upstream = await send(refreshed.accessToken);
711
+ }
712
+ const responseHeaders = new Headers();
713
+ upstream.headers.forEach((value, key) => {
714
+ if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) responseHeaders.set(key, value);
715
+ });
716
+ return new Response(upstream.body, {
717
+ status: upstream.status,
718
+ statusText: upstream.statusText,
719
+ headers: responseHeaders
720
+ });
721
+ }
722
+ return {
723
+ GET: handle,
724
+ POST: handle,
725
+ PUT: handle,
726
+ PATCH: handle,
727
+ DELETE: handle,
728
+ HEAD: handle
729
+ };
730
+ }
731
+ //#endregion
38
732
  //#region src/server.ts
39
733
  /**
40
734
  * @poly-x/next/server — App Router server helpers (F007). `auth()` reads the
@@ -46,6 +740,25 @@ const VERIFIER_COOKIE = "polyx_pkce";
46
740
  const VERIFIER_MAX_AGE = 600;
47
741
  /** poly-auth's own floor (Joi: min 6, max 128) — reject early rather than round-trip. */
48
742
  const MIN_PASSWORD_LENGTH = 6;
743
+ /**
744
+ * Narrow the platform's user record to what the profile surface needs.
745
+ *
746
+ * Relaying the whole record would leak whatever else poly-auth happens to return alongside it —
747
+ * role and permission references, internal flags, fields added by a future release nobody
748
+ * revisited this code for. An allow-list means new upstream fields stay server-side by default.
749
+ */
750
+ function pickProfileFields(user) {
751
+ const picked = {};
752
+ for (const field of [
753
+ ..._poly_x_core.PROFILE_FIELDS,
754
+ "profilePicture",
755
+ "email"
756
+ ]) {
757
+ const value = user[field];
758
+ if (typeof value === "string") picked[field] = value;
759
+ }
760
+ return picked;
761
+ }
49
762
  function adapt(store) {
50
763
  return {
51
764
  get: (name) => store.get(name)?.value,
@@ -148,17 +861,68 @@ function deviceContextHeadersFromBody(deviceContext) {
148
861
  /** Read the current session — safe in Server Components (no cookie write). */
149
862
  async function auth(overrides) {
150
863
  const config = resolveNextConfig(overrides);
151
- return require_server_session.toAuthContext(await new require_server_session.ServerSession({
864
+ return toAuthContext(await new ServerSession({
152
865
  authClient: buildServerAuthClient(config),
153
866
  secret: config.secret,
154
867
  cookies: await requestCookies()
155
868
  }).read());
156
869
  }
870
+ /**
871
+ * Single-flight keyed on the per-request cookie store. Next hands back the same store
872
+ * object for the life of a request, so concurrent callers within one request coalesce,
873
+ * while unrelated users in the same server process never serialise behind each other —
874
+ * which a module-level lock would have caused.
875
+ */
876
+ const inFlightRefresh = /* @__PURE__ */ new WeakMap();
877
+ /**
878
+ * Refresh the current session from server code (F013 / FR-SESS-1).
879
+ *
880
+ * Before this existed there was no supported way to do it, so both live integrations
881
+ * did something worse: the server issued an HTTP request to its OWN
882
+ * `/api/polyx/refresh` route and then re-parsed the returned `Set-Cookie` header to
883
+ * recover the new token. That is a network round trip through the app's own edge to do
884
+ * something already possible in-process, and the header re-parse is fragile.
885
+ *
886
+ * Terminal-vs-transient classification lives in `ServerSession.refresh()` and is not
887
+ * duplicated here: a revoked family or an unstorable session signs the user out, and a
888
+ * transient upstream failure propagates so the caller can fail closed.
889
+ */
890
+ async function refreshSession(options = {}) {
891
+ const store = await (0, next_headers.cookies)();
892
+ const pending = inFlightRefresh.get(store);
893
+ if (pending) return pending;
894
+ const run = (async () => {
895
+ const config = resolveNextConfig(options);
896
+ const secure = options.request ? resolveCookieSecure(options.request, config) : config.cookieSecure ?? true;
897
+ const next = await new ServerSession({
898
+ authClient: buildServerAuthClient(config),
899
+ secret: config.secret,
900
+ cookies: adapt(store),
901
+ secure
902
+ }).refresh();
903
+ if (!next) return {
904
+ isSignedIn: false,
905
+ accessToken: null,
906
+ claims: null
907
+ };
908
+ return {
909
+ isSignedIn: true,
910
+ accessToken: next.tokens.accessToken,
911
+ claims: next.session.claims
912
+ };
913
+ })();
914
+ inFlightRefresh.set(store, run);
915
+ try {
916
+ return await run;
917
+ } finally {
918
+ inFlightRefresh.delete(store);
919
+ }
920
+ }
157
921
  function createAuthHandlers(handlersConfig = {}) {
158
922
  const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
159
923
  const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
160
924
  function newSession(store, config, secure) {
161
- return new require_server_session.ServerSession({
925
+ return new ServerSession({
162
926
  authClient: buildServerAuthClient(config),
163
927
  secret: config.secret,
164
928
  cookies: store,
@@ -169,7 +933,7 @@ function createAuthHandlers(handlersConfig = {}) {
169
933
  async signin(request) {
170
934
  const config = resolveNextConfig(handlersConfig);
171
935
  const secure = resolveCookieSecure(request, config);
172
- const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
936
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
173
937
  const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
174
938
  const state = (0, _poly_x_core.randomState)();
175
939
  (await (0, next_headers.cookies)()).set(VERIFIER_COOKIE, JSON.stringify({
@@ -193,7 +957,7 @@ function createAuthHandlers(handlersConfig = {}) {
193
957
  async authenticate(request) {
194
958
  const config = resolveNextConfig(handlersConfig);
195
959
  const secure = resolveCookieSecure(request, config);
196
- const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
960
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
197
961
  const client = buildServerAuthClient(config);
198
962
  let payload;
199
963
  try {
@@ -232,7 +996,7 @@ function createAuthHandlers(handlersConfig = {}) {
232
996
  ...deviceContextHeaders
233
997
  });
234
998
  } catch (error) {
235
- if (error instanceof require_server_session.SessionCookieTooLargeError) {
999
+ if (error instanceof SessionCookieTooLargeError) {
236
1000
  console.error(`[polyx] ${error.message}`);
237
1001
  return Response.json({
238
1002
  status: "session_too_large",
@@ -245,7 +1009,7 @@ function createAuthHandlers(handlersConfig = {}) {
245
1009
  return Response.json({
246
1010
  status: "success",
247
1011
  redirectTo: afterSignInPath
248
- });
1012
+ }, { headers: { "set-cookie": buildAuthEventCookie("signed-in", secure) } });
249
1013
  }
250
1014
  case "select_tenant": return Response.json({
251
1015
  status: "select_tenant",
@@ -257,6 +1021,18 @@ function createAuthHandlers(handlersConfig = {}) {
257
1021
  userId: outcome.userId
258
1022
  }, { status: 403 });
259
1023
  case "no_access": return Response.json({ status: "no_access" }, { status: 403 });
1024
+ case "verification_required": return Response.json({
1025
+ status: "verification_required",
1026
+ challengeId: outcome.challengeId,
1027
+ factorTypes: outcome.factorTypes
1028
+ }, { status: 401 });
1029
+ case "configuration_error":
1030
+ 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.`);
1031
+ return Response.json({
1032
+ status: "configuration_error",
1033
+ code: outcome.code,
1034
+ message: outcome.message
1035
+ }, { status: 500 });
260
1036
  default: return Response.json({ status: "invalid_credentials" }, { status: 401 });
261
1037
  }
262
1038
  }
@@ -345,7 +1121,7 @@ function createAuthHandlers(handlersConfig = {}) {
345
1121
  try {
346
1122
  await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
347
1123
  } catch (error) {
348
- if (error instanceof require_server_session.SessionCookieTooLargeError) {
1124
+ if (error instanceof SessionCookieTooLargeError) {
349
1125
  console.error(`[polyx] ${error.message}`);
350
1126
  return Response.redirect(home, 302);
351
1127
  }
@@ -353,11 +1129,116 @@ function createAuthHandlers(handlersConfig = {}) {
353
1129
  }
354
1130
  return Response.redirect(home, 302);
355
1131
  },
356
- async refresh(request) {
1132
+ async hop(request) {
357
1133
  const config = resolveNextConfig(handlersConfig);
358
1134
  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 });
1135
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
1136
+ let hint = {};
1137
+ try {
1138
+ hint = await request.json() ?? {};
1139
+ } catch {
1140
+ hint = {};
1141
+ }
1142
+ const organizationCode = typeof hint.tenantId === "string" && hint.tenantId !== "" ? hint.tenantId : typeof hint.organizationCode === "string" && hint.organizationCode !== "" ? hint.organizationCode : void 0;
1143
+ const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
1144
+ const state = (0, _poly_x_core.randomState)();
1145
+ (await (0, next_headers.cookies)()).set(VERIFIER_COOKIE, JSON.stringify({
1146
+ verifier,
1147
+ state,
1148
+ redirectUri,
1149
+ ...organizationCode ? { requested: organizationCode } : {}
1150
+ }), {
1151
+ httpOnly: true,
1152
+ secure,
1153
+ sameSite: "lax",
1154
+ path: "/",
1155
+ maxAge: VERIFIER_MAX_AGE
1156
+ });
1157
+ const authorizeUrl = buildServerAuthClient(config).buildAuthorizeUrl({
1158
+ redirectUri,
1159
+ state,
1160
+ challenge,
1161
+ prompt: "none",
1162
+ ...organizationCode ? { organizationCode } : {}
1163
+ });
1164
+ return Response.json({
1165
+ status: "ready",
1166
+ authorizeUrl
1167
+ });
1168
+ },
1169
+ async hopComplete(request) {
1170
+ const config = resolveNextConfig(handlersConfig);
1171
+ const secure = resolveCookieSecure(request, config);
1172
+ const store = await (0, next_headers.cookies)();
1173
+ let body;
1174
+ try {
1175
+ body = await request.json() ?? {};
1176
+ } catch {
1177
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1178
+ }
1179
+ const code = typeof body.code === "string" ? body.code : "";
1180
+ const state = typeof body.state === "string" ? body.state : "";
1181
+ const raw = store.get(VERIFIER_COOKIE)?.value;
1182
+ store.delete(VERIFIER_COOKIE);
1183
+ if (!code || !state || !raw) return Response.json({ status: "invalid_request" }, { status: 400 });
1184
+ let tx;
1185
+ try {
1186
+ tx = JSON.parse(raw);
1187
+ } catch {
1188
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1189
+ }
1190
+ if (tx.state !== state) return Response.json({ status: "invalid_request" }, { status: 400 });
1191
+ let entered;
1192
+ try {
1193
+ entered = (await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request))).session.claims.organizationId;
1194
+ } catch (error) {
1195
+ if (error instanceof SessionCookieTooLargeError) {
1196
+ console.error(`[polyx] ${error.message}`);
1197
+ return Response.json({
1198
+ status: "session_too_large",
1199
+ bytes: error.bytes,
1200
+ limit: error.limit
1201
+ }, { status: 400 });
1202
+ }
1203
+ return Response.json({ status: "exchange_failed" }, { status: 400 });
1204
+ }
1205
+ /**
1206
+ * The substitution guard (verified against a live platform, 2026-07-29).
1207
+ *
1208
+ * The platform only consults a workspace hint when the person has **more than one**
1209
+ * candidate workspace for that application. With exactly one, the hint is ignored and they
1210
+ * are placed in that one — answering `authorized`, with nothing to say the request was not
1211
+ * honoured. Asking for workspace A and being silently seated in workspace B is precisely
1212
+ * the outcome the scope decisions call worse than a prompt: someone can act in the wrong
1213
+ * tenant without noticing.
1214
+ *
1215
+ * A hint can be a tenant id, an organization code, or a slug, and only the first is
1216
+ * comparable to what the session carries. So an id-shaped hint that does not match is
1217
+ * reported as a mismatch; for the other forms the workspace actually entered is returned
1218
+ * and the caller can decide. Either way the answer is never a bare "success" that hides a
1219
+ * substitution.
1220
+ *
1221
+ * The session IS established at this point and is left in place: the person is signed in
1222
+ * somewhere they are entitled to be. This reports what happened rather than tearing down a
1223
+ * valid session the caller may be perfectly happy with.
1224
+ */
1225
+ const requested = tx.requested;
1226
+ if (typeof requested === "string" && /^[a-f0-9]{24}$/i.test(requested) && entered && requested.toLowerCase() !== entered.toLowerCase()) return Response.json({
1227
+ status: "workspace_mismatch",
1228
+ requested,
1229
+ workspace: entered
1230
+ });
1231
+ return Response.json({
1232
+ status: "success",
1233
+ ...entered ? { workspace: entered } : {}
1234
+ });
1235
+ },
1236
+ async refresh(request) {
1237
+ const { isSignedIn } = await refreshSession({
1238
+ ...handlersConfig,
1239
+ request
1240
+ });
1241
+ return Response.json({ isSignedIn }, { status: isSignedIn ? 200 : 401 });
361
1242
  },
362
1243
  /**
363
1244
  * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
@@ -374,15 +1255,188 @@ function createAuthHandlers(handlersConfig = {}) {
374
1255
  const secure = resolveCookieSecure(request, config);
375
1256
  const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
376
1257
  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);
1258
+ const event = buildAuthEventCookie("signed-out", secure);
1259
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true }, { headers: { "set-cookie": event } });
1260
+ return new Response(null, {
1261
+ status: 302,
1262
+ headers: {
1263
+ location: resolvePublicOrigin(request, config) + "/",
1264
+ "set-cookie": event
1265
+ }
1266
+ });
379
1267
  },
380
1268
  async session() {
381
1269
  return Response.json(await auth(handlersConfig));
382
1270
  },
1271
+ /**
1272
+ * Exchange the sealed session for a realtime handshake ticket (F019 / FR-RT-2).
1273
+ *
1274
+ * The session credential stays on this side: the SDK calls the platform with it and
1275
+ * hands the browser only the ticket, which is single-use and expires in seconds.
1276
+ */
1277
+ async realtimeTicket(request) {
1278
+ const config = resolveNextConfig(handlersConfig);
1279
+ if (!config.realtimeBaseUrl) return Response.json({
1280
+ status: "unavailable",
1281
+ message: "Set POLYX_REALTIME_URL to issue realtime tickets."
1282
+ }, { status: 503 });
1283
+ const current = await new ServerSession({
1284
+ authClient: buildServerAuthClient(config),
1285
+ secret: config.secret,
1286
+ cookies: await requestCookies()
1287
+ }).read();
1288
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
1289
+ const endpoint = `${config.realtimeBaseUrl.replace(/\/$/, "")}/v1/socket-ticket`;
1290
+ const send = (token) => fetch(endpoint, {
1291
+ method: "POST",
1292
+ headers: {
1293
+ Authorization: `Bearer ${token}`,
1294
+ Accept: "application/json"
1295
+ }
1296
+ });
1297
+ let organizationId = current.session.claims.organizationId;
1298
+ try {
1299
+ let upstream = await send(current.tokens.accessToken);
1300
+ if (upstream.status === 401) {
1301
+ const refreshed = await refreshSession({
1302
+ ...handlersConfig,
1303
+ request
1304
+ }).catch(() => null);
1305
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
1306
+ organizationId = refreshed.claims?.organizationId ?? organizationId;
1307
+ upstream = await send(refreshed.accessToken);
1308
+ }
1309
+ if (!upstream.ok) return Response.json({ status: "unavailable" }, { status: 503 });
1310
+ const body = await upstream.json();
1311
+ if (!body.ticket) return Response.json({ status: "unavailable" }, { status: 503 });
1312
+ return Response.json({
1313
+ ticket: body.ticket,
1314
+ expiresInSeconds: body.expiresInSeconds,
1315
+ organizationId
1316
+ });
1317
+ } catch {
1318
+ return Response.json({ status: "unavailable" }, { status: 503 });
1319
+ }
1320
+ },
1321
+ async profile(request) {
1322
+ const config = resolveNextConfig(handlersConfig);
1323
+ const session = new ServerSession({
1324
+ authClient: buildServerAuthClient(config),
1325
+ secret: config.secret,
1326
+ cookies: await requestCookies()
1327
+ });
1328
+ const current = await session.read();
1329
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
1330
+ const { userId, organizationId } = current.session.claims;
1331
+ if (!organizationId) return Response.json({ status: "no_organization" }, { status: 409 });
1332
+ if (!config.baseUrl) return Response.json({ status: "configuration_error" }, { status: 503 });
1333
+ const target = `${config.baseUrl.replace(/\/$/, "")}/v1/user/${encodeURIComponent(userId)}`;
1334
+ const headers = {
1335
+ "organization-id": organizationId,
1336
+ Accept: "application/json"
1337
+ };
1338
+ let body;
1339
+ if (request.method !== "GET") {
1340
+ /**
1341
+ * The body is REBUILT from an allow-list rather than forwarded.
1342
+ *
1343
+ * `updateUser` upstream applies whatever recognised fields it is given — including
1344
+ * `email`, `password` and `role`. Relaying the browser's multipart verbatim would
1345
+ * therefore turn a profile form into a way to change a credential or grant a role,
1346
+ * skipping the verification each of those has (D25). The allow-list is the control:
1347
+ * anything not in it never leaves this process.
1348
+ */
1349
+ /**
1350
+ * Parsed inside its own guard. `formData()` throws on a body that is not multipart or
1351
+ * form-encoded — the likeliest cause being an integrator posting JSON, which is the
1352
+ * obvious guess — and this sits before the try block that covers the upstream call, so
1353
+ * the rejection escaped the handler and Next answered 500. A 500 says the SDK broke;
1354
+ * an unreadable request is the caller's, and it belongs in nobody's alerting.
1355
+ */
1356
+ let submitted;
1357
+ try {
1358
+ submitted = await request.formData();
1359
+ } catch {
1360
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1361
+ }
1362
+ body = new FormData();
1363
+ for (const field of _poly_x_core.PROFILE_FIELDS) {
1364
+ const value = submitted.get(field);
1365
+ if (typeof value === "string" && value.trim() !== "") body.append(field, value.trim());
1366
+ }
1367
+ const image = submitted.get("image");
1368
+ if (image && typeof image !== "string") body.append("image", image);
1369
+ }
1370
+ const send = (token) => fetch(target, {
1371
+ method: request.method === "GET" ? "GET" : "PUT",
1372
+ headers: {
1373
+ ...headers,
1374
+ Authorization: `Bearer ${token}`
1375
+ },
1376
+ body
1377
+ });
1378
+ let activeToken = current.tokens.accessToken;
1379
+ try {
1380
+ let upstream = await send(activeToken);
1381
+ if (upstream.status === 401) {
1382
+ const refreshed = await refreshSession({
1383
+ ...handlersConfig,
1384
+ request
1385
+ }).catch(() => null);
1386
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
1387
+ activeToken = refreshed.accessToken;
1388
+ upstream = await send(activeToken);
1389
+ }
1390
+ const payload = await upstream.json().catch(() => ({}));
1391
+ if (!upstream.ok) return Response.json({
1392
+ status: "error",
1393
+ message: typeof payload.message === "string" ? payload.message : void 0
1394
+ }, { status: upstream.status });
1395
+ let user = payload.user ?? payload.data ?? payload;
1396
+ if (request.method !== "GET") {
1397
+ /**
1398
+ * Re-read after a save, because the update answers with the id ALONE.
1399
+ *
1400
+ * poly-auth returns `{message, data: {_id}}` from the update — deliberately, so a
1401
+ * password hash never rides on the response. So the saved document is simply not in
1402
+ * hand here: without this read the caller gets `{}` back and the display-claim update
1403
+ * below is a no-op against every field, leaving the account menu showing the name the
1404
+ * person just corrected (the exact thing FR-PROF-5 exists to prevent).
1405
+ *
1406
+ * The platform contract is frozen for the SDK, so the extra round trip is the answer
1407
+ * rather than asking poly-auth to return more.
1408
+ */
1409
+ const reread = await fetch(target, {
1410
+ method: "GET",
1411
+ headers: {
1412
+ ...headers,
1413
+ Authorization: `Bearer ${activeToken}`
1414
+ }
1415
+ }).catch(() => null);
1416
+ if (reread?.ok) {
1417
+ const body = await reread.json().catch(() => ({}));
1418
+ const fresh = body.user ?? body.data ?? body;
1419
+ if (fresh && typeof fresh === "object") user = fresh;
1420
+ }
1421
+ await session.updateDisplayClaims((0, _poly_x_core.deriveDisplayClaims)(user));
1422
+ }
1423
+ return Response.json({
1424
+ status: "ok",
1425
+ user: pickProfileFields(user)
1426
+ });
1427
+ } catch {
1428
+ return Response.json({ status: "unavailable" }, { status: 503 });
1429
+ }
1430
+ },
1431
+ /**
1432
+ * @deprecated Superseded by `realtimeTicket` (FR-RT-4). This hands the browser the
1433
+ * SESSION ACCESS TOKEN — the one place the custody guarantee is knowingly broken.
1434
+ * Kept working for this release so consumers migrate on their own schedule
1435
+ * (FR-COMPAT-3); it will be removed in a later major.
1436
+ */
383
1437
  async subscriptionCredential() {
384
1438
  const config = resolveNextConfig(handlersConfig);
385
- const current = await new require_server_session.ServerSession({
1439
+ const current = await new ServerSession({
386
1440
  authClient: buildServerAuthClient(config),
387
1441
  secret: config.secret,
388
1442
  cookies: await requestCookies()
@@ -396,13 +1450,22 @@ function createAuthHandlers(handlersConfig = {}) {
396
1450
  };
397
1451
  }
398
1452
  //#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;
1453
+ exports.AUTH_EVENT_COOKIE = AUTH_EVENT_COOKIE;
1454
+ exports.DEFAULT_SESSION_COOKIE = require_session_cookie.DEFAULT_SESSION_COOKIE;
1455
+ exports.POLYX_ACTIONS = POLYX_ACTIONS;
1456
+ exports.SESSION_COOKIE_MAX_BYTES = SESSION_COOKIE_MAX_BYTES;
1457
+ exports.ServerSession = ServerSession;
1458
+ exports.SessionCookieTooLargeError = SessionCookieTooLargeError;
403
1459
  exports.auth = auth;
1460
+ exports.buildAuthEventCookie = buildAuthEventCookie;
404
1461
  exports.createAuthHandlers = createAuthHandlers;
405
- exports.openSession = require_server_session.openSession;
1462
+ exports.createDataProxy = createDataProxy;
1463
+ exports.createPolyXHandler = createPolyXHandler;
1464
+ exports.getSessionContext = getSessionContext;
1465
+ exports.openSession = openSession;
1466
+ exports.readSessionCookie = require_session_cookie.readSessionCookie;
1467
+ exports.refreshSession = refreshSession;
406
1468
  exports.resolveNextConfig = resolveNextConfig;
407
- exports.sealSession = require_server_session.sealSession;
408
- exports.sealedCookieByteLength = require_server_session.sealedCookieByteLength;
1469
+ exports.resolvePublicOrigin = resolvePublicOrigin;
1470
+ exports.sealSession = sealSession;
1471
+ exports.sealedCookieByteLength = sealedCookieByteLength;