@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,617 @@
1
+ // TikTok twin — the CONTENT POSTING API: a creator's video posted through TikTok's own API.
2
+ //
3
+ // open.tiktokapis.com POST /v2/post/publish/creator_info/query/ → who is posting, and what they may choose
4
+ // POST /v2/post/publish/video/init/ → Direct Post (video.publish): publish_id + upload_url
5
+ // POST /v2/post/publish/inbox/video/init/ → upload to the creator's inbox (video.upload)
6
+ // POST /v2/post/publish/status/fetch/ → the publish_id's lifecycle
7
+ // open-upload.tiktokapis.com PUT /video/?upload_id=…&upload_token=… → the bytes, in chunks, with Content-Range
8
+ //
9
+ // Every shape here is from the vendor's own references (developers.tiktok.com, fetched 2026-09-27):
10
+ // content-posting-api-reference-query-creator-info, -direct-post, -upload-video, -get-video-status and
11
+ // content-posting-api-media-transfer-guide. Where a reference is silent the twin says so inline and a
12
+ // manifest todo pins the live behaviour.
13
+ //
14
+ // ── THE UPLOAD URL ───────────────────────────────────────────────────────────────────────────────
15
+ // TikTok's upload_url is on ANOTHER HOST than the API and carries its own authorization in the query
16
+ // (`upload_id` + `upload_token`); the PUT sends no bearer. The twin mints it the same way: the upload
17
+ // path `/video/` under the twin's own public base — or, when the request names the vendor's API host
18
+ // (an app inside a World reaches the twin through the injector as open.tiktokapis.com), on the
19
+ // vendor's own upload host, which the pack claims so the injector routes that PUT here too. The
20
+ // `upload_token` is an HMAC-SHA256 over the upload id and its expiry, keyed by that upload's OWN secret
21
+ // drawn from entropy at init (LinkedIn's per-asset secret), so the twin VERIFIES a token rather than
22
+ // looking one up, a token for one upload opens no other, and no credential is ever a key. "The
23
+ // upload_url is valid for one hour after issuance" — after that the PUT is 403. The upload record
24
+ // holds the posting token's SHA-256, never the token.
25
+ //
26
+ // ── WHAT IS A KERNEL WRITE ───────────────────────────────────────────────────────────────────────
27
+ // Only the FINALIZE. The init is staging (the byte annex holds the upload record, tiktok-blobs.ts),
28
+ // every chunk is its own staged key, and the final chunk joins them, stores the video content-addressed
29
+ // and makes ONE kernel write: `video.publish` (a Direct Post: the video on the creator's profile, plus
30
+ // its `publish` row) or `video.inbox_upload` (a draft: the `publish` row alone). That write is what a
31
+ // gated deploy performs against the vendor (tiktok-connector.ts).
32
+ //
33
+ // ── THE LIFECYCLE IS WORLD TIME ──────────────────────────────────────────────────────────────────
34
+ // status/fetch is DERIVED at read from the finalize instant: PROCESSING_UPLOAD while chunks are
35
+ // arriving and for PROCESSING_MS of World time after the last one ("The video processing will occur
36
+ // asynchronously once the upload is complete"), then PUBLISH_COMPLETE (Direct Post) or
37
+ // SEND_TO_USER_INBOX (inbox). A published video appears in /v2/video/list/ from that same instant.
38
+ import { createHmac } from 'node:crypto';
39
+ import { applyTwinWriteAtomic, type TwinResource } from '@volter/world-core';
40
+ import {
41
+ clearStaged,
42
+ joinStaged,
43
+ putTikTokBlob,
44
+ readTikTokBlobRange,
45
+ readUpload,
46
+ readUploadByPublishId,
47
+ stageChunk,
48
+ stagedSize,
49
+ tikTokBlobSize,
50
+ writeUpload,
51
+ type TikTokUpload,
52
+ } from './tiktok-blobs.ts';
53
+ import { apiError, apiOk, postingError, type TikTokResponse } from './tiktok-errors.ts';
54
+ import { logId } from './tiktok-ids.ts';
55
+ import { probeMp4 } from './tiktok-media.ts';
56
+ import { BK_ACCESS, mintPostId, mintPublishId, mintUploadId, nextRevIn, secretKey, type Row } from './tiktok-store.ts';
57
+
58
+ /** The vendor's own upload host, and the path its upload_url carries. */
59
+ export const UPLOAD_ORIGIN = 'https://open-upload.tiktokapis.com';
60
+ export const UPLOAD_PATH = '/video';
61
+ /** The API host a request names when an app inside a World reached the twin through the injector. */
62
+ const API_HOST = 'open.tiktokapis.com';
63
+
64
+ /** "The upload_url is valid for one hour after issuance. The upload must be completed in this time range." */
65
+ export const UPLOAD_URL_TTL_MS = 60 * 60 * 1000;
66
+
67
+ // The media transfer guide's chunk rules, verbatim in substance: "Minimum chunk size: 5 MB", "Maximum
68
+ // chunk size: 64 MB", "the final chunk ... can be up to 128 MB", "Videos under 5 MB must upload whole",
69
+ // "minimum 1 chunk, maximum 1000 chunks", total_chunk_count = video_size / chunk_size rounded DOWN (the
70
+ // trailing bytes ride the final chunk), and a 4 GB file maximum. The guide writes "MB" without saying
71
+ // which; the twin reads MiB, the stricter floor, so a client the twin accepts is one TikTok accepts.
72
+ export const MIN_CHUNK_BYTES = 5 * 1024 * 1024;
73
+ export const MAX_CHUNK_BYTES = 64 * 1024 * 1024;
74
+ export const MAX_FINAL_CHUNK_BYTES = 128 * 1024 * 1024;
75
+ export const MAX_CHUNK_COUNT = 1000;
76
+ export const MAX_VIDEO_BYTES = 4 * 1024 * 1024 * 1024;
77
+ /**
78
+ * THE TWIN'S OWN LIMIT, below TikTok's 4 GB, and stated rather than hidden: the final chunk joins the
79
+ * staged chunks into ONE allocation and stores it with one put, because the kernel's blob seam takes
80
+ * bytes (`BlobStore.put(key, bytes)`), not a stream. So the twin accepts a video up to what it can hold
81
+ * whole — 512 MiB, generous for a release video — and refuses a larger init with invalid_param naming
82
+ * this limit (`tiktok.content_posting.large_files`, todo, is the streaming seam that would lift it).
83
+ */
84
+ export const TWIN_MAX_VIDEO_BYTES = 512 * 1024 * 1024;
85
+ /** "Framerate: 23–60 FPS" (media transfer guide). */
86
+ export const MIN_FRAME_RATE = 23;
87
+ export const MAX_FRAME_RATE = 60;
88
+
89
+ /** THE TWIN'S VALUE (TikTok publishes no processing time): how long, in World time, a finished upload
90
+ * stays PROCESSING_UPLOAD — two seconds, plus one per 10 MiB. */
91
+ export function processingMs(size: number): number {
92
+ return 2000 + Math.floor(size / (10 * 1024 * 1024)) * 1000;
93
+ }
94
+
95
+ /** Direct Post `privacy_level` values ("PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY"). */
96
+ export const PRIVACY_LEVELS = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'] as const;
97
+ /** The options a public account offers — the creator_info reference's own example. A PRIVATE account
98
+ * offers FOLLOWER_OF_CREATOR in place of PUBLIC_TO_EVERYONE. */
99
+ const PUBLIC_ACCOUNT_OPTIONS = ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'];
100
+ const PRIVATE_ACCOUNT_OPTIONS = ['FOLLOWER_OF_CREATOR', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'];
101
+ /** "Duration: Up to 10 minutes via API" (media transfer guide). An account may carry a lower
102
+ * `maxVideoPostDurationSec`; the reference's own example shows 300. */
103
+ export const DEFAULT_MAX_VIDEO_POST_DURATION_SEC = 600;
104
+ /** "Title: max 2200 UTF-16 runes" — `String.length` counts UTF-16 code units. */
105
+ export const MAX_TITLE_UTF16 = 2200;
106
+ /** "max 5 pending shares per 24 hours" (inbox upload reference, spam_risk_too_many_pending_share). */
107
+ export const MAX_PENDING_SHARES = 5;
108
+ /** "Supported formats: MP4 (recommended), WebM, MOV". */
109
+ const VIDEO_TYPES = ['video/mp4', 'video/quicktime', 'video/webm'];
110
+ /** "Resolution: 360–4096 pixels per dimension". */
111
+ const MIN_SIDE = 360;
112
+ const MAX_SIDE = 4096;
113
+
114
+ /** The poster a Content Posting call acts for, resolved from its bearer by the router. */
115
+ export type PostingContext = {
116
+ resources: readonly TwinResource[];
117
+ account: Row;
118
+ openId: string;
119
+ scopes: string[];
120
+ /** The bearer's SHA-256 — what an upload record keeps of it. */
121
+ tokenHash: string;
122
+ clientKey: string;
123
+ at: string;
124
+ root?: string;
125
+ /** The twin's public base (origin plus any served-World mount path), when the request came over HTTP. */
126
+ origin?: string;
127
+ /** The vendor host the request named, when the injector or a World door forwarded it here. */
128
+ originalHost?: string;
129
+ readOnly?: boolean;
130
+ };
131
+
132
+ const nowMs = (at: string) => Date.parse(at);
133
+
134
+ function parseBody(body: string | undefined): Record<string, unknown> | null {
135
+ if (body === undefined || body.trim() === '') return null;
136
+ try {
137
+ const parsed = JSON.parse(body) as unknown;
138
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function needScope(ctx: PostingContext, anyOf: readonly string[]): TikTokResponse | null {
145
+ if (anyOf.some((s) => ctx.scopes.includes(s))) return null;
146
+ return apiError('scope_not_authorized', ctx.at, `The user did not authorize the scope required for completing this request: ${anyOf.join(' or ')}.`);
147
+ }
148
+
149
+ /** What creator_info answers for an account — every value from the account row, the reference's
150
+ * defaults where the row names none. */
151
+ export function creatorOptions(account: Row): { privacyLevelOptions: string[]; commentDisabled: boolean; duetDisabled: boolean; stitchDisabled: boolean; maxDurationSec: number } {
152
+ const options = Array.isArray(account.privacyLevelOptions)
153
+ ? (account.privacyLevelOptions as unknown[]).filter((o): o is string => typeof o === 'string')
154
+ : account.isPrivate === true ? PRIVATE_ACCOUNT_OPTIONS : PUBLIC_ACCOUNT_OPTIONS;
155
+ return {
156
+ privacyLevelOptions: options,
157
+ commentDisabled: account.commentDisabled === true,
158
+ duetDisabled: account.duetDisabled === true,
159
+ stitchDisabled: account.stitchDisabled === true,
160
+ maxDurationSec: typeof account.maxVideoPostDurationSec === 'number' ? account.maxVideoPostDurationSec : DEFAULT_MAX_VIDEO_POST_DURATION_SEC,
161
+ };
162
+ }
163
+
164
+ // ── creator_info ─────────────────────────────────────────────────────────────────────────────────
165
+
166
+ export function creatorInfo(ctx: PostingContext): TikTokResponse {
167
+ const refusal = needScope(ctx, ['video.publish']);
168
+ if (refusal) return refusal;
169
+ const o = creatorOptions(ctx.account);
170
+ return apiOk({
171
+ creator_avatar_url: typeof ctx.account.avatarUrl === 'string' ? ctx.account.avatarUrl : '',
172
+ creator_username: String(ctx.account.username ?? ''),
173
+ creator_nickname: String(ctx.account.displayName ?? ''),
174
+ privacy_level_options: o.privacyLevelOptions,
175
+ comment_disabled: o.commentDisabled,
176
+ duet_disabled: o.duetDisabled,
177
+ stitch_disabled: o.stitchDisabled,
178
+ max_video_post_duration_sec: o.maxDurationSec,
179
+ }, ctx.at, `creator_info:${ctx.openId}`);
180
+ }
181
+
182
+ // ── init (Direct Post and inbox) ─────────────────────────────────────────────────────────────────
183
+
184
+ const isInt = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v);
185
+
186
+ /** The chunk plan a FILE_UPLOAD init names, checked against the guide's rules; a string is the refusal. */
187
+ export function checkChunkPlan(videoSize: unknown, chunkSize: unknown, count: unknown): string | null {
188
+ if (!isInt(videoSize) || videoSize <= 0) return 'source_info.video_size must be a positive integer (bytes).';
189
+ if (videoSize > MAX_VIDEO_BYTES) return `source_info.video_size ${videoSize} exceeds the 4 GB maximum.`;
190
+ if (videoSize > TWIN_MAX_VIDEO_BYTES) return `source_info.video_size ${videoSize} exceeds this twin's ${TWIN_MAX_VIDEO_BYTES}-byte limit (it holds a video whole when the last chunk lands); TikTok itself accepts up to 4 GB.`;
191
+ if (!isInt(chunkSize) || chunkSize <= 0) return 'source_info.chunk_size must be a positive integer (bytes).';
192
+ if (!isInt(count) || count <= 0) return 'source_info.total_chunk_count must be a positive integer.';
193
+ if (videoSize < MIN_CHUNK_BYTES) {
194
+ if (chunkSize !== videoSize || count !== 1) return `A video under 5 MB must be uploaded whole: chunk_size must equal video_size (${videoSize}) and total_chunk_count must be 1.`;
195
+ return null;
196
+ }
197
+ if (chunkSize < MIN_CHUNK_BYTES) return `chunk_size ${chunkSize} is below the 5 MB minimum.`;
198
+ if (chunkSize > MAX_CHUNK_BYTES) return `chunk_size ${chunkSize} is above the 64 MB maximum.`;
199
+ const expected = Math.floor(videoSize / chunkSize);
200
+ if (count !== expected) return `total_chunk_count must be video_size / chunk_size rounded down: ${expected}, not ${count}.`;
201
+ if (count > MAX_CHUNK_COUNT) return `total_chunk_count ${count} exceeds the maximum of ${MAX_CHUNK_COUNT}.`;
202
+ // "videos over 64 MB require multiple chunks"
203
+ if (videoSize > MAX_CHUNK_BYTES && count < 2) return `A video over 64 MB must be uploaded in multiple chunks; ${videoSize} bytes in ${count} chunk is refused.`;
204
+ const last = videoSize - chunkSize * (count - 1);
205
+ if (last > MAX_FINAL_CHUNK_BYTES) return `The final chunk would carry ${last} bytes, above the 128 MB the final chunk may hold.`;
206
+ return null;
207
+ }
208
+
209
+ /** The byte length chunk `index` must carry under an upload's plan. */
210
+ export function chunkLength(upload: Pick<TikTokUpload, 'videoSize' | 'chunkSize' | 'totalChunkCount'>, index: number): number {
211
+ return index < upload.totalChunkCount - 1 ? upload.chunkSize : upload.videoSize - upload.chunkSize * (upload.totalChunkCount - 1);
212
+ }
213
+
214
+ /** The upload_token: HMAC-SHA256 over `<upload_id>.<expiry seconds>`, keyed by the initializing token. */
215
+ function signUpload(upload: Pick<TikTokUpload, 'uploadId' | 'expiresAt' | 'uploadSecret'>): string {
216
+ const mac = createHmac('sha256', upload.uploadSecret).update(`${upload.uploadId}.${Math.floor(upload.expiresAt / 1000)}`).digest('base64url');
217
+ return `${Math.floor(upload.expiresAt / 1000)}.${mac}`;
218
+ }
219
+
220
+ /** Compared in constant time over the expected length (the mirror's browser bundle walks this module,
221
+ * so no node-only comparison helper). */
222
+ function uploadTokenValid(upload: TikTokUpload, presented: string): boolean {
223
+ const expected = signUpload(upload);
224
+ if (presented.length !== expected.length) return false;
225
+ let diff = 0;
226
+ for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ presented.charCodeAt(i);
227
+ return diff === 0;
228
+ }
229
+
230
+ /** Where the upload_url points: the vendor's upload host when the request named the vendor's API host
231
+ * (the injector's route), else the twin's own public base; vendor-real for an in-process call. */
232
+ function uploadBase(ctx: PostingContext): string {
233
+ if (ctx.originalHost === API_HOST || !ctx.origin) return UPLOAD_ORIGIN;
234
+ return ctx.origin.replace(/\/+$/, '');
235
+ }
236
+
237
+ export async function initUpload(ctx: PostingContext, mode: 'direct' | 'inbox', rawBody: string | undefined): Promise<TikTokResponse> {
238
+ const refusal = needScope(ctx, [mode === 'direct' ? 'video.publish' : 'video.upload']);
239
+ if (refusal) return refusal;
240
+ const body = parseBody(rawBody);
241
+ if (!body) return postingError('invalid_param', ctx.at, 'The request body must be a JSON object.');
242
+
243
+ let postInfo: Record<string, unknown> = {};
244
+ if (mode === 'direct') {
245
+ const raw = body['post_info'];
246
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return postingError('invalid_param', ctx.at, 'post_info is required.');
247
+ const p = raw as Record<string, unknown>;
248
+ // A missing privacy_level, or one this creator is not offered (any value outside creator_info's
249
+ // privacy_level_options), is the Direct Post reference's privacy_level_option_mismatch (403).
250
+ const privacy = p['privacy_level'];
251
+ if (typeof privacy !== 'string' || !creatorOptions(ctx.account).privacyLevelOptions.includes(privacy)) {
252
+ return postingError('privacy_level_option_mismatch', ctx.at, typeof privacy === 'string'
253
+ ? `privacy_level ${privacy} is not one of this creator's privacy_level_options.`
254
+ : 'post_info.privacy_level is required: one of the privacy_level_options creator_info answers.');
255
+ }
256
+ if (p['title'] !== undefined && typeof p['title'] !== 'string') return postingError('invalid_param', ctx.at, 'post_info.title must be a string.');
257
+ if (typeof p['title'] === 'string' && p['title'].length > MAX_TITLE_UTF16) return postingError('invalid_param', ctx.at, `post_info.title exceeds ${MAX_TITLE_UTF16} UTF-16 runes.`);
258
+ for (const flag of ['disable_duet', 'disable_comment', 'disable_stitch', 'brand_content_toggle', 'brand_organic_toggle', 'is_aigc']) {
259
+ if (p[flag] !== undefined && typeof p[flag] !== 'boolean') return postingError('invalid_param', ctx.at, `post_info.${flag} must be a boolean.`);
260
+ }
261
+ if (p['video_cover_timestamp_ms'] !== undefined && (!isInt(p['video_cover_timestamp_ms']) || (p['video_cover_timestamp_ms'] as number) < 0)) {
262
+ return postingError('invalid_param', ctx.at, 'post_info.video_cover_timestamp_ms must be a non-negative integer.');
263
+ }
264
+ postInfo = { ...p };
265
+ }
266
+
267
+ const source = body['source_info'];
268
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return postingError('invalid_param', ctx.at, 'source_info is required.');
269
+ const s = source as Record<string, unknown>;
270
+ if (s['source'] === 'PULL_FROM_URL') {
271
+ // The twin holds no verified URL prefixes or domains (a developer-portal setting), so every pull
272
+ // is the reference's own url_ownership_unverified — never a fetch of somebody's URL.
273
+ return postingError('url_ownership_unverified', ctx.at);
274
+ }
275
+ if (s['source'] !== 'FILE_UPLOAD') return postingError('invalid_param', ctx.at, 'source_info.source must be FILE_UPLOAD or PULL_FROM_URL.');
276
+ const plan = checkChunkPlan(s['video_size'], s['chunk_size'], s['total_chunk_count']);
277
+ if (plan) return postingError('invalid_param', ctx.at, plan);
278
+
279
+ if (mode === 'inbox') {
280
+ const since = nowMs(ctx.at) - 24 * 60 * 60 * 1000;
281
+ const pending = ctx.resources.filter((r) => r.type === 'publish' && (r as Row).mode === 'inbox' && (r as Row).accountId === ctx.account.id && Number((r as Row).finalizedAt) >= since).length;
282
+ if (pending >= MAX_PENDING_SHARES) return postingError('spam_risk_too_many_pending_share', ctx.at);
283
+ }
284
+ if (ctx.readOnly) return apiError('internal_error', ctx.at, 'This twin is read-only: nothing can be posted to it.');
285
+
286
+ const publishId = mintPublishId(mode);
287
+ const upload: TikTokUpload = {
288
+ uploadId: mintUploadId(),
289
+ publishId,
290
+ mode,
291
+ accountId: ctx.account.id,
292
+ clientKey: ctx.clientKey,
293
+ tokenSha256: ctx.tokenHash,
294
+ uploadSecret: Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join(''),
295
+ postInfo,
296
+ videoSize: s['video_size'] as number,
297
+ chunkSize: s['chunk_size'] as number,
298
+ totalChunkCount: s['total_chunk_count'] as number,
299
+ issuedAt: nowMs(ctx.at),
300
+ expiresAt: nowMs(ctx.at) + UPLOAD_URL_TTL_MS,
301
+ };
302
+ await writeUpload(upload, ctx.root);
303
+ const url = new URL(`${uploadBase(ctx)}${UPLOAD_PATH}/`);
304
+ url.searchParams.set('upload_id', upload.uploadId);
305
+ url.searchParams.set('upload_token', signUpload(upload));
306
+ return apiOk({ publish_id: publishId, upload_url: url.toString() }, ctx.at, `init:${publishId}`);
307
+ }
308
+
309
+ // ── the chunk PUT ────────────────────────────────────────────────────────────────────────────────
310
+ // The guide publishes the STATUSES (201 all chunks in, 206 more to come, 400 bad headers or size,
311
+ // 403 expired URL, 404 unknown upload, 416 range out of order) and no body; the twin answers each with
312
+ // a short plain-text reason (EXTRAPOLATION: the live body is unpinned, `tiktok.content_posting.upload_bodies`).
313
+
314
+ export type UploadRequest = {
315
+ /** Always PUT: the router sends nothing else to the upload path. */
316
+ method: string;
317
+ query: URLSearchParams;
318
+ headers?: Record<string, string>;
319
+ bytes?: Uint8Array;
320
+ at: string;
321
+ root?: string;
322
+ readOnly?: boolean;
323
+ };
324
+
325
+ function plain(status: number, text: string): TikTokResponse {
326
+ return { status, body: text, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } };
327
+ }
328
+
329
+ export async function uploadChunk(req: UploadRequest): Promise<TikTokResponse> {
330
+ if (req.readOnly) return plain(403, 'This twin is read-only.');
331
+ const upload = await readUpload(req.query.get('upload_id') ?? '', req.root);
332
+ if (!upload) return plain(404, 'No upload task has this upload_id.');
333
+ if (!uploadTokenValid(upload, req.query.get('upload_token') ?? '')) return plain(403, 'The upload_token does not authorize this upload.');
334
+ if (nowMs(req.at) >= upload.expiresAt && !upload.sha256) return plain(403, 'The upload URL has expired: it is valid for one hour after issuance.');
335
+
336
+ const header = (name: string) => req.headers?.[name];
337
+ const type = (header('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
338
+ if (!VIDEO_TYPES.includes(type)) return plain(400, `Content-Type must be one of ${VIDEO_TYPES.join(', ')}.`);
339
+ const m = /^bytes (\d+)-(\d+)\/(\d+)$/.exec((header('content-range') ?? '').trim());
340
+ if (!m) return plain(400, 'Content-Range must be "bytes {FIRST_BYTE}-{LAST_BYTE}/{TOTAL_BYTE_LENGTH}".');
341
+ const first = Number(m[1]);
342
+ const last = Number(m[2]);
343
+ const total = Number(m[3]);
344
+ const bytes = req.bytes ?? new Uint8Array(0);
345
+ if (total !== upload.videoSize) return plain(400, `Content-Range total ${total} is not the video_size ${upload.videoSize} the upload was initialized with.`);
346
+ if (last < first || bytes.length !== last - first + 1) return plain(400, `Content-Range names ${last - first + 1} bytes; the body carries ${bytes.length}.`);
347
+ const declaredLength = header('content-length');
348
+ if (declaredLength !== undefined && Number(declaredLength) !== bytes.length) return plain(400, `Content-Length ${declaredLength} is not the body's ${bytes.length} bytes.`);
349
+
350
+ // a final PUT repeated after its answer was lost: the upload is already whole
351
+ const finalFirst = upload.chunkSize * (upload.totalChunkCount - 1);
352
+ const isFinal = first === finalFirst && last === upload.videoSize - 1;
353
+ if (upload.sha256) return isFinal ? { status: 201, body: '', headers: {} } : plain(416, 'This upload is already complete.');
354
+ const held = await stagedSize(upload.uploadId, req.root);
355
+ // every chunk is staged but the finalize did not finish (the process stopped between the last chunk
356
+ // and its answer): the repeated final PUT completes it — the finalize is idempotent by publish_id
357
+ if (held === upload.videoSize) {
358
+ if (!isFinal) return plain(416, 'Every chunk of this upload has arrived.');
359
+ await finalize(upload, (header('content-type') ?? '').split(';')[0]!.trim().toLowerCase(), req.at, req.root);
360
+ return { status: 201, body: '', headers: {} };
361
+ }
362
+ if (first !== held) return plain(416, `Chunks must be uploaded in order: the next chunk starts at byte ${held}.`);
363
+ const index = Math.floor(first / upload.chunkSize);
364
+ if (first !== index * upload.chunkSize || index >= upload.totalChunkCount) return plain(416, `Byte ${first} does not start a chunk of this upload.`);
365
+ const want = chunkLength(upload, index);
366
+ if (bytes.length !== want) return plain(400, `Chunk ${index + 1} of ${upload.totalChunkCount} must carry ${want} bytes, not ${bytes.length}.`);
367
+
368
+ await stageChunk(upload.uploadId, first, bytes, req.root);
369
+ if (index < upload.totalChunkCount - 1) return { status: 206, body: '', headers: {} };
370
+ await finalize(upload, type, req.at, req.root);
371
+ return { status: 201, body: '', headers: {} };
372
+ }
373
+
374
+ /**
375
+ * The final chunk landed: join the chunks, store the video content-addressed, check what TikTok's
376
+ * processing checks that the file itself can show — that it is a video file the twin can read
377
+ * (file_format_check_failed), its duration against the creator's maximum (duration_check_failed), its
378
+ * frame rate against 23–60 FPS (frame_rate_check_failed) and its picture size against 360–4096
379
+ * (picture_size_check_failed) — and make the ONE kernel write.
380
+ *
381
+ * ORDER, for a process that stops part-way: the bytes (content-addressed, so storing twice is a
382
+ * no-op), then the kernel write (skipped when the publish already exists), and only THEN the staging
383
+ * record's digest and the chunks' removal. A stop before the kernel write leaves every chunk staged,
384
+ * and the repeated final PUT runs this again.
385
+ */
386
+ async function finalize(upload: TikTokUpload, contentType: string, at: string, root?: string): Promise<void> {
387
+ const whole = await joinStaged(upload.uploadId, root);
388
+ const blob = await putTikTokBlob(whole, root);
389
+ const probe = probeMp4(whole);
390
+
391
+ const finalizedAt = nowMs(at);
392
+ const completesAt = finalizedAt + processingMs(blob.size);
393
+ await applyTwinWriteAtomic(
394
+ 'tiktok',
395
+ (resources) => {
396
+ if (resources.some((r) => r.type === 'publish' && r.id === upload.publishId)) return { kind: 'skip', value: undefined };
397
+ const account = resources.find((r) => r.type === 'account' && r.id === upload.accountId) as Row | undefined;
398
+ const max = account ? creatorOptions(account).maxDurationSec : DEFAULT_MAX_VIDEO_POST_DURATION_SEC;
399
+ // a file the reader cannot find a video track in is not a video TikTok can process
400
+ const failReason = probe.width === undefined || probe.height === undefined || probe.durationSeconds === undefined
401
+ ? 'file_format_check_failed'
402
+ : probe.durationSeconds > max
403
+ ? 'duration_check_failed'
404
+ : probe.frameRate !== undefined && (probe.frameRate < MIN_FRAME_RATE - 0.5 || probe.frameRate > MAX_FRAME_RATE + 0.5)
405
+ ? 'frame_rate_check_failed'
406
+ : Math.min(probe.width, probe.height) < MIN_SIDE || Math.max(probe.width, probe.height) > MAX_SIDE
407
+ ? 'picture_size_check_failed'
408
+ : null;
409
+ const media = {
410
+ _content_sha256: blob.sha256,
411
+ _content_type: contentType,
412
+ size: blob.size,
413
+ ...(probe.durationSeconds !== undefined ? { duration: Math.round(probe.durationSeconds) } : {}),
414
+ ...(probe.width !== undefined ? { width: probe.width, height: probe.height } : {}),
415
+ };
416
+ // a post id is drawn from entropy and RE-DRAWN while any id the branch holds — its own or an
417
+ // ancestor's, which the projection carries — already has it (the X and LinkedIn rule)
418
+ let postId: string | null = null;
419
+ if (upload.mode === 'direct' && !failReason) {
420
+ const held = new Set(resources.map((r) => String(r.id)));
421
+ do postId = mintPostId(); while (held.has(postId));
422
+ }
423
+ const publish = {
424
+ mode: upload.mode,
425
+ accountId: upload.accountId,
426
+ clientKey: upload.clientKey,
427
+ tokenSha256: upload.tokenSha256,
428
+ finalizedAt,
429
+ completesAt,
430
+ ...(failReason ? { failReason } : {}),
431
+ ...(postId ? { postId } : {}),
432
+ ...media,
433
+ rev: nextRevIn(resources, 'publish', upload.publishId),
434
+ };
435
+ if (postId) {
436
+ const p = upload.postInfo;
437
+ const caption = typeof p['title'] === 'string' ? p['title'] : '';
438
+ return {
439
+ kind: 'write',
440
+ value: undefined,
441
+ write: {
442
+ operation: 'video.publish',
443
+ subjectType: 'video',
444
+ subjectId: postId,
445
+ fields: {
446
+ ownerUnionId: upload.accountId,
447
+ publishId: upload.publishId,
448
+ // TikTok's post_info.title IS the caption; the Video Object reports a caption as its
449
+ // description (EXTRAPOLATION for `title`, pinned by tiktok.content_posting.caption_fields)
450
+ title: caption,
451
+ videoDescription: caption,
452
+ privacyLevel: p['privacy_level'],
453
+ disableComment: p['disable_comment'] === true,
454
+ disableDuet: p['disable_duet'] === true,
455
+ disableStitch: p['disable_stitch'] === true,
456
+ ...(typeof p['video_cover_timestamp_ms'] === 'number' ? { videoCoverTimestampMs: p['video_cover_timestamp_ms'] } : {}),
457
+ ...(p['brand_content_toggle'] === true ? { brandContentToggle: true } : {}),
458
+ ...(p['brand_organic_toggle'] === true ? { brandOrganicToggle: true } : {}),
459
+ ...(p['is_aigc'] === true ? { isAigc: true } : {}),
460
+ // it becomes a post when processing ends: /v2/video/list/ shows it from then
461
+ createTime: Math.floor(completesAt / 1000),
462
+ duration: media.duration ?? 0,
463
+ width: media.width ?? 0,
464
+ height: media.height ?? 0,
465
+ likeCount: 0,
466
+ commentCount: 0,
467
+ shareCount: 0,
468
+ viewCount: 0,
469
+ _content_sha256: blob.sha256,
470
+ _content_type: contentType,
471
+ rev: 1,
472
+ },
473
+ projection: { creates: [{ type: 'publish', id: upload.publishId, fields: publish }] },
474
+ occurredAt: at,
475
+ actor: { kind: 'agent' as const },
476
+ },
477
+ };
478
+ }
479
+ return {
480
+ kind: 'write',
481
+ value: undefined,
482
+ write: {
483
+ operation: failReason ? 'publish.fail' : 'video.inbox_upload',
484
+ subjectType: 'publish',
485
+ subjectId: upload.publishId,
486
+ fields: publish,
487
+ occurredAt: at,
488
+ actor: { kind: 'agent' as const },
489
+ },
490
+ };
491
+ },
492
+ root,
493
+ );
494
+ // the kernel holds the publish: now the staging record may say so, and the chunks may go
495
+ await writeUpload({ ...upload, sha256: blob.sha256 }, root);
496
+ await clearStaged(upload.uploadId, root);
497
+ }
498
+
499
+ // ── status/fetch ─────────────────────────────────────────────────────────────────────────────────
500
+
501
+ /**
502
+ * The publish_id's lifecycle at World instant `at`. `publicaly_available_post_id` (the vendor's own
503
+ * spelling) is a list<int64>: the twin writes it as bare JSON NUMBERS, as TikTok does, so an
504
+ * integration that JSON.parse()s a 19-digit id into a double loses digits HERE rather than in
505
+ * production. It lists the post once it is public (PUBLIC_TO_EVERYONE) and complete.
506
+ */
507
+ export async function fetchStatus(ctx: PostingContext, rawBody: string | undefined): Promise<TikTokResponse> {
508
+ const refusal = needScope(ctx, ['video.upload', 'video.publish']);
509
+ if (refusal) return refusal;
510
+ const body = parseBody(rawBody);
511
+ const publishId = body?.['publish_id'];
512
+ if (typeof publishId !== 'string' || publishId === '') return postingError('invalid_param', ctx.at, 'publish_id is required.');
513
+
514
+ const row = ctx.resources.find((r) => r.type === 'publish' && r.id === publishId) as Row | undefined;
515
+ const staging = row ? null : await readUploadByPublishId(publishId, ctx.root);
516
+ if (!row && !staging) return postingError('invalid_publish_id', ctx.at);
517
+ const owner = row ? { accountId: row.accountId, clientKey: row.clientKey } : { accountId: staging!.accountId, clientKey: staging!.clientKey };
518
+ if (owner.accountId !== ctx.account.id || owner.clientKey !== ctx.clientKey) return postingError('token_not_authorized_for_specified_publish_id', ctx.at);
519
+
520
+ let data: { status: string; fail_reason?: string; post?: string; uploaded_bytes: number };
521
+ if (!row) {
522
+ data = { status: 'PROCESSING_UPLOAD', uploaded_bytes: await stagedSize(staging!.uploadId, ctx.root) };
523
+ } else if (typeof row.failReason === 'string') {
524
+ data = { status: 'FAILED', fail_reason: row.failReason, uploaded_bytes: Number(row.size ?? 0) };
525
+ } else if (nowMs(ctx.at) < Number(row.completesAt)) {
526
+ data = { status: 'PROCESSING_UPLOAD', uploaded_bytes: Number(row.size ?? 0) };
527
+ } else if (row.mode === 'inbox') {
528
+ data = { status: 'SEND_TO_USER_INBOX', uploaded_bytes: Number(row.size ?? 0) };
529
+ } else {
530
+ const video = ctx.resources.find((r) => r.type === 'video' && r.id === row.postId) as Row | undefined;
531
+ const isPublic = video?.privacyLevel === 'PUBLIC_TO_EVERYONE';
532
+ data = { status: 'PUBLISH_COMPLETE', uploaded_bytes: Number(row.size ?? 0), ...(isPublic && typeof row.postId === 'string' ? { post: row.postId } : {}) };
533
+ }
534
+ const logid = logId(ctx.at, `api:ok:status:${publishId}:${data.status}`);
535
+ // hand-serialized so the int64 ids stay bare numbers (JSON.stringify of a JS number would round them)
536
+ const text = `{"data":{"status":${JSON.stringify(data.status)},${data.fail_reason ? `"fail_reason":${JSON.stringify(data.fail_reason)},` : ''}`
537
+ + `"publicaly_available_post_id":[${data.post && /^\d+$/.test(data.post) ? data.post : ''}],"uploaded_bytes":${data.uploaded_bytes}},`
538
+ + `"error":{"code":"ok","message":"","log_id":${JSON.stringify(logid)}}}`;
539
+ return { status: 200, body: text, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache, no-store, max-age=0' } };
540
+ }
541
+
542
+ // ── the published video's bytes ──────────────────────────────────────────────────────────────────
543
+ // TikTok plays a post from its CDN, which is not Open API surface. The twin serves a published video's
544
+ // bytes on its own origin, under its public base, at `/_twin/media/video/<post id>`, so the mirror (or
545
+ // any client) plays exactly what was uploaded — here or at a branch's ancestor. Range is honoured (206
546
+ // + Content-Range, 416 past the end), only the range asked for is read, and an open-ended range is
547
+ // capped at MAX_SERVED_RANGE (a player asks again from where it got to). A post that is not
548
+ // PUBLIC_TO_EVERYONE plays only for its creator: a <video> element sends no header, so the creator's
549
+ // token rides the URL as `access_token`.
550
+
551
+ export const MEDIA_PREFIX = '/_twin/media/video/';
552
+ /** The most one open-ended range answers (`bytes=N-`): 8 MiB. */
553
+ export const MAX_SERVED_RANGE = 8 * 1024 * 1024;
554
+
555
+ type ParsedRange = { start: number; end: number } | 'unsatisfiable' | null;
556
+ function parseRange(value: string | undefined, size: number): ParsedRange {
557
+ if (!value) return null;
558
+ const m = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
559
+ if (!m || (m[1] === '' && m[2] === '')) return null;
560
+ if (m[1] === '') {
561
+ const n = Number(m[2]);
562
+ if (n === 0 || size === 0) return 'unsatisfiable';
563
+ return { start: Math.max(0, size - Math.min(n, MAX_SERVED_RANGE)), end: size - 1 };
564
+ }
565
+ const start = Number(m[1]);
566
+ if (start >= size) return 'unsatisfiable';
567
+ // a last-byte-pos below the first-byte-pos is an INVALID range, which RFC 9110 §14.1.1 says to
568
+ // ignore: the whole representation is answered (200), not a 416
569
+ if (m[2] !== '' && Number(m[2]) < start) return null;
570
+ const end = m[2] === '' ? Math.min(size - 1, start + MAX_SERVED_RANGE - 1) : Math.min(Number(m[2]), size - 1, start + MAX_SERVED_RANGE - 1);
571
+ return { start, end };
572
+ }
573
+
574
+ export type MediaRequest = { method: string; path: string; query: URLSearchParams; headers?: Record<string, string>; at: string; root?: string; resources: readonly TwinResource[] };
575
+
576
+ export async function serveVideo(req: MediaRequest): Promise<TikTokResponse> {
577
+ const missing = (): TikTokResponse => ({ status: 404, body: new Uint8Array(0), headers: { 'content-type': 'text/plain' } });
578
+ const method = req.method.toUpperCase();
579
+ if (method !== 'GET' && method !== 'HEAD') return { status: 405, body: new Uint8Array(0), headers: { allow: 'GET, HEAD' } };
580
+ const id = req.path.slice(MEDIA_PREFIX.length).replace(/\.mp4$/, '');
581
+ const video = req.resources.find((r) => r.type === 'video' && r.id === id) as Row | undefined;
582
+ // a post plays once it IS a post: while processing it is not on the profile, and neither are its bytes
583
+ if (!video || Number(video.createTime) > Math.floor(nowMs(req.at) / 1000)) return missing();
584
+ const sha256 = String(video._content_sha256 ?? '');
585
+ const size = await tikTokBlobSize(sha256, req.root);
586
+ if (size === null) return missing();
587
+ if (video.privacyLevel !== undefined && video.privacyLevel !== 'PUBLIC_TO_EVERYONE') {
588
+ const presented = req.query.get('access_token') ?? /^bearer\s+(.+)$/i.exec(req.headers?.['authorization'] ?? '')?.[1]?.trim();
589
+ const token = presented ? req.resources.find((r) => r.type === BK_ACCESS && r.id === secretKey(presented)) as Row | undefined : undefined;
590
+ if (!token || token.revoked === true || token.sub !== video.ownerUnionId) {
591
+ return { status: 403, body: new TextEncoder().encode('This video is private.'), headers: { 'content-type': 'text/plain' } };
592
+ }
593
+ }
594
+ const base = { 'content-type': String(video._content_type ?? 'video/mp4'), 'accept-ranges': 'bytes', etag: `"${sha256}"`, 'cache-control': 'private, max-age=0' };
595
+ const head = method === 'HEAD';
596
+ const range = parseRange(req.headers?.['range'], size);
597
+ if (range === 'unsatisfiable') return { status: 416, body: new Uint8Array(0), headers: { ...base, 'content-range': `bytes */${size}` } };
598
+ if (range) {
599
+ const headers = { ...base, 'content-range': `bytes ${range.start}-${range.end}/${size}`, 'content-length': String(range.end - range.start + 1) };
600
+ if (head) return { status: 206, body: new Uint8Array(0), headers };
601
+ return { status: 206, body: (await readTikTokBlobRange(sha256, range.start, range.end, req.root)) ?? new Uint8Array(0), headers };
602
+ }
603
+ const headers = { ...base, 'content-length': String(size) };
604
+ if (head) return { status: 200, body: new Uint8Array(0), headers };
605
+ let at = 0;
606
+ const root = req.root;
607
+ const stream = new ReadableStream<Uint8Array>({
608
+ async pull(controller) {
609
+ if (at >= size) { controller.close(); return; }
610
+ const piece = await readTikTokBlobRange(sha256, at, Math.min(size - 1, at + MAX_SERVED_RANGE - 1), root);
611
+ if (!piece || piece.length === 0) { controller.close(); return; }
612
+ controller.enqueue(piece);
613
+ at += piece.length;
614
+ },
615
+ });
616
+ return { status: 200, body: stream, headers };
617
+ }