@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,305 @@
1
+ // TIKTOK UI JOURNEY — the authorization leg, driven in a real headless chromium.
2
+ //
3
+ // The browser is the protocol participant: the app redirects, a human reads what the app is asking
4
+ // for, DECIDES WHICH PERMISSIONS TO LEAVE ON, and clicks Continue (or Cancel). The browser is
5
+ // bounced back to the app's callback carrying a code and the granted `scopes` — which the test then
6
+ // REDEEMS at the twin's token endpoint and USES at the Display API. A journey that stopped at "the
7
+ // screen rendered" would prove the page exists; this one proves the round trip closes, and that the
8
+ // person's per-scope decision really reaches the token.
9
+ //
10
+ // Seeds through the pack's OWN write path (the twin-only client/session controls and the real
11
+ // authorize endpoint — never a hand-written events.jsonl), boots the real twin handler on an
12
+ // ephemeral port with the app's callback page alongside it, and drives it with role/text locators
13
+ // only. Every step writes a filmstrip frame (the harness helpers do it), so a reviewer can LOOK at
14
+ // the screens rather than trusting a green tick.
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { atPath, clickByName, requireBrowser, runUiJourney, visible } from '@volter/world-tooling';
17
+ import { DEFAULT_ACCOUNTS } from './tiktok-store.ts';
18
+ import { handleTikTokTwinRequest } from './tiktok-twin.ts';
19
+
20
+ const JOURNEY_LABEL = 'tiktok UI journey';
21
+
22
+ const CLIENT_KEY = 'awjourneyapp000001';
23
+ const CLIENT_SECRET = 'journey-secret-0000';
24
+ const APP_NAME = 'Twin Journey Scheduler';
25
+ const SCOPE = 'user.info.basic,user.info.profile,video.list';
26
+ const ADA = DEFAULT_ACCOUNTS[0]!;
27
+ /** The integrating app's callback, served on the same loopback origin (see `startJourneyServer`). */
28
+ const APP_CALLBACK_PATH = '/oauth/callback';
29
+
30
+ /**
31
+ * The journey's server: the REAL twin handler, plus a tiny `/oauth/callback` page standing in for
32
+ * the integrating app.
33
+ *
34
+ * WHY THEY SHARE ONE ORIGIN. The journey harness is loopback-only and installs a route guard that
35
+ * ABORTS any request leaving the server origin it was given — so a callback server on its own port
36
+ * is unreachable from the piloted page. Sharing the loopback origin is a harness accommodation, not
37
+ * a fidelity claim; the SDK fidelity test exercises the ordinary cross-origin configuration. What
38
+ * the journey proves is the leg only a browser can: a human's clicks turning into a code and a
39
+ * granted scope set on the app's callback URL.
40
+ */
41
+ function startJourneyServer(root: string): { url: string; stop: () => void; last: () => URL | null } {
42
+ let last: URL | null = null;
43
+ const server = Bun.serve({
44
+ hostname: '127.0.0.1',
45
+ port: 0,
46
+ idleTimeout: 30,
47
+ async fetch(request) {
48
+ const url = new URL(request.url);
49
+
50
+ // ── the harness's landing page ──
51
+ // `runUiJourney` navigates to the server URL and waits for `#root` to have children before it
52
+ // hands the page over. A vendor twin has no app shell at `/` (tiktok.com's root is the For
53
+ // You feed, which this pack does not model), so the JOURNEY supplies one. Test scaffolding in
54
+ // this file — never pack surface.
55
+ if (url.pathname === '/') {
56
+ return new Response(
57
+ `<!doctype html><html><body><div id="root"><h1>Twin TikTok world</h1>`
58
+ + `<p>the ${APP_NAME} integration starts by redirecting to the tiktok.com authorization page</p></div></body></html>`,
59
+ { headers: { 'content-type': 'text/html; charset=utf-8' } },
60
+ );
61
+ }
62
+
63
+ // ── the app's callback ──
64
+ if (url.pathname === APP_CALLBACK_PATH) {
65
+ last = url;
66
+ const code = url.searchParams.get('code');
67
+ const error = url.searchParams.get('error');
68
+ const body = code
69
+ ? `<h1>${APP_NAME}</h1><p>authorization code received</p><pre>${code}</pre><p>granted: ${url.searchParams.get('scopes')}</p>`
70
+ : `<h1>${APP_NAME}</h1><p>authorization failed</p><pre>${error ?? 'no error parameter'}</pre>`;
71
+ return new Response(`<!doctype html><html><body>${body}</body></html>`, {
72
+ headers: { 'content-type': 'text/html; charset=utf-8' },
73
+ });
74
+ }
75
+
76
+ // ── everything else: the real twin ──
77
+ const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
78
+ const headers: Record<string, string> = {};
79
+ request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
80
+ const res = await handleTikTokTwinRequest({
81
+ method: request.method,
82
+ path: url.pathname + (url.search || ''),
83
+ body,
84
+ headers,
85
+ root,
86
+ occurredAt: new Date().toISOString(),
87
+ origin: url.origin,
88
+ });
89
+ const out = { ...(res.headers ?? {}) };
90
+ if (typeof res.body === 'string') {
91
+ if (!out['content-type'] && res.body) out['content-type'] = 'text/html; charset=utf-8';
92
+ return new Response(res.body, { status: res.status, headers: out });
93
+ }
94
+ out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
95
+ return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
96
+ },
97
+ });
98
+ return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true), last: () => last };
99
+ }
100
+
101
+ /** Register the journey's TikTok app through the twin's own write path. */
102
+ async function seedClient(twinOrigin: string, redirectUri: string) {
103
+ const res = await fetch(`${twinOrigin}/_twin/clients`, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json' },
106
+ body: JSON.stringify({ client_key: CLIENT_KEY, client_secret: CLIENT_SECRET, name: APP_NAME, redirect_uris: [redirectUri] }),
107
+ });
108
+ expect(res.status).toBe(200);
109
+ }
110
+
111
+ const authUrl = (twinOrigin: string, redirectUri: string, extra: Record<string, string> = {}) =>
112
+ `${twinOrigin}/v2/auth/authorize?${new URLSearchParams({
113
+ client_key: CLIENT_KEY,
114
+ response_type: 'code',
115
+ redirect_uri: redirectUri,
116
+ scope: SCOPE,
117
+ state: 'journey-state-1',
118
+ ...extra,
119
+ })}`;
120
+
121
+ describe('tiktok UI journey', () => {
122
+ test('a human reads the authorization page, clicks Continue — and the code redeems into a usable identity', async () => {
123
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
124
+
125
+ // Captured inside the journey while the server is still up; asserted after teardown, so a
126
+ // torn-down fixture can never read as a product failure.
127
+ let redeemed: Record<string, any> | null = null;
128
+ let identity: Record<string, any> | null = null;
129
+ let callback: URL | null = null;
130
+
131
+ await runUiJourney({
132
+ label: 'tiktok authorization round trip',
133
+ // The pack's write path IS its HTTP server, and the harness calls `seed` BEFORE `serve` — so
134
+ // the app registration happens as the journey's first step instead of here.
135
+ seed: async () => {},
136
+ serve: (root) => startJourneyServer(root),
137
+ journey: async (page) => {
138
+ const origin = new URL(page.url()).origin;
139
+ const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
140
+ await seedClient(origin, redirectUri);
141
+
142
+ // 1. The app redirects the browser to the vendor. What lands is TikTok's AUTHORIZATION
143
+ // page, naming the registered app, the signed-in account, and the scopes in the
144
+ // vendor's own published wording.
145
+ await page.goto(authUrl(origin, redirectUri));
146
+ await atPath(page, '/v2/auth/authorize');
147
+ await visible(page, `${APP_NAME} would like to access your TikTok account`);
148
+ await visible(page, ADA.displayName);
149
+ await visible(page, `@${ADA.username}`);
150
+ await visible(page, "Read a user's profile info (open id, avatar, display name...)");
151
+ await visible(page, "Read a user's public videos on TikTok");
152
+
153
+ // 2. Continue. The browser leaves the vendor's path and lands on the APP's callback.
154
+ await clickByName(page, 'Continue');
155
+ await atPath(page, APP_CALLBACK_PATH);
156
+ await visible(page, 'authorization code received');
157
+
158
+ callback = new URL(page.url());
159
+ const code = callback.searchParams.get('code')!;
160
+
161
+ // 3. THE POINT OF THE WHOLE FLOW: the code a browser CLICK produced is redeemable at the
162
+ // token endpoint, and the token it yields answers the Display API with the account that
163
+ // was signed in on the screen.
164
+ const tokenRes = await fetch(`${origin}/v2/oauth/token/`, {
165
+ method: 'POST',
166
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
167
+ body: new URLSearchParams({
168
+ client_key: CLIENT_KEY,
169
+ client_secret: CLIENT_SECRET,
170
+ code,
171
+ grant_type: 'authorization_code',
172
+ redirect_uri: redirectUri,
173
+ }).toString(),
174
+ });
175
+ redeemed = (await tokenRes.json()) as Record<string, any>;
176
+
177
+ const me = await fetch(`${origin}/v2/user/info/?fields=open_id,union_id,username`, {
178
+ headers: { authorization: `Bearer ${redeemed.access_token}` },
179
+ });
180
+ identity = (await me.json()) as Record<string, any>;
181
+ },
182
+ });
183
+
184
+ expect(callback!.searchParams.get('state')).toBe('journey-state-1');
185
+ expect(callback!.searchParams.get('scopes')).toBe(SCOPE);
186
+ expect(redeemed!.token_type).toBe('Bearer');
187
+ expect(redeemed!.scope).toBe(SCOPE);
188
+ expect(String(redeemed!.refresh_token)).toMatch(/^rft\./);
189
+ // The account that was on the screen is the identity the token yields — the click really
190
+ // consented as that person.
191
+ expect(identity!.data.user.union_id).toBe(ADA.unionId);
192
+ expect(identity!.data.user.username).toBe(ADA.username);
193
+ }, 30_000);
194
+
195
+ test('unchecking a permission narrows the grant — the callback and the token both say so, and the Display API refuses the field', async () => {
196
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
197
+
198
+ let callback: URL | null = null;
199
+ let refusal: Record<string, any> | null = null;
200
+ let redeemed: Record<string, any> | null = null;
201
+
202
+ await runUiJourney({
203
+ label: 'tiktok granular consent',
204
+ seed: async () => {},
205
+ serve: (root) => startJourneyServer(root),
206
+ journey: async (page) => {
207
+ const origin = new URL(page.url()).origin;
208
+ const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
209
+ await seedClient(origin, redirectUri);
210
+
211
+ await page.goto(authUrl(origin, redirectUri));
212
+ // The person switches OFF the profile permission before continuing — the decision TikTok's
213
+ // own `scopes` callback parameter exists to report.
214
+ await page.locator('input[value="user.info.profile"]').uncheck();
215
+ await clickByName(page, 'Continue');
216
+ await atPath(page, APP_CALLBACK_PATH);
217
+ callback = new URL(page.url());
218
+
219
+ const tokenRes = await fetch(`${origin}/v2/oauth/token/`, {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
222
+ body: new URLSearchParams({
223
+ client_key: CLIENT_KEY,
224
+ client_secret: CLIENT_SECRET,
225
+ code: callback.searchParams.get('code')!,
226
+ grant_type: 'authorization_code',
227
+ redirect_uri: redirectUri,
228
+ }).toString(),
229
+ });
230
+ redeemed = (await tokenRes.json()) as Record<string, any>;
231
+ const me = await fetch(`${origin}/v2/user/info/?fields=username`, {
232
+ headers: { authorization: `Bearer ${redeemed.access_token}` },
233
+ });
234
+ refusal = (await me.json()) as Record<string, any>;
235
+ },
236
+ });
237
+
238
+ expect(callback!.searchParams.get('scopes')).toBe('user.info.basic,video.list');
239
+ expect(redeemed!.scope).toBe('user.info.basic,video.list');
240
+ expect(refusal!.error.code).toBe('scope_not_authorized');
241
+ }, 30_000);
242
+
243
+ test('Cancel bounces back with error=access_denied and no code', async () => {
244
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
245
+
246
+ let callback: URL | null = null;
247
+ await runUiJourney({
248
+ label: 'tiktok authorization denial',
249
+ seed: async () => {},
250
+ serve: (root) => startJourneyServer(root),
251
+ journey: async (page) => {
252
+ const origin = new URL(page.url()).origin;
253
+ const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
254
+ await seedClient(origin, redirectUri);
255
+
256
+ await page.goto(authUrl(origin, redirectUri));
257
+ await visible(page, `${APP_NAME} would like to access your TikTok account`);
258
+ await clickByName(page, 'Cancel');
259
+ await atPath(page, APP_CALLBACK_PATH);
260
+ await visible(page, 'authorization failed');
261
+ await visible(page, 'access_denied');
262
+ callback = new URL(page.url());
263
+ },
264
+ });
265
+
266
+ expect(callback!.searchParams.get('error')).toBe('access_denied');
267
+ expect(callback!.searchParams.get('state')).toBe('journey-state-1');
268
+ expect(callback!.searchParams.get('code')).toBeNull();
269
+ }, 30_000);
270
+
271
+ test('a misconfigured redirect_uri stays on the vendor error page, and the app is NEVER reached', async () => {
272
+ if (!(await requireBrowser(JOURNEY_LABEL))) return;
273
+
274
+ let finalUrl = '';
275
+ // The HANDLE, kept so the assertion can read it AFTER the journey (the googleoauth §9 lesson:
276
+ // reading `last()` inside `serve` stores a value that is null by construction, and the security
277
+ // assertion could then never fail).
278
+ let app: { last: () => URL | null } | null = null;
279
+ await runUiJourney({
280
+ label: 'tiktok redirect_uri mismatch error page',
281
+ seed: async () => {},
282
+ serve: (root) => {
283
+ const server = startJourneyServer(root);
284
+ app = server;
285
+ return server;
286
+ },
287
+ journey: async (page) => {
288
+ const origin = new URL(page.url()).origin;
289
+ await seedClient(origin, `${origin}${APP_CALLBACK_PATH}`);
290
+
291
+ // The single most common Login Kit integration bug, seen the way a developer sees it: the
292
+ // browser STAYS on the vendor showing the error, and the app is never reached.
293
+ await page.goto(authUrl(origin, `${origin}/oauth/WRONG`));
294
+ await atPath(page, '/v2/auth/authorize');
295
+ await visible(page, 'Something went wrong');
296
+ await visible(page, 'Error 400: invalid_request');
297
+ finalUrl = page.url();
298
+ },
299
+ });
300
+
301
+ expect(new URL(finalUrl).pathname, 'the browser must end on the vendor authorize path showing the error').toBe('/v2/auth/authorize');
302
+ // Read AFTER the journey: the app must never have been reached at all.
303
+ expect(app!.last(), 'the app callback must never have been called').toBeNull();
304
+ }, 30_000);
305
+ });
@@ -0,0 +1,89 @@
1
+ // What TikTok's processing reads off an uploaded file, for the fields the twin reports on the video it
2
+ // publishes: `duration`, `width` and `height` (the Video Object's own fields), and the
3
+ // max_video_post_duration_sec check the init promised, and the frame rate (23–60 FPS) the media transfer
4
+ // guide requires.
5
+ //
6
+ // Transcribed from the YouTube pack's reader (youtube-media.ts): only the ISO BMFF container (MP4/MOV,
7
+ // the format TikTok recommends) is read, and only its headers — `moov/mvhd` for the duration and the
8
+ // first `moov/trak/tkhd` with a picture size for the dimensions; that video track's
9
+ // `mdia/mdhd` timescale and duration over its `mdia/minf/stbl/stts` sample count for the frame rate. No decoder, no toolchain: a file the
10
+ // reader does not understand leaves the fields unset rather than inventing values.
11
+ export type MediaProbe = { durationSeconds?: number; width?: number; height?: number; frameRate?: number };
12
+
13
+ type Box = { type: string; start: number; end: number };
14
+
15
+ function boxes(view: DataView, start: number, end: number): Box[] {
16
+ const out: Box[] = [];
17
+ let at = start;
18
+ while (at + 8 <= end) {
19
+ let size = view.getUint32(at);
20
+ const type = String.fromCharCode(view.getUint8(at + 4), view.getUint8(at + 5), view.getUint8(at + 6), view.getUint8(at + 7));
21
+ let header = 8;
22
+ if (size === 1) {
23
+ if (at + 16 > end) break;
24
+ size = Number(view.getBigUint64(at + 8));
25
+ header = 16;
26
+ } else if (size === 0) {
27
+ size = end - at;
28
+ }
29
+ if (size < header || at + size > end) break;
30
+ out.push({ type, start: at + header, end: at + size });
31
+ at += size;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** A track's frame rate: its sample count over its media duration (mdhd timescale units). */
37
+ function frameRateOf(view: DataView, trak: Box): number | undefined {
38
+ const mdia = boxes(view, trak.start, trak.end).find((b) => b.type === 'mdia');
39
+ if (!mdia) return undefined;
40
+ const inMdia = boxes(view, mdia.start, mdia.end);
41
+ const mdhd = inMdia.find((b) => b.type === 'mdhd');
42
+ const stbl = (() => { const minf = inMdia.find((b) => b.type === 'minf'); return minf ? boxes(view, minf.start, minf.end).find((b) => b.type === 'stbl') : undefined; })();
43
+ const stts = stbl ? boxes(view, stbl.start, stbl.end).find((b) => b.type === 'stts') : undefined;
44
+ if (!mdhd || !stts) return undefined;
45
+ const version = view.getUint8(mdhd.start);
46
+ const timescale = view.getUint32(mdhd.start + (version === 1 ? 20 : 12));
47
+ const duration = version === 1 ? Number(view.getBigUint64(mdhd.start + 24)) : view.getUint32(mdhd.start + 16);
48
+ const entries = view.getUint32(stts.start + 4);
49
+ let samples = 0;
50
+ for (let i = 0; i < entries && stts.start + 8 + i * 8 + 4 <= stts.end; i += 1) samples += view.getUint32(stts.start + 8 + i * 8);
51
+ if (timescale <= 0 || duration <= 0 || samples <= 0) return undefined;
52
+ return samples / (duration / timescale);
53
+ }
54
+
55
+ export function probeMp4(bytes: Uint8Array): MediaProbe {
56
+ try {
57
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
58
+ const moov = boxes(view, 0, bytes.byteLength).find((b) => b.type === 'moov');
59
+ if (!moov) return {};
60
+ const out: MediaProbe = {};
61
+ const children = boxes(view, moov.start, moov.end);
62
+ const mvhd = children.find((b) => b.type === 'mvhd');
63
+ if (mvhd) {
64
+ const version = view.getUint8(mvhd.start);
65
+ const timescale = view.getUint32(mvhd.start + (version === 1 ? 20 : 12));
66
+ const duration = version === 1 ? Number(view.getBigUint64(mvhd.start + 24)) : view.getUint32(mvhd.start + 16);
67
+ if (timescale > 0) out.durationSeconds = duration / timescale;
68
+ }
69
+ for (const trak of children.filter((b) => b.type === 'trak')) {
70
+ const tkhd = boxes(view, trak.start, trak.end).find((b) => b.type === 'tkhd');
71
+ if (!tkhd) continue;
72
+ const version = view.getUint8(tkhd.start);
73
+ const sizeAt = tkhd.start + (version === 1 ? 88 : 76);
74
+ if (sizeAt + 8 > tkhd.end) continue;
75
+ const width = view.getUint32(sizeAt) / 65536;
76
+ const height = view.getUint32(sizeAt + 4) / 65536;
77
+ if (width > 0 && height > 0) {
78
+ out.width = Math.round(width);
79
+ out.height = Math.round(height);
80
+ const rate = frameRateOf(view, trak);
81
+ if (rate !== undefined) out.frameRate = rate;
82
+ break;
83
+ }
84
+ }
85
+ return out;
86
+ } catch {
87
+ return {};
88
+ }
89
+ }
@@ -0,0 +1,167 @@
1
+ // TIKTOK MIRROR UI — tiktok.com's own view of the twin's state: sign in as the creator, read their
2
+ // profile (avatar, @handle, nickname, the Following / Followers / Likes counts, bio) over the 9:16
3
+ // Videos grid, and open a post in the full-height vertical player with its caption and @handle. A
4
+ // React/TSX app bundled by Bun that renders by consuming the twin's OWN API on the same origin —
5
+ // `GET /v2/user/info/` and `POST /v2/video/list/`, the Display API reads any TikTok client makes —
6
+ // and plays the posted bytes from the twin's media route, so every screen is data-coupled to real
7
+ // twin state. Archetype A (passthrough), transcribed from the X and YouTube mirrors: one serving code
8
+ // path, so API<->UI parity cannot drift.
9
+ //
10
+ // NOTHING IS INVENTED. A count the Display API does not return (a token without user.info.stats, a
11
+ // persona seeded without it) is not drawn; a zero is drawn only when the twin holds a zero. Avatars
12
+ // the twin holds no image for (the vendor's CDN URLs a seeded persona carries) are drawn as the
13
+ // creator's initial.
14
+ //
15
+ // SIGN-IN. TikTok's "Log in" form (username, then the password) with the password leg replaced by the
16
+ // user access token the twin issued through /_twin/tokens: the mirror presents a registered token and
17
+ // `/v2/user/info/` says which creator it is — a token for another account is refused on the form.
18
+ // Both are kept in the TAB'S sessionStorage.
19
+ //
20
+ // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
21
+ // fetch adapter as its API backend and reads every byte of state back over the wire.
22
+ import { readFile } from 'node:fs/promises';
23
+ import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
24
+ import { createTikTokTwinFetch } from './tiktok-server.ts';
25
+
26
+ const CLIENT_ENTRY = () => new URL('../client/tiktok-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
27
+ const CLIENT_CSS = () => new URL('../client/tiktok-mirror.css', import.meta.url).pathname; // lazy: same reason
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Pure, dependency-free helpers (importable by the React client; Bun tree-shakes the server-only
31
+ // exports out of the browser bundle).
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type TtRow = Record<string, any>;
35
+
36
+ /** The user fields the profile reads — every one the Display API gates behind the three user scopes. */
37
+ export const PROFILE_FIELDS = 'open_id,union_id,avatar_url,display_name,username,bio_description,is_verified,follower_count,following_count,likes_count,video_count';
38
+ /** The fields a token holding only user.info.basic may ask for (the header then carries no counts). */
39
+ export const BASIC_FIELDS = 'open_id,union_id,avatar_url,display_name';
40
+ /** The Video Object fields the grid and the player read. */
41
+ export const VIDEO_READ_FIELDS = 'id,create_time,title,video_description,duration,width,height,like_count,comment_count,share_count,view_count';
42
+ /** The Display API's own page maximum. */
43
+ export const VIDEO_PAGE_SIZE = 20;
44
+
45
+ /** tiktok.com's count: exact under 10,000 ("1280"), then one decimal of K or M ("45.1K", "1.4M"),
46
+ * the trailing ".0" dropped ("12K"). */
47
+ export function compactCount(raw: unknown): string {
48
+ const n = Number(raw);
49
+ if (!Number.isFinite(n) || n < 0) return '0';
50
+ const scaled = (value: number, unit: string) => `${String(Math.floor(value * 10) / 10).replace(/\.0$/, '')}${unit}`;
51
+ if (n >= 1e9) return scaled(n / 1e9, 'B');
52
+ if (n >= 1e6) return scaled(n / 1e6, 'M');
53
+ if (n >= 1e4) return scaled(n / 1e3, 'K');
54
+ return String(Math.floor(n));
55
+ }
56
+
57
+ /** The profile header's three counts, each only when the twin returned it. */
58
+ export function profileStats(user: TtRow | undefined): Array<{ label: 'Following' | 'Followers' | 'Likes'; value: string }> {
59
+ const out: Array<{ label: 'Following' | 'Followers' | 'Likes'; value: string }> = [];
60
+ if (typeof user?.following_count === 'number') out.push({ label: 'Following', value: compactCount(user.following_count) });
61
+ if (typeof user?.follower_count === 'number') out.push({ label: 'Followers', value: compactCount(user.follower_count) });
62
+ if (typeof user?.likes_count === 'number') out.push({ label: 'Likes', value: compactCount(user.likes_count) });
63
+ return out;
64
+ }
65
+
66
+ export type CaptionSegment = { kind: 'text' | 'hashtag' | 'mention'; value: string };
67
+
68
+ /** A caption split the way tiktok.com bolds it: #hashtags and @mentions are links, the rest text. */
69
+ export function captionSegments(text: unknown): CaptionSegment[] {
70
+ const source = typeof text === 'string' ? text : '';
71
+ const out: CaptionSegment[] = [];
72
+ let last = 0;
73
+ for (const m of source.matchAll(/(#[\p{L}\p{N}_]+)|(@[A-Za-z0-9_.]{2,24})/gu)) {
74
+ const at = m.index ?? 0;
75
+ if (at > 0 && /[\p{L}\p{N}_]/u.test(source[at - 1]!)) continue;
76
+ if (at > last) out.push({ kind: 'text', value: source.slice(last, at) });
77
+ out.push({ kind: m[1] ? 'hashtag' : 'mention', value: m[0] });
78
+ last = at + m[0].length;
79
+ }
80
+ if (last < source.length) out.push({ kind: 'text', value: source.slice(last) });
81
+ return out;
82
+ }
83
+
84
+ /** The caption a post shows: the Video Object's description, else its title. */
85
+ export function captionOf(video: TtRow | undefined): string {
86
+ const description = typeof video?.video_description === 'string' ? video.video_description : '';
87
+ return description !== '' ? description : typeof video?.title === 'string' ? video.title : '';
88
+ }
89
+
90
+ /** "0:03", "1:02" — the player's time readout. */
91
+ export function clock(seconds: unknown): string {
92
+ const s = Math.max(0, Math.floor(Number(seconds) || 0));
93
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
94
+ }
95
+
96
+ /** A post's bytes on the twin's media route, relative to the wire base. The creator's token rides the
97
+ * URL (a <video> element sends no header), which is what a post that is not public needs. */
98
+ export function mediaSource(base: string, videoId: string, token: string): string {
99
+ return `${base}/_twin/media/video/${encodeURIComponent(videoId)}?access_token=${encodeURIComponent(token)}`;
100
+ }
101
+
102
+ /** The avatar placeholder's letter and hue: pure functions of the account. */
103
+ export function avatarInitial(user: TtRow | undefined): string {
104
+ const source = String(user?.display_name ?? user?.username ?? '?').trim();
105
+ return (Array.from(source)[0] ?? '?').toUpperCase();
106
+ }
107
+ export function avatarHue(key: unknown): number {
108
+ const s = String(key ?? '');
109
+ let h = 0;
110
+ for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) % 360;
111
+ return h;
112
+ }
113
+
114
+ const APP_SHELL = `<!doctype html>
115
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
116
+ <base href="/"><title>TikTok - Make Your Day</title><link rel="stylesheet" href="assets/styles.css"></head>
117
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
118
+
119
+ let clientBundle: Promise<string> | null = null;
120
+ /** Build the React/TSX mirror client to browser JS; memoized, so a pack does exactly one Bun.build. */
121
+ export function buildTiktokMirrorClient(): Promise<string> {
122
+ if (!clientBundle) {
123
+ clientBundle = bundleClient(CLIENT_ENTRY())
124
+ .catch((error) => { clientBundle = null; throw error; });
125
+ }
126
+ return clientBundle;
127
+ }
128
+
129
+ /** Serve the TikTok mirror UI (React app) + its backing API, upload and media routes on one origin. */
130
+ export async function createTiktokMirrorServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; url: string; stop: () => void }> {
131
+ const twin = createTikTokTwinFetch(options);
132
+ const server = await serveHttp({
133
+ // LOOPBACK-SPECIFIC bind, as the X and YouTube mirrors: a wildcard bind on `port: 0` can be
134
+ // shadowed by an app already listening on 127.0.0.1 at the same port.
135
+ hostname: '127.0.0.1',
136
+ port: options.port ?? 0,
137
+ idleTimeout: 60,
138
+ async fetch(request) {
139
+ const url = new URL(request.url);
140
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
141
+ try { return new Response(await buildTiktokMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
142
+ catch (error) { return new Response(String(error), { status: 500 }); }
143
+ }
144
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
145
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
146
+ }
147
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
148
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
149
+ }
150
+ // Everything else -> the twin's OWN FETCH ADAPTER: Login Kit, the Display API, Content Posting,
151
+ // the upload path and the media route, the same closure `createTikTokTwinServer` serves.
152
+ return twin(request);
153
+ },
154
+ });
155
+ const port = server.port ?? options.port ?? 0;
156
+ return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
157
+ }
158
+
159
+ /** The app-shell HTML (pure). The client itself is the React app. */
160
+ export function tiktokMirrorHtml(): string {
161
+ return APP_SHELL;
162
+ }
163
+
164
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
165
+ export function tiktokMirrorStyles(): Promise<string> {
166
+ return readFile(CLIENT_CSS(), 'utf8');
167
+ }
@@ -0,0 +1,61 @@
1
+ // PKCE, TikTok's way — and TikTok's way is NOT RFC 7636's, which is the whole reason this file
2
+ // exists instead of a copied helper.
3
+ //
4
+ // RFC 7636 §4.2 defines `S256` as BASE64URL(SHA256(verifier)). TikTok's Login Kit for Desktop
5
+ // guide says instead: "Create the code challenge by hashing the code verifier using hex encoding
6
+ // of SHA256. Since TikTok only supports S256 as code_challenge_method, use code_challenge =
7
+ // SHA256(code_verifier)" — i.e. the challenge is the 64-character LOWERCASE HEX digest
8
+ // (developers.tiktok.com/doc/login-kit-desktop, fetched 2026-09-13). A twin that verified the
9
+ // base64url form would accept challenges the real vendor rejects and reject the ones it accepts,
10
+ // which is precisely the class of bug a twin exists to reproduce.
11
+ //
12
+ // PKCE IS OPTIONAL ON THIS VENDOR. The desktop guide requires it "for desktop apps"; the Login
13
+ // Kit for Web parameter table lists neither `code_challenge` nor `code_challenge_method`, and the
14
+ // token endpoint's own parameter table marks `code_verifier` "Required for mobile and desktop app
15
+ // only". Dub — a web app — sends no challenge at all. So the twin BINDS a verifier only when the
16
+ // authorize request carried a challenge, and requires none when it did not. Requiring PKCE the
17
+ // way X requires it would refuse the motivating application outright.
18
+ import { createHash } from 'node:crypto';
19
+
20
+ /** TikTok's `code_challenge`: the LOWERCASE HEX SHA-256 of the verifier (not base64url). */
21
+ export function tiktokCodeChallenge(verifier: string): string {
22
+ return createHash('sha256').update(verifier).digest('hex');
23
+ }
24
+
25
+ /** The RFC 7636 spelling, kept ONLY so a verify can prove the twin refuses it — TikTok does not
26
+ * accept it, and a caller that carried an X/Google-shaped challenge here must fail visibly. */
27
+ export function rfc7636Challenge(verifier: string): string {
28
+ return createHash('sha256').update(verifier).digest('base64url');
29
+ }
30
+
31
+ /** The only `code_challenge_method` TikTok supports, per the desktop guide, verbatim: `S256`. */
32
+ export const CHALLENGE_METHOD = 'S256';
33
+
34
+ /**
35
+ * Is `raw` a code_challenge_method TikTok accepts? Only `S256` is documented ("TikTok only
36
+ * supports S256"), and unlike X there is no `plain` fallback to model. The comparison is
37
+ * case-SENSITIVE: no vendor artefact shows TikTok accepting `s256`, and inventing a tolerance
38
+ * would be the inverse false-green (`tiktok.authorize.challenge_method_case`, todo).
39
+ */
40
+ export function isSupportedChallengeMethod(raw: string): boolean {
41
+ return raw === CHALLENGE_METHOD;
42
+ }
43
+
44
+ /**
45
+ * Does `verifier` satisfy the challenge the code was minted against?
46
+ *
47
+ * The verifier must also be a well-formed one: "a high-entropy cryptographic random string using
48
+ * the unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a minimum length
49
+ * of 43 characters and a maximum length of 128 characters" (the desktop guide, verbatim). A
50
+ * malformed verifier can never satisfy a challenge, so the shape check is folded in here rather
51
+ * than left to a caller that might forget it.
52
+ */
53
+ export function pkceVerifies(challenge: string, verifier: string): boolean {
54
+ if (!isWellFormedVerifier(verifier)) return false;
55
+ return tiktokCodeChallenge(verifier) === challenge;
56
+ }
57
+
58
+ const VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
59
+ export function isWellFormedVerifier(verifier: string): boolean {
60
+ return VERIFIER_RE.test(verifier);
61
+ }