@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,28 @@
1
+ import type { Row } from './tiktok-store.js';
2
+ export declare const USER_INFO_SCOPES: readonly ["user.info.basic", "user.info.profile", "user.info.stats"];
3
+ /** Field -> the scope that gates it, in the reference table's own order. */
4
+ export declare const USER_FIELD_SCOPES: Record<string, string>;
5
+ export declare const USER_FIELDS: readonly string[];
6
+ /**
7
+ * Render an account row as the vendor's User object for a requested field set.
8
+ *
9
+ * `open_id` is APP-SCOPED and therefore comes from the grant, not from the account — that is the
10
+ * whole distinction TikTok draws between `open_id` and `union_id`. A modelled field whose value
11
+ * this persona genuinely lacks is OMITTED rather than served as a placeholder (the connector's
12
+ * "invent nothing" rule, applied at the read side too).
13
+ */
14
+ export declare function renderUser(account: Row, openId: string, fields: readonly string[]): Record<string, unknown>;
15
+ /** The Video Object's fields, verbatim from the vendor's own reference table. */
16
+ export declare const VIDEO_FIELDS: readonly string[];
17
+ /** The single scope every Display API video read is gated behind. */
18
+ export declare const VIDEO_SCOPE = "video.list";
19
+ /**
20
+ * Render a video row for a requested field set.
21
+ *
22
+ * The four LINK fields are DERIVED, deterministically, from the stored row — TikTok composes them
23
+ * the same way (`share_url` is the canonical `/@handle/video/<id>` permalink and `embed_link` the
24
+ * `/embed/v2/<id>` player). Deriving keeps them consistent with the id and the owner's handle
25
+ * instead of storing four strings that could drift apart; the exact CDN host of `cover_image_url`
26
+ * is a twin host, not the vendor's, and `tiktok.video.link_shapes` (todo) pins the live forms.
27
+ */
28
+ export declare function renderVideo(video: Row, ownerUsername: string, fields: readonly string[]): Record<string, unknown>;
@@ -0,0 +1,174 @@
1
+ export const USER_INFO_SCOPES = ['user.info.basic', 'user.info.profile', 'user.info.stats'];
2
+ /** Field -> the scope that gates it, in the reference table's own order. */
3
+ export const USER_FIELD_SCOPES = {
4
+ open_id: 'user.info.basic',
5
+ union_id: 'user.info.basic',
6
+ avatar_url: 'user.info.basic',
7
+ avatar_url_100: 'user.info.basic',
8
+ avatar_large_url: 'user.info.basic',
9
+ display_name: 'user.info.basic',
10
+ bio_description: 'user.info.profile',
11
+ profile_deep_link: 'user.info.profile',
12
+ is_verified: 'user.info.profile',
13
+ username: 'user.info.profile',
14
+ follower_count: 'user.info.stats',
15
+ following_count: 'user.info.stats',
16
+ likes_count: 'user.info.stats',
17
+ video_count: 'user.info.stats',
18
+ };
19
+ export const USER_FIELDS = Object.keys(USER_FIELD_SCOPES);
20
+ /**
21
+ * Render an account row as the vendor's User object for a requested field set.
22
+ *
23
+ * `open_id` is APP-SCOPED and therefore comes from the grant, not from the account — that is the
24
+ * whole distinction TikTok draws between `open_id` and `union_id`. A modelled field whose value
25
+ * this persona genuinely lacks is OMITTED rather than served as a placeholder (the connector's
26
+ * "invent nothing" rule, applied at the read side too).
27
+ */
28
+ export function renderUser(account, openId, fields) {
29
+ const out = {};
30
+ const put = (key, value) => {
31
+ if (value !== undefined && value !== null)
32
+ out[key] = value;
33
+ };
34
+ for (const field of fields) {
35
+ switch (field) {
36
+ case 'open_id':
37
+ put(field, openId);
38
+ break;
39
+ case 'union_id':
40
+ put(field, account.id);
41
+ break;
42
+ case 'avatar_url':
43
+ put(field, account.avatarUrl);
44
+ break;
45
+ case 'avatar_url_100':
46
+ put(field, account.avatarUrl100);
47
+ break;
48
+ case 'avatar_large_url':
49
+ put(field, account.avatarLargeUrl);
50
+ break;
51
+ case 'display_name':
52
+ put(field, account.displayName);
53
+ break;
54
+ case 'bio_description':
55
+ put(field, account.bioDescription);
56
+ break;
57
+ case 'profile_deep_link':
58
+ put(field, account.profileDeepLink);
59
+ break;
60
+ case 'is_verified':
61
+ put(field, typeof account.isVerified === 'boolean' ? account.isVerified : undefined);
62
+ break;
63
+ case 'username':
64
+ put(field, account.username);
65
+ break;
66
+ case 'follower_count':
67
+ put(field, account.followerCount);
68
+ break;
69
+ case 'following_count':
70
+ put(field, account.followingCount);
71
+ break;
72
+ case 'likes_count':
73
+ put(field, account.likesCount);
74
+ break;
75
+ case 'video_count':
76
+ put(field, account.videoCount);
77
+ break;
78
+ default: break;
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ /** The Video Object's fields, verbatim from the vendor's own reference table. */
84
+ export const VIDEO_FIELDS = [
85
+ 'id',
86
+ 'create_time',
87
+ 'cover_image_url',
88
+ 'share_url',
89
+ 'video_description',
90
+ 'duration',
91
+ 'height',
92
+ 'width',
93
+ 'title',
94
+ 'embed_html',
95
+ 'embed_link',
96
+ 'like_count',
97
+ 'comment_count',
98
+ 'share_count',
99
+ 'view_count',
100
+ 'is_aigc',
101
+ ];
102
+ /** The single scope every Display API video read is gated behind. */
103
+ export const VIDEO_SCOPE = 'video.list';
104
+ /**
105
+ * Render a video row for a requested field set.
106
+ *
107
+ * The four LINK fields are DERIVED, deterministically, from the stored row — TikTok composes them
108
+ * the same way (`share_url` is the canonical `/@handle/video/<id>` permalink and `embed_link` the
109
+ * `/embed/v2/<id>` player). Deriving keeps them consistent with the id and the owner's handle
110
+ * instead of storing four strings that could drift apart; the exact CDN host of `cover_image_url`
111
+ * is a twin host, not the vendor's, and `tiktok.video.link_shapes` (todo) pins the live forms.
112
+ */
113
+ export function renderVideo(video, ownerUsername, fields) {
114
+ const shareUrl = `https://www.tiktok.com/@${ownerUsername}/video/${video.id}`;
115
+ const out = {};
116
+ const put = (key, value) => {
117
+ if (value !== undefined && value !== null)
118
+ out[key] = value;
119
+ };
120
+ for (const field of fields) {
121
+ switch (field) {
122
+ case 'id':
123
+ put(field, video.id);
124
+ break;
125
+ case 'create_time':
126
+ put(field, video.createTime);
127
+ break;
128
+ case 'cover_image_url':
129
+ put(field, `https://p16-sign.tiktokcdn-us.com/twin/${video.id}~tplv-cover.jpeg`);
130
+ break;
131
+ case 'share_url':
132
+ put(field, shareUrl);
133
+ break;
134
+ case 'video_description':
135
+ put(field, video.videoDescription);
136
+ break;
137
+ case 'duration':
138
+ put(field, video.duration);
139
+ break;
140
+ case 'height':
141
+ put(field, video.height);
142
+ break;
143
+ case 'width':
144
+ put(field, video.width);
145
+ break;
146
+ case 'title':
147
+ put(field, video.title);
148
+ break;
149
+ case 'embed_html':
150
+ put(field, `<blockquote class="tiktok-embed" cite="${shareUrl}" data-video-id="${video.id}"></blockquote>`);
151
+ break;
152
+ case 'embed_link':
153
+ put(field, `https://www.tiktok.com/embed/v2/${video.id}`);
154
+ break;
155
+ case 'like_count':
156
+ put(field, video.likeCount);
157
+ break;
158
+ case 'comment_count':
159
+ put(field, video.commentCount);
160
+ break;
161
+ case 'share_count':
162
+ put(field, video.shareCount);
163
+ break;
164
+ case 'view_count':
165
+ put(field, video.viewCount);
166
+ break;
167
+ case 'is_aigc':
168
+ put(field, video.isAigc === true);
169
+ break;
170
+ default: break;
171
+ }
172
+ }
173
+ return out;
174
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@volter/twin-tiktok",
3
+ "version": "0.1.0",
4
+ "description": "Local TikTok twin — the real tiktok.com authorization page, the full Login Kit OAuth round trip (token, refresh, revoke, client_credentials) and the Display API user/video reads with TikTok's own comma-separated scopes, per-app open_id, field-level scope gating and both of its error envelopes, so an unmodified TikTok integration completes sign-in-with-TikTok against it. Built on @volter/world-core.",
5
+ "keywords": [
6
+ "twin",
7
+ "local",
8
+ "mock",
9
+ "mirror",
10
+ "simulator",
11
+ "fixtures",
12
+ "testing",
13
+ "oauth",
14
+ "oauth2",
15
+ "tiktok",
16
+ "login-kit",
17
+ "display-api",
18
+ "identity",
19
+ "sign-in-with-tiktok"
20
+ ],
21
+ "author": "Volter (https://github.com/volter-ai)",
22
+ "license": "Apache-2.0",
23
+ "files": [
24
+ "src",
25
+ "client",
26
+ "README.md",
27
+ "LICENSE",
28
+ "!**/*.test.ts",
29
+ "!**/*.test.tsx",
30
+ "dist"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/volter-ai/twin.git",
35
+ "directory": "packages/twin/tiktok"
36
+ },
37
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/tiktok#readme",
38
+ "type": "module",
39
+ "exports": {
40
+ ".": {
41
+ "types": "./dist/src/index.d.ts",
42
+ "default": "./dist/src/index.js"
43
+ }
44
+ },
45
+ "bin": {
46
+ "world-tiktok": "dist/src/cli.js"
47
+ },
48
+ "scripts": {
49
+ "test": "bun test src/*.test.ts",
50
+ "typecheck": "tsc --noEmit",
51
+ "build": "node ../../../scripts/publish/build.mjs",
52
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
53
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
54
+ },
55
+ "dependencies": {
56
+ "react": "^19.2.7",
57
+ "react-dom": "^19.2.7"
58
+ },
59
+ "peerDependencies": {
60
+ "@volter/world-core": "2.0.0"
61
+ },
62
+ "devDependencies": {
63
+ "@types/bun": "^1.2.20",
64
+ "@types/node": "^24.0.0",
65
+ "@types/react": "^19.2.17",
66
+ "@types/react-dom": "^19.2.3",
67
+ "@volter/world-core": "2.0.0",
68
+ "@volter/world-tooling": "0.1.0",
69
+ "typescript": "^5.9.0"
70
+ },
71
+ "engines": {
72
+ "node": ">=22.3"
73
+ }
74
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,43 @@
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.ts';
11
+ import { createTiktokMirrorServer } from './tiktok-mirror-ui.ts';
12
+ import { DEFAULT_CLIENT_KEY, defaultRedirectUris } from './tiktok-store.ts';
13
+
14
+ const [cmd, ...rest] = process.argv.slice(2);
15
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
16
+ const root = optionValue(rest, '--root') || undefined;
17
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
18
+
19
+ if (cmd === 'serve' || cmd === 'mirror') {
20
+ const options = { readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) };
21
+ const s = cmd === 'mirror' ? await createTiktokMirrorServer(options) : await createTikTokTwinServer(options);
22
+ const origin = `http://127.0.0.1:${s.port}`;
23
+ process.stdout.write(`tiktok twin (Login Kit OAuth + Display API + Content Posting)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
24
+ if (cmd === 'mirror') {
25
+ process.stdout.write(`mirror (tiktok.com profile + player): ${origin}/\n`);
26
+ const url = new URL(`${origin}/v2/auth/authorize`);
27
+ url.searchParams.set('client_key', DEFAULT_CLIENT_KEY);
28
+ url.searchParams.set('response_type', 'code');
29
+ url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]!);
30
+ url.searchParams.set('scope', 'user.info.basic,user.info.profile,video.list');
31
+ url.searchParams.set('state', 'twin-demo-state');
32
+ process.stdout.write(`authorization page: ${url.toString()}\n`);
33
+ }
34
+ await keepProcessAlive();
35
+ } else if (cmd === 'conformance') {
36
+ // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
37
+ const { checkTikTokConformance } = await import('./tiktok-conformance.ts');
38
+ const report = await checkTikTokConformance({ ...(root ? { root } : {}) });
39
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
40
+ if (!report.ok) process.exitCode = 1;
41
+ } else {
42
+ process.stdout.write('Usage: world-tiktok serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,270 @@
1
+ // @volter/twin-tiktok — the TikTok twin (one vendor, one package), built on the shared
2
+ // @volter/world-core kernel.
3
+ //
4
+ // A BROWSER-FACING protocol pack (the googleoauth class): it serves TikTok's own authorization
5
+ // page as HTML at www.tiktok.com's real path, completes the full Login Kit authorization-code
6
+ // round trip (consent -> 302 with code+scopes+state -> redemption at the token endpoint -> refresh
7
+ // -> revoke), and answers the Display API's `/v2/user/info/`, `/v2/video/list/` and
8
+ // `/v2/video/query/` reads with TikTok's real field-selection, scope-gating and error envelopes, and
9
+ // posts a creator's video through the Content Posting API (creator_info, the Direct Post and inbox
10
+ // inits, the chunked upload to a signed upload URL, status/fetch) with the bytes on the blob seam, a
11
+ // tiktok.com-style mirror over that state, and a perform that sends a World's post to TikTok.
12
+ // Deliberately NON-OIDC, exactly as the vendor is: opaque `act.`/`rft.` tokens, no id_token, no
13
+ // JWKS — identity comes from the Display API, and `open_id` is per-app while `union_id` is
14
+ // per-human. Conformance tooling lives in @volter/world-tooling (a dev dependency), not here.
15
+ import { registerPack, type TwinPack } from '@volter/world-core';
16
+
17
+ export {
18
+ ACCESS_TOKEN_TTL_SECONDS,
19
+ API_ORIGIN,
20
+ AUTH_CODE_TTL_SECONDS,
21
+ AUTHORIZE_ORIGIN,
22
+ CLIENT_TOKEN_TTL_SECONDS,
23
+ handleTikTokTwinRequest,
24
+ METERED_ENDPOINTS,
25
+ RATE_LIMIT_PER_WINDOW,
26
+ RATE_LIMITS,
27
+ RATE_WINDOW_SECONDS,
28
+ REFRESH_TOKEN_TTL_SECONDS,
29
+ RESOURCE_TYPES,
30
+ tiktokTwinSnapshot,
31
+ USER_FIELDS,
32
+ VIDEO_FIELDS,
33
+ VIDEO_LIST_DEFAULT_COUNT,
34
+ VIDEO_LIST_MAX_COUNT,
35
+ VIDEO_QUERY_MAX_IDS,
36
+ } from './tiktok-twin.ts';
37
+ export type { MeteredEndpoint, TikTokRequest, TikTokResponse } from './tiktok-twin.ts';
38
+ export { createTikTokConsentServer, createTikTokTwinFetch, createTikTokTwinServer, type TikTokTwinFetchOptions } from './tiktok-server.ts';
39
+ export {
40
+ DEFAULT_ACCOUNTS,
41
+ DEFAULT_CLIENT_KEY,
42
+ DEFAULT_CLIENT_SECRET,
43
+ DEFAULT_VIDEOS,
44
+ defaultRedirectUris,
45
+ openIdFor,
46
+ redirectUriAllowed,
47
+ sessionAccount,
48
+ } from './tiktok-store.ts';
49
+ export { CHALLENGE_METHOD, isSupportedChallengeMethod, isWellFormedVerifier, pkceVerifies, rfc7636Challenge, tiktokCodeChallenge } from './tiktok-pkce.ts';
50
+ export {
51
+ describeScope,
52
+ describeScopes,
53
+ formatScopeParam,
54
+ KNOWN_SCOPES,
55
+ parseScopeParam,
56
+ SCOPE_CATALOG,
57
+ sortScopesForConsent,
58
+ } from './tiktok-scopes.ts';
59
+ export type { ScopeInfo, ScopeProduct } from './tiktok-scopes.ts';
60
+ export { API_ERRORS, OAUTH_ERRORS } from './tiktok-errors.ts';
61
+ export { logId } from './tiktok-ids.ts';
62
+ export { renderUser, renderVideo, USER_FIELD_SCOPES, USER_INFO_SCOPES, VIDEO_SCOPE } from './tiktok-user.ts';
63
+ export {
64
+ liveTikTokExecute,
65
+ mapUserInfoAccount,
66
+ mapVideo,
67
+ performTikTokAction,
68
+ PULL_USER_FIELDS,
69
+ PULL_VIDEO_FIELDS,
70
+ pullTikTok,
71
+ pushPendingTikTokActions,
72
+ syncTikTokFromReal,
73
+ syncTikTokFromRemote,
74
+ tiktokExecuteOver,
75
+ } from './tiktok-connector.ts';
76
+ export type { LiveTikTokOptions, PullOptions, TikTokExecute } from './tiktok-connector.ts';
77
+ export {
78
+ budgetedTikTokExecute,
79
+ MAX_PUBLISH_WAIT_MS,
80
+ TikTokRetryableError,
81
+ PERFORM_CHUNK_BYTES,
82
+ performChunkPlan,
83
+ STATUS_POLL_MS,
84
+ TikTokStillProcessingError,
85
+ } from './tiktok-connector.ts';
86
+ export {
87
+ checkChunkPlan,
88
+ chunkLength,
89
+ creatorOptions,
90
+ DEFAULT_MAX_VIDEO_POST_DURATION_SEC,
91
+ MAX_CHUNK_BYTES,
92
+ MAX_CHUNK_COUNT,
93
+ MAX_FINAL_CHUNK_BYTES,
94
+ MAX_SERVED_RANGE,
95
+ MAX_VIDEO_BYTES,
96
+ MEDIA_PREFIX,
97
+ MIN_CHUNK_BYTES,
98
+ PRIVACY_LEVELS,
99
+ processingMs,
100
+ UPLOAD_ORIGIN,
101
+ UPLOAD_PATH,
102
+ UPLOAD_URL_TTL_MS,
103
+ } from './tiktok-posting.ts';
104
+ export { POSTING_ERRORS } from './tiktok-errors.ts';
105
+ // The client-side rate budget — the fail-closed backstop `liveTikTokExecute` routes every live
106
+ // request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
107
+ // here is this vendor's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
108
+ // bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
109
+ // `TikTokBudgetError` by type; there is deliberately no export that disables the guard.
110
+ export {
111
+ TIKTOK_BUDGET_BURST_CEILING,
112
+ TIKTOK_BUDGET_CEILING,
113
+ TIKTOK_BUDGET_MAX_RETRY_AFTER_S,
114
+ TIKTOK_BUDGET_WINDOW_MS,
115
+ TIKTOK_CALL_WEIGHTS,
116
+ TIKTOK_RATE_BUDGET,
117
+ TikTokBudget,
118
+ TikTokBudgetError,
119
+ tiktokBudgetPath,
120
+ tiktokCallWeight,
121
+ } from './tiktok-budget.ts';
122
+ export type {
123
+ TikTokBudgetErrorKind,
124
+ TikTokBudgetOptions,
125
+ TikTokBudgetReservation,
126
+ TikTokBudgetSnapshot,
127
+ } from './tiktok-budget.ts';
128
+ export { CONSENT_CSS, consentPageHtml, errorPageHtml, tiktokConsentState } from './tiktok-consent-ui.ts';
129
+ // The tiktok.com mirror (profile, grid, vertical player): its pure helpers and its server. The hosted
130
+ // mirror mount reads tiktok-mirror-ui.ts directly (`tiktokMirrorHtml`, `buildTiktokMirrorClient`,
131
+ // `tiktokMirrorStyles`).
132
+ export { captionOf, captionSegments, compactCount, createTiktokMirrorServer, mediaSource, profileStats } from './tiktok-mirror-ui.ts';
133
+ export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './tiktok-consent-ui.ts';
134
+
135
+ import { TIKTOK_RATE_BUDGET as RATE_BUDGET } from './tiktok-budget.ts';
136
+ import { performTikTokAction, syncTikTokFromRemote } from './tiktok-connector.ts';
137
+
138
+ /**
139
+ * The www.tiktok.com paths THIS pack serves — the Login Kit authorization leg — as a RegExp SOURCE
140
+ * for the descriptor's `hosts` path rule. www.tiktok.com is NOT an API host: it is the whole
141
+ * TikTok web property (the For You feed, profiles, settings, everything). Claiming the host
142
+ * outright would be the Cal.com mis-route incident with the largest possible blast radius, so this
143
+ * claims EXACTLY the authorize path plus the twin-only prefix. ANCHORED, the googleoauth lesson:
144
+ * an unanchored claim swallows `/v2/auth/authorize2` and friends. The optional trailing slash is
145
+ * load-bearing — TikTok's own documentation writes `/v2/auth/authorize/` while Dub's URL builder
146
+ * omits it, and both must route here.
147
+ */
148
+ const TIKTOK_AUTHORIZE_PATHS = '^/v2/auth/authorize/?$|^/_twin/';
149
+
150
+ /**
151
+ * The open.tiktokapis.com paths THIS pack serves: the OAuth token/revoke endpoints, the Display
152
+ * API's user and video reads and the Content Posting API's four calls. open.tiktokapis.com is the
153
+ * ENTIRE TikTok open platform host — Research, Data Portability, Commercial Content, Business and
154
+ * the photo post besides — and nothing this pack does not model is claimed: an unmodelled TikTok
155
+ * call must refuse loudly rather than land in a twin that cannot serve it.
156
+ */
157
+ const TIKTOK_API_PATHS = '^/v2/(?:oauth/(?:token|revoke)|user/info|video/(?:list|query)|post/publish/(?:creator_info/query|video/init|inbox/video/init|status/fetch))/?$|^/_twin/';
158
+
159
+ /**
160
+ * The upload host's path: the Content Posting API's upload_url is `https://open-upload.tiktokapis.com/
161
+ * video/?upload_id=…&upload_token=…` (the Direct Post and inbox references' own examples). An app in a
162
+ * World PUTs its chunks there, so the injector must route that host to the twin too. Nothing else on
163
+ * the upload host is modelled, so nothing else is claimed.
164
+ */
165
+ const TIKTOK_UPLOAD_PATHS = '^/video/?$';
166
+
167
+ export const pack: TwinPack = {
168
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of
169
+ // the real state system, registered on load. A World's POST crosses at `perform`, through the
170
+ // Content Posting API (creator_info, init, the presigned chunk PUTs, status/fetch); nothing else
171
+ // does: TikTok publishes no API that creates a developer app, seeds a user or grants a scope —
172
+ // those are developer-portal and in-app settings actions a person takes in a browser — so
173
+ // `perform` settles every other entry with that reason.
174
+ protocol: '2',
175
+ // The vendor's access token lives 24 hours and its Display API limit is 600/minute, so a served
176
+ // world can afford a half-hourly pull; `onDemand` throttles a hand-run refresh to one a minute,
177
+ // well inside that budget.
178
+ refresh: { every: '30m', onDemand: { atMost: '60s' } },
179
+ stateSystem: { perform: performTikTokAction, refresh: syncTikTokFromRemote },
180
+ // The round trip registers a developer app through the twin's OWN door, because the vendor has no
181
+ // API for it at all — which is the same reason `perform` never crosses. (The Login Kit legs
182
+ // cannot serve as the round trip: the consent post answers a 302 and the harness fails any step
183
+ // answering >=300.)
184
+ roundTrip: {
185
+ method: 'POST',
186
+ path: '/_twin/clients',
187
+ body: { client_key: 'awroundtripapp0001', client_secret: 'round-trip-secret', name: 'Round Trip', redirect_uris: ['https://round.trip.test/callback'] },
188
+ },
189
+ parityOrigin: 'http://twin',
190
+ // shapeParity is NOT held, and the reason is STRUCTURAL rather than a divergence to close: the
191
+ // refresh reads the ACCOUNT the credential names (`GET /v2/user/info/`), while the only write a
192
+ // blind round trip can make on this vendor is registering a developer app — which TikTok
193
+ // publishes no API to read back. Written and observed are different subjects by design, so there
194
+ // is no pair of shapes to compare. Reaching parity would need the refresh to observe an
195
+ // oauth_client, and no TikTok endpoint returns one.
196
+ vendor: 'tiktok',
197
+ // The SAME object tiktok-budget.ts declares at module load — one source of truth, so registering
198
+ // the pack and importing the connector can never arm two different ceilings.
199
+ rateBudget: RATE_BUDGET,
200
+ transport: 'rest',
201
+ archetype: 'crud',
202
+ bin: 'world-tiktok',
203
+ resources: [
204
+ 'oauth_client',
205
+ 'account',
206
+ 'session',
207
+ 'auth_request',
208
+ 'grant',
209
+ 'video',
210
+ 'publish',
211
+ ],
212
+ specSource:
213
+ 'developers.tiktok.com, fetched 2026-09-13 — no first-party machine-readable spec exists for '
214
+ + 'the TikTok open API, so the denominator was authored top-down from the published references: '
215
+ + 'Login Kit for Web (the authorize URL, its parameters and the callback parameters), User '
216
+ + 'Access Token Management (token / refresh / revoke request + response tables and examples), '
217
+ + 'Client Access Token Management (the client_credentials grant), Login Kit for Desktop (PKCE: '
218
+ + 'hex-encoded SHA-256, S256 only), the Scopes Overview, Get User Info (the field x scope '
219
+ + 'table), the Video Object / Video List / Video Query references, the OAuth error-handling '
220
+ + 'reference (the ten flat `error` values) and the API v2 error-handling reference (the seven '
221
+ + 'nested `error.code` values with their HTTP statuses) and rate-limit reference (600/minute, '
222
+ + 'one-minute sliding window, 429 + rate_limit_exceeded); and, fetched 2026-09-27, the Content '
223
+ + 'Posting references (Query Creator Info, Direct Post, Upload to inbox, Get Post Status and the '
224
+ + 'Media Transfer Guide: chunk rules, the upload_url PUT with Content-Range, per-token minute limits).',
225
+ description:
226
+ "TikTok twin — the real tiktok.com authorization page, the full Login Kit OAuth round trip "
227
+ + '(token, refresh, revoke, client_credentials) and the Display API user/video reads with '
228
+ + "TikTok's own comma-separated scopes, per-app open_id, field-level scope gating and both of "
229
+ + 'its error envelopes; and the Content Posting API — creator_info, Direct Post and inbox uploads '
230
+ + 'through a signed upload URL in chunks, status/fetch by World time — with a tiktok.com-style '
231
+ + "profile and vertical player over the posted videos, and a perform that posts a World's video "
232
+ + 'to TikTok.',
233
+ adoption: {
234
+ // TikTok publishes NO first-party npm client for Login Kit or the Display API: its own
235
+ // reference material is curl and raw fetch, which is exactly how the motivating application
236
+ // (Dub) calls it. The community `tiktok` package on npm is deliberately NOT claimed — it is
237
+ // not a TikTok-published client, and `adoption.sdks` is for official clients only (the bitly
238
+ // precedent). Interception is therefore host-based, which is why `hosts` below is the whole
239
+ // adoption story for this vendor.
240
+ sdks: [],
241
+ // No official Python distribution either. `tiktok-business-api-sdk` (PyPI and npm) is
242
+ // first-party but speaks the BUSINESS/Ads API on business-api.tiktok.com — a different half of
243
+ // the vendor that this pack does not serve, so claiming it would over-claim coverage.
244
+ pypi: [],
245
+ // TIKTOK_CLIENT_ID / TIKTOK_CLIENT_SECRET are exactly the pair Dub reads, and TIKTOK_CLIENT_KEY
246
+ // is the vendor's own name for the same value. The `TIKTOKBUSINESS` stem is deliberately left
247
+ // to the packless registry: it names the Business API this pack does not model.
248
+ envStems: ['TIKTOK'],
249
+ },
250
+ hosts: [
251
+ { host: 'www.tiktok.com', pathPattern: TIKTOK_AUTHORIZE_PATHS },
252
+ { host: 'open.tiktokapis.com', pathPattern: TIKTOK_API_PATHS },
253
+ { host: 'open-upload.tiktokapis.com', pathPattern: TIKTOK_UPLOAD_PATHS },
254
+ ],
255
+ // Dub HARDCODES both hosts — `https://open.tiktokapis.com/v2` in its TikTokClient and the
256
+ // authorize/token URLs in its provider table — and TikTok publishes no official SDK with a
257
+ // base-URL option, so there is no env var an app reads that a world could point at the twin.
258
+ // Inventing a TIKTOK_BASE_URL would make `covers` report the world covered while the app still
259
+ // talked to the real vendor; interception through the two `hosts` entries above is the whole
260
+ // mechanism.
261
+ endpointEnvNone:
262
+ 'no app-read base-URL env exists: TikTok publishes no official SDK with a base-URL option and '
263
+ + 'integrations (Dub included) hardcode www.tiktok.com and open.tiktokapis.com, so interception '
264
+ + 'is the path-scoped hosts entries above.',
265
+ // A browser is REDIRECTED to www.tiktok.com/v2/auth/authorize — the loader host is the consent
266
+ // host and the prefix is the Login Kit path root.
267
+ browserRouting: { apiPathPrefix: '/v2/auth/', loaderHost: 'https://www.tiktok.com' },
268
+ };
269
+ // registered at import: the kernel learns the pack's state system (protocol 2)
270
+ registerPack(pack);