@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 +1 -1
- package/dist/proxy.mjs +1 -1
- package/dist/{server-session-DmR0ZJqP.cjs → server-session-Df9HXl_5.cjs} +106 -21
- package/dist/{server-session-CXVB1Rgx.mjs → server-session-pY-nzh5x.mjs} +90 -23
- package/dist/server.cjs +137 -8
- package/dist/server.d.cts +62 -4
- package/dist/server.d.mts +62 -4
- package/dist/server.mjs +135 -9
- package/dist/styles.css +216 -0
- package/package.json +3 -3
package/dist/proxy.cjs
CHANGED
package/dist/proxy.mjs
CHANGED
|
@@ -1,23 +1,45 @@
|
|
|
1
1
|
let _poly_x_core = require("@poly-x/core");
|
|
2
|
-
//#region src/cookie-
|
|
2
|
+
//#region src/cookie-codec.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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-
|
|
1
|
+
import { PolyXError, SessionExpiredError, SessionRevokedError } from "@poly-x/core";
|
|
2
|
+
//#region src/cookie-codec.ts
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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.
|
|
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.
|
|
51
|
-
"@poly-x/react": "0.1.
|
|
50
|
+
"@poly-x/core": "0.1.1",
|
|
51
|
+
"@poly-x/react": "0.1.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"next": ">=14",
|