@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.
- package/LICENSE +202 -0
- package/README.md +310 -0
- package/client/tiktok-consent.tsx +154 -0
- package/client/tiktok-mirror.css +137 -0
- package/client/tiktok-mirror.tsx +492 -0
- package/dist/client/tiktok-consent.bundle.js +18 -0
- package/dist/client/tiktok-consent.d.ts +47 -0
- package/dist/client/tiktok-consent.js +20 -0
- package/dist/client/tiktok-consent.tsx +154 -0
- package/dist/client/tiktok-mirror.bundle.js +487 -0
- package/dist/client/tiktok-mirror.css +137 -0
- package/dist/client/tiktok-mirror.d.ts +42 -0
- package/dist/client/tiktok-mirror.js +315 -0
- package/dist/client/tiktok-mirror.tsx +492 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +167 -0
- package/dist/src/tiktok-blobs.d.ts +66 -0
- package/dist/src/tiktok-blobs.js +161 -0
- package/dist/src/tiktok-budget.d.ts +56 -0
- package/dist/src/tiktok-budget.js +136 -0
- package/dist/src/tiktok-capabilities.d.ts +7 -0
- package/dist/src/tiktok-capabilities.js +1855 -0
- package/dist/src/tiktok-conformance.d.ts +11 -0
- package/dist/src/tiktok-conformance.js +498 -0
- package/dist/src/tiktok-connector.d.ts +158 -0
- package/dist/src/tiktok-connector.js +600 -0
- package/dist/src/tiktok-consent-ui.d.ts +19 -0
- package/dist/src/tiktok-consent-ui.js +127 -0
- package/dist/src/tiktok-errors.d.ts +78 -0
- package/dist/src/tiktok-errors.js +175 -0
- package/dist/src/tiktok-ids.d.ts +16 -0
- package/dist/src/tiktok-ids.js +48 -0
- package/dist/src/tiktok-media.d.ts +7 -0
- package/dist/src/tiktok-media.js +86 -0
- package/dist/src/tiktok-mirror-ui.d.ts +49 -0
- package/dist/src/tiktok-mirror-ui.js +159 -0
- package/dist/src/tiktok-pkce.d.ts +25 -0
- package/dist/src/tiktok-pkce.js +56 -0
- package/dist/src/tiktok-posting.d.ts +100 -0
- package/dist/src/tiktok-posting.js +599 -0
- package/dist/src/tiktok-sample-mp4.d.ts +10 -0
- package/dist/src/tiktok-sample-mp4.js +55 -0
- package/dist/src/tiktok-scopes.d.ts +29 -0
- package/dist/src/tiktok-scopes.js +106 -0
- package/dist/src/tiktok-server.d.ts +28 -0
- package/dist/src/tiktok-server.js +89 -0
- package/dist/src/tiktok-store.d.ts +164 -0
- package/dist/src/tiktok-store.js +451 -0
- package/dist/src/tiktok-twin.d.ts +70 -0
- package/dist/src/tiktok-twin.js +1197 -0
- package/dist/src/tiktok-user.d.ts +28 -0
- package/dist/src/tiktok-user.js +174 -0
- package/package.json +74 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +270 -0
- package/src/tiktok-blobs.ts +217 -0
- package/src/tiktok-budget.ts +163 -0
- package/src/tiktok-capabilities.ts +2022 -0
- package/src/tiktok-conformance.ts +526 -0
- package/src/tiktok-connector.ts +637 -0
- package/src/tiktok-consent-ui.ts +146 -0
- package/src/tiktok-errors.ts +197 -0
- package/src/tiktok-ids.ts +51 -0
- package/src/tiktok-journey.uitest.ts +305 -0
- package/src/tiktok-media.ts +89 -0
- package/src/tiktok-mirror-ui.ts +167 -0
- package/src/tiktok-pkce.ts +61 -0
- package/src/tiktok-posting.ts +617 -0
- package/src/tiktok-sample-mp4.ts +54 -0
- package/src/tiktok-scopes.ts +122 -0
- package/src/tiktok-server.ts +100 -0
- package/src/tiktok-store.ts +543 -0
- package/src/tiktok-twin.ts +1361 -0
- package/src/tiktok-user.ts +137 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Content-addressed BYTE storage for the TikTok twin — a posted video's real media.
|
|
2
|
+
//
|
|
3
|
+
// Transcribed from the YouTube pack's byte annex (youtube-blobs.ts), which took it from Slack's: bytes
|
|
4
|
+
// ride the KERNEL'S BLOB SEAM (`getActiveBlobStore()`, runtime contract R11) — the filesystem locally,
|
|
5
|
+
// object storage inside a served namespace — under this service's own `resources` dir, which
|
|
6
|
+
// `scrubService('tiktok')` already owns. Keys (relative to that dir): blobs/sha256/<ab>/<hex>. The
|
|
7
|
+
// kernel projection references a blob by digest only (`_content_sha256` on a video or a draft), so the
|
|
8
|
+
// event log stays small however large the media is.
|
|
9
|
+
//
|
|
10
|
+
// READS GO THROUGH THE KERNEL'S RESOURCE-BLOB HELPERS (`readResourceBlobRange`, `resourceBlobSize`): a
|
|
11
|
+
// branch inherits its ancestors' videos in the projection, so it must inherit their bytes too, and
|
|
12
|
+
// those helpers look at the branch and then its retained ancestors. They read a RANGE natively, so
|
|
13
|
+
// serving a seek, or sending one upload chunk, never loads the whole video.
|
|
14
|
+
//
|
|
15
|
+
// AN UPLOAD IS STAGING, and staging is NOT a kernel action: the upload_url the init hands back is a
|
|
16
|
+
// place to put bytes, not an event in the creator's history. An upload is a pointer record plus its
|
|
17
|
+
// chunks, each chunk its own key, joined ONCE when the final chunk lands; only that finalize is a
|
|
18
|
+
// kernel write. An upload that is abandoned leaves no pending action to wedge a later deploy, and its
|
|
19
|
+
// upload_url stops answering after TikTok's own hour.
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { blobDigest, getActiveBlobStore, readResourceBlobRange, resourceBlobSize, worldPaths } from '@volter/world-core';
|
|
22
|
+
|
|
23
|
+
const SERVICE = 'tiktok';
|
|
24
|
+
|
|
25
|
+
export interface TikTokBlobRef {
|
|
26
|
+
sha256: string;
|
|
27
|
+
size: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
31
|
+
const UPLOAD_ID = /^[0-9]{1,24}$/;
|
|
32
|
+
|
|
33
|
+
function resources(root?: string): string {
|
|
34
|
+
return worldPaths(SERVICE, root).resources;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function blobKey(sha256: string): string {
|
|
38
|
+
return join('blobs', 'sha256', sha256.slice(0, 2), sha256);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Store bytes content-addressed in this branch's annex; identical content is a no-op write. */
|
|
42
|
+
export async function putTikTokBlob(bytes: Uint8Array, root?: string): Promise<TikTokBlobRef> {
|
|
43
|
+
const sha256 = blobDigest(bytes);
|
|
44
|
+
const key = join(resources(root), blobKey(sha256));
|
|
45
|
+
if (!(await getActiveBlobStore().exists(key))) await getActiveBlobStore().put(key, bytes);
|
|
46
|
+
return { sha256, size: bytes.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A stored blob's size, here or at an ancestor; null when absent or the digest is malformed. */
|
|
50
|
+
export async function tikTokBlobSize(sha256: unknown, root?: string): Promise<number | null> {
|
|
51
|
+
if (typeof sha256 !== 'string' || !DIGEST.test(sha256)) return null;
|
|
52
|
+
return resourceBlobSize(SERVICE, blobKey(sha256), root);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bytes start..endInclusive of a stored blob, here or at an ancestor, read as a range. */
|
|
56
|
+
export async function readTikTokBlobRange(sha256: unknown, start: number, endInclusive: number, root?: string): Promise<Uint8Array | null> {
|
|
57
|
+
if (typeof sha256 !== 'string' || !DIGEST.test(sha256)) return null;
|
|
58
|
+
return readResourceBlobRange(SERVICE, blobKey(sha256), start, endInclusive, root);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── uploads in flight (staging: pointer record + one key per chunk) ────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** What an upload remembers between its init and its final chunk. */
|
|
64
|
+
export type TikTokUpload = {
|
|
65
|
+
/** The upload_id in the upload_url's query. */
|
|
66
|
+
uploadId: string;
|
|
67
|
+
/** The publish_id the init answered; status/fetch reads the upload by it until it finalizes. */
|
|
68
|
+
publishId: string;
|
|
69
|
+
/** `direct` (video.publish: Direct Post) or `inbox` (video.upload: the creator's drafts). */
|
|
70
|
+
mode: 'direct' | 'inbox';
|
|
71
|
+
/** The account and app the posting token belongs to (status/fetch answers
|
|
72
|
+
* token_not_authorized_for_specified_publish_id to any other), and the token's SHA-256 — never the
|
|
73
|
+
* token. */
|
|
74
|
+
accountId: string;
|
|
75
|
+
clientKey: string;
|
|
76
|
+
tokenSha256: string;
|
|
77
|
+
/** This upload's own signing secret (hex), drawn from entropy at init: the upload_token is an HMAC
|
|
78
|
+
* under it, so no other upload's URL, and no credential, is ever the key (LinkedIn's per-asset
|
|
79
|
+
* secret). */
|
|
80
|
+
uploadSecret: string;
|
|
81
|
+
/** The Direct Post `post_info` as the init validated it (empty for an inbox upload). */
|
|
82
|
+
postInfo: Record<string, unknown>;
|
|
83
|
+
videoSize: number;
|
|
84
|
+
chunkSize: number;
|
|
85
|
+
totalChunkCount: number;
|
|
86
|
+
/** World time (ms) the upload_url was issued and stops being valid. */
|
|
87
|
+
issuedAt: number;
|
|
88
|
+
expiresAt: number;
|
|
89
|
+
/** Set once the final chunk has landed: the digest the bytes were stored under. */
|
|
90
|
+
sha256?: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function uploadDir(root?: string): string {
|
|
94
|
+
return join(resources(root), 'uploads');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assertUploadId(id: string): string {
|
|
98
|
+
if (!UPLOAD_ID.test(id)) throw new Error(`invalid TikTok upload id: ${JSON.stringify(id)}`);
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function uploadKey(id: string, root?: string): string {
|
|
103
|
+
return join(uploadDir(root), `${assertUploadId(id)}.json`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function publishKey(publishId: string, root?: string): string {
|
|
107
|
+
return join(uploadDir(root), 'by-publish', `${blobDigest(new TextEncoder().encode(publishId))}.json`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function chunkDir(id: string, root?: string): string {
|
|
111
|
+
return join(uploadDir(root), assertUploadId(id));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A chunk's key carries its first byte, zero-padded so the seam's sorted listing is byte order. */
|
|
115
|
+
function chunkKey(id: string, offset: number, root?: string): string {
|
|
116
|
+
return join(chunkDir(id, root), `${String(offset).padStart(16, '0')}.part`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readJson<T>(key: string): Promise<T | null> {
|
|
120
|
+
try {
|
|
121
|
+
const stored = await getActiveBlobStore().get(key);
|
|
122
|
+
return stored === null ? null : (JSON.parse(new TextDecoder().decode(stored)) as T);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function writeUpload(upload: TikTokUpload, root?: string): Promise<void> {
|
|
129
|
+
const bytes = new TextEncoder().encode(JSON.stringify(upload));
|
|
130
|
+
await getActiveBlobStore().put(uploadKey(upload.uploadId, root), bytes);
|
|
131
|
+
await getActiveBlobStore().put(publishKey(upload.publishId, root), new TextEncoder().encode(JSON.stringify({ uploadId: upload.uploadId })));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function readUpload(id: string, root?: string): Promise<TikTokUpload | null> {
|
|
135
|
+
if (!UPLOAD_ID.test(id)) return null;
|
|
136
|
+
const upload = await readJson<TikTokUpload>(uploadKey(id, root));
|
|
137
|
+
return upload && typeof upload.uploadId === 'string' ? upload : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The upload a publish_id names, while it is staging (before its final chunk lands). */
|
|
141
|
+
export async function readUploadByPublishId(publishId: string, root?: string): Promise<TikTokUpload | null> {
|
|
142
|
+
const pointer = await readJson<{ uploadId: string }>(publishKey(publishId, root));
|
|
143
|
+
return pointer ? readUpload(pointer.uploadId, root) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The upload's chunks in byte order, with their offsets and sizes. */
|
|
147
|
+
async function chunks(id: string, root?: string): Promise<Array<{ key: string; offset: number; size: number }>> {
|
|
148
|
+
const prefix = `${chunkDir(id, root)}/`;
|
|
149
|
+
const out: Array<{ key: string; offset: number; size: number }> = [];
|
|
150
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
151
|
+
const m = /(\d{16})\.part$/.exec(key);
|
|
152
|
+
if (!m) continue;
|
|
153
|
+
out.push({ key, offset: Number(m[1]), size: (await getActiveBlobStore().size(key)) ?? 0 });
|
|
154
|
+
}
|
|
155
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Bytes an upload holds so far: the contiguous run from 0. */
|
|
159
|
+
export async function stagedSize(id: string, root?: string): Promise<number> {
|
|
160
|
+
let held = 0;
|
|
161
|
+
for (const c of await chunks(id, root)) {
|
|
162
|
+
if (c.offset !== held) break;
|
|
163
|
+
held += c.size;
|
|
164
|
+
}
|
|
165
|
+
return held;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Stage one chunk at its first byte — its own key; nothing already staged is read or rewritten. */
|
|
169
|
+
export async function stageChunk(id: string, offset: number, bytes: Uint8Array, root?: string): Promise<void> {
|
|
170
|
+
if (bytes.length > 0) await getActiveBlobStore().put(chunkKey(id, offset, root), bytes);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Join an upload's chunks, once, into the video's bytes. */
|
|
174
|
+
export async function joinStaged(id: string, root?: string): Promise<Uint8Array> {
|
|
175
|
+
const parts = await chunks(id, root);
|
|
176
|
+
const total = parts.reduce((n, c) => n + c.size, 0);
|
|
177
|
+
const out = new Uint8Array(total);
|
|
178
|
+
let at = 0;
|
|
179
|
+
for (const c of parts) {
|
|
180
|
+
const bytes = await getActiveBlobStore().get(c.key);
|
|
181
|
+
if (bytes === null) continue;
|
|
182
|
+
out.set(bytes, at);
|
|
183
|
+
at += bytes.length;
|
|
184
|
+
}
|
|
185
|
+
return out.subarray(0, at);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Drop a finalized upload's chunks (the bytes now live content-addressed); the record stays, so a
|
|
189
|
+
* final PUT whose answer was lost can be asked again and status/fetch still finds the publish. */
|
|
190
|
+
export async function clearStaged(id: string, root?: string): Promise<void> {
|
|
191
|
+
try { await getActiveBlobStore().remove(`${chunkDir(id, root)}/`); } catch { /* never staged */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── a perform's own progress (not a kernel action; the byte annex, as staging is) ──────────────
|
|
195
|
+
// A Direct Post publishes by itself once its last chunk lands, so a perform that died after its init
|
|
196
|
+
// must never init again while TikTok still knows it: that could post the video twice. The publish it
|
|
197
|
+
// opened is kept here, keyed by the entry and written only by the World performing it, so the next
|
|
198
|
+
// perform of the same entry asks that publish where it stands before it opens another.
|
|
199
|
+
|
|
200
|
+
/** The publish an entry's perform opened, and the upload_url TikTok gave it: kept only in this World's
|
|
201
|
+
* byte annex (never an entry, never a changeset), so the next perform can resume the SAME publish's
|
|
202
|
+
* chunks rather than open a second one. */
|
|
203
|
+
export type PerformPublish = { publishId: string; uploadUrl: string; sha256: string; size: number; chunkSize: number; totalChunkCount: number };
|
|
204
|
+
|
|
205
|
+
function performKey(actionId: string, root?: string): string {
|
|
206
|
+
return join(resources(root), 'perform-publishes', `${blobDigest(new TextEncoder().encode(actionId))}.json`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function readPerformPublish(actionId: string, root?: string): Promise<PerformPublish | null> {
|
|
210
|
+
return readJson<PerformPublish>(performKey(actionId, root));
|
|
211
|
+
}
|
|
212
|
+
export async function writePerformPublish(actionId: string, record: PerformPublish, root?: string): Promise<void> {
|
|
213
|
+
await getActiveBlobStore().put(performKey(actionId, root), new TextEncoder().encode(JSON.stringify(record)));
|
|
214
|
+
}
|
|
215
|
+
export async function clearPerformPublish(actionId: string, root?: string): Promise<void> {
|
|
216
|
+
try { await getActiveBlobStore().remove(performKey(actionId, root)); } catch { /* none */ }
|
|
217
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// TikTok's CLIENT-SIDE RATE BUDGET — this pack's DECLARATION (the numbers) plus the thin typed
|
|
2
|
+
// bindings `liveTikTokExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
|
|
3
|
+
// window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
|
|
4
|
+
// lives ONCE in the vendor-agnostic kernel (`@volter/world-core` -> `rateBudget.ts`).
|
|
5
|
+
//
|
|
6
|
+
// ── HOW THE CEILING WAS CHOSEN (live-fetched first-party figures) ───────────────────────────────
|
|
7
|
+
// TikTok PUBLISHES per-endpoint rate limits. From https://developers.tiktok.com/doc/tiktok-api-v2-rate-limit
|
|
8
|
+
// (fetched 2026-09-13): "Request rate calculation is based on a one minute sliding window", the
|
|
9
|
+
// table gives `/v2/user/info/`, `/v2/video/list/` and `/v2/video/query/` a limit of **600** each,
|
|
10
|
+
// and "If the number of requests exceeds the threshold, new requests will be throttled and a
|
|
11
|
+
// response will be returned with HTTP status 429 and error code rate_limit_exceeded."
|
|
12
|
+
//
|
|
13
|
+
// So the window is the vendor's own ONE MINUTE and the ceiling is the vendor's own 600, with each
|
|
14
|
+
// Display API read at weight 1 — the declaration REPRODUCES the published scheme for the endpoints
|
|
15
|
+
// the connector reads (the github-budget precedent: when the vendor's scheme is already a windowed
|
|
16
|
+
// budget, transcribe it). Two deliberate tightenings on top, both stated rather than hidden:
|
|
17
|
+
//
|
|
18
|
+
// • The vendor's 600 is PER ENDPOINT; this ledger is ONE budget across all of them, so 600
|
|
19
|
+
// shared is strictly tighter than the vendor's 1800 across the three.
|
|
20
|
+
// • The OAuth endpoints (`/v2/oauth/token/`, `/v2/oauth/revoke/`) publish NO scalar limit on
|
|
21
|
+
// that page. That absence is stated here rather than dressed up, and those calls are priced
|
|
22
|
+
// ABOVE the documented read so the undocumented surface is the conservative one.
|
|
23
|
+
//
|
|
24
|
+
// The window is already the kernel's 60s burst sub-window, so the ceiling IS the burst and the
|
|
25
|
+
// kernel refuses a `burstCeiling` here. 600 units/minute out-bursts the kernel fallback's 30
|
|
26
|
+
// calls/minute, which is licensed by the hand-written `burstAnchor` in this pack's gate.ts —
|
|
27
|
+
// the vendor's own documented per-minute allowance, with its source.
|
|
28
|
+
import {
|
|
29
|
+
declareRateBudget,
|
|
30
|
+
rateBudgetPath,
|
|
31
|
+
rateBudgetWeight,
|
|
32
|
+
RateBudget,
|
|
33
|
+
type RateBudgetDeclaration,
|
|
34
|
+
type RateBudgetOptions,
|
|
35
|
+
type RateBudgetReservation,
|
|
36
|
+
type RateBudgetSnapshot,
|
|
37
|
+
} from '@volter/world-core';
|
|
38
|
+
|
|
39
|
+
const VENDOR = 'tiktok';
|
|
40
|
+
|
|
41
|
+
/** Rolling window, in ms — TikTok's own one-minute sliding window. */
|
|
42
|
+
export const TIKTOK_BUDGET_WINDOW_MS = 60_000;
|
|
43
|
+
|
|
44
|
+
/** Weighted units allowed inside one window — TikTok's own published per-endpoint figure, applied
|
|
45
|
+
* across ALL endpoints at once (strictly tighter than the vendor's own per-endpoint allowance). */
|
|
46
|
+
export const TIKTOK_BUDGET_CEILING = 600;
|
|
47
|
+
|
|
48
|
+
/** The window IS the kernel's burst sub-window, so this is also the per-minute burst bound. */
|
|
49
|
+
export const TIKTOK_BUDGET_BURST_CEILING = TIKTOK_BUDGET_CEILING;
|
|
50
|
+
|
|
51
|
+
/** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
|
|
52
|
+
export const TIKTOK_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
53
|
+
|
|
54
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs judged. */
|
|
55
|
+
export const TIKTOK_CALL_WEIGHTS = {
|
|
56
|
+
/** The three Display API reads — 600/min is the PUBLISHED limit; weight 1 transcribes it. */
|
|
57
|
+
displayRead: 1,
|
|
58
|
+
/** `POST /v2/oauth/revoke/` — disconnects an app from a REAL human's TikTok account. Priced as a
|
|
59
|
+
* human blast radius, not a throttle (a judgement call, stated as one). */
|
|
60
|
+
revoke: 60,
|
|
61
|
+
/** Everything else (the token endpoint): no published scalar limit -> priced conservatively. */
|
|
62
|
+
other: 5,
|
|
63
|
+
// The Content Posting calls publish their OWN per-token minute allowance on each reference page
|
|
64
|
+
// (fetched 2026-09-27), and each is priced so that the endpoint alone can never exceed it inside
|
|
65
|
+
// this 600-unit minute: weight = 600 / the published per-minute figure.
|
|
66
|
+
/** `POST /v2/post/publish/creator_info/query/` — "limited to 20 requests per minute". */
|
|
67
|
+
creatorInfo: 30,
|
|
68
|
+
/** `POST /v2/post/publish/video/init/` and `/inbox/video/init/` — "limited to 6 requests per minute". */
|
|
69
|
+
postInit: 100,
|
|
70
|
+
/** `POST /v2/post/publish/status/fetch/` — "limited to 30 requests per minute". */
|
|
71
|
+
postStatus: 20,
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
/** THE PACK'S DECLARATION — pure data, the only TikTok-specific thing in the whole budget. */
|
|
75
|
+
export const TIKTOK_RATE_BUDGET: RateBudgetDeclaration = {
|
|
76
|
+
windowMs: TIKTOK_BUDGET_WINDOW_MS,
|
|
77
|
+
ceiling: TIKTOK_BUDGET_CEILING,
|
|
78
|
+
defaultWeight: TIKTOK_CALL_WEIGHTS.other,
|
|
79
|
+
maxRetryAfterSeconds: TIKTOK_BUDGET_MAX_RETRY_AFTER_S,
|
|
80
|
+
rules: [
|
|
81
|
+
{ match: '^GET /v2/user/info$', weight: TIKTOK_CALL_WEIGHTS.displayRead },
|
|
82
|
+
{ match: '^POST /v2/video/list$', weight: TIKTOK_CALL_WEIGHTS.displayRead },
|
|
83
|
+
{ match: '^POST /v2/video/query$', weight: TIKTOK_CALL_WEIGHTS.displayRead },
|
|
84
|
+
{ match: '^POST /v2/oauth/revoke$', weight: TIKTOK_CALL_WEIGHTS.revoke },
|
|
85
|
+
{ match: '^POST /v2/post/publish/creator_info/query$', weight: TIKTOK_CALL_WEIGHTS.creatorInfo },
|
|
86
|
+
{ match: '^POST /v2/post/publish/video/init$', weight: TIKTOK_CALL_WEIGHTS.postInit },
|
|
87
|
+
{ match: '^POST /v2/post/publish/inbox/video/init$', weight: TIKTOK_CALL_WEIGHTS.postInit },
|
|
88
|
+
{ match: '^POST /v2/post/publish/status/fetch$', weight: TIKTOK_CALL_WEIGHTS.postStatus },
|
|
89
|
+
],
|
|
90
|
+
reason:
|
|
91
|
+
'TikTok publishes per-endpoint rate limits (https://developers.tiktok.com/doc/tiktok-api-v2-rate-limit, '
|
|
92
|
+
+ 'fetched 2026-09-13): "Request rate calculation is based on a one minute sliding window", the '
|
|
93
|
+
+ 'table lists /v2/user/info/, /v2/video/list/ and /v2/video/query/ at 600 each, and exceeding a '
|
|
94
|
+
+ 'limit answers "HTTP status 429 and error code rate_limit_exceeded". This declaration '
|
|
95
|
+
+ "transcribes that scheme: the window is the vendor's own one minute, the ceiling is the "
|
|
96
|
+
+ "vendor's own 600, and each Display API read costs 1. It is tighter than the vendor in one "
|
|
97
|
+
+ 'respect, deliberately: the published 600 is PER ENDPOINT while this is ONE ledger shared '
|
|
98
|
+
+ 'across all three, so the twin spends at a third of the vendor allowance in the worst case. '
|
|
99
|
+
+ 'The OAuth token and revoke endpoints publish NO scalar limit on that page; they are priced '
|
|
100
|
+
+ 'ABOVE the documented read (defaultWeight 5) so the undocumented surface is the conservative '
|
|
101
|
+
+ 'one. POST /v2/oauth/revoke/ costs 60 because it is the one destructive call here — it '
|
|
102
|
+
+ "disconnects an app from a real human's TikTok account — which is a human blast radius, not a "
|
|
103
|
+
+ 'throttle; that price is a judgement call, stated as one. The Content Posting references each '
|
|
104
|
+
+ 'publish a per-token minute allowance (fetched 2026-09-27): creator_info 20, the Direct Post and '
|
|
105
|
+
+ 'inbox inits 6, status/fetch 30; each is priced 600 divided by its figure (30, 100, 20), so no '
|
|
106
|
+
+ 'one of them can exceed its own allowance inside this minute. The chunk PUTs to the upload URL '
|
|
107
|
+
+ 'are not charged: they go to the upload host on a presigned URL, and once a publish has started '
|
|
108
|
+
+ 'the budget cannot stop it. The vendor publishes no rate-limit response headers, so the 429 / '
|
|
109
|
+
+ 'Retry-After cooldown is the only backstop and is armed from whatever the vendor does send.',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Declared at module load, so merely importing this module (which `tiktok-connector.ts` does) is
|
|
113
|
+
// enough to arm the real ceiling.
|
|
114
|
+
declareRateBudget(VENDOR, TIKTOK_RATE_BUDGET);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
|
|
118
|
+
* (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
|
|
119
|
+
* evaded by input variation — and on THIS vendor the trailing slash is not hypothetical: every
|
|
120
|
+
* documented TikTok path carries one (`/v2/oauth/revoke/`), so a rule anchored on the bare form
|
|
121
|
+
* would price the vendor's OWN spelling of the destructive call at the default weight.
|
|
122
|
+
*/
|
|
123
|
+
export function tiktokCallWeight(method: string, path: string): number {
|
|
124
|
+
const { bare, query } = splitQuery(path);
|
|
125
|
+
return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function splitQuery(path: string): { bare: string; query: Record<string, string> } {
|
|
129
|
+
const at = path.indexOf('?');
|
|
130
|
+
const query: Record<string, string> = {};
|
|
131
|
+
if (at !== -1) for (const [k, v] of new URLSearchParams(path.slice(at + 1))) query[k] = v;
|
|
132
|
+
const raw = at === -1 ? path : path.slice(0, at);
|
|
133
|
+
const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
|
|
134
|
+
return { bare, query };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to
|
|
138
|
+
* opt into world-scoped accounting. */
|
|
139
|
+
export function tiktokBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
|
|
140
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
141
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through must not
|
|
142
|
+
// redirect this pack's ledger.
|
|
143
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Construction options. The vendor is fixed; everything else may only TIGHTEN. */
|
|
147
|
+
export type TikTokBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not
|
|
151
|
+
* an alias, so `budget instanceof TikTokBudget` means "a budget that accounts against TIKTOK's
|
|
152
|
+
* ledger under TIKTOK's ceiling".
|
|
153
|
+
*/
|
|
154
|
+
export class TikTokBudget extends RateBudget {
|
|
155
|
+
constructor(opts: TikTokBudgetOptions = {}) {
|
|
156
|
+
super({ ...opts, vendor: VENDOR });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { RateBudgetError as TikTokBudgetError } from '@volter/world-core';
|
|
161
|
+
export type { RateBudgetErrorKind as TikTokBudgetErrorKind } from '@volter/world-core';
|
|
162
|
+
export type TikTokBudgetReservation = RateBudgetReservation;
|
|
163
|
+
export type TikTokBudgetSnapshot = RateBudgetSnapshot;
|