@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,526 @@
1
+ // TikTok conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
2
+ //
3
+ // ── THIS CHECK DRIVES THE ROUTER AND ASSERTS WHAT CAME BACK ─────────────────────────────────────
4
+ // Held to ADDING_A_TWIN.md §6's bar:
5
+ // • two constants asserting about each other is not a check — every expectation below is a
6
+ // LITERAL, never a value read back out of the handler's own module;
7
+ // • "not the router's own miss" has teeth at the dispatch and nowhere deeper — each probe
8
+ // declares the STATUS SET and a PREDICATE over the body a live handler produces, the endpoint
9
+ // census is a two-way bijection with the snapshot, and a hand-enumerated ROUTER_SURFACE catches
10
+ // served-but-unclaimed surface (the direction probes<->snapshot is blind to).
11
+ //
12
+ // And the whole point of this pack — the ROUND TRIP — is checked as a round trip: a real authorize
13
+ // request, a real consent with a real per-scope decision, a real 302 carrying code + scopes +
14
+ // state, that code redeemed for a token, and that token answering the Display API with the
15
+ // consenting persona. A router that dispatches every route while folding nothing cannot pass that.
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_KEY, DEFAULT_CLIENT_SECRET, DEFAULT_VIDEOS, defaultRedirectUris, openIdFor } from './tiktok-store.ts';
20
+ import { handleTikTokTwinRequest, tiktokTwinSnapshot, type TikTokResponse } from './tiktok-twin.ts';
21
+
22
+ export { paddedMp4, TINY_MP4 } from './tiktok-sample-mp4.ts';
23
+ import { TINY_MP4 } from './tiktok-sample-mp4.ts';
24
+
25
+ export type TikTokConformanceReport = {
26
+ ok: boolean;
27
+ endpointsChecked: number;
28
+ endpointsProbed: number;
29
+ resourceTypesChecked: number;
30
+ violations: string[];
31
+ };
32
+
33
+ type Probe = {
34
+ method: string;
35
+ path: string;
36
+ body?: string;
37
+ headers?: Record<string, string>;
38
+ /** The status(es) a WORKING handler answers with. */
39
+ status: number[];
40
+ /** What a working handler's body must look like. */
41
+ expect?: (body: unknown) => boolean;
42
+ };
43
+
44
+ const isObject = (b: unknown): b is Record<string, unknown> => !!b && typeof b === 'object';
45
+ const htmlContaining = (...needles: string[]) => (b: unknown) => typeof b === 'string' && needles.every((n) => b.includes(n));
46
+
47
+ /** The origin this harness's world serves the twin at. The seeded demo app's callbacks are DERIVED
48
+ * from it (runtime contract R7: the port belongs to the caller's world, never to the twin's
49
+ * source), so the probes below name no port of their own. */
50
+ const DEMO_ORIGIN = 'http://localhost:3000';
51
+ const REDIRECT_URI = defaultRedirectUris(DEMO_ORIGIN)[0]!;
52
+ const SCOPE = 'user.info.basic,user.info.profile,user.info.stats,video.list';
53
+ const AT = '2026-02-01T00:00:00.000Z';
54
+ const ADA = DEFAULT_ACCOUNTS[0]!;
55
+ const GRACE = DEFAULT_ACCOUNTS[1]!;
56
+ const ADA_OPEN_ID = openIdFor(DEFAULT_CLIENT_KEY, ADA.unionId);
57
+ const ADA_VIDEO = DEFAULT_VIDEOS[0]!;
58
+
59
+ /** Placeholders substituted with values the live flow actually minted. */
60
+ const CODE = 'PROBE_CODE';
61
+ const ACCESS = 'PROBE_ACCESS_TOKEN';
62
+ /** A token holding video.publish + video.upload (issued through /_twin/tokens: no consent is needed
63
+ * to prove the posting surface), a staged publish to read, and an upload URL to PUT to. */
64
+ const POSTER = 'PROBE_POST_TOKEN';
65
+ const PUBLISH = 'PROBE_PUBLISH_ID';
66
+ const UPLOAD = 'PROBE_UPLOAD_PATH';
67
+ /** One whole chunk (under TikTok's 5 MB floor). */
68
+ const CHUNK = TINY_MP4;
69
+ const postHeaders = { authorization: `Bearer ${POSTER}`, 'content-type': 'application/json; charset=UTF-8' };
70
+ const sourceInfo = { source: 'FILE_UPLOAD', video_size: CHUNK.length, chunk_size: CHUNK.length, total_chunk_count: 1 };
71
+ const initData = (prefix: string) => (b: unknown) => {
72
+ if (!isObject(b) || !isObject(b.data)) return false;
73
+ const d = b.data as Record<string, unknown>;
74
+ return typeof d['publish_id'] === 'string' && d['publish_id'].startsWith(prefix)
75
+ && typeof d['upload_url'] === 'string' && /\/video\/\?upload_id=\d+&upload_token=[^&]+$/.test(d['upload_url'])
76
+ && isObject(b.error) && (b.error as Record<string, unknown>)['code'] === 'ok';
77
+ };
78
+
79
+ const authQuery = `client_key=${encodeURIComponent(DEFAULT_CLIENT_KEY)}&response_type=code`
80
+ + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}&state=probe-state`;
81
+
82
+ const form = (params: Record<string, string>) => new URLSearchParams(params).toString();
83
+ const clientCreds = { client_key: DEFAULT_CLIENT_KEY, client_secret: DEFAULT_CLIENT_SECRET };
84
+
85
+ /** A representative request per declared endpoint, with the outcome a LIVE handler produces. */
86
+ const PROBES: Record<string, Probe> = {
87
+ 'GET /v2/auth/authorize': {
88
+ method: 'GET',
89
+ path: `/v2/auth/authorize?${authQuery}`,
90
+ status: [200],
91
+ // The authorization page is HTML, and its content is the claim: the app name from the app
92
+ // registry, the signed-in persona, both buttons, and a scope's consent wording — TikTok's own,
93
+ // from the scope catalog — must all be ON the page.
94
+ // NB the scope wording is asserted WITHOUT its apostrophe: the served markup is React's, and
95
+ // React escapes `'` to `&#x27;` in text content.
96
+ expect: htmlContaining('Continue', 'Cancel', 'Twin Demo App', `@${ADA.username}`, 'public videos on TikTok'),
97
+ },
98
+ 'POST /v2/oauth/token': {
99
+ method: 'POST',
100
+ path: '/v2/oauth/token/',
101
+ body: form({ ...clientCreds, code: CODE, grant_type: 'authorization_code', redirect_uri: REDIRECT_URI }),
102
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
103
+ status: [200],
104
+ // Literals, deliberately (the field table the guide publishes): capital-B `Bearer`, the
105
+ // documented 24-hour expiry and 365-day refresh expiry, TikTok-shaped `act.`/`rft.` tokens, the
106
+ // per-app open_id, and the comma-separated granted scope string.
107
+ expect: (b) =>
108
+ isObject(b)
109
+ && b.token_type === 'Bearer'
110
+ && b.expires_in === 86_400
111
+ && b.refresh_expires_in === 31_536_000
112
+ && typeof b.access_token === 'string' && b.access_token.startsWith('act.')
113
+ && typeof b.refresh_token === 'string' && b.refresh_token.startsWith('rft.')
114
+ && b.open_id === ADA_OPEN_ID
115
+ && b.scope === SCOPE,
116
+ },
117
+ 'POST /v2/oauth/revoke': {
118
+ method: 'POST',
119
+ path: '/v2/oauth/revoke/',
120
+ body: form({ ...clientCreds, token: ACCESS }),
121
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
122
+ status: [200],
123
+ // "If the request is successful, the response struct will be empty."
124
+ expect: (b) => isObject(b) && Object.keys(b).length === 0,
125
+ },
126
+ 'GET /v2/user/info': {
127
+ method: 'GET',
128
+ path: '/v2/user/info/?fields=open_id,union_id,display_name,username,follower_count',
129
+ headers: { authorization: `Bearer ${ACCESS}` },
130
+ status: [200],
131
+ expect: (b) =>
132
+ isObject(b)
133
+ && isObject(b.data)
134
+ && isObject((b.data as Record<string, unknown>)['user'])
135
+ && ((b.data as Record<string, any>)['user']['open_id']) === ADA_OPEN_ID
136
+ && ((b.data as Record<string, any>)['user']['union_id']) === ADA.unionId
137
+ && ((b.data as Record<string, any>)['user']['username']) === ADA.username
138
+ && ((b.data as Record<string, any>)['user']['display_name']) === ADA.displayName
139
+ && ((b.data as Record<string, any>)['user']['follower_count']) === ADA.followerCount
140
+ && isObject(b.error) && (b.error as Record<string, unknown>)['code'] === 'ok',
141
+ },
142
+ 'POST /v2/video/list': {
143
+ method: 'POST',
144
+ path: '/v2/video/list/?fields=id,title,create_time,share_url',
145
+ body: JSON.stringify({ max_count: 20 }),
146
+ headers: { authorization: `Bearer ${ACCESS}`, 'content-type': 'application/json' },
147
+ status: [200],
148
+ expect: (b) => {
149
+ if (!isObject(b) || !isObject(b.data)) return false;
150
+ const data = b.data as Record<string, any>;
151
+ const videos = data['videos'] as Array<Record<string, unknown>> | undefined;
152
+ return Array.isArray(videos)
153
+ && videos.some((v) => v['id'] === ADA_VIDEO.id && v['title'] === ADA_VIDEO.title
154
+ && v['share_url'] === `https://www.tiktok.com/@${ADA.username}/video/${ADA_VIDEO.id}`)
155
+ && typeof data['cursor'] === 'number'
156
+ && typeof data['has_more'] === 'boolean';
157
+ },
158
+ },
159
+ 'POST /v2/video/query': {
160
+ method: 'POST',
161
+ path: '/v2/video/query/?fields=id,view_count',
162
+ body: JSON.stringify({ filters: { video_ids: [ADA_VIDEO.id] } }),
163
+ headers: { authorization: `Bearer ${ACCESS}`, 'content-type': 'application/json' },
164
+ status: [200],
165
+ expect: (b) => {
166
+ if (!isObject(b) || !isObject(b.data)) return false;
167
+ const videos = (b.data as Record<string, any>)['videos'] as Array<Record<string, unknown>> | undefined;
168
+ return Array.isArray(videos) && videos.length === 1
169
+ && videos[0]!['id'] === ADA_VIDEO.id && videos[0]!['view_count'] === ADA_VIDEO.viewCount;
170
+ },
171
+ },
172
+ 'POST /v2/post/publish/creator_info/query': {
173
+ method: 'POST',
174
+ path: '/v2/post/publish/creator_info/query/',
175
+ body: '{}',
176
+ headers: postHeaders,
177
+ status: [200],
178
+ // the reference's own field set, with the reference's own example options for a public account
179
+ expect: (b) => {
180
+ if (!isObject(b) || !isObject(b.data)) return false;
181
+ const d = b.data as Record<string, any>;
182
+ return d['creator_username'] === ADA.username && d['creator_nickname'] === ADA.displayName
183
+ && JSON.stringify(d['privacy_level_options']) === '["PUBLIC_TO_EVERYONE","MUTUAL_FOLLOW_FRIENDS","SELF_ONLY"]'
184
+ && d['comment_disabled'] === false && d['duet_disabled'] === false && d['stitch_disabled'] === false
185
+ && d['max_video_post_duration_sec'] === 600 && typeof d['creator_avatar_url'] === 'string';
186
+ },
187
+ },
188
+ 'POST /v2/post/publish/video/init': {
189
+ method: 'POST',
190
+ path: '/v2/post/publish/video/init/',
191
+ body: JSON.stringify({ post_info: { title: 'probe #conformance', privacy_level: 'SELF_ONLY' }, source_info: sourceInfo }),
192
+ headers: postHeaders,
193
+ status: [200],
194
+ expect: initData('v_pub_file~v2-1.'),
195
+ },
196
+ 'POST /v2/post/publish/inbox/video/init': {
197
+ method: 'POST',
198
+ path: '/v2/post/publish/inbox/video/init/',
199
+ body: JSON.stringify({ source_info: sourceInfo }),
200
+ headers: postHeaders,
201
+ status: [200],
202
+ expect: initData('v_inbox_file~v2.'),
203
+ },
204
+ 'POST /v2/post/publish/status/fetch': {
205
+ method: 'POST',
206
+ path: '/v2/post/publish/status/fetch/',
207
+ body: JSON.stringify({ publish_id: PUBLISH }),
208
+ headers: postHeaders,
209
+ status: [200],
210
+ // a publish whose chunk has not arrived: PROCESSING_UPLOAD with nothing uploaded, an empty id list
211
+ expect: (b) => typeof b === 'string'
212
+ && b.includes('"status":"PROCESSING_UPLOAD"') && b.includes('"publicaly_available_post_id":[]') && b.includes('"uploaded_bytes":0')
213
+ && b.includes('"code":"ok"'),
214
+ },
215
+ 'PUT /video': {
216
+ method: 'PUT',
217
+ path: UPLOAD,
218
+ headers: { 'content-type': 'video/mp4', 'content-range': `bytes 0-${CHUNK.length - 1}/${CHUNK.length}` },
219
+ // the one chunk of a one-chunk upload: 201, "All chunks uploaded; processing begins"
220
+ status: [201],
221
+ },
222
+ };
223
+
224
+ /**
225
+ * Every VENDOR method/path pair a reader of `routeTikTokTwinRequest` can see the router branch on,
226
+ * PLUS near-miss pairs (wrong method on a claimed path, a sibling open-API route, the legacy v1
227
+ * path) that must answer the vendor-shaped refusal. Written by hand from the router, so the census
228
+ * can catch VENDOR surface that is served without being claimed. The `/_twin/*` control routes are
229
+ * DELIBERATELY outside this census: they are twin-only scaffolding (ADDING_A_TWIN.md §6), answer
230
+ * non-refusal by design, and are never claimable — so this check is blind to them BY CONSTRUCTION,
231
+ * and a §9 reader must compare `twinControl`'s branches against the scaffolding list in the README.
232
+ */
233
+ const ROUTER_SURFACE: Array<[string, string]> = [
234
+ ['GET', `/v2/auth/authorize?${authQuery}`],
235
+ // The vendor's OWN spelling carries a trailing slash; it must reach the same route.
236
+ ['GET', `/v2/auth/authorize/?${authQuery}`],
237
+ ['POST', '/v2/auth/authorize'],
238
+ ['POST', '/v2/oauth/token/'],
239
+ ['GET', '/v2/oauth/token/'],
240
+ ['POST', '/v2/oauth/revoke/'],
241
+ ['GET', '/v2/oauth/revoke/'],
242
+ ['GET', '/v2/user/info/'],
243
+ // …and the slash-less spelling Dub's own client builds (`${baseUrl}/user/info/` lands with one,
244
+ // but a hand-written integration may not) — it must reach the same route, not refuse.
245
+ ['GET', '/v2/user/info'],
246
+ ['POST', '/v2/user/info/'],
247
+ ['POST', '/v2/video/list/'],
248
+ ['GET', '/v2/video/list/'],
249
+ ['POST', '/v2/video/query/'],
250
+ ['GET', '/v2/video/query/'],
251
+ ['POST', '/v2/post/publish/creator_info/query/'],
252
+ ['GET', '/v2/post/publish/creator_info/query/'],
253
+ ['POST', '/v2/post/publish/video/init/'],
254
+ ['POST', '/v2/post/publish/inbox/video/init/'],
255
+ ['POST', '/v2/post/publish/status/fetch/'],
256
+ ['PUT', '/video/'],
257
+ ['GET', '/video/'],
258
+ // Unmodelled TikTok open-API surface — every one must refuse, never fake a success.
259
+ ['POST', '/v2/post/publish/content/init/'],
260
+ ['POST', '/v2/research/video/query/'],
261
+ ['POST', '/oauth/access_token/'],
262
+ ];
263
+
264
+ /** The refusal an unmodelled route gets: the API family's `invalid_params`, naming the route. */
265
+ const isRefusal = (res: TikTokResponse): boolean =>
266
+ res.status === 400
267
+ && isObject(res.body)
268
+ && isObject((res.body as Record<string, unknown>)['error'])
269
+ && ((res.body as Record<string, any>)['error']['code']) === 'invalid_params'
270
+ && String((res.body as Record<string, any>)['error']['message']).includes('is not a TikTok open API endpoint');
271
+
272
+ const AUTH_REQUEST_RE = /name="auth_request" value="([^"]+)"/;
273
+
274
+ export async function checkTikTokConformance(opts: { root?: string } = {}): Promise<TikTokConformanceReport> {
275
+ void opts;
276
+ const snapshot = tiktokTwinSnapshot();
277
+ const violations: string[] = [];
278
+ // Always a THROWAWAY root, even when a caller passes one: the check mints and then REVOKES
279
+ // credentials, and doing that in an operator's world would disconnect them from their own twin.
280
+ const root = mkdtempSync(join(tmpdir(), 'tiktok-conformance-'));
281
+ const call = (method: string, path: string, body?: string, headers?: Record<string, string>, extra: { bytes?: Uint8Array; at?: string } = {}): Promise<TikTokResponse> =>
282
+ handleTikTokTwinRequest({ method, path, ...(body !== undefined ? { body } : {}), ...(headers ? { headers } : {}), ...(extra.bytes ? { bytes: extra.bytes } : {}), root, origin: DEMO_ORIGIN, occurredAt: extra.at ?? AT });
283
+
284
+ /** Drive a full consent (granting every requested scope) and return the minted code. */
285
+ const mintCode = async (scope = SCOPE): Promise<string> => {
286
+ const q = authQuery.replace(`scope=${encodeURIComponent(SCOPE)}`, `scope=${encodeURIComponent(scope)}`);
287
+ const a = await call('GET', `/v2/auth/authorize?${q}`);
288
+ const rid = AUTH_REQUEST_RE.exec(String(a.body))?.[1] ?? '';
289
+ const decision = new URLSearchParams({ auth_request: rid, decision: 'allow' });
290
+ for (const s of scope.split(',')) decision.append('scope', s);
291
+ const d = await call('POST', '/_twin/consent', decision.toString());
292
+ return new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
293
+ };
294
+ const redeem = (code: string) =>
295
+ call('POST', '/v2/oauth/token/', form({ ...clientCreds, code, grant_type: 'authorization_code', redirect_uri: REDIRECT_URI }), { 'content-type': 'application/x-www-form-urlencoded' });
296
+
297
+ let probed = 0;
298
+ try {
299
+ // ── the ROUND TRIP, driven for real, before any probing ──
300
+ const authRes = await call('GET', `/v2/auth/authorize?${authQuery}`);
301
+ const requestId = AUTH_REQUEST_RE.exec(String(authRes.body))?.[1] ?? '';
302
+ if (!requestId) violations.push('the authorize endpoint did not render a consent form carrying an auth_request handle');
303
+ const decision = new URLSearchParams({ auth_request: requestId, decision: 'allow' });
304
+ for (const s of SCOPE.split(',')) decision.append('scope', s);
305
+ const settled = await call('POST', '/_twin/consent', decision.toString());
306
+ const location = settled.headers?.location ?? '';
307
+ if (settled.status !== 302 || !location.startsWith(REDIRECT_URI)) {
308
+ violations.push(`consent did not 302 back to the registered redirect_uri: ${settled.status} ${location}`);
309
+ }
310
+ const back = new URL(location || 'http://invalid.test/');
311
+ const code = back.searchParams.get('code') ?? '';
312
+ if (back.searchParams.get('state') !== 'probe-state') {
313
+ violations.push(`the redirect did not echo the caller's state verbatim: ${back.searchParams.get('state')}`);
314
+ }
315
+ if (back.searchParams.get('scopes') !== SCOPE) {
316
+ violations.push(`the redirect did not carry the documented granted-scopes parameter: ${back.searchParams.get('scopes')}`);
317
+ }
318
+ if (!code.includes('*!')) {
319
+ violations.push(`the redirect did not carry a TikTok-shaped authorization code: ${code}`);
320
+ }
321
+ const extras = [...back.searchParams.keys()].filter((k) => k !== 'state' && k !== 'code' && k !== 'scopes');
322
+ if (extras.length > 0) {
323
+ // The documented callback carries exactly code, scopes and state; an invented parameter is
324
+ // surface an integration could come to depend on and then break against the real vendor.
325
+ violations.push(`the success redirect carries parameters TikTok's documented callback does not: ${extras.join(', ')}`);
326
+ }
327
+
328
+ const tokenRes = await redeem(code);
329
+ const tokens = tokenRes.body as Record<string, any>;
330
+ if (tokenRes.status !== 200 || typeof tokens?.access_token !== 'string') {
331
+ violations.push(`the minted code was not redeemable at the token endpoint: ${tokenRes.status} ${JSON.stringify(tokenRes.body).slice(0, 160)}`);
332
+ }
333
+ // …and the token is USABLE: it answers the Display API with the consenting persona.
334
+ const me = await call('GET', '/v2/user/info/?fields=open_id,union_id,username', undefined, { authorization: `Bearer ${tokens?.access_token}` });
335
+ const meUser = (me.body as Record<string, any>)?.data?.user;
336
+ if (me.status !== 200 || meUser?.union_id !== ADA.unionId || meUser?.username !== ADA.username || meUser?.open_id !== ADA_OPEN_ID) {
337
+ violations.push(`the minted token did not answer /v2/user/info/ with the consenting persona: ${me.status} ${JSON.stringify(me.body).slice(0, 160)}`);
338
+ }
339
+ // A redeemed code must be DEAD. This is the security property, so it is checked here and not
340
+ // only in the manifest.
341
+ const replay = await redeem(code);
342
+ if (replay.status !== 400 || (replay.body as Record<string, unknown>)?.['error'] !== 'invalid_grant') {
343
+ violations.push(`a replayed authorization code was not refused: ${replay.status} ${JSON.stringify(replay.body).slice(0, 120)}`);
344
+ }
345
+ // The refresh token ROTATES: the replacement works, the spent one is refused.
346
+ const refresh1 = await call('POST', '/v2/oauth/token/', form({ ...clientCreds, grant_type: 'refresh_token', refresh_token: String(tokens?.refresh_token) }), { 'content-type': 'application/x-www-form-urlencoded' });
347
+ const refreshed = refresh1.body as Record<string, any>;
348
+ if (refresh1.status !== 200 || typeof refreshed?.refresh_token !== 'string' || refreshed.refresh_token === tokens?.refresh_token) {
349
+ violations.push(`the refresh grant did not rotate the refresh token: ${refresh1.status} ${JSON.stringify(refresh1.body).slice(0, 120)}`);
350
+ }
351
+ const spent = await call('POST', '/v2/oauth/token/', form({ ...clientCreds, grant_type: 'refresh_token', refresh_token: String(tokens?.refresh_token) }), { 'content-type': 'application/x-www-form-urlencoded' });
352
+ if (spent.status !== 400) violations.push(`a spent refresh token was accepted a second time: ${spent.status}`);
353
+
354
+ // ── the endpoint census, THREE ways ──
355
+ const claimed = new Set(snapshot.implementedEndpoints);
356
+ for (const key of Object.keys(PROBES)) {
357
+ if (!claimed.has(key)) violations.push(`probe '${key}' does not correspond to any claimed endpoint — the probe table has drifted`);
358
+ }
359
+ for (const [method, probePath] of ROUTER_SURFACE) {
360
+ const res = await call(method, probePath);
361
+ const key = `${method} ${(probePath.split('?')[0] ?? '').replace(/\/+$/, '')}`;
362
+ if (!isRefusal(res) && !claimed.has(key)) {
363
+ violations.push(`the router answers '${key}' (${res.status}) but the snapshot does not claim it — served surface outside the census is deletable without this check noticing`);
364
+ }
365
+ }
366
+
367
+ // Fixtures per destructive probe, so probe ORDER cannot make this check lie: the revoke probe
368
+ // kills a WHOLE (app, user) grant family, which would take the Display API probes' token with
369
+ // it if the two fixtures shared a user. The revoke fixture is therefore minted under the
370
+ // SECOND persona's session (switched and restored via the twin-only session control).
371
+ const probeTokens = async () => (await redeem(await mintCode())).body as Record<string, any>;
372
+ await call('POST', '/_twin/session', JSON.stringify({ union_id: GRACE.unionId }));
373
+ const revokeFixture = await probeTokens();
374
+ await call('POST', '/_twin/session', JSON.stringify({ union_id: ADA.unionId }));
375
+ const displayFixture = await probeTokens();
376
+ const tokenProbeCode = await mintCode();
377
+
378
+ // the posting fixtures: a token that may post, a publish to read (staged, nothing uploaded yet)
379
+ // and a second one whose upload URL the PUT probe sends its one chunk to
380
+ const posterToken = String(((await call('POST', '/_twin/tokens', JSON.stringify({ union_id: ADA.unionId, scopes: ['user.info.basic', 'video.list', 'video.publish', 'video.upload'] }))).body as Record<string, any>)?.access_token ?? '');
381
+ const postAuth = { ...postHeaders, authorization: `Bearer ${posterToken}` };
382
+ const initFixture = async () => ((await call('POST', '/v2/post/publish/video/init/', JSON.stringify({ post_info: { privacy_level: 'PUBLIC_TO_EVERYONE' }, source_info: sourceInfo }), postAuth)).body as Record<string, any>)?.data ?? {};
383
+ const statusFixture = await initFixture();
384
+ const uploadFixture = await initFixture();
385
+ const uploadPath = (() => { try { const u = new URL(String(uploadFixture.upload_url)); return `${u.pathname}${u.search}`; } catch { return '/video/'; } })();
386
+
387
+ const substitute = (s: string) => s.replace(CODE, tokenProbeCode).replace(ACCESS, String(displayFixture?.access_token ?? ''))
388
+ .replace(POSTER, posterToken).replace(PUBLISH, String(statusFixture.publish_id ?? '')).replace(UPLOAD, uploadPath);
389
+
390
+ for (const endpoint of snapshot.implementedEndpoints) {
391
+ const probe = PROBES[endpoint];
392
+ if (!probe) {
393
+ violations.push(`endpoint '${endpoint}' is claimed but has no conformance probe — the claim is unverified`);
394
+ continue;
395
+ }
396
+ const [claimedMethod, claimedPath] = endpoint.split(' ');
397
+ if (probe.method !== claimedMethod) {
398
+ violations.push(`probe '${endpoint}' drives ${probe.method}, not ${claimedMethod}`);
399
+ continue;
400
+ }
401
+ if (probe.path !== UPLOAD && (probe.path.split('?')[0] ?? '').replace(/\/+$/, '') !== claimedPath) {
402
+ violations.push(`probe '${endpoint}' drives ${probe.path}, which is not the claimed path ${claimedPath}`);
403
+ continue;
404
+ }
405
+ const path = substitute(probe.path);
406
+ const body = endpoint === 'POST /v2/oauth/revoke'
407
+ ? form({ ...clientCreds, token: String(revokeFixture?.access_token ?? '') })
408
+ : probe.body === undefined ? undefined : substitute(probe.body);
409
+ const headers = probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
410
+ const res = await call(probe.method, path, body, headers, endpoint === 'PUT /video' ? { bytes: CHUNK } : {});
411
+ probed += 1;
412
+ if (!probe.status.includes(res.status)) {
413
+ violations.push(`endpoint '${endpoint}' answered ${res.status} (expected ${probe.status.join('/')}): ${JSON.stringify(res.body).slice(0, 160)}`);
414
+ continue;
415
+ }
416
+ if (probe.expect && !probe.expect(res.body)) {
417
+ violations.push(`endpoint '${endpoint}' answered ${res.status} but the body is not the shape this route returns: ${JSON.stringify(res.body).slice(0, 200)}`);
418
+ }
419
+ }
420
+
421
+ // Every grant type the snapshot claims must actually dispatch — an unsupported one answers
422
+ // `unsupported_grant_type`, so a claimed-but-missing grant is caught by name.
423
+ for (const grantType of snapshot.grantTypes) {
424
+ const res = await call('POST', '/v2/oauth/token/', form({ ...clientCreds, grant_type: grantType }), { 'content-type': 'application/x-www-form-urlencoded' });
425
+ const err = (res.body as Record<string, unknown> | null)?.['error'];
426
+ if (err === 'unsupported_grant_type') violations.push(`grant type '${grantType}' is claimed but the token endpoint does not dispatch it`);
427
+ }
428
+
429
+ // Every resource type the twin projects must be reachable through the protocol — a type with
430
+ // no observable effect is state a consumer can never see. Each witness reads a VALUE only a
431
+ // live handler that genuinely projects that type can produce (never a bare status, and never
432
+ // the snapshot asserting about itself).
433
+ const witness: Record<string, () => Promise<boolean>> = {
434
+ // the app's registered NAME is only on the page if the oauth_client row projected
435
+ oauth_client: async () => String((await call('GET', `/v2/auth/authorize?${authQuery}`)).body).includes('Twin Demo App'),
436
+ // the persona's username comes back only if the account row projected
437
+ account: async () => {
438
+ const t = await probeTokens();
439
+ const r = await call('GET', '/v2/user/info/?fields=username', undefined, { authorization: `Bearer ${t?.access_token}` });
440
+ return r.status === 200 && (r.body as Record<string, any>)?.data?.user?.username === ADA.username;
441
+ },
442
+ // switching the session changes WHO the authorization page consents as
443
+ session: async () => {
444
+ await call('POST', '/_twin/session', JSON.stringify({ union_id: GRACE.unionId }));
445
+ const shows = String((await call('GET', `/v2/auth/authorize?${authQuery}`)).body).includes(`@${GRACE.username}`);
446
+ await call('POST', '/_twin/session', JSON.stringify({ union_id: ADA.unionId }));
447
+ return shows;
448
+ },
449
+ // an auth_request that did not project could not have carried a settleable handle
450
+ auth_request: async () => /name="auth_request" value="ar_[0-9a-f]{32}"/.test(String((await call('GET', `/v2/auth/authorize?${authQuery}`)).body)),
451
+ // a code that did not project could not be REDEEMED — the witness is the SUCCESS, because
452
+ // the refusal is exactly what a twin projecting nothing would also say
453
+ _authorization_code: async () => {
454
+ const r = await redeem(await mintCode());
455
+ return r.status === 200 && typeof (r.body as Record<string, unknown>)?.['access_token'] === 'string';
456
+ },
457
+ _access_token: async () => {
458
+ const t = await probeTokens();
459
+ return (await call('GET', '/v2/user/info/?fields=open_id', undefined, { authorization: `Bearer ${t?.access_token}` })).status === 200;
460
+ },
461
+ _refresh_token: async () => {
462
+ const t = await probeTokens();
463
+ return (await call('POST', '/v2/oauth/token/', form({ ...clientCreds, grant_type: 'refresh_token', refresh_token: String(t?.refresh_token) }), { 'content-type': 'application/x-www-form-urlencoded' })).status === 200;
464
+ },
465
+ _client_token: async () => {
466
+ const r = await call('POST', '/v2/oauth/token/', form({ ...clientCreds, grant_type: 'client_credentials' }), { 'content-type': 'application/x-www-form-urlencoded' });
467
+ const b = r.body as Record<string, any>;
468
+ return r.status === 200 && typeof b?.access_token === 'string' && b.access_token.startsWith('clt.') && b.expires_in === 7200;
469
+ },
470
+ // the grant row is what revocation resolves: revoking the ACCESS half must kill the REFRESH
471
+ // half too, and only a projected grant makes that pair-wide sweep possible
472
+ grant: async () => {
473
+ const t = await probeTokens();
474
+ await call('POST', '/v2/oauth/revoke/', form({ ...clientCreds, token: String(t?.access_token) }), { 'content-type': 'application/x-www-form-urlencoded' });
475
+ const r = await call('POST', '/v2/oauth/token/', form({ ...clientCreds, grant_type: 'refresh_token', refresh_token: String(t?.refresh_token) }), { 'content-type': 'application/x-www-form-urlencoded' });
476
+ return r.status === 400;
477
+ },
478
+ // a video row that did not project could not appear in the list read
479
+ video: async () => {
480
+ const t = await probeTokens();
481
+ const r = await call('POST', '/v2/video/list/?fields=id,title', JSON.stringify({ max_count: 20 }), { authorization: `Bearer ${t?.access_token}`, 'content-type': 'application/json' });
482
+ const videos = (r.body as Record<string, any>)?.data?.videos as Array<Record<string, unknown>> | undefined;
483
+ return r.status === 200 && Array.isArray(videos) && videos.some((v) => v['id'] === ADA_VIDEO.id);
484
+ },
485
+ // a publish row is what status/fetch reads once the last chunk has landed: a finished Direct
486
+ // Post answers PUBLISH_COMPLETE and names its post, which only a projected publish can say
487
+ publish: async () => {
488
+ // a token of its own: the grant witness's revoke has swept every token of this (app, user) pair
489
+ const fresh = String(((await call('POST', '/_twin/tokens', JSON.stringify({ union_id: ADA.unionId, scopes: ['video.publish'] }))).body as Record<string, any>)?.access_token ?? '');
490
+ const auth = { ...postHeaders, authorization: `Bearer ${fresh}` };
491
+ const started = ((await call('POST', '/v2/post/publish/video/init/', JSON.stringify({ post_info: { privacy_level: 'PUBLIC_TO_EVERYONE' }, source_info: sourceInfo }), auth)).body as Record<string, any>)?.data ?? {};
492
+ const url = new URL(String(started.upload_url ?? 'http://invalid.test/video/'));
493
+ await call('PUT', `${url.pathname}${url.search}`, undefined, { 'content-type': 'video/mp4', 'content-range': `bytes 0-${CHUNK.length - 1}/${CHUNK.length}` }, { bytes: CHUNK });
494
+ const later = new Date(Date.parse(AT) + 60_000).toISOString();
495
+ const r = await call('POST', '/v2/post/publish/status/fetch/', JSON.stringify({ publish_id: started.publish_id }), auth, { at: later });
496
+ return typeof r.body === 'string' && r.body.includes('"status":"PUBLISH_COMPLETE"') && /"publicaly_available_post_id":\[\d{19}\]/.test(r.body);
497
+ },
498
+ // an armed rate window turns the NEXT read into the documented 429 + rate_limit_exceeded
499
+ _rate_window: async () => {
500
+ const t = await probeTokens();
501
+ await call('POST', '/_twin/rate_limit', JSON.stringify({ endpoint: 'user_info', open_id: ADA_OPEN_ID, used: 600 }));
502
+ const r = await call('GET', '/v2/user/info/?fields=open_id', undefined, { authorization: `Bearer ${t?.access_token}` });
503
+ await call('POST', '/_twin/rate_limit', JSON.stringify({ endpoint: 'user_info', open_id: ADA_OPEN_ID, used: 0 }));
504
+ return r.status === 429 && (r.body as Record<string, any>)?.error?.code === 'rate_limit_exceeded';
505
+ },
506
+ };
507
+ for (const type of snapshot.resourceTypes) {
508
+ const probe = witness[type];
509
+ if (!probe) {
510
+ violations.push(`resource type '${type}' has no reachability witness — the claim is unverified`);
511
+ continue;
512
+ }
513
+ if (!(await probe())) violations.push(`resource type '${type}' is not reachable through any served endpoint`);
514
+ }
515
+ } finally {
516
+ rmSync(root, { recursive: true, force: true });
517
+ }
518
+
519
+ return {
520
+ ok: violations.length === 0,
521
+ endpointsChecked: snapshot.implementedEndpoints.length,
522
+ endpointsProbed: probed,
523
+ resourceTypesChecked: snapshot.resourceTypes.length,
524
+ violations,
525
+ };
526
+ }