@poly-x/next 0.1.0-alpha.9 → 0.1.1

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/proxy.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- require("./server-session-DmR0ZJqP.cjs");
2
+ require("./server-session-Df9HXl_5.cjs");
3
3
  let next_server = require("next/server");
4
4
  //#region src/proxy.ts
5
5
  /**
package/dist/proxy.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import "./server-session-CXVB1Rgx.mjs";
1
+ import "./server-session-pY-nzh5x.mjs";
2
2
  import { NextResponse } from "next/server";
3
3
  //#region src/proxy.ts
4
4
  /**
@@ -1,23 +1,45 @@
1
1
  let _poly_x_core = require("@poly-x/core");
2
- //#region src/cookie-adapter.ts
2
+ //#region src/cookie-codec.ts
3
3
  /**
4
- * Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
5
- * write time from the actual transport: a `Secure` cookie is silently dropped by the
6
- * browser over plain http, so http/localhost dev must set `secure:false` while https
7
- * always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
8
- * http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
4
+ * AES-GCM sealing of the session cookie (F007 / ADR-0004). The key is derived
5
+ * (SHA-256) from the consumer's `POLYX_SESSION_SECRET`; AES-GCM gives
6
+ * confidentiality + tamper-detection together, so any corruption, truncation,
7
+ * or wrong-secret input decrypts to `null` rather than a partial session.
8
+ * Web Crypto is universal (Node ≥20 + edge runtimes), so this runs anywhere a
9
+ * Next route handler runs.
9
10
  */
10
- function buildSessionCookieOptions(secure) {
11
- return {
12
- httpOnly: true,
13
- secure,
14
- sameSite: "lax",
15
- path: "/"
16
- };
17
- }
18
- //#endregion
19
- //#region src/cookie-codec.ts
20
11
  const IV_BYTES = 12;
