@poly-x/next 0.1.0-alpha.8 → 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-YbnXBt3c.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-B3zJ7CS8.mjs";
1
+ import "./server-session-CjP44zFC.mjs";
2
2
  import { NextResponse } from "next/server";
3
3
  //#region src/proxy.ts
4
4
  /**
@@ -1,12 +1,20 @@
1
1
  import { SessionExpiredError, SessionRevokedError } from "@poly-x/core";
2
2
  //#region src/cookie-adapter.ts
3
- /** Default attributes for the session cookie (ADR-0004 secure custody). */
4
- const SESSION_COOKIE_OPTIONS = {
5
- httpOnly: true,
6
- secure: true,
7
- sameSite: "lax",
8
- path: "/"
9
- };
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.
9
+ */
10
+ function buildSessionCookieOptions(secure) {
11
+ return {
12
+ httpOnly: true,
13
+ secure,
14
+ sameSite: "lax",
15
+ path: "/"
16
+ };
17
+ }
10
18
  //#endregion
11
19
  //#region src/cookie-codec.ts
12
20
  const IV_BYTES = 12;
@@ -69,17 +77,20 @@ var ServerSession = class {
69
77
  secret;
70
78
  cookies;
71
79
  cookieName;
80
+ secure;
72
81
  constructor(options) {
73
82
  this.authClient = options.authClient;
74
83
  this.secret = options.secret;
75
84
  this.cookies = options.cookies;
76
85
  this.cookieName = options.cookieName ?? "polyx_session";
86
+ this.secure = options.secure ?? true;
77
87
  }
78
- async establishFromCode(code, codeVerifier, redirectUri) {
88
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
79
89
  const stored = await this.authClient.exchangeCode({
80
90
  code,
81
91
  codeVerifier,
82
- redirectUri
92
+ redirectUri,
93
+ forwardedHeaders
83
94
  });
84
95
  await this.write(stored);
85
96
  return stored;
@@ -105,12 +116,30 @@ var ServerSession = class {
105
116
  throw error;
106
117
  }
107
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
+ }
108
137
  clear() {
109
138
  this.cookies.delete(this.cookieName);
110
139
  }
111
140
  async write(session) {
112
141
  const sealed = await sealSession(session, this.secret);
113
- this.cookies.set(this.cookieName, sealed, SESSION_COOKIE_OPTIONS);
142
+ this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
114
143
  }
115
144
  };
