tca-js 0.4.0 → 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,13 +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
- | `IM_ERROR` | TikTok rejected the request |
191
- | `HTTP_ERROR` · `BAD_RESPONSE` | Network or unexpected response |
192
- | `UPLOAD_FAILED` · `DOWNLOAD_FAILED` | Media transfer failed |
193
- | `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 |
194
201
 
195
202
  ```ts
196
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>;
@@ -307,7 +328,12 @@ declare class TikTokError extends Error {
307
328
  }
308
329
  type TikTokErrorCode =
309
330
  /** Cookie is no longer logged in — a new session is required. */
310
- 'SESSION_EXPIRED' | 'HTTP_ERROR'
331
+ 'SESSION_EXPIRED'
332
+ /**
333
+ * TikTok's gateway rejects this session for the command (JSON `status_code: 7` instead of protobuf). Seen on
334
+ * `message/send` while reads keep working — log in again to get a fresh session.
335
+ */
336
+ | 'SESSION_BLOCKED' | 'HTTP_ERROR'
311
337
  /** IM returned a non-zero `status_code`. */
312
338
  | 'IM_ERROR' | 'UPLOAD_FAILED' | 'DOWNLOAD_FAILED' | 'BAD_RESPONSE'
313
339
  /** QR login failed (Chrome failed to launch, QR expired, no cookie found…). */
@@ -366,4 +392,4 @@ declare function loginQR(opts: QrLoginOptions): Promise<Credentials>;
366
392
  declare function findChrome(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
367
393
 
368
394
  export { API, Listener, MANUAL_CLOSE, MessageType, TikTok, TikTokError, findChrome, loginQR };
369
- 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>;
@@ -307,7 +328,12 @@ declare class TikTokError extends Error {
307
328
  }
308
329
  type TikTokErrorCode =
309
330
  /** Cookie is no longer logged in — a new session is required. */
310
- 'SESSION_EXPIRED' | 'HTTP_ERROR'
331
+ 'SESSION_EXPIRED'
332
+ /**
333
+ * TikTok's gateway rejects this session for the command (JSON `status_code: 7` instead of protobuf). Seen on
334
+ * `message/send` while reads keep working — log in again to get a fresh session.
335
+ */
336
+ | 'SESSION_BLOCKED' | 'HTTP_ERROR'
311
337
  /** IM returned a non-zero `status_code`. */
312
338
  | 'IM_ERROR' | 'UPLOAD_FAILED' | 'DOWNLOAD_FAILED' | 'BAD_RESPONSE'
313
339
  /** QR login failed (Chrome failed to launch, QR expired, no cookie found…). */
@@ -366,4 +392,4 @@ declare function loginQR(opts: QrLoginOptions): Promise<Credentials>;
366
392
  declare function findChrome(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
367
393
 
368
394
  export { API, Listener, MANUAL_CLOSE, MessageType, TikTok, TikTokError, findChrome, loginQR };
369
- 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,17 +1053,24 @@ 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);
1012
- const resp = Msg.tryParse(Buffer.from(await res.arrayBuffer()));
1071
+ const raw = Buffer.from(await res.arrayBuffer());
1072
+ if (/json/i.test(res.headers.get("content-type") ?? "")) throw gatewayError(path, raw);
1073
+ const resp = Msg.tryParse(raw);
1013
1074
  if (!resp) throw new TikTokError("BAD_RESPONSE", `${path}: response is not protobuf`);
1014
1075
  const status = resp.num(Resp.status);
1015
1076
  if (status !== 0) {
@@ -1020,6 +1081,19 @@ var Context = class {
1020
1081
  return resp.msg(Resp.body)?.msg(cmd) ?? resp.msg(Resp.body)?.msg(bodyField) ?? Msg.parse(Buffer.alloc(0));
1021
1082
  }
1022
1083
  };
1084
+ function gatewayError(path, raw) {
1085
+ let status;
1086
+ try {
1087
+ status = JSON.parse(raw.toString("utf8")).status_code;
1088
+ } catch {
1089
+ }
1090
+ if (status === 7) return new TikTokError("SESSION_BLOCKED", `${path}: gateway status_code 7 \u2014 session blocked`, 7);
1091
+ return new TikTokError(
1092
+ "BAD_RESPONSE",
1093
+ `${path}: gateway JSON status_code ${String(status)}`,
1094
+ typeof status === "number" ? status : void 0
1095
+ );
1096
+ }
1023
1097
 
1024
1098
  // src/client.ts
1025
1099
  var TikTok = class {
@@ -1227,6 +1301,28 @@ async function loginQR(opts) {
1227
1301
  return null;
1228
1302
  }
1229
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
+ };
1230
1326
  const collectCookies = async () => {
1231
1327
  for (let i = 0; i < 30 && !done; i++) {
1232
1328
  const { cookies } = await tab.send(
@@ -1240,7 +1336,8 @@ async function loginQR(opts) {
1240
1336
  )) {
1241
1337
  byName.set(c.name, { name: c.name, value: c.value });
1242
1338
  }
1243
- return finish(null, { cookies: [...byName.values()], userAgent });
1339
+ const ticketGuard = await readTicketGuard();
1340
+ return finish(null, { cookies: [...byName.values()], userAgent, ...ticketGuard ? { ticketGuard } : {} });
1244
1341
  }
1245
1342
  await new Promise((r) => setTimeout(r, 1e3));
1246
1343
  }