tca-js 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,11 @@ await new TikTok().login({ cookies: 'sessionid=…; ttwid=…' }); // header str
86
86
  Required cookies: `sessionid`, `sid_tt`, `sid_guard`, `uid_tt`, `ttwid`, `odin_tt`, `store-idc`, `tt-target-idc`.
87
87
  A session lasts about 6 months, and logging in does not sign out other devices.
88
88
 
89
+ > **Sending needs `ticketGuard`** (since 12/11/2026). TikTok signs `message/send` with a per-login key pair; a session
90
+ > that sends unsigned gets blocked after its first message (`SESSION_BLOCKED`). Cookies alone are enough to READ, but
91
+ > to send use credentials from `loginQR` (which captures `ticketGuard`) and persist them as a whole — including
92
+ > `api.getContext().ticketGuard`. Treat it as secret as the cookies.
93
+
89
94
  ### QR code
90
95
 
91
96
  `loginQR` opens a headless Chrome on TikTok's QR page, emits the QR image, and resolves with credentials once the code is
@@ -101,7 +106,7 @@ const credentials = await loginQR({
101
106
  });
102
107
 
103
108
  const api = await new TikTok().login(credentials);
104
- // Save `credentials` to skip scanning next time.
109
+ // Save `credentials` (cookies + ticketGuard) to skip scanning next time — both are secrets.
105
110
  ```
106
111
 
107
112
  | Option | Type | Default | Description |
@@ -143,12 +148,13 @@ with `sendMessage(msg, …)`.
143
148
 
144
149
  ### Session
145
150
 
146
- | Method / property | Description |
147
- | ----------------- | ------------------------------------------------------------ |
148
- | `account` | Logged-in account: `uid`, `username`, `screenName`, `avatar` |
149
- | `getOwnId()` | Own user id |
150
- | `getCookies()` | Current cookies — persist them to resume the session |
151
- | `isAlive()` | `true` while the session is still valid |
151
+ | Method / property | Description |
152
+ | ----------------- | ------------------------------------------------------------- |
153
+ | `account` | Logged-in account: `uid`, `username`, `screenName`, `avatar` |
154
+ | `getOwnId()` | Own user id |
155
+ | `getCookies()` | Current cookies — persist them to resume the session |
156
+ | `getContext()` | `deviceId`, `userAgent`, `ticketGuard` — persist with cookies |
157
+ | `isAlive()` | `true` while the session is still valid |
152
158
 
153
159
  ### Message object
154
160
 
@@ -184,14 +190,14 @@ api.listener.stop();
184
190
 
185
191
  All library errors are instances of `TikTokError` with a `code`:
186
192
 
187
- | Code | Meaning |
188
- | ----------------------------------- | --------------------------------------------------------------------------------------------- |
189
- | `SESSION_EXPIRED` | Cookies are no longer valid — log in again |
190
- | `SESSION_BLOCKED` | TikTok's gateway refuses this session (JSON `status_code: 7`, seen on sending) — log in again |
191
- | `IM_ERROR` | TikTok rejected the request |
192
- | `HTTP_ERROR` · `BAD_RESPONSE` | Network or unexpected response |
193
- | `UPLOAD_FAILED` · `DOWNLOAD_FAILED` | Media transfer failed |
194
- | `LOGIN_FAILED` · `LOGIN_ABORTED` | QR login failed or was cancelled |
193
+ | Code | Meaning |
194
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `SESSION_EXPIRED` | Cookies are no longer valid — log in again |
196
+ | `SESSION_BLOCKED` | TikTok's gateway refuses this session (JSON `status_code: 7`) — usually sent without `ticketGuard`; log in again with `loginQR` |
197
+ | `IM_ERROR` | TikTok rejected the request |
198
+ | `HTTP_ERROR` · `BAD_RESPONSE` | Network or unexpected response |
199
+ | `UPLOAD_FAILED` · `DOWNLOAD_FAILED` | Media transfer failed |
200
+ | `LOGIN_FAILED` · `LOGIN_ABORTED` | QR login failed or was cancelled |
195
201
 
196
202
  ```ts
197
203
  import { TikTokError } from 'tca-js';
