@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,492 @@
1
+ // TikTok mirror — React/TSX client. tiktok.com's own view of the twin's state: log in as the creator,
2
+ // read their profile over the Videos grid, and open a post in the vertical player. Every read is the
3
+ // twin's OWN Display API on the same origin (GET /v2/user/info/, POST /v2/video/list/); the player
4
+ // plays the posted bytes from the twin's media route. Nothing is kept but the signed-in username and
5
+ // token, in the tab's sessionStorage.
6
+ import { createRoot } from 'react-dom/client';
7
+ import { useCallback, useEffect, useRef, useState } from 'react';
8
+ import {
9
+ avatarHue, avatarInitial, BASIC_FIELDS, captionOf, captionSegments, clock, compactCount, mediaSource, PROFILE_FIELDS, profileStats,
10
+ VIDEO_PAGE_SIZE, VIDEO_READ_FIELDS, type TtRow,
11
+ } from '../src/tiktok-mirror-ui.ts';
12
+
13
+ // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative); served
14
+ // under a path prefix with a <base> tag, every read resolves inside that prefix.
15
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
16
+
17
+ const SESSION_HANDLE = 'tiktok-mirror.username';
18
+ const SESSION_TOKEN = 'tiktok-mirror.token';
19
+
20
+ type Session = { handle: string; token: string };
21
+ type Wire = { status: number; body: TtRow };
22
+
23
+ function readSession(): Session | null {
24
+ if (typeof sessionStorage === 'undefined') return null;
25
+ const handle = sessionStorage.getItem(SESSION_HANDLE);
26
+ const token = sessionStorage.getItem(SESSION_TOKEN);
27
+ return handle && token ? { handle, token } : null;
28
+ }
29
+
30
+ async function wire(token: string, path: string, init?: { method?: string; body?: unknown }): Promise<Wire> {
31
+ const res = await fetch(`${WIRE_BASE}${path}`, {
32
+ method: init?.method ?? 'GET',
33
+ headers: { authorization: `Bearer ${token}`, ...(init?.body !== undefined ? { 'content-type': 'application/json' } : {}) },
34
+ ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
35
+ });
36
+ let body: TtRow = {};
37
+ try { body = await res.json() as TtRow; } catch { body = {}; }
38
+ return { status: res.status, body };
39
+ }
40
+
41
+ /** TikTok's nested error, as one sentence for the screen. */
42
+ const errorText = (w: Wire): string => String(w.body?.error?.message ?? `HTTP ${w.status}`);
43
+ const errorCode = (w: Wire): string => String(w.body?.error?.code ?? '');
44
+
45
+ /** The signed-in creator: every profile field, or — for a token without the profile/stats scopes —
46
+ * the basic ones, so the header shows what the twin returns and nothing it does not. */
47
+ async function readMe(token: string): Promise<Wire> {
48
+ const full = await wire(token, `/v2/user/info/?fields=${PROFILE_FIELDS}`);
49
+ if (full.status === 401 && errorCode(full) === 'scope_not_authorized') return wire(token, `/v2/user/info/?fields=${BASIC_FIELDS}`);
50
+ return full;
51
+ }
52
+
53
+ // ── routing: hash routes, so the shell answers every screen under any mount ─────────────────────
54
+ type Route = { page: 'login' } | { page: 'profile'; handle: string } | { page: 'video'; handle: string; id: string };
55
+
56
+ function parseRoute(hash: string): Route | null {
57
+ const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean);
58
+ if (parts.length === 0) return null;
59
+ if (parts[0] === 'login') return { page: 'login' };
60
+ const handle = decodeURIComponent(parts[0]!).replace(/^@/, '');
61
+ if (parts[1] === 'video' && parts[2]) return { page: 'video', handle, id: parts[2] };
62
+ return { page: 'profile', handle };
63
+ }
64
+ const go = (path: string) => { location.hash = `#/${path}`; };
65
+
66
+ // ── icons (vendor mirror: tiktok.com's look, drawn here) ────────────────────────────────────────
67
+ const NOTE = 'M16.6 5.82s.51.5 0 0A4.28 4.28 0 0 1 15.54 3h-3.09v12.4a2.59 2.59 0 0 1-2.59 2.5c-1.42 0-2.6-1.16-2.6-2.6 0-1.72 1.66-3.01 3.37-2.48V9.66c-3.45-.46-6.47 2.22-6.47 5.64 0 3.33 2.76 5.7 5.69 5.7 3.14 0 5.69-2.55 5.69-5.7V9.01a7.35 7.35 0 0 0 4.3 1.38V7.3s-1.88.09-3.24-1.48z';
68
+ export function Logo({ size = 34, word = true }: { size?: number; word?: boolean }) {
69
+ return (
70
+ <span className="logo">
71
+ <svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
72
+ <path d={NOTE} fill="#25F4EE" transform="translate(-0.9 -0.7)" />
73
+ <path d={NOTE} fill="#FE2C55" transform="translate(0.9 0.7)" />
74
+ <path d={NOTE} fill="currentColor" />
75
+ </svg>
76
+ {word ? <span className="logo-word">TikTok</span> : null}
77
+ </span>
78
+ );
79
+ }
80
+ function Icon({ d, size = 24, fill = false, className }: { d: string[]; size?: number; fill?: boolean; className?: string }) {
81
+ return (
82
+ <svg className={className} viewBox="0 0 24 24" width={size} height={size} aria-hidden="true"
83
+ fill={fill ? 'currentColor' : 'none'} stroke={fill ? 'none' : 'currentColor'} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
84
+ {d.map((p) => <path key={p} d={p} />)}
85
+ </svg>
86
+ );
87
+ }
88
+ const I_HOME = ['M3 10.5 12 3l9 7.5V21h-6v-6H9v6H3z'];
89
+ const I_EXPLORE = ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'm16.2 7.8-2.1 6.3-6.3 2.1 2.1-6.3z'];
90
+ const I_FOLLOWING = ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', 'M19 8v6', 'M22 11h-6'];
91
+ const I_FRIENDS = ['M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2', 'M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', 'M23 21v-2a4 4 0 0 0-3-3.87', 'M16 3.13a4 4 0 0 1 0 7.75'];
92
+ const I_LIVE = ['M4 6h16v12H4z', 'm10 9 5 3-5 3z'];
93
+ const I_UPLOAD = ['M12 5v14', 'M5 12h14'];
94
+ const I_PROFILE = ['M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2', 'M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'];
95
+ const I_PLAY = ['M7 4.5v15l12-7.5z'];
96
+ const I_PAUSE = ['M7 4h3.5v16H7z', 'M13.5 4H17v16h-3.5z'];
97
+ const I_CLOSE = ['M18 6 6 18', 'm6 6 12 12'];
98
+ const I_UP = ['m18 15-6-6-6 6'];
99
+ const I_DOWN = ['m6 9 6 6 6-6'];
100
+ const I_SHARE_ARROW = ['M14 4.5V9C7 9 3.5 13 3 19.5c2-3.5 5-5 11-5V19l7-7.25z'];
101
+ const I_HEART = ['M12 21s-7.5-4.6-9.6-9.2C.9 8.4 2.9 4.5 6.7 4.5c2.2 0 3.7 1.2 5.3 3 1.6-1.8 3.1-3 5.3-3 3.8 0 5.8 3.9 4.3 7.3C19.5 16.4 12 21 12 21z'];
102
+ const I_COMMENT = ['M12 3C6.5 3 2 6.9 2 11.7c0 2.6 1.3 4.9 3.4 6.5L4.7 22l4.6-2.4c.9.2 1.8.3 2.7.3 5.5 0 10-3.9 10-8.7S17.5 3 12 3z'];
103
+ const I_BOOKMARK = ['M6 2.5h12a1 1 0 0 1 1 1V22l-7-4.5L5 22V3.5a1 1 0 0 1 1-1z'];
104
+ const I_VERIFIED = ['M12 1.5 14.6 3.4l3.2-.2.9 3.1 2.8 1.6-1 3.1 1 3.1-2.8 1.6-.9 3.1-3.2-.2L12 22.5l-2.6-1.9-3.2.2-.9-3.1-2.8-1.6 1-3.1-1-3.1 2.8-1.6.9-3.1 3.2.2z'];
105
+ const I_CHECK = ['m7.5 12.2 3 3 6-6.4'];
106
+ const I_MUSIC = ['M9 18V5l12-2v13', 'M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', 'M18 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'];
107
+ const I_VOLUME = ['M11 5 6 9H2v6h4l5 4z', 'M15.5 8.5a5 5 0 0 1 0 7', 'M19 5a10 10 0 0 1 0 14'];
108
+ const I_MUTED = ['M11 5 6 9H2v6h4l5 4z', 'm22 9-6 6', 'm16 9 6 6'];
109
+ const I_LOCK = ['M5 11h14v10H5z', 'M8 11V7a4 4 0 0 1 8 0v4'];
110
+ const I_GRID = ['M4 4h7v7H4z', 'M13 4h7v7h-7z', 'M4 13h7v7H4z', 'M13 13h7v7h-7z'];
111
+
112
+ // ── pieces ───────────────────────────────────────────────────────────────────────────────────────
113
+ export function Avatar({ user, size }: { user: TtRow | undefined; size: number }) {
114
+ return (
115
+ <div className="avatar" style={{ width: size, height: size, fontSize: size * 0.42, background: `hsl(${avatarHue(user?.union_id ?? user?.username)} 55% 45%)` }} aria-hidden="true">
116
+ {avatarInitial(user)}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function Verified({ size = 20 }: { size?: number }) {
122
+ return (
123
+ <span className="verified" title="Verified account">
124
+ <Icon d={I_VERIFIED} fill size={size} />
125
+ <Icon d={I_CHECK} size={size} className="verified-check" />
126
+ </span>
127
+ );
128
+ }
129
+
130
+ export function Caption({ text }: { text: unknown }) {
131
+ return (
132
+ <span className="caption">
133
+ {captionSegments(text).map((s, i) => s.kind === 'text' ? <span key={i}>{s.value}</span> : <strong key={i} className="tag">{s.value}</strong>)}
134
+ </span>
135
+ );
136
+ }
137
+
138
+ /** The profile header: avatar, @username (h1) with the verified tick, the nickname, the buttons a
139
+ * creator sees on their own page, the three counts (only those the twin returned) and the bio. */
140
+ export function ProfileHeader({ user, own }: { user: TtRow; own: boolean }) {
141
+ const stats = profileStats(user);
142
+ return (
143
+ <header className="profile-header">
144
+ <Avatar user={user} size={212} />
145
+ <div className="profile-info">
146
+ <div className="profile-names">
147
+ <h1 className="profile-username">{String(user.username ?? user.display_name ?? '')}</h1>
148
+ {user.is_verified === true ? <Verified /> : null}
149
+ <h2 className="profile-nickname">{String(user.display_name ?? '')}</h2>
150
+ </div>
151
+ <div className="profile-buttons">
152
+ {own ? (
153
+ <>
154
+ <button className="btn btn-red">Edit profile</button>
155
+ <button className="btn btn-gray">Promote post</button>
156
+ </>
157
+ ) : (
158
+ <>
159
+ <button className="btn btn-red">Follow</button>
160
+ <button className="btn btn-gray">Message</button>
161
+ </>
162
+ )}
163
+ </div>
164
+ {stats.length > 0 ? (
165
+ <div className="profile-stats">
166
+ {stats.map((s) => <span key={s.label} className="stat" data-stat={s.label}><strong>{s.value}</strong><span className="stat-label">{s.label}</span></span>)}
167
+ </div>
168
+ ) : null}
169
+ {typeof user.bio_description === 'string' && user.bio_description !== '' ? <p className="profile-bio">{user.bio_description}</p> : <p className="profile-bio muted">No bio yet.</p>}
170
+ </div>
171
+ </header>
172
+ );
173
+ }
174
+
175
+ /** One grid tile: the post's own first frame (the twin serves no cover image), 9:16, with the play
176
+ * count at the bottom left. A post whose bytes the twin does not hold shows its caption instead. */
177
+ export function VideoTile({ video, src, onOpen }: { video: TtRow; src: string; onOpen: () => void }) {
178
+ const [broken, setBroken] = useState(false);
179
+ return (
180
+ <div className="tile" data-video-id={String(video.id)} onClick={onOpen} role="link" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') onOpen(); }}>
181
+ {broken ? <div className="tile-fallback"><Caption text={captionOf(video)} /></div> : (
182
+ <video className="tile-video" src={`${src}#t=0.1`} preload="metadata" muted playsInline onError={() => setBroken(true)}
183
+ onMouseEnter={(e) => { void e.currentTarget.play().catch(() => undefined); }}
184
+ onMouseLeave={(e) => { e.currentTarget.pause(); }} />
185
+ )}
186
+ {typeof video.view_count === 'number' ? (
187
+ <span className="tile-views"><Icon d={I_PLAY} size={16} />{compactCount(video.view_count)}</span>
188
+ ) : null}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ export function VideoGrid({ videos, sourceOf, onOpen }: { videos: TtRow[]; sourceOf: (id: string) => string; onOpen: (id: string) => void }) {
194
+ return (
195
+ <div className="grid">
196
+ {videos.map((v) => <VideoTile key={String(v.id)} video={v} src={sourceOf(String(v.id))} onOpen={() => onOpen(String(v.id))} />)}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /** The player's overlay: @handle, the caption with its tags, and the sound line TikTok prints for a
202
+ * video's own audio ("original sound - <nickname>"). */
203
+ export function PlayerCaption({ user, video }: { user: TtRow; video: TtRow }) {
204
+ return (
205
+ <div className="player-caption">
206
+ <div className="player-author">@{String(user.username ?? user.display_name ?? '')}{user.is_verified === true ? <Verified size={16} /> : null}</div>
207
+ <div className="player-text"><Caption text={captionOf(video)} /></div>
208
+ <div className="player-sound"><Icon d={I_MUSIC} size={14} /><span>original sound - {String(user.display_name ?? user.username ?? '')}</span></div>
209
+ </div>
210
+ );
211
+ }
212
+
213
+ /** The right-hand action rail: the creator's avatar, then like / comment / save / share, each with the
214
+ * count the Video Object carries — and no number where the Display API returns none (saves). */
215
+ export function ActionRail({ user, video }: { user: TtRow; video: TtRow }) {
216
+ const count = (raw: unknown) => (typeof raw === 'number' ? compactCount(raw) : '');
217
+ return (
218
+ <div className="rail">
219
+ <div className="rail-avatar"><Avatar user={user} size={48} /><span className="rail-plus">+</span></div>
220
+ <div className="rail-item"><span className="rail-btn"><Icon d={I_HEART} fill size={26} /></span><strong>{count(video.like_count)}</strong></div>
221
+ <div className="rail-item"><span className="rail-btn"><Icon d={I_COMMENT} fill size={26} /></span><strong>{count(video.comment_count)}</strong></div>
222
+ <div className="rail-item"><span className="rail-btn"><Icon d={I_BOOKMARK} fill size={24} /></span><strong /></div>
223
+ <div className="rail-item"><span className="rail-btn"><Icon d={I_SHARE_ARROW} fill size={26} /></span><strong>{count(video.share_count)}</strong></div>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ /** The full-height vertical player: the post playing, looped, in a 9:16 frame as tall as the window,
229
+ * over a blurred copy of itself; tap to pause; the caption and the rail over it; up/down to the
230
+ * neighbouring posts; close back to the profile. */
231
+ function Player({ user, video, src, prev, next, onClose }: { user: TtRow; video: TtRow; src: string; prev?: string; next?: string; onClose: () => void }) {
232
+ const ref = useRef<HTMLVideoElement | null>(null);
233
+ const [paused, setPaused] = useState(false);
234
+ const [muted, setMuted] = useState(false);
235
+ const [time, setTime] = useState({ at: 0, of: Number(video.duration) || 0 });
236
+ const toggle = () => {
237
+ const el = ref.current;
238
+ if (!el) return;
239
+ if (el.paused) { void el.play().catch(() => undefined); setPaused(false); } else { el.pause(); setPaused(true); }
240
+ };
241
+ useEffect(() => {
242
+ const onKey = (e: KeyboardEvent) => {
243
+ if (e.key === 'Escape') onClose();
244
+ if (e.key === 'ArrowUp' && prev) go(`@${user.username}/video/${prev}`);
245
+ if (e.key === 'ArrowDown' && next) go(`@${user.username}/video/${next}`);
246
+ };
247
+ window.addEventListener('keydown', onKey);
248
+ return () => window.removeEventListener('keydown', onKey);
249
+ }, [prev, next, onClose, user.username]);
250
+ return (
251
+ <div className="player-page" data-video-id={String(video.id)}>
252
+ <video className="player-backdrop" src={src} muted autoPlay loop playsInline aria-hidden="true" />
253
+ <button className="player-close" aria-label="Close" onClick={onClose}><Icon d={I_CLOSE} size={24} /></button>
254
+ <div className="player-logo"><Logo size={30} /></div>
255
+ <div className="player-stage">
256
+ <div className="player-frame" onClick={toggle}>
257
+ <button className="player-sound-toggle" aria-label={muted ? 'Unmute' : 'Mute'} onClick={(e) => { e.stopPropagation(); setMuted(!muted); }}><Icon d={muted ? I_MUTED : I_VOLUME} size={20} /></button>
258
+ <video ref={ref} className="player-video" src={src} autoPlay loop playsInline muted={muted}
259
+ onTimeUpdate={(e) => setTime({ at: e.currentTarget.currentTime, of: e.currentTarget.duration || time.of })}
260
+ onPlay={() => setPaused(false)} onPause={() => setPaused(true)} />
261
+ {paused ? <span className="player-paused"><Icon d={I_PLAY} fill size={64} /></span> : null}
262
+ <div className="player-shade" />
263
+ <PlayerCaption user={user} video={video} />
264
+ <div className="player-bar" onClick={(e) => e.stopPropagation()}>
265
+ <button className="player-toggle" aria-label={paused ? 'Play' : 'Pause'} onClick={toggle}><Icon d={paused ? I_PLAY : I_PAUSE} fill size={18} /></button>
266
+ <div className="player-track"><div className="player-progress" style={{ width: `${time.of > 0 ? Math.min(100, (time.at / time.of) * 100) : 0}%` }} /></div>
267
+ <span className="player-time">{clock(time.at)}/{clock(time.of)}</span>
268
+ </div>
269
+ </div>
270
+ <ActionRail user={user} video={video} />
271
+ </div>
272
+ <div className="player-nav">
273
+ <button aria-label="Previous video" disabled={!prev} onClick={() => prev && go(`@${user.username}/video/${prev}`)}><Icon d={I_UP} size={26} /></button>
274
+ <button aria-label="Next video" disabled={!next} onClick={() => next && go(`@${user.username}/video/${next}`)}><Icon d={I_DOWN} size={26} /></button>
275
+ </div>
276
+ </div>
277
+ );
278
+ }
279
+
280
+ // ── the creator's videos: POST /v2/video/list/, newest first, paged on the vendor's own cursor ────
281
+ function useVideos(session: Session | null, reloadKey: number): { videos: TtRow[]; error: string | null; more: (() => void) | null; loaded: boolean } {
282
+ const [videos, setVideos] = useState<TtRow[]>([]);
283
+ const [cursor, setCursor] = useState<number | null>(null);
284
+ const [error, setError] = useState<string | null>(null);
285
+ const [loaded, setLoaded] = useState(false);
286
+ const load = useCallback(async (from: number | null, prev: TtRow[]) => {
287
+ if (!session) return;
288
+ const w = await wire(session.token, `/v2/video/list/?fields=${VIDEO_READ_FIELDS}`, { method: 'POST', body: { max_count: VIDEO_PAGE_SIZE, ...(from !== null ? { cursor: from } : {}) } });
289
+ setLoaded(true);
290
+ if (w.status !== 200) { setError(errorCode(w) === 'scope_not_authorized' ? "This token can't read the account's videos (it needs video.list)." : errorText(w)); return; }
291
+ setError(null);
292
+ const page = Array.isArray(w.body?.data?.videos) ? w.body.data.videos as TtRow[] : [];
293
+ setVideos([...prev, ...page]);
294
+ setCursor(w.body?.data?.has_more === true ? Number(w.body.data.cursor) : null);
295
+ }, [session]);
296
+ useEffect(() => { setVideos([]); setCursor(null); setLoaded(false); void load(null, []); }, [load, reloadKey]);
297
+ return { videos, error, loaded, more: cursor !== null ? () => void load(cursor, videos) : null };
298
+ }
299
+
300
+ // ── screens ──────────────────────────────────────────────────────────────────────────────────────
301
+ function SideNav({ me, active, onSignOut }: { me: TtRow | null; active: 'profile' | 'none'; onSignOut: () => void }) {
302
+ const item = (icon: string[], label: string, on = false, onClick?: () => void) => (
303
+ <button className={on ? 'nav-item active' : 'nav-item'} onClick={onClick}><Icon d={icon} size={26} /><span>{label}</span></button>
304
+ );
305
+ return (
306
+ <nav className="sidenav">
307
+ <a className="nav-logo" href={me ? `#/@${me.username}` : '#/'}><Logo /></a>
308
+ <div className="nav-search"><span>Search</span></div>
309
+ {item(I_HOME, 'For You')}
310
+ {item(I_EXPLORE, 'Explore')}
311
+ {item(I_FOLLOWING, 'Following')}
312
+ {item(I_FRIENDS, 'Friends')}
313
+ {item(I_UPLOAD, 'Upload')}
314
+ {item(I_LIVE, 'LIVE')}
315
+ {me ? (
316
+ <button className={active === 'profile' ? 'nav-item active' : 'nav-item'} onClick={() => go(`@${me.username}`)}>
317
+ <Avatar user={me} size={32} /><span>Profile</span>
318
+ </button>
319
+ ) : item(I_PROFILE, 'Profile')}
320
+ <div className="nav-foot">
321
+ {me ? <button className="nav-signout" onClick={onSignOut}>Log out @{String(me.username ?? '')}</button> : null}
322
+ <div className="muted">© 2026 TikTok</div>
323
+ </div>
324
+ </nav>
325
+ );
326
+ }
327
+
328
+ function ProfileScreen({ session, me, handle }: { session: Session; me: TtRow; handle: string }) {
329
+ const own = handle === String(me.username ?? '');
330
+ const read = useVideos(own ? session : null, 0);
331
+ const [tab, setTab] = useState<'videos' | 'reposts' | 'favorites' | 'liked'>('videos');
332
+ if (!own) {
333
+ // The Display API reads only the token's own account; another creator's page is not readable here.
334
+ return <div className="profile-missing"><h2>Couldn't find this account</h2><p className="muted">Looking for videos? Try browsing our trending creators, hashtags, and sounds.</p></div>;
335
+ }
336
+ return (
337
+ <div className="profile">
338
+ <ProfileHeader user={me} own />
339
+ <div className="profile-tabs">
340
+ <button className={tab === 'videos' ? 'ptab active' : 'ptab'} onClick={() => setTab('videos')}><Icon d={I_GRID} size={18} />Videos</button>
341
+ <button className={tab === 'reposts' ? 'ptab active' : 'ptab'} onClick={() => setTab('reposts')}>Reposts</button>
342
+ <button className={tab === 'favorites' ? 'ptab active' : 'ptab'} onClick={() => setTab('favorites')}><Icon d={I_LOCK} size={16} />Favorites</button>
343
+ <button className={tab === 'liked' ? 'ptab active' : 'ptab'} onClick={() => setTab('liked')}><Icon d={I_LOCK} size={16} />Liked</button>
344
+ {tab === 'videos' ? <div className="sort"><span className="sort-on">Latest</span><span>Popular</span><span>Oldest</span></div> : null}
345
+ </div>
346
+ {tab !== 'videos' ? <div className="empty"><h3>Nothing here yet</h3><p className="muted">This twin models the account's own posts.</p></div>
347
+ : read.error ? <div className="empty" role="alert">{read.error}</div>
348
+ : read.loaded && read.videos.length === 0 ? <div className="empty"><h3>Upload your first video</h3><p className="muted">Your videos will appear here</p></div>
349
+ : (
350
+ <>
351
+ <VideoGrid videos={read.videos} sourceOf={(id) => mediaSource(WIRE_BASE, id, session.token)} onOpen={(id) => go(`@${me.username}/video/${id}`)} />
352
+ {read.more ? <button className="show-more" onClick={read.more}>Load more</button> : null}
353
+ </>
354
+ )}
355
+ </div>
356
+ );
357
+ }
358
+
359
+ function VideoScreen({ session, me, id }: { session: Session; me: TtRow; id: string }) {
360
+ const read = useVideos(session, 0);
361
+ const at = read.videos.findIndex((v) => String(v.id) === id);
362
+ const close = useCallback(() => go(`@${me.username}`), [me.username]);
363
+ if (!read.loaded) return <div className="player-page" />;
364
+ if (at < 0) return <div className="player-page"><div className="player-missing">Video currently unavailable</div><button className="player-close" aria-label="Close" onClick={close}><Icon d={I_CLOSE} size={24} /></button></div>;
365
+ return (
366
+ <Player key={id} user={me} video={read.videos[at]!} src={mediaSource(WIRE_BASE, id, session.token)}
367
+ {...(at > 0 ? { prev: String(read.videos[at - 1]!.id) } : {})}
368
+ {...(at < read.videos.length - 1 ? { next: String(read.videos[at + 1]!.id) } : {})}
369
+ onClose={close} />
370
+ );
371
+ }
372
+
373
+ function LoginScreen({ onSignedIn }: { onSignedIn: (s: Session, me: TtRow) => void }) {
374
+ const [step, setStep] = useState<'username' | 'token'>('username');
375
+ const [handle, setHandle] = useState('');
376
+ const [token, setToken] = useState('');
377
+ const [error, setError] = useState<string | null>(null);
378
+ const [busy, setBusy] = useState(false);
379
+ const clean = handle.trim().replace(/^@/, '');
380
+ const next = () => {
381
+ if (!/^[A-Za-z0-9_.]{2,24}$/.test(clean)) { setError("Couldn't find this account"); return; }
382
+ setError(null); setStep('token');
383
+ };
384
+ const login = async () => {
385
+ setBusy(true);
386
+ const w = await readMe(token.trim());
387
+ setBusy(false);
388
+ if (w.status === 401 && errorCode(w) === 'access_token_invalid') { setError('Incorrect account or password'); return; }
389
+ if (w.status !== 200 || !w.body?.data?.user) { setError(errorText(w)); return; }
390
+ const me = w.body.data.user as TtRow;
391
+ // Is this token THIS account's? The Display API answers the token's own account, so the answer says.
392
+ if (me.username !== undefined && String(me.username) !== clean) { setError(`That token does not log in as @${clean}.`); return; }
393
+ onSignedIn({ handle: clean, token: token.trim() }, { ...me, username: me.username ?? clean });
394
+ };
395
+ return (
396
+ <div className="login-backdrop">
397
+ <div className="login">
398
+ <div className="login-logo"><Logo size={40} word={false} /></div>
399
+ {step === 'username' ? (
400
+ <form className="login-form" onSubmit={(e) => { e.preventDefault(); next(); }}>
401
+ <h1>Log in to TikTok</h1>
402
+ <div className="login-sub">Username</div>
403
+ <input autoFocus name="username" className="login-input" placeholder="Username" value={handle} onChange={(e) => setHandle(e.target.value)} autoComplete="username" />
404
+ {error ? <div className="login-error" role="alert">{error}</div> : null}
405
+ <button className="btn-login" type="submit" disabled={clean === ''}>Next</button>
406
+ <div className="muted login-note">This twin logs you in with the access token it issued for the account (<code>/_twin/tokens</code>), in place of a password.</div>
407
+ </form>
408
+ ) : (
409
+ <form className="login-form" onSubmit={(e) => { e.preventDefault(); void login(); }}>
410
+ <h1>Log in</h1>
411
+ <div className="login-sub">@{clean} <button type="button" className="link" onClick={() => { setStep('username'); setError(null); }}>Change</button></div>
412
+ <input autoFocus name="token" className="login-input" type="password" placeholder="Access token" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="current-password" />
413
+ {error ? <div className="login-error" role="alert">{error}</div> : null}
414
+ <button className="btn-login" type="submit" disabled={token.trim() === '' || busy}>Log in</button>
415
+ </form>
416
+ )}
417
+ <div className="login-foot">Don't have an account? <span className="link-red">Sign up</span></div>
418
+ </div>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ function App() {
424
+ const [session, setSession] = useState<Session | null>(readSession);
425
+ const [me, setMe] = useState<TtRow | null>(null);
426
+ const [route, setRoute] = useState<Route | null>(() => parseRoute(typeof location === 'undefined' ? '' : location.hash));
427
+
428
+ useEffect(() => {
429
+ const onHash = () => { setRoute(parseRoute(location.hash)); window.scrollTo(0, 0); };
430
+ // a `#/…` link is one of the mirror's own routes; under a World's <base> the browser would resolve
431
+ // it against the base and leave the shell, so it moves the hash here
432
+ const onClick = (e: MouseEvent) => {
433
+ const a = (e.target as Element | null)?.closest?.('a[href^="#/"]');
434
+ if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
435
+ e.preventDefault();
436
+ location.hash = a.getAttribute('href')!;
437
+ };
438
+ window.addEventListener('hashchange', onHash);
439
+ document.addEventListener('click', onClick, true);
440
+ return () => { window.removeEventListener('hashchange', onHash); document.removeEventListener('click', onClick, true); };
441
+ }, []);
442
+
443
+ const signOut = useCallback(() => {
444
+ sessionStorage.removeItem(SESSION_HANDLE);
445
+ sessionStorage.removeItem(SESSION_TOKEN);
446
+ setSession(null);
447
+ setMe(null);
448
+ go('login');
449
+ }, []);
450
+
451
+ // who am I: the signed-in creator, read back through the vendor's own user read
452
+ useEffect(() => {
453
+ if (!session) { setMe(null); return; }
454
+ void readMe(session.token).then((w) => {
455
+ if (w.status === 200 && w.body?.data?.user) setMe({ ...w.body.data.user, username: w.body.data.user.username ?? session.handle });
456
+ else if (w.status === 401) signOut();
457
+ });
458
+ }, [session, signOut]);
459
+
460
+ const signIn = (s: Session, user: TtRow) => {
461
+ sessionStorage.setItem(SESSION_HANDLE, s.handle);
462
+ sessionStorage.setItem(SESSION_TOKEN, s.token);
463
+ setSession(s);
464
+ setMe(user);
465
+ go(`@${s.handle}`);
466
+ };
467
+
468
+ if (!session || route?.page === 'login') {
469
+ return session && route?.page === 'login' ? <LoginRedirect handle={session.handle} /> : <LoginScreen onSignedIn={signIn} />;
470
+ }
471
+ if (!me) return <div className="loading"><Logo /></div>;
472
+ const current: Route = route ?? { page: 'profile', handle: String(me.username) };
473
+ if (current.page === 'video') return <VideoScreen session={session} me={me} id={current.id} />;
474
+ return (
475
+ <div className="layout">
476
+ <SideNav me={me} active={current.page === 'profile' && current.handle === me.username ? 'profile' : 'none'} onSignOut={signOut} />
477
+ <main className="main">
478
+ <ProfileScreen session={session} me={me} handle={current.page === 'profile' ? current.handle : String(me.username)} />
479
+ </main>
480
+ </div>
481
+ );
482
+ }
483
+
484
+ function LoginRedirect({ handle }: { handle: string }) {
485
+ useEffect(() => { go(`@${handle}`); }, [handle]);
486
+ return null;
487
+ }
488
+
489
+ if (typeof document !== 'undefined') {
490
+ const el = document.getElementById('root');
491
+ if (el) createRoot(el).render(<App />);
492
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // world-tiktok CLI: serve the TikTok twin, or run conformance.
3
+ //
4
+ // `serve` starts the twin. `mirror` starts the same twin with tiktok.com's profile and player in
5
+ // front of it (tiktok-mirror-ui.ts: the shell at `/`, everything else the twin's own fetch adapter),
6
+ // and prints both screens an operator asks to "see": the mirror, and a ready-to-open authorize URL
7
+ // for the authorization page the twin serves at the vendor's own path.
8
+ import { hasFlag, optionValue } from '@volter/world-core/args';
9
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
10
+ import { createTikTokTwinServer } from "./tiktok-server.js";
11
+ import { createTiktokMirrorServer } from "./tiktok-mirror-ui.js";
12
+ import { DEFAULT_CLIENT_KEY, defaultRedirectUris } from "./tiktok-store.js";
13
+ const [cmd, ...rest] = process.argv.slice(2);
14
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
15
+ const root = optionValue(rest, '--root') || undefined;
16
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
17
+ if (cmd === 'serve' || cmd === 'mirror') {
18
+ const options = { readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) };
19
+ const s = cmd === 'mirror' ? await createTiktokMirrorServer(options) : await createTikTokTwinServer(options);
20
+ const origin = `http://127.0.0.1:${s.port}`;
21
+ process.stdout.write(`tiktok twin (Login Kit OAuth + Display API + Content Posting)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
22
+ if (cmd === 'mirror') {
23
+ process.stdout.write(`mirror (tiktok.com profile + player): ${origin}/\n`);
24
+ const url = new URL(`${origin}/v2/auth/authorize`);
25
+ url.searchParams.set('client_key', DEFAULT_CLIENT_KEY);
26
+ url.searchParams.set('response_type', 'code');
27
+ url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]);
28
+ url.searchParams.set('scope', 'user.info.basic,user.info.profile,video.list');
29
+ url.searchParams.set('state', 'twin-demo-state');
30
+ process.stdout.write(`authorization page: ${url.toString()}\n`);
31
+ }
32
+ await keepProcessAlive();
33
+ }
34
+ else if (cmd === 'conformance') {
35
+ // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
36
+ const { checkTikTokConformance } = await import("./tiktok-conformance.js");
37
+ const report = await checkTikTokConformance({ ...(root ? { root } : {}) });
38
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
39
+ if (!report.ok)
40
+ process.exitCode = 1;
41
+ }
42
+ else {
43
+ process.stdout.write('Usage: world-tiktok serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
44
+ }
@@ -0,0 +1,22 @@
1
+ import { type TwinPack } from '@volter/world-core';
2
+ export { ACCESS_TOKEN_TTL_SECONDS, API_ORIGIN, AUTH_CODE_TTL_SECONDS, AUTHORIZE_ORIGIN, CLIENT_TOKEN_TTL_SECONDS, handleTikTokTwinRequest, METERED_ENDPOINTS, RATE_LIMIT_PER_WINDOW, RATE_LIMITS, RATE_WINDOW_SECONDS, REFRESH_TOKEN_TTL_SECONDS, RESOURCE_TYPES, tiktokTwinSnapshot, USER_FIELDS, VIDEO_FIELDS, VIDEO_LIST_DEFAULT_COUNT, VIDEO_LIST_MAX_COUNT, VIDEO_QUERY_MAX_IDS, } from './tiktok-twin.js';
3
+ export type { MeteredEndpoint, TikTokRequest, TikTokResponse } from './tiktok-twin.js';
4
+ export { createTikTokConsentServer, createTikTokTwinFetch, createTikTokTwinServer, type TikTokTwinFetchOptions } from './tiktok-server.js';
5
+ export { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_KEY, DEFAULT_CLIENT_SECRET, DEFAULT_VIDEOS, defaultRedirectUris, openIdFor, redirectUriAllowed, sessionAccount, } from './tiktok-store.js';
6
+ export { CHALLENGE_METHOD, isSupportedChallengeMethod, isWellFormedVerifier, pkceVerifies, rfc7636Challenge, tiktokCodeChallenge } from './tiktok-pkce.js';
7
+ export { describeScope, describeScopes, formatScopeParam, KNOWN_SCOPES, parseScopeParam, SCOPE_CATALOG, sortScopesForConsent, } from './tiktok-scopes.js';
8
+ export type { ScopeInfo, ScopeProduct } from './tiktok-scopes.js';
9
+ export { API_ERRORS, OAUTH_ERRORS } from './tiktok-errors.js';
10
+ export { logId } from './tiktok-ids.js';
11
+ export { renderUser, renderVideo, USER_FIELD_SCOPES, USER_INFO_SCOPES, VIDEO_SCOPE } from './tiktok-user.js';
12
+ export { liveTikTokExecute, mapUserInfoAccount, mapVideo, performTikTokAction, PULL_USER_FIELDS, PULL_VIDEO_FIELDS, pullTikTok, pushPendingTikTokActions, syncTikTokFromReal, syncTikTokFromRemote, tiktokExecuteOver, } from './tiktok-connector.js';
13
+ export type { LiveTikTokOptions, PullOptions, TikTokExecute } from './tiktok-connector.js';
14
+ export { budgetedTikTokExecute, MAX_PUBLISH_WAIT_MS, TikTokRetryableError, PERFORM_CHUNK_BYTES, performChunkPlan, STATUS_POLL_MS, TikTokStillProcessingError, } from './tiktok-connector.js';
15
+ export { checkChunkPlan, chunkLength, creatorOptions, DEFAULT_MAX_VIDEO_POST_DURATION_SEC, MAX_CHUNK_BYTES, MAX_CHUNK_COUNT, MAX_FINAL_CHUNK_BYTES, MAX_SERVED_RANGE, MAX_VIDEO_BYTES, MEDIA_PREFIX, MIN_CHUNK_BYTES, PRIVACY_LEVELS, processingMs, UPLOAD_ORIGIN, UPLOAD_PATH, UPLOAD_URL_TTL_MS, } from './tiktok-posting.js';
16
+ export { POSTING_ERRORS } from './tiktok-errors.js';
17
+ export { TIKTOK_BUDGET_BURST_CEILING, TIKTOK_BUDGET_CEILING, TIKTOK_BUDGET_MAX_RETRY_AFTER_S, TIKTOK_BUDGET_WINDOW_MS, TIKTOK_CALL_WEIGHTS, TIKTOK_RATE_BUDGET, TikTokBudget, TikTokBudgetError, tiktokBudgetPath, tiktokCallWeight, } from './tiktok-budget.js';
18
+ export type { TikTokBudgetErrorKind, TikTokBudgetOptions, TikTokBudgetReservation, TikTokBudgetSnapshot, } from './tiktok-budget.js';
19
+ export { CONSENT_CSS, consentPageHtml, errorPageHtml, tiktokConsentState } from './tiktok-consent-ui.js';
20
+ export { captionOf, captionSegments, compactCount, createTiktokMirrorServer, mediaSource, profileStats } from './tiktok-mirror-ui.js';
21
+ export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './tiktok-consent-ui.js';
22
+ export declare const pack: TwinPack;