@poly-x/next 0.1.0-alpha.9 → 0.1.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/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-QJLA_Tz-.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-CjP44zFC.mjs";
2
2
  import { NextResponse } from "next/server";
3
3
  //#region src/proxy.ts
4
4
  /**
@@ -85,11 +85,12 @@ var ServerSession = class {
85
85
  this.cookieName = options.cookieName ?? "polyx_session";
86
86
  this.secure = options.secure ?? true;
87
87
  }
88
- async establishFromCode(code, codeVerifier, redirectUri) {
88
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
89
89
  const stored = await this.authClient.exchangeCode({
90
90
  code,
91
91
  codeVerifier,
92
- redirectUri
92
+ redirectUri,
93
+ forwardedHeaders
93
94
  });
94
95
  await this.write(stored);
95
96
  return stored;
@@ -115,6 +116,24 @@ var ServerSession = class {
115
116
  throw error;
116
117
  }
117
118
  }
119
+ /**
120
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
121
+ * revokes every session for the user (FR-SSO-5).
122
+ *
123
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
124
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
125
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
126
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
127
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
128
+ * nothing and answered 200.
129
+ */
130
+ async revoke(scope = "device") {
131
+ const current = await this.read();
132
+ if (current) try {
133
+ await this.authClient.revokeSession(current.tokens.accessToken, scope);
134
+ } catch {}
135
+ this.clear();
136
+ }
118
137
  clear() {
119
138
  this.cookies.delete(this.cookieName);
120
139
  }
@@ -85,11 +85,12 @@ var ServerSession = class {
85
85
  this.cookieName = options.cookieName ?? "polyx_session";
86
86
  this.secure = options.secure ?? true;
87
87
  }
88
- async establishFromCode(code, codeVerifier, redirectUri) {
88
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
89
89
  const stored = await this.authClient.exchangeCode({
90
90
  code,
91
91
  codeVerifier,
92
- redirectUri
92
+ redirectUri,
93
+ forwardedHeaders
93
94
  });
94
95
  await this.write(stored);
95
96
  return stored;
@@ -115,6 +116,24 @@ var ServerSession = class {
115
116
  throw error;
116
117
  }
117
118
  }
119
+ /**
120
+ * End the session upstream, then locally. `device` revokes this session; `everywhere`
121
+ * revokes every session for the user (FR-SSO-5).
122
+ *
123
+ * Order is load-bearing: revocation is authenticated with the access token sealed in the
124
+ * cookie, so the cookie must be read before it is dropped. The upstream call is
125
+ * best-effort — an unreachable platform must not strand the user half-signed-in — but the
126
+ * call itself is real: the ecosystem logout the SDK used before resolves its session from
127
+ * a browser cookie, which a server-to-server fetch never carries, so it silently revoked
128
+ * nothing and answered 200.
129
+ */
130
+ async revoke(scope = "device") {
131
+ const current = await this.read();
132
+ if (current) try {
133
+ await this.authClient.revokeSession(current.tokens.accessToken, scope);
134
+ } catch {}
135
+ this.clear();
136
+ }
118
137
  clear() {
119
138
  this.cookies.delete(this.cookieName);
120
139
  }
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-QJLA_Tz-.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 });
@@ -149,7 +224,11 @@ function createAuthHandlers(handlersConfig = {}) {
149
224
  });
150
225
  switch (outcome.type) {
151
226
  case "authorized":
152
- await newSession(adapt(await (0, next_headers.cookies)()), config, secure).establishFromCode(outcome.code, verifier, redirectUri);
227
+ await newSession(adapt(await (0, next_headers.cookies)()), config, secure).establishFromCode(outcome.code, verifier, redirectUri, {
228
+ ...collectForwardedDeviceHeaders(request),
229
+ ...geoHeaders,
230
+ ...deviceContextHeaders
231
+ });
153
232
  return Response.json({
154
233
  status: "success",
155
234
  redirectTo: afterSignInPath
@@ -249,7 +328,7 @@ function createAuthHandlers(handlersConfig = {}) {
249
328
  if (!code || !state || !raw) return Response.redirect(home, 302);
250
329
  const tx = JSON.parse(raw);
251
330
  if (tx.state !== state) return Response.redirect(home, 302);
252
- await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri);
331
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
253
332
  return Response.redirect(home, 302);
254
333
  },
255
334
  async refresh(request) {
@@ -258,14 +337,39 @@ function createAuthHandlers(handlersConfig = {}) {
258
337
  const session = await newSession(await requestCookies(), config, secure).refresh();
259
338
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
260
339
  },
340
+ /**
341
+ * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
342
+ * and unseal the cookie. `?scope=everywhere` signs the user out of every device.
343
+ *
344
+ * Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
345
+ * expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
346
+ * decides its own destination client-side. Deliberately no server-side redirect target
347
+ * — a caller-supplied one would be an open redirect on the auth path, and the client
348
+ * already knows where it wants to land.
349
+ */
261
350
  async signout(request) {
262
351
  const config = resolveNextConfig(handlersConfig);
263
352
  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 {}
353
+ const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
354
+ await newSession(adapt(await (0, next_headers.cookies)()), config, secure).revoke(scope);
355
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
268
356
  return Response.redirect(new URL(request.url).origin + "/", 302);
357
+ },
358
+ async session() {
359
+ return Response.json(await auth(handlersConfig));
360
+ },
361
+ async subscriptionCredential() {
362
+ const config = resolveNextConfig(handlersConfig);
363
+ const current = await new require_server_session.ServerSession({
364
+ authClient: buildServerAuthClient(config),
365
+ secret: config.secret,
366
+ cookies: await requestCookies()
367
+ }).read();
368
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
369
+ return Response.json({
370
+ token: current.tokens.accessToken,
371
+ organizationId: current.session.claims.organizationId
372
+ });
269
373
  }
270
374
  };
271
375
  }
package/dist/server.d.cts CHANGED
@@ -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
  }