package/dist/index.d.mts CHANGED
@@ -44,6 +44,22 @@ interface Credentials {
44
44
  /** IM device id (`wid`) — leave empty and `login` asks TikTok using `ttwid`. Only used when that fails. */
45
45
  deviceId?: string;
46
46
  userAgent?: string;
47
+ /**
48
+ * Request-signing material captured at login (`loginQR` fills it). Required to SEND messages since 12/11/2026 —
49
+ * without it TikTok blocks the session's sends after the first one (`SESSION_BLOCKED`). Keep it with the cookies.
50
+ */
51
+ ticketGuard?: TicketGuard;
52
+ }
53
+ /** TikTok "ticket guard" signing material — see `protocol/ticket-guard.ts`. Secret: treat like the session cookie. */
54
+ interface TicketGuard {
55
+ /** EC P-256 private key, PKCS#8 PEM. */
56
+ privateKey: string;
57
+ /** Matching public key, SPKI PEM. */
58
+ publicKey: string;
59
+ /** Server-issued `ts_sign` bound to the public key. */
60
+ tsSign: string;
61
+ /** Server-issued ticket (plain text). */
62
+ ticket: string;
47
63
  }
48
64
  interface TikTokOptions {
49
65
  /** Debug logging — silent by default. */
@@ -152,6 +168,8 @@ declare class Context {
152
168
  */
153
169
  deviceId: string;
154
170
  readonly userAgent: string;
171
+ /** Signing material for `SIGNED_PATHS` — absent for sessions saved before 0.5.0 (sends then go unsigned). */
172
+ readonly ticketGuard?: TicketGuard;
155
173
  ownId: string;
156
174
  private seq;
157
175
  constructor(creds: Credentials, options?: TikTokOptions);
@@ -166,7 +184,8 @@ declare class Context {
166
184
  buildRequest(cmd: number, bodyField: number, body: Buffer): Buffer;
167
185
  /**
168
186
  * Call an IM command over HTTP. Returns the unwrapped body (`ResponseBody[cmd]`).
169
- * NO X-Gnarly/X-Bogus/msToken signature needed — the IM server only checks cookies (tested 09/11/2026).
187
+ * NO X-Gnarly/X-Bogus/msToken signature needed. `message/send` additionally needs the ticket-guard headers
188
+ * (enforced 12/11/2026) — added from `ticketGuard` when present.
170
189
  */
171
190
  im(path: string, cmd: number, body: Buffer, bodyField?: number): Promise<Msg>;
172
191
  }
@@ -230,9 +249,11 @@ declare class API {
230
249
  name: string;
231
250
  value: string;
232
251
  }[];
252
+ /** Non-cookie session state to save alongside the cookies (pass back into `login`). */
233
253
  getContext(): {
234
254
  deviceId: string;
235
255
  userAgent: string;
256
+ ticketGuard?: TicketGuard;
236
257
  };
237
258
  /** Whether the session is still alive — call periodically to detect expiry early. */
238
259
  isAlive(): Promise<boolean>;
@@ -371,4 +392,4 @@ declare function loginQR(opts: QrLoginOptions): Promise<Credentials>;
371
392
  declare function findChrome(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
372
393
 
373
394
  export { API, Listener, MANUAL_CLOSE, MessageType, TikTok, TikTokError, findChrome, loginQR };
374
- export type { ConversationPage, ConversationRef, CookieInput, Credentials, ImageInput, ListenerEvents, MessagePage, QrLoginEvent, QrLoginOptions, SendResult, TikTokAccount, TikTokConversation, TikTokErrorCode, TikTokImage, TikTokMessage, TikTokOptions, TikTokReaction, TikTokSticker, TikTokUser };
395
+ export type { ConversationPage, ConversationRef, CookieInput, Credentials, ImageInput, ListenerEvents, MessagePage, QrLoginEvent, QrLoginOptions, SendResult, TicketGuard, TikTokAccount, TikTokConversation, TikTokErrorCode, TikTokImage, TikTokMessage, TikTokOptions, TikTokReaction, TikTokSticker, TikTokUser };
package/dist/index.d.ts CHANGED
@@ -44,6 +44,22 @@ interface Credentials {
44
44
  /** IM device id (`wid`) — leave empty and `login` asks TikTok using `ttwid`. Only used when that fails. */
45
45
  deviceId?: string;
46
46
  userAgent?: string;
47
+ /**
48
+ * Request-signing material captured at login (`loginQR` fills it). Required to SEND messages since 12/11/2026 —
49
+ * without it TikTok blocks the session's sends after the first one (`SESSION_BLOCKED`). Keep it with the cookies.
50
+ */
51
+ ticketGuard?: TicketGuard;
52
+ }
53
+ /** TikTok "ticket guard" signing material — see `protocol/ticket-guard.ts`. Secret: treat like the session cookie. */
54
+ interface TicketGuard {
55
+ /** EC P-256 private key, PKCS#8 PEM. */
56
+ privateKey: string;
57
+ /** Matching public key, SPKI PEM. */
58
+ publicKey: string;
59
+ /** Server-issued `ts_sign` bound to the public key. */
60
+ tsSign: string;
61
+ /** Server-issued ticket (plain text). */
62
+ ticket: string;
47
63
  }
48
64
  interface TikTokOptions {
49
65
  /** Debug logging — silent by default. */
@@ -152,6 +168,8 @@ declare class Context {
152
168
  */
153
169
  deviceId: string;
154
170
  readonly userAgent: string;
171
+ /** Signing material for `SIGNED_PATHS` — absent for sessions saved before 0.5.0 (sends then go unsigned). */
172
+ readonly ticketGuard?: TicketGuard;
155
173
  ownId: string;
156
174
  private seq;
157
175
  constructor(creds: Credentials, options?: TikTokOptions);
@@ -166,7 +184,8 @@ declare class Context {
166
184
  buildRequest(cmd: number, bodyField: number, body: Buffer): Buffer;
167
185
  /**
168
186
  * Call an IM command over HTTP. Returns the unwrapped body (`ResponseBody[cmd]`).
169
- * NO X-Gnarly/X-Bogus/msToken signature needed — the IM server only checks cookies (tested 09/11/2026).
187
+ * NO X-Gnarly/X-Bogus/msToken signature needed. `message/send` additionally needs the ticket-guard headers
188
+ * (enforced 12/11/2026) — added from `ticketGuard` when present.
170
189
  */
171
190
  im(path: string, cmd: number, body: Buffer, bodyField?: number): Promise<Msg>;
172
191
  }
@@ -230,9 +249,11 @@ declare class API {
230
249
  name: string;
231
250
  value: string;
232
251
  }[];
252
+ /** Non-cookie session state to save alongside the cookies (pass back into `login`). */
233
253
  getContext(): {
234
254
  deviceId: string;
235
255
  userAgent: string;
256
+ ticketGuard?: TicketGuard;
236
257
  };
237
258
  /** Whether the session is still alive — call periodically to detect expiry early. */
238
259
  isAlive(): Promise<boolean>;
@@ -371,4 +392,4 @@ declare function loginQR(opts: QrLoginOptions): Promise<Credentials>;
371
392
  declare function findChrome(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
372
393
 
373
394
  export { API, Listener, MANUAL_CLOSE, MessageType, TikTok, TikTokError, findChrome, loginQR };
374
- export type { ConversationPage, ConversationRef, CookieInput, Credentials, ImageInput, ListenerEvents, MessagePage, QrLoginEvent, QrLoginOptions, SendResult, TikTokAccount, TikTokConversation, TikTokErrorCode, TikTokImage, TikTokMessage, TikTokOptions, TikTokReaction, TikTokSticker, TikTokUser };
395
+ export type { ConversationPage, ConversationRef, CookieInput, Credentials, ImageInput, ListenerEvents, MessagePage, QrLoginEvent, QrLoginOptions, SendResult, TicketGuard, TikTokAccount, TikTokConversation, TikTokErrorCode, TikTokImage, TikTokMessage, TikTokOptions, TikTokReaction, TikTokSticker, TikTokUser };
package/dist/index.js CHANGED
@@ -793,8 +793,9 @@ var API = class {
793
793
  getCookies() {
794
794
  return [...this.ctx.cookies].map(([name, value]) => ({ name, value }));
795
795
  }
796
+ /** Non-cookie session state to save alongside the cookies (pass back into `login`). */
796
797
  getContext() {
797
- return { deviceId: this.ctx.deviceId, userAgent: this.ctx.userAgent };
798
+ return { deviceId: this.ctx.deviceId, userAgent: this.ctx.userAgent, ticketGuard: this.ctx.ticketGuard };
798
799
  }
799
800
  /** Whether the session is still alive — call periodically to detect expiry early. */
800
801
  async isAlive() {
@@ -924,6 +925,56 @@ var API = class {
924
925
  }
925
926
  };
926
927
 
928
+ // src/protocol/ticket-guard.ts
929
+ var import_node_crypto4 = require("crypto");
930
+ var SIGNED_PATHS = /* @__PURE__ */ new Set(["/v1/message/send"]);
931
+ function ticketGuardHeaders(guard, path, now = Date.now()) {
932
+ const timestamp = Math.floor(now / 1e3);
933
+ const message = `ticket=${guard.ticket}&path=${path}&timestamp=${timestamp}`;
934
+ const reqSign = (0, import_node_crypto4.sign)("sha256", Buffer.from(message), (0, import_node_crypto4.createPrivateKey)(guard.privateKey)).toString("base64");
935
+ const clientData = { ts_sign: guard.tsSign, req_content: "ticket,path,timestamp", req_sign: reqSign, timestamp };
936
+ return {
937
+ "tt-ticket-guard-client-data": Buffer.from(JSON.stringify(clientData)).toString("base64"),
938
+ "tt-ticket-guard-public-key": publicPoint(guard.publicKey),
939
+ "tt-ticket-guard-version": "2",
940
+ "tt-ticket-guard-web-version": "1",
941
+ "tt-ticket-guard-iteration-version": "0"
942
+ };
943
+ }
944
+ function publicPoint(pem) {
945
+ const jwk = (0, import_node_crypto4.createPublicKey)(pem).export({ format: "jwk" });
946
+ return Buffer.concat([
947
+ Buffer.from([4]),
948
+ Buffer.from(jwk.x ?? "", "base64url"),
949
+ Buffer.from(jwk.y ?? "", "base64url")
950
+ ]).toString("base64");
951
+ }
952
+ var READ_TICKET_GUARD_JS = `(async () => {
953
+ try {
954
+ const db = await new Promise((ok, ko) => { const r = indexedDB.open('secure-store'); r.onsuccess = () => ok(r.result); r.onerror = ko; });
955
+ if (!db.objectStoreNames.contains('cryptvalues')) return null;
956
+ const get = (k) => new Promise((ok) => { const q = db.transaction('cryptvalues').objectStore('cryptvalues').get(k); q.onsuccess = () => ok(q.result); q.onerror = () => ok(null); });
957
+ const keysRaw = await get('security-sdk/s_sdk_crypt_sdk');
958
+ const signRaw = await get('security-sdk/s_sdk_sign_data_key/tt_fetch');
959
+ if (!keysRaw || !signRaw) return null;
960
+ const keys = JSON.parse(JSON.parse(keysRaw).data);
961
+ const signData = JSON.parse(JSON.parse(signRaw).data);
962
+ if (!keys.ec_privateKey || !keys.ec_publicKey || !signData.ts_sign || !signData.encrypt_ticket) return null;
963
+ const te = new TextEncoder();
964
+ const base = await crypto.subtle.importKey('raw', te.encode('tt-ticket-guard-iv'), 'PBKDF2', false, ['deriveKey']);
965
+ const aes = await crypto.subtle.deriveKey({ name: 'PBKDF2', salt: te.encode('secure-salt'), iterations: 1000, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 128 }, false, ['decrypt']);
966
+ const enc = Uint8Array.from(atob(signData.encrypt_ticket), (c) => c.charCodeAt(0));
967
+ const ticket = new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: enc.slice(0, 12) }, aes, enc.slice(12)));
968
+ return { privateKey: keys.ec_privateKey, publicKey: keys.ec_publicKey, tsSign: signData.ts_sign, ticket };
969
+ } catch (e) {
970
+ return null;
971
+ }
972
+ })()`;
973
+ function isTicketGuard(v) {
974
+ const g = v;
975
+ return !!g && typeof g.privateKey === "string" && typeof g.publicKey === "string" && typeof g.tsSign === "string" && typeof g.ticket === "string" && g.ticket.length > 0;
976
+ }
977
+
927
978
  // src/protocol/context.ts
928
979
  function normalizeCookies(input) {
929
980
  const jar = /* @__PURE__ */ new Map();
@@ -945,6 +996,7 @@ var Context = class {
945
996
  this.cookies = normalizeCookies(creds.cookies);
946
997
  this.deviceId = creds.deviceId ?? "";
947
998
  this.userAgent = creds.userAgent || DEFAULT_USER_AGENT;
999
+ if (isTicketGuard(creds.ticketGuard)) this.ticketGuard = creds.ticketGuard;
948
1000
  }
949
1001
  options;
950
1002
  cookies;
@@ -954,6 +1006,8 @@ var Context = class {
954
1006
  */
955
1007
  deviceId;
956
1008
  userAgent;
1009
+ /** Signing material for `SIGNED_PATHS` — absent for sessions saved before 0.5.0 (sends then go unsigned). */
1010
+ ticketGuard;
957
1011
  ownId = "";
958
1012
  seq = 1e4;
959
1013
  cookieHeader() {
@@ -999,13 +1053,18 @@ var Context = class {
999
1053
  }
1000
1054
  /**
1001
1055
  * Call an IM command over HTTP. Returns the unwrapped body (`ResponseBody[cmd]`).
1002
- * NO X-Gnarly/X-Bogus/msToken signature needed — the IM server only checks cookies (tested 09/11/2026).
1056
+ * NO X-Gnarly/X-Bogus/msToken signature needed. `message/send` additionally needs the ticket-guard headers
1057
+ * (enforced 12/11/2026) — added from `ticketGuard` when present.
1003
1058
  */
1004
1059
  async im(path, cmd, body, bodyField = cmd) {
1005
1060
  const url = `${IM_API}${path}?aid=${AID_WEB}&version_code=1.0.0&app_name=tiktok_web&device_platform=web_pc`;
1006
1061
  const res = await this.fetch(url, {
1007
1062
  method: "POST",
1008
- headers: { "Content-Type": "application/x-protobuf", Accept: "application/x-protobuf" },
1063
+ headers: {
1064
+ "Content-Type": "application/x-protobuf",
1065
+ Accept: "application/x-protobuf",
1066
+ ...this.ticketGuard && SIGNED_PATHS.has(path) ? ticketGuardHeaders(this.ticketGuard, path) : {}
1067
+ },
1009
1068
  body: new Uint8Array(this.buildRequest(cmd, bodyField, body))
1010
1069
  });
1011
1070
  if (!res.ok) throw new TikTokError("HTTP_ERROR", `${path}: HTTP ${res.status}`, res.status);
@@ -1242,6 +1301,28 @@ async function loginQR(opts) {
1242
1301
  return null;
1243
1302
  }
1244
1303
  };
1304
+ const readTicketGuard = async () => {
1305
+ const read = async () => {
1306
+ const r = await tab.send("Runtime.evaluate", {
1307
+ expression: READ_TICKET_GUARD_JS,
1308
+ awaitPromise: true,
1309
+ returnByValue: true
1310
+ }).catch(() => null);
1311
+ return isTicketGuard(r?.result?.value) ? r.result.value : void 0;
1312
+ };
1313
+ for (let i = 0; i < 8 && !done; i++) {
1314
+ const g = await read();
1315
+ if (g) return g;
1316
+ await new Promise((r) => setTimeout(r, 1e3));
1317
+ }
1318
+ await tab.send("Page.navigate", { url: `${WEB}/messages` }).catch(() => void 0);
1319
+ for (let i = 0; i < 20 && !done; i++) {
1320
+ await new Promise((r) => setTimeout(r, 1e3));
1321
+ const g = await read();
1322
+ if (g) return g;
1323
+ }
1324
+ return void 0;
1325
+ };
1245
1326
  const collectCookies = async () => {
1246
1327
  for (let i = 0; i < 30 && !done; i++) {
1247
1328
  const { cookies } = await tab.send(
@@ -1255,7 +1336,8 @@ async function loginQR(opts) {
1255
1336
  )) {
1256
1337
  byName.set(c.name, { name: c.name, value: c.value });
1257
1338
  }
1258
- return finish(null, { cookies: [...byName.values()], userAgent });
1339
+ const ticketGuard = await readTicketGuard();
1340
+ return finish(null, { cookies: [...byName.values()], userAgent, ...ticketGuard ? { ticketGuard } : {} });
1259
1341
  }
1260
1342
  await new Promise((r) => setTimeout(r, 1e3));
1261
1343
  }