@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,127 @@
1
+ // TikTok twin — the SERVER HALF of the authorization page.
2
+ //
3
+ // Named `-consent-ui` rather than `-mirror-ui` on purpose (the googleoauth/xidentity class
4
+ // doctrine): a dashboard mirror is a SECOND renderer this repo writes over a vendor's data,
5
+ // whereas this is the vendor's OWN page, on the vendor's OWN path, in the middle of the vendor's
6
+ // OWN protocol. The mirror DISCIPLINE still applies in full — one renderer, data-coupled to the
7
+ // projection, driven by a real browser journey.
8
+ //
9
+ // The pieces:
10
+ // • `tiktokConsentState` — THE STATE BUILDER. Everything on screen is derived here, from the
11
+ // kernel projection, and nowhere else. It is the seam `scripts/mutation-test.ts` sabotages in
12
+ // the mirror phase (TWIN-20/B9): with it dead the screen has no app name, no account and no
13
+ // scopes, so every UI capability must go red while the write/handler path stays real.
14
+ // • `consentPageHtml` / `errorPageHtml` — server-render the REAL exported React components with
15
+ // `renderToStaticMarkup`. There is no template string of vendor markup anywhere.
16
+ // • `CONSENT_CSS` — the stylesheet, INLINE. This pack ships no browser bundle (the whole screen
17
+ // is a plain form; see client/tiktok-consent.tsx), so there is no build on the serve path
18
+ // (R12b) and no generated asset to drift — the page is one self-contained document.
19
+ import { createElement } from 'react';
20
+ import { renderToStaticMarkup } from 'react-dom/server';
21
+ import { ConsentPage, ErrorPage, } from "../client/tiktok-consent.js";
22
+ import { describeScopes, parseScopeParam, sortScopesForConsent } from "./tiktok-scopes.js";
23
+ import { readOne } from "./tiktok-store.js";
24
+ function toConsentAccount(row) {
25
+ return {
26
+ unionId: row.id,
27
+ username: String(row.username ?? ''),
28
+ displayName: String(row.displayName ?? ''),
29
+ };
30
+ }
31
+ /**
32
+ * THE STATE BUILDER — the authorization page's entire view model, folded out of the kernel
33
+ * projection. Returns `null` when the authorize request is unknown or already settled, or when the
34
+ * app or the signed-in account no longer exists (the caller renders the error page); it never
35
+ * invents an app name, an account or a scope row.
36
+ */
37
+ export function tiktokConsentState(opts) {
38
+ const authRequest = readOne(opts.root, 'auth_request', opts.requestId);
39
+ if (!authRequest || authRequest.settled === true)
40
+ return null;
41
+ const client = readOne(opts.root, 'oauth_client', String(authRequest.clientKey));
42
+ if (!client)
43
+ return null;
44
+ const account = readOne(opts.root, 'account', String(authRequest.accountId ?? ''));
45
+ if (!account)
46
+ return null;
47
+ const requested = sortScopesForConsent(parseScopeParam(String(authRequest.scope ?? '')));
48
+ const scopes = describeScopes(requested).map((s) => ({
49
+ scope: s.scope,
50
+ label: s.label,
51
+ product: s.product,
52
+ known: s.known,
53
+ }));
54
+ let redirectHost = '';
55
+ try {
56
+ redirectHost = new URL(String(authRequest.redirectUri)).host;
57
+ }
58
+ catch {
59
+ redirectHost = String(authRequest.redirectUri);
60
+ }
61
+ return {
62
+ requestId: opts.requestId,
63
+ origin: opts.origin,
64
+ app: { clientKey: client.id, name: String(client.name ?? '') },
65
+ account: toConsentAccount(account),
66
+ scopes,
67
+ redirectHost,
68
+ };
69
+ }
70
+ /** The authorization page's stylesheet, served inline. Static text: no clock, no state, no build. */
71
+ export const CONSENT_CSS = `
72
+ :root { color-scheme: light dark; }
73
+ * { box-sizing: border-box; }
74
+ body {
75
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
76
+ background: #f1f1f2; color: #161823; padding: 24px;
77
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
78
+ }
79
+ .card {
80
+ background: #fff; width: 100%; max-width: 440px; border-radius: 12px; padding: 28px 28px 22px;
81
+ box-shadow: 0 2px 12px rgba(0,0,0,.10); border: 1px solid rgba(22,24,35,.10);
82
+ }
83
+ .tt-header { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; }
84
+ .tt-mark { font-size: 26px; line-height: 1; color: #fe2c55; }
85
+ .tt-wordmark { font-size: 18px; font-weight: 700; letter-spacing: -0.4px; }
86
+ .signed-in { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
87
+ .account-text { display: flex; align-items: baseline; gap: 8px; font-size: 14px; }
88
+ .account-name { font-weight: 600; }
89
+ .account-username { color: rgba(22,24,35,.60); }
90
+ .title { font-size: 20px; line-height: 1.35; font-weight: 600; margin: 0 0 18px; }
91
+ .app-name { font-weight: 700; }
92
+ .scope-lead { font-size: 14px; color: rgba(22,24,35,.72); margin: 0 0 10px; }
93
+ .scope-list { list-style: none; margin: 0 0 22px; padding: 0; display: flex; flex-direction: column; gap: 12px; }
94
+ .scope-row { display: flex; flex-direction: column; gap: 4px; }
95
+ .scope-label { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; line-height: 1.45; cursor: pointer; }
96
+ .scope-check { margin-top: 3px; accent-color: #fe2c55; width: 16px; height: 16px; flex: none; }
97
+ .scope-tag { align-self: flex-start; font-size: 11px; border-radius: 999px; padding: 2px 8px; margin-left: 26px; }
98
+ .scope-tag-unknown { background: #fff1f3; color: #b3082f; border: 1px solid #ffd3db; }
99
+ .actions { display: flex; gap: 10px; justify-content: flex-end; }
100
+ .btn { font: inherit; font-weight: 600; border-radius: 6px; padding: 10px 20px; cursor: pointer; border: 1px solid transparent; }
101
+ .btn-primary { background: #fe2c55; color: #fff; }
102
+ .btn-secondary { background: #fff; color: #161823; border-color: rgba(22,24,35,.20); }
103
+ .legal { font-size: 12px; line-height: 1.5; color: rgba(22,24,35,.55); margin: 18px 0 0; }
104
+ .redirect-host { color: rgba(22,24,35,.80); }
105
+ .card-error .title { margin-bottom: 12px; }
106
+ .error-detail { font-size: 14px; line-height: 1.5; margin: 0 0 12px; }
107
+ .error-code { font-size: 13px; font-weight: 600; color: #b3082f; margin: 0 0 6px; }
108
+ .error-request { font-size: 12px; color: rgba(22,24,35,.55); word-break: break-all; margin: 0; }
109
+ `.trim();
110
+ function page(title, bodyMarkup) {
111
+ return `<!doctype html>
112
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
113
+ <title>${escapeHtml(title)}</title><style>${CONSENT_CSS}</style></head>
114
+ <body><div id="root">${bodyMarkup}</div></body></html>`;
115
+ }
116
+ function escapeHtml(s) {
117
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
118
+ }
119
+ /** Render the authorization page from a view model. */
120
+ export function consentPageHtml(view) {
121
+ const markup = renderToStaticMarkup(createElement(ConsentPage, { view }));
122
+ return page(`Authorize ${view.app.name} | TikTok`, markup);
123
+ }
124
+ /** Render the authorize-endpoint error page (an un-redirectable failure). */
125
+ export function errorPageHtml(props) {
126
+ return page(`Error ${props.status}: ${props.code} | TikTok`, renderToStaticMarkup(createElement(ErrorPage, props)));
127
+ }
@@ -0,0 +1,78 @@
1
+ export type TikTokResponse = {
2
+ status: number;
3
+ body: unknown;
4
+ headers?: Record<string, string>;
5
+ };
6
+ /** The ten `error` values the OAuth error-handling reference publishes, with its descriptions
7
+ * verbatim. Exported so a verify can assert against the vendor's list rather than the twin's. */
8
+ export declare const OAUTH_ERRORS: Record<string, string>;
9
+ /**
10
+ * An OAuth-endpoint refusal. `status` defaults to RFC 6749 §5.2's 400 — see the EVIDENCE BOUNDARY
11
+ * in this file's header; the live status is unpinned.
12
+ */
13
+ export declare function oauthError(error: string, description: string, occurredAt: string, status?: number): TikTokResponse;
14
+ /** A refusal whose description is the vendor's OWN published one for that code. */
15
+ export declare function oauthErrorCode(error: keyof typeof OAUTH_ERRORS & string, occurredAt: string, status?: number): TikTokResponse;
16
+ /** The vendor's own wording for a malformed request — the literal string its refresh/revoke
17
+ * examples show ("The request parameters are malformed."). */
18
+ export declare function malformedRequest(occurredAt: string, detail?: string): TikTokResponse;
19
+ /**
20
+ * The ONE answer an unusable authorization code or refresh token gets. Unknown, already redeemed,
21
+ * expired, minted for another client, wrong redirect_uri pairing or failed PKCE are deliberately
22
+ * indistinguishable — telling them apart would leak which codes exist — and `invalid_grant` is the
23
+ * code the vendor's own reference assigns to exactly that family ("The provided authorization
24
+ * grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in
25
+ * the authorization request, or was issued to another client").
26
+ */
27
+ export declare function invalidGrant(occurredAt: string): TikTokResponse;
28
+ /** Client authentication failed at the token/revoke endpoint (RFC 6749 §5.2: 401). */
29
+ export declare function invalidClient(occurredAt: string): TikTokResponse;
30
+ /**
31
+ * The seven codes the v2 error-handling reference publishes, with their documented HTTP status.
32
+ * `ok` is the success value the same envelope carries.
33
+ *
34
+ * EVIDENCE BOUNDARY ON THE MESSAGES, so a reader can tell a quotation from twin prose. VERBATIM
35
+ * from the vendor's table: `access_token_invalid`, `invalid_file_upload`, `rate_limit_exceeded`
36
+ * and `scope_not_authorized`. TWIN PROSE, because the reference describes those rows rather than
37
+ * quoting a message ("generic TikTok internal error; refer to message for details", "one or more
38
+ * request fields are invalid", "access token lacks required scopes"): `internal_error`,
39
+ * `invalid_params` and `scope_permission_missed`. The CODES and the STATUSES are the vendor's for
40
+ * all seven, which is what `tiktok.errors.api_codes_and_statuses_match_vendor_table` asserts.
41
+ */
42
+ export declare const API_ERRORS: Record<string, {
43
+ status: number;
44
+ message: string;
45
+ }>;
46
+ /** The success envelope: the `data` the endpoint computed, plus `error: {code:"ok", message:"", log_id}`. */
47
+ export declare function apiOk(data: unknown, occurredAt: string, seed: string): TikTokResponse;
48
+ /**
49
+ * An API-endpoint refusal. The status comes from the vendor's own published table, so a caller
50
+ * that branches on the status and a caller that branches on `error.code` agree by construction.
51
+ * `message` may be overridden to carry the specific detail the vendor's message field carries
52
+ * ("consult the error message for specifics" is what its own reference says of `invalid_params`).
53
+ */
54
+ export declare function apiError(code: keyof typeof API_ERRORS & string, occurredAt: string, message?: string): TikTokResponse;
55
+ /**
56
+ * The Content Posting API's OWN codes, with the HTTP status each reference page gives them
57
+ * (developers.tiktok.com content-posting-api-reference-direct-post, -upload-video,
58
+ * -query-creator-info and -get-video-status, fetched 2026-09-27). Note the SINGULAR `invalid_param`:
59
+ * the posting references spell it that way while the v2 error table spells `invalid_params`, and a
60
+ * client that branches on the code must meet the spelling of the endpoint it called. Messages are
61
+ * the reference's descriptions, lightly completed into sentences (twin prose); codes and statuses are
62
+ * the vendor's.
63
+ */
64
+ export declare const POSTING_ERRORS: Record<string, {
65
+ status: number;
66
+ message: string;
67
+ }>;
68
+ /** A Content Posting refusal, in the same nested envelope as the rest of the v2 API. */
69
+ export declare function postingError(code: keyof typeof POSTING_ERRORS & string, occurredAt: string, message?: string): TikTokResponse;
70
+ /**
71
+ * An UNMODELLED path on a twinned TikTok host fails like the vendor rather than faking a success.
72
+ * TikTok publishes no 404 body for an unknown v2 route, so the twin answers the API family's own
73
+ * `invalid_params` shape with a message that names the route — an honest refusal in the envelope
74
+ * the surrounding endpoints speak. `tiktok.errors.unknown_route_pin` (todo) pins the live answer.
75
+ */
76
+ export declare function unknownRoute(method: string, path: string, occurredAt: string): TikTokResponse;
77
+ /** A contained unexpected failure. Never exposes local exception text. */
78
+ export declare function internalError(occurredAt: string): TikTokResponse;
@@ -0,0 +1,175 @@
1
+ // TikTok's TWO error envelopes, kept distinct because they really are two different contracts and
2
+ // an integration branches on them (Dub's own code does: the OAuth leg reads `response.ok` plus
3
+ // `access_token`, the Display leg parses `data.user` through a Zod schema).
4
+ //
5
+ // 1. THE OAUTH FAMILY — `/v2/oauth/token/` and `/v2/oauth/revoke/`. A FLAT body:
6
+ // { "error": "invalid_request",
7
+ // "error_description": "Redirect_uri is not matched with the uri when requesting code.",
8
+ // "log_id": "202206221854370101130062072500FFA2" }
9
+ // Verbatim from the User Access Token Management guide
10
+ // (developers.tiktok.com/doc/oauth-user-access-token-management, fetched 2026-09-13). The
11
+ // `error` values are the ten the OAuth error-handling reference publishes
12
+ // (developers.tiktok.com/docs/en/oauth-error-handling) — access_denied, invalid_client,
13
+ // invalid_grant, invalid_request, invalid_scope, unauthorized_client, unsupported_grant_type,
14
+ // unsupported_response_type, server_error, temporarily_unavailable — each with the
15
+ // description that page gives it.
16
+ //
17
+ // EVIDENCE BOUNDARY — THE HTTP STATUS. That reference publishes the codes and descriptions
18
+ // and NO status column, and no fetched artefact shows the status line of a TikTok OAuth
19
+ // failure. The twin answers RFC 6749 §5.2's statuses (400, or 401 for invalid_client),
20
+ // which is the reading every OAuth client library assumes; `tiktok.token.error_http_status`
21
+ // (todo) pins the live one. Marked here rather than dressed up as a vendor fact.
22
+ //
23
+ // 2. THE API FAMILY — `/v2/user/info/`, `/v2/video/list/`, `/v2/video/query/`. A NESTED error
24
+ // object that is present on SUCCESS TOO:
25
+ // { "data": { ... }, "error": { "code": "ok", "message": "", "log_id": "…" } }
26
+ // Verbatim from the Get User Info reference. The codes are the seven the v2 error-handling
27
+ // reference publishes WITH their HTTP statuses: access_token_invalid (401), internal_error
28
+ // (500), invalid_file_upload (400), invalid_params (400), rate_limit_exceeded (429),
29
+ // scope_not_authorized (401), scope_permission_missed (400).
30
+ //
31
+ // `log_id` is deterministic here (see tiktok-ids.ts) because it is served content: runtime
32
+ // contract R9 forbids a clock or entropy in a served byte, and a twin whose every response
33
+ // carried a fresh random id would fail the two-fresh-roots replay on every single read.
34
+ import { logId } from "./tiktok-ids.js";
35
+ /** TikTok's own curl examples set `Cache-Control: no-cache` on the request; the twin refuses to
36
+ * let a credential-bearing reply be cached on the way back. */
37
+ const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
38
+ const JSON_CT = { 'content-type': 'application/json; charset=utf-8' };
39
+ // ── family 1: the OAuth endpoints (flat body) ───────────────────────────────────────────────────
40
+ /** The ten `error` values the OAuth error-handling reference publishes, with its descriptions
41
+ * verbatim. Exported so a verify can assert against the vendor's list rather than the twin's. */
42
+ export const OAUTH_ERRORS = {
43
+ access_denied: 'The resource owner or authorization server denied the request.',
44
+ invalid_client: 'Client authentication failed (for example, unknown client, no client authentication included, or unsupported authentication method).',
45
+ invalid_grant: 'The provided authorization grant (for example, authorization code or resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.',
46
+ invalid_request: 'The request misses a required parameter or is otherwise malformed.',
47
+ invalid_scope: 'The requested scope is invalid, unknown, or malformed.',
48
+ unauthorized_client: 'The client is not authorized to request an authorization code using this method.',
49
+ unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.',
50
+ unsupported_response_type: 'The authorization server does not support obtaining an authorization code using this method.',
51
+ server_error: 'Other internal server errors.',
52
+ temporarily_unavailable: 'Service is temporarily unavailable.',
53
+ };
54
+ /**
55
+ * An OAuth-endpoint refusal. `status` defaults to RFC 6749 §5.2's 400 — see the EVIDENCE BOUNDARY
56
+ * in this file's header; the live status is unpinned.
57
+ */
58
+ export function oauthError(error, description, occurredAt, status = 400) {
59
+ return {
60
+ status,
61
+ body: { error, error_description: description, log_id: logId(occurredAt, `oauth:${error}:${description}`) },
62
+ headers: { ...NOSTORE, ...JSON_CT },
63
+ };
64
+ }
65
+ /** A refusal whose description is the vendor's OWN published one for that code. */
66
+ export function oauthErrorCode(error, occurredAt, status = 400) {
67
+ return oauthError(error, OAUTH_ERRORS[error] ?? 'The request misses a required parameter or is otherwise malformed.', occurredAt, status);
68
+ }
69
+ /** The vendor's own wording for a malformed request — the literal string its refresh/revoke
70
+ * examples show ("The request parameters are malformed."). */
71
+ export function malformedRequest(occurredAt, detail) {
72
+ return oauthError('invalid_request', detail ?? 'The request parameters are malformed.', occurredAt);
73
+ }
74
+ /**
75
+ * The ONE answer an unusable authorization code or refresh token gets. Unknown, already redeemed,
76
+ * expired, minted for another client, wrong redirect_uri pairing or failed PKCE are deliberately
77
+ * indistinguishable — telling them apart would leak which codes exist — and `invalid_grant` is the
78
+ * code the vendor's own reference assigns to exactly that family ("The provided authorization
79
+ * grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in
80
+ * the authorization request, or was issued to another client").
81
+ */
82
+ export function invalidGrant(occurredAt) {
83
+ return oauthErrorCode('invalid_grant', occurredAt);
84
+ }
85
+ /** Client authentication failed at the token/revoke endpoint (RFC 6749 §5.2: 401). */
86
+ export function invalidClient(occurredAt) {
87
+ return oauthErrorCode('invalid_client', occurredAt, 401);
88
+ }
89
+ // ── family 2: the v2 API endpoints (nested error object, present on success too) ─────────────────
90
+ /**
91
+ * The seven codes the v2 error-handling reference publishes, with their documented HTTP status.
92
+ * `ok` is the success value the same envelope carries.
93
+ *
94
+ * EVIDENCE BOUNDARY ON THE MESSAGES, so a reader can tell a quotation from twin prose. VERBATIM
95
+ * from the vendor's table: `access_token_invalid`, `invalid_file_upload`, `rate_limit_exceeded`
96
+ * and `scope_not_authorized`. TWIN PROSE, because the reference describes those rows rather than
97
+ * quoting a message ("generic TikTok internal error; refer to message for details", "one or more
98
+ * request fields are invalid", "access token lacks required scopes"): `internal_error`,
99
+ * `invalid_params` and `scope_permission_missed`. The CODES and the STATUSES are the vendor's for
100
+ * all seven, which is what `tiktok.errors.api_codes_and_statuses_match_vendor_table` asserts.
101
+ */
102
+ export const API_ERRORS = {
103
+ access_token_invalid: { status: 401, message: 'The access token is invalid or not found in the request. Please refresh the token and retry.' },
104
+ internal_error: { status: 500, message: 'Server internal error. Please retry later.' },
105
+ invalid_file_upload: { status: 400, message: 'The uploaded file does not meet API specifications. Please correct the file and try again.' },
106
+ invalid_params: { status: 400, message: 'One or more request fields are invalid.' },
107
+ rate_limit_exceeded: { status: 429, message: 'The API rate limit was exceeded. Please try again later.' },
108
+ scope_not_authorized: { status: 401, message: 'The user did not authorize the scope required for completing this request. Please ask the user to authorize and then retry.' },
109
+ scope_permission_missed: { status: 400, message: 'The access token does not carry the scopes required by this request.' },
110
+ };
111
+ /** The success envelope: the `data` the endpoint computed, plus `error: {code:"ok", message:"", log_id}`. */
112
+ export function apiOk(data, occurredAt, seed) {
113
+ return {
114
+ status: 200,
115
+ body: { data, error: { code: 'ok', message: '', log_id: logId(occurredAt, `api:ok:${seed}`) } },
116
+ headers: { ...NOSTORE, ...JSON_CT },
117
+ };
118
+ }
119
+ /**
120
+ * An API-endpoint refusal. The status comes from the vendor's own published table, so a caller
121
+ * that branches on the status and a caller that branches on `error.code` agree by construction.
122
+ * `message` may be overridden to carry the specific detail the vendor's message field carries
123
+ * ("consult the error message for specifics" is what its own reference says of `invalid_params`).
124
+ */
125
+ export function apiError(code, occurredAt, message) {
126
+ const spec = API_ERRORS[code];
127
+ return {
128
+ status: spec.status,
129
+ body: { error: { code, message: message ?? spec.message, log_id: logId(occurredAt, `api:${code}:${message ?? ''}`) } },
130
+ headers: { ...NOSTORE, ...JSON_CT },
131
+ };
132
+ }
133
+ /**
134
+ * The Content Posting API's OWN codes, with the HTTP status each reference page gives them
135
+ * (developers.tiktok.com content-posting-api-reference-direct-post, -upload-video,
136
+ * -query-creator-info and -get-video-status, fetched 2026-09-27). Note the SINGULAR `invalid_param`:
137
+ * the posting references spell it that way while the v2 error table spells `invalid_params`, and a
138
+ * client that branches on the code must meet the spelling of the endpoint it called. Messages are
139
+ * the reference's descriptions, lightly completed into sentences (twin prose); codes and statuses are
140
+ * the vendor's.
141
+ */
142
+ export const POSTING_ERRORS = {
143
+ invalid_param: { status: 400, message: 'The request contains an invalid parameter. Check the error message for details.' },
144
+ spam_risk_too_many_posts: { status: 403, message: 'The daily post cap for this user has been reached.' },
145
+ spam_risk_user_banned_from_posting: { status: 403, message: 'This user is banned from making new posts.' },
146
+ reached_active_user_cap: { status: 403, message: 'The daily quota for active publishing users from this client has been reached.' },
147
+ unaudited_client_can_only_post_to_private_accounts: { status: 403, message: 'Unaudited clients can only post to a private account.' },
148
+ url_ownership_unverified: { status: 403, message: 'To use PULL_FROM_URL, the developer must verify ownership of the URL prefix or domain.' },
149
+ privacy_level_option_mismatch: { status: 403, message: 'The privacy_level is not one of the privacy_level_options this creator has.' },
150
+ spam_risk_too_many_pending_share: { status: 403, message: 'This user has too many pending shares in the last 24 hours.' },
151
+ invalid_publish_id: { status: 400, message: 'The publish_id does not exist.' },
152
+ token_not_authorized_for_specified_publish_id: { status: 400, message: 'The access token is not authorized to read the status of this publish_id.' },
153
+ };
154
+ /** A Content Posting refusal, in the same nested envelope as the rest of the v2 API. */
155
+ export function postingError(code, occurredAt, message) {
156
+ const spec = POSTING_ERRORS[code];
157
+ return {
158
+ status: spec.status,
159
+ body: { error: { code, message: message ?? spec.message, log_id: logId(occurredAt, `posting:${code}:${message ?? ''}`) } },
160
+ headers: { ...NOSTORE, ...JSON_CT },
161
+ };
162
+ }
163
+ /**
164
+ * An UNMODELLED path on a twinned TikTok host fails like the vendor rather than faking a success.
165
+ * TikTok publishes no 404 body for an unknown v2 route, so the twin answers the API family's own
166
+ * `invalid_params` shape with a message that names the route — an honest refusal in the envelope
167
+ * the surrounding endpoints speak. `tiktok.errors.unknown_route_pin` (todo) pins the live answer.
168
+ */
169
+ export function unknownRoute(method, path, occurredAt) {
170
+ return apiError('invalid_params', occurredAt, `${method} ${path} is not a TikTok open API endpoint this twin serves`);
171
+ }
172
+ /** A contained unexpected failure. Never exposes local exception text. */
173
+ export function internalError(occurredAt) {
174
+ return apiError('internal_error', occurredAt);
175
+ }
@@ -0,0 +1,16 @@
1
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
2
+ * (the resend/xidentity exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
3
+ export declare function stableHex(seed: string, n: number): string;
4
+ /** `yyyyMMddHHmmss` in UTC — the leading 14 digits of TikTok's own `log_id` examples. */
5
+ export declare function logTimestamp(occurredAt: string): string;
6
+ /**
7
+ * A TikTok `log_id` — "the unique id associated with every request for debugging purposes"
8
+ * (developers.tiktok.com error-handling reference, fetched 2026-09-13).
9
+ *
10
+ * SHAPE, from the vendor's own two published examples
11
+ * (`20220829194722CBE87ED59D524E727021`, `202206221854370101130062072500FFA2`): 14 digits of UTC
12
+ * `yyyyMMddHHmmss` followed by 20 UPPERCASE hex characters — 34 characters total. The twin
13
+ * reproduces the shape (an integration's log scraper may key on it); the trailing bytes are a
14
+ * stable hash of the request coordinates, never entropy, because the value is served (R9).
15
+ */
16
+ export declare function logId(occurredAt: string, seed: string): string;
@@ -0,0 +1,48 @@
1
+ // Deterministic id material for the TikTok twin — the ONE place a served identifier's bytes are
2
+ // derived, shared by the credential minting in `tiktok-store.ts` and the `log_id` every TikTok
3
+ // response carries (`tiktok-errors.ts`).
4
+ //
5
+ // WHY IT IS PURE (runtime contract R9): every value minted here is SERVED — the authorization
6
+ // code rides the callback redirect, the `act.`/`rft.`/`clt.` tokens ride the token reply, and
7
+ // `log_id` is in the envelope of literally every TikTok v2 response, success or failure. A served
8
+ // response must be a pure function of (request, stored state), so there is no clock read and no
9
+ // entropy in this module: callers pass the world instant and a state-derived seed.
10
+ //
11
+ // A twin's credentials are stand-ins, not secrets. Determinism is the contract and predictability
12
+ // from public state is not a threat model a twin has; UNIQUENESS is, and the callers carry it by
13
+ // seeding with the row COUNT of the type being minted (which advances on every mint) plus the
14
+ // subject the credential is issued for.
15
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
16
+ * (the resend/xidentity exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
17
+ export function stableHex(seed, n) {
18
+ let out = '';
19
+ for (let round = 0; out.length < n; round += 1) {
20
+ let h = 0x811c9dc5;
21
+ const s = `${round}:${seed}`;
22
+ for (let i = 0; i < s.length; i += 1) {
23
+ h ^= s.charCodeAt(i);
24
+ h = Math.imul(h, 0x01000193) >>> 0;
25
+ }
26
+ out += h.toString(16).padStart(8, '0');
27
+ }
28
+ return out.slice(0, n);
29
+ }
30
+ /** `yyyyMMddHHmmss` in UTC — the leading 14 digits of TikTok's own `log_id` examples. */
31
+ export function logTimestamp(occurredAt) {
32
+ const d = new Date(occurredAt);
33
+ const p = (n, w = 2) => String(n).padStart(w, '0');
34
+ return `${p(d.getUTCFullYear(), 4)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
35
+ }
36
+ /**
37
+ * A TikTok `log_id` — "the unique id associated with every request for debugging purposes"
38
+ * (developers.tiktok.com error-handling reference, fetched 2026-09-13).
39
+ *
40
+ * SHAPE, from the vendor's own two published examples
41
+ * (`20220829194722CBE87ED59D524E727021`, `202206221854370101130062072500FFA2`): 14 digits of UTC
42
+ * `yyyyMMddHHmmss` followed by 20 UPPERCASE hex characters — 34 characters total. The twin
43
+ * reproduces the shape (an integration's log scraper may key on it); the trailing bytes are a
44
+ * stable hash of the request coordinates, never entropy, because the value is served (R9).
45
+ */
46
+ export function logId(occurredAt, seed) {
47
+ return `${logTimestamp(occurredAt)}${stableHex(`log:${occurredAt}:${seed}`, 20).toUpperCase()}`;
48
+ }
@@ -0,0 +1,7 @@
1
+ export type MediaProbe = {
2
+ durationSeconds?: number;
3
+ width?: number;
4
+ height?: number;
5
+ frameRate?: number;
6
+ };
7
+ export declare function probeMp4(bytes: Uint8Array): MediaProbe;
@@ -0,0 +1,86 @@
1
+ function boxes(view, start, end) {
2
+ const out = [];
3
+ let at = start;
4
+ while (at + 8 <= end) {
5
+ let size = view.getUint32(at);
6
+ const type = String.fromCharCode(view.getUint8(at + 4), view.getUint8(at + 5), view.getUint8(at + 6), view.getUint8(at + 7));
7
+ let header = 8;
8
+ if (size === 1) {
9
+ if (at + 16 > end)
10
+ break;
11
+ size = Number(view.getBigUint64(at + 8));
12
+ header = 16;
13
+ }
14
+ else if (size === 0) {
15
+ size = end - at;
16
+ }
17
+ if (size < header || at + size > end)
18
+ break;
19
+ out.push({ type, start: at + header, end: at + size });
20
+ at += size;
21
+ }
22
+ return out;
23
+ }
24
+ /** A track's frame rate: its sample count over its media duration (mdhd timescale units). */
25
+ function frameRateOf(view, trak) {
26
+ const mdia = boxes(view, trak.start, trak.end).find((b) => b.type === 'mdia');
27
+ if (!mdia)
28
+ return undefined;
29
+ const inMdia = boxes(view, mdia.start, mdia.end);
30
+ const mdhd = inMdia.find((b) => b.type === 'mdhd');
31
+ const stbl = (() => { const minf = inMdia.find((b) => b.type === 'minf'); return minf ? boxes(view, minf.start, minf.end).find((b) => b.type === 'stbl') : undefined; })();
32
+ const stts = stbl ? boxes(view, stbl.start, stbl.end).find((b) => b.type === 'stts') : undefined;
33
+ if (!mdhd || !stts)
34
+ return undefined;
35
+ const version = view.getUint8(mdhd.start);
36
+ const timescale = view.getUint32(mdhd.start + (version === 1 ? 20 : 12));
37
+ const duration = version === 1 ? Number(view.getBigUint64(mdhd.start + 24)) : view.getUint32(mdhd.start + 16);
38
+ const entries = view.getUint32(stts.start + 4);
39
+ let samples = 0;
40
+ for (let i = 0; i < entries && stts.start + 8 + i * 8 + 4 <= stts.end; i += 1)
41
+ samples += view.getUint32(stts.start + 8 + i * 8);
42
+ if (timescale <= 0 || duration <= 0 || samples <= 0)
43
+ return undefined;
44
+ return samples / (duration / timescale);
45
+ }
46
+ export function probeMp4(bytes) {
47
+ try {
48
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
49
+ const moov = boxes(view, 0, bytes.byteLength).find((b) => b.type === 'moov');
50
+ if (!moov)
51
+ return {};
52
+ const out = {};
53
+ const children = boxes(view, moov.start, moov.end);
54
+ const mvhd = children.find((b) => b.type === 'mvhd');
55
+ if (mvhd) {
56
+ const version = view.getUint8(mvhd.start);
57
+ const timescale = view.getUint32(mvhd.start + (version === 1 ? 20 : 12));
58
+ const duration = version === 1 ? Number(view.getBigUint64(mvhd.start + 24)) : view.getUint32(mvhd.start + 16);
59
+ if (timescale > 0)
60
+ out.durationSeconds = duration / timescale;
61
+ }
62
+ for (const trak of children.filter((b) => b.type === 'trak')) {
63
+ const tkhd = boxes(view, trak.start, trak.end).find((b) => b.type === 'tkhd');
64
+ if (!tkhd)
65
+ continue;
66
+ const version = view.getUint8(tkhd.start);
67
+ const sizeAt = tkhd.start + (version === 1 ? 88 : 76);
68
+ if (sizeAt + 8 > tkhd.end)
69
+ continue;
70
+ const width = view.getUint32(sizeAt) / 65536;
71
+ const height = view.getUint32(sizeAt + 4) / 65536;
72
+ if (width > 0 && height > 0) {
73
+ out.width = Math.round(width);
74
+ out.height = Math.round(height);
75
+ const rate = frameRateOf(view, trak);
76
+ if (rate !== undefined)
77
+ out.frameRate = rate;
78
+ break;
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ catch {
84
+ return {};
85
+ }
86
+ }
@@ -0,0 +1,49 @@
1
+ export type TtRow = Record<string, any>;
2
+ /** The user fields the profile reads — every one the Display API gates behind the three user scopes. */
3
+ export declare const PROFILE_FIELDS = "open_id,union_id,avatar_url,display_name,username,bio_description,is_verified,follower_count,following_count,likes_count,video_count";
4
+ /** The fields a token holding only user.info.basic may ask for (the header then carries no counts). */
5
+ export declare const BASIC_FIELDS = "open_id,union_id,avatar_url,display_name";
6
+ /** The Video Object fields the grid and the player read. */
7
+ export declare const VIDEO_READ_FIELDS = "id,create_time,title,video_description,duration,width,height,like_count,comment_count,share_count,view_count";
8
+ /** The Display API's own page maximum. */
9
+ export declare const VIDEO_PAGE_SIZE = 20;
10
+ /** tiktok.com's count: exact under 10,000 ("1280"), then one decimal of K or M ("45.1K", "1.4M"),
11
+ * the trailing ".0" dropped ("12K"). */
12
+ export declare function compactCount(raw: unknown): string;
13
+ /** The profile header's three counts, each only when the twin returned it. */
14
+ export declare function profileStats(user: TtRow | undefined): Array<{
15
+ label: 'Following' | 'Followers' | 'Likes';
16
+ value: string;
17
+ }>;
18
+ export type CaptionSegment = {
19
+ kind: 'text' | 'hashtag' | 'mention';
20
+ value: string;
21
+ };
22
+ /** A caption split the way tiktok.com bolds it: #hashtags and @mentions are links, the rest text. */
23
+ export declare function captionSegments(text: unknown): CaptionSegment[];
24
+ /** The caption a post shows: the Video Object's description, else its title. */
25
+ export declare function captionOf(video: TtRow | undefined): string;
26
+ /** "0:03", "1:02" — the player's time readout. */
27
+ export declare function clock(seconds: unknown): string;
28
+ /** A post's bytes on the twin's media route, relative to the wire base. The creator's token rides the
29
+ * URL (a <video> element sends no header), which is what a post that is not public needs. */
30
+ export declare function mediaSource(base: string, videoId: string, token: string): string;
31
+ /** The avatar placeholder's letter and hue: pure functions of the account. */
32
+ export declare function avatarInitial(user: TtRow | undefined): string;
33
+ export declare function avatarHue(key: unknown): number;
34
+ /** Build the React/TSX mirror client to browser JS; memoized, so a pack does exactly one Bun.build. */
35
+ export declare function buildTiktokMirrorClient(): Promise<string>;
36
+ /** Serve the TikTok mirror UI (React app) + its backing API, upload and media routes on one origin. */
37
+ export declare function createTiktokMirrorServer(options: {
38
+ root?: string;
39
+ port?: number;
40
+ readOnly?: boolean;
41
+ }): Promise<{
42
+ port: number;
43
+ url: string;
44
+ stop: () => void;
45
+ }>;
46
+ /** The app-shell HTML (pure). The client itself is the React app. */
47
+ export declare function tiktokMirrorHtml(): string;
48
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
49
+ export declare function tiktokMirrorStyles(): Promise<string>;