@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,600 @@
|
|
|
1
|
+
// TikTok CONNECTOR — the live-vendor pull path.
|
|
2
|
+
//
|
|
3
|
+
// PULL (real -> twin): the twin's persona registry is only as useful as the identities in it, and
|
|
4
|
+
// Login Kit exposes exactly two authenticated reads a user token can perform: `GET /v2/user/info/`
|
|
5
|
+
// and the Display API's video reads. A pull observes the real account behind the operator's own
|
|
6
|
+
// user access token and, on request, that account's public videos, and folds them into the
|
|
7
|
+
// observed log.
|
|
8
|
+
//
|
|
9
|
+
// SCOPES DECIDE WHAT A PULL CAN SEE, and that is a fact about this vendor rather than a bug to
|
|
10
|
+
// paper over:
|
|
11
|
+
// • `/v2/user/info/` fields are gated per scope (basic / profile / stats), so a token that holds
|
|
12
|
+
// only `user.info.basic` cannot observe `username` or `follower_count`. The default pull asks
|
|
13
|
+
// for EVERY modelled field — a default-field pull would silently observe a bald persona — and
|
|
14
|
+
// a token with fewer scopes must NARROW `userFields` explicitly. A `scope_not_authorized`
|
|
15
|
+
// refusal THROWS; it is never read as "the account has no username".
|
|
16
|
+
// • the video reads need `video.list`, which the common Login Kit integration (Dub's included)
|
|
17
|
+
// does not request. So `videos` is OPT-IN: a refused read must never fold an empty video list
|
|
18
|
+
// over observed state, and silently skipping it would make "no videos" ambiguous.
|
|
19
|
+
//
|
|
20
|
+
// WHAT A PULL CANNOT OBSERVE, recorded as reasoned gaps rather than faked:
|
|
21
|
+
// • the DEVELOPER APP registry — TikTok apps are created in the developer portal; no API reads
|
|
22
|
+
// them (`tiktok.connector.pull_clients`, todo);
|
|
23
|
+
// • the GRANT (which scopes this token holds) — TikTok publishes no token-introspection
|
|
24
|
+
// endpoint, so the granted set cannot be enumerated; a successful user/info read evidences the
|
|
25
|
+
// scopes of the fields it returned and nothing more (`tiktok.connector.pull_grants`, todo).
|
|
26
|
+
//
|
|
27
|
+
// PUSH of the identity surface is an honest gap, not an omission: TikTok exposes NO write API for apps,
|
|
28
|
+
// users or grants — apps live in the developer portal, and a user's app permissions are managed in
|
|
29
|
+
// TikTok's own settings. `pushPendingTikTokActions` reports that; filed as `tiktok.connector.push`
|
|
30
|
+
// (todo). A World's POSTS are the exception, and they cross through the kernel's perform: the
|
|
31
|
+
// Content Posting API is TikTok's own write door for a new video (PERFORM, below).
|
|
32
|
+
//
|
|
33
|
+
// The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO
|
|
34
|
+
// TikTok credential and import NO network client. Offline/tests pass a fake executor; live runs
|
|
35
|
+
// pass `liveTikTokExecute(accessToken)`. Same code path either way.
|
|
36
|
+
import { assertBudgetGuardIntact, deployableEntries, observeResources } from '@volter/world-core';
|
|
37
|
+
import { TikTokBudget, TikTokBudgetError, tiktokBudgetPath, tiktokCallWeight } from "./tiktok-budget.js";
|
|
38
|
+
import { clearPerformPublish, readPerformPublish, readTikTokBlobRange, tikTokBlobSize, writePerformPublish } from "./tiktok-blobs.js";
|
|
39
|
+
import { USER_FIELDS, VIDEO_FIELDS } from "./tiktok-user.js";
|
|
40
|
+
const SERVICE = 'tiktok';
|
|
41
|
+
/** TikTok's real API host — the live executor's routing table. ONLY mapped paths may be called
|
|
42
|
+
* live. The token/revoke paths are mapped so a live rehearsal can refresh or revoke the
|
|
43
|
+
* operator's OWN token through the guarded path; nothing else on open.tiktokapis.com is
|
|
44
|
+
* reachable, which is what keeps an unmodelled surface from being poked by accident (D8). */
|
|
45
|
+
const HOSTS = {
|
|
46
|
+
'/v2/user/info': 'https://open.tiktokapis.com',
|
|
47
|
+
'/v2/video/list': 'https://open.tiktokapis.com',
|
|
48
|
+
'/v2/video/query': 'https://open.tiktokapis.com',
|
|
49
|
+
'/v2/oauth/token': 'https://open.tiktokapis.com',
|
|
50
|
+
'/v2/oauth/revoke': 'https://open.tiktokapis.com',
|
|
51
|
+
};
|
|
52
|
+
/** Every modelled user field a pull requests by default — narrow it for a narrower token. */
|
|
53
|
+
export const PULL_USER_FIELDS = USER_FIELDS;
|
|
54
|
+
/** Every modelled video field a pull requests when `videos` is enabled. */
|
|
55
|
+
export const PULL_VIDEO_FIELDS = VIDEO_FIELDS;
|
|
56
|
+
/**
|
|
57
|
+
* A live executor against the real TikTok open API, holding the operator's OWN user access token.
|
|
58
|
+
*
|
|
59
|
+
* THIS IS THE ONE PLACE this pack issues a live TikTok request, and therefore the one place the
|
|
60
|
+
* rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request
|
|
61
|
+
* goes out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says
|
|
62
|
+
* stop) and the response is fed back (`recordCall`) so a 429 / `Retry-After` becomes a PERSISTED
|
|
63
|
+
* cooldown that makes every later call fail fast WITHOUT touching TikTok. There is deliberately no
|
|
64
|
+
* option to disable the guard and no value of `budget` that yields an unguarded client
|
|
65
|
+
* (`assertBudgetGuardIntact`).
|
|
66
|
+
*/
|
|
67
|
+
export function liveTikTokExecute(accessToken, opts = {}) {
|
|
68
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
69
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
70
|
+
? assertBudgetGuardIntact(opts.budget, TikTokBudget, 'liveTikTokExecute')
|
|
71
|
+
: new TikTokBudget({ ...(opts.budgetOptions ?? {}), token: accessToken });
|
|
72
|
+
const explicitLedger = opts.budgetOptions?.path !== undefined || opts.budgetOptions?.root !== undefined;
|
|
73
|
+
if (opts.budget && !explicitLedger && budget.path !== tiktokBudgetPath({ token: accessToken })) {
|
|
74
|
+
throw new Error('liveTikTokExecute: injected budget is not keyed to the credential this client will send');
|
|
75
|
+
}
|
|
76
|
+
return async (method, path, init) => {
|
|
77
|
+
// TikTok writes every documented path WITH a trailing slash; the routing table and the price
|
|
78
|
+
// rules are keyed on the bare form, so both spellings resolve to one entry.
|
|
79
|
+
const bare = (path.split('?')[0] ?? path).replace(/\/+$/, '');
|
|
80
|
+
const host = HOSTS[bare];
|
|
81
|
+
if (!host)
|
|
82
|
+
throw new Error(`liveTikTokExecute: refusing to call an unmapped TikTok path: ${bare}`);
|
|
83
|
+
const weight = tiktokCallWeight(method, path);
|
|
84
|
+
if (Object.keys(init?.headers ?? {}).some((name) => name.toLowerCase() === 'authorization')) {
|
|
85
|
+
throw new Error('liveTikTokExecute: refusing an injected Authorization header; the guarded credential is fixed at construction');
|
|
86
|
+
}
|
|
87
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
88
|
+
const reservation = budget.checkBudget(weight);
|
|
89
|
+
const res = await doFetch(`${host}${path}`, {
|
|
90
|
+
method,
|
|
91
|
+
headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${accessToken}` },
|
|
92
|
+
...(init?.body !== undefined ? { body: init.body } : {}),
|
|
93
|
+
});
|
|
94
|
+
const resHeaders = {};
|
|
95
|
+
res.headers.forEach((v, k) => {
|
|
96
|
+
resHeaders[k.toLowerCase()] = v;
|
|
97
|
+
});
|
|
98
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. The cooldown is
|
|
99
|
+
// persisted before body parsing or any throw, so even an HTML/plain-text 429 survives it.
|
|
100
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
101
|
+
// call that louder refusal wins; an answer TikTok ACCEPTED is kept, so a write that landed is
|
|
102
|
+
// never recorded as failed and performed again on retry.
|
|
103
|
+
try {
|
|
104
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!(error instanceof TikTokBudgetError) || !res.ok)
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
const raw = await res.text();
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = raw === '' ? {} : JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error(`tiktok returned non-JSON for ${method} ${bare}: HTTP ${res.status}`);
|
|
117
|
+
}
|
|
118
|
+
// A REFUSED pull is NOT an empty account. TikTok answers a Display API failure with a NESTED
|
|
119
|
+
// error object whose `code` is something other than `ok` — and it carries that object on
|
|
120
|
+
// SUCCESS too, so a status check alone cannot tell refusal from genuine emptiness. Both
|
|
121
|
+
// shapes throw.
|
|
122
|
+
if (res.status >= 400)
|
|
123
|
+
throw new Error(`tiktok refused ${method} ${bare}: HTTP ${res.status} ${JSON.stringify(parsed)}`);
|
|
124
|
+
const code = parsed?.['error']?.['code'];
|
|
125
|
+
if (typeof code === 'string' && code !== 'ok') {
|
|
126
|
+
throw new Error(`tiktok refused ${method} ${bare}: ${JSON.stringify(parsed['error'])}`);
|
|
127
|
+
}
|
|
128
|
+
// The OAuth endpoints use the FLAT envelope instead — a string `error` field is a refusal.
|
|
129
|
+
if (typeof parsed?.['error'] === 'string') {
|
|
130
|
+
throw new Error(`tiktok refused ${method} ${bare}: ${JSON.stringify(parsed)}`);
|
|
131
|
+
}
|
|
132
|
+
return parsed;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Map a real `GET /v2/user/info/` response -> the account (persona) SyncResource.
|
|
137
|
+
*
|
|
138
|
+
* NOTHING IS INVENTED: a field the response omits records NOTHING rather than a placeholder —
|
|
139
|
+
* including username and display_name, because a scope-narrowed token legitimately omits them and
|
|
140
|
+
* a `?? null` there would fold null over a previously pulled persona's real values.
|
|
141
|
+
*
|
|
142
|
+
* The subject id is `union_id`, the vendor's own cross-app user key — NOT `open_id`, which is
|
|
143
|
+
* per-app and would give one human as many twin accounts as they have authorized apps.
|
|
144
|
+
*/
|
|
145
|
+
export function mapUserInfoAccount(body) {
|
|
146
|
+
const user = (body?.data?.user ?? {});
|
|
147
|
+
const str = (key, field) => (typeof user[key] === 'string' ? { [field]: user[key] } : {});
|
|
148
|
+
const num = (key, field) => (typeof user[key] === 'number' ? { [field]: user[key] } : {});
|
|
149
|
+
return {
|
|
150
|
+
type: 'account',
|
|
151
|
+
id: String(user['union_id'] ?? ''),
|
|
152
|
+
fields: {
|
|
153
|
+
...str('username', 'username'),
|
|
154
|
+
...str('display_name', 'displayName'),
|
|
155
|
+
...str('avatar_url', 'avatarUrl'),
|
|
156
|
+
...str('avatar_url_100', 'avatarUrl100'),
|
|
157
|
+
...str('avatar_large_url', 'avatarLargeUrl'),
|
|
158
|
+
...str('bio_description', 'bioDescription'),
|
|
159
|
+
...str('profile_deep_link', 'profileDeepLink'),
|
|
160
|
+
...(typeof user['is_verified'] === 'boolean' ? { isVerified: user['is_verified'] } : {}),
|
|
161
|
+
...num('follower_count', 'followerCount'),
|
|
162
|
+
...num('following_count', 'followingCount'),
|
|
163
|
+
...num('likes_count', 'likesCount'),
|
|
164
|
+
...num('video_count', 'videoCount'),
|
|
165
|
+
pulled: true,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Map one real Video object -> the video SyncResource, owned by the pulled account. */
|
|
170
|
+
export function mapVideo(video, ownerUnionId) {
|
|
171
|
+
const num = (key, field) => (typeof video[key] === 'number' ? { [field]: video[key] } : {});
|
|
172
|
+
return {
|
|
173
|
+
type: 'video',
|
|
174
|
+
id: String(video['id'] ?? ''),
|
|
175
|
+
fields: {
|
|
176
|
+
ownerUnionId,
|
|
177
|
+
...(typeof video['title'] === 'string' ? { title: video['title'] } : {}),
|
|
178
|
+
...(typeof video['video_description'] === 'string' ? { videoDescription: video['video_description'] } : {}),
|
|
179
|
+
...num('create_time', 'createTime'),
|
|
180
|
+
...num('duration', 'duration'),
|
|
181
|
+
...num('height', 'height'),
|
|
182
|
+
...num('width', 'width'),
|
|
183
|
+
...num('like_count', 'likeCount'),
|
|
184
|
+
...num('comment_count', 'commentCount'),
|
|
185
|
+
...num('share_count', 'shareCount'),
|
|
186
|
+
...num('view_count', 'viewCount'),
|
|
187
|
+
...(typeof video['is_aigc'] === 'boolean' ? { isAigc: video['is_aigc'] } : {}),
|
|
188
|
+
pulled: true,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A MOVING pull timestamp, forced strictly increasing within the process — never a pinned constant
|
|
194
|
+
* (ADDING_A_TWIN.md §6: under a fixed poll time a vendor value that REVERTS across polls collides
|
|
195
|
+
* with its own earlier observation and the delta silently vanishes).
|
|
196
|
+
*/
|
|
197
|
+
let lastPollMs = 0;
|
|
198
|
+
function pollTimestamp() {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
|
|
201
|
+
return new Date(lastPollMs).toISOString();
|
|
202
|
+
}
|
|
203
|
+
async function collectTikTok(execute, opts) {
|
|
204
|
+
const fields = (opts.userFields ?? PULL_USER_FIELDS).join(',');
|
|
205
|
+
const body = await execute('GET', `/v2/user/info/?fields=${fields}`);
|
|
206
|
+
// The refusal check lives HERE, not only in the live executor: an injected executor (or a vendor
|
|
207
|
+
// 200 carrying a non-ok error object) must never fold an empty account over observed state. A
|
|
208
|
+
// reply with no `data.user.union_id` is a refusal or a malformed body, and either one THROWS.
|
|
209
|
+
const unionId = body?.['data']?.['user']?.['union_id'];
|
|
210
|
+
if (!body || typeof body !== 'object' || typeof unionId !== 'string' || unionId === '') {
|
|
211
|
+
throw new Error(`tiktok pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
|
|
212
|
+
}
|
|
213
|
+
const out = [mapUserInfoAccount(body)];
|
|
214
|
+
if (opts.videos === true) {
|
|
215
|
+
const count = opts.videoCount ?? 20;
|
|
216
|
+
const listed = await execute('POST', `/v2/video/list/?fields=${PULL_VIDEO_FIELDS.join(',')}`, { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ max_count: count }) });
|
|
217
|
+
const videos = listed?.['data']?.['videos'];
|
|
218
|
+
if (!Array.isArray(videos)) {
|
|
219
|
+
throw new Error(`tiktok video pull refused or malformed: ${JSON.stringify(listed).slice(0, 200)}`);
|
|
220
|
+
}
|
|
221
|
+
for (const video of videos) {
|
|
222
|
+
if (typeof video?.['id'] === 'string' && video['id'] !== '')
|
|
223
|
+
out.push(mapVideo(video, unionId));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
/** PULL the operator's own identity (and optionally their videos) into the observed log. */
|
|
229
|
+
export async function pullTikTok(execute, opts = {}) {
|
|
230
|
+
const resources = await collectTikTok(execute, opts);
|
|
231
|
+
const at = opts.occurredAt ?? pollTimestamp();
|
|
232
|
+
observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:${SERVICE}:${at}` });
|
|
233
|
+
return resources.length;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* D7 consumer-facing pull entry point: pull everything readable from the real TikTok surface and
|
|
237
|
+
* fold it into the twin in ONE observation, returning the standard `{ observed, deltasAppended }`.
|
|
238
|
+
* Idempotent — a re-pull of identical state appends nothing.
|
|
239
|
+
*/
|
|
240
|
+
export async function syncTikTokFromReal(execute, opts = {}) {
|
|
241
|
+
const at = opts.occurredAt ?? pollTimestamp();
|
|
242
|
+
const resources = await collectTikTok(execute, opts);
|
|
243
|
+
const result = observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:${SERVICE}:${at}` });
|
|
244
|
+
return { observed: resources.length, deltasAppended: result.appended };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* PUSH without a kernel executor — reported, never faked. TikTok has no API that creates a
|
|
248
|
+
* developer app, seeds a user, grants a scope, or edits a published video: those are developer-portal
|
|
249
|
+
* and in-app actions. A World's new posts DO have a door (the Content Posting API), and they cross
|
|
250
|
+
* through the kernel's deploy (`performTikTokAction`), which holds the sealed credential this
|
|
251
|
+
* function never does. This never confirms an action and never pretends to have pushed one; it
|
|
252
|
+
* returns the pending count so a caller can see exactly how much local state is waiting.
|
|
253
|
+
*/
|
|
254
|
+
export function pushPendingTikTokActions(root) {
|
|
255
|
+
return { pushed: 0, unpushable: deployableEntries(SERVICE, root).length };
|
|
256
|
+
}
|
|
257
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
258
|
+
/** A `TikTokExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
|
|
259
|
+
* credential over these headers (executor.ts); at the twin's own wire any credential is one. */
|
|
260
|
+
export function tiktokExecuteOver(execute) {
|
|
261
|
+
return async (method, path, init) => {
|
|
262
|
+
const res = await execute({
|
|
263
|
+
method,
|
|
264
|
+
path,
|
|
265
|
+
headers: { accept: 'application/json', authorization: 'Bearer twin', ...(init?.headers ?? {}) },
|
|
266
|
+
...(init?.body === undefined ? {} : { body: init.body }),
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(res.body || '{}');
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return {};
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The refresh adapter (protocol 2): `GET /v2/user/info/` is a real read of the real account, so the
|
|
278
|
+
* operator's own identity comes back from TikTok itself. It is also nearly the only thing this
|
|
279
|
+
* vendor lets a client read about its own identity surface — apps, grants and scopes are portal
|
|
280
|
+
* state.
|
|
281
|
+
*
|
|
282
|
+
* ── WHY THIS ONE DOES NOT THROW, AND THE PULL ABOVE STILL DOES ──────────────────────────────────
|
|
283
|
+
* Two entry points, two callers, two correct behaviours — and the difference is deliberate rather
|
|
284
|
+
* than a softened refusal:
|
|
285
|
+
*
|
|
286
|
+
* • `pullTikTok` / `syncTikTokFromReal` are the OPERATOR's pull over an executor they built. The
|
|
287
|
+
* credential is theirs and it is meant to work, so any refusal is a fault they must see: those
|
|
288
|
+
* THROW on every refusal shape, and `tiktok.connector.refused_pull_throws` holds that.
|
|
289
|
+
* • THIS adapter is driven by the kernel against whatever credential the root holds — including
|
|
290
|
+
* none. A reply that discloses no account is then not "the vendor failed", it is "this wire
|
|
291
|
+
* has nothing to tell us", and the honest answer is ZERO OBSERVATIONS. It is the googleoauth
|
|
292
|
+
* remote adapter's own shape (`collectGoogleOAuthIdentity` pushes a resource only when the
|
|
293
|
+
* reply carries one), transcribed.
|
|
294
|
+
*
|
|
295
|
+
* WHAT IS NOT WEAKENED, because this is the line the doctrine actually draws: nothing empty is ever
|
|
296
|
+
* FOLDED. `observeResources` is not called at all on this path, so no prior observation is
|
|
297
|
+
* overwritten, no subject is tombstoned, and a previously pulled persona survives byte for byte —
|
|
298
|
+
* which is the whole content of "a refused pull is not an empty account". The count comes back 0,
|
|
299
|
+
* and a caller that reads the count sees exactly that nothing was observed.
|
|
300
|
+
* `tiktok.connector.remote_refresh_folds_nothing_when_unreadable` pins both halves at once.
|
|
301
|
+
*/
|
|
302
|
+
export async function syncTikTokFromRemote(execute, opts = {}) {
|
|
303
|
+
const at = opts.occurredAt ?? new Date().toISOString();
|
|
304
|
+
const body = await tiktokExecuteOver(execute)('GET', `/v2/user/info/?fields=${PULL_USER_FIELDS.join(',')}`);
|
|
305
|
+
const unionId = body?.['data']?.['user']?.['union_id'];
|
|
306
|
+
if (typeof unionId !== 'string' || unionId === '')
|
|
307
|
+
return { observed: 0, deltasAppended: 0 };
|
|
308
|
+
const resources = [mapUserInfoAccount(body)];
|
|
309
|
+
const result = observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at, batch: `obs:${SERVICE}:${at}` });
|
|
310
|
+
return { observed: resources.length, deltasAppended: result.appended };
|
|
311
|
+
}
|
|
312
|
+
// ── PERFORM: a World's post crosses to TikTok through the Content Posting API ──────────────────
|
|
313
|
+
//
|
|
314
|
+
// A `video.publish` entry (a Direct Post the World made) crosses as the vendor's own flow, the one
|
|
315
|
+
// its Direct Post guide walks: (1) creator_info — the creator the credential names, and whether the
|
|
316
|
+
// entry's privacy_level is one of their options (a mismatch is refused here, before anything is
|
|
317
|
+
// sent); (2) video/init with FILE_UPLOAD and the chunk plan TikTok's rules require for the stored
|
|
318
|
+
// size; (3) each chunk PUT to the upload_url the vendor returned, PRESIGNED — the URL's upload_token
|
|
319
|
+
// is its authorization, so the sealed credential never goes with it, and the host is the vendor's
|
|
320
|
+
// upload host, not the API's; (4) status/fetch until PUBLISH_COMPLETE. A `video.inbox_upload` entry
|
|
321
|
+
// crosses the same way through inbox/video/init and ends at SEND_TO_USER_INBOX. The bytes are read
|
|
322
|
+
// off the blob seam by the entry's digest, a chunk at a time, across the branch's ancestors.
|
|
323
|
+
//
|
|
324
|
+
// THE BUDGET wraps the executor the perform receives (`budgetedTikTokExecute`, the X pack's shape),
|
|
325
|
+
// with ONE LEDGER PER CREDENTIAL — keyed by `ctx.credential`, the sealed credential's keyed
|
|
326
|
+
// fingerprint, so every World and branch performing with one TikTok account spends one allowance (the
|
|
327
|
+
// vendor meters the token, not the World), and by `ctx.root` only when no credential is sealed (the
|
|
328
|
+
// X / YouTube / LinkedIn keying): creator_info, the init and every status read
|
|
329
|
+
// are charged before they go and refused without calling TikTok when the ledger says stop; an answer
|
|
330
|
+
// that has arrived is never dropped. The chunk PUTs are NOT charged: once a publish has started, the
|
|
331
|
+
// budget cannot stop it — a half-sent video would be worse than a spent minute.
|
|
332
|
+
//
|
|
333
|
+
// THE BOUND. TikTok publishes no processing time and no polling interval, only status/fetch's 30 reads
|
|
334
|
+
// a minute per token; the wait reads every STATUS_POLL_MS (3 s: twenty a minute, which leaves the
|
|
335
|
+
// ledger room for the creator_info and init beside them) and stops at MAX_PUBLISH_WAIT_MS (120 s).
|
|
336
|
+
// Past that the perform fails RETRYABLY, and the publish it opened is KEPT (tiktok-blobs.ts
|
|
337
|
+
// perform-publishes): a Direct Post publishes itself once its bytes are in, so the next deploy asks
|
|
338
|
+
// that publish where it stands rather than initializing a second post, and resumes its missing chunks
|
|
339
|
+
// on the upload_url it kept. Only a publish TikTok says is gone — `invalid_publish_id`, or FAILED —
|
|
340
|
+
// is cleared, so the next perform opens a fresh one. A chunk TikTok does not take fails the perform
|
|
341
|
+
// retryably with the publish still kept. A 429 or a 5xx
|
|
342
|
+
// from status/fetch is TikTok saying "later", so the wait keeps reading inside its bound and ends
|
|
343
|
+
// RETRYABLY, never as a permanent failure.
|
|
344
|
+
/** How often the perform reads status/fetch while TikTok processes (see THE BOUND above). */
|
|
345
|
+
export const STATUS_POLL_MS = 3_000;
|
|
346
|
+
/** How long, in total, a perform waits on TikTok's processing before it fails the entry retryably. */
|
|
347
|
+
export const MAX_PUBLISH_WAIT_MS = 120_000;
|
|
348
|
+
/** The chunk the perform sends: TikTok's 5 MB minimum (read as MiB), so a 4 GB video is 800 chunks. */
|
|
349
|
+
export const PERFORM_CHUNK_BYTES = 5 * 1024 * 1024;
|
|
350
|
+
/** TikTok answered "later" (429, a 5xx) — the entry stays deployable and the next deploy tries again. */
|
|
351
|
+
export class TikTokRetryableError extends Error {
|
|
352
|
+
retryable = true;
|
|
353
|
+
constructor(message) {
|
|
354
|
+
super(message);
|
|
355
|
+
this.name = 'TikTokRetryableError';
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** TikTok has the video but has not finished processing it inside the bound: retry later. */
|
|
359
|
+
export class TikTokStillProcessingError extends TikTokRetryableError {
|
|
360
|
+
publishId;
|
|
361
|
+
constructor(publishId, waitedMs, reads) {
|
|
362
|
+
super(`video still processing at TikTok (publish ${publishId}) after ${Math.round(waitedMs / 1000)}s and ${reads} status reads — the entry stays pending; the next deploy asks this publish where it stands`);
|
|
363
|
+
this.publishId = publishId;
|
|
364
|
+
this.name = 'TikTokStillProcessingError';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The kernel executor, charged to this pack's TikTokBudget: the check RESERVES before the call and
|
|
369
|
+
* throws (TikTokBudgetError) instead of calling when the ceiling or a cooldown says stop; the answer
|
|
370
|
+
* settles the reservation and arms a cooldown on 429 / Retry-After. The vendor has ANSWERED by then:
|
|
371
|
+
* if recording throws (a back-off past the cap, persisted first), the answer still goes back and the
|
|
372
|
+
* next call meets the cooldown — dropping it would record a post TikTok accepted as failed.
|
|
373
|
+
*/
|
|
374
|
+
export function budgetedTikTokExecute(execute, budget = new TikTokBudget()) {
|
|
375
|
+
const guard = assertBudgetGuardIntact(budget, TikTokBudget, 'budgetedTikTokExecute');
|
|
376
|
+
return async (request) => {
|
|
377
|
+
const weight = tiktokCallWeight(request.method, request.path);
|
|
378
|
+
const reservation = guard.checkBudget(weight);
|
|
379
|
+
const res = await execute(request);
|
|
380
|
+
try {
|
|
381
|
+
guard.recordCall(weight, Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])), { status: res.status, reservation });
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (!(error instanceof TikTokBudgetError))
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
return res;
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
391
|
+
/** One Content Posting call: its `data`, or a throw carrying the vendor's refusal. */
|
|
392
|
+
async function postingCall(execute, label, path, body) {
|
|
393
|
+
const res = await execute({ method: 'POST', path, headers: { accept: 'application/json', 'content-type': 'application/json; charset=UTF-8' }, body: JSON.stringify(body) });
|
|
394
|
+
let parsed = {};
|
|
395
|
+
try {
|
|
396
|
+
parsed = res.body ? JSON.parse(res.body) : {};
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
throw new Error(`tiktok answered ${label} with a body that is not JSON: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
400
|
+
}
|
|
401
|
+
const code = parsed?.error?.code;
|
|
402
|
+
// TikTok saying "later" is not a refusal of the post
|
|
403
|
+
if (res.status === 429 || res.status >= 500)
|
|
404
|
+
throw new TikTokRetryableError(`tiktok answered ${label} with HTTP ${res.status} (${String(code ?? 'no code')}) — retry later`);
|
|
405
|
+
if (res.status < 200 || res.status >= 300 || (typeof code === 'string' && code !== 'ok')) {
|
|
406
|
+
throw new TikTokRefusal(label, res.status, typeof code === 'string' ? code : '', JSON.stringify(parsed?.error ?? parsed).slice(0, 300));
|
|
407
|
+
}
|
|
408
|
+
return { data: (parsed.data ?? {}), raw: res.body };
|
|
409
|
+
}
|
|
410
|
+
/** TikTok refused a call, with its own `error.code`. */
|
|
411
|
+
class TikTokRefusal extends Error {
|
|
412
|
+
status;
|
|
413
|
+
code;
|
|
414
|
+
constructor(label, status, code, detail) {
|
|
415
|
+
super(`tiktok refused ${label}: HTTP ${status} ${detail}`);
|
|
416
|
+
this.status = status;
|
|
417
|
+
this.code = code;
|
|
418
|
+
this.name = 'TikTokRefusal';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** A budget refusal is a wait while the bound has room; past it, the caller's verdict. */
|
|
422
|
+
async function withinBudget(deadline, call) {
|
|
423
|
+
for (;;) {
|
|
424
|
+
try {
|
|
425
|
+
return await call();
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
// only a window that will open again is a wait: ceiling, burst, a cooldown the vendor asked for
|
|
429
|
+
if (!(error instanceof TikTokBudgetError) || !['ceiling', 'burst', 'cooldown'].includes(error.kind))
|
|
430
|
+
throw error;
|
|
431
|
+
if (Date.now() + error.retryAfterMs > deadline)
|
|
432
|
+
throw error;
|
|
433
|
+
await sleep(error.retryAfterMs);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** The chunk plan TikTok's rules require for `size` bytes at the perform's chunk size. */
|
|
438
|
+
export function performChunkPlan(size) {
|
|
439
|
+
if (size < PERFORM_CHUNK_BYTES)
|
|
440
|
+
return { chunkSize: size, totalChunkCount: 1 };
|
|
441
|
+
return { chunkSize: PERFORM_CHUNK_BYTES, totalChunkCount: Math.floor(size / PERFORM_CHUNK_BYTES) };
|
|
442
|
+
}
|
|
443
|
+
/** status/fetch, with `publicaly_available_post_id` read off the RAW body: it is a list of int64 JSON
|
|
444
|
+
* numbers, and JSON.parse would round a 19-digit id to the nearest double. */
|
|
445
|
+
async function readStatus(execute, publishId) {
|
|
446
|
+
const { data, raw } = await postingCall(execute, 'status/fetch', '/v2/post/publish/status/fetch/', { publish_id: publishId });
|
|
447
|
+
const ids = /"publicaly_available_post_id"\s*:\s*\[\s*"?(\d+)/.exec(raw);
|
|
448
|
+
return {
|
|
449
|
+
status: String(data.status ?? ''),
|
|
450
|
+
...(typeof data.fail_reason === 'string' && data.fail_reason ? { failReason: data.fail_reason } : {}),
|
|
451
|
+
...(ids ? { postId: ids[1] } : {}),
|
|
452
|
+
uploadedBytes: Number(data.uploaded_bytes ?? 0),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async function publishOver(budgeted, raw, action, mode, root) {
|
|
456
|
+
const f = (action.fields ?? {});
|
|
457
|
+
const sha = f._content_sha256;
|
|
458
|
+
const size = await tikTokBlobSize(sha, root);
|
|
459
|
+
if (size === null)
|
|
460
|
+
throw new Error(`tiktok: ${action.operation} ${action.subject.id} has no stored media to post (_content_sha256 ${String(sha ?? 'absent')})`);
|
|
461
|
+
const contentType = typeof f._content_type === 'string' ? f._content_type : 'video/mp4';
|
|
462
|
+
const started = Date.now();
|
|
463
|
+
const deadline = started + MAX_PUBLISH_WAIT_MS;
|
|
464
|
+
const done = mode === 'direct' ? 'PUBLISH_COMPLETE' : 'SEND_TO_USER_INBOX';
|
|
465
|
+
// (0) a publish an earlier perform of this entry opened: where does it stand? It is re-opened ONLY
|
|
466
|
+
// when TikTok says it is gone — `invalid_publish_id`, or status FAILED ("an error has occurred and
|
|
467
|
+
// the entire process has failed"). Anything else is the same publish, still alive: PROCESSING_UPLOAD
|
|
468
|
+
// is "the upload is in process", and `uploaded_bytes` is "the number of bytes uploaded (1-indexed)
|
|
469
|
+
// for FILE_UPLOAD" (Get Post Status reference) — a count of what arrived, which says nothing about
|
|
470
|
+
// whether TikTok will still post it. So a partial count is where this perform resumes the SAME
|
|
471
|
+
// publish's chunks, on the upload_url it kept; a second init could put the video up twice.
|
|
472
|
+
let kept = await readPerformPublish(action.id, root);
|
|
473
|
+
if (kept && (kept.sha256 !== sha || kept.size !== size))
|
|
474
|
+
kept = null;
|
|
475
|
+
let offset = 0;
|
|
476
|
+
if (kept) {
|
|
477
|
+
let state = null;
|
|
478
|
+
try {
|
|
479
|
+
state = await withinBudget(deadline, () => readStatus(budgeted, kept.publishId));
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (!(error instanceof TikTokRefusal) || error.code !== 'invalid_publish_id')
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
if (state === null || state.status === 'FAILED') {
|
|
486
|
+
await clearPerformPublish(action.id, root);
|
|
487
|
+
kept = null;
|
|
488
|
+
}
|
|
489
|
+
else
|
|
490
|
+
offset = state.status === 'PROCESSING_UPLOAD' ? state.uploadedBytes : size;
|
|
491
|
+
}
|
|
492
|
+
// (1) + (2) the creator and the init — the only calls charged before a publish exists
|
|
493
|
+
if (!kept) {
|
|
494
|
+
if (mode === 'direct') {
|
|
495
|
+
const creator = await withinBudget(deadline, () => postingCall(budgeted, 'creator_info', '/v2/post/publish/creator_info/query/', {}));
|
|
496
|
+
const options = Array.isArray(creator.data.privacy_level_options) ? creator.data.privacy_level_options : [];
|
|
497
|
+
if (!options.includes(f.privacyLevel)) {
|
|
498
|
+
throw new Error(`tiktok: the creator @${String(creator.data.creator_username ?? '?')} cannot post as ${String(f.privacyLevel)} (their options: ${options.join(', ')})`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const plan = performChunkPlan(size);
|
|
502
|
+
const postInfo = {
|
|
503
|
+
title: String(f.videoDescription ?? f.title ?? ''),
|
|
504
|
+
privacy_level: f.privacyLevel,
|
|
505
|
+
disable_duet: f.disableDuet === true,
|
|
506
|
+
disable_comment: f.disableComment === true,
|
|
507
|
+
disable_stitch: f.disableStitch === true,
|
|
508
|
+
...(typeof f.videoCoverTimestampMs === 'number' ? { video_cover_timestamp_ms: f.videoCoverTimestampMs } : {}),
|
|
509
|
+
...(f.brandContentToggle === true ? { brand_content_toggle: true } : {}),
|
|
510
|
+
...(f.brandOrganicToggle === true ? { brand_organic_toggle: true } : {}),
|
|
511
|
+
...(f.isAigc === true ? { is_aigc: true } : {}),
|
|
512
|
+
};
|
|
513
|
+
const init = await withinBudget(deadline, () => postingCall(budgeted, mode === 'direct' ? 'video/init' : 'inbox/video/init', mode === 'direct' ? '/v2/post/publish/video/init/' : '/v2/post/publish/inbox/video/init/', {
|
|
514
|
+
...(mode === 'direct' ? { post_info: postInfo } : {}),
|
|
515
|
+
source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: plan.chunkSize, total_chunk_count: plan.totalChunkCount },
|
|
516
|
+
}));
|
|
517
|
+
const publishId = init.data.publish_id;
|
|
518
|
+
if (typeof publishId !== 'string' || typeof init.data.upload_url !== 'string')
|
|
519
|
+
throw new Error(`tiktok init answered no publish_id/upload_url: ${init.raw.slice(0, 200)}`);
|
|
520
|
+
kept = { publishId, uploadUrl: init.data.upload_url, sha256: String(sha), size, ...plan };
|
|
521
|
+
await writePerformPublish(action.id, kept, root);
|
|
522
|
+
offset = 0;
|
|
523
|
+
}
|
|
524
|
+
const publish = kept;
|
|
525
|
+
// (3) the chunks still missing, in order, presigned, unbudgeted. A PUT TikTok does not take leaves
|
|
526
|
+
// the publish kept and fails RETRYABLY: the next perform asks status/fetch where it stands.
|
|
527
|
+
for (let index = Math.floor(offset / publish.chunkSize); offset < size && index < publish.totalChunkCount; index += 1) {
|
|
528
|
+
const first = index * publish.chunkSize;
|
|
529
|
+
const last = index === publish.totalChunkCount - 1 ? size - 1 : first + publish.chunkSize - 1;
|
|
530
|
+
const chunk = (await readTikTokBlobRange(sha, first, last, root)) ?? new Uint8Array(0);
|
|
531
|
+
const answer = await raw({
|
|
532
|
+
method: 'PUT',
|
|
533
|
+
path: publish.uploadUrl,
|
|
534
|
+
presigned: true,
|
|
535
|
+
headers: { 'content-type': contentType, 'content-length': String(chunk.length), 'content-range': `bytes ${first}-${last}/${size}` },
|
|
536
|
+
body: chunk,
|
|
537
|
+
});
|
|
538
|
+
if (answer.status === 206 || answer.status === 201)
|
|
539
|
+
continue;
|
|
540
|
+
throw new TikTokRetryableError(`tiktok: chunk ${index + 1}/${publish.totalChunkCount} of publish ${publish.publishId} answered HTTP ${answer.status} — the publish is kept; the next deploy asks it where it stands: ${answer.body.slice(0, 200)}`);
|
|
541
|
+
}
|
|
542
|
+
// (4) the wait, bounded; "later" answers keep it waiting
|
|
543
|
+
let reads = 0;
|
|
544
|
+
for (;;) {
|
|
545
|
+
let state = null;
|
|
546
|
+
try {
|
|
547
|
+
state = await withinBudget(deadline, () => readStatus(budgeted, publish.publishId));
|
|
548
|
+
reads += 1;
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
if (error instanceof TikTokBudgetError)
|
|
552
|
+
throw new TikTokStillProcessingError(publish.publishId, Date.now() - started, reads);
|
|
553
|
+
if (!(error instanceof TikTokRetryableError))
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
if (state?.status === done) {
|
|
557
|
+
await clearPerformPublish(action.id, root);
|
|
558
|
+
const postId = state.postId;
|
|
559
|
+
return {
|
|
560
|
+
externalId: postId ?? publish.publishId,
|
|
561
|
+
...(postId ? { url: `https://www.tiktok.com/video/${postId}` } : {}),
|
|
562
|
+
data: { publish_id: publish.publishId, status: state.status, ...(postId ? { post_id: postId } : {}) },
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
if (state?.status === 'FAILED') {
|
|
566
|
+
await clearPerformPublish(action.id, root);
|
|
567
|
+
throw new Error(`tiktok failed publish ${publish.publishId}: ${state.failReason ?? 'no fail_reason'}`);
|
|
568
|
+
}
|
|
569
|
+
if (Date.now() + STATUS_POLL_MS > deadline)
|
|
570
|
+
throw new TikTokStillProcessingError(publish.publishId, Date.now() - started, reads);
|
|
571
|
+
await sleep(STATUS_POLL_MS);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The perform adapter (protocol 2).
|
|
576
|
+
*
|
|
577
|
+
* A World's post crosses (`video.publish`, `video.inbox_upload`: see PERFORM above). Everything else a
|
|
578
|
+
* World writes here has no upstream home, and saying so is the point: TikTok publishes NO API that
|
|
579
|
+
* creates a developer app, seeds a user, grants a scope or issues a token — those are developer-portal
|
|
580
|
+
* and in-app settings actions a person takes in a browser — and an upload whose file failed TikTok's
|
|
581
|
+
* checks here (`publish.fail`) has nothing to send.
|
|
582
|
+
*/
|
|
583
|
+
export async function performTikTokAction(execute, action, ctx, budget) {
|
|
584
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
585
|
+
if (op === 'video.publish' || op === 'video.inbox_upload') {
|
|
586
|
+
// ONE LEDGER PER CREDENTIAL (its keyed fingerprint), else per World root when none is sealed;
|
|
587
|
+
// `budget` is injectable so a verify charges a temporary ledger of its own
|
|
588
|
+
const budgeted = budgetedTikTokExecute(execute, budget ?? new TikTokBudget(ctx.credential !== undefined ? { token: ctx.credential } : ctx.root !== undefined ? { root: ctx.root } : {}));
|
|
589
|
+
return publishOver(budgeted, execute, action, op === 'video.publish' ? 'direct' : 'inbox', ctx.root);
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
externalId: action.subject.id,
|
|
593
|
+
data: {
|
|
594
|
+
performed: false,
|
|
595
|
+
reason: op === 'publish.fail'
|
|
596
|
+
? `${op}: the upload failed TikTok's checks on this twin (${String((action.fields ?? {}).failReason ?? 'unknown')}), so there is no post to send`
|
|
597
|
+
: `${op} has nowhere to go: TikTok creates developer apps, users, tokens and scope grants in the developer portal and in TikTok's own account settings, and publishes no API for any of them`,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ConsentView, type ErrorPageProps } from '../client/tiktok-consent.js';
|
|
2
|
+
export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from '../client/tiktok-consent.js';
|
|
3
|
+
/**
|
|
4
|
+
* THE STATE BUILDER — the authorization page's entire view model, folded out of the kernel
|
|
5
|
+
* projection. Returns `null` when the authorize request is unknown or already settled, or when the
|
|
6
|
+
* app or the signed-in account no longer exists (the caller renders the error page); it never
|
|
7
|
+
* invents an app name, an account or a scope row.
|
|
8
|
+
*/
|
|
9
|
+
export declare function tiktokConsentState(opts: {
|
|
10
|
+
root?: string;
|
|
11
|
+
requestId: string;
|
|
12
|
+
origin: string;
|
|
13
|
+
}): ConsentView | null;
|
|
14
|
+
/** The authorization page's stylesheet, served inline. Static text: no clock, no state, no build. */
|
|
15
|
+
export declare const CONSENT_CSS: string;
|
|
16
|
+
/** Render the authorization page from a view model. */
|
|
17
|
+
export declare function consentPageHtml(view: ConsentView): string;
|
|
18
|
+
/** Render the authorize-endpoint error page (an un-redirectable failure). */
|
|
19
|
+
export declare function errorPageHtml(props: ErrorPageProps): string;
|