@poly-x/next 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.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,503 @@ 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
+ refresh: "refresh",
515
+ signout: "signout",
516
+ session: "session",
517
+ "subscription-credential": "subscriptionCredential",
518
+ "realtime-ticket": "realtimeTicket",
519
+ profile: "profile"
520
+ };
521
+ /**
522
+ * Allowed methods per action, stated rather than inferred.
523
+ *
524
+ * `signout` accepts GET as well as POST because signing out has to be a full-page
525
+ * navigation: a client-side redirect leaves the httpOnly cookie in place and the route
526
+ * gate bounces the user straight back in. Both consumers hard-code a
527
+ * `window.location` sign-out for exactly this reason.
528
+ */
529
+ const POLYX_ACTION_METHODS = {
530
+ signin: ["GET"],
531
+ callback: ["GET"],
532
+ session: ["GET"],
533
+ "subscription-credential": ["GET"],
534
+ "realtime-ticket": ["GET"],
535
+ authenticate: ["POST"],
536
+ "forgot-password": ["POST"],
537
+ "reset-password": ["POST"],
538
+ refresh: ["POST"],
539
+ profile: ["GET", "POST"],
540
+ signout: ["GET", "POST"]
541
+ };
542
+ function jsonError$1(error, status) {
543
+ return Response.json({ error }, { status });
544
+ }
545
+ /**
546
+ * Resolve the action from the ROUTE PARAMS, never from the request URL.
547
+ *
548
+ * The handler must not trust a path it did not route: the framework tells us what it
549
+ * matched, and a URL string can disagree with that (rewrites, proxies, or a crafted
550
+ * request). Exactly one segment is required, so `/api/polyx/session/extra` is a 404
551
+ * rather than silently resolving to `session`.
552
+ */
553
+ function resolveActionSegment(params) {
554
+ const values = Object.values(params);
555
+ if (values.length !== 1) return null;
556
+ const segments = values[0];
557
+ if (!Array.isArray(segments)) return typeof segments === "string" ? segments : null;
558
+ if (segments.length !== 1) return null;
559
+ return segments[0] ?? null;
560
+ }
561
+ function isKnownAction(segment) {
562
+ return Object.prototype.hasOwnProperty.call(POLYX_ACTIONS, segment);
563
+ }
564
+ /**
565
+ * Mount every SDK auth route from one declaration:
566
+ *
567
+ * ```ts
568
+ * // app/api/polyx/[...polyx]/route.ts
569
+ * export const { GET, POST } = createPolyXHandler();
570
+ * ```
571
+ *
572
+ * `handlers` is injectable for tests; production callers pass only the config.
573
+ */
574
+ function createPolyXHandler(config = {}, handlers = createAuthHandlers(config)) {
575
+ async function dispatch(request, context, method) {
576
+ const segment = resolveActionSegment(await context.params);
577
+ if (segment === null || !isKnownAction(segment)) return jsonError$1("unknown_action", 404);
578
+ if (!POLYX_ACTION_METHODS[segment].includes(method)) return jsonError$1("method_not_allowed", 405);
579
+ return handlers[POLYX_ACTIONS[segment]](request);
580
+ }
581
+ return {
582
+ GET: (request, context) => dispatch(request, context, "GET"),
583
+ POST: (request, context) => dispatch(request, context, "POST")
584
+ };
585
+ }
586
+ //#endregion
587
+ //#region src/data-proxy.ts
588
+ /**
589
+ * The authenticated data path (F014 / FR-DATA-1…10).
590
+ *
591
+ * Under BFF custody the browser holds no credential, so every authenticated call to an
592
+ * app's own backend has to be proxied server-side. The SDK never shipped that half, even
593
+ * though the tech discovery that chose this custody model said it would ("proxies/
594
+ * decorates outbound PolyX calls"). So both live integrations wrote it themselves — and
595
+ * the result is byte-for-byte identical in each, ~250 lines of the subtlest code in the
596
+ * whole integration, sitting in two places to drift apart.
597
+ *
598
+ * This is a PORT of that proven implementation, not a rewrite: it has run in production
599
+ * in two apps. Two things are deliberately better here:
600
+ *
601
+ * 1. Refresh is IN-PROCESS (`refreshSession`, F013). The consumers had to `fetch` their
602
+ * own `/api/polyx/refresh` route and re-parse the returned `Set-Cookie` to recover
603
+ * the new token — a network round trip through their own edge, and a fragile parse.
604
+ * 2. Rotation needs no manual propagation. `refreshSession` writes through Next's cookie
605
+ * store, so the refreshed cookie lands on the response by itself; the consumers had
606
+ * to append the re-parsed `Set-Cookie` by hand.
607
+ */
608
+ /** Never copied from the browser request onto the upstream call. */
609
+ const STRIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
610
+ "host",
611
+ "connection",
612
+ "content-length",
613
+ "cookie",
614
+ "authorization"
615
+ ]);
616
+ /** Never copied from the upstream response back to the browser. */
617
+ const STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
618
+ "content-encoding",
619
+ "content-length",
620
+ "transfer-encoding",
621
+ "connection",
622
+ "set-cookie"
623
+ ]);
624
+ function jsonError(message, status) {
625
+ return Response.json({ message }, { status });
626
+ }
627
+ function trailingSlashOff(base) {
628
+ return base.replace(/\/$/, "");
629
+ }
630
+ /** The catch-all segments, whatever the route names its parameter. */
631
+ function pathSegments(params) {
632
+ for (const value of Object.values(params)) if (Array.isArray(value)) return value;
633
+ return [];
634
+ }
635
+ /**
636
+ * Read the sealed session's access credential. Server-side only — this value must never
637
+ * be handed to the browser.
638
+ */
639
+ async function readAccessToken(store, config) {
640
+ return (await new ServerSession({
641
+ authClient: buildServerAuthClient(config),
642
+ secret: config.secret,
643
+ cookies: store
644
+ }).read())?.tokens.accessToken ?? null;
645
+ }
646
+ function adaptStore(store) {
647
+ return {
648
+ get: (name) => store.get(name)?.value,
649
+ set: (name, value, options) => store.set(name, value, options),
650
+ delete: (name) => store.delete(name)
651
+ };
652
+ }
653
+ /**
654
+ * Mount an authenticated proxy to one declared backend:
655
+ *
656
+ * ```ts
657
+ * // app/api/billing/proxy/[...path]/route.ts
658
+ * export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
659
+ * upstream: process.env.BILLING_URL!,
660
+ * label: "Billing",
661
+ * });
662
+ * ```
663
+ *
664
+ * The client keeps calling a same-origin path; the credential is attached on this side of
665
+ * it and never reaches page scripts.
666
+ */
667
+ function createDataProxy(options) {
668
+ const label = options.label ?? "PolyX";
669
+ async function handle(request, context) {
670
+ const upstreamBase = typeof options.upstream === "function" ? options.upstream() : options.upstream;
671
+ if (!upstreamBase) return jsonError(`${label} proxy misconfigured: no upstream URL configured.`, 500);
672
+ let config;
673
+ try {
674
+ config = resolveNextConfig(options);
675
+ } catch {
676
+ return jsonError(`${label} proxy misconfigured: PolyX configuration is incomplete.`, 500);
677
+ }
678
+ const accessToken = await readAccessToken(adaptStore(await cookies()), config);
679
+ if (!accessToken) return jsonError("Not authenticated.", 401);
680
+ const incoming = new URL(request.url);
681
+ const target = `${trailingSlashOff(upstreamBase)}/${pathSegments(await context.params).map(encodeURIComponent).join("/")}${incoming.search}`;
682
+ const baseHeaders = new Headers();
683
+ request.headers.forEach((value, key) => {
684
+ if (!STRIP_REQUEST_HEADERS.has(key.toLowerCase())) baseHeaders.set(key, value);
685
+ });
686
+ const method = request.method.toUpperCase();
687
+ const body = method !== "GET" && method !== "HEAD" ? await request.arrayBuffer() : void 0;
688
+ const send = (token) => {
689
+ const headers = new Headers(baseHeaders);
690
+ headers.set("Authorization", `Bearer ${token}`);
691
+ return fetch(target, {
692
+ method,
693
+ headers,
694
+ body,
695
+ redirect: "manual"
696
+ });
697
+ };
698
+ let upstream = await send(accessToken);
699
+ if (upstream.status === 401) {
700
+ const refreshed = await refreshSession({
701
+ ...options,
702
+ request
703
+ }).catch(() => null);
704
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return jsonError("Not authenticated.", 401);
705
+ upstream = await send(refreshed.accessToken);
706
+ }
707
+ const responseHeaders = new Headers();
708
+ upstream.headers.forEach((value, key) => {
709
+ if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) responseHeaders.set(key, value);
710
+ });
711
+ return new Response(upstream.body, {
712
+ status: upstream.status,
713
+ statusText: upstream.statusText,
714
+ headers: responseHeaders
715
+ });
716
+ }
717
+ return {
718
+ GET: handle,
719
+ POST: handle,
720
+ PUT: handle,
721
+ PATCH: handle,
722
+ DELETE: handle,
723
+ HEAD: handle
724
+ };
725
+ }
726
+ //#endregion
37
727
  //#region src/server.ts
