@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,54 @@
1
+ // A real video, as data only — the one sample file the pack's conformance probes, capability verifies
2
+ // and gate replay upload. Kept apart from the handler so the dev-plane gate can read it without
3
+ // importing the code it exists to sabotage.
4
+ /**
5
+ * A REAL video the posting probes upload: one second of 360x640 black at 24 FPS, H.264 in MP4
6
+ * (`ffmpeg -f lavfi -i color=c=black:size=360x640:rate=24 -t 1 -crf 51 -movflags +faststart`), the
7
+ * smallest file TikTok's checks accept — the twin reads its duration, picture size and frame rate,
8
+ * and a file it cannot read fails with file_format_check_failed.
9
+ */
10
+ export const TINY_MP4: Uint8Array<ArrayBuffer> = Uint8Array.from(atob(''
11
+ + 'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAQHbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAA'
12
+ + 'AAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA'
13
+ + 'AzF0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAA'
14
+ + 'AAAAAAAAAAAAAABAAAAAAWgAAAKAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAEAAABAAAAAAKpbWRpYQAAACBtZGhk'
15
+ + 'AAAAAAAAAAAAAAAAAAAwAAAAMABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAACVG1p'
16
+ + 'bmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAhRzdGJsAAAAxHN0c2QA'
17
+ + 'AAAAAAAAAQAAALRhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAWgCgABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGli'
18
+ + 'eDI2NAAAAAAAAAAAAAAAGP//AAAAOmF2Y0MBZAAf/+EAHGdkAB+scgRBcFHl8BEAAAMAAQAAAwAwDxgxhGABAAdo6EOBlLIs/fj4'
19
+ + 'AAAAABBwYXNwAAAAAQAAAAEAAAAUYnRydAAAAAAAACVYAAAAAAAAABhzdHRzAAAAAAAAAAEAAAAYAAACAAAAABRzdHNzAAAAAAAA'
20
+ + 'AAEAAAABAAAAeGN0dHMAAAAAAAAADQAAAAEAAAQAAAAAAQAAFAAAAAABAAAIAAAAAAMAAAAAAAAABAAAAgAAAAABAAAUAAAAAAEA'
21
+ + 'AAgAAAAAAwAAAAAAAAAEAAACAAAAAAEAAAwAAAAAAQAABAAAAAABAAAAAAAAAAIAAAIAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAAY'
22
+ + 'AAAAAQAAAHRzdHN6AAAAAAAAAAAAAAAYAAADCQAAABIAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABcAAAASAAAA'
23
+ + 'EgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABkAAAASAAAAEgAAABIAAAASAAAAFHN0Y28AAAAAAAAAAQAABDcAAABidWR0YQAA'
24
+ + 'AFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAA'
25
+ + 'TGF2ZjYyLjEyLjEwMgAAAAhmcmVlAAAEs21kYXQAAAKxBgX//63cRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIy'
26
+ + 'IGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xh'
27
+ + 'bi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MSByZWY9MTYgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDEzMyBt'
28
+ + 'ZT11bWggc3VibWU9MTAgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0yNCBjaHJvbWFfbWU9MSB0'
29
+ + 'cmVsbGlzPTIgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhy'
30
+ + 'ZWFkcz0xOCBsb29rYWhlYWRfdGhyZWFkcz0zIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBi'
31
+ + 'bHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTggYl9weXJhbWlkPTIgYl9hZGFwdD0yIGJfYmlhcz0w'
32
+ + 'IGRpcmVjdD0zIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MiBrZXlpbnQ9MjUwIGtleWludF9taW49MjQgc2NlbmVjdXQ9'
33
+ + 'NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD02MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTUxLjAgcWNvbXA9MC42MCBxcG1p'
34
+ + 'bj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAABQZYiBAAT/Hru//lSYAAADAAAKEgzs1poj'
35
+ + 'pAAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAAwAAC7kAAAAOQZoJLYhP/wAAAwAAEnAA'
36
+ + 'AAANQZ4QhxCPAAADAAA1IQAAAA0BnhgmiL8AAAMAADegAAAADQGeGEaIvwAAAwAAN6EAAAANAZ4YZoi/AAADAAA3oQAAAA0Bnhit'
37
+ + 'SL8AAAMAADehAAAADQGeGM1IvwAAAwAAN6EAAAANAZ4Y7Ui/AAADAAA3oAAAAA0BnhkNSL8AAAMAADegAAAAE0GaGkk1AgLRMpgQ'
38
+ + 'jwAAAwAAFTEAAAAOQZ4hpcQj/wAAAwAANSAAAAAOAZ4pRaIv/wAAAwAAN6AAAAAOAZ4pZaIv/wAAAwAAN6EAAAAOAZ4phaIv/wAA'
39
+ + 'AwAAN6EAAAAOAZ4pzJIv/wAAAwAAN6EAAAAOAZ4p7JIv/wAAAwAAN6AAAAAOAZ4qDJIv/wAAAwAAN6AAAAAOAZ4qLJIv/wAAAwAA'
40
+ + 'N6EAAAAVQZoq6bUCAtrRMpgBF/8AAAMAAB6QAAAADkGeMoSxH/8AAAMAADZgAAAADgGeOmSoi/8AAAMAADehAAAADgGeOqzSL/8A'
41
+ + 'AAMAADegAAAADgGeOszSL/8AAAMAADeh'), (c) => c.charCodeAt(0));
42
+
43
+ /** TINY_MP4 grown to exactly `size` bytes by a trailing `free` box (ISO BMFF's padding box): still a
44
+ * valid, readable MP4, for the probes that need a video past TikTok's 5 MB chunk floor. */
45
+ export function paddedMp4(size: number): Uint8Array<ArrayBuffer> {
46
+ const pad = size - TINY_MP4.length;
47
+ if (pad < 8) throw new Error(`paddedMp4: ${size} is below ${TINY_MP4.length + 8}`);
48
+ const out = new Uint8Array(size);
49
+ out.set(TINY_MP4);
50
+ new DataView(out.buffer).setUint32(TINY_MP4.length, pad);
51
+ out.set([0x66, 0x72, 0x65, 0x65], TINY_MP4.length + 4);
52
+ for (let i = TINY_MP4.length + 8; i < size; i += 1) out[i] = (i * 31 + (i >> 11)) & 0xff;
53
+ return out;
54
+ }
@@ -0,0 +1,122 @@
1
+ // TikTok OAuth scopes — the vendor's own catalog, transcribed from the TikTok for Developers
2
+ // "Scopes Overview" page (https://developers.tiktok.com/doc/tiktok-api-scopes/, fetched
3
+ // 2026-09-13) together with the per-product reference pages the overview links to.
4
+ //
5
+ // TWO THINGS THIS VENDOR DOES THAT THE X / GOOGLE CONSENT MODELS DO NOT:
6
+ //
7
+ // 1. The scope parameter is COMMA-separated (`scope=user.info.basic,user.info.profile`), not
8
+ // space-separated. That is documented on the Login Kit for Web parameter table ("A comma (,)
9
+ // separated string of authorization scope(s)") and it is exactly how Dub builds its authorize
10
+ // URL. A twin that accepted only spaces would refuse the vendor's own callers.
11
+ //
12
+ // 2. Consent is GRANULAR, not all-or-nothing. The documented callback carries a `scopes`
13
+ // parameter — "A comma-separated (,) string of authorization scope(s), which the user has
14
+ // granted" — which only has meaning if the granted set can be a SUBSET of the requested set,
15
+ // and the token response's `scope` is likewise "the scopes the user has agreed to authorize".
16
+ // So the authorize screen this twin serves offers one checkbox per requested scope and the
17
+ // granted set is what came back checked. EVIDENCE BOUNDARY: the two parameters above are
18
+ // documented; the SHAPE of the live affordance (per-scope toggles, which scopes are
19
+ // mandatory, what the buttons say) was not captured from a vendor artefact and is pinned by
20
+ // `tiktok.consent.optional_scope_toggles` and `tiktok.consent.button_wording` (todo).
21
+ //
22
+ // EVIDENCE BOUNDARY on the LABELS, and it is NOT uniform — say which is which rather than let a
23
+ // "verbatim" claim cover the lot. THREE labels are the Scopes Overview's own published text, word
24
+ // for word, and are asserted as such by `tiktok.scopes.catalog_matches_vendor_list`:
25
+ // `user.info.basic`, `user.info.profile` and `video.list`. The other EIGHTEEN are TWIN PARAPHRASES
26
+ // written from the overview's per-product descriptions — accurate in substance, not quotations —
27
+ // because the overview publishes those rows as product prose rather than as a scope string. Every
28
+ // label is developer-facing either way, never a capture of the live consent sheet, so
29
+ // `tiktok.scopes.consent_wording` (todo) pins the strings a person actually reads. The SCOPE IDS,
30
+ // by contrast, are all twenty-one exactly as published, and that set is what the capability
31
+ // asserts a bijection against.
32
+
33
+ export type ScopeProduct = 'user' | 'video' | 'portability' | 'research' | 'local';
34
+
35
+ export type ScopeInfo = {
36
+ scope: string;
37
+ /** The vendor's own published description of the scope. */
38
+ label: string;
39
+ product: ScopeProduct;
40
+ /** In the vendor catalog? An unknown scope is still displayed, flagged, so a typo is visible. */
41
+ known: boolean;
42
+ };
43
+
44
+ /** The scopes TikTok's scopes-overview page publishes, keyed by scope id. */
45
+ export const SCOPE_CATALOG: Record<string, { label: string; product: ScopeProduct }> = {
46
+ // ── User Info API ──
47
+ // VERBATIM marks a label that is the Scopes Overview's own published text, word for word;
48
+ // every unmarked label is a TWIN PARAPHRASE of that page's product prose (see the header).
49
+ // VERBATIM
50
+ 'user.info.basic': { label: "Read a user's profile info (open id, avatar, display name...)", product: 'user' },
51
+ // VERBATIM
52
+ 'user.info.profile': { label: 'Read access to profile_web_link, profile_deep_link, bio_description, is_verified', product: 'user' },
53
+ 'user.info.stats': { label: 'Read your engagement metrics: follower count, following count, likes count and video count', product: 'user' },
54
+ // ── Display API / Content Posting API ──
55
+ // VERBATIM
56
+ 'video.list': { label: "Read a user's public videos on TikTok", product: 'video' },
57
+ 'video.upload': { label: 'Share videos to your account as drafts, for you to edit before posting', product: 'video' },
58
+ 'video.publish': { label: "Directly post content to a user's TikTok profile", product: 'video' },
59
+ // ── Data Portability API ──
60
+ 'portability.activity.ongoing': { label: 'Export your activity data on an ongoing basis', product: 'portability' },
61
+ 'portability.activity.single': { label: 'Export your activity data once', product: 'portability' },
62
+ 'portability.all.ongoing': { label: 'Export your complete data archive on an ongoing basis', product: 'portability' },
63
+ 'portability.all.single': { label: 'Export your complete data archive once', product: 'portability' },
64
+ 'portability.directmessages.ongoing': { label: 'Export your direct messages on an ongoing basis', product: 'portability' },
65
+ 'portability.directmessages.single': { label: 'Export your direct messages once', product: 'portability' },
66
+ 'portability.postsandprofile.ongoing': { label: 'Export your posts and profile data on an ongoing basis', product: 'portability' },
67
+ 'portability.postsandprofile.single': { label: 'Export your posts and profile data once', product: 'portability' },
68
+ // ── Research API ──
69
+ 'research.data.basic': { label: 'Access public data for research purposes', product: 'research' },
70
+ 'research.data.u18eu': { label: 'Access data of European users under 18 for research purposes', product: 'research' },
71
+ 'research.data.vra': { label: 'Access vetted-researcher provisioned data', product: 'research' },
72
+ 'research.adlib.basic': { label: 'Access commercial content data for research purposes', product: 'research' },
73
+ // ── Local Services API ──
74
+ 'local.product.manage': { label: 'Manage the products of your local services account', product: 'local' },
75
+ 'local.shop.manage': { label: 'Manage the shops of your local services account', product: 'local' },
76
+ 'local.voucher.manage': { label: 'Manage the vouchers of your local services account', product: 'local' },
77
+ };
78
+
79
+ export const KNOWN_SCOPES: readonly string[] = Object.keys(SCOPE_CATALOG);
80
+
81
+ /**
82
+ * Comma-separated scope param -> ordered unique scope list.
83
+ *
84
+ * Whitespace around a comma is tolerated because a caller that pretty-prints its scope constant
85
+ * would otherwise send a scope literally named " video.list". The SEPARATOR the twin requires is
86
+ * still the comma: a space-separated X-style string parses as ONE unknown scope and renders
87
+ * flagged on the screen, which is the integration bug a developer needs to see.
88
+ */
89
+ export function parseScopeParam(raw: string | null): string[] {
90
+ if (!raw) return [];
91
+ const seen = new Set<string>();
92
+ const out: string[] = [];
93
+ for (const piece of raw.split(',')) {
94
+ const s = piece.trim();
95
+ if (!s || seen.has(s)) continue;
96
+ seen.add(s);
97
+ out.push(s);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** The wire form: comma-separated, no spaces (the docs' own `user.info.basic,video.list`). */
103
+ export function formatScopeParam(scopes: readonly string[]): string {
104
+ return scopes.join(',');
105
+ }
106
+
107
+ export function describeScope(scope: string): ScopeInfo {
108
+ const known = SCOPE_CATALOG[scope];
109
+ return known
110
+ ? { scope, label: known.label, product: known.product, known: true }
111
+ : { scope, label: scope, product: 'user', known: false };
112
+ }
113
+
114
+ export function describeScopes(scopes: readonly string[]): ScopeInfo[] {
115
+ return scopes.map(describeScope);
116
+ }
117
+
118
+ /** Screen order: the vendor's own product order, stable so the page is byte-identical on reload. */
119
+ const PRODUCT_ORDER: Record<ScopeProduct, number> = { user: 0, video: 1, portability: 2, research: 3, local: 4 };
120
+ export function sortScopesForConsent(scopes: readonly string[]): string[] {
121
+ return [...scopes].sort((a, b) => PRODUCT_ORDER[describeScope(a).product] - PRODUCT_ORDER[describeScope(b).product]);
122
+ }
@@ -0,0 +1,100 @@
1
+ // TikTok twin HTTP server — ONE server for the whole vendor surface, because Login Kit is one
2
+ // product spread over two hosts. Point `www.tiktok.com` (the authorization page) and
3
+ // `open.tiktokapis.com` (the OAuth + Display API endpoints) here and an unmodified TikTok
4
+ // integration completes a full authorization-code round trip against it.
5
+ //
6
+ // Two things this server does that a JSON-only twin server does not:
7
+ // • it can answer with HTML and with 302 redirects — the authorization page and the callback
8
+ // bounce are the protocol, not decoration, so the handler's `headers` (content-type, location)
9
+ // pass straight through rather than being flattened into a JSON envelope;
10
+ // • it tells the handler its OWN origin, so the consent form's action points BACK AT THE TWIN.
11
+ //
12
+ // The page needs no assets: the stylesheet is inlined and the screen ships no JavaScript, so there
13
+ // is no bundler and no disk read anywhere on the serve path (runtime contract R12b, R9).
14
+ import { serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
15
+ import { UPLOAD_PATH } from './tiktok-posting.ts';
16
+ import { handleTikTokTwinRequest } from './tiktok-twin.ts';
17
+
18
+ /** Options every TikTok-twin HTTP surface needs, independent of who owns the socket. */
19
+ export interface TikTokTwinFetchOptions {
20
+ root?: string;
21
+ readOnly?: boolean;
22
+ }
23
+
24
+ /**
25
+ * The pack's whole HTTP surface as a plain `fetch` — Request in, Response out, no listener.
26
+ *
27
+ * This is the composable form (runtime contract R12b): a Worker / Durable Object entry has NO
28
+ * loopback ports, so it must mount a pack's handler IN-PROCESS. `createTikTokTwinServer` is
29
+ * nothing but `Bun.serve` wrapped around this closure, so the standalone (R1) and hosted surfaces
30
+ * are the SAME code — there is no second HTTP adaptation to drift.
31
+ */
32
+ export function createTikTokTwinFetch(options: TikTokTwinFetchOptions = {}): (request: Request) => Promise<Response> {
33
+ const readOnly = options.readOnly ?? false;
34
+ return async function tiktokTwinFetch(request: Request): Promise<Response> {
35
+ const url = new URL(request.url);
36
+ // GET /twin — the discovery manifest (education inside the twin).
37
+ if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
38
+ return Response.json(statefulTwinManifest({
39
+ vendor: 'tiktok',
40
+ twinOf: 'TikTok Login Kit, the Display API and the Content Posting API',
41
+ stores: 'developer apps, TikTok accounts and their videos (with the posted media), authorization codes, user access/refresh tokens, the authorization-page round trip and Content Posting publishes',
42
+ }));
43
+ }
44
+
45
+ // the upload URL's chunk PUT carries BYTES; every other body is text (form or JSON)
46
+ const upload = url.pathname.replace(/\/+$/, '') === UPLOAD_PATH;
47
+ const raw = request.method === 'GET' || request.method === 'HEAD' ? new Uint8Array(0) : new Uint8Array(await request.arrayBuffer());
48
+ const body = upload ? '' : new TextDecoder().decode(raw);
49
+ const headers: Record<string, string> = {};
50
+ request.headers.forEach((value, key) => {
51
+ headers[key.toLowerCase()] = value;
52
+ });
53
+
54
+ const res = await handleTikTokTwinRequest({
55
+ method: request.method,
56
+ path: url.pathname + (url.search || ''),
57
+ body,
58
+ headers,
59
+ readOnly,
60
+ occurredAt: worldNow(),
61
+ origin: twinPublicBase(request),
62
+ callbackOrigin: url.origin,
63
+ ...(upload ? { bytes: raw } : {}),
64
+ ...(headers['x-volter-twin-original-host'] ? { originalHost: headers['x-volter-twin-original-host'].toLowerCase() } : {}),
65
+ ...(options.root !== undefined ? { root: options.root } : {}),
66
+ });
67
+
68
+ const out = { ...(res.headers ?? {}) };
69
+ // a post's bytes (the media route): a range as bytes, a whole file as a stream of ranged reads;
70
+ // HEAD answers the headers alone
71
+ if (res.body instanceof Uint8Array || res.body instanceof ReadableStream) {
72
+ const media = request.method === 'HEAD' ? null : (res.body as Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array>);
73
+ return new Response(media, { status: res.status, headers: out });
74
+ }
75
+ // A string body is already rendered (HTML, or the empty body of a 302); anything else is the
76
+ // vendor's JSON.
77
+ if (typeof res.body === 'string') {
78
+ if (!out['content-type'] && res.body) out['content-type'] = 'text/html; charset=utf-8';
79
+ return new Response(res.body, { status: res.status, headers: out });
80
+ }
81
+ out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
82
+ return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
83
+ };
84
+ }
85
+
86
+ export async function createTikTokTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; stop: () => void }> {
87
+ const server = await serveHttp({
88
+ port: options.port ?? 0,
89
+ idleTimeout: 60,
90
+ fetch: createTikTokTwinFetch(options),
91
+ });
92
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
93
+ }
94
+
95
+ /**
96
+ * The authorization page is served BY THE TWIN, at the vendor's own path — there is no second
97
+ * "mirror" server to start. This alias exists so the `world-tiktok mirror` command and the journey
98
+ * harness have the conventional entry point, and it returns the very same server.
99
+ */
100
+ export const createTikTokConsentServer = createTikTokTwinServer;