@@ -108,6 +120,21 @@ interface AuthHandlers {
108
120
  callback(request: Request): Promise<Response>;
109
121
  refresh(request: Request): Promise<Response>;
110
122
  signout(request: Request): Promise<Response>;
123
+ /**
124
+ * The client's view of the session: the same `AuthContext` `auth()` returns in a Server
125
+ * Component, as JSON. This is what makes client components work under BFF custody, where
126
+ * the browser holds no token and so cannot derive a session for itself — the hooks read
127
+ * server truth instead. Never carries a token.
128
+ */
129
+ session(request: Request): Promise<Response>;
130
+ /**
131
+ * v2 (ADR-0008): a session-derived credential for the real-time `authz:changed` channel. Returns
132
+ * `{ token, organizationId }` read from the sealed session server-side — the token is for the
133
+ * socket handshake only (in-memory; never persisted), per the pragmatic custody decision. A
134
+ * signed-out caller gets `401` with no token. This is the one place a token leaves the BFF, and
135
+ * only to open the live-authz subscription.
136
+ */
137
+ subscriptionCredential(request: Request): Promise<Response>;
111
138
  }
112
139
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
113
140
  type ForgotPasswordResult = {
package/dist/server.d.mts CHANGED
@@ -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
  }
@@ -108,6 +120,21 @@ interface AuthHandlers {
108
120
  callback(request: Request): Promise<Response>;
109
121
  refresh(request: Request): Promise<Response>;
110
122
  signout(request: Request): Promise<Response>;
123
+ /**
124
+ * The client's view of the session: the same `AuthContext` `auth()` returns in a Server
125
+ * Component, as JSON. This is what makes client components work under BFF custody, where
126
+ * the browser holds no token and so cannot derive a session for itself — the hooks read
127
+ * server truth instead. Never carries a token.
128
+ */
129
+ session(request: Request): Promise<Response>;
130
+ /**
131
+ * v2 (ADR-0008): a session-derived credential for the real-time `authz:changed` channel. Returns
132
+ * `{ token, organizationId }` read from the sealed session server-side — the token is for the
133
+ * socket handshake only (in-memory; never persisted), per the pragmatic custody decision. A
134
+ * signed-out caller gets `401` with no token. This is the one place a token leaves the BFF, and
135
+ * only to open the live-authz subscription.
136
+ */
137
+ subscriptionCredential(request: Request): Promise<Response>;
111
138
  }
112
139
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
113
140
  type ForgotPasswordResult = {
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 sealSession, i as openSession, n as ServerSession, r as toAuthContext, t as DEFAULT_SESSION_COOKIE } from "./server-session-CjP44zFC.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 });
@@ -148,7 +223,11 @@ function createAuthHandlers(handlersConfig = {}) {
148
223
  });
149
224
  switch (outcome.type) {
150
225
  case "authorized":
151
- await newSession(adapt(await cookies()), config, secure).establishFromCode(outcome.code, verifier, redirectUri);
226
+ await newSession(adapt(await cookies()), config, secure).establishFromCode(outcome.code, verifier, redirectUri, {
227
+ ...collectForwardedDeviceHeaders(request),
228
+ ...geoHeaders,
229
+ ...deviceContextHeaders
230
+ });
152
231
  return Response.json({
153
232
  status: "success",
154
233
  redirectTo: afterSignInPath
@@ -248,7 +327,7 @@ function createAuthHandlers(handlersConfig = {}) {
248
327
  if (!code || !state || !raw) return Response.redirect(home, 302);
249
328
  const tx = JSON.parse(raw);
250
329
  if (tx.state !== state) return Response.redirect(home, 302);
251
- await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri);
330
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
252
331
  return Response.redirect(home, 302);
253
332
  },
254
333
  async refresh(request) {
@@ -257,14 +336,39 @@ function createAuthHandlers(handlersConfig = {}) {
257
336
  const session = await newSession(await requestCookies(), config, secure).refresh();
258
337
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
259
338
  },
339
+ /**
340
+ * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
341
+ * and unseal the cookie. `?scope=everywhere` signs the user out of every device.
342
+ *
343
+ * Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
344
+ * expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
345
+ * decides its own destination client-side. Deliberately no server-side redirect target
346
+ * — a caller-supplied one would be an open redirect on the auth path, and the client
347
+ * already knows where it wants to land.
348
+ */
260
349
  async signout(request) {
261
350
  const config = resolveNextConfig(handlersConfig);
262
351
  const secure = resolveCookieSecure(request, config);
263
- newSession(adapt(await cookies()), config, secure).clear();
264
- try {
265
- await buildServerAuthClient(config).logout("device");
266
- } catch {}
352
+ const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
353
+ await newSession(adapt(await cookies()), config, secure).revoke(scope);
354
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
267
355
  return Response.redirect(new URL(request.url).origin + "/", 302);
356
+ },
357
+ async session() {
358
+ return Response.json(await auth(handlersConfig));
359
+ },
360
+ async subscriptionCredential() {
361
+ const config = resolveNextConfig(handlersConfig);
362
+ const current = await new ServerSession({
363
+ authClient: buildServerAuthClient(config),
364
+ secret: config.secret,
365
+ cookies: await requestCookies()
366
+ }).read();
367
+ if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
368
+ return Response.json({
369
+ token: current.tokens.accessToken,
370
+ organizationId: current.session.claims.organizationId
371
+ });
268
372
  }
269
373
  };
270
374
  }
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.0",
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.0",
51
+ "@poly-x/react": "0.1.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "next": ">=14",