@volter/twin-tiktok 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +310 -0
  3. package/client/tiktok-consent.tsx +154 -0
  4. package/client/tiktok-mirror.css +137 -0
  5. package/client/tiktok-mirror.tsx +492 -0
  6. package/dist/client/tiktok-consent.bundle.js +18 -0
  7. package/dist/client/tiktok-consent.d.ts +47 -0
  8. package/dist/client/tiktok-consent.js +20 -0
  9. package/dist/client/tiktok-consent.tsx +154 -0
  10. package/dist/client/tiktok-mirror.bundle.js +487 -0
  11. package/dist/client/tiktok-mirror.css +137 -0
  12. package/dist/client/tiktok-mirror.d.ts +42 -0
  13. package/dist/client/tiktok-mirror.js +315 -0
  14. package/dist/client/tiktok-mirror.tsx +492 -0
  15. package/dist/src/cli.d.ts +2 -0
  16. package/dist/src/cli.js +44 -0
  17. package/dist/src/index.d.ts +22 -0
  18. package/dist/src/index.js +167 -0
  19. package/dist/src/tiktok-blobs.d.ts +66 -0
  20. package/dist/src/tiktok-blobs.js +161 -0
  21. package/dist/src/tiktok-budget.d.ts +56 -0
  22. package/dist/src/tiktok-budget.js +136 -0
  23. package/dist/src/tiktok-capabilities.d.ts +7 -0
  24. package/dist/src/tiktok-capabilities.js +1855 -0
  25. package/dist/src/tiktok-conformance.d.ts +11 -0
  26. package/dist/src/tiktok-conformance.js +498 -0
  27. package/dist/src/tiktok-connector.d.ts +158 -0
  28. package/dist/src/tiktok-connector.js +600 -0
  29. package/dist/src/tiktok-consent-ui.d.ts +19 -0
  30. package/dist/src/tiktok-consent-ui.js +127 -0
  31. package/dist/src/tiktok-errors.d.ts +78 -0
  32. package/dist/src/tiktok-errors.js +175 -0
  33. package/dist/src/tiktok-ids.d.ts +16 -0
  34. package/dist/src/tiktok-ids.js +48 -0
  35. package/dist/src/tiktok-media.d.ts +7 -0
  36. package/dist/src/tiktok-media.js +86 -0
  37. package/dist/src/tiktok-mirror-ui.d.ts +49 -0
  38. package/dist/src/tiktok-mirror-ui.js +159 -0
  39. package/dist/src/tiktok-pkce.d.ts +25 -0
  40. package/dist/src/tiktok-pkce.js +56 -0
  41. package/dist/src/tiktok-posting.d.ts +100 -0
  42. package/dist/src/tiktok-posting.js +599 -0
  43. package/dist/src/tiktok-sample-mp4.d.ts +10 -0
  44. package/dist/src/tiktok-sample-mp4.js +55 -0
  45. package/dist/src/tiktok-scopes.d.ts +29 -0
  46. package/dist/src/tiktok-scopes.js +106 -0
  47. package/dist/src/tiktok-server.d.ts +28 -0
  48. package/dist/src/tiktok-server.js +89 -0
  49. package/dist/src/tiktok-store.d.ts +164 -0
  50. package/dist/src/tiktok-store.js +451 -0
  51. package/dist/src/tiktok-twin.d.ts +70 -0
  52. package/dist/src/tiktok-twin.js +1197 -0
  53. package/dist/src/tiktok-user.d.ts +28 -0
  54. package/dist/src/tiktok-user.js +174 -0
  55. package/package.json +74 -0
  56. package/src/cli.ts +43 -0
  57. package/src/index.ts +270 -0
  58. package/src/tiktok-blobs.ts +217 -0
  59. package/src/tiktok-budget.ts +163 -0
  60. package/src/tiktok-capabilities.ts +2022 -0
  61. package/src/tiktok-conformance.ts +526 -0
  62. package/src/tiktok-connector.ts +637 -0
  63. package/src/tiktok-consent-ui.ts +146 -0
  64. package/src/tiktok-errors.ts +197 -0
  65. package/src/tiktok-ids.ts +51 -0
  66. package/src/tiktok-journey.uitest.ts +305 -0
  67. package/src/tiktok-media.ts +89 -0
  68. package/src/tiktok-mirror-ui.ts +167 -0
  69. package/src/tiktok-pkce.ts +61 -0
  70. package/src/tiktok-posting.ts +617 -0
  71. package/src/tiktok-sample-mp4.ts +54 -0
  72. package/src/tiktok-scopes.ts +122 -0
  73. package/src/tiktok-server.ts +100 -0
  74. package/src/tiktok-store.ts +543 -0
  75. package/src/tiktok-twin.ts +1361 -0
  76. package/src/tiktok-user.ts +137 -0
