@volter/twin-tiktok 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +310 -0
  3. package/client/tiktok-consent.tsx +154 -0
  4. package/client/tiktok-mirror.css +137 -0
  5. package/client/tiktok-mirror.tsx +492 -0
  6. package/dist/client/tiktok-consent.bundle.js +18 -0
  7. package/dist/client/tiktok-consent.d.ts +47 -0
  8. package/dist/client/tiktok-consent.js +20 -0
  9. package/dist/client/tiktok-consent.tsx +154 -0
  10. package/dist/client/tiktok-mirror.bundle.js +487 -0
  11. package/dist/client/tiktok-mirror.css +137 -0
  12. package/dist/client/tiktok-mirror.d.ts +42 -0
  13. package/dist/client/tiktok-mirror.js +315 -0
  14. package/dist/client/tiktok-mirror.tsx +492 -0
  15. package/dist/src/cli.d.ts +2 -0
  16. package/dist/src/cli.js +44 -0
  17. package/dist/src/index.d.ts +22 -0
  18. package/dist/src/index.js +167 -0
  19. package/dist/src/tiktok-blobs.d.ts +66 -0
  20. package/dist/src/tiktok-blobs.js +161 -0
  21. package/dist/src/tiktok-budget.d.ts +56 -0
  22. package/dist/src/tiktok-budget.js +136 -0
  23. package/dist/src/tiktok-capabilities.d.ts +7 -0
  24. package/dist/src/tiktok-capabilities.js +1855 -0
  25. package/dist/src/tiktok-conformance.d.ts +11 -0
  26. package/dist/src/tiktok-conformance.js +498 -0
  27. package/dist/src/tiktok-connector.d.ts +158 -0
  28. package/dist/src/tiktok-connector.js +600 -0
  29. package/dist/src/tiktok-consent-ui.d.ts +19 -0
  30. package/dist/src/tiktok-consent-ui.js +127 -0
  31. package/dist/src/tiktok-errors.d.ts +78 -0
  32. package/dist/src/tiktok-errors.js +175 -0
  33. package/dist/src/tiktok-ids.d.ts +16 -0
  34. package/dist/src/tiktok-ids.js +48 -0
  35. package/dist/src/tiktok-media.d.ts +7 -0
  36. package/dist/src/tiktok-media.js +86 -0
  37. package/dist/src/tiktok-mirror-ui.d.ts +49 -0
  38. package/dist/src/tiktok-mirror-ui.js +159 -0
  39. package/dist/src/tiktok-pkce.d.ts +25 -0
  40. package/dist/src/tiktok-pkce.js +56 -0
  41. package/dist/src/tiktok-posting.d.ts +100 -0
  42. package/dist/src/tiktok-posting.js +599 -0
  43. package/dist/src/tiktok-sample-mp4.d.ts +10 -0
  44. package/dist/src/tiktok-sample-mp4.js +55 -0
  45. package/dist/src/tiktok-scopes.d.ts +29 -0
  46. package/dist/src/tiktok-scopes.js +106 -0
  47. package/dist/src/tiktok-server.d.ts +28 -0
  48. package/dist/src/tiktok-server.js +89 -0
  49. package/dist/src/tiktok-store.d.ts +164 -0
  50. package/dist/src/tiktok-store.js +451 -0
  51. package/dist/src/tiktok-twin.d.ts +70 -0
  52. package/dist/src/tiktok-twin.js +1197 -0
  53. package/dist/src/tiktok-user.d.ts +28 -0
  54. package/dist/src/tiktok-user.js +174 -0
  55. package/package.json +74 -0
  56. package/src/cli.ts +43 -0
  57. package/src/index.ts +270 -0
  58. package/src/tiktok-blobs.ts +217 -0
  59. package/src/tiktok-budget.ts +163 -0
  60. package/src/tiktok-capabilities.ts +2022 -0
  61. package/src/tiktok-conformance.ts +526 -0
  62. package/src/tiktok-connector.ts +637 -0
  63. package/src/tiktok-consent-ui.ts +146 -0
  64. package/src/tiktok-errors.ts +197 -0
  65. package/src/tiktok-ids.ts +51 -0
  66. package/src/tiktok-journey.uitest.ts +305 -0
  67. package/src/tiktok-media.ts +89 -0
  68. package/src/tiktok-mirror-ui.ts +167 -0
  69. package/src/tiktok-pkce.ts +61 -0
  70. package/src/tiktok-posting.ts +617 -0
  71. package/src/tiktok-sample-mp4.ts +54 -0
  72. package/src/tiktok-scopes.ts +122 -0
  73. package/src/tiktok-server.ts +100 -0
  74. package/src/tiktok-store.ts +543 -0
  75. package/src/tiktok-twin.ts +1361 -0
  76. package/src/tiktok-user.ts +137 -0
@@ -0,0 +1,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
+ }