@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,167 @@
|
|
|
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 } from '@volter/world-core';
|
|
16
|
+
export { ACCESS_TOKEN_TTL_SECONDS, API_ORIGIN, AUTH_CODE_TTL_SECONDS, AUTHORIZE_ORIGIN, CLIENT_TOKEN_TTL_SECONDS, handleTikTokTwinRequest, METERED_ENDPOINTS, RATE_LIMIT_PER_WINDOW, RATE_LIMITS, RATE_WINDOW_SECONDS, REFRESH_TOKEN_TTL_SECONDS, RESOURCE_TYPES, tiktokTwinSnapshot, USER_FIELDS, VIDEO_FIELDS, VIDEO_LIST_DEFAULT_COUNT, VIDEO_LIST_MAX_COUNT, VIDEO_QUERY_MAX_IDS, } from "./tiktok-twin.js";
|
|
17
|
+
export { createTikTokConsentServer, createTikTokTwinFetch, createTikTokTwinServer } from "./tiktok-server.js";
|
|
18
|
+
export { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_KEY, DEFAULT_CLIENT_SECRET, DEFAULT_VIDEOS, defaultRedirectUris, openIdFor, redirectUriAllowed, sessionAccount, } from "./tiktok-store.js";
|
|
19
|
+
export { CHALLENGE_METHOD, isSupportedChallengeMethod, isWellFormedVerifier, pkceVerifies, rfc7636Challenge, tiktokCodeChallenge } from "./tiktok-pkce.js";
|
|
20
|
+
export { describeScope, describeScopes, formatScopeParam, KNOWN_SCOPES, parseScopeParam, SCOPE_CATALOG, sortScopesForConsent, } from "./tiktok-scopes.js";
|
|
21
|
+
export { API_ERRORS, OAUTH_ERRORS } from "./tiktok-errors.js";
|
|
22
|
+
export { logId } from "./tiktok-ids.js";
|
|
23
|
+
export { renderUser, renderVideo, USER_FIELD_SCOPES, USER_INFO_SCOPES, VIDEO_SCOPE } from "./tiktok-user.js";
|
|
24
|
+
export { liveTikTokExecute, mapUserInfoAccount, mapVideo, performTikTokAction, PULL_USER_FIELDS, PULL_VIDEO_FIELDS, pullTikTok, pushPendingTikTokActions, syncTikTokFromReal, syncTikTokFromRemote, tiktokExecuteOver, } from "./tiktok-connector.js";
|
|
25
|
+
export { budgetedTikTokExecute, MAX_PUBLISH_WAIT_MS, TikTokRetryableError, PERFORM_CHUNK_BYTES, performChunkPlan, STATUS_POLL_MS, TikTokStillProcessingError, } from "./tiktok-connector.js";
|
|
26
|
+
export { checkChunkPlan, chunkLength, creatorOptions, DEFAULT_MAX_VIDEO_POST_DURATION_SEC, MAX_CHUNK_BYTES, MAX_CHUNK_COUNT, MAX_FINAL_CHUNK_BYTES, MAX_SERVED_RANGE, MAX_VIDEO_BYTES, MEDIA_PREFIX, MIN_CHUNK_BYTES, PRIVACY_LEVELS, processingMs, UPLOAD_ORIGIN, UPLOAD_PATH, UPLOAD_URL_TTL_MS, } from "./tiktok-posting.js";
|
|
27
|
+
export { POSTING_ERRORS } from "./tiktok-errors.js";
|
|
28
|
+
// The client-side rate budget — the fail-closed backstop `liveTikTokExecute` routes every live
|
|
29
|
+
// request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
|
|
30
|
+
// here is this vendor's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
|
|
31
|
+
// bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
|
|
32
|
+
// `TikTokBudgetError` by type; there is deliberately no export that disables the guard.
|
|
33
|
+
export { TIKTOK_BUDGET_BURST_CEILING, TIKTOK_BUDGET_CEILING, TIKTOK_BUDGET_MAX_RETRY_AFTER_S, TIKTOK_BUDGET_WINDOW_MS, TIKTOK_CALL_WEIGHTS, TIKTOK_RATE_BUDGET, TikTokBudget, TikTokBudgetError, tiktokBudgetPath, tiktokCallWeight, } from "./tiktok-budget.js";
|
|
34
|
+
export { CONSENT_CSS, consentPageHtml, errorPageHtml, tiktokConsentState } from "./tiktok-consent-ui.js";
|
|
35
|
+
// The tiktok.com mirror (profile, grid, vertical player): its pure helpers and its server. The hosted
|
|
36
|
+
// mirror mount reads tiktok-mirror-ui.ts directly (`tiktokMirrorHtml`, `buildTiktokMirrorClient`,
|
|
37
|
+
// `tiktokMirrorStyles`).
|
|
38
|
+
export { captionOf, captionSegments, compactCount, createTiktokMirrorServer, mediaSource, profileStats } from "./tiktok-mirror-ui.js";
|
|
39
|
+
import { TIKTOK_RATE_BUDGET as RATE_BUDGET } from "./tiktok-budget.js";
|
|
40
|
+
import { performTikTokAction, syncTikTokFromRemote } from "./tiktok-connector.js";
|
|
41
|
+
/**
|
|
42
|
+
* The www.tiktok.com paths THIS pack serves — the Login Kit authorization leg — as a RegExp SOURCE
|
|
43
|
+
* for the descriptor's `hosts` path rule. www.tiktok.com is NOT an API host: it is the whole
|
|
44
|
+
* TikTok web property (the For You feed, profiles, settings, everything). Claiming the host
|
|
45
|
+
* outright would be the Cal.com mis-route incident with the largest possible blast radius, so this
|
|
46
|
+
* claims EXACTLY the authorize path plus the twin-only prefix. ANCHORED, the googleoauth lesson:
|
|
47
|
+
* an unanchored claim swallows `/v2/auth/authorize2` and friends. The optional trailing slash is
|
|
48
|
+
* load-bearing — TikTok's own documentation writes `/v2/auth/authorize/` while Dub's URL builder
|
|
49
|
+
* omits it, and both must route here.
|
|
50
|
+
*/
|
|
51
|
+
const TIKTOK_AUTHORIZE_PATHS = '^/v2/auth/authorize/?$|^/_twin/';
|
|
52
|
+
/**
|
|
53
|
+
* The open.tiktokapis.com paths THIS pack serves: the OAuth token/revoke endpoints, the Display
|
|
54
|
+
* API's user and video reads and the Content Posting API's four calls. open.tiktokapis.com is the
|
|
55
|
+
* ENTIRE TikTok open platform host — Research, Data Portability, Commercial Content, Business and
|
|
56
|
+
* the photo post besides — and nothing this pack does not model is claimed: an unmodelled TikTok
|
|
57
|
+
* call must refuse loudly rather than land in a twin that cannot serve it.
|
|
58
|
+
*/
|
|
59
|
+
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/';
|
|
60
|
+
/**
|
|
61
|
+
* The upload host's path: the Content Posting API's upload_url is `https://open-upload.tiktokapis.com/
|
|
62
|
+
* video/?upload_id=…&upload_token=…` (the Direct Post and inbox references' own examples). An app in a
|
|
63
|
+
* World PUTs its chunks there, so the injector must route that host to the twin too. Nothing else on
|
|
64
|
+
* the upload host is modelled, so nothing else is claimed.
|
|
65
|
+
*/
|
|
66
|
+
const TIKTOK_UPLOAD_PATHS = '^/video/?$';
|
|
67
|
+
export const pack = {
|
|
68
|
+
// 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
|
|
69
|
+
// the real state system, registered on load. A World's POST crosses at `perform`, through the
|
|
70
|
+
// Content Posting API (creator_info, init, the presigned chunk PUTs, status/fetch); nothing else
|
|
71
|
+
// does: TikTok publishes no API that creates a developer app, seeds a user or grants a scope —
|
|
72
|
+
// those are developer-portal and in-app settings actions a person takes in a browser — so
|
|
73
|
+
// `perform` settles every other entry with that reason.
|
|
74
|
+
protocol: '2',
|
|
75
|
+
// The vendor's access token lives 24 hours and its Display API limit is 600/minute, so a served
|
|
76
|
+
// world can afford a half-hourly pull; `onDemand` throttles a hand-run refresh to one a minute,
|
|
77
|
+
// well inside that budget.
|
|
78
|
+
refresh: { every: '30m', onDemand: { atMost: '60s' } },
|
|
79
|
+
stateSystem: { perform: performTikTokAction, refresh: syncTikTokFromRemote },
|
|
80
|
+
// The round trip registers a developer app through the twin's OWN door, because the vendor has no
|
|
81
|
+
// API for it at all — which is the same reason `perform` never crosses. (The Login Kit legs
|
|
82
|
+
// cannot serve as the round trip: the consent post answers a 302 and the harness fails any step
|
|
83
|
+
// answering >=300.)
|
|
84
|
+
roundTrip: {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
path: '/_twin/clients',
|
|
87
|
+
body: { client_key: 'awroundtripapp0001', client_secret: 'round-trip-secret', name: 'Round Trip', redirect_uris: ['https://round.trip.test/callback'] },
|
|
88
|
+
},
|
|
89
|
+
parityOrigin: 'http://twin',
|
|
90
|
+
// shapeParity is NOT held, and the reason is STRUCTURAL rather than a divergence to close: the
|
|
91
|
+
// refresh reads the ACCOUNT the credential names (`GET /v2/user/info/`), while the only write a
|
|
92
|
+
// blind round trip can make on this vendor is registering a developer app — which TikTok
|
|
93
|
+
// publishes no API to read back. Written and observed are different subjects by design, so there
|
|
94
|
+
// is no pair of shapes to compare. Reaching parity would need the refresh to observe an
|
|
95
|
+
// oauth_client, and no TikTok endpoint returns one.
|
|
96
|
+
vendor: 'tiktok',
|
|
97
|
+
// The SAME object tiktok-budget.ts declares at module load — one source of truth, so registering
|
|
98
|
+
// the pack and importing the connector can never arm two different ceilings.
|
|
99
|
+
rateBudget: RATE_BUDGET,
|
|
100
|
+
transport: 'rest',
|
|
101
|
+
archetype: 'crud',
|
|
102
|
+
bin: 'world-tiktok',
|
|
103
|
+
resources: [
|
|
104
|
+
'oauth_client',
|
|
105
|
+
'account',
|
|
106
|
+
'session',
|
|
107
|
+
'auth_request',
|
|
108
|
+
'grant',
|
|
109
|
+
'video',
|
|
110
|
+
'publish',
|
|
111
|
+
],
|
|
112
|
+
specSource: 'developers.tiktok.com, fetched 2026-09-13 — no first-party machine-readable spec exists for '
|
|
113
|
+
+ 'the TikTok open API, so the denominator was authored top-down from the published references: '
|
|
114
|
+
+ 'Login Kit for Web (the authorize URL, its parameters and the callback parameters), User '
|
|
115
|
+
+ 'Access Token Management (token / refresh / revoke request + response tables and examples), '
|
|
116
|
+
+ 'Client Access Token Management (the client_credentials grant), Login Kit for Desktop (PKCE: '
|
|
117
|
+
+ 'hex-encoded SHA-256, S256 only), the Scopes Overview, Get User Info (the field x scope '
|
|
118
|
+
+ 'table), the Video Object / Video List / Video Query references, the OAuth error-handling '
|
|
119
|
+
+ 'reference (the ten flat `error` values) and the API v2 error-handling reference (the seven '
|
|
120
|
+
+ 'nested `error.code` values with their HTTP statuses) and rate-limit reference (600/minute, '
|
|
121
|
+
+ 'one-minute sliding window, 429 + rate_limit_exceeded); and, fetched 2026-09-27, the Content '
|
|
122
|
+
+ 'Posting references (Query Creator Info, Direct Post, Upload to inbox, Get Post Status and the '
|
|
123
|
+
+ 'Media Transfer Guide: chunk rules, the upload_url PUT with Content-Range, per-token minute limits).',
|
|
124
|
+
description: "TikTok twin — the real tiktok.com authorization page, the full Login Kit OAuth round trip "
|
|
125
|
+
+ '(token, refresh, revoke, client_credentials) and the Display API user/video reads with '
|
|
126
|
+
+ "TikTok's own comma-separated scopes, per-app open_id, field-level scope gating and both of "
|
|
127
|
+
+ 'its error envelopes; and the Content Posting API — creator_info, Direct Post and inbox uploads '
|
|
128
|
+
+ 'through a signed upload URL in chunks, status/fetch by World time — with a tiktok.com-style '
|
|
129
|
+
+ "profile and vertical player over the posted videos, and a perform that posts a World's video "
|
|
130
|
+
+ 'to TikTok.',
|
|
131
|
+
adoption: {
|
|
132
|
+
// TikTok publishes NO first-party npm client for Login Kit or the Display API: its own
|
|
133
|
+
// reference material is curl and raw fetch, which is exactly how the motivating application
|
|
134
|
+
// (Dub) calls it. The community `tiktok` package on npm is deliberately NOT claimed — it is
|
|
135
|
+
// not a TikTok-published client, and `adoption.sdks` is for official clients only (the bitly
|
|
136
|
+
// precedent). Interception is therefore host-based, which is why `hosts` below is the whole
|
|
137
|
+
// adoption story for this vendor.
|
|
138
|
+
sdks: [],
|
|
139
|
+
// No official Python distribution either. `tiktok-business-api-sdk` (PyPI and npm) is
|
|
140
|
+
// first-party but speaks the BUSINESS/Ads API on business-api.tiktok.com — a different half of
|
|
141
|
+
// the vendor that this pack does not serve, so claiming it would over-claim coverage.
|
|
142
|
+
pypi: [],
|
|
143
|
+
// TIKTOK_CLIENT_ID / TIKTOK_CLIENT_SECRET are exactly the pair Dub reads, and TIKTOK_CLIENT_KEY
|
|
144
|
+
// is the vendor's own name for the same value. The `TIKTOKBUSINESS` stem is deliberately left
|
|
145
|
+
// to the packless registry: it names the Business API this pack does not model.
|
|
146
|
+
envStems: ['TIKTOK'],
|
|
147
|
+
},
|
|
148
|
+
hosts: [
|
|
149
|
+
{ host: 'www.tiktok.com', pathPattern: TIKTOK_AUTHORIZE_PATHS },
|
|
150
|
+
{ host: 'open.tiktokapis.com', pathPattern: TIKTOK_API_PATHS },
|
|
151
|
+
{ host: 'open-upload.tiktokapis.com', pathPattern: TIKTOK_UPLOAD_PATHS },
|
|
152
|
+
],
|
|
153
|
+
// Dub HARDCODES both hosts — `https://open.tiktokapis.com/v2` in its TikTokClient and the
|
|
154
|
+
// authorize/token URLs in its provider table — and TikTok publishes no official SDK with a
|
|
155
|
+
// base-URL option, so there is no env var an app reads that a world could point at the twin.
|
|
156
|
+
// Inventing a TIKTOK_BASE_URL would make `covers` report the world covered while the app still
|
|
157
|
+
// talked to the real vendor; interception through the two `hosts` entries above is the whole
|
|
158
|
+
// mechanism.
|
|
159
|
+
endpointEnvNone: 'no app-read base-URL env exists: TikTok publishes no official SDK with a base-URL option and '
|
|
160
|
+
+ 'integrations (Dub included) hardcode www.tiktok.com and open.tiktokapis.com, so interception '
|
|
161
|
+
+ 'is the path-scoped hosts entries above.',
|
|
162
|
+
// A browser is REDIRECTED to www.tiktok.com/v2/auth/authorize — the loader host is the consent
|
|
163
|
+
// host and the prefix is the Login Kit path root.
|
|
164
|
+
browserRouting: { apiPathPrefix: '/v2/auth/', loaderHost: 'https://www.tiktok.com' },
|
|
165
|
+
};
|
|
166
|
+
// registered at import: the kernel learns the pack's state system (protocol 2)
|
|
167
|
+
registerPack(pack);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface TikTokBlobRef {
|
|
2
|
+
sha256: string;
|
|
3
|
+
size: number;
|
|
4
|
+
}
|
|
5
|
+
/** Store bytes content-addressed in this branch's annex; identical content is a no-op write. */
|
|
6
|
+
export declare function putTikTokBlob(bytes: Uint8Array, root?: string): Promise<TikTokBlobRef>;
|
|
7
|
+
/** A stored blob's size, here or at an ancestor; null when absent or the digest is malformed. */
|
|
8
|
+
export declare function tikTokBlobSize(sha256: unknown, root?: string): Promise<number | null>;
|
|
9
|
+
/** Bytes start..endInclusive of a stored blob, here or at an ancestor, read as a range. */
|
|
10
|
+
export declare function readTikTokBlobRange(sha256: unknown, start: number, endInclusive: number, root?: string): Promise<Uint8Array | null>;
|
|
11
|
+
/** What an upload remembers between its init and its final chunk. */
|
|
12
|
+
export type TikTokUpload = {
|
|
13
|
+
/** The upload_id in the upload_url's query. */
|
|
14
|
+
uploadId: string;
|
|
15
|
+
/** The publish_id the init answered; status/fetch reads the upload by it until it finalizes. */
|
|
16
|
+
publishId: string;
|
|
17
|
+
/** `direct` (video.publish: Direct Post) or `inbox` (video.upload: the creator's drafts). */
|
|
18
|
+
mode: 'direct' | 'inbox';
|
|
19
|
+
/** The account and app the posting token belongs to (status/fetch answers
|
|
20
|
+
* token_not_authorized_for_specified_publish_id to any other), and the token's SHA-256 — never the
|
|
21
|
+
* token. */
|
|
22
|
+
accountId: string;
|
|
23
|
+
clientKey: string;
|
|
24
|
+
tokenSha256: string;
|
|
25
|
+
/** This upload's own signing secret (hex), drawn from entropy at init: the upload_token is an HMAC
|
|
26
|
+
* under it, so no other upload's URL, and no credential, is ever the key (LinkedIn's per-asset
|
|
27
|
+
* secret). */
|
|
28
|
+
uploadSecret: string;
|
|
29
|
+
/** The Direct Post `post_info` as the init validated it (empty for an inbox upload). */
|
|
30
|
+
postInfo: Record<string, unknown>;
|
|
31
|
+
videoSize: number;
|
|
32
|
+
chunkSize: number;
|
|
33
|
+
totalChunkCount: number;
|
|
34
|
+
/** World time (ms) the upload_url was issued and stops being valid. */
|
|
35
|
+
issuedAt: number;
|
|
36
|
+
expiresAt: number;
|
|
37
|
+
/** Set once the final chunk has landed: the digest the bytes were stored under. */
|
|
38
|
+
sha256?: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function writeUpload(upload: TikTokUpload, root?: string): Promise<void>;
|
|
41
|
+
export declare function readUpload(id: string, root?: string): Promise<TikTokUpload | null>;
|
|
42
|
+
/** The upload a publish_id names, while it is staging (before its final chunk lands). */
|
|
43
|
+
export declare function readUploadByPublishId(publishId: string, root?: string): Promise<TikTokUpload | null>;
|
|
44
|
+
/** Bytes an upload holds so far: the contiguous run from 0. */
|
|
45
|
+
export declare function stagedSize(id: string, root?: string): Promise<number>;
|
|
46
|
+
/** Stage one chunk at its first byte — its own key; nothing already staged is read or rewritten. */
|
|
47
|
+
export declare function stageChunk(id: string, offset: number, bytes: Uint8Array, root?: string): Promise<void>;
|
|
48
|
+
/** Join an upload's chunks, once, into the video's bytes. */
|
|
49
|
+
export declare function joinStaged(id: string, root?: string): Promise<Uint8Array>;
|
|
50
|
+
/** Drop a finalized upload's chunks (the bytes now live content-addressed); the record stays, so a
|
|
51
|
+
* final PUT whose answer was lost can be asked again and status/fetch still finds the publish. */
|
|
52
|
+
export declare function clearStaged(id: string, root?: string): Promise<void>;
|
|
53
|
+
/** The publish an entry's perform opened, and the upload_url TikTok gave it: kept only in this World's
|
|
54
|
+
* byte annex (never an entry, never a changeset), so the next perform can resume the SAME publish's
|
|
55
|
+
* chunks rather than open a second one. */
|
|
56
|
+
export type PerformPublish = {
|
|
57
|
+
publishId: string;
|
|
58
|
+
uploadUrl: string;
|
|
59
|
+
sha256: string;
|
|
60
|
+
size: number;
|
|
61
|
+
chunkSize: number;
|
|
62
|
+
totalChunkCount: number;
|
|
63
|
+
};
|
|
64
|
+
export declare function readPerformPublish(actionId: string, root?: string): Promise<PerformPublish | null>;
|
|
65
|
+
export declare function writePerformPublish(actionId: string, record: PerformPublish, root?: string): Promise<void>;
|
|
66
|
+
export declare function clearPerformPublish(actionId: string, root?: string): Promise<void>;
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
const SERVICE = 'tiktok';
|
|
23
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
24
|
+
const UPLOAD_ID = /^[0-9]{1,24}$/;
|
|
25
|
+
function resources(root) {
|
|
26
|
+
return worldPaths(SERVICE, root).resources;
|
|
27
|
+
}
|
|
28
|
+
function blobKey(sha256) {
|
|
29
|
+
return join('blobs', 'sha256', sha256.slice(0, 2), sha256);
|
|
30
|
+
}
|
|
31
|
+
/** Store bytes content-addressed in this branch's annex; identical content is a no-op write. */
|
|
32
|
+
export async function putTikTokBlob(bytes, root) {
|
|
33
|
+
const sha256 = blobDigest(bytes);
|
|
34
|
+
const key = join(resources(root), blobKey(sha256));
|
|
35
|
+
if (!(await getActiveBlobStore().exists(key)))
|
|
36
|
+
await getActiveBlobStore().put(key, bytes);
|
|
37
|
+
return { sha256, size: bytes.length };
|
|
38
|
+
}
|
|
39
|
+
/** A stored blob's size, here or at an ancestor; null when absent or the digest is malformed. */
|
|
40
|
+
export async function tikTokBlobSize(sha256, root) {
|
|
41
|
+
if (typeof sha256 !== 'string' || !DIGEST.test(sha256))
|
|
42
|
+
return null;
|
|
43
|
+
return resourceBlobSize(SERVICE, blobKey(sha256), root);
|
|
44
|
+
}
|
|
45
|
+
/** Bytes start..endInclusive of a stored blob, here or at an ancestor, read as a range. */
|
|
46
|
+
export async function readTikTokBlobRange(sha256, start, endInclusive, root) {
|
|
47
|
+
if (typeof sha256 !== 'string' || !DIGEST.test(sha256))
|
|
48
|
+
return null;
|
|
49
|
+
return readResourceBlobRange(SERVICE, blobKey(sha256), start, endInclusive, root);
|
|
50
|
+
}
|
|
51
|
+
function uploadDir(root) {
|
|
52
|
+
return join(resources(root), 'uploads');
|
|
53
|
+
}
|
|
54
|
+
function assertUploadId(id) {
|
|
55
|
+
if (!UPLOAD_ID.test(id))
|
|
56
|
+
throw new Error(`invalid TikTok upload id: ${JSON.stringify(id)}`);
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
function uploadKey(id, root) {
|
|
60
|
+
return join(uploadDir(root), `${assertUploadId(id)}.json`);
|
|
61
|
+
}
|
|
62
|
+
function publishKey(publishId, root) {
|
|
63
|
+
return join(uploadDir(root), 'by-publish', `${blobDigest(new TextEncoder().encode(publishId))}.json`);
|
|
64
|
+
}
|
|
65
|
+
function chunkDir(id, root) {
|
|
66
|
+
return join(uploadDir(root), assertUploadId(id));
|
|
67
|
+
}
|
|
68
|
+
/** A chunk's key carries its first byte, zero-padded so the seam's sorted listing is byte order. */
|
|
69
|
+
function chunkKey(id, offset, root) {
|
|
70
|
+
return join(chunkDir(id, root), `${String(offset).padStart(16, '0')}.part`);
|
|
71
|
+
}
|
|
72
|
+
async function readJson(key) {
|
|
73
|
+
try {
|
|
74
|
+
const stored = await getActiveBlobStore().get(key);
|
|
75
|
+
return stored === null ? null : JSON.parse(new TextDecoder().decode(stored));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function writeUpload(upload, root) {
|
|
82
|
+
const bytes = new TextEncoder().encode(JSON.stringify(upload));
|
|
83
|
+
await getActiveBlobStore().put(uploadKey(upload.uploadId, root), bytes);
|
|
84
|
+
await getActiveBlobStore().put(publishKey(upload.publishId, root), new TextEncoder().encode(JSON.stringify({ uploadId: upload.uploadId })));
|
|
85
|
+
}
|
|
86
|
+
export async function readUpload(id, root) {
|
|
87
|
+
if (!UPLOAD_ID.test(id))
|
|
88
|
+
return null;
|
|
89
|
+
const upload = await readJson(uploadKey(id, root));
|
|
90
|
+
return upload && typeof upload.uploadId === 'string' ? upload : null;
|
|
91
|
+
}
|
|
92
|
+
/** The upload a publish_id names, while it is staging (before its final chunk lands). */
|
|
93
|
+
export async function readUploadByPublishId(publishId, root) {
|
|
94
|
+
const pointer = await readJson(publishKey(publishId, root));
|
|
95
|
+
return pointer ? readUpload(pointer.uploadId, root) : null;
|
|
96
|
+
}
|
|
97
|
+
/** The upload's chunks in byte order, with their offsets and sizes. */
|
|
98
|
+
async function chunks(id, root) {
|
|
99
|
+
const prefix = `${chunkDir(id, root)}/`;
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
102
|
+
const m = /(\d{16})\.part$/.exec(key);
|
|
103
|
+
if (!m)
|
|
104
|
+
continue;
|
|
105
|
+
out.push({ key, offset: Number(m[1]), size: (await getActiveBlobStore().size(key)) ?? 0 });
|
|
106
|
+
}
|
|
107
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
108
|
+
}
|
|
109
|
+
/** Bytes an upload holds so far: the contiguous run from 0. */
|
|
110
|
+
export async function stagedSize(id, root) {
|
|
111
|
+
let held = 0;
|
|
112
|
+
for (const c of await chunks(id, root)) {
|
|
113
|
+
if (c.offset !== held)
|
|
114
|
+
break;
|
|
115
|
+
held += c.size;
|
|
116
|
+
}
|
|
117
|
+
return held;
|
|
118
|
+
}
|
|
119
|
+
/** Stage one chunk at its first byte — its own key; nothing already staged is read or rewritten. */
|
|
120
|
+
export async function stageChunk(id, offset, bytes, root) {
|
|
121
|
+
if (bytes.length > 0)
|
|
122
|
+
await getActiveBlobStore().put(chunkKey(id, offset, root), bytes);
|
|
123
|
+
}
|
|
124
|
+
/** Join an upload's chunks, once, into the video's bytes. */
|
|
125
|
+
export async function joinStaged(id, root) {
|
|
126
|
+
const parts = await chunks(id, root);
|
|
127
|
+
const total = parts.reduce((n, c) => n + c.size, 0);
|
|
128
|
+
const out = new Uint8Array(total);
|
|
129
|
+
let at = 0;
|
|
130
|
+
for (const c of parts) {
|
|
131
|
+
const bytes = await getActiveBlobStore().get(c.key);
|
|
132
|
+
if (bytes === null)
|
|
133
|
+
continue;
|
|
134
|
+
out.set(bytes, at);
|
|
135
|
+
at += bytes.length;
|
|
136
|
+
}
|
|
137
|
+
return out.subarray(0, at);
|
|
138
|
+
}
|
|
139
|
+
/** Drop a finalized upload's chunks (the bytes now live content-addressed); the record stays, so a
|
|
140
|
+
* final PUT whose answer was lost can be asked again and status/fetch still finds the publish. */
|
|
141
|
+
export async function clearStaged(id, root) {
|
|
142
|
+
try {
|
|
143
|
+
await getActiveBlobStore().remove(`${chunkDir(id, root)}/`);
|
|
144
|
+
}
|
|
145
|
+
catch { /* never staged */ }
|
|
146
|
+
}
|
|
147
|
+
function performKey(actionId, root) {
|
|
148
|
+
return join(resources(root), 'perform-publishes', `${blobDigest(new TextEncoder().encode(actionId))}.json`);
|
|
149
|
+
}
|
|
150
|
+
export async function readPerformPublish(actionId, root) {
|
|
151
|
+
return readJson(performKey(actionId, root));
|
|
152
|
+
}
|
|
153
|
+
export async function writePerformPublish(actionId, record, root) {
|
|
154
|
+
await getActiveBlobStore().put(performKey(actionId, root), new TextEncoder().encode(JSON.stringify(record)));
|
|
155
|
+
}
|
|
156
|
+
export async function clearPerformPublish(actionId, root) {
|
|
157
|
+
try {
|
|
158
|
+
await getActiveBlobStore().remove(performKey(actionId, root));
|
|
159
|
+
}
|
|
160
|
+
catch { /* none */ }
|
|
161
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
|
|
2
|
+
/** Rolling window, in ms — TikTok's own one-minute sliding window. */
|
|
3
|
+
export declare const TIKTOK_BUDGET_WINDOW_MS = 60000;
|
|
4
|
+
/** Weighted units allowed inside one window — TikTok's own published per-endpoint figure, applied
|
|
5
|
+
* across ALL endpoints at once (strictly tighter than the vendor's own per-endpoint allowance). */
|
|
6
|
+
export declare const TIKTOK_BUDGET_CEILING = 600;
|
|
7
|
+
/** The window IS the kernel's burst sub-window, so this is also the per-minute burst bound. */
|
|
8
|
+
export declare const TIKTOK_BUDGET_BURST_CEILING = 600;
|
|
9
|
+
/** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
|
|
10
|
+
export declare const TIKTOK_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
11
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs judged. */
|
|
12
|
+
export declare const TIKTOK_CALL_WEIGHTS: {
|
|
13
|
+
/** The three Display API reads — 600/min is the PUBLISHED limit; weight 1 transcribes it. */
|
|
14
|
+
readonly displayRead: 1;
|
|
15
|
+
/** `POST /v2/oauth/revoke/` — disconnects an app from a REAL human's TikTok account. Priced as a
|
|
16
|
+
* human blast radius, not a throttle (a judgement call, stated as one). */
|
|
17
|
+
readonly revoke: 60;
|
|
18
|
+
/** Everything else (the token endpoint): no published scalar limit -> priced conservatively. */
|
|
19
|
+
readonly other: 5;
|
|
20
|
+
/** `POST /v2/post/publish/creator_info/query/` — "limited to 20 requests per minute". */
|
|
21
|
+
readonly creatorInfo: 30;
|
|
22
|
+
/** `POST /v2/post/publish/video/init/` and `/inbox/video/init/` — "limited to 6 requests per minute". */
|
|
23
|
+
readonly postInit: 100;
|
|
24
|
+
/** `POST /v2/post/publish/status/fetch/` — "limited to 30 requests per minute". */
|
|
25
|
+
readonly postStatus: 20;
|
|
26
|
+
};
|
|
27
|
+
/** THE PACK'S DECLARATION — pure data, the only TikTok-specific thing in the whole budget. */
|
|
28
|
+
export declare const TIKTOK_RATE_BUDGET: RateBudgetDeclaration;
|
|
29
|
+
/**
|
|
30
|
+
* Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
|
|
31
|
+
* (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
|
|
32
|
+
* evaded by input variation — and on THIS vendor the trailing slash is not hypothetical: every
|
|
33
|
+
* documented TikTok path carries one (`/v2/oauth/revoke/`), so a rule anchored on the bare form
|
|
34
|
+
* would price the vendor's OWN spelling of the destructive call at the default weight.
|
|
35
|
+
*/
|
|
36
|
+
export declare function tiktokCallWeight(method: string, path: string): number;
|
|
37
|
+
/** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to
|
|
38
|
+
* opt into world-scoped accounting. */
|
|
39
|
+
export declare function tiktokBudgetPath(opts?: {
|
|
40
|
+
root?: string;
|
|
41
|
+
token?: string;
|
|
42
|
+
} | string): string;
|
|
43
|
+
/** Construction options. The vendor is fixed; everything else may only TIGHTEN. */
|
|
44
|
+
export type TikTokBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
45
|
+
/**
|
|
46
|
+
* This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not
|
|
47
|
+
* an alias, so `budget instanceof TikTokBudget` means "a budget that accounts against TIKTOK's
|
|
48
|
+
* ledger under TIKTOK's ceiling".
|
|
49
|
+
*/
|
|
50
|
+
export declare class TikTokBudget extends RateBudget {
|
|
51
|
+
constructor(opts?: TikTokBudgetOptions);
|
|
52
|
+
}
|
|
53
|
+
export { RateBudgetError as TikTokBudgetError } from '@volter/world-core';
|
|
54
|
+
export type { RateBudgetErrorKind as TikTokBudgetErrorKind } from '@volter/world-core';
|
|
55
|
+
export type TikTokBudgetReservation = RateBudgetReservation;
|
|
56
|
+
export type TikTokBudgetSnapshot = RateBudgetSnapshot;
|
|
@@ -0,0 +1,136 @@
|
|
|
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 { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
|
|
29
|
+
const VENDOR = 'tiktok';
|
|
30
|
+
/** Rolling window, in ms — TikTok's own one-minute sliding window. */
|
|
31
|
+
export const TIKTOK_BUDGET_WINDOW_MS = 60_000;
|
|
32
|
+
/** Weighted units allowed inside one window — TikTok's own published per-endpoint figure, applied
|
|
33
|
+
* across ALL endpoints at once (strictly tighter than the vendor's own per-endpoint allowance). */
|
|
34
|
+
export const TIKTOK_BUDGET_CEILING = 600;
|
|
35
|
+
/** The window IS the kernel's burst sub-window, so this is also the per-minute burst bound. */
|
|
36
|
+
export const TIKTOK_BUDGET_BURST_CEILING = TIKTOK_BUDGET_CEILING;
|
|
37
|
+
/** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
|
|
38
|
+
export const TIKTOK_BUDGET_MAX_RETRY_AFTER_S = 300;
|
|
39
|
+
/** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs judged. */
|
|
40
|
+
export const TIKTOK_CALL_WEIGHTS = {
|
|
41
|
+
/** The three Display API reads — 600/min is the PUBLISHED limit; weight 1 transcribes it. */
|
|
42
|
+
displayRead: 1,
|
|
43
|
+
/** `POST /v2/oauth/revoke/` — disconnects an app from a REAL human's TikTok account. Priced as a
|
|
44
|
+
* human blast radius, not a throttle (a judgement call, stated as one). */
|
|
45
|
+
revoke: 60,
|
|
46
|
+
/** Everything else (the token endpoint): no published scalar limit -> priced conservatively. */
|
|
47
|
+
other: 5,
|
|
48
|
+
// The Content Posting calls publish their OWN per-token minute allowance on each reference page
|
|
49
|
+
// (fetched 2026-09-27), and each is priced so that the endpoint alone can never exceed it inside
|
|
50
|
+
// this 600-unit minute: weight = 600 / the published per-minute figure.
|
|
51
|
+
/** `POST /v2/post/publish/creator_info/query/` — "limited to 20 requests per minute". */
|
|
52
|
+
creatorInfo: 30,
|
|
53
|
+
/** `POST /v2/post/publish/video/init/` and `/inbox/video/init/` — "limited to 6 requests per minute". */
|
|
54
|
+
postInit: 100,
|
|
55
|
+
/** `POST /v2/post/publish/status/fetch/` — "limited to 30 requests per minute". */
|
|
56
|
+
postStatus: 20,
|
|
57
|
+
};
|
|
58
|
+
/** THE PACK'S DECLARATION — pure data, the only TikTok-specific thing in the whole budget. */
|
|
59
|
+
export const TIKTOK_RATE_BUDGET = {
|
|
60
|
+
windowMs: TIKTOK_BUDGET_WINDOW_MS,
|
|
61
|
+
ceiling: TIKTOK_BUDGET_CEILING,
|
|
62
|
+
defaultWeight: TIKTOK_CALL_WEIGHTS.other,
|
|
63
|
+
maxRetryAfterSeconds: TIKTOK_BUDGET_MAX_RETRY_AFTER_S,
|
|
64
|
+
rules: [
|
|
65
|
+
{ match: '^GET /v2/user/info$', weight: TIKTOK_CALL_WEIGHTS.displayRead },
|
|
66
|
+
{ match: '^POST /v2/video/list$', weight: TIKTOK_CALL_WEIGHTS.displayRead },
|
|
67
|
+
{ match: '^POST /v2/video/query$', weight: TIKTOK_CALL_WEIGHTS.displayRead },
|
|
68
|
+
{ match: '^POST /v2/oauth/revoke$', weight: TIKTOK_CALL_WEIGHTS.revoke },
|
|
69
|
+
{ match: '^POST /v2/post/publish/creator_info/query$', weight: TIKTOK_CALL_WEIGHTS.creatorInfo },
|
|
70
|
+
{ match: '^POST /v2/post/publish/video/init$', weight: TIKTOK_CALL_WEIGHTS.postInit },
|
|
71
|
+
{ match: '^POST /v2/post/publish/inbox/video/init$', weight: TIKTOK_CALL_WEIGHTS.postInit },
|
|
72
|
+
{ match: '^POST /v2/post/publish/status/fetch$', weight: TIKTOK_CALL_WEIGHTS.postStatus },
|
|
73
|
+
],
|
|
74
|
+
reason: 'TikTok publishes per-endpoint rate limits (https://developers.tiktok.com/doc/tiktok-api-v2-rate-limit, '
|
|
75
|
+
+ 'fetched 2026-09-13): "Request rate calculation is based on a one minute sliding window", the '
|
|
76
|
+
+ 'table lists /v2/user/info/, /v2/video/list/ and /v2/video/query/ at 600 each, and exceeding a '
|
|
77
|
+
+ 'limit answers "HTTP status 429 and error code rate_limit_exceeded". This declaration '
|
|
78
|
+
+ "transcribes that scheme: the window is the vendor's own one minute, the ceiling is the "
|
|
79
|
+
+ "vendor's own 600, and each Display API read costs 1. It is tighter than the vendor in one "
|
|
80
|
+
+ 'respect, deliberately: the published 600 is PER ENDPOINT while this is ONE ledger shared '
|
|
81
|
+
+ 'across all three, so the twin spends at a third of the vendor allowance in the worst case. '
|
|
82
|
+
+ 'The OAuth token and revoke endpoints publish NO scalar limit on that page; they are priced '
|
|
83
|
+
+ 'ABOVE the documented read (defaultWeight 5) so the undocumented surface is the conservative '
|
|
84
|
+
+ 'one. POST /v2/oauth/revoke/ costs 60 because it is the one destructive call here — it '
|
|
85
|
+
+ "disconnects an app from a real human's TikTok account — which is a human blast radius, not a "
|
|
86
|
+
+ 'throttle; that price is a judgement call, stated as one. The Content Posting references each '
|
|
87
|
+
+ 'publish a per-token minute allowance (fetched 2026-09-27): creator_info 20, the Direct Post and '
|
|
88
|
+
+ 'inbox inits 6, status/fetch 30; each is priced 600 divided by its figure (30, 100, 20), so no '
|
|
89
|
+
+ 'one of them can exceed its own allowance inside this minute. The chunk PUTs to the upload URL '
|
|
90
|
+
+ 'are not charged: they go to the upload host on a presigned URL, and once a publish has started '
|
|
91
|
+
+ 'the budget cannot stop it. The vendor publishes no rate-limit response headers, so the 429 / '
|
|
92
|
+
+ 'Retry-After cooldown is the only backstop and is armed from whatever the vendor does send.',
|
|
93
|
+
};
|
|
94
|
+
// Declared at module load, so merely importing this module (which `tiktok-connector.ts` does) is
|
|
95
|
+
// enough to arm the real ceiling.
|
|
96
|
+
declareRateBudget(VENDOR, TIKTOK_RATE_BUDGET);
|
|
97
|
+
/**
|
|
98
|
+
* Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
|
|
99
|
+
* (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
|
|
100
|
+
* evaded by input variation — and on THIS vendor the trailing slash is not hypothetical: every
|
|
101
|
+
* documented TikTok path carries one (`/v2/oauth/revoke/`), so a rule anchored on the bare form
|
|
102
|
+
* would price the vendor's OWN spelling of the destructive call at the default weight.
|
|
103
|
+
*/
|
|
104
|
+
export function tiktokCallWeight(method, path) {
|
|
105
|
+
const { bare, query } = splitQuery(path);
|
|
106
|
+
return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
|
|
107
|
+
}
|
|
108
|
+
function splitQuery(path) {
|
|
109
|
+
const at = path.indexOf('?');
|
|
110
|
+
const query = {};
|
|
111
|
+
if (at !== -1)
|
|
112
|
+
for (const [k, v] of new URLSearchParams(path.slice(at + 1)))
|
|
113
|
+
query[k] = v;
|
|
114
|
+
const raw = at === -1 ? path : path.slice(0, at);
|
|
115
|
+
const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
|
|
116
|
+
return { bare, query };
|
|
117
|
+
}
|
|
118
|
+
/** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to
|
|
119
|
+
* opt into world-scoped accounting. */
|
|
120
|
+
export function tiktokBudgetPath(opts = {}) {
|
|
121
|
+
const o = typeof opts === 'string' ? { root: opts } : opts;
|
|
122
|
+
// VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through must not
|
|
123
|
+
// redirect this pack's ledger.
|
|
124
|
+
return rateBudgetPath({ ...o, vendor: VENDOR });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not
|
|
128
|
+
* an alias, so `budget instanceof TikTokBudget` means "a budget that accounts against TIKTOK's
|
|
129
|
+
* ledger under TIKTOK's ceiling".
|
|
130
|
+
*/
|
|
131
|
+
export class TikTokBudget extends RateBudget {
|
|
132
|
+
constructor(opts = {}) {
|
|
133
|
+
super({ ...opts, vendor: VENDOR });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export { RateBudgetError as TikTokBudgetError } from '@volter/world-core';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
|
|
2
|
+
export declare const TIKTOK_CAPABILITIES: CapabilitySpec[];
|
|
3
|
+
/** Committed area census (TWIN-87/F1) — enumerated TOP-DOWN from the vendor's own product nav
|
|
4
|
+
* (Login Kit, Display API, Content Posting, Research, Data Portability, Local Services, Business)
|
|
5
|
+
* plus this pack's structural areas, not derived from the manifest (which would be a tautology). */
|
|
6
|
+
export declare const TIKTOK_AREAS: readonly ["authorize", "business", "client_credentials", "conformance", "connector", "consent", "content_posting", "errors", "local_services", "login_kit", "mini_apps", "oembed", "portability", "rate", "readonly", "refresh", "research", "revoke", "scopes", "share_kit", "token", "ui", "user_info", "video", "webhooks"];
|
|
7
|
+
export declare function tiktokCapabilities(): Promise<CapabilityReport>;
|