@@ -0,0 +1,1855 @@
1
+ // TikTok capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored top-down
2
+ // from the vendor's own published references, all fetched 2026-09-13 from developers.tiktok.com:
3
+ // • Login Kit for Web (the authorize URL, its six query parameters, the five callback
4
+ // parameters) and Login Kit for Desktop (PKCE: hex-encoded SHA-256, S256 only, the 43-128
5
+ // character verifier alphabet);
6
+ // • User Access Token Management (the authorization_code and refresh_token grants and revoke —
7
+ // request tables, response field tables, curl examples and JSON examples) and Client Access
8
+ // Token Management (the client_credentials grant);
9
+ // • the Scopes Overview (the scope catalog);
10
+ // • Get User Info (the field x scope table) and the Video Object / Video List / Video Query
11
+ // references (the video field table, max_count, the 20-id query limit, cursor semantics);
12
+ // • the OAuth error-handling reference (ten flat `error` values with their descriptions) and the
13
+ // API v2 error-handling reference (seven nested `error.code` values WITH HTTP statuses);
14
+ // • the rate-limit reference (a one-minute sliding window, 600 per endpoint, 429 +
15
+ // rate_limit_exceeded).
16
+ // NOT from what this twin has built. `verify()` (required to count as done) is ground truth; every
17
+ // `expected:'done'` is genuinely claimed, so a broken one shows as a regression.
18
+ //
19
+ // GRANULARITY: one capability per PROTOCOL BEHAVIOUR (a parameter honoured, an error produced, a
20
+ // field selected) — the googleoauth/xidentity precedent. This surface is six endpoints with dozens
21
+ // of behaviours, so per-endpoint entries would have been a denominator of six and a lie.
22
+ //
23
+ // AND THE ASYMMETRY THAT BUYS, STATED PLAINLY RATHER THAN HIDDEN: the six endpoints this pack
24
+ // SERVES are enumerated at that behaviour granularity, while the vendor products it does not serve
25
+ // yet (Content Posting, Research, Data Portability, Local Services, the Business API, the mobile /
26
+ // QR login variants) are enumerated at OPERATION-FAMILY granularity — one todo per endpoint family
27
+ // rather than per behaviour. That makes the headline percentage flattering: modelling Content
28
+ // Posting would add one `done` per behaviour where one `todo` sits today. The right correction is
29
+ // to expand those rows as each product is actually studied from its own reference, never to pad
30
+ // them now with rows nobody has read the docs for — an invented denominator is the mirror image of
31
+ // an invented numerator. Read the percentage as "the Login Kit + Display half is deep; the rest of
32
+ // the open platform is a list of doors".
33
+ //
34
+ // SCOPE OF THE DENOMINATOR. This is the LOGIN KIT + DISPLAY API service-area of the TikTok vendor
35
+ // pack — the half a user access token reaches, which is the half the motivating application (Dub)
36
+ // calls. The vendor's other open-platform products (Content Posting, Research, Data Portability,
37
+ // Commercial Content, and the mobile/QR login variants) ARE in this denominator as honest todos,
38
+ // one per operation family, because they are real TikTok surface a twin could serve. The separate
39
+ // business-api.tiktok.com Ads/Business API is named as a todo area too rather than silently
40
+ // dropped.
41
+ //
42
+ // EVIDENCE BOUNDARIES: a `done` whose behaviour is derived from RFC 6749/7009 or from a
43
+ // widely-reported capture rather than from a fetched official artefact says so in its title or its
44
+ // comment, and a sibling `todo` pins the live capture. PIN-todos are not padding: each names a
45
+ // behaviour the twin serves whose vendor truth is unverified, and closing one is real work against
46
+ // the live vendor. Coverage % only gets WORSE from carrying them.
47
+ //
48
+ // TWIN-ONLY ROUTES ARE NOT COUNTED. `/_twin/consent`, `/_twin/clients`, `/_twin/accounts`,
49
+ // `/_twin/videos`, `/_twin/session` and `/_twin/rate_limit` are scaffolding (TikTok's consent sheet
50
+ // posts to an undocumented internal endpoint; no API creates a TikTok app, a user or a published
51
+ // video), so they are absent from this denominator exactly as ADDING_A_TWIN.md §6 requires.
52
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
53
+ import { tmpdir } from 'node:os';
54
+ import { join } from 'node:path';
55
+ import { createElement } from 'react';
56
+ import { renderToStaticMarkup } from 'react-dom/server';
57
+ import { checkCapabilities, uiDataCoupled, verifyBoundary } from '@volter/world-tooling';
58
+ import { ConsentPage, ErrorPage } from "../client/tiktok-consent.js";
59
+ import { ActionRail, PlayerCaption, ProfileHeader, VideoGrid } from "../client/tiktok-mirror.js";
60
+ import { buildTiktokMirrorClient, createTiktokMirrorServer, mediaSource, PROFILE_FIELDS as MIRROR_PROFILE_FIELDS, tiktokMirrorHtml, tiktokMirrorStyles, VIDEO_READ_FIELDS as MIRROR_VIDEO_FIELDS, } from "./tiktok-mirror-ui.js";
61
+ import { tiktokConsentState } from "./tiktok-consent-ui.js";
62
+ import { checkTikTokConformance, paddedMp4, TINY_MP4 } from "./tiktok-conformance.js";
63
+ import { TikTokBudget, TikTokBudgetError, TIKTOK_BUDGET_CEILING, TIKTOK_CALL_WEIGHTS, tiktokCallWeight } from "./tiktok-budget.js";
64
+ import { deployableEntries } from '@volter/world-core';
65
+ import { liveTikTokExecute, mapUserInfoAccount, mapVideo, pullTikTok, performTikTokAction, pushPendingTikTokActions, syncTikTokFromReal, syncTikTokFromRemote, } from "./tiktok-connector.js";
66
+ import { rfc7636Challenge, tiktokCodeChallenge } from "./tiktok-pkce.js";
67
+ import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_KEY, DEFAULT_CLIENT_SECRET, DEFAULT_VIDEOS, defaultRedirectUris, openIdFor, } from "./tiktok-store.js";
68
+ import { handleTikTokTwinRequest } from "./tiktok-twin.js";
69
+ import { readAll } from "./tiktok-store.js";
70
+ import { stageChunk } from "./tiktok-blobs.js";
71
+ const AT = '2026-02-01T00:00:00.000Z';
72
+ const atPlus = (seconds) => new Date(Date.parse(AT) + seconds * 1000).toISOString();
73
+ /** The origin this harness's world serves the twin at. The seeded demo app's callbacks are DERIVED
74
+ * from it (runtime contract R7: the port belongs to the caller's world, never to the twin's
75
+ * source), so the probes below name no port of their own. */
76
+ const DEMO_ORIGIN = 'http://localhost:3000';
77
+ const REDIRECT_URIS = defaultRedirectUris(DEMO_ORIGIN);
78
+ const REDIRECT = REDIRECT_URIS[0];
79
+ const ADA = DEFAULT_ACCOUNTS[0];
80
+ const GRACE = DEFAULT_ACCOUNTS[1];
81
+ const ADA_VIDEOS = DEFAULT_VIDEOS.filter((v) => v.ownerUnionId === ADA.unionId);
82
+ const SCOPE = 'user.info.basic,user.info.profile,user.info.stats,video.list';
83
+ const ADA_OPEN_ID = openIdFor(DEFAULT_CLIENT_KEY, ADA.unionId);
84
+ /** A well-formed PKCE verifier: 43+ characters from the documented unreserved alphabet. */
85
+ const VERIFIER = 'twin-verifier-0123456789-0123456789-0123456789';
86
+ const FORM_CT = { 'content-type': 'application/x-www-form-urlencoded' };
87
+ const JSON_CT = { 'content-type': 'application/json' };
88
+ async function withRoot(steps) {
89
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-cap-'));
90
+ const h = ((s) => handleTikTokTwinRequest({
91
+ method: s.m,
92
+ path: s.p,
93
+ ...(s.b === undefined ? {} : { body: s.b }),
94
+ ...(s.h ? { headers: s.h } : {}),
95
+ root,
96
+ origin: DEMO_ORIGIN,
97
+ occurredAt: s.at ?? AT,
98
+ }));
99
+ h.root = root;
100
+ try {
101
+ return await verifyBoundary('tiktok.withRoot', () => steps(h));
102
+ }
103
+ finally {
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ }
107
+ const ok = (r) => r.status >= 200 && r.status < 300;
108
+ const body = (r) => r.body;
109
+ const html = (r) => String(r.body);
110
+ const loc = (r) => r.headers?.['location'] ?? '';
111
+ const qp = (r, key) => {
112
+ try {
113
+ return new URL(loc(r)).searchParams.get(key);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ };
119
+ const user = (r) => (body(r)?.data?.user ?? {});
120
+ const videos = (r) => (body(r)?.data?.videos ?? []);
121
+ const apiCode = (r) => body(r)?.error?.code;
122
+ const form = (params) => new URLSearchParams(params).toString();
123
+ const creds = { client_key: DEFAULT_CLIENT_KEY, client_secret: DEFAULT_CLIENT_SECRET };
124
+ /** Build an authorize URL from parameters, so a verify can vary exactly one of them.
125
+ * `undefined` REMOVES a base parameter. */
126
+ function authUrl(params = {}) {
127
+ const q = new URLSearchParams();
128
+ const base = {
129
+ client_key: DEFAULT_CLIENT_KEY,
130
+ response_type: 'code',
131
+ redirect_uri: REDIRECT,
132
+ scope: SCOPE,
133
+ state: 'cap-state',
134
+ ...params,
135
+ };
136
+ for (const [k, v] of Object.entries(base))
137
+ if (v !== undefined)
138
+ q.set(k, v);
139
+ return `/v2/auth/authorize?${q.toString()}`;
140
+ }
141
+ const AUTH_REQUEST_RE = /name="auth_request" value="([^"]+)"/;
142
+ /** Drive the REAL browser legs: authorize request -> authorization page -> Continue/Cancel -> the
143
+ * 302. Everything a verify needs from the round trip comes back. */
144
+ async function consentFlow(h, opts = {}) {
145
+ const auth = await h({ m: 'GET', p: authUrl(opts.params ?? {}), ...(opts.at ? { at: opts.at } : {}) });
146
+ const requestId = AUTH_REQUEST_RE.exec(html(auth))?.[1] ?? '';
147
+ const requested = (opts.params?.['scope'] ?? SCOPE).split(',');
148
+ const decision = new URLSearchParams({ auth_request: requestId, decision: opts.decision ?? 'allow' });
149
+ for (const s of opts.grant ?? requested)
150
+ decision.append('scope', s);
151
+ const redirect = await h({ m: 'POST', p: '/_twin/consent', b: decision.toString(), ...(opts.at ? { at: opts.at } : {}) });
152
+ return { auth, requestId, redirect, code: qp(redirect, 'code'), state: qp(redirect, 'state'), scopes: qp(redirect, 'scopes') };
153
+ }
154
+ /** The full round trip, ending in redeemed tokens. */
155
+ async function fullFlow(h, opts = {}) {
156
+ const flow = await consentFlow(h, opts);
157
+ if (!flow.code)
158
+ return { code: null, tokens: {}, status: 0, redirect: flow.redirect };
159
+ const res = await h({
160
+ m: 'POST',
161
+ p: '/v2/oauth/token/',
162
+ b: form({
163
+ client_key: opts.clientKey ?? DEFAULT_CLIENT_KEY,
164
+ client_secret: opts.clientSecret ?? DEFAULT_CLIENT_SECRET,
165
+ code: flow.code,
166
+ grant_type: 'authorization_code',
167
+ redirect_uri: opts.redirectUri ?? REDIRECT,
168
+ ...(opts.verifier ? { code_verifier: opts.verifier } : {}),
169
+ }),
170
+ h: { ...FORM_CT, ...(opts.headers ?? {}) },
171
+ ...(opts.tokenAt ?? opts.at ? { at: opts.tokenAt ?? opts.at } : {}),
172
+ });
173
+ return { code: flow.code, tokens: body(res), status: res.status, redirect: flow.redirect };
174
+ }
175
+ /** A live user access token for the seeded persona, with every scope granted. */
176
+ async function accessToken(h, opts = {}) {
177
+ const { tokens } = await fullFlow(h, opts);
178
+ return String(tokens['access_token'] ?? '');
179
+ }
180
+ const bearer = (token) => ({ authorization: `Bearer ${token}` });
181
+ // ── shorthands ──
182
+ const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
183
+ const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
184
+ // ── the Content Posting harness: a creator and tokens seeded through the twin's own doors, and the
185
+ // vendor's calls driven through the one handler, bytes and all ──
186
+ const POSTING_ORIGIN = 'http://twin.local:4000';
187
+ const POSTER = '11111111-1111-4111-8111-111111111111';
188
+ /** Under TikTok's 5 MB floor: one whole chunk — a real MP4 (1 s, 360x640, 24 FPS) the twin's checks pass. */
189
+ const SMALL = TINY_MP4;
190
+ /** A real MP4 of exactly `n` bytes (TINY_MP4 plus a `free` padding box). */
191
+ const pattern = (n) => paddedMp4(n);
192
+ const sameBytes = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
193
+ async function bytesOf(value) {
194
+ if (value instanceof Uint8Array)
195
+ return value;
196
+ if (value instanceof ReadableStream)
197
+ return new Uint8Array(await new Response(value).arrayBuffer());
198
+ return new Uint8Array(0);
199
+ }
200
+ /** status/fetch, read the way a careful client must: the int64 post id off the raw text. */
201
+ function parseStatus(r) {
202
+ const text = typeof r.body === 'string' ? r.body : JSON.stringify(r.body);
203
+ let parsed = {};
204
+ try {
205
+ parsed = JSON.parse(text);
206
+ }
207
+ catch { /* checked by the caller */ }
208
+ const id = /"publicaly_available_post_id":\[(\d+)/.exec(text)?.[1];
209
+ return { status: parsed.data?.status, uploaded: parsed.data?.uploaded_bytes, ...(id ? { postId: id } : {}), code: parsed.error?.code, ...(typeof parsed.data?.fail_reason === 'string' ? { failReason: parsed.data.fail_reason } : {}) };
210
+ }
211
+ async function withPosting(steps, rootDir) {
212
+ const root = rootDir ?? mkdtempSync(join(tmpdir(), 'tiktok-post-'));
213
+ const send = (req) => handleTikTokTwinRequest({
214
+ method: req.m,
215
+ path: req.p,
216
+ ...(req.b !== undefined ? { body: req.b } : {}),
217
+ ...(req.bytes ? { bytes: req.bytes } : {}),
218
+ ...(req.h ? { headers: req.h } : {}),
219
+ ...(req.originalHost ? { originalHost: req.originalHost } : {}),
220
+ root,
221
+ origin: POSTING_ORIGIN,
222
+ occurredAt: req.at ?? AT,
223
+ });
224
+ const seed = async (path, json) => body(await send({ m: 'POST', p: path, b: JSON.stringify(json), h: JSON_CT }));
225
+ try {
226
+ return await verifyBoundary('tiktok.withPosting', async () => {
227
+ await seed('/_twin/accounts', { union_id: POSTER, username: 'poster', display_name: 'The Poster', stitch_disabled: true, max_video_post_duration_sec: 300 });
228
+ const token = String((await seed('/_twin/tokens', { union_id: POSTER, scopes: ['user.info.basic', 'video.list', 'video.publish', 'video.upload'] })).access_token);
229
+ const readToken = String((await seed('/_twin/tokens', { union_id: POSTER, client_key: DEFAULT_CLIENT_KEY, scopes: ['user.info.basic'], access_token: 'act.readonlytoken0000' })).access_token);
230
+ const otherToken = String((await seed('/_twin/tokens', { union_id: GRACE.unionId, scopes: ['video.publish', 'video.upload'] })).access_token);
231
+ const call = (path, json, bearer, at) => send({ m: 'POST', p: path, b: JSON.stringify(json), h: { ...JSON_CT, authorization: `Bearer ${bearer ?? token}` }, ...(at ? { at } : {}) });
232
+ const init = (mode, size, chunk, count, opts = {}) => send({
233
+ m: 'POST',
234
+ p: mode === 'direct' ? '/v2/post/publish/video/init/' : '/v2/post/publish/inbox/video/init/',
235
+ b: JSON.stringify({
236
+ ...(mode === 'direct' ? { post_info: { privacy_level: opts.privacy ?? 'PUBLIC_TO_EVERYONE', ...(opts.title ? { title: opts.title } : {}) } } : {}),
237
+ source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: chunk, total_chunk_count: count },
238
+ }),
239
+ h: { ...JSON_CT, authorization: `Bearer ${token}` },
240
+ ...(opts.at ? { at: opts.at } : {}),
241
+ ...(opts.originalHost ? { originalHost: opts.originalHost } : {}),
242
+ });
243
+ const put = (url, bytes, range, at) => {
244
+ const u = new URL(url);
245
+ return send({ m: 'PUT', p: `${u.pathname}${u.search}`, bytes, h: { 'content-type': 'video/mp4', 'content-range': range }, ...(at ? { at } : {}) });
246
+ };
247
+ const media = (postId, headers, at, bearer) => send({ m: 'GET', p: `/_twin/media/video/${postId}${bearer ? `?access_token=${encodeURIComponent(bearer)}` : ''}`, h: headers, at: at ?? atPlus(600) });
248
+ const publish = async (bytes, chunk, privacy) => {
249
+ const count = Math.floor(bytes.length / chunk);
250
+ const started = await init('direct', bytes.length, chunk, count, { privacy });
251
+ const url = String(body(started).data?.upload_url);
252
+ for (let i = 0; i < count; i += 1) {
253
+ const first = i * chunk;
254
+ const last = i === count - 1 ? bytes.length - 1 : first + chunk - 1;
255
+ await put(url, bytes.subarray(first, last + 1), `bytes ${first}-${last}/${bytes.length}`);
256
+ }
257
+ const publishId = String(body(started).data?.publish_id);
258
+ const status = await call('/v2/post/publish/status/fetch/', { publish_id: publishId }, undefined, atPlus(300));
259
+ const text = String(status.body);
260
+ const postId = /"publicaly_available_post_id":\[(\d+)/.exec(text)?.[1]
261
+ ?? String(readAll(root).find((r) => r.type === 'publish' && r.id === publishId)?.postId ?? '');
262
+ return { publishId, postId };
263
+ };
264
+ return steps({ root, token, readToken, otherToken, call, init, put, media, publish });
265
+ });
266
+ }
267
+ finally {
268
+ if (!rootDir)
269
+ rmSync(root, { recursive: true, force: true });
270
+ }
271
+ }
272
+ // ── the connector's offline fake: a REAL-SHAPED TikTok reply (the vendor's own Get User Info
273
+ // example, extended with the fields the twin models), never an invented one ──
274
+ const FAKE_USER_INFO = {
275
+ data: {
276
+ user: {
277
+ open_id: '723f24d7-e717-40f8-a2b6-cb8464cd23b4',
278
+ union_id: 'c9c60f44-a68e-4f5d-84dd-ce22faeb0ba1',
279
+ avatar_url: 'https://p19-sign.tiktokcdn-us.com/tos-avt-0068-tx/b17f0e4b3a4f4a50993cf72cda8b88b8~c5_168x168.jpeg',
280
+ avatar_url_100: 'https://p19-sign.tiktokcdn-us.com/tos-avt-0068-tx/b17f0e4b3a4f4a50993cf72cda8b88b8~c5_100x100.jpeg',
281
+ avatar_large_url: 'https://p19-sign.tiktokcdn-us.com/tos-avt-0068-tx/b17f0e4b3a4f4a50993cf72cda8b88b8~c5_1080x1080.jpeg',
282
+ display_name: 'TikTok Developers',
283
+ bio_description: 'Build with TikTok',
284
+ profile_deep_link: 'https://www.tiktok.com/@tiktokdev',
285
+ is_verified: true,
286
+ username: 'tiktokdev',
287
+ follower_count: 583_423,
288
+ following_count: 2048,
289
+ likes_count: 1_405_233,
290
+ video_count: 42,
291
+ },
292
+ },
293
+ error: { code: 'ok', message: '', log_id: '20220829194722CBE87ED59D524E727021' },
294
+ };
295
+ const FAKE_VIDEO_LIST = {
296
+ data: {
297
+ videos: [
298
+ { id: '7010000000000000001', title: 'A real-shaped video', video_description: 'from the vendor example', create_time: 1_700_000_000, duration: 15, height: 1024, width: 576, like_count: 10, comment_count: 2, share_count: 1, view_count: 900 },
299
+ ],
300
+ cursor: 1_700_000_000_000,
301
+ has_more: false,
302
+ },
303
+ error: { code: 'ok', message: '', log_id: '20220829194722CBE87ED59D524E727022' },
304
+ };
305
+ const fakeExecute = async (_method, path) => {
306
+ if (path.startsWith('/v2/user/info'))
307
+ return structuredClone(FAKE_USER_INFO);
308
+ if (path.startsWith('/v2/video/list'))
309
+ return structuredClone(FAKE_VIDEO_LIST);
310
+ return {};
311
+ };
312
+ // ── the manifest ──────────────────────────────────────────────────────────────────────────────
313
+ export const TIKTOK_CAPABILITIES = [
314
+ // ═══ authorize — GET www.tiktok.com/v2/auth/authorize ═════════════════════════════════════
315
+ done('tiktok.authorize.renders_consent', 'authorize', 'A valid authorize request renders the authorization page (HTML, 200) with a settleable request handle', 'api', 'core', () => withRoot(async (h) => {
316
+ const r = await h({ m: 'GET', p: authUrl() });
317
+ return r.status === 200 && /ar_[0-9a-f]{32}/.test(html(r)) && html(r).includes('Continue') && html(r).includes('Cancel');
318
+ })),
319
+ done('tiktok.authorize.trailing_slash_tolerated', 'authorize', "The vendor's documented path carries a trailing slash (/v2/auth/authorize/) while Dub's URL builder omits it — both reach the same screen", 'api', 'core', () => withRoot(async (h) => {
320
+ const bare = await h({ m: 'GET', p: authUrl() });
321
+ const slashed = await h({ m: 'GET', p: authUrl().replace('/v2/auth/authorize?', '/v2/auth/authorize/?') });
322
+ return bare.status === 200 && slashed.status === 200 && html(slashed).includes('Continue');
323
+ })),
324
+ done('tiktok.authorize.requires_client_key', 'authorize', 'A missing client_key renders the error page (no redirect — there is no trusted callback yet); the parameter is client_key, not client_id', 'api', 'core', () => withRoot(async (h) => {
325
+ const r = await h({ m: 'GET', p: authUrl({ client_key: undefined }) });
326
+ return r.status === 400 && html(r).includes('client_key') && !loc(r);
327
+ })),
328
+ done('tiktok.authorize.unknown_client_key', 'authorize', 'An unknown client_key renders the error page naming invalid_client (401), never a redirect', 'api', 'core', () => withRoot(async (h) => {
329
+ const r = await h({ m: 'GET', p: authUrl({ client_key: 'awnotarealapp00001' }) });
330
+ return r.status === 401 && html(r).includes('invalid_client') && !loc(r);
331
+ })),
332
+ done('tiktok.authorize.requires_redirect_uri', 'authorize', 'A missing redirect_uri renders the error page (no redirect)', 'api', 'core', () => withRoot(async (h) => {
333
+ const r = await h({ m: 'GET', p: authUrl({ redirect_uri: undefined }) });
334
+ return r.status === 400 && html(r).includes('redirect_uri') && !loc(r);
335
+ })),
336
+ done('tiktok.authorize.redirect_uri_must_be_registered', 'authorize', '"It must match one of the redirect URIs you registered": a near-miss (trailing slash) is refused with the error page, the registered one passes', 'api', 'core', () => withRoot(async (h) => {
337
+ const near = await h({ m: 'GET', p: authUrl({ redirect_uri: `${REDIRECT}/` }) });
338
+ const exact = await h({ m: 'GET', p: authUrl() });
339
+ return near.status === 400 && !loc(near) && html(near).includes('redirect_uri') && exact.status === 200;
340
+ })),
341
+ done('tiktok.authorize.fragment_redirect_refused', 'authorize', 'A redirect_uri carrying a fragment is refused outright EVEN WHEN REGISTERED with one (RFC 6749 §3.1.2; a fragment never reaches the server)', 'api', 'niche', () => withRoot(async (h) => {
342
+ // The discriminating case: an unregistered fragment URI is refused by plain exact-match
343
+ // anyway, so the guard is only provable against an app REGISTERED with the fragment URI.
344
+ const fragKey = 'awfragapp000000001';
345
+ const fragUri = 'http://localhost:3000/cb#frag';
346
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_key: fragKey, client_secret: 's', name: 'Frag App', redirect_uris: [fragUri] }) });
347
+ const r = await h({ m: 'GET', p: authUrl({ client_key: fragKey, redirect_uri: fragUri }) });
348
+ return r.status === 400 && !loc(r);
349
+ })),
350
+ done('tiktok.authorize.requires_response_type_code', 'authorize', 'response_type is required and must be `code` — "This value should always be set to code"; anything else bounces unsupported_response_type to the validated callback', 'api', 'core', () => withRoot(async (h) => {
351
+ const missing = await h({ m: 'GET', p: authUrl({ response_type: undefined }) });
352
+ const wrong = await h({ m: 'GET', p: authUrl({ response_type: 'token' }) });
353
+ return missing.status === 302 && loc(missing).startsWith(REDIRECT) && qp(missing, 'error') === 'invalid_request'
354
+ && wrong.status === 302 && qp(wrong, 'error') === 'unsupported_response_type' && qp(wrong, 'state') === 'cap-state';
355
+ })),
356
+ done('tiktok.authorize.requires_scope', 'authorize', 'scope is required (the Login Kit parameter table); a request without one bounces invalid_request', 'api', 'core', () => withRoot(async (h) => {
357
+ const r = await h({ m: 'GET', p: authUrl({ scope: undefined }) });
358
+ return r.status === 302 && qp(r, 'error') === 'invalid_request' && String(qp(r, 'error_description')).includes('scope');
359
+ })),
360
+ done('tiktok.authorize.all_unknown_scopes_refused', 'authorize', "A scope string naming nothing in the vendor catalog bounces the vendor's own invalid_scope", 'api', 'common', () => withRoot(async (h) => {
361
+ const r = await h({ m: 'GET', p: authUrl({ scope: 'totally.bogus,also.bogus' }) });
362
+ return r.status === 302 && qp(r, 'error') === 'invalid_scope'
363
+ && String(qp(r, 'error_description')).includes('invalid, unknown, or malformed');
364
+ })),
365
+ done('tiktok.authorize.state_roundtrip', 'authorize', "The caller's state is echoed VERBATIM on the success redirect", 'api', 'core', () => withRoot(async (h) => {
366
+ const state = 'st~ate-With_Odd.Chars123';
367
+ const flow = await consentFlow(h, { params: { state } });
368
+ return flow.redirect.status === 302 && flow.state === state && !!flow.code;
369
+ })),
370
+ done('tiktok.authorize.state_optional', 'authorize', "state is NOT required (the parameter table marks it a CSRF convenience): omitting it still authorizes, and the callback then carries no state at all rather than an empty one", 'api', 'common', () => withRoot(async (h) => {
371
+ const flow = await consentFlow(h, { params: { state: undefined } });
372
+ return flow.redirect.status === 302 && !!flow.code && flow.state === null
373
+ && !new URL(loc(flow.redirect)).searchParams.has('state');
374
+ })),
375
+ done('tiktok.authorize.callback_params_exact', 'authorize', 'The success redirect carries EXACTLY code, scopes and state — the three the documented callback table lists, and the twin invents none', 'api', 'common', () => withRoot(async (h) => {
376
+ const flow = await consentFlow(h);
377
+ if (flow.redirect.status !== 302)
378
+ return false;
379
+ const keys = [...new URL(loc(flow.redirect)).searchParams.keys()].sort();
380
+ return keys.length === 3 && keys[0] === 'code' && keys[1] === 'scopes' && keys[2] === 'state';
381
+ })),
382
+ done('tiktok.authorize.granted_scopes_on_callback', 'authorize', 'The callback carries `scopes` — "the authorization scope(s) which the user has granted" — comma-separated, reflecting the decision and not the request', 'api', 'core', () => withRoot(async (h) => {
383
+ const flow = await consentFlow(h, { grant: ['user.info.basic', 'video.list'] });
384
+ return flow.redirect.status === 302 && flow.scopes === 'user.info.basic,video.list';
385
+ })),
386
+ done('tiktok.authorize.pkce_optional_for_web', 'authorize', 'PKCE is OPTIONAL (required for desktop apps only): an authorize request with no code_challenge renders the page and its code redeems with no code_verifier — the exact shape the motivating web app uses', 'api', 'core', () => withRoot(async (h) => {
387
+ const r = await fullFlow(h);
388
+ return r.status === 200 && typeof r.tokens['access_token'] === 'string';
389
+ })),
390
+ done('tiktok.authorize.challenge_needs_method', 'authorize', 'code_challenge and code_challenge_method travel together — either one alone bounces invalid_request', 'api', 'common', () => withRoot(async (h) => {
391
+ const noMethod = await h({ m: 'GET', p: authUrl({ code_challenge: tiktokCodeChallenge(VERIFIER) }) });
392
+ const noChallenge = await h({ m: 'GET', p: authUrl({ code_challenge_method: 'S256' }) });
393
+ return noMethod.status === 302 && qp(noMethod, 'error') === 'invalid_request'
394
+ && noChallenge.status === 302 && qp(noChallenge, 'error') === 'invalid_request';
395
+ })),
396
+ done('tiktok.authorize.challenge_method_s256_only', 'authorize', '"TikTok only supports S256 as code_challenge_method": `plain` — which RFC 7636 and the X twin both accept — is refused here', 'api', 'common', () => withRoot(async (h) => {
397
+ const plain = await h({ m: 'GET', p: authUrl({ code_challenge: 'plainvalue', code_challenge_method: 'plain' }) });
398
+ const s256 = await h({ m: 'GET', p: authUrl({ code_challenge: tiktokCodeChallenge(VERIFIER), code_challenge_method: 'S256' }) });
399
+ return plain.status === 302 && qp(plain, 'error') === 'invalid_request' && s256.status === 200;
400
+ })),
401
+ done('tiktok.authorize.disable_auto_auth_validated', 'authorize', 'disable_auto_auth is an int (0 or 1); anything else bounces invalid_request', 'api', 'niche', () => withRoot(async (h) => {
402
+ const bad = await h({ m: 'GET', p: authUrl({ disable_auto_auth: 'true' }) });
403
+ const good = await h({ m: 'GET', p: authUrl({ disable_auto_auth: '1' }) });
404
+ return bad.status === 302 && qp(bad, 'error') === 'invalid_request' && good.status === 200;
405
+ })),
406
+ done('tiktok.authorize.auto_auth_skips_page', 'authorize', '"When set to 0, skips the authorization page for valid sessions": with an existing grant covering the request, disable_auto_auth=0 bounces a code without a screen', 'api', 'common', () => withRoot(async (h) => {
407
+ const first = await consentFlow(h);
408
+ if (!first.code)
409
+ return false;
410
+ const again = await h({ m: 'GET', p: authUrl({ disable_auto_auth: '0', state: 'auto-state' }) });
411
+ return again.status === 302 && !!qp(again, 'code') && qp(again, 'state') === 'auto-state'
412
+ && qp(again, 'scopes') === SCOPE;
413
+ })),
414
+ done('tiktok.authorize.auto_auth_needs_covering_grant', 'authorize', "EXTRAPOLATION (§9 F5): the docs say disable_auto_auth=0 skips the page for \"valid sessions\" and say nothing about scope coverage. The twin reads that conservatively — it skips only for a session that ALREADY granted every requested scope, so a first authorization or a widened scope set still shows the page — because skipping on a session alone would hand an app scopes nobody consented to. tiktok.authorize.auto_auth_scope_coverage (todo) pins the live rule", 'api', 'common', () => withRoot(async (h) => {
415
+ const fresh = await h({ m: 'GET', p: authUrl({ disable_auto_auth: '0' }) });
416
+ if (fresh.status !== 200)
417
+ return false;
418
+ await consentFlow(h, { params: { scope: 'user.info.basic' }, grant: ['user.info.basic'] });
419
+ const widened = await h({ m: 'GET', p: authUrl({ disable_auto_auth: '0', scope: 'user.info.basic,video.list' }) });
420
+ return widened.status === 200 && html(widened).includes('Continue');
421
+ })),
422
+ done('tiktok.authorize.request_single_use', 'authorize', 'A settled authorization request cannot be settled twice (the browser cannot replay a decision)', 'api', 'common', () => withRoot(async (h) => {
423
+ const flow = await consentFlow(h);
424
+ const again = await h({ m: 'POST', p: '/_twin/consent', b: form({ auth_request: flow.requestId, decision: 'allow' }) });
425
+ return !!flow.code && again.status === 400 && body(again)['error'] === 'invalid_request';
426
+ })),
427
+ todo('tiktok.authorize.error_channel', 'authorize', 'PIN which authorize failures TikTok renders as a page vs bounces to the callback (the twin follows RFC 6749 §4.1.2.1 after the redirect_uri boundary, unpinned)', 'api', 'common'),
428
+ todo('tiktok.authorize.error_page_fidelity', 'authorize', "PIN tiktok.com's live authorize error page (DOM, wording, HTTP status) — the twin renders a plain machine-readable card, unpinned", 'api', 'niche'),
429
+ todo('tiktok.authorize.auto_auth_scope_coverage', 'authorize', 'PIN what disable_auto_auth=0 actually keys on — the docs say "valid sessions" and say nothing about scope coverage, so the twin additionally requires a grant covering every requested scope', 'api', 'common'),
430
+ todo('tiktok.authorize.disable_auto_auth_default', 'authorize', 'PIN the live default when disable_auto_auth is ABSENT (the twin shows the page, the conservative reading, because bypassing the human leg is what this pack exists to make visible)', 'api', 'common'),
431
+ todo('tiktok.authorize.redirect_uri_matching', 'authorize', 'PIN whether TikTok compares Redirect URIs exactly or by prefix (the twin compares exactly, the strict reading)', 'api', 'common'),
432
+ todo('tiktok.authorize.redirect_uri_scheme_rules', 'authorize', 'MODEL the documented scheme/port restrictions on registered Redirect URIs (https for web; localhost/127.0.0.1 with a port, wildcard port allowed, for desktop)', 'api', 'niche'),
433
+ todo('tiktok.authorize.challenge_method_case', 'authorize', 'PIN the live case tolerance for code_challenge_method (the twin accepts only the documented `S256`, not `s256`)', 'api', 'niche'),
434
+ todo('tiktok.authorize.state_length_limit', 'authorize', 'PIN whether TikTok caps the state parameter length (the twin imposes none)', 'api', 'niche'),
435
+ todo('tiktok.login_kit.qr_code_authorization', 'login_kit', 'Model the QR-code authorization flow (the desktop pairing surface with its own polling endpoint)', 'api', 'common'),
436
+ todo('tiktok.login_kit.silent_login', 'login_kit', 'Model Silent Login (re-authorization without a visible sheet for an already-connected user)', 'api', 'niche'),
437
+ todo('tiktok.login_kit.mobile_sdk_flows', 'login_kit', 'Model the iOS/Android Login Kit handshakes (app-to-app authorization via the TikTok app rather than a browser redirect)', 'api', 'common'),
438
+ todo('tiktok.login_kit.web_sdk_button', 'login_kit', 'Serve the Login Kit JavaScript SDK / login button asset the web quickstart embeds', 'api', 'niche'),
439
+ // ═══ consent semantics (what the person on the page actually decides) ═════════════════════
440
+ done('tiktok.consent.granular_subset', 'consent', "Consent is GRANULAR: unchecking a scope yields a token whose `scope` lacks it, and the Display API then refuses the field that scope gated", 'api', 'core', () => withRoot(async (h) => {
441
+ const { tokens, status } = await fullFlow(h, { grant: ['user.info.basic'] });
442
+ if (status !== 200 || tokens['scope'] !== 'user.info.basic')
443
+ return false;
444
+ const me = await h({ m: 'GET', p: '/v2/user/info/?fields=username', h: bearer(String(tokens['access_token'])) });
445
+ return me.status === 401 && apiCode(me) === 'scope_not_authorized';
446
+ })),
447
+ done('tiktok.consent.cannot_widen_beyond_request', 'consent', 'A hand-crafted decision post cannot grant a scope the authorize request never asked for', 'api', 'core', () => withRoot(async (h) => {
448
+ const auth = await h({ m: 'GET', p: authUrl({ scope: 'user.info.basic' }) });
449
+ const rid = AUTH_REQUEST_RE.exec(html(auth))?.[1] ?? '';
450
+ const decision = new URLSearchParams({ auth_request: rid, decision: 'allow' });
451
+ decision.append('scope', 'user.info.basic');
452
+ decision.append('scope', 'video.list');
453
+ const r = await h({ m: 'POST', p: '/_twin/consent', b: decision.toString() });
454
+ return r.status === 302 && qp(r, 'scopes') === 'user.info.basic';
455
+ })),
456
+ done('tiktok.consent.deny_access_denied', 'consent', 'Cancelling bounces error=access_denied with the vendor\'s own description and the state echoed, and NO code', 'api', 'core', () => withRoot(async (h) => {
457
+ const flow = await consentFlow(h, { decision: 'deny' });
458
+ return flow.redirect.status === 302 && qp(flow.redirect, 'error') === 'access_denied'
459
+ && String(qp(flow.redirect, 'error_description')).includes('denied the request')
460
+ && flow.state === 'cap-state' && flow.code === null;
461
+ })),
462
+ done('tiktok.consent.no_scope_is_denial', 'consent', 'Unchecking EVERY scope is a refusal, not a zero-scope grant — a token that could serve nothing is never minted', 'api', 'common', () => withRoot(async (h) => {
463
+ const flow = await consentFlow(h, { grant: [] });
464
+ return flow.redirect.status === 302 && qp(flow.redirect, 'error') === 'access_denied' && flow.code === null;
465
+ })),
466
+ done('tiktok.consent.session_account_consents', 'consent', "The consent is made BY the tiktok.com session's signed-in account — switching the session switches whose identity the flow yields", 'api', 'common', () => withRoot(async (h) => {
467
+ await h({ m: 'POST', p: '/_twin/session', b: JSON.stringify({ union_id: GRACE.unionId }) });
468
+ const token = await accessToken(h);
469
+ if (!token)
470
+ return false;
471
+ const me = await h({ m: 'GET', p: '/v2/user/info/?fields=union_id,username', h: bearer(token) });
472
+ return ok(me) && user(me)['union_id'] === GRACE.unionId && user(me)['username'] === GRACE.username;
473
+ })),
474
+ done('tiktok.consent.regrant_updates_grant', 'consent', "EXTRAPOLATION (§9 F5): no vendor artefact states whether a re-authorization REPLACES or UNIONS the app's recorded scopes, nor whether an older wider token keeps its reach. The twin replaces — the live grant is the authority and an older token is narrowed with it — because the union reading would let a person who revoked a permission on the sheet keep granting it. tiktok.consent.regrant_semantics (todo) pins the live behaviour", 'api', 'common', () => withRoot(async (h) => {
475
+ const wide = await accessToken(h);
476
+ const narrow = await fullFlow(h, { grant: ['user.info.basic'] });
477
+ if (!wide || narrow.status !== 200)
478
+ return false;
479
+ // The OLD token's scope string still says video.list, but the live grant no longer does, so
480
+ // the read is refused — the grant is the authority, not the token's frozen scope string.
481
+ const stale = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({}), h: { ...bearer(wide), ...JSON_CT } });
482
+ return stale.status === 401 && apiCode(stale) === 'scope_not_authorized';
483
+ })),
484
+ todo('tiktok.consent.regrant_semantics', 'consent', "PIN whether a re-authorization REPLACES or UNIONS the app's recorded scope set, and whether a previously issued wider token keeps its reach after a narrower re-grant (the twin replaces, and narrows the older token with it)", 'api', 'common'),
485
+ todo('tiktok.consent.optional_scope_toggles', 'consent', "PIN the live authorization sheet's per-scope affordance (which scopes are mandatory, whether they render as toggles or checkboxes) — the twin offers a checkbox per requested scope, unpinned", 'ui', 'common'),
486
+ todo('tiktok.consent.button_wording', 'consent', "PIN the live sheet's button labels (the twin renders Continue / Cancel)", 'ui', 'niche'),
487
+ todo('tiktok.consent.no_scope_granted', 'consent', 'PIN what the live endpoint does when a user grants none of the requested scopes (the twin treats it as access_denied)', 'api', 'niche'),
488
+ // ═══ token — POST open.tiktokapis.com/v2/oauth/token/ (authorization_code) ════════════════
489
+ done('tiktok.token.code_exchange', 'token', "A minted code redeems for the guide's own body: access_token, expires_in 86400, open_id, refresh_expires_in 31536000, refresh_token, scope, token_type Bearer", 'api', 'core', () => withRoot(async (h) => {
490
+ const { tokens, status } = await fullFlow(h);
491
+ return status === 200
492
+ && String(tokens['access_token']).startsWith('act.')
493
+ && tokens['expires_in'] === 86_400
494
+ && tokens['open_id'] === ADA_OPEN_ID
495
+ && tokens['refresh_expires_in'] === 31_536_000
496
+ && String(tokens['refresh_token']).startsWith('rft.')
497
+ && tokens['scope'] === SCOPE
498
+ && tokens['token_type'] === 'Bearer'
499
+ && Object.keys(tokens).length === 7;
500
+ })),
501
+ done('tiktok.token.body_fields_are_the_scheme', 'token', "The app authenticates with the client_key / client_secret FORM FIELDS — the only scheme TikTok documents. An Authorization: Basic header is IGNORED, neither required nor refused (Dub sends one, its callback route being shared with a Basic-auth provider, and the exchange must still succeed); the same header WITHOUT the body fields authenticates nothing", 'api', 'core', () => withRoot(async (h) => {
502
+ const basic = `Basic ${Buffer.from(`${DEFAULT_CLIENT_KEY}:${DEFAULT_CLIENT_SECRET}`).toString('base64')}`;
503
+ // (a) the body fields alone, with NO Authorization header at all, are enough — and the
504
+ // open_id that comes back proves the BODY's client_key is what selected the app…
505
+ const bare = await fullFlow(h, { params: { state: 'bare' } });
506
+ // (b) …the header alongside them changes nothing…
507
+ const withHeader = await fullFlow(h, { headers: { authorization: basic } });
508
+ // …and the header alone is NOT accepted as authentication: the body fields are the scheme.
509
+ const flow = await consentFlow(h);
510
+ const headerOnly = await h({
511
+ m: 'POST',
512
+ p: '/v2/oauth/token/',
513
+ b: form({ code: String(flow.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }),
514
+ h: { ...FORM_CT, authorization: basic },
515
+ });
516
+ // …and (c) the header WITHOUT the body fields is not authentication on this vendor.
517
+ return bare.status === 200 && bare.tokens['open_id'] === ADA_OPEN_ID
518
+ && withHeader.status === 200 && typeof withHeader.tokens['access_token'] === 'string'
519
+ && headerOnly.status === 400 && body(headerOnly)['error'] === 'invalid_request';
520
+ })),
521
+ done('tiktok.token.missing_client_secret', 'token', 'A request without client_secret is refused with the vendor\'s own wording: "Client secret is missed in request."', 'api', 'common', () => withRoot(async (h) => {
522
+ const flow = await consentFlow(h);
523
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ client_key: DEFAULT_CLIENT_KEY, code: String(flow.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
524
+ return r.status === 400 && body(r)['error'] === 'invalid_request'
525
+ && body(r)['error_description'] === 'Client secret is missed in request.';
526
+ })),
527
+ done('tiktok.token.missing_grant_type', 'token', 'A request without grant_type is refused before anything else is looked at', 'api', 'common', () => withRoot(async (h) => {
528
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds }), h: FORM_CT });
529
+ return r.status === 400 && body(r)['error'] === 'invalid_request' && String(body(r)['error_description']).includes('Grant type');
530
+ })),
531
+ done('tiktok.token.unknown_client_key', 'token', 'An unknown client_key fails client authentication: invalid_client, 401', 'api', 'common', () => withRoot(async (h) => {
532
+ const flow = await consentFlow(h);
533
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ client_key: 'awnotarealapp00001', client_secret: 'x', code: String(flow.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
534
+ return r.status === 401 && body(r)['error'] === 'invalid_client'
535
+ && String(body(r)['error_description']).includes('Client authentication failed');
536
+ })),
537
+ done('tiktok.token.wrong_client_secret', 'token', 'A wrong client_secret fails client authentication the same way an unknown key does (no oracle for which half was wrong)', 'api', 'common', () => withRoot(async (h) => {
538
+ const flow = await consentFlow(h);
539
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ client_key: DEFAULT_CLIENT_KEY, client_secret: 'not-the-secret', code: String(flow.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
540
+ return r.status === 401 && body(r)['error'] === 'invalid_client';
541
+ })),
542
+ done('tiktok.token.redirect_uri_must_match', 'token', 'The redirect_uri "must be the same as the redirect_uri used for requesting code" — the vendor\'s own message: "Redirect_uri is not matched with the uri when requesting code."', 'api', 'core', () => withRoot(async (h) => {
543
+ const r = await fullFlow(h, { redirectUri: REDIRECT_URIS[1] });
544
+ return r.status === 400 && r.tokens['error'] === 'invalid_request'
545
+ && r.tokens['error_description'] === 'Redirect_uri is not matched with the uri when requesting code.';
546
+ })),
547
+ done('tiktok.token.code_single_use', 'token', 'An authorization code redeems exactly ONCE; the replay is invalid_grant', 'api', 'core', () => withRoot(async (h) => {
548
+ const first = await fullFlow(h);
549
+ const again = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, code: String(first.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
550
+ return first.status === 200 && again.status === 400 && body(again)['error'] === 'invalid_grant';
551
+ })),
552
+ done('tiktok.token.code_expires', 'token', 'An authorization code expires; a redemption after its lifetime is invalid_grant while the same code redeems inside it', 'api', 'common', () => withRoot(async (h) => {
553
+ const inside = await fullFlow(h, { tokenAt: atPlus(60) });
554
+ const late = await fullFlow(h, { params: { state: 'late' }, tokenAt: atPlus(3600) });
555
+ return inside.status === 200 && late.status === 400 && late.tokens['error'] === 'invalid_grant';
556
+ })),
557
+ done('tiktok.token.code_bound_to_client', 'token', 'A code minted for one app cannot be redeemed by another (the "was issued to another client" half of invalid_grant)', 'api', 'core', () => withRoot(async (h) => {
558
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_key: 'awotherapp00000001', client_secret: 'other-secret', name: 'Other App', redirect_uris: [REDIRECT] }) });
559
+ const flow = await consentFlow(h);
560
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ client_key: 'awotherapp00000001', client_secret: 'other-secret', code: String(flow.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
561
+ return r.status === 400 && body(r)['error'] === 'invalid_grant';
562
+ })),
563
+ done('tiktok.token.pkce_hex_sha256', 'token', "TikTok's code_challenge is the HEX SHA-256 of the verifier, NOT RFC 7636's base64url: the hex challenge verifies and the base64url one does not", 'api', 'core', () => withRoot(async (h) => {
564
+ const good = await fullFlow(h, { params: { code_challenge: tiktokCodeChallenge(VERIFIER), code_challenge_method: 'S256' }, verifier: VERIFIER });
565
+ const rfc = await fullFlow(h, { params: { code_challenge: rfc7636Challenge(VERIFIER), code_challenge_method: 'S256', state: 'rfc' }, verifier: VERIFIER });
566
+ return good.status === 200 && rfc.status === 400 && rfc.tokens['error'] === 'invalid_grant';
567
+ })),
568
+ done('tiktok.token.pkce_requires_verifier_when_bound', 'token', 'A code minted WITH a challenge demands a code_verifier; a wrong verifier is invalid_grant', 'api', 'common', () => withRoot(async (h) => {
569
+ const params = { code_challenge: tiktokCodeChallenge(VERIFIER), code_challenge_method: 'S256' };
570
+ const missing = await fullFlow(h, { params });
571
+ const wrong = await fullFlow(h, { params: { ...params, state: 'wrong' }, verifier: 'twin-verifier-9999999999-9999999999-9999999999' });
572
+ return missing.status === 400 && String(missing.tokens['error_description']).includes('Code verifier')
573
+ && wrong.status === 400 && wrong.tokens['error'] === 'invalid_grant';
574
+ })),
575
+ done('tiktok.token.verifier_shape_enforced', 'token', 'A code_verifier outside the documented alphabet/length (43-128 unreserved characters) can never satisfy a challenge', 'api', 'niche', () => withRoot(async (h) => {
576
+ const short = 'tooshort';
577
+ const r = await fullFlow(h, { params: { code_challenge: tiktokCodeChallenge(short), code_challenge_method: 'S256' }, verifier: short });
578
+ return r.status === 400 && r.tokens['error'] === 'invalid_grant';
579
+ })),
580
+ done('tiktok.token.unsupported_grant_type', 'token', 'An unknown grant_type is the vendor\'s own unsupported_grant_type with its published description', 'api', 'common', () => withRoot(async (h) => {
581
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'password' }), h: FORM_CT });
582
+ return r.status === 400 && body(r)['error'] === 'unsupported_grant_type'
583
+ && String(body(r)['error_description']).includes('not supported by the authorization server');
584
+ })),
585
+ done('tiktok.token.open_id_is_per_app', 'token', "open_id is APP-SCOPED while union_id is per-human: the same account authorizing two apps yields two different open_ids and one union_id", 'api', 'core', () => withRoot(async (h) => {
586
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_key: 'awsecondapp0000001', client_secret: 'second-secret', name: 'Second App', redirect_uris: [REDIRECT] }) });
587
+ const first = await fullFlow(h);
588
+ const second = await fullFlow(h, { params: { client_key: 'awsecondapp0000001', state: 'second' }, clientKey: 'awsecondapp0000001', clientSecret: 'second-secret' });
589
+ if (first.status !== 200 || second.status !== 200)
590
+ return false;
591
+ const a = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id,union_id', h: bearer(String(first.tokens['access_token'])) });
592
+ const b = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id,union_id', h: bearer(String(second.tokens['access_token'])) });
593
+ return user(a)['open_id'] !== user(b)['open_id']
594
+ && user(a)['union_id'] === user(b)['union_id']
595
+ && user(a)['union_id'] === ADA.unionId;
596
+ })),
597
+ done('tiktok.token.open_id_is_a_valid_uuid', 'token', "open_id and union_id are valid version-4 UUIDs, not merely UUID-shaped: an integration that validates the id it stores (a Zod .uuid(), a Postgres uuid column) must accept the twin's exactly as it accepts the vendor's documented example", 'api', 'core', () => withRoot(async (h) => {
598
+ // §9 F4: the mint used to slice a hash into 8-4-4-4-12 without setting the version or variant
599
+ // nibbles, so every open_id this twin issued FAILED uuid validation while TikTok's own example
600
+ // (723f24d7-e717-40f8-a2b6-cb8464cd23b4) passes. The regex below is the one such a validator
601
+ // applies, and the vendor's example is asserted against the SAME regex so the bar cannot be
602
+ // quietly lowered to whatever the twin happens to emit.
603
+ const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
604
+ if (!V4.test('723f24d7-e717-40f8-a2b6-cb8464cd23b4'))
605
+ return false;
606
+ const token = await accessToken(h);
607
+ const me = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id,union_id', h: bearer(token) });
608
+ const u = user(me);
609
+ return ok(me) && V4.test(String(u['open_id'])) && V4.test(String(u['union_id']))
610
+ && u['open_id'] !== u['union_id'];
611
+ })),
612
+ done('tiktok.token.open_id_is_stable', 'token', 'The same (app, account) pair gets the SAME open_id on every re-authorization — an app that keys its user records on open_id must not see a new user each login', 'api', 'core', () => withRoot(async (h) => {
613
+ const first = await fullFlow(h);
614
+ const second = await fullFlow(h, { params: { state: 'again' } });
615
+ return first.status === 200 && second.status === 200
616
+ && first.tokens['open_id'] === second.tokens['open_id']
617
+ && first.tokens['open_id'] === ADA_OPEN_ID;
618
+ })),
619
+ done('tiktok.token.code_needs_url_decoding', 'token', 'The code is "URL decoded" at the token endpoint because it carries characters a query string percent-encodes — the raw callback text does NOT redeem, the decoded one does', 'api', 'common', () => withRoot(async (h) => {
620
+ const flow = await consentFlow(h);
621
+ const raw = new URL(loc(flow.redirect)).search.match(/[?&]code=([^&]*)/)?.[1] ?? '';
622
+ // The wire form really IS encoded, and decoding it really does produce the stored code.
623
+ if (!raw.includes('%') || raw === flow.code || decodeURIComponent(raw) !== flow.code)
624
+ return false;
625
+ const undecoded = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, code: raw, grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
626
+ const decoded = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, code: String(flow.code), grant_type: 'authorization_code', redirect_uri: REDIRECT }), h: FORM_CT });
627
+ return undecoded.status === 400 && body(undecoded)['error'] === 'invalid_grant' && decoded.status === 200;
628
+ })),
629
+ todo('tiktok.token.error_http_status', 'token', "PIN the HTTP status TikTok pairs with each OAuth `error` value — its reference publishes the codes and descriptions with NO status column, so the twin answers RFC 6749 §5.2's 400/401", 'api', 'common'),
630
+ todo('tiktok.token.error_wording', 'token', 'PIN the live error_description strings for the cases the docs never exemplify (missing client_key, missing code, missing grant_type)', 'api', 'niche'),
631
+ todo('tiktok.token.code_format', 'token', 'PIN the live authorization-code format (the twin mints the widely-reported `…*!1!` shape that makes the documented URL-decoding requirement observable)', 'api', 'niche'),
632
+ todo('tiktok.token.code_ttl', 'token', 'PIN the live authorization-code lifetime (the twin uses ten minutes, the RFC 6749 §4.1.2 recommendation)', 'api', 'niche'),
633
+ todo('tiktok.token.json_body_tolerance', 'token', 'PIN whether the live token endpoint tolerates a JSON body (the twin accepts only the documented application/x-www-form-urlencoded)', 'api', 'niche'),
634
+ todo('tiktok.token.basic_auth', 'token', 'PIN whether the live token endpoint ACCEPTS HTTP Basic client authentication as an alternative to the body fields (the twin ignores the header)', 'api', 'common'),
635
+ todo('tiktok.token.scope_narrowing_at_token', 'token', 'MODEL a `scope` parameter at the token endpoint if TikTok honours one (down-scoping an exchange)', 'api', 'niche'),
636
+ // ═══ refresh ══════════════════════════════════════════════════════════════════════════════
637
+ done('tiktok.refresh.rotates', 'refresh', '"The returned refresh_token may be different than the one passed in the payload": the twin always rotates, so a client that fails to persist the new one breaks HERE', 'api', 'core', () => withRoot(async (h) => {
638
+ const { tokens } = await fullFlow(h);
639
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']) }), h: FORM_CT });
640
+ return r.status === 200 && String(body(r)['refresh_token']).startsWith('rft.')
641
+ && body(r)['refresh_token'] !== tokens['refresh_token']
642
+ && String(body(r)['access_token']).startsWith('act.')
643
+ && body(r)['access_token'] !== tokens['access_token']
644
+ && body(r)['token_type'] === 'Bearer';
645
+ })),
646
+ done('tiktok.refresh.spent_token_refused', 'refresh', 'A rotated-away refresh token is dead: presenting it again is invalid_grant', 'api', 'core', () => withRoot(async (h) => {
647
+ const { tokens } = await fullFlow(h);
648
+ const spend = form({ ...creds, grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']) });
649
+ const first = await h({ m: 'POST', p: '/v2/oauth/token/', b: spend, h: FORM_CT });
650
+ const second = await h({ m: 'POST', p: '/v2/oauth/token/', b: spend, h: FORM_CT });
651
+ return first.status === 200 && second.status === 400 && body(second)['error'] === 'invalid_grant';
652
+ })),
653
+ done('tiktok.refresh.requires_refresh_token', 'refresh', 'A refresh_token grant with no refresh_token is refused', 'api', 'common', () => withRoot(async (h) => {
654
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'refresh_token' }), h: FORM_CT });
655
+ return r.status === 400 && body(r)['error'] === 'invalid_request' && String(body(r)['error_description']).includes('Refresh token');
656
+ })),
657
+ done('tiktok.refresh.preserves_scope_and_open_id', 'refresh', 'A refreshed token carries the SAME granted scope set and the same open_id — a refresh is not a re-authorization', 'api', 'common', () => withRoot(async (h) => {
658
+ const { tokens } = await fullFlow(h, { grant: ['user.info.basic', 'video.list'] });
659
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']) }), h: FORM_CT });
660
+ return r.status === 200 && body(r)['scope'] === 'user.info.basic,video.list' && body(r)['open_id'] === ADA_OPEN_ID;
661
+ })),
662
+ done('tiktok.refresh.new_token_works', 'refresh', 'The refreshed access token is usable at the Display API and the previous one is not silently kept alive by the refresh itself', 'api', 'common', () => withRoot(async (h) => {
663
+ const { tokens } = await fullFlow(h);
664
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']) }), h: FORM_CT });
665
+ const me = await h({ m: 'GET', p: '/v2/user/info/?fields=union_id', h: bearer(String(body(r)['access_token'])) });
666
+ return r.status === 200 && me.status === 200 && user(me)['union_id'] === ADA.unionId;
667
+ })),
668
+ todo('tiktok.refresh.rotation', 'refresh', 'PIN whether the live vendor ALWAYS rotates the refresh token or only sometimes ("may be different"), and whether the presented one dies', 'api', 'common'),
669
+ todo('tiktok.refresh.expiry_behaviour', 'refresh', 'PIN what a refresh past the 365-day refresh_expires_in returns, and whether the window restarts on every refresh', 'api', 'niche'),
670
+ // ═══ revoke ═══════════════════════════════════════════════════════════════════════════════
671
+ done('tiktok.revoke.empty_success_body', 'revoke', '"If the request is successful, the response struct will be empty" — 200 with an empty object, no envelope', 'api', 'core', () => withRoot(async (h) => {
672
+ const token = await accessToken(h);
673
+ const r = await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ ...creds, token }), h: FORM_CT });
674
+ return r.status === 200 && typeof r.body === 'object' && Object.keys(body(r)).length === 0;
675
+ })),
676
+ done('tiktok.revoke.kills_the_access_token', 'revoke', 'A revoked access token no longer answers the Display API (access_token_invalid, 401)', 'api', 'core', () => withRoot(async (h) => {
677
+ const token = await accessToken(h);
678
+ const before = await h({ m: 'GET', p: '/v2/user/info/?fields=union_id', h: bearer(token) });
679
+ await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ ...creds, token }), h: FORM_CT });
680
+ const after = await h({ m: 'GET', p: '/v2/user/info/?fields=union_id', h: bearer(token) });
681
+ return before.status === 200 && after.status === 401 && apiCode(after) === 'access_token_invalid';
682
+ })),
683
+ done('tiktok.revoke.kills_the_grant_pair', 'revoke', 'Revoking the ACCESS half kills the REFRESH half too — "remove this app" disconnects the whole (app, user) grant', 'api', 'core', () => withRoot(async (h) => {
684
+ const { tokens } = await fullFlow(h);
685
+ await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ ...creds, token: String(tokens['access_token']) }), h: FORM_CT });
686
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']) }), h: FORM_CT });
687
+ return r.status === 400 && body(r)['error'] === 'invalid_grant';
688
+ })),
689
+ done('tiktok.revoke.unknown_token_succeeds', 'revoke', 'An unknown token answers the same empty success (RFC 7009 §2.2: an app must not be able to probe which tokens exist)', 'api', 'common', () => withRoot(async (h) => {
690
+ const r = await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ ...creds, token: 'act.not-a-real-token' }), h: FORM_CT });
691
+ return r.status === 200 && Object.keys(body(r)).length === 0;
692
+ })),
693
+ done('tiktok.revoke.cross_client_no_effect', 'revoke', "Another app's revoke call cannot kill this app's token: the call answers empty success and the token still works", 'api', 'common', () => withRoot(async (h) => {
694
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_key: 'awthirdapp00000001', client_secret: 'third-secret', name: 'Third App', redirect_uris: [REDIRECT] }) });
695
+ const token = await accessToken(h);
696
+ const r = await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ client_key: 'awthirdapp00000001', client_secret: 'third-secret', token }), h: FORM_CT });
697
+ const still = await h({ m: 'GET', p: '/v2/user/info/?fields=union_id', h: bearer(token) });
698
+ return r.status === 200 && still.status === 200 && user(still)['union_id'] === ADA.unionId;
699
+ })),
700
+ done('tiktok.revoke.requires_token', 'revoke', 'A revoke without the token field is the vendor\'s own "The request parameters are malformed."', 'api', 'common', () => withRoot(async (h) => {
701
+ const r = await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ ...creds }), h: FORM_CT });
702
+ return r.status === 400 && body(r)['error'] === 'invalid_request'
703
+ && body(r)['error_description'] === 'The request parameters are malformed.';
704
+ })),
705
+ done('tiktok.revoke.requires_client_auth', 'revoke', 'Revoke authenticates the app exactly as the token endpoint does — a wrong secret is invalid_client, 401', 'api', 'common', () => withRoot(async (h) => {
706
+ const token = await accessToken(h);
707
+ const r = await h({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ client_key: DEFAULT_CLIENT_KEY, client_secret: 'nope', token }), h: FORM_CT });
708
+ return r.status === 401 && body(r)['error'] === 'invalid_client';
709
+ })),
710
+ todo('tiktok.revoke.unknown_token', 'revoke', 'PIN the live answer for an unknown token (the twin follows RFC 7009 §2.2 and answers empty success)', 'api', 'niche'),
711
+ todo('tiktok.revoke.cross_client', 'revoke', "PIN the live answer when an app revokes another app's token (the twin answers empty success without revoking)", 'api', 'niche'),
712
+ todo('tiktok.revoke.pair_revocation', 'revoke', 'PIN whether the live revoke kills the whole (app, user) grant or only the named token', 'api', 'common'),
713
+ todo('tiktok.revoke.refresh_token_hint', 'revoke', 'MODEL revoking by REFRESH token if TikTok accepts one (the documented parameter names the access_token)', 'api', 'niche'),
714
+ // ═══ client_credentials — the app-only client token ═══════════════════════════════════════
715
+ done('tiktok.client_credentials.issues_client_token', 'client_credentials', "The app-only grant returns exactly three fields — a `clt.` access_token, expires_in 7200 and token_type Bearer — with no open_id, no scope and no refresh_token", 'api', 'common', () => withRoot(async (h) => {
716
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'client_credentials' }), h: FORM_CT });
717
+ return r.status === 200 && String(body(r)['access_token']).startsWith('clt.')
718
+ && body(r)['expires_in'] === 7200 && body(r)['token_type'] === 'Bearer'
719
+ && Object.keys(body(r)).length === 3;
720
+ })),
721
+ done('tiktok.client_credentials.requires_secret', 'client_credentials', 'The app-only grant is refused without client_secret, with the vendor\'s own example wording', 'api', 'common', () => withRoot(async (h) => {
722
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ client_key: DEFAULT_CLIENT_KEY, grant_type: 'client_credentials' }), h: FORM_CT });
723
+ return r.status === 400 && body(r)['error_description'] === 'Client secret is missed in request.';
724
+ })),
725
+ done('tiktok.client_credentials.not_a_user_token', 'client_credentials', 'A client token is NOT a user token: it cannot read the Display API (access_token_invalid)', 'api', 'common', () => withRoot(async (h) => {
726
+ const r = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'client_credentials' }), h: FORM_CT });
727
+ const me = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(String(body(r)['access_token'])) });
728
+ return r.status === 200 && me.status === 401 && apiCode(me) === 'access_token_invalid';
729
+ })),
730
+ todo('tiktok.client_credentials.consumers', 'client_credentials', 'MODEL the endpoints a client access token actually reaches (Research API, Commercial Content Library) — the twin issues the token but serves none of them yet', 'api', 'common'),
731
+ // ═══ user_info — GET open.tiktokapis.com/v2/user/info/ ════════════════════════════════════
732
+ done('tiktok.user_info.field_selection', 'user_info', 'The `fields` parameter selects exactly what comes back — nothing more, nothing less', 'api', 'core', () => withRoot(async (h) => {
733
+ const token = await accessToken(h);
734
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id,display_name', h: bearer(token) });
735
+ const u = user(r);
736
+ return ok(r) && Object.keys(u).sort().join(',') === 'display_name,open_id'
737
+ && u['display_name'] === ADA.displayName && u['open_id'] === ADA_OPEN_ID;
738
+ })),
739
+ done('tiktok.user_info.dub_username_read', 'user_info', "The exact call the motivating application makes — GET /v2/user/info/?fields=username with a bearer token — answers data.user.username, the value Dub compares against the partner's declared handle", 'api', 'core', () => withRoot(async (h) => {
740
+ const token = await accessToken(h);
741
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=username', h: bearer(token) });
742
+ return ok(r) && user(r)['username'] === ADA.username && Object.keys(user(r)).length === 1;
743
+ })),
744
+ done('tiktok.user_info.requires_fields', 'user_info', 'The `fields` parameter is REQUIRED; omitting it is invalid_params (400)', 'api', 'core', () => withRoot(async (h) => {
745
+ const token = await accessToken(h);
746
+ const missing = await h({ m: 'GET', p: '/v2/user/info/', h: bearer(token) });
747
+ const empty = await h({ m: 'GET', p: '/v2/user/info/?fields=', h: bearer(token) });
748
+ return missing.status === 400 && apiCode(missing) === 'invalid_params'
749
+ && empty.status === 400 && apiCode(empty) === 'invalid_params';
750
+ })),
751
+ done('tiktok.user_info.invalid_field', 'user_info', 'A field outside the documented table is invalid_params, and the message names it', 'api', 'common', () => withRoot(async (h) => {
752
+ const token = await accessToken(h);
753
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id,email_address', h: bearer(token) });
754
+ return r.status === 400 && apiCode(r) === 'invalid_params' && String(body(r)['error']['message']).includes('email_address');
755
+ })),
756
+ done('tiktok.user_info.scope_gated_fields', 'user_info', 'Fields are gated per scope: username needs user.info.profile and follower_count needs user.info.stats — asking without them is scope_not_authorized (401)', 'api', 'core', () => withRoot(async (h) => {
757
+ const token = await accessToken(h, { grant: ['user.info.basic'] });
758
+ const basic = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id,avatar_url,display_name', h: bearer(token) });
759
+ const profile = await h({ m: 'GET', p: '/v2/user/info/?fields=username', h: bearer(token) });
760
+ const stats = await h({ m: 'GET', p: '/v2/user/info/?fields=follower_count', h: bearer(token) });
761
+ return basic.status === 200 && profile.status === 401 && apiCode(profile) === 'scope_not_authorized'
762
+ && stats.status === 401 && apiCode(stats) === 'scope_not_authorized'
763
+ && String(body(profile)['error']['message']).includes('user.info.profile');
764
+ })),
765
+ done('tiktok.user_info.bearer_required', 'user_info', 'A missing or non-bearer Authorization header is access_token_invalid (401)', 'api', 'core', () => withRoot(async (h) => {
766
+ const none = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id' });
767
+ const wrong = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: { authorization: 'Basic abc' } });
768
+ return none.status === 401 && apiCode(none) === 'access_token_invalid'
769
+ && wrong.status === 401 && apiCode(wrong) === 'access_token_invalid';
770
+ })),
771
+ done('tiktok.user_info.unknown_token', 'user_info', 'An unknown bearer token is access_token_invalid with the vendor\'s own message', 'api', 'core', () => withRoot(async (h) => {
772
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer('act.nope') });
773
+ return r.status === 401 && apiCode(r) === 'access_token_invalid'
774
+ && String(body(r)['error']['message']).includes('Please refresh the token and retry');
775
+ })),
776
+ done('tiktok.user_info.token_expires', 'user_info', 'An access token stops working after its documented 24 hours', 'api', 'common', () => withRoot(async (h) => {
777
+ const token = await accessToken(h);
778
+ const inside = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token), at: atPlus(86_000) });
779
+ const after = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token), at: atPlus(86_401) });
780
+ return inside.status === 200 && after.status === 401 && apiCode(after) === 'access_token_invalid';
781
+ })),
782
+ done('tiktok.user_info.ok_envelope', 'user_info', 'A SUCCESS carries the error object too: {code:"ok", message:"", log_id} beside data — the shape every v2 read returns', 'api', 'core', () => withRoot(async (h) => {
783
+ const token = await accessToken(h);
784
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
785
+ const e = body(r)['error'];
786
+ return ok(r) && e.code === 'ok' && e.message === '' && typeof e.log_id === 'string' && e.log_id.length === 34;
787
+ })),
788
+ done('tiktok.user_info.all_modeled_fields', 'user_info', 'Every field in the documented table is served with its documented type when the scopes allow it', 'api', 'common', () => withRoot(async (h) => {
789
+ const token = await accessToken(h);
790
+ const fields = 'open_id,union_id,avatar_url,avatar_url_100,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count';
791
+ const r = await h({ m: 'GET', p: `/v2/user/info/?fields=${fields}`, h: bearer(token) });
792
+ const u = user(r);
793
+ return ok(r) && Object.keys(u).length === 14
794
+ && u['union_id'] === ADA.unionId && u['is_verified'] === ADA.isVerified
795
+ && u['likes_count'] === ADA.likesCount && u['avatar_large_url'] === ADA.avatarLargeUrl
796
+ && u['profile_deep_link'] === ADA.profileDeepLink;
797
+ })),
798
+ todo('tiktok.user_info.scope_permission_missed', 'user_info', 'MODEL `scope_permission_missed` (400) — the "token lacks the scopes" case the error reference distinguishes from scope_not_authorized', 'api', 'common'),
799
+ todo('tiktok.user_info.partial_field_errors', 'user_info', 'PIN whether a partially-unauthorized field set returns the authorized fields alongside an error, or refuses the whole read (the twin refuses the whole read)', 'api', 'common'),
800
+ todo('tiktok.user_info.legacy_v1', 'user_info', 'MODEL the legacy v1 user surface (open-api.tiktok.com /oauth/userinfo/) the migration guide still documents', 'api', 'niche'),
801
+ // ═══ video — the Display API reads ════════════════════════════════════════════════════════
802
+ done('tiktok.video.list', 'video', '/v2/video/list/ returns the token owner\'s videos with the requested fields, newest first, in the documented {videos, cursor, has_more} envelope', 'api', 'core', () => withRoot(async (h) => {
803
+ const token = await accessToken(h);
804
+ const r = await h({ m: 'POST', p: '/v2/video/list/?fields=id,title,create_time', b: JSON.stringify({}), h: { ...bearer(token), ...JSON_CT } });
805
+ const vs = videos(r);
806
+ const newest = [...ADA_VIDEOS].sort((a, b) => b.createTime - a.createTime)[0];
807
+ return ok(r) && vs.length === ADA_VIDEOS.length && vs[0]['id'] === newest.id
808
+ && vs[0]['title'] === newest.title
809
+ && typeof body(r)['data']['cursor'] === 'number' && body(r)['data']['has_more'] === false;
810
+ })),
811
+ done('tiktok.video.list_only_owner_videos', 'video', "The list is the TOKEN OWNER's videos — another account's video is never in it", 'api', 'core', () => withRoot(async (h) => {
812
+ const token = await accessToken(h);
813
+ const r = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({ max_count: 20 }), h: { ...bearer(token), ...JSON_CT } });
814
+ const ids = videos(r).map((v) => v['id']);
815
+ const graceVideo = DEFAULT_VIDEOS.find((v) => v.ownerUnionId === GRACE.unionId);
816
+ return ok(r) && ids.length === ADA_VIDEOS.length && !ids.includes(graceVideo.id);
817
+ })),
818
+ done('tiktok.video.list_max_count', 'video', 'max_count defaults to 10 and is capped at 20; a larger or non-integer value is invalid_params', 'api', 'common', () => withRoot(async (h) => {
819
+ const token = await accessToken(h);
820
+ const one = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({ max_count: 1 }), h: { ...bearer(token), ...JSON_CT } });
821
+ const tooMany = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({ max_count: 21 }), h: { ...bearer(token), ...JSON_CT } });
822
+ const zero = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({ max_count: 0 }), h: { ...bearer(token), ...JSON_CT } });
823
+ return ok(one) && videos(one).length === 1 && body(one)['data']['has_more'] === true
824
+ && tooMany.status === 400 && apiCode(tooMany) === 'invalid_params'
825
+ && zero.status === 400 && apiCode(zero) === 'invalid_params';
826
+ })),
827
+ done('tiktok.video.list_cursor', 'video', 'The cursor is "a UTC Unix timestamp in milliseconds" that fetches videos posted BEFORE it — paging with the returned cursor walks the account', 'api', 'core', () => withRoot(async (h) => {
828
+ const token = await accessToken(h);
829
+ const page1 = await h({ m: 'POST', p: '/v2/video/list/?fields=id,create_time', b: JSON.stringify({ max_count: 1 }), h: { ...bearer(token), ...JSON_CT } });
830
+ const cursor = body(page1)['data']['cursor'];
831
+ const page2 = await h({ m: 'POST', p: '/v2/video/list/?fields=id,create_time', b: JSON.stringify({ max_count: 1, cursor }), h: { ...bearer(token), ...JSON_CT } });
832
+ const sorted = [...ADA_VIDEOS].sort((a, b) => b.createTime - a.createTime);
833
+ return ok(page1) && ok(page2)
834
+ && videos(page1)[0]['id'] === sorted[0].id
835
+ && videos(page2)[0]['id'] === sorted[1].id
836
+ && cursor === sorted[0].createTime * 1000
837
+ && body(page2)['data']['has_more'] === false;
838
+ })),
839
+ done('tiktok.video.requires_video_list_scope', 'video', 'Both video reads need `video.list`; a token without it is scope_not_authorized (401)', 'api', 'core', () => withRoot(async (h) => {
840
+ const token = await accessToken(h, { grant: ['user.info.basic'] });
841
+ const list = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({}), h: { ...bearer(token), ...JSON_CT } });
842
+ const query = await h({ m: 'POST', p: '/v2/video/query/?fields=id', b: JSON.stringify({ filters: { video_ids: [ADA_VIDEOS[0].id] } }), h: { ...bearer(token), ...JSON_CT } });
843
+ return list.status === 401 && apiCode(list) === 'scope_not_authorized'
844
+ && query.status === 401 && apiCode(query) === 'scope_not_authorized';
845
+ })),
846
+ done('tiktok.video.query_by_ids', 'video', '/v2/video/query/ returns the named videos in the requested fields, and its envelope has NO cursor or has_more', 'api', 'core', () => withRoot(async (h) => {
847
+ const token = await accessToken(h);
848
+ const target = ADA_VIDEOS[1];
849
+ const r = await h({ m: 'POST', p: '/v2/video/query/?fields=id,title,view_count', b: JSON.stringify({ filters: { video_ids: [target.id] } }), h: { ...bearer(token), ...JSON_CT } });
850
+ return ok(r) && videos(r).length === 1 && videos(r)[0]['id'] === target.id
851
+ && videos(r)[0]['view_count'] === target.viewCount
852
+ && Object.keys(body(r)['data']).join(',') === 'videos';
853
+ })),
854
+ done('tiktok.video.query_foreign_id_omitted', 'video', "A video id that is not the token owner's is ABSENT from the answer rather than a 404 — the read must not leak whether a foreign video exists", 'api', 'common', () => withRoot(async (h) => {
855
+ const token = await accessToken(h);
856
+ const graceVideo = DEFAULT_VIDEOS.find((v) => v.ownerUnionId === GRACE.unionId);
857
+ const r = await h({ m: 'POST', p: '/v2/video/query/?fields=id', b: JSON.stringify({ filters: { video_ids: [ADA_VIDEOS[0].id, graceVideo.id, '7999999999999999999'] } }), h: { ...bearer(token), ...JSON_CT } });
858
+ return ok(r) && videos(r).length === 1 && videos(r)[0]['id'] === ADA_VIDEOS[0].id;
859
+ })),
860
+ done('tiktok.video.query_id_limit', 'video', '"Up to 20 video IDs can be included per request" — 21 is invalid_params, and an empty or missing filter is too', 'api', 'common', () => withRoot(async (h) => {
861
+ const token = await accessToken(h);
862
+ const many = Array.from({ length: 21 }, (_, i) => `70000000000000000${String(i).padStart(2, '0')}`);
863
+ const over = await h({ m: 'POST', p: '/v2/video/query/?fields=id', b: JSON.stringify({ filters: { video_ids: many } }), h: { ...bearer(token), ...JSON_CT } });
864
+ const none = await h({ m: 'POST', p: '/v2/video/query/?fields=id', b: JSON.stringify({ filters: { video_ids: [] } }), h: { ...bearer(token), ...JSON_CT } });
865
+ const missing = await h({ m: 'POST', p: '/v2/video/query/?fields=id', b: JSON.stringify({}), h: { ...bearer(token), ...JSON_CT } });
866
+ return over.status === 400 && apiCode(over) === 'invalid_params'
867
+ && none.status === 400 && apiCode(none) === 'invalid_params'
868
+ && missing.status === 400 && apiCode(missing) === 'invalid_params';
869
+ })),
870
+ done('tiktok.video.invalid_field', 'video', 'A field outside the Video Object table is invalid_params on both video reads', 'api', 'common', () => withRoot(async (h) => {
871
+ const token = await accessToken(h);
872
+ const r = await h({ m: 'POST', p: '/v2/video/list/?fields=id,music_id', b: JSON.stringify({}), h: { ...bearer(token), ...JSON_CT } });
873
+ return r.status === 400 && apiCode(r) === 'invalid_params' && String(body(r)['error']['message']).includes('music_id');
874
+ })),
875
+ done('tiktok.video.all_modeled_fields', 'video', 'Every field in the Video Object table is served, including the derived share_url / embed_link / embed_html and the is_aigc flag', 'api', 'common', () => withRoot(async (h) => {
876
+ const token = await accessToken(h);
877
+ const fields = 'id,create_time,cover_image_url,share_url,video_description,duration,height,width,title,embed_html,embed_link,like_count,comment_count,share_count,view_count,is_aigc';
878
+ const r = await h({ m: 'POST', p: `/v2/video/query/?fields=${fields}`, b: JSON.stringify({ filters: { video_ids: [ADA_VIDEOS[0].id] } }), h: { ...bearer(token), ...JSON_CT } });
879
+ const v = videos(r)[0] ?? {};
880
+ const id = ADA_VIDEOS[0].id;
881
+ return ok(r) && Object.keys(v).length === 16
882
+ && v['share_url'] === `https://www.tiktok.com/@${ADA.username}/video/${id}`
883
+ && v['embed_link'] === `https://www.tiktok.com/embed/v2/${id}`
884
+ && String(v['embed_html']).includes(`data-video-id="${id}"`)
885
+ && v['is_aigc'] === false
886
+ && v['create_time'] === ADA_VIDEOS[0].createTime;
887
+ })),
888
+ done('tiktok.video.malformed_body', 'video', 'A body that is not a JSON object is invalid_params rather than an unhandled crash', 'api', 'niche', () => withRoot(async (h) => {
889
+ const token = await accessToken(h);
890
+ const r = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: 'not json at all', h: { ...bearer(token), ...JSON_CT } });
891
+ return r.status === 400 && apiCode(r) === 'invalid_params';
892
+ })),
893
+ todo('tiktok.video.link_shapes', 'video', 'PIN the live CDN/permalink shapes for cover_image_url, share_url, embed_link and embed_html (the twin derives vendor-shaped values from the stored row)', 'api', 'niche'),
894
+ todo('tiktok.video.cover_image_ttl', 'video', 'MODEL the documented 6-hour TTL on cover_image_url (the twin serves a stable link)', 'api', 'niche'),
895
+ todo('tiktok.video.query_foreign_id', 'video', "PIN the live answer when a queried video id is not the token owner's (the twin omits it silently)", 'api', 'niche'),
896
+ todo('tiktok.video.is_aigc_source', 'video', 'PIN where is_aigc comes from and whether it appears for videos posted before the flag existed', 'api', 'niche'),
897
+ // ═══ scopes catalog ═══════════════════════════════════════════════════════════════════════
898
+ done('tiktok.scopes.catalog_matches_vendor_list', 'scopes', "The scope ID SET bijects with the vendor's published list — 21 ids across five products, and nothing else (a LITERAL list, so drift in EITHER direction goes red). The three labels the Scopes Overview publishes as scope text are asserted VERBATIM; the other eighteen are twin paraphrases of that page's product prose and are asserted only to be present and non-empty, with tiktok.scopes.consent_wording pinning the live strings", 'api', 'core', async () => {
899
+ // §9 F3: this used to be titled "…is_vendor_verbatim" while checking 21 ids and 2 labels, with
900
+ // 18 of the 21 labels being paraphrases — the title claimed more than the assertion or the data
901
+ // could carry. The ID set really IS the vendor's, verbatim, and that is what this asserts; the
902
+ // label claim is now scoped to the three the vendor actually publishes as scope text.
903
+ const PUBLISHED = [
904
+ 'user.info.basic', 'user.info.profile', 'user.info.stats',
905
+ 'video.list', 'video.upload', 'video.publish',
906
+ 'portability.activity.ongoing', 'portability.activity.single',
907
+ 'portability.all.ongoing', 'portability.all.single',
908
+ 'portability.directmessages.ongoing', 'portability.directmessages.single',
909
+ 'portability.postsandprofile.ongoing', 'portability.postsandprofile.single',
910
+ 'research.data.basic', 'research.data.u18eu', 'research.data.vra', 'research.adlib.basic',
911
+ 'local.product.manage', 'local.shop.manage', 'local.voucher.manage',
912
+ ].sort();
913
+ // The THREE the Scopes Overview publishes as scope text, word for word.
914
+ const VERBATIM = {
915
+ 'user.info.basic': "Read a user's profile info (open id, avatar, display name...)",
916
+ 'user.info.profile': 'Read access to profile_web_link, profile_deep_link, bio_description, is_verified',
917
+ 'video.list': "Read a user's public videos on TikTok",
918
+ };
919
+ const { SCOPE_CATALOG } = await import("./tiktok-scopes.js");
920
+ const have = Object.keys(SCOPE_CATALOG).sort();
921
+ return have.join(',') === PUBLISHED.join(',')
922
+ && Object.entries(VERBATIM).every(([id, label]) => SCOPE_CATALOG[id].label === label)
923
+ && have.every((id) => (SCOPE_CATALOG[id].label ?? '').trim().length > 0);
924
+ }),
925
+ done('tiktok.scopes.comma_separated', 'scopes', 'The scope parameter is COMMA-separated on the wire, and the token response echoes it comma-joined with no spaces', 'api', 'core', () => withRoot(async (h) => {
926
+ const r = await fullFlow(h, { params: { scope: 'user.info.basic,video.list' } });
927
+ return r.status === 200 && r.tokens['scope'] === 'user.info.basic,video.list';
928
+ })),
929
+ done('tiktok.scopes.space_separated_is_not_a_list', 'scopes', "A space-separated scope string — the X/Google spelling — is ONE unknown scope here, not a list, and is refused as invalid_scope", 'api', 'common', () => withRoot(async (h) => {
930
+ const r = await h({ m: 'GET', p: authUrl({ scope: 'user.info.basic user.info.profile' }) });
931
+ return r.status === 302 && qp(r, 'error') === 'invalid_scope';
932
+ })),
933
+ done('tiktok.scopes.unknown_scope_is_not_granted', 'scopes', 'A mixed request renders, but an unknown scope can never be granted: it is absent from the callback `scopes` and from the token', 'api', 'common', () => withRoot(async (h) => {
934
+ const r = await fullFlow(h, { params: { scope: 'user.info.basic,totally.bogus' }, grant: ['user.info.basic', 'totally.bogus'] });
935
+ return r.status === 200 && r.tokens['scope'] === 'user.info.basic';
936
+ })),
937
+ todo('tiktok.scopes.consent_wording', 'scopes', "PIN the live consent sheet's per-scope strings (the twin renders the developer-facing descriptions the Scopes Overview publishes)", 'ui', 'common'),
938
+ todo('tiktok.scopes.unknown_scope_live', 'scopes', 'PIN what the live authorize endpoint does with an unknown scope alongside known ones (the twin renders it flagged rather than inventing a rejection)', 'api', 'niche'),
939
+ todo('tiktok.scopes.app_approval_state', 'scopes', "MODEL the developer-portal side: a scope the APP was never approved for is refused before the user ever sees it", 'api', 'common'),
940
+ // ═══ errors ═══════════════════════════════════════════════════════════════════════════════
941
+ done('tiktok.errors.two_envelopes', 'errors', 'The OAuth endpoints speak the FLAT {error, error_description, log_id} body and the v2 API speaks the NESTED {error:{code, message, log_id}} one — two contracts, never mixed', 'api', 'core', () => withRoot(async (h) => {
942
+ const oauth = await h({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'password' }), h: FORM_CT });
943
+ const api = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer('act.nope') });
944
+ const o = body(oauth);
945
+ const a = body(api);
946
+ return typeof o['error'] === 'string' && typeof o['error_description'] === 'string' && typeof o['log_id'] === 'string'
947
+ && typeof a['error'] === 'object' && typeof a['error']['code'] === 'string' && typeof a['error']['message'] === 'string' && typeof a['error']['log_id'] === 'string';
948
+ })),
949
+ done('tiktok.errors.log_id_shape', 'errors', "Every response carries a log_id in the vendor's own shape (14 digits of UTC yyyyMMddHHmmss plus 20 uppercase hex) and it is DETERMINISTIC, not a clock read", 'api', 'common', () => withRoot(async (h) => {
950
+ const token = await accessToken(h);
951
+ const first = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
952
+ const second = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
953
+ const id = String(body(first)['error']['log_id']);
954
+ return /^20260201000000[0-9A-F]{20}$/.test(id) && body(second)['error']['log_id'] === id;
955
+ })),
956
+ done('tiktok.errors.api_status_matches_code', 'errors', "Each nested error code carries the HTTP status the vendor's own table gives it: access_token_invalid 401, invalid_params 400, scope_not_authorized 401, rate_limit_exceeded 429", 'api', 'core', () => withRoot(async (h) => {
957
+ const token = await accessToken(h, { grant: ['user.info.basic'] });
958
+ const bad = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer('act.nope') });
959
+ const params = await h({ m: 'GET', p: '/v2/user/info/?fields=nope', h: bearer(token) });
960
+ const scope = await h({ m: 'GET', p: '/v2/user/info/?fields=username', h: bearer(token) });
961
+ await h({ m: 'POST', p: '/_twin/rate_limit', b: JSON.stringify({ endpoint: 'user_info', open_id: ADA_OPEN_ID, used: 600 }) });
962
+ const rate = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
963
+ return bad.status === 401 && params.status === 400 && scope.status === 401 && rate.status === 429
964
+ && apiCode(rate) === 'rate_limit_exceeded';
965
+ })),
966
+ done('tiktok.errors.unknown_route_refuses', 'errors', 'An unmodelled open-API route fails like the vendor rather than faking a success — a named refusal, never a 2xx', 'api', 'core', () => withRoot(async (h) => {
967
+ const post = await h({ m: 'POST', p: '/v2/post/publish/content/init/', b: '{}' });
968
+ const research = await h({ m: 'POST', p: '/v2/research/video/query/', b: '{}' });
969
+ return post.status === 400 && apiCode(post) === 'invalid_params'
970
+ && String(body(post)['error']['message']).includes('not a TikTok open API endpoint')
971
+ && research.status === 400 && apiCode(research) === 'invalid_params';
972
+ })),
973
+ done('tiktok.errors.malformed_request_contained', 'errors', "A residual TypeError on the authorize path is contained as the vendor's own flat 400 envelope, never a non-vendor 500 and never an HTML surprise", 'api', 'niche', () => withRoot(async (h) => {
974
+ // §9 F1: the previous version of this verify (a hostile percent-encoding in client_key) never
975
+ // reached the guard at all — URLSearchParams does not throw, so it took the ordinary
976
+ // unknown-client 401 branch and the capability was a false green. THIS reaches it: an app
977
+ // registered with an UNPARSEABLE callback passes the exact-match boundary, and the bounce
978
+ // then calls `new URL(redirectUri)`, which throws TypeError inside the router.
979
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_key: 'awbadcallback00001', client_secret: 's', name: 'Bad Callback App', redirect_uris: ['not-a-url'] }) });
980
+ const r = await h({ m: 'GET', p: authUrl({ client_key: 'awbadcallback00001', redirect_uri: 'not-a-url', response_type: undefined }) });
981
+ const b = body(r);
982
+ // The control: with a PARSEABLE registered callback the same request bounces normally, so a
983
+ // handler that 400'd everything could not pass this.
984
+ const control = await h({ m: 'GET', p: authUrl({ response_type: undefined }) });
985
+ return r.status === 400
986
+ && typeof r.body === 'object'
987
+ && b['error'] === 'invalid_request'
988
+ && b['error_description'] === 'The request parameters are malformed.'
989
+ && typeof b['log_id'] === 'string' && String(b['log_id']).length === 34
990
+ && control.status === 302 && qp(control, 'error') === 'invalid_request';
991
+ })),
992
+ done('tiktok.errors.oauth_codes_are_vendor_verbatim', 'errors', "The ten OAuth error codes AND all ten of their descriptions are the vendor's published list, word for word (a LITERAL map comparison, so a dropped clause goes red)", 'api', 'common', async () => {
993
+ // §9 F3: this used to spot-check two of the ten, and the gap it hid was real — `invalid_grant`
994
+ // had lost the vendor's own "(for example, authorization code or resource owner credentials)"
995
+ // parenthetical. The whole map is compared now.
996
+ const { OAUTH_ERRORS } = await import("./tiktok-errors.js");
997
+ const PUBLISHED = {
998
+ access_denied: 'The resource owner or authorization server denied the request.',
999
+ invalid_client: 'Client authentication failed (for example, unknown client, no client authentication included, or unsupported authentication method).',
1000
+ invalid_grant: 'The provided authorization grant (for example, authorization code or resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.',
1001
+ invalid_request: 'The request misses a required parameter or is otherwise malformed.',
1002
+ invalid_scope: 'The requested scope is invalid, unknown, or malformed.',
1003
+ unauthorized_client: 'The client is not authorized to request an authorization code using this method.',
1004
+ unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.',
1005
+ unsupported_response_type: 'The authorization server does not support obtaining an authorization code using this method.',
1006
+ server_error: 'Other internal server errors.',
1007
+ temporarily_unavailable: 'Service is temporarily unavailable.',
1008
+ };
1009
+ return Object.keys(OAUTH_ERRORS).sort().join(',') === Object.keys(PUBLISHED).sort().join(',')
1010
+ && Object.entries(PUBLISHED).every(([code, text]) => OAUTH_ERRORS[code] === text);
1011
+ }),
1012
+ done('tiktok.errors.api_codes_and_statuses_match_vendor_table', 'errors', "The seven v2 error codes and their documented HTTP statuses are the vendor's published table, verbatim; the four messages that table quotes are asserted word for word, and the other three are twin prose the reference only describes", 'api', 'common', async () => {
1013
+ const { API_ERRORS } = await import("./tiktok-errors.js");
1014
+ const PUBLISHED = {
1015
+ access_token_invalid: 401, internal_error: 500, invalid_file_upload: 400, invalid_params: 400,
1016
+ rate_limit_exceeded: 429, scope_not_authorized: 401, scope_permission_missed: 400,
1017
+ };
1018
+ // The four the reference quotes as messages rather than describing.
1019
+ const QUOTED = {
1020
+ access_token_invalid: 'The access token is invalid or not found in the request. Please refresh the token and retry.',
1021
+ invalid_file_upload: 'The uploaded file does not meet API specifications. Please correct the file and try again.',
1022
+ rate_limit_exceeded: 'The API rate limit was exceeded. Please try again later.',
1023
+ scope_not_authorized: 'The user did not authorize the scope required for completing this request. Please ask the user to authorize and then retry.',
1024
+ };
1025
+ return Object.keys(API_ERRORS).sort().join(',') === Object.keys(PUBLISHED).sort().join(',')
1026
+ && Object.entries(PUBLISHED).every(([code, status]) => API_ERRORS[code].status === status)
1027
+ && Object.entries(QUOTED).every(([code, text]) => API_ERRORS[code].message === text);
1028
+ }),
1029
+ todo('tiktok.errors.unknown_route_pin', 'errors', "PIN the live answer for an unrouted path on open.tiktokapis.com (the twin answers the API family's invalid_params naming the route)", 'api', 'niche'),
1030
+ todo('tiktok.errors.internal_error_pin', 'errors', 'PIN the live internal_error body and whether it carries a retry hint', 'api', 'niche'),
1031
+ // ═══ rate limiting ════════════════════════════════════════════════════════════════════════
1032
+ done('tiktok.rate.limit_exceeded_429', 'rate', 'Exceeding the documented 600-per-minute limit answers "HTTP status 429 and error code rate_limit_exceeded"', 'api', 'core', () => withRoot(async (h) => {
1033
+ const token = await accessToken(h);
1034
+ await h({ m: 'POST', p: '/_twin/rate_limit', b: JSON.stringify({ endpoint: 'user_info', open_id: ADA_OPEN_ID, used: 600 }) });
1035
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
1036
+ return r.status === 429 && apiCode(r) === 'rate_limit_exceeded'
1037
+ && String(body(r)['error']['message']).includes('rate limit was exceeded');
1038
+ })),
1039
+ done('tiktok.rate.limits_are_per_endpoint', 'rate', '"Limits for each API are set and enforced separately": exhausting /v2/user/info/ leaves /v2/video/list/ serving', 'api', 'common', () => withRoot(async (h) => {
1040
+ const token = await accessToken(h);
1041
+ await h({ m: 'POST', p: '/_twin/rate_limit', b: JSON.stringify({ endpoint: 'user_info', open_id: ADA_OPEN_ID, used: 600 }) });
1042
+ const blocked = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
1043
+ const served = await h({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({}), h: { ...bearer(token), ...JSON_CT } });
1044
+ return blocked.status === 429 && served.status === 200;
1045
+ })),
1046
+ done('tiktok.rate.sliding_window_rolls', 'rate', 'The window is one minute: a request past it is served again, with the count restarted', 'api', 'common', () => withRoot(async (h) => {
1047
+ const token = await accessToken(h);
1048
+ await h({ m: 'POST', p: '/_twin/rate_limit', b: JSON.stringify({ endpoint: 'user_info', open_id: ADA_OPEN_ID, used: 600 }) });
1049
+ const inside = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token), at: atPlus(30) });
1050
+ const after = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token), at: atPlus(61) });
1051
+ return inside.status === 429 && after.status === 200;
1052
+ })),
1053
+ done('tiktok.rate.no_headers_invented', 'rate', "TikTok's rate-limit reference publishes NO response headers, so the twin invents none — a client cannot be taught to read a header the vendor does not send", 'api', 'niche', () => withRoot(async (h) => {
1054
+ const token = await accessToken(h);
1055
+ const r = await h({ m: 'GET', p: '/v2/user/info/?fields=open_id', h: bearer(token) });
1056
+ const names = Object.keys(r.headers ?? {}).map((k) => k.toLowerCase());
1057
+ return ok(r) && !names.some((n) => n.includes('ratelimit') || n.includes('rate-limit'));
1058
+ })),
1059
+ todo('tiktok.rate.what_counts', 'rate', 'PIN what the live meter counts (refused requests? unauthorized ones?) — the twin spends only on a served success', 'api', 'common'),
1060
+ todo('tiktok.rate.limit_dimension', 'rate', 'PIN whether the published 600/minute is per client key, per user access token or per app+user — the reference states the window and the number but not the dimension (the twin meters per (endpoint, open_id))', 'api', 'common'),
1061
+ todo('tiktok.rate.oauth_endpoint_limits', 'rate', 'PIN the rate limits on the OAuth token/revoke endpoints — the reference lists only the three Display API rows', 'api', 'niche'),
1062
+ // ═══ read-only twin ═══════════════════════════════════════════════════════════════════════
1063
+ done('tiktok.readonly.writes_refused', 'readonly', 'A read-only twin refuses every credential-minting leg (405, the vendor\'s temporarily_unavailable) — D3', 'api', 'common', () => withRoot(async (h) => {
1064
+ await fullFlow(h); // seed a world first, through the ordinary write path
1065
+ const ro = (s) => handleTikTokTwinRequest({ method: s.m, path: s.p, ...(s.b !== undefined ? { body: s.b } : {}), ...(s.h ? { headers: s.h } : {}), root: h.root, origin: DEMO_ORIGIN, occurredAt: AT, readOnly: true });
1066
+ const tok = await ro({ m: 'POST', p: '/v2/oauth/token/', b: form({ ...creds, grant_type: 'authorization_code', code: 'x', redirect_uri: REDIRECT }), h: FORM_CT });
1067
+ const rev = await ro({ m: 'POST', p: '/v2/oauth/revoke/', b: form({ ...creds, token: 'x' }), h: FORM_CT });
1068
+ const auth = await ro({ m: 'GET', p: authUrl() });
1069
+ return tok.status === 405 && body(tok)['error'] === 'temporarily_unavailable' && rev.status === 405 && auth.status === 405;
1070
+ })),
1071
+ done('tiktok.readonly.reads_served', 'readonly', 'A read-only twin still serves the Display API over existing state, with rate accounting suspended', 'api', 'niche', () => withRoot(async (h) => {
1072
+ const token = await accessToken(h);
1073
+ const ro = (s) => handleTikTokTwinRequest({ method: s.m, path: s.p, ...(s.b !== undefined ? { body: s.b } : {}), ...(s.h ? { headers: s.h } : {}), root: h.root, origin: DEMO_ORIGIN, occurredAt: AT, readOnly: true });
1074
+ const me = await ro({ m: 'GET', p: '/v2/user/info/?fields=union_id', h: bearer(token) });
1075
+ const list = await ro({ m: 'POST', p: '/v2/video/list/?fields=id', b: JSON.stringify({}), h: { ...bearer(token), ...JSON_CT } });
1076
+ return me.status === 200 && me.body.data.user.union_id === ADA.unionId && list.status === 200;
1077
+ })),
1078
+ // ═══ conformance (the endpoint census with teeth) ═════════════════════════════════════════
1079
+ done('tiktok.conformance.census', 'conformance', 'The conformance check passes: one real probe per claimed endpoint, the ROUTER_SURFACE census, the live round trip, and a reachability witness per resource type', 'api', 'core', async () => {
1080
+ const report = await checkTikTokConformance();
1081
+ return report.ok && report.endpointsProbed === 11 && report.resourceTypesChecked === 12;
1082
+ }),
1083
+ // ═══ the authorization PAGE (the vendor's own UI — data-coupled, per ADDING_A_TWIN.md §6) ══
1084
+ done('tiktok.ui.authorization_page', 'ui', 'The page names the REGISTERED app and the SIGNED-IN account from the projection (data-coupled: a seeded app + persona appear on screen)', 'ui', 'core', uiDataCoupled({
1085
+ withRoot,
1086
+ // Seed a THIRD persona and a SECOND app through the twin's own write paths, then sign the new
1087
+ // persona in — the screen cannot be passing on the seeded defaults alone.
1088
+ seed: async (h) => {
1089
+ await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_key: 'awacmescheduler001', client_secret: 'acme-secret', name: 'Acme Scheduling', redirect_uris: [REDIRECT] }) });
1090
+ await h({ m: 'POST', p: '/_twin/accounts', b: JSON.stringify({ union_id: '00000000-0000-4000-8000-000000000003', username: 'katherine_twin', display_name: 'Katherine Johnson' }) });
1091
+ await h({ m: 'POST', p: '/_twin/session', b: JSON.stringify({ union_id: '00000000-0000-4000-8000-000000000003' }) });
1092
+ },
1093
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ client_key: 'awacmescheduler001' }) })),
1094
+ assert: (markup) => markup.includes('Acme Scheduling')
1095
+ && markup.includes('would like to access your TikTok account')
1096
+ && markup.includes('Katherine Johnson') && markup.includes('@katherine_twin')
1097
+ && !markup.includes('Twin Demo App') && !markup.includes('@ada_twin'),
1098
+ })),
1099
+ done('tiktok.ui.scope_checkboxes', 'ui', "Each requested scope renders as its OWN checkbox carrying the scope id as its value, with the vendor's published wording (data-coupled to the auth request)", 'ui', 'core', uiDataCoupled({
1100
+ withRoot,
1101
+ seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'user.info.basic,video.list' }) }),
1102
+ // §9 nit: read the VENDOR's own path. The twin-only `/_twin/consent` re-render door serves the
1103
+ // same component from the same state builder, so asserting there proved the screen but not the
1104
+ // ROUTE a browser is actually redirected to.
1105
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ scope: 'user.info.basic,video.list' }) })),
1106
+ assert: (markup) => (markup.match(/name="scope"/g) ?? []).length === 2
1107
+ && markup.includes('value="user.info.basic"')
1108
+ && markup.includes('value="video.list"')
1109
+ && markup.includes("Read a user&#x27;s profile info (open id, avatar, display name...)")
1110
+ && markup.includes("Read a user&#x27;s public videos on TikTok")
1111
+ && !markup.includes('value="user.info.stats"'),
1112
+ })),
1113
+ done('tiktok.ui.decision_buttons', 'ui', 'The page\'s two decisions are REAL form submits — Continue and Cancel carry decision values into the twin\'s consent post', 'ui', 'core', uiDataCoupled({
1114
+ withRoot,
1115
+ seed: (h) => h({ m: 'GET', p: authUrl() }),
1116
+ // §9 nit: the VENDOR's own path, not the twin-only re-render door.
1117
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl() })),
1118
+ // Attribute ORDER is the renderer's business (React emits value before name) — assert each
1119
+ // decision button as a whole tag, order-independent within it.
1120
+ assert: (markup) => /<button[^>]*value="allow"[^>]*>Continue<\/button>/.test(markup)
1121
+ && /<button[^>]*value="deny"[^>]*>Cancel<\/button>/.test(markup)
1122
+ && (markup.match(/name="decision"/g) ?? []).length === 2
1123
+ && markup.includes('/_twin/consent'),
1124
+ })),
1125
+ done('tiktok.ui.redirect_notice', 'ui', "The page shows WHERE the browser will be sent — the validated redirect_uri's host, from the auth request (data-coupled: the SECOND registered callback's host, so a hardcoded first/default host fails)", 'ui', 'common', uiDataCoupled({
1126
+ withRoot,
1127
+ seed: (h) => h({ m: 'GET', p: authUrl({ redirect_uri: REDIRECT_URIS[1] }) }),
1128
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ redirect_uri: REDIRECT_URIS[1] }) })),
1129
+ // The assertion is pinned to the NOTICE ELEMENT itself rather than the whole page: the twin
1130
+ // is served at the first callback's origin, so that host also appears in the consent form's
1131
+ // own action — a whole-markup `!includes` would fail for a reason unrelated to coupling.
1132
+ assert: (markup) => markup.includes('redirected to')
1133
+ && markup.includes(`class="redirect-host">${new URL(REDIRECT_URIS[1]).host}<`)
1134
+ && !markup.includes(`class="redirect-host">${new URL(REDIRECT_URIS[0]).host}<`),
1135
+ })),
1136
+ done('tiktok.ui.unknown_scope_flagged', 'ui', 'A scope outside the vendor catalog renders VISIBLY FLAGGED rather than silently or prettily (a typo\'d scope is a real integration bug)', 'ui', 'common', uiDataCoupled({
1137
+ withRoot,
1138
+ seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'user.info.basic,totally.bogus' }) }),
1139
+ fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ scope: 'user.info.basic,totally.bogus' }) })),
1140
+ assert: (markup) => markup.includes('totally.bogus') && markup.includes('not in the twin&#x27;s scope catalog'),
1141
+ })),
1142
+ done('tiktok.ui.consent_state_renders_real_component', 'ui', "The SHIPPED ConsentPage component renders the twin's OWN view model (the same state builder the served page uses) — one renderer, no lookalike", 'ui', 'core', uiDataCoupled({
1143
+ withRoot,
1144
+ seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'user.info.basic,user.info.profile' }) }),
1145
+ // The handle comes off the VENDOR path (§9 nit); the view model is then read from the state
1146
+ // builder on purpose — proving the SHIPPED component renders the twin's own projection is
1147
+ // this capability's whole claim, and `tiktok.ui.scope_checkboxes` above asserts the served
1148
+ // bytes of that same route.
1149
+ fetch: async (h) => {
1150
+ const a = await h({ m: 'GET', p: authUrl({ scope: 'user.info.basic,user.info.profile' }) });
1151
+ const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
1152
+ return tiktokConsentState({ root: h.root, requestId: rid, origin: 'https://www.tiktok.com' });
1153
+ },
1154
+ render: (view) => (view ? renderToStaticMarkup(createElement(ConsentPage, { view })) : ''),
1155
+ assert: (view, markup) => !!view && !!markup
1156
+ && view.scopes.length === 2
1157
+ && view.account.username === ADA.username
1158
+ && view.app.name === 'Twin Demo App'
1159
+ && markup.includes(`@${ADA.username}`)
1160
+ && markup.includes('Read access to profile_web_link, profile_deep_link, bio_description, is_verified'),
1161
+ })),
1162
+ done('tiktok.ui.error_page_renders_real_component', 'ui', 'The authorize endpoint SERVES the real ErrorPage component for an un-redirectable failure (driven through the handler, not a lookalike render)', 'ui', 'common', () => withRoot(async (h) => {
1163
+ const served = await h({ m: 'GET', p: authUrl({ client_key: 'awnotarealapp00001' }) });
1164
+ // The SERVED page must literally CONTAIN the real component's own render for the same
1165
+ // failure facts — a hand-rolled template carrying the same headline strings cannot pass.
1166
+ const direct = renderToStaticMarkup(createElement(ErrorPage, {
1167
+ status: 401,
1168
+ code: 'invalid_client',
1169
+ detail: 'The client_key does not name a known TikTok app.',
1170
+ requestParam: 'client_key=awnotarealapp00001',
1171
+ }));
1172
+ return served.status === 401 && html(served).includes('Something went wrong') && html(served).includes(direct);
1173
+ })),
1174
+ done('tiktok.ui.page_needs_no_javascript', 'ui', 'The whole decision is a plain form: the served page carries no script tag, so curl, a redirect-following library and a headless browser all complete the flow identically', 'ui', 'niche', () => withRoot(async (h) => {
1175
+ const page = await h({ m: 'GET', p: authUrl() });
1176
+ const markup = html(page);
1177
+ // Attribute ORDER is the renderer's business (React emits className first), so the form is
1178
+ // matched as a whole tag rather than as a literal prefix.
1179
+ return page.status === 200 && !markup.includes('<script')
1180
+ && /<form[^>]*method="POST"[^>]*>/.test(markup) && markup.includes('type="submit"')
1181
+ && markup.includes('<style>') && markup.includes('.scope-check');
1182
+ })),
1183
+ // ═══ the MIRROR (tiktok.com's profile and player over the twin's own API — every one DATA-COUPLED:
1184
+ // seed and post through the handler, read the SAME routes the screen reads, render the mirror's OWN
1185
+ // components over them) ══════════════════════════════════════════════════════════════════════
1186
+ done('tiktok.mirror.profile_header', 'ui', "The profile header renders the creator /v2/user/info/ returns — @username, nickname, the verified tick, the Following / Followers / Likes counts in tiktok.com's compact form and the bio — and draws NO count the read did not return", 'ui', 'core', () => withPosting(async (p) => {
1187
+ const seed = async (json) => p.call('/_twin/accounts', json);
1188
+ await seed({ union_id: '22222222-2222-4222-8222-222222222222', username: 'counted', display_name: 'Counted Creator', is_verified: true, follower_count: 12840, following_count: 12, likes_count: 1_450_000, bio_description: 'Release videos.' });
1189
+ await seed({ union_id: '33333333-3333-4333-8333-333333333333', username: 'bare', display_name: 'Bare Creator' });
1190
+ const tokenFor = async (id) => String(body(await p.call('/_twin/tokens', { union_id: id, scopes: ['user.info.basic', 'user.info.profile', 'user.info.stats'] })).access_token);
1191
+ const readGet = async (id) => body(await handleTikTokTwinRequest({ method: 'GET', path: `/v2/user/info/?fields=${MIRROR_PROFILE_FIELDS}`, headers: { authorization: `Bearer ${await tokenFor(id)}` }, root: p.root, occurredAt: AT })).data?.user;
1192
+ const counted = await readGet('22222222-2222-4222-8222-222222222222');
1193
+ const bare = await readGet('33333333-3333-4333-8333-333333333333');
1194
+ if (!counted || !bare)
1195
+ return false;
1196
+ const a = renderToStaticMarkup(createElement(ProfileHeader, { user: counted, own: true }));
1197
+ const b = renderToStaticMarkup(createElement(ProfileHeader, { user: bare, own: true }));
1198
+ return a.includes('>counted</h1>') && a.includes('Counted Creator') && a.includes('Verified account')
1199
+ && a.includes('<strong>12</strong><span class="stat-label">Following</span>') && a.includes('<strong>12.8K</strong><span class="stat-label">Followers</span>')
1200
+ && a.includes('<strong>1.4M</strong><span class="stat-label">Likes</span>') && a.includes('Release videos.') && a.includes('Edit profile')
1201
+ && b.includes('>bare</h1>') && !b.includes('Followers') && !b.includes('Likes') && !b.includes('Verified account');
1202
+ })),
1203
+ done('tiktok.mirror.video_grid', 'ui', "The Videos grid renders the creator's posts /v2/video/list/ returns, newest first, each tile the post's own bytes from the twin's media route (with the creator's token) and its play count", 'ui', 'core', () => withPosting(async (p) => {
1204
+ const first = await p.publish(SMALL, SMALL.length, 'PUBLIC_TO_EVERYONE');
1205
+ const second = await p.publish(pattern(3000), 3000, 'SELF_ONLY');
1206
+ const listed = await p.call(`/v2/video/list/?fields=${MIRROR_VIDEO_FIELDS}`, { max_count: 20 }, undefined, atPlus(600));
1207
+ const videos = (body(listed).data?.videos ?? []);
1208
+ const markup = renderToStaticMarkup(createElement(VideoGrid, { videos, sourceOf: (id) => mediaSource('/w', id, p.token), onOpen: () => { } }));
1209
+ const src = (id) => `src="/w/_twin/media/video/${id}?access_token=${encodeURIComponent(p.token)}#t=0.1"`;
1210
+ const played = await p.media(second.postId, {}, atPlus(600), p.token);
1211
+ return videos.map((v) => v.id).join(',') === `${second.postId},${first.postId}`
1212
+ && markup.indexOf(`data-video-id="${second.postId}"`) < markup.indexOf(`data-video-id="${first.postId}"`)
1213
+ && markup.includes(src(first.postId)) && markup.includes(src(second.postId))
1214
+ && (markup.match(/class="tile-views"/g) ?? []).length === 2
1215
+ && played.status === 200 && sameBytes(await bytesOf(played.body), pattern(3000));
1216
+ })),
1217
+ done('tiktok.mirror.vertical_player', 'ui', "The player renders a posted video's caption (its #hashtags bolded), the @handle, the sound line and the action rail with the Video Object's own counts — and no count where the API returns none", 'ui', 'core', () => withPosting(async (p) => {
1218
+ await p.init('direct', SMALL.length, SMALL.length, 1, { title: 'Launch day #release @poster' }).then(async (init) => {
1219
+ await p.put(String(body(init).data?.upload_url), SMALL, `bytes 0-${SMALL.length - 1}/${SMALL.length}`);
1220
+ });
1221
+ const video = (body(await p.call(`/v2/video/list/?fields=${MIRROR_VIDEO_FIELDS}`, { max_count: 1 }, undefined, atPlus(600))).data?.videos ?? [])[0];
1222
+ const me = body(await handleTikTokTwinRequest({ method: 'GET', path: '/v2/user/info/?fields=open_id,union_id,display_name', headers: { authorization: `Bearer ${p.token}` }, root: p.root, occurredAt: atPlus(600) })).data?.user;
1223
+ if (!video || !me)
1224
+ return false;
1225
+ const user = { ...me, username: 'poster' };
1226
+ const caption = renderToStaticMarkup(createElement(PlayerCaption, { user, video }));
1227
+ const rail = renderToStaticMarkup(createElement(ActionRail, { user, video }));
1228
+ return caption.includes('@poster') && caption.includes('<strong class="tag">#release</strong>') && caption.includes('<strong class="tag">@poster</strong>')
1229
+ && caption.includes('Launch day ') && caption.includes('original sound - The Poster')
1230
+ && (rail.match(/<strong>0<\/strong>/g) ?? []).length === 3 && rail.includes('<strong></strong>');
1231
+ })),
1232
+ done('tiktok.mirror.serves_the_vendor_api_on_its_own_origin', 'ui', 'The mirror origin serves the shell (base href, relative assets, #/ routes) and the REAL TikTok handler behind it — the Display API read, the Content Posting init and the upload PUT on one origin, one serving code path', 'ui', 'core', async () => {
1233
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-cap-mirror-'));
1234
+ const server = await createTiktokMirrorServer({ root, port: 0 });
1235
+ try {
1236
+ const json = { 'content-type': 'application/json' };
1237
+ await fetch(`${server.url}/_twin/accounts`, { method: 'POST', headers: json, body: JSON.stringify({ union_id: POSTER, username: 'origin', display_name: 'Origin' }) });
1238
+ const token = String((await (await fetch(`${server.url}/_twin/tokens`, { method: 'POST', headers: json, body: JSON.stringify({ union_id: POSTER, scopes: ['user.info.basic', 'user.info.profile', 'video.publish'] }) })).json()).access_token);
1239
+ const auth = { authorization: `Bearer ${token}`, ...json };
1240
+ const me = (await (await fetch(`${server.url}/v2/user/info/?fields=username`, { headers: auth })).json());
1241
+ const init = (await (await fetch(`${server.url}/v2/post/publish/video/init/`, { method: 'POST', headers: auth, body: JSON.stringify({ post_info: { privacy_level: 'SELF_ONLY' }, source_info: { source: 'FILE_UPLOAD', video_size: SMALL.length, chunk_size: SMALL.length, total_chunk_count: 1 } }) })).json());
1242
+ const uploadUrl = String(init.data?.upload_url ?? '');
1243
+ const put = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': 'video/mp4', 'content-range': `bytes 0-${SMALL.length - 1}/${SMALL.length}` }, body: SMALL });
1244
+ const shell = await (await fetch(`${server.url}/`)).text();
1245
+ const css = await fetch(`${server.url}/assets/styles.css`);
1246
+ return me.data?.user?.username === 'origin' && uploadUrl.startsWith(`${server.url}/video/?upload_id=`) && put.status === 201
1247
+ && shell.includes('<base href="/">') && shell.includes('<div id="root">') && shell.includes('src="assets/app.js"') && css.status === 200;
1248
+ }
1249
+ finally {
1250
+ server.stop();
1251
+ rmSync(root, { recursive: true, force: true });
1252
+ }
1253
+ }),
1254
+ done('tiktok.mirror.hosted_mount_exports', 'ui', "The module the hosted mirror mount reads exports the shell (a <base href=\"/\"> it rewrites and relative assets/ it remaps), a client bundle that BUILDS for the browser, and the stylesheet", 'ui', 'common', async () => {
1255
+ const html = tiktokMirrorHtml();
1256
+ const css = await tiktokMirrorStyles();
1257
+ const client = await buildTiktokMirrorClient();
1258
+ return html.includes('<base href="/">') && html.includes('href="assets/styles.css"') && html.includes('src="assets/app.js"')
1259
+ && css.includes('.player-frame') && css.includes('aspect-ratio: 9 / 16') && client.length > 1000;
1260
+ }),
1261
+ // ═══ connector (dimension: connector — the saboteur phase keys on this) ═══════════════════
1262
+ done('tiktok.connector.pull_identity', 'connector', "Pull observes the real account behind the operator's token (a real-shaped /v2/user/info/ reply over an injected fake) and folds it into the twin, keyed by union_id", 'connector', 'core', async () => verifyBoundary('tiktok.connector.pull_identity', async () => {
1263
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conn-'));
1264
+ try {
1265
+ const n = await pullTikTok(fakeExecute, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
1266
+ const { readOne } = await import("./tiktok-store.js");
1267
+ const row = readOne(root, 'account', 'c9c60f44-a68e-4f5d-84dd-ce22faeb0ba1');
1268
+ return n === 1 && row?.username === 'tiktokdev' && row?.displayName === 'TikTok Developers'
1269
+ && row?.followerCount === 583_423 && row?.isVerified === true && row?.pulled === true;
1270
+ }
1271
+ finally {
1272
+ rmSync(root, { recursive: true, force: true });
1273
+ }
1274
+ })),
1275
+ done('tiktok.connector.pull_idempotent', 'connector', 'A re-pull of identical vendor state appends NOTHING (shadow-diffed sync; deltasAppended 0 on the second pass)', 'connector', 'core', async () => verifyBoundary('tiktok.connector.pull_idempotent', async () => {
1276
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conn-'));
1277
+ try {
1278
+ const first = await syncTikTokFromReal(fakeExecute, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
1279
+ const second = await syncTikTokFromReal(fakeExecute, { root, occurredAt: '2026-02-01T00:00:02.000Z' });
1280
+ return first.deltasAppended > 0 && second.deltasAppended === 0 && second.observed === 1;
1281
+ }
1282
+ finally {
1283
+ rmSync(root, { recursive: true, force: true });
1284
+ }
1285
+ })),
1286
+ done('tiktok.connector.pull_requests_modeled_fields', 'connector', 'The pull asks for every modelled user field (a default-field pull would silently observe a bald persona) and narrows on request', 'connector', 'common', async () => verifyBoundary('tiktok.connector.pull_requests_modeled_fields', async () => {
1287
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conn-'));
1288
+ const seen = [];
1289
+ const recording = async (m, p) => {
1290
+ seen.push(`${m} ${p}`);
1291
+ return structuredClone(FAKE_USER_INFO);
1292
+ };
1293
+ try {
1294
+ await pullTikTok(recording, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
1295
+ await pullTikTok(recording, { root, occurredAt: '2026-02-01T00:00:02.000Z', userFields: ['open_id', 'union_id'] });
1296
+ const wide = seen[0] ?? '';
1297
+ const narrow = seen[1] ?? '';
1298
+ return seen.length === 2
1299
+ && wide.startsWith('GET /v2/user/info/?fields=')
1300
+ && ['open_id', 'union_id', 'avatar_large_url', 'bio_description', 'is_verified', 'follower_count', 'video_count'].every((f) => wide.includes(f))
1301
+ && narrow === 'GET /v2/user/info/?fields=open_id,union_id';
1302
+ }
1303
+ finally {
1304
+ rmSync(root, { recursive: true, force: true });
1305
+ }
1306
+ })),
1307
+ done('tiktok.connector.pull_videos_opt_in', 'connector', 'Videos are pulled only when asked for — the video reads need the separate video.list scope, so a default pull observes the account and nothing else', 'connector', 'common', async () => verifyBoundary('tiktok.connector.pull_videos_opt_in', async () => {
1308
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conn-'));
1309
+ const seen = [];
1310
+ const recording = async (m, p) => {
1311
+ seen.push(`${m} ${p.split('?')[0]}`);
1312
+ return p.startsWith('/v2/video/list') ? structuredClone(FAKE_VIDEO_LIST) : structuredClone(FAKE_USER_INFO);
1313
+ };
1314
+ try {
1315
+ const withoutVideos = await pullTikTok(recording, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
1316
+ const withVideos = await pullTikTok(recording, { root, occurredAt: '2026-02-01T00:00:02.000Z', videos: true });
1317
+ const { readOne } = await import("./tiktok-store.js");
1318
+ const video = readOne(root, 'video', '7010000000000000001');
1319
+ return withoutVideos === 1 && withVideos === 2
1320
+ && seen.filter((s) => s.includes('/v2/video/list')).length === 1
1321
+ && video?.ownerUnionId === 'c9c60f44-a68e-4f5d-84dd-ce22faeb0ba1'
1322
+ && video?.viewCount === 900 && video?.pulled === true;
1323
+ }
1324
+ finally {
1325
+ rmSync(root, { recursive: true, force: true });
1326
+ }
1327
+ })),
1328
+ done('tiktok.connector.refused_pull_throws', 'connector', "A REFUSED pull is NOT an empty account: TikTok's nested error object arrives even under HTTP 200, so a non-ok code THROWS instead of folding emptiness over observed state", 'connector', 'core', async () => verifyBoundary('tiktok.connector.refused_pull_throws', async () => {
1329
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conn-'));
1330
+ const refusing = async () => ({ data: {}, error: { code: 'scope_not_authorized', message: 'The user did not authorize the scope', log_id: '20260201000000AAAAAAAAAAAAAAAAAAAAAA' } });
1331
+ try {
1332
+ // The HAPPY path must still work first — otherwise a dead connector that throws on
1333
+ // EVERYTHING would satisfy the refusal expectation below.
1334
+ const n = await pullTikTok(fakeExecute, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
1335
+ if (n !== 1)
1336
+ return false;
1337
+ await pullTikTok(refusing, { root, occurredAt: '2026-02-01T00:00:02.000Z' });
1338
+ return false; // the refusal must throw
1339
+ }
1340
+ catch (e) {
1341
+ if (!String(e).includes('refused'))
1342
+ return false;
1343
+ const { readOne, readType } = await import("./tiktok-store.js");
1344
+ // The earlier good pull SURVIVES the refused one — nothing was folded over it.
1345
+ return readType(root, 'account').length === 1
1346
+ && readOne(root, 'account', 'c9c60f44-a68e-4f5d-84dd-ce22faeb0ba1')?.username === 'tiktokdev';
1347
+ }
1348
+ finally {
1349
+ rmSync(root, { recursive: true, force: true });
1350
+ }
1351
+ })),
1352
+ done('tiktok.connector.remote_refresh_folds_nothing_when_unreadable', 'connector', "The protocol-2 refresh adapter and the operator's pull differ ON PURPOSE, and both halves are pinned here: a wire that discloses no account makes the REFRESH observe zero and fold NOTHING (a previously pulled persona survives untouched), while the same executor makes the PULL throw", 'connector', 'core', async () => verifyBoundary('tiktok.connector.remote_refresh_folds_nothing_when_unreadable', async () => {
1353
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conn-'));
1354
+ // The twin's own answer to a credential it does not know — the exact body the kernel executor
1355
+ // gets when a root holds no TikTok credential yet.
1356
+ const refusingRemote = async () => ({ status: 401, headers: {}, body: JSON.stringify({ error: { code: 'access_token_invalid', message: 'The access token is invalid or not found in the request. Please refresh the token and retry.', log_id: '20260201000000AAAAAAAAAAAAAAAAAAAA' } }) });
1357
+ try {
1358
+ // A REAL observation first: without it "nothing was overwritten" is true by construction.
1359
+ const seeded = await pullTikTok(fakeExecute, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
1360
+ const { readOne, readType } = await import("./tiktok-store.js");
1361
+ const before = readOne(root, 'account', 'c9c60f44-a68e-4f5d-84dd-ce22faeb0ba1');
1362
+ if (seeded !== 1 || before?.username !== 'tiktokdev')
1363
+ return false;
1364
+ // (a) the REFRESH observes nothing and folds nothing…
1365
+ const refreshed = await syncTikTokFromRemote(refusingRemote, { root, occurredAt: '2026-02-01T00:00:02.000Z' });
1366
+ const after = readOne(root, 'account', 'c9c60f44-a68e-4f5d-84dd-ce22faeb0ba1');
1367
+ if (refreshed.observed !== 0 || refreshed.deltasAppended !== 0)
1368
+ return false;
1369
+ if (readType(root, 'account').length !== 1 || after?.username !== 'tiktokdev' || after?.followerCount !== before.followerCount)
1370
+ return false;
1371
+ // …and (b) the operator's own pull over a refusing executor still THROWS, so the sharp
1372
+ // refusal rule keeps every tooth it had.
1373
+ try {
1374
+ await pullTikTok(async () => ({ error: { code: 'access_token_invalid', message: 'nope', log_id: 'x' } }), { root, occurredAt: '2026-02-01T00:00:03.000Z' });
1375
+ return false;
1376
+ }
1377
+ catch (e) {
1378
+ return String(e).includes('refused or malformed');
1379
+ }
1380
+ }
1381
+ finally {
1382
+ rmSync(root, { recursive: true, force: true });
1383
+ }
1384
+ })),
1385
+ done('tiktok.connector.mapping_invents_nothing', 'connector', 'The mappings record ONLY what the reply disclosed — a scope-narrowed reply leaves the fields it omitted ABSENT, never placeholders', 'connector', 'common', async () => {
1386
+ const mapped = mapUserInfoAccount({ data: { user: { union_id: 'u-1', open_id: 'o-1', display_name: 'Terse' } } });
1387
+ const f = mapped.fields;
1388
+ const video = mapVideo({ id: '7010000000000000009', title: 'T' }, 'u-1');
1389
+ const vf = video.fields;
1390
+ return mapped.id === 'u-1' && f['displayName'] === 'Terse'
1391
+ && !('username' in f) && !('followerCount' in f) && !('isVerified' in f) && !('avatarUrl' in f)
1392
+ && f['pulled'] === true
1393
+ && video.id === '7010000000000000009' && vf['ownerUnionId'] === 'u-1'
1394
+ && !('viewCount' in vf) && !('createTime' in vf) && vf['pulled'] === true;
1395
+ }),
1396
+ done('tiktok.connector.open_id_is_not_the_key', 'connector', "A pulled account is keyed by union_id, never open_id: one human authorizing two apps must be ONE twin account, not two", 'connector', 'core', async () => {
1397
+ const a = mapUserInfoAccount({ data: { user: { union_id: 'same-human', open_id: 'app-a-view', username: 'x' } } });
1398
+ const b = mapUserInfoAccount({ data: { user: { union_id: 'same-human', open_id: 'app-b-view', username: 'x' } } });
1399
+ return a.id === 'same-human' && b.id === 'same-human' && a.id === b.id;
1400
+ }),
1401
+ done('tiktok.connector.budget_enforced', 'connector', "The live executor is fail-closed: at the ceiling the next call THROWS with the injected fetch's call count UNCHANGED (counted on the fake — nothing reached the vendor)", 'connector', 'core', async () => verifyBoundary('tiktok.connector.budget_enforced', async () => {
1402
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-budget-'));
1403
+ let calls = 0;
1404
+ const fakeFetch = (async () => {
1405
+ calls += 1;
1406
+ return new Response(JSON.stringify(structuredClone(FAKE_USER_INFO)), { status: 200, headers: { 'content-type': 'application/json' } });
1407
+ });
1408
+ try {
1409
+ const clock = { t: 1_700_000_000_000 };
1410
+ const execute = liveTikTokExecute('act.cap-test-token', { fetchImpl: fakeFetch, budgetOptions: { path: join(root, 'ledger.json'), now: () => clock.t } });
1411
+ // Spent through the WEIGHTED path — the destructive revoke costs 60, so the vendor's own
1412
+ // 600/minute ceiling is reached in ten calls. That the weights are what fill the ledger is
1413
+ // the point: a flat-priced budget would let sixty revocations through where ten belong.
1414
+ const allowed = TIKTOK_BUDGET_CEILING / TIKTOK_CALL_WEIGHTS.revoke;
1415
+ for (let i = 0; i < allowed; i += 1)
1416
+ await execute('POST', '/v2/oauth/revoke/');
1417
+ if (calls !== allowed)
1418
+ return false;
1419
+ try {
1420
+ await execute('POST', '/v2/oauth/revoke/');
1421
+ return false; // the ceiling must throw
1422
+ }
1423
+ catch (e) {
1424
+ return e instanceof TikTokBudgetError && calls === allowed;
1425
+ }
1426
+ }
1427
+ finally {
1428
+ rmSync(root, { recursive: true, force: true });
1429
+ }
1430
+ })),
1431
+ done('tiktok.connector.destructive_call_priced_above_reads', 'connector', "The budget prices the one destructive call above a read, and the vendor's OWN trailing-slash spelling is priced the same as the bare one (a rule anchored on the bare path alone would under-price the documented URL)", 'connector', 'common', async () => {
1432
+ return tiktokCallWeight('GET', '/v2/user/info/?fields=open_id') === TIKTOK_CALL_WEIGHTS.displayRead
1433
+ && tiktokCallWeight('POST', '/v2/oauth/revoke/') === TIKTOK_CALL_WEIGHTS.revoke
1434
+ && tiktokCallWeight('post', '/v2/oauth/revoke') === TIKTOK_CALL_WEIGHTS.revoke
1435
+ && tiktokCallWeight('POST', '/v2/oauth/token/') === TIKTOK_CALL_WEIGHTS.other
1436
+ && TIKTOK_CALL_WEIGHTS.revoke > TIKTOK_CALL_WEIGHTS.displayRead;
1437
+ }),
1438
+ done('tiktok.connector.unmapped_path_refused', 'connector', 'The live executor refuses an unmapped TikTok path OUTRIGHT — zero requests issued (the raw-vendor-call ban, D8)', 'connector', 'common', async () => verifyBoundary('tiktok.connector.unmapped_path_refused', async () => {
1439
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-budget-'));
1440
+ let calls = 0;
1441
+ const fakeFetch = (async () => {
1442
+ calls += 1;
1443
+ return new Response('{}', { status: 200 });
1444
+ });
1445
+ try {
1446
+ const execute = liveTikTokExecute('act.cap-test-token', { fetchImpl: fakeFetch, budgetOptions: { path: join(root, 'ledger.json'), now: () => 1_700_000_000_000 } });
1447
+ try {
1448
+ await execute('POST', '/v2/post/publish/video/init/');
1449
+ return false;
1450
+ }
1451
+ catch {
1452
+ return calls === 0;
1453
+ }
1454
+ }
1455
+ finally {
1456
+ rmSync(root, { recursive: true, force: true });
1457
+ }
1458
+ })),
1459
+ done('tiktok.connector.push_reports_impossible', 'connector', 'Push never fakes: TikTok has no write API for this surface, so push confirms nothing and REPORTS the real pending count as unpushable', 'connector', 'common', () =>
1460
+ // Runs inside withRoot so real local writes exist first — on a virgin root the pending count is
1461
+ // zero BY CONSTRUCTION and "reports the pending count" would be unproven.
1462
+ withRoot(async (h) => {
1463
+ const before = pushPendingTikTokActions(h.root);
1464
+ const { status } = await fullFlow(h);
1465
+ const after = pushPendingTikTokActions(h.root);
1466
+ return status === 200 && before.pushed === 0 && after.pushed === 0 && after.unpushable > before.unpushable;
1467
+ })),
1468
+ done('tiktok.connector.perform_direct_post', 'connector', "A World's Direct Post performs against a vendor TikTok (a second twin) through the vendor's own flow — creator_info, video/init, every chunk PUT PRESIGNED to the upload_url it returned and carrying no Authorization, status/fetch to PUBLISH_COMPLETE — and the vendor ends holding the published video with identical bytes, its id the entry's external id", 'connector', 'core', async () => {
1469
+ const vendorRoot = mkdtempSync(join(tmpdir(), 'tiktok-vendor-'));
1470
+ const ledger = mkdtempSync(join(tmpdir(), 'tiktok-ledger-'));
1471
+ try {
1472
+ const MB5 = 5 * 1024 * 1024;
1473
+ const media = pattern(2 * MB5 + 777);
1474
+ // the vendor: a creator and the credential a root would seal
1475
+ let vendorToken = '';
1476
+ await withPosting(async (v) => { vendorToken = v.token; return true; }, vendorRoot);
1477
+ return await withPosting(async (w) => {
1478
+ const posted = await w.publish(media, MB5, 'PUBLIC_TO_EVERYONE');
1479
+ const action = deployableEntries('tiktok', w.root).find((a) => a.operation === 'video.publish');
1480
+ if (!action || !posted.postId)
1481
+ return false;
1482
+ const seen = [];
1483
+ let clock = Date.parse(AT);
1484
+ const execute = async (req) => {
1485
+ clock += 20_000;
1486
+ const url = new URL(req.path, POSTING_ORIGIN);
1487
+ const headers = Object.fromEntries(Object.entries(req.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));
1488
+ seen.push({ method: req.method, path: url.pathname, presigned: req.presigned === true, authorized: headers['authorization'] !== undefined });
1489
+ // what the kernel executor does: the sealed credential rides every anchored request, never a presigned one
1490
+ const res = await handleTikTokTwinRequest({
1491
+ method: req.method,
1492
+ path: `${url.pathname}${url.search}`,
1493
+ ...(typeof req.body === 'string' ? { body: req.body } : {}),
1494
+ ...(req.body instanceof Uint8Array ? { bytes: req.body } : {}),
1495
+ headers: req.presigned ? headers : { ...headers, authorization: `Bearer ${vendorToken}` },
1496
+ root: vendorRoot,
1497
+ origin: POSTING_ORIGIN,
1498
+ occurredAt: new Date(clock).toISOString(),
1499
+ });
1500
+ return { status: res.status, headers: res.headers ?? {}, body: typeof res.body === 'string' ? res.body : JSON.stringify(res.body ?? '') };
1501
+ };
1502
+ const outcome = await performTikTokAction(execute, action, { resolve: (_t, id) => id, root: w.root }, new TikTokBudget({ path: join(ledger, 'ledger.json') }));
1503
+ const vendorVideo = readAll(vendorRoot).find((r) => r.type === 'video' && r.id === outcome.externalId);
1504
+ const served = await handleTikTokTwinRequest({ method: 'GET', path: `/_twin/media/video/${outcome.externalId}`, root: vendorRoot, occurredAt: new Date(clock + 60_000).toISOString() });
1505
+ const puts = seen.filter((c) => c.method === 'PUT');
1506
+ return seen[0]?.path === '/v2/post/publish/creator_info/query/' && seen[1]?.path === '/v2/post/publish/video/init/'
1507
+ && puts.length === 2 && puts.every((c) => c.presigned && !c.authorized && c.path === '/video/')
1508
+ && seen.slice(1).some((c) => c.path === '/v2/post/publish/status/fetch/')
1509
+ && /^\d{19}$/.test(String(outcome.externalId)) && outcome.externalId !== posted.postId
1510
+ && vendorVideo?.privacyLevel === 'PUBLIC_TO_EVERYONE'
1511
+ && served.status === 200 && sameBytes(await bytesOf(served.body), media);
1512
+ });
1513
+ }
1514
+ finally {
1515
+ rmSync(vendorRoot, { recursive: true, force: true });
1516
+ rmSync(ledger, { recursive: true, force: true });
1517
+ }
1518
+ }),
1519
+ done('tiktok.connector.perform_never_posts_twice', 'connector', "A kept publish is re-opened only when TikTok says it is gone: a publish answering PROCESSING_UPLOAD with 0 bytes after its chunks were refused is NOT re-initialized — the perform fails retryably and keeps it — and a later perform resumes the SAME publish on its kept upload_url, so the vendor holds one post from one init", 'connector', 'core', async () => {
1520
+ const vendorRoot = mkdtempSync(join(tmpdir(), 'tiktok-vendor-'));
1521
+ const ledger = mkdtempSync(join(tmpdir(), 'tiktok-ledger-'));
1522
+ try {
1523
+ let vendorToken = '';
1524
+ await withPosting(async (v) => { vendorToken = v.token; return true; }, vendorRoot);
1525
+ return await withPosting(async (w) => {
1526
+ await w.publish(SMALL, SMALL.length, 'PUBLIC_TO_EVERYONE');
1527
+ const action = deployableEntries('tiktok', w.root).find((a) => a.operation === 'video.publish');
1528
+ if (!action)
1529
+ return false;
1530
+ let clock = Date.parse(AT);
1531
+ let refusePuts = true;
1532
+ let inits = 0;
1533
+ const execute = async (req) => {
1534
+ clock += 20_000;
1535
+ const url = new URL(req.path, POSTING_ORIGIN);
1536
+ if (url.pathname === '/v2/post/publish/video/init/')
1537
+ inits += 1;
1538
+ // the upload host is down: the chunk never arrives
1539
+ if (req.presigned && refusePuts)
1540
+ return { status: 503, headers: {}, body: 'upload host unavailable' };
1541
+ const headers = Object.fromEntries(Object.entries(req.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]));
1542
+ const res = await handleTikTokTwinRequest({
1543
+ method: req.method,
1544
+ path: `${url.pathname}${url.search}`,
1545
+ ...(typeof req.body === 'string' ? { body: req.body } : {}),
1546
+ ...(req.body instanceof Uint8Array ? { bytes: req.body } : {}),
1547
+ headers: req.presigned ? headers : { ...headers, authorization: `Bearer ${vendorToken}` },
1548
+ root: vendorRoot,
1549
+ origin: POSTING_ORIGIN,
1550
+ occurredAt: new Date(clock).toISOString(),
1551
+ });
1552
+ return { status: res.status, headers: res.headers ?? {}, body: typeof res.body === 'string' ? res.body : JSON.stringify(res.body ?? '') };
1553
+ };
1554
+ const perform = () => performTikTokAction(execute, action, { resolve: (_t, id) => id, root: w.root }, new TikTokBudget({ path: join(ledger, 'ledger.json') }));
1555
+ const attempt = async () => { try {
1556
+ await perform();
1557
+ return 'deployed';
1558
+ }
1559
+ catch (e) {
1560
+ return e.retryable === true ? 'retryable' : 'failed';
1561
+ } };
1562
+ const first = await attempt(); // the init lands, the chunk does not
1563
+ const second = await attempt(); // PROCESSING_UPLOAD, 0 bytes: the same publish, still refused
1564
+ const publishesAfterTwo = readAll(vendorRoot).filter((r) => r.type === 'publish').length;
1565
+ refusePuts = false;
1566
+ const outcome = await perform(); // the same publish resumes and completes
1567
+ const posts = readAll(vendorRoot).filter((r) => r.type === 'video' && r.publishId !== undefined);
1568
+ return first === 'retryable' && second === 'retryable' && inits === 1 && publishesAfterTwo === 0
1569
+ && posts.length === 1 && String(outcome.externalId) === String(posts[0].id);
1570
+ });
1571
+ }
1572
+ finally {
1573
+ rmSync(vendorRoot, { recursive: true, force: true });
1574
+ rmSync(ledger, { recursive: true, force: true });
1575
+ }
1576
+ }),
1577
+ done('tiktok.connector.posting_calls_priced_at_their_published_limits', 'connector', "The budget prices each Content Posting call at 600 divided by its reference's per-token minute (creator_info 20 -> 30, the inits 6 -> 100, status/fetch 30 -> 20), so none can outrun its own allowance inside the ledger's minute", 'connector', 'common', async () => tiktokCallWeight('POST', '/v2/post/publish/creator_info/query/') === TIKTOK_BUDGET_CEILING / 20
1578
+ && tiktokCallWeight('POST', '/v2/post/publish/video/init/') === TIKTOK_BUDGET_CEILING / 6
1579
+ && tiktokCallWeight('POST', '/v2/post/publish/inbox/video/init') === TIKTOK_CALL_WEIGHTS.postInit
1580
+ && tiktokCallWeight('POST', '/v2/post/publish/status/fetch/') === TIKTOK_BUDGET_CEILING / 30),
1581
+ todo('tiktok.connector.push', 'connector', 'PUSH remains structurally impossible (TikTok exposes no app/user/grant write API); revisit if TikTok ever ships one', 'connector', 'niche'),
1582
+ todo('tiktok.connector.pull_clients', 'connector', 'PULL the developer-app registry — impossible today (TikTok apps live in the developer portal; no API reads them)', 'connector', 'niche'),
1583
+ todo('tiktok.connector.pull_grants', 'connector', "PULL the token's granted scope set — impossible today (TikTok publishes no token-introspection endpoint)", 'connector', 'niche'),
1584
+ // ═══ adjacent TikTok open-platform surface this pack does not model yet (honest denominator) ══
1585
+ // ═══ the Content Posting API (developers.tiktok.com content-posting references, fetched 2026-09-27) ══
1586
+ done('tiktok.content_posting.creator_info', 'content_posting', 'POST /v2/post/publish/creator_info/query/ answers the creator the token names — username, nickname, avatar, privacy_level_options, the comment/duet/stitch switches and max_video_post_duration_sec — and needs video.publish', 'api', 'common', () => withPosting(async (p) => {
1587
+ const ok = await p.call('/v2/post/publish/creator_info/query/', {});
1588
+ const d = body(ok).data ?? {};
1589
+ const bare = await p.call('/v2/post/publish/creator_info/query/', {}, p.readToken);
1590
+ return ok.status === 200 && body(ok).error?.code === 'ok'
1591
+ && d.creator_username === 'poster' && d.creator_nickname === 'The Poster'
1592
+ && JSON.stringify(d.privacy_level_options) === JSON.stringify(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'])
1593
+ && d.stitch_disabled === true && d.max_video_post_duration_sec === 300 && d.comment_disabled === false
1594
+ && bare.status === 401 && body(bare).error?.code === 'scope_not_authorized';
1595
+ })),
1596
+ done('tiktok.content_posting.direct_post_init', 'content_posting', 'POST /v2/post/publish/video/init/ with FILE_UPLOAD answers a v_pub_file publish_id and an upload_url on the upload path with upload_id + upload_token; a privacy_level outside the creator\'s options is privacy_level_option_mismatch (403) and PULL_FROM_URL without a verified domain is url_ownership_unverified (403)', 'api', 'common', () => withPosting(async (p) => {
1597
+ const init = await p.init('direct', SMALL.length, SMALL.length, 1);
1598
+ const url = new URL(String(body(init).data?.upload_url ?? 'http://x/'));
1599
+ const mismatch = await p.call('/v2/post/publish/video/init/', { post_info: { privacy_level: 'FOLLOWER_OF_CREATOR' }, source_info: { source: 'FILE_UPLOAD', video_size: 10, chunk_size: 10, total_chunk_count: 1 } });
1600
+ const pull = await p.call('/v2/post/publish/video/init/', { post_info: { privacy_level: 'SELF_ONLY' }, source_info: { source: 'PULL_FROM_URL', video_url: 'https://example.com/v.mp4' } });
1601
+ const noScope = await p.call('/v2/post/publish/video/init/', { post_info: { privacy_level: 'SELF_ONLY' }, source_info: { source: 'FILE_UPLOAD', video_size: 10, chunk_size: 10, total_chunk_count: 1 } }, p.readToken);
1602
+ return init.status === 200 && /^v_pub_file~v2-1\.\d+$/.test(String(body(init).data?.publish_id))
1603
+ && url.origin === POSTING_ORIGIN && url.pathname === '/video/' && /^\d+$/.test(url.searchParams.get('upload_id') ?? '') && (url.searchParams.get('upload_token') ?? '') !== ''
1604
+ && mismatch.status === 403 && body(mismatch).error?.code === 'privacy_level_option_mismatch'
1605
+ && pull.status === 403 && body(pull).error?.code === 'url_ownership_unverified'
1606
+ && noScope.status === 401 && body(noScope).error?.code === 'scope_not_authorized';
1607
+ })),
1608
+ done('tiktok.content_posting.upload_url_vendor_host_through_the_injector', 'content_posting', 'A request that names open.tiktokapis.com (the injector\'s x-volter-twin-original-host) gets its upload_url on the vendor\'s own upload host, open-upload.tiktokapis.com/video/, which the pack claims so the PUT routes back here', 'api', 'common', () => withPosting(async (p) => {
1609
+ const init = await p.init('direct', SMALL.length, SMALL.length, 1, { originalHost: 'open.tiktokapis.com' });
1610
+ return String(body(init).data?.upload_url ?? '').startsWith('https://open-upload.tiktokapis.com/video/?upload_id=');
1611
+ })),
1612
+ done('tiktok.content_posting.chunk_rules', 'content_posting', "The init enforces the media transfer guide's chunk rules: a video under 5 MB is one whole chunk, chunks are 5–64 MB, total_chunk_count is video_size / chunk_size rounded down (the final chunk carries the rest, up to 128 MB), and a 4 GB file maximum — each refusal is invalid_param (400)", 'api', 'common', () => withPosting(async (p) => {
1613
+ const MB5 = 5 * 1024 * 1024;
1614
+ const refused = async (size, chunk, count) => {
1615
+ const r = await p.init('direct', size, chunk, count);
1616
+ return r.status === 400 && body(r).error?.code === 'invalid_param';
1617
+ };
1618
+ const accepted = async (size, chunk, count) => (await p.init('direct', size, chunk, count)).status === 200;
1619
+ return (await refused(1000, 500, 2)) // under 5 MB: whole
1620
+ && (await accepted(1000, 1000, 1))
1621
+ && (await refused(20 * MB5, MB5 - 1, 20)) // below the minimum chunk
1622
+ && (await refused(20 * MB5, 65 * 1024 * 1024, 1)) // above the maximum chunk
1623
+ && (await refused(3 * MB5 + 7, MB5, 4)) // count must round DOWN
1624
+ && (await accepted(3 * MB5 + 7, MB5, 3))
1625
+ && (await refused(100 * 1024 * 1024, 64 * 1024 * 1024, 1)) // over 64 MB in one chunk
1626
+ && (await refused(5 * 1024 * 1024 * 1024, 64 * 1024 * 1024, 80)); // over 4 GB
1627
+ })),
1628
+ done('tiktok.content_posting.upload_video', 'content_posting', 'The chunked PUT to the upload_url: Content-Range bytes a-b/total per chunk, 206 while chunks remain and 201 when the last lands; out of order is 416, a size or total that disagrees with the plan is 400, and the stored video is byte-identical to what was sent', 'api', 'common', () => withPosting(async (p) => {
1629
+ const MB5 = 5 * 1024 * 1024;
1630
+ const media = pattern(2 * MB5 + 12345);
1631
+ const init = await p.init('direct', media.length, MB5, 2);
1632
+ const url = String(body(init).data?.upload_url);
1633
+ const second = media.subarray(MB5);
1634
+ const early = await p.put(url, second, `bytes ${MB5}-${media.length - 1}/${media.length}`);
1635
+ const wrongTotal = await p.put(url, media.subarray(0, MB5), `bytes 0-${MB5 - 1}/${media.length + 1}`);
1636
+ const first = await p.put(url, media.subarray(0, MB5), `bytes 0-${MB5 - 1}/${media.length}`);
1637
+ const last = await p.put(url, second, `bytes ${MB5}-${media.length - 1}/${media.length}`);
1638
+ const publishId = String(body(init).data?.publish_id);
1639
+ const status = parseStatus(await p.call('/v2/post/publish/status/fetch/', { publish_id: publishId }, undefined, atPlus(120)));
1640
+ const served = await p.media(status.postId ?? '', {}, atPlus(120));
1641
+ return early.status === 416 && wrongTotal.status === 400 && first.status === 206 && last.status === 201
1642
+ && served.status === 200 && sameBytes(await bytesOf(served.body), media);
1643
+ })),
1644
+ done('tiktok.content_posting.upload_url_signed', 'content_posting', "The upload_url's upload_token is VERIFIED, not looked up: a token altered by one character is 403, an unknown upload_id is 404, and the URL is refused (403) once its one hour of World time has passed", 'api', 'common', () => withPosting(async (p) => {
1645
+ const init = await p.init('direct', SMALL.length, SMALL.length, 1);
1646
+ const url = new URL(String(body(init).data?.upload_url));
1647
+ const token = url.searchParams.get('upload_token') ?? '';
1648
+ const tampered = new URL(url);
1649
+ tampered.searchParams.set('upload_token', `${token.slice(0, -1)}${token.endsWith('A') ? 'B' : 'A'}`);
1650
+ const unknown = new URL(url);
1651
+ unknown.searchParams.set('upload_id', '7000000000000000000');
1652
+ const range = `bytes 0-${SMALL.length - 1}/${SMALL.length}`;
1653
+ const bad = await p.put(tampered.toString(), SMALL, range);
1654
+ const missing = await p.put(unknown.toString(), SMALL, range);
1655
+ const expired = await p.put(url.toString(), SMALL, range, atPlus(3601));
1656
+ const fresh = await p.put(url.toString(), SMALL, range, atPlus(60));
1657
+ return bad.status === 403 && missing.status === 404 && expired.status === 403 && fresh.status === 201;
1658
+ })),
1659
+ done('tiktok.content_posting.post_status', 'content_posting', 'POST /v2/post/publish/status/fetch/ walks the publish by World time: PROCESSING_UPLOAD with uploaded_bytes while chunks arrive and while TikTok processes, then PUBLISH_COMPLETE with publicaly_available_post_id as bare int64 numbers; an unknown id is invalid_publish_id and another creator\'s is token_not_authorized_for_specified_publish_id', 'api', 'common', () => withPosting(async (p) => {
1660
+ const MB5 = 5 * 1024 * 1024;
1661
+ const media = pattern(2 * MB5);
1662
+ const init = await p.init('direct', media.length, MB5, 2);
1663
+ const url = String(body(init).data?.upload_url);
1664
+ const publishId = String(body(init).data?.publish_id);
1665
+ const read = async (at, token) => p.call('/v2/post/publish/status/fetch/', { publish_id: publishId }, token, at);
1666
+ await p.put(url, media.subarray(0, MB5), `bytes 0-${MB5 - 1}/${media.length}`);
1667
+ const partway = parseStatus(await read(atPlus(1)));
1668
+ await p.put(url, media.subarray(MB5), `bytes ${MB5}-${media.length - 1}/${media.length}`, atPlus(2));
1669
+ const processing = parseStatus(await read(atPlus(3)));
1670
+ const doneRead = await read(atPlus(30));
1671
+ const done = parseStatus(doneRead);
1672
+ const unknown = await p.call('/v2/post/publish/status/fetch/', { publish_id: 'v_pub_file~v2-1.1' });
1673
+ const foreign = await read(atPlus(30), p.otherToken);
1674
+ return partway.status === 'PROCESSING_UPLOAD' && partway.uploaded === MB5
1675
+ && processing.status === 'PROCESSING_UPLOAD' && processing.uploaded === media.length
1676
+ && done.status === 'PUBLISH_COMPLETE' && /^\d{19}$/.test(done.postId ?? '')
1677
+ && new RegExp(`"publicaly_available_post_id":\\[${done.postId}\\]`).test(String(doneRead.body))
1678
+ && unknown.status === 400 && body(unknown).error?.code === 'invalid_publish_id'
1679
+ && foreign.status === 400 && parseStatus(foreign).code === 'token_not_authorized_for_specified_publish_id';
1680
+ })),
1681
+ done('tiktok.content_posting.published_video_listed', 'content_posting', 'A Direct Post becomes a Video Object on the creator\'s profile when its processing ends — /v2/video/list/ does not list it while PROCESSING_UPLOAD and lists it (caption, duration, id) after', 'api', 'common', () => withPosting(async (p) => {
1682
+ const init = await p.init('direct', SMALL.length, SMALL.length, 1, { title: 'Release 0.5.68 #volter' });
1683
+ await p.put(String(body(init).data?.upload_url), SMALL, `bytes 0-${SMALL.length - 1}/${SMALL.length}`);
1684
+ const list = async (at) => p.call('/v2/video/list/?fields=id,video_description,title', { max_count: 20 }, undefined, at);
1685
+ const before = body(await list(AT)).data?.videos ?? [];
1686
+ const after = body(await list(atPlus(30))).data?.videos ?? [];
1687
+ return before.length === 0 && after.length === 1 && after[0].video_description === 'Release 0.5.68 #volter';
1688
+ })),
1689
+ done('tiktok.content_posting.inbox_upload', 'content_posting', 'POST /v2/post/publish/inbox/video/init/ (video.upload) answers a v_inbox_file publish_id; the upload ends at SEND_TO_USER_INBOX and posts nothing to the profile; a sixth pending share inside 24 hours is spam_risk_too_many_pending_share', 'api', 'common', () => withPosting(async (p) => {
1690
+ const upload = async (at) => {
1691
+ const init = await p.init('inbox', SMALL.length, SMALL.length, 1, { at });
1692
+ if (init.status !== 200)
1693
+ return { init, publishId: '' };
1694
+ await p.put(String(body(init).data?.upload_url), SMALL, `bytes 0-${SMALL.length - 1}/${SMALL.length}`, at);
1695
+ return { init, publishId: String(body(init).data?.publish_id) };
1696
+ };
1697
+ const first = await upload(AT);
1698
+ const status = parseStatus(await p.call('/v2/post/publish/status/fetch/', { publish_id: first.publishId }, undefined, atPlus(60)));
1699
+ const listed = body(await p.call('/v2/video/list/?fields=id', { max_count: 20 }, undefined, atPlus(60))).data?.videos ?? [];
1700
+ for (let i = 1; i < 5; i += 1)
1701
+ await upload(atPlus(i * 10));
1702
+ const sixth = await p.init('inbox', SMALL.length, SMALL.length, 1, { at: atPlus(70) });
1703
+ return /^v_inbox_file~v2\.\d+$/.test(first.publishId) && status.status === 'SEND_TO_USER_INBOX' && listed.length === 0
1704
+ && sixth.status === 403 && body(sixth).error?.code === 'spam_risk_too_many_pending_share';
1705
+ })),
1706
+ done('tiktok.content_posting.rate_limits', 'content_posting', "Each Content Posting endpoint meters its OWN per-token minute: the Direct Post init's seventh call inside a minute is 429 rate_limit_exceeded (the reference: 6 per minute)", 'api', 'niche', () => withPosting(async (p) => {
1707
+ const codes = [];
1708
+ for (let i = 0; i < 7; i += 1)
1709
+ codes.push((await p.init('direct', SMALL.length, SMALL.length, 1, { at: atPlus(i) })).status);
1710
+ return codes.slice(0, 6).every((c) => c === 200) && codes[6] === 429;
1711
+ })),
1712
+ done('tiktok.content_posting.media_served_with_range', 'content_posting', "A published video's bytes are served at the twin's media route under its public base: Range answers 206 with Content-Range, an open-ended range is capped at 8 MiB, past the end is 416, and a SELF_ONLY post plays only with its creator's access_token", 'api', 'common', () => withPosting(async (p) => {
1713
+ const MB5 = 5 * 1024 * 1024;
1714
+ const media = pattern(2 * MB5);
1715
+ const pub = await p.publish(media, MB5, 'PUBLIC_TO_EVERYONE');
1716
+ const ranged = await p.media(pub.postId, { range: 'bytes=10-19' });
1717
+ const open = await p.media(pub.postId, { range: 'bytes=0-' });
1718
+ const past = await p.media(pub.postId, { range: `bytes=${media.length}-` });
1719
+ const backwards = await p.media(pub.postId, { range: 'bytes=5-3' });
1720
+ const priv = await p.publish(SMALL, SMALL.length, 'SELF_ONLY');
1721
+ const anon = await p.media(priv.postId, {});
1722
+ const owner = await p.media(priv.postId, {}, undefined, p.token);
1723
+ return ranged.status === 206 && ranged.headers?.['content-range'] === `bytes 10-19/${media.length}` && sameBytes(await bytesOf(ranged.body), media.subarray(10, 20))
1724
+ && open.status === 206 && open.headers?.['content-range'] === `bytes 0-${8 * 1024 * 1024 - 1}/${media.length}`
1725
+ && past.status === 416 && backwards.status === 200 && anon.status === 403 && owner.status === 200;
1726
+ })),
1727
+ done('tiktok.content_posting.privacy_level_required', 'content_posting', "A Direct Post init with no post_info.privacy_level, or one outside the creator's privacy_level_options, is privacy_level_option_mismatch (403) — the Direct Post reference's code for both", 'api', 'common', () => withPosting(async (p) => {
1728
+ const plan = { source: 'FILE_UPLOAD', video_size: SMALL.length, chunk_size: SMALL.length, total_chunk_count: 1 };
1729
+ const missing = await p.call('/v2/post/publish/video/init/', { post_info: { title: 'no privacy' }, source_info: plan });
1730
+ const unknown = await p.call('/v2/post/publish/video/init/', { post_info: { privacy_level: 'EVERYONE' }, source_info: plan });
1731
+ return missing.status === 403 && body(missing).error?.code === 'privacy_level_option_mismatch'
1732
+ && unknown.status === 403 && body(unknown).error?.code === 'privacy_level_option_mismatch';
1733
+ })),
1734
+ done('tiktok.content_posting.processing_checks', 'content_posting', "Processing FAILS what TikTok's checks refuse, by the file's own headers: bytes that are no readable video are file_format_check_failed, and a frame rate outside 23–60 FPS is frame_rate_check_failed — nothing is posted", 'api', 'common', () => withPosting(async (p) => {
1735
+ const failed = async (bytes) => {
1736
+ const init = await p.init('direct', bytes.length, bytes.length, 1);
1737
+ await p.put(String(body(init).data?.upload_url), bytes, `bytes 0-${bytes.length - 1}/${bytes.length}`);
1738
+ return parseStatus(await p.call('/v2/post/publish/status/fetch/', { publish_id: String(body(init).data?.publish_id) }, undefined, atPlus(60)));
1739
+ };
1740
+ const zeros = await failed(new Uint8Array(1024 * 1024));
1741
+ // TINY_MP4's 24 frames over a media duration doubled in its mdhd: 12 FPS
1742
+ const slow = TINY_MP4.slice();
1743
+ const at = Buffer.from(slow).indexOf('mdhd') + 4;
1744
+ const view = new DataView(slow.buffer);
1745
+ view.setUint32(at + 16, view.getUint32(at + 16) * 2);
1746
+ const slowStatus = await failed(slow);
1747
+ const good = await failed(TINY_MP4);
1748
+ const listed = body(await p.call('/v2/video/list/?fields=id', { max_count: 20 }, undefined, atPlus(120))).data?.videos ?? [];
1749
+ return zeros.status === 'FAILED' && zeros.failReason === 'file_format_check_failed'
1750
+ && slowStatus.status === 'FAILED' && slowStatus.failReason === 'frame_rate_check_failed'
1751
+ && good.status === 'PUBLISH_COMPLETE' && listed.length === 1;
1752
+ })),
1753
+ done('tiktok.content_posting.final_put_completes_after_a_stop', 'content_posting', 'A process that stopped after the last chunk was staged and before the publish was written leaves the upload whole but unfinished; the repeated final PUT completes it (201) and the post publishes once', 'api', 'niche', () => withPosting(async (p) => {
1754
+ const init = await p.init('direct', SMALL.length, SMALL.length, 1);
1755
+ const url = String(body(init).data?.upload_url);
1756
+ // the stop: the one chunk staged, nothing after it ran
1757
+ await stageChunk(new URL(url).searchParams.get('upload_id') ?? '', 0, SMALL, p.root);
1758
+ const again = await p.put(url, SMALL, `bytes 0-${SMALL.length - 1}/${SMALL.length}`);
1759
+ const status = parseStatus(await p.call('/v2/post/publish/status/fetch/', { publish_id: String(body(init).data?.publish_id) }, undefined, atPlus(60)));
1760
+ const once = readAll(p.root).filter((r) => r.type === 'publish').length;
1761
+ return again.status === 201 && status.status === 'PUBLISH_COMPLETE' && once === 1;
1762
+ })),
1763
+ done('tiktok.token.no_credential_in_the_clear', 'token', "No credential is kept in the clear: after a posted video, an inbox draft, a /_twin/tokens token and the whole Login Kit round trip, no file under the root — event log, projection, byte annex, staging records — contains any access token, refresh token, authorization code, client secret or upload token", 'api', 'core', () => withPosting(async (p) => {
1764
+ await p.publish(SMALL, SMALL.length, 'PUBLIC_TO_EVERYONE');
1765
+ const inbox = await p.init('inbox', SMALL.length, SMALL.length, 1);
1766
+ const uploadUrl = new URL(String(body(inbox).data?.upload_url));
1767
+ await p.put(uploadUrl.toString(), SMALL, `bytes 0-${SMALL.length - 1}/${SMALL.length}`);
1768
+ // the OAuth leg on the same root: consent, redeem, refresh
1769
+ const send = (m, path, b, h) => handleTikTokTwinRequest({ method: m, path, ...(b !== undefined ? { body: b } : {}), ...(h ? { headers: h } : {}), root: p.root, origin: POSTING_ORIGIN, occurredAt: AT });
1770
+ const cb = `${POSTING_ORIGIN}/api/auth/callback/tiktok`;
1771
+ const auth = await send('GET', `/v2/auth/authorize?client_key=${DEFAULT_CLIENT_KEY}&response_type=code&scope=user.info.basic&redirect_uri=${encodeURIComponent(cb)}&state=s`);
1772
+ const request = /name="auth_request" value="([^"]+)"/.exec(String(auth.body))?.[1] ?? '';
1773
+ const consent = await send('POST', '/_twin/consent', `auth_request=${request}&decision=allow&scope=user.info.basic`, FORM_CT);
1774
+ const code = new URL(consent.headers?.location ?? 'http://x/').searchParams.get('code') ?? '';
1775
+ const minted = body(await send('POST', '/v2/oauth/token/', new URLSearchParams({ client_key: DEFAULT_CLIENT_KEY, client_secret: DEFAULT_CLIENT_SECRET, grant_type: 'authorization_code', code, redirect_uri: cb }).toString(), FORM_CT));
1776
+ const rotated = body(await send('POST', '/v2/oauth/token/', new URLSearchParams({ client_key: DEFAULT_CLIENT_KEY, client_secret: DEFAULT_CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: String(minted.refresh_token) }).toString(), FORM_CT));
1777
+ const secrets = [p.token, p.readToken, p.otherToken, code, String(minted.access_token), String(minted.refresh_token), String(rotated.access_token), String(rotated.refresh_token), DEFAULT_CLIENT_SECRET, uploadUrl.searchParams.get('upload_token') ?? '']
1778
+ .filter((v) => v.length >= 8);
1779
+ const files = [];
1780
+ const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) {
1781
+ const f = join(dir, e.name);
1782
+ if (e.isDirectory())
1783
+ walk(f);
1784
+ else
1785
+ files.push(f);
1786
+ } };
1787
+ walk(p.root);
1788
+ const leaks = files.filter((f) => { const text = readFileSync(f).toString('latin1'); return secrets.some((v) => text.includes(v)); });
1789
+ return secrets.length === 10 && files.length > 5 && leaks.length === 0;
1790
+ })),
1791
+ todo('tiktok.content_posting.large_files', 'content_posting', "Accept TikTok's full 4 GB: the twin holds a video whole at its last chunk (the blob seam's put takes bytes), so it refuses an init over 512 MiB — a streaming join onto the blob seam would lift it", 'api', 'niche'),
1792
+ todo('tiktok.content_posting.photo_post', 'content_posting', 'Model POST /v2/post/publish/content/init/ — the photo post, which is PULL_FROM_URL only (images fetched from a verified domain)', 'api', 'niche'),
1793
+ todo('tiktok.content_posting.pull_from_url', 'content_posting', 'Model PULL_FROM_URL: the developer-portal registry of verified URL prefixes and domains, the fetch, PROCESSING_DOWNLOAD and downloaded_bytes (today every pull is url_ownership_unverified)', 'api', 'niche'),
1794
+ todo('tiktok.content_posting.upload_bodies', 'content_posting', 'PIN the body the upload URL answers with each status (the media transfer guide publishes the statuses only; the twin answers a plain-text reason)', 'api', 'niche'),
1795
+ todo('tiktok.content_posting.caption_fields', 'content_posting', "PIN which Video Object fields a Direct Post's post_info.title fills (the twin fills video_description and title with the caption)", 'api', 'niche'),
1796
+ todo('tiktok.content_posting.processing_time', 'content_posting', 'PIN how long TikTok takes to process a posted video (the twin holds PROCESSING_UPLOAD for 2 s plus 1 s per 10 MiB of World time)', 'api', 'niche'),
1797
+ todo('tiktok.content_posting.daily_post_cap', 'content_posting', 'Model spam_risk_too_many_posts and reached_active_user_cap — the per-creator daily post cap and the per-client active-user cap (the references name the codes, not the numbers)', 'api', 'niche'),
1798
+ todo('tiktok.content_posting.unaudited_client', 'content_posting', "Model the unaudited-client rule: \"All content posted by unaudited clients will be restricted to private viewing mode\" and unaudited_client_can_only_post_to_private_accounts", 'api', 'common'),
1799
+ todo('tiktok.research.query_videos', 'research', 'Model POST /v2/research/video/query/ — the Research API video search with its own field and condition grammar', 'api', 'niche'),
1800
+ todo('tiktok.research.query_users', 'research', 'Model POST /v2/research/user/info/ and the follower/following reads', 'api', 'niche'),
1801
+ todo('tiktok.research.query_comments', 'research', 'Model POST /v2/research/video/comment/list/', 'api', 'niche'),
1802
+ todo('tiktok.research.adlib', 'research', 'Model the Commercial Content Library / ad-library research reads', 'api', 'niche'),
1803
+ todo('tiktok.portability.add_request', 'portability', 'Model POST /v2/portability/data/add/ — a data-portability export request', 'api', 'niche'),
1804
+ todo('tiktok.portability.check_request', 'portability', 'Model the export status poll', 'api', 'niche'),
1805
+ todo('tiktok.portability.download', 'portability', 'Model the export download endpoint and its archive shape', 'api', 'niche'),
1806
+ todo('tiktok.local_services.product_manage', 'local_services', 'Model the Local Services product/shop/voucher management surface behind the local.*.manage scopes', 'api', 'niche'),
1807
+ // ── webhooks: the Events page's own event catalog (§9: the names below are the vendor's) ──
1808
+ todo('tiktok.webhooks.delivery', 'webhooks', "Model webhook DELIVERY itself: the registered callback URL, the envelope (client_key, event, create_time, user_openid, content) and the signature scheme a receiver verifies", 'api', 'common'),
1809
+ todo('tiktok.webhooks.authorization_removed', 'webhooks', "Model the `authorization.removed` event — fired when a user disconnects the app, carrying the REASON enum that says whether the user, the platform or a policy action removed it. The twin already models the revoke that causes it and tells nobody", 'api', 'common'),
1810
+ todo('tiktok.webhooks.video_publish_completed', 'webhooks', 'Model the `video.publish.completed` event (Content Posting: a Direct Post finished publishing)', 'api', 'common'),
1811
+ todo('tiktok.webhooks.video_upload_failed', 'webhooks', 'Model the `video.upload.failed` event (Content Posting: an upload did not become a post)', 'api', 'common'),
1812
+ todo('tiktok.webhooks.portability_download_ready', 'webhooks', 'Model the `portability.download.ready` event (Data Portability: an export archive is ready to fetch)', 'api', 'niche'),
1813
+ // ── oEmbed: the one PUBLIC, unauthenticated read this vendor publishes ──
1814
+ todo('tiktok.oembed.lookup', 'oembed', 'Model GET https://www.tiktok.com/oembed?url=… — the public oEmbed read (no token, no app) returning title, author_name, author_url, thumbnail_url, html and the player dimensions for a video URL', 'api', 'common'),
1815
+ // ── product families this pack does not serve, named so each is a visible gap ──
1816
+ todo('tiktok.share_kit.share', 'share_kit', 'Model Share Kit — the app-to-app hand-off that opens the TikTok composer with media a partner app supplies, and its share-status callbacks', 'api', 'common'),
1817
+ todo('tiktok.mini_apps.mini_games', 'mini_apps', 'Model the Mini Games platform surface (its own developer APIs and in-app runtime)', 'api', 'niche'),
1818
+ todo('tiktok.mini_apps.mini_dramas', 'mini_apps', 'Model the Mini Dramas platform surface', 'api', 'niche'),
1819
+ todo('tiktok.mini_apps.tiktok_go', 'mini_apps', 'Model the TikTok GO platform surface', 'api', 'niche'),
1820
+ todo('tiktok.business.ads_api', 'business', 'Model the business-api.tiktok.com Business/Ads API — a SEPARATE service-area of this same vendor (its own host, its own access-token scheme and the tiktok-business-api-sdk clients), named here so it is a visible gap rather than a silent omission', 'api', 'common'),
1821
+ todo('tiktok.business.webhooks', 'business', 'Model the Business API webhook subscriptions', 'api', 'niche'),
1822
+ ];
1823
+ /** Committed area census (TWIN-87/F1) — enumerated TOP-DOWN from the vendor's own product nav
1824
+ * (Login Kit, Display API, Content Posting, Research, Data Portability, Local Services, Business)
1825
+ * plus this pack's structural areas, not derived from the manifest (which would be a tautology). */
1826
+ export const TIKTOK_AREAS = [
1827
+ 'authorize',
1828
+ 'business',
1829
+ 'client_credentials',
1830
+ 'conformance',
1831
+ 'connector',
1832
+ 'consent',
1833
+ 'content_posting',
1834
+ 'errors',
1835
+ 'local_services',
1836
+ 'login_kit',
1837
+ 'mini_apps',
1838
+ 'oembed',
1839
+ 'portability',
1840
+ 'rate',
1841
+ 'readonly',
1842
+ 'refresh',
1843
+ 'research',
1844
+ 'revoke',
1845
+ 'scopes',
1846
+ 'share_kit',
1847
+ 'token',
1848
+ 'ui',
1849
+ 'user_info',
1850
+ 'video',
1851
+ 'webhooks',
1852
+ ];
1853
+ export function tiktokCapabilities() {
1854
+ return checkCapabilities('tiktok', TIKTOK_CAPABILITIES);
1855
+ }