emusks 2.3.6 → 2.3.8

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.6",
3
+ "version": "2.3.8",
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) {
@@ -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,51 @@ 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 alternate = await onRequest("alternate_identifier");
271
+ if (alternate != null && alternate !== "") {
272
+ response = await post("finish_knowledge_check", { ...knowledgeCheck.fields, challenge_response: alternate });
273
+ strings = response.strings;
274
+ sessionToken = sessionTokenFrom(strings) || sessionToken;
275
+ continue;
276
+ }
277
+ }
278
+ response = await post("begin_login", passwordSwitch.fields);
279
+ strings = response.strings;
280
+ sessionToken = sessionTokenFrom(strings) || sessionToken;
281
+ continue;
282
+ }
283
+
284
+ const form = response.forms.find((entry) => /\/onboarding\/web\/actions\/login_enter_password$/.test(entry.url))
285
+ ?? response.forms.find((entry) => /\/onboarding\/web\/actions\/(?:finish_login|finish_knowledge_check|login_)/.test(entry.url));
286
+ if (form) {
287
+ const action = form.url.match(/^\/onboarding\/web\/actions\/([a-z0-9_]+)$/)?.[1];
288
+ if (!action) throw new Error("invalid login action URL");
289
+ const fields = { ...form.fields };
290
+ if (Object.hasOwn(fields, "password")) fields.password = password;
291
+ else {
292
+ const field = ["challenge_response", "code", "text"].find((key) => Object.hasOwn(fields, key));
293
+ if (!field) throw new Error(`unsupported login form: ${action}`);
294
+ const type = action === "finish_knowledge_check" || /username|phone/i.test(fields.challenge_type ?? "") ? "alternate_identifier"
295
+ : has("two_factor") || has("authenticator") || has("authentication code") ? "two_factor_code" : "email_code";
296
+ let alternate = null;
297
+ if (type === "alternate_identifier") {
298
+ const challenge = fields.challenge_type?.toLowerCase();
299
+ if (challenge === "username" && !identifier.includes("@") && !/^\+?[\d\s-]+$/.test(identifier)) alternate = identifier;
300
+ if (challenge === "email") alternate = email || (identifier.includes("@") ? identifier : null);
301
+ if (challenge === "phone") alternate = phone;
302
+ }
303
+ fields[field] = await resolve(alternate, onRequest, type);
304
+ }
305
+ response = await post(action, fields);
306
+ strings = response.strings;
307
+ sessionToken = sessionTokenFrom(strings) || sessionToken;
308
+ continue;
309
+ }
310
+
242
311
  const wantsPasswordEntry = has("enter your password") || has("login_enter_password");
243
312
  const wantsTwoFactor = has("two_factor") || has("2fa") || has("authentication code");
244
313
  const wantsEmailCode = has("acid") || has("verifycode") || has("verification code") ||
@@ -247,31 +316,35 @@ export default async function flowLoginJetfuel(opts) {
247
316
 
248
317
  if (wantsTwoFactor) {
249
318
  const code = await resolve(null, onRequest, "two_factor_code");
250
- strings = await post("login_enter_two_factor", withSession({ text: code }));
319
+ response = await post("login_enter_two_factor", withSession({ text: code }));
320
+ strings = response.strings;
251
321
  sessionToken = sessionTokenFrom(strings) || sessionToken;
252
322
  continue;
253
323
  }
254
324
  if (wantsEmailCode) {
255
325
  const code = await resolve(null, onRequest, "email_code");
256
- strings = await post("login_acid", withSession({ text: code }));
326
+ response = await post("login_acid", withSession({ text: code }));
327
+ strings = response.strings;
257
328
  sessionToken = sessionTokenFrom(strings) || sessionToken;
258
329
  continue;
259
330
  }
260
331
  if (wantsAlternate) {
261
332
  const value = await resolve(email || phone, onRequest, "alternate_identifier");
262
- strings = await post("login_enter_alternate_identifier", withSession({ text: value }));
333
+ response = await post("login_enter_alternate_identifier", withSession({ text: value }));
334
+ strings = response.strings;
263
335
  sessionToken = sessionTokenFrom(strings) || sessionToken;
264
336
  continue;
265
337
  }
266
338
  if (wantsPasswordEntry || has("password")) {
267
- strings = await post("login_enter_password", withSession({ password }));
339
+ response = await post("login_enter_password", withSession({ password }));
340
+ strings = response.strings;
268
341
  sessionToken = sessionTokenFrom(strings) || sessionToken;
269
342
  continue;
270
343
  }
271
344
 
272
345
  throw new Error(
273
346
  `unhandled jetfuel login step. decoded actions: [${extractActionIds(strings).join(", ")}]. ` +
274
- `strings sample: ${JSON.stringify(strings.slice(0, 20))}`,
347
+ `no supported password or verification form was returned`,
275
348
  );
276
349
  }
277
350
 
@@ -284,5 +357,6 @@ export default async function flowLoginJetfuel(opts) {
284
357
  csrfToken: session.cookies.ct0 || null,
285
358
  userId: extractUserId(session.cookies),
286
359
  cookies: { ...session.cookies },
360
+ clientProfile: profile ? { userAgent: profile.userAgent, clientHints: profile.clientHints } : null,
287
361
  };
288
362
  }
@@ -219,7 +219,7 @@ export async function create(source, opts = {}) {
219
219
  });
220
220
  }
221
221
 
222
- return { media_id: mediaId, ...finalizeData };
222
+ return { ...finalizeData, media_id: mediaId };
223
223
  }
224
224
 
225
225
  export async function createFromUrl(url, opts = {}) {
@@ -274,7 +274,7 @@ export async function createFromUrl(url, opts = {}) {
274
274
  });
275
275
  }
276
276
 
277
- return { media_id: mediaId, ...initData };
277
+ return { ...initData, media_id: mediaId };
278
278
  }
279
279
 
280
280
  export async function createMetadata(mediaId, altText, opts = {}) {