12
+ /**
13
+ * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
14
+ * name, value and attributes together — and enforce it by DISCARDING the cookie without
15
+ * an error. For a session cookie that failure is invisible and badly misleading: the
16
+ * platform authenticates, the handler answers 200, and the user is bounced back to
17
+ * sign-in on the next request because the cookie was never stored.
18
+ *
19
+ * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
20
+ * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
21
+ * the line. We fail loudly here instead.
22
+ */
23
+ const SESSION_COOKIE_MAX_BYTES = 4096;
24
+ /** Attributes `buildSessionCookieOptions` always sets: `Path=/; HttpOnly; Secure; SameSite=Lax`. */
25
+ const COOKIE_ATTRIBUTE_BYTES = 45;
26
+ /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
27
+ function sealedCookieByteLength(value, cookieName = "polyx_session") {
28
+ return new TextEncoder().encode(cookieName).length + 1 + value.length + COOKIE_ATTRIBUTE_BYTES;
29
+ }
30
+ /**
31
+ * Thrown when a sealed session would exceed what a browser will store. Never includes the
32
+ * session itself — the claims that caused the overflow are exactly the data we must not log.
33
+ */
34
+ var SessionCookieTooLargeError = class extends _poly_x_core.PolyXError {
35
+ bytes;
36
+ limit;
37
+ constructor(bytes, limit, cookieName) {
38
+ 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" });
39
+ this.bytes = bytes;
40
+ this.limit = limit;
41
+ }
42
+ };
21
43
  function base64UrlEncode(bytes) {
22
44
  let binary = "";
23
45
  for (const byte of bytes) binary += String.fromCharCode(byte);
@@ -35,7 +57,7 @@ async function deriveKey(secret) {
35
57
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
36
58
  return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt", "decrypt"]);
37
59
  }
38
- async function sealSession(session, secret) {
60
+ async function sealSession(session, secret, options = {}) {
39
61
  const key = await deriveKey(secret);
40
62
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
41
63
  const plaintext = new TextEncoder().encode(JSON.stringify(session));
@@ -46,7 +68,11 @@ async function sealSession(session, secret) {
46
68
  const packed = new Uint8Array(iv.length + ciphertext.length);
47
69
  packed.set(iv);
48
70
  packed.set(ciphertext, iv.length);
49
- return base64UrlEncode(packed);
71
+ const sealed = base64UrlEncode(packed);
72
+ const cookieName = options.cookieName ?? "polyx_session";
73
+ const bytes = sealedCookieByteLength(sealed, cookieName);
74
+ if (bytes > 4096) throw new SessionCookieTooLargeError(bytes, SESSION_COOKIE_MAX_BYTES, cookieName);
75
+ return sealed;
50
76
  }
51
77
  async function openSession(token, secret) {
52
78
  try {
@@ -65,6 +91,23 @@ async function openSession(token, secret) {
65
91
  }
66
92
  }
67
93
  //#endregion
94
+ //#region src/cookie-adapter.ts
95
+ /**
96
+ * Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
97
+ * write time from the actual transport: a `Secure` cookie is silently dropped by the
98
+ * browser over plain http, so http/localhost dev must set `secure:false` while https
99
+ * always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
100
+ * http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
101
+ */
102
+ function buildSessionCookieOptions(secure) {
103
+ return {
104
+ httpOnly: true,
105
+ secure,
106
+ sameSite: "lax",
107
+ path: "/"
108
+ };
109
+ }
110
+ //#endregion
68
111
  //#region src/server-session.ts
69
112
  /**
70
113
  * The framework-agnostic BFF session controller (F007): tokens sealed in an
@@ -85,11 +128,12 @@ var ServerSession = class {
85
128
  this.cookieName = options.cookieName ?? "polyx_session";
86
129
  this.secure = options.secure ?? true;
87
130
  }
88
- async establishFromCode(code, codeVerifier, redirectUri) {
131
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
89
132
  const stored = await this.authClient.exchangeCode({
90
133
  code,
91
134
  codeVerifier,
92
- redirectUri
135
+ redirectUri,
136
+ forwardedHeaders
93
137
  });
94
138
  await this.write(stored);
95
139
  return stored;
@@ -112,14 +156,37 @@ var ServerSession = class {
112
156
  this.clear();
113
157
  return null;
114
158
  }
159
+ if (error instanceof SessionCookieTooLargeError) {
160
+ console.error(`[polyx] ${error.message}`);
161
+ this.clear();
162
+ return null;
163
+ }
115
164
  throw error;
116
165
  }
117
166
  }
167
+ /**
168
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
169
+ * revokes every session for the user (FR-SSO-5).
170
+ *
171
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
172
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
173
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
174
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
175
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
176
+ * nothing and answered 200.
177
+ */
178
+ async revoke(scope = "device") {
179
+ const current = await this.read();
180
+ if (current) try {
181
+ await this.authClient.revokeSession(current.tokens.accessToken, scope);
182
+ } catch {}
183
+ this.clear();
184
+ }
118
185
  clear() {
119
186
  this.cookies.delete(this.cookieName);
120
187
  }
121
188
  async write(session) {
122
- const sealed = await sealSession(session, this.secret);
189
+ const sealed = await sealSession(session, this.secret, { cookieName: this.cookieName });
123
190
  this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
124
191
  }
125
192
  };
@@ -143,12 +210,24 @@ Object.defineProperty(exports, "DEFAULT_SESSION_COOKIE", {
143
210
  return DEFAULT_SESSION_COOKIE;
144
211
  }
145
212
  });
213
+ Object.defineProperty(exports, "SESSION_COOKIE_MAX_BYTES", {
214
+ enumerable: true,
215
+ get: function() {
216
+ return SESSION_COOKIE_MAX_BYTES;
217
+ }
218
+ });
146
219
  Object.defineProperty(exports, "ServerSession", {
147
220
  enumerable: true,
148
221
  get: function() {
149
222
  return ServerSession;
150
223
  }
151
224
  });
225
+ Object.defineProperty(exports, "SessionCookieTooLargeError", {
226
+ enumerable: true,
227
+ get: function() {
228
+ return SessionCookieTooLargeError;
229
+ }
230
+ });
152
231
  Object.defineProperty(exports, "openSession", {
153
232
  enumerable: true,
154
233
  get: function() {
@@ -161,6 +240,12 @@ Object.defineProperty(exports, "sealSession", {
161
240
  return sealSession;
162
241
  }
163
242
  });
243
+ Object.defineProperty(exports, "sealedCookieByteLength", {
244
+ enumerable: true,
245
+ get: function() {
246
+ return sealedCookieByteLength;
247
+ }
248
+ });
164
249
  Object.defineProperty(exports, "toAuthContext", {
165
250
  enumerable: true,
166
251
  get: function() {
@@ -1,23 +1,45 @@
1
- import { SessionExpiredError, SessionRevokedError } from "@poly-x/core";
2
- //#region src/cookie-adapter.ts
1
+ import { PolyXError, SessionExpiredError, SessionRevokedError } from "@poly-x/core";
2
+ //#region src/cookie-codec.ts
3
3
  /**
4
- * Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
5
- * write time from the actual transport: a `Secure` cookie is silently dropped by the
6
- * browser over plain http, so http/localhost dev must set `secure:false` while https
7
- * always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
8
- * http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
4
+ * AES-GCM sealing of the session cookie (F007 / ADR-0004). The key is derived
5
+ * (SHA-256) from the consumer's `POLYX_SESSION_SECRET`; AES-GCM gives
6
+ * confidentiality + tamper-detection together, so any corruption, truncation,
7
+ * or wrong-secret input decrypts to `null` rather than a partial session.
8
+ * Web Crypto is universal (Node ≥20 + edge runtimes), so this runs anywhere a
9
+ * Next route handler runs.
9
10
  */
10
- function buildSessionCookieOptions(secure) {
11
- return {
12
- httpOnly: true,
13
- secure,
14
- sameSite: "lax",
15
- path: "/"
16
- };
17
- }
18
- //#endregion
19
- //#region src/cookie-codec.ts
20
11
  const IV_BYTES = 12;
12
+ /**
13
+ * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
14
+ * name, value and attributes together — and enforce it by DISCARDING the cookie without
15
+ * an error. For a session cookie that failure is invisible and badly misleading: the
16
+ * platform authenticates, the handler answers 200, and the user is bounced back to
17
+ * sign-in on the next request because the cookie was never stored.
18
+ *
19
+ * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
20
+ * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
21
+ * the line. We fail loudly here instead.
22
+ */
23
+ const SESSION_COOKIE_MAX_BYTES = 4096;
24
+ /** Attributes `buildSessionCookieOptions` always sets: `Path=/; HttpOnly; Secure; SameSite=Lax`. */
25
+ const COOKIE_ATTRIBUTE_BYTES = 45;
26
+ /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
27
+ function sealedCookieByteLength(value, cookieName = "polyx_session") {
28
+ return new TextEncoder().encode(cookieName).length + 1 + value.length + COOKIE_ATTRIBUTE_BYTES;
29
+ }
30
+ /**
31
+ * Thrown when a sealed session would exceed what a browser will store. Never includes the
32
+ * session itself — the claims that caused the overflow are exactly the data we must not log.
33
+ */
34
+ var SessionCookieTooLargeError = class extends PolyXError {
35
+ bytes;
36
+ limit;
37
+ constructor(bytes, limit, cookieName) {
38
+ 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" });
39
+ this.bytes = bytes;
40
+ this.limit = limit;
41
+ }
42
+ };
21
43
  function base64UrlEncode(bytes) {
22
44
  let binary = "";
23
45
  for (const byte of bytes) binary += String.fromCharCode(byte);
@@ -35,7 +57,7 @@ async function deriveKey(secret) {
35
57
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
36
58
  return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt", "decrypt"]);
37
59
  }
38
- async function sealSession(session, secret) {
60
+ async function sealSession(session, secret, options = {}) {
39
61
  const key = await deriveKey(secret);
40
62
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
41
63
  const plaintext = new TextEncoder().encode(JSON.stringify(session));
@@ -46,7 +68,11 @@ async function sealSession(session, secret) {
46
68
  const packed = new Uint8Array(iv.length + ciphertext.length);
47
69
  packed.set(iv);
48
70
  packed.set(ciphertext, iv.length);
49
- return base64UrlEncode(packed);
71
+ const sealed = base64UrlEncode(packed);
72
+ const cookieName = options.cookieName ?? "polyx_session";
73
+ const bytes = sealedCookieByteLength(sealed, cookieName);
74
+ if (bytes > 4096) throw new SessionCookieTooLargeError(bytes, SESSION_COOKIE_MAX_BYTES, cookieName);
75
+ return sealed;
50
76
  }
51
77
  async function openSession(token, secret) {
52
78
  try {
@@ -65,6 +91,23 @@ async function openSession(token, secret) {
65
91
  }
66
92
  }
67
93
  //#endregion
94
+ //#region src/cookie-adapter.ts
95
+ /**
96
+ * Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
97
+ * write time from the actual transport: a `Secure` cookie is silently dropped by the
98
+ * browser over plain http, so http/localhost dev must set `secure:false` while https
99
+ * always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
100
+ * http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
101
+ */
102
+ function buildSessionCookieOptions(secure) {
103
+ return {
104
+ httpOnly: true,
105
+ secure,
106
+ sameSite: "lax",
107
+ path: "/"
108
+ };
109
+ }
110
+ //#endregion
68
111
  //#region src/server-session.ts
69
112
  /**
70
113
  * The framework-agnostic BFF session controller (F007): tokens sealed in an
@@ -85,11 +128,12 @@ var ServerSession = class {
85
128
  this.cookieName = options.cookieName ?? "polyx_session";
86
129
  this.secure = options.secure ?? true;
87
130
  }
88
- async establishFromCode(code, codeVerifier, redirectUri) {
131
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
89
132
  const stored = await this.authClient.exchangeCode({
90
133
  code,
91
134
  codeVerifier,
92
- redirectUri
135
+ redirectUri,
136
+ forwardedHeaders
93
137
  });
94
138
  await this.write(stored);
95
139
  return stored;
@@ -112,14 +156,37 @@ var ServerSession = class {
112
156
  this.clear();
113
157
  return null;
114
158
  }
159
+ if (error instanceof SessionCookieTooLargeError) {
160
+ console.error(`[polyx] ${error.message}`);
161
+ this.clear();
162
+ return null;
163
+ }
115
164
  throw error;
116
165
  }
117
166
  }
167
+ /**
168
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
169
+ * revokes every session for the user (FR-SSO-5).
170
+ *
171
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
172
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
173
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
174
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
175
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
176
+ * nothing and answered 200.
177
+ */
178
+ async revoke(scope = "device") {
179
+ const current = await this.read();
180
+ if (current) try {
181
+ await this.authClient.revokeSession(current.tokens.accessToken, scope);
182
+ } catch {}
183
+ this.clear();
184
+ }
118
185
  clear() {
119
186
  this.cookies.delete(this.cookieName);
120
187
  }
121
188
  async write(session) {
122
- const sealed = await sealSession(session, this.secret);
189
+ const sealed = await sealSession(session, this.secret, { cookieName: this.cookieName });
123
190
  this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
124
191
  }
125
192
  };
@@ -137,4 +204,4 @@ function toAuthContext(session) {
137
204
  };
138
205
  }
139
206
  //#endregion
140
- export { sealSession as a, openSession as i, ServerSession as n, toAuthContext as r, DEFAULT_SESSION_COOKIE as t };
207
+ export { SessionCookieTooLargeError as a, sealedCookieByteLength as c, SESSION_COOKIE_MAX_BYTES as i, ServerSession as n, openSession as o, toAuthContext as r, sealSession as s, DEFAULT_SESSION_COOKIE as t };
package/dist/server.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_server_session = require("./server-session-DmR0ZJqP.cjs");
2
+ const require_server_session = require("./server-session-Df9HXl_5.cjs");
3
3
  let _poly_x_core = require("@poly-x/core");
4
4
  let next_headers = require("next/headers");
5
5
  //#region src/config.ts
@@ -72,6 +72,79 @@ function resolveCookieSecure(request, config) {
72
72
  return true;
73
73
  }
74
74
  }
75
+ /** Browser request headers the platform reads for session device attribution. */
76
+ const FORWARDED_DEVICE_HEADERS = [
77
+ "user-agent",
78
+ "accept-language",
79
+ "sec-ch-ua",
80
+ "sec-ch-ua-platform",
81
+ "sec-ch-ua-mobile",
82
+ "sec-ch-ua-model"
83
+ ];
84
+ /**
85
+ * The end user's IP as observed by THIS BFF's own infrastructure — the left-most
86
+ * entry of the inbound `x-forwarded-for` (what the platform/proxy in front of the
87
+ * app set), falling back to `x-real-ip`. Returns undefined on localhost/dev where
88
+ * neither is present, letting the platform fall back to its own resolution rather
89
+ * than recording a blank. (This trusts the infra-set forwarded chain, not a value
90
+ * the browser could set on the same-origin BFF request.)
91
+ */
92
+ function resolveClientIp(request) {
93
+ const first = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
94
+ if (first) return first;
95
+ return request.headers.get("x-real-ip")?.trim() || void 0;
96
+ }
97
+ /**
98
+ * Curate the browser context to forward on the session-creating token exchange.
99
+ * In a BFF deployment the outbound call to poly-auth is made by Node, so without
100
+ * this the platform attributes the session to the server ("node"/Unknown UA, the
101
+ * server's IP). We forward a fixed allowlist of the browser's own request headers
102
+ * plus the observed client IP (as `x-forwarded-for`). Only headers actually
103
+ * present are forwarded — never blank values that would clobber the platform's
104
+ * own fallback resolution.
105
+ */
106
+ function collectForwardedDeviceHeaders(request) {
107
+ const forwarded = {};
108
+ for (const name of FORWARDED_DEVICE_HEADERS) {
109
+ const value = request.headers.get(name);
110
+ if (value) forwarded[name] = value;
111
+ }
112
+ const clientIp = resolveClientIp(request);
113
+ if (clientIp) forwarded["x-forwarded-for"] = clientIp;
114
+ return forwarded;
115
+ }
116
+ /**
117
+ * Build the forwarded precise-location headers (`x-polyx-geo-*`) from the browser's login
118
+ * body (v6, FR-LOC-7). The browser captures a consented position client-side and posts it;
119
+ * the platform reads these on the token exchange. Only well-formed values are forwarded, and
120
+ * the consent state rides even when coordinates are absent (denied/timeout → coarse fallback).
121
+ */
122
+ function geoHeadersFromBody(geo) {
123
+ const headers = {};
124
+ if (!geo || typeof geo !== "object") return headers;
125
+ const g = geo;
126
+ const lat = Number(g.latitude);
127
+ const lon = Number(g.longitude);
128
+ if (typeof g.latitude === "string" && Number.isFinite(lat) && lat >= -90 && lat <= 90) headers["x-polyx-geo-lat"] = g.latitude;
129
+ if (typeof g.longitude === "string" && Number.isFinite(lon) && lon >= -180 && lon <= 180) headers["x-polyx-geo-lon"] = g.longitude;
130
+ if (typeof g.accuracyMeters === "number" && Number.isFinite(g.accuracyMeters) && g.accuracyMeters >= 0) headers["x-polyx-geo-accuracy"] = String(g.accuracyMeters);
131
+ if (g.consent === "granted" || g.consent === "denied" || g.consent === "revoked") headers["x-polyx-geo-consent"] = g.consent;
132
+ return headers;
133
+ }
134
+ /**
135
+ * Build the device-context headers (`x-timezone` / `x-screen-resolution`) from the browser's login
136
+ * body (v6). These are not automatic request headers — the browser captures them client-side and
137
+ * posts them; the platform reads them for session device attribution. Only well-formed values are
138
+ * forwarded (a sane timezone string, a `WxH` resolution) so junk never reaches the platform.
139
+ */
140
+ function deviceContextHeadersFromBody(deviceContext) {
141
+ const headers = {};
142
+ if (!deviceContext || typeof deviceContext !== "object") return headers;
143
+ const d = deviceContext;
144
+ if (typeof d.timezone === "string" && d.timezone.length > 0 && d.timezone.length <= 64) headers["x-timezone"] = d.timezone;
145
+ if (typeof d.screenResolution === "string" && /^\d{1,5}x\d{1,5}$/.test(d.screenResolution)) headers["x-screen-resolution"] = d.screenResolution;
146
+ return headers;
147
+ }
75
148
  /** Read the current session — safe in Server Components (no cookie write). */
76
149
  async function auth(overrides) {
77
150
  const config = resolveNextConfig(overrides);
@@ -128,6 +201,8 @@ function createAuthHandlers(handlersConfig = {}) {
128
201
  } catch {
129
202
  return Response.json({ status: "invalid_request" }, { status: 400 });
130
203
  }
204
+ const geoHeaders = geoHeadersFromBody(payload.geo);
205
+ const deviceContextHeaders = deviceContextHeadersFromBody(payload.deviceContext);
131
206
  const email = typeof payload.email === "string" ? payload.email : "";
132
207
  const password = typeof payload.password === "string" ? payload.password : "";
133
208
  if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
@@ -148,12 +223,30 @@ function createAuthHandlers(handlersConfig = {}) {
148
223
  tenantId
149
224
  });
150
225
  switch (outcome.type) {
151
- case "authorized":
152
- await newSession(adapt(await (0, next_headers.cookies)()), config, secure).establishFromCode(outcome.code, verifier, redirectUri);
226
+ case "authorized": {
227
+ const store = await (0, next_headers.cookies)();
228
+ try {
229
+ await newSession(adapt(store), config, secure).establishFromCode(outcome.code, verifier, redirectUri, {
230
+ ...collectForwardedDeviceHeaders(request),
231
+ ...geoHeaders,
232
+ ...deviceContextHeaders
233
+ });
234
+ } catch (error) {
235
+ if (error instanceof require_server_session.SessionCookieTooLargeError) {
236
+ console.error(`[polyx] ${error.message}`);
237
+ return Response.json({
238
+ status: "session_too_large",
239
+ bytes: error.bytes,
240
+ limit: error.limit
241
+ }, { status: 500 });
242
+ }
243
+ throw error;
244
+ }
153
245
  return Response.json({
154
246
  status: "success",
155
247
  redirectTo: afterSignInPath
156
248
  });
249
+ }
157
250
  case "select_tenant": return Response.json({
158
251
  status: "select_tenant",
159
252
  tenants: outcome.tenants,
@@ -249,7 +342,15 @@ function createAuthHandlers(handlersConfig = {}) {
249
342
  if (!code || !state || !raw) return Response.redirect(home, 302);
250
343
  const tx = JSON.parse(raw);
251
344
  if (tx.state !== state) return Response.redirect(home, 302);
252
- await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri);
345
+ try {
346
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
347
+ } catch (error) {
348
+ if (error instanceof require_server_session.SessionCookieTooLargeError) {
349
+ console.error(`[polyx] ${error.message}`);
350
+ return Response.redirect(home, 302);
351
+ }
352
+ throw error;
353
+ }
253
354
  return Response.redirect(home, 302);
254
355
  },
255
356
  async refresh(request) {
@@ -258,22 +359,50 @@ function createAuthHandlers(handlersConfig = {}) {
258
359
  const session = await newSession(await requestCookies(), config, secure).refresh();
259
360
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
260
361
  },
362
+ /**
363
+ * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
364
+ * and unseal the cookie. `?scope=everywhere` signs the user out of every device.
365
+ *
366
+ * Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
367
+ * expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
368
+ * decides its own destination client-side. Deliberately no server-side redirect target
369
+ * — a caller-supplied one would be an open redirect on the auth path, and the client
370
+ * already knows where it wants to land.
371
+ */
261
372
  async signout(request) {
262
373
  const config = resolveNextConfig(handlersConfig);
263
374
  const secure = resolveCookieSecure(request, config);
264
- newSession(adapt(await (0, next_headers.cookies)()), config, secure).clear();
265
- try {
266
- await buildServerAuthClient(config).logout("device");
267
- } catch {}
375
+ const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
376
+ 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 });
268
378
  return Response.redirect(new URL(request.url).origin + "/", 302);
379
+ },
380
+ async session() {
381
+ return Response.json(await auth(handlersConfig));
382
+ },
383
+ async subscriptionCredential() {
384
+ const config = resolveNextConfig(handlersConfig);
385
+ const current = await new require_server_session.ServerSession({
386
+ authClient: buildServerAuthClient(config),
387
+ secret: config.secret,
388
+ cookies: await requestCookies()
389
+ }).read();
390
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
391
+ return Response.json({
392
+ token: current.tokens.accessToken,
393
+ organizationId: current.session.claims.organizationId
394
+ });
269
395
  }
270
396
  };
271
397
  }
272
398
  //#endregion
273
399
  exports.DEFAULT_SESSION_COOKIE = require_server_session.DEFAULT_SESSION_COOKIE;
400
+ exports.SESSION_COOKIE_MAX_BYTES = require_server_session.SESSION_COOKIE_MAX_BYTES;
274
401
  exports.ServerSession = require_server_session.ServerSession;
402
+ exports.SessionCookieTooLargeError = require_server_session.SessionCookieTooLargeError;
275
403
  exports.auth = auth;
276
404
  exports.createAuthHandlers = createAuthHandlers;
277
405
  exports.openSession = require_server_session.openSession;
278
406
  exports.resolveNextConfig = resolveNextConfig;
279
407
  exports.sealSession = require_server_session.sealSession;
408
+ exports.sealedCookieByteLength = require_server_session.sealedCookieByteLength;
package/dist/server.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AuthClient, StoredSession } from "@poly-x/core";
1
+ import { AuthClient, PolyXError, StoredSession } from "@poly-x/core";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  interface NextServerConfig {
@@ -58,10 +58,22 @@ declare class ServerSession {
58
58
  private readonly cookieName;
59
59
  private readonly secure;
60
60
  constructor(options: ServerSessionOptions);
61
- establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
61
+ establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
62
62
  read(): Promise<StoredSession | null>;
63
63
  /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
64
64
  refresh(): Promise<StoredSession | null>;
65
+ /**
66
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
67
+ * revokes every session for the user (FR-SSO-5).
68
+ *
69
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
70
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
71
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
72
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
73
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
74
+ * nothing and answered 200.
75
+ */
76
+ revoke(scope?: "device" | "everywhere"): Promise<void>;
65
77
  clear(): void;
66
78
  private write;
67
79
  }
@@ -73,7 +85,34 @@ interface AuthContext {
73
85
  }
74
86
  //#endregion
75
87
  //#region src/cookie-codec.d.ts
76
- declare function sealSession(session: StoredSession, secret: string): Promise<string>;
88
+ /**
89
+ * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
90
+ * name, value and attributes together — and enforce it by DISCARDING the cookie without
91
+ * an error. For a session cookie that failure is invisible and badly misleading: the
92
+ * platform authenticates, the handler answers 200, and the user is bounced back to
93
+ * sign-in on the next request because the cookie was never stored.
94
+ *
95
+ * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
96
+ * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
97
+ * the line. We fail loudly here instead.
98
+ */
99
+ declare const SESSION_COOKIE_MAX_BYTES = 4096;
100
+ /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
101
+ declare function sealedCookieByteLength(value: string, cookieName?: string): number;
102
+ /**
103
+ * Thrown when a sealed session would exceed what a browser will store. Never includes the
104
+ * session itself — the claims that caused the overflow are exactly the data we must not log.
105
+ */
106
+ declare class SessionCookieTooLargeError extends PolyXError {
107
+ readonly bytes: number;
108
+ readonly limit: number;
109
+ constructor(bytes: number, limit: number, cookieName: string);
110
+ }
111
+ interface SealSessionOptions {
112
+ /** The cookie name the value will be stored under; counted against the browser budget. */
113
+ cookieName?: string;
114
+ }
115
+ declare function sealSession(session: StoredSession, secret: string, options?: SealSessionOptions): Promise<string>;
77
116
  declare function openSession(token: string, secret: string): Promise<StoredSession | null>;
78
117
  //#endregion
79
118
  //#region src/server.d.ts
@@ -108,6 +147,21 @@ interface AuthHandlers {
108
147
  callback(request: Request): Promise<Response>;
109
148
  refresh(request: Request): Promise<Response>;
110
149
  signout(request: Request): Promise<Response>;
150
+ /**
151
+ * The client's view of the session: the same `AuthContext` `auth()` returns in a Server
152
+ * Component, as JSON. This is what makes client components work under BFF custody, where
153
+ * the browser holds no token and so cannot derive a session for itself — the hooks read
154
+ * server truth instead. Never carries a token.
155
+ */
156
+ session(request: Request): Promise<Response>;
157
+ /**
158
+ * v2 (ADR-0008): a session-derived credential for the real-time `authz:changed` channel. Returns
159
+ * `{ token, organizationId }` read from the sealed session server-side — the token is for the
160
+ * socket handshake only (in-memory; never persisted), per the pragmatic custody decision. A
161
+ * signed-out caller gets `401` with no token. This is the one place a token leaves the BFF, and
162
+ * only to open the live-authz subscription.
163
+ */
164
+ subscriptionCredential(request: Request): Promise<Response>;
111
165
  }
112
166
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
113
167
  type ForgotPasswordResult = {
@@ -148,9 +202,13 @@ type AuthenticateResult = {
148
202
  status: "no_access";
149
203
  } | {
150
204
  status: "invalid_credentials";
205
+ } /** Credentials were correct, but the sealed session exceeds what a browser will store. */ | {
206
+ status: "session_too_large";
207
+ bytes: number;
208
+ limit: number;
151
209
  } | {
152
210
  status: "invalid_request";
153
211
  };
154
212
  declare function createAuthHandlers(handlersConfig?: AuthHandlersConfig): AuthHandlers;
155
213
  //#endregion
156
- export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, ServerSession, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession };
214
+ export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, SESSION_COOKIE_MAX_BYTES, ServerSession, SessionCookieTooLargeError, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession, sealedCookieByteLength };
package/dist/server.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AuthClient, StoredSession } from "@poly-x/core";
1
+ import { AuthClient, PolyXError, StoredSession } from "@poly-x/core";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  interface NextServerConfig {
@@ -58,10 +58,22 @@ declare class ServerSession {
58
58
  private readonly cookieName;
59
59
  private readonly secure;
60
60
  constructor(options: ServerSessionOptions);
61
- establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
61
+ establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
62
62
  read(): Promise<StoredSession | null>;
63
63
  /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
64
64
  refresh(): Promise<StoredSession | null>;
65
+ /**
66
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
67
+ * revokes every session for the user (FR-SSO-5).
68
+ *
69
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
70
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
71
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
72
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
73
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
74
+ * nothing and answered 200.
75
+ */
76
+ revoke(scope?: "device" | "everywhere"): Promise<void>;
65
77
  clear(): void;
66
78
  private write;
67
79
  }
@@ -73,7 +85,34 @@ interface AuthContext {
73
85
  }
74
86
  //#endregion
75
87
  //#region src/cookie-codec.d.ts
76
- declare function sealSession(session: StoredSession, secret: string): Promise<string>;
88
+ /**
89
+ * Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
90
+ * name, value and attributes together — and enforce it by DISCARDING the cookie without
91
+ * an error. For a session cookie that failure is invisible and badly misleading: the
92
+ * platform authenticates, the handler answers 200, and the user is bounced back to
93
+ * sign-in on the next request because the cookie was never stored.
94
+ *
95
+ * poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
96
+ * guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
97
+ * the line. We fail loudly here instead.
98
+ */
99
+ declare const SESSION_COOKIE_MAX_BYTES = 4096;
100
+ /** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
101
+ declare function sealedCookieByteLength(value: string, cookieName?: string): number;
102
+ /**
103
+ * Thrown when a sealed session would exceed what a browser will store. Never includes the
104
+ * session itself — the claims that caused the overflow are exactly the data we must not log.
105
+ */
106
+ declare class SessionCookieTooLargeError extends PolyXError {
107
+ readonly bytes: number;
108
+ readonly limit: number;
109
+ constructor(bytes: number, limit: number, cookieName: string);
110
+ }
111
+ interface SealSessionOptions {
112
+ /** The cookie name the value will be stored under; counted against the browser budget. */
113
+ cookieName?: string;
114
+ }
115
+ declare function sealSession(session: StoredSession, secret: string, options?: SealSessionOptions): Promise<string>;
77
116
  declare function openSession(token: string, secret: string): Promise<StoredSession | null>;
78
117
  //#endregion
79
118
  //#region src/server.d.ts
@@ -108,6 +147,21 @@ interface AuthHandlers {
108
147
  callback(request: Request): Promise<Response>;
109
148
  refresh(request: Request): Promise<Response>;
110
149
  signout(request: Request): Promise<Response>;
150
+ /**
151
+ * The client's view of the session: the same `AuthContext` `auth()` returns in a Server
152
+ * Component, as JSON. This is what makes client components work under BFF custody, where
153
+ * the browser holds no token and so cannot derive a session for itself — the hooks read
154
+ * server truth instead. Never carries a token.
155
+ */
156
+ session(request: Request): Promise<Response>;
157
+ /**
158
+ * v2 (ADR-0008): a session-derived credential for the real-time `authz:changed` channel. Returns
159
+ * `{ token, organizationId }` read from the sealed session server-side — the token is for the
160
+ * socket handshake only (in-memory; never persisted), per the pragmatic custody decision. A
161
+ * signed-out caller gets `401` with no token. This is the one place a token leaves the BFF, and
162
+ * only to open the live-authz subscription.
163
+ */
164
+ subscriptionCredential(request: Request): Promise<Response>;
111
165
  }
112
166
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
113
167
  type ForgotPasswordResult = {
@@ -148,9 +202,13 @@ type AuthenticateResult = {
148
202
  status: "no_access";
149
203
  } | {
150
204
  status: "invalid_credentials";
205
+ } /** Credentials were correct, but the sealed session exceeds what a browser will store. */ | {
206
+ status: "session_too_large";
207
+ bytes: number;
208
+ limit: number;
151
209
  } | {
152
210
  status: "invalid_request";
153
211
  };
154
212
  declare function createAuthHandlers(handlersConfig?: AuthHandlersConfig): AuthHandlers;
155
213
  //#endregion
156
- export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, ServerSession, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession };
214
+ export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, SESSION_COOKIE_MAX_BYTES, ServerSession, SessionCookieTooLargeError, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession, sealedCookieByteLength };
package/dist/server.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as sealSession, i as openSession, n as ServerSession, r as toAuthContext, t as DEFAULT_SESSION_COOKIE } from "./server-session-CXVB1Rgx.mjs";
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
2
  import { AuthClient, FetchTransport, generatePkce, randomState, resolveConfig } from "@poly-x/core";
3
3
  import { cookies } from "next/headers";
4
4
  //#region src/config.ts
@@ -71,6 +71,79 @@ function resolveCookieSecure(request, config) {
71
71
  return true;
72
72
  }
73
73
  }
74
+ /** Browser request headers the platform reads for session device attribution. */
75
+ const FORWARDED_DEVICE_HEADERS = [
76
+ "user-agent",
77
+ "accept-language",
78
+ "sec-ch-ua",
79
+ "sec-ch-ua-platform",
80
+ "sec-ch-ua-mobile",
81
+ "sec-ch-ua-model"
82
+ ];
83
+ /**
84
+ * The end user's IP as observed by THIS BFF's own infrastructure — the left-most
85
+ * entry of the inbound `x-forwarded-for` (what the platform/proxy in front of the
86
+ * app set), falling back to `x-real-ip`. Returns undefined on localhost/dev where
87
+ * neither is present, letting the platform fall back to its own resolution rather
88
+ * than recording a blank. (This trusts the infra-set forwarded chain, not a value
89
+ * the browser could set on the same-origin BFF request.)
90
+ */
91
+ function resolveClientIp(request) {
92
+ const first = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
93
+ if (first) return first;
94
+ return request.headers.get("x-real-ip")?.trim() || void 0;
95
+ }
96
+ /**
97
+ * Curate the browser context to forward on the session-creating token exchange.
98
+ * In a BFF deployment the outbound call to poly-auth is made by Node, so without
99
+ * this the platform attributes the session to the server ("node"/Unknown UA, the
100
+ * server's IP). We forward a fixed allowlist of the browser's own request headers
101
+ * plus the observed client IP (as `x-forwarded-for`). Only headers actually
102
+ * present are forwarded — never blank values that would clobber the platform's
103
+ * own fallback resolution.
104
+ */
105
+ function collectForwardedDeviceHeaders(request) {
106
+ const forwarded = {};
107
+ for (const name of FORWARDED_DEVICE_HEADERS) {
108
+ const value = request.headers.get(name);
109
+ if (value) forwarded[name] = value;
110
+ }
111
+ const clientIp = resolveClientIp(request);
112
+ if (clientIp) forwarded["x-forwarded-for"] = clientIp;
113
+ return forwarded;
114
+ }
115
+ /**
116
+ * Build the forwarded precise-location headers (`x-polyx-geo-*`) from the browser's login
117
+ * body (v6, FR-LOC-7). The browser captures a consented position client-side and posts it;
118
+ * the platform reads these on the token exchange. Only well-formed values are forwarded, and
119
+ * the consent state rides even when coordinates are absent (denied/timeout → coarse fallback).
120
+ */
121
+ function geoHeadersFromBody(geo) {
122
+ const headers = {};
123
+ if (!geo || typeof geo !== "object") return headers;
124
+ const g = geo;
125
+ const lat = Number(g.latitude);
126
+ const lon = Number(g.longitude);
127
+ if (typeof g.latitude === "string" && Number.isFinite(lat) && lat >= -90 && lat <= 90) headers["x-polyx-geo-lat"] = g.latitude;
128
+ if (typeof g.longitude === "string" && Number.isFinite(lon) && lon >= -180 && lon <= 180) headers["x-polyx-geo-lon"] = g.longitude;
129
+ if (typeof g.accuracyMeters === "number" && Number.isFinite(g.accuracyMeters) && g.accuracyMeters >= 0) headers["x-polyx-geo-accuracy"] = String(g.accuracyMeters);
130
+ if (g.consent === "granted" || g.consent === "denied" || g.consent === "revoked") headers["x-polyx-geo-consent"] = g.consent;
131
+ return headers;
132
+ }
133
+ /**
134
+ * Build the device-context headers (`x-timezone` / `x-screen-resolution`) from the browser's login
135
+ * body (v6). These are not automatic request headers — the browser captures them client-side and
136
+ * posts them; the platform reads them for session device attribution. Only well-formed values are
137
+ * forwarded (a sane timezone string, a `WxH` resolution) so junk never reaches the platform.
138
+ */
139
+ function deviceContextHeadersFromBody(deviceContext) {
140
+ const headers = {};
141
+ if (!deviceContext || typeof deviceContext !== "object") return headers;
142
+ const d = deviceContext;
143
+ if (typeof d.timezone === "string" && d.timezone.length > 0 && d.timezone.length <= 64) headers["x-timezone"] = d.timezone;
144
+ if (typeof d.screenResolution === "string" && /^\d{1,5}x\d{1,5}$/.test(d.screenResolution)) headers["x-screen-resolution"] = d.screenResolution;
145
+ return headers;
146
+ }
74
147
  /** Read the current session — safe in Server Components (no cookie write). */
75
148
  async function auth(overrides) {
76
149
  const config = resolveNextConfig(overrides);
@@ -127,6 +200,8 @@ function createAuthHandlers(handlersConfig = {}) {
127
200
  } catch {
128
201
  return Response.json({ status: "invalid_request" }, { status: 400 });
129
202
  }
203
+ const geoHeaders = geoHeadersFromBody(payload.geo);
204
+ const deviceContextHeaders = deviceContextHeadersFromBody(payload.deviceContext);
130
205
  const email = typeof payload.email === "string" ? payload.email : "";
131
206
  const password = typeof payload.password === "string" ? payload.password : "";
132
207
  if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
@@ -147,12 +222,30 @@ function createAuthHandlers(handlersConfig = {}) {
147
222
  tenantId
148
223
  });
149
224
  switch (outcome.type) {
150
- case "authorized":
151
- await newSession(adapt(await cookies()), config, secure).establishFromCode(outcome.code, verifier, redirectUri);
225
+ case "authorized": {
226
+ const store = await cookies();
227
+ try {
228
+ await newSession(adapt(store), config, secure).establishFromCode(outcome.code, verifier, redirectUri, {
229
+ ...collectForwardedDeviceHeaders(request),
230
+ ...geoHeaders,
231
+ ...deviceContextHeaders
232
+ });
233
+ } catch (error) {
234
+ if (error instanceof SessionCookieTooLargeError) {
235
+ console.error(`[polyx] ${error.message}`);
236
+ return Response.json({
237
+ status: "session_too_large",
238
+ bytes: error.bytes,
239
+ limit: error.limit
240
+ }, { status: 500 });
241
+ }
242
+ throw error;
243
+ }
152
244
  return Response.json({
153
245
  status: "success",
154
246
  redirectTo: afterSignInPath
155
247
  });
248
+ }
156
249
  case "select_tenant": return Response.json({
157
250
  status: "select_tenant",
158
251
  tenants: outcome.tenants,
@@ -248,7 +341,15 @@ function createAuthHandlers(handlersConfig = {}) {
248
341
  if (!code || !state || !raw) return Response.redirect(home, 302);
249
342
  const tx = JSON.parse(raw);
250
343
  if (tx.state !== state) return Response.redirect(home, 302);
251
- await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri);
344
+ try {
345
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
346
+ } catch (error) {
347
+ if (error instanceof SessionCookieTooLargeError) {
348
+ console.error(`[polyx] ${error.message}`);
349
+ return Response.redirect(home, 302);
350
+ }
351
+ throw error;
352
+ }
252
353
  return Response.redirect(home, 302);
253
354
  },
254
355
  async refresh(request) {
@@ -257,16 +358,41 @@ function createAuthHandlers(handlersConfig = {}) {
257
358
  const session = await newSession(await requestCookies(), config, secure).refresh();
258
359
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
259
360
  },
361
+ /**
362
+ * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
363
+ * and unseal the cookie. `?scope=everywhere` signs the user out of every device.
364
+ *
365
+ * Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
366
+ * expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
367
+ * decides its own destination client-side. Deliberately no server-side redirect target
368
+ * — a caller-supplied one would be an open redirect on the auth path, and the client
369
+ * already knows where it wants to land.
370
+ */
260
371
  async signout(request) {
261
372
  const config = resolveNextConfig(handlersConfig);
262
373
  const secure = resolveCookieSecure(request, config);
263
- newSession(adapt(await cookies()), config, secure).clear();
264
- try {
265
- await buildServerAuthClient(config).logout("device");
266
- } catch {}
374
+ const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
375
+ await newSession(adapt(await cookies()), config, secure).revoke(scope);
376
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
267
377
  return Response.redirect(new URL(request.url).origin + "/", 302);
378
+ },
379
+ async session() {
380
+ return Response.json(await auth(handlersConfig));
381
+ },
382
+ async subscriptionCredential() {
383
+ const config = resolveNextConfig(handlersConfig);
384
+ const current = await new ServerSession({
385
+ authClient: buildServerAuthClient(config),
386
+ secret: config.secret,
387
+ cookies: await requestCookies()
388
+ }).read();
389
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
390
+ return Response.json({
391
+ token: current.tokens.accessToken,
392
+ organizationId: current.session.claims.organizationId
393
+ });
268
394
  }
269
395
  };
270
396
  }
271
397
  //#endregion
272
- export { DEFAULT_SESSION_COOKIE, ServerSession, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession };
398
+ export { DEFAULT_SESSION_COOKIE, SESSION_COOKIE_MAX_BYTES, ServerSession, SessionCookieTooLargeError, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession, sealedCookieByteLength };
package/dist/styles.css CHANGED
@@ -10,8 +10,33 @@
10
10
  * (`:root`, a wrapper div, …) — that is the supported theming surface. Dark mode
11
11
  * follows the OS by default and defers to an explicit `.dark` / `[data-theme]`
12
12
  * on a host ancestor when one is present.
13
+ *
14
+ * ---------------------------------------------------------------------------
15
+ * Cascade: everything here lives in the `polyx` layer, and that is load-bearing.
16
+ *
17
+ * Unlayered CSS beats layered CSS outright — not by specificity, but by rank; no
18
+ * import order or selector weight changes it. Shipping these rules unlayered
19
+ * therefore beat every Tailwind utility a host wrote (Tailwind emits utilities
20
+ * into `@layer utilities`), so `className`/`triggerClassName` silently did
21
+ * nothing and hosts were pushed toward `!important`. Layering hands that control
22
+ * back: a host's utilities and unlayered CSS both outrank us now.
23
+ *
24
+ * The statement below fixes where `polyx` sits. It must land AFTER `base` —
25
+ * Tailwind's preflight puts `*, ::before, ::after { border: 0 solid }` there, and
26
+ * that would erase our own borders if we ranked lower — and BEFORE `utilities`,
27
+ * so host utilities still win. Naming Tailwind's layers costs nothing when the
28
+ * host doesn't use Tailwind: the names simply resolve to empty layers.
29
+ *
30
+ * Layer order is set by first declaration, so this only holds if our stylesheet
31
+ * is imported BEFORE the host's Tailwind entry. Import it later and `polyx` is
32
+ * appended last, which puts us back to outranking utilities — no worse than the
33
+ * unlayered behaviour, but not the fix either. Import us first.
13
34
  */
14
35
 
36
+ @layer theme, base, polyx, components, utilities;
37
+
38
+ @layer polyx {
39
+
15
40
  .polyx-signin {
16
41
  /* Colors */
17
42
  --polyx-brand: #4f46e5;
@@ -497,3 +522,194 @@
497
522
  background: var(--polyx-input-bg);
498
523
  border-color: var(--polyx-brand);
499
524
  }
525
+
526
+ /* --- User button --------------------------------------------------------- */
527
+
528
+ /* The root carries `polyx-signin` too, so the theme tokens above (and any consumer
529
+ override of them) reach this menu without a second declaration to keep in sync. */
530
+
531
+ .polyx-userbutton {
532
+ position: relative;
533
+ display: inline-block;
534
+ }
535
+
536
+ .polyx-userbutton__trigger {
537
+ display: inline-flex;
538
+ align-items: center;
539
+ gap: 0.5rem;
540
+ padding: 0.125rem;
541
+ color: var(--polyx-fg);
542
+ background: none;
543
+ border: none;
544
+ border-radius: 999px;
545
+ cursor: pointer;
546
+ font: inherit;
547
+ }
548
+
549
+ .polyx-userbutton__trigger:focus-visible {
550
+ outline: none;
551
+ box-shadow: 0 0 0 3px var(--polyx-ring);
552
+ }
553
+
554
+ .polyx-userbutton__trigger-name {
555
+ padding-right: 0.25rem;
556
+ font-size: 0.875rem;
557
+ font-weight: 500;
558
+ }
559
+
560
+ .polyx-userbutton__avatar {
561
+ display: inline-flex;
562
+ flex: none;
563
+ align-items: center;
564
+ justify-content: center;
565
+ overflow: hidden;
566
+ border-radius: 999px;
567
+ background: color-mix(in srgb, var(--polyx-brand) 12%, transparent);
568
+ color: var(--polyx-brand);
569
+ font-size: 0.75rem;
570
+ font-weight: 600;
571
+ letter-spacing: 0.02em;
572
+ object-fit: cover;
573
+ user-select: none;
574
+ }
575
+
576
+ /* Placement is expressed as data attributes and resolved in CSS: the component measures which
577
+ side has room and says so; where that lands is a styling concern. `--polyx-gap` is the space
578
+ between trigger and menu, and the JS assumes the same 8px when it measures. */
579
+ .polyx-userbutton__menu {
580
+ --polyx-gap: 0.5rem;
581
+ position: absolute;
582
+ z-index: 50;
583
+ min-width: 15rem;
584
+ padding: 0.375rem;
585
+ background: var(--polyx-bg);
586
+ border: 1px solid var(--polyx-border);
587
+ border-radius: var(--polyx-radius);
588
+ box-shadow: var(--polyx-shadow);
589
+ }
590
+
591
+ /* Vertical sides: offset above/below, then align along the horizontal axis. */
592
+ .polyx-userbutton__menu[data-polyx-side="bottom"] {
593
+ top: calc(100% + var(--polyx-gap));
594
+ }
595
+ .polyx-userbutton__menu[data-polyx-side="top"] {
596
+ bottom: calc(100% + var(--polyx-gap));
597
+ }
598
+ .polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="end"],
599
+ .polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="end"] {
600
+ right: 0;
601
+ }
602
+ .polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="start"],
603
+ .polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="start"] {
604
+ left: 0;
605
+ }
606
+ .polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="center"],
607
+ .polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="center"] {
608
+ left: 50%;
609
+ transform: translateX(-50%);
610
+ }
611
+
612
+ /* Horizontal sides: offset left/right, then align along the vertical axis. */
613
+ .polyx-userbutton__menu[data-polyx-side="right"] {
614
+ left: calc(100% + var(--polyx-gap));
615
+ }
616
+ .polyx-userbutton__menu[data-polyx-side="left"] {
617
+ right: calc(100% + var(--polyx-gap));
618
+ }
619
+ .polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="end"],
620
+ .polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="end"] {
621
+ bottom: 0;
622
+ }
623
+ .polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="start"],
624
+ .polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="start"] {
625
+ top: 0;
626
+ }
627
+ .polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="center"],
628
+ .polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="center"] {
629
+ top: 50%;
630
+ transform: translateY(-50%);
631
+ }
632
+
633
+ /* Consumer-supplied rows: give them room and get out of the way — no colours, no type, no
634
+ cursor. Whatever they render is theirs. */
635
+ .polyx-userbutton__custom {
636
+ padding: 0.375rem 0.625rem;
637
+ }
638
+
639
+ .polyx-userbutton__identity {
640
+ display: flex;
641
+ align-items: center;
642
+ gap: 0.625rem;
643
+ padding: 0.5rem 0.625rem 0.625rem;
644
+ border-bottom: 1px solid var(--polyx-border);
645
+ }
646
+
647
+ .polyx-userbutton__identity-text {
648
+ display: flex;
649
+ min-width: 0;
650
+ flex-direction: column;
651
+ }
652
+
653
+ /* Names and emails are user data: let them truncate rather than stretch the menu. */
654
+ .polyx-userbutton__name,
655
+ .polyx-userbutton__email {
656
+ overflow: hidden;
657
+ text-overflow: ellipsis;
658
+ white-space: nowrap;
659
+ }
660
+
661
+ .polyx-userbutton__name {
662
+ font-size: 0.875rem;
663
+ font-weight: 600;
664
+ }
665
+
666
+ .polyx-userbutton__email {
667
+ font-size: 0.75rem;
668
+ color: var(--polyx-muted-fg);
669
+ }
670
+
671
+ .polyx-userbutton__items {
672
+ display: flex;
673
+ flex-direction: column;
674
+ margin: 0.375rem 0 0;
675
+ padding: 0;
676
+ list-style: none;
677
+ }
678
+
679
+ .polyx-userbutton__item {
680
+ display: flex;
681
+ width: 100%;
682
+ align-items: center;
683
+ gap: 0.5rem;
684
+ padding: 0.5rem 0.625rem;
685
+ color: var(--polyx-fg);
686
+ background: none;
687
+ border: none;
688
+ border-radius: var(--polyx-radius-sm);
689
+ cursor: pointer;
690
+ font: inherit;
691
+ font-size: 0.875rem;
692
+ text-align: left;
693
+ text-decoration: none;
694
+ }
695
+
696
+ .polyx-userbutton__item:hover {
697
+ background: color-mix(in srgb, var(--polyx-fg) 6%, transparent);
698
+ }
699
+
700
+ .polyx-userbutton__item:focus-visible {
701
+ outline: none;
702
+ box-shadow: 0 0 0 2px var(--polyx-ring);
703
+ }
704
+
705
+ .polyx-userbutton__icon {
706
+ flex: none;
707
+ width: 1rem;
708
+ height: 1rem;
709
+ color: var(--polyx-muted-fg);
710
+ }
711
+
712
+ /* Closes `@layer polyx` — opened at the top of the file. The rules above are left
713
+ unindented deliberately: wrapping the sheet in a layer is a cascade change, not a
714
+ reformat, and re-indenting 600 lines would bury it in the diff. */
715
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poly-x/next",
3
- "version": "0.1.0-alpha.9",
3
+ "version": "0.1.1",
4
4
  "description": "PolyX SDK for Next.js App Router - components plus server helpers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,8 +47,8 @@
47
47
  "module": "./dist/index.mjs",
48
48
  "types": "./dist/index.d.cts",
49
49
  "dependencies": {
50
- "@poly-x/core": "0.1.0-alpha.9",
51
- "@poly-x/react": "0.1.0-alpha.9"
50
+ "@poly-x/core": "0.1.1",
51
+ "@poly-x/react": "0.1.1"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "next": ">=14",