116
145
  function toAuthContext(session) {
@@ -1,12 +1,20 @@
1
1
  let _poly_x_core = require("@poly-x/core");
2
2
  //#region src/cookie-adapter.ts
3
- /** Default attributes for the session cookie (ADR-0004 secure custody). */
4
- const SESSION_COOKIE_OPTIONS = {
5
- httpOnly: true,
6
- secure: true,
7
- sameSite: "lax",
8
- path: "/"
9
- };
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.
9
+ */
10
+ function buildSessionCookieOptions(secure) {
11
+ return {
12
+ httpOnly: true,
13
+ secure,
14
+ sameSite: "lax",
15
+ path: "/"
16
+ };
17
+ }
10
18
  //#endregion
11
19
  //#region src/cookie-codec.ts
12
20
  const IV_BYTES = 12;
@@ -69,17 +77,20 @@ var ServerSession = class {
69
77
  secret;
70
78
  cookies;
71
79
  cookieName;
80
+ secure;
72
81
  constructor(options) {
73
82
  this.authClient = options.authClient;
74
83
  this.secret = options.secret;
75
84
  this.cookies = options.cookies;
76
85
  this.cookieName = options.cookieName ?? "polyx_session";
86
+ this.secure = options.secure ?? true;
77
87
  }
78
- async establishFromCode(code, codeVerifier, redirectUri) {
88
+ async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
79
89
  const stored = await this.authClient.exchangeCode({
80
90
  code,
81
91
  codeVerifier,
82
- redirectUri
92
+ redirectUri,
93
+ forwardedHeaders
83
94
  });
84
95
  await this.write(stored);
85
96
  return stored;
@@ -105,12 +116,30 @@ var ServerSession = class {
105
116
  throw error;
106
117
  }
107
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
+ }
108
137
  clear() {
109
138
  this.cookies.delete(this.cookieName);
110
139
  }
111
140
  async write(session) {
112
141
  const sealed = await sealSession(session, this.secret);
113
- this.cookies.set(this.cookieName, sealed, SESSION_COOKIE_OPTIONS);
142
+ this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
114
143
  }
115
144
  };
116
145
  function toAuthContext(session) {
package/dist/server.cjs CHANGED
@@ -1,8 +1,15 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_server_session = require("./server-session-YbnXBt3c.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
6
+ /** Parse a boolean-ish env var; undefined when unset/unrecognized (→ auto-derive). */
7
+ function parseBoolEnv(value) {
8
+ if (value === void 0) return void 0;
9
+ const v = value.trim().toLowerCase();
10
+ if (v === "true" || v === "1") return true;
11
+ if (v === "false" || v === "0") return false;
12
+ }
6
13
  function resolveNextConfig(overrides = {}) {
7
14
  const publishableKey = overrides.publishableKey ?? process.env.NEXT_PUBLIC_POLYX_PUBLISHABLE_KEY;
8
15
  const secret = overrides.secret ?? process.env.POLYX_SESSION_SECRET;
@@ -12,7 +19,8 @@ function resolveNextConfig(overrides = {}) {
12
19
  publishableKey,
13
20
  secret,
14
21
  baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
15
- signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL
22
+ signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
23
+ cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
16
24
  };
17
25
  }
18
26
  function buildServerAuthClient(config) {
@@ -48,6 +56,95 @@ function adapt(store) {
48
56
  async function requestCookies() {
49
57
  return adapt(await (0, next_headers.cookies)());
50
58
  }
59
+ /**
60
+ * Decide the cookie `Secure` attribute for this request. An explicit `cookieSecure`
61
+ * (from `POLYX_COOKIE_SECURE` or code) wins; otherwise derive from the transport —
62
+ * `x-forwarded-proto` (proxied TLS) then the request URL — and fail secure if unknown.
63
+ * A Secure cookie is dropped by the browser over http, so http/localhost must be false.
64
+ */
65
+ function resolveCookieSecure(request, config) {
66
+ if (config.cookieSecure !== void 0) return config.cookieSecure;
67
+ const forwarded = request.headers.get("x-forwarded-proto");
68
+ if (forwarded) return forwarded.split(",")[0].trim() === "https";
69
+ try {
70
+ return new URL(request.url).protocol === "https:";
71
+ } catch {
72
+ return true;
73
+ }
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
+ }
51
148
  /** Read the current session — safe in Server Components (no cookie write). */
52
149
  async function auth(overrides) {
53
150
  const config = resolveNextConfig(overrides);
@@ -60,16 +157,18 @@ async function auth(overrides) {
60
157
  function createAuthHandlers(handlersConfig = {}) {
61
158
  const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
62
159
  const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
63
- function newSession(store, config) {
160
+ function newSession(store, config, secure) {
64
161
  return new require_server_session.ServerSession({
65
162
  authClient: buildServerAuthClient(config),
66
163
  secret: config.secret,
67
- cookies: store
164
+ cookies: store,
165
+ secure
68
166
  });
69
167
  }
70
168
  return {
71
169
  async signin(request) {
72
170
  const config = resolveNextConfig(handlersConfig);
171
+ const secure = resolveCookieSecure(request, config);
73
172
  const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
74
173
  const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
75
174
  const state = (0, _poly_x_core.randomState)();
@@ -79,7 +178,7 @@ function createAuthHandlers(handlersConfig = {}) {
79
178
  redirectUri
80
179
  }), {
81
180
  httpOnly: true,
82
- secure: true,
181
+ secure,
83
182
  sameSite: "lax",
84
183
  path: "/",
85
184
  maxAge: VERIFIER_MAX_AGE
@@ -93,6 +192,7 @@ function createAuthHandlers(handlersConfig = {}) {
93
192
  },
94
193
  async authenticate(request) {
95
194
  const config = resolveNextConfig(handlersConfig);
195
+ const secure = resolveCookieSecure(request, config);
96
196
  const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
97
197
  const client = buildServerAuthClient(config);
98
198
  let payload;
@@ -101,6 +201,8 @@ function createAuthHandlers(handlersConfig = {}) {
101
201
  } catch {
102
202
  return Response.json({ status: "invalid_request" }, { status: 400 });
103
203
  }
204
+ const geoHeaders = geoHeadersFromBody(payload.geo);
205
+ const deviceContextHeaders = deviceContextHeadersFromBody(payload.deviceContext);
104
206
  const email = typeof payload.email === "string" ? payload.email : "";
105
207
  const password = typeof payload.password === "string" ? payload.password : "";
106
208
  if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
@@ -122,7 +224,11 @@ function createAuthHandlers(handlersConfig = {}) {
122
224
  });
123
225
  switch (outcome.type) {
124
226
  case "authorized":
125
- await newSession(adapt(await (0, next_headers.cookies)()), config).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
+ });
126
232
  return Response.json({
127
233
  status: "success",
128
234
  redirectTo: afterSignInPath
@@ -211,6 +317,7 @@ function createAuthHandlers(handlersConfig = {}) {
211
317
  },
212
318
  async callback(request) {
213
319
  const config = resolveNextConfig(handlersConfig);
320
+ const secure = resolveCookieSecure(request, config);
214
321
  const url = new URL(request.url);
215
322
  const code = url.searchParams.get("code");
216
323
  const state = url.searchParams.get("state");
@@ -221,21 +328,48 @@ function createAuthHandlers(handlersConfig = {}) {
221
328
  if (!code || !state || !raw) return Response.redirect(home, 302);
222
329
  const tx = JSON.parse(raw);
223
330
  if (tx.state !== state) return Response.redirect(home, 302);
224
- await newSession(adapt(store), config).establishFromCode(code, tx.verifier, tx.redirectUri);
331
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
225
332
  return Response.redirect(home, 302);
226
333
  },
227
- async refresh() {
334
+ async refresh(request) {
228
335
  const config = resolveNextConfig(handlersConfig);
229
- const session = await newSession(await requestCookies(), config).refresh();
336
+ const secure = resolveCookieSecure(request, config);
337
+ const session = await newSession(await requestCookies(), config, secure).refresh();
230
338
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
231
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
+ */
232
350
  async signout(request) {
233
351
  const config = resolveNextConfig(handlersConfig);
234
- newSession(adapt(await (0, next_headers.cookies)()), config).clear();
235
- try {
236
- await buildServerAuthClient(config).logout("device");
237
- } catch {}
352
+ const secure = resolveCookieSecure(request, config);
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 });
238
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
+ });
239
373
  }
240
374
  };
241
375
  }
package/dist/server.d.cts CHANGED
@@ -8,6 +8,13 @@ interface NextServerConfig {
8
8
  baseUrl?: string;
9
9
  /** Hosted sign-in page URL the signin route redirects to. */
10
10
  signInUrl?: string;
11
+ /**
12
+ * Force the session/verifier cookie `Secure` attribute. Leave undefined (default) to
13
+ * derive it from the request transport (https → true, http/localhost → false). Set via
14
+ * `POLYX_COOKIE_SECURE=true|false` only for edge cases (e.g. a TLS-terminating proxy
15
+ * that doesn't forward `x-forwarded-proto`).
16
+ */
17
+ cookieSecure?: boolean;
11
18
  }
12
19
  declare function resolveNextConfig(overrides?: Partial<NextServerConfig>): NextServerConfig;
13
20
  //#endregion
@@ -37,17 +44,36 @@ interface ServerSessionOptions {
37
44
  secret: string;
38
45
  cookies: CookieAdapter;
39
46
  cookieName?: string;
47
+ /**
48
+ * Whether to set the `Secure` attribute on the session cookie. Defaults to `true`
49
+ * (fail secure). Handlers derive it from the request transport so http/localhost dev
50
+ * works (a Secure cookie is dropped over http). See buildSessionCookieOptions.
51
+ */
52
+ secure?: boolean;
40
53
  }
41
54
  declare class ServerSession {
42
55
  private readonly authClient;
43
56
  private readonly secret;
44
57
  private readonly cookies;
45
58
  private readonly cookieName;
59
+ private readonly secure;
46
60
  constructor(options: ServerSessionOptions);
47
- establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
61
+ establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
48
62
  read(): Promise<StoredSession | null>;
49
63
  /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
50
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>;
51
77
  clear(): void;
52
78
  private write;
53
79
  }
@@ -94,6 +120,21 @@ interface AuthHandlers {
94
120
  callback(request: Request): Promise<Response>;
95
121
  refresh(request: Request): Promise<Response>;
96
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>;
97
138
  }
98
139
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
99
140
  type ForgotPasswordResult = {
package/dist/server.d.mts CHANGED
@@ -8,6 +8,13 @@ interface NextServerConfig {
8
8
  baseUrl?: string;
9
9
  /** Hosted sign-in page URL the signin route redirects to. */
10
10
  signInUrl?: string;
11
+ /**
12
+ * Force the session/verifier cookie `Secure` attribute. Leave undefined (default) to
13
+ * derive it from the request transport (https → true, http/localhost → false). Set via
14
+ * `POLYX_COOKIE_SECURE=true|false` only for edge cases (e.g. a TLS-terminating proxy
15
+ * that doesn't forward `x-forwarded-proto`).
16
+ */
17
+ cookieSecure?: boolean;
11
18
  }
12
19
  declare function resolveNextConfig(overrides?: Partial<NextServerConfig>): NextServerConfig;
13
20
  //#endregion
@@ -37,17 +44,36 @@ interface ServerSessionOptions {
37
44
  secret: string;
38
45
  cookies: CookieAdapter;
39
46
  cookieName?: string;
47
+ /**
48
+ * Whether to set the `Secure` attribute on the session cookie. Defaults to `true`
49
+ * (fail secure). Handlers derive it from the request transport so http/localhost dev
50
+ * works (a Secure cookie is dropped over http). See buildSessionCookieOptions.
51
+ */
52
+ secure?: boolean;
40
53
  }
41
54
  declare class ServerSession {
42
55
  private readonly authClient;
43
56
  private readonly secret;
44
57
  private readonly cookies;
45
58
  private readonly cookieName;
59
+ private readonly secure;
46
60
  constructor(options: ServerSessionOptions);
47
- establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
61
+ establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
48
62
  read(): Promise<StoredSession | null>;
49
63
  /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
50
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>;
51
77
  clear(): void;
52
78
  private write;
53
79
  }
@@ -94,6 +120,21 @@ interface AuthHandlers {
94
120
  callback(request: Request): Promise<Response>;
95
121
  refresh(request: Request): Promise<Response>;
96
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>;
97
138
  }
98
139
  /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
99
140
  type ForgotPasswordResult = {
package/dist/server.mjs CHANGED
@@ -1,7 +1,14 @@
1
- import { a as sealSession, i as openSession, n as ServerSession, r as toAuthContext, t as DEFAULT_SESSION_COOKIE } from "./server-session-B3zJ7CS8.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
5
+ /** Parse a boolean-ish env var; undefined when unset/unrecognized (→ auto-derive). */
6
+ function parseBoolEnv(value) {
7
+ if (value === void 0) return void 0;
8
+ const v = value.trim().toLowerCase();
9
+ if (v === "true" || v === "1") return true;
10
+ if (v === "false" || v === "0") return false;
11
+ }
5
12
  function resolveNextConfig(overrides = {}) {
6
13
  const publishableKey = overrides.publishableKey ?? process.env.NEXT_PUBLIC_POLYX_PUBLISHABLE_KEY;
7
14
  const secret = overrides.secret ?? process.env.POLYX_SESSION_SECRET;
@@ -11,7 +18,8 @@ function resolveNextConfig(overrides = {}) {
11
18
  publishableKey,
12
19
  secret,
13
20
  baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
14
- signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL
21
+ signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
22
+ cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
15
23
  };
16
24
  }
17
25
  function buildServerAuthClient(config) {
@@ -47,6 +55,95 @@ function adapt(store) {
47
55
  async function requestCookies() {
48
56
  return adapt(await cookies());
49
57
  }
58
+ /**
59
+ * Decide the cookie `Secure` attribute for this request. An explicit `cookieSecure`
60
+ * (from `POLYX_COOKIE_SECURE` or code) wins; otherwise derive from the transport —
61
+ * `x-forwarded-proto` (proxied TLS) then the request URL — and fail secure if unknown.
62
+ * A Secure cookie is dropped by the browser over http, so http/localhost must be false.
63
+ */
64
+ function resolveCookieSecure(request, config) {
65
+ if (config.cookieSecure !== void 0) return config.cookieSecure;
66
+ const forwarded = request.headers.get("x-forwarded-proto");
67
+ if (forwarded) return forwarded.split(",")[0].trim() === "https";
68
+ try {
69
+ return new URL(request.url).protocol === "https:";
70
+ } catch {
71
+ return true;
72
+ }
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
+ }
50
147
  /** Read the current session — safe in Server Components (no cookie write). */
51
148
  async function auth(overrides) {
52
149
  const config = resolveNextConfig(overrides);
@@ -59,16 +156,18 @@ async function auth(overrides) {
59
156
  function createAuthHandlers(handlersConfig = {}) {
60
157
  const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
61
158
  const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
62
- function newSession(store, config) {
159
+ function newSession(store, config, secure) {
63
160
  return new ServerSession({
64
161
  authClient: buildServerAuthClient(config),
65
162
  secret: config.secret,
66
- cookies: store
163
+ cookies: store,
164
+ secure
67
165
  });
68
166
  }
69
167
  return {
70
168
  async signin(request) {
71
169
  const config = resolveNextConfig(handlersConfig);
170
+ const secure = resolveCookieSecure(request, config);
72
171
  const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
73
172
  const { verifier, challenge } = await generatePkce();
74
173
  const state = randomState();
@@ -78,7 +177,7 @@ function createAuthHandlers(handlersConfig = {}) {
78
177
  redirectUri
79
178
  }), {
80
179
  httpOnly: true,
81
- secure: true,
180
+ secure,
82
181
  sameSite: "lax",
83
182
  path: "/",
84
183
  maxAge: VERIFIER_MAX_AGE
@@ -92,6 +191,7 @@ function createAuthHandlers(handlersConfig = {}) {
92
191
  },
93
192
  async authenticate(request) {
94
193
  const config = resolveNextConfig(handlersConfig);
194
+ const secure = resolveCookieSecure(request, config);
95
195
  const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
96
196
  const client = buildServerAuthClient(config);
97
197
  let payload;
@@ -100,6 +200,8 @@ function createAuthHandlers(handlersConfig = {}) {
100
200
  } catch {
101
201
  return Response.json({ status: "invalid_request" }, { status: 400 });
102
202
  }
203
+ const geoHeaders = geoHeadersFromBody(payload.geo);
204
+ const deviceContextHeaders = deviceContextHeadersFromBody(payload.deviceContext);
103
205
  const email = typeof payload.email === "string" ? payload.email : "";
104
206
  const password = typeof payload.password === "string" ? payload.password : "";
105
207
  if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
@@ -121,7 +223,11 @@ function createAuthHandlers(handlersConfig = {}) {
121
223
  });
122
224
  switch (outcome.type) {
123
225
  case "authorized":
124
- await newSession(adapt(await cookies()), config).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
+ });
125
231
  return Response.json({
126
232
  status: "success",
127
233
  redirectTo: afterSignInPath
@@ -210,6 +316,7 @@ function createAuthHandlers(handlersConfig = {}) {
210
316
  },
211
317
  async callback(request) {
212
318
  const config = resolveNextConfig(handlersConfig);
319
+ const secure = resolveCookieSecure(request, config);
213
320
  const url = new URL(request.url);
214
321
  const code = url.searchParams.get("code");
215
322
  const state = url.searchParams.get("state");
@@ -220,21 +327,48 @@ function createAuthHandlers(handlersConfig = {}) {
220
327
  if (!code || !state || !raw) return Response.redirect(home, 302);
221
328
  const tx = JSON.parse(raw);
222
329
  if (tx.state !== state) return Response.redirect(home, 302);
223
- await newSession(adapt(store), config).establishFromCode(code, tx.verifier, tx.redirectUri);
330
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
224
331
  return Response.redirect(home, 302);
225
332
  },
226
- async refresh() {
333
+ async refresh(request) {
227
334
  const config = resolveNextConfig(handlersConfig);
228
- const session = await newSession(await requestCookies(), config).refresh();
335
+ const secure = resolveCookieSecure(request, config);
336
+ const session = await newSession(await requestCookies(), config, secure).refresh();
229
337
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
230
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
+ */
231
349
  async signout(request) {
232
350
  const config = resolveNextConfig(handlersConfig);
233
- newSession(adapt(await cookies()), config).clear();
234
- try {
235
- await buildServerAuthClient(config).logout("device");
236
- } catch {}
351
+ const secure = resolveCookieSecure(request, config);
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 });
237
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
+ });
238
372
  }
239
373
  };
240
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;
@@ -208,6 +233,13 @@
208
233
  margin-bottom: 0.75rem;
209
234
  }
210
235
 
236
+ /* Branding logo fetched from the app's tokens (FR-BRND). */
237
+ .polyx-signin__brand-logo {
238
+ max-height: 2.25rem;
239
+ width: auto;
240
+ object-fit: contain;
241
+ }
242
+
211
243
  .polyx-signin__heading {
212
244
  margin: 0;
213
245
  font-size: 1.375rem;
@@ -270,6 +302,56 @@
270
302
  cursor: not-allowed;
271
303
  }
272
304
 
305
+ /* --- Password reveal toggle --------------------------------------------- */
306
+
307
+ .polyx-signin__input-wrap {
308
+ position: relative;
309
+ display: flex;
310
+ align-items: center;
311
+ }
312
+
313
+ /* Leave room for the reveal button so masked text never slides under it. */
314
+ .polyx-signin__input--has-reveal {
315
+ padding-right: 2.75rem;
316
+ }
317
+
318
+ .polyx-signin__reveal {
319
+ position: absolute;
320
+ right: 0.25rem;
321
+ display: inline-flex;
322
+ align-items: center;
323
+ justify-content: center;
324
+ width: 2rem;
325
+ height: 2rem;
326
+ padding: 0;
327
+ color: var(--polyx-muted-fg);
328
+ background: transparent;
329
+ border: none;
330
+ border-radius: var(--polyx-radius-sm);
331
+ cursor: pointer;
332
+ transition: color 0.15s ease, box-shadow 0.15s ease;
333
+ }
334
+
335
+ .polyx-signin__reveal:hover:not(:disabled) {
336
+ color: var(--polyx-fg);
337
+ }
338
+
339
+ .polyx-signin__reveal:focus-visible {
340
+ outline: none;
341
+ color: var(--polyx-fg);
342
+ box-shadow: 0 0 0 3px var(--polyx-ring);
343
+ }
344
+
345
+ .polyx-signin__reveal:disabled {
346
+ cursor: not-allowed;
347
+ opacity: 0.6;
348
+ }
349
+
350
+ .polyx-signin__reveal svg {
351
+ width: 1.125rem;
352
+ height: 1.125rem;
353
+ }
354
+
273
355
  /* --- Buttons ------------------------------------------------------------- */
274
356
 
275
357
  .polyx-signin__submit,
@@ -440,3 +522,194 @@
440
522
  background: var(--polyx-input-bg);
441
523
  border-color: var(--polyx-brand);
442
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.8",
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.8",
51
- "@poly-x/react": "0.1.0-alpha.8"
50
+ "@poly-x/core": "0.1.0",
51
+ "@poly-x/react": "0.1.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "next": ">=14",