38
728
  /**
39
729
  * @poly-x/next/server — App Router server helpers (F007). `auth()` reads the
@@ -45,6 +735,25 @@ const VERIFIER_COOKIE = "polyx_pkce";
45
735
  const VERIFIER_MAX_AGE = 600;
46
736
  /** poly-auth's own floor (Joi: min 6, max 128) — reject early rather than round-trip. */
47
737
  const MIN_PASSWORD_LENGTH = 6;
738
+ /**
739
+ * Narrow the platform's user record to what the profile surface needs.
740
+ *
741
+ * Relaying the whole record would leak whatever else poly-auth happens to return alongside it —
742
+ * role and permission references, internal flags, fields added by a future release nobody
743
+ * revisited this code for. An allow-list means new upstream fields stay server-side by default.
744
+ */
745
+ function pickProfileFields(user) {
746
+ const picked = {};
747
+ for (const field of [
748
+ ...PROFILE_FIELDS,
749
+ "profilePicture",
750
+ "email"
751
+ ]) {
752
+ const value = user[field];
753
+ if (typeof value === "string") picked[field] = value;
754
+ }
755
+ return picked;
756
+ }
48
757
  function adapt(store) {
49
758
  return {
50
759
  get: (name) => store.get(name)?.value,
@@ -153,6 +862,57 @@ async function auth(overrides) {
153
862
  cookies: await requestCookies()
154
863
  }).read());
155
864
  }
865
+ /**
866
+ * Single-flight keyed on the per-request cookie store. Next hands back the same store
867
+ * object for the life of a request, so concurrent callers within one request coalesce,
868
+ * while unrelated users in the same server process never serialise behind each other —
869
+ * which a module-level lock would have caused.
870
+ */
871
+ const inFlightRefresh = /* @__PURE__ */ new WeakMap();
872
+ /**
873
+ * Refresh the current session from server code (F013 / FR-SESS-1).
874
+ *
875
+ * Before this existed there was no supported way to do it, so both live integrations
876
+ * did something worse: the server issued an HTTP request to its OWN
877
+ * `/api/polyx/refresh` route and then re-parsed the returned `Set-Cookie` header to
878
+ * recover the new token. That is a network round trip through the app's own edge to do
879
+ * something already possible in-process, and the header re-parse is fragile.
880
+ *
881
+ * Terminal-vs-transient classification lives in `ServerSession.refresh()` and is not
882
+ * duplicated here: a revoked family or an unstorable session signs the user out, and a
883
+ * transient upstream failure propagates so the caller can fail closed.
884
+ */
885
+ async function refreshSession(options = {}) {
886
+ const store = await cookies();
887
+ const pending = inFlightRefresh.get(store);
888
+ if (pending) return pending;
889
+ const run = (async () => {
890
+ const config = resolveNextConfig(options);
891
+ const secure = options.request ? resolveCookieSecure(options.request, config) : config.cookieSecure ?? true;
892
+ const next = await new ServerSession({
893
+ authClient: buildServerAuthClient(config),
894
+ secret: config.secret,
895
+ cookies: adapt(store),
896
+ secure
897
+ }).refresh();
898
+ if (!next) return {
899
+ isSignedIn: false,
900
+ accessToken: null,
901
+ claims: null
902
+ };
903
+ return {
904
+ isSignedIn: true,
905
+ accessToken: next.tokens.accessToken,
906
+ claims: next.session.claims
907
+ };
908
+ })();
909
+ inFlightRefresh.set(store, run);
910
+ try {
911
+ return await run;
912
+ } finally {
913
+ inFlightRefresh.delete(store);
914
+ }
915
+ }
156
916
  function createAuthHandlers(handlersConfig = {}) {
157
917
  const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
158
918
  const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
@@ -168,7 +928,7 @@ function createAuthHandlers(handlersConfig = {}) {
168
928
  async signin(request) {
169
929
  const config = resolveNextConfig(handlersConfig);
170
930
  const secure = resolveCookieSecure(request, config);
171
- const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
931
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
172
932
  const { verifier, challenge } = await generatePkce();
173
933
  const state = randomState();
174
934
  (await cookies()).set(VERIFIER_COOKIE, JSON.stringify({
@@ -192,7 +952,7 @@ function createAuthHandlers(handlersConfig = {}) {
192
952
  async authenticate(request) {
193
953
  const config = resolveNextConfig(handlersConfig);
194
954
  const secure = resolveCookieSecure(request, config);
195
- const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
955
+ const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
196
956
  const client = buildServerAuthClient(config);
197
957
  let payload;
198
958
  try {
@@ -244,7 +1004,7 @@ function createAuthHandlers(handlersConfig = {}) {
244
1004
  return Response.json({
245
1005
  status: "success",
246
1006
  redirectTo: afterSignInPath
247
- });
1007
+ }, { headers: { "set-cookie": buildAuthEventCookie("signed-in", secure) } });
248
1008
  }
249
1009
  case "select_tenant": return Response.json({
250
1010
  status: "select_tenant",
@@ -256,6 +1016,18 @@ function createAuthHandlers(handlersConfig = {}) {
256
1016
  userId: outcome.userId
257
1017
  }, { status: 403 });
258
1018
  case "no_access": return Response.json({ status: "no_access" }, { status: 403 });
1019
+ case "verification_required": return Response.json({
1020
+ status: "verification_required",
1021
+ challengeId: outcome.challengeId,
1022
+ factorTypes: outcome.factorTypes
1023
+ }, { status: 401 });
1024
+ case "configuration_error":
1025
+ 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.`);
1026
+ return Response.json({
1027
+ status: "configuration_error",
1028
+ code: outcome.code,
1029
+ message: outcome.message
1030
+ }, { status: 500 });
259
1031
  default: return Response.json({ status: "invalid_credentials" }, { status: 401 });
260
1032
  }
261
1033
  }
@@ -353,10 +1125,11 @@ function createAuthHandlers(handlersConfig = {}) {
353
1125
  return Response.redirect(home, 302);
354
1126
  },
355
1127
  async refresh(request) {
356
- const config = resolveNextConfig(handlersConfig);
357
- 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 });
1128
+ const { isSignedIn } = await refreshSession({
1129
+ ...handlersConfig,
1130
+ request
1131
+ });
1132
+ return Response.json({ isSignedIn }, { status: isSignedIn ? 200 : 401 });
360
1133
  },
361
1134
  /**
362
1135
  * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
@@ -373,12 +1146,185 @@ function createAuthHandlers(handlersConfig = {}) {
373
1146
  const secure = resolveCookieSecure(request, config);
374
1147
  const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
375
1148
  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);
1149
+ const event = buildAuthEventCookie("signed-out", secure);
1150
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true }, { headers: { "set-cookie": event } });
1151
+ return new Response(null, {
1152
+ status: 302,
1153
+ headers: {
1154
+ location: resolvePublicOrigin(request, config) + "/",
1155
+ "set-cookie": event
1156
+ }
1157
+ });
378
1158
  },
379
1159
  async session() {
380
1160
  return Response.json(await auth(handlersConfig));
381
1161
  },
1162
+ /**
1163
+ * Exchange the sealed session for a realtime handshake ticket (F019 / FR-RT-2).
1164
+ *
1165
+ * The session credential stays on this side: the SDK calls the platform with it and
1166
+ * hands the browser only the ticket, which is single-use and expires in seconds.
1167
+ */
1168
+ async realtimeTicket(request) {
1169
+ const config = resolveNextConfig(handlersConfig);
1170
+ if (!config.realtimeBaseUrl) return Response.json({
1171
+ status: "unavailable",
1172
+ message: "Set POLYX_REALTIME_URL to issue realtime tickets."
1173
+ }, { status: 503 });
1174
+ const current = await new ServerSession({
1175
+ authClient: buildServerAuthClient(config),
1176
+ secret: config.secret,
1177
+ cookies: await requestCookies()
1178
+ }).read();
1179
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
1180
+ const endpoint = `${config.realtimeBaseUrl.replace(/\/$/, "")}/v1/socket-ticket`;
1181
+ const send = (token) => fetch(endpoint, {
1182
+ method: "POST",
1183
+ headers: {
1184
+ Authorization: `Bearer ${token}`,
1185
+ Accept: "application/json"
1186
+ }
1187
+ });
1188
+ let organizationId = current.session.claims.organizationId;
1189
+ try {
1190
+ let upstream = await send(current.tokens.accessToken);
1191
+ if (upstream.status === 401) {
1192
+ const refreshed = await refreshSession({
1193
+ ...handlersConfig,
1194
+ request
1195
+ }).catch(() => null);
1196
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
1197
+ organizationId = refreshed.claims?.organizationId ?? organizationId;
1198
+ upstream = await send(refreshed.accessToken);
1199
+ }
1200
+ if (!upstream.ok) return Response.json({ status: "unavailable" }, { status: 503 });
1201
+ const body = await upstream.json();
1202
+ if (!body.ticket) return Response.json({ status: "unavailable" }, { status: 503 });
1203
+ return Response.json({
1204
+ ticket: body.ticket,
1205
+ expiresInSeconds: body.expiresInSeconds,
1206
+ organizationId
1207
+ });
1208
+ } catch {
1209
+ return Response.json({ status: "unavailable" }, { status: 503 });
1210
+ }
1211
+ },
1212
+ async profile(request) {
1213
+ const config = resolveNextConfig(handlersConfig);
1214
+ const session = new ServerSession({
1215
+ authClient: buildServerAuthClient(config),
1216
+ secret: config.secret,
1217
+ cookies: await requestCookies()
1218
+ });
1219
+ const current = await session.read();
1220
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
1221
+ const { userId, organizationId } = current.session.claims;
1222
+ if (!organizationId) return Response.json({ status: "no_organization" }, { status: 409 });
1223
+ if (!config.baseUrl) return Response.json({ status: "configuration_error" }, { status: 503 });
1224
+ const target = `${config.baseUrl.replace(/\/$/, "")}/v1/user/${encodeURIComponent(userId)}`;
1225
+ const headers = {
1226
+ "organization-id": organizationId,
1227
+ Accept: "application/json"
1228
+ };
1229
+ let body;
1230
+ if (request.method !== "GET") {
1231
+ /**
1232
+ * The body is REBUILT from an allow-list rather than forwarded.
1233
+ *
1234
+ * `updateUser` upstream applies whatever recognised fields it is given — including
1235
+ * `email`, `password` and `role`. Relaying the browser's multipart verbatim would
1236
+ * therefore turn a profile form into a way to change a credential or grant a role,
1237
+ * skipping the verification each of those has (D25). The allow-list is the control:
1238
+ * anything not in it never leaves this process.
1239
+ */
1240
+ /**
1241
+ * Parsed inside its own guard. `formData()` throws on a body that is not multipart or
1242
+ * form-encoded — the likeliest cause being an integrator posting JSON, which is the
1243
+ * obvious guess — and this sits before the try block that covers the upstream call, so
1244
+ * the rejection escaped the handler and Next answered 500. A 500 says the SDK broke;
1245
+ * an unreadable request is the caller's, and it belongs in nobody's alerting.
1246
+ */
1247
+ let submitted;
1248
+ try {
1249
+ submitted = await request.formData();
1250
+ } catch {
1251
+ return Response.json({ status: "invalid_request" }, { status: 400 });
1252
+ }
1253
+ body = new FormData();
1254
+ for (const field of PROFILE_FIELDS) {
1255
+ const value = submitted.get(field);
1256
+ if (typeof value === "string" && value.trim() !== "") body.append(field, value.trim());
1257
+ }
1258
+ const image = submitted.get("image");
1259
+ if (image && typeof image !== "string") body.append("image", image);
1260
+ }
1261
+ const send = (token) => fetch(target, {
1262
+ method: request.method === "GET" ? "GET" : "PUT",
1263
+ headers: {
1264
+ ...headers,
1265
+ Authorization: `Bearer ${token}`
1266
+ },
1267
+ body
1268
+ });
1269
+ let activeToken = current.tokens.accessToken;
1270
+ try {
1271
+ let upstream = await send(activeToken);
1272
+ if (upstream.status === 401) {
1273
+ const refreshed = await refreshSession({
1274
+ ...handlersConfig,
1275
+ request
1276
+ }).catch(() => null);
1277
+ if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
1278
+ activeToken = refreshed.accessToken;
1279
+ upstream = await send(activeToken);
1280
+ }
1281
+ const payload = await upstream.json().catch(() => ({}));
1282
+ if (!upstream.ok) return Response.json({
1283
+ status: "error",
1284
+ message: typeof payload.message === "string" ? payload.message : void 0
1285
+ }, { status: upstream.status });
1286
+ let user = payload.user ?? payload.data ?? payload;
1287
+ if (request.method !== "GET") {
1288
+ /**
1289
+ * Re-read after a save, because the update answers with the id ALONE.
1290
+ *
1291
+ * poly-auth returns `{message, data: {_id}}` from the update — deliberately, so a
1292
+ * password hash never rides on the response. So the saved document is simply not in
1293
+ * hand here: without this read the caller gets `{}` back and the display-claim update
1294
+ * below is a no-op against every field, leaving the account menu showing the name the
1295
+ * person just corrected (the exact thing FR-PROF-5 exists to prevent).
1296
+ *
1297
+ * The platform contract is frozen for the SDK, so the extra round trip is the answer
1298
+ * rather than asking poly-auth to return more.
1299
+ */
1300
+ const reread = await fetch(target, {
1301
+ method: "GET",
1302
+ headers: {
1303
+ ...headers,
1304
+ Authorization: `Bearer ${activeToken}`
1305
+ }
1306
+ }).catch(() => null);
1307
+ if (reread?.ok) {
1308
+ const body = await reread.json().catch(() => ({}));
1309
+ const fresh = body.user ?? body.data ?? body;
1310
+ if (fresh && typeof fresh === "object") user = fresh;
1311
+ }
1312
+ await session.updateDisplayClaims(deriveDisplayClaims(user));
1313
+ }
1314
+ return Response.json({
1315
+ status: "ok",
1316
+ user: pickProfileFields(user)
1317
+ });
1318
+ } catch {
1319
+ return Response.json({ status: "unavailable" }, { status: 503 });
1320
+ }
1321
+ },
1322
+ /**
1323
+ * @deprecated Superseded by `realtimeTicket` (FR-RT-4). This hands the browser the
1324
+ * SESSION ACCESS TOKEN — the one place the custody guarantee is knowingly broken.
1325
+ * Kept working for this release so consumers migrate on their own schedule
1326
+ * (FR-COMPAT-3); it will be removed in a later major.
1327
+ */
382
1328
  async subscriptionCredential() {
383
1329
  const config = resolveNextConfig(handlersConfig);
384
1330
  const current = await new ServerSession({
@@ -395,4 +1341,4 @@ function createAuthHandlers(handlersConfig = {}) {
395
1341
  };
396
1342
  }
397
1343
  //#endregion
398
- export { DEFAULT_SESSION_COOKIE, SESSION_COOKIE_MAX_BYTES, ServerSession, SessionCookieTooLargeError, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession, sealedCookieByteLength };
1344
+ 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 };