@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,146 @@
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 {
22
+ ConsentPage,
23
+ ErrorPage,
24
+ type ConsentAccount,
25
+ type ConsentScopeRow,
26
+ type ConsentView,
27
+ type ErrorPageProps,
28
+ } from '../client/tiktok-consent.tsx';
29
+ import { describeScopes, parseScopeParam, sortScopesForConsent } from './tiktok-scopes.ts';
30
+ import { readOne, type Row } from './tiktok-store.ts';
31
+
32
+ export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from '../client/tiktok-consent.tsx';
33
+
34
+ function toConsentAccount(row: Row): ConsentAccount {
35
+ return {
36
+ unionId: row.id,
37
+ username: String(row.username ?? ''),
38
+ displayName: String(row.displayName ?? ''),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * THE STATE BUILDER — the authorization page's entire view model, folded out of the kernel
44
+ * projection. Returns `null` when the authorize request is unknown or already settled, or when the
45
+ * app or the signed-in account no longer exists (the caller renders the error page); it never
46
+ * invents an app name, an account or a scope row.
47
+ */
48
+ export function tiktokConsentState(opts: {
49
+ root?: string;
50
+ requestId: string;
51
+ origin: string;
52
+ }): ConsentView | null {
53
+ const authRequest = readOne(opts.root, 'auth_request', opts.requestId);
54
+ if (!authRequest || authRequest.settled === true) return null;
55
+ const client = readOne(opts.root, 'oauth_client', String(authRequest.clientKey));
56
+ if (!client) return null;
57
+ const account = readOne(opts.root, 'account', String(authRequest.accountId ?? ''));
58
+ if (!account) return null;
59
+
60
+ const requested = sortScopesForConsent(parseScopeParam(String(authRequest.scope ?? '')));
61
+ const scopes: ConsentScopeRow[] = describeScopes(requested).map((s) => ({
62
+ scope: s.scope,
63
+ label: s.label,
64
+ product: s.product,
65
+ known: s.known,
66
+ }));
67
+
68
+ let redirectHost = '';
69
+ try {
70
+ redirectHost = new URL(String(authRequest.redirectUri)).host;
71
+ } catch {
72
+ redirectHost = String(authRequest.redirectUri);
73
+ }
74
+
75
+ return {
76
+ requestId: opts.requestId,
77
+ origin: opts.origin,
78
+ app: { clientKey: client.id, name: String(client.name ?? '') },
79
+ account: toConsentAccount(account),
80
+ scopes,
81
+ redirectHost,
82
+ };
83
+ }
84
+
85
+ /** The authorization page's stylesheet, served inline. Static text: no clock, no state, no build. */
86
+ export const CONSENT_CSS = `
87
+ :root { color-scheme: light dark; }
88
+ * { box-sizing: border-box; }
89
+ body {
90
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
91
+ background: #f1f1f2; color: #161823; padding: 24px;
92
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
93
+ }
94
+ .card {
95
+ background: #fff; width: 100%; max-width: 440px; border-radius: 12px; padding: 28px 28px 22px;
96
+ box-shadow: 0 2px 12px rgba(0,0,0,.10); border: 1px solid rgba(22,24,35,.10);
97
+ }
98
+ .tt-header { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; }
99
+ .tt-mark { font-size: 26px; line-height: 1; color: #fe2c55; }
100
+ .tt-wordmark { font-size: 18px; font-weight: 700; letter-spacing: -0.4px; }
101
+ .signed-in { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
102
+ .account-text { display: flex; align-items: baseline; gap: 8px; font-size: 14px; }
103
+ .account-name { font-weight: 600; }
104
+ .account-username { color: rgba(22,24,35,.60); }
105
+ .title { font-size: 20px; line-height: 1.35; font-weight: 600; margin: 0 0 18px; }
106
+ .app-name { font-weight: 700; }
107
+ .scope-lead { font-size: 14px; color: rgba(22,24,35,.72); margin: 0 0 10px; }
108
+ .scope-list { list-style: none; margin: 0 0 22px; padding: 0; display: flex; flex-direction: column; gap: 12px; }
109
+ .scope-row { display: flex; flex-direction: column; gap: 4px; }
110
+ .scope-label { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; line-height: 1.45; cursor: pointer; }
111
+ .scope-check { margin-top: 3px; accent-color: #fe2c55; width: 16px; height: 16px; flex: none; }
112
+ .scope-tag { align-self: flex-start; font-size: 11px; border-radius: 999px; padding: 2px 8px; margin-left: 26px; }
113
+ .scope-tag-unknown { background: #fff1f3; color: #b3082f; border: 1px solid #ffd3db; }
114
+ .actions { display: flex; gap: 10px; justify-content: flex-end; }
115
+ .btn { font: inherit; font-weight: 600; border-radius: 6px; padding: 10px 20px; cursor: pointer; border: 1px solid transparent; }
116
+ .btn-primary { background: #fe2c55; color: #fff; }
117
+ .btn-secondary { background: #fff; color: #161823; border-color: rgba(22,24,35,.20); }
118
+ .legal { font-size: 12px; line-height: 1.5; color: rgba(22,24,35,.55); margin: 18px 0 0; }
119
+ .redirect-host { color: rgba(22,24,35,.80); }
120
+ .card-error .title { margin-bottom: 12px; }
121
+ .error-detail { font-size: 14px; line-height: 1.5; margin: 0 0 12px; }
122
+ .error-code { font-size: 13px; font-weight: 600; color: #b3082f; margin: 0 0 6px; }
123
+ .error-request { font-size: 12px; color: rgba(22,24,35,.55); word-break: break-all; margin: 0; }
124
+ `.trim();
125
+
126
+ function page(title: string, bodyMarkup: string): string {
127
+ return `<!doctype html>
128
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
129
+ <title>${escapeHtml(title)}</title><style>${CONSENT_CSS}</style></head>
130
+ <body><div id="root">${bodyMarkup}</div></body></html>`;
131
+ }
132
+
133
+ function escapeHtml(s: string): string {
134
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
135
+ }
136
+
137
+ /** Render the authorization page from a view model. */
138
+ export function consentPageHtml(view: ConsentView): string {
139
+ const markup = renderToStaticMarkup(createElement(ConsentPage, { view }));
140
+ return page(`Authorize ${view.app.name} | TikTok`, markup);
141
+ }
142
+
143
+ /** Render the authorize-endpoint error page (an un-redirectable failure). */
144
+ export function errorPageHtml(props: ErrorPageProps): string {
145
+ return page(`Error ${props.status}: ${props.code} | TikTok`, renderToStaticMarkup(createElement(ErrorPage, props)));
146
+ }
@@ -0,0 +1,197 @@
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.ts';
35
+
36
+ export type TikTokResponse = {
37
+ status: number;
38
+ body: unknown;
39
+ headers?: Record<string, string>;
40
+ };
41
+
42
+ /** TikTok's own curl examples set `Cache-Control: no-cache` on the request; the twin refuses to
43
+ * let a credential-bearing reply be cached on the way back. */
44
+ const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
45
+ const JSON_CT = { 'content-type': 'application/json; charset=utf-8' };
46
+
47
+ // ── family 1: the OAuth endpoints (flat body) ───────────────────────────────────────────────────
48
+
49
+ /** The ten `error` values the OAuth error-handling reference publishes, with its descriptions
50
+ * verbatim. Exported so a verify can assert against the vendor's list rather than the twin's. */
51
+ export const OAUTH_ERRORS: Record<string, string> = {
52
+ access_denied: 'The resource owner or authorization server denied the request.',
53
+ invalid_client: 'Client authentication failed (for example, unknown client, no client authentication included, or unsupported authentication method).',
54
+ 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.',
55
+ invalid_request: 'The request misses a required parameter or is otherwise malformed.',
56
+ invalid_scope: 'The requested scope is invalid, unknown, or malformed.',
57
+ unauthorized_client: 'The client is not authorized to request an authorization code using this method.',
58
+ unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.',
59
+ unsupported_response_type: 'The authorization server does not support obtaining an authorization code using this method.',
60
+ server_error: 'Other internal server errors.',
61
+ temporarily_unavailable: 'Service is temporarily unavailable.',
62
+ };
63
+
64
+ /**
65
+ * An OAuth-endpoint refusal. `status` defaults to RFC 6749 §5.2's 400 — see the EVIDENCE BOUNDARY
66
+ * in this file's header; the live status is unpinned.
67
+ */
68
+ export function oauthError(error: string, description: string, occurredAt: string, status = 400): TikTokResponse {
69
+ return {
70
+ status,
71
+ body: { error, error_description: description, log_id: logId(occurredAt, `oauth:${error}:${description}`) },
72
+ headers: { ...NOSTORE, ...JSON_CT },
73
+ };
74
+ }
75
+
76
+ /** A refusal whose description is the vendor's OWN published one for that code. */
77
+ export function oauthErrorCode(error: keyof typeof OAUTH_ERRORS & string, occurredAt: string, status = 400): TikTokResponse {
78
+ return oauthError(error, OAUTH_ERRORS[error] ?? 'The request misses a required parameter or is otherwise malformed.', occurredAt, status);
79
+ }
80
+
81
+ /** The vendor's own wording for a malformed request — the literal string its refresh/revoke
82
+ * examples show ("The request parameters are malformed."). */
83
+ export function malformedRequest(occurredAt: string, detail?: string): TikTokResponse {
84
+ return oauthError('invalid_request', detail ?? 'The request parameters are malformed.', occurredAt);
85
+ }
86
+
87
+ /**
88
+ * The ONE answer an unusable authorization code or refresh token gets. Unknown, already redeemed,
89
+ * expired, minted for another client, wrong redirect_uri pairing or failed PKCE are deliberately
90
+ * indistinguishable — telling them apart would leak which codes exist — and `invalid_grant` is the
91
+ * code the vendor's own reference assigns to exactly that family ("The provided authorization
92
+ * grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in
93
+ * the authorization request, or was issued to another client").
94
+ */
95
+ export function invalidGrant(occurredAt: string): TikTokResponse {
96
+ return oauthErrorCode('invalid_grant', occurredAt);
97
+ }
98
+
99
+ /** Client authentication failed at the token/revoke endpoint (RFC 6749 §5.2: 401). */
100
+ export function invalidClient(occurredAt: string): TikTokResponse {
101
+ return oauthErrorCode('invalid_client', occurredAt, 401);
102
+ }
103
+
104
+ // ── family 2: the v2 API endpoints (nested error object, present on success too) ─────────────────
105
+
106
+ /**
107
+ * The seven codes the v2 error-handling reference publishes, with their documented HTTP status.
108
+ * `ok` is the success value the same envelope carries.
109
+ *
110
+ * EVIDENCE BOUNDARY ON THE MESSAGES, so a reader can tell a quotation from twin prose. VERBATIM
111
+ * from the vendor's table: `access_token_invalid`, `invalid_file_upload`, `rate_limit_exceeded`
112
+ * and `scope_not_authorized`. TWIN PROSE, because the reference describes those rows rather than
113
+ * quoting a message ("generic TikTok internal error; refer to message for details", "one or more
114
+ * request fields are invalid", "access token lacks required scopes"): `internal_error`,
115
+ * `invalid_params` and `scope_permission_missed`. The CODES and the STATUSES are the vendor's for
116
+ * all seven, which is what `tiktok.errors.api_codes_and_statuses_match_vendor_table` asserts.
117
+ */
118
+ export const API_ERRORS: Record<string, { status: number; message: string }> = {
119
+ access_token_invalid: { status: 401, message: 'The access token is invalid or not found in the request. Please refresh the token and retry.' },
120
+ internal_error: { status: 500, message: 'Server internal error. Please retry later.' },
121
+ invalid_file_upload: { status: 400, message: 'The uploaded file does not meet API specifications. Please correct the file and try again.' },
122
+ invalid_params: { status: 400, message: 'One or more request fields are invalid.' },
123
+ rate_limit_exceeded: { status: 429, message: 'The API rate limit was exceeded. Please try again later.' },
124
+ 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.' },
125
+ scope_permission_missed: { status: 400, message: 'The access token does not carry the scopes required by this request.' },
126
+ };
127
+
128
+ /** The success envelope: the `data` the endpoint computed, plus `error: {code:"ok", message:"", log_id}`. */
129
+ export function apiOk(data: unknown, occurredAt: string, seed: string): TikTokResponse {
130
+ return {
131
+ status: 200,
132
+ body: { data, error: { code: 'ok', message: '', log_id: logId(occurredAt, `api:ok:${seed}`) } },
133
+ headers: { ...NOSTORE, ...JSON_CT },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * An API-endpoint refusal. The status comes from the vendor's own published table, so a caller
139
+ * that branches on the status and a caller that branches on `error.code` agree by construction.
140
+ * `message` may be overridden to carry the specific detail the vendor's message field carries
141
+ * ("consult the error message for specifics" is what its own reference says of `invalid_params`).
142
+ */
143
+ export function apiError(code: keyof typeof API_ERRORS & string, occurredAt: string, message?: string): TikTokResponse {
144
+ const spec = API_ERRORS[code]!;
145
+ return {
146
+ status: spec.status,
147
+ body: { error: { code, message: message ?? spec.message, log_id: logId(occurredAt, `api:${code}:${message ?? ''}`) } },
148
+ headers: { ...NOSTORE, ...JSON_CT },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * The Content Posting API's OWN codes, with the HTTP status each reference page gives them
154
+ * (developers.tiktok.com content-posting-api-reference-direct-post, -upload-video,
155
+ * -query-creator-info and -get-video-status, fetched 2026-09-27). Note the SINGULAR `invalid_param`:
156
+ * the posting references spell it that way while the v2 error table spells `invalid_params`, and a
157
+ * client that branches on the code must meet the spelling of the endpoint it called. Messages are
158
+ * the reference's descriptions, lightly completed into sentences (twin prose); codes and statuses are
159
+ * the vendor's.
160
+ */
161
+ export const POSTING_ERRORS: Record<string, { status: number; message: string }> = {
162
+ invalid_param: { status: 400, message: 'The request contains an invalid parameter. Check the error message for details.' },
163
+ spam_risk_too_many_posts: { status: 403, message: 'The daily post cap for this user has been reached.' },
164
+ spam_risk_user_banned_from_posting: { status: 403, message: 'This user is banned from making new posts.' },
165
+ reached_active_user_cap: { status: 403, message: 'The daily quota for active publishing users from this client has been reached.' },
166
+ unaudited_client_can_only_post_to_private_accounts: { status: 403, message: 'Unaudited clients can only post to a private account.' },
167
+ url_ownership_unverified: { status: 403, message: 'To use PULL_FROM_URL, the developer must verify ownership of the URL prefix or domain.' },
168
+ privacy_level_option_mismatch: { status: 403, message: 'The privacy_level is not one of the privacy_level_options this creator has.' },
169
+ spam_risk_too_many_pending_share: { status: 403, message: 'This user has too many pending shares in the last 24 hours.' },
170
+ invalid_publish_id: { status: 400, message: 'The publish_id does not exist.' },
171
+ token_not_authorized_for_specified_publish_id: { status: 400, message: 'The access token is not authorized to read the status of this publish_id.' },
172
+ };
173
+
174
+ /** A Content Posting refusal, in the same nested envelope as the rest of the v2 API. */
175
+ export function postingError(code: keyof typeof POSTING_ERRORS & string, occurredAt: string, message?: string): TikTokResponse {
176
+ const spec = POSTING_ERRORS[code]!;
177
+ return {
178
+ status: spec.status,
179
+ body: { error: { code, message: message ?? spec.message, log_id: logId(occurredAt, `posting:${code}:${message ?? ''}`) } },
180
+ headers: { ...NOSTORE, ...JSON_CT },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * An UNMODELLED path on a twinned TikTok host fails like the vendor rather than faking a success.
186
+ * TikTok publishes no 404 body for an unknown v2 route, so the twin answers the API family's own
187
+ * `invalid_params` shape with a message that names the route — an honest refusal in the envelope
188
+ * the surrounding endpoints speak. `tiktok.errors.unknown_route_pin` (todo) pins the live answer.
189
+ */
190
+ export function unknownRoute(method: string, path: string, occurredAt: string): TikTokResponse {
191
+ return apiError('invalid_params', occurredAt, `${method} ${path} is not a TikTok open API endpoint this twin serves`);
192
+ }
193
+
194
+ /** A contained unexpected failure. Never exposes local exception text. */
195
+ export function internalError(occurredAt: string): TikTokResponse {
196
+ return apiError('internal_error', occurredAt);
197
+ }
@@ -0,0 +1,51 @@
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
+
16
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
17
+ * (the resend/xidentity exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
18
+ export function stableHex(seed: string, n: number): string {
19
+ let out = '';
20
+ for (let round = 0; out.length < n; round += 1) {
21
+ let h = 0x811c9dc5;
22
+ const s = `${round}:${seed}`;
23
+ for (let i = 0; i < s.length; i += 1) {
24
+ h ^= s.charCodeAt(i);
25
+ h = Math.imul(h, 0x01000193) >>> 0;
26
+ }
27
+ out += h.toString(16).padStart(8, '0');
28
+ }
29
+ return out.slice(0, n);
30
+ }
31
+
32
+ /** `yyyyMMddHHmmss` in UTC — the leading 14 digits of TikTok's own `log_id` examples. */
33
+ export function logTimestamp(occurredAt: string): string {
34
+ const d = new Date(occurredAt);
35
+ const p = (n: number, w = 2) => String(n).padStart(w, '0');
36
+ return `${p(d.getUTCFullYear(), 4)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
37
+ }
38
+
39
+ /**
40
+ * A TikTok `log_id` — "the unique id associated with every request for debugging purposes"
41
+ * (developers.tiktok.com error-handling reference, fetched 2026-09-13).
42
+ *
43
+ * SHAPE, from the vendor's own two published examples
44
+ * (`20220829194722CBE87ED59D524E727021`, `202206221854370101130062072500FFA2`): 14 digits of UTC
45
+ * `yyyyMMddHHmmss` followed by 20 UPPERCASE hex characters — 34 characters total. The twin
46
+ * reproduces the shape (an integration's log scraper may key on it); the trailing bytes are a
47
+ * stable hash of the request coordinates, never entropy, because the value is served (R9).
48
+ */
49
+ export function logId(occurredAt: string, seed: string): string {
50
+ return `${logTimestamp(occurredAt)}${stableHex(`log:${occurredAt}:${seed}`, 20).toUpperCase()}`;
51
+ }