@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,1361 @@
|
|
|
1
|
+
// TikTok twin REQUEST HANDLER — Login Kit + the Display API, served locally.
|
|
2
|
+
//
|
|
3
|
+
// Contract: handleTikTokTwinRequest({method, path, ...}) -> {status, body, headers}. It is the
|
|
4
|
+
// faithful surface an unmodified TikTok integration talks to:
|
|
5
|
+
//
|
|
6
|
+
// www.tiktok.com GET /v2/auth/authorize → the AUTHORIZATION page (Login Kit)
|
|
7
|
+
// open.tiktokapis.com POST /v2/oauth/token/ → authorization_code | refresh_token |
|
|
8
|
+
// client_credentials grants
|
|
9
|
+
// POST /v2/oauth/revoke/ → revoke a user access token
|
|
10
|
+
// GET /v2/user/info/ → the Display API user read
|
|
11
|
+
// POST /v2/video/list/ → the Display API video list
|
|
12
|
+
// POST /v2/video/query/ → the Display API video lookup
|
|
13
|
+
// POST /v2/post/publish/… → the Content Posting API (tiktok-posting.ts)
|
|
14
|
+
// open-upload.tiktokapis.com PUT /video/ → the chunked video upload (tiktok-posting.ts)
|
|
15
|
+
//
|
|
16
|
+
// ── WHY THIS PACK IS BROWSER-FACING ─────────────────────────────────────────────────────────────
|
|
17
|
+
// The point of the authorization-code flow is that a browser is redirected to the vendor, a person
|
|
18
|
+
// reads what the app is asking for, and decides. A twin that skipped the page and 302'd straight
|
|
19
|
+
// back would be a bypass, not a twin — every integration bug that lives in the consent leg (an
|
|
20
|
+
// unregistered redirect_uri, a dropped state, a user who cancels, a user who UNCHECKS a scope)
|
|
21
|
+
// would be invisible. So the twin SERVES HTML at the vendor's real path, rendered server-side from
|
|
22
|
+
// its own projection by the SAME React components the pack ships (tiktok-consent-ui.ts). The
|
|
23
|
+
// googleoauth pack is the class precedent; the mirror DISCIPLINE applies in full.
|
|
24
|
+
//
|
|
25
|
+
// ── WHAT THIS VENDOR DOES THAT ITS SIBLINGS DO NOT (the fidelity surface) ───────────────────────
|
|
26
|
+
// • SCOPES ARE COMMA-SEPARATED, and consent is GRANULAR: the documented callback carries a
|
|
27
|
+
// `scopes` parameter naming what the user GRANTED, which may be a subset of what was asked.
|
|
28
|
+
// • PKCE IS OPTIONAL (web) and, when used, its `code_challenge` is HEX-encoded SHA-256, not
|
|
29
|
+
// RFC 7636's base64url. See tiktok-pkce.ts.
|
|
30
|
+
// • THE CLIENT AUTHENTICATES IN THE BODY: `client_key` + `client_secret` form fields. TikTok
|
|
31
|
+
// documents no HTTP Basic alternative, so an Authorization header on the token endpoint is
|
|
32
|
+
// IGNORED — which matters, because Dub sends one.
|
|
33
|
+
// • TWO ERROR ENVELOPES, not one: flat `{error, error_description, log_id}` on the OAuth
|
|
34
|
+
// endpoints and nested `{data, error:{code, message, log_id}}` on the v2 API. See
|
|
35
|
+
// tiktok-errors.ts.
|
|
36
|
+
// • `open_id` IS PER-APP while `union_id` is per-human: the same account authorizing two apps
|
|
37
|
+
// yields two different open_ids and one union_id.
|
|
38
|
+
//
|
|
39
|
+
// ── WHAT IS NOT ─────────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// The twin authenticates NOBODY: no password, no 2FA, no risk engine. The tiktok.com "signed in"
|
|
41
|
+
// session is a seeded persona row (`POST /_twin/session` switches it) — a deliberate design, not a
|
|
42
|
+
// hidden gap.
|
|
43
|
+
//
|
|
44
|
+
// EVIDENCE BOUNDARIES are marked inline: behaviours grounded in developers.tiktok.com (all fetched
|
|
45
|
+
// 2026-09-13) say so; behaviours derived from RFC 6749/7009 or widely-reported wire captures are
|
|
46
|
+
// marked EXTRAPOLATION with a manifest todo pinning them.
|
|
47
|
+
//
|
|
48
|
+
// State lives in the kernel action log (D1). No real TikTok endpoint is ever called from this path.
|
|
49
|
+
import { applyTwinWriteAtomic, type TwinResource } from '@volter/world-core';
|
|
50
|
+
import { consentPageHtml, errorPageHtml, tiktokConsentState } from './tiktok-consent-ui.ts';
|
|
51
|
+
import {
|
|
52
|
+
apiError,
|
|
53
|
+
apiOk,
|
|
54
|
+
internalError,
|
|
55
|
+
invalidClient,
|
|
56
|
+
invalidGrant,
|
|
57
|
+
malformedRequest,
|
|
58
|
+
oauthError,
|
|
59
|
+
oauthErrorCode,
|
|
60
|
+
unknownRoute,
|
|
61
|
+
type TikTokResponse,
|
|
62
|
+
} from './tiktok-errors.ts';
|
|
63
|
+
import { formatScopeParam, parseScopeParam, KNOWN_SCOPES } from './tiktok-scopes.ts';
|
|
64
|
+
import { isSupportedChallengeMethod, pkceVerifies } from './tiktok-pkce.ts';
|
|
65
|
+
import { creatorInfo, fetchStatus, initUpload, MEDIA_PREFIX, serveVideo, UPLOAD_PATH, uploadChunk, type PostingContext } from './tiktok-posting.ts';
|
|
66
|
+
import {
|
|
67
|
+
USER_FIELDS,
|
|
68
|
+
USER_FIELD_SCOPES,
|
|
69
|
+
VIDEO_FIELDS,
|
|
70
|
+
VIDEO_SCOPE,
|
|
71
|
+
renderUser,
|
|
72
|
+
renderVideo,
|
|
73
|
+
} from './tiktok-user.ts';
|
|
74
|
+
import {
|
|
75
|
+
authRequestIdFor,
|
|
76
|
+
BK_ACCESS,
|
|
77
|
+
BK_CLIENT,
|
|
78
|
+
BK_CODE,
|
|
79
|
+
BK_RATE,
|
|
80
|
+
BK_REFRESH,
|
|
81
|
+
secretKey,
|
|
82
|
+
DEFAULT_CLIENT_KEY,
|
|
83
|
+
ensureSeed,
|
|
84
|
+
mintAccessToken,
|
|
85
|
+
mintAuthorizationCode,
|
|
86
|
+
mintClientKey,
|
|
87
|
+
mintClientToken,
|
|
88
|
+
mintRefreshToken,
|
|
89
|
+
nextRev,
|
|
90
|
+
nextRevIn,
|
|
91
|
+
openIdFor,
|
|
92
|
+
readAll,
|
|
93
|
+
readOne,
|
|
94
|
+
readType,
|
|
95
|
+
redirectUriAllowed,
|
|
96
|
+
RESOURCE_TYPES as STORE_RESOURCE_TYPES,
|
|
97
|
+
sessionAccount,
|
|
98
|
+
write,
|
|
99
|
+
writeAtomic,
|
|
100
|
+
type Row,
|
|
101
|
+
} from './tiktok-store.ts';
|
|
102
|
+
|
|
103
|
+
export { RESOURCE_TYPES } from './tiktok-store.ts';
|
|
104
|
+
export type { TikTokResponse } from './tiktok-errors.ts';
|
|
105
|
+
export { USER_FIELDS, VIDEO_FIELDS } from './tiktok-user.ts';
|
|
106
|
+
|
|
107
|
+
/** TikTok's real hosts. The authorization page lives on the web property; the API on the open
|
|
108
|
+
* platform host. Both are claimed path-scoped by the pack descriptor. */
|
|
109
|
+
export const AUTHORIZE_ORIGIN = 'https://www.tiktok.com';
|
|
110
|
+
export const API_ORIGIN = 'https://open.tiktokapis.com';
|
|
111
|
+
|
|
112
|
+
/** "The expiration of `access_token` in seconds. It is valid for 24 hours" (User Access Token
|
|
113
|
+
* Management guide) — and the guide's own example body says `"expires_in": 86400`. */
|
|
114
|
+
export const ACCESS_TOKEN_TTL_SECONDS = 86_400;
|
|
115
|
+
/** "The token to refresh `access_token`. It is valid for 365 days"; the example says 31536000. */
|
|
116
|
+
export const REFRESH_TOKEN_TTL_SECONDS = 31_536_000;
|
|
117
|
+
/** The app-only client token: "valid for 2 hours after the initial issuance" (client-credentials
|
|
118
|
+
* guide), example `"expires_in": 7200`. */
|
|
119
|
+
export const CLIENT_TOKEN_TTL_SECONDS = 7_200;
|
|
120
|
+
/** EXTRAPOLATION: TikTok publishes no authorization-code lifetime. Ten minutes is the OAuth
|
|
121
|
+
* convention (RFC 6749 §4.1.2 recommends ≤10 min); `tiktok.token.code_ttl` (todo) pins the live
|
|
122
|
+
* value. The SINGLE-USE rule, by contrast, is hard protocol and is modelled as such. */
|
|
123
|
+
export const AUTH_CODE_TTL_SECONDS = 600;
|
|
124
|
+
|
|
125
|
+
/** "Request rate calculation is based on a one minute sliding window" and each of /v2/user/info/,
|
|
126
|
+
* /v2/video/list/ and /v2/video/query/ is published at 600 (developers.tiktok.com rate-limit
|
|
127
|
+
* reference, fetched 2026-09-13). Exceeding one answers HTTP 429 + `rate_limit_exceeded`. */
|
|
128
|
+
export const RATE_LIMIT_PER_WINDOW = 600;
|
|
129
|
+
export const RATE_WINDOW_SECONDS = 60;
|
|
130
|
+
|
|
131
|
+
/** The metered endpoints, keyed the way the rate rows are. */
|
|
132
|
+
export const METERED_ENDPOINTS = ['user_info', 'video_list', 'video_query', 'creator_info', 'video_init', 'inbox_init', 'post_status'] as const;
|
|
133
|
+
export type MeteredEndpoint = (typeof METERED_ENDPOINTS)[number];
|
|
134
|
+
|
|
135
|
+
/** Each metered endpoint's allowance per user access_token per minute. The Display API reads are the
|
|
136
|
+
* rate-limit reference's 600; the Content Posting ones are each reference's own sentence (fetched
|
|
137
|
+
* 2026-09-27): creator_info "limited to 20 requests per minute", the Direct Post and inbox inits
|
|
138
|
+
* "limited to 6 requests per minute", status/fetch "limited to 30 requests per minute". */
|
|
139
|
+
export const RATE_LIMITS: Record<MeteredEndpoint, number> = {
|
|
140
|
+
user_info: RATE_LIMIT_PER_WINDOW,
|
|
141
|
+
video_list: RATE_LIMIT_PER_WINDOW,
|
|
142
|
+
video_query: RATE_LIMIT_PER_WINDOW,
|
|
143
|
+
creator_info: 20,
|
|
144
|
+
video_init: 6,
|
|
145
|
+
inbox_init: 6,
|
|
146
|
+
post_status: 30,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export type TikTokRequest = {
|
|
150
|
+
method: string;
|
|
151
|
+
/** Path plus query string, e.g. `/v2/auth/authorize?client_key=…`. */
|
|
152
|
+
path: string;
|
|
153
|
+
body?: string;
|
|
154
|
+
/** Lower-cased request headers (authorization / content-type). */
|
|
155
|
+
headers?: Record<string, string>;
|
|
156
|
+
occurredAt?: string;
|
|
157
|
+
root?: string;
|
|
158
|
+
readOnly?: boolean;
|
|
159
|
+
/** Where this twin is reached (`twinPublicBase`: origin plus any served-World mount path) — the
|
|
160
|
+
* consent form's action must point back at the twin. Absent (an in-process call), vendor-real
|
|
161
|
+
* URLs are used. */
|
|
162
|
+
origin?: string;
|
|
163
|
+
/** The bare origin the request arrived at, when it differs from `origin` (a served World mounts
|
|
164
|
+
* the twin under a path). The seeded demo app's callbacks derive from it; defaults to `origin`. */
|
|
165
|
+
callbackOrigin?: string;
|
|
166
|
+
/** The raw body of a chunk PUT to the upload URL (every other body is text). */
|
|
167
|
+
bytes?: Uint8Array;
|
|
168
|
+
/** The vendor host the request named (`x-volter-twin-original-host`), when the injector or a World
|
|
169
|
+
* door forwarded it: an upload_url minted for it is on the vendor's own upload host. */
|
|
170
|
+
originalHost?: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const HTML = { 'content-type': 'text/html; charset=utf-8' };
|
|
174
|
+
const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
|
|
175
|
+
|
|
176
|
+
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/** Split path from query and NORMALISE the trailing slash. TikTok's own documentation writes the
|
|
179
|
+
* API paths WITH one (`/v2/oauth/token/`) while Dub's authorize URL omits it
|
|
180
|
+
* (`/v2/auth/authorize`), so both spellings must reach the same route. */
|
|
181
|
+
function splitPath(raw: string): { path: string; query: URLSearchParams } {
|
|
182
|
+
const [p, q = ''] = raw.split('?');
|
|
183
|
+
const path = (p ?? '/').replace(/\/+$/, '') || '/';
|
|
184
|
+
return { path, query: new URLSearchParams(q) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The world instant this request happened at. The twin has NO clock of its own (runtime contract
|
|
189
|
+
* R9: no clock in served content) — `createTikTokTwinFetch` stamps every request with `worldNow()`,
|
|
190
|
+
* and a caller that names no instant is a DEFECT rather than a cue to read the wall clock. Every
|
|
191
|
+
* `log_id`, every `expires_in` and every minted credential is derived from this value.
|
|
192
|
+
*/
|
|
193
|
+
function instantOf(occurredAt?: string): string {
|
|
194
|
+
if (!occurredAt) {
|
|
195
|
+
throw new Error('tiktok twin: the request carries no occurredAt — this twin has no clock of its own; the server stamps worldNow()');
|
|
196
|
+
}
|
|
197
|
+
return occurredAt;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function nowMillis(occurredAt?: string): number {
|
|
201
|
+
const ms = Date.parse(instantOf(occurredAt));
|
|
202
|
+
if (Number.isNaN(ms)) throw new Error(`tiktok twin: occurredAt is not a timestamp: ${occurredAt}`);
|
|
203
|
+
return ms;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function nowSeconds(occurredAt?: string): number {
|
|
207
|
+
return Math.floor(nowMillis(occurredAt) / 1000);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The authorize endpoint's un-redirectable failure path: until the redirect_uri is proven to
|
|
212
|
+
* belong to the app there is no address the twin may bounce an error to, so unknown-client and
|
|
213
|
+
* bad-callback failures render the vendor's error page (200 HTML would hide the failure from
|
|
214
|
+
* scripts; the page is served WITH the failure's status). EXTRAPOLATION: TikTok's live page and
|
|
215
|
+
* its HTTP status were not captured — the security boundary itself is RFC 6749 §4.1.2.1's hard
|
|
216
|
+
* rule. Pinned as `tiktok.authorize.error_page_fidelity` (todo).
|
|
217
|
+
*/
|
|
218
|
+
function errorPage(e: { status: number; code: string; detail: string; requestParam?: string }): TikTokResponse {
|
|
219
|
+
return { status: e.status, body: errorPageHtml(e), headers: { ...HTML, ...NOSTORE } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A 302 back to a VALIDATED redirect_uri carrying the documented `error`/`error_description`
|
|
223
|
+
* callback parameters, plus the caller's `state` when it sent one. */
|
|
224
|
+
function redirectError(redirectUri: string, error: string, description: string, state: string | null): TikTokResponse {
|
|
225
|
+
const url = new URL(redirectUri);
|
|
226
|
+
url.searchParams.set('error', error);
|
|
227
|
+
url.searchParams.set('error_description', description);
|
|
228
|
+
if (state !== null) url.searchParams.set('state', state);
|
|
229
|
+
return { status: 302, body: '', headers: { location: url.toString(), ...NOSTORE } };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The success bounce. TikTok's documented callback table carries `code`, `scopes` and `state` —
|
|
234
|
+
* `scopes` being "A comma-separated (,) string of authorization scope(s), which the user has
|
|
235
|
+
* granted", which is what makes granular consent observable to the app before it ever calls the
|
|
236
|
+
* token endpoint. `state` is echoed only when the caller sent one.
|
|
237
|
+
*/
|
|
238
|
+
function redirectSuccess(redirectUri: string, code: string, grantedScopes: readonly string[], state: string | null): TikTokResponse {
|
|
239
|
+
const url = new URL(redirectUri);
|
|
240
|
+
url.searchParams.set('code', code);
|
|
241
|
+
url.searchParams.set('scopes', formatScopeParam(grantedScopes));
|
|
242
|
+
if (state !== null) url.searchParams.set('state', state);
|
|
243
|
+
return { status: 302, body: '', headers: { location: url.toString(), ...NOSTORE } };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Client authentication at the token/revoke endpoints.
|
|
248
|
+
*
|
|
249
|
+
* TikTok authenticates the app with the `client_key` and `client_secret` FORM FIELDS — every curl
|
|
250
|
+
* example in the User Access Token Management guide does exactly that, and the guide documents no
|
|
251
|
+
* HTTP Basic alternative. So an `Authorization` header here is IGNORED rather than honoured: Dub
|
|
252
|
+
* sends one (it shares a callback route with its Twitter provider, which needs Basic), and a twin
|
|
253
|
+
* that required or preferred it would be modelling a different vendor. `tiktok.token.basic_auth`
|
|
254
|
+
* (todo) pins whether the live endpoint tolerates or refuses the header.
|
|
255
|
+
*
|
|
256
|
+
* The missing-secret wording is the vendor's OWN, from the client-credentials guide's error
|
|
257
|
+
* example: "Client secret is missed in request." The missing-key wording mirrors its shape and is
|
|
258
|
+
* marked EXTRAPOLATION by `tiktok.token.error_wording` (todo).
|
|
259
|
+
*/
|
|
260
|
+
function authenticateClient(
|
|
261
|
+
form: URLSearchParams,
|
|
262
|
+
occurredAt: string,
|
|
263
|
+
resources: readonly TwinResource[],
|
|
264
|
+
): { client: Row } | { fail: TikTokResponse } {
|
|
265
|
+
const clientKey = form.get('client_key');
|
|
266
|
+
if (!clientKey) return { fail: oauthError('invalid_request', 'Client key is missed in request.', occurredAt) };
|
|
267
|
+
const clientSecret = form.get('client_secret');
|
|
268
|
+
if (!clientSecret) return { fail: oauthError('invalid_request', 'Client secret is missed in request.', occurredAt) };
|
|
269
|
+
const client = resources.find((row) => row.type === 'oauth_client' && row.id === clientKey) as Row | undefined;
|
|
270
|
+
if (!client || client.secretSha256 !== secretKey(clientSecret)) return { fail: invalidClient(occurredAt) };
|
|
271
|
+
return { client };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── the authorization endpoint (the consent page) ───────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const AUTHORIZE_PATH = '/v2/auth/authorize';
|
|
277
|
+
|
|
278
|
+
async function authorize(req: TikTokRequest, query: URLSearchParams): Promise<TikTokResponse> {
|
|
279
|
+
const root = req.root;
|
|
280
|
+
const at = instantOf(req.occurredAt);
|
|
281
|
+
|
|
282
|
+
// 1. client_key — without a trusted app there is no trusted redirect_uri, so failures here
|
|
283
|
+
// render the error page.
|
|
284
|
+
const clientKey = query.get('client_key');
|
|
285
|
+
if (!clientKey) return errorPage({ status: 400, code: 'invalid_request', detail: 'Missing required parameter: client_key.', requestParam: 'client_key' });
|
|
286
|
+
const client = readOne(root, 'oauth_client', clientKey);
|
|
287
|
+
if (!client) return errorPage({ status: 401, code: 'invalid_client', detail: 'The client_key does not name a known TikTok app.', requestParam: `client_key=${clientKey}` });
|
|
288
|
+
|
|
289
|
+
// 2. redirect_uri — THE SECURITY BOUNDARY. "It must match one of the redirect URIs you
|
|
290
|
+
// registered"; an unregistered one gets the page, never a bounce.
|
|
291
|
+
const redirectUri = query.get('redirect_uri');
|
|
292
|
+
if (!redirectUri) return errorPage({ status: 400, code: 'invalid_request', detail: 'Missing required parameter: redirect_uri.', requestParam: 'redirect_uri' });
|
|
293
|
+
if (!redirectUriAllowed(client, redirectUri)) {
|
|
294
|
+
return errorPage({
|
|
295
|
+
status: 400,
|
|
296
|
+
code: 'invalid_request',
|
|
297
|
+
detail: 'The redirect_uri does not match any Redirect URI registered for this app.',
|
|
298
|
+
requestParam: `redirect_uri=${redirectUri}`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 3. Everything after the boundary has a trustworthy address, so protocol errors bounce there
|
|
303
|
+
// (RFC 6749 §4.1.2.1 — EXTRAPOLATION for TikTok: the docs show no authorize error examples;
|
|
304
|
+
// the page-vs-redirect split is pinned by `tiktok.authorize.error_channel`, todo). The
|
|
305
|
+
// `error`/`error_description` pair and the codes are the vendor's own.
|
|
306
|
+
const state = query.get('state');
|
|
307
|
+
const bounce = (error: string, description: string) => redirectError(redirectUri, error, description, state);
|
|
308
|
+
|
|
309
|
+
const responseType = query.get('response_type');
|
|
310
|
+
if (!responseType) return bounce('invalid_request', 'Missing required parameter: response_type.');
|
|
311
|
+
// "This value should always be set to `code`" — there is no implicit flow to model, so an
|
|
312
|
+
// unknown value is the vendor's own unsupported_response_type, not "unmodelled".
|
|
313
|
+
if (responseType !== 'code') return bounce('unsupported_response_type', `Unsupported response_type: ${responseType}.`);
|
|
314
|
+
|
|
315
|
+
const requested = parseScopeParam(query.get('scope'));
|
|
316
|
+
if (requested.length === 0) return bounce('invalid_request', 'Missing required parameter: scope.');
|
|
317
|
+
// Every requested scope outside the vendor's catalog: there is nothing to consent to, and
|
|
318
|
+
// `invalid_scope` is the vendor's own code for "invalid, unknown, or malformed". A MIXED request
|
|
319
|
+
// still renders, with the unknown entries flagged on the page, so a typo is visible rather than
|
|
320
|
+
// fatal (`tiktok.scopes.unknown_scope_live`, todo, pins what the live endpoint does).
|
|
321
|
+
if (!requested.some((s) => KNOWN_SCOPES.includes(s))) {
|
|
322
|
+
return bounce('invalid_scope', `The requested scope is invalid, unknown, or malformed: ${formatScopeParam(requested)}.`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// PKCE is OPTIONAL on this vendor (required for desktop apps only) and its only method is S256.
|
|
326
|
+
const codeChallenge = query.get('code_challenge');
|
|
327
|
+
const rawMethod = query.get('code_challenge_method');
|
|
328
|
+
if (codeChallenge && !rawMethod) return bounce('invalid_request', 'Missing required parameter: code_challenge_method.');
|
|
329
|
+
if (!codeChallenge && rawMethod) return bounce('invalid_request', 'Missing required parameter: code_challenge.');
|
|
330
|
+
if (rawMethod && !isSupportedChallengeMethod(rawMethod)) {
|
|
331
|
+
return bounce('invalid_request', `Invalid code_challenge_method: ${rawMethod}. TikTok only supports S256.`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// `disable_auto_auth` — documented: "When set to 0, skips the authorization page for valid
|
|
335
|
+
// sessions. When set to 1, always displays the authorization page." An int, so a non-int value
|
|
336
|
+
// is malformed input the vendor's own invalid_request covers.
|
|
337
|
+
const rawAutoAuth = query.get('disable_auto_auth');
|
|
338
|
+
if (rawAutoAuth !== null && rawAutoAuth !== '0' && rawAutoAuth !== '1') {
|
|
339
|
+
return bounce('invalid_request', 'Invalid disable_auto_auth: expected 0 or 1.');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (req.readOnly) {
|
|
343
|
+
return oauthErrorCode('temporarily_unavailable', at, 405);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const account = sessionAccount(root);
|
|
347
|
+
if (!account) {
|
|
348
|
+
// No signed-in session to consent as. The REAL tiktok.com would show a login screen here; the
|
|
349
|
+
// twin authenticates nobody, so it states that plainly rather than pretending.
|
|
350
|
+
return errorPage({ status: 400, code: 'invalid_request', detail: 'No tiktok.com session is signed in on this twin. Seed an account and set /_twin/session.' });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 4. AUTO-AUTH: with `disable_auto_auth=0` and an existing grant that already covers every
|
|
354
|
+
// requested KNOWN scope, the vendor skips the page. The twin does the same — and ONLY then:
|
|
355
|
+
// the default (parameter absent) is to show the page, because bypassing the human leg is the
|
|
356
|
+
// thing this pack exists to make visible and no artefact states the live default
|
|
357
|
+
// (`tiktok.authorize.disable_auto_auth_default`, todo).
|
|
358
|
+
const grantId = `${clientKey}:${account.id}`;
|
|
359
|
+
const existing = readOne(root, 'grant', grantId);
|
|
360
|
+
const held: string[] = Array.isArray(existing?.scopes) ? existing.scopes : [];
|
|
361
|
+
const wantedKnown = requested.filter((s) => KNOWN_SCOPES.includes(s));
|
|
362
|
+
if (rawAutoAuth === '0' && existing && wantedKnown.every((s) => held.includes(s))) {
|
|
363
|
+
return settleAuthorization({
|
|
364
|
+
root,
|
|
365
|
+
at,
|
|
366
|
+
clientKey,
|
|
367
|
+
accountId: account.id,
|
|
368
|
+
redirectUri,
|
|
369
|
+
state,
|
|
370
|
+
granted: wantedKnown,
|
|
371
|
+
codeChallenge,
|
|
372
|
+
codeChallengeMethod: rawMethod,
|
|
373
|
+
requestId: null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 5. Record the request, then render the page. The auth_request row is what the Continue/Cancel
|
|
378
|
+
// post resolves — the browser never carries the parameters back, so they cannot be tampered
|
|
379
|
+
// with between the two legs.
|
|
380
|
+
const fields = {
|
|
381
|
+
clientKey,
|
|
382
|
+
redirectUri,
|
|
383
|
+
scope: formatScopeParam(requested),
|
|
384
|
+
state,
|
|
385
|
+
codeChallenge,
|
|
386
|
+
codeChallengeMethod: rawMethod,
|
|
387
|
+
accountId: account.id,
|
|
388
|
+
settled: false,
|
|
389
|
+
rev: 1,
|
|
390
|
+
};
|
|
391
|
+
const requestId = authRequestIdFor(root, at, fields);
|
|
392
|
+
await write('auth_request', requestId, 'auth_request.create', fields, { root, occurredAt: at });
|
|
393
|
+
return renderConsent(req, requestId);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderConsent(req: TikTokRequest, requestId: string): TikTokResponse {
|
|
397
|
+
const view = tiktokConsentState({
|
|
398
|
+
root: req.root,
|
|
399
|
+
requestId,
|
|
400
|
+
origin: req.origin ?? AUTHORIZE_ORIGIN,
|
|
401
|
+
});
|
|
402
|
+
if (!view) {
|
|
403
|
+
return errorPage({ status: 400, code: 'invalid_request', detail: 'Unknown or already-settled authorization request.' });
|
|
404
|
+
}
|
|
405
|
+
return { status: 200, body: consentPageHtml(view), headers: { ...HTML, ...NOSTORE } };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Mint the code + grant and bounce. Shared by the auto-auth path (no screen) and the consent post
|
|
410
|
+
* (a screen the person acted on), so the two can never disagree about what a granted authorization
|
|
411
|
+
* writes. `requestId` is the pending row to settle, or null when there is none (auto-auth).
|
|
412
|
+
*/
|
|
413
|
+
async function settleAuthorization(opts: {
|
|
414
|
+
root?: string;
|
|
415
|
+
at: string;
|
|
416
|
+
clientKey: string;
|
|
417
|
+
accountId: string;
|
|
418
|
+
redirectUri: string;
|
|
419
|
+
state: string | null;
|
|
420
|
+
granted: readonly string[];
|
|
421
|
+
codeChallenge: string | null;
|
|
422
|
+
codeChallengeMethod: string | null;
|
|
423
|
+
requestId: string | null;
|
|
424
|
+
}): Promise<TikTokResponse> {
|
|
425
|
+
const { root, at, clientKey, accountId, requestId } = opts;
|
|
426
|
+
const issuedMs = Date.parse(at);
|
|
427
|
+
const code = mintAuthorizationCode(root, issuedMs, requestId ?? `${clientKey}:${accountId}`);
|
|
428
|
+
const grantId = `${clientKey}:${accountId}`;
|
|
429
|
+
const openId = openIdFor(clientKey, accountId);
|
|
430
|
+
const decided = await applyTwinWriteAtomic(
|
|
431
|
+
'tiktok',
|
|
432
|
+
(resources) => {
|
|
433
|
+
// The settle decision is re-read INSIDE the transaction: two browsers posting the same
|
|
434
|
+
// screen must not both mint a code (the second sees `settled` and is refused).
|
|
435
|
+
if (requestId) {
|
|
436
|
+
const pending = resources.find((row) => row.type === 'auth_request' && row.id === requestId) as Row | undefined;
|
|
437
|
+
if (!pending || pending.settled === true) {
|
|
438
|
+
return { kind: 'skip', value: oauthError('invalid_request', 'Unknown, expired, or already-settled authorization request.', at) };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const grantExists = resources.some((row) => row.type === 'grant' && row.id === grantId);
|
|
442
|
+
const grantFields = {
|
|
443
|
+
clientKey,
|
|
444
|
+
sub: accountId,
|
|
445
|
+
openId,
|
|
446
|
+
scopes: [...opts.granted],
|
|
447
|
+
rev: nextRevIn(resources, 'grant', grantId),
|
|
448
|
+
};
|
|
449
|
+
const codeRow = {
|
|
450
|
+
type: BK_CODE,
|
|
451
|
+
id: secretKey(code),
|
|
452
|
+
fields: {
|
|
453
|
+
clientKey,
|
|
454
|
+
sub: accountId,
|
|
455
|
+
openId,
|
|
456
|
+
scope: formatScopeParam(opts.granted),
|
|
457
|
+
redirectUri: opts.redirectUri,
|
|
458
|
+
codeChallenge: opts.codeChallenge,
|
|
459
|
+
codeChallengeMethod: opts.codeChallengeMethod,
|
|
460
|
+
consumed: false,
|
|
461
|
+
expiresAt: Math.floor(issuedMs / 1000) + AUTH_CODE_TTL_SECONDS,
|
|
462
|
+
rev: 1,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
// The write's SUBJECT is the settled pending request when there is one; on the auto-auth
|
|
466
|
+
// path there is no screen, so the grant itself is the subject.
|
|
467
|
+
const base = requestId
|
|
468
|
+
? {
|
|
469
|
+
operation: 'auth_request.settle',
|
|
470
|
+
subjectType: 'auth_request',
|
|
471
|
+
subjectId: requestId,
|
|
472
|
+
fields: { settled: true, rev: nextRevIn(resources, 'auth_request', requestId) },
|
|
473
|
+
}
|
|
474
|
+
: { operation: 'grant.auto_authorize', subjectType: 'grant', subjectId: grantId, fields: grantFields };
|
|
475
|
+
return {
|
|
476
|
+
kind: 'write',
|
|
477
|
+
value: redirectSuccess(opts.redirectUri, code, opts.granted, opts.state),
|
|
478
|
+
write: {
|
|
479
|
+
...base,
|
|
480
|
+
projection: {
|
|
481
|
+
creates: [
|
|
482
|
+
...(requestId && !grantExists ? [{ type: 'grant', id: grantId, fields: grantFields }] : []),
|
|
483
|
+
codeRow,
|
|
484
|
+
],
|
|
485
|
+
...(requestId && grantExists ? { updates: [{ type: 'grant', id: grantId, fields: grantFields }] } : {}),
|
|
486
|
+
},
|
|
487
|
+
occurredAt: at,
|
|
488
|
+
actor: { kind: 'human' as const },
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
},
|
|
492
|
+
root,
|
|
493
|
+
);
|
|
494
|
+
return decided.value;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The Continue/Cancel post. TWIN-ONLY SURFACE, deliberately namespaced under `/_twin/` and
|
|
499
|
+
* deliberately ABSENT from the capability manifest: TikTok's authorization sheet posts to an
|
|
500
|
+
* undocumented internal endpoint, so there is no vendor path to be faithful to here (the
|
|
501
|
+
* googleoauth `/_twin/consent` precedent, sanctioned by ADDING_A_TWIN.md §6's seed-route rule).
|
|
502
|
+
*
|
|
503
|
+
* The checked `scope` fields are the GRANTED set, intersected with what was actually requested so
|
|
504
|
+
* a hand-crafted post can never widen the grant beyond the screen.
|
|
505
|
+
*/
|
|
506
|
+
async function consentDecision(req: TikTokRequest, form: URLSearchParams): Promise<TikTokResponse> {
|
|
507
|
+
const root = req.root;
|
|
508
|
+
const at = instantOf(req.occurredAt);
|
|
509
|
+
const requestId = form.get('auth_request') ?? '';
|
|
510
|
+
const authRequest = readOne(root, 'auth_request', requestId);
|
|
511
|
+
if (!authRequest || authRequest.settled === true) {
|
|
512
|
+
return oauthError('invalid_request', 'Unknown, expired, or already-settled authorization request.', at);
|
|
513
|
+
}
|
|
514
|
+
const redirectUri = String(authRequest.redirectUri);
|
|
515
|
+
const state = typeof authRequest.state === 'string' ? authRequest.state : null;
|
|
516
|
+
const requested = parseScopeParam(String(authRequest.scope ?? ''));
|
|
517
|
+
|
|
518
|
+
const settleDenial = async (description: string) => {
|
|
519
|
+
await write('auth_request', requestId, 'auth_request.settle', { settled: true, rev: nextRev(root, 'auth_request', requestId) }, { root, occurredAt: at });
|
|
520
|
+
return redirectError(redirectUri, 'access_denied', description, state);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
if (form.get('decision') !== 'allow') {
|
|
524
|
+
return settleDenial('The resource owner or authorization server denied the request.');
|
|
525
|
+
}
|
|
526
|
+
const account = readOne(root, 'account', String(authRequest.accountId ?? ''));
|
|
527
|
+
if (!account) return oauthError('invalid_request', 'The account shown for this authorization request no longer exists.', at);
|
|
528
|
+
|
|
529
|
+
// Only scopes that were REQUESTED and are in the vendor catalog can be granted.
|
|
530
|
+
const checked = form.getAll('scope').filter((s) => requested.includes(s) && KNOWN_SCOPES.includes(s));
|
|
531
|
+
if (checked.length === 0) {
|
|
532
|
+
// Unchecking everything is a refusal, not a zero-scope grant. EXTRAPOLATION on the wire
|
|
533
|
+
// (`tiktok.consent.no_scope_granted`, todo); what is NOT extrapolated is that a token with no
|
|
534
|
+
// scope could serve nothing, so minting one would be the fake success §6 forbids.
|
|
535
|
+
return settleDenial('The resource owner granted none of the requested scopes.');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return settleAuthorization({
|
|
539
|
+
root,
|
|
540
|
+
at,
|
|
541
|
+
clientKey: String(authRequest.clientKey),
|
|
542
|
+
accountId: account.id,
|
|
543
|
+
redirectUri,
|
|
544
|
+
state,
|
|
545
|
+
granted: requested.filter((s) => checked.includes(s)),
|
|
546
|
+
codeChallenge: typeof authRequest.codeChallenge === 'string' ? authRequest.codeChallenge : null,
|
|
547
|
+
codeChallengeMethod: typeof authRequest.codeChallengeMethod === 'string' ? authRequest.codeChallengeMethod : null,
|
|
548
|
+
requestId,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── the token endpoint ──────────────────────────────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
async function token(req: TikTokRequest): Promise<TikTokResponse> {
|
|
555
|
+
const at = instantOf(req.occurredAt);
|
|
556
|
+
// TikTok's token endpoint is `application/x-www-form-urlencoded` (every documented curl). The
|
|
557
|
+
// twin is deliberately strict about that rather than tolerating a JSON body it has no evidence
|
|
558
|
+
// the vendor accepts (`tiktok.token.json_body_tolerance`, todo).
|
|
559
|
+
const form = new URLSearchParams(req.body ?? '');
|
|
560
|
+
const grantType = form.get('grant_type');
|
|
561
|
+
if (!grantType) return oauthError('invalid_request', 'Grant type is missed in request.', at);
|
|
562
|
+
if (grantType === 'authorization_code') return authorizationCodeGrant(req, form, at);
|
|
563
|
+
if (grantType === 'refresh_token') return refreshTokenGrant(req, form, at);
|
|
564
|
+
if (grantType === 'client_credentials') return clientCredentialsGrant(req, form, at);
|
|
565
|
+
return oauthErrorCode('unsupported_grant_type', at);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The user-token body, field for field from the guide's own success example. */
|
|
569
|
+
function issuedTokenResponse(accessToken: string, openId: string, scope: string, refreshToken: string): TikTokResponse {
|
|
570
|
+
return {
|
|
571
|
+
status: 200,
|
|
572
|
+
body: {
|
|
573
|
+
access_token: accessToken,
|
|
574
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
575
|
+
open_id: openId,
|
|
576
|
+
refresh_expires_in: REFRESH_TOKEN_TTL_SECONDS,
|
|
577
|
+
refresh_token: refreshToken,
|
|
578
|
+
scope,
|
|
579
|
+
token_type: 'Bearer',
|
|
580
|
+
},
|
|
581
|
+
headers: { ...NOSTORE, 'content-type': 'application/json; charset=utf-8' },
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function authorizationCodeGrant(req: TikTokRequest, form: URLSearchParams, at: string): Promise<TikTokResponse> {
|
|
586
|
+
const root = req.root;
|
|
587
|
+
const code = form.get('code');
|
|
588
|
+
if (!code) return oauthError('invalid_request', 'Authorization code is missed in request.', at);
|
|
589
|
+
const redirectUri = form.get('redirect_uri');
|
|
590
|
+
if (!redirectUri) return oauthError('invalid_request', 'Redirect uri is missed in request.', at);
|
|
591
|
+
|
|
592
|
+
const issuedMs = Date.parse(at);
|
|
593
|
+
const accessToken = mintAccessToken(root, issuedMs, code);
|
|
594
|
+
const refreshToken = mintRefreshToken(root, issuedMs, code);
|
|
595
|
+
const committed = await applyTwinWriteAtomic(
|
|
596
|
+
'tiktok',
|
|
597
|
+
(resources) => {
|
|
598
|
+
const auth = authenticateClient(form, at, resources);
|
|
599
|
+
if ('fail' in auth) return { kind: 'skip', value: auth.fail };
|
|
600
|
+
const row = resources.find((r) => r.type === BK_CODE && r.id === secretKey(code)) as Row | undefined;
|
|
601
|
+
if (!row || row.clientKey !== auth.client.id || row.consumed === true
|
|
602
|
+
|| (typeof row.expiresAt === 'number' && nowSeconds(at) >= row.expiresAt)) {
|
|
603
|
+
return { kind: 'skip', value: invalidGrant(at) };
|
|
604
|
+
}
|
|
605
|
+
// The redirect_uri "must be the same as the redirect_uri used for requesting code" — the
|
|
606
|
+
// vendor's own error example for this case is `invalid_request` with "Redirect_uri is not
|
|
607
|
+
// matched with the uri when requesting code.", so it is NOT folded into invalid_grant.
|
|
608
|
+
if (redirectUri !== row.redirectUri) {
|
|
609
|
+
return { kind: 'skip', value: oauthError('invalid_request', 'Redirect_uri is not matched with the uri when requesting code.', at) };
|
|
610
|
+
}
|
|
611
|
+
// PKCE binds only when the authorize leg carried a challenge (web apps send none).
|
|
612
|
+
const challenge = typeof row.codeChallenge === 'string' ? row.codeChallenge : null;
|
|
613
|
+
if (challenge) {
|
|
614
|
+
const verifier = form.get('code_verifier');
|
|
615
|
+
if (!verifier) return { kind: 'skip', value: oauthError('invalid_request', 'Code verifier is missed in request.', at) };
|
|
616
|
+
if (!pkceVerifies(challenge, verifier)) return { kind: 'skip', value: invalidGrant(at) };
|
|
617
|
+
}
|
|
618
|
+
const scope = String(row.scope);
|
|
619
|
+
const openId = String(row.openId);
|
|
620
|
+
return {
|
|
621
|
+
kind: 'write',
|
|
622
|
+
value: issuedTokenResponse(accessToken, openId, scope, refreshToken),
|
|
623
|
+
write: {
|
|
624
|
+
operation: 'authorization_code.redeem',
|
|
625
|
+
subjectType: BK_CODE,
|
|
626
|
+
subjectId: secretKey(code),
|
|
627
|
+
fields: { consumed: true, rev: nextRevIn(resources, BK_CODE, secretKey(code)) },
|
|
628
|
+
projection: {
|
|
629
|
+
creates: [
|
|
630
|
+
{
|
|
631
|
+
type: BK_ACCESS,
|
|
632
|
+
id: secretKey(accessToken),
|
|
633
|
+
fields: { clientKey: auth.client.id, sub: String(row.sub), openId, scope, expiresAt: nowSeconds(at) + ACCESS_TOKEN_TTL_SECONDS, revoked: false, rev: 1 },
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
type: BK_REFRESH,
|
|
637
|
+
id: secretKey(refreshToken),
|
|
638
|
+
fields: { clientKey: auth.client.id, sub: String(row.sub), openId, scope, expiresAt: nowSeconds(at) + REFRESH_TOKEN_TTL_SECONDS, revoked: false, rev: 1 },
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
occurredAt: at,
|
|
643
|
+
actor: { kind: 'system' },
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
},
|
|
647
|
+
root,
|
|
648
|
+
);
|
|
649
|
+
return committed.value;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function refreshTokenGrant(req: TikTokRequest, form: URLSearchParams, at: string): Promise<TikTokResponse> {
|
|
653
|
+
const root = req.root;
|
|
654
|
+
const presented = form.get('refresh_token');
|
|
655
|
+
if (!presented) return oauthError('invalid_request', 'Refresh token is missed in request.', at);
|
|
656
|
+
const issuedMs = Date.parse(at);
|
|
657
|
+
const accessToken = mintAccessToken(root, issuedMs, presented);
|
|
658
|
+
const replacement = mintRefreshToken(root, issuedMs, presented);
|
|
659
|
+
const committed = await applyTwinWriteAtomic(
|
|
660
|
+
'tiktok',
|
|
661
|
+
(resources) => {
|
|
662
|
+
const auth = authenticateClient(form, at, resources);
|
|
663
|
+
if ('fail' in auth) return { kind: 'skip', value: auth.fail };
|
|
664
|
+
const row = resources.find((r) => r.type === BK_REFRESH && r.id === secretKey(presented)) as Row | undefined;
|
|
665
|
+
if (!row || row.revoked === true || row.clientKey !== auth.client.id
|
|
666
|
+
|| (typeof row.expiresAt === 'number' && nowSeconds(at) >= row.expiresAt)) {
|
|
667
|
+
return { kind: 'skip', value: invalidGrant(at) };
|
|
668
|
+
}
|
|
669
|
+
const scope = String(row.scope);
|
|
670
|
+
const openId = String(row.openId);
|
|
671
|
+
return {
|
|
672
|
+
kind: 'write',
|
|
673
|
+
value: issuedTokenResponse(accessToken, openId, scope, replacement),
|
|
674
|
+
write: {
|
|
675
|
+
// ROTATION. The guide says "the returned refresh_token may be different than the one
|
|
676
|
+
// passed in the payload", which makes persisting the returned value mandatory for a
|
|
677
|
+
// correct client. The twin always rotates and retires the presented token, so an
|
|
678
|
+
// integration that keeps the old one fails HERE rather than in production
|
|
679
|
+
// (`tiktok.refresh.rotation`, todo, pins whether the live vendor always rotates).
|
|
680
|
+
operation: 'refresh_token.rotate',
|
|
681
|
+
subjectType: BK_REFRESH,
|
|
682
|
+
subjectId: secretKey(presented),
|
|
683
|
+
fields: { revoked: true, rev: nextRevIn(resources, BK_REFRESH, secretKey(presented)) },
|
|
684
|
+
projection: {
|
|
685
|
+
creates: [
|
|
686
|
+
{ type: BK_ACCESS, id: secretKey(accessToken), fields: { clientKey: auth.client.id, sub: String(row.sub), openId, scope, expiresAt: nowSeconds(at) + ACCESS_TOKEN_TTL_SECONDS, revoked: false, rev: 1 } },
|
|
687
|
+
{ type: BK_REFRESH, id: secretKey(replacement), fields: { clientKey: auth.client.id, sub: String(row.sub), openId, scope, expiresAt: nowSeconds(at) + REFRESH_TOKEN_TTL_SECONDS, revoked: false, rev: 1 } },
|
|
688
|
+
],
|
|
689
|
+
},
|
|
690
|
+
occurredAt: at,
|
|
691
|
+
actor: { kind: 'system' },
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
},
|
|
695
|
+
root,
|
|
696
|
+
);
|
|
697
|
+
return committed.value;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The APP-ONLY token (client-credentials guide): no user, no open_id, no scope, no refresh token,
|
|
702
|
+
* and a 2-hour life. Its own example body is `{access_token: "clt.…", expires_in: 7200,
|
|
703
|
+
* token_type: "Bearer"}` — three fields, and the twin serves exactly three.
|
|
704
|
+
*/
|
|
705
|
+
async function clientCredentialsGrant(req: TikTokRequest, form: URLSearchParams, at: string): Promise<TikTokResponse> {
|
|
706
|
+
const root = req.root;
|
|
707
|
+
const issuedMs = Date.parse(at);
|
|
708
|
+
const clientToken = mintClientToken(root, issuedMs, form.get('client_key') ?? 'client');
|
|
709
|
+
const committed = await applyTwinWriteAtomic(
|
|
710
|
+
'tiktok',
|
|
711
|
+
(resources) => {
|
|
712
|
+
const auth = authenticateClient(form, at, resources);
|
|
713
|
+
if ('fail' in auth) return { kind: 'skip', value: auth.fail };
|
|
714
|
+
return {
|
|
715
|
+
kind: 'write',
|
|
716
|
+
value: {
|
|
717
|
+
status: 200,
|
|
718
|
+
body: { access_token: clientToken, expires_in: CLIENT_TOKEN_TTL_SECONDS, token_type: 'Bearer' },
|
|
719
|
+
headers: { ...NOSTORE, 'content-type': 'application/json; charset=utf-8' },
|
|
720
|
+
} as TikTokResponse,
|
|
721
|
+
write: {
|
|
722
|
+
operation: 'client_token.issue',
|
|
723
|
+
subjectType: BK_CLIENT,
|
|
724
|
+
subjectId: secretKey(clientToken),
|
|
725
|
+
fields: { clientKey: auth.client.id, expiresAt: nowSeconds(at) + CLIENT_TOKEN_TTL_SECONDS, revoked: false, rev: 1 },
|
|
726
|
+
occurredAt: at,
|
|
727
|
+
actor: { kind: 'system' },
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
},
|
|
731
|
+
root,
|
|
732
|
+
);
|
|
733
|
+
return committed.value;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── the revoke endpoint ─────────────────────────────────────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
async function revoke(req: TikTokRequest): Promise<TikTokResponse> {
|
|
739
|
+
const root = req.root;
|
|
740
|
+
const at = instantOf(req.occurredAt);
|
|
741
|
+
const form = new URLSearchParams(req.body ?? '');
|
|
742
|
+
const auth = authenticateClient(form, at, readAll(root));
|
|
743
|
+
if ('fail' in auth) return auth.fail;
|
|
744
|
+
const { client } = auth;
|
|
745
|
+
|
|
746
|
+
const presented = form.get('token');
|
|
747
|
+
if (!presented) return malformedRequest(at);
|
|
748
|
+
|
|
749
|
+
const access = readOne(root, BK_ACCESS, secretKey(presented));
|
|
750
|
+
const refresh = readOne(root, BK_REFRESH, secretKey(presented));
|
|
751
|
+
const match = access ?? refresh;
|
|
752
|
+
|
|
753
|
+
// An unknown token answers the documented empty success (RFC 7009 §2.2: "invalid tokens do not
|
|
754
|
+
// cause an error" — answering differently would let an app probe which tokens exist). A token
|
|
755
|
+
// belonging to ANOTHER app also answers empty, WITHOUT revoking. Which reading TikTok takes is
|
|
756
|
+
// UNPINNED — `tiktok.revoke.unknown_token` and `tiktok.revoke.cross_client` (todo).
|
|
757
|
+
if (match && match.clientKey === client.id) {
|
|
758
|
+
// Revocation is the "remove this app" action, and the GRANT is what dies: every access token,
|
|
759
|
+
// every refresh token, and the grant itself, whichever half was named. Revoking only the named
|
|
760
|
+
// token would leave a "disconnected" user's other half alive (`tiktok.revoke.pair_revocation`,
|
|
761
|
+
// todo, pins the live sweep).
|
|
762
|
+
const opts = { root, occurredAt: at };
|
|
763
|
+
const sub = String(match.sub);
|
|
764
|
+
const clientKey = String(match.clientKey);
|
|
765
|
+
const grantId = `${clientKey}:${sub}`;
|
|
766
|
+
const grant = readOne(root, 'grant', grantId);
|
|
767
|
+
if (grant) {
|
|
768
|
+
for (const row of readType(root, BK_ACCESS)) {
|
|
769
|
+
if (row.sub === sub && row.clientKey === clientKey && row.revoked !== true) {
|
|
770
|
+
await write(BK_ACCESS, row.id, 'access_token.revoke', { revoked: true, rev: nextRev(root, BK_ACCESS, row.id) }, opts);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
for (const row of readType(root, BK_REFRESH)) {
|
|
774
|
+
if (row.sub === sub && row.clientKey === clientKey && row.revoked !== true) {
|
|
775
|
+
await write(BK_REFRESH, row.id, 'refresh_token.revoke', { revoked: true, rev: nextRev(root, BK_REFRESH, row.id) }, opts);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
await write('grant', grantId, 'grant.revoke', { clientKey, sub, openId: grant.openId, scopes: [], rev: nextRev(root, 'grant', grantId) }, opts);
|
|
779
|
+
} else {
|
|
780
|
+
const type = access ? BK_ACCESS : BK_REFRESH;
|
|
781
|
+
await write(type, match.id, `${access ? 'access_token' : 'refresh_token'}.revoke`, { revoked: true, rev: nextRev(root, type, match.id) }, opts);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// "If the request is successful, the response struct will be empty."
|
|
785
|
+
return { status: 200, body: {}, headers: { ...NOSTORE, 'content-type': 'application/json; charset=utf-8' } };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── the Display API reads ───────────────────────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
type AuthorizedContext = {
|
|
791
|
+
resources: readonly TwinResource[];
|
|
792
|
+
account: Row;
|
|
793
|
+
openId: string;
|
|
794
|
+
scopes: string[];
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The shared front half of every Display API read: bearer -> live user access token -> the grant's
|
|
799
|
+
* scope set and open_id -> the account -> the one-minute rate window. The endpoint body then runs
|
|
800
|
+
* over that context and returns its own response; the rate window is spent only when the read
|
|
801
|
+
* SUCCEEDS (`tiktok.rate.what_counts`, todo, pins what the live meter counts) and never when the
|
|
802
|
+
* twin is read-only.
|
|
803
|
+
*/
|
|
804
|
+
async function authorizedRead(
|
|
805
|
+
req: TikTokRequest,
|
|
806
|
+
endpoint: MeteredEndpoint,
|
|
807
|
+
serve: (ctx: AuthorizedContext) => TikTokResponse,
|
|
808
|
+
): Promise<TikTokResponse> {
|
|
809
|
+
const root = req.root;
|
|
810
|
+
const at = instantOf(req.occurredAt);
|
|
811
|
+
const header = req.headers?.authorization ?? '';
|
|
812
|
+
const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
|
|
813
|
+
if (!bearer) return apiError('access_token_invalid', at);
|
|
814
|
+
|
|
815
|
+
const evaluate = (resources: readonly TwinResource[]) => {
|
|
816
|
+
const tokenRow = resources.find((r) => r.type === BK_ACCESS && r.id === secretKey(bearer)) as Row | undefined;
|
|
817
|
+
if (!tokenRow || tokenRow.revoked === true
|
|
818
|
+
|| (typeof tokenRow.expiresAt === 'number' && nowSeconds(at) >= tokenRow.expiresAt)) {
|
|
819
|
+
return { response: apiError('access_token_invalid', at) };
|
|
820
|
+
}
|
|
821
|
+
const grantId = `${String(tokenRow.clientKey)}:${String(tokenRow.sub)}`;
|
|
822
|
+
const grant = resources.find((r) => r.type === 'grant' && r.id === grantId) as Row | undefined;
|
|
823
|
+
// The TOKEN's scope is what was granted when it was minted; the GRANT is the live record and a
|
|
824
|
+
// revoked grant empties it. The intersection is what the token may still do.
|
|
825
|
+
const granted: string[] = Array.isArray(grant?.scopes) ? grant.scopes : [];
|
|
826
|
+
const scopes = parseScopeParam(String(tokenRow.scope ?? '')).filter((s) => granted.includes(s));
|
|
827
|
+
const account = resources.find((r) => r.type === 'account' && r.id === String(tokenRow.sub)) as Row | undefined;
|
|
828
|
+
if (!account) return { response: apiError('access_token_invalid', at) };
|
|
829
|
+
|
|
830
|
+
const now = nowSeconds(at);
|
|
831
|
+
const windowId = `${endpoint}:${String(tokenRow.openId ?? '')}`;
|
|
832
|
+
const rateRow = resources.find((r) => r.type === BK_RATE && r.id === windowId) as Row | undefined;
|
|
833
|
+
const active = rateRow && typeof rateRow.windowStart === 'number' && now < rateRow.windowStart + RATE_WINDOW_SECONDS;
|
|
834
|
+
const windowStart = active ? Number(rateRow.windowStart) : now;
|
|
835
|
+
const before = active && typeof rateRow.used === 'number' ? rateRow.used : 0;
|
|
836
|
+
if (before >= RATE_LIMITS[endpoint]) return { response: apiError('rate_limit_exceeded', at) };
|
|
837
|
+
|
|
838
|
+
const response = serve({ resources, account, openId: String(tokenRow.openId ?? ''), scopes });
|
|
839
|
+
if (response.status !== 200) return { response };
|
|
840
|
+
return { response, windowId, windowStart, used: before + 1 };
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
if (req.readOnly) return evaluate(readAll(root)).response;
|
|
844
|
+
const committed = await applyTwinWriteAtomic(
|
|
845
|
+
'tiktok',
|
|
846
|
+
(resources) => {
|
|
847
|
+
const result = evaluate(resources);
|
|
848
|
+
if (!result.windowId) return { kind: 'skip', value: result.response };
|
|
849
|
+
return {
|
|
850
|
+
kind: 'write',
|
|
851
|
+
value: result.response,
|
|
852
|
+
write: {
|
|
853
|
+
operation: 'rate_window.spend',
|
|
854
|
+
subjectType: BK_RATE,
|
|
855
|
+
subjectId: result.windowId,
|
|
856
|
+
fields: { windowStart: result.windowStart, used: result.used, rev: nextRevIn(resources, BK_RATE, result.windowId) },
|
|
857
|
+
occurredAt: at,
|
|
858
|
+
actor: { kind: 'system' },
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
},
|
|
862
|
+
root,
|
|
863
|
+
);
|
|
864
|
+
return committed.value;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* The Content Posting calls' front half: the same bearer -> token -> grant -> account -> rate window
|
|
869
|
+
* resolution as a Display API read, then the endpoint body, which may stage bytes (so it runs OUTSIDE
|
|
870
|
+
* the kernel transaction). The window is spent once the endpoint has answered 2xx, and never on a
|
|
871
|
+
* read-only twin.
|
|
872
|
+
*/
|
|
873
|
+
async function authorizedPosting(
|
|
874
|
+
req: TikTokRequest,
|
|
875
|
+
endpoint: MeteredEndpoint,
|
|
876
|
+
serve: (ctx: PostingContext) => Promise<TikTokResponse> | TikTokResponse,
|
|
877
|
+
): Promise<TikTokResponse> {
|
|
878
|
+
const root = req.root;
|
|
879
|
+
const at = instantOf(req.occurredAt);
|
|
880
|
+
const bearer = /^bearer\s+(.+)$/i.exec(req.headers?.authorization ?? '')?.[1]?.trim();
|
|
881
|
+
if (!bearer) return apiError('access_token_invalid', at);
|
|
882
|
+
const resources = readAll(root);
|
|
883
|
+
const tokenRow = resources.find((r) => r.type === BK_ACCESS && r.id === secretKey(bearer)) as Row | undefined;
|
|
884
|
+
if (!tokenRow || tokenRow.revoked === true || (typeof tokenRow.expiresAt === 'number' && nowSeconds(at) >= tokenRow.expiresAt)) {
|
|
885
|
+
return apiError('access_token_invalid', at);
|
|
886
|
+
}
|
|
887
|
+
const grant = resources.find((r) => r.type === 'grant' && r.id === `${String(tokenRow.clientKey)}:${String(tokenRow.sub)}`) as Row | undefined;
|
|
888
|
+
const granted: string[] = Array.isArray(grant?.scopes) ? grant.scopes : [];
|
|
889
|
+
const scopes = parseScopeParam(String(tokenRow.scope ?? '')).filter((s) => granted.includes(s));
|
|
890
|
+
const account = resources.find((r) => r.type === 'account' && r.id === String(tokenRow.sub)) as Row | undefined;
|
|
891
|
+
if (!account) return apiError('access_token_invalid', at);
|
|
892
|
+
const openId = String(tokenRow.openId ?? '');
|
|
893
|
+
// The Content Posting references meter "each user access_token", so a posting window is the token's
|
|
894
|
+
// own — keyed by its hash, never the token
|
|
895
|
+
const tokenHash = secretKey(bearer);
|
|
896
|
+
const windowId = `${endpoint}:${tokenHash}`;
|
|
897
|
+
const windowOf = (rows: readonly TwinResource[]) => {
|
|
898
|
+
const rateRow = rows.find((r) => r.type === BK_RATE && r.id === windowId) as Row | undefined;
|
|
899
|
+
const active = rateRow && typeof rateRow.windowStart === 'number' && nowSeconds(at) < rateRow.windowStart + RATE_WINDOW_SECONDS;
|
|
900
|
+
return { start: active ? Number(rateRow.windowStart) : nowSeconds(at), used: active && typeof rateRow.used === 'number' ? rateRow.used : 0 };
|
|
901
|
+
};
|
|
902
|
+
if (windowOf(resources).used >= RATE_LIMITS[endpoint]) return apiError('rate_limit_exceeded', at);
|
|
903
|
+
const response = await serve({
|
|
904
|
+
resources,
|
|
905
|
+
account,
|
|
906
|
+
openId,
|
|
907
|
+
scopes,
|
|
908
|
+
tokenHash,
|
|
909
|
+
clientKey: String(tokenRow.clientKey),
|
|
910
|
+
at,
|
|
911
|
+
...(root !== undefined ? { root } : {}),
|
|
912
|
+
...(req.origin !== undefined ? { origin: req.origin } : {}),
|
|
913
|
+
...(req.originalHost !== undefined ? { originalHost: req.originalHost } : {}),
|
|
914
|
+
...(req.readOnly ? { readOnly: true } : {}),
|
|
915
|
+
});
|
|
916
|
+
if (req.readOnly || response.status < 200 || response.status >= 300) return response;
|
|
917
|
+
await applyTwinWriteAtomic(
|
|
918
|
+
'tiktok',
|
|
919
|
+
(rows) => {
|
|
920
|
+
const w = windowOf(rows);
|
|
921
|
+
return {
|
|
922
|
+
kind: 'write',
|
|
923
|
+
value: undefined,
|
|
924
|
+
write: {
|
|
925
|
+
operation: 'rate_window.spend',
|
|
926
|
+
subjectType: BK_RATE,
|
|
927
|
+
subjectId: windowId,
|
|
928
|
+
fields: { windowStart: w.start, used: w.used + 1, rev: nextRevIn(rows, BK_RATE, windowId) },
|
|
929
|
+
occurredAt: at,
|
|
930
|
+
actor: { kind: 'system' },
|
|
931
|
+
},
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
root,
|
|
935
|
+
);
|
|
936
|
+
return response;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** `fields` is REQUIRED on all three Display API reads ("The set of user fields to request for",
|
|
940
|
+
* Required: Yes). An unknown name is `invalid_params`, the code the vendor assigns to "one or
|
|
941
|
+
* more request fields are invalid". */
|
|
942
|
+
function readFields(query: URLSearchParams, allowed: readonly string[], at: string): { fields: string[] } | { fail: TikTokResponse } {
|
|
943
|
+
const raw = query.get('fields');
|
|
944
|
+
if (raw === null || raw.trim() === '') {
|
|
945
|
+
return { fail: apiError('invalid_params', at, 'The "fields" query parameter is required.') };
|
|
946
|
+
}
|
|
947
|
+
const fields = raw.split(',').map((f) => f.trim()).filter((f) => f !== '');
|
|
948
|
+
const unknown = fields.filter((f) => !allowed.includes(f));
|
|
949
|
+
if (unknown.length > 0) {
|
|
950
|
+
return { fail: apiError('invalid_params', at, `Invalid field(s): ${unknown.join(', ')}. Valid fields are: ${allowed.join(', ')}.`) };
|
|
951
|
+
}
|
|
952
|
+
return { fields };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** The documented `scope_not_authorized`: "The user did not authorize the scope required for
|
|
956
|
+
* completing this request." Returned as a 401, the status the vendor's own table gives it. */
|
|
957
|
+
function requireScopes(needed: readonly string[], held: readonly string[], at: string): TikTokResponse | null {
|
|
958
|
+
const missing = needed.filter((s) => !held.includes(s));
|
|
959
|
+
if (missing.length === 0) return null;
|
|
960
|
+
return apiError('scope_not_authorized', at, `The user did not authorize the scope(s) required for completing this request: ${missing.join(', ')}.`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function usersInfo(req: TikTokRequest, query: URLSearchParams): Promise<TikTokResponse> {
|
|
964
|
+
const at = instantOf(req.occurredAt);
|
|
965
|
+
return authorizedRead(req, 'user_info', (ctx) => {
|
|
966
|
+
const asked = readFields(query, USER_FIELDS, at);
|
|
967
|
+
if ('fail' in asked) return asked.fail;
|
|
968
|
+
const needed = [...new Set(asked.fields.map((f) => USER_FIELD_SCOPES[f]!))];
|
|
969
|
+
const refusal = requireScopes(needed, ctx.scopes, at);
|
|
970
|
+
if (refusal) return refusal;
|
|
971
|
+
return apiOk({ user: renderUser(ctx.account, ctx.openId, asked.fields) }, at, `user_info:${ctx.openId}:${asked.fields.join(',')}`);
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function parseJsonBody(body: string | undefined): Record<string, unknown> | null {
|
|
976
|
+
if (body === undefined || body.trim() === '') return {};
|
|
977
|
+
try {
|
|
978
|
+
const parsed = JSON.parse(body) as unknown;
|
|
979
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
980
|
+
} catch {
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** The videos this token's account owns, newest first (the list endpoint's own ordering — its
|
|
986
|
+
* cursor is "UTC Unix timestamp in milliseconds… fetches videos posted before that time"). */
|
|
987
|
+
function ownedVideos(resources: readonly TwinResource[], accountId: string, at: string): Row[] {
|
|
988
|
+
// a Direct Post is on the profile from the instant its processing ends (its create_time), not before
|
|
989
|
+
const now = nowSeconds(at);
|
|
990
|
+
// create_time is whole seconds, so two posts of one second tie: the one written later is newer
|
|
991
|
+
const order = new Map(resources.map((r, i) => [r, i] as const));
|
|
992
|
+
return (resources.filter((r) => r.type === 'video' && (r as Row).ownerUnionId === accountId && Number((r as Row).createTime ?? 0) <= now) as Row[])
|
|
993
|
+
.sort((a, b) => Number(b.createTime ?? 0) - Number(a.createTime ?? 0) || (order.get(b) ?? 0) - (order.get(a) ?? 0));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** Default 10, maximum 20 — the list endpoint's own parameter table. */
|
|
997
|
+
export const VIDEO_LIST_DEFAULT_COUNT = 10;
|
|
998
|
+
export const VIDEO_LIST_MAX_COUNT = 20;
|
|
999
|
+
/** "Up to 20 video IDs can be included per request" — the query endpoint's own limit. */
|
|
1000
|
+
export const VIDEO_QUERY_MAX_IDS = 20;
|
|
1001
|
+
|
|
1002
|
+
function videoList(req: TikTokRequest, query: URLSearchParams): Promise<TikTokResponse> {
|
|
1003
|
+
const at = instantOf(req.occurredAt);
|
|
1004
|
+
return authorizedRead(req, 'video_list', (ctx) => {
|
|
1005
|
+
const asked = readFields(query, VIDEO_FIELDS, at);
|
|
1006
|
+
if ('fail' in asked) return asked.fail;
|
|
1007
|
+
const refusal = requireScopes([VIDEO_SCOPE], ctx.scopes, at);
|
|
1008
|
+
if (refusal) return refusal;
|
|
1009
|
+
const body = parseJsonBody(req.body);
|
|
1010
|
+
if (body === null) return apiError('invalid_params', at, 'The request body must be a JSON object.');
|
|
1011
|
+
const rawCount = body['max_count'];
|
|
1012
|
+
if (rawCount !== undefined && (typeof rawCount !== 'number' || !Number.isInteger(rawCount) || rawCount < 1 || rawCount > VIDEO_LIST_MAX_COUNT)) {
|
|
1013
|
+
return apiError('invalid_params', at, `max_count must be an integer between 1 and ${VIDEO_LIST_MAX_COUNT}.`);
|
|
1014
|
+
}
|
|
1015
|
+
const rawCursor = body['cursor'];
|
|
1016
|
+
if (rawCursor !== undefined && (typeof rawCursor !== 'number' || !Number.isInteger(rawCursor) || rawCursor < 0)) {
|
|
1017
|
+
return apiError('invalid_params', at, 'cursor must be a UTC Unix timestamp in milliseconds.');
|
|
1018
|
+
}
|
|
1019
|
+
const maxCount = typeof rawCount === 'number' ? rawCount : VIDEO_LIST_DEFAULT_COUNT;
|
|
1020
|
+
const all = ownedVideos(ctx.resources, ctx.account.id, at);
|
|
1021
|
+
const before = typeof rawCursor === 'number' ? all.filter((v) => Number(v.createTime) * 1000 < rawCursor) : all;
|
|
1022
|
+
const page = before.slice(0, maxCount);
|
|
1023
|
+
const last = page[page.length - 1];
|
|
1024
|
+
const cursor = last ? Number(last.createTime) * 1000 : (typeof rawCursor === 'number' ? rawCursor : 0);
|
|
1025
|
+
return apiOk(
|
|
1026
|
+
{
|
|
1027
|
+
videos: page.map((v) => renderVideo(v, String(ctx.account.username ?? ''), asked.fields)),
|
|
1028
|
+
cursor,
|
|
1029
|
+
has_more: before.length > page.length,
|
|
1030
|
+
},
|
|
1031
|
+
at,
|
|
1032
|
+
`video_list:${ctx.openId}:${cursor}`,
|
|
1033
|
+
);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function videoQuery(req: TikTokRequest, query: URLSearchParams): Promise<TikTokResponse> {
|
|
1038
|
+
const at = instantOf(req.occurredAt);
|
|
1039
|
+
return authorizedRead(req, 'video_query', (ctx) => {
|
|
1040
|
+
const asked = readFields(query, VIDEO_FIELDS, at);
|
|
1041
|
+
if ('fail' in asked) return asked.fail;
|
|
1042
|
+
const refusal = requireScopes([VIDEO_SCOPE], ctx.scopes, at);
|
|
1043
|
+
if (refusal) return refusal;
|
|
1044
|
+
const body = parseJsonBody(req.body);
|
|
1045
|
+
if (body === null) return apiError('invalid_params', at, 'The request body must be a JSON object.');
|
|
1046
|
+
const filters = body['filters'];
|
|
1047
|
+
const ids = filters && typeof filters === 'object' && !Array.isArray(filters)
|
|
1048
|
+
? (filters as Record<string, unknown>)['video_ids']
|
|
1049
|
+
: undefined;
|
|
1050
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'string')) {
|
|
1051
|
+
return apiError('invalid_params', at, 'filters.video_ids must be a non-empty array of video id strings.');
|
|
1052
|
+
}
|
|
1053
|
+
if (ids.length > VIDEO_QUERY_MAX_IDS) {
|
|
1054
|
+
return apiError('invalid_params', at, `Up to ${VIDEO_QUERY_MAX_IDS} video IDs can be included per request.`);
|
|
1055
|
+
}
|
|
1056
|
+
// A video that is not this user's is simply ABSENT from the answer — the endpoint reads "the
|
|
1057
|
+
// videos of the user whose token this is", so a foreign id must not become a 404 that leaks
|
|
1058
|
+
// whether it exists (`tiktok.video.query_foreign_id`, todo, pins the live behaviour).
|
|
1059
|
+
const owned = new Map(ownedVideos(ctx.resources, ctx.account.id, at).map((v) => [v.id, v]));
|
|
1060
|
+
const videos = (ids as string[]).map((id) => owned.get(id)).filter((v): v is Row => v !== undefined);
|
|
1061
|
+
return apiOk(
|
|
1062
|
+
{ videos: videos.map((v) => renderVideo(v, String(ctx.account.username ?? ''), asked.fields)) },
|
|
1063
|
+
at,
|
|
1064
|
+
`video_query:${ctx.openId}:${(ids as string[]).join(',')}`,
|
|
1065
|
+
);
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** Every VENDOR endpoint this twin claims to serve. The conformance check drives one real request
|
|
1070
|
+
* per entry and grades the OUTCOME, so an entry here is a promise with teeth. Twin-only routes
|
|
1071
|
+
* (`/_twin/*`) are deliberately absent — they are scaffolding, not vendor surface. */
|
|
1072
|
+
export function tiktokTwinSnapshot(): { implementedEndpoints: string[]; resourceTypes: readonly string[]; grantTypes: string[] } {
|
|
1073
|
+
return {
|
|
1074
|
+
implementedEndpoints: [
|
|
1075
|
+
'GET /v2/auth/authorize',
|
|
1076
|
+
'POST /v2/oauth/token',
|
|
1077
|
+
'POST /v2/oauth/revoke',
|
|
1078
|
+
'GET /v2/user/info',
|
|
1079
|
+
'POST /v2/video/list',
|
|
1080
|
+
'POST /v2/video/query',
|
|
1081
|
+
'POST /v2/post/publish/creator_info/query',
|
|
1082
|
+
'POST /v2/post/publish/video/init',
|
|
1083
|
+
'POST /v2/post/publish/inbox/video/init',
|
|
1084
|
+
'POST /v2/post/publish/status/fetch',
|
|
1085
|
+
'PUT /video',
|
|
1086
|
+
],
|
|
1087
|
+
resourceTypes: STORE_RESOURCE_TYPES,
|
|
1088
|
+
grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ── twin-only control routes (NOT vendor surface — see the manifest) ────────────────────────────
|
|
1093
|
+
|
|
1094
|
+
async function twinControl(req: TikTokRequest, path: string, form: URLSearchParams): Promise<TikTokResponse | null> {
|
|
1095
|
+
const root = req.root;
|
|
1096
|
+
const at = instantOf(req.occurredAt);
|
|
1097
|
+
const opts = { root, occurredAt: at };
|
|
1098
|
+
const json = () => {
|
|
1099
|
+
try {
|
|
1100
|
+
return JSON.parse(req.body ?? '{}') as Record<string, unknown>;
|
|
1101
|
+
} catch {
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
if (req.method === 'GET' && path === '/_twin/consent') {
|
|
1106
|
+
const { query } = splitPath(req.path);
|
|
1107
|
+
return renderConsent(req, query.get('auth_request') ?? '');
|
|
1108
|
+
}
|
|
1109
|
+
if (req.method === 'POST' && path === '/_twin/consent') return consentDecision(req, form);
|
|
1110
|
+
|
|
1111
|
+
if (req.method === 'POST' && path === '/_twin/clients') {
|
|
1112
|
+
const b = json();
|
|
1113
|
+
if (!b) return malformedRequest(at, 'A JSON body is required.');
|
|
1114
|
+
// `:` is the grant-id separator (`clientKey:sub`), so a colon in a caller-supplied key could
|
|
1115
|
+
// forge a composite collision (app `A:B` + user `C` vs app `A` + user `B:C`). Real TikTok
|
|
1116
|
+
// client keys are colon-free; the scaffolding refuses the character outright.
|
|
1117
|
+
if (typeof b['client_key'] === 'string' && b['client_key'].includes(':')) {
|
|
1118
|
+
return malformedRequest(at, 'client_key must not contain ":".');
|
|
1119
|
+
}
|
|
1120
|
+
const clientKey = typeof b['client_key'] === 'string' && b['client_key'] ? (b['client_key'] as string) : mintClientKey(root, at);
|
|
1121
|
+
// No secret given: one is drawn from entropy, as the developer portal issues one, and answered ONCE
|
|
1122
|
+
// here — the twin keeps only its hash, so it can never be shown again.
|
|
1123
|
+
const drawn = typeof b['client_secret'] === 'string' && b['client_secret'] !== '' ? null
|
|
1124
|
+
: Array.from(crypto.getRandomValues(new Uint8Array(24)), (x) => x.toString(16).padStart(2, '0')).join('');
|
|
1125
|
+
const clientSecret = drawn ?? (b['client_secret'] as string);
|
|
1126
|
+
const row = await writeAtomic(
|
|
1127
|
+
'oauth_client',
|
|
1128
|
+
clientKey,
|
|
1129
|
+
'oauth_client.create',
|
|
1130
|
+
() => ({
|
|
1131
|
+
name: typeof b['name'] === 'string' ? b['name'] : 'Twin App',
|
|
1132
|
+
// the app's secret is kept as its SHA-256: the token endpoint hashes what it is shown and compares
|
|
1133
|
+
secretSha256: secretKey(clientSecret),
|
|
1134
|
+
redirectUris: Array.isArray(b['redirect_uris']) ? b['redirect_uris'] : [],
|
|
1135
|
+
}),
|
|
1136
|
+
opts,
|
|
1137
|
+
);
|
|
1138
|
+
return { status: 200, body: { ...row, ...(drawn !== null ? { client_secret: drawn } : {}) } };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (req.method === 'POST' && path === '/_twin/accounts') {
|
|
1142
|
+
const b = json();
|
|
1143
|
+
if (!b || typeof b['union_id'] !== 'string' || !b['union_id']) return malformedRequest(at, 'union_id is required');
|
|
1144
|
+
if (b['union_id'].includes(':')) return malformedRequest(at, 'union_id must not contain ":".');
|
|
1145
|
+
const id = b['union_id'] as string;
|
|
1146
|
+
const row = await writeAtomic(
|
|
1147
|
+
'account',
|
|
1148
|
+
id,
|
|
1149
|
+
'account.create',
|
|
1150
|
+
() => ({
|
|
1151
|
+
username: typeof b['username'] === 'string' ? b['username'] : `user_${id}`,
|
|
1152
|
+
displayName: typeof b['display_name'] === 'string' ? b['display_name'] : 'Twin Persona',
|
|
1153
|
+
...(typeof b['avatar_url'] === 'string' ? { avatarUrl: b['avatar_url'] } : {}),
|
|
1154
|
+
...(typeof b['avatar_url_100'] === 'string' ? { avatarUrl100: b['avatar_url_100'] } : {}),
|
|
1155
|
+
...(typeof b['avatar_large_url'] === 'string' ? { avatarLargeUrl: b['avatar_large_url'] } : {}),
|
|
1156
|
+
...(typeof b['bio_description'] === 'string' ? { bioDescription: b['bio_description'] } : {}),
|
|
1157
|
+
...(typeof b['profile_deep_link'] === 'string' ? { profileDeepLink: b['profile_deep_link'] } : {}),
|
|
1158
|
+
...(typeof b['is_verified'] === 'boolean' ? { isVerified: b['is_verified'] } : {}),
|
|
1159
|
+
...(typeof b['follower_count'] === 'number' ? { followerCount: b['follower_count'] } : {}),
|
|
1160
|
+
...(typeof b['following_count'] === 'number' ? { followingCount: b['following_count'] } : {}),
|
|
1161
|
+
...(typeof b['likes_count'] === 'number' ? { likesCount: b['likes_count'] } : {}),
|
|
1162
|
+
...(typeof b['video_count'] === 'number' ? { videoCount: b['video_count'] } : {}),
|
|
1163
|
+
// what creator_info answers for this creator (tiktok-posting.ts creatorOptions)
|
|
1164
|
+
...(typeof b['is_private'] === 'boolean' ? { isPrivate: b['is_private'] } : {}),
|
|
1165
|
+
...(Array.isArray(b['privacy_level_options']) ? { privacyLevelOptions: b['privacy_level_options'] } : {}),
|
|
1166
|
+
...(typeof b['comment_disabled'] === 'boolean' ? { commentDisabled: b['comment_disabled'] } : {}),
|
|
1167
|
+
...(typeof b['duet_disabled'] === 'boolean' ? { duetDisabled: b['duet_disabled'] } : {}),
|
|
1168
|
+
...(typeof b['stitch_disabled'] === 'boolean' ? { stitchDisabled: b['stitch_disabled'] } : {}),
|
|
1169
|
+
...(typeof b['max_video_post_duration_sec'] === 'number' ? { maxVideoPostDurationSec: b['max_video_post_duration_sec'] } : {}),
|
|
1170
|
+
}),
|
|
1171
|
+
opts,
|
|
1172
|
+
);
|
|
1173
|
+
return { status: 200, body: row };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (req.method === 'POST' && path === '/_twin/videos') {
|
|
1177
|
+
// A video WITHOUT bytes, for a Display API read to find. A video with its media is posted
|
|
1178
|
+
// through the vendor's own Content Posting API (tiktok-posting.ts); this door seeds the
|
|
1179
|
+
// metadata-only rows a Display API fixture needs (ADDING_A_TWIN.md §6).
|
|
1180
|
+
const b = json();
|
|
1181
|
+
if (!b || typeof b['id'] !== 'string' || !b['id']) return malformedRequest(at, 'id is required');
|
|
1182
|
+
if (typeof b['owner_union_id'] !== 'string' || !b['owner_union_id']) return malformedRequest(at, 'owner_union_id is required');
|
|
1183
|
+
if (!readOne(root, 'account', b['owner_union_id'] as string)) return malformedRequest(at, 'Unknown owner_union_id.');
|
|
1184
|
+
const id = b['id'] as string;
|
|
1185
|
+
const row = await writeAtomic(
|
|
1186
|
+
'video',
|
|
1187
|
+
id,
|
|
1188
|
+
'video.create',
|
|
1189
|
+
() => ({
|
|
1190
|
+
ownerUnionId: b['owner_union_id'],
|
|
1191
|
+
title: typeof b['title'] === 'string' ? b['title'] : '',
|
|
1192
|
+
videoDescription: typeof b['video_description'] === 'string' ? b['video_description'] : '',
|
|
1193
|
+
createTime: typeof b['create_time'] === 'number' ? b['create_time'] : 0,
|
|
1194
|
+
duration: typeof b['duration'] === 'number' ? b['duration'] : 0,
|
|
1195
|
+
height: typeof b['height'] === 'number' ? b['height'] : 0,
|
|
1196
|
+
width: typeof b['width'] === 'number' ? b['width'] : 0,
|
|
1197
|
+
likeCount: typeof b['like_count'] === 'number' ? b['like_count'] : 0,
|
|
1198
|
+
commentCount: typeof b['comment_count'] === 'number' ? b['comment_count'] : 0,
|
|
1199
|
+
shareCount: typeof b['share_count'] === 'number' ? b['share_count'] : 0,
|
|
1200
|
+
viewCount: typeof b['view_count'] === 'number' ? b['view_count'] : 0,
|
|
1201
|
+
...(typeof b['is_aigc'] === 'boolean' ? { isAigc: b['is_aigc'] } : {}),
|
|
1202
|
+
}),
|
|
1203
|
+
opts,
|
|
1204
|
+
);
|
|
1205
|
+
return { status: 200, body: row };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (req.method === 'POST' && path === '/_twin/tokens') {
|
|
1209
|
+
// A user access token for an account, as a World issues one in place of running the consent leg
|
|
1210
|
+
// (the X/YouTube `/_twin/tokens` precedent): the grant is widened to the named scopes and a token
|
|
1211
|
+
// carrying them is recorded. The mirror signs in with it, and a Content Posting client posts with it.
|
|
1212
|
+
const b = json();
|
|
1213
|
+
if (!b || typeof b['union_id'] !== 'string' || !b['union_id']) return malformedRequest(at, 'union_id is required');
|
|
1214
|
+
const account = readOne(root, 'account', b['union_id'] as string);
|
|
1215
|
+
if (!account) return malformedRequest(at, 'Unknown account.');
|
|
1216
|
+
const clientKey = typeof b['client_key'] === 'string' && b['client_key'] ? (b['client_key'] as string) : DEFAULT_CLIENT_KEY;
|
|
1217
|
+
if (!readOne(root, 'oauth_client', clientKey)) return malformedRequest(at, 'Unknown client_key.');
|
|
1218
|
+
const rawScopes = Array.isArray(b['scopes']) ? (b['scopes'] as unknown[]).filter((s): s is string => typeof s === 'string') : parseScopeParam(typeof b['scope'] === 'string' ? b['scope'] : '');
|
|
1219
|
+
const scopes = rawScopes.filter((s) => KNOWN_SCOPES.includes(s));
|
|
1220
|
+
if (scopes.length === 0) return malformedRequest(at, `scopes must name at least one of: ${KNOWN_SCOPES.join(', ')}`);
|
|
1221
|
+
if (typeof b['access_token'] === 'string' && !/^act\.[A-Za-z0-9._~-]{8,}$/.test(b['access_token'] as string)) return malformedRequest(at, 'access_token must look like act.<opaque>.');
|
|
1222
|
+
const token = typeof b['access_token'] === 'string' ? (b['access_token'] as string) : mintAccessToken(root, Date.parse(at), `twin:${clientKey}:${account.id}`);
|
|
1223
|
+
const grantId = `${clientKey}:${account.id}`;
|
|
1224
|
+
const openId = openIdFor(clientKey, account.id);
|
|
1225
|
+
const scope = formatScopeParam(scopes);
|
|
1226
|
+
await applyTwinWriteAtomic(
|
|
1227
|
+
'tiktok',
|
|
1228
|
+
(resources) => {
|
|
1229
|
+
const grant = resources.find((r) => r.type === 'grant' && r.id === grantId) as Row | undefined;
|
|
1230
|
+
const held: string[] = Array.isArray(grant?.scopes) ? grant.scopes : [];
|
|
1231
|
+
const grantFields = { clientKey, sub: account.id, openId, scopes: [...new Set([...held, ...scopes])], rev: nextRevIn(resources, 'grant', grantId) };
|
|
1232
|
+
const tokenKey = secretKey(token);
|
|
1233
|
+
return {
|
|
1234
|
+
kind: 'write',
|
|
1235
|
+
value: undefined,
|
|
1236
|
+
write: {
|
|
1237
|
+
operation: 'access_token.issue',
|
|
1238
|
+
subjectType: BK_ACCESS,
|
|
1239
|
+
subjectId: tokenKey,
|
|
1240
|
+
fields: { clientKey, sub: account.id, openId, scope, expiresAt: nowSeconds(at) + ACCESS_TOKEN_TTL_SECONDS, revoked: false, rev: nextRevIn(resources, BK_ACCESS, tokenKey) },
|
|
1241
|
+
projection: grant ? { updates: [{ type: 'grant', id: grantId, fields: grantFields }] } : { creates: [{ type: 'grant', id: grantId, fields: grantFields }] },
|
|
1242
|
+
occurredAt: at,
|
|
1243
|
+
actor: { kind: 'agent' as const },
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
},
|
|
1247
|
+
root,
|
|
1248
|
+
);
|
|
1249
|
+
return { status: 200, body: { access_token: token, open_id: openId, scope, expires_in: ACCESS_TOKEN_TTL_SECONDS, token_type: 'Bearer' } };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (req.method === 'POST' && path === '/_twin/session') {
|
|
1253
|
+
const b = json();
|
|
1254
|
+
if (!b || typeof b['union_id'] !== 'string' || !b['union_id']) return malformedRequest(at, 'union_id is required');
|
|
1255
|
+
if (!readOne(root, 'account', b['union_id'] as string)) return malformedRequest(at, 'Unknown account.');
|
|
1256
|
+
const row = await writeAtomic('session', 'current', 'session.switch', () => ({ accountId: b['union_id'] }), opts);
|
|
1257
|
+
return { status: 200, body: row };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (req.method === 'POST' && path === '/_twin/rate_limit') {
|
|
1261
|
+
// Arm a deterministic rate state (the figma armed-429 precedent): set the used count for a window
|
|
1262
|
+
// so a verify can prove the 429 without 600 real reads. A Display API window is the (endpoint,
|
|
1263
|
+
// open_id) pair; a Content Posting window is the token's own, named by `access_token` and kept by
|
|
1264
|
+
// its hash.
|
|
1265
|
+
const b = json();
|
|
1266
|
+
if (!b || typeof b['endpoint'] !== 'string' || typeof b['used'] !== 'number') {
|
|
1267
|
+
return malformedRequest(at, 'endpoint and used are required');
|
|
1268
|
+
}
|
|
1269
|
+
if (!(METERED_ENDPOINTS as readonly string[]).includes(b['endpoint'] as string)) {
|
|
1270
|
+
return malformedRequest(at, `endpoint must be one of ${METERED_ENDPOINTS.join(', ')}`);
|
|
1271
|
+
}
|
|
1272
|
+
const display = ['user_info', 'video_list', 'video_query'].includes(b['endpoint'] as string);
|
|
1273
|
+
const key = display ? b['open_id'] : b['access_token'];
|
|
1274
|
+
if (typeof key !== 'string' || key === '') return malformedRequest(at, display ? 'open_id is required for a Display API window' : 'access_token is required for a Content Posting window');
|
|
1275
|
+
const windowId = `${b['endpoint']}:${display ? key : secretKey(key)}`;
|
|
1276
|
+
const row = await writeAtomic(
|
|
1277
|
+
BK_RATE,
|
|
1278
|
+
windowId,
|
|
1279
|
+
'rate_window.arm',
|
|
1280
|
+
() => ({ windowStart: nowSeconds(at), used: b['used'] }),
|
|
1281
|
+
opts,
|
|
1282
|
+
);
|
|
1283
|
+
return { status: 200, body: row };
|
|
1284
|
+
}
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// ── public entry + router ───────────────────────────────────────────────────────────────────────
|
|
1289
|
+
|
|
1290
|
+
export async function handleTikTokTwinRequest(req: TikTokRequest): Promise<TikTokResponse> {
|
|
1291
|
+
try {
|
|
1292
|
+
return await routeTikTokTwinRequest(req);
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
// MALFORMED-REQUEST GUARD (route boundary), the gemini/googleoauth precedent: a residual
|
|
1295
|
+
// TypeError (a wrong-typed field the router walked) or URIError (bad percent-encoding) becomes
|
|
1296
|
+
// the vendor's own 400 envelope instead of escaping as a non-vendor 500 — in the error FAMILY
|
|
1297
|
+
// of the endpoint that failed: the Display API speaks the nested error object, the OAuth
|
|
1298
|
+
// endpoints and the authorize page speak the flat one. Every OTHER error type still propagates
|
|
1299
|
+
// loudly rather than being masked as a caller mistake.
|
|
1300
|
+
const at = typeof req.occurredAt === 'string' ? req.occurredAt : new Date(0).toISOString();
|
|
1301
|
+
const { path } = splitPath(typeof req.path === 'string' ? req.path : '/');
|
|
1302
|
+
const isApi = path === '/v2/user/info' || path === '/v2/video/list' || path === '/v2/video/query' || path.startsWith('/v2/post/publish/');
|
|
1303
|
+
if (e instanceof TypeError || e instanceof URIError) {
|
|
1304
|
+
return isApi
|
|
1305
|
+
? apiError('invalid_params', at, 'The request contains an invalid argument.')
|
|
1306
|
+
: oauthError('invalid_request', 'The request parameters are malformed.', at);
|
|
1307
|
+
}
|
|
1308
|
+
if (isApi) return internalError(at);
|
|
1309
|
+
if (path === AUTHORIZE_PATH || path === '/_twin/consent') {
|
|
1310
|
+
return errorPage({ status: 500, code: 'server_error', detail: 'The authorization server could not complete the request.' });
|
|
1311
|
+
}
|
|
1312
|
+
return oauthErrorCode('server_error', at, 500);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function routeTikTokTwinRequest(req: TikTokRequest): Promise<TikTokResponse> {
|
|
1317
|
+
const method = req.method.toUpperCase();
|
|
1318
|
+
const { path, query } = splitPath(req.path);
|
|
1319
|
+
const at = instantOf(req.occurredAt);
|
|
1320
|
+
const form = new URLSearchParams(method === 'GET' || method === 'HEAD' ? '' : (req.body ?? ''));
|
|
1321
|
+
|
|
1322
|
+
// D3: a read-only twin cannot mint credentials or settle consent. The Display API reads stay
|
|
1323
|
+
// served (they are reads, even though two of them are POSTs), with rate accounting suspended.
|
|
1324
|
+
// The media route is a READ (a player fetching a post's bytes) that lives under the twin prefix; the
|
|
1325
|
+
// Content Posting inits and the chunk PUT refuse on their own, in their own envelopes.
|
|
1326
|
+
const writes = (path.startsWith('/_twin/') && !path.startsWith(MEDIA_PREFIX)) || path === '/v2/oauth/token' || path === '/v2/oauth/revoke';
|
|
1327
|
+
if (req.readOnly && writes) {
|
|
1328
|
+
return oauthErrorCode('temporarily_unavailable', at, 405);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Seeding is idempotent and cheap; doing it at the router boundary means every entry point sees
|
|
1332
|
+
// a world with an app, personas, videos and a signed-in session — what a browser at tiktok.com
|
|
1333
|
+
// has.
|
|
1334
|
+
if (!req.readOnly) await ensureSeed({ root: req.root, occurredAt: at, ...((req.callbackOrigin ?? req.origin) ? { origin: req.callbackOrigin ?? req.origin } : {}) });
|
|
1335
|
+
|
|
1336
|
+
if (path.startsWith(MEDIA_PREFIX)) {
|
|
1337
|
+
return serveVideo({ method, path, query, at, resources: readAll(req.root), ...(req.headers ? { headers: req.headers } : {}), ...(req.root !== undefined ? { root: req.root } : {}) });
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const control = await twinControl(req, path, form);
|
|
1341
|
+
if (control) return control;
|
|
1342
|
+
|
|
1343
|
+
if (method === 'GET' && path === AUTHORIZE_PATH) return authorize(req, query);
|
|
1344
|
+
if (method === 'POST' && path === '/v2/oauth/token') return token(req);
|
|
1345
|
+
if (method === 'POST' && path === '/v2/oauth/revoke') return revoke(req);
|
|
1346
|
+
if (method === 'GET' && path === '/v2/user/info') return usersInfo(req, query);
|
|
1347
|
+
if (method === 'POST' && path === '/v2/video/list') return videoList(req, query);
|
|
1348
|
+
if (method === 'POST' && path === '/v2/video/query') return videoQuery(req, query);
|
|
1349
|
+
|
|
1350
|
+
// the Content Posting API (tiktok-posting.ts)
|
|
1351
|
+
if (method === 'POST' && path === '/v2/post/publish/creator_info/query') return authorizedPosting(req, 'creator_info', creatorInfo);
|
|
1352
|
+
if (method === 'POST' && path === '/v2/post/publish/video/init') return authorizedPosting(req, 'video_init', (ctx) => initUpload(ctx, 'direct', req.body));
|
|
1353
|
+
if (method === 'POST' && path === '/v2/post/publish/inbox/video/init') return authorizedPosting(req, 'inbox_init', (ctx) => initUpload(ctx, 'inbox', req.body));
|
|
1354
|
+
if (method === 'POST' && path === '/v2/post/publish/status/fetch') return authorizedPosting(req, 'post_status', (ctx) => fetchStatus(ctx, req.body));
|
|
1355
|
+
if (method === 'PUT' && path === UPLOAD_PATH) {
|
|
1356
|
+
return uploadChunk({ method, query, at, ...(req.headers ? { headers: req.headers } : {}), ...(req.bytes ? { bytes: req.bytes } : {}), ...(req.root !== undefined ? { root: req.root } : {}), ...(req.readOnly ? { readOnly: true } : {}) });
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// An operation the twin does not model fails like the vendor — never a fake success.
|
|
1360
|
+
return unknownRoute(method, path, at);
|
|
1361
|
+
}
|