emusks 2.3.7 → 2.3.9

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/README.md CHANGED
@@ -5,4 +5,12 @@ Log in and interact with the unofficial X API using any client identity - web, A
5
5
 
6
6
  officially dmca'd by twitter™ 🏆 • includes a few leaked ads bearers
7
7
 
8
- [Learn more →](https://emusks.tiago.zip) [Radicle](https://app.radicle.xyz/nodes/rosa.radicle.xyz/rad:z4F48Wmhm5Mian1rgZjmnKxEQ5ays)
8
+ [Learn more →](https://emusks.tiago.zip) [Source](https://code.lgbt/tiago/emusks)
9
+
10
+ ## browser-free password login
11
+
12
+ password login can use a private Castle signal profile to generate fresh request tokens locally. emusks loads `.emusks/castle-profile.json` from the working directory, or the file selected by `EMUSKS_CASTLE_PROFILE`. an explicit `castleProfile` login option accepts a path or profile object; `false` disables profile loading.
13
+
14
+ the profile contains saved browser measurements, not a pre-generated request token. six request-specific fields are generated by the SDK for each local instance. the profile's user agent and client hints are retained through login and the authenticated session. profiles are bound to the bundled SDK's SHA-256 hash; a changed SDK produces an error before credential submission.
15
+
16
+ keep the profile private and exclude `.emusks/` from version control. profiles are not included in the published package. token generation and login do not launch or contact a browser.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emusks",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
4
4
  "description": "Reverse-engineered Twitter API client. Log in and interact with the unofficial X API using any client identity - web, Android, iOS, or TweetDeck",
5
5
  "keywords": [
6
6
  "client",
@@ -12,12 +12,12 @@
12
12
  ],
13
13
  "homepage": "https://emusks.tiago.zip",
14
14
  "bugs": {
15
- "url": "https://app.radicle.xyz/nodes/rosa.radicle.xyz/rad:z4F48Wmhm5Mian1rgZjmnKxEQ5ays"
15
+ "url": "https://code.lgbt/tiago/emusks/issues"
16
16
  },
17
17
  "license": "AGPL-3.0-only",
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://app.radicle.xyz/nodes/rosa.radicle.xyz/rad:z4F48Wmhm5Mian1rgZjmnKxEQ5ays"
20
+ "url": "https://code.lgbt/tiago/emusks"
21
21
  },
22
22
  "type": "module",
23
23
  "main": "src/index.js",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "files": [
28
28
  "src",
29
+ "!src/scripts",
29
30
  "README.md"
30
31
  ],
31
32
  "dependencies": {
@@ -0,0 +1,18 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export function loadCastleProfile(value) {
5
+ if (value === false) return null;
6
+ const path = value ?? process.env.EMUSKS_CASTLE_PROFILE ?? resolve(".emusks/castle-profile.json");
7
+ if (value == null && !process.env.EMUSKS_CASTLE_PROFILE && !existsSync(path)) return null;
8
+ const profile = typeof path === "string" ? JSON.parse(readFileSync(path, "utf8")) : path;
9
+ if (profile?.format !== 1 || !/^[a-f0-9]{64}$/.test(profile.sdkSha256 ?? "") || typeof profile.userAgent !== "string" || !Array.isArray(profile.signals) || profile.signals.length !== 749) {
10
+ throw new Error("invalid Castle profile");
11
+ }
12
+ const clientHints = {};
13
+ for (const name of ["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"]) {
14
+ if (typeof profile.clientHints?.[name] !== "string") throw new Error(`Castle profile is missing ${name}`);
15
+ clientHints[name] = profile.clientHints[name];
16
+ }
17
+ return { ...profile, clientHints };
18
+ }
package/src/castle.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ import { loadCastleProfile } from "./castle-profile.js";
1
3
  import { readFileSync } from "node:fs";
2
4
  import { dirname, join } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
@@ -7,7 +9,7 @@ import { parseHTML } from "linkedom";
7
9
  const HERE = dirname(fileURLToPath(import.meta.url));
8
10
 
9
11
  export const DEFAULT_CASTLE_PK = "pk_AvRa79bHyJSYSQHnRpcVtzyxetSvFerx";
10
- export const CASTLE_MODULE_ID = "84197";
12
+ export const CASTLE_MODULE_ID = "855881";
11
13
 
12
14
  const DEFAULT_UA =
13
15
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36";
@@ -42,36 +44,53 @@ function mimeTypeArray() {
42
44
  return arr;
43
45
  }
44
46
 
45
- function buildSandbox(userAgent) {
46
- const { document } = parseHTML(
47
- "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
47
+ function buildSandbox(userAgent, cookies, html) {
48
+ const { document, window: domWindow } = parseHTML(
49
+ html ?? "<!DOCTYPE html><html><head><title>X</title></head><body></body></html>",
48
50
  );
49
51
  const define = (prop, value) => {
50
52
  try {
51
53
  Object.defineProperty(document, prop, { get: () => value, configurable: true });
52
54
  } catch {}
53
55
  };
54
- define("cookie", "");
56
+ Object.defineProperty(document, "cookie", {
57
+ configurable: true,
58
+ get: () => Object.entries(cookies).map(([name, value]) => `${name}=${value}`).join("; "),
59
+ set: (cookie) => {
60
+ const [pair, ...attributes] = String(cookie).split(";");
61
+ const separator = pair.indexOf("=");
62
+ if (separator < 1) return;
63
+ const name = pair.slice(0, separator).trim();
64
+ const attrs = Object.fromEntries(attributes.map((attribute) => {
65
+ const [key, ...parts] = attribute.trim().split("=");
66
+ return [key.toLowerCase(), parts.join("=")];
67
+ }));
68
+ const maxAge = /^-?\d+$/.test(attrs["max-age"] ?? "") ? Number(attrs["max-age"]) : null;
69
+ const expired = maxAge !== null ? maxAge <= 0 : Date.parse(attrs.expires) <= Date.now();
70
+ if (expired) delete cookies[name];
71
+ else cookies[name] = pair.slice(separator + 1).trim();
72
+ },
73
+ });
55
74
  define("readyState", "complete");
56
75
  define("referrer", "https://x.com/");
57
76
  define("visibilityState", "visible");
58
77
  define("hidden", false);
78
+ Object.defineProperty(document, "scripts", { get: () => document.getElementsByTagName("script"), configurable: true });
59
79
  document.hasFocus = () => true;
60
80
 
61
81
  const s = {};
62
- s.globalThis = s;
63
- s.self = s;
64
- s.window = s;
65
- s.global = s;
66
- s.top = s;
67
- s.parent = s;
68
- s.frames = s;
82
+ for (const name of ["Element", "HTMLElement", "Node", "Document", "DocumentFragment", "Event", "CustomEvent", "EventTarget", "MutationObserver", "HTMLCanvasElement", "HTMLIFrameElement", "HTMLImageElement"]) {
83
+ if (domWindow[name]) s[name] = domWindow[name];
84
+ }
85
+ Object.assign(s, { Request, Response, Headers, AbortController, AbortSignal, URL, URLSearchParams, Blob, CompressionStream, DecompressionStream, ReadableStream, WritableStream, TransformStream });
69
86
  s.name = "";
70
87
  s.closed = false;
71
88
  s.origin = "https://x.com";
72
89
  s.isSecureContext = true;
73
90
 
74
91
  s.document = document;
92
+ const context2d = document.createElement("canvas").getContext("2d");
93
+ if (context2d) s.CanvasRenderingContext2D = context2d.constructor;
75
94
  s.navigator = {
76
95
  userAgent,
77
96
  appVersion: userAgent.replace("Mozilla/", ""),
@@ -129,12 +148,12 @@ function buildSandbox(userAgent) {
129
148
  orientation: { type: "landscape-primary", angle: 0 },
130
149
  };
131
150
  s.location = {
132
- href: "https://x.com/i/flow/login",
151
+ href: "https://x.com/",
133
152
  protocol: "https:",
134
153
  host: "x.com",
135
154
  hostname: "x.com",
136
155
  origin: "https://x.com",
137
- pathname: "/i/flow/login",
156
+ pathname: "/",
138
157
  search: "",
139
158
  hash: "",
140
159
  };
@@ -208,6 +227,22 @@ function buildSandbox(userAgent) {
208
227
  addEventListener() {}
209
228
  };
210
229
  s.getComputedStyle = () => ({ getPropertyValue: () => "" });
230
+ const createElement = document.createElement.bind(document);
231
+ document.createElement = (name, ...args) => {
232
+ const element = createElement(name, ...args);
233
+ if (String(name).toLowerCase() !== "iframe") return element;
234
+ const frameDocument = parseHTML("<!doctype html><html><head></head><body></body></html>").document;
235
+ const frame = vm.createContext({ document: frameDocument, navigator: s.navigator, performance: s.performance });
236
+ const frameWindow = vm.runInContext("self = window = globalThis", frame);
237
+ frameWindow.parent = document.defaultView;
238
+ frameWindow.top = document.defaultView;
239
+ Object.defineProperty(frameDocument, "defaultView", { get: () => frameWindow, configurable: true });
240
+ Object.defineProperties(element, {
241
+ contentWindow: { get: () => frameWindow },
242
+ contentDocument: { get: () => frameDocument },
243
+ });
244
+ return element;
245
+ };
211
246
  return s;
212
247
  }
213
248
 
@@ -217,20 +252,40 @@ export function createCastleMinter(opts = {}) {
217
252
  userAgent = DEFAULT_UA,
218
253
  sdkPath = join(HERE, "vendor", "castle-sdk.min.js"),
219
254
  sdkSource,
255
+ cookies = {},
256
+ html,
257
+ profile: profileOption,
220
258
  } = opts;
221
259
 
222
- const src = sdkSource ?? readFileSync(sdkPath, "utf8");
223
- const sandbox = buildSandbox(userAgent);
260
+ let src = sdkSource ?? readFileSync(sdkPath, "utf8");
261
+ const profile = loadCastleProfile(profileOption);
262
+ if (profile) {
263
+ if (createHash("sha256").update(src).digest("hex") !== profile.sdkSha256 || !src.includes(",$=[],")) {
264
+ throw new Error("Castle profile does not match the bundled SDK");
265
+ }
266
+ const signals = Object.fromEntries(profile.signals.flatMap((value, index) => [4, 116, 342, 356, 465, 550].includes(index) ? [] : [[index, value]]));
267
+ src = src.replace(",$=[],", `,$=new Proxy(Object.assign([],${JSON.stringify(signals)}),{set(target,key,value){const profile=${JSON.stringify(signals)};target[key]=Object.hasOwn(profile,key)?profile[key]:value;return true;}}),`);
268
+ }
269
+ const sandbox = buildSandbox(profile?.userAgent ?? userAgent, cookies, html);
224
270
 
225
271
  let factory = null;
226
272
  sandbox.webpackChunk_twitter_responsive_web = {
227
273
  push: (entry) => {
228
274
  const modules = entry?.[1];
229
- if (modules && modules[CASTLE_MODULE_ID]) factory = modules[CASTLE_MODULE_ID];
275
+ if (modules?.[CASTLE_MODULE_ID]) factory = modules[CASTLE_MODULE_ID];
230
276
  },
231
277
  };
232
278
 
233
279
  const ctx = vm.createContext(sandbox);
280
+ vm.runInContext("self = window = global = top = parent = frames = globalThis", ctx);
281
+ vm.runInContext(`
282
+ Object.defineProperties(document, {
283
+ defaultView: { get: () => window, configurable: true },
284
+ location: { get: () => location, configurable: true },
285
+ URL: { get: () => location.href, configurable: true },
286
+ documentURI: { get: () => location.href, configurable: true },
287
+ });
288
+ `, ctx);
234
289
  vm.runInContext(src, ctx, { filename: "castle-sdk.min.js" });
235
290
  if (!factory) throw new Error(`castle sdk module ${CASTLE_MODULE_ID} not found (X may have rotated the chunk)`);
236
291
 
@@ -1,11 +1,11 @@
1
- import { ClientTransaction, handleXMigration } from "x-client-transaction-id";
2
- import { mintCastleToken } from "./castle.js";
1
+ import { loadCastleProfile } from "./castle-profile.js";
2
+ import { createCastleMinter } from "./castle.js";
3
3
  import clients from "./clients.js";
4
4
  import getCycleTLS from "./cycletls.js";
5
- import { decodeJetfuel } from "./parsers/jetfuel.js";
5
+ import { decodeJetfuelMessages } from "./parsers/jetfuel-messages.js";
6
6
 
7
7
  const GUEST_ACTIVATE_URL = "https://api.x.com/1.1/guest/activate.json";
8
- const JFAPI_BASE = "https://x.com/i/jfapi";
8
+ const JFAPI_BASE = "https://jf.x.com";
9
9
  const SURFACE = "onboarding/web";
10
10
  const UA =
11
11
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36";
@@ -13,11 +13,12 @@ const UA =
13
13
  const MAX_STEPS = 15;
14
14
 
15
15
  class CookieSession {
16
- constructor(cycleTLS, proxy, timeout) {
16
+ constructor(cycleTLS, proxy, timeout, profile) {
17
17
  this.cycleTLS = cycleTLS;
18
18
  this.cookies = {};
19
19
  this.proxy = proxy;
20
20
  this.timeout = timeout;
21
+ this.profile = profile;
21
22
  }
22
23
 
23
24
  cookieString() {
@@ -30,13 +31,24 @@ class CookieSession {
30
31
  const setCookie = res.headers?.["set-cookie"] || res.headers?.["Set-Cookie"];
31
32
  if (!setCookie) return;
32
33
  for (const cookie of Array.isArray(setCookie) ? setCookie : [setCookie]) {
33
- const [key, ...rest] = cookie.split(";")[0].split("=");
34
- if (rest.length) this.cookies[key.trim()] = rest.join("=").trim();
34
+ const [pair, ...attributes] = cookie.split(";");
35
+ const separator = pair.indexOf("=");
36
+ if (separator < 1) continue;
37
+ const key = pair.slice(0, separator).trim();
38
+ const attrs = Object.fromEntries(attributes.map((attribute) => {
39
+ const [name, ...value] = attribute.trim().split("=");
40
+ return [name.toLowerCase(), value.join("=")];
41
+ }));
42
+ const maxAge = /^-?\d+$/.test(attrs["max-age"] ?? "") ? Number(attrs["max-age"]) : null;
43
+ const expired = maxAge !== null ? maxAge <= 0 : Date.parse(attrs.expires) <= Date.now();
44
+ if (expired) delete this.cookies[key];
45
+ else this.cookies[key] = pair.slice(separator + 1).trim();
35
46
  }
36
47
  }
37
48
 
38
49
  async request(url, method, { headers = {}, body, responseType } = {}) {
39
- const h = { ...headers };
50
+ const h = { ...this.profile?.clientHints, ...headers };
51
+ if (this.cookies.ct0) h["x-csrf-token"] = this.cookies.ct0;
40
52
  const cookieStr = this.cookieString();
41
53
  if (cookieStr) h.Cookie = cookieStr;
42
54
 
@@ -44,9 +56,8 @@ class CookieSession {
44
56
  url,
45
57
  {
46
58
  body,
47
- ja3: clients.web.fingerprints.ja3,
48
- ja4r: clients.web.fingerprints.ja4r,
49
- userAgent: UA,
59
+ ja3: clients.web.fingerprints.ja3.replace(/^771,/, "772,").replace("4588-", ""),
60
+ userAgent: this.profile?.userAgent ?? UA,
50
61
  headers: h,
51
62
  proxy: this.proxy || undefined,
52
63
  responseType,
@@ -59,7 +70,7 @@ class CookieSession {
59
70
  }
60
71
  }
61
72
 
62
- function jfHeaders(guestToken, tid, theme) {
73
+ function jfHeaders(guestToken, theme) {
63
74
  const headers = {
64
75
  authorization: `Bearer ${clients.web.bearer}`,
65
76
  "x-guest-token": guestToken,
@@ -71,9 +82,8 @@ function jfHeaders(guestToken, tid, theme) {
71
82
  accept: "*/*",
72
83
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
73
84
  Origin: "https://x.com",
74
- Referer: "https://x.com/i/flow/login",
85
+ Referer: "https://x.com/",
75
86
  };
76
- if (tid) headers["x-client-transaction-id"] = tid;
77
87
  return headers;
78
88
  }
79
89
 
@@ -95,12 +105,18 @@ async function toBuffer(res) {
95
105
  return Buffer.from(String(body ?? ""), "utf8");
96
106
  }
97
107
 
98
- function decodeStrings(buffer) {
99
- try {
100
- return decodeJetfuel(buffer).strings || [];
101
- } catch {
102
- return [];
108
+ function decodeResponse(buffer) {
109
+ const { messages, strings } = decodeJetfuelMessages(buffer);
110
+ const forms = [];
111
+ for (const [tag, message] of messages) {
112
+ if (tag !== 0) continue;
113
+ for (const [type, value] of message.props) {
114
+ if (type === 30) {
115
+ forms.push({ url: value[0], fields: Object.fromEntries(value[1]), errors: Object.fromEntries(value[2]) });
116
+ }
117
+ }
103
118
  }
119
+ return { strings, forms };
104
120
  }
105
121
 
106
122
  function extractActionIds(strings) {
@@ -130,10 +146,10 @@ function extractUserId(cookies) {
130
146
  return null;
131
147
  }
132
148
 
133
- async function resolve(value, onRequest, type) {
149
+ async function resolve(value, onRequest, type, context) {
134
150
  if (value != null && value !== "") return value;
135
151
  if (onRequest) {
136
- const v = await onRequest(type);
152
+ const v = await onRequest(type, context);
137
153
  if (v != null && v !== "") return v;
138
154
  }
139
155
  throw new Error(
@@ -148,7 +164,8 @@ export default async function flowLoginJetfuel(opts) {
148
164
  email,
149
165
  phone,
150
166
  onRequest,
151
- getCastleToken = () => mintCastleToken(),
167
+ getCastleToken,
168
+ castleProfile,
152
169
  proxy,
153
170
  timeout,
154
171
  theme = "light",
@@ -161,57 +178,63 @@ export default async function flowLoginJetfuel(opts) {
161
178
  if (!password) throw new Error("password is required for login");
162
179
 
163
180
  const cycleTLS = await getCycleTLS();
164
- const session = new CookieSession(cycleTLS, proxy, timeout);
181
+ const profile = loadCastleProfile(castleProfile);
182
+ const session = new CookieSession(cycleTLS, proxy, timeout, profile);
165
183
 
166
- const migrationDoc = await handleXMigration();
167
- const transaction = new ClientTransaction(migrationDoc);
168
- await transaction.initialize();
169
- const tid = (method, path) => transaction.generateTransactionId(method, path);
184
+ const home = await session.request("https://x.com/", "get", {
185
+ headers: { accept: "text/html", "accept-language": "en-US,en;q=0.9" },
186
+ });
187
+ if (home.status !== 200) throw new Error(`login bootstrap failed: ${home.status}`);
188
+ const homeHtml = (await toBuffer(home)).toString("utf8");
189
+ const mint = getCastleToken ?? createCastleMinter({ userAgent: UA, cookies: session.cookies, html: homeHtml, profile: profile ?? false });
170
190
 
171
- const guestToken = await getGuestToken(session);
172
- log("guest token", guestToken);
191
+ const guestToken = homeHtml.match(/document\.cookie\s*=\s*["']gt=(\d+);/)?.[1]
192
+ ?? session.cookies.gt
193
+ ?? await getGuestToken(session);
194
+ session.cookies.gt = guestToken;
195
+ log("guest token obtained");
173
196
 
174
- const landingPath = `/i/jfapi/${SURFACE}?mode=login`;
175
- const landing = await session.request(`${JFAPI_BASE}/${SURFACE}?mode=login`, "get", {
176
- headers: jfHeaders(guestToken, await tid("GET", landingPath), theme),
197
+ const landing = await session.request(`${JFAPI_BASE}/${SURFACE}/landing`, "get", {
198
+ headers: jfHeaders(guestToken, theme),
177
199
  responseType: "arraybuffer",
178
200
  });
179
201
  if (landing.status !== 200) throw new Error(`landing failed: ${landing.status}`);
180
- let strings = decodeStrings(await toBuffer(landing));
202
+ let response = decodeResponse(await toBuffer(landing));
203
+ let { strings } = response;
181
204
  log("landing actions", extractActionIds(strings));
182
205
 
183
206
  const post = async (action, fields) => {
184
- const path = `/i/jfapi/${SURFACE}/actions/${action}`;
185
- const castle = await getCastleToken(action);
207
+ const castle = await mint(action);
186
208
  const form = new URLSearchParams({ ...fields, $castle_token: castle }).toString();
187
- const headers = jfHeaders(guestToken, await tid("POST", path), theme);
209
+ const headers = jfHeaders(guestToken, theme);
188
210
  headers["content-type"] = "application/x-www-form-urlencoded";
189
- if (session.cookies.ct0) headers["x-csrf-token"] = session.cookies.ct0;
190
211
  const res = await session.request(`${JFAPI_BASE}/${SURFACE}/actions/${action}`, "post", {
191
212
  headers,
192
213
  body: form,
193
214
  responseType: "arraybuffer",
194
215
  });
195
216
  const buffer = await toBuffer(res);
196
- const responseStrings = decodeStrings(buffer);
197
- log(`POST ${action} -> ${res.status}`, extractActionIds(responseStrings));
198
217
  if (res.status !== 200) {
199
- throw new Error(`action ${action} failed: ${res.status} - ${buffer.toString("utf8").slice(0, 200)}`);
218
+ throw new Error(`action ${action} failed: ${res.status}`);
200
219
  }
220
+ const result = decodeResponse(buffer);
221
+ const responseStrings = result.strings;
222
+ log(`POST ${action} -> ${res.status}`, extractActionIds(responseStrings));
201
223
  const genericError = responseStrings.find((s) => /something went wrong/i.test(s));
202
224
  if (genericError && responseStrings.length < 6) {
203
225
  throw new Error(
204
- `action ${action} rejected ("${genericError}") - usually an invalid or missing $castle_token.`,
226
+ `action ${action} rejected ("${genericError}")`,
205
227
  );
206
228
  }
207
- const serverError = extractServerError(responseStrings);
229
+ const serverError = result.forms.flatMap((form) => Object.values(form.errors)).find(Boolean)
230
+ ?? extractServerError(responseStrings);
208
231
  if (serverError) {
209
232
  if (/temporarily limited|rate.?limit|try again later/i.test(serverError)) {
210
- throw new Error(`login rate-limited by X: "${serverError.trim()}" (back off this account/IP)`);
233
+ throw new Error(`login rate-limited by X: "${serverError.trim()}"`);
211
234
  }
212
235
  throw new Error(`action ${action} error: "${serverError.trim()}"`);
213
236
  }
214
- return responseStrings;
237
+ return result;
215
238
  };
216
239
 
217
240
  const sessionTokenFrom = (arr) => {
@@ -223,7 +246,8 @@ export default async function flowLoginJetfuel(opts) {
223
246
  let sessionToken = null;
224
247
  const withSession = (fields) => (sessionToken ? { session_token: sessionToken, ...fields } : fields);
225
248
 
226
- strings = await post("begin_login", { username_or_email: identifier });
249
+ response = await post("begin_login", { username_or_email: identifier });
250
+ strings = response.strings;
227
251
  sessionToken = sessionTokenFrom(strings) || sessionToken;
228
252
 
229
253
  let step = 0;
@@ -239,6 +263,52 @@ export default async function flowLoginJetfuel(opts) {
239
263
  throw new Error("login denied: account locked or suspended");
240
264
  }
241
265
 
266
+ const passwordSwitch = response.forms.find((form) => form.fields.force_password);
267
+ if (passwordSwitch) {
268
+ const knowledgeCheck = response.forms.find((form) => form.url === "/onboarding/web/actions/finish_knowledge_check");
269
+ if (knowledgeCheck && onRequest) {
270
+ const kc = knowledgeCheck.fields;
271
+ const alternate = await onRequest("alternate_identifier", { challengeType: kc.challenge_type ?? null, accountFound: kc.account_found ?? null, accountFoundType: kc.account_found_type ?? null });
272
+ if (alternate != null && alternate !== "") {
273
+ response = await post("finish_knowledge_check", { ...knowledgeCheck.fields, challenge_response: alternate });
274
+ strings = response.strings;
275
+ sessionToken = sessionTokenFrom(strings) || sessionToken;
276
+ continue;
277
+ }
278
+ }
279
+ response = await post("begin_login", passwordSwitch.fields);
280
+ strings = response.strings;
281
+ sessionToken = sessionTokenFrom(strings) || sessionToken;
282
+ continue;
283
+ }
284
+
285
+ const form = response.forms.find((entry) => /\/onboarding\/web\/actions\/login_enter_password$/.test(entry.url))
286
+ ?? response.forms.find((entry) => /\/onboarding\/web\/actions\/(?:finish_login|finish_knowledge_check|login_)/.test(entry.url));
287
+ if (form) {
288
+ const action = form.url.match(/^\/onboarding\/web\/actions\/([a-z0-9_]+)$/)?.[1];
289
+ if (!action) throw new Error("invalid login action URL");
290
+ const fields = { ...form.fields };
291
+ if (Object.hasOwn(fields, "password")) fields.password = password;
292
+ else {
293
+ const field = ["challenge_response", "code", "text"].find((key) => Object.hasOwn(fields, key));
294
+ if (!field) throw new Error(`unsupported login form: ${action}`);
295
+ const type = action === "finish_knowledge_check" || /username|phone/i.test(fields.challenge_type ?? "") ? "alternate_identifier"
296
+ : has("two_factor") || has("authenticator") || has("authentication code") ? "two_factor_code" : "email_code";
297
+ let alternate = null;
298
+ if (type === "alternate_identifier") {
299
+ const challenge = fields.challenge_type?.toLowerCase();
300
+ if (challenge === "username" && !identifier.includes("@") && !/^\+?[\d\s-]+$/.test(identifier)) alternate = identifier;
301
+ if (challenge === "email") alternate = email || (identifier.includes("@") ? identifier : null);
302
+ if (challenge === "phone") alternate = phone;
303
+ }
304
+ fields[field] = await resolve(alternate, onRequest, type, { challengeType: fields.challenge_type ?? null, accountFound: fields.account_found ?? null, accountFoundType: fields.account_found_type ?? null });
305
+ }
306
+ response = await post(action, fields);
307
+ strings = response.strings;
308
+ sessionToken = sessionTokenFrom(strings) || sessionToken;
309
+ continue;
310
+ }
311
+
242
312
  const wantsPasswordEntry = has("enter your password") || has("login_enter_password");
243
313
  const wantsTwoFactor = has("two_factor") || has("2fa") || has("authentication code");
244
314
  const wantsEmailCode = has("acid") || has("verifycode") || has("verification code") ||
@@ -247,31 +317,35 @@ export default async function flowLoginJetfuel(opts) {
247
317
 
248
318
  if (wantsTwoFactor) {
249
319
  const code = await resolve(null, onRequest, "two_factor_code");
250
- strings = await post("login_enter_two_factor", withSession({ text: code }));
320
+ response = await post("login_enter_two_factor", withSession({ text: code }));
321
+ strings = response.strings;
251
322
  sessionToken = sessionTokenFrom(strings) || sessionToken;
252
323
  continue;
253
324
  }
254
325
  if (wantsEmailCode) {
255
326
  const code = await resolve(null, onRequest, "email_code");
256
- strings = await post("login_acid", withSession({ text: code }));
327
+ response = await post("login_acid", withSession({ text: code }));
328
+ strings = response.strings;
257
329
  sessionToken = sessionTokenFrom(strings) || sessionToken;
258
330
  continue;
259
331
  }
260
332
  if (wantsAlternate) {
261
333
  const value = await resolve(email || phone, onRequest, "alternate_identifier");
262
- strings = await post("login_enter_alternate_identifier", withSession({ text: value }));
334
+ response = await post("login_enter_alternate_identifier", withSession({ text: value }));
335
+ strings = response.strings;
263
336
  sessionToken = sessionTokenFrom(strings) || sessionToken;
264
337
  continue;
265
338
  }
266
339
  if (wantsPasswordEntry || has("password")) {
267
- strings = await post("login_enter_password", withSession({ password }));
340
+ response = await post("login_enter_password", withSession({ password }));
341
+ strings = response.strings;
268
342
  sessionToken = sessionTokenFrom(strings) || sessionToken;
269
343
  continue;
270
344
  }
271
345
 
272
346
  throw new Error(
273
347
  `unhandled jetfuel login step. decoded actions: [${extractActionIds(strings).join(", ")}]. ` +
274
- `strings sample: ${JSON.stringify(strings.slice(0, 20))}`,
348
+ `no supported password or verification form was returned`,
275
349
  );
276
350
  }
277
351
 
@@ -284,5 +358,6 @@ export default async function flowLoginJetfuel(opts) {
284
358
  csrfToken: session.cookies.ct0 || null,
285
359
  userId: extractUserId(session.cookies),
286
360
  cookies: { ...session.cookies },
361
+ clientProfile: profile ? { userAgent: profile.userAgent, clientHints: profile.clientHints } : null,
287
362
  };
288
363
  }
@@ -52,7 +52,8 @@ export async function viewer() {
52
52
  fieldToggles: { withAuxiliaryUserLabels: false },
53
53
  });
54
54
  const user = res?.data?.viewer?.user_results?.result;
55
- return user ? parseUser(user) : res;
55
+ if (!user) throw new Error("couldn't get viewer, response: " + JSON.stringify(res));
56
+ return parseUser(user);
56
57
  }
57
58
 
58
59
  export async function sessions() {
@@ -241,7 +241,8 @@ export async function get(tweetId) {
241
241
  },
242
242
  });
243
243
  const tweet = res?.data?.tweetResult?.result;
244
- return tweet ? parseTweet(tweet) : res;
244
+ if (!tweet) throw new Error("couldn't get tweet, response: " + JSON.stringify(res));
245
+ return parseTweet(tweet);
245
246
  }
246
247
 
247
248
  export async function getMany(tweetIds) {
@@ -7,7 +7,8 @@ export async function get(userId) {
7
7
  fieldToggles: { withAuxiliaryUserLabels: false },
8
8
  });
9
9
  const user = res?.data?.user?.result;
10
- return user ? parseUser(user) : res;
10
+ if (!user) throw new Error("couldn't get user, response: " + JSON.stringify(res));
11
+ return parseUser(user);
11
12
  }
12
13
 
13
14
  export async function getByUsername(username) {
@@ -16,7 +17,8 @@ export async function getByUsername(username) {
16
17
  fieldToggles: { withAuxiliaryUserLabels: false },
17
18
  });
18
19
  const user = res?.data?.user?.result;
19
- return user ? parseUser(user) : res;
20
+ if (!user) throw new Error("couldn't get user, response: " + JSON.stringify(res));
21
+ return parseUser(user);
20
22
  }
21
23
 
22
24
  export async function getMany(